Files
gridpilot.gg/apps/website/lib/leagueWizardService.ts
2025-12-05 15:18:27 +01:00

272 lines
7.9 KiB
TypeScript

import type {
LeagueConfigFormModel,
} from '@gridpilot/racing/application';
import type {
CreateLeagueWithSeasonAndScoringCommand,
CreateLeagueWithSeasonAndScoringResultDTO,
} from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import {
getDriverRepository,
getCreateLeagueWithSeasonAndScoringUseCase,
} from '@/lib/di-container';
export type WizardStep = 1 | 2 | 3 | 4 | 5;
export interface WizardErrors {
basics?: {
name?: string;
visibility?: string;
};
structure?: {
maxDrivers?: string;
maxTeams?: string;
driversPerTeam?: string;
};
timings?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
scoring?: {
patternId?: string;
};
submit?: string;
}
/**
* Step-scoped validation extracted from the React wizard.
* Returns a fresh error bag for the given step based on the provided form model.
*/
export function validateLeagueWizardStep(
form: LeagueConfigFormModel,
step: WizardStep,
): WizardErrors {
const errors: WizardErrors = {};
if (step === 1) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
if (!form.basics.name.trim()) {
basicsErrors.name = 'Name is required';
}
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
}
if (Object.keys(basicsErrors).length > 0) {
errors.basics = basicsErrors;
}
}
if (step === 2) {
const structureErrors: NonNullable<WizardErrors['structure']> = {};
if (form.structure.mode === 'solo') {
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
structureErrors.maxDrivers =
'Max drivers must be greater than 0 for solo leagues';
}
} else if (form.structure.mode === 'fixedTeams') {
if (!form.structure.maxTeams || form.structure.maxTeams <= 0) {
structureErrors.maxTeams =
'Max teams must be greater than 0 for team leagues';
}
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
}
if (Object.keys(structureErrors).length > 0) {
errors.structure = structureErrors;
}
}
if (step === 3) {
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes =
'Qualifying duration must be greater than 0 minutes';
}
if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) {
timingsErrors.mainRaceMinutes =
'Main race duration must be greater than 0 minutes';
}
if (Object.keys(timingsErrors).length > 0) {
errors.timings = timingsErrors;
}
}
if (step === 4) {
const scoringErrors: NonNullable<WizardErrors['scoring']> = {};
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
scoringErrors.patternId =
'Select a scoring preset or enable custom scoring';
}
if (Object.keys(scoringErrors).length > 0) {
errors.scoring = scoringErrors;
}
}
return errors;
}
/**
* Helper to validate all steps (1-4) and merge errors into a single bag.
*/
export function validateAllLeagueWizardSteps(
form: LeagueConfigFormModel,
): WizardErrors {
const aggregate: WizardErrors = {};
const merge = (next: WizardErrors) => {
if (next.basics) {
aggregate.basics = { ...aggregate.basics, ...next.basics };
}
if (next.structure) {
aggregate.structure = { ...aggregate.structure, ...next.structure };
}
if (next.timings) {
aggregate.timings = { ...aggregate.timings, ...next.timings };
}
if (next.scoring) {
aggregate.scoring = { ...aggregate.scoring, ...next.scoring };
}
if (next.submit) {
aggregate.submit = next.submit;
}
};
merge(validateLeagueWizardStep(form, 1));
merge(validateLeagueWizardStep(form, 2));
merge(validateLeagueWizardStep(form, 3));
merge(validateLeagueWizardStep(form, 4));
return aggregate;
}
export function hasWizardErrors(errors: WizardErrors): boolean {
return Object.keys(errors).some((key) => {
const value = (errors as any)[key];
if (!value) return false;
if (typeof value === 'string') return true;
return Object.keys(value).length > 0;
});
}
/**
* Pure mapping from LeagueConfigFormModel to the creation command.
* Driver ownership is handled by the caller.
*/
export function buildCreateLeagueCommandFromConfig(
form: LeagueConfigFormModel,
ownerId: string,
): CreateLeagueWithSeasonAndScoringCommand {
const structure = form.structure;
let maxDrivers: number | undefined;
let maxTeams: number | undefined;
if (structure.mode === 'solo') {
maxDrivers =
typeof structure.maxDrivers === 'number' ? structure.maxDrivers : undefined;
maxTeams = undefined;
} else {
const teams =
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
const perTeam =
typeof structure.driversPerTeam === 'number'
? structure.driversPerTeam
: 0;
maxTeams = teams > 0 ? teams : undefined;
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
}
return {
name: form.basics.name.trim(),
description: form.basics.description?.trim() || undefined,
visibility: form.basics.visibility,
ownerId,
gameId: form.basics.gameId,
maxDrivers,
maxTeams,
enableDriverChampionship: form.championships.enableDriverChampionship,
enableTeamChampionship: form.championships.enableTeamChampionship,
enableNationsChampionship: form.championships.enableNationsChampionship,
enableTrophyChampionship: form.championships.enableTrophyChampionship,
scoringPresetId: form.scoring.patternId || undefined,
};
}
/**
* Thin application-level facade that:
* - pulls the current driver via repository
* - builds the creation command
* - delegates to the create-league use case
*/
export async function createLeagueFromConfig(
form: LeagueConfigFormModel,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
const error = new Error(
'No driver profile found. Please create a driver profile first.',
);
(error as any).code = 'NO_DRIVER';
throw error;
}
const useCase = getCreateLeagueWithSeasonAndScoringUseCase();
const command = buildCreateLeagueCommandFromConfig(form, currentDriver.id);
return useCase.execute(command);
}
/**
* Apply scoring preset selection and derive timings, returning a new form model.
* This mirrors the previous React handler but keeps it in testable, non-UI logic.
*/
export function applyScoringPresetToConfig(
form: LeagueConfigFormModel,
patternId: string,
): LeagueConfigFormModel {
const lowerPresetId = patternId.toLowerCase();
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
let updatedTimings = { ...timings };
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
};
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 30,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 90,
sessionCount: 1,
};
} else {
updatedTimings = {
...updatedTimings,
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
};
}
return {
...form,
scoring: {
...form.scoring,
patternId,
customScoringEnabled: false,
},
timings: updatedTimings,
};
}