wip
This commit is contained in:
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user