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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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