226 lines
7.9 KiB
TypeScript
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));
|
|
}
|
|
} |