Files
gridpilot.gg/core/racing/domain/entities/Race.ts
2025-12-27 19:18:54 +01:00

417 lines
14 KiB
TypeScript

/**
* Domain Entity: Race
*
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { SessionType } from '../value-objects/SessionType';
import { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus';
import { ParticipantCount } from '../value-objects/ParticipantCount';
import { MaxParticipants } from '../value-objects/MaxParticipants';
import { StrengthOfField } from '../value-objects/StrengthOfField';
export type { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus';
export class Race implements IEntity<string> {
readonly id: string;
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;
}) {
this.id = 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);
}
// Validate scheduled time is not in the past for new races
// Allow some flexibility for testing and bootstrap scenarios
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
if (status.isScheduled() && props.scheduledAt < oneHourAgo) {
throw new RacingDomainValidationError('Scheduled time cannot be more than 1 hour in the past');
}
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 } : {}),
});
}
/**
* 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 {
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 {
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();
}
}