This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -1,8 +0,0 @@
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
export interface BonusRule {
id: string;
type: BonusRuleType;
points: number;
requiresFinishInTopN?: number;
}

View File

@@ -1,15 +0,0 @@
import type { ChampionshipType } from './ChampionshipType';
import type { SessionType } from './SessionType';
import { PointsTable } from './PointsTable';
import type { BonusRule } from './BonusRule';
import type { DropScorePolicy } from './DropScorePolicy';
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -1 +0,0 @@
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';

View File

@@ -1,13 +0,0 @@
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface DropScorePolicy {
strategy: DropScoreStrategy;
/**
* For 'bestNResults': number of best-scoring events to count.
*/
count?: number;
/**
* For 'dropWorstN': number of worst-scoring events to drop.
*/
dropCount?: number;
}

View File

@@ -1,10 +1,12 @@
/**
* Domain Value Object: GameConstraints
*
*
* Represents game-specific constraints for leagues.
* Different sim racing games have different maximum grid sizes.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
export interface GameConstraintsData {
readonly maxDrivers: number;
readonly maxTeams: number;
@@ -14,6 +16,11 @@ export interface GameConstraintsData {
readonly supportsMultiClass: boolean;
}
export interface GameConstraintsProps {
gameId: string;
constraints: GameConstraintsData;
}
/**
* Game-specific constraints for popular sim racing games
*/
@@ -69,7 +76,7 @@ const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
},
};
export class GameConstraints {
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
@@ -78,6 +85,17 @@ export class GameConstraints {
this.constraints = constraints;
}
get props(): GameConstraintsProps {
return {
gameId: this.gameId,
constraints: this.constraints,
};
}
equals(other: IValueObject<GameConstraintsProps>): boolean {
return this.props.gameId === other.props.gameId;
}
/**
* Get constraints for a specific game
*/

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueDescriptionValidationResult {
valid: boolean;
@@ -17,7 +18,11 @@ export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
recommendedMinLength: 50,
} as const;
export class LeagueDescription {
export interface LeagueDescriptionProps {
value: string;
}
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
readonly value: string;
private constructor(value: string) {
@@ -70,6 +75,10 @@ export class LeagueDescription {
return new LeagueDescription(value.trim());
}
get props(): LeagueDescriptionProps {
return { value: this.value };
}
/**
* Try to create a LeagueDescription, returning null if invalid
*/
@@ -84,8 +93,8 @@ export class LeagueDescription {
toString(): string {
return this.value;
}
equals(other: LeagueDescription): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueNameValidationResult {
valid: boolean;
@@ -22,7 +23,11 @@ export const LEAGUE_NAME_CONSTRAINTS = {
],
} as const;
export class LeagueName {
export interface LeagueNameProps {
value: string;
}
export class LeagueName implements IValueObject<LeagueNameProps> {
readonly value: string;
private constructor(value: string) {
@@ -83,6 +88,10 @@ export class LeagueName {
return new LeagueName(value.trim());
}
get props(): LeagueNameProps {
return { value: this.value };
}
/**
* Try to create a LeagueName, returning null if invalid
*/
@@ -97,8 +106,8 @@ export class LeagueName {
toString(): string {
return this.value;
}
equals(other: LeagueName): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueNameProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -1,73 +0,0 @@
/**
* Domain Value Object: LeagueRoles
*
* Utility functions for working with league membership roles.
*/
import type { MembershipRole } from '../entities/LeagueMembership';
/**
* Role hierarchy (higher number = more authority)
*/
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
member: 0,
steward: 1,
admin: 2,
owner: 3,
};
/**
* Check if a role is at least steward level
*/
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
}
/**
* Check if a role is at least admin level
*/
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
}
/**
* Check if a role is owner
*/
export function isLeagueOwnerRole(role: MembershipRole): boolean {
return role === 'owner';
}
/**
* Compare two roles
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
*/
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
}
/**
* Get role display name
*/
export function getRoleDisplayName(role: MembershipRole): string {
const names: Record<MembershipRole, string> = {
member: 'Member',
steward: 'Steward',
admin: 'Admin',
owner: 'Owner',
};
return names[role];
}
/**
* Get all roles in order of hierarchy
*/
export function getAllRolesOrdered(): MembershipRole[] {
return ['member', 'steward', 'admin', 'owner'];
}
/**
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
*/
export function getAssignableRoles(): MembershipRole[] {
return ['member', 'steward', 'admin'];
}

View File

@@ -1,6 +1,11 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class LeagueTimezone {
export interface LeagueTimezoneProps {
id: string;
}
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
private readonly id: string;
constructor(id: string) {
@@ -13,4 +18,12 @@ export class LeagueTimezone {
getId(): string {
return this.id;
}
get props(): LeagueTimezoneProps {
return { id: this.id };
}
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
return this.props.id === other.props.id;
}
}

View File

@@ -1,13 +1,16 @@
/**
* Domain Value Object: LeagueVisibility
*
*
* Represents the visibility and ranking status of a league.
*
*
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
* Requires minimum 10 players to ensure competitive integrity.
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
* Can have any number of players.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type LeagueVisibilityType = 'ranked' | 'unranked';
@@ -33,7 +36,11 @@ const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConst
},
};
export class LeagueVisibility {
export interface LeagueVisibilityProps {
type: LeagueVisibilityType;
}
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
@@ -112,6 +119,10 @@ export class LeagueVisibility {
return this.type;
}
get props(): LeagueVisibilityProps {
return { type: this.type };
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
@@ -119,8 +130,8 @@ export class LeagueVisibility {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: LeagueVisibility): boolean {
return this.type === other.type;
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
return this.props.type === other.props.type;
}
}

View File

@@ -2,6 +2,9 @@
* Value Object: LiveryDecal
* Represents a decal/logo placed on a livery
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type DecalType = 'sponsor' | 'user';
@@ -16,8 +19,8 @@ export interface LiveryDecalProps {
zIndex: number;
type: DecalType;
}
export class LiveryDecal {
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
readonly id: string;
readonly imageUrl: string;
readonly x: number;
@@ -138,6 +141,20 @@ export class LiveryDecal {
return `rotate(${this.rotation}deg)`;
}
get props(): LiveryDecalProps {
return {
id: this.id,
imageUrl: this.imageUrl,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
zIndex: this.zIndex,
type: this.type,
};
}
/**
* Check if this decal overlaps with another
*/
@@ -146,7 +163,7 @@ export class LiveryDecal {
const thisBottom = this.y + this.height;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
return !(
thisRight <= other.x ||
this.x >= otherRight ||
@@ -154,4 +171,20 @@ export class LiveryDecal {
this.y >= otherBottom
);
}
equals(other: IValueObject<LiveryDecalProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.id === b.id &&
a.imageUrl === b.imageUrl &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height &&
a.rotation === b.rotation &&
a.zIndex === b.zIndex &&
a.type === b.type
);
}
}

View File

@@ -2,10 +2,11 @@
* Value Object: MembershipFee
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
@@ -14,7 +15,7 @@ export interface MembershipFeeProps {
amount: Money;
}
export class MembershipFee {
export class MembershipFee implements IValueObject<MembershipFeeProps> {
readonly type: MembershipFeeType;
readonly amount: Money;
@@ -53,6 +54,13 @@ export class MembershipFee {
return this.amount.calculateNetAmount();
}
get props(): MembershipFeeProps {
return {
type: this.type,
amount: this.amount,
};
}
/**
* Check if this is a recurring fee
*/
@@ -60,6 +68,12 @@ export class MembershipFee {
return this.type === 'monthly';
}
equals(other: IValueObject<MembershipFeeProps>): boolean {
const a = this.props;
const b = other.props;
return a.type === b.type && a.amount.equals(b.amount);
}
/**
* Get display name for fee type
*/

View File

@@ -2,12 +2,18 @@
* Value Object: Money
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type Currency = 'USD' | 'EUR' | 'GBP';
export class Money {
export interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money implements IValueObject<MoneyProps> {
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
readonly amount: number;
@@ -78,11 +84,20 @@ export class Money {
return this.amount > other.amount;
}
get props(): MoneyProps {
return {
amount: this.amount,
currency: this.currency,
};
}
/**
* Check if this money equals another
*/
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
equals(other: IValueObject<MoneyProps>): boolean {
const a = this.props;
const b = other.props;
return a.amount === b.amount && a.currency === b.currency;
}
/**

View File

@@ -1,6 +1,12 @@
import type { Weekday } from './Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export class MonthlyRecurrencePattern {
export interface MonthlyRecurrencePatternProps {
ordinal: 1 | 2 | 3 | 4;
weekday: Weekday;
}
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
@@ -8,4 +14,17 @@ export class MonthlyRecurrencePattern {
this.ordinal = ordinal;
this.weekday = weekday;
}
get props(): MonthlyRecurrencePatternProps {
return {
ordinal: this.ordinal,
weekday: this.weekday,
};
}
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
const a = this.props;
const b = other.props;
return a.ordinal === b.ordinal && a.weekday === b.weekday;
}
}

View File

@@ -1,6 +0,0 @@
import type { ChampionshipType } from './ChampionshipType';
export interface ParticipantRef {
type: ChampionshipType;
id: string;
}

View File

@@ -1,4 +1,10 @@
export class PointsTable {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PointsTableProps {
pointsByPosition: Map<number, number>;
}
export class PointsTable implements IValueObject<PointsTableProps> {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
@@ -18,4 +24,23 @@ export class PointsTable {
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
get props(): PointsTableProps {
return {
pointsByPosition: new Map(this.pointsByPosition),
};
}
equals(other: IValueObject<PointsTableProps>): boolean {
const a = this.props.pointsByPosition;
const b = other.props.pointsByPosition;
if (a.size !== b.size) return false;
for (const [key, value] of a.entries()) {
if (b.get(key) !== value) {
return false;
}
}
return true;
}
}

View File

@@ -1,6 +1,12 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class RaceTimeOfDay {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface RaceTimeOfDayProps {
hour: number;
minute: number;
}
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
readonly hour: number;
readonly minute: number;
@@ -21,16 +27,29 @@ export class RaceTimeOfDay {
if (!match) {
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
get props(): RaceTimeOfDayProps {
return {
hour: this.hour,
minute: this.minute,
};
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
const a = this.props;
const b = other.props;
return a.hour === b.hour && a.minute === b.minute;
}
}

View File

@@ -1,56 +0,0 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
export type WeeklyRecurrence = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrence = {
kind: 'everyNWeeks';
intervalWeeks: number;
weekdays: WeekdaySet;
};
export type MonthlyNthWeekdayRecurrence = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrence
| EveryNWeeksRecurrence
| MonthlyNthWeekdayRecurrence;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
throw new RacingDomainValidationError(
'everyNWeeks intervalWeeks must be an integer between 1 and 12',
);
}
return {
kind: 'everyNWeeks',
intervalWeeks,
weekdays,
};
}
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern,
};
}
}

View File

@@ -1,8 +1,15 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class ScheduledRaceSlot {
export interface ScheduledRaceSlotProps {
roundNumber: number;
scheduledAt: Date;
timezone: LeagueTimezone;
}
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
@@ -19,4 +26,22 @@ export class ScheduledRaceSlot {
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
get props(): ScheduledRaceSlotProps {
return {
roundNumber: this.roundNumber,
scheduledAt: this.scheduledAt,
timezone: this.timezone,
};
}
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.roundNumber === b.roundNumber &&
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
a.timezone.equals(b.timezone)
);
}
}

View File

@@ -2,8 +2,17 @@ import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class SeasonSchedule {
export interface SeasonScheduleProps {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}
export class SeasonSchedule implements IValueObject<SeasonScheduleProps> {
readonly startDate: Date;
readonly timeOfDay: RaceTimeOfDay;
readonly timezone: LeagueTimezone;
@@ -34,4 +43,26 @@ export class SeasonSchedule {
this.recurrence = params.recurrence;
this.plannedRounds = params.plannedRounds;
}
get props(): SeasonScheduleProps {
return {
startDate: this.startDate,
timeOfDay: this.timeOfDay,
timezone: this.timezone,
recurrence: this.recurrence,
plannedRounds: this.plannedRounds,
};
}
equals(other: IValueObject<SeasonScheduleProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.startDate.getTime() === b.startDate.getTime() &&
a.timeOfDay.equals(b.timeOfDay) &&
a.timezone.equals(b.timezone) &&
a.recurrence.kind === b.recurrence.kind &&
a.plannedRounds === b.plannedRounds
);
}
}

View File

@@ -1,9 +0,0 @@
export type SessionType =
| 'practice'
| 'qualifying'
| 'q1'
| 'q2'
| 'q3'
| 'sprint'
| 'main'
| 'timeTrial';

View File

@@ -1,11 +1,12 @@
/**
* Value Object: SponsorshipPricing
*
*
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
*/
import { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SponsorshipSlotConfig {
tier: 'main' | 'secondary';
@@ -22,7 +23,7 @@ export interface SponsorshipPricingProps {
customRequirements?: string;
}
export class SponsorshipPricing {
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot?: SponsorshipSlotConfig;
readonly secondarySlots?: SponsorshipSlotConfig;
readonly acceptingApplications: boolean;

View File

@@ -1,27 +0,0 @@
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
import { RacingDomainInvariantError } from '../errors/RacingDomainError';
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function weekdayToIndex(day: Weekday): number {
switch (day) {
case 'Mon':
return 1;
case 'Tue':
return 2;
case 'Wed':
return 3;
case 'Thu':
return 4;
case 'Fri':
return 5;
case 'Sat':
return 6;
case 'Sun':
return 7;
default:
// This should be unreachable because Weekday is a closed union.
throw new RacingDomainInvariantError(`Unknown weekday: ${day}`);
}
}

View File

@@ -1,19 +1,28 @@
import type { Weekday } from './Weekday';
import { weekdayToIndex } from './Weekday';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class WeekdaySet {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface WeekdaySetProps {
days: Weekday[];
}
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
}
get props(): WeekdaySetProps {
return { days: [...this.days] };
}
getAll(): Weekday[] {
return [...this.days];
}
@@ -21,4 +30,11 @@ export class WeekdaySet {
includes(day: Weekday): boolean {
return this.days.includes(day);
}
equals(other: IValueObject<WeekdaySetProps>): boolean {
const a = this.props.days;
const b = other.props.days;
if (a.length !== b.length) return false;
return a.every((day, index) => day === b[index]);
}
}