import type { Driver } from '@core/racing/domain/entities/Driver'; import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; import { Protest } from '@core/racing/domain/entities/Protest'; import type { Race } from '@core/racing/domain/entities/Race'; import { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; import { faker } from '@faker-js/faker'; import { seedId } from './SeedIdHelper'; type StewardingSeed = { protests: Protest[]; penalties: Penalty[]; }; export class RacingStewardingFactory { constructor( private readonly baseDate: Date, private readonly persistence: 'postgres' | 'inmemory' = 'inmemory', ) {} create(races: Race[], drivers: Driver[], leagueMemberships: LeagueMembership[]): StewardingSeed { const protests: Protest[] = []; const penalties: Penalty[] = []; const driversById = new Map(drivers.map((d) => [d.id.toString(), d])); const activeMembersByLeague = new Map(); for (const membership of leagueMemberships) { if (membership.status.toString() !== 'active') continue; const leagueId = membership.leagueId.toString(); const driverId = membership.driverId.toString(); if (!driversById.has(driverId)) continue; const list = activeMembersByLeague.get(leagueId) ?? []; list.push(driverId); activeMembersByLeague.set(leagueId, list); } const racesByLeague = new Map(); for (const race of races) { const leagueId = race.leagueId.toString(); const list = racesByLeague.get(leagueId) ?? []; list.push(race); racesByLeague.set(leagueId, list); } // Make league-5 the "busy demo league" for stewarding UIs: // - at least one pending/under_review protest // - some resolved protests // - penalties with and without protest linkage const demoLeagueRaces = (racesByLeague.get('league-5') ?? []).slice().sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); const demoMembers = activeMembersByLeague.get('league-5') ?? []; if (demoLeagueRaces.length > 0 && demoMembers.length >= 4) { const [firstRace, secondRace, thirdRace] = demoLeagueRaces; const protester = demoMembers[0]!; const accused = demoMembers[1]!; const steward = demoMembers[2]!; const spare = demoMembers[3]!; if (firstRace) { protests.push( Protest.create({ id: seedId('protest-1', this.persistence), raceId: firstRace.id.toString(), protestingDriverId: protester, accusedDriverId: accused, incident: { lap: 1, description: 'Avoidable contact into Turn 1 causing a spin and damage.', timeInRace: 85, }, comment: 'I left a car width but got punted. Please review the onboard and external.', proofVideoUrl: 'https://example.com/videos/protest-1', status: 'pending', filedAt: faker.date.recent({ days: 6, refDate: this.baseDate }), }), ); // No penalty yet (pending), but seed a direct steward warning on same race. penalties.push( Penalty.create({ id: seedId('penalty-1', this.persistence), leagueId: 'league-5', raceId: firstRace.id.toString(), driverId: accused, type: 'warning', reason: 'Repeated track limits warnings (race director note).', issuedBy: steward, status: 'applied', issuedAt: faker.date.recent({ days: 5, refDate: this.baseDate }), appliedAt: faker.date.recent({ days: 5, refDate: this.baseDate }), notes: 'Keep it within track limits; further offenses will escalate.', }), ); } if (secondRace) { protests.push( Protest.create({ id: seedId('protest-2', this.persistence), raceId: secondRace.id.toString(), protestingDriverId: spare, accusedDriverId: accused, incident: { lap: 7, description: 'Unsafe rejoin after off-track caused a collision with following traffic.', timeInRace: 610, }, proofVideoUrl: 'https://example.com/videos/protest-2', status: 'under_review', reviewedBy: steward, filedAt: faker.date.recent({ days: 12, refDate: this.baseDate }), }), ); // Under review penalty still pending (linked to protest) penalties.push( Penalty.create({ id: seedId('penalty-2', this.persistence), leagueId: 'league-5', raceId: secondRace.id.toString(), driverId: accused, type: 'time_penalty', value: 10, reason: 'Unsafe rejoin (protest pending review)', protestId: seedId('protest-2', this.persistence), issuedBy: steward, status: 'pending', issuedAt: faker.date.recent({ days: 10, refDate: this.baseDate }), notes: 'Will be applied to results if upheld.', }), ); } if (thirdRace) { const upheld = Protest.create({ id: seedId('protest-3', this.persistence), raceId: thirdRace.id.toString(), protestingDriverId: protester, accusedDriverId: spare, incident: { lap: 12, description: 'Brake check on the straight leading to rear-end contact.', timeInRace: 1280, }, status: 'upheld', reviewedBy: steward, decisionNotes: 'Brake check is not acceptable. Penalty applied.', reviewedAt: faker.date.recent({ days: 20, refDate: this.baseDate }), filedAt: faker.date.recent({ days: 25, refDate: this.baseDate }), }); protests.push(upheld); penalties.push( Penalty.create({ id: seedId('penalty-3', this.persistence), leagueId: 'league-5', raceId: thirdRace.id.toString(), driverId: spare, type: 'points_deduction', value: 5, reason: upheld.incident.description.toString(), protestId: upheld.id, issuedBy: steward, status: 'applied', issuedAt: faker.date.recent({ days: 20, refDate: this.baseDate }), appliedAt: faker.date.recent({ days: 19, refDate: this.baseDate }), notes: 'Applied after stewarding decision.', }), ); const dismissed = Protest.create({ id: seedId('protest-4', this.persistence), raceId: thirdRace.id.toString(), protestingDriverId: accused, accusedDriverId: protester, incident: { lap: 3, description: 'Minor side-by-side contact with no loss of control.', timeInRace: 240, }, status: 'dismissed', reviewedBy: steward, decisionNotes: 'Racing incident, no further action.', reviewedAt: faker.date.recent({ days: 18, refDate: this.baseDate }), filedAt: faker.date.recent({ days: 22, refDate: this.baseDate }), }); protests.push(dismissed); } } // Fill other leagues lightly with variety (including awaiting_defense and withdrawn). for (const [leagueId, leagueRaces] of racesByLeague.entries()) { if (leagueId === 'league-5') continue; const members = activeMembersByLeague.get(leagueId) ?? []; if (members.length < 3) continue; const completedRaces = leagueRaces.filter((r) => r.status.toString() === 'completed').slice(0, 2); if (completedRaces.length === 0) continue; const [race] = completedRaces; if (!race) continue; const a = members[0]!; const b = members[1]!; const steward = members[2]!; const seedOne = faker.number.int({ min: 0, max: 2 }) === 0; if (!seedOne) continue; const status = faker.helpers.arrayElement(['awaiting_defense', 'withdrawn'] as const); const protest = Protest.create({ id: seedId(`protest-${leagueId}-${race.id.toString()}`, this.persistence), raceId: race.id.toString(), protestingDriverId: a, accusedDriverId: b, incident: { lap: faker.number.int({ min: 1, max: 20 }), description: faker.helpers.arrayElement([ 'Divebomb attempt from too far back caused avoidable contact.', 'Blocking under braking and changing line multiple times.', 'Track limits abuse provided a sustained advantage.', 'Blue flag was ignored causing unnecessary time loss.', ]), }, status, ...(status === 'awaiting_defense' ? { defenseRequestedBy: steward, defenseRequestedAt: faker.date.recent({ days: 3, refDate: this.baseDate }), } : {}), filedAt: faker.date.recent({ days: 14, refDate: this.baseDate }), }); protests.push(protest); if (status === 'withdrawn') { // A non-protest-linked penalty can still exist for the same race. penalties.push( Penalty.create({ id: seedId(`penalty-${leagueId}-${race.id.toString()}`, this.persistence), leagueId, raceId: race.id.toString(), driverId: b, type: faker.helpers.arrayElement(['grid_penalty', 'license_points'] as const), value: faker.number.int({ min: 2, max: 5 }), reason: 'Steward discretion: repeated behavior across sessions.', issuedBy: steward, status: 'applied', issuedAt: faker.date.recent({ days: 9, refDate: this.baseDate }), appliedAt: faker.date.recent({ days: 9, refDate: this.baseDate }), }), ); } } // Add comprehensive penalty coverage for all types and statuses // This ensures we have examples of every penalty type in every status const penaltyTypes = ['time_penalty', 'grid_penalty', 'points_deduction', 'disqualification', 'warning', 'license_points', 'probation', 'fine', 'race_ban'] as const; const penaltyStatuses = ['pending', 'applied', 'appealed', 'overturned'] as const; // Get some races and members for penalty seeding const allLeagueIds = Array.from(racesByLeague.keys()); for (const leagueId of allLeagueIds) { const members = activeMembersByLeague.get(leagueId) ?? []; if (members.length < 2) continue; const leagueRaces = racesByLeague.get(leagueId) ?? []; const completedRaces = leagueRaces.filter((r) => r.status.toString() === 'completed'); if (completedRaces.length === 0) continue; const steward = members[0]!; const targetDriver = members[1]!; // Create one penalty for each type/status combination let penaltyIndex = 0; for (const type of penaltyTypes) { for (const status of penaltyStatuses) { // Skip some combinations to avoid too many records if (faker.number.int({ min: 0, max: 2 }) > 0) continue; const race = faker.helpers.arrayElement(completedRaces); const value = type === 'time_penalty' ? faker.number.int({ min: 5, max: 30 }) : type === 'grid_penalty' ? faker.number.int({ min: 1, max: 5 }) : type === 'points_deduction' ? faker.number.int({ min: 2, max: 10 }) : type === 'license_points' ? faker.number.int({ min: 1, max: 4 }) : type === 'fine' ? faker.number.int({ min: 50, max: 500 }) : type === 'race_ban' ? faker.number.int({ min: 1, max: 3 }) : type === 'warning' ? 1 : 1; // disqualification, probation have no value const penaltyData: { id: string; leagueId: string; raceId: string; driverId: string; type: typeof penaltyTypes[number]; value?: number; reason: string; issuedBy: string; status: typeof penaltyStatuses[number]; issuedAt: Date; appliedAt?: Date; notes?: string; } = { id: seedId(`penalty-${leagueId}-${type}-${status}-${penaltyIndex}`, this.persistence), leagueId, raceId: race.id.toString(), driverId: targetDriver, type, reason: this.getPenaltyReason(type), issuedBy: steward, status, issuedAt: faker.date.recent({ days: faker.number.int({ min: 1, max: 30 }), refDate: this.baseDate }), }; // Add value only for types that require it if (type !== 'warning') { penaltyData.value = value; } if (status === 'applied') { penaltyData.appliedAt = faker.date.recent({ days: faker.number.int({ min: 1, max: 20 }), refDate: this.baseDate }); } if (type === 'race_ban') { penaltyData.notes = 'Multiple serious violations'; } penalties.push(Penalty.create(penaltyData)); penaltyIndex++; } } } return { protests, penalties }; } private getPenaltyReason(type: string): string { const reasons = { time_penalty: ['Avoidable contact', 'Track limits abuse', 'Unsafe rejoin', 'Blocking'], grid_penalty: ['Qualifying infringement', 'Parc fermé violation', 'Practice session breach'], points_deduction: ['Serious breach of rules', 'Multiple incidents', 'Unsportsmanlike conduct'], disqualification: ['Severe dangerous driving', 'Gross misconduct', 'Multiple serious violations'], warning: ['Track limits reminder', 'Minor contact', 'Procedure reminder'], license_points: ['General misconduct', 'Minor incidents', 'Warning escalation'], probation: ['Pattern of minor violations', 'Behavioral concerns', 'Conditional status'], fine: ['Financial penalty for rule breach', 'Administrative violation', 'Late entry fee'], race_ban: ['Multiple race bans', 'Severe dangerous driving', 'Gross misconduct'], }; return faker.helpers.arrayElement(reasons[type as keyof typeof reasons] || ['Rule violation']); } }