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

659 lines
22 KiB
TypeScript

import { RatingEvent } from '../entities/RatingEvent';
import { AdminTrustReasonCode } from '../value-objects/AdminTrustReasonCode';
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
import { RatingDelta } from '../value-objects/RatingDelta';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingEventId } from '../value-objects/RatingEventId';
// 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<string, unknown>;
}
// 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<string, RatingEvent[]> {
const eventsByUser = new Map<string, RatingEvent[]>();
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';