557 lines
17 KiB
TypeScript
557 lines
17 KiB
TypeScript
import { Driver } from '@core/racing/domain/entities/Driver';
|
||
import { League } from '@core/racing/domain/entities/League';
|
||
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||
import { Race } from '@core/racing/domain/entities/Race';
|
||
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
|
||
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
|
||
import { Standing } from '@core/racing/domain/entities/Standing';
|
||
import { Team } from '@core/racing/domain/entities/Team';
|
||
import type { TeamMembership } from '@core/racing/domain/types/TeamMembership';
|
||
import type { FeedItem } from '@core/social/domain/types/FeedItem';
|
||
import { getPointsSystems } from '../PointsSystems';
|
||
|
||
export type Friendship = {
|
||
driverId: string;
|
||
friendId: string;
|
||
};
|
||
|
||
export type InMemoryRacingSeed = {
|
||
drivers: Driver[];
|
||
leagues: League[];
|
||
races: Race[];
|
||
results: RaceResult[];
|
||
standings: Standing[];
|
||
leagueMemberships: LeagueMembership[];
|
||
raceRegistrations: RaceRegistration[];
|
||
teams: Team[];
|
||
teamMemberships: TeamMembership[];
|
||
friendships: Friendship[];
|
||
feedEvents: FeedItem[];
|
||
};
|
||
|
||
export type InMemoryRacingSeedOptions = {
|
||
driverCount?: number;
|
||
baseDate?: Date;
|
||
};
|
||
|
||
export const inMemoryRacingSeedDefaults: Readonly<
|
||
Required<InMemoryRacingSeedOptions>
|
||
> = {
|
||
driverCount: 32,
|
||
baseDate: new Date('2025-01-15T12:00:00.000Z'),
|
||
};
|
||
|
||
class InMemoryRacingSeedFactory {
|
||
private readonly driverCount: number;
|
||
private readonly baseDate: Date;
|
||
|
||
constructor(options: InMemoryRacingSeedOptions) {
|
||
this.driverCount = options.driverCount ?? inMemoryRacingSeedDefaults.driverCount;
|
||
this.baseDate = options.baseDate ?? inMemoryRacingSeedDefaults.baseDate;
|
||
}
|
||
|
||
create(): InMemoryRacingSeed {
|
||
const drivers = this.createDrivers();
|
||
const leagues = this.createLeagues();
|
||
const races = this.createRaces(leagues);
|
||
const results = this.createResults(drivers, races);
|
||
const standings = this.createStandings(leagues, races, results);
|
||
const leagueMemberships = this.createLeagueMemberships(drivers, leagues);
|
||
const raceRegistrations = this.createRaceRegistrations(races);
|
||
const teams = this.createTeams();
|
||
const teamMemberships = this.createTeamMemberships(drivers, teams);
|
||
const friendships = this.createFriendships(drivers);
|
||
const feedEvents = this.createFeedEvents(drivers, friendships, races, leagues);
|
||
|
||
return {
|
||
drivers,
|
||
leagues,
|
||
races,
|
||
results,
|
||
standings,
|
||
leagueMemberships,
|
||
raceRegistrations,
|
||
teams,
|
||
teamMemberships,
|
||
friendships,
|
||
feedEvents,
|
||
};
|
||
}
|
||
|
||
private addDays(date: Date, days: number): Date {
|
||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||
}
|
||
|
||
private addMinutes(date: Date, minutes: number): Date {
|
||
return new Date(date.getTime() + minutes * 60 * 1000);
|
||
}
|
||
|
||
private createDrivers(): Driver[] {
|
||
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES'] as const;
|
||
|
||
return Array.from({ length: this.driverCount }, (_, idx) => {
|
||
const i = idx + 1;
|
||
|
||
return Driver.create({
|
||
id: `driver-${i}`,
|
||
iracingId: String(100000 + i),
|
||
name: `Driver ${i}`,
|
||
country: countries[idx % countries.length]!,
|
||
bio: `Demo driver #${i} seeded for in-memory mode.`,
|
||
joinedAt: this.addDays(this.baseDate, -90 + i),
|
||
});
|
||
});
|
||
}
|
||
|
||
private createLeagues(): League[] {
|
||
const createdAtBase = this.baseDate;
|
||
|
||
return [
|
||
League.create({
|
||
id: 'league-1',
|
||
name: 'GridPilot Sprint Series',
|
||
description: 'Weekly sprint races with stable grids.',
|
||
ownerId: 'driver-1',
|
||
settings: {
|
||
pointsSystem: 'f1-2024',
|
||
maxDrivers: 24,
|
||
sessionDuration: 60,
|
||
qualifyingFormat: 'open',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -200),
|
||
socialLinks: {
|
||
discordUrl: 'https://discord.gg/gridpilot-demo',
|
||
youtubeUrl: 'https://youtube.com/@gridpilot-demo',
|
||
websiteUrl: 'https://gridpilot-demo.example.com',
|
||
},
|
||
}),
|
||
League.create({
|
||
id: 'league-2',
|
||
name: 'GridPilot Endurance Cup',
|
||
description: 'Longer races with strategy and consistency.',
|
||
ownerId: 'driver-2',
|
||
settings: {
|
||
pointsSystem: 'indycar',
|
||
maxDrivers: 32,
|
||
sessionDuration: 120,
|
||
qualifyingFormat: 'open',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -180),
|
||
socialLinks: { discordUrl: 'https://discord.gg/gridpilot-endurance' },
|
||
}),
|
||
League.create({
|
||
id: 'league-3',
|
||
name: 'GridPilot Club Ladder',
|
||
description: 'Casual ladder with fast onboarding.',
|
||
ownerId: 'driver-3',
|
||
settings: {
|
||
pointsSystem: 'f1-2024',
|
||
maxDrivers: 48,
|
||
sessionDuration: 45,
|
||
qualifyingFormat: 'single-lap',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -160),
|
||
}),
|
||
League.create({
|
||
id: 'league-4',
|
||
name: 'Nordic Night Series',
|
||
description: 'Evening races with tight fields.',
|
||
ownerId: 'driver-4',
|
||
settings: {
|
||
pointsSystem: 'f1-2024',
|
||
maxDrivers: 32,
|
||
sessionDuration: 60,
|
||
qualifyingFormat: 'open',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -150),
|
||
}),
|
||
League.create({
|
||
id: 'league-5',
|
||
name: 'Demo League (Admin)',
|
||
description: 'Primary demo league owned by driver-1.',
|
||
ownerId: 'driver-1',
|
||
settings: {
|
||
pointsSystem: 'f1-2024',
|
||
maxDrivers: 24,
|
||
sessionDuration: 60,
|
||
qualifyingFormat: 'open',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -140),
|
||
}),
|
||
League.create({
|
||
id: 'league-6',
|
||
name: 'Sim Racing Alliance',
|
||
description: 'Mixed-format season with community events.',
|
||
ownerId: 'driver-5',
|
||
settings: {
|
||
pointsSystem: 'indycar',
|
||
maxDrivers: 40,
|
||
sessionDuration: 90,
|
||
qualifyingFormat: 'open',
|
||
},
|
||
createdAt: this.addDays(createdAtBase, -130),
|
||
}),
|
||
];
|
||
}
|
||
|
||
private createRaces(leagues: League[]): Race[] {
|
||
const tracks = [
|
||
'Monza GP',
|
||
'Spa-Francorchamps',
|
||
'Suzuka',
|
||
'Mount Panorama',
|
||
'Silverstone GP',
|
||
'Interlagos',
|
||
'Imola',
|
||
'Laguna Seca',
|
||
];
|
||
const cars = ['GT3 – Porsche 911', 'GT3 – BMW M4', 'LMP3 Prototype', 'GT4 – Alpine', 'Touring – Civic'];
|
||
|
||
const leagueIds = leagues.map((l) => l.id.toString());
|
||
const demoLeagueId = 'league-5';
|
||
|
||
const races: Race[] = [];
|
||
|
||
for (let i = 1; i <= 25; i++) {
|
||
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
|
||
const scheduledAt = this.addDays(this.baseDate, i <= 10 ? -35 + i : 1 + (i - 10) * 2);
|
||
|
||
const base = {
|
||
id: `race-${i}`,
|
||
leagueId,
|
||
scheduledAt,
|
||
track: tracks[(i - 1) % tracks.length]!,
|
||
car: cars[(i - 1) % cars.length]!,
|
||
};
|
||
|
||
if (i === 1) {
|
||
races.push(
|
||
Race.create({
|
||
...base,
|
||
leagueId: demoLeagueId,
|
||
scheduledAt: this.addMinutes(this.baseDate, -30),
|
||
status: 'running',
|
||
strengthOfField: 1530,
|
||
registeredCount: 16,
|
||
}),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (scheduledAt < this.baseDate) {
|
||
races.push(
|
||
Race.create({
|
||
...base,
|
||
status: 'completed',
|
||
}),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
races.push(
|
||
Race.create({
|
||
...base,
|
||
status: 'scheduled',
|
||
}),
|
||
);
|
||
}
|
||
|
||
return races;
|
||
}
|
||
|
||
private createResults(drivers: Driver[], races: Race[]): RaceResult[] {
|
||
const results: RaceResult[] = [];
|
||
const completed = races.filter((r) => r.status === 'completed');
|
||
|
||
for (const race of completed) {
|
||
const participants = drivers.slice(0, Math.min(16, drivers.length));
|
||
|
||
for (let idx = 0; idx < participants.length; idx++) {
|
||
const driver = participants[idx]!;
|
||
const position = idx + 1;
|
||
const startPosition = ((idx + 3) % participants.length) + 1;
|
||
|
||
results.push(
|
||
RaceResult.create({
|
||
id: `${race.id}:${driver.id}`,
|
||
raceId: race.id,
|
||
driverId: driver.id,
|
||
position,
|
||
startPosition,
|
||
fastestLap: 88_000 + idx * 120,
|
||
incidents: idx % 4 === 0 ? 2 : 0,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
private resolvePointsSystem(
|
||
league: League,
|
||
pointsSystems: Record<string, Record<number, number>>,
|
||
): Record<number, number> {
|
||
const settings = league.settings;
|
||
return settings.customPoints ?? pointsSystems[settings.pointsSystem] ?? pointsSystems['f1-2024'] ?? {};
|
||
}
|
||
|
||
private createStandings(leagues: League[], races: Race[], results: RaceResult[]): Standing[] {
|
||
const pointsSystems = getPointsSystems();
|
||
|
||
const racesByLeague = new Map<string, Set<string>>();
|
||
for (const race of races) {
|
||
if (race.status !== 'completed') continue;
|
||
|
||
const set = racesByLeague.get(race.leagueId) ?? new Set<string>();
|
||
set.add(race.id);
|
||
racesByLeague.set(race.leagueId, set);
|
||
}
|
||
|
||
const standings: Standing[] = [];
|
||
|
||
for (const league of leagues) {
|
||
const leagueId = league.id.toString();
|
||
const completedRaceIds = racesByLeague.get(leagueId) ?? new Set<string>();
|
||
if (completedRaceIds.size === 0) continue;
|
||
|
||
const pointsTable = this.resolvePointsSystem(league, pointsSystems);
|
||
|
||
const byDriver = new Map<string, Standing>();
|
||
|
||
for (const result of results) {
|
||
if (!completedRaceIds.has(result.raceId.toString())) continue;
|
||
|
||
const driverId = result.driverId.toString();
|
||
const previousStanding = byDriver.get(driverId) ?? Standing.create({ leagueId, driverId, position: 1 });
|
||
const nextStanding = previousStanding.addRaceResult(result.position.toNumber(), pointsTable);
|
||
byDriver.set(driverId, nextStanding);
|
||
}
|
||
|
||
const sorted = Array.from(byDriver.values()).sort((a, b) => {
|
||
if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber();
|
||
if (b.wins !== a.wins) return b.wins - a.wins;
|
||
return b.racesCompleted - a.racesCompleted;
|
||
});
|
||
|
||
sorted.forEach((standing, index) => standings.push(standing.updatePosition(index + 1)));
|
||
}
|
||
|
||
return standings;
|
||
}
|
||
|
||
private createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
|
||
const memberships: LeagueMembership[] = [];
|
||
|
||
for (const driver of drivers) {
|
||
const driverId = driver.id;
|
||
|
||
memberships.push(
|
||
LeagueMembership.create({
|
||
leagueId: 'league-5',
|
||
driverId,
|
||
role: driverId === 'driver-1' ? 'owner' : 'member',
|
||
status: 'active',
|
||
joinedAt: this.addDays(this.baseDate, -60),
|
||
}),
|
||
);
|
||
|
||
const driverNumber = Number(driverId.split('-')[1]);
|
||
const extraLeague = leagues[(driverNumber % (leagues.length - 1)) + 1];
|
||
|
||
if (extraLeague) {
|
||
memberships.push(
|
||
LeagueMembership.create({
|
||
leagueId: extraLeague.id.toString(),
|
||
driverId,
|
||
role: 'member',
|
||
status: 'active',
|
||
joinedAt: this.addDays(this.baseDate, -40),
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
return memberships;
|
||
}
|
||
|
||
private createRaceRegistrations(races: Race[]): RaceRegistration[] {
|
||
const registrations: RaceRegistration[] = [];
|
||
|
||
const upcomingDemoLeague = races.filter((r) => r.status === 'scheduled' && r.leagueId === 'league-5').slice(0, 3);
|
||
|
||
for (const race of upcomingDemoLeague) {
|
||
registrations.push(
|
||
RaceRegistration.create({
|
||
raceId: race.id,
|
||
driverId: 'driver-1',
|
||
}),
|
||
);
|
||
}
|
||
|
||
return registrations;
|
||
}
|
||
|
||
private createTeams(): Team[] {
|
||
return [
|
||
Team.create({
|
||
id: 'team-1',
|
||
name: 'Apex Racing',
|
||
tag: 'APEX',
|
||
description: 'Demo team focused on clean racing.',
|
||
ownerId: 'driver-1',
|
||
leagues: ['league-5'],
|
||
createdAt: this.addDays(this.baseDate, -100),
|
||
}),
|
||
Team.create({
|
||
id: 'team-2',
|
||
name: 'Night Owls',
|
||
tag: 'NITE',
|
||
description: 'Late-night grinders and endurance lovers.',
|
||
ownerId: 'driver-2',
|
||
leagues: ['league-4'],
|
||
createdAt: this.addDays(this.baseDate, -90),
|
||
}),
|
||
Team.create({
|
||
id: 'team-3',
|
||
name: 'Club Legends',
|
||
tag: 'CLUB',
|
||
description: 'A casual team for ladder climbing.',
|
||
ownerId: 'driver-3',
|
||
leagues: ['league-3'],
|
||
createdAt: this.addDays(this.baseDate, -80),
|
||
}),
|
||
];
|
||
}
|
||
|
||
private createTeamMemberships(drivers: Driver[], teams: Team[]): TeamMembership[] {
|
||
const memberships: TeamMembership[] = [];
|
||
|
||
const team1 = teams.find((t) => t.id === 'team-1');
|
||
const team2 = teams.find((t) => t.id === 'team-2');
|
||
const team3 = teams.find((t) => t.id === 'team-3');
|
||
|
||
if (team1) {
|
||
const members = drivers.slice(0, 6);
|
||
members.forEach((d, idx) => {
|
||
memberships.push({
|
||
teamId: team1.id,
|
||
driverId: d.id,
|
||
role: d.id === team1.ownerId.toString() ? 'owner' : idx === 1 ? 'manager' : 'driver',
|
||
status: 'active',
|
||
joinedAt: this.addDays(this.baseDate, -50),
|
||
});
|
||
});
|
||
}
|
||
|
||
if (team2) {
|
||
const members = drivers.slice(6, 12);
|
||
members.forEach((d) => {
|
||
memberships.push({
|
||
teamId: team2.id,
|
||
driverId: d.id,
|
||
role: d.id === team2.ownerId.toString() ? 'owner' : 'driver',
|
||
status: 'active',
|
||
joinedAt: this.addDays(this.baseDate, -45),
|
||
});
|
||
});
|
||
}
|
||
|
||
if (team3) {
|
||
const members = drivers.slice(12, 18);
|
||
members.forEach((d) => {
|
||
memberships.push({
|
||
teamId: team3.id,
|
||
driverId: d.id,
|
||
role: d.id === team3.ownerId.toString() ? 'owner' : 'driver',
|
||
status: 'active',
|
||
joinedAt: this.addDays(this.baseDate, -40),
|
||
});
|
||
});
|
||
}
|
||
|
||
return memberships;
|
||
}
|
||
|
||
private createFriendships(drivers: Driver[]): Friendship[] {
|
||
const friendships: Friendship[] = [];
|
||
|
||
for (let i = 0; i < drivers.length; i++) {
|
||
const driver = drivers[i]!;
|
||
for (let offset = 1; offset <= 3; offset++) {
|
||
const friend = drivers[(i + offset) % drivers.length]!;
|
||
friendships.push({ driverId: driver.id, friendId: friend.id });
|
||
}
|
||
}
|
||
|
||
return friendships;
|
||
}
|
||
|
||
private createFeedEvents(drivers: Driver[], friendships: Friendship[], races: Race[], leagues: League[]): FeedItem[] {
|
||
const items: FeedItem[] = [];
|
||
const friendMap = new Map(friendships.map((f) => [`${f.driverId}:${f.friendId}`, true] as const));
|
||
|
||
const completedRace = races.find((r) => r.status === 'completed');
|
||
const upcomingRace = races.find((r) => r.status === 'scheduled');
|
||
const league = leagues.find((l) => l.id.toString() === 'league-5') ?? leagues[0]!;
|
||
|
||
const now = this.addMinutes(this.baseDate, 10);
|
||
|
||
for (let i = 2; i <= 10; i++) {
|
||
const actor = drivers.find((d) => d.id === `driver-${i}`);
|
||
if (!actor) continue;
|
||
|
||
if (!friendMap.has(`driver-1:${actor.id}`)) continue;
|
||
|
||
items.push({
|
||
id: `feed:${actor.id}:joined:${i}`,
|
||
type: 'friend-joined-league',
|
||
timestamp: this.addMinutes(now, -(i * 7)),
|
||
actorDriverId: actor.id,
|
||
actorFriendId: actor.id,
|
||
leagueId: league.id.toString(),
|
||
headline: `${actor.name} joined ${String(league.name)}`,
|
||
body: 'Demo activity in in-memory mode.',
|
||
ctaLabel: 'View league',
|
||
ctaHref: `/leagues/${league.id.toString()}`,
|
||
});
|
||
|
||
if (completedRace) {
|
||
items.push({
|
||
id: `feed:${actor.id}:result:${i}`,
|
||
type: 'friend-finished-race',
|
||
timestamp: this.addMinutes(now, -(i * 7 + 3)),
|
||
actorDriverId: actor.id,
|
||
actorFriendId: actor.id,
|
||
leagueId: completedRace.leagueId,
|
||
raceId: completedRace.id,
|
||
position: (i % 5) + 1,
|
||
headline: `${actor.name} finished a race`,
|
||
body: `Completed at ${completedRace.track}.`,
|
||
ctaLabel: 'View results',
|
||
ctaHref: `/races/${completedRace.id}/results`,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (upcomingRace) {
|
||
items.push({
|
||
id: `feed:system:scheduled:${upcomingRace.id}`,
|
||
type: 'new-race-scheduled',
|
||
timestamp: this.addMinutes(now, -3),
|
||
leagueId: upcomingRace.leagueId,
|
||
raceId: upcomingRace.id,
|
||
headline: `New race scheduled at ${upcomingRace.track}`,
|
||
body: `${upcomingRace.car} • ${upcomingRace.scheduledAt.toISOString()}`,
|
||
ctaLabel: 'View schedule',
|
||
ctaHref: `/races/${upcomingRace.id}`,
|
||
});
|
||
}
|
||
|
||
return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||
}
|
||
}
|
||
|
||
export function createInMemoryRacingSeed(options: InMemoryRacingSeedOptions = {}): InMemoryRacingSeed {
|
||
return new InMemoryRacingSeedFactory(options).create();
|
||
} |