refactor to adapters

This commit is contained in:
2025-12-15 18:34:20 +01:00
parent fc671482c8
commit c817d76092
145 changed files with 906 additions and 361 deletions

View File

@@ -0,0 +1,50 @@
import { randomUUID } from 'crypto';
import { createStaticRacingSeed } from '@gridpilot/testing-support';
import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort';
import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO';
import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO';
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
export class IracingDemoIdentityProviderAdapter implements IdentityProviderPort {
private readonly seedDriverId: string;
constructor() {
const seed = createStaticRacingSeed(42);
this.seedDriverId = seed.drivers[0]?.id ?? 'driver-1';
}
async startAuth(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> {
const state = randomUUID();
const params = new URLSearchParams();
params.set('code', 'dummy-code');
params.set('state', state);
if (command.returnTo) {
params.set('returnTo', command.returnTo);
}
return {
redirectUrl: `/auth/iracing/callback?${params.toString()}`,
state,
};
}
async completeAuth(command: AuthCallbackCommandDTO): Promise<AuthenticatedUserDTO> {
if (!command.code) {
throw new Error('Missing auth code');
}
if (!command.state) {
throw new Error('Missing auth state');
}
const user: AuthenticatedUserDTO = {
id: 'demo-user',
displayName: 'GridPilot Demo Driver',
iracingCustomerId: '000000',
primaryDriverId: this.seedDriverId,
avatarUrl: `/api/avatar/${this.seedDriverId}`,
};
return user;
}
}

View File

@@ -0,0 +1,124 @@
import type {
AvatarGenerationPort,
AvatarGenerationOptions,
AvatarGenerationResult,
} from '@gridpilot/media';
/**
* Demo implementation of AvatarGenerationPort.
*
* In production, this would use a real AI image generation API like:
* - OpenAI DALL-E
* - Midjourney API
* - Stable Diffusion
* - RunwayML
*
* For demo purposes, this returns placeholder avatar images.
*/
export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
private readonly placeholderAvatars: Record<string, string[]> = {
red: [
'/images/avatars/generated/red-1.png',
'/images/avatars/generated/red-2.png',
'/images/avatars/generated/red-3.png',
],
blue: [
'/images/avatars/generated/blue-1.png',
'/images/avatars/generated/blue-2.png',
'/images/avatars/generated/blue-3.png',
],
green: [
'/images/avatars/generated/green-1.png',
'/images/avatars/generated/green-2.png',
'/images/avatars/generated/green-3.png',
],
yellow: [
'/images/avatars/generated/yellow-1.png',
'/images/avatars/generated/yellow-2.png',
'/images/avatars/generated/yellow-3.png',
],
orange: [
'/images/avatars/generated/orange-1.png',
'/images/avatars/generated/orange-2.png',
'/images/avatars/generated/orange-3.png',
],
purple: [
'/images/avatars/generated/purple-1.png',
'/images/avatars/generated/purple-2.png',
'/images/avatars/generated/purple-3.png',
],
black: [
'/images/avatars/generated/black-1.png',
'/images/avatars/generated/black-2.png',
'/images/avatars/generated/black-3.png',
],
white: [
'/images/avatars/generated/white-1.png',
'/images/avatars/generated/white-2.png',
'/images/avatars/generated/white-3.png',
],
pink: [
'/images/avatars/generated/pink-1.png',
'/images/avatars/generated/pink-2.png',
'/images/avatars/generated/pink-3.png',
],
cyan: [
'/images/avatars/generated/cyan-1.png',
'/images/avatars/generated/cyan-2.png',
'/images/avatars/generated/cyan-3.png',
],
};
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
// Simulate AI processing time (1-3 seconds)
await this.delay(1500 + Math.random() * 1500);
// Log what would be sent to the AI (for debugging)
console.log('[DemoAvatarGeneration] Would generate with prompt:', options.prompt);
console.log('[DemoAvatarGeneration] Suit color:', options.suitColor);
console.log('[DemoAvatarGeneration] Style:', options.style);
console.log('[DemoAvatarGeneration] Count:', options.count);
// For demo, return placeholder URLs based on suit color
// In production, these would be actual AI-generated images
const colorAvatars = this.getPlaceholderAvatars(options.suitColor) ?? [];
// Generate unique URLs with a hash to simulate different generations
const hash = this.generateHash((options.facePhotoUrl ?? '') + Date.now());
const avatars = colorAvatars.slice(0, options.count).map((baseUrl, index) => {
// In demo mode, use dicebear or similar for generating varied avatars
const seed = `${hash}-${options.suitColor}-${index}`;
return {
url: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent`,
thumbnailUrl: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent&size=64`,
};
});
return {
success: true,
avatars,
};
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private getPlaceholderAvatars(color: string): string[] | undefined {
const avatars = this.placeholderAvatars[color];
if (!avatars || avatars.length === 0) {
return this.placeholderAvatars.blue;
}
return avatars;
}
private generateHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
}

View File

@@ -0,0 +1,62 @@
import type { FaceValidationPort, FaceValidationResult } from '@gridpilot/media';
/**
* Demo implementation of FaceValidationPort.
*
* In production, this would use a real face detection API like:
* - AWS Rekognition
* - Google Cloud Vision
* - Azure Face API
* - OpenCV / face-api.js
*
* For demo purposes, this always returns a valid face if the image data is provided.
*/
export class DemoFaceValidationAdapter implements FaceValidationPort {
async validateFacePhoto(imageData: string | Buffer): Promise<FaceValidationResult> {
// Simulate some processing time
await this.delay(500);
// Check if we have any image data
const dataString = typeof imageData === 'string' ? imageData : imageData.toString();
if (!dataString || dataString.length < 100) {
return {
isValid: false,
hasFace: false,
faceCount: 0,
confidence: 0,
errorMessage: 'Invalid or empty image data',
};
}
// Check for valid base64 image data or data URL
const isValidImage =
dataString.startsWith('data:image/') ||
dataString.startsWith('/9j/') || // JPEG magic bytes in base64
dataString.startsWith('iVBOR') || // PNG magic bytes in base64
dataString.length > 1000; // Assume long strings are valid image data
if (!isValidImage) {
return {
isValid: false,
hasFace: false,
faceCount: 0,
confidence: 0,
errorMessage: 'Please upload a valid image file (JPEG or PNG)',
};
}
// For demo: always return success with high confidence
// In production, this would actually analyze the image
return {
isValid: true,
hasFace: true,
faceCount: 1,
confidence: 0.95,
};
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,41 @@
import type { ImageServicePort } from '@gridpilot/media';
const MALE_DEFAULT_AVATAR = '/images/avatars/male-default-avatar.jpg';
const FEMALE_DEFAULT_AVATAR = '/images/avatars/female-default-avatar.jpeg';
export class DemoImageServiceAdapter implements ImageServicePort {
getDriverAvatar(driverId: string): string {
const numericSuffixMatch = driverId.match(/(\d+)$/);
if (numericSuffixMatch) {
const numericSuffixString = numericSuffixMatch[1] ?? '';
const numericSuffix = Number.parseInt(numericSuffixString, 10);
return numericSuffix % 2 === 0 ? FEMALE_DEFAULT_AVATAR : MALE_DEFAULT_AVATAR;
}
const seed = stableHash(driverId);
return seed % 2 === 0 ? FEMALE_DEFAULT_AVATAR : MALE_DEFAULT_AVATAR;
}
getTeamLogo(teamId: string): string {
const seed = stableHash(teamId);
return `https://picsum.photos/seed/team-${seed}/256/256`;
}
getLeagueCover(leagueId: string): string {
const seed = stableHash(leagueId);
return `https://picsum.photos/seed/league-cover-${seed}/1200/280?blur=2`;
}
getLeagueLogo(leagueId: string): string {
const seed = stableHash(leagueId);
return `https://picsum.photos/seed/league-logo-${seed}/160/160`;
}
}
function stableHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}

View File

@@ -0,0 +1,93 @@
import { Car } from '@gridpilot/racing/domain/entities/Car';
/**
* Demo car data for iRacing.
* Extracted from the legacy DemoData module so that cars
* live in their own focused file.
*/
export const DEMO_CARS: Car[] = [
Car.create({
id: 'car-porsche-992',
name: '911 GT3 R',
shortName: '992 GT3R',
manufacturer: 'Porsche',
carClass: 'gt',
license: 'B',
year: 2023,
horsepower: 565,
weight: 1300,
gameId: 'iracing',
}),
Car.create({
id: 'car-ferrari-296',
name: '296 GT3',
shortName: '296 GT3',
manufacturer: 'Ferrari',
carClass: 'gt',
license: 'B',
year: 2023,
horsepower: 600,
weight: 1270,
gameId: 'iracing',
}),
Car.create({
id: 'car-mclaren-720s',
name: '720S GT3 Evo',
shortName: '720S',
manufacturer: 'McLaren',
carClass: 'gt',
license: 'B',
year: 2023,
horsepower: 552,
weight: 1290,
gameId: 'iracing',
}),
Car.create({
id: 'car-mercedes-gt3',
name: 'AMG GT3 2020',
shortName: 'AMG GT3',
manufacturer: 'Mercedes',
carClass: 'gt',
license: 'B',
year: 2020,
horsepower: 550,
weight: 1285,
gameId: 'iracing',
}),
Car.create({
id: 'car-lmp2',
name: 'Dallara P217 LMP2',
shortName: 'LMP2',
manufacturer: 'Dallara',
carClass: 'prototype',
license: 'A',
year: 2021,
horsepower: 600,
weight: 930,
gameId: 'iracing',
}),
Car.create({
id: 'car-f4',
name: 'Formula 4',
shortName: 'F4',
manufacturer: 'Tatuus',
carClass: 'formula',
license: 'D',
year: 2022,
horsepower: 160,
weight: 570,
gameId: 'iracing',
}),
Car.create({
id: 'car-mx5',
name: 'MX-5 Cup',
shortName: 'MX5',
manufacturer: 'Mazda',
carClass: 'sports',
license: 'D',
year: 2023,
horsepower: 181,
weight: 1128,
gameId: 'iracing',
}),
];

View File

@@ -0,0 +1,75 @@
/**
* Driver statistics and ranking data used for demo seeding.
* Split out from the legacy DemoData module to keep responsibilities focused.
*/
export interface DriverStats {
driverId: string;
rating: number;
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
overallRank: number;
percentile: number;
}
/**
* Create demo driver statistics based on seed data.
* This is deterministic for a given driver ordering so it can be reused
* by any in-memory repository wiring.
*/
export function createDemoDriverStats(drivers: Array<{ id: string }>): Record<string, DriverStats> {
const stats: Record<string, DriverStats> = {};
drivers.forEach((driver, index) => {
const totalRaces = 40 + index * 5;
const wins = Math.max(0, Math.floor(totalRaces * 0.2) - index);
const podiums = Math.max(wins * 2, 0);
const dnfs = Math.max(0, Math.floor(index / 2));
const rating = 1500 + index * 25;
stats[driver.id] = {
driverId: driver.id,
rating,
totalRaces,
wins,
podiums,
dnfs,
avgFinish: 4,
bestFinish: 1,
worstFinish: 20,
consistency: 80,
overallRank: index + 1,
percentile: Math.max(0, 100 - index),
};
});
return stats;
}
/**
* Get league-specific rankings for a driver (demo implementation).
* In production this would be calculated from actual league membership
* and results; here we keep a very small static example for UI wiring.
*/
export function getDemoLeagueRankings(driverId: string, leagueId: string): {
rank: number;
totalDrivers: number;
percentile: number;
} {
// Mock league rankings (in production, calculate from actual league membership)
const mockLeagueRanks: Record<string, Record<string, any>> = {
'league-1': {
'driver-1': { rank: 1, totalDrivers: 12, percentile: 92 },
'driver-2': { rank: 2, totalDrivers: 12, percentile: 84 },
'driver-3': { rank: 4, totalDrivers: 12, percentile: 67 },
'driver-4': { rank: 5, totalDrivers: 12, percentile: 58 },
},
};
return mockLeagueRanks[leagueId]?.[driverId] || { rank: 0, totalDrivers: 0, percentile: 0 };
}

View File

@@ -0,0 +1,93 @@
import { Track } from '@gridpilot/racing/domain/entities/Track';
/**
* Demo track data for iRacing.
* Extracted from the legacy DemoData module so that tracks
* live in their own focused file.
*/
export const DEMO_TRACKS: Track[] = [
Track.create({
id: 'track-spa',
name: 'Spa-Francorchamps',
shortName: 'SPA',
country: 'Belgium',
category: 'road',
difficulty: 'advanced',
lengthKm: 7.004,
turns: 19,
imageUrl: '/images/tracks/spa.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-monza',
name: 'Autodromo Nazionale Monza',
shortName: 'MON',
country: 'Italy',
category: 'road',
difficulty: 'intermediate',
lengthKm: 5.793,
turns: 11,
imageUrl: '/images/tracks/monza.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-nurburgring',
name: 'Nürburgring Grand Prix',
shortName: 'NUR',
country: 'Germany',
category: 'road',
difficulty: 'advanced',
lengthKm: 5.148,
turns: 15,
imageUrl: '/images/tracks/nurburgring.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-silverstone',
name: 'Silverstone Circuit',
shortName: 'SIL',
country: 'United Kingdom',
category: 'road',
difficulty: 'intermediate',
lengthKm: 5.891,
turns: 18,
imageUrl: '/images/tracks/silverstone.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-suzuka',
name: 'Suzuka International Racing Course',
shortName: 'SUZ',
country: 'Japan',
category: 'road',
difficulty: 'expert',
lengthKm: 5.807,
turns: 18,
imageUrl: '/images/tracks/suzuka.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-daytona',
name: 'Daytona International Speedway',
shortName: 'DAY',
country: 'United States',
category: 'oval',
difficulty: 'intermediate',
lengthKm: 4.023,
turns: 4,
imageUrl: '/images/tracks/daytona.jpg',
gameId: 'iracing',
}),
Track.create({
id: 'track-laguna',
name: 'WeatherTech Raceway Laguna Seca',
shortName: 'LAG',
country: 'United States',
category: 'road',
difficulty: 'advanced',
lengthKm: 3.602,
turns: 11,
imageUrl: '/images/tracks/laguna.jpg',
gameId: 'iracing',
}),
];