import { TeamRatingEvent } from '../entities/TeamRatingEvent'; import { TeamRatingEventId } from '../value-objects/TeamRatingEventId'; import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey'; import { TeamRatingDelta } from '../value-objects/TeamRatingDelta'; import { TeamDrivingReasonCode } from '../value-objects/TeamDrivingReasonCode'; 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'; } }