359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
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<string, Driver>(drivers.map((d) => [d.id.toString(), d]));
|
|
const activeMembersByLeague = new Map<string, string[]>();
|
|
|
|
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<string, Race[]>();
|
|
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']);
|
|
}
|
|
} |