wip
This commit is contained in:
1
packages/demo-infrastructure/index.ts
Normal file
1
packages/demo-infrastructure/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './media/DemoImageServiceAdapter';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ImageServicePort } from '@gridpilot/media';
|
||||
|
||||
export class DemoImageServiceAdapter implements ImageServicePort {
|
||||
getDriverAvatar(driverId: string): string {
|
||||
const seed = stableHash(driverId);
|
||||
return `https://picsum.photos/seed/driver-${seed}/128/128`;
|
||||
}
|
||||
|
||||
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++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
15
packages/demo-infrastructure/package.json
Normal file
15
packages/demo-infrastructure/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@gridpilot/demo-infrastructure",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"dependencies": {
|
||||
"@gridpilot/media": "file:../media",
|
||||
"@faker-js/faker": "^9.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./media/*": "./media/*"
|
||||
}
|
||||
}
|
||||
13
packages/demo-infrastructure/tsconfig.json
Normal file
13
packages/demo-infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"../../packages/demo-infrastructure/**/*.ts",
|
||||
"../../packages/media/**/*.ts"
|
||||
]
|
||||
}
|
||||
6
packages/media/application/ports/ImageServicePort.ts
Normal file
6
packages/media/application/ports/ImageServicePort.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ImageServicePort {
|
||||
getDriverAvatar(driverId: string): string;
|
||||
getTeamLogo(teamId: string): string;
|
||||
getLeagueCover(leagueId: string): string;
|
||||
getLeagueLogo(leagueId: string): string;
|
||||
}
|
||||
1
packages/media/index.ts
Normal file
1
packages/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './application/ports/ImageServicePort';
|
||||
11
packages/media/package.json
Normal file
11
packages/media/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@gridpilot/media",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./application/*": "./application/*"
|
||||
}
|
||||
}
|
||||
10
packages/media/tsconfig.json
Normal file
10
packages/media/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
16
packages/racing/application/dto/ChampionshipStandingsDTO.ts
Normal file
16
packages/racing/application/dto/ChampionshipStandingsDTO.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
||||
|
||||
export interface ChampionshipStandingsRowDTO {
|
||||
participant: ParticipantRef;
|
||||
position: number;
|
||||
totalPoints: number;
|
||||
resultsCounted: number;
|
||||
resultsDropped: number;
|
||||
}
|
||||
|
||||
export interface ChampionshipStandingsDTO {
|
||||
seasonId: string;
|
||||
championshipId: string;
|
||||
championshipName: string;
|
||||
rows: ChampionshipStandingsRowDTO[];
|
||||
}
|
||||
@@ -8,6 +8,17 @@ export type LeagueDTO = {
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
maxDrivers?: number;
|
||||
};
|
||||
createdAt: string;
|
||||
socialLinks?: {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
/**
|
||||
* Number of active driver slots currently used in this league.
|
||||
* Populated by capacity-aware queries such as GetAllLeaguesWithCapacityQuery.
|
||||
*/
|
||||
usedSlots?: number;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export type LeagueDriverSeasonStatsDTO = {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
driverName: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
totalPoints: number;
|
||||
basePoints: number;
|
||||
penaltyPoints: number;
|
||||
bonusPoints: number;
|
||||
pointsPerRace: number;
|
||||
racesStarted: number;
|
||||
racesFinished: number;
|
||||
dnfs: number;
|
||||
noShows: number;
|
||||
avgFinish: number | null;
|
||||
rating: number | null;
|
||||
ratingChange: number | null;
|
||||
};
|
||||
@@ -14,6 +14,10 @@ export * from './use-cases/GetTeamDetailsQuery';
|
||||
export * from './use-cases/GetTeamMembersQuery';
|
||||
export * from './use-cases/GetTeamJoinRequestsQuery';
|
||||
export * from './use-cases/GetDriverTeamQuery';
|
||||
export * from './use-cases/GetLeagueStandingsQuery';
|
||||
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
|
||||
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
@@ -37,4 +41,9 @@ export type { DriverDTO } from './dto/DriverDTO';
|
||||
export type { LeagueDTO } from './dto/LeagueDTO';
|
||||
export type { RaceDTO } from './dto/RaceDTO';
|
||||
export type { ResultDTO } from './dto/ResultDTO';
|
||||
export type { StandingDTO } from './dto/StandingDTO';
|
||||
export type { StandingDTO } from './dto/StandingDTO';
|
||||
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
|
||||
export type {
|
||||
ChampionshipStandingsDTO,
|
||||
ChampionshipStandingsRowDTO,
|
||||
} from './dto/ChampionshipStandingsDTO';
|
||||
@@ -38,6 +38,15 @@ export class EntityMappers {
|
||||
ownerId: league.ownerId,
|
||||
settings: league.settings,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
socialLinks: league.socialLinks
|
||||
? {
|
||||
discordUrl: league.socialLinks.discordUrl,
|
||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
||||
websiteUrl: league.socialLinks.websiteUrl,
|
||||
}
|
||||
: undefined,
|
||||
// usedSlots is populated by capacity-aware queries, so leave undefined here
|
||||
usedSlots: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +58,14 @@ export class EntityMappers {
|
||||
ownerId: league.ownerId,
|
||||
settings: league.settings,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
socialLinks: league.socialLinks
|
||||
? {
|
||||
discordUrl: league.socialLinks.discordUrl,
|
||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
||||
websiteUrl: league.socialLinks.websiteUrl,
|
||||
}
|
||||
: undefined,
|
||||
usedSlots: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { LeagueDTO } from '../dto/LeagueDTO';
|
||||
|
||||
export class GetAllLeaguesWithCapacityQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<LeagueDTO[]> {
|
||||
const leagues = await this.leagueRepository.findAll();
|
||||
|
||||
const results: LeagueDTO[] = [];
|
||||
|
||||
for (const league of leagues) {
|
||||
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
|
||||
|
||||
const usedSlots = members.filter(
|
||||
(m) =>
|
||||
m.status === 'active' &&
|
||||
(m.role === 'owner' ||
|
||||
m.role === 'admin' ||
|
||||
m.role === 'steward' ||
|
||||
m.role === 'member'),
|
||||
).length;
|
||||
|
||||
// Ensure we never expose an impossible state like 26/24:
|
||||
// clamp maxDrivers to at least usedSlots at the application boundary.
|
||||
const configuredMax = league.settings.maxDrivers ?? usedSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
|
||||
|
||||
const dto: LeagueDTO = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
settings: {
|
||||
...league.settings,
|
||||
maxDrivers: safeMaxDrivers,
|
||||
},
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
socialLinks: league.socialLinks
|
||||
? {
|
||||
discordUrl: league.socialLinks.discordUrl,
|
||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
||||
websiteUrl: league.socialLinks.websiteUrl,
|
||||
}
|
||||
: undefined,
|
||||
usedSlots,
|
||||
};
|
||||
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||
import type { LeagueDriverSeasonStatsDTO } from '../dto/LeagueDriverSeasonStatsDTO';
|
||||
|
||||
export interface DriverRatingPort {
|
||||
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
|
||||
}
|
||||
|
||||
export interface GetLeagueDriverSeasonStatsQueryParamsDTO {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export class GetLeagueDriverSeasonStatsQuery {
|
||||
constructor(
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
private readonly driverRatingPort: DriverRatingPort,
|
||||
) {}
|
||||
|
||||
async execute(params: GetLeagueDriverSeasonStatsQueryParamsDTO): Promise<LeagueDriverSeasonStatsDTO[]> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const [standings, penaltiesForLeague] = await Promise.all([
|
||||
this.standingRepository.findByLeagueId(leagueId),
|
||||
this.penaltyRepository.findByLeagueId(leagueId),
|
||||
]);
|
||||
|
||||
// Group penalties by driver for quick lookup
|
||||
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
|
||||
for (const p of penaltiesForLeague) {
|
||||
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
|
||||
if (p.pointsDelta < 0) {
|
||||
current.baseDelta += p.pointsDelta;
|
||||
} else {
|
||||
current.bonusDelta += p.pointsDelta;
|
||||
}
|
||||
penaltiesByDriver.set(p.driverId, current);
|
||||
}
|
||||
|
||||
// Build basic stats per driver from standings
|
||||
const statsByDriver = new Map<string, LeagueDriverSeasonStatsDTO>();
|
||||
|
||||
for (const standing of standings) {
|
||||
const penalty = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
|
||||
const totalPenaltyPoints = penalty.baseDelta;
|
||||
const bonusPoints = penalty.bonusDelta;
|
||||
|
||||
const racesCompleted = standing.racesCompleted;
|
||||
const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0;
|
||||
|
||||
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
|
||||
|
||||
const dto: LeagueDriverSeasonStatsDTO = {
|
||||
leagueId,
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
driverName: '',
|
||||
teamId: undefined,
|
||||
teamName: undefined,
|
||||
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
|
||||
basePoints: standing.points,
|
||||
penaltyPoints: Math.abs(totalPenaltyPoints),
|
||||
bonusPoints,
|
||||
pointsPerRace,
|
||||
racesStarted: racesCompleted,
|
||||
racesFinished: racesCompleted,
|
||||
dnfs: 0,
|
||||
noShows: 0,
|
||||
avgFinish: null,
|
||||
rating: ratingInfo.rating,
|
||||
ratingChange: ratingInfo.ratingChange,
|
||||
};
|
||||
|
||||
statsByDriver.set(standing.driverId, dto);
|
||||
}
|
||||
|
||||
// Enhance stats with basic finish-position-based avgFinish from results
|
||||
for (const [driverId, dto] of statsByDriver.entries()) {
|
||||
const driverResults = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId);
|
||||
if (driverResults.length > 0) {
|
||||
const totalPositions = driverResults.reduce((sum, r) => sum + r.position, 0);
|
||||
const avgFinish = totalPositions / driverResults.length;
|
||||
dto.avgFinish = Number.isFinite(avgFinish) ? Number(avgFinish.toFixed(2)) : null;
|
||||
dto.racesStarted = driverResults.length;
|
||||
dto.racesFinished = driverResults.length;
|
||||
}
|
||||
statsByDriver.set(driverId, dto);
|
||||
}
|
||||
|
||||
// Ensure ordering by position
|
||||
const result = Array.from(statsByDriver.values()).sort((a, b) => a.position - b.position);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { StandingDTO } from '../dto/StandingDTO';
|
||||
import { EntityMappers } from '../mappers/EntityMappers';
|
||||
|
||||
export interface GetLeagueStandingsQueryParamsDTO {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export class GetLeagueStandingsQuery {
|
||||
constructor(
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
) {}
|
||||
|
||||
async execute(params: GetLeagueStandingsQueryParamsDTO): Promise<StandingDTO[]> {
|
||||
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
|
||||
return EntityMappers.toStandingDTOs(standings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
|
||||
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
|
||||
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
|
||||
|
||||
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
|
||||
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
|
||||
import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
|
||||
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
|
||||
import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator';
|
||||
|
||||
import type {
|
||||
ChampionshipStandingsDTO,
|
||||
ChampionshipStandingsRowDTO,
|
||||
} from '../dto/ChampionshipStandingsDTO';
|
||||
|
||||
export class RecalculateChampionshipStandingsUseCase {
|
||||
constructor(
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
private readonly championshipStandingRepository: IChampionshipStandingRepository,
|
||||
private readonly eventScoringService: EventScoringService,
|
||||
private readonly championshipAggregator: ChampionshipAggregator,
|
||||
) {}
|
||||
|
||||
async execute(params: {
|
||||
seasonId: string;
|
||||
championshipId: string;
|
||||
}): Promise<ChampionshipStandingsDTO> {
|
||||
const { seasonId, championshipId } = params;
|
||||
|
||||
const season = await this.seasonRepository.findById(seasonId);
|
||||
if (!season) {
|
||||
throw new Error(`Season not found: ${seasonId}`);
|
||||
}
|
||||
|
||||
const leagueScoringConfig =
|
||||
await this.leagueScoringConfigRepository.findBySeasonId(seasonId);
|
||||
if (!leagueScoringConfig) {
|
||||
throw new Error(`League scoring config not found for season: ${seasonId}`);
|
||||
}
|
||||
|
||||
const championship = this.findChampionshipConfig(
|
||||
leagueScoringConfig.championships,
|
||||
championshipId,
|
||||
);
|
||||
|
||||
const races = await this.raceRepository.findByLeagueId(season.leagueId);
|
||||
|
||||
const eventPointsByEventId: Record<string, ReturnType<EventScoringService['scoreSession']>> =
|
||||
{};
|
||||
|
||||
for (const race of races) {
|
||||
// Map existing Race.sessionType into scoring SessionType where possible.
|
||||
const sessionType = this.mapRaceSessionType(race.sessionType);
|
||||
if (!championship.sessionTypes.includes(sessionType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const results = await this.resultRepository.findByRaceId(race.id);
|
||||
|
||||
// For this slice, penalties are league-level and not race-specific,
|
||||
// so we simply ignore them in the use case to keep behavior minimal.
|
||||
const penalties = await this.penaltyRepository.findByLeagueId(season.leagueId);
|
||||
|
||||
const participantPoints = this.eventScoringService.scoreSession({
|
||||
seasonId,
|
||||
championship,
|
||||
sessionType,
|
||||
results,
|
||||
penalties,
|
||||
});
|
||||
|
||||
eventPointsByEventId[race.id] = participantPoints;
|
||||
}
|
||||
|
||||
const standings: ChampionshipStanding[] = this.championshipAggregator.aggregate({
|
||||
seasonId,
|
||||
championship,
|
||||
eventPointsByEventId,
|
||||
});
|
||||
|
||||
await this.championshipStandingRepository.saveAll(standings);
|
||||
|
||||
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
|
||||
participant: s.participant,
|
||||
position: s.position,
|
||||
totalPoints: s.totalPoints,
|
||||
resultsCounted: s.resultsCounted,
|
||||
resultsDropped: s.resultsDropped,
|
||||
}));
|
||||
|
||||
const dto: ChampionshipStandingsDTO = {
|
||||
seasonId,
|
||||
championshipId: championship.id,
|
||||
championshipName: championship.name,
|
||||
rows,
|
||||
};
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private findChampionshipConfig(
|
||||
configs: ChampionshipConfig[],
|
||||
championshipId: string,
|
||||
): ChampionshipConfig {
|
||||
const found = configs.find((c) => c.id === championshipId);
|
||||
if (!found) {
|
||||
throw new Error(`Championship config not found: ${championshipId}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private mapRaceSessionType(sessionType: string): SessionType {
|
||||
if (sessionType === 'race') {
|
||||
return 'main';
|
||||
}
|
||||
if (
|
||||
sessionType === 'practice' ||
|
||||
sessionType === 'qualifying' ||
|
||||
sessionType === 'timeTrial'
|
||||
) {
|
||||
return sessionType;
|
||||
}
|
||||
return 'main';
|
||||
}
|
||||
}
|
||||
41
packages/racing/domain/entities/ChampionshipStanding.ts
Normal file
41
packages/racing/domain/entities/ChampionshipStanding.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ParticipantRef } from '../value-objects/ParticipantRef';
|
||||
|
||||
export class ChampionshipStanding {
|
||||
readonly seasonId: string;
|
||||
readonly championshipId: string;
|
||||
readonly participant: ParticipantRef;
|
||||
readonly totalPoints: number;
|
||||
readonly resultsCounted: number;
|
||||
readonly resultsDropped: number;
|
||||
readonly position: number;
|
||||
|
||||
constructor(props: {
|
||||
seasonId: string;
|
||||
championshipId: string;
|
||||
participant: ParticipantRef;
|
||||
totalPoints: number;
|
||||
resultsCounted: number;
|
||||
resultsDropped: number;
|
||||
position: number;
|
||||
}) {
|
||||
this.seasonId = props.seasonId;
|
||||
this.championshipId = props.championshipId;
|
||||
this.participant = props.participant;
|
||||
this.totalPoints = props.totalPoints;
|
||||
this.resultsCounted = props.resultsCounted;
|
||||
this.resultsDropped = props.resultsDropped;
|
||||
this.position = props.position;
|
||||
}
|
||||
|
||||
withPosition(position: number): ChampionshipStanding {
|
||||
return new ChampionshipStanding({
|
||||
seasonId: this.seasonId,
|
||||
championshipId: this.championshipId,
|
||||
participant: this.participant,
|
||||
totalPoints: this.totalPoints,
|
||||
resultsCounted: this.resultsCounted,
|
||||
resultsDropped: this.resultsDropped,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
packages/racing/domain/entities/Game.ts
Normal file
24
packages/racing/domain/entities/Game.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class Game {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
private constructor(props: { id: string; name: string }) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
}
|
||||
|
||||
static create(props: { id: string; name: string }): Game {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Game name is required');
|
||||
}
|
||||
|
||||
return new Game({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ export interface LeagueSettings {
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
/**
|
||||
* Maximum number of drivers allowed in the league.
|
||||
* Used for simple capacity display on the website.
|
||||
*/
|
||||
maxDrivers?: number;
|
||||
}
|
||||
|
||||
export interface LeagueSocialLinks {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export class League {
|
||||
@@ -19,6 +30,7 @@ export class League {
|
||||
readonly ownerId: string;
|
||||
readonly settings: LeagueSettings;
|
||||
readonly createdAt: Date;
|
||||
readonly socialLinks?: LeagueSocialLinks;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
@@ -27,6 +39,7 @@ export class League {
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
createdAt: Date;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
@@ -34,6 +47,7 @@ export class League {
|
||||
this.ownerId = props.ownerId;
|
||||
this.settings = props.settings;
|
||||
this.createdAt = props.createdAt;
|
||||
this.socialLinks = props.socialLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +60,7 @@ export class League {
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
createdAt?: Date;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
}): League {
|
||||
this.validate(props);
|
||||
|
||||
@@ -53,6 +68,7 @@ export class League {
|
||||
pointsSystem: 'f1-2024',
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
maxDrivers: 32,
|
||||
};
|
||||
|
||||
return new League({
|
||||
@@ -62,6 +78,7 @@ export class League {
|
||||
ownerId: props.ownerId,
|
||||
settings: { ...defaultSettings, ...props.settings },
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
socialLinks: props.socialLinks,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +119,7 @@ export class League {
|
||||
name: string;
|
||||
description: string;
|
||||
settings: LeagueSettings;
|
||||
socialLinks: LeagueSocialLinks | undefined;
|
||||
}>): League {
|
||||
return new League({
|
||||
id: this.id,
|
||||
@@ -110,6 +128,7 @@ export class League {
|
||||
ownerId: this.ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
createdAt: this.createdAt,
|
||||
socialLinks: props.socialLinks ?? this.socialLinks,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
packages/racing/domain/entities/LeagueScoringConfig.ts
Normal file
7
packages/racing/domain/entities/LeagueScoringConfig.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
|
||||
export interface LeagueScoringConfig {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
29
packages/racing/domain/entities/Penalty.ts
Normal file
29
packages/racing/domain/entities/Penalty.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Domain Entity: Penalty
|
||||
*
|
||||
* Represents a season-long penalty or bonus applied to a driver
|
||||
* within a specific league. This is intentionally simple for the
|
||||
* alpha demo and models points adjustments only.
|
||||
*/
|
||||
export type PenaltyType = 'points-deduction' | 'points-bonus';
|
||||
|
||||
export interface Penalty {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
type: PenaltyType;
|
||||
/**
|
||||
* Signed integer representing points adjustment:
|
||||
* - negative for deductions
|
||||
* - positive for bonuses
|
||||
*/
|
||||
pointsDelta: number;
|
||||
/**
|
||||
* Optional short reason/label (e.g. "Incident penalty", "Fastest laps bonus").
|
||||
*/
|
||||
reason?: string;
|
||||
/**
|
||||
* When this penalty was applied.
|
||||
*/
|
||||
appliedAt: Date;
|
||||
}
|
||||
77
packages/racing/domain/entities/Season.ts
Normal file
77
packages/racing/domain/entities/Season.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type SeasonStatus = 'planned' | 'active' | 'completed';
|
||||
|
||||
export class Season {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly gameId: string;
|
||||
readonly name: string;
|
||||
readonly year?: number;
|
||||
readonly order?: number;
|
||||
readonly status: SeasonStatus;
|
||||
readonly startDate?: Date;
|
||||
readonly endDate?: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.gameId = props.gameId;
|
||||
this.name = props.name;
|
||||
this.year = props.year;
|
||||
this.order = props.order;
|
||||
this.status = props.status;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status?: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Season {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Season ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('Season leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Season gameId is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Season name is required');
|
||||
}
|
||||
|
||||
const status: SeasonStatus = props.status ?? 'planned';
|
||||
|
||||
return new Season({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
gameId: props.gameId,
|
||||
name: props.name,
|
||||
year: props.year,
|
||||
order: props.order,
|
||||
status,
|
||||
startDate: props.startDate,
|
||||
endDate: props.endDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ChampionshipStanding } from '../entities/ChampionshipStanding';
|
||||
|
||||
export interface IChampionshipStandingRepository {
|
||||
findBySeasonAndChampionship(
|
||||
seasonId: string,
|
||||
championshipId: string,
|
||||
): Promise<ChampionshipStanding[]>;
|
||||
|
||||
saveAll(standings: ChampionshipStanding[]): Promise<void>;
|
||||
}
|
||||
6
packages/racing/domain/repositories/IGameRepository.ts
Normal file
6
packages/racing/domain/repositories/IGameRepository.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Game } from '../entities/Game';
|
||||
|
||||
export interface IGameRepository {
|
||||
findById(id: string): Promise<Game | null>;
|
||||
findAll(): Promise<Game[]>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
}
|
||||
25
packages/racing/domain/repositories/IPenaltyRepository.ts
Normal file
25
packages/racing/domain/repositories/IPenaltyRepository.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Application Port: IPenaltyRepository
|
||||
*
|
||||
* Repository interface for season-long penalties and bonuses applied
|
||||
* to drivers within a league. This is intentionally simple for the
|
||||
* alpha demo and operates purely on in-memory data.
|
||||
*/
|
||||
import type { Penalty } from '../entities/Penalty';
|
||||
|
||||
export interface IPenaltyRepository {
|
||||
/**
|
||||
* Get all penalties for a given league.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Get all penalties for a driver in a specific league.
|
||||
*/
|
||||
findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Get all penalties in the system.
|
||||
*/
|
||||
findAll(): Promise<Penalty[]>;
|
||||
}
|
||||
6
packages/racing/domain/repositories/ISeasonRepository.ts
Normal file
6
packages/racing/domain/repositories/ISeasonRepository.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Season } from '../entities/Season';
|
||||
|
||||
export interface ISeasonRepository {
|
||||
findById(id: string): Promise<Season | null>;
|
||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||
}
|
||||
71
packages/racing/domain/services/ChampionshipAggregator.ts
Normal file
71
packages/racing/domain/services/ChampionshipAggregator.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
import type { ParticipantRef } from '../value-objects/ParticipantRef';
|
||||
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
|
||||
import type { ParticipantEventPoints } from './EventScoringService';
|
||||
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';
|
||||
|
||||
export class ChampionshipAggregator {
|
||||
constructor(private readonly dropScoreApplier: DropScoreApplier) {}
|
||||
|
||||
aggregate(params: {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
eventPointsByEventId: Record<string, ParticipantEventPoints[]>;
|
||||
}): ChampionshipStanding[] {
|
||||
const { seasonId, championship, eventPointsByEventId } = params;
|
||||
|
||||
const perParticipantEvents = new Map<
|
||||
string,
|
||||
{ participant: ParticipantRef; events: EventPointsEntry[] }
|
||||
>();
|
||||
|
||||
for (const [eventId, pointsList] of Object.entries(eventPointsByEventId)) {
|
||||
for (const entry of pointsList) {
|
||||
const key = entry.participant.id;
|
||||
const existing = perParticipantEvents.get(key);
|
||||
const eventEntry: EventPointsEntry = {
|
||||
eventId,
|
||||
points: entry.totalPoints,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.events.push(eventEntry);
|
||||
} else {
|
||||
perParticipantEvents.set(key, {
|
||||
participant: entry.participant,
|
||||
events: [eventEntry],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standings: ChampionshipStanding[] = [];
|
||||
|
||||
for (const { participant, events } of perParticipantEvents.values()) {
|
||||
const dropResult = this.dropScoreApplier.apply(
|
||||
championship.dropScorePolicy,
|
||||
events,
|
||||
);
|
||||
|
||||
const totalPoints = dropResult.totalPoints;
|
||||
const resultsCounted = dropResult.counted.length;
|
||||
const resultsDropped = dropResult.dropped.length;
|
||||
|
||||
standings.push(
|
||||
new ChampionshipStanding({
|
||||
seasonId,
|
||||
championshipId: championship.id,
|
||||
participant,
|
||||
totalPoints,
|
||||
resultsCounted,
|
||||
resultsDropped,
|
||||
position: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
standings.sort((a, b) => b.totalPoints - a.totalPoints);
|
||||
|
||||
return standings.map((s, index) => s.withPosition(index + 1));
|
||||
}
|
||||
}
|
||||
56
packages/racing/domain/services/DropScoreApplier.ts
Normal file
56
packages/racing/domain/services/DropScoreApplier.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { DropScorePolicy } from '../value-objects/DropScorePolicy';
|
||||
|
||||
export interface EventPointsEntry {
|
||||
eventId: string;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface DropScoreResult {
|
||||
counted: EventPointsEntry[];
|
||||
dropped: EventPointsEntry[];
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export class DropScoreApplier {
|
||||
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
|
||||
if (policy.strategy === 'none' || events.length === 0) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults') {
|
||||
const count = policy.count ?? events.length;
|
||||
if (count >= events.length) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...events].sort((a, b) => b.points - a.points);
|
||||
const counted = sorted.slice(0, count);
|
||||
const dropped = sorted.slice(count);
|
||||
const totalPoints = counted.reduce((sum, e) => sum + e.points, 0);
|
||||
|
||||
return {
|
||||
counted,
|
||||
dropped,
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
// For this slice, treat unsupported strategies as 'none'
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
}
|
||||
128
packages/racing/domain/services/EventScoringService.ts
Normal file
128
packages/racing/domain/services/EventScoringService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
import type { SessionType } from '../value-objects/SessionType';
|
||||
import type { ParticipantRef } from '../value-objects/ParticipantRef';
|
||||
import type { Result } from '../entities/Result';
|
||||
import type { Penalty } from '../entities/Penalty';
|
||||
import type { BonusRule } from '../value-objects/BonusRule';
|
||||
import type { ChampionshipType } from '../value-objects/ChampionshipType';
|
||||
|
||||
import type { PointsTable } from '../value-objects/PointsTable';
|
||||
|
||||
export interface ParticipantEventPoints {
|
||||
participant: ParticipantRef;
|
||||
basePoints: number;
|
||||
bonusPoints: number;
|
||||
penaltyPoints: number;
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
function createDriverParticipant(driverId: string): ParticipantRef {
|
||||
return {
|
||||
type: 'driver' as ChampionshipType,
|
||||
id: driverId,
|
||||
};
|
||||
}
|
||||
|
||||
export class EventScoringService {
|
||||
scoreSession(params: {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
sessionType: SessionType;
|
||||
results: Result[];
|
||||
penalties: Penalty[];
|
||||
}): ParticipantEventPoints[] {
|
||||
const { championship, sessionType, results } = params;
|
||||
|
||||
const pointsTable = this.getPointsTableForSession(championship, sessionType);
|
||||
const bonusRules = this.getBonusRulesForSession(championship, sessionType);
|
||||
|
||||
const baseByDriver = new Map<string, number>();
|
||||
const bonusByDriver = new Map<string, number>();
|
||||
const penaltyByDriver = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
const driverId = result.driverId;
|
||||
const currentBase = baseByDriver.get(driverId) ?? 0;
|
||||
const added = pointsTable.getPointsForPosition(result.position);
|
||||
baseByDriver.set(driverId, currentBase + added);
|
||||
}
|
||||
|
||||
const fastestLapRule = bonusRules.find((r) => r.type === 'fastestLap');
|
||||
if (fastestLapRule) {
|
||||
this.applyFastestLapBonus(fastestLapRule, results, bonusByDriver);
|
||||
}
|
||||
|
||||
const penaltyMap = this.aggregatePenalties(params.penalties);
|
||||
for (const [driverId, value] of penaltyMap.entries()) {
|
||||
penaltyByDriver.set(driverId, value);
|
||||
}
|
||||
|
||||
const allDriverIds = new Set<string>();
|
||||
for (const id of baseByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of bonusByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of penaltyByDriver.keys()) allDriverIds.add(id);
|
||||
|
||||
const participants: ParticipantEventPoints[] = [];
|
||||
for (const driverId of allDriverIds) {
|
||||
const basePoints = baseByDriver.get(driverId) ?? 0;
|
||||
const bonusPoints = bonusByDriver.get(driverId) ?? 0;
|
||||
const penaltyPoints = penaltyByDriver.get(driverId) ?? 0;
|
||||
const totalPoints = basePoints + bonusPoints - penaltyPoints;
|
||||
|
||||
participants.push({
|
||||
participant: createDriverParticipant(driverId),
|
||||
basePoints,
|
||||
bonusPoints,
|
||||
penaltyPoints,
|
||||
totalPoints,
|
||||
});
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
private getPointsTableForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): PointsTable {
|
||||
return championship.pointsTableBySessionType[sessionType];
|
||||
}
|
||||
|
||||
private getBonusRulesForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): BonusRule[] {
|
||||
const all = championship.bonusRulesBySessionType ?? {};
|
||||
return all[sessionType] ?? [];
|
||||
}
|
||||
|
||||
private applyFastestLapBonus(
|
||||
rule: BonusRule,
|
||||
results: Result[],
|
||||
bonusByDriver: Map<string, number>,
|
||||
): void {
|
||||
if (results.length === 0) return;
|
||||
|
||||
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
|
||||
const best = sortedByLap[0];
|
||||
|
||||
const requiresTop = rule.requiresFinishInTopN;
|
||||
if (typeof requiresTop === 'number') {
|
||||
if (best.position <= 0 || best.position > requiresTop) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const current = bonusByDriver.get(best.driverId) ?? 0;
|
||||
bonusByDriver.set(best.driverId, current + rule.points);
|
||||
}
|
||||
|
||||
private aggregatePenalties(penalties: Penalty[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const penalty of penalties) {
|
||||
const current = map.get(penalty.driverId) ?? 0;
|
||||
map.set(penalty.driverId, current + penalty.pointsDelta);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
8
packages/racing/domain/value-objects/BonusRule.ts
Normal file
8
packages/racing/domain/value-objects/BonusRule.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
|
||||
|
||||
export interface BonusRule {
|
||||
id: string;
|
||||
type: BonusRuleType;
|
||||
points: number;
|
||||
requiresFinishInTopN?: number;
|
||||
}
|
||||
15
packages/racing/domain/value-objects/ChampionshipConfig.ts
Normal file
15
packages/racing/domain/value-objects/ChampionshipConfig.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ChampionshipType } from './ChampionshipType';
|
||||
import type { SessionType } from './SessionType';
|
||||
import { PointsTable } from './PointsTable';
|
||||
import type { BonusRule } from './BonusRule';
|
||||
import type { DropScorePolicy } from './DropScorePolicy';
|
||||
|
||||
export interface ChampionshipConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChampionshipType;
|
||||
sessionTypes: SessionType[];
|
||||
pointsTableBySessionType: Record<SessionType, PointsTable>;
|
||||
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
|
||||
dropScorePolicy: DropScorePolicy;
|
||||
}
|
||||
1
packages/racing/domain/value-objects/ChampionshipType.ts
Normal file
1
packages/racing/domain/value-objects/ChampionshipType.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';
|
||||
13
packages/racing/domain/value-objects/DropScorePolicy.ts
Normal file
13
packages/racing/domain/value-objects/DropScorePolicy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
|
||||
|
||||
export interface DropScorePolicy {
|
||||
strategy: DropScoreStrategy;
|
||||
/**
|
||||
* For 'bestNResults': number of best-scoring events to count.
|
||||
*/
|
||||
count?: number;
|
||||
/**
|
||||
* For 'dropWorstN': number of worst-scoring events to drop.
|
||||
*/
|
||||
dropCount?: number;
|
||||
}
|
||||
6
packages/racing/domain/value-objects/ParticipantRef.ts
Normal file
6
packages/racing/domain/value-objects/ParticipantRef.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ChampionshipType } from './ChampionshipType';
|
||||
|
||||
export interface ParticipantRef {
|
||||
type: ChampionshipType;
|
||||
id: string;
|
||||
}
|
||||
21
packages/racing/domain/value-objects/PointsTable.ts
Normal file
21
packages/racing/domain/value-objects/PointsTable.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class PointsTable {
|
||||
private readonly pointsByPosition: Map<number, number>;
|
||||
|
||||
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
|
||||
if (pointsByPosition instanceof Map) {
|
||||
this.pointsByPosition = new Map(pointsByPosition);
|
||||
} else {
|
||||
this.pointsByPosition = new Map(
|
||||
Object.entries(pointsByPosition).map(([key, value]) => [Number(key), value]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getPointsForPosition(position: number): number {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
return 0;
|
||||
}
|
||||
const value = this.pointsByPosition.get(position);
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
}
|
||||
9
packages/racing/domain/value-objects/SessionType.ts
Normal file
9
packages/racing/domain/value-objects/SessionType.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type SessionType =
|
||||
| 'practice'
|
||||
| 'qualifying'
|
||||
| 'q1'
|
||||
| 'q2'
|
||||
| 'q3'
|
||||
| 'sprint'
|
||||
| 'main'
|
||||
| 'timeTrial';
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryLeagueMembershipRepository
|
||||
*
|
||||
* In-memory implementation of ILeagueMembershipRepository.
|
||||
* Stores memberships and join requests in maps keyed by league.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeagueMembership,
|
||||
JoinRequest,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
|
||||
export class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
|
||||
private membershipsByLeague: Map<string, LeagueMembership[]>;
|
||||
private joinRequestsByLeague: Map<string, JoinRequest[]>;
|
||||
|
||||
constructor(seedMemberships?: LeagueMembership[], seedJoinRequests?: JoinRequest[]) {
|
||||
this.membershipsByLeague = new Map();
|
||||
this.joinRequestsByLeague = new Map();
|
||||
|
||||
if (seedMemberships) {
|
||||
seedMemberships.forEach((membership) => {
|
||||
const list = this.membershipsByLeague.get(membership.leagueId) ?? [];
|
||||
list.push(membership);
|
||||
this.membershipsByLeague.set(membership.leagueId, list);
|
||||
});
|
||||
}
|
||||
|
||||
if (seedJoinRequests) {
|
||||
seedJoinRequests.forEach((request) => {
|
||||
const list = this.joinRequestsByLeague.get(request.leagueId) ?? [];
|
||||
list.push(request);
|
||||
this.joinRequestsByLeague.set(request.leagueId, list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
const list = this.membershipsByLeague.get(leagueId);
|
||||
if (!list) return null;
|
||||
return list.find((m) => m.driverId === driverId) ?? null;
|
||||
}
|
||||
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return [...(this.membershipsByLeague.get(leagueId) ?? [])];
|
||||
}
|
||||
|
||||
async getJoinRequests(leagueId: string): Promise<JoinRequest[]> {
|
||||
return [...(this.joinRequestsByLeague.get(leagueId) ?? [])];
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
const list = this.membershipsByLeague.get(membership.leagueId) ?? [];
|
||||
const existingIndex = list.findIndex(
|
||||
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = membership;
|
||||
} else {
|
||||
list.push(membership);
|
||||
}
|
||||
|
||||
this.membershipsByLeague.set(membership.leagueId, list);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(leagueId: string, driverId: string): Promise<void> {
|
||||
const list = this.membershipsByLeague.get(leagueId);
|
||||
if (!list) return;
|
||||
|
||||
const next = list.filter((m) => m.driverId !== driverId);
|
||||
this.membershipsByLeague.set(leagueId, next);
|
||||
}
|
||||
|
||||
async saveJoinRequest(request: JoinRequest): Promise<JoinRequest> {
|
||||
const list = this.joinRequestsByLeague.get(request.leagueId) ?? [];
|
||||
const existingIndex = list.findIndex((r) => r.id === request.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = request;
|
||||
} else {
|
||||
list.push(request);
|
||||
}
|
||||
|
||||
this.joinRequestsByLeague.set(request.leagueId, list);
|
||||
return request;
|
||||
}
|
||||
|
||||
async removeJoinRequest(requestId: string): Promise<void> {
|
||||
for (const [leagueId, requests] of this.joinRequestsByLeague.entries()) {
|
||||
const next = requests.filter((r) => r.id !== requestId);
|
||||
if (next.length !== requests.length) {
|
||||
this.joinRequestsByLeague.set(leagueId, next);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryPenaltyRepository
|
||||
*
|
||||
* Simple in-memory implementation of IPenaltyRepository seeded with
|
||||
* a handful of demo penalties and bonuses for leagues/drivers.
|
||||
*/
|
||||
import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
|
||||
|
||||
export class InMemoryPenaltyRepository implements IPenaltyRepository {
|
||||
private readonly penalties: Penalty[];
|
||||
|
||||
constructor(seedPenalties?: Penalty[]) {
|
||||
this.penalties = seedPenalties ? [...seedPenalties] : InMemoryPenaltyRepository.createDefaultSeed();
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.leagueId === leagueId && p.driverId === driverId);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Penalty[]> {
|
||||
return [...this.penalties];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default demo seed with a mix of deductions and bonuses
|
||||
* across a couple of leagues and drivers.
|
||||
*/
|
||||
private static createDefaultSeed(): Penalty[] {
|
||||
const now = new Date();
|
||||
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'pen-league-1-driver-1-main',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'points-deduction',
|
||||
pointsDelta: -3,
|
||||
reason: 'Incident points penalty',
|
||||
appliedAt: daysAgo(7),
|
||||
},
|
||||
{
|
||||
id: 'pen-league-1-driver-2-bonus',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-2',
|
||||
type: 'points-bonus',
|
||||
pointsDelta: 2,
|
||||
reason: 'Fastest laps bonus',
|
||||
appliedAt: daysAgo(5),
|
||||
},
|
||||
{
|
||||
id: 'pen-league-1-driver-3-bonus',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
type: 'points-bonus',
|
||||
pointsDelta: 1,
|
||||
reason: 'Pole position bonus',
|
||||
appliedAt: daysAgo(3),
|
||||
},
|
||||
{
|
||||
id: 'pen-league-2-driver-4-main',
|
||||
leagueId: 'league-2',
|
||||
driverId: 'driver-4',
|
||||
type: 'points-deduction',
|
||||
pointsDelta: -5,
|
||||
reason: 'Post-race steward decision',
|
||||
appliedAt: daysAgo(10),
|
||||
},
|
||||
{
|
||||
id: 'pen-league-2-driver-5-bonus',
|
||||
leagueId: 'league-2',
|
||||
driverId: 'driver-5',
|
||||
type: 'points-bonus',
|
||||
pointsDelta: 3,
|
||||
reason: 'Clean race awards',
|
||||
appliedAt: daysAgo(2),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryRaceRegistrationRepository
|
||||
*
|
||||
* In-memory implementation of IRaceRegistrationRepository.
|
||||
* Stores race registrations in Maps keyed by raceId and driverId.
|
||||
*/
|
||||
|
||||
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
||||
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
|
||||
export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
|
||||
private registrationsByRace: Map<string, Set<string>>;
|
||||
private registrationsByDriver: Map<string, Set<string>>;
|
||||
|
||||
constructor(seedRegistrations?: RaceRegistration[]) {
|
||||
this.registrationsByRace = new Map();
|
||||
this.registrationsByDriver = new Map();
|
||||
|
||||
if (seedRegistrations) {
|
||||
seedRegistrations.forEach((registration) => {
|
||||
this.addToIndexes(registration.raceId, registration.driverId, registration.registeredAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private addToIndexes(raceId: string, driverId: string, _registeredAt: Date): void {
|
||||
let raceSet = this.registrationsByRace.get(raceId);
|
||||
if (!raceSet) {
|
||||
raceSet = new Set();
|
||||
this.registrationsByRace.set(raceId, raceSet);
|
||||
}
|
||||
raceSet.add(driverId);
|
||||
|
||||
let driverSet = this.registrationsByDriver.get(driverId);
|
||||
if (!driverSet) {
|
||||
driverSet = new Set();
|
||||
this.registrationsByDriver.set(driverId, driverSet);
|
||||
}
|
||||
driverSet.add(raceId);
|
||||
}
|
||||
|
||||
private removeFromIndexes(raceId: string, driverId: string): void {
|
||||
const raceSet = this.registrationsByRace.get(raceId);
|
||||
if (raceSet) {
|
||||
raceSet.delete(driverId);
|
||||
if (raceSet.size === 0) {
|
||||
this.registrationsByRace.delete(raceId);
|
||||
}
|
||||
}
|
||||
|
||||
const driverSet = this.registrationsByDriver.get(driverId);
|
||||
if (driverSet) {
|
||||
driverSet.delete(raceId);
|
||||
if (driverSet.size === 0) {
|
||||
this.registrationsByDriver.delete(driverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
|
||||
const raceSet = this.registrationsByRace.get(raceId);
|
||||
if (!raceSet) return false;
|
||||
return raceSet.has(driverId);
|
||||
}
|
||||
|
||||
async getRegisteredDrivers(raceId: string): Promise<string[]> {
|
||||
const raceSet = this.registrationsByRace.get(raceId);
|
||||
if (!raceSet) return [];
|
||||
return Array.from(raceSet.values());
|
||||
}
|
||||
|
||||
async getRegistrationCount(raceId: string): Promise<number> {
|
||||
const raceSet = this.registrationsByRace.get(raceId);
|
||||
return raceSet ? raceSet.size : 0;
|
||||
}
|
||||
|
||||
async register(registration: RaceRegistration): Promise<void> {
|
||||
const alreadyRegistered = await this.isRegistered(registration.raceId, registration.driverId);
|
||||
if (alreadyRegistered) {
|
||||
throw new Error('Already registered for this race');
|
||||
}
|
||||
this.addToIndexes(registration.raceId, registration.driverId, registration.registeredAt);
|
||||
}
|
||||
|
||||
async withdraw(raceId: string, driverId: string): Promise<void> {
|
||||
const alreadyRegistered = await this.isRegistered(raceId, driverId);
|
||||
if (!alreadyRegistered) {
|
||||
throw new Error('Not registered for this race');
|
||||
}
|
||||
this.removeFromIndexes(raceId, driverId);
|
||||
}
|
||||
|
||||
async getDriverRegistrations(driverId: string): Promise<string[]> {
|
||||
const driverSet = this.registrationsByDriver.get(driverId);
|
||||
if (!driverSet) return [];
|
||||
return Array.from(driverSet.values());
|
||||
}
|
||||
|
||||
async clearRaceRegistrations(raceId: string): Promise<void> {
|
||||
const raceSet = this.registrationsByRace.get(raceId);
|
||||
if (!raceSet) return;
|
||||
|
||||
for (const driverId of raceSet.values()) {
|
||||
const driverSet = this.registrationsByDriver.get(driverId);
|
||||
if (driverSet) {
|
||||
driverSet.delete(raceId);
|
||||
if (driverSet.size === 0) {
|
||||
this.registrationsByDriver.delete(driverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.registrationsByRace.delete(raceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Game } from '@gridpilot/racing/domain/entities/Game';
|
||||
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||
import type { LeagueScoringConfig } from '@gridpilot/racing/domain/entities/LeagueScoringConfig';
|
||||
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
|
||||
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
|
||||
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
|
||||
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule';
|
||||
import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy';
|
||||
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
|
||||
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
|
||||
import { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
|
||||
import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
||||
|
||||
export class InMemoryGameRepository implements IGameRepository {
|
||||
private games: Game[];
|
||||
|
||||
constructor(seedData?: Game[]) {
|
||||
this.games = seedData ? [...seedData] : [];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Game | null> {
|
||||
return this.games.find((g) => g.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Game[]> {
|
||||
return [...this.games];
|
||||
}
|
||||
|
||||
seed(game: Game): void {
|
||||
this.games.push(game);
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemorySeasonRepository implements ISeasonRepository {
|
||||
private seasons: Season[];
|
||||
|
||||
constructor(seedData?: Season[]) {
|
||||
this.seasons = seedData ? [...seedData] : [];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Season | null> {
|
||||
return this.seasons.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
seed(season: Season): void {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryLeagueScoringConfigRepository
|
||||
implements ILeagueScoringConfigRepository
|
||||
{
|
||||
private configs: LeagueScoringConfig[];
|
||||
|
||||
constructor(seedData?: LeagueScoringConfig[]) {
|
||||
this.configs = seedData ? [...seedData] : [];
|
||||
}
|
||||
|
||||
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
|
||||
return this.configs.find((c) => c.seasonId === seasonId) ?? null;
|
||||
}
|
||||
|
||||
seed(config: LeagueScoringConfig): void {
|
||||
this.configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryChampionshipStandingRepository
|
||||
implements IChampionshipStandingRepository
|
||||
{
|
||||
private standings: ChampionshipStanding[] = [];
|
||||
|
||||
async findBySeasonAndChampionship(
|
||||
seasonId: string,
|
||||
championshipId: string,
|
||||
): Promise<ChampionshipStanding[]> {
|
||||
return this.standings.filter(
|
||||
(s) => s.seasonId === seasonId && s.championshipId === championshipId,
|
||||
);
|
||||
}
|
||||
|
||||
async saveAll(standings: ChampionshipStanding[]): Promise<void> {
|
||||
this.standings = standings;
|
||||
}
|
||||
|
||||
seed(standing: ChampionshipStanding): void {
|
||||
this.standings.push(standing);
|
||||
}
|
||||
|
||||
getAll(): ChampionshipStanding[] {
|
||||
return [...this.standings];
|
||||
}
|
||||
}
|
||||
|
||||
export function createF1DemoScoringSetup(params: {
|
||||
leagueId: string;
|
||||
seasonId?: string;
|
||||
}): {
|
||||
gameRepo: InMemoryGameRepository;
|
||||
seasonRepo: InMemorySeasonRepository;
|
||||
scoringConfigRepo: InMemoryLeagueScoringConfigRepository;
|
||||
championshipStandingRepo: InMemoryChampionshipStandingRepository;
|
||||
seasonId: string;
|
||||
championshipId: string;
|
||||
} {
|
||||
const { leagueId } = params;
|
||||
const seasonId = params.seasonId ?? 'season-f1-demo';
|
||||
const championshipId = 'driver-champ';
|
||||
|
||||
const game = Game.create({ id: 'iracing', name: 'iRacing' });
|
||||
|
||||
const season = Season.create({
|
||||
id: seasonId,
|
||||
leagueId,
|
||||
gameId: game.id,
|
||||
name: 'F1-Style Demo Season',
|
||||
year: 2025,
|
||||
order: 1,
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-12-31'),
|
||||
});
|
||||
|
||||
const mainPoints = new PointsTable({
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
4: 12,
|
||||
5: 10,
|
||||
6: 8,
|
||||
7: 6,
|
||||
8: 4,
|
||||
9: 2,
|
||||
10: 1,
|
||||
});
|
||||
|
||||
const sprintPoints = new PointsTable({
|
||||
1: 8,
|
||||
2: 7,
|
||||
3: 6,
|
||||
4: 5,
|
||||
5: 4,
|
||||
6: 3,
|
||||
7: 2,
|
||||
8: 1,
|
||||
});
|
||||
|
||||
const fastestLapBonus: BonusRule = {
|
||||
id: 'fastest-lap-main',
|
||||
type: 'fastestLap',
|
||||
points: 1,
|
||||
requiresFinishInTopN: 10,
|
||||
};
|
||||
|
||||
const sessionTypes: SessionType[] = ['sprint', 'main'];
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
sprint: sprintPoints,
|
||||
main: mainPoints,
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
|
||||
sprint: [],
|
||||
main: [fastestLapBonus],
|
||||
practice: [],
|
||||
qualifying: [],
|
||||
q1: [],
|
||||
q2: [],
|
||||
q3: [],
|
||||
timeTrial: [],
|
||||
};
|
||||
|
||||
const dropScorePolicy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 6,
|
||||
};
|
||||
|
||||
const championship: ChampionshipConfig = {
|
||||
id: championshipId,
|
||||
name: 'Driver Championship',
|
||||
type: 'driver' as ChampionshipType,
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy,
|
||||
};
|
||||
|
||||
const leagueScoringConfig: LeagueScoringConfig = {
|
||||
id: 'lsc-f1-demo',
|
||||
seasonId: season.id,
|
||||
championships: [championship],
|
||||
};
|
||||
|
||||
const gameRepo = new InMemoryGameRepository([game]);
|
||||
const seasonRepo = new InMemorySeasonRepository([season]);
|
||||
const scoringConfigRepo = new InMemoryLeagueScoringConfigRepository([
|
||||
leagueScoringConfig,
|
||||
]);
|
||||
const championshipStandingRepo = new InMemoryChampionshipStandingRepository();
|
||||
|
||||
return {
|
||||
gameRepo,
|
||||
seasonRepo,
|
||||
scoringConfigRepo,
|
||||
championshipStandingRepo,
|
||||
seasonId: season.id,
|
||||
championshipId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createParticipantRef(driverId: string): ParticipantRef {
|
||||
return {
|
||||
type: 'driver' as ChampionshipType,
|
||||
id: driverId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamMembershipRepository
|
||||
*
|
||||
* In-memory implementation of ITeamMembershipRepository.
|
||||
* Stores memberships and join requests in Map structures.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TeamMembership,
|
||||
TeamJoinRequest,
|
||||
} from '@gridpilot/racing/domain/entities/Team';
|
||||
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
|
||||
|
||||
export class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
|
||||
private membershipsByTeam: Map<string, TeamMembership[]>;
|
||||
private joinRequestsByTeam: Map<string, TeamJoinRequest[]>;
|
||||
|
||||
constructor(seedMemberships?: TeamMembership[], seedJoinRequests?: TeamJoinRequest[]) {
|
||||
this.membershipsByTeam = new Map();
|
||||
this.joinRequestsByTeam = new Map();
|
||||
|
||||
if (seedMemberships) {
|
||||
seedMemberships.forEach((membership) => {
|
||||
const list = this.membershipsByTeam.get(membership.teamId) ?? [];
|
||||
list.push(membership);
|
||||
this.membershipsByTeam.set(membership.teamId, list);
|
||||
});
|
||||
}
|
||||
|
||||
if (seedJoinRequests) {
|
||||
seedJoinRequests.forEach((request) => {
|
||||
const list = this.joinRequestsByTeam.get(request.teamId) ?? [];
|
||||
list.push(request);
|
||||
this.joinRequestsByTeam.set(request.teamId, list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getMembershipList(teamId: string): TeamMembership[] {
|
||||
let list = this.membershipsByTeam.get(teamId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.membershipsByTeam.set(teamId, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private getJoinRequestList(teamId: string): TeamJoinRequest[] {
|
||||
let list = this.joinRequestsByTeam.get(teamId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.joinRequestsByTeam.set(teamId, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
|
||||
const list = this.membershipsByTeam.get(teamId);
|
||||
if (!list) return null;
|
||||
return list.find((m) => m.driverId === driverId) ?? null;
|
||||
}
|
||||
|
||||
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
|
||||
for (const list of this.membershipsByTeam.values()) {
|
||||
const membership = list.find(
|
||||
(m) => m.driverId === driverId && m.status === 'active',
|
||||
);
|
||||
if (membership) {
|
||||
return membership;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
|
||||
return [...(this.membershipsByTeam.get(teamId) ?? [])];
|
||||
}
|
||||
|
||||
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
|
||||
const list = this.getMembershipList(membership.teamId);
|
||||
const existingIndex = list.findIndex(
|
||||
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = membership;
|
||||
} else {
|
||||
list.push(membership);
|
||||
}
|
||||
|
||||
this.membershipsByTeam.set(membership.teamId, list);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(teamId: string, driverId: string): Promise<void> {
|
||||
const list = this.membershipsByTeam.get(teamId);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const index = list.findIndex((m) => m.driverId === driverId);
|
||||
if (index >= 0) {
|
||||
list.splice(index, 1);
|
||||
this.membershipsByTeam.set(teamId, list);
|
||||
}
|
||||
}
|
||||
|
||||
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
|
||||
return [...(this.joinRequestsByTeam.get(teamId) ?? [])];
|
||||
}
|
||||
|
||||
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
|
||||
const list = this.getJoinRequestList(request.teamId);
|
||||
const existingIndex = list.findIndex((r) => r.id === request.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = request;
|
||||
} else {
|
||||
list.push(request);
|
||||
}
|
||||
|
||||
this.joinRequestsByTeam.set(request.teamId, list);
|
||||
return request;
|
||||
}
|
||||
|
||||
async removeJoinRequest(requestId: string): Promise<void> {
|
||||
for (const [teamId, list] of this.joinRequestsByTeam.entries()) {
|
||||
const index = list.findIndex((r) => r.id === requestId);
|
||||
if (index >= 0) {
|
||||
list.splice(index, 1);
|
||||
this.joinRequestsByTeam.set(teamId, list);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamRepository
|
||||
*
|
||||
* In-memory implementation of ITeamRepository.
|
||||
* Stores data in a Map structure.
|
||||
*/
|
||||
|
||||
import type { Team } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
|
||||
|
||||
export class InMemoryTeamRepository implements ITeamRepository {
|
||||
private teams: Map<string, Team>;
|
||||
|
||||
constructor(seedData?: Team[]) {
|
||||
this.teams = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach((team) => {
|
||||
this.teams.set(team.id, team);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Team[]> {
|
||||
return Array.from(this.teams.values()).filter((team) =>
|
||||
team.leagues.includes(leagueId),
|
||||
);
|
||||
}
|
||||
|
||||
async create(team: Team): Promise<Team> {
|
||||
if (await this.exists(team.id)) {
|
||||
throw new Error(`Team with ID ${team.id} already exists`);
|
||||
}
|
||||
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(team: Team): Promise<Team> {
|
||||
if (!(await this.exists(team.id))) {
|
||||
throw new Error(`Team with ID ${team.id} not found`);
|
||||
}
|
||||
|
||||
this.teams.set(team.id, team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!(await this.exists(id))) {
|
||||
throw new Error(`Team with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.teams.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.teams.has(id);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { faker } from '../faker/faker';
|
||||
|
||||
const DRIVER_AVATARS = [
|
||||
'/images/avatars/avatar-1.svg',
|
||||
'/images/avatars/avatar-2.svg',
|
||||
@@ -44,4 +46,20 @@ export function getLeagueBanner(leagueId: string): string {
|
||||
return LEAGUE_BANNERS[index];
|
||||
}
|
||||
|
||||
export interface LeagueCoverImage {
|
||||
url: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export function getLeagueCoverImage(leagueId: string): LeagueCoverImage {
|
||||
const seed = hashString(leagueId);
|
||||
|
||||
faker.seed(seed);
|
||||
const alt = faker.lorem.words(3);
|
||||
|
||||
const url = `https://picsum.photos/seed/${seed}/1200/280?blur=2`;
|
||||
|
||||
return { url, alt };
|
||||
}
|
||||
|
||||
export { DRIVER_AVATARS, TEAM_LOGOS, LEAGUE_BANNERS };
|
||||
@@ -103,12 +103,32 @@ function createLeagues(ownerIds: string[]): League[] {
|
||||
const name = leagueNames[i] ?? faker.company.name();
|
||||
const ownerId = pickOne(ownerIds);
|
||||
|
||||
const maxDriversOptions = [24, 32, 48, 64];
|
||||
const settings = {
|
||||
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
|
||||
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
|
||||
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
|
||||
maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
|
||||
};
|
||||
|
||||
const socialLinks =
|
||||
i === 0
|
||||
? {
|
||||
discordUrl: 'https://discord.gg/gridpilot-demo',
|
||||
youtubeUrl: 'https://youtube.com/@gridpilot-demo',
|
||||
websiteUrl: 'https://gridpilot-demo.example.com',
|
||||
}
|
||||
: i === 1
|
||||
? {
|
||||
discordUrl: 'https://discord.gg/gridpilot-endurance',
|
||||
youtubeUrl: 'https://youtube.com/@gridpilot-endurance',
|
||||
}
|
||||
: i === 2
|
||||
? {
|
||||
websiteUrl: 'https://virtual-touring.example.com',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
leagues.push(
|
||||
League.create({
|
||||
id,
|
||||
@@ -117,6 +137,7 @@ function createLeagues(ownerIds: string[]): League[] {
|
||||
ownerId,
|
||||
settings,
|
||||
createdAt: faker.date.past(),
|
||||
socialLinks,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user