Files
gridpilot.gg/core/racing/domain/services/TeamDrivingRatingCalculator.ts
2026-01-16 19:46:49 +01:00

476 lines
16 KiB
TypeScript

import { TeamRatingEvent } from '../entities/TeamRatingEvent';
import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode';
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
export interface TeamDrivingRaceResult {
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number; // Average rating of competing teams
raceId: string;
pace?: number | undefined; // Optional: pace rating (0-100)
consistency?: number | undefined; // Optional: consistency rating (0-100)
teamwork?: number | undefined; // Optional: teamwork rating (0-100)
sportsmanship?: number | undefined; // Optional: sportsmanship rating (0-100)
}
export interface TeamDrivingQualifyingResult {
teamId: string;
qualifyingPosition: number;
fieldSize: number;
raceId: string;
}
export interface TeamDrivingOvertakeStats {
teamId: string;
overtakes: number;
successfulDefenses: number;
raceId: string;
}
/**
* Domain Service: TeamDrivingRatingCalculator
*
* Full calculator for team driving rating events.
* Mirrors user slice 3 in core/racing/ with comprehensive driving dimension logic.
*
* Pure domain logic - no persistence concerns.
*/
export class TeamDrivingRatingCalculator {
/**
* Calculate rating events from a team's race finish.
* Generates comprehensive driving dimension events.
*/
static calculateFromRaceFinish(result: TeamDrivingRaceResult): TeamRatingEvent[] {
const events: TeamRatingEvent[] = [];
const now = new Date();
if (result.status === 'finished') {
// 1. Performance delta based on position and field strength
const performanceDelta = this.calculatePerformanceDelta(
result.position,
result.fieldSize,
result.strengthOfField
);
if (performanceDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(performanceDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_PERFORMANCE').value,
description: `Finished ${result.position}${this.getOrdinalSuffix(result.position)} in race`,
},
visibility: { public: true },
version: 1,
})
);
}
// 2. Gain bonus for beating higher-rated teams
const gainBonus = this.calculateGainBonus(result.position, result.strengthOfField);
if (gainBonus !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(gainBonus),
weight: 0.5,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_GAIN_BONUS').value,
description: `Bonus for beating higher-rated opponents`,
},
visibility: { public: true },
version: 1,
})
);
}
// 3. Pace rating (if provided)
if (result.pace !== undefined) {
const paceDelta = this.calculatePaceDelta(result.pace);
if (paceDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(paceDelta),
weight: 0.3,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_PACE').value,
description: `Pace rating: ${result.pace}/100`,
},
visibility: { public: true },
version: 1,
})
);
}
}
// 4. Consistency rating (if provided)
if (result.consistency !== undefined) {
const consistencyDelta = this.calculateConsistencyDelta(result.consistency);
if (consistencyDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(consistencyDelta),
weight: 0.3,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_CONSISTENCY').value,
description: `Consistency rating: ${result.consistency}/100`,
},
visibility: { public: true },
version: 1,
})
);
}
}
// 5. Teamwork rating (if provided)
if (result.teamwork !== undefined) {
const teamworkDelta = this.calculateTeamworkDelta(result.teamwork);
if (teamworkDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(teamworkDelta),
weight: 0.4,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_TEAMWORK').value,
description: `Teamwork rating: ${result.teamwork}/100`,
},
visibility: { public: true },
version: 1,
})
);
}
}
// 6. Sportsmanship rating (if provided)
if (result.sportsmanship !== undefined) {
const sportsmanshipDelta = this.calculateSportsmanshipDelta(result.sportsmanship);
if (sportsmanshipDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(sportsmanshipDelta),
weight: 0.3,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_SPORTSMANSHIP').value,
description: `Sportsmanship rating: ${result.sportsmanship}/100`,
},
visibility: { public: true },
version: 1,
})
);
}
}
}
// 7. Incident penalty (applies to all statuses)
if (result.incidents > 0) {
const incidentPenalty = this.calculateIncidentPenalty(result.incidents);
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-incidentPenalty),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
description: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
},
visibility: { public: true },
version: 1,
})
);
}
// 8. Status-based penalties
if (result.status === 'dnf') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-15),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_DNF').value,
description: 'Did not finish',
},
visibility: { public: true },
version: 1,
})
);
} else if (result.status === 'dsq') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-25),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_DSQ').value,
description: 'Disqualified',
},
visibility: { public: true },
version: 1,
})
);
} else if (result.status === 'dns') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-10),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_DNS').value,
description: 'Did not start',
},
visibility: { public: true },
version: 1,
})
);
} else if (result.status === 'afk') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-20),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_AFK').value,
description: 'Away from keyboard',
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Calculate rating events from qualifying results.
*/
static calculateFromQualifying(result: TeamDrivingQualifyingResult): TeamRatingEvent[] {
const events: TeamRatingEvent[] = [];
const now = new Date();
const qualifyingDelta = this.calculateQualifyingDelta(result.qualifyingPosition, result.fieldSize);
if (qualifyingDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: result.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(qualifyingDelta),
weight: 0.25,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: result.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_QUALIFYING').value,
description: `Qualified ${result.qualifyingPosition}${this.getOrdinalSuffix(result.qualifyingPosition)}`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Calculate rating events from overtake/defense statistics.
*/
static calculateFromOvertakeStats(stats: TeamDrivingOvertakeStats): TeamRatingEvent[] {
const events: TeamRatingEvent[] = [];
const now = new Date();
// Overtake bonus
if (stats.overtakes > 0) {
const overtakeDelta = this.calculateOvertakeDelta(stats.overtakes);
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: stats.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(overtakeDelta),
weight: 0.5,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: stats.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_OVERTAKE').value,
description: `${stats.overtakes} overtakes`,
},
visibility: { public: true },
version: 1,
})
);
}
// Defense bonus
if (stats.successfulDefenses > 0) {
const defenseDelta = this.calculateDefenseDelta(stats.successfulDefenses);
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: stats.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(defenseDelta),
weight: 0.4,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: stats.raceId },
reason: {
code: TeamDrivingReasonCode.create('RACE_DEFENSE').value,
description: `${stats.successfulDefenses} successful defenses`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
// Private helper methods
private static calculatePerformanceDelta(
position: number,
fieldSize: number,
strengthOfField: number
): number {
// Base delta from position (1st = +20, last = -20)
const positionFactor = ((fieldSize - position + 1) / fieldSize) * 40 - 20;
// Adjust for field strength
const strengthFactor = (strengthOfField - 50) * 0.1; // +/- 5 for strong/weak fields
return Math.round((positionFactor + strengthFactor) * 10) / 10;
}
private static calculateGainBonus(position: number, strengthOfField: number): number {
// Bonus for beating teams with higher ratings
if (strengthOfField > 60 && position <= Math.floor(strengthOfField / 10)) {
return 5;
}
return 0;
}
private static calculateIncidentPenalty(incidents: number): number {
// Exponential penalty for multiple incidents
return Math.min(incidents * 2, 20);
}
private static calculatePaceDelta(pace: number): number {
// Pace rating 0-100, convert to delta -10 to +10
if (pace < 0 || pace > 100) return 0;
return Math.round(((pace - 50) * 0.2) * 10) / 10;
}
private static calculateConsistencyDelta(consistency: number): number {
// Consistency rating 0-100, convert to delta -8 to +8
if (consistency < 0 || consistency > 100) return 0;
return Math.round(((consistency - 50) * 0.16) * 10) / 10;
}
private static calculateTeamworkDelta(teamwork: number): number {
// Teamwork rating 0-100, convert to delta -10 to +10
if (teamwork < 0 || teamwork > 100) return 0;
return Math.round(((teamwork - 50) * 0.2) * 10) / 10;
}
private static calculateSportsmanshipDelta(sportsmanship: number): number {
// Sportsmanship rating 0-100, convert to delta -8 to +8
if (sportsmanship < 0 || sportsmanship > 100) return 0;
return Math.round(((sportsmanship - 50) * 0.16) * 10) / 10;
}
private static calculateQualifyingDelta(qualifyingPosition: number, fieldSize: number): number {
// Qualifying performance (less weight than race)
const positionFactor = ((fieldSize - qualifyingPosition + 1) / fieldSize) * 10 - 5;
return Math.round(positionFactor * 10) / 10;
}
private static calculateOvertakeDelta(overtakes: number): number {
// Overtake bonus: +2 per overtake, max +10
return Math.min(overtakes * 2, 10);
}
private static calculateDefenseDelta(defenses: number): number {
// Defense bonus: +1.5 per defense, max +8
return Math.min(Math.round(defenses * 1.5 * 10) / 10, 8);
}
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';
}
}