wip
This commit is contained in:
@@ -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 } : {}),
|
||||
|
||||
283
packages/racing/domain/entities/RaceEvent.ts
Normal file
283
packages/racing/domain/entities/RaceEvent.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
175
packages/racing/domain/entities/ResultWithIncidents.ts
Normal file
175
packages/racing/domain/entities/ResultWithIncidents.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
311
packages/racing/domain/entities/Session.ts
Normal file
311
packages/racing/domain/entities/Session.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
29
packages/racing/domain/events/MainRaceCompleted.ts
Normal file
29
packages/racing/domain/events/MainRaceCompleted.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Domain Event: MainRaceCompleted
|
||||
*
|
||||
* Fired when the main race session of a race event is completed.
|
||||
* This triggers immediate performance summary notifications to drivers.
|
||||
*/
|
||||
export interface MainRaceCompletedEventData {
|
||||
raceEventId: string;
|
||||
sessionId: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
completedAt: Date;
|
||||
driverIds: string[]; // Drivers who participated in the main race
|
||||
}
|
||||
|
||||
export class MainRaceCompletedEvent implements IDomainEvent<MainRaceCompletedEventData> {
|
||||
readonly eventType = 'MainRaceCompleted';
|
||||
readonly aggregateId: string;
|
||||
readonly eventData: MainRaceCompletedEventData;
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(data: MainRaceCompletedEventData) {
|
||||
this.aggregateId = data.raceEventId;
|
||||
this.eventData = { ...data };
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
29
packages/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
29
packages/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Domain Event: RaceEventStewardingClosed
|
||||
*
|
||||
* Fired when the stewarding window closes for a race event.
|
||||
* This triggers final results notifications to drivers with any penalty adjustments.
|
||||
*/
|
||||
export interface RaceEventStewardingClosedEventData {
|
||||
raceEventId: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
closedAt: Date;
|
||||
driverIds: string[]; // Drivers who participated in the race event
|
||||
hadPenaltiesApplied: boolean; // Whether any penalties were applied during stewarding
|
||||
}
|
||||
|
||||
export class RaceEventStewardingClosedEvent implements IDomainEvent<RaceEventStewardingClosedEventData> {
|
||||
readonly eventType = 'RaceEventStewardingClosed';
|
||||
readonly aggregateId: string;
|
||||
readonly eventData: RaceEventStewardingClosedEventData;
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(data: RaceEventStewardingClosedEventData) {
|
||||
this.aggregateId = data.raceEventId;
|
||||
this.eventData = { ...data };
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
14
packages/racing/domain/repositories/IRaceEventRepository.ts
Normal file
14
packages/racing/domain/repositories/IRaceEventRepository.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { RaceEvent } from '../entities/RaceEvent';
|
||||
|
||||
export interface IRaceEventRepository {
|
||||
findById(id: string): Promise<RaceEvent | null>;
|
||||
findAll(): Promise<RaceEvent[]>;
|
||||
findBySeasonId(seasonId: string): Promise<RaceEvent[]>;
|
||||
findByLeagueId(leagueId: string): Promise<RaceEvent[]>;
|
||||
findByStatus(status: string): Promise<RaceEvent[]>;
|
||||
findAwaitingStewardingClose(): Promise<RaceEvent[]>;
|
||||
create(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||
update(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
13
packages/racing/domain/repositories/ISessionRepository.ts
Normal file
13
packages/racing/domain/repositories/ISessionRepository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Session } from '../entities/Session';
|
||||
|
||||
export interface ISessionRepository {
|
||||
findById(id: string): Promise<Session | null>;
|
||||
findAll(): Promise<Session[]>;
|
||||
findByRaceEventId(raceEventId: string): Promise<Session[]>;
|
||||
findByLeagueId(leagueId: string): Promise<Session[]>;
|
||||
findByStatus(status: string): Promise<Session[]>;
|
||||
create(session: Session): Promise<Session>;
|
||||
update(session: Session): Promise<Session>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
239
packages/racing/domain/value-objects/RaceIncidents.ts
Normal file
239
packages/racing/domain/value-objects/RaceIncidents.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Incident types that can occur during a race
|
||||
*/
|
||||
export type IncidentType =
|
||||
| 'track_limits' // Driver went off track and gained advantage
|
||||
| 'contact' // Physical contact with another car
|
||||
| 'unsafe_rejoin' // Unsafe rejoining of the track
|
||||
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
|
||||
| 'false_start' // Started before green flag
|
||||
| 'collision' // Major collision involving multiple cars
|
||||
| 'spin' // Driver spun out
|
||||
| 'mechanical' // Mechanical failure (not driver error)
|
||||
| 'other'; // Other incident types
|
||||
|
||||
/**
|
||||
* Individual incident record
|
||||
*/
|
||||
export interface IncidentRecord {
|
||||
type: IncidentType;
|
||||
lap: number;
|
||||
description?: string;
|
||||
penaltyPoints?: number; // Points deducted for this incident
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: RaceIncidents
|
||||
*
|
||||
* Encapsulates all incidents that occurred during a driver's race.
|
||||
* Provides methods to calculate total penalty points and incident severity.
|
||||
*/
|
||||
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
||||
private readonly incidents: IncidentRecord[];
|
||||
|
||||
constructor(incidents: IncidentRecord[] = []) {
|
||||
this.incidents = [...incidents];
|
||||
}
|
||||
|
||||
get props(): IncidentRecord[] {
|
||||
return [...this.incidents];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new incident
|
||||
*/
|
||||
addIncident(incident: IncidentRecord): RaceIncidents {
|
||||
return new RaceIncidents([...this.incidents, incident]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all incidents
|
||||
*/
|
||||
getAllIncidents(): IncidentRecord[] {
|
||||
return [...this.incidents];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of incidents
|
||||
*/
|
||||
getTotalCount(): number {
|
||||
return this.incidents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total penalty points from all incidents
|
||||
*/
|
||||
getTotalPenaltyPoints(): number {
|
||||
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incidents by type
|
||||
*/
|
||||
getIncidentsByType(type: IncidentType): IncidentRecord[] {
|
||||
return this.incidents.filter(incident => incident.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had any incidents
|
||||
*/
|
||||
hasIncidents(): boolean {
|
||||
return this.incidents.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (no incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident severity score (0-100, higher = more severe)
|
||||
*/
|
||||
getSeverityScore(): number {
|
||||
if (this.incidents.length === 0) return 0;
|
||||
|
||||
const severityWeights: Record<IncidentType, number> = {
|
||||
track_limits: 10,
|
||||
contact: 20,
|
||||
unsafe_rejoin: 25,
|
||||
aggressive_driving: 15,
|
||||
false_start: 30,
|
||||
collision: 40,
|
||||
spin: 35,
|
||||
mechanical: 5, // Lower weight as it's not driver error
|
||||
other: 15,
|
||||
};
|
||||
|
||||
const totalSeverity = this.incidents.reduce((total, incident) => {
|
||||
return total + severityWeights[incident.type];
|
||||
}, 0);
|
||||
|
||||
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
|
||||
return Math.min(100, totalSeverity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable incident summary
|
||||
*/
|
||||
getSummary(): string {
|
||||
if (this.incidents.length === 0) {
|
||||
return 'Clean race';
|
||||
}
|
||||
|
||||
const typeCounts = this.incidents.reduce((counts, incident) => {
|
||||
counts[incident.type] = (counts[incident.type] || 0) + 1;
|
||||
return counts;
|
||||
}, {} as Record<IncidentType, number>);
|
||||
|
||||
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
|
||||
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
|
||||
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
|
||||
});
|
||||
|
||||
return summaryParts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for incident type
|
||||
*/
|
||||
private getIncidentTypeLabel(type: IncidentType): string {
|
||||
const labels: Record<IncidentType, string> = {
|
||||
track_limits: 'Track Limits',
|
||||
contact: 'Contact',
|
||||
unsafe_rejoin: 'Unsafe Rejoin',
|
||||
aggressive_driving: 'Aggressive Driving',
|
||||
false_start: 'False Start',
|
||||
collision: 'Collision',
|
||||
spin: 'Spin',
|
||||
mechanical: 'Mechanical',
|
||||
other: 'Other',
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||
const otherIncidents = other.props;
|
||||
if (this.incidents.length !== otherIncidents.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sort both arrays and compare
|
||||
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
|
||||
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
|
||||
|
||||
return sortedThis.every((incident, index) => {
|
||||
const otherIncident = sortedOther[index];
|
||||
return incident.type === otherIncident.type &&
|
||||
incident.lap === otherIncident.lap &&
|
||||
incident.description === otherIncident.description &&
|
||||
incident.penaltyPoints === otherIncident.penaltyPoints;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RaceIncidents from legacy incidents count
|
||||
*/
|
||||
static fromLegacyIncidentsCount(count: number): RaceIncidents {
|
||||
if (count === 0) {
|
||||
return new RaceIncidents();
|
||||
}
|
||||
|
||||
// Distribute legacy incidents across different types based on probability
|
||||
const incidents: IncidentRecord[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = RaceIncidents.getRandomIncidentType();
|
||||
incidents.push({
|
||||
type,
|
||||
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
|
||||
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
|
||||
});
|
||||
}
|
||||
|
||||
return new RaceIncidents(incidents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random incident type for legacy data conversion
|
||||
*/
|
||||
private static getRandomIncidentType(): IncidentType {
|
||||
const types: IncidentType[] = [
|
||||
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
|
||||
'collision', 'spin', 'other'
|
||||
];
|
||||
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
|
||||
|
||||
const random = Math.random();
|
||||
let cumulativeWeight = 0;
|
||||
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
cumulativeWeight += weights[i];
|
||||
if (random <= cumulativeWeight) {
|
||||
return types[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default penalty points for incident type
|
||||
*/
|
||||
private static getDefaultPenaltyPoints(type: IncidentType): number {
|
||||
const penalties: Record<IncidentType, number> = {
|
||||
track_limits: 0, // Usually just a warning
|
||||
contact: 2,
|
||||
unsafe_rejoin: 3,
|
||||
aggressive_driving: 2,
|
||||
false_start: 5,
|
||||
collision: 5,
|
||||
spin: 0, // Usually no penalty if no contact
|
||||
mechanical: 0,
|
||||
other: 2,
|
||||
};
|
||||
return penalties[type];
|
||||
}
|
||||
}
|
||||
103
packages/racing/domain/value-objects/SessionType.ts
Normal file
103
packages/racing/domain/value-objects/SessionType.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: SessionType
|
||||
*
|
||||
* Represents the type of racing session within a race event.
|
||||
* Immutable value object with domain validation.
|
||||
*/
|
||||
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
|
||||
|
||||
export class SessionType implements IValueObject<SessionTypeValue> {
|
||||
readonly value: SessionTypeValue;
|
||||
|
||||
constructor(value: SessionTypeValue) {
|
||||
if (!value || !this.isValidSessionType(value)) {
|
||||
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
|
||||
}
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
private isValidSessionType(value: string): value is SessionTypeValue {
|
||||
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
|
||||
return validTypes.includes(value as SessionTypeValue);
|
||||
}
|
||||
|
||||
get props(): SessionTypeValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionTypeValue>): boolean {
|
||||
return this.value === other.props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session type counts for championship points
|
||||
*/
|
||||
countsForPoints(): boolean {
|
||||
return this.value === 'main' || this.value === 'sprint';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session type determines grid positions
|
||||
*/
|
||||
determinesGrid(): boolean {
|
||||
return this.value === 'qualifying' || this.value.startsWith('q');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
const names: Record<SessionTypeValue, string> = {
|
||||
practice: 'Practice',
|
||||
qualifying: 'Qualifying',
|
||||
q1: 'Q1',
|
||||
q2: 'Q2',
|
||||
q3: 'Q3',
|
||||
sprint: 'Sprint Race',
|
||||
main: 'Main Race',
|
||||
timeTrial: 'Time Trial',
|
||||
};
|
||||
return names[this.value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display name for UI
|
||||
*/
|
||||
getShortName(): string {
|
||||
const names: Record<SessionTypeValue, string> = {
|
||||
practice: 'P',
|
||||
qualifying: 'Q',
|
||||
q1: 'Q1',
|
||||
q2: 'Q2',
|
||||
q3: 'Q3',
|
||||
sprint: 'SPR',
|
||||
main: 'RACE',
|
||||
timeTrial: 'TT',
|
||||
};
|
||||
return names[this.value];
|
||||
}
|
||||
|
||||
// Static factory methods for common types
|
||||
static practice(): SessionType {
|
||||
return new SessionType('practice');
|
||||
}
|
||||
|
||||
static qualifying(): SessionType {
|
||||
return new SessionType('qualifying');
|
||||
}
|
||||
|
||||
static sprint(): SessionType {
|
||||
return new SessionType('sprint');
|
||||
}
|
||||
|
||||
static main(): SessionType {
|
||||
return new SessionType('main');
|
||||
}
|
||||
|
||||
static timeTrial(): SessionType {
|
||||
return new SessionType('timeTrial');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user