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

451 lines
13 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';
import { TeamDrivingOvertakeStats, TeamDrivingQualifyingResult, TeamDrivingRaceResult, TeamDrivingRatingCalculator } 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;
}
}