Files
gridpilot.gg/core/racing/application/use-cases/CompleteRaceUseCase.ts
2026-01-21 16:52:43 +01:00

231 lines
8.3 KiB
TypeScript

import { Result } from '@core/shared/domain/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result as RaceResult } from '../../domain/entities/result/Result';
import { Standing } from '../../domain/entities/Standing';
import type { League } from '../../domain/entities/League';
import type { LeagueRepository } from '../../domain/repositories/LeagueRepository';
import type { RaceRegistrationRepository } from '../../domain/repositories/RaceRegistrationRepository';
import type { RaceRepository } from '../../domain/repositories/RaceRepository';
import type { ResultRepository } from '../../domain/repositories/ResultRepository';
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
export interface CompleteRaceInput {
raceId: string;
}
export type CompleteRaceResult = {
raceId: string;
registeredDriverIds: string[];
};
export type CompleteRaceErrorCode = 'RACE_NOT_FOUND' | 'NO_REGISTERED_DRIVERS' | 'REPOSITORY_ERROR';
/**
* Use Case: CompleteRaceUseCase
*
* Encapsulates the workflow for completing a race:
* - loads the race by id
* - returns error if the race does not exist
* - delegates completion rules to the Race domain entity
* - automatically generates realistic results for registered drivers
* - updates league standings
* - persists all changes via repositories.
*/
interface DriverRatingInput {
driverId: string;
}
interface DriverRatingOutput {
rating: number | null;
ratingChange: number | null;
}
export class CompleteRaceUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly raceRepository: RaceRepository,
private readonly raceRegistrationRepository: RaceRegistrationRepository,
private readonly resultRepository: ResultRepository,
private readonly standingRepository: StandingRepository,
private readonly getDriverRating: (input: DriverRatingInput) => Promise<DriverRatingOutput>,
) {}
async execute(command: CompleteRaceInput): Promise<
Result<CompleteRaceResult, ApplicationErrorCode<CompleteRaceErrorCode | 'REPOSITORY_ERROR', { message: string }>>
> {
try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err<CompleteRaceResult, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
return Result.err<CompleteRaceResult, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'NO_REGISTERED_DRIVERS',
details: { message: 'No registered drivers for this race' },
});
}
// Get driver ratings using injected provider
const ratingPromises = registeredDriverIds.map((driverId) =>
this.getDriverRating({ driverId }),
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = new Map<string, number>();
registeredDriverIds.forEach((driverId, index) => {
const rating = ratingResults[index]?.rating ?? null;
if (rating !== null) {
driverRatings.set(driverId, rating);
}
});
// Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Get league to retrieve points system
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: `League not found: ${race.leagueId}` },
});
}
// Update standings
await this.updateStandings(league, results);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
const result: CompleteRaceResult = { raceId, registeredDriverIds };
return Result.ok(result);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private generateRaceResults(
raceId: string,
driverIds: string[],
driverRatings: Map<string, number>,
): RaceResult[] {
// Create driver performance data
const driverPerformances = driverIds.map(driverId => ({
driverId,
rating: driverRatings.get(driverId) ?? 1500, // Default rating
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
}));
// Sort by performance (rating + randomization)
driverPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
const perfB = b.rating + (b.randomFactor * 200);
return perfB - perfA; // Higher performance first
});
// Generate qualifying results for start positions (similar but different from race results)
const qualiPerformances = driverPerformances.map(p => ({
...p,
randomFactor: Math.random() - 0.5, // New randomization for quali
}));
qualiPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 150);
const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA;
});
// Generate results
const results: RaceResult[] = [];
for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i]!;
const position = i + 1;
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
// Generate realistic lap times (90-120 seconds for a lap)
const baseLapTime = 90000 + Math.random() * 30000;
const positionBonus = (position - 1) * 500; // Winners are faster
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
// Generate incidents (0-3, higher for lower positions)
const incidentProbability = Math.min(0.8, position / driverPerformances.length);
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
results.push(
RaceResult.create({
id: `${raceId}-${driverId}`,
raceId,
driverId,
position,
startPosition,
fastestLap,
incidents,
}),
);
}
return results;
}
private async updateStandings(league: League, results: RaceResult[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) {
const driverIdStr = result.driverId.toString();
const existing = resultsByDriver.get(driverIdStr) || [];
existing.push(result);
resultsByDriver.set(driverIdStr, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, league.id.toString());
if (!standing) {
standing = Standing.create({
leagueId: league.id.toString(),
driverId,
});
}
// Get points system from league configuration
const pointsSystem = league.settings.customPoints ??
this.getPointsSystem(league.settings.pointsSystem);
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position.toNumber(), pointsSystem);
}
await this.standingRepository.save(standing);
}
}
private getPointsSystem(pointsSystem: 'f1-2024' | 'indycar' | 'custom'): Record<number, number> {
const systems: Record<string, Record<number, number>> = {
'f1-2024': { 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 },
'indycar': { 1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20, 11: 19, 12: 18, 13: 17, 14: 16, 15: 15 },
};
return systems[pointsSystem] ?? systems['f1-2024'] ?? { 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 };
}
}