wip
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IDomainEventPublisher } from '@gridpilot/shared/domain';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
|
||||
/**
|
||||
* Use Case: CloseRaceEventStewardingUseCase
|
||||
*
|
||||
* Scheduled job that checks for race events with expired stewarding windows
|
||||
* and closes them, triggering final results notifications.
|
||||
*
|
||||
* This would typically be run by a scheduled job (e.g., every 5 minutes)
|
||||
* to automatically close stewarding windows based on league configuration.
|
||||
*/
|
||||
export interface CloseRaceEventStewardingCommand {
|
||||
// No parameters needed - finds all expired events automatically
|
||||
}
|
||||
|
||||
export class CloseRaceEventStewardingUseCase
|
||||
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
|
||||
{
|
||||
constructor(
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly domainEventPublisher: IDomainEventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
|
||||
// Find all race events awaiting stewarding that have expired windows
|
||||
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
|
||||
|
||||
for (const raceEvent of expiredEvents) {
|
||||
await this.closeStewardingForRaceEvent(raceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> {
|
||||
try {
|
||||
// Close the stewarding window
|
||||
const closedRaceEvent = raceEvent.closeStewarding();
|
||||
await this.raceEventRepository.update(closedRaceEvent);
|
||||
|
||||
// Get list of participating drivers (would need to be implemented)
|
||||
const driverIds = await this.getParticipatingDriverIds(raceEvent);
|
||||
|
||||
// Check if any penalties were applied during stewarding
|
||||
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
|
||||
|
||||
// Publish domain event to trigger final results notifications
|
||||
const event = new RaceEventStewardingClosedEvent({
|
||||
raceEventId: raceEvent.id,
|
||||
leagueId: raceEvent.leagueId,
|
||||
seasonId: raceEvent.seasonId,
|
||||
closedAt: new Date(),
|
||||
driverIds,
|
||||
hadPenaltiesApplied,
|
||||
});
|
||||
|
||||
await this.domainEventPublisher.publish(event);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
|
||||
// In production, this would trigger alerts/monitoring
|
||||
}
|
||||
}
|
||||
|
||||
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> {
|
||||
// In a real implementation, this would query race registrations
|
||||
// For the prototype, we'll return a mock list
|
||||
// This would typically involve:
|
||||
// 1. Get all sessions in the race event
|
||||
// 2. For each session, get registered drivers
|
||||
// 3. Return unique driver IDs across all sessions
|
||||
|
||||
// Mock implementation for prototype
|
||||
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
|
||||
}
|
||||
|
||||
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
|
||||
// In a real implementation, this would check if any penalties were issued
|
||||
// during the stewarding window for this race event
|
||||
// This would query the penalty repository for penalties related to this race event
|
||||
|
||||
// Mock implementation for prototype - randomly simulate penalties
|
||||
return Math.random() > 0.7; // 30% chance of penalties being applied
|
||||
}
|
||||
}
|
||||
160
packages/racing/application/use-cases/CompleteRaceUseCase.ts
Normal file
160
packages/racing/application/use-cases/CompleteRaceUseCase.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import { Standing } from '../../domain/entities/Standing';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case: CompleteRaceUseCase
|
||||
*
|
||||
* Encapsulates the workflow for completing a race:
|
||||
* - loads the race by id
|
||||
* - throws 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.
|
||||
*/
|
||||
export interface CompleteRaceCommandDTO {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export class CompleteRaceUseCase
|
||||
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRatingProvider: DriverRatingProvider,
|
||||
) {}
|
||||
|
||||
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
||||
const { raceId } = command;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
// Get registered drivers for this race
|
||||
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||
if (registeredDriverIds.length === 0) {
|
||||
throw new Error('Cannot complete race with no registered drivers');
|
||||
}
|
||||
|
||||
// Get driver ratings
|
||||
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||
|
||||
// Generate realistic race results
|
||||
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||
|
||||
// Save results
|
||||
for (const result of results) {
|
||||
await this.resultRepository.create(result);
|
||||
}
|
||||
|
||||
// Update standings
|
||||
await this.updateStandings(race.leagueId, results);
|
||||
|
||||
// Complete the race
|
||||
const completedRace = race.complete();
|
||||
await this.raceRepository.update(completedRace);
|
||||
}
|
||||
|
||||
private generateRaceResults(
|
||||
raceId: string,
|
||||
driverIds: string[],
|
||||
driverRatings: Map<string, number>
|
||||
): Result[] {
|
||||
// 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: Result[] = [];
|
||||
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(
|
||||
Result.create({
|
||||
id: `${raceId}-${driverId}`,
|
||||
raceId,
|
||||
driverId,
|
||||
position,
|
||||
startPosition,
|
||||
fastestLap,
|
||||
incidents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
||||
// Group results by driver
|
||||
const resultsByDriver = new Map<string, Result[]>();
|
||||
for (const result of results) {
|
||||
const existing = resultsByDriver.get(result.driverId) || [];
|
||||
existing.push(result);
|
||||
resultsByDriver.set(result.driverId, existing);
|
||||
}
|
||||
|
||||
// Update or create standings for each driver
|
||||
for (const [driverId, driverResults] of resultsByDriver) {
|
||||
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
|
||||
|
||||
if (!standing) {
|
||||
standing = Standing.create({
|
||||
leagueId,
|
||||
driverId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add all results for this driver (should be just one for this race)
|
||||
for (const result of driverResults) {
|
||||
standing = standing.addRaceResult(result.position, {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||
});
|
||||
}
|
||||
|
||||
await this.standingRepository.save(standing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import { Standing } from '../../domain/entities/Standing';
|
||||
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
|
||||
import { RatingUpdateService } from '@gridpilot/identity/domain/services/RatingUpdateService';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Enhanced CompleteRaceUseCase that includes rating updates
|
||||
*/
|
||||
export interface CompleteRaceCommandDTO {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export class CompleteRaceUseCaseWithRatings
|
||||
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRatingProvider: DriverRatingProvider,
|
||||
private readonly ratingUpdateService: RatingUpdateService,
|
||||
) {}
|
||||
|
||||
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
||||
const { raceId } = command;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
// Get registered drivers for this race
|
||||
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||
if (registeredDriverIds.length === 0) {
|
||||
throw new Error('Cannot complete race with no registered drivers');
|
||||
}
|
||||
|
||||
// Get driver ratings
|
||||
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||
|
||||
// Generate realistic race results
|
||||
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||
|
||||
// Save results
|
||||
for (const result of results) {
|
||||
await this.resultRepository.create(result);
|
||||
}
|
||||
|
||||
// Update standings
|
||||
await this.updateStandings(race.leagueId, results);
|
||||
|
||||
// Update driver ratings based on performance
|
||||
await this.updateDriverRatings(results, registeredDriverIds.length);
|
||||
|
||||
// Complete the race
|
||||
const completedRace = race.complete();
|
||||
await this.raceRepository.update(completedRace);
|
||||
}
|
||||
|
||||
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
||||
// Group results by driver
|
||||
const resultsByDriver = new Map<string, Result[]>();
|
||||
for (const result of results) {
|
||||
const existing = resultsByDriver.get(result.driverId) || [];
|
||||
existing.push(result);
|
||||
resultsByDriver.set(result.driverId, existing);
|
||||
}
|
||||
|
||||
// Update or create standings for each driver
|
||||
for (const [driverId, driverResults] of resultsByDriver) {
|
||||
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
|
||||
|
||||
if (!standing) {
|
||||
standing = Standing.create({
|
||||
leagueId,
|
||||
driverId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add all results for this driver (should be just one for this race)
|
||||
for (const result of driverResults) {
|
||||
standing = standing.addRaceResult(result.position, {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||
});
|
||||
}
|
||||
|
||||
await this.standingRepository.save(standing);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
|
||||
const driverResults = results.map(result => ({
|
||||
driverId: result.driverId,
|
||||
position: result.position,
|
||||
totalDrivers,
|
||||
incidents: result.incidents,
|
||||
startPosition: result.startPosition,
|
||||
}));
|
||||
|
||||
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
|
||||
}
|
||||
}
|
||||
138
packages/racing/application/use-cases/QuickPenaltyUseCase.ts
Normal file
138
packages/racing/application/use-cases/QuickPenaltyUseCase.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Use Case: QuickPenaltyUseCase
|
||||
*
|
||||
* Allows league admins to quickly issue common penalties without protest process.
|
||||
* Designed for fast, common penalty scenarios like track limits, warnings, etc.
|
||||
*/
|
||||
|
||||
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
|
||||
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export interface QuickPenaltyCommand {
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
adminId: string;
|
||||
infractionType: 'track_limits' | 'unsafe_rejoin' | 'aggressive_driving' | 'false_start' | 'other';
|
||||
severity: 'warning' | 'minor' | 'major' | 'severe';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class QuickPenaltyUseCase
|
||||
implements AsyncUseCase<QuickPenaltyCommand, { penaltyId: string }> {
|
||||
constructor(
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> {
|
||||
// Validate race exists
|
||||
const race = await this.raceRepository.findById(command.raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
// Validate admin has authority
|
||||
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
|
||||
const adminMembership = memberships.find(
|
||||
m => m.driverId === command.adminId && m.status === 'active'
|
||||
);
|
||||
|
||||
if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) {
|
||||
throw new Error('Only league owners and admins can issue penalties');
|
||||
}
|
||||
|
||||
// Map infraction + severity to penalty type and value
|
||||
const { type, value, reason } = this.mapInfractionToPenalty(
|
||||
command.infractionType,
|
||||
command.severity
|
||||
);
|
||||
|
||||
// Create the penalty
|
||||
const penalty = Penalty.create({
|
||||
id: randomUUID(),
|
||||
leagueId: race.leagueId,
|
||||
raceId: command.raceId,
|
||||
driverId: command.driverId,
|
||||
type,
|
||||
...(value !== undefined ? { value } : {}),
|
||||
reason,
|
||||
issuedBy: command.adminId,
|
||||
status: 'applied', // Quick penalties are applied immediately
|
||||
issuedAt: new Date(),
|
||||
appliedAt: new Date(),
|
||||
...(command.notes !== undefined ? { notes: command.notes } : {}),
|
||||
});
|
||||
|
||||
await this.penaltyRepository.create(penalty);
|
||||
|
||||
return { penaltyId: penalty.id };
|
||||
}
|
||||
|
||||
private mapInfractionToPenalty(
|
||||
infractionType: QuickPenaltyCommand['infractionType'],
|
||||
severity: QuickPenaltyCommand['severity']
|
||||
): { type: PenaltyType; value?: number; reason: string } {
|
||||
const severityMultipliers = {
|
||||
warning: 1,
|
||||
minor: 2,
|
||||
major: 3,
|
||||
severe: 4,
|
||||
};
|
||||
|
||||
const multiplier = severityMultipliers[severity];
|
||||
|
||||
switch (infractionType) {
|
||||
case 'track_limits':
|
||||
if (severity === 'warning') {
|
||||
return { type: 'warning', reason: 'Track limits violation - warning' };
|
||||
}
|
||||
return {
|
||||
type: 'points_deduction',
|
||||
value: multiplier,
|
||||
reason: `Track limits violation - ${multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||
};
|
||||
|
||||
case 'unsafe_rejoin':
|
||||
return {
|
||||
type: 'time_penalty',
|
||||
value: 5 * multiplier,
|
||||
reason: `Unsafe rejoining to track - +${5 * multiplier}s time penalty`
|
||||
};
|
||||
|
||||
case 'aggressive_driving':
|
||||
if (severity === 'warning') {
|
||||
return { type: 'warning', reason: 'Aggressive driving - warning' };
|
||||
}
|
||||
return {
|
||||
type: 'points_deduction',
|
||||
value: 2 * multiplier,
|
||||
reason: `Aggressive driving - ${2 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||
};
|
||||
|
||||
case 'false_start':
|
||||
return {
|
||||
type: 'grid_penalty',
|
||||
value: multiplier,
|
||||
reason: `False start - ${multiplier} grid position${multiplier > 1 ? 's' : ''} penalty`
|
||||
};
|
||||
|
||||
case 'other':
|
||||
if (severity === 'warning') {
|
||||
return { type: 'warning', reason: 'General infraction - warning' };
|
||||
}
|
||||
return {
|
||||
type: 'points_deduction',
|
||||
value: 3 * multiplier,
|
||||
reason: `General infraction - ${3 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown infraction type: ${infractionType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
158
packages/racing/application/use-cases/SendFinalResultsUseCase.ts
Normal file
158
packages/racing/application/use-cases/SendFinalResultsUseCase.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||
|
||||
/**
|
||||
* Use Case: SendFinalResultsUseCase
|
||||
*
|
||||
* Triggered by RaceEventStewardingClosed domain event.
|
||||
* Sends final results modal notifications to all drivers who participated,
|
||||
* including any penalty adjustments applied during stewarding.
|
||||
*/
|
||||
export class SendFinalResultsUseCase implements UseCase<RaceEventStewardingClosedEvent, void, void, void> {
|
||||
constructor(
|
||||
private readonly notificationService: INotificationService,
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
) {}
|
||||
|
||||
async execute(event: RaceEventStewardingClosedEvent): Promise<void> {
|
||||
const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData;
|
||||
|
||||
// Get race event to include context
|
||||
const raceEvent = await this.raceEventRepository.findById(raceEventId);
|
||||
if (!raceEvent) {
|
||||
console.warn(`RaceEvent ${raceEventId} not found, skipping final results notifications`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get final results for the main race session
|
||||
const mainRaceSession = raceEvent.getMainRaceSession();
|
||||
if (!mainRaceSession) {
|
||||
console.warn(`No main race session found for RaceEvent ${raceEventId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.resultRepository.findByRaceId(mainRaceSession.id);
|
||||
|
||||
// Send final results to each participating driver
|
||||
for (const driverId of driverIds) {
|
||||
const driverResult = results.find(r => r.driverId === driverId);
|
||||
|
||||
await this.sendFinalResultsNotification(
|
||||
driverId,
|
||||
raceEvent,
|
||||
driverResult,
|
||||
leagueId,
|
||||
hadPenaltiesApplied
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendFinalResultsNotification(
|
||||
driverId: string,
|
||||
raceEvent: any, // RaceEvent type
|
||||
driverResult: any, // Result type
|
||||
leagueId: string,
|
||||
hadPenaltiesApplied: boolean
|
||||
): Promise<void> {
|
||||
const position = driverResult?.position ?? 'DNF';
|
||||
const positionChange = driverResult?.getPositionChange() ?? 0;
|
||||
const incidents = driverResult?.incidents ?? 0;
|
||||
|
||||
// Calculate final rating change (could include penalty adjustments)
|
||||
const finalRatingChange = this.calculateFinalRatingChange(
|
||||
driverResult?.position,
|
||||
driverResult?.incidents,
|
||||
hadPenaltiesApplied
|
||||
);
|
||||
|
||||
const title = `Final Results: ${raceEvent.name}`;
|
||||
const body = this.buildFinalResultsBody(
|
||||
position,
|
||||
positionChange,
|
||||
incidents,
|
||||
finalRatingChange,
|
||||
hadPenaltiesApplied
|
||||
);
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
recipientId: driverId,
|
||||
type: 'race_final_results' as NotificationType,
|
||||
title,
|
||||
body,
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
data: {
|
||||
raceEventId: raceEvent.id,
|
||||
sessionId: raceEvent.getMainRaceSession()?.id,
|
||||
leagueId,
|
||||
position,
|
||||
positionChange,
|
||||
incidents,
|
||||
finalRatingChange,
|
||||
hadPenaltiesApplied,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
label: 'View Championship Standings',
|
||||
type: 'primary',
|
||||
href: `/leagues/${leagueId}/standings`,
|
||||
},
|
||||
{
|
||||
label: 'Race Details',
|
||||
type: 'secondary',
|
||||
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
|
||||
},
|
||||
],
|
||||
requiresResponse: false, // Can be dismissed, shows final results
|
||||
});
|
||||
}
|
||||
|
||||
private buildFinalResultsBody(
|
||||
position: number | 'DNF',
|
||||
positionChange: number,
|
||||
incidents: number,
|
||||
finalRatingChange: number,
|
||||
hadPenaltiesApplied: boolean
|
||||
): string {
|
||||
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
|
||||
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
|
||||
positionChange < 0 ? `${positionChange}` : '±0';
|
||||
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
|
||||
const ratingText = finalRatingChange >= 0 ?
|
||||
`+${finalRatingChange} rating` :
|
||||
`${finalRatingChange} rating`;
|
||||
const penaltyText = hadPenaltiesApplied ?
|
||||
' (including stewarding adjustments)' : '';
|
||||
|
||||
return `Final result: ${positionText} (${positionChangeText} positions). ${incidentsText} ${ratingText}${penaltyText}.`;
|
||||
}
|
||||
|
||||
private calculateFinalRatingChange(
|
||||
position?: number,
|
||||
incidents?: number,
|
||||
hadPenaltiesApplied?: boolean
|
||||
): number {
|
||||
if (!position) return -10; // DNF penalty
|
||||
|
||||
// Base calculation (same as provisional)
|
||||
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
|
||||
const positionBonus = Math.max(0, (20 - position) * 2);
|
||||
const incidentPenalty = (incidents ?? 0) * -5;
|
||||
|
||||
let finalChange = baseChange + positionBonus + incidentPenalty;
|
||||
|
||||
// Additional penalty adjustments if stewarding applied penalties
|
||||
if (hadPenaltiesApplied) {
|
||||
// In a real implementation, this would check actual penalties applied
|
||||
// For now, we'll assume some penalties might have been applied
|
||||
finalChange = Math.max(finalChange - 5, -20); // Cap penalty at -20
|
||||
}
|
||||
|
||||
return finalChange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||
|
||||
/**
|
||||
* Use Case: SendPerformanceSummaryUseCase
|
||||
*
|
||||
* Triggered by MainRaceCompleted domain event.
|
||||
* Sends immediate performance summary modal notifications to all drivers who participated in the main race.
|
||||
*/
|
||||
export class SendPerformanceSummaryUseCase implements UseCase<MainRaceCompletedEvent, void, void, void> {
|
||||
constructor(
|
||||
private readonly notificationService: INotificationService,
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
) {}
|
||||
|
||||
async execute(event: MainRaceCompletedEvent): Promise<void> {
|
||||
const { raceEventId, sessionId, leagueId, driverIds } = event.eventData;
|
||||
|
||||
// Get race event to include context
|
||||
const raceEvent = await this.raceEventRepository.findById(raceEventId);
|
||||
if (!raceEvent) {
|
||||
console.warn(`RaceEvent ${raceEventId} not found, skipping performance summary notifications`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get results for the main race session to calculate performance data
|
||||
const results = await this.resultRepository.findByRaceId(sessionId);
|
||||
|
||||
// Send performance summary to each participating driver
|
||||
for (const driverId of driverIds) {
|
||||
const driverResult = results.find(r => r.driverId === driverId);
|
||||
|
||||
await this.sendPerformanceSummaryNotification(
|
||||
driverId,
|
||||
raceEvent,
|
||||
driverResult,
|
||||
leagueId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendPerformanceSummaryNotification(
|
||||
driverId: string,
|
||||
raceEvent: any, // RaceEvent type
|
||||
driverResult: any, // Result type
|
||||
leagueId: string
|
||||
): Promise<void> {
|
||||
const position = driverResult?.position ?? 'DNF';
|
||||
const positionChange = driverResult?.getPositionChange() ?? 0;
|
||||
const incidents = driverResult?.incidents ?? 0;
|
||||
|
||||
// Calculate provisional rating change (simplified version)
|
||||
const provisionalRatingChange = this.calculateProvisionalRatingChange(
|
||||
driverResult?.position,
|
||||
driverResult?.incidents
|
||||
);
|
||||
|
||||
const title = `Race Complete: ${raceEvent.name}`;
|
||||
const body = this.buildPerformanceSummaryBody(
|
||||
position,
|
||||
positionChange,
|
||||
incidents,
|
||||
provisionalRatingChange
|
||||
);
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
recipientId: driverId,
|
||||
type: 'race_performance_summary' as NotificationType,
|
||||
title,
|
||||
body,
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
data: {
|
||||
raceEventId: raceEvent.id,
|
||||
sessionId: raceEvent.getMainRaceSession()?.id,
|
||||
leagueId,
|
||||
position,
|
||||
positionChange,
|
||||
incidents,
|
||||
provisionalRatingChange,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
label: 'View Full Results',
|
||||
type: 'primary',
|
||||
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
|
||||
},
|
||||
],
|
||||
requiresResponse: false, // Can be dismissed, but shows performance data
|
||||
});
|
||||
}
|
||||
|
||||
private buildPerformanceSummaryBody(
|
||||
position: number | 'DNF',
|
||||
positionChange: number,
|
||||
incidents: number,
|
||||
provisionalRatingChange: number
|
||||
): string {
|
||||
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
|
||||
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
|
||||
positionChange < 0 ? `${positionChange}` : '±0';
|
||||
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
|
||||
const ratingText = provisionalRatingChange >= 0 ?
|
||||
`+${provisionalRatingChange} rating` :
|
||||
`${provisionalRatingChange} rating`;
|
||||
|
||||
return `You finished ${positionText} (${positionChangeText} positions). ${incidentsText} Provisional ${ratingText}.`;
|
||||
}
|
||||
|
||||
private calculateProvisionalRatingChange(position?: number, incidents?: number): number {
|
||||
if (!position) return -10; // DNF penalty
|
||||
|
||||
// Simplified rating calculation (matches existing GetRaceDetailUseCase logic)
|
||||
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
|
||||
const positionBonus = Math.max(0, (20 - position) * 2);
|
||||
const incidentPenalty = (incidents ?? 0) * -5;
|
||||
|
||||
return baseChange + positionBonus + incidentPenalty;
|
||||
}
|
||||
}
|
||||
130
packages/racing/application/utils/RaceResultGenerator.ts
Normal file
130
packages/racing/application/utils/RaceResultGenerator.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
|
||||
/**
|
||||
* Enhanced race result generator with detailed incident types
|
||||
*/
|
||||
export class RaceResultGenerator {
|
||||
/**
|
||||
* Generate realistic race results with detailed incidents
|
||||
*/
|
||||
static generateRaceResults(
|
||||
raceId: string,
|
||||
driverIds: string[],
|
||||
driverRatings: Map<string, number>
|
||||
): Result[] {
|
||||
// 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: Result[] = [];
|
||||
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 detailed incidents
|
||||
const incidents = this.generateDetailedIncidents(position, driverPerformances.length);
|
||||
|
||||
results.push(
|
||||
Result.create({
|
||||
id: `${raceId}-${driverId}`,
|
||||
raceId,
|
||||
driverId,
|
||||
position,
|
||||
startPosition,
|
||||
fastestLap,
|
||||
incidents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate detailed incidents with specific types
|
||||
*/
|
||||
private static generateDetailedIncidents(position: number, totalDrivers: number): number {
|
||||
// Base probability increases for lower positions (more aggressive driving)
|
||||
const baseProbability = Math.min(0.85, position / totalDrivers + 0.1);
|
||||
|
||||
// Add some randomness
|
||||
const randomFactor = Math.random();
|
||||
|
||||
if (randomFactor > baseProbability) {
|
||||
return 0; // Clean race
|
||||
}
|
||||
|
||||
// Determine incident severity based on position and randomness
|
||||
const severityRoll = Math.random();
|
||||
|
||||
if (severityRoll < 0.4) {
|
||||
// Minor incident (track limits, small contact)
|
||||
return 1;
|
||||
} else if (severityRoll < 0.7) {
|
||||
// Moderate incident (off-track, contact with damage)
|
||||
return 2;
|
||||
} else if (severityRoll < 0.9) {
|
||||
// Major incident (spin, collision)
|
||||
return 3;
|
||||
} else {
|
||||
// Severe incident (multiple cars involved, safety car)
|
||||
return Math.floor(Math.random() * 2) + 3; // 3-4 incidents
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident type description for a given incident count
|
||||
*/
|
||||
static getIncidentDescription(incidents: number): string {
|
||||
switch (incidents) {
|
||||
case 0:
|
||||
return 'Clean race';
|
||||
case 1:
|
||||
return 'Track limits violation';
|
||||
case 2:
|
||||
return 'Contact with another car';
|
||||
case 3:
|
||||
return 'Off-track incident';
|
||||
case 4:
|
||||
return 'Collision requiring safety car';
|
||||
default:
|
||||
return `${incidents} incidents`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate incident penalty points for standings
|
||||
*/
|
||||
static getIncidentPenaltyPoints(incidents: number): number {
|
||||
// Each incident deducts points from championship standings
|
||||
return Math.max(0, incidents - 1) * 2; // First incident free, then 2 points each
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ResultWithIncidents } from '../../domain/entities/ResultWithIncidents';
|
||||
import { RaceIncidents, type IncidentRecord, type IncidentType } from '../../domain/value-objects/RaceIncidents';
|
||||
|
||||
/**
|
||||
* Enhanced race result generator with detailed incident types
|
||||
*/
|
||||
export class RaceResultGeneratorWithIncidents {
|
||||
/**
|
||||
* Generate realistic race results with detailed incidents
|
||||
*/
|
||||
static generateRaceResults(
|
||||
raceId: string,
|
||||
driverIds: string[],
|
||||
driverRatings: Map<string, number>
|
||||
): ResultWithIncidents[] {
|
||||
// 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: ResultWithIncidents[] = [];
|
||||
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 detailed incidents
|
||||
const incidents = this.generateDetailedIncidents(position, driverPerformances.length);
|
||||
|
||||
results.push(
|
||||
ResultWithIncidents.create({
|
||||
id: `${raceId}-${driverId}`,
|
||||
raceId,
|
||||
driverId,
|
||||
position,
|
||||
startPosition,
|
||||
fastestLap,
|
||||
incidents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate detailed incidents with specific types and severity
|
||||
*/
|
||||
private static generateDetailedIncidents(position: number, totalDrivers: number): RaceIncidents {
|
||||
// Base probability increases for lower positions (more aggressive driving)
|
||||
const baseProbability = Math.min(0.85, position / totalDrivers + 0.1);
|
||||
|
||||
// Add some randomness
|
||||
const randomFactor = Math.random();
|
||||
|
||||
if (randomFactor > baseProbability) {
|
||||
// Clean race
|
||||
return new RaceIncidents();
|
||||
}
|
||||
|
||||
// Determine number of incidents based on position and severity
|
||||
const severityRoll = Math.random();
|
||||
let incidentCount: number;
|
||||
|
||||
if (severityRoll < 0.5) {
|
||||
incidentCount = 1; // Minor incident
|
||||
} else if (severityRoll < 0.8) {
|
||||
incidentCount = 2; // Moderate incident
|
||||
} else if (severityRoll < 0.95) {
|
||||
incidentCount = 3; // Major incident
|
||||
} else {
|
||||
incidentCount = Math.floor(Math.random() * 2) + 3; // 3-4 incidents (severe)
|
||||
}
|
||||
|
||||
// Generate specific incidents
|
||||
const incidents: IncidentRecord[] = [];
|
||||
for (let i = 0; i < incidentCount; i++) {
|
||||
const incidentType = this.selectIncidentType(position, totalDrivers, i);
|
||||
const lap = this.selectIncidentLap(i + 1, incidentCount);
|
||||
|
||||
incidents.push({
|
||||
type: incidentType,
|
||||
lap,
|
||||
description: this.generateIncidentDescription(incidentType),
|
||||
penaltyPoints: this.getPenaltyPoints(incidentType),
|
||||
});
|
||||
}
|
||||
|
||||
return new RaceIncidents(incidents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate incident type based on context
|
||||
*/
|
||||
private static selectIncidentType(position: number, totalDrivers: number, incidentIndex: number): IncidentType {
|
||||
// Different incident types have different probabilities
|
||||
const incidentProbabilities: Array<{ type: IncidentType; weight: number }> = [
|
||||
{ type: 'track_limits', weight: 40 }, // Most common
|
||||
{ type: 'contact', weight: 25 }, // Common in traffic
|
||||
{ type: 'unsafe_rejoin', weight: 15 }, // Dangerous
|
||||
{ type: 'aggressive_driving', weight: 10 }, // Less common
|
||||
{ type: 'collision', weight: 5 }, // Rare
|
||||
{ type: 'spin', weight: 4 }, // Rare
|
||||
{ type: 'false_start', weight: 1 }, // Very rare in race
|
||||
];
|
||||
|
||||
// Adjust weights based on position (lower positions more likely to have contact/aggressive driving)
|
||||
if (position > totalDrivers * 0.7) { // Bottom 30%
|
||||
incidentProbabilities.find(p => p.type === 'contact')!.weight += 10;
|
||||
incidentProbabilities.find(p => p.type === 'aggressive_driving')!.weight += 5;
|
||||
}
|
||||
|
||||
// Select based on weights
|
||||
const totalWeight = incidentProbabilities.reduce((sum, p) => sum + p.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const { type, weight } of incidentProbabilities) {
|
||||
random -= weight;
|
||||
if (random <= 0) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return 'track_limits'; // Fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate lap for incident
|
||||
*/
|
||||
private static selectIncidentLap(incidentNumber: number, totalIncidents: number): number {
|
||||
// Spread incidents throughout the race
|
||||
const raceLaps = 20; // Assume 20 lap race
|
||||
const lapRanges = [
|
||||
{ min: 1, max: 5 }, // Early race
|
||||
{ min: 6, max: 12 }, // Mid race
|
||||
{ min: 13, max: 20 }, // Late race
|
||||
];
|
||||
|
||||
// Distribute incidents across race phases
|
||||
const phaseIndex = Math.min(incidentNumber - 1, lapRanges.length - 1);
|
||||
const range = lapRanges[phaseIndex];
|
||||
|
||||
return Math.floor(Math.random() * (range.max - range.min + 1)) + range.min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable description for incident
|
||||
*/
|
||||
private static generateIncidentDescription(type: IncidentType): string {
|
||||
const descriptions: Record<IncidentType, string[]> = {
|
||||
track_limits: [
|
||||
'Went off track at corner exit',
|
||||
'Cut corner to maintain position',
|
||||
'Ran wide under braking',
|
||||
'Off-track excursion gaining advantage',
|
||||
],
|
||||
contact: [
|
||||
'Light contact while defending position',
|
||||
'Side-by-side contact into corner',
|
||||
'Rear-end contact under braking',
|
||||
'Wheel-to-wheel contact',
|
||||
],
|
||||
unsafe_rejoin: [
|
||||
'Unsafe rejoin across track',
|
||||
'Rejoined directly into racing line',
|
||||
'Failed to check mirrors before rejoining',
|
||||
'Forced another driver off track on rejoin',
|
||||
],
|
||||
aggressive_driving: [
|
||||
'Multiple defensive moves under braking',
|
||||
'Moved under braking three times',
|
||||
'Aggressive defending forcing driver wide',
|
||||
'Persistent blocking maneuvers',
|
||||
],
|
||||
collision: [
|
||||
'Collision involving multiple cars',
|
||||
'Major contact causing safety car',
|
||||
'Chain reaction collision',
|
||||
'Heavy impact collision',
|
||||
],
|
||||
spin: [
|
||||
'Lost control and spun',
|
||||
'Oversteer spin into gravel',
|
||||
'Spin following contact',
|
||||
'Lost rear grip and spun',
|
||||
],
|
||||
false_start: [
|
||||
'Jumped start before green flag',
|
||||
'Early launch from grid',
|
||||
'Premature start',
|
||||
],
|
||||
mechanical: [
|
||||
'Engine failure',
|
||||
'Gearbox issue',
|
||||
'Brake failure',
|
||||
'Suspension damage',
|
||||
],
|
||||
other: [
|
||||
'Unspecified incident',
|
||||
'Race incident',
|
||||
'Driving infraction',
|
||||
],
|
||||
};
|
||||
|
||||
const options = descriptions[type] || descriptions.other;
|
||||
return options[Math.floor(Math.random() * options.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get penalty points for incident type
|
||||
*/
|
||||
private static getPenaltyPoints(type: IncidentType): number {
|
||||
const penalties: Record<IncidentType, number> = {
|
||||
track_limits: 0, // Usually warning only
|
||||
contact: 2, // Light penalty
|
||||
unsafe_rejoin: 3, // Moderate penalty
|
||||
aggressive_driving: 2, // Light penalty
|
||||
false_start: 5, // Heavy penalty
|
||||
collision: 5, // Heavy penalty
|
||||
spin: 0, // Usually no penalty if no contact
|
||||
mechanical: 0, // Not driver fault
|
||||
other: 2, // Default penalty
|
||||
};
|
||||
return penalties[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident description for display
|
||||
*/
|
||||
static getIncidentDescription(incidents: RaceIncidents): string {
|
||||
return incidents.getSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate incident penalty points for standings
|
||||
*/
|
||||
static getIncidentPenaltyPoints(incidents: RaceIncidents): number {
|
||||
return incidents.getTotalPenaltyPoints();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user