rating
This commit is contained in:
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal file
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user