import type { IValueObject } from '@core/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 { 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(); 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: IValueObject): 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; }); } }