seed data

This commit is contained in:
2025-12-30 00:15:35 +01:00
parent 7a853d4e43
commit ccaa39c39c
22 changed files with 1342 additions and 173 deletions

View File

@@ -61,5 +61,61 @@ export class EnsureInitialData {
}
this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`);
// Create additional users for comprehensive seeding
await this.createAdditionalUsers();
// Create user achievements linking users to achievements
await this.createUserAchievements();
}
private async createAdditionalUsers(): Promise<void> {
const userConfigs = [
// Driver with iRacing linked
{ displayName: 'Max Verstappen', email: 'max@racing.com', password: 'Test123!', iracingCustomerId: '12345' },
// Driver without email
{ displayName: 'Lewis Hamilton', email: undefined, password: undefined, iracingCustomerId: '67890' },
// Sponsor user
{ displayName: 'Sponsor Inc', email: 'sponsor@example.com', password: 'Test123!', iracingCustomerId: undefined },
// Various driver profiles
{ displayName: 'Charles Leclerc', email: 'charles@ferrari.com', password: 'Test123!', iracingCustomerId: '11111' },
{ displayName: 'Lando Norris', email: 'lando@mclaren.com', password: 'Test123!', iracingCustomerId: '22222' },
{ displayName: 'George Russell', email: 'george@mercedes.com', password: 'Test123!', iracingCustomerId: '33333' },
{ displayName: 'Carlos Sainz', email: 'carlos@ferrari.com', password: 'Test123!', iracingCustomerId: '44444' },
{ displayName: 'Fernando Alonso', email: 'fernando@aston.com', password: 'Test123!', iracingCustomerId: '55555' },
{ displayName: 'Sergio Perez', email: 'sergio@redbull.com', password: 'Test123!', iracingCustomerId: '66666' },
{ displayName: 'Valtteri Bottas', email: 'valtteri@sauber.com', password: 'Test123!', iracingCustomerId: '77777' },
];
let createdCount = 0;
for (const config of userConfigs) {
if (!config.email) {
// Skip users without email for now (would need different creation method)
continue;
}
try {
const result = await this.signupUseCase.execute({
email: config.email,
password: config.password!,
displayName: config.displayName,
});
if (result.isOk()) {
createdCount++;
}
} catch (error) {
// User might already exist, skip
}
}
this.logger.info(`[Bootstrap] Created ${createdCount} additional users`);
}
private async createUserAchievements(): Promise<void> {
// This would require access to user and achievement repositories
// For now, we'll log that this would be done
this.logger.info('[Bootstrap] User achievements would be created here (requires repository access)');
}
}

View File

@@ -22,8 +22,9 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { createRacingSeed } from './racing/RacingSeed';
import { getApiPersistence } from '../../apps/api/src/env';
import { seedId } from './racing/SeedIdHelper';
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
export type RacingSeedDependencies = {
driverRepository: IDriverRepository;
@@ -54,16 +55,56 @@ export class SeedRacingData {
private readonly seedDeps: RacingSeedDependencies,
) {}
private getApiPersistence(): 'postgres' | 'inmemory' {
const configured = process.env.GRIDPILOT_API_PERSISTENCE?.toLowerCase();
if (configured === 'postgres' || configured === 'inmemory') {
return configured;
}
if (process.env.NODE_ENV === 'test') {
return 'inmemory';
}
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
}
async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll();
if (existingDrivers.length > 0) {
const persistence = this.getApiPersistence();
// Check for force reseed via environment variable
const forceReseedRaw = process.env.GRIDPILOT_API_FORCE_RESEED;
const forceReseed = forceReseedRaw !== undefined && forceReseedRaw !== '0' && forceReseedRaw.toLowerCase() !== 'false';
if (existingDrivers.length > 0 && !forceReseed) {
this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData();
return;
}
const persistence = getApiPersistence();
const seed = createRacingSeed({ persistence });
if (forceReseed && existingDrivers.length > 0) {
this.logger.info('[Bootstrap] Force reseed enabled - clearing existing racing data');
await this.clearExistingRacingData();
}
const seed = createRacingSeed({
persistence,
driverCount: 150 // Expanded from 100 to 150
});
// Populate the driver stats store for the InMemoryDriverStatsService
const driverStatsStore = DriverStatsStore.getInstance();
driverStatsStore.clear(); // Clear any existing stats
driverStatsStore.loadStats(seed.driverStats);
this.logger.info(`[Bootstrap] Loaded driver stats for ${seed.driverStats.size} drivers`);
// Populate the team stats store for the AllTeamsPresenter
const teamStatsStore = TeamStatsStore.getInstance();
teamStatsStore.clear(); // Clear any existing stats
teamStatsStore.loadStats(seed.teamStats);
this.logger.info(`[Bootstrap] Loaded team stats for ${seed.teamStats.size} teams`);
let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps
@@ -268,6 +309,36 @@ export class SeedRacingData {
);
}
private async clearExistingRacingData(): Promise<void> {
// Get all existing drivers
const drivers = await this.seedDeps.driverRepository.findAll();
// Delete drivers first (this should cascade to related data in most cases)
for (const driver of drivers) {
try {
await this.seedDeps.driverRepository.delete(driver.id);
} catch {
// Ignore errors
}
}
// Try to clean up other data if repositories support it
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
try {
await this.seedDeps.leagueRepository.delete(league.id.toString());
} catch {
// Ignore
}
}
} catch {
// Ignore
}
this.logger.info('[Bootstrap] Cleared existing racing data');
}
private async ensureScoringConfigsForExistingData(): Promise<void> {
const leagues = await this.seedDeps.leagueRepository.findAll();

View File

@@ -2,6 +2,22 @@ import { Driver } from '@core/racing/domain/entities/Driver';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export interface DriverStats {
rating: number;
safetyRating: number;
sportsmanshipRating: number;
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
experienceLevel: 'beginner' | 'intermediate' | 'advanced' | 'veteran';
overallRank: number | null;
}
export class RacingDriverFactory {
constructor(
private readonly driverCount: number,
@@ -15,14 +31,156 @@ export class RacingDriverFactory {
return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1;
return Driver.create({
// Vary bio presence: 80% have bio, 20% empty
const hasBio = i % 5 !== 0;
// Vary joined dates: some recent (new drivers), some old (veterans)
let joinedAt: Date;
if (i % 10 === 0) {
// Very recent drivers (within last week)
joinedAt = faker.date.recent({ days: 7, refDate: this.baseDate });
} else if (i % 7 === 0) {
// Recent drivers (within last month)
joinedAt = faker.date.recent({ days: 30, refDate: this.baseDate });
} else if (i % 5 === 0) {
// Medium tenure (6-12 months ago)
joinedAt = faker.date.between({
from: new Date(this.baseDate.getTime() - 365 * 24 * 60 * 60 * 1000),
to: new Date(this.baseDate.getTime() - 180 * 24 * 60 * 60 * 1000)
});
} else {
// Veterans (1-2 years ago)
joinedAt = faker.date.past({ years: 2, refDate: this.baseDate });
}
const driverData: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt?: Date;
} = {
id: seedId(`driver-${i}`, this.persistence),
iracingId: String(100000 + i),
name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries),
bio: faker.lorem.sentences(2),
joinedAt: faker.date.past({ years: 2, refDate: this.baseDate }),
});
joinedAt,
};
if (hasBio) {
driverData.bio = faker.lorem.sentences(2);
}
return Driver.create(driverData);
});
}
/**
* Generate driver statistics for profile display
* This would be stored in a separate stats table/service in production
*/
generateDriverStats(drivers: Driver[]): Map<string, DriverStats> {
const statsMap = new Map<string, DriverStats>();
drivers.forEach((driver, idx) => {
const i = idx + 1;
// Determine experience level based on join date and index
let experienceLevel: 'beginner' | 'intermediate' | 'advanced' | 'veteran';
let totalRaces: number;
let rating: number;
let safetyRating: number;
let sportsmanshipRating: number;
if (i % 10 === 0) {
// Very recent drivers
experienceLevel = 'beginner';
totalRaces = faker.number.int({ min: 1, max: 10 });
rating = faker.number.int({ min: 800, max: 1200 });
safetyRating = faker.number.int({ min: 60, max: 85 });
sportsmanshipRating = Math.round(faker.number.float({ min: 3.0, max: 4.2 }) * 10) / 10;
} else if (i % 7 === 0) {
// Recent drivers
experienceLevel = 'beginner';
totalRaces = faker.number.int({ min: 5, max: 25 });
rating = faker.number.int({ min: 1000, max: 1400 });
safetyRating = faker.number.int({ min: 70, max: 90 });
sportsmanshipRating = Math.round(faker.number.float({ min: 3.5, max: 4.5 }) * 10) / 10;
} else if (i % 5 === 0) {
// Medium tenure
experienceLevel = 'intermediate';
totalRaces = faker.number.int({ min: 20, max: 60 });
rating = faker.number.int({ min: 1300, max: 1700 });
safetyRating = faker.number.int({ min: 75, max: 95 });
sportsmanshipRating = Math.round(faker.number.float({ min: 3.8, max: 4.8 }) * 10) / 10;
} else if (i % 3 === 0) {
// Advanced
experienceLevel = 'advanced';
totalRaces = faker.number.int({ min: 50, max: 120 });
rating = faker.number.int({ min: 1600, max: 1900 });
safetyRating = faker.number.int({ min: 80, max: 98 });
sportsmanshipRating = Math.round(faker.number.float({ min: 4.0, max: 5.0 }) * 10) / 10;
} else {
// Veterans
experienceLevel = 'veteran';
totalRaces = faker.number.int({ min: 100, max: 200 });
rating = faker.number.int({ min: 1700, max: 2000 });
safetyRating = faker.number.int({ min: 85, max: 100 });
sportsmanshipRating = Math.round(faker.number.float({ min: 4.2, max: 5.0 }) * 10) / 10;
}
// Calculate performance stats based on total races
const winRate = experienceLevel === 'beginner' ? faker.number.float({ min: 0, max: 5, fractionDigits: 1 }) :
experienceLevel === 'intermediate' ? faker.number.float({ min: 2, max: 10, fractionDigits: 1 }) :
experienceLevel === 'advanced' ? faker.number.float({ min: 5, max: 15, fractionDigits: 1 }) :
faker.number.float({ min: 8, max: 20, fractionDigits: 1 });
const podiumRate = experienceLevel === 'beginner' ? faker.number.float({ min: 5, max: 15, fractionDigits: 1 }) :
experienceLevel === 'intermediate' ? faker.number.float({ min: 10, max: 25, fractionDigits: 1 }) :
experienceLevel === 'advanced' ? faker.number.float({ min: 15, max: 35, fractionDigits: 1 }) :
faker.number.float({ min: 20, max: 45, fractionDigits: 1 });
const wins = Math.round((winRate / 100) * totalRaces);
const podiums = Math.round((podiumRate / 100) * totalRaces);
const dnfs = Math.round(faker.number.float({ min: 0.05, max: 0.15, fractionDigits: 2 }) * totalRaces);
const avgFinish = experienceLevel === 'beginner' ? faker.number.float({ min: 8, max: 15, fractionDigits: 1 }) :
experienceLevel === 'intermediate' ? faker.number.float({ min: 5, max: 10, fractionDigits: 1 }) :
experienceLevel === 'advanced' ? faker.number.float({ min: 3, max: 7, fractionDigits: 1 }) :
faker.number.float({ min: 2, max: 5, fractionDigits: 1 });
const bestFinish = experienceLevel === 'beginner' ? faker.number.int({ min: 3, max: 8 }) :
experienceLevel === 'intermediate' ? faker.number.int({ min: 1, max: 5 }) :
faker.number.int({ min: 1, max: 3 });
const worstFinish = experienceLevel === 'beginner' ? faker.number.int({ min: 12, max: 20 }) :
experienceLevel === 'intermediate' ? faker.number.int({ min: 8, max: 15 }) :
experienceLevel === 'advanced' ? faker.number.int({ min: 5, max: 12 }) :
faker.number.int({ min: 4, max: 10 });
const consistency = experienceLevel === 'beginner' ? faker.number.float({ min: 40, max: 65, fractionDigits: 0 }) :
experienceLevel === 'intermediate' ? faker.number.float({ min: 60, max: 80, fractionDigits: 0 }) :
experienceLevel === 'advanced' ? faker.number.float({ min: 75, max: 90, fractionDigits: 0 }) :
faker.number.float({ min: 85, max: 98, fractionDigits: 0 });
statsMap.set(driver.id.toString(), {
rating,
safetyRating,
sportsmanshipRating,
totalRaces,
wins,
podiums,
dnfs,
avgFinish: parseFloat(avgFinish.toFixed(1)),
bestFinish,
worstFinish,
consistency: parseFloat(consistency.toFixed(1)),
experienceLevel,
overallRank: null, // Will be calculated after all stats are loaded
});
});
return statsMap;
}
}

View File

@@ -11,29 +11,273 @@ export class RacingLeagueFactory {
) {}
create(): League[] {
const leagueCount = 20;
const leagueCount = 30;
// Create diverse league configurations
// Create diverse league configurations covering ALL enum combinations
// Points systems: f1-2024, indycar, custom (3)
// Qualifying formats: single-lap, open (2)
// Visibility: ranked, unranked (2)
// Decision modes: admin_only, steward_decides, steward_vote, member_vote, steward_veto, member_veto (6)
// Total combinations: 3 * 2 * 2 * 6 = 72, but we'll sample 30 covering extremes
const leagueConfigs = [
// Small sprint leagues
{ maxDrivers: 16, sessionDuration: 30, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'single-lap' as const },
{ maxDrivers: 20, sessionDuration: 45, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const },
// Medium endurance leagues
{ maxDrivers: 24, sessionDuration: 60, pointsSystem: 'indycar' as const, qualifyingFormat: 'open' as const },
{ maxDrivers: 28, sessionDuration: 90, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const },
// Large mixed leagues
{ maxDrivers: 32, sessionDuration: 120, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const },
{ maxDrivers: 36, sessionDuration: 75, pointsSystem: 'indycar' as const, qualifyingFormat: 'single-lap' as const },
{ maxDrivers: 40, sessionDuration: 100, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const },
{ maxDrivers: 44, sessionDuration: 85, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const },
{ maxDrivers: 48, sessionDuration: 110, pointsSystem: 'indycar' as const, qualifyingFormat: 'single-lap' as const },
{ maxDrivers: 50, sessionDuration: 95, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const },
// 1-5: Ranked, F1-2024, various stewarding
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 40,
sessionDuration: 60,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 32,
sessionDuration: 45,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 3, requireDefense: true, defenseTimeLimit: 24 }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 50,
sessionDuration: 90,
stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 5, requireDefense: false }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 24,
sessionDuration: 30,
stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: true }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 60,
sessionDuration: 120,
stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 4, requireDefense: false }
},
// 6-10: Ranked, IndyCar, various stewarding
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 28,
sessionDuration: 75,
stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 48 }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 36,
sessionDuration: 60,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 44,
sessionDuration: 100,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 4, requireDefense: true, voteTimeLimit: 72 }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 20,
sessionDuration: 45,
stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 6, requireDefense: false }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 48,
sessionDuration: 110,
stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 3, requireDefense: true }
},
// 11-15: Ranked, Custom, various stewarding
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 32,
sessionDuration: 50,
stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 5, requireDefense: false }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 40,
sessionDuration: 85,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 52,
sessionDuration: 95,
stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 36 }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'open' as const,
visibility: 'ranked' as const,
maxDrivers: 26,
sessionDuration: 65,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'ranked' as const,
maxDrivers: 56,
sessionDuration: 105,
stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 7, requireDefense: true, protestDeadlineHours: 24 }
},
// 16-20: Unranked, F1-2024, various stewarding (smaller, no participant requirements)
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 8,
sessionDuration: 30,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 12,
sessionDuration: 45,
stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 16,
sessionDuration: 60,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 10,
sessionDuration: 35,
stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 3, requireDefense: true }
},
{
pointsSystem: 'f1-2024' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 20,
sessionDuration: 55,
stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: false }
},
// 21-25: Unranked, IndyCar, various stewarding
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 14,
sessionDuration: 40,
stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 3, requireDefense: true }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 18,
sessionDuration: 50,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 22,
sessionDuration: 65,
stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 12 }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 6,
sessionDuration: 25,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false }
},
{
pointsSystem: 'indycar' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 24,
sessionDuration: 70,
stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 4, requireDefense: true }
},
// 26-30: Unranked, Custom, various stewarding
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 12,
sessionDuration: 35,
stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: false }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 16,
sessionDuration: 45,
stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 3, requireDefense: true }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 20,
sessionDuration: 55,
stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'single-lap' as const,
visibility: 'unranked' as const,
maxDrivers: 8,
sessionDuration: 30,
stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 18 }
},
{
pointsSystem: 'custom' as const,
qualifyingFormat: 'open' as const,
visibility: 'unranked' as const,
maxDrivers: 24,
sessionDuration: 60,
stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 3, requireDefense: false }
},
];
return Array.from({ length: leagueCount }, (_, idx) => {
const i = idx + 1;
const owner = faker.helpers.arrayElement(this.drivers);
const config = leagueConfigs[idx % leagueConfigs.length]!;
const config = leagueConfigs[idx]!;
const createdAt =
// Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated.
@@ -41,6 +285,31 @@ export class RacingLeagueFactory {
? faker.date.recent({ days: 6, refDate: this.baseDate })
: faker.date.past({ years: 2, refDate: this.baseDate });
// Calculate participant count based on visibility and league
let participantCount: number;
if (config.visibility === 'ranked') {
// Ranked leagues need >= 10 participants
// Some are full (max), some have minimum, some have none
if (idx % 5 === 0) {
participantCount = config.maxDrivers; // Full league
} else if (idx % 3 === 0) {
participantCount = 12; // Just meets minimum
} else if (idx % 7 === 0) {
participantCount = 0; // Empty but valid
} else {
participantCount = faker.number.int({ min: 10, max: config.maxDrivers - 5 });
}
} else {
// Unranked can have any count, but must have at least 2 if not empty
if (idx % 4 === 0) {
participantCount = config.maxDrivers; // Full
} else if (idx % 5 === 0) {
participantCount = 0; // Empty
} else {
participantCount = faker.number.int({ min: 2, max: Math.min(config.maxDrivers, 15) });
}
}
const leagueData: {
id: string;
name: string;
@@ -51,6 +320,18 @@ export class RacingLeagueFactory {
maxDrivers: number;
sessionDuration: number;
qualifyingFormat: 'open' | 'single-lap';
visibility?: 'ranked' | 'unranked';
stewarding?: {
decisionMode: 'admin_only' | 'steward_decides' | 'steward_vote' | 'member_vote' | 'steward_veto' | 'member_veto';
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
};
createdAt: Date;
socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string };
@@ -60,11 +341,23 @@ export class RacingLeagueFactory {
name: faker.company.name() + ' Racing League',
description: faker.lorem.sentences(2),
ownerId: owner.id.toString(),
settings: config,
settings: {
pointsSystem: config.pointsSystem,
maxDrivers: config.maxDrivers,
sessionDuration: config.sessionDuration,
qualifyingFormat: config.qualifyingFormat,
visibility: config.visibility,
stewarding: {
...config.stewarding,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
},
createdAt,
// Start with some participants for ranked leagues to meet minimum requirements
// Note: ranked leagues require >= 10 participants (see LeagueVisibility)
participantCount: i % 3 === 0 ? 12 : 0,
participantCount,
};
// Add social links with varying completeness

View File

@@ -21,7 +21,7 @@ export class RacingRaceFactory {
// Create races with systematic coverage of different statuses and scenarios
const statuses: Array<'scheduled' | 'running' | 'completed' | 'cancelled'> = ['scheduled', 'running', 'completed', 'cancelled'];
for (let i = 1; i <= 50; i++) {
for (let i = 1; i <= 100; i++) {
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
const trackId = trackIds[(i - 1) % trackIds.length]!;
const track = tracks.find(t => t.id === trackId)!;

View File

@@ -19,111 +19,143 @@ export class RacingSeasonSponsorshipFactory {
for (const league of leagues) {
const leagueId = league.id.toString();
const leagueIndex = parseInt(leagueId.split('-')[1] || '0');
if (leagueId === seedId('league-5', this.persistence)) {
seasons.push(
Season.create({
id: seedId('season-1', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 1 (GT Sprint)',
year: 2025,
order: 1,
status: 'active',
startDate: this.daysFromBase(-30),
}),
Season.create({
id: seedId('season-2', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 2 (Endurance Cup)',
year: 2024,
order: 0,
status: 'completed',
startDate: this.daysFromBase(-120),
endDate: this.daysFromBase(-60),
}),
Season.create({
id: seedId('season-3', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 3 (Planned)',
year: 2025,
order: 2,
status: 'planned',
startDate: this.daysFromBase(14),
}),
);
continue;
}
if (leagueId === seedId('league-3', this.persistence)) {
seasons.push(
Season.create({
id: seedId('league-3-season-a', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Split Season A',
year: 2025,
order: 1,
status: 'active',
startDate: this.daysFromBase(-10),
}),
Season.create({
id: seedId('league-3-season-b', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Split Season B',
year: 2025,
order: 2,
status: 'active',
startDate: this.daysFromBase(-3),
}),
);
continue;
}
const baseYear = this.baseDate.getUTCFullYear();
const seasonCount = leagueId === seedId('league-2', this.persistence) ? 1 : faker.number.int({ min: 1, max: 3 });
// Create 2-4 seasons per league to reach ~100 total seasons
const seasonCount = faker.number.int({ min: 2, max: 4 });
for (let i = 0; i < seasonCount; i++) {
const id = seedId(`${leagueId}-season-${i + 1}`, this.persistence);
const isFirst = i === 0;
// Systematically cover all 5 statuses across all leagues
// planned: 20%, active: 20%, completed: 30%, archived: 20%, cancelled: 10%
let status: SeasonStatusValue;
if (i === 0 && leagueIndex % 5 === 0) {
status = 'planned';
} else if (i === 0 && leagueIndex % 5 === 1) {
status = 'active';
} else if (i === 1 && leagueIndex % 5 === 2) {
status = 'completed';
} else if (i === 2 && leagueIndex % 5 === 3) {
status = 'archived';
} else if (i === 3 && leagueIndex % 5 === 4) {
status = 'cancelled';
} else {
// Weighted random distribution
const statusWeights = [
{ weight: 2, value: 'planned' as const },
{ weight: 2, value: 'active' as const },
{ weight: 3, value: 'completed' as const },
{ weight: 2, value: 'archived' as const },
{ weight: 1, value: 'cancelled' as const },
];
const totalWeight = statusWeights.reduce((sum, item) => sum + item.weight, 0);
const random = faker.number.int({ min: 1, max: totalWeight });
let cumulative = 0;
status = 'planned';
for (const item of statusWeights) {
cumulative += item.weight;
if (random <= cumulative) {
status = item.value;
break;
}
}
}
const status: SeasonStatusValue =
leagueId === seedId('league-1', this.persistence) && isFirst
? 'active'
: leagueId === seedId('league-2', this.persistence)
? 'planned'
: isFirst
? faker.helpers.arrayElement(['active', 'planned'] as const)
: faker.helpers.arrayElement(['completed', 'archived', 'cancelled'] as const);
const baseYear = this.baseDate.getUTCFullYear() + faker.number.int({ min: -1, max: 1 });
const startOffset =
status === 'active'
? faker.number.int({ min: -60, max: -1 })
: status === 'planned'
? faker.number.int({ min: 7, max: 60 })
: faker.number.int({ min: -200, max: -90 });
// Calculate dates based on status
let startDate: Date | undefined;
let endDate: Date | undefined;
let schedulePublished: boolean | undefined;
let participantCount: number | undefined;
let maxDrivers: number | undefined;
const endOffset =
status === 'completed' || status === 'archived' || status === 'cancelled'
? faker.number.int({ min: -89, max: -7 })
: undefined;
switch (status) {
case 'planned':
startDate = this.daysFromBase(faker.number.int({ min: 7, max: 90 }));
schedulePublished = faker.datatype.boolean({ probability: 0.6 });
participantCount = 0;
break;
seasons.push(
Season.create({
id,
leagueId,
gameId: 'iracing',
name: `${faker.word.adjective()} ${faker.word.noun()} Season`,
year: baseYear + faker.number.int({ min: -1, max: 1 }),
order: i + 1,
status,
startDate: this.daysFromBase(startOffset),
...(endOffset !== undefined ? { endDate: this.daysFromBase(endOffset) } : {}),
}),
);
case 'active':
startDate = this.daysFromBase(faker.number.int({ min: -60, max: -1 }));
schedulePublished = true;
maxDrivers = faker.number.int({
min: 10,
max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100))
});
participantCount = faker.number.int({ min: 5, max: maxDrivers });
break;
case 'completed':
startDate = this.daysFromBase(faker.number.int({ min: -180, max: -60 }));
endDate = this.daysFromBase(faker.number.int({ min: -59, max: -7 }));
schedulePublished = true;
maxDrivers = faker.number.int({
min: 10,
max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100))
});
participantCount = faker.number.int({ min: 10, max: maxDrivers });
break;
case 'archived':
startDate = this.daysFromBase(faker.number.int({ min: -365, max: -200 }));
endDate = this.daysFromBase(faker.number.int({ min: -199, max: -150 }));
schedulePublished = true;
maxDrivers = faker.number.int({
min: 10,
max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100))
});
participantCount = faker.number.int({ min: 8, max: maxDrivers });
break;
case 'cancelled':
startDate = this.daysFromBase(faker.number.int({ min: -30, max: -1 }));
endDate = this.daysFromBase(faker.number.int({ min: -1, max: 1 })); // Cancelled early
schedulePublished = faker.datatype.boolean({ probability: 0.3 });
// Cancelled seasons can have maxDrivers but participantCount should be low
maxDrivers = faker.number.int({
min: 5,
max: Math.max(5, Math.min(league.settings.maxDrivers || 32, 100))
});
participantCount = faker.number.int({ min: 0, max: Math.min(5, maxDrivers) }); // Minimal participants
break;
}
// Build season data with proper undefined handling
const seasonData: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status: SeasonStatusValue;
startDate?: Date;
endDate?: Date;
schedulePublished?: boolean;
participantCount?: number;
maxDrivers?: number;
} = {
id,
leagueId,
gameId: 'iracing',
name: `${faker.word.adjective()} ${faker.word.noun()} Season`,
status,
};
// Add optional fields only if they have values
if (baseYear !== undefined) seasonData.year = baseYear;
if (i + 1 !== undefined) seasonData.order = i + 1;
if (startDate !== undefined) seasonData.startDate = startDate;
if (endDate !== undefined) seasonData.endDate = endDate;
if (schedulePublished !== undefined) seasonData.schedulePublished = schedulePublished;
if (participantCount !== undefined) seasonData.participantCount = participantCount;
if (maxDrivers !== undefined) seasonData.maxDrivers = maxDrivers;
const season = Season.create(seasonData);
seasons.push(season);
}
}

View File

@@ -14,7 +14,7 @@ import { Season } from '@core/racing/domain/entities/season/Season';
import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { RacingDriverFactory } from './RacingDriverFactory';
import { RacingDriverFactory, type DriverStats } from './RacingDriverFactory';
import { RacingFeedFactory } from './RacingFeedFactory';
import { RacingFriendshipFactory } from './RacingFriendshipFactory';
import { RacingLeagueFactory } from './RacingLeagueFactory';
@@ -22,7 +22,7 @@ import { RacingMembershipFactory } from './RacingMembershipFactory';
import { RacingRaceFactory } from './RacingRaceFactory';
import { RacingResultFactory } from './RacingResultFactory';
import { RacingStandingFactory } from './RacingStandingFactory';
import { RacingTeamFactory } from './RacingTeamFactory';
import { RacingTeamFactory, type TeamStats } from './RacingTeamFactory';
import { RacingTrackFactory } from './RacingTrackFactory';
import { RacingSponsorFactory } from './RacingSponsorFactory';
import { RacingSeasonSponsorshipFactory } from './RacingSeasonSponsorshipFactory';
@@ -36,6 +36,7 @@ export type Friendship = {
export type RacingSeed = {
drivers: Driver[];
driverStats: Map<string, DriverStats>;
leagues: League[];
seasons: Season[];
seasonSponsorships: SeasonSponsorship[];
@@ -51,6 +52,7 @@ export type RacingSeed = {
leagueJoinRequests: JoinRequest[];
raceRegistrations: RaceRegistration[];
teams: Team[];
teamStats: Map<string, TeamStats>;
teamMemberships: TeamMembership[];
teamJoinRequests: TeamJoinRequest[];
sponsors: Sponsor[];
@@ -68,7 +70,7 @@ export type RacingSeedOptions = {
export const racingSeedDefaults: Readonly<
Required<RacingSeedOptions>
> = {
driverCount: 100,
driverCount: 150, // Increased from 100 to 150
baseDate: new Date(),
persistence: 'inmemory',
};
@@ -98,6 +100,7 @@ class RacingSeedFactory {
const feedFactory = new RacingFeedFactory(this.baseDate, this.persistence);
const drivers = driverFactory.create();
const driverStats = driverFactory.generateDriverStats(drivers);
const tracks = trackFactory.create();
const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers, this.persistence);
const leagues = leagueFactory.create();
@@ -113,6 +116,7 @@ class RacingSeedFactory {
const teamFactory = new RacingTeamFactory(this.baseDate, this.persistence);
const teams = teamFactory.createTeams(drivers, leagues);
const teamStats = teamFactory.generateTeamStats(teams);
const races = raceFactory.create(leagues, tracks);
const results = resultFactory.create(drivers, races);
const standings = standingFactory.create(leagues, races, results);
@@ -128,6 +132,7 @@ class RacingSeedFactory {
return {
drivers,
driverStats,
leagues,
seasons,
seasonSponsorships,
@@ -143,6 +148,7 @@ class RacingSeedFactory {
leagueJoinRequests,
raceRegistrations,
teams,
teamStats,
teamMemberships,
teamJoinRequests,
sponsors,

View File

@@ -259,6 +259,101 @@ export class RacingStewardingFactory {
}
}
// 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']);
}
}

View File

@@ -5,6 +5,17 @@ import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export interface TeamStats {
logoUrl: string;
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
specialization: 'endurance' | 'sprint' | 'mixed';
region: string;
languages: string[];
totalWins: number;
totalRaces: number;
rating: number;
}
export class RacingTeamFactory {
constructor(
private readonly baseDate: Date,
@@ -12,7 +23,7 @@ export class RacingTeamFactory {
) {}
createTeams(drivers: Driver[], leagues: League[]): Team[] {
const teamCount = 15;
const teamCount = 50; // Increased from 15 to 50
return Array.from({ length: teamCount }, (_, idx) => {
const i = idx + 1;
@@ -177,8 +188,98 @@ export class RacingTeamFactory {
return requests;
}
/**
* Generate team statistics and metadata for display
* This would be stored in a separate stats table/service in production
*/
generateTeamStats(teams: Team[]): Map<string, TeamStats> {
const statsMap = new Map<string, TeamStats>();
// Available logo URLs (simulating media uploads)
const logoUrls = [
'/images/ff1600.jpeg',
'/images/header.jpeg',
'/images/avatars/male-default-avatar.jpg',
'/images/avatars/female-default-avatar.jpeg',
'/images/avatars/neutral-default-avatar.jpeg',
'/images/leagues/placeholder-cover.svg',
];
// Available regions
const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa'];
// Available languages
const allLanguages = ['English', 'German', 'French', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Korean', 'Russian', 'Chinese'];
teams.forEach((team, idx) => {
const i = idx + 1;
// Determine performance level based on index
let performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
let totalRaces: number;
let rating: number;
let totalWins: number;
if (i % 8 === 0) {
// Pro teams
performanceLevel = 'pro';
totalRaces = faker.number.int({ min: 80, max: 150 });
rating = faker.number.int({ min: 1700, max: 2000 });
totalWins = faker.number.int({ min: 15, max: 40 });
} else if (i % 5 === 0) {
// Advanced teams
performanceLevel = 'advanced';
totalRaces = faker.number.int({ min: 40, max: 100 });
rating = faker.number.int({ min: 1500, max: 1800 });
totalWins = faker.number.int({ min: 8, max: 20 });
} else if (i % 3 === 0) {
// Intermediate teams
performanceLevel = 'intermediate';
totalRaces = faker.number.int({ min: 20, max: 60 });
rating = faker.number.int({ min: 1200, max: 1600 });
totalWins = faker.number.int({ min: 3, max: 12 });
} else {
// Beginner teams
performanceLevel = 'beginner';
totalRaces = faker.number.int({ min: 5, max: 25 });
rating = faker.number.int({ min: 900, max: 1300 });
totalWins = faker.number.int({ min: 0, max: 5 });
}
// Determine specialization
let specialization: 'endurance' | 'sprint' | 'mixed';
if (i % 7 === 0) {
specialization = 'endurance';
} else if (i % 4 === 0) {
specialization = 'sprint';
} else {
specialization = 'mixed';
}
// Generate region and languages
const region = faker.helpers.arrayElement(regions);
const languageCount = faker.number.int({ min: 1, max: 3 });
const languages = faker.helpers.arrayElements(allLanguages, languageCount);
// Generate logo URL (varied)
const logoUrl = logoUrls[i % logoUrls.length] ?? logoUrls[0];
statsMap.set(team.id.toString(), {
logoUrl: logoUrl!,
performanceLevel,
specialization,
region,
languages,
totalWins,
totalRaces,
rating,
});
});
return statsMap;
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}