Files
gridpilot.gg/core/racing/domain/entities/RaceEvent.ts
2025-12-17 00:33:13 +01:00

283 lines
8.0 KiB
TypeScript

/**
* Domain Entity: RaceEvent (Aggregate Root)
*
* Represents a race event containing multiple sessions (practice, quali, race).
* Immutable aggregate root with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import type { Session } from './Session';
import { SessionType } from '../value-objects/SessionType';
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
export class RaceEvent implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly leagueId: string;
readonly name: string;
readonly sessions: readonly Session[];
readonly status: RaceEventStatus;
readonly stewardingClosesAt: Date | undefined;
private constructor(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: readonly Session[];
status: RaceEventStatus;
stewardingClosesAt?: Date;
}) {
this.id = props.id;
this.seasonId = props.seasonId;
this.leagueId = props.leagueId;
this.name = props.name;
this.sessions = props.sessions;
this.status = props.status;
this.stewardingClosesAt = props.stewardingClosesAt;
}
/**
* Factory method to create a new RaceEvent entity
*/
static create(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: Session[];
status?: RaceEventStatus;
stewardingClosesAt?: Date;
}): RaceEvent {
this.validate(props);
return new RaceEvent({
id: props.id,
seasonId: props.seasonId,
leagueId: props.leagueId,
name: props.name,
sessions: [...props.sessions], // Create immutable copy
status: props.status ?? 'scheduled',
...(props.stewardingClosesAt !== undefined ? { stewardingClosesAt: props.stewardingClosesAt } : {}),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: Session[];
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('RaceEvent ID is required');
}
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new RacingDomainValidationError('Season ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('RaceEvent name is required');
}
if (!props.sessions || props.sessions.length === 0) {
throw new RacingDomainValidationError('RaceEvent must have at least one session');
}
// Validate all sessions belong to this race event
const invalidSessions = props.sessions.filter(s => s.raceEventId !== props.id);
if (invalidSessions.length > 0) {
throw new RacingDomainValidationError('All sessions must belong to this race event');
}
// Validate session types are unique
const sessionTypes = props.sessions.map(s => s.sessionType.value);
const uniqueTypes = new Set(sessionTypes);
if (uniqueTypes.size !== sessionTypes.length) {
throw new RacingDomainValidationError('Session types must be unique within a race event');
}
// Validate at least one main race session exists
const hasMainRace = props.sessions.some(s => s.sessionType.value === 'main');
if (!hasMainRace) {
throw new RacingDomainValidationError('RaceEvent must have at least one main race session');
}
}
/**
* Start the race event (move from scheduled to in_progress)
*/
start(): RaceEvent {
if (this.status !== 'scheduled') {
throw new RacingDomainInvariantError('Only scheduled race events can be started');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: [...this.sessions],
status: 'in_progress',
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
/**
* Complete the main race session and move to awaiting_stewarding
*/
completeMainRace(): RaceEvent {
if (this.status !== 'in_progress') {
throw new RacingDomainInvariantError('Only in-progress race events can complete main race');
}
const mainRaceSession = this.getMainRaceSession();
if (!mainRaceSession || mainRaceSession.status !== 'completed') {
throw new RacingDomainInvariantError('Main race session must be completed first');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: [...this.sessions],
status: 'awaiting_stewarding',
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
/**
* Close stewarding and finalize the race event
*/
closeStewarding(): RaceEvent {
if (this.status !== 'awaiting_stewarding') {
throw new RacingDomainInvariantError('Only race events awaiting stewarding can be closed');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: [...this.sessions],
status: 'closed',
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
/**
* Cancel the race event
*/
cancel(): RaceEvent {
if (this.status === 'closed') {
throw new RacingDomainInvariantError('Cannot cancel a closed race event');
}
if (this.status === 'cancelled') {
return this;
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: [...this.sessions],
status: 'cancelled',
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
/**
* Get the main race session (the one that counts for championship points)
*/
getMainRaceSession(): Session | undefined {
return this.sessions.find(s => s.sessionType.equals(SessionType.main()));
}
/**
* Get all sessions of a specific type
*/
getSessionsByType(sessionType: SessionType): Session[] {
return this.sessions.filter(s => s.sessionType.equals(sessionType));
}
/**
* Get all completed sessions
*/
getCompletedSessions(): Session[] {
return this.sessions.filter(s => s.status === 'completed');
}
/**
* Check if all sessions are completed
*/
areAllSessionsCompleted(): boolean {
return this.sessions.every(s => s.status === 'completed');
}
/**
* Check if the main race is completed
*/
isMainRaceCompleted(): boolean {
const mainRace = this.getMainRaceSession();
return mainRace ? mainRace.status === 'completed' : false;
}
/**
* Check if stewarding window has expired
*/
hasStewardingExpired(): boolean {
if (!this.stewardingClosesAt) return false;
return new Date() > this.stewardingClosesAt;
}
/**
* Check if race event is in the past
*/
isPast(): boolean {
const latestSession = this.sessions.reduce((latest, session) =>
session.scheduledAt > latest.scheduledAt ? session : latest
);
return latestSession.scheduledAt < new Date();
}
/**
* Check if race event is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
/**
* Check if race event is currently running
*/
isLive(): boolean {
return this.status === 'in_progress';
}
/**
* Check if race event is awaiting stewarding decisions
*/
isAwaitingStewarding(): boolean {
return this.status === 'awaiting_stewarding';
}
/**
* Check if race event is closed (stewarding complete)
*/
isClosed(): boolean {
return this.status === 'closed';
}
}