wip
This commit is contained in:
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal file
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type LeagueStructureMode = 'solo' | 'fixedTeams';
|
||||
|
||||
export interface LeagueStructureFormDTO {
|
||||
mode: LeagueStructureMode;
|
||||
maxDrivers: number;
|
||||
maxTeams?: number;
|
||||
driversPerTeam?: number;
|
||||
multiClassEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueChampionshipsFormDTO {
|
||||
enableDriverChampionship: boolean;
|
||||
enableTeamChampionship: boolean;
|
||||
enableNationsChampionship: boolean;
|
||||
enableTrophyChampionship: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueScoringFormDTO {
|
||||
patternId?: string; // e.g. 'sprint-main-driver', 'club-ladder-solo'
|
||||
// For now, keep customScoring optional and simple:
|
||||
customScoringEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueDropPolicyFormDTO {
|
||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||
n?: number;
|
||||
}
|
||||
|
||||
export interface LeagueTimingsFormDTO {
|
||||
practiceMinutes?: number;
|
||||
qualifyingMinutes: number;
|
||||
sprintRaceMinutes?: number;
|
||||
mainRaceMinutes: number;
|
||||
sessionCount: number;
|
||||
roundsPlanned?: number;
|
||||
|
||||
seasonStartDate?: string; // ISO date YYYY-MM-DD
|
||||
raceStartTime?: string; // "HH:MM" 24h
|
||||
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin"
|
||||
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
intervalWeeks?: number;
|
||||
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];
|
||||
monthlyOrdinal?: 1 | 2 | 3 | 4;
|
||||
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
|
||||
}
|
||||
|
||||
export interface LeagueConfigFormModel {
|
||||
leagueId?: string; // present for admin, omitted for create
|
||||
basics: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private';
|
||||
gameId: string;
|
||||
};
|
||||
structure: LeagueStructureFormDTO;
|
||||
championships: LeagueChampionshipsFormDTO;
|
||||
scoring: LeagueScoringFormDTO;
|
||||
dropPolicy: LeagueDropPolicyFormDTO;
|
||||
timings: LeagueTimingsFormDTO;
|
||||
}
|
||||
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal file
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
|
||||
import type { Weekday } from '../../domain/value-objects/Weekday';
|
||||
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
|
||||
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
|
||||
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
|
||||
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
|
||||
export interface LeagueScheduleDTO {
|
||||
seasonStartDate: string;
|
||||
raceStartTime: string;
|
||||
timezoneId: string;
|
||||
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
intervalWeeks?: number;
|
||||
weekdays?: Weekday[];
|
||||
monthlyOrdinal?: 1 | 2 | 3 | 4;
|
||||
monthlyWeekday?: Weekday;
|
||||
plannedRounds: number;
|
||||
}
|
||||
|
||||
export interface LeagueSchedulePreviewDTO {
|
||||
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function leagueTimingsToScheduleDTO(
|
||||
timings: LeagueTimingsFormDTO,
|
||||
): LeagueScheduleDTO | null {
|
||||
if (
|
||||
!timings.seasonStartDate ||
|
||||
!timings.raceStartTime ||
|
||||
!timings.timezoneId ||
|
||||
!timings.recurrenceStrategy ||
|
||||
!timings.roundsPlanned
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
seasonStartDate: timings.seasonStartDate,
|
||||
raceStartTime: timings.raceStartTime,
|
||||
timezoneId: timings.timezoneId,
|
||||
recurrenceStrategy: timings.recurrenceStrategy,
|
||||
intervalWeeks: timings.intervalWeeks,
|
||||
weekdays: timings.weekdays,
|
||||
monthlyOrdinal: timings.monthlyOrdinal,
|
||||
monthlyWeekday: timings.monthlyWeekday,
|
||||
plannedRounds: timings.roundsPlanned,
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
|
||||
if (!dto.seasonStartDate) {
|
||||
throw new Error('seasonStartDate is required');
|
||||
}
|
||||
if (!dto.raceStartTime) {
|
||||
throw new Error('raceStartTime is required');
|
||||
}
|
||||
if (!dto.timezoneId) {
|
||||
throw new Error('timezoneId is required');
|
||||
}
|
||||
if (!dto.recurrenceStrategy) {
|
||||
throw new Error('recurrenceStrategy is required');
|
||||
}
|
||||
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
|
||||
throw new Error('plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const startDate = new Date(dto.seasonStartDate);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new Error(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
|
||||
}
|
||||
|
||||
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
|
||||
const timezone = new LeagueTimezone(dto.timezoneId);
|
||||
|
||||
let recurrence: RecurrenceStrategy;
|
||||
|
||||
if (dto.recurrenceStrategy === 'weekly') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for weekly recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
|
||||
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
if (dto.intervalWeeks == null) {
|
||||
throw new Error('intervalWeeks is required for everyNWeeks recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.everyNWeeks(
|
||||
dto.intervalWeeks,
|
||||
new WeekdaySet(dto.weekdays),
|
||||
);
|
||||
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
|
||||
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
|
||||
throw new Error('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
|
||||
}
|
||||
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
|
||||
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
} else {
|
||||
throw new Error(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
|
||||
}
|
||||
|
||||
return new SeasonSchedule({
|
||||
startDate,
|
||||
timeOfDay,
|
||||
timezone,
|
||||
recurrence,
|
||||
plannedRounds: dto.plannedRounds,
|
||||
});
|
||||
}
|
||||
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal file
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface LeagueScoringChampionshipDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
sessionTypes: string[];
|
||||
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
|
||||
bonusSummary: string[];
|
||||
dropPolicyDescription: string;
|
||||
}
|
||||
|
||||
export interface LeagueScoringConfigDTO {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
scoringPresetId?: string;
|
||||
scoringPresetName?: string;
|
||||
dropPolicySummary: string;
|
||||
championships: LeagueScoringChampionshipDTO[];
|
||||
}
|
||||
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal file
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface LeagueSummaryScoringDTO {
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
scoringPresetId: string;
|
||||
scoringPresetName: string;
|
||||
dropPolicySummary: string;
|
||||
/**
|
||||
* Human-readable scoring pattern summary combining preset name and drop policy,
|
||||
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
|
||||
*/
|
||||
scoringPatternSummary: string;
|
||||
}
|
||||
|
||||
export interface LeagueSummaryDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
ownerId: string;
|
||||
maxDrivers?: number;
|
||||
usedDriverSlots?: number;
|
||||
maxTeams?: number;
|
||||
usedTeamSlots?: number;
|
||||
/**
|
||||
* Human-readable structure summary derived from capacity and (future) team settings,
|
||||
* e.g. "Solo • 24 drivers" or "Teams • 12 × 2 drivers".
|
||||
*/
|
||||
structureSummary?: string;
|
||||
/**
|
||||
* Human-readable scoring pattern summary for list views,
|
||||
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
|
||||
*/
|
||||
scoringPatternSummary?: string;
|
||||
/**
|
||||
* Human-readable timing summary for list views,
|
||||
* e.g. "30 min Quali • 40 min Race".
|
||||
*/
|
||||
timingSummary?: string;
|
||||
scoring?: LeagueSummaryScoringDTO;
|
||||
}
|
||||
@@ -17,15 +17,21 @@ export * from './use-cases/GetDriverTeamQuery';
|
||||
export * from './use-cases/GetLeagueStandingsQuery';
|
||||
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringQuery';
|
||||
export * from './use-cases/ListLeagueScoringPresetsQuery';
|
||||
export * from './use-cases/GetLeagueScoringConfigQuery';
|
||||
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
JoinRequest,
|
||||
} from '../domain/entities/LeagueMembership';
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
JoinRequest,
|
||||
} from '../domain/entities/LeagueMembership';
|
||||
|
||||
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
||||
|
||||
@@ -43,7 +49,20 @@ export type { RaceDTO } from './dto/RaceDTO';
|
||||
export type { ResultDTO } from './dto/ResultDTO';
|
||||
export type { StandingDTO } from './dto/StandingDTO';
|
||||
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
|
||||
export type {
|
||||
LeagueScheduleDTO,
|
||||
LeagueSchedulePreviewDTO,
|
||||
} from './dto/LeagueScheduleDTO';
|
||||
export type {
|
||||
ChampionshipStandingsDTO,
|
||||
ChampionshipStandingsRowDTO,
|
||||
} from './dto/ChampionshipStandingsDTO';
|
||||
} from './dto/ChampionshipStandingsDTO';
|
||||
export type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueStructureFormDTO,
|
||||
LeagueChampionshipsFormDTO,
|
||||
LeagueScoringFormDTO,
|
||||
LeagueDropPolicyFormDTO,
|
||||
LeagueStructureMode,
|
||||
LeagueTimingsFormDTO,
|
||||
} from './dto/LeagueConfigFormDTO';
|
||||
@@ -0,0 +1,26 @@
|
||||
export type LeagueScoringPresetPrimaryChampionshipType =
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy';
|
||||
|
||||
export interface LeagueScoringPresetDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
|
||||
sessionSummary: string;
|
||||
bonusSummary: string;
|
||||
dropPolicySummary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider abstraction for league scoring presets used by application-layer queries.
|
||||
*
|
||||
* In-memory implementation is backed by the preset registry in
|
||||
* InMemoryScoringRepositories.
|
||||
*/
|
||||
export interface LeagueScoringPresetProvider {
|
||||
listPresets(): LeagueScoringPresetDTO[];
|
||||
getPresetById(id: string): LeagueScoringPresetDTO | undefined;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { League } from '../../domain/entities/League';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
|
||||
export interface CreateLeagueWithSeasonAndScoringCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private';
|
||||
ownerId: string;
|
||||
gameId: string;
|
||||
maxDrivers?: number;
|
||||
maxTeams?: number;
|
||||
enableDriverChampionship: boolean;
|
||||
enableTeamChampionship: boolean;
|
||||
enableNationsChampionship: boolean;
|
||||
enableTrophyChampionship: boolean;
|
||||
scoringPresetId?: string;
|
||||
}
|
||||
|
||||
export interface CreateLeagueWithSeasonAndScoringResultDTO {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
scoringPresetId?: string;
|
||||
scoringPresetName?: string;
|
||||
}
|
||||
|
||||
export class CreateLeagueWithSeasonAndScoringUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: CreateLeagueWithSeasonAndScoringCommand,
|
||||
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
|
||||
this.validate(command);
|
||||
|
||||
const leagueId = uuidv4();
|
||||
|
||||
const league = League.create({
|
||||
id: leagueId,
|
||||
name: command.name,
|
||||
description: command.description ?? '',
|
||||
ownerId: command.ownerId,
|
||||
settings: {
|
||||
pointsSystem: (command.scoringPresetId as any) ?? 'custom',
|
||||
maxDrivers: command.maxDrivers,
|
||||
},
|
||||
});
|
||||
|
||||
await this.leagueRepository.create(league);
|
||||
|
||||
const seasonId = uuidv4();
|
||||
const season = {
|
||||
id: seasonId,
|
||||
leagueId: league.id,
|
||||
gameId: command.gameId,
|
||||
name: `${command.name} Season 1`,
|
||||
year: new Date().getFullYear(),
|
||||
order: 1,
|
||||
status: 'active' as const,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
};
|
||||
|
||||
// Season is a domain entity; use the repository's create, but shape matches Season.create expectations.
|
||||
// To keep this use case independent, we rely on repository to persist the plain object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await this.seasonRepository.create(season as any);
|
||||
|
||||
const presetId = command.scoringPresetId ?? 'club-default';
|
||||
const preset: LeagueScoringPresetDTO | undefined =
|
||||
this.presetProvider.getPresetById(presetId);
|
||||
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown scoring preset: ${presetId}`);
|
||||
}
|
||||
|
||||
const scoringConfig: LeagueScoringConfig = {
|
||||
id: uuidv4(),
|
||||
seasonId,
|
||||
scoringPresetId: preset.id,
|
||||
championships: [],
|
||||
};
|
||||
|
||||
// For the initial alpha slice, we keep using the preset's config shape from the in-memory registry.
|
||||
// The preset registry is responsible for building the full LeagueScoringConfig; we only attach the preset id here.
|
||||
const fullConfigFactory = (await import(
|
||||
'../../infrastructure/repositories/InMemoryScoringRepositories'
|
||||
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
|
||||
|
||||
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
|
||||
preset.id,
|
||||
);
|
||||
if (!presetFromInfra) {
|
||||
throw new Error(`Preset registry missing preset: ${preset.id}`);
|
||||
}
|
||||
|
||||
const infraConfig = presetFromInfra.createConfig({ seasonId });
|
||||
const finalConfig: LeagueScoringConfig = {
|
||||
...infraConfig,
|
||||
scoringPresetId: preset.id,
|
||||
};
|
||||
|
||||
await this.leagueScoringConfigRepository.save(finalConfig);
|
||||
|
||||
return {
|
||||
leagueId: league.id,
|
||||
seasonId,
|
||||
scoringPresetId: preset.id,
|
||||
scoringPresetName: preset.name,
|
||||
};
|
||||
}
|
||||
|
||||
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
|
||||
if (!command.name || command.name.trim().length === 0) {
|
||||
throw new Error('League name is required');
|
||||
}
|
||||
if (!command.ownerId || command.ownerId.trim().length === 0) {
|
||||
throw new Error('League ownerId is required');
|
||||
}
|
||||
if (!command.gameId || command.gameId.trim().length === 0) {
|
||||
throw new Error('gameId is required');
|
||||
}
|
||||
if (!command.visibility) {
|
||||
throw new Error('visibility is required');
|
||||
}
|
||||
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
|
||||
throw new Error('maxDrivers must be greater than 0 when provided');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
import type {
|
||||
LeagueSummaryDTO,
|
||||
LeagueSummaryScoringDTO,
|
||||
} from '../dto/LeagueSummaryDTO';
|
||||
|
||||
/**
|
||||
* Combined capacity + scoring summary query for leagues.
|
||||
*
|
||||
* Extends the behavior of GetAllLeaguesWithCapacityQuery by including
|
||||
* scoring preset and game summaries when an active season and
|
||||
* LeagueScoringConfig are available.
|
||||
*/
|
||||
export class GetAllLeaguesWithCapacityAndScoringQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<LeagueSummaryDTO[]> {
|
||||
const leagues = await this.leagueRepository.findAll();
|
||||
|
||||
const results: LeagueSummaryDTO[] = [];
|
||||
|
||||
for (const league of leagues) {
|
||||
const members = await this.leagueMembershipRepository.getLeagueMembers(
|
||||
league.id,
|
||||
);
|
||||
|
||||
const usedDriverSlots = members.filter(
|
||||
(m) =>
|
||||
m.status === 'active' &&
|
||||
(m.role === 'owner' ||
|
||||
m.role === 'admin' ||
|
||||
m.role === 'steward' ||
|
||||
m.role === 'member'),
|
||||
).length;
|
||||
|
||||
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
|
||||
|
||||
const scoringSummary = await this.buildScoringSummary(league.id);
|
||||
|
||||
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
|
||||
|
||||
const qualifyingMinutes = 30;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40;
|
||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||
|
||||
const dto: LeagueSummaryDTO = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: safeMaxDrivers,
|
||||
usedDriverSlots,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary,
|
||||
scoringPatternSummary: scoringSummary?.scoringPatternSummary,
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async buildScoringSummary(
|
||||
leagueId: string,
|
||||
): Promise<LeagueSummaryScoringDTO | undefined> {
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
if (!seasons || seasons.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activeSeason =
|
||||
seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
|
||||
const scoringConfig =
|
||||
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
|
||||
if (!scoringConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const game = await this.gameRepository.findById(activeSeason.gameId);
|
||||
if (!game) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const presetId = scoringConfig.scoringPresetId;
|
||||
let preset: LeagueScoringPresetDTO | undefined;
|
||||
if (presetId) {
|
||||
preset = this.presetProvider.getPresetById(presetId);
|
||||
}
|
||||
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
|
||||
const primaryChampionshipType =
|
||||
preset?.primaryChampionshipType ??
|
||||
(scoringConfig.championships[0]?.type ?? 'driver');
|
||||
|
||||
const scoringPresetName = preset?.name ?? 'Custom';
|
||||
const scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
primaryChampionshipType,
|
||||
scoringPresetId: presetId ?? 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveDropPolicySummary(config: {
|
||||
championships: Array<{
|
||||
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
|
||||
}>;
|
||||
}): string {
|
||||
const championship = config.championships[0];
|
||||
if (!championship) {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
const policy = championship.dropScorePolicy;
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
|
||||
import type { DropScorePolicy } from '../../domain/value-objects/DropScorePolicy';
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueDropPolicyFormDTO,
|
||||
} from '../dto/LeagueConfigFormDTO';
|
||||
|
||||
/**
|
||||
* Query returning a unified LeagueConfigFormModel for a given league.
|
||||
*
|
||||
* First iteration focuses on:
|
||||
* - Basics derived from League
|
||||
* - Simple solo structure derived from League.settings.maxDrivers
|
||||
* - Championships flags with driver enabled and others disabled
|
||||
* - Scoring pattern id taken from LeagueScoringConfig.scoringPresetId
|
||||
* - Drop policy inferred from the primary championship configuration
|
||||
*/
|
||||
export class GetLeagueFullConfigQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
) {}
|
||||
|
||||
async execute(params: { leagueId: string }): Promise<LeagueConfigFormModel | null> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
const activeSeason =
|
||||
seasons && seasons.length > 0
|
||||
? seasons.find((s) => s.status === 'active') ?? seasons[0]
|
||||
: null;
|
||||
|
||||
const scoringConfig = activeSeason
|
||||
? await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)
|
||||
: null;
|
||||
|
||||
const game =
|
||||
activeSeason && activeSeason.gameId
|
||||
? await this.gameRepository.findById(activeSeason.gameId)
|
||||
: null;
|
||||
|
||||
const patternId = scoringConfig?.scoringPresetId;
|
||||
|
||||
const primaryChampionship: ChampionshipConfig | undefined =
|
||||
scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0
|
||||
? scoringConfig.championships[0]
|
||||
: undefined;
|
||||
|
||||
const dropPolicy: DropScorePolicy | undefined =
|
||||
primaryChampionship?.dropScorePolicy ?? undefined;
|
||||
|
||||
const dropPolicyForm: LeagueDropPolicyFormDTO = this.mapDropPolicy(dropPolicy);
|
||||
|
||||
const defaultQualifyingMinutes = 30;
|
||||
const defaultMainRaceMinutes = 40;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: defaultMainRaceMinutes;
|
||||
const qualifyingMinutes = defaultQualifyingMinutes;
|
||||
|
||||
const roundsPlanned = 8;
|
||||
|
||||
let sessionCount = 2;
|
||||
if (
|
||||
primaryChampionship &&
|
||||
Array.isArray((primaryChampionship as any).sessionTypes) &&
|
||||
(primaryChampionship as any).sessionTypes.length > 0
|
||||
) {
|
||||
sessionCount = (primaryChampionship as any).sessionTypes.length;
|
||||
}
|
||||
|
||||
const practiceMinutes = 20;
|
||||
const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined;
|
||||
|
||||
const form: LeagueConfigFormModel = {
|
||||
leagueId: league.id,
|
||||
basics: {
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
visibility: 'public', // current domain model does not track visibility; default to public for now
|
||||
gameId: game?.id ?? 'iracing',
|
||||
},
|
||||
structure: {
|
||||
// First slice: treat everything as solo structure based on maxDrivers
|
||||
mode: 'solo',
|
||||
maxDrivers: league.settings.maxDrivers ?? 32,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
multiClassEnabled: false,
|
||||
},
|
||||
championships: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
},
|
||||
scoring: {
|
||||
patternId: patternId ?? undefined,
|
||||
customScoringEnabled: !patternId,
|
||||
},
|
||||
dropPolicy: dropPolicyForm,
|
||||
timings: {
|
||||
practiceMinutes,
|
||||
qualifyingMinutes,
|
||||
sprintRaceMinutes,
|
||||
mainRaceMinutes,
|
||||
sessionCount,
|
||||
roundsPlanned,
|
||||
},
|
||||
};
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
private mapDropPolicy(policy: DropScorePolicy | undefined): LeagueDropPolicyFormDTO {
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return { strategy: 'none' };
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults') {
|
||||
const n = typeof policy.count === 'number' ? policy.count : undefined;
|
||||
return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' };
|
||||
}
|
||||
|
||||
if (policy.strategy === 'dropWorstN') {
|
||||
const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined;
|
||||
return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' };
|
||||
}
|
||||
|
||||
return { strategy: 'none' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { LeagueScoringConfigDTO } from '../dto/LeagueScoringConfigDTO';
|
||||
import type { LeagueScoringChampionshipDTO } from '../dto/LeagueScoringConfigDTO';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
|
||||
import type { PointsTable } from '../../domain/value-objects/PointsTable';
|
||||
import type { BonusRule } from '../../domain/value-objects/BonusRule';
|
||||
|
||||
/**
|
||||
* Query returning a league's scoring configuration for its active season.
|
||||
*
|
||||
* Designed for the league detail "Scoring" tab.
|
||||
*/
|
||||
export class GetLeagueScoringConfigQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(params: { leagueId: string }): Promise<LeagueScoringConfigDTO | null> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
if (!seasons || seasons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSeason =
|
||||
seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
|
||||
const scoringConfig =
|
||||
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
|
||||
if (!scoringConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = await this.gameRepository.findById(activeSeason.gameId);
|
||||
if (!game) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const presetId = scoringConfig.scoringPresetId;
|
||||
const preset: LeagueScoringPresetDTO | undefined =
|
||||
presetId ? this.presetProvider.getPresetById(presetId) : undefined;
|
||||
|
||||
const championships: LeagueScoringChampionshipDTO[] =
|
||||
scoringConfig.championships.map((champ) =>
|
||||
this.mapChampionship(champ),
|
||||
);
|
||||
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ??
|
||||
this.deriveDropPolicyDescriptionFromChampionships(
|
||||
scoringConfig.championships,
|
||||
);
|
||||
|
||||
return {
|
||||
leagueId: league.id,
|
||||
seasonId: activeSeason.id,
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
scoringPresetId: presetId,
|
||||
scoringPresetName: preset?.name,
|
||||
dropPolicySummary,
|
||||
championships,
|
||||
};
|
||||
}
|
||||
|
||||
private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipDTO {
|
||||
const sessionTypes = championship.sessionTypes.map((s) => s.toString());
|
||||
const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType);
|
||||
const bonusSummary = this.buildBonusSummary(
|
||||
championship.bonusRulesBySessionType ?? {},
|
||||
);
|
||||
const dropPolicyDescription = this.deriveDropPolicyDescription(
|
||||
championship.dropScorePolicy,
|
||||
);
|
||||
|
||||
return {
|
||||
id: championship.id,
|
||||
name: championship.name,
|
||||
type: championship.type,
|
||||
sessionTypes,
|
||||
pointsPreview,
|
||||
bonusSummary,
|
||||
dropPolicyDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private buildPointsPreview(
|
||||
tables: Record<string, PointsTable>,
|
||||
): Array<{ sessionType: string; position: number; points: number }> {
|
||||
const preview: Array<{
|
||||
sessionType: string;
|
||||
position: number;
|
||||
points: number;
|
||||
}> = [];
|
||||
|
||||
const maxPositions = 10;
|
||||
|
||||
for (const [sessionType, table] of Object.entries(tables)) {
|
||||
for (let pos = 1; pos <= maxPositions; pos++) {
|
||||
const points = table.getPoints(pos);
|
||||
if (points && points !== 0) {
|
||||
preview.push({
|
||||
sessionType,
|
||||
position: pos,
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
private buildBonusSummary(
|
||||
bonusRulesBySessionType: Record<string, BonusRule[]>,
|
||||
): string[] {
|
||||
const summaries: string[] = [];
|
||||
|
||||
for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) {
|
||||
for (const rule of rules) {
|
||||
if (rule.type === 'fastestLap') {
|
||||
const base = `Fastest lap in ${sessionType}`;
|
||||
if (rule.requiresFinishInTopN) {
|
||||
summaries.push(
|
||||
`${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`,
|
||||
);
|
||||
} else {
|
||||
summaries.push(`${base} +${rule.points} points`);
|
||||
}
|
||||
} else {
|
||||
summaries.push(
|
||||
`${rule.type} bonus in ${sessionType} worth ${rule.points} points`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private deriveDropPolicyDescriptionFromChampionships(
|
||||
championships: ChampionshipConfig[],
|
||||
): string {
|
||||
const first = championships[0];
|
||||
if (!first) {
|
||||
return 'All results count';
|
||||
}
|
||||
return this.deriveDropPolicyDescription(first.dropScorePolicy);
|
||||
}
|
||||
|
||||
private deriveDropPolicyDescription(policy: {
|
||||
strategy: string;
|
||||
count?: number;
|
||||
dropCount?: number;
|
||||
}): string {
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count towards the championship`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped from the championship total`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules apply';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
LeagueScoringPresetDTO,
|
||||
LeagueScoringPresetProvider,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
|
||||
/**
|
||||
* Read-only query exposing league scoring presets for UI consumption.
|
||||
*
|
||||
* Backed by the in-memory preset registry via a LeagueScoringPresetProvider
|
||||
* implementation in the infrastructure layer.
|
||||
*/
|
||||
export class ListLeagueScoringPresetsQuery {
|
||||
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
|
||||
|
||||
async execute(): Promise<LeagueScoringPresetDTO[]> {
|
||||
return this.presetProvider.listPresets();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
|
||||
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
|
||||
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
|
||||
|
||||
interface PreviewLeagueScheduleQueryParams {
|
||||
schedule: LeagueScheduleDTO;
|
||||
maxRounds?: number;
|
||||
}
|
||||
|
||||
export class PreviewLeagueScheduleQuery {
|
||||
constructor(
|
||||
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
|
||||
) {}
|
||||
|
||||
execute(params: PreviewLeagueScheduleQueryParams): LeagueSchedulePreviewDTO {
|
||||
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
|
||||
|
||||
const maxRounds =
|
||||
params.maxRounds && params.maxRounds > 0
|
||||
? Math.min(params.maxRounds, seasonSchedule.plannedRounds)
|
||||
: seasonSchedule.plannedRounds;
|
||||
|
||||
const slots = this.scheduleGenerator.generateSlotsUpTo(seasonSchedule, maxRounds);
|
||||
|
||||
const rounds = slots.map((slot) => ({
|
||||
roundNumber: slot.roundNumber,
|
||||
scheduledAt: slot.scheduledAt.toISOString(),
|
||||
timezoneId: slot.timezone.getId(),
|
||||
}));
|
||||
|
||||
const summary = this.buildSummary(params.schedule, rounds);
|
||||
|
||||
return {
|
||||
rounds,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSummary(
|
||||
schedule: LeagueScheduleDTO,
|
||||
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>,
|
||||
): string {
|
||||
if (rounds.length === 0) {
|
||||
return 'No rounds scheduled.';
|
||||
}
|
||||
|
||||
const first = new Date(rounds[0].scheduledAt);
|
||||
const last = new Date(rounds[rounds.length - 1].scheduledAt);
|
||||
|
||||
const firstDate = first.toISOString().slice(0, 10);
|
||||
const lastDate = last.toISOString().slice(0, 10);
|
||||
|
||||
const timePart = schedule.raceStartTime;
|
||||
const tz = schedule.timezoneId;
|
||||
|
||||
let recurrenceDescription: string;
|
||||
|
||||
if (schedule.recurrenceStrategy === 'weekly') {
|
||||
const days = (schedule.weekdays ?? []).join(', ');
|
||||
recurrenceDescription = `Every ${days}`;
|
||||
} else if (schedule.recurrenceStrategy === 'everyNWeeks') {
|
||||
const interval = schedule.intervalWeeks ?? 1;
|
||||
const days = (schedule.weekdays ?? []).join(', ');
|
||||
recurrenceDescription = `Every ${interval} week(s) on ${days}`;
|
||||
} else if (schedule.recurrenceStrategy === 'monthlyNthWeekday') {
|
||||
const ordinalLabel = this.ordinalToLabel(schedule.monthlyOrdinal ?? 1);
|
||||
const weekday = schedule.monthlyWeekday ?? 'Mon';
|
||||
recurrenceDescription = `Every ${ordinalLabel} ${weekday}`;
|
||||
} else {
|
||||
recurrenceDescription = 'Custom recurrence';
|
||||
}
|
||||
|
||||
return `${recurrenceDescription} at ${timePart} ${tz}, starting ${firstDate} — ${rounds.length} rounds from ${firstDate} to ${lastDate}.`;
|
||||
}
|
||||
|
||||
private ordinalToLabel(ordinal: 1 | 2 | 3 | 4): string {
|
||||
switch (ordinal) {
|
||||
case 1:
|
||||
return '1st';
|
||||
case 2:
|
||||
return '2nd';
|
||||
case 3:
|
||||
return '3rd';
|
||||
case 4:
|
||||
return '4th';
|
||||
default:
|
||||
return `${ordinal}th`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,11 @@ import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
export interface LeagueScoringConfig {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
/**
|
||||
* Optional ID of the scoring preset this configuration was derived from.
|
||||
* Used by application-layer read models to surface preset metadata such as
|
||||
* name and drop policy summaries.
|
||||
*/
|
||||
scoringPresetId?: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
@@ -2,4 +2,5 @@ import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ import type { Season } from '../entities/Season';
|
||||
export interface ISeasonRepository {
|
||||
findById(id: string): Promise<Season | null>;
|
||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||
create(season: Season): Promise<Season>;
|
||||
}
|
||||
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
|
||||
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import type { Weekday } from '../value-objects/Weekday';
|
||||
import { weekdayToIndex } from '../value-objects/Weekday';
|
||||
|
||||
function cloneDate(date: Date): Date {
|
||||
return new Date(date.getTime());
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = cloneDate(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addWeeks(date: Date, weeks: number): Date {
|
||||
return addDays(date, weeks * 7);
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const d = cloneDate(date);
|
||||
const targetMonth = d.getMonth() + months;
|
||||
d.setMonth(targetMonth);
|
||||
return d;
|
||||
}
|
||||
|
||||
function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date {
|
||||
const d = new Date(
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth(),
|
||||
baseDate.getDate(),
|
||||
timeOfDay.hour,
|
||||
timeOfDay.minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Treat Monday as 1 ... Sunday as 7
|
||||
function getCalendarWeekdayIndex(date: Date): number {
|
||||
const jsDay = date.getDay(); // 0=Sun ... 6=Sat
|
||||
if (jsDay === 0) {
|
||||
return 7;
|
||||
}
|
||||
return jsDay;
|
||||
}
|
||||
|
||||
function weekdayToCalendarOffset(anchor: Date, target: Weekday): number {
|
||||
const anchorIndex = getCalendarWeekdayIndex(anchor);
|
||||
const targetIndex = weekdayToIndex(target);
|
||||
return targetIndex - anchorIndex;
|
||||
}
|
||||
|
||||
function generateWeeklyOrEveryNWeeksSlots(
|
||||
schedule: SeasonSchedule,
|
||||
maxRounds: number,
|
||||
): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
const weekdays =
|
||||
recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks'
|
||||
? recurrence.weekdays.getAll()
|
||||
: [];
|
||||
|
||||
if (weekdays.length === 0) {
|
||||
throw new Error('RecurrenceStrategy has no weekdays');
|
||||
}
|
||||
|
||||
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
|
||||
|
||||
let anchorWeekStart = cloneDate(schedule.startDate);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
for (const weekday of weekdays) {
|
||||
const offset = weekdayToCalendarOffset(anchorWeekStart, weekday);
|
||||
const candidateDate = addDays(anchorWeekStart, offset);
|
||||
|
||||
if (candidateDate < schedule.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
|
||||
if (result.length >= maxRounds) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date {
|
||||
const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1);
|
||||
const firstIndex = getCalendarWeekdayIndex(firstOfMonth);
|
||||
const targetIndex = weekdayToIndex(weekday);
|
||||
|
||||
let offset = targetIndex - firstIndex;
|
||||
if (offset < 0) {
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
const dayOfMonth = 1 + offset + (ordinal - 1) * 7;
|
||||
return new Date(base.getFullYear(), base.getMonth(), dayOfMonth);
|
||||
}
|
||||
|
||||
function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
if (recurrence.kind !== 'monthlyNthWeekday') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { ordinal, weekday } = recurrence.monthlyPattern;
|
||||
let currentMonthDate = new Date(
|
||||
schedule.startDate.getFullYear(),
|
||||
schedule.startDate.getMonth(),
|
||||
1,
|
||||
);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday);
|
||||
|
||||
if (candidateDate >= schedule.startDate) {
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
}
|
||||
|
||||
currentMonthDate = addMonths(currentMonthDate, 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SeasonScheduleGenerator {
|
||||
static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] {
|
||||
return this.generateSlotsUpTo(schedule, schedule.plannedRounds);
|
||||
}
|
||||
|
||||
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
|
||||
throw new Error('maxRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const recurrence: RecurrenceStrategy = schedule.recurrence;
|
||||
|
||||
if (recurrence.kind === 'monthlyNthWeekday') {
|
||||
return generateMonthlySlots(schedule, maxRounds);
|
||||
}
|
||||
|
||||
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
|
||||
}
|
||||
}
|
||||
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal file
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export class LeagueTimezone {
|
||||
private readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new Error('LeagueTimezone id must be a non-empty string');
|
||||
}
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
|
||||
export class MonthlyRecurrencePattern {
|
||||
readonly ordinal: 1 | 2 | 3 | 4;
|
||||
readonly weekday: Weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
|
||||
this.ordinal = ordinal;
|
||||
this.weekday = weekday;
|
||||
}
|
||||
}
|
||||
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export class RaceTimeOfDay {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
|
||||
constructor(hour: number, minute: number) {
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||
throw new Error(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
|
||||
}
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
throw new Error(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
|
||||
}
|
||||
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
}
|
||||
|
||||
static fromString(value: string): RaceTimeOfDay {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
|
||||
}
|
||||
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
|
||||
return new RaceTimeOfDay(hour, minute);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const hh = this.hour.toString().padStart(2, '0');
|
||||
const mm = this.minute.toString().padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
}
|
||||
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { WeekdaySet } from './WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
|
||||
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
|
||||
export type WeeklyRecurrence = {
|
||||
kind: 'weekly';
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type EveryNWeeksRecurrence = {
|
||||
kind: 'everyNWeeks';
|
||||
intervalWeeks: number;
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type MonthlyNthWeekdayRecurrence = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
monthlyPattern: MonthlyRecurrencePattern;
|
||||
};
|
||||
|
||||
export type RecurrenceStrategy =
|
||||
| WeeklyRecurrence
|
||||
| EveryNWeeksRecurrence
|
||||
| MonthlyNthWeekdayRecurrence;
|
||||
|
||||
export class RecurrenceStrategyFactory {
|
||||
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
|
||||
throw new Error('everyNWeeks intervalWeeks must be an integer between 1 and 12');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
intervalWeeks,
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
monthlyPattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
|
||||
export class ScheduledRaceSlot {
|
||||
readonly roundNumber: number;
|
||||
readonly scheduledAt: Date;
|
||||
readonly timezone: LeagueTimezone;
|
||||
|
||||
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
|
||||
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
|
||||
throw new Error('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||
}
|
||||
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
|
||||
throw new Error('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||
}
|
||||
|
||||
this.roundNumber = params.roundNumber;
|
||||
this.scheduledAt = params.scheduledAt;
|
||||
this.timezone = params.timezone;
|
||||
}
|
||||
}
|
||||
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal file
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
import type { RecurrenceStrategy } from './RecurrenceStrategy';
|
||||
|
||||
export class SeasonSchedule {
|
||||
readonly startDate: Date;
|
||||
readonly timeOfDay: RaceTimeOfDay;
|
||||
readonly timezone: LeagueTimezone;
|
||||
readonly recurrence: RecurrenceStrategy;
|
||||
readonly plannedRounds: number;
|
||||
|
||||
constructor(params: {
|
||||
startDate: Date;
|
||||
timeOfDay: RaceTimeOfDay;
|
||||
timezone: LeagueTimezone;
|
||||
recurrence: RecurrenceStrategy;
|
||||
plannedRounds: number;
|
||||
}) {
|
||||
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
|
||||
throw new Error('SeasonSchedule.startDate must be a valid Date');
|
||||
}
|
||||
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
|
||||
throw new Error('SeasonSchedule.plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
this.startDate = new Date(
|
||||
params.startDate.getFullYear(),
|
||||
params.startDate.getMonth(),
|
||||
params.startDate.getDate(),
|
||||
);
|
||||
this.timeOfDay = params.timeOfDay;
|
||||
this.timezone = params.timezone;
|
||||
this.recurrence = params.recurrence;
|
||||
this.plannedRounds = params.plannedRounds;
|
||||
}
|
||||
}
|
||||
25
packages/racing/domain/value-objects/Weekday.ts
Normal file
25
packages/racing/domain/value-objects/Weekday.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
|
||||
|
||||
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function weekdayToIndex(day: Weekday): number {
|
||||
switch (day) {
|
||||
case 'Mon':
|
||||
return 1;
|
||||
case 'Tue':
|
||||
return 2;
|
||||
case 'Wed':
|
||||
return 3;
|
||||
case 'Thu':
|
||||
return 4;
|
||||
case 'Fri':
|
||||
return 5;
|
||||
case 'Sat':
|
||||
return 6;
|
||||
case 'Sun':
|
||||
return 7;
|
||||
default:
|
||||
// This should be unreachable because Weekday is a closed union.
|
||||
throw new Error(`Unknown weekday: ${day}`);
|
||||
}
|
||||
}
|
||||
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal file
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
import { weekdayToIndex } from './Weekday';
|
||||
|
||||
export class WeekdaySet {
|
||||
private readonly days: Weekday[];
|
||||
|
||||
constructor(days: Weekday[]) {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new Error('WeekdaySet requires at least one weekday');
|
||||
}
|
||||
|
||||
const unique = Array.from(new Set(days));
|
||||
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
|
||||
}
|
||||
|
||||
getAll(): Weekday[] {
|
||||
return [...this.days];
|
||||
}
|
||||
|
||||
includes(day: Weekday): boolean {
|
||||
return this.days.includes(day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
getLeagueScoringPresetById,
|
||||
listLeagueScoringPresets,
|
||||
} from './InMemoryScoringRepositories';
|
||||
import type {
|
||||
LeagueScoringPresetDTO,
|
||||
LeagueScoringPresetProvider,
|
||||
} from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter exposing the in-memory scoring preset registry
|
||||
* through the LeagueScoringPresetProvider application port.
|
||||
*/
|
||||
export class InMemoryLeagueScoringPresetProvider
|
||||
implements LeagueScoringPresetProvider
|
||||
{
|
||||
listPresets(): LeagueScoringPresetDTO[] {
|
||||
return listLeagueScoringPresets().map((preset) => ({
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
primaryChampionshipType: preset.primaryChampionshipType,
|
||||
sessionSummary: preset.sessionSummary,
|
||||
bonusSummary: preset.bonusSummary,
|
||||
dropPolicySummary: preset.dropPolicySummary,
|
||||
}));
|
||||
}
|
||||
|
||||
getPresetById(id: string): LeagueScoringPresetDTO | undefined {
|
||||
const preset = getLeagueScoringPresetById(id);
|
||||
if (!preset) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
primaryChampionshipType: preset.primaryChampionshipType,
|
||||
sessionSummary: preset.sessionSummary,
|
||||
bonusSummary: preset.bonusSummary,
|
||||
dropPolicySummary: preset.dropPolicySummary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,238 @@ import { ChampionshipStanding } from '@gridpilot/racing/domain/entities/Champion
|
||||
import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
||||
|
||||
export type LeagueScoringPresetPrimaryChampionshipType =
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy';
|
||||
|
||||
export interface LeagueScoringPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
|
||||
dropPolicySummary: string;
|
||||
sessionSummary: string;
|
||||
bonusSummary: string;
|
||||
createConfig: (options: { seasonId: string }) => LeagueScoringConfig;
|
||||
}
|
||||
|
||||
const mainPointsSprintMain = new PointsTable({
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
4: 12,
|
||||
5: 10,
|
||||
6: 8,
|
||||
7: 6,
|
||||
8: 4,
|
||||
9: 2,
|
||||
10: 1,
|
||||
});
|
||||
|
||||
const sprintPointsSprintMain = new PointsTable({
|
||||
1: 8,
|
||||
2: 7,
|
||||
3: 6,
|
||||
4: 5,
|
||||
5: 4,
|
||||
6: 3,
|
||||
7: 2,
|
||||
8: 1,
|
||||
});
|
||||
|
||||
const clubMainPoints = new PointsTable({
|
||||
1: 20,
|
||||
2: 15,
|
||||
3: 12,
|
||||
4: 10,
|
||||
5: 8,
|
||||
6: 6,
|
||||
7: 4,
|
||||
8: 2,
|
||||
9: 1,
|
||||
});
|
||||
|
||||
const enduranceMainPoints = new PointsTable({
|
||||
1: 50,
|
||||
2: 36,
|
||||
3: 30,
|
||||
4: 24,
|
||||
5: 20,
|
||||
6: 16,
|
||||
7: 12,
|
||||
8: 8,
|
||||
9: 4,
|
||||
10: 2,
|
||||
});
|
||||
|
||||
const leagueScoringPresets: LeagueScoringPreset[] = [
|
||||
{
|
||||
id: 'sprint-main-driver',
|
||||
name: 'Sprint + Main',
|
||||
description:
|
||||
'Short sprint race plus main race; sprint gives fewer points.',
|
||||
primaryChampionshipType: 'driver',
|
||||
dropPolicySummary: 'Best 6 results of 8 count towards the championship.',
|
||||
sessionSummary: 'Sprint + Main',
|
||||
bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.',
|
||||
createConfig: ({ seasonId }) => {
|
||||
const fastestLapBonus: BonusRule = {
|
||||
id: 'fastest-lap-main',
|
||||
type: 'fastestLap',
|
||||
points: 1,
|
||||
requiresFinishInTopN: 10,
|
||||
};
|
||||
|
||||
const sessionTypes: SessionType[] = ['sprint', 'main'];
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
sprint: sprintPointsSprintMain,
|
||||
main: mainPointsSprintMain,
|
||||
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: 'driver-champ-sprint-main',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver' as ChampionshipType,
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy,
|
||||
};
|
||||
|
||||
return {
|
||||
id: `lsc-${seasonId}-sprint-main-driver`,
|
||||
seasonId,
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
championships: [championship],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'club-default',
|
||||
name: 'Club ladder',
|
||||
description:
|
||||
'Simple club ladder with a single main race and no bonuses or drop scores.',
|
||||
primaryChampionshipType: 'driver',
|
||||
dropPolicySummary: 'All race results count, no drop scores.',
|
||||
sessionSummary: 'Main race only',
|
||||
bonusSummary: 'No bonus points.',
|
||||
createConfig: ({ seasonId }) => {
|
||||
const sessionTypes: SessionType[] = ['main'];
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
sprint: new PointsTable({}),
|
||||
main: clubMainPoints,
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const dropScorePolicy: DropScorePolicy = {
|
||||
strategy: 'none',
|
||||
};
|
||||
|
||||
const championship: ChampionshipConfig = {
|
||||
id: 'driver-champ-club-default',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver' as ChampionshipType,
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
dropScorePolicy,
|
||||
};
|
||||
|
||||
return {
|
||||
id: `lsc-${seasonId}-club-default`,
|
||||
seasonId,
|
||||
scoringPresetId: 'club-default',
|
||||
championships: [championship],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'endurance-main-double',
|
||||
name: 'Endurance weekend',
|
||||
description:
|
||||
'Single main endurance race with double points and a simple drop policy.',
|
||||
primaryChampionshipType: 'driver',
|
||||
dropPolicySummary: 'Best 4 results of 6 count towards the championship.',
|
||||
sessionSummary: 'Main race only',
|
||||
bonusSummary: 'No bonus points.',
|
||||
createConfig: ({ seasonId }) => {
|
||||
const sessionTypes: SessionType[] = ['main'];
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
sprint: new PointsTable({}),
|
||||
main: enduranceMainPoints,
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const dropScorePolicy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 4,
|
||||
};
|
||||
|
||||
const championship: ChampionshipConfig = {
|
||||
id: 'driver-champ-endurance-main-double',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver' as ChampionshipType,
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
dropScorePolicy,
|
||||
};
|
||||
|
||||
return {
|
||||
id: `lsc-${seasonId}-endurance-main-double`,
|
||||
seasonId,
|
||||
scoringPresetId: 'endurance-main-double',
|
||||
championships: [championship],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function listLeagueScoringPresets(): LeagueScoringPreset[] {
|
||||
return [...leagueScoringPresets];
|
||||
}
|
||||
|
||||
export function getLeagueScoringPresetById(
|
||||
id: string,
|
||||
): LeagueScoringPreset | undefined {
|
||||
return leagueScoringPresets.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
export class InMemoryGameRepository implements IGameRepository {
|
||||
private games: Game[];
|
||||
|
||||
@@ -49,6 +281,11 @@ export class InMemorySeasonRepository implements ISeasonRepository {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async create(season: Season): Promise<Season> {
|
||||
this.seasons.push(season);
|
||||
return season;
|
||||
}
|
||||
|
||||
seed(season: Season): void {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
@@ -67,6 +304,18 @@ export class InMemoryLeagueScoringConfigRepository
|
||||
return this.configs.find((c) => c.seasonId === seasonId) ?? null;
|
||||
}
|
||||
|
||||
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
|
||||
const existingIndex = this.configs.findIndex(
|
||||
(c) => c.id === config.id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
this.configs[existingIndex] = config;
|
||||
} else {
|
||||
this.configs.push(config);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
seed(config: LeagueScoringConfig): void {
|
||||
this.configs.push(config);
|
||||
}
|
||||
@@ -99,7 +348,7 @@ export class InMemoryChampionshipStandingRepository
|
||||
}
|
||||
}
|
||||
|
||||
export function createF1DemoScoringSetup(params: {
|
||||
export function createSprintMainDemoScoringSetup(params: {
|
||||
leagueId: string;
|
||||
seasonId?: string;
|
||||
}): {
|
||||
@@ -111,7 +360,7 @@ export function createF1DemoScoringSetup(params: {
|
||||
championshipId: string;
|
||||
} {
|
||||
const { leagueId } = params;
|
||||
const seasonId = params.seasonId ?? 'season-f1-demo';
|
||||
const seasonId = params.seasonId ?? 'season-sprint-main-demo';
|
||||
const championshipId = 'driver-champ';
|
||||
|
||||
const game = Game.create({ id: 'iracing', name: 'iRacing' });
|
||||
@@ -120,7 +369,7 @@ export function createF1DemoScoringSetup(params: {
|
||||
id: seasonId,
|
||||
leagueId,
|
||||
gameId: game.id,
|
||||
name: 'F1-Style Demo Season',
|
||||
name: 'Sprint + Main Demo Season',
|
||||
year: 2025,
|
||||
order: 1,
|
||||
status: 'active',
|
||||
@@ -128,81 +377,14 @@ export function createF1DemoScoringSetup(params: {
|
||||
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 preset = getLeagueScoringPresetById('sprint-main-driver');
|
||||
if (!preset) {
|
||||
throw new Error('Missing sprint-main-driver scoring preset');
|
||||
}
|
||||
|
||||
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',
|
||||
const leagueScoringConfig: LeagueScoringConfig = preset.createConfig({
|
||||
seasonId: season.id,
|
||||
championships: [championship],
|
||||
};
|
||||
});
|
||||
|
||||
const gameRepo = new InMemoryGameRepository([game]);
|
||||
const seasonRepo = new InMemorySeasonRepository([season]);
|
||||
|
||||
@@ -85,9 +85,9 @@ function createDrivers(count: number): Driver[] {
|
||||
|
||||
function createLeagues(ownerIds: string[]): League[] {
|
||||
const leagueNames = [
|
||||
'Global GT Masters',
|
||||
'Midnight Endurance Series',
|
||||
'Virtual Touring Cup',
|
||||
'GridPilot Sprint Series',
|
||||
'GridPilot Endurance Cup',
|
||||
'GridPilot Club Ladder',
|
||||
'Sprint Challenge League',
|
||||
'Club Racers Collective',
|
||||
'Sim Racing Alliance',
|
||||
@@ -104,12 +104,29 @@ function createLeagues(ownerIds: string[]): League[] {
|
||||
const ownerId = pickOne(ownerIds);
|
||||
|
||||
const maxDriversOptions = [24, 32, 48, 64];
|
||||
const settings = {
|
||||
let 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),
|
||||
};
|
||||
} as const;
|
||||
|
||||
if (i === 0) {
|
||||
settings = {
|
||||
...settings,
|
||||
maxDrivers: 24,
|
||||
};
|
||||
} else if (i === 1) {
|
||||
settings = {
|
||||
...settings,
|
||||
maxDrivers: 24,
|
||||
};
|
||||
} else if (i === 2) {
|
||||
settings = {
|
||||
...settings,
|
||||
maxDrivers: 40,
|
||||
};
|
||||
}
|
||||
|
||||
const socialLinks =
|
||||
i === 0
|
||||
@@ -615,4 +632,60 @@ export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[]
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
||||
|
||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo league archetype helper for seeding structure and scoring.
|
||||
*
|
||||
* This keeps archetype knowledge local to the static racing seed while allowing
|
||||
* demo infrastructure (e.g. DI container) to attach seasons and scoring configs.
|
||||
*/
|
||||
export type DemoLeagueArchetype =
|
||||
| {
|
||||
id: 'sprint-series';
|
||||
name: 'GridPilot Sprint Series';
|
||||
structure: { mode: 'solo'; maxDrivers: 24 };
|
||||
scoringPresetId: 'sprint-main-driver';
|
||||
}
|
||||
| {
|
||||
id: 'endurance-cup';
|
||||
name: 'GridPilot Endurance Cup';
|
||||
structure: { mode: 'fixedTeams'; maxTeams: 12; driversPerTeam: 2 };
|
||||
scoringPresetId: 'endurance-main-double';
|
||||
}
|
||||
| {
|
||||
id: 'club-ladder';
|
||||
name: 'GridPilot Club Ladder';
|
||||
structure: { mode: 'solo'; maxDrivers: 40 };
|
||||
scoringPresetId: 'club-default';
|
||||
};
|
||||
|
||||
export function getDemoLeagueArchetypeByName(
|
||||
leagueName: string,
|
||||
): DemoLeagueArchetype | undefined {
|
||||
switch (leagueName) {
|
||||
case 'GridPilot Sprint Series':
|
||||
return {
|
||||
id: 'sprint-series',
|
||||
name: 'GridPilot Sprint Series',
|
||||
structure: { mode: 'solo', maxDrivers: 24 },
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
};
|
||||
case 'GridPilot Endurance Cup':
|
||||
return {
|
||||
id: 'endurance-cup',
|
||||
name: 'GridPilot Endurance Cup',
|
||||
structure: { mode: 'fixedTeams', maxTeams: 12, driversPerTeam: 2 },
|
||||
scoringPresetId: 'endurance-main-double',
|
||||
};
|
||||
case 'GridPilot Club Ladder':
|
||||
return {
|
||||
id: 'club-ladder',
|
||||
name: 'GridPilot Club Ladder',
|
||||
structure: { mode: 'solo', maxDrivers: 40 },
|
||||
scoringPresetId: 'club-default',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user