import { RatingEvent } from '../entities/RatingEvent'; import { RatingEventId } from '../value-objects/RatingEventId'; import { RatingDimensionKey } from '../value-objects/RatingDimensionKey'; import { RatingDelta } from '../value-objects/RatingDelta'; import { DrivingReasonCode } from '../value-objects/DrivingReasonCode'; import { AdminTrustReasonCode } from '../value-objects/AdminTrustReasonCode'; // Existing interfaces interface RaceFinishInput { userId: string; raceId: string; position: number; totalDrivers: number; startPosition: number; incidents: number; fieldStrength: number; status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'; } interface PenaltyInput { userId: string; penaltyId: string; penaltyType: 'incident' | 'admin_violation'; severity: 'minor' | 'major'; reason: string; } interface VoteInput { userId: string; voteSessionId: string; outcome: 'positive' | 'negative'; voteCount: number; eligibleVoterCount: number; percentPositive: number; } interface AdminActionInput { userId: string; adminActionId: string; actionType: 'sla_response' | 'abuse_report' | 'rule_clarity'; details: Record; } // NEW: Enhanced interface for race facts (per plans section 5.1.2) export interface RaceFactsDto { raceId: string; results: Array<{ userId: string; startPos: number; finishPos: number; incidents: number; status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'; sof?: number; // Optional strength of field }>; } /** * Domain Service: RatingEventFactory * * Pure, stateless factory that turns domain facts into rating events. * Follows the pattern of creating immutable entities from business facts. * Enhanced to support full driving event taxonomy from plans. */ export class RatingEventFactory { /** * Create rating events from race finish data * Handles performance, clean driving, and reliability dimensions */ static createFromRaceFinish(input: RaceFinishInput): RatingEvent[] { const events: RatingEvent[] = []; const now = new Date(); // Performance events (only for finished races) if (input.status === 'finished') { const performanceDelta = this.calculatePerformanceDelta( input.position, input.totalDrivers, input.startPosition, input.fieldStrength ); if (performanceDelta !== 0) { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(performanceDelta), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in field of ${input.totalDrivers}`, details: { position: input.position, totalDrivers: input.totalDrivers, startPosition: input.startPosition, fieldStrength: input.fieldStrength, positionsGained: input.startPosition - input.position, }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } // Positions gained bonus const positionsGained = input.startPosition - input.position; if (positionsGained > 0) { const gainBonus = Math.min(positionsGained * 2, 10); // Max 10 points events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(gainBonus), weight: 0.5, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_POSITIONS_GAINED_BONUS', summary: `Gained ${positionsGained} positions`, details: { positionsGained }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } } // Clean driving penalty (incidents) if (input.incidents > 0) { const incidentPenalty = Math.min(input.incidents * 5, 30); // Max 30 points penalty events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-incidentPenalty), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_INCIDENTS_PENALTY', summary: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`, details: { incidents: input.incidents }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } // Reliability penalties if (input.status === 'dns') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-15), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_DNS_PENALTY', summary: 'Did not start', details: {}, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } else if (input.status === 'dnf') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-10), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_DNF_PENALTY', summary: 'Did not finish', details: {}, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } else if (input.status === 'dsq') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-25), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_DSQ_PENALTY', summary: 'Disqualified', details: {}, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } else if (input.status === 'afk') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-20), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: input.raceId }, reason: { code: 'DRIVING_AFK_PENALTY', summary: 'AFK / Not responsive', details: {}, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } return events; } /** * NEW: Create rating events from race facts DTO * Supports multiple drivers and full event taxonomy * Returns a map of userId to events for efficient processing */ static createDrivingEventsFromRace(raceFacts: RaceFactsDto): Map { const eventsByUser = new Map(); const now = new Date(); // Calculate field strength if not provided in all results const hasSof = raceFacts.results.some(r => r.sof !== undefined); let fieldStrength = 0; if (hasSof) { const sofResults = raceFacts.results.filter(r => r.sof !== undefined); fieldStrength = sofResults.reduce((sum, r) => sum + r.sof!, 0) / sofResults.length; } else { // Use average of finished positions as proxy const finishedResults = raceFacts.results.filter(r => r.status === 'finished'); if (finishedResults.length > 0) { fieldStrength = finishedResults.reduce((sum, r) => sum + (r.finishPos * 100), 0) / finishedResults.length; } } for (const result of raceFacts.results) { const events: RatingEvent[] = []; // 1. Performance events (only for finished races) if (result.status === 'finished') { const performanceDelta = this.calculatePerformanceDelta( result.finishPos, raceFacts.results.length, result.startPos, fieldStrength ); if (performanceDelta !== 0) { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: result.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(performanceDelta), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: raceFacts.raceId }, reason: { code: DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN').value, summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${raceFacts.results.length}`, details: { startPos: result.startPos, finishPos: result.finishPos, positionsGained: result.startPos - result.finishPos, fieldStrength: fieldStrength, totalDrivers: raceFacts.results.length, }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } // Positions gained bonus const positionsGained = result.startPos - result.finishPos; if (positionsGained > 0) { const gainBonus = Math.min(positionsGained * 2, 10); events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: result.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(gainBonus), weight: 0.5, occurredAt: now, createdAt: now, source: { type: 'race', id: raceFacts.raceId }, reason: { code: DrivingReasonCode.create('DRIVING_POSITIONS_GAINED_BONUS').value, summary: `Gained ${positionsGained} positions`, details: { positionsGained }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } } // 2. Clean driving penalty (incidents) if (result.incidents > 0) { const incidentPenalty = Math.min(result.incidents * 5, 30); events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: result.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(-incidentPenalty), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: raceFacts.raceId }, reason: { code: DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY').value, summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`, details: { incidents: result.incidents }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } // 3. Reliability penalties if (result.status !== 'finished') { let penaltyDelta = 0; let reasonCode: DrivingReasonCodeValue; switch (result.status) { case 'dns': penaltyDelta = -15; reasonCode = 'DRIVING_DNS_PENALTY'; break; case 'dnf': penaltyDelta = -10; reasonCode = 'DRIVING_DNF_PENALTY'; break; case 'dsq': penaltyDelta = -25; reasonCode = 'DRIVING_DSQ_PENALTY'; break; case 'afk': penaltyDelta = -20; reasonCode = 'DRIVING_AFK_PENALTY'; break; default: continue; // Skip unknown statuses } events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: result.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(penaltyDelta), weight: 1, occurredAt: now, createdAt: now, source: { type: 'race', id: raceFacts.raceId }, reason: { code: DrivingReasonCode.create(reasonCode).value, summary: this.getStatusSummary(result.status), details: { status: result.status }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } if (events.length > 0) { eventsByUser.set(result.userId, events); } } return eventsByUser; } /** * Create rating events from penalty data */ static createFromPenalty(input: PenaltyInput): RatingEvent[] { const now = new Date(); const events: RatingEvent[] = []; if (input.penaltyType === 'incident') { const delta = input.severity === 'major' ? -15 : -5; events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('driving'), delta: RatingDelta.create(delta), weight: 1, occurredAt: now, createdAt: now, source: { type: 'penalty', id: input.penaltyId }, reason: { code: DrivingReasonCode.create('DRIVING_PENALTY_INVOLVEMENT_PENALTY').value, summary: input.reason, details: { severity: input.severity, type: input.penaltyType }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } else if (input.penaltyType === 'admin_violation') { const delta = input.severity === 'major' ? -20 : -10; events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('adminTrust'), delta: RatingDelta.create(delta), weight: 1, occurredAt: now, createdAt: now, source: { type: 'penalty', id: input.penaltyId }, reason: { code: 'ADMIN_ACTION_ABUSE_REPORT_PENALTY', summary: input.reason, details: { severity: input.severity, type: input.penaltyType }, }, visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] }, version: 1, }) ); } return events; } /** * Create rating events from vote outcome */ static createFromVote(input: VoteInput): RatingEvent[] { const now = new Date(); const events: RatingEvent[] = []; // Calculate delta based on vote outcome // Scale: -20 to +20 based on percentage let delta: number; if (input.outcome === 'positive') { delta = Math.round((input.percentPositive / 100) * 20); // 0 to +20 } else { delta = -Math.round(((100 - input.percentPositive) / 100) * 20); // -20 to 0 } if (delta !== 0) { const reasonCode = input.outcome === 'positive' ? AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE').value : AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE').value; events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('adminTrust'), delta: RatingDelta.create(delta), weight: input.voteCount, // Weight by number of votes occurredAt: now, createdAt: now, source: { type: 'vote', id: input.voteSessionId }, reason: { code: reasonCode, summary: `Vote outcome: ${input.percentPositive}% positive (${input.voteCount}/${input.eligibleVoterCount})`, details: { voteCount: input.voteCount, eligibleVoterCount: input.eligibleVoterCount, percentPositive: input.percentPositive, }, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } return events; } /** * Create rating events from admin action */ static createFromAdminAction(input: AdminActionInput): RatingEvent[] { const now = new Date(); const events: RatingEvent[] = []; if (input.actionType === 'sla_response') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('adminTrust'), delta: RatingDelta.create(5), weight: 1, occurredAt: now, createdAt: now, source: { type: 'adminAction', id: input.adminActionId }, reason: { code: AdminTrustReasonCode.create('ADMIN_ACTION_SLA_BONUS').value, summary: 'Timely response to admin task', details: input.details, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } else if (input.actionType === 'abuse_report') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('adminTrust'), delta: RatingDelta.create(-15), weight: 1, occurredAt: now, createdAt: now, source: { type: 'adminAction', id: input.adminActionId }, reason: { code: AdminTrustReasonCode.create('ADMIN_ACTION_ABUSE_REPORT_PENALTY').value, summary: 'Validated abuse report', details: input.details, }, visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] }, version: 1, }) ); } else if (input.actionType === 'rule_clarity') { events.push( RatingEvent.create({ id: RatingEventId.generate(), userId: input.userId, dimension: RatingDimensionKey.create('adminTrust'), delta: RatingDelta.create(3), weight: 1, occurredAt: now, createdAt: now, source: { type: 'adminAction', id: input.adminActionId }, reason: { code: AdminTrustReasonCode.create('ADMIN_ACTION_RULE_CLARITY_BONUS').value, summary: 'Published clear rules/changes', details: input.details, }, visibility: { public: true, redactedFields: [] }, version: 1, }) ); } return events; } /** * Calculate performance delta based on position and field strength */ private static calculatePerformanceDelta( position: number, totalDrivers: number, startPosition: number, fieldStrength: number ): number { // Handle edge cases where data might be inconsistent // If totalDrivers is less than position, use position as totalDrivers for calculation const effectiveTotalDrivers = Math.max(totalDrivers, position); // Base score from position (reverse percentile) const positionScore = ((effectiveTotalDrivers - position + 1) / effectiveTotalDrivers) * 100; // Bonus for positions gained const positionsGained = startPosition - position; const gainBonus = Math.max(0, positionsGained * 2); // Field strength multiplier (higher field strength = harder competition) // Normalize field strength to 0.8-1.2 range const fieldMultiplier = 0.8 + Math.min(fieldStrength / 10000, 0.4); const rawScore = (positionScore + gainBonus) * fieldMultiplier; // Convert to delta (range -50 to +50) // 50th percentile = 0, top = +50, bottom = -50 return Math.round(rawScore - 50); } /** * Get ordinal suffix for position */ 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'; } /** * Get human-readable summary for status */ private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string { switch (status) { case 'finished': return 'Race completed'; case 'dnf': return 'Did not finish'; case 'dns': return 'Did not start'; case 'dsq': return 'Disqualified'; case 'afk': return 'AFK / Not responsive'; } } } // Type export for convenience export type DrivingReasonCodeValue = | 'DRIVING_FINISH_STRENGTH_GAIN' | 'DRIVING_POSITIONS_GAINED_BONUS' | 'DRIVING_PACE_RELATIVE_GAIN' | 'DRIVING_INCIDENTS_PENALTY' | 'DRIVING_MAJOR_CONTACT_PENALTY' | 'DRIVING_PENALTY_INVOLVEMENT_PENALTY' | 'DRIVING_DNS_PENALTY' | 'DRIVING_DNF_PENALTY' | 'DRIVING_DSQ_PENALTY' | 'DRIVING_AFK_PENALTY' | 'DRIVING_SEASON_ATTENDANCE_BONUS';