import { Entity } from '@core/shared/domain/Entity'; import { RacingDomainInvariantError, RacingDomainValidationError, } from '../../errors/RacingDomainError'; import { MaxParticipants } from '../../value-objects/MaxParticipants'; import { ParticipantCount } from '../../value-objects/ParticipantCount'; import type { SeasonDropPolicy } from '../../value-objects/SeasonDropPolicy'; import type { SeasonSchedule } from '../../value-objects/SeasonSchedule'; import type { SeasonScoringConfig } from '../../value-objects/SeasonScoringConfig'; import { SeasonStatus, SeasonStatusValue } from '../../value-objects/SeasonStatus'; import type { SeasonStewardingConfig } from '../../value-objects/SeasonStewardingConfig'; export class Season extends Entity { readonly leagueId: string; readonly gameId: string; readonly name: string; readonly year: number | undefined; readonly order: number | undefined; readonly status: SeasonStatus; readonly startDate: Date | undefined; readonly endDate: Date | undefined; readonly schedule: SeasonSchedule | undefined; readonly schedulePublished: boolean; readonly scoringConfig: SeasonScoringConfig | undefined; readonly dropPolicy: SeasonDropPolicy | undefined; readonly stewardingConfig: SeasonStewardingConfig | undefined; readonly maxDrivers: number | undefined; // Domain state for business rule enforcement private readonly _participantCount: ParticipantCount; private constructor(props: { id: string; leagueId: string; gameId: string; name: string; year?: number; order?: number; status: SeasonStatus; startDate?: Date; endDate?: Date; schedule?: SeasonSchedule; schedulePublished: boolean; scoringConfig?: SeasonScoringConfig; dropPolicy?: SeasonDropPolicy; stewardingConfig?: SeasonStewardingConfig; maxDrivers?: number; participantCount: ParticipantCount; }) { super(props.id); this.leagueId = props.leagueId; this.gameId = props.gameId; this.name = props.name; this.year = props.year; this.order = props.order; this.status = props.status; this.startDate = props.startDate; this.endDate = props.endDate; this.schedule = props.schedule; this.schedulePublished = props.schedulePublished; this.scoringConfig = props.scoringConfig; this.dropPolicy = props.dropPolicy; this.stewardingConfig = props.stewardingConfig; this.maxDrivers = props.maxDrivers; this._participantCount = props.participantCount; } static create(props: { id: string; leagueId: string; gameId: string; name: string; year?: number; order?: number; status?: SeasonStatusValue; startDate?: Date; endDate?: Date | undefined; schedule?: SeasonSchedule; schedulePublished?: boolean; scoringConfig?: SeasonScoringConfig; dropPolicy?: SeasonDropPolicy; stewardingConfig?: SeasonStewardingConfig; maxDrivers?: number; participantCount?: number; }): Season { // Validate required fields if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Season ID is required'); } if (!props.leagueId || props.leagueId.trim().length === 0) { throw new RacingDomainValidationError('Season leagueId is required'); } if (!props.gameId || props.gameId.trim().length === 0) { throw new RacingDomainValidationError('Season gameId is required'); } if (!props.name || props.name.trim().length === 0) { throw new RacingDomainValidationError('Season name is required'); } // Validate maxDrivers if provided if (props.maxDrivers !== undefined) { if (props.maxDrivers <= 0) { throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided'); } if (props.maxDrivers > 100) { throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100'); } } // Validate participant count const participantCount = ParticipantCount.create(props.participantCount ?? 0); if (props.maxDrivers !== undefined) { const maxParticipants = MaxParticipants.create(props.maxDrivers); if (!maxParticipants.canAccommodate(participantCount.toNumber())) { throw new RacingDomainValidationError( `Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})` ); } } // Validate status transitions if status is provided const status = SeasonStatus.create(props.status ?? 'planned'); // Validate schedule if provided if (props.schedule) { // Ensure schedule dates align with season dates if (props.startDate && props.schedule.startDate < props.startDate) { throw new RacingDomainValidationError('Schedule start date cannot be before season start date'); } } // Validate scoring config if provided if (props.scoringConfig) { if (!props.scoringConfig.scoringPresetId || props.scoringConfig.scoringPresetId.trim().length === 0) { throw new RacingDomainValidationError('Scoring preset ID is required'); } } // Validate drop policy if provided if (props.dropPolicy) { if (props.dropPolicy.strategy === 'bestNResults' || props.dropPolicy.strategy === 'dropWorstN') { if (!props.dropPolicy.n || props.dropPolicy.n <= 0) { throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy'); } } } // Validate stewarding config if provided if (props.stewardingConfig) { Season.validateStewardingConfig(props.stewardingConfig); } return new Season({ id: props.id, leagueId: props.leagueId, gameId: props.gameId, name: props.name, ...(props.year !== undefined ? { year: props.year } : {}), ...(props.order !== undefined ? { order: props.order } : {}), status, ...(props.startDate !== undefined ? { startDate: props.startDate } : {}), ...(props.endDate !== undefined ? { endDate: props.endDate } : {}), ...(props.schedule !== undefined ? { schedule: props.schedule } : {}), schedulePublished: props.schedulePublished ?? false, ...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}), ...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}), ...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}), ...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}), participantCount, }); } static rehydrate(props: { id: string; leagueId: string; gameId: string; name: string; year?: number; order?: number; status: SeasonStatus; startDate?: Date; endDate?: Date; schedule?: SeasonSchedule; schedulePublished: boolean; scoringConfig?: SeasonScoringConfig; dropPolicy?: SeasonDropPolicy; stewardingConfig?: SeasonStewardingConfig; maxDrivers?: number; participantCount: number; }): Season { const participantCount = ParticipantCount.create(props.participantCount); if (props.maxDrivers !== undefined) { const maxParticipants = MaxParticipants.create(props.maxDrivers); if (!maxParticipants.canAccommodate(participantCount.toNumber())) { throw new RacingDomainValidationError( `Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})`, ); } } return new Season({ id: props.id, leagueId: props.leagueId, gameId: props.gameId, name: props.name, ...(props.year !== undefined ? { year: props.year } : {}), ...(props.order !== undefined ? { order: props.order } : {}), status: props.status, ...(props.startDate !== undefined ? { startDate: props.startDate } : {}), ...(props.endDate !== undefined ? { endDate: props.endDate } : {}), ...(props.schedule !== undefined ? { schedule: props.schedule } : {}), schedulePublished: props.schedulePublished, ...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}), ...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}), ...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}), ...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}), participantCount, }); } /** * Validate stewarding configuration */ private static validateStewardingConfig(config: SeasonStewardingConfig): void { if (!config.decisionMode) { throw new RacingDomainValidationError('Stewarding decision mode is required'); } const votingModes = ['steward_vote', 'member_vote', 'steward_veto', 'member_veto']; if (votingModes.includes(config.decisionMode)) { if (!config.requiredVotes || config.requiredVotes <= 0) { throw new RacingDomainValidationError( 'Stewarding settings with voting modes require a positive requiredVotes value' ); } } if (config.requiredVotes !== undefined && !votingModes.includes(config.decisionMode)) { throw new RacingDomainValidationError( 'requiredVotes should only be provided for voting/veto modes' ); } // Validate time limits if (config.defenseTimeLimit !== undefined && config.defenseTimeLimit < 0) { throw new RacingDomainValidationError('Defense time limit must be non-negative'); } if (config.voteTimeLimit !== undefined && config.voteTimeLimit <= 0) { throw new RacingDomainValidationError('Vote time limit must be positive'); } if (config.protestDeadlineHours !== undefined && config.protestDeadlineHours <= 0) { throw new RacingDomainValidationError('Protest deadline must be positive'); } if (config.stewardingClosesHours !== undefined && config.stewardingClosesHours <= 0) { throw new RacingDomainValidationError('Stewarding close time must be positive'); } } /** * Domain rule: Wallet withdrawals are only allowed when season is completed */ canWithdrawFromWallet(): boolean { return this.status.isCompleted(); } /** * Check if season is active */ isActive(): boolean { return this.status.isActive(); } /** * Check if season is completed */ isCompleted(): boolean { return this.status.isCompleted(); } /** * Check if season is planned (not yet active) */ isPlanned(): boolean { return this.status.isPlanned(); } /** * Check if season can accept more participants */ canAcceptMore(): boolean { if (this.maxDrivers === undefined) return true; return this._participantCount.toNumber() < this.maxDrivers; } /** * Get current participant count */ getParticipantCount(): number { return this._participantCount.toNumber(); } /** * Activate the season from planned state. */ activate(): Season { const transition = this.status.canTransitionTo('active'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } // Ensure start date is set const startDate = this.startDate ?? new Date(); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: 'active', startDate, ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Mark the season as completed. */ complete(): Season { const transition = this.status.canTransitionTo('completed'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } // Ensure end date is set const endDate = this.endDate ?? new Date(); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: 'completed', ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), endDate, ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Archive a completed season. */ archive(): Season { const transition = this.status.canTransitionTo('archived'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: 'archived', ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Cancel a planned or active season. */ cancel(): Season { // If already cancelled, return this (idempotent). if (this.status.isCancelled()) { return this; } const transition = this.status.canTransitionTo('cancelled'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } // Ensure end date is set const endDate = this.endDate ?? new Date(); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: 'cancelled', ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), endDate, ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Update schedule while keeping other properties intact. */ withSchedule(schedule: SeasonSchedule): Season { // Validate schedule against season dates if (this.startDate && schedule.startDate < this.startDate) { throw new RacingDomainValidationError('Schedule start date cannot be before season start date'); } return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), schedule, schedulePublished: this.schedulePublished, ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } withSchedulePublished(published: boolean): Season { return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), schedulePublished: published, ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Update scoring configuration for the season. */ withScoringConfig(scoringConfig: SeasonScoringConfig): Season { if (!scoringConfig.scoringPresetId || scoringConfig.scoringPresetId.trim().length === 0) { throw new RacingDomainValidationError('Scoring preset ID is required'); } return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), scoringConfig, ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Update drop policy for the season. */ withDropPolicy(dropPolicy: SeasonDropPolicy): Season { // Validate drop policy if (dropPolicy.strategy === 'bestNResults' || dropPolicy.strategy === 'dropWorstN') { if (!dropPolicy.n || dropPolicy.n <= 0) { throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy'); } } return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), dropPolicy, ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Update stewarding configuration for the season. */ withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season { Season.validateStewardingConfig(stewardingConfig); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), stewardingConfig, ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Update max driver capacity for the season. */ withMaxDrivers(maxDrivers: number | undefined): Season { if (maxDrivers !== undefined) { if (maxDrivers <= 0) { throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided'); } if (maxDrivers > 100) { throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100'); } if (maxDrivers < this._participantCount.toNumber()) { throw new RacingDomainValidationError( `Cannot reduce max drivers (${maxDrivers}) below current participant count (${this._participantCount.toNumber()})` ); } } return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(maxDrivers !== undefined ? { maxDrivers } : {}), participantCount: this._participantCount.toNumber(), }); } /** * Add a participant to the season */ addParticipant(): Season { if (!this.canAcceptMore()) { throw new RacingDomainInvariantError( `Season capacity (${this.maxDrivers}) would be exceeded` ); } const newCount = this._participantCount.increment(); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: newCount.toNumber(), }); } /** * Remove a participant from the season */ removeParticipant(): Season { if (this._participantCount.isZero()) { throw new RacingDomainInvariantError('Cannot remove participant: season has no participants'); } const newCount = this._participantCount.decrement(); return Season.create({ id: this.id, leagueId: this.leagueId, gameId: this.gameId, name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), participantCount: newCount.toNumber(), }); } equals(other: Entity): boolean { if (!(other instanceof Season)) { return false; } return this.id === other.id; } }