rating
This commit is contained in:
@@ -1,22 +1,88 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
|
||||
import { UserRating } from '../value-objects/UserRating';
|
||||
import type { IRatingEventRepository } from '../repositories/IRatingEventRepository';
|
||||
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 IDomainService {
|
||||
readonly serviceName = 'RatingUpdateService';
|
||||
|
||||
constructor(
|
||||
private readonly userRatingRepository: IUserRatingRepository
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
private readonly ratingEventRepository: IRatingEventRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Update driver ratings after race completion
|
||||
* 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<{
|
||||
@@ -27,13 +93,28 @@ export class RatingUpdateService implements IDomainService {
|
||||
startPosition: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
for (const result of driverResults) {
|
||||
await this.updateDriverRating(result);
|
||||
// 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
|
||||
* 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;
|
||||
@@ -42,103 +123,104 @@ export class RatingUpdateService implements IDomainService {
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): Promise<void> {
|
||||
const { driverId, position, totalDrivers, incidents, startPosition } = result;
|
||||
// Delegate to new event-based approach
|
||||
await this.updateDriverRatingsAfterRace([result]);
|
||||
}
|
||||
|
||||
// Get or create user rating
|
||||
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(driverId);
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
// Calculate performance score (0-100)
|
||||
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
|
||||
// Save event
|
||||
await this.ratingEventRepository.save(event);
|
||||
|
||||
// Calculate fairness score based on incidents (lower incidents = higher fairness)
|
||||
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
|
||||
// Recompute snapshot
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(driverId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
// Update ratings
|
||||
const updatedRating = userRating
|
||||
.updateDriverRating(performanceScore)
|
||||
.updateFairnessScore(fairnessScore);
|
||||
/**
|
||||
* 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 updated rating
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
// 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 {
|
||||
// Base score from finishing position (reverse percentile)
|
||||
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||
|
||||
// Bonus for positions gained
|
||||
const positionsGained = startPosition - position;
|
||||
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
|
||||
|
||||
// Field strength adjustment (harder fields give higher scores for same position)
|
||||
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
|
||||
|
||||
const gainBonus = Math.max(0, positionsGained * 2);
|
||||
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50);
|
||||
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
|
||||
|
||||
// Clamp to 0-100 range
|
||||
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 {
|
||||
// Base fairness score (100 = perfect, 0 = terrible)
|
||||
let fairnessScore = 100;
|
||||
|
||||
// Deduct points for incidents
|
||||
fairnessScore -= incidents * 15; // 15 points per incident
|
||||
|
||||
// Additional deduction for high incident rate relative to field
|
||||
fairnessScore -= incidents * 15;
|
||||
const incidentRate = incidents / totalDrivers;
|
||||
if (incidentRate > 0.5) {
|
||||
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
|
||||
fairnessScore -= 20;
|
||||
}
|
||||
|
||||
// Clamp to 0-100 range
|
||||
return Math.max(0, Math.min(100, fairnessScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trust score based on sportsmanship actions
|
||||
*/
|
||||
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
|
||||
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(driverId);
|
||||
}
|
||||
|
||||
// Convert trust change (-50 to +50) to 0-100 scale
|
||||
const currentTrust = userRating.trust.value;
|
||||
const newTrustValue = Math.max(0, Math.min(100, currentTrust + trustChange));
|
||||
|
||||
const updatedRating = userRating.updateTrustScore(newTrustValue);
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update steward rating based on protest handling quality
|
||||
*/
|
||||
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
|
||||
let userRating = await this.userRatingRepository.findByUserId(stewardId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(stewardId);
|
||||
}
|
||||
|
||||
const currentRating = userRating.steward.value;
|
||||
const newRatingValue = Math.max(0, Math.min(100, currentRating + ratingChange));
|
||||
|
||||
const updatedRating = userRating.updateStewardRating(newRatingValue);
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user