417 lines
14 KiB
TypeScript
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();
|
|
}
|
|
} |