This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -7,8 +7,7 @@
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type SessionType = 'practice' | 'qualifying' | 'race';
import type { SessionType } from '../value-objects/SessionType';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race implements IEntity<string> {
@@ -80,7 +79,7 @@ export class Race implements IEntity<string> {
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
car: props.car,
...(props.carId !== undefined ? { carId: props.carId } : {}),
sessionType: props.sessionType ?? 'race',
sessionType: props.sessionType ?? SessionType.main(),
status: props.status ?? 'scheduled',
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),

View File

@@ -0,0 +1,283 @@
/**
* 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 '@gridpilot/shared/domain';
import type { Session } from './Session';
import type { 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',
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',
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',
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',
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?.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';
}
}

View File

@@ -0,0 +1,175 @@
/**
* Enhanced Result entity with detailed incident tracking
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
export class ResultWithIncidents implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly position: number;
readonly fastestLap: number;
readonly incidents: RaceIncidents;
readonly startPosition: number;
private constructor(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.position = props.position;
this.fastestLap = props.fastestLap;
this.incidents = props.incidents;
this.startPosition = props.startPosition;
}
/**
* Factory method to create a new Result entity
*/
static create(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}): ResultWithIncidents {
ResultWithIncidents.validate(props);
return new ResultWithIncidents(props);
}
/**
* Create from legacy Result data (with incidents as number)
*/
static fromLegacy(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): ResultWithIncidents {
const raceIncidents = RaceIncidents.fromLegacyIncidentsCount(props.incidents);
return ResultWithIncidents.create({
...props,
incidents: raceIncidents,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new RacingDomainValidationError('Position must be a positive integer');
}
if (props.fastestLap < 0) {
throw new RacingDomainValidationError('Fastest lap cannot be negative');
}
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
throw new RacingDomainValidationError('Start position must be a positive integer');
}
}
/**
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition - this.position;
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position <= 3;
}
/**
* Check if driver had a clean race (no incidents)
*/
isClean(): boolean {
return this.incidents.isClean();
}
/**
* Get total incident count (for backward compatibility)
*/
getTotalIncidents(): number {
return this.incidents.getTotalCount();
}
/**
* Get incident severity score
*/
getIncidentSeverityScore(): number {
return this.incidents.getSeverityScore();
}
/**
* Get human-readable incident summary
*/
getIncidentSummary(): string {
return this.incidents.getSummary();
}
/**
* Add an incident to this result
*/
addIncident(incident: IncidentRecord): ResultWithIncidents {
const updatedIncidents = this.incidents.addIncident(incident);
return new ResultWithIncidents({
...this,
incidents: updatedIncidents,
});
}
/**
* Convert to legacy format (for backward compatibility)
*/
toLegacyFormat() {
return {
id: this.id,
raceId: this.raceId,
driverId: this.driverId,
position: this.position,
fastestLap: this.fastestLap,
incidents: this.getTotalIncidents(),
startPosition: this.startPosition,
};
}
}

View File

@@ -0,0 +1,311 @@
/**
* Domain Entity: Session
*
* Represents a racing session within a race event.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { SessionType } from '../value-objects/SessionType';
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Session implements IEntity<string> {
readonly id: string;
readonly raceEventId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly trackId: string | undefined;
readonly car: string;
readonly carId: string | undefined;
readonly sessionType: SessionType;
readonly status: SessionStatus;
readonly strengthOfField: number | undefined;
readonly registeredCount: number | undefined;
readonly maxParticipants: number | undefined;
private constructor(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: SessionType;
status: SessionStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}) {
this.id = props.id;
this.raceEventId = props.raceEventId;
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 Session entity
*/
static create(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: SessionType;
status?: SessionStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}): Session {
this.validate(props);
return new Session({
id: props.id,
raceEventId: props.raceEventId,
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 ?? 'scheduled',
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType: SessionType;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Session ID is required');
}
if (!props.raceEventId || props.raceEventId.trim().length === 0) {
throw new RacingDomainValidationError('Race Event ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
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');
}
if (!props.sessionType) {
throw new RacingDomainValidationError('Session type is required');
}
}
/**
* Start the session (move from scheduled to running)
*/
start(): Session {
if (this.status !== 'scheduled') {
throw new RacingDomainInvariantError('Only scheduled sessions can be started');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'running' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Mark session as completed
*/
complete(): Session {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Session is already completed');
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Cannot complete a cancelled session');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'completed' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Cancel the session
*/
cancel(): Session {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Cannot cancel a completed session');
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Session is already cancelled');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'cancelled' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Session {
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: this.status,
strengthOfField,
registeredCount,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const props =
this.maxParticipants !== undefined
? { ...withCarId, maxParticipants: this.maxParticipants }
: withCarId;
return Session.create(props);
}
/**
* Check if session is in the past
*/
isPast(): boolean {
return this.scheduledAt < new Date();
}
/**
* Check if session is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
/**
* Check if session is live/running
*/
isLive(): boolean {
return this.status === 'running';
}
/**
* Check if this session counts for championship points
*/
countsForPoints(): boolean {
return this.sessionType.countsForPoints();
}
/**
* Check if this session determines grid positions
*/
determinesGrid(): boolean {
return this.sessionType.determinesGrid();
}
}