Files
gridpilot.gg/core/racing/domain/value-objects/RaceIncidents.ts
2026-01-16 16:46:57 +01:00

158 lines
4.4 KiB
TypeScript

import type { ValueObject } from '@core/shared/domain/ValueObject';
/**
* 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 ValueObject<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;
}
/**
* Backwards-compatible helper for legacy "incident count" fields.
* Creates `count` placeholder incidents of type `other`.
*/
static fromLegacyIncidentsCount(count: number): RaceIncidents {
if (!Number.isFinite(count) || count <= 0) {
return new RaceIncidents();
}
const incidents: IncidentRecord[] = Array.from({ length: Math.floor(count) }, (_, index) => ({
type: 'other',
lap: index + 1,
penaltyPoints: 0,
}));
return new RaceIncidents(incidents);
}
/**
* A coarse severity score for incidents.
* Kept intentionally data-light: derived only from `penaltyPoints`.
*/
getSeverityScore(): number {
return this.getTotalPenaltyPoints();
}
/**
* Human-readable summary without hardcoded incident labels.
*/
getSummary(): string {
const total = this.getTotalCount();
if (total === 0) return 'Clean race';
const countsByType = new Map<IncidentType, number>();
for (const incident of this.incidents) {
countsByType.set(incident.type, (countsByType.get(incident.type) ?? 0) + 1);
}
const typeSummary = Array.from(countsByType.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, n]) => `${type}:${n}`)
.join(', ');
return typeSummary.length > 0 ? `${total} incidents (${typeSummary})` : `${total} incidents`;
}
equals(other: ValueObject<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;
});
}
}