This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -0,0 +1,142 @@
import type { IDomainService } from '@gridpilot/shared/domain';
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
import { UserRating } from '../value-objects/UserRating';
/**
* Domain Service: RatingUpdateService
*
* Handles updating user ratings based on various events and performance metrics.
* Centralizes rating calculation logic and ensures consistency across the system.
*/
export class RatingUpdateService implements IDomainService {
constructor(
private readonly userRatingRepository: IUserRatingRepository
) {}
/**
* Update driver ratings after race completion
*/
async updateDriverRatingsAfterRace(
driverResults: Array<{
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}>
): Promise<void> {
for (const result of driverResults) {
await this.updateDriverRating(result);
}
}
/**
* Update individual driver rating based on race result
*/
private async updateDriverRating(result: {
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}): Promise<void> {
const { driverId, position, totalDrivers, incidents, startPosition } = result;
// Get or create user rating
let userRating = await this.userRatingRepository.findByUserId(driverId);
if (!userRating) {
userRating = UserRating.create(driverId);
}
// Calculate performance score (0-100)
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
// Calculate fairness score based on incidents (lower incidents = higher fairness)
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
// Update ratings
const updatedRating = userRating
.updateDriverRating(performanceScore)
.updateFairnessScore(fairnessScore);
// Save updated rating
await this.userRatingRepository.save(updatedRating);
}
/**
* Calculate performance score based on finishing position and field strength
*/
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 rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
// Clamp to 0-100 range
return Math.max(0, Math.min(100, rawScore));
}
/**
* Calculate fairness score based on incident involvement
*/
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
const incidentRate = incidents / totalDrivers;
if (incidentRate > 0.5) {
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
}
// 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);
}
}

View File

@@ -0,0 +1,41 @@
import type { NotificationType } from '../../domain/types/NotificationTypes';
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
export interface NotificationData {
raceEventId?: string;
sessionId?: string;
leagueId?: string;
position?: number | 'DNF';
positionChange?: number;
incidents?: number;
provisionalRatingChange?: number;
finalRatingChange?: number;
hadPenaltiesApplied?: boolean;
deadline?: Date;
protestId?: string;
[key: string]: unknown;
}
export interface NotificationAction {
label: string;
type: 'primary' | 'secondary' | 'danger';
href?: string;
actionId?: string;
}
export interface SendNotificationCommand {
recipientId: string;
type: NotificationType;
title: string;
body: string;
channel: NotificationChannel;
urgency: 'silent' | 'toast' | 'modal';
data?: NotificationData;
actionUrl?: string;
actions?: NotificationAction[];
requiresResponse?: boolean;
}
export interface INotificationService {
sendNotification(command: SendNotificationCommand): Promise<void>;
}

View File

@@ -64,6 +64,8 @@ export type NotificationType =
| 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder
| 'race_results_posted' // Race results are available
| 'race_performance_summary' // Immediate performance summary after main race
| 'race_final_results' // Final results after stewarding closes
// League-related
| 'league_invite' // You were invited to a league
| 'league_join_request' // Someone requested to join your league
@@ -102,6 +104,8 @@ export function getNotificationTypeTitle(type: NotificationType): string {
race_registration_open: 'Registration Open',
race_reminder: 'Race Reminder',
race_results_posted: 'Results Posted',
race_performance_summary: 'Performance Summary',
race_final_results: 'Final Results',
league_invite: 'League Invitation',
league_join_request: 'Join Request',
league_join_approved: 'Request Approved',
@@ -139,6 +143,8 @@ export function getNotificationTypePriority(type: NotificationType): number {
race_registration_open: 5,
race_reminder: 8,
race_results_posted: 5,
race_performance_summary: 9, // High priority - immediate race feedback
race_final_results: 7, // Medium-high priority - final standings
league_invite: 6,
league_join_request: 5,
league_join_approved: 7,

View File

@@ -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
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View 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}`);
}
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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
}
}

View File

@@ -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();
}
}

View File

@@ -7,8 +7,7 @@
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type SessionType = 'practice' | 'qualifying' | 'race';
import type { SessionType } from '../value-objects/SessionType';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race implements IEntity<string> {
@@ -80,7 +79,7 @@ export class Race implements IEntity<string> {
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
car: props.car,
...(props.carId !== undefined ? { carId: props.carId } : {}),
sessionType: props.sessionType ?? 'race',
sessionType: props.sessionType ?? SessionType.main(),
status: props.status ?? 'scheduled',
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),

View File

@@ -0,0 +1,283 @@
/**
* Domain Entity: RaceEvent (Aggregate Root)
*
* Represents a race event containing multiple sessions (practice, quali, race).
* Immutable aggregate root with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Session } from './Session';
import type { SessionType } from '../value-objects/SessionType';
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
export class RaceEvent implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly leagueId: string;
readonly name: string;
readonly sessions: readonly Session[];
readonly status: RaceEventStatus;
readonly stewardingClosesAt: Date | undefined;
private constructor(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: readonly Session[];
status: RaceEventStatus;
stewardingClosesAt?: Date;
}) {
this.id = props.id;
this.seasonId = props.seasonId;
this.leagueId = props.leagueId;
this.name = props.name;
this.sessions = props.sessions;
this.status = props.status;
this.stewardingClosesAt = props.stewardingClosesAt;
}
/**
* Factory method to create a new RaceEvent entity
*/
static create(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: Session[];
status?: RaceEventStatus;
stewardingClosesAt?: Date;
}): RaceEvent {
this.validate(props);
return new RaceEvent({
id: props.id,
seasonId: props.seasonId,
leagueId: props.leagueId,
name: props.name,
sessions: [...props.sessions], // Create immutable copy
status: props.status ?? 'scheduled',
...(props.stewardingClosesAt !== undefined ? { stewardingClosesAt: props.stewardingClosesAt } : {}),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
seasonId: string;
leagueId: string;
name: string;
sessions: Session[];
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('RaceEvent ID is required');
}
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new RacingDomainValidationError('Season ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('RaceEvent name is required');
}
if (!props.sessions || props.sessions.length === 0) {
throw new RacingDomainValidationError('RaceEvent must have at least one session');
}
// Validate all sessions belong to this race event
const invalidSessions = props.sessions.filter(s => s.raceEventId !== props.id);
if (invalidSessions.length > 0) {
throw new RacingDomainValidationError('All sessions must belong to this race event');
}
// Validate session types are unique
const sessionTypes = props.sessions.map(s => s.sessionType.value);
const uniqueTypes = new Set(sessionTypes);
if (uniqueTypes.size !== sessionTypes.length) {
throw new RacingDomainValidationError('Session types must be unique within a race event');
}
// Validate at least one main race session exists
const hasMainRace = props.sessions.some(s => s.sessionType.value === 'main');
if (!hasMainRace) {
throw new RacingDomainValidationError('RaceEvent must have at least one main race session');
}
}
/**
* Start the race event (move from scheduled to in_progress)
*/
start(): RaceEvent {
if (this.status !== 'scheduled') {
throw new RacingDomainInvariantError('Only scheduled race events can be started');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
status: 'in_progress',
stewardingClosesAt: this.stewardingClosesAt,
});
}
/**
* Complete the main race session and move to awaiting_stewarding
*/
completeMainRace(): RaceEvent {
if (this.status !== 'in_progress') {
throw new RacingDomainInvariantError('Only in-progress race events can complete main race');
}
const mainRaceSession = this.getMainRaceSession();
if (!mainRaceSession || mainRaceSession.status !== 'completed') {
throw new RacingDomainInvariantError('Main race session must be completed first');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
status: 'awaiting_stewarding',
stewardingClosesAt: this.stewardingClosesAt,
});
}
/**
* Close stewarding and finalize the race event
*/
closeStewarding(): RaceEvent {
if (this.status !== 'awaiting_stewarding') {
throw new RacingDomainInvariantError('Only race events awaiting stewarding can be closed');
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
status: 'closed',
stewardingClosesAt: this.stewardingClosesAt,
});
}
/**
* Cancel the race event
*/
cancel(): RaceEvent {
if (this.status === 'closed') {
throw new RacingDomainInvariantError('Cannot cancel a closed race event');
}
if (this.status === 'cancelled') {
return this;
}
return RaceEvent.create({
id: this.id,
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
status: 'cancelled',
stewardingClosesAt: this.stewardingClosesAt,
});
}
/**
* Get the main race session (the one that counts for championship points)
*/
getMainRaceSession(): Session | undefined {
return this.sessions.find(s => s.sessionType.equals(SessionType.main()));
}
/**
* Get all sessions of a specific type
*/
getSessionsByType(sessionType: SessionType): Session[] {
return this.sessions.filter(s => s.sessionType.equals(sessionType));
}
/**
* Get all completed sessions
*/
getCompletedSessions(): Session[] {
return this.sessions.filter(s => s.status === 'completed');
}
/**
* Check if all sessions are completed
*/
areAllSessionsCompleted(): boolean {
return this.sessions.every(s => s.status === 'completed');
}
/**
* Check if the main race is completed
*/
isMainRaceCompleted(): boolean {
const mainRace = this.getMainRaceSession();
return mainRace?.status === 'completed' ?? false;
}
/**
* Check if stewarding window has expired
*/
hasStewardingExpired(): boolean {
if (!this.stewardingClosesAt) return false;
return new Date() > this.stewardingClosesAt;
}
/**
* Check if race event is in the past
*/
isPast(): boolean {
const latestSession = this.sessions.reduce((latest, session) =>
session.scheduledAt > latest.scheduledAt ? session : latest
);
return latestSession.scheduledAt < new Date();
}
/**
* Check if race event is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
/**
* Check if race event is currently running
*/
isLive(): boolean {
return this.status === 'in_progress';
}
/**
* Check if race event is awaiting stewarding decisions
*/
isAwaitingStewarding(): boolean {
return this.status === 'awaiting_stewarding';
}
/**
* Check if race event is closed (stewarding complete)
*/
isClosed(): boolean {
return this.status === 'closed';
}
}

View File

@@ -0,0 +1,175 @@
/**
* Enhanced Result entity with detailed incident tracking
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
export class ResultWithIncidents implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly position: number;
readonly fastestLap: number;
readonly incidents: RaceIncidents;
readonly startPosition: number;
private constructor(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.position = props.position;
this.fastestLap = props.fastestLap;
this.incidents = props.incidents;
this.startPosition = props.startPosition;
}
/**
* Factory method to create a new Result entity
*/
static create(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}): ResultWithIncidents {
ResultWithIncidents.validate(props);
return new ResultWithIncidents(props);
}
/**
* Create from legacy Result data (with incidents as number)
*/
static fromLegacy(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): ResultWithIncidents {
const raceIncidents = RaceIncidents.fromLegacyIncidentsCount(props.incidents);
return ResultWithIncidents.create({
...props,
incidents: raceIncidents,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: RaceIncidents;
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new RacingDomainValidationError('Position must be a positive integer');
}
if (props.fastestLap < 0) {
throw new RacingDomainValidationError('Fastest lap cannot be negative');
}
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
throw new RacingDomainValidationError('Start position must be a positive integer');
}
}
/**
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition - this.position;
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position <= 3;
}
/**
* Check if driver had a clean race (no incidents)
*/
isClean(): boolean {
return this.incidents.isClean();
}
/**
* Get total incident count (for backward compatibility)
*/
getTotalIncidents(): number {
return this.incidents.getTotalCount();
}
/**
* Get incident severity score
*/
getIncidentSeverityScore(): number {
return this.incidents.getSeverityScore();
}
/**
* Get human-readable incident summary
*/
getIncidentSummary(): string {
return this.incidents.getSummary();
}
/**
* Add an incident to this result
*/
addIncident(incident: IncidentRecord): ResultWithIncidents {
const updatedIncidents = this.incidents.addIncident(incident);
return new ResultWithIncidents({
...this,
incidents: updatedIncidents,
});
}
/**
* Convert to legacy format (for backward compatibility)
*/
toLegacyFormat() {
return {
id: this.id,
raceId: this.raceId,
driverId: this.driverId,
position: this.position,
fastestLap: this.fastestLap,
incidents: this.getTotalIncidents(),
startPosition: this.startPosition,
};
}
}

View File

@@ -0,0 +1,311 @@
/**
* Domain Entity: Session
*
* Represents a racing session within a race event.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { SessionType } from '../value-objects/SessionType';
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Session implements IEntity<string> {
readonly id: string;
readonly raceEventId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly trackId: string | undefined;
readonly car: string;
readonly carId: string | undefined;
readonly sessionType: SessionType;
readonly status: SessionStatus;
readonly strengthOfField: number | undefined;
readonly registeredCount: number | undefined;
readonly maxParticipants: number | undefined;
private constructor(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: SessionType;
status: SessionStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}) {
this.id = props.id;
this.raceEventId = props.raceEventId;
this.scheduledAt = props.scheduledAt;
this.track = props.track;
this.trackId = props.trackId;
this.car = props.car;
this.carId = props.carId;
this.sessionType = props.sessionType;
this.status = props.status;
this.strengthOfField = props.strengthOfField;
this.registeredCount = props.registeredCount;
this.maxParticipants = props.maxParticipants;
}
/**
* Factory method to create a new Session entity
*/
static create(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: SessionType;
status?: SessionStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}): Session {
this.validate(props);
return new Session({
id: props.id,
raceEventId: props.raceEventId,
scheduledAt: props.scheduledAt,
track: props.track,
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
car: props.car,
...(props.carId !== undefined ? { carId: props.carId } : {}),
sessionType: props.sessionType,
status: props.status ?? 'scheduled',
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceEventId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType: SessionType;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Session ID is required');
}
if (!props.raceEventId || props.raceEventId.trim().length === 0) {
throw new RacingDomainValidationError('Race Event ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
throw new RacingDomainValidationError('Valid scheduled date is required');
}
if (!props.track || props.track.trim().length === 0) {
throw new RacingDomainValidationError('Track is required');
}
if (!props.car || props.car.trim().length === 0) {
throw new RacingDomainValidationError('Car is required');
}
if (!props.sessionType) {
throw new RacingDomainValidationError('Session type is required');
}
}
/**
* Start the session (move from scheduled to running)
*/
start(): Session {
if (this.status !== 'scheduled') {
throw new RacingDomainInvariantError('Only scheduled sessions can be started');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'running' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Mark session as completed
*/
complete(): Session {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Session is already completed');
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Cannot complete a cancelled session');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'completed' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Cancel the session
*/
cancel(): Session {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Cannot cancel a completed session');
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Session is already cancelled');
}
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'cancelled' as SessionStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Session.create(props);
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Session {
const base = {
id: this.id,
raceEventId: this.raceEventId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: this.status,
strengthOfField,
registeredCount,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const props =
this.maxParticipants !== undefined
? { ...withCarId, maxParticipants: this.maxParticipants }
: withCarId;
return Session.create(props);
}
/**
* Check if session is in the past
*/
isPast(): boolean {
return this.scheduledAt < new Date();
}
/**
* Check if session is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
/**
* Check if session is live/running
*/
isLive(): boolean {
return this.status === 'running';
}
/**
* Check if this session counts for championship points
*/
countsForPoints(): boolean {
return this.sessionType.countsForPoints();
}
/**
* Check if this session determines grid positions
*/
determinesGrid(): boolean {
return this.sessionType.determinesGrid();
}
}

View File

@@ -0,0 +1,29 @@
import type { IDomainEvent } from '@gridpilot/shared/domain';
/**
* Domain Event: MainRaceCompleted
*
* Fired when the main race session of a race event is completed.
* This triggers immediate performance summary notifications to drivers.
*/
export interface MainRaceCompletedEventData {
raceEventId: string;
sessionId: string;
leagueId: string;
seasonId: string;
completedAt: Date;
driverIds: string[]; // Drivers who participated in the main race
}
export class MainRaceCompletedEvent implements IDomainEvent<MainRaceCompletedEventData> {
readonly eventType = 'MainRaceCompleted';
readonly aggregateId: string;
readonly eventData: MainRaceCompletedEventData;
readonly occurredAt: Date;
constructor(data: MainRaceCompletedEventData) {
this.aggregateId = data.raceEventId;
this.eventData = { ...data };
this.occurredAt = new Date();
}
}

View File

@@ -0,0 +1,29 @@
import type { IDomainEvent } from '@gridpilot/shared/domain';
/**
* Domain Event: RaceEventStewardingClosed
*
* Fired when the stewarding window closes for a race event.
* This triggers final results notifications to drivers with any penalty adjustments.
*/
export interface RaceEventStewardingClosedEventData {
raceEventId: string;
leagueId: string;
seasonId: string;
closedAt: Date;
driverIds: string[]; // Drivers who participated in the race event
hadPenaltiesApplied: boolean; // Whether any penalties were applied during stewarding
}
export class RaceEventStewardingClosedEvent implements IDomainEvent<RaceEventStewardingClosedEventData> {
readonly eventType = 'RaceEventStewardingClosed';
readonly aggregateId: string;
readonly eventData: RaceEventStewardingClosedEventData;
readonly occurredAt: Date;
constructor(data: RaceEventStewardingClosedEventData) {
this.aggregateId = data.raceEventId;
this.eventData = { ...data };
this.occurredAt = new Date();
}
}

View File

@@ -0,0 +1,14 @@
import type { RaceEvent } from '../entities/RaceEvent';
export interface IRaceEventRepository {
findById(id: string): Promise<RaceEvent | null>;
findAll(): Promise<RaceEvent[]>;
findBySeasonId(seasonId: string): Promise<RaceEvent[]>;
findByLeagueId(leagueId: string): Promise<RaceEvent[]>;
findByStatus(status: string): Promise<RaceEvent[]>;
findAwaitingStewardingClose(): Promise<RaceEvent[]>;
create(raceEvent: RaceEvent): Promise<RaceEvent>;
update(raceEvent: RaceEvent): Promise<RaceEvent>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,13 @@
import type { Session } from '../entities/Session';
export interface ISessionRepository {
findById(id: string): Promise<Session | null>;
findAll(): Promise<Session[]>;
findByRaceEventId(raceEventId: string): Promise<Session[]>;
findByLeagueId(leagueId: string): Promise<Session[]>;
findByStatus(status: string): Promise<Session[]>;
create(session: Session): Promise<Session>;
update(session: Session): Promise<Session>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,239 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Incident types that can occur during a race
*/
export type IncidentType =
| 'track_limits' // Driver went off track and gained advantage
| 'contact' // Physical contact with another car
| 'unsafe_rejoin' // Unsafe rejoining of the track
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
| 'false_start' // Started before green flag
| 'collision' // Major collision involving multiple cars
| 'spin' // Driver spun out
| 'mechanical' // Mechanical failure (not driver error)
| 'other'; // Other incident types
/**
* Individual incident record
*/
export interface IncidentRecord {
type: IncidentType;
lap: number;
description?: string;
penaltyPoints?: number; // Points deducted for this incident
}
/**
* Value Object: RaceIncidents
*
* Encapsulates all incidents that occurred during a driver's race.
* Provides methods to calculate total penalty points and incident severity.
*/
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
private readonly incidents: IncidentRecord[];
constructor(incidents: IncidentRecord[] = []) {
this.incidents = [...incidents];
}
get props(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Add a new incident
*/
addIncident(incident: IncidentRecord): RaceIncidents {
return new RaceIncidents([...this.incidents, incident]);
}
/**
* Get all incidents
*/
getAllIncidents(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Get total number of incidents
*/
getTotalCount(): number {
return this.incidents.length;
}
/**
* Get total penalty points from all incidents
*/
getTotalPenaltyPoints(): number {
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
}
/**
* Get incidents by type
*/
getIncidentsByType(type: IncidentType): IncidentRecord[] {
return this.incidents.filter(incident => incident.type === type);
}
/**
* Check if driver had any incidents
*/
hasIncidents(): boolean {
return this.incidents.length > 0;
}
/**
* Check if driver had a clean race (no incidents)
*/
isClean(): boolean {
return this.incidents.length === 0;
}
/**
* Get incident severity score (0-100, higher = more severe)
*/
getSeverityScore(): number {
if (this.incidents.length === 0) return 0;
const severityWeights: Record<IncidentType, number> = {
track_limits: 10,
contact: 20,
unsafe_rejoin: 25,
aggressive_driving: 15,
false_start: 30,
collision: 40,
spin: 35,
mechanical: 5, // Lower weight as it's not driver error
other: 15,
};
const totalSeverity = this.incidents.reduce((total, incident) => {
return total + severityWeights[incident.type];
}, 0);
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
return Math.min(100, totalSeverity);
}
/**
* Get human-readable incident summary
*/
getSummary(): string {
if (this.incidents.length === 0) {
return 'Clean race';
}
const typeCounts = this.incidents.reduce((counts, incident) => {
counts[incident.type] = (counts[incident.type] || 0) + 1;
return counts;
}, {} as Record<IncidentType, number>);
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
});
return summaryParts.join(', ');
}
/**
* Get human-readable label for incident type
*/
private getIncidentTypeLabel(type: IncidentType): string {
const labels: Record<IncidentType, string> = {
track_limits: 'Track Limits',
contact: 'Contact',
unsafe_rejoin: 'Unsafe Rejoin',
aggressive_driving: 'Aggressive Driving',
false_start: 'False Start',
collision: 'Collision',
spin: 'Spin',
mechanical: 'Mechanical',
other: 'Other',
};
return labels[type];
}
equals(other: IValueObject<IncidentRecord[]>): boolean {
const otherIncidents = other.props;
if (this.incidents.length !== otherIncidents.length) {
return false;
}
// Sort both arrays and compare
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
return sortedThis.every((incident, index) => {
const otherIncident = sortedOther[index];
return incident.type === otherIncident.type &&
incident.lap === otherIncident.lap &&
incident.description === otherIncident.description &&
incident.penaltyPoints === otherIncident.penaltyPoints;
});
}
/**
* Create RaceIncidents from legacy incidents count
*/
static fromLegacyIncidentsCount(count: number): RaceIncidents {
if (count === 0) {
return new RaceIncidents();
}
// Distribute legacy incidents across different types based on probability
const incidents: IncidentRecord[] = [];
for (let i = 0; i < count; i++) {
const type = RaceIncidents.getRandomIncidentType();
incidents.push({
type,
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
});
}
return new RaceIncidents(incidents);
}
/**
* Get random incident type for legacy data conversion
*/
private static getRandomIncidentType(): IncidentType {
const types: IncidentType[] = [
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
'collision', 'spin', 'other'
];
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
const random = Math.random();
let cumulativeWeight = 0;
for (let i = 0; i < types.length; i++) {
cumulativeWeight += weights[i];
if (random <= cumulativeWeight) {
return types[i];
}
}
return 'other';
}
/**
* Get default penalty points for incident type
*/
private static getDefaultPenaltyPoints(type: IncidentType): number {
const penalties: Record<IncidentType, number> = {
track_limits: 0, // Usually just a warning
contact: 2,
unsafe_rejoin: 3,
aggressive_driving: 2,
false_start: 5,
collision: 5,
spin: 0, // Usually no penalty if no contact
mechanical: 0,
other: 2,
};
return penalties[type];
}
}

View File

@@ -0,0 +1,103 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SessionType
*
* Represents the type of racing session within a race event.
* Immutable value object with domain validation.
*/
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
export class SessionType implements IValueObject<SessionTypeValue> {
readonly value: SessionTypeValue;
constructor(value: SessionTypeValue) {
if (!value || !this.isValidSessionType(value)) {
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
}
this.value = value;
}
private isValidSessionType(value: string): value is SessionTypeValue {
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
return validTypes.includes(value as SessionTypeValue);
}
get props(): SessionTypeValue {
return this.value;
}
equals(other: IValueObject<SessionTypeValue>): boolean {
return this.value === other.props;
}
/**
* Check if this session type counts for championship points
*/
countsForPoints(): boolean {
return this.value === 'main' || this.value === 'sprint';
}
/**
* Check if this session type determines grid positions
*/
determinesGrid(): boolean {
return this.value === 'qualifying' || this.value.startsWith('q');
}
/**
* Get human-readable display name
*/
getDisplayName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'Practice',
qualifying: 'Qualifying',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'Sprint Race',
main: 'Main Race',
timeTrial: 'Time Trial',
};
return names[this.value];
}
/**
* Get short display name for UI
*/
getShortName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'P',
qualifying: 'Q',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'SPR',
main: 'RACE',
timeTrial: 'TT',
};
return names[this.value];
}
// Static factory methods for common types
static practice(): SessionType {
return new SessionType('practice');
}
static qualifying(): SessionType {
return new SessionType('qualifying');
}
static sprint(): SessionType {
return new SessionType('sprint');
}
static main(): SessionType {
return new SessionType('main');
}
static timeTrial(): SessionType {
return new SessionType('timeTrial');
}
}

View File

@@ -0,0 +1,72 @@
/**
* In-memory implementation of IRaceEventRepository for development/testing.
*/
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { RaceEvent } from '../../domain/entities/RaceEvent';
export class InMemoryRaceEventRepository implements IRaceEventRepository {
private raceEvents: Map<string, RaceEvent> = new Map();
async findById(id: string): Promise<RaceEvent | null> {
return this.raceEvents.get(id) ?? null;
}
async findAll(): Promise<RaceEvent[]> {
return Array.from(this.raceEvents.values());
}
async findBySeasonId(seasonId: string): Promise<RaceEvent[]> {
return Array.from(this.raceEvents.values()).filter(
raceEvent => raceEvent.seasonId === seasonId
);
}
async findByLeagueId(leagueId: string): Promise<RaceEvent[]> {
return Array.from(this.raceEvents.values()).filter(
raceEvent => raceEvent.leagueId === leagueId
);
}
async findByStatus(status: string): Promise<RaceEvent[]> {
return Array.from(this.raceEvents.values()).filter(
raceEvent => raceEvent.status === status
);
}
async findAwaitingStewardingClose(): Promise<RaceEvent[]> {
const now = new Date();
return Array.from(this.raceEvents.values()).filter(
raceEvent =>
raceEvent.status === 'awaiting_stewarding' &&
raceEvent.stewardingClosesAt &&
raceEvent.stewardingClosesAt <= now
);
}
async create(raceEvent: RaceEvent): Promise<RaceEvent> {
this.raceEvents.set(raceEvent.id, raceEvent);
return raceEvent;
}
async update(raceEvent: RaceEvent): Promise<RaceEvent> {
this.raceEvents.set(raceEvent.id, raceEvent);
return raceEvent;
}
async delete(id: string): Promise<void> {
this.raceEvents.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.raceEvents.has(id);
}
// Test helper methods
clear(): void {
this.raceEvents.clear();
}
getAll(): RaceEvent[] {
return Array.from(this.raceEvents.values());
}
}

View File

@@ -0,0 +1,62 @@
/**
* In-memory implementation of ISessionRepository for development/testing.
*/
import type { ISessionRepository } from '../../domain/repositories/ISessionRepository';
import type { Session } from '../../domain/entities/Session';
export class InMemorySessionRepository implements ISessionRepository {
private sessions: Map<string, Session> = new Map();
async findById(id: string): Promise<Session | null> {
return this.sessions.get(id) ?? null;
}
async findAll(): Promise<Session[]> {
return Array.from(this.sessions.values());
}
async findByRaceEventId(raceEventId: string): Promise<Session[]> {
return Array.from(this.sessions.values()).filter(
session => session.raceEventId === raceEventId
);
}
async findByLeagueId(leagueId: string): Promise<Session[]> {
// Sessions don't have leagueId directly - would need to join with RaceEvent
// For now, return empty array
return [];
}
async findByStatus(status: string): Promise<Session[]> {
return Array.from(this.sessions.values()).filter(
session => session.status === status
);
}
async create(session: Session): Promise<Session> {
this.sessions.set(session.id, session);
return session;
}
async update(session: Session): Promise<Session> {
this.sessions.set(session.id, session);
return session;
}
async delete(id: string): Promise<void> {
this.sessions.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.sessions.has(id);
}
// Test helper methods
clear(): void {
this.sessions.clear();
}
getAll(): Session[] {
return Array.from(this.sessions.values());
}
}

View File

@@ -0,0 +1,10 @@
export interface IDomainEvent<T = any> {
readonly eventType: string;
readonly aggregateId: string;
readonly eventData: T;
readonly occurredAt: Date;
}
export interface IDomainEventPublisher {
publish(event: IDomainEvent): Promise<void>;
}

View File

@@ -96,7 +96,8 @@ export function createLeagues(ownerIds: string[]): League[] {
for (let i = 0; i < leagueCount; i++) {
const id = `league-${i + 1}`;
const name = leagueNames[i] ?? faker.company.name();
const ownerId = pickOne(ownerIds);
// Ensure league-5 (demo league with running race) is owned by driver-1
const ownerId = i === 4 ? 'driver-1' : pickOne(ownerIds);
const maxDriversOptions = [24, 32, 48, 64];
let settings = {
@@ -209,6 +210,7 @@ export function createMemberships(
teamsByLeague.set(team.primaryLeagueId, list);
});
drivers.forEach((driver) => {
// Each driver participates in 13 leagues
const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) });
@@ -264,10 +266,24 @@ export function createRaces(leagues: League[]): Race[] {
for (let i = 0; i < raceCount; i++) {
const id = `race-${i + 1}`;
const league = pickOne(leagues);
let league = pickOne(leagues);
const offsetDays = faker.number.int({ min: -30, max: 45 });
const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000);
const status = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
let status: 'scheduled' | 'completed' | 'running' = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
let strengthOfField: number | undefined;
// Special case: Make race-1 a running race in league-5 (user's admin league)
if (i === 0) {
const league5 = leagues.find(l => l.id === 'league-5');
if (league5) {
league = league5;
status = 'running';
// Calculate SOF for the running race (simulate 12-20 drivers with average rating ~1500)
const participantCount = faker.number.int({ min: 12, max: 20 });
const averageRating = 1500 + faker.number.int({ min: -200, max: 300 });
strengthOfField = Math.round(averageRating);
}
}
races.push(
Race.create({
@@ -278,6 +294,8 @@ export function createRaces(leagues: League[]): Race[] {
car: faker.helpers.arrayElement(cars),
sessionType: 'race',
status,
...(strengthOfField !== undefined ? { strengthOfField } : {}),
...(status === 'running' ? { registeredCount: faker.number.int({ min: 12, max: 20 }) } : {}),
}),
);
}