Files
gridpilot.gg/core/identity/domain/services/DrivingRatingCalculator.ts
2025-12-29 22:27:33 +01:00

358 lines
11 KiB
TypeScript

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<string, unknown>;
}>;
}
/**
* 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<string, DriverCalculationResult> {
const results = new Map<string, DriverCalculationResult>();
// 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<string, unknown>;
}> = [];
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';
}
}