451 lines
13 KiB
TypeScript
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;
|
|
}
|
|
} |