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 { const eventsByTeam = new Map(); 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'; } }