496 lines
15 KiB
TypeScript
496 lines
15 KiB
TypeScript
import { TeamRatingEvent } from '../entities/TeamRatingEvent';
|
|
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
|
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
|
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
|
|
|
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';
|
|
}
|
|
} |