476 lines
16 KiB
TypeScript
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';
|
|
}
|
|
} |