Files
gridpilot.gg/adapters/bootstrap/racing/RacingDriverFactory.ts
2025-12-31 15:39:28 +01:00

236 lines
9.6 KiB
TypeScript

import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
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,
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
/**
* Get deterministic MediaReference for a driver's avatar based on their ID
* Uses hash % 3 to determine variant: 0 -> male, 1 -> female, 2 -> neutral
*/
getDriverAvatarRef(driverId: string): MediaReference {
// Deterministic selection based on driver ID hash
const numericSuffixMatch = driverId.match(/(\d+)$/);
let avatarVariant: 'male' | 'female' | 'neutral';
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
const hashMod = numericSuffix % 3;
if (hashMod === 0) {
avatarVariant = 'male';
} else if (hashMod === 1) {
avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
}
} else {
// Fallback hash
let hash = 0;
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashMod = Math.abs(hash) % 3;
if (hashMod === 0) {
avatarVariant = 'male';
} else if (hashMod === 1) {
avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
}
}
// Create system-default reference with avatar variant
return MediaReference.systemDefault(avatarVariant);
}
create(): Driver[] {
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES', 'AU', 'BR', 'JP', 'KR', 'RU', 'PL', 'CZ', 'HU', 'AT', 'CH'] as const;
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1;
// 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 });
}
// Assign category - use all available categories
const category = faker.helpers.arrayElement(categories);
const driverId = seedId(`driver-${i}`, this.persistence);
const driverData: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt?: Date;
category?: string;
avatarRef: MediaReference;
} = {
id: driverId,
iracingId: String(100000 + i),
name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries),
joinedAt,
category,
avatarRef: this.getDriverAvatarRef(driverId),
};
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;
}
}