239 lines
6.5 KiB
TypeScript
239 lines
6.5 KiB
TypeScript
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<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];
|
|
}
|
|
} |