alpha wip
This commit is contained in:
99
apps/website/domain/entities/Driver.ts
Normal file
99
apps/website/domain/entities/Driver.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Domain Entity: Driver
|
||||
*
|
||||
* Represents a driver profile in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Driver {
|
||||
readonly id: string;
|
||||
readonly iracingId: string;
|
||||
readonly name: string;
|
||||
readonly country: string;
|
||||
readonly bio?: string;
|
||||
readonly joinedAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.iracingId = props.iracingId;
|
||||
this.name = props.name;
|
||||
this.country = props.country;
|
||||
this.bio = props.bio;
|
||||
this.joinedAt = props.joinedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Driver entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt?: Date;
|
||||
}): Driver {
|
||||
this.validate(props);
|
||||
|
||||
return new Driver({
|
||||
...props,
|
||||
joinedAt: props.joinedAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!props.iracingId || props.iracingId.trim().length === 0) {
|
||||
throw new Error('iRacing ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Driver name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Country code is required');
|
||||
}
|
||||
|
||||
// Validate ISO country code format (2-3 letters)
|
||||
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
|
||||
throw new Error('Country must be a valid ISO code (2-3 letters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
country: string;
|
||||
bio: string;
|
||||
}>): Driver {
|
||||
return new Driver({
|
||||
id: this.id,
|
||||
iracingId: this.iracingId,
|
||||
name: props.name ?? this.name,
|
||||
country: props.country ?? this.country,
|
||||
bio: props.bio ?? this.bio,
|
||||
joinedAt: this.joinedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
115
apps/website/domain/entities/League.ts
Normal file
115
apps/website/domain/entities/League.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Domain Entity: League
|
||||
*
|
||||
* Represents a league in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export interface LeagueSettings {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
}
|
||||
|
||||
export class League {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly ownerId: string;
|
||||
readonly settings: LeagueSettings;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.ownerId = props.ownerId;
|
||||
this.settings = props.settings;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new League entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
createdAt?: Date;
|
||||
}): League {
|
||||
this.validate(props);
|
||||
|
||||
const defaultSettings: LeagueSettings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
};
|
||||
|
||||
return new League({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
description: props.description,
|
||||
ownerId: props.ownerId,
|
||||
settings: { ...defaultSettings, ...props.settings },
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('League name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new Error('League name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.description || props.description.trim().length === 0) {
|
||||
throw new Error('League description is required');
|
||||
}
|
||||
|
||||
if (!props.ownerId || props.ownerId.trim().length === 0) {
|
||||
throw new Error('League owner ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
settings: LeagueSettings;
|
||||
}>): League {
|
||||
return new League({
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
description: props.description ?? this.description,
|
||||
ownerId: this.ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
createdAt: this.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
143
apps/website/domain/entities/Race.ts
Normal file
143
apps/website/domain/entities/Race.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Domain Entity: Race
|
||||
*
|
||||
* Represents a race/session in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
|
||||
|
||||
export class Race {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly car: string;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: RaceStatus;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.scheduledAt = props.scheduledAt;
|
||||
this.track = props.track;
|
||||
this.car = props.car;
|
||||
this.sessionType = props.sessionType;
|
||||
this.status = props.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Race entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType?: SessionType;
|
||||
status?: RaceStatus;
|
||||
}): Race {
|
||||
this.validate(props);
|
||||
|
||||
return new Race({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
car: props.car,
|
||||
sessionType: props.sessionType ?? 'race',
|
||||
status: props.status ?? 'scheduled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||
throw new Error('Valid scheduled date is required');
|
||||
}
|
||||
|
||||
if (!props.track || props.track.trim().length === 0) {
|
||||
throw new Error('Track is required');
|
||||
}
|
||||
|
||||
if (!props.car || props.car.trim().length === 0) {
|
||||
throw new Error('Car is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark race as completed
|
||||
*/
|
||||
complete(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Race is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Cannot complete a cancelled race');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the race
|
||||
*/
|
||||
cancel(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Cannot cancel a completed race');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Race is already cancelled');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in the past
|
||||
*/
|
||||
isPast(): boolean {
|
||||
return this.scheduledAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is upcoming
|
||||
*/
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
}
|
||||
113
apps/website/domain/entities/Result.ts
Normal file
113
apps/website/domain/entities/Result.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Domain Entity: Result
|
||||
*
|
||||
* Represents a race result in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Result {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
readonly driverId: string;
|
||||
readonly position: number;
|
||||
readonly fastestLap: number;
|
||||
readonly incidents: number;
|
||||
readonly startPosition: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.raceId = props.raceId;
|
||||
this.driverId = props.driverId;
|
||||
this.position = props.position;
|
||||
this.fastestLap = props.fastestLap;
|
||||
this.incidents = props.incidents;
|
||||
this.startPosition = props.startPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Result entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): Result {
|
||||
this.validate(props);
|
||||
|
||||
return new Result(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Result ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
}
|
||||
|
||||
if (props.fastestLap < 0) {
|
||||
throw new Error('Fastest lap cannot be negative');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
|
||||
throw new Error('Incidents must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||
throw new Error('Start position must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions gained/lost
|
||||
*/
|
||||
getPositionChange(): number {
|
||||
return this.startPosition - this.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver finished on podium
|
||||
*/
|
||||
isPodium(): boolean {
|
||||
return this.position <= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (0 incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents === 0;
|
||||
}
|
||||
}
|
||||
117
apps/website/domain/entities/Standing.ts
Normal file
117
apps/website/domain/entities/Standing.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Domain Entity: Standing
|
||||
*
|
||||
* Represents a championship standing in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Standing {
|
||||
readonly leagueId: string;
|
||||
readonly driverId: string;
|
||||
readonly points: number;
|
||||
readonly wins: number;
|
||||
readonly position: number;
|
||||
readonly racesCompleted: number;
|
||||
|
||||
private constructor(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
position: number;
|
||||
racesCompleted: number;
|
||||
}) {
|
||||
this.leagueId = props.leagueId;
|
||||
this.driverId = props.driverId;
|
||||
this.points = props.points;
|
||||
this.wins = props.wins;
|
||||
this.position = props.position;
|
||||
this.racesCompleted = props.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Standing entity
|
||||
*/
|
||||
static create(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points?: number;
|
||||
wins?: number;
|
||||
position?: number;
|
||||
racesCompleted?: number;
|
||||
}): Standing {
|
||||
this.validate(props);
|
||||
|
||||
return new Standing({
|
||||
leagueId: props.leagueId,
|
||||
driverId: props.driverId,
|
||||
points: props.points ?? 0,
|
||||
wins: props.wins ?? 0,
|
||||
position: props.position ?? 0,
|
||||
racesCompleted: props.racesCompleted ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
}): void {
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add points from a race result
|
||||
*/
|
||||
addRaceResult(position: number, pointsSystem: Record<number, number>): Standing {
|
||||
const racePoints = pointsSystem[position] ?? 0;
|
||||
const isWin = position === 1;
|
||||
|
||||
return new Standing({
|
||||
leagueId: this.leagueId,
|
||||
driverId: this.driverId,
|
||||
points: this.points + racePoints,
|
||||
wins: this.wins + (isWin ? 1 : 0),
|
||||
position: this.position,
|
||||
racesCompleted: this.racesCompleted + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship position
|
||||
*/
|
||||
updatePosition(position: number): Standing {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
}
|
||||
|
||||
return new Standing({
|
||||
...this,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average points per race
|
||||
*/
|
||||
getAveragePoints(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return this.points / this.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate win percentage
|
||||
*/
|
||||
getWinPercentage(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return (this.wins / this.racesCompleted) * 100;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user