260 lines
9.5 KiB
TypeScript
260 lines
9.5 KiB
TypeScript
import { faker } from '@faker-js/faker';
|
|
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';
|
|
|
|
type StewardingSeed = {
|
|
protests: Protest[];
|
|
penalties: Penalty[];
|
|
};
|
|
|
|
export class RacingStewardingFactory {
|
|
constructor(private readonly baseDate: Date) {}
|
|
|
|
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: 'protest-1',
|
|
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: 'penalty-1',
|
|
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: 'protest-2',
|
|
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: 'penalty-2',
|
|
leagueId: 'league-5',
|
|
raceId: secondRace.id.toString(),
|
|
driverId: accused,
|
|
type: 'time_penalty',
|
|
value: 10,
|
|
reason: 'Unsafe rejoin (protest pending review)',
|
|
protestId: 'protest-2',
|
|
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: 'protest-3',
|
|
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: 'penalty-3',
|
|
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: 'protest-4',
|
|
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: `protest-${leagueId}-${race.id.toString()}`,
|
|
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: `penalty-${leagueId}-${race.id.toString()}`,
|
|
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 }),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return { protests, penalties };
|
|
}
|
|
} |