/** * Domain Entity: Race * * Represents a race/session in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ 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 { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus'; import { SessionType } from '../value-objects/SessionType'; import { StrengthOfField } from '../value-objects/StrengthOfField'; export type { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus'; export class Race extends Entity { readonly leagueId: string; readonly scheduledAt: Date; readonly track: string; readonly trackId: string | undefined; readonly car: string; readonly carId: string | undefined; readonly sessionType: SessionType; readonly status: RaceStatus; readonly strengthOfField: StrengthOfField | undefined; readonly registeredCount: ParticipantCount | undefined; readonly maxParticipants: MaxParticipants | undefined; // Compatibility properties for existing code get statusString(): string { return this.status.toString(); } get strengthOfFieldNumber(): number | undefined { return this.strengthOfField ? this.strengthOfField.toNumber() : undefined; } get registeredCountNumber(): number | undefined { return this.registeredCount ? this.registeredCount.toNumber() : undefined; } get maxParticipantsNumber(): number | undefined { return this.maxParticipants ? this.maxParticipants.toNumber() : undefined; } private constructor(props: { id: string; leagueId: string; scheduledAt: Date; track: string; trackId?: string; car: string; carId?: string; sessionType: SessionType; status: RaceStatus; strengthOfField?: StrengthOfField; registeredCount?: ParticipantCount; maxParticipants?: MaxParticipants; }) { super(props.id); this.leagueId = props.leagueId; this.scheduledAt = props.scheduledAt; this.track = props.track; this.trackId = props.trackId; this.car = props.car; this.carId = props.carId; this.sessionType = props.sessionType; this.status = props.status; this.strengthOfField = props.strengthOfField; this.registeredCount = props.registeredCount; this.maxParticipants = props.maxParticipants; } /** * Factory method to create a new Race entity * Enforces all business rules and invariants */ static create(props: { id: string; leagueId: string; scheduledAt: Date; track: string; trackId?: string; car: string; carId?: string; sessionType?: SessionType; status?: RaceStatusValue; strengthOfField?: number; registeredCount?: number; maxParticipants?: number; }): Race { // Validate required fields if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Race ID is required'); } if (!props.leagueId || props.leagueId.trim().length === 0) { throw new RacingDomainValidationError('League ID is required'); } if (!props.scheduledAt || !(props.scheduledAt instanceof Date) || isNaN(props.scheduledAt.getTime())) { throw new RacingDomainValidationError('Valid scheduled date is required'); } if (!props.track || props.track.trim().length === 0) { throw new RacingDomainValidationError('Track is required'); } if (!props.car || props.car.trim().length === 0) { throw new RacingDomainValidationError('Car is required'); } // Validate status const status = RaceStatus.create(props.status ?? 'scheduled'); // Validate participant counts let registeredCount: ParticipantCount | undefined; let maxParticipants: MaxParticipants | undefined; if (props.registeredCount !== undefined) { registeredCount = ParticipantCount.create(props.registeredCount); } if (props.maxParticipants !== undefined) { maxParticipants = MaxParticipants.create(props.maxParticipants); // Validate that registered count doesn't exceed max if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) { throw new RacingDomainValidationError( `Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})` ); } } // Validate strength of field if provided let strengthOfField: StrengthOfField | undefined; if (props.strengthOfField !== undefined) { strengthOfField = StrengthOfField.create(props.strengthOfField); } return new Race({ id: props.id, leagueId: props.leagueId, scheduledAt: props.scheduledAt, track: props.track, ...(props.trackId !== undefined ? { trackId: props.trackId } : {}), car: props.car, ...(props.carId !== undefined ? { carId: props.carId } : {}), sessionType: props.sessionType ?? SessionType.main(), status, ...(strengthOfField !== undefined ? { strengthOfField } : {}), ...(registeredCount !== undefined ? { registeredCount } : {}), ...(maxParticipants !== undefined ? { maxParticipants } : {}), }); } static rehydrate(props: { id: string; leagueId: string; scheduledAt: Date; track: string; trackId?: string; car: string; carId?: string; sessionType: SessionType; status: RaceStatus; strengthOfField?: number; registeredCount?: number; maxParticipants?: number; }): Race { let registeredCount: ParticipantCount | undefined; let maxParticipants: MaxParticipants | undefined; if (props.registeredCount !== undefined) { registeredCount = ParticipantCount.create(props.registeredCount); } if (props.maxParticipants !== undefined) { maxParticipants = MaxParticipants.create(props.maxParticipants); if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) { throw new RacingDomainValidationError( `Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})`, ); } } let strengthOfField: StrengthOfField | undefined; if (props.strengthOfField !== undefined) { strengthOfField = StrengthOfField.create(props.strengthOfField); } return new Race({ id: props.id, leagueId: props.leagueId, scheduledAt: props.scheduledAt, track: props.track, ...(props.trackId !== undefined ? { trackId: props.trackId } : {}), car: props.car, ...(props.carId !== undefined ? { carId: props.carId } : {}), sessionType: props.sessionType, status: props.status, ...(strengthOfField !== undefined ? { strengthOfField } : {}), ...(registeredCount !== undefined ? { registeredCount } : {}), ...(maxParticipants !== undefined ? { maxParticipants } : {}), }); } /** * Start the race (move from scheduled to running) */ start(): Race { const transition = this.status.canTransitionTo('running'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: 'running', ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Mark race as completed */ complete(): Race { const transition = this.status.canTransitionTo('completed'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: 'completed', ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Cancel the race */ cancel(): Race { if (this.status.isCancelled()) { throw new RacingDomainInvariantError('Race is already cancelled'); } if (this.status.isCompleted()) { throw new RacingDomainInvariantError('Cannot cancel completed race'); } const transition = this.status.canTransitionTo('cancelled'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: 'cancelled', ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Re-open a previously completed or cancelled race */ reopen(): Race { if (this.status.isScheduled()) { throw new RacingDomainInvariantError('Race is already scheduled'); } if (this.status.isRunning()) { throw new RacingDomainInvariantError('Cannot reopen running race'); } const transition = this.status.canTransitionTo('scheduled'); if (!transition.valid) { throw new RacingDomainInvariantError(transition.error!); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: 'scheduled', ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Update SOF and participant count */ updateField(strengthOfField: number, registeredCount: number): Race { // Validate strength of field const newStrengthOfField = StrengthOfField.create(strengthOfField); // Validate registered count against max participants const newRegisteredCount = ParticipantCount.create(registeredCount); if (this.maxParticipants && !this.maxParticipants.canAccommodate(newRegisteredCount.toNumber())) { throw new RacingDomainValidationError( `Registered count (${newRegisteredCount.toNumber()}) exceeds max participants (${this.maxParticipants.toNumber()})` ); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: this.status.toString(), strengthOfField: newStrengthOfField.toNumber(), registeredCount: newRegisteredCount.toNumber(), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Add a participant to the race */ addParticipant(): Race { if (!this.registeredCount) { throw new RacingDomainInvariantError('Race must have a registered count initialized'); } const newCount = this.registeredCount.increment(); if (this.maxParticipants && !this.maxParticipants.canAccommodate(newCount.toNumber())) { throw new RacingDomainInvariantError( `Cannot add participant: race capacity (${this.maxParticipants.toNumber()}) would be exceeded` ); } return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: this.status.toString(), ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), registeredCount: newCount.toNumber(), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Remove a participant from the race */ removeParticipant(): Race { if (!this.registeredCount) { throw new RacingDomainInvariantError('Race must have a registered count initialized'); } if (this.registeredCount.isZero()) { throw new RacingDomainInvariantError('Cannot remove participant: race has no participants'); } const newCount = this.registeredCount.decrement(); return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, status: this.status.toString(), ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}), registeredCount: newCount.toNumber(), ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), }); } /** * Check if race is in the past */ isPast(): boolean { return this.scheduledAt < new Date(); } /** * Check if race is upcoming */ isUpcoming(): boolean { return this.status.isScheduled() && !this.isPast(); } /** * Check if race is live/running */ isLive(): boolean { return this.status.isRunning(); } /** * Check if race can accept more participants */ canAcceptMore(): boolean { if (!this.maxParticipants || !this.registeredCount) return false; return this.maxParticipants.canAccommodate(this.registeredCount.toNumber() + 1); } /** * Get current registered count */ getRegisteredCount(): number { return this.registeredCount ? this.registeredCount.toNumber() : 0; } /** * Get max participants */ getMaxParticipants(): number | undefined { return this.maxParticipants ? this.maxParticipants.toNumber() : undefined; } /** * Get strength of field as number */ getStrengthOfField(): number | undefined { return this.strengthOfField ? this.strengthOfField.toNumber() : undefined; } /** * Get status as string (for compatibility with existing code) */ getStatus(): string { return this.status.toString(); } equals(other: Entity): boolean { if (!(other instanceof Race)) { return false; } return this.id === other.id; } }