import { RatingEvent } from '../entities/RatingEvent'; import { DrivingReasonCode } from '../value-objects/DrivingReasonCode'; import { RatingDelta } from '../value-objects/RatingDelta'; /** * Input DTO for driving rating calculation from race facts */ export interface DrivingRaceFactsDto { raceId: string; results: Array<{ userId: string; startPos: number; finishPos: number; incidents: number; status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'; sof?: number; // Optional: strength of field (platform ratings or external) }>; } /** * Individual driver calculation result */ export interface DriverCalculationResult { userId: string; delta: number; events: Array<{ reasonCode: string; delta: number; weight: number; summary: string; details: Record; }>; } /** * Domain Service: DrivingRatingCalculator * * Pure, stateless calculator for driving rating. * Implements full logic per ratings-architecture-concept.md section 5.1. * * Key principles: * - Performance: position vs field strength * - Clean driving: incident penalties * - Reliability: DNS/DNF/DSQ/AFK penalties * - Weighted by event recency and confidence */ export class DrivingRatingCalculator { // Weights for different components (sum to 1.0) private static readonly PERFORMANCE_WEIGHT = 0.5; private static readonly CLEAN_DRIVING_WEIGHT = 0.3; private static readonly RELIABILITY_WEIGHT = 0.2; // Penalty values for reliability issues private static readonly DNS_PENALTY = -15; private static readonly DNF_PENALTY = -10; private static readonly DSQ_PENALTY = -25; private static readonly AFK_PENALTY = -20; // Incident penalty per incident private static readonly INCIDENT_PENALTY = -5; private static readonly MAJOR_INCIDENT_PENALTY = -15; /** * Calculate driving rating deltas from race facts * Returns per-driver results with detailed event breakdown */ static calculateFromRaceFacts(facts: DrivingRaceFactsDto): Map { const results = new Map(); // Calculate field strength if not provided const fieldStrength = facts.results.length > 0 ? (facts.results .filter(r => r.status === 'finished') .reduce((sum, r) => sum + (r.sof || this.estimateDriverRating(r.userId)), 0) / Math.max(1, facts.results.filter(r => r.status === 'finished').length)) : 0; for (const result of facts.results) { const calculation = this.calculateDriverResult(result, fieldStrength, facts.results.length); results.set(result.userId, calculation); } return results; } /** * Calculate delta from existing rating events (for snapshot recomputation) * This is the "pure" calculation that sums weighted deltas */ static calculate(events: RatingEvent[]): number { if (events.length === 0) return 0; // Group events by type and apply weights let totalDelta = 0; let performanceWeight = 0; let cleanDrivingWeight = 0; let reliabilityWeight = 0; for (const event of events) { const reasonCode = event.reason.code; const delta = event.delta.value; const weight = event.weight || 1; let componentWeight = 1; if (this.isPerformanceEvent(reasonCode)) { componentWeight = this.PERFORMANCE_WEIGHT; performanceWeight += weight; } else if (this.isCleanDrivingEvent(reasonCode)) { componentWeight = this.CLEAN_DRIVING_WEIGHT; cleanDrivingWeight += weight; } else if (this.isReliabilityEvent(reasonCode)) { componentWeight = this.RELIABILITY_WEIGHT; reliabilityWeight += weight; } // Apply component weight and event weight totalDelta += delta * componentWeight * weight; } // Normalize by total weight to prevent inflation const totalWeight = performanceWeight + cleanDrivingWeight + reliabilityWeight; if (totalWeight > 0) { totalDelta = totalDelta / totalWeight; } return Math.round(totalDelta * 100) / 100; // Round to 2 decimal places } /** * Calculate result for a single driver */ private static calculateDriverResult( result: { userId: string; startPos: number; finishPos: number; incidents: number; status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'; sof?: number; }, fieldStrength: number, totalDrivers: number ): DriverCalculationResult { const events: Array<{ reasonCode: string; delta: number; weight: number; summary: string; details: Record; }> = []; let totalDelta = 0; // 1. Performance calculation (only for finished races) if (result.status === 'finished') { const performanceDelta = this.calculatePerformanceDelta( result.startPos, result.finishPos, fieldStrength, totalDrivers ); if (performanceDelta !== 0) { events.push({ reasonCode: 'DRIVING_FINISH_STRENGTH_GAIN', delta: performanceDelta, weight: 1, summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${totalDrivers}`, details: { startPos: result.startPos, finishPos: result.finishPos, positionsGained: result.startPos - result.finishPos, fieldStrength: fieldStrength, totalDrivers: totalDrivers, }, }); totalDelta += performanceDelta * this.PERFORMANCE_WEIGHT; // Positions gained bonus const positionsGained = result.startPos - result.finishPos; if (positionsGained > 0) { const gainBonus = Math.min(positionsGained * 2, 10); events.push({ reasonCode: 'DRIVING_POSITIONS_GAINED_BONUS', delta: gainBonus, weight: 0.5, summary: `Gained ${positionsGained} positions`, details: { positionsGained }, }); totalDelta += gainBonus * this.PERFORMANCE_WEIGHT * 0.5; } } } // 2. Clean driving calculation if (result.incidents > 0) { const incidentPenalty = Math.min(result.incidents * this.INCIDENT_PENALTY, -30); events.push({ reasonCode: 'DRIVING_INCIDENTS_PENALTY', delta: incidentPenalty, weight: 1, summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`, details: { incidents: result.incidents }, }); totalDelta += incidentPenalty * this.CLEAN_DRIVING_WEIGHT; } // 3. Reliability calculation if (result.status !== 'finished') { let reliabilityDelta = 0; let reasonCode = ''; switch (result.status) { case 'dns': reliabilityDelta = this.DNS_PENALTY; reasonCode = 'DRIVING_DNS_PENALTY'; break; case 'dnf': reliabilityDelta = this.DNF_PENALTY; reasonCode = 'DRIVING_DNF_PENALTY'; break; case 'dsq': reliabilityDelta = this.DSQ_PENALTY; reasonCode = 'DRIVING_DSQ_PENALTY'; break; case 'afk': reliabilityDelta = this.AFK_PENALTY; reasonCode = 'DRIVING_AFK_PENALTY'; break; } events.push({ reasonCode, delta: reliabilityDelta, weight: 1, summary: this.getStatusSummary(result.status), details: { status: result.status }, }); totalDelta += reliabilityDelta * this.RELIABILITY_WEIGHT; } // Normalize total delta by component weights const componentsUsed = [ result.status === 'finished' ? 1 : 0, result.incidents > 0 ? 1 : 0, result.status !== 'finished' ? 1 : 0, ].reduce((sum, val) => sum + val, 0); if (componentsUsed > 0) { // The totalDelta is already weighted, but we need to normalize // to ensure the final result is within reasonable bounds const maxPossible = 50; // Max positive const minPossible = -50; // Max negative totalDelta = Math.max(minPossible, Math.min(maxPossible, totalDelta)); } return { userId: result.userId, delta: Math.round(totalDelta * 100) / 100, events, }; } /** * Calculate performance delta based on position vs field strength */ private static calculatePerformanceDelta( startPos: number, finishPos: number, fieldStrength: number, totalDrivers: number ): number { // Base performance score from position (reverse percentile) // Higher position score = better performance const positionScore = ((totalDrivers - finishPos + 1) / totalDrivers) * 100; // Expected score (50th percentile baseline) const expectedScore = 50; // Field strength multiplier (higher = harder competition, bigger rewards) // Normalize to 0.8-2.0 range const fieldMultiplier = 0.8 + Math.min(fieldStrength / 2000, 1.2); // Performance delta: how much better/worse than expected let delta = (positionScore - expectedScore) * fieldMultiplier; // Bonus for positions gained/lost const positionsGained = startPos - finishPos; delta += positionsGained * 2; // Clamp to reasonable range return Math.max(-30, Math.min(30, delta)); } /** * Estimate driver rating for SoF calculation * This is a placeholder - in real implementation, would query user rating snapshot */ private static estimateDriverRating(userId: string): number { // Default rating for new drivers return 50; } /** * Get ordinal suffix for position */ private static getOrdinalSuffix(position: number): string { const j = position % 10; const k = position % 100; if (j === 1 && k !== 11) return 'st'; if (j === 2 && k !== 12) return 'nd'; if (j === 3 && k !== 13) return 'rd'; return 'th'; } /** * Get human-readable summary for status */ private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string { switch (status) { case 'finished': return 'Race completed'; case 'dnf': return 'Did not finish'; case 'dns': return 'Did not start'; case 'dsq': return 'Disqualified'; case 'afk': return 'AFK / Not responsive'; } } /** * Check if reason code is performance-related */ private static isPerformanceEvent(reasonCode: string): boolean { return reasonCode === 'DRIVING_FINISH_STRENGTH_GAIN' || reasonCode === 'DRIVING_POSITIONS_GAINED_BONUS' || reasonCode === 'DRIVING_PACE_RELATIVE_GAIN'; } /** * Check if reason code is clean driving-related */ private static isCleanDrivingEvent(reasonCode: string): boolean { return reasonCode === 'DRIVING_INCIDENTS_PENALTY' || reasonCode === 'DRIVING_MAJOR_CONTACT_PENALTY' || reasonCode === 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'; } /** * Check if reason code is reliability-related */ private static isReliabilityEvent(reasonCode: string): boolean { return reasonCode === 'DRIVING_DNS_PENALTY' || reasonCode === 'DRIVING_DNF_PENALTY' || reasonCode === 'DRIVING_DSQ_PENALTY' || reasonCode === 'DRIVING_AFK_PENALTY' || reasonCode === 'DRIVING_SEASON_ATTENDANCE_BONUS'; } }