This commit is contained in:
2025-12-04 23:31:55 +01:00
parent 9fa21a488a
commit fb509607c1
96 changed files with 5839 additions and 1609 deletions

View File

@@ -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;
}
}
}
}

View File

@@ -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),
},
];
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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;
}
}
}
}

View File

@@ -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);
}
}