Files
gridpilot.gg/core/identity/domain/services/RatingUpdateService.ts
2026-01-16 16:46:57 +01:00

226 lines
7.9 KiB
TypeScript

import type { DomainService } from '@core/shared/domain/Service';
import type { UserRatingRepository } from '../repositories/UserRatingRepository';
import type { RatingEventRepository } from '../repositories/RatingEventRepository';
import { RatingEventFactory } from './RatingEventFactory';
import { RatingSnapshotCalculator } from './RatingSnapshotCalculator';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
/**
* Domain Service: RatingUpdateService
*
* Handles updating user ratings based on various events and performance metrics.
* Centralizes rating calculation logic and ensures consistency across the system.
*
* EVOLVED (Slice 7): Now uses event-driven approach with ledger pattern.
* Records rating events and recomputes snapshots for transparency and auditability.
*/
export class RatingUpdateService implements DomainService {
readonly serviceName = 'RatingUpdateService';
constructor(
private readonly userRatingRepository: UserRatingRepository,
private readonly ratingEventRepository: RatingEventRepository
) {}
/**
* Record race rating events and update snapshots (NEW LEDGER APPROACH)
* Replaces direct rating updates with event recording + snapshot recomputation
*/
async recordRaceRatingEvents(raceId: string, raceResults: Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number;
}>): Promise<{ success: boolean; eventsCreated: number; driversUpdated: string[] }> {
try {
// Use factory to create rating events from race results
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({
raceId,
results: raceResults,
});
let totalEvents = 0;
const driversUpdated: string[] = [];
// Process each user's events
for (const [userId, events] of eventsByUser) {
if (events.length === 0) continue;
// Save all events to ledger
for (const event of events) {
await this.ratingEventRepository.save(event);
totalEvents++;
}
// Recompute snapshot from all events for this user
const allEvents = await this.ratingEventRepository.getAllByUserId(userId);
const snapshot = RatingSnapshotCalculator.calculate(userId, allEvents);
await this.userRatingRepository.save(snapshot);
driversUpdated.push(userId);
}
return {
success: true,
eventsCreated: totalEvents,
driversUpdated,
};
} catch (error) {
console.error('[RatingUpdateService] Failed to record race rating events:', error);
return {
success: false,
eventsCreated: 0,
driversUpdated: [],
};
}
}
/**
* Update driver ratings after race completion (BACKWARD COMPATIBLE)
* Still supported but now delegates to event-based approach internally
*/
async updateDriverRatingsAfterRace(
driverResults: Array<{
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}>
): Promise<void> {
// Convert to new format and use event-based approach
const raceResults = driverResults.map(result => ({
userId: result.driverId,
startPos: result.startPosition,
finishPos: result.position,
incidents: result.incidents,
status: 'finished' as const,
}));
// Generate a synthetic race ID for backward compatibility
const raceId = `backward-compat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const result = await this.recordRaceRatingEvents(raceId, raceResults);
if (!result.success) {
throw new Error('Failed to update ratings via event system');
}
}
/**
* Update individual driver rating based on race result (LEGACY - DEPRECATED)
* Kept for backward compatibility but now uses event-based approach
*/
private async updateDriverRating(result: {
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}): Promise<void> {
// Delegate to new event-based approach
await this.updateDriverRatingsAfterRace([result]);
}
/**
* Update trust score based on sportsmanship actions (USES LEDGER)
*/
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
// Create trust-related rating event using manual event creation
const now = new Date();
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: driverId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(trustChange),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'manualAdjustment', id: `trust-${now.getTime()}` },
reason: {
code: trustChange > 0 ? 'TRUST_BONUS' : 'TRUST_PENALTY',
summary: trustChange > 0 ? 'Positive sportsmanship' : 'Negative sportsmanship',
details: { trustChange },
},
visibility: { public: true, redactedFields: [] },
version: 1,
});
// Save event
await this.ratingEventRepository.save(event);
// Recompute snapshot
const allEvents = await this.ratingEventRepository.getAllByUserId(driverId);
const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents);
await this.userRatingRepository.save(snapshot);
}
/**
* Update steward rating based on protest handling quality (USES LEDGER)
*/
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
// Create steward-related rating event using manual event creation
const now = new Date();
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: stewardId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(ratingChange),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'manualAdjustment', id: `steward-${now.getTime()}` },
reason: {
code: ratingChange > 0 ? 'STEWARD_BONUS' : 'STEWARD_PENALTY',
summary: ratingChange > 0 ? 'Good protest handling' : 'Poor protest handling',
details: { ratingChange },
},
visibility: { public: true, redactedFields: [] },
version: 1,
});
// Save event
await this.ratingEventRepository.save(event);
// Recompute snapshot
const allEvents = await this.ratingEventRepository.getAllByUserId(stewardId);
const snapshot = RatingSnapshotCalculator.calculate(stewardId, allEvents);
await this.userRatingRepository.save(snapshot);
}
/**
* Calculate performance score based on finishing position and field strength
* (Utility method kept for reference, but now handled by RatingEventFactory)
*/
private calculatePerformanceScore(
position: number,
totalDrivers: number,
startPosition: number
): number {
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
const positionsGained = startPosition - position;
const gainBonus = Math.max(0, positionsGained * 2);
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50);
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
return Math.max(0, Math.min(100, rawScore));
}
/**
* Calculate fairness score based on incident involvement
* (Utility method kept for reference, but now handled by RatingEventFactory)
*/
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
let fairnessScore = 100;
fairnessScore -= incidents * 15;
const incidentRate = incidents / totalDrivers;
if (incidentRate > 0.5) {
fairnessScore -= 20;
}
return Math.max(0, Math.min(100, fairnessScore));
}
}