team rating

This commit is contained in:
2025-12-30 12:25:45 +01:00
parent ccaa39c39c
commit 83371ea839
93 changed files with 10324 additions and 490 deletions

View File

@@ -1,13 +0,0 @@
import type { IDomainService } from '@core/shared/domain';
export interface DriverStats {
rating: number;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
}
export interface IDriverStatsService extends IDomainService {
getDriverStats(driverId: string): DriverStats | null;
}

View File

@@ -1,11 +0,0 @@
import type { IDomainService } from '@core/shared/domain';
export interface DriverRanking {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface IRankingService extends IDomainService {
getAllDriverRankings(): DriverRanking[];
}

View File

@@ -0,0 +1,452 @@
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
describe('TeamDrivingRatingCalculator', () => {
describe('calculateFromRaceFinish', () => {
it('should create events from race finish data', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
expect(events[0].teamId).toBe('team-123');
expect(events[0].dimension.value).toBe('driving');
});
it('should create events for DNS status', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'dns',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
expect(dnsEvent).toBeDefined();
expect(dnsEvent?.delta.value).toBeLessThan(0);
});
it('should create events for DNF status', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 5,
incidents: 2,
status: 'dnf',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
expect(dnfEvent).toBeDefined();
expect(dnfEvent?.delta.value).toBe(-15);
});
it('should create events for DSQ status', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'dsq',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
expect(dsqEvent).toBeDefined();
expect(dsqEvent?.delta.value).toBe(-25);
});
it('should create events for AFK status', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'afk',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
expect(afkEvent).toBeDefined();
expect(afkEvent?.delta.value).toBe(-20);
});
it('should apply incident penalties', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 3,
incidents: 5,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
expect(incidentEvent).toBeDefined();
expect(incidentEvent?.delta.value).toBeLessThan(0);
});
it('should apply gain bonus for beating higher-rated teams', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 65, // High strength
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
expect(gainEvent).toBeDefined();
expect(gainEvent?.delta.value).toBeGreaterThan(0);
expect(gainEvent?.weight).toBe(0.5);
});
it('should create pace events when pace is provided', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
pace: 80,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
expect(paceEvent).toBeDefined();
expect(paceEvent?.delta.value).toBeGreaterThan(0);
expect(paceEvent?.weight).toBe(0.3);
});
it('should create consistency events when consistency is provided', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
consistency: 85,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
expect(consistencyEvent).toBeDefined();
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
expect(consistencyEvent?.weight).toBe(0.3);
});
it('should create teamwork events when teamwork is provided', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
teamwork: 90,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
expect(teamworkEvent).toBeDefined();
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
expect(teamworkEvent?.weight).toBe(0.4);
});
it('should create sportsmanship events when sportsmanship is provided', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
sportsmanship: 95,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
expect(sportsmanshipEvent).toBeDefined();
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
expect(sportsmanshipEvent?.weight).toBe(0.3);
});
it('should handle all optional ratings together', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 1,
status: 'finished',
fieldSize: 10,
strengthOfField: 65, // High enough for gain bonus
raceId: 'race-456',
pace: 75,
consistency: 80,
teamwork: 85,
sportsmanship: 90,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
// Should have multiple events
expect(events.length).toBeGreaterThan(5);
// Check for specific events
expect(events.find(e => e.reason.code === 'RACE_PERFORMANCE')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_GAIN_BONUS')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_INCIDENTS')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
});
});
describe('calculateFromQualifying', () => {
it('should create qualifying events', () => {
const result: TeamDrivingQualifyingResult = {
teamId: 'team-123',
qualifyingPosition: 3,
fieldSize: 10,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
expect(events.length).toBeGreaterThan(0);
expect(events[0].teamId).toBe('team-123');
expect(events[0].dimension.value).toBe('driving');
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
expect(events[0].weight).toBe(0.25);
});
it('should create positive delta for good qualifying position', () => {
const result: TeamDrivingQualifyingResult = {
teamId: 'team-123',
qualifyingPosition: 1,
fieldSize: 10,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should create negative delta for poor qualifying position', () => {
const result: TeamDrivingQualifyingResult = {
teamId: 'team-123',
qualifyingPosition: 10,
fieldSize: 10,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromQualifying(result);
expect(events[0].delta.value).toBeLessThan(0);
});
});
describe('calculateFromOvertakeStats', () => {
it('should create overtake events', () => {
const stats: TeamDrivingOvertakeStats = {
teamId: 'team-123',
overtakes: 5,
successfulDefenses: 3,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
expect(events.length).toBeGreaterThan(0);
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
expect(overtakeEvent).toBeDefined();
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
expect(overtakeEvent?.weight).toBe(0.5);
});
it('should create defense events', () => {
const stats: TeamDrivingOvertakeStats = {
teamId: 'team-123',
overtakes: 0,
successfulDefenses: 4,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
expect(defenseEvent).toBeDefined();
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
expect(defenseEvent?.weight).toBe(0.4);
});
it('should create both overtake and defense events', () => {
const stats: TeamDrivingOvertakeStats = {
teamId: 'team-123',
overtakes: 3,
successfulDefenses: 2,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
expect(events.length).toBe(2);
expect(events.find(e => e.reason.code === 'RACE_OVERTAKE')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_DEFENSE')).toBeDefined();
});
it('should return empty array for zero stats', () => {
const stats: TeamDrivingOvertakeStats = {
teamId: 'team-123',
overtakes: 0,
successfulDefenses: 0,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
expect(events.length).toBe(0);
});
});
describe('Edge cases', () => {
it('should handle extreme field sizes', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 100,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
expect(events.length).toBeGreaterThan(0);
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should handle many incidents', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 5,
incidents: 20,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
expect(incidentEvent).toBeDefined();
// Should be capped at 20
expect(incidentEvent?.delta.value).toBe(-20);
});
it('should handle low ratings', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
pace: 10,
consistency: 15,
teamwork: 20,
sportsmanship: 25,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
expect(paceEvent?.delta.value).toBeLessThan(0);
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
expect(consistencyEvent?.delta.value).toBeLessThan(0);
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
expect(teamworkEvent?.delta.value).toBeLessThan(0);
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
expect(sportsmanshipEvent?.delta.value).toBeLessThan(0);
});
it('should handle high ratings', () => {
const result: TeamDrivingRaceResult = {
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 65,
raceId: 'race-456',
pace: 95,
consistency: 98,
teamwork: 92,
sportsmanship: 97,
};
const events = TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
expect(paceEvent?.delta.value).toBeGreaterThan(0);
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,476 @@
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';
}
}

View File

@@ -0,0 +1,512 @@
import { TeamDrivingRatingEventFactory, TeamDrivingRaceFactsDto, TeamDrivingQualifyingFactsDto, TeamDrivingOvertakeFactsDto } from './TeamDrivingRatingEventFactory';
describe('TeamDrivingRatingEventFactory', () => {
describe('createFromRaceFinish', () => {
it('should create events from race finish data', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].teamId).toBe('team-123');
expect(events[0].dimension.value).toBe('driving');
});
it('should create events for DNS status', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'dns',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
expect(dnsEvent).toBeDefined();
expect(dnsEvent?.delta.value).toBeLessThan(0);
});
it('should create events for DNF status', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 2,
status: 'dnf',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
expect(dnfEvent).toBeDefined();
expect(dnfEvent?.delta.value).toBe(-15);
});
it('should create events for DSQ status', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'dsq',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
expect(dsqEvent).toBeDefined();
expect(dsqEvent?.delta.value).toBe(-25);
});
it('should create events for AFK status', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'afk',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
expect(afkEvent).toBeDefined();
expect(afkEvent?.delta.value).toBe(-20);
});
it('should apply incident penalties', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 5,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
expect(incidentEvent).toBeDefined();
expect(incidentEvent?.delta.value).toBeLessThan(0);
});
it('should apply gain bonus for beating higher-rated teams', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 65, // High strength
raceId: 'race-456',
});
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
expect(gainEvent).toBeDefined();
expect(gainEvent?.delta.value).toBeGreaterThan(0);
expect(gainEvent?.weight).toBe(0.5);
});
it('should create pace events when pace is provided', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
pace: 80,
});
const paceEvent = events.find(e => e.reason.code === 'RACE_PACE');
expect(paceEvent).toBeDefined();
expect(paceEvent?.delta.value).toBeGreaterThan(0);
expect(paceEvent?.weight).toBe(0.3);
});
it('should create consistency events when consistency is provided', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
consistency: 85,
});
const consistencyEvent = events.find(e => e.reason.code === 'RACE_CONSISTENCY');
expect(consistencyEvent).toBeDefined();
expect(consistencyEvent?.delta.value).toBeGreaterThan(0);
expect(consistencyEvent?.weight).toBe(0.3);
});
it('should create teamwork events when teamwork is provided', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
teamwork: 90,
});
const teamworkEvent = events.find(e => e.reason.code === 'RACE_TEAMWORK');
expect(teamworkEvent).toBeDefined();
expect(teamworkEvent?.delta.value).toBeGreaterThan(0);
expect(teamworkEvent?.weight).toBe(0.4);
});
it('should create sportsmanship events when sportsmanship is provided', () => {
const events = TeamDrivingRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
sportsmanship: 95,
});
const sportsmanshipEvent = events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP');
expect(sportsmanshipEvent).toBeDefined();
expect(sportsmanshipEvent?.delta.value).toBeGreaterThan(0);
expect(sportsmanshipEvent?.weight).toBe(0.3);
});
});
describe('createDrivingEventsFromRace', () => {
it('should create events for multiple teams', () => {
const raceFacts: TeamDrivingRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [
{
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 3,
strengthOfField: 55,
},
{
teamId: 'team-456',
position: 2,
incidents: 1,
status: 'finished',
fieldSize: 3,
strengthOfField: 55,
},
],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(2);
expect(eventsByTeam.get('team-123')).toBeDefined();
expect(eventsByTeam.get('team-456')).toBeDefined();
});
it('should handle empty results', () => {
const raceFacts: TeamDrivingRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(0);
});
it('should skip teams with no events', () => {
const raceFacts: TeamDrivingRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [
{
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 1,
strengthOfField: 55,
},
],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(1);
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
});
it('should handle optional ratings in results', () => {
const raceFacts: TeamDrivingRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [
{
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 3,
strengthOfField: 65,
pace: 85,
consistency: 80,
teamwork: 90,
sportsmanship: 95,
},
],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByTeam.get('team-123')!;
expect(events.length).toBeGreaterThan(5);
expect(events.find(e => e.reason.code === 'RACE_PACE')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_CONSISTENCY')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_TEAMWORK')).toBeDefined();
expect(events.find(e => e.reason.code === 'RACE_SPORTSMANSHIP')).toBeDefined();
});
});
describe('createFromQualifying', () => {
it('should create qualifying events', () => {
const events = TeamDrivingRatingEventFactory.createFromQualifying({
teamId: 'team-123',
qualifyingPosition: 3,
fieldSize: 10,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].teamId).toBe('team-123');
expect(events[0].dimension.value).toBe('driving');
expect(events[0].reason.code).toBe('RACE_QUALIFYING');
expect(events[0].weight).toBe(0.25);
});
});
describe('createDrivingEventsFromQualifying', () => {
it('should create events for multiple teams', () => {
const qualifyingFacts: TeamDrivingQualifyingFactsDto = {
raceId: 'race-456',
results: [
{
teamId: 'team-123',
qualifyingPosition: 1,
fieldSize: 10,
},
{
teamId: 'team-456',
qualifyingPosition: 5,
fieldSize: 10,
},
],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromQualifying(qualifyingFacts);
expect(eventsByTeam.size).toBe(2);
expect(eventsByTeam.get('team-123')).toBeDefined();
expect(eventsByTeam.get('team-456')).toBeDefined();
});
});
describe('createFromOvertakeStats', () => {
it('should create overtake events', () => {
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
teamId: 'team-123',
overtakes: 5,
successfulDefenses: 3,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const overtakeEvent = events.find(e => e.reason.code === 'RACE_OVERTAKE');
expect(overtakeEvent).toBeDefined();
expect(overtakeEvent?.delta.value).toBeGreaterThan(0);
});
it('should create defense events', () => {
const events = TeamDrivingRatingEventFactory.createFromOvertakeStats({
teamId: 'team-123',
overtakes: 0,
successfulDefenses: 4,
raceId: 'race-456',
});
const defenseEvent = events.find(e => e.reason.code === 'RACE_DEFENSE');
expect(defenseEvent).toBeDefined();
expect(defenseEvent?.delta.value).toBeGreaterThan(0);
});
});
describe('createDrivingEventsFromOvertakes', () => {
it('should create events for multiple teams', () => {
const overtakeFacts: TeamDrivingOvertakeFactsDto = {
raceId: 'race-456',
results: [
{
teamId: 'team-123',
overtakes: 3,
successfulDefenses: 2,
},
{
teamId: 'team-456',
overtakes: 1,
successfulDefenses: 5,
},
],
};
const eventsByTeam = TeamDrivingRatingEventFactory.createDrivingEventsFromOvertakes(overtakeFacts);
expect(eventsByTeam.size).toBe(2);
expect(eventsByTeam.get('team-123')).toBeDefined();
expect(eventsByTeam.get('team-456')).toBeDefined();
});
});
describe('createFromPenalty', () => {
it('should create driving penalty event', () => {
const events = TeamDrivingRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'minor',
severity: 'low',
});
const drivingEvent = events.find(e => e.dimension.value === 'driving');
expect(drivingEvent).toBeDefined();
expect(drivingEvent?.delta.value).toBeLessThan(0);
});
it('should create admin trust penalty event', () => {
const events = TeamDrivingRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'minor',
severity: 'low',
});
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
expect(adminEvent).toBeDefined();
expect(adminEvent?.delta.value).toBeLessThan(0);
});
it('should apply severity multipliers', () => {
const lowEvents = TeamDrivingRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'major',
severity: 'low',
});
const highEvents = TeamDrivingRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'major',
severity: 'high',
});
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
expect(highDelta).toBeLessThan(lowDelta);
});
});
describe('createFromVote', () => {
it('should create positive vote event', () => {
const events = TeamDrivingRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'positive',
voteCount: 10,
eligibleVoterCount: 15,
percentPositive: 80,
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should create negative vote event', () => {
const events = TeamDrivingRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'negative',
voteCount: 10,
eligibleVoterCount: 15,
percentPositive: 20,
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeLessThan(0);
});
it('should weight by vote count', () => {
const events = TeamDrivingRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'positive',
voteCount: 20,
eligibleVoterCount: 20,
percentPositive: 100,
});
expect(events[0].weight).toBe(20);
});
});
describe('createFromAdminAction', () => {
it('should create admin action bonus event', () => {
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'bonus',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should create admin action penalty event', () => {
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'penalty',
severity: 'high',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeLessThan(0);
});
it('should create admin warning response event', () => {
const events = TeamDrivingRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'warning',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,451 @@
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';
import { TeamDrivingRatingCalculator, TeamDrivingRaceResult, TeamDrivingQualifyingResult, TeamDrivingOvertakeStats } from './TeamDrivingRatingCalculator';
export interface TeamDrivingRaceFactsDto {
raceId: string;
teamId: string;
results: Array<{
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number;
pace?: number;
consistency?: number;
teamwork?: number;
sportsmanship?: number;
}>;
}
export interface TeamDrivingQualifyingFactsDto {
raceId: string;
results: Array<{
teamId: string;
qualifyingPosition: number;
fieldSize: number;
}>;
}
export interface TeamDrivingOvertakeFactsDto {
raceId: string;
results: Array<{
teamId: string;
overtakes: number;
successfulDefenses: number;
}>;
}
/**
* Domain Service: TeamDrivingRatingEventFactory
*
* Factory for creating team driving rating events using the full TeamDrivingRatingCalculator.
* Mirrors user slice 3 pattern in core/racing/.
*
* Pure domain logic - no persistence concerns.
*/
export class TeamDrivingRatingEventFactory {
/**
* Create rating events from a team's race finish.
* Uses TeamDrivingRatingCalculator for comprehensive calculations.
*/
static createFromRaceFinish(input: {
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number;
raceId: string;
pace?: number;
consistency?: number;
teamwork?: number;
sportsmanship?: number;
}): TeamRatingEvent[] {
const result: TeamDrivingRaceResult = {
teamId: input.teamId,
position: input.position,
incidents: input.incidents,
status: input.status,
fieldSize: input.fieldSize,
strengthOfField: input.strengthOfField,
raceId: input.raceId,
pace: input.pace as number | undefined,
consistency: input.consistency as number | undefined,
teamwork: input.teamwork as number | undefined,
sportsmanship: input.sportsmanship as number | undefined,
};
return TeamDrivingRatingCalculator.calculateFromRaceFinish(result);
}
/**
* Create rating events from multiple race results.
* Returns events grouped by team ID.
*/
static createDrivingEventsFromRace(raceFacts: TeamDrivingRaceFactsDto): Map<string, TeamRatingEvent[]> {
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
for (const result of raceFacts.results) {
const input: {
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number;
raceId: string;
pace?: number;
consistency?: number;
teamwork?: number;
sportsmanship?: number;
} = {
teamId: result.teamId,
position: result.position,
incidents: result.incidents,
status: result.status,
fieldSize: raceFacts.results.length,
strengthOfField: result.strengthOfField,
raceId: raceFacts.raceId,
};
if (result.pace !== undefined) {
input.pace = result.pace;
}
if (result.consistency !== undefined) {
input.consistency = result.consistency;
}
if (result.teamwork !== undefined) {
input.teamwork = result.teamwork;
}
if (result.sportsmanship !== undefined) {
input.sportsmanship = result.sportsmanship;
}
const events = this.createFromRaceFinish(input);
if (events.length > 0) {
eventsByTeam.set(result.teamId, events);
}
}
return eventsByTeam;
}
/**
* Create rating events from qualifying results.
* Uses TeamDrivingRatingCalculator for qualifying calculations.
*/
static createFromQualifying(input: {
teamId: string;
qualifyingPosition: number;
fieldSize: number;
raceId: string;
}): TeamRatingEvent[] {
const result: TeamDrivingQualifyingResult = {
teamId: input.teamId,
qualifyingPosition: input.qualifyingPosition,
fieldSize: input.fieldSize,
raceId: input.raceId,
};
return TeamDrivingRatingCalculator.calculateFromQualifying(result);
}
/**
* Create rating events from multiple qualifying results.
* Returns events grouped by team ID.
*/
static createDrivingEventsFromQualifying(qualifyingFacts: TeamDrivingQualifyingFactsDto): Map<string, TeamRatingEvent[]> {
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
for (const result of qualifyingFacts.results) {
const events = this.createFromQualifying({
teamId: result.teamId,
qualifyingPosition: result.qualifyingPosition,
fieldSize: result.fieldSize,
raceId: qualifyingFacts.raceId,
});
if (events.length > 0) {
eventsByTeam.set(result.teamId, events);
}
}
return eventsByTeam;
}
/**
* Create rating events from overtake/defense statistics.
* Uses TeamDrivingRatingCalculator for overtake calculations.
*/
static createFromOvertakeStats(input: {
teamId: string;
overtakes: number;
successfulDefenses: number;
raceId: string;
}): TeamRatingEvent[] {
const stats: TeamDrivingOvertakeStats = {
teamId: input.teamId,
overtakes: input.overtakes,
successfulDefenses: input.successfulDefenses,
raceId: input.raceId,
};
return TeamDrivingRatingCalculator.calculateFromOvertakeStats(stats);
}
/**
* Create rating events from multiple overtake stats.
* Returns events grouped by team ID.
*/
static createDrivingEventsFromOvertakes(overtakeFacts: TeamDrivingOvertakeFactsDto): Map<string, TeamRatingEvent[]> {
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
for (const result of overtakeFacts.results) {
const events = this.createFromOvertakeStats({
teamId: result.teamId,
overtakes: result.overtakes,
successfulDefenses: result.successfulDefenses,
raceId: overtakeFacts.raceId,
});
if (events.length > 0) {
eventsByTeam.set(result.teamId, events);
}
}
return eventsByTeam;
}
/**
* Create rating events from a penalty.
* Generates both driving and adminTrust events.
* Uses TeamDrivingReasonCode for validation.
*/
static createFromPenalty(input: {
teamId: string;
penaltyType: 'minor' | 'major' | 'critical';
severity: 'low' | 'medium' | 'high';
incidentCount?: number;
}): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
// Driving dimension penalty
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
if (drivingDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(drivingDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyType },
reason: {
code: TeamDrivingReasonCode.create('RACE_INCIDENTS').value,
description: `${input.penaltyType} penalty for driving violations`,
},
visibility: { public: true },
version: 1,
})
);
}
// AdminTrust dimension penalty
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
if (adminDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(adminDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyType },
reason: {
code: 'PENALTY_ADMIN',
description: `${input.penaltyType} penalty for rule violations`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from a vote outcome.
* Generates adminTrust events.
*/
static createFromVote(input: {
teamId: string;
outcome: 'positive' | 'negative';
voteCount: number;
eligibleVoterCount: number;
percentPositive: number;
}): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
// Calculate delta based on vote outcome
const delta = this.calculateVoteDelta(
input.outcome,
input.eligibleVoterCount,
input.voteCount,
input.percentPositive
);
if (delta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(delta),
weight: input.voteCount, // Weight by number of votes
occurredAt: now,
createdAt: now,
source: { type: 'vote', id: 'admin_vote' },
reason: {
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
description: `Admin vote outcome: ${input.outcome}`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from an admin action.
* Generates adminTrust events.
*/
static createFromAdminAction(input: {
teamId: string;
actionType: 'bonus' | 'penalty' | 'warning';
severity?: 'low' | 'medium' | 'high';
}): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
if (input.actionType === 'bonus') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'bonus' },
reason: {
code: 'ADMIN_BONUS',
description: 'Admin bonus for positive contribution',
},
visibility: { public: true },
version: 1,
})
);
} else if (input.actionType === 'penalty') {
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(delta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'penalty' },
reason: {
code: 'ADMIN_PENALTY',
description: `Admin penalty (${input.severity} severity)`,
},
visibility: { public: true },
version: 1,
})
);
} else if (input.actionType === 'warning') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(3),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'warning' },
reason: {
code: 'ADMIN_WARNING_RESPONSE',
description: 'Response to admin warning',
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
// Private helper methods
private static calculatePenaltyDelta(
penaltyType: 'minor' | 'major' | 'critical',
severity: 'low' | 'medium' | 'high',
dimension: 'driving' | 'adminTrust'
): number {
const baseValues = {
minor: { driving: -5, adminTrust: -3 },
major: { driving: -10, adminTrust: -8 },
critical: { driving: -20, adminTrust: -15 },
};
const severityMultipliers = {
low: 1,
medium: 1.5,
high: 2,
};
const base = baseValues[penaltyType][dimension];
const multiplier = severityMultipliers[severity];
return Math.round(base * multiplier);
}
private static calculateVoteDelta(
outcome: 'positive' | 'negative',
eligibleVoterCount: number,
voteCount: number,
percentPositive: number
): number {
if (voteCount === 0) return 0;
const participationRate = voteCount / eligibleVoterCount;
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
// Base delta of +/- 10, scaled by participation and strength
const baseDelta = outcome === 'positive' ? 10 : -10;
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
return Math.round(scaledDelta * 10) / 10;
}
}

View File

@@ -0,0 +1,312 @@
import { TeamRatingEventFactory, TeamRaceFactsDto } from './TeamRatingEventFactory';
describe('TeamRatingEventFactory', () => {
describe('createFromRaceFinish', () => {
it('should create events from race finish data', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].teamId).toBe('team-123');
expect(events[0].dimension.value).toBe('driving');
});
it('should create events for DNS status', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'dns',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dnsEvent = events.find(e => e.reason.code === 'RACE_DNS');
expect(dnsEvent).toBeDefined();
expect(dnsEvent?.delta.value).toBeLessThan(0);
});
it('should create events for DNF status', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 2,
status: 'dnf',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dnfEvent = events.find(e => e.reason.code === 'RACE_DNF');
expect(dnfEvent).toBeDefined();
expect(dnfEvent?.delta.value).toBe(-15);
});
it('should create events for DSQ status', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'dsq',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const dsqEvent = events.find(e => e.reason.code === 'RACE_DSQ');
expect(dsqEvent).toBeDefined();
expect(dsqEvent?.delta.value).toBe(-25);
});
it('should create events for AFK status', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 5,
incidents: 0,
status: 'afk',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
expect(events.length).toBeGreaterThan(0);
const afkEvent = events.find(e => e.reason.code === 'RACE_AFK');
expect(afkEvent).toBeDefined();
expect(afkEvent?.delta.value).toBe(-20);
});
it('should apply incident penalties', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 3,
incidents: 5,
status: 'finished',
fieldSize: 10,
strengthOfField: 55,
raceId: 'race-456',
});
const incidentEvent = events.find(e => e.reason.code === 'RACE_INCIDENTS');
expect(incidentEvent).toBeDefined();
expect(incidentEvent?.delta.value).toBeLessThan(0);
});
it('should apply gain bonus for beating higher-rated teams', () => {
const events = TeamRatingEventFactory.createFromRaceFinish({
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 10,
strengthOfField: 65, // High strength
raceId: 'race-456',
});
const gainEvent = events.find(e => e.reason.code === 'RACE_GAIN_BONUS');
expect(gainEvent).toBeDefined();
expect(gainEvent?.delta.value).toBeGreaterThan(0);
expect(gainEvent?.weight).toBe(0.5);
});
});
describe('createDrivingEventsFromRace', () => {
it('should create events for multiple teams', () => {
const raceFacts: TeamRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [
{
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 3,
strengthOfField: 55,
},
{
teamId: 'team-456',
position: 2,
incidents: 1,
status: 'finished',
fieldSize: 3,
strengthOfField: 55,
},
],
};
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(2);
expect(eventsByTeam.get('team-123')).toBeDefined();
expect(eventsByTeam.get('team-456')).toBeDefined();
});
it('should handle empty results', () => {
const raceFacts: TeamRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [],
};
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(0);
});
it('should skip teams with no events', () => {
const raceFacts: TeamRaceFactsDto = {
raceId: 'race-456',
teamId: 'team-123',
results: [
{
teamId: 'team-123',
position: 1,
incidents: 0,
status: 'finished',
fieldSize: 1,
strengthOfField: 55,
},
],
};
const eventsByTeam = TeamRatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByTeam.size).toBe(1);
expect(eventsByTeam.get('team-123')?.length).toBeGreaterThan(0);
});
});
describe('createFromPenalty', () => {
it('should create driving penalty event', () => {
const events = TeamRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'minor',
severity: 'low',
});
const drivingEvent = events.find(e => e.dimension.value === 'driving');
expect(drivingEvent).toBeDefined();
expect(drivingEvent?.delta.value).toBeLessThan(0);
});
it('should create admin trust penalty event', () => {
const events = TeamRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'minor',
severity: 'low',
});
const adminEvent = events.find(e => e.dimension.value === 'adminTrust');
expect(adminEvent).toBeDefined();
expect(adminEvent?.delta.value).toBeLessThan(0);
});
it('should apply severity multipliers', () => {
const lowEvents = TeamRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'major',
severity: 'low',
});
const highEvents = TeamRatingEventFactory.createFromPenalty({
teamId: 'team-123',
penaltyType: 'major',
severity: 'high',
});
const lowDelta = lowEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
const highDelta = highEvents.find(e => e.dimension.value === 'driving')?.delta.value || 0;
expect(highDelta).toBeLessThan(lowDelta);
});
});
describe('createFromVote', () => {
it('should create positive vote event', () => {
const events = TeamRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'positive',
voteCount: 10,
eligibleVoterCount: 15,
percentPositive: 80,
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should create negative vote event', () => {
const events = TeamRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'negative',
voteCount: 10,
eligibleVoterCount: 15,
percentPositive: 20,
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeLessThan(0);
});
it('should weight by vote count', () => {
const events = TeamRatingEventFactory.createFromVote({
teamId: 'team-123',
outcome: 'positive',
voteCount: 20,
eligibleVoterCount: 20,
percentPositive: 100,
});
expect(events[0].weight).toBe(20);
});
});
describe('createFromAdminAction', () => {
it('should create admin action bonus event', () => {
const events = TeamRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'bonus',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
it('should create admin action penalty event', () => {
const events = TeamRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'penalty',
severity: 'high',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeLessThan(0);
});
it('should create admin warning response event', () => {
const events = TeamRatingEventFactory.createFromAdminAction({
teamId: 'team-123',
actionType: 'warning',
});
expect(events.length).toBeGreaterThan(0);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].delta.value).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,496 @@
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
export interface TeamRaceFactsDto {
raceId: string;
teamId: string;
results: Array<{
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number; // Average rating of competing teams
}>;
}
export interface TeamPenaltyInput {
teamId: string;
penaltyType: 'minor' | 'major' | 'critical';
severity: 'low' | 'medium' | 'high';
incidentCount?: number;
}
export interface TeamVoteInput {
teamId: string;
outcome: 'positive' | 'negative';
voteCount: number;
eligibleVoterCount: number;
percentPositive: number;
}
export interface TeamAdminActionInput {
teamId: string;
actionType: 'bonus' | 'penalty' | 'warning';
severity?: 'low' | 'medium' | 'high';
}
/**
* Domain Service: TeamRatingEventFactory
*
* Factory for creating team rating events from various sources.
* Mirrors the RatingEventFactory pattern for user ratings.
*
* Pure domain logic - no persistence concerns.
*/
export class TeamRatingEventFactory {
/**
* Create rating events from a team's race finish.
* Generates driving dimension events.
*/
static createFromRaceFinish(input: {
teamId: string;
position: number;
incidents: number;
status: 'finished' | 'dnf' | 'dsq' | 'dns' | 'afk';
fieldSize: number;
strengthOfField: number;
raceId: string;
}): TeamRatingEvent[] {
const events: TeamRatingEvent[] = [];
const now = new Date();
if (input.status === 'finished') {
// Performance delta based on position and field strength
const performanceDelta = this.calculatePerformanceDelta(
input.position,
input.fieldSize,
input.strengthOfField
);
if (performanceDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(performanceDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_PERFORMANCE',
description: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in race`,
},
visibility: { public: true },
version: 1,
})
);
}
// Gain bonus for beating higher-rated teams
const gainBonus = this.calculateGainBonus(input.position, input.strengthOfField);
if (gainBonus !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(gainBonus),
weight: 0.5,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_GAIN_BONUS',
description: `Bonus for beating higher-rated opponents`,
},
visibility: { public: true },
version: 1,
})
);
}
}
// Incident penalty
if (input.incidents > 0) {
const incidentPenalty = this.calculateIncidentPenalty(input.incidents);
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-incidentPenalty),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_INCIDENTS',
description: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
},
visibility: { public: true },
version: 1,
})
);
}
// Status-based penalties
if (input.status === 'dnf') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-15),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_DNF',
description: 'Did not finish',
},
visibility: { public: true },
version: 1,
})
);
} else if (input.status === 'dsq') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-25),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_DSQ',
description: 'Disqualified',
},
visibility: { public: true },
version: 1,
})
);
} else if (input.status === 'dns') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-10),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_DNS',
description: 'Did not start',
},
visibility: { public: true },
version: 1,
})
);
} else if (input.status === 'afk') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-20),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'RACE_AFK',
description: 'Away from keyboard',
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from multiple race results.
* Returns events grouped by team ID.
*/
static createDrivingEventsFromRace(raceFacts: TeamRaceFactsDto): Map<string, TeamRatingEvent[]> {
const eventsByTeam = new Map<string, TeamRatingEvent[]>();
for (const result of raceFacts.results) {
const events = this.createFromRaceFinish({
teamId: result.teamId,
position: result.position,
incidents: result.incidents,
status: result.status,
fieldSize: raceFacts.results.length,
strengthOfField: 50, // Default strength if not provided
raceId: raceFacts.raceId,
});
if (events.length > 0) {
eventsByTeam.set(result.teamId, events);
}
}
return eventsByTeam;
}
/**
* Create rating events from a penalty.
* Generates both driving and adminTrust events.
*/
static createFromPenalty(input: TeamPenaltyInput): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
// Driving dimension penalty
const drivingDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'driving');
if (drivingDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(drivingDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyType },
reason: {
code: 'PENALTY_DRIVING',
description: `${input.penaltyType} penalty for driving violations`,
},
visibility: { public: true },
version: 1,
})
);
}
// AdminTrust dimension penalty
const adminDelta = this.calculatePenaltyDelta(input.penaltyType, input.severity, 'adminTrust');
if (adminDelta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(adminDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyType },
reason: {
code: 'PENALTY_ADMIN',
description: `${input.penaltyType} penalty for rule violations`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from a vote outcome.
* Generates adminTrust events.
*/
static createFromVote(input: TeamVoteInput): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
// Calculate delta based on vote outcome
const delta = this.calculateVoteDelta(
input.outcome,
input.eligibleVoterCount,
input.voteCount,
input.percentPositive
);
if (delta !== 0) {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(delta),
weight: input.voteCount, // Weight by number of votes
occurredAt: now,
createdAt: now,
source: { type: 'vote', id: 'admin_vote' },
reason: {
code: input.outcome === 'positive' ? 'VOTE_POSITIVE' : 'VOTE_NEGATIVE',
description: `Admin vote outcome: ${input.outcome}`,
},
visibility: { public: true },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from an admin action.
* Generates adminTrust events.
*/
static createFromAdminAction(input: TeamAdminActionInput): TeamRatingEvent[] {
const now = new Date();
const events: TeamRatingEvent[] = [];
if (input.actionType === 'bonus') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'bonus' },
reason: {
code: 'ADMIN_BONUS',
description: 'Admin bonus for positive contribution',
},
visibility: { public: true },
version: 1,
})
);
} else if (input.actionType === 'penalty') {
const delta = input.severity === 'high' ? -15 : input.severity === 'medium' ? -10 : -5;
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(delta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'penalty' },
reason: {
code: 'ADMIN_PENALTY',
description: `Admin penalty (${input.severity} severity)`,
},
visibility: { public: true },
version: 1,
})
);
} else if (input.actionType === 'warning') {
events.push(
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: input.teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(3),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: 'warning' },
reason: {
code: 'ADMIN_WARNING_RESPONSE',
description: 'Response to admin warning',
},
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 calculatePenaltyDelta(
penaltyType: 'minor' | 'major' | 'critical',
severity: 'low' | 'medium' | 'high',
dimension: 'driving' | 'adminTrust'
): number {
const baseValues = {
minor: { driving: -5, adminTrust: -3 },
major: { driving: -10, adminTrust: -8 },
critical: { driving: -20, adminTrust: -15 },
};
const severityMultipliers = {
low: 1,
medium: 1.5,
high: 2,
};
const base = baseValues[penaltyType][dimension];
const multiplier = severityMultipliers[severity];
return Math.round(base * multiplier);
}
private static calculateVoteDelta(
outcome: 'positive' | 'negative',
eligibleVoterCount: number,
voteCount: number,
percentPositive: number
): number {
if (voteCount === 0) return 0;
const participationRate = voteCount / eligibleVoterCount;
const strength = (percentPositive / 100) * 2 - 1; // -1 to +1
// Base delta of +/- 10, scaled by participation and strength
const baseDelta = outcome === 'positive' ? 10 : -10;
const scaledDelta = baseDelta * participationRate * (0.5 + Math.abs(strength) * 0.5);
return Math.round(scaledDelta * 10) / 10;
}
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';
}
}

View File

@@ -0,0 +1,290 @@
import { TeamRatingSnapshotCalculator } from './TeamRatingSnapshotCalculator';
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
describe('TeamRatingSnapshotCalculator', () => {
describe('calculate', () => {
it('should return default ratings for empty events', () => {
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', []);
expect(snapshot.teamId).toBe('team-123');
expect(snapshot.driving.value).toBe(50);
expect(snapshot.adminTrust.value).toBe(50);
expect(snapshot.overall).toBe(50);
expect(snapshot.eventCount).toBe(0);
});
it('should calculate single dimension rating', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
expect(snapshot.driving.value).toBe(60); // 50 + 10
expect(snapshot.adminTrust.value).toBe(50); // Default
expect(snapshot.overall).toBeCloseTo(57, 1); // 60 * 0.7 + 50 * 0.3 = 57
});
it('should calculate multiple events with weights', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
weight: 1,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-5),
weight: 2,
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
// Weighted average: (10*1 + (-5)*2) / (1+2) = 0/3 = 0
// So driving = 50 + 0 = 50
expect(snapshot.driving.value).toBe(50);
});
it('should calculate mixed dimensions', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(15),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'adminAction', id: 'action-1' },
reason: { code: 'ADMIN_BONUS', description: 'Helpful admin work' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
expect(snapshot.driving.value).toBe(65); // 50 + 15
expect(snapshot.adminTrust.value).toBe(55); // 50 + 5
expect(snapshot.overall).toBeCloseTo(62, 1); // 65 * 0.7 + 55 * 0.3 = 62
});
it('should clamp values between 0 and 100', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(60), // Would make it 110
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
expect(snapshot.driving.value).toBe(100); // Clamped
});
it('should track last updated date', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(3),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculate('team-123', events);
expect(snapshot.lastUpdated).toEqual(new Date('2024-01-02T10:00:00Z'));
expect(snapshot.eventCount).toBe(2);
});
});
describe('calculateDimensionChange', () => {
it('should calculate net change for a dimension', () => {
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
weight: 1,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(-5),
weight: 2,
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'RACE_PENALTY', description: 'Incident penalty' },
visibility: { public: true },
version: 1,
}),
];
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
TeamRatingDimensionKey.create('driving'),
events
);
// (10*1 + (-5)*2) / (1+2) = 0/3 = 0
expect(change).toBe(0);
});
it('should return 0 for no events', () => {
const change = TeamRatingSnapshotCalculator.calculateDimensionChange(
TeamRatingDimensionKey.create('driving'),
[]
);
expect(change).toBe(0);
});
});
describe('calculateOverWindow', () => {
it('should calculate ratings for a time window', () => {
const allEvents = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId: 'team-123',
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'RACE_FINISH', description: 'Finished 2nd' },
visibility: { public: true },
version: 1,
}),
];
const snapshot = TeamRatingSnapshotCalculator.calculateOverWindow(
'team-123',
allEvents,
new Date('2024-01-01T00:00:00Z'),
new Date('2024-01-01T23:59:59Z')
);
// Only first event in window
expect(snapshot.driving.value).toBe(60); // 50 + 10
expect(snapshot.eventCount).toBe(1);
});
});
describe('calculateDelta', () => {
it('should calculate differences between snapshots', () => {
const before = {
teamId: 'team-123',
driving: TeamRatingValue.create(50),
adminTrust: TeamRatingValue.create(50),
overall: 50,
lastUpdated: new Date('2024-01-01'),
eventCount: 10,
};
const after = {
teamId: 'team-123',
driving: TeamRatingValue.create(65),
adminTrust: TeamRatingValue.create(55),
overall: 62,
lastUpdated: new Date('2024-01-02'),
eventCount: 15,
};
const delta = TeamRatingSnapshotCalculator.calculateDelta(before, after);
expect(delta.driving).toBe(15);
expect(delta.adminTrust).toBe(5);
expect(delta.overall).toBe(12);
});
});
});

View File

@@ -0,0 +1,162 @@
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
import { TeamRatingValue } from '../value-objects/TeamRatingValue';
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
export interface TeamRatingSnapshot {
teamId: string;
driving: TeamRatingValue;
adminTrust: TeamRatingValue;
overall: number; // Calculated overall rating
lastUpdated: Date;
eventCount: number;
}
/**
* Domain Service: TeamRatingSnapshotCalculator
*
* Calculates team rating snapshots from event ledgers.
* Mirrors the user RatingSnapshotCalculator pattern.
*
* Pure domain logic - no persistence concerns.
*/
export class TeamRatingSnapshotCalculator {
/**
* Calculate current team rating snapshot from all events.
*
* @param teamId - The team ID to calculate for
* @param events - All rating events for the team
* @returns TeamRatingSnapshot with current ratings
*/
static calculate(teamId: string, events: TeamRatingEvent[]): TeamRatingSnapshot {
// Start with default ratings (50 for each dimension)
const defaultRating = 50;
if (events.length === 0) {
return {
teamId,
driving: TeamRatingValue.create(defaultRating),
adminTrust: TeamRatingValue.create(defaultRating),
overall: defaultRating,
lastUpdated: new Date(),
eventCount: 0,
};
}
// Group events by dimension
const eventsByDimension = events.reduce((acc, event) => {
const key = event.dimension.value;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(event);
return acc;
}, {} as Record<string, TeamRatingEvent[]>);
// Calculate each dimension
const dimensionRatings: Record<string, number> = {};
for (const [dimensionKey, dimensionEvents] of Object.entries(eventsByDimension)) {
const totalWeight = dimensionEvents.reduce((sum, event) => {
return sum + (event.weight || 1);
}, 0);
const weightedSum = dimensionEvents.reduce((sum, event) => {
return sum + (event.delta.value * (event.weight || 1));
}, 0);
// Normalize and add to base rating
const normalizedDelta = weightedSum / totalWeight;
dimensionRatings[dimensionKey] = Math.max(0, Math.min(100, defaultRating + normalizedDelta));
}
const drivingRating = dimensionRatings['driving'] ?? defaultRating;
const adminTrustRating = dimensionRatings['adminTrust'] ?? defaultRating;
// Calculate overall as weighted average
const overall = (drivingRating * 0.7 + adminTrustRating * 0.3);
// Find latest event date
const lastUpdated = events.reduce((latest, event) => {
return event.occurredAt > latest ? event.occurredAt : latest;
}, new Date(0));
return {
teamId,
driving: TeamRatingValue.create(drivingRating),
adminTrust: TeamRatingValue.create(adminTrustRating),
overall: Math.round(overall * 10) / 10, // Round to 1 decimal
lastUpdated,
eventCount: events.length,
};
}
/**
* Calculate rating change for a specific dimension from events.
*
* @param dimension - The dimension to calculate for
* @param events - Events to calculate from
* @returns Net change value
*/
static calculateDimensionChange(
dimension: TeamRatingDimensionKey,
events: TeamRatingEvent[]
): number {
const filtered = events.filter(e => e.dimension.equals(dimension));
if (filtered.length === 0) return 0;
const totalWeight = filtered.reduce((sum, event) => {
return sum + (event.weight || 1);
}, 0);
const weightedSum = filtered.reduce((sum, event) => {
return sum + (event.delta.value * (event.weight || 1));
}, 0);
return weightedSum / totalWeight;
}
/**
* Calculate rating change over a time window.
*
* @param teamId - The team ID
* @param events - All events
* @param from - Start date
* @param to - End date
* @returns Snapshot of ratings at the end of the window
*/
static calculateOverWindow(
teamId: string,
events: TeamRatingEvent[],
from: Date,
to: Date
): TeamRatingSnapshot {
const windowEvents = events.filter(e =>
e.occurredAt >= from && e.occurredAt <= to
);
return this.calculate(teamId, windowEvents);
}
/**
* Calculate rating change between two snapshots.
*
* @param before - Snapshot before changes
* @param after - Snapshot after changes
* @returns Object with change values
*/
static calculateDelta(
before: TeamRatingSnapshot,
after: TeamRatingSnapshot
): {
driving: number;
adminTrust: number;
overall: number;
} {
return {
driving: after.driving.value - before.driving.value,
adminTrust: after.adminTrust.value - before.adminTrust.value,
overall: after.overall - before.overall,
};
}
}