358 lines
11 KiB
TypeScript
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';
|
|
}
|
|
} |