831 lines
30 KiB
JavaScript
831 lines
30 KiB
JavaScript
export function normalizeRaceTeam(team, { uid }) {
|
|
return {
|
|
id: String(team?.id || uid("team")),
|
|
name: String(team?.name || "").trim(),
|
|
driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [],
|
|
carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [],
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function normalizeStoredRacePreset(preset, { uid }) {
|
|
return {
|
|
id: String(preset?.id || uid("preset")),
|
|
name: String(preset?.name || "").trim(),
|
|
values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {},
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset }) {
|
|
const builtins = [
|
|
{
|
|
id: "custom",
|
|
label: t("events.preset_custom"),
|
|
values: {},
|
|
},
|
|
{
|
|
id: "short_technical",
|
|
label: t("events.preset_short_technical"),
|
|
values: {
|
|
qualifyingScoring: "points",
|
|
qualifyingRounds: 3,
|
|
carsPerHeat: 6,
|
|
qualDurationMin: 5,
|
|
qualStartMode: "staggered",
|
|
qualSeedLapCount: 3,
|
|
qualSeedMethod: "best_sum",
|
|
countedQualRounds: 2,
|
|
qualifyingPointsTable: "rank_low",
|
|
qualifyingTieBreak: "best_lap",
|
|
carsPerFinal: 8,
|
|
finalLegs: 3,
|
|
countedFinalLegs: 2,
|
|
finalDurationMin: 5,
|
|
finalStartMode: "position",
|
|
followUpSec: 10,
|
|
minLapMs: 11000,
|
|
maxLapMs: 60000,
|
|
bumpCount: 0,
|
|
},
|
|
},
|
|
{
|
|
id: "club_qualifying",
|
|
label: t("events.preset_club_qualifying"),
|
|
values: {
|
|
qualifyingScoring: "points",
|
|
qualifyingRounds: 4,
|
|
carsPerHeat: 8,
|
|
qualDurationMin: 5,
|
|
qualStartMode: "staggered",
|
|
qualSeedLapCount: 3,
|
|
qualSeedMethod: "best_sum",
|
|
countedQualRounds: 2,
|
|
qualifyingPointsTable: "rank_low",
|
|
qualifyingTieBreak: "rounds",
|
|
carsPerFinal: 8,
|
|
finalLegs: 3,
|
|
countedFinalLegs: 2,
|
|
finalDurationMin: 5,
|
|
finalStartMode: "position",
|
|
followUpSec: 15,
|
|
minLapMs: 12000,
|
|
maxLapMs: 60000,
|
|
bumpCount: 0,
|
|
},
|
|
},
|
|
{
|
|
id: "ifmar",
|
|
label: t("events.preset_ifmar"),
|
|
values: {
|
|
qualifyingScoring: "points",
|
|
qualifyingRounds: 5,
|
|
carsPerHeat: 10,
|
|
qualDurationMin: 5,
|
|
qualStartMode: "staggered",
|
|
qualSeedLapCount: 3,
|
|
qualSeedMethod: "best_sum",
|
|
countedQualRounds: 3,
|
|
qualifyingPointsTable: "ifmar",
|
|
qualifyingTieBreak: "best_lap",
|
|
carsPerFinal: 10,
|
|
finalLegs: 3,
|
|
countedFinalLegs: 2,
|
|
finalDurationMin: 5,
|
|
finalStartMode: "position",
|
|
followUpSec: 15,
|
|
minLapMs: 12000,
|
|
maxLapMs: 70000,
|
|
bumpCount: 0,
|
|
},
|
|
},
|
|
{
|
|
id: "endurance",
|
|
label: t("events.preset_endurance"),
|
|
values: {
|
|
qualifyingScoring: "best",
|
|
qualifyingRounds: 1,
|
|
carsPerHeat: 12,
|
|
qualDurationMin: 10,
|
|
qualStartMode: "mass",
|
|
qualSeedLapCount: 0,
|
|
qualSeedMethod: "best_sum",
|
|
countedQualRounds: 1,
|
|
qualifyingPointsTable: "rank_low",
|
|
qualifyingTieBreak: "best_round",
|
|
carsPerFinal: 12,
|
|
finalLegs: 1,
|
|
countedFinalLegs: 1,
|
|
finalDurationMin: 240,
|
|
finalStartMode: "mass",
|
|
followUpSec: 60,
|
|
minLapMs: 10000,
|
|
maxLapMs: 120000,
|
|
bumpCount: 0,
|
|
},
|
|
},
|
|
];
|
|
const customPresets = Array.isArray(state.settings?.racePresets)
|
|
? state.settings.racePresets
|
|
.map((preset) => normalizeStoredRacePreset(preset))
|
|
.filter((preset) => preset.name)
|
|
.map((preset) => ({
|
|
id: preset.id,
|
|
label: preset.name,
|
|
custom: true,
|
|
values: { ...preset.values },
|
|
}))
|
|
: [];
|
|
return [...builtins, ...customPresets];
|
|
}
|
|
|
|
|
|
|
|
export function applyRaceFormatPreset(event, presetId, { getRaceFormatPresets }) {
|
|
const preset = getRaceFormatPresets().find((item) => item.id === presetId);
|
|
if (!preset || preset.id === "custom") {
|
|
event.raceConfig.presetId = "custom";
|
|
return;
|
|
}
|
|
Object.assign(event.raceConfig, preset.values, { presetId: preset.id });
|
|
event.raceConfig.practiceOnly = false;
|
|
}
|
|
|
|
|
|
|
|
export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode, getEventTeams }) {
|
|
return {
|
|
presetId: String(form.get("presetId") || "custom").trim() || "custom",
|
|
qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points",
|
|
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
|
|
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
|
|
qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5),
|
|
qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")),
|
|
qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0),
|
|
qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase())
|
|
? String(form.get("qualSeedMethod")).toLowerCase()
|
|
: "best_sum",
|
|
countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1),
|
|
qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase())
|
|
? String(form.get("qualifyingPointsTable")).toLowerCase()
|
|
: "rank_low",
|
|
qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase())
|
|
? String(form.get("qualifyingTieBreak")).toLowerCase()
|
|
: "rounds",
|
|
carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8),
|
|
finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1),
|
|
countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1),
|
|
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
|
|
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
|
|
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
|
|
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
|
|
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
|
|
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
|
|
reserveBumpSlots: form.get("reserveBumpSlots") === "on",
|
|
driverIds: event.raceConfig.driverIds || [],
|
|
participantsConfigured: event.raceConfig.participantsConfigured !== false,
|
|
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
|
|
teams: getEventTeams(event),
|
|
practiceOnly: event.raceConfig.practiceOnly === true,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartMode, normalizeRaceTeam }) {
|
|
return {
|
|
...event,
|
|
branding: normalizeBrandingConfig(event?.branding),
|
|
raceConfig: {
|
|
presetId: String(event?.raceConfig?.presetId || "custom").trim() || "custom",
|
|
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
|
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
|
|
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
|
|
qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5),
|
|
qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"),
|
|
qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2),
|
|
qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase())
|
|
? String(event.raceConfig.qualSeedMethod).toLowerCase()
|
|
: "best_sum",
|
|
countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1),
|
|
qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(event?.raceConfig?.qualifyingPointsTable || "").toLowerCase())
|
|
? String(event.raceConfig.qualifyingPointsTable).toLowerCase()
|
|
: "rank_low",
|
|
qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(event?.raceConfig?.qualifyingTieBreak || "").toLowerCase())
|
|
? String(event.raceConfig.qualifyingTieBreak).toLowerCase()
|
|
: "rounds",
|
|
carsPerFinal: Math.max(2, Number(event?.raceConfig?.carsPerFinal || 8) || 8),
|
|
finalLegs: Math.max(1, Number(event?.raceConfig?.finalLegs || 1) || 1),
|
|
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
|
|
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
|
|
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
|
|
followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0),
|
|
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
|
|
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
|
|
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
|
|
reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false,
|
|
driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [],
|
|
participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured),
|
|
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
|
|
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [],
|
|
practiceOnly: event?.raceConfig?.practiceOnly === true,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function normalizeBrandingConfig(branding) {
|
|
const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase())
|
|
? String(branding.pdfTheme).toLowerCase()
|
|
: "";
|
|
return {
|
|
brandName: String(branding?.brandName || "").trim(),
|
|
brandTagline: String(branding?.brandTagline || "").trim(),
|
|
pdfFooter: String(branding?.pdfFooter || "").trim(),
|
|
pdfTheme: theme,
|
|
logoDataUrl: String(branding?.logoDataUrl || "").trim(),
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function resolveEventBranding(event, { normalizeBrandingConfig, state }) {
|
|
const local = normalizeBrandingConfig(event?.branding);
|
|
return {
|
|
brandName: local.brandName || state.settings.clubName || "JMK RB",
|
|
brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event",
|
|
pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController",
|
|
pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic",
|
|
logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "",
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }) {
|
|
const cfg = event.raceConfig || {};
|
|
const isPracticeOnly = cfg.practiceOnly === true;
|
|
const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length;
|
|
const isEndurancePreset = selectedPreset?.id === "endurance";
|
|
const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length;
|
|
const qualCount = sessions.filter((session) => session.type === "qualification").length;
|
|
const finalCount = sessions.filter((session) => session.type === "final").length;
|
|
const teamCount = sessions.filter((session) => session.type === "team_race").length;
|
|
const hasQualifying = qualCount > 0;
|
|
const hasFinals = finalCount > 0;
|
|
const hasTeamRace = teamCount > 0;
|
|
const startedCount = sessions.filter((session) => ["running", "finished", "stopped"].includes(String(session.status || "").toLowerCase()) || session.startedAt).length;
|
|
const practiceRows = buildPracticeStandings(event);
|
|
const qualifyingRows = buildQualifyingStandings(event);
|
|
const finalRows = buildFinalStandings(event);
|
|
const lapWindowValid = Math.max(0, Number(cfg.maxLapMs || 0) || 0) > Math.max(0, Number(cfg.minLapMs || 0) || 0);
|
|
const resultSourceCount = [practiceRows, qualifyingRows, finalRows].filter((rows) => rows.length > 0).length;
|
|
|
|
if (isPracticeOnly) {
|
|
const openPracticeCount = sessions.filter((session) => session.type === "open_practice").length;
|
|
return {
|
|
setup: {
|
|
status: openPracticeCount > 0 ? "complete" : "pending",
|
|
detail: t("events.open_practice_all_transponders"),
|
|
},
|
|
format: {
|
|
status: "complete",
|
|
detail: t("events.open_practice_untimed"),
|
|
},
|
|
generation: {
|
|
status: openPracticeCount > 0 ? "complete" : "pending",
|
|
detail: `${openPracticeCount} ${t("events.detail_sessions")}`,
|
|
},
|
|
live: {
|
|
status: startedCount > 0 || resultSourceCount > 0 ? "complete" : "pending",
|
|
detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
const setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0)
|
|
? "complete"
|
|
: participantCount > 0 || raceTeams.length > 0
|
|
? "attention"
|
|
: "pending";
|
|
|
|
const formatStatus = lapWindowValid ? "complete" : "attention";
|
|
|
|
const generationReady = isEndurancePreset ? hasTeamRace : hasQualifying || hasFinals;
|
|
const generationStatus = generationReady ? "complete" : sessions.length > 0 ? "attention" : "pending";
|
|
|
|
const liveStatus = startedCount > 0 || resultSourceCount > 0 ? "complete" : generationReady ? "pending" : "pending";
|
|
|
|
return {
|
|
setup: {
|
|
status: setupStatus,
|
|
detail: `${participantCount} ${t("events.detail_drivers")} · ${raceTeams.length} ${t("events.detail_teams")}`,
|
|
},
|
|
format: {
|
|
status: formatStatus,
|
|
detail: `${selectedPreset?.label || t("events.preset_custom")} · ${((cfg.minLapMs || 0) / 1000).toFixed(1)} / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`,
|
|
},
|
|
generation: {
|
|
status: generationStatus,
|
|
detail: isEndurancePreset
|
|
? `P ${practiceCount} · T ${teamCount} · ${sessions.length} ${t("events.detail_sessions")}`
|
|
: `Q ${qualCount} · F ${finalCount} · ${sessions.length} ${t("events.detail_sessions")}`,
|
|
},
|
|
live: {
|
|
status: liveStatus,
|
|
detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function buildDefaultRaceWizardDraft({ state, getDriversForClass }) {
|
|
const classId = state.classes[0]?.id || "";
|
|
const presetId = "club_qualifying";
|
|
const classDrivers = getDriversForClass(classId);
|
|
return {
|
|
name: "",
|
|
date: new Date().toISOString().slice(0, 10),
|
|
classId,
|
|
presetId,
|
|
driverIds: classDrivers.map((driver) => driver.id),
|
|
createPractice: true,
|
|
practiceSessions: 1,
|
|
createQualifying: true,
|
|
qualifyingRounds: 4,
|
|
createTeamRace: false,
|
|
teamRaceDurationMin: 240,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function getRaceWizardPreset(presetId, { getRaceFormatPresets }) {
|
|
return getRaceFormatPresets().find((preset) => preset.id === presetId) || getRaceFormatPresets()[0];
|
|
}
|
|
|
|
|
|
|
|
export function applyRaceWizardPresetDefaults(draft, presetId, { getRaceWizardPreset }) {
|
|
const preset = getRaceWizardPreset(presetId);
|
|
const endurance = preset.id === "endurance";
|
|
draft.presetId = preset.id;
|
|
draft.createPractice = !endurance;
|
|
draft.practiceSessions = endurance ? 0 : Math.max(1, Number(draft.practiceSessions || 1));
|
|
draft.createQualifying = !endurance;
|
|
draft.qualifyingRounds = Math.max(1, Number(preset.values?.qualifyingRounds || draft.qualifyingRounds || 3));
|
|
draft.createTeamRace = endurance;
|
|
draft.teamRaceDurationMin = Math.max(1, Number(preset.values?.finalDurationMin || draft.teamRaceDurationMin || 240));
|
|
return draft;
|
|
}
|
|
|
|
|
|
|
|
export function ensureRaceParticipantsConfigured(event, { state }) {
|
|
if (!event || event.mode !== "race") {
|
|
return;
|
|
}
|
|
if (event.raceConfig?.participantsConfigured) {
|
|
return;
|
|
}
|
|
const classDrivers = state.drivers.filter((driver) => !event.classId || driver.classId === event.classId);
|
|
event.raceConfig.driverIds = classDrivers.map((driver) => driver.id);
|
|
event.raceConfig.participantsConfigured = true;
|
|
}
|
|
|
|
|
|
|
|
export function buildRaceSession(eventId, name, type, durationMin, overrides = {}, { normalizeSession, uid }) {
|
|
return normalizeSession({
|
|
id: uid("session"),
|
|
eventId,
|
|
name,
|
|
type,
|
|
durationMin,
|
|
maxCars: null,
|
|
mode: "race",
|
|
status: "ready",
|
|
startedAt: null,
|
|
endedAt: null,
|
|
finishedByTimer: false,
|
|
assignments: [],
|
|
driverIds: overrides.driverIds || [],
|
|
startMode: overrides.startMode || "mass",
|
|
seedBestLapCount: overrides.seedBestLapCount || 0,
|
|
seedMethod: overrides.seedMethod || "best_sum",
|
|
followUpSec: overrides.followUpSec || 0,
|
|
});
|
|
}
|
|
|
|
export function buildOpenPracticeBundle(input = {}, { normalizeEvent, buildRaceSession, uid }) {
|
|
const name = String(input.name || "Open Practice").trim() || "Open Practice";
|
|
const event = normalizeEvent({
|
|
id: uid("event"),
|
|
name,
|
|
date: String(input.date || new Date().toISOString().slice(0, 10)),
|
|
classId: "",
|
|
mode: "race",
|
|
});
|
|
event.raceConfig = {
|
|
...event.raceConfig,
|
|
presetId: "custom",
|
|
practiceOnly: true,
|
|
driverIds: [],
|
|
participantsConfigured: false,
|
|
teams: [],
|
|
};
|
|
const session = buildRaceSession(event.id, name, "open_practice", 1, {
|
|
driverIds: [],
|
|
startMode: "mass",
|
|
followUpSec: 0,
|
|
});
|
|
return { event, session };
|
|
}
|
|
|
|
export function buildTrackSession(eventId, name, type, durationMin, { normalizeSession, uid }) {
|
|
return normalizeSession({
|
|
id: uid("session"),
|
|
eventId,
|
|
name,
|
|
type,
|
|
durationMin,
|
|
maxCars: null,
|
|
mode: "track",
|
|
status: "ready",
|
|
startedAt: null,
|
|
endedAt: null,
|
|
finishedByTimer: false,
|
|
assignments: [],
|
|
});
|
|
}
|
|
|
|
|
|
|
|
export function createSponsorRounds(eventId, config, { state, t, buildTrackSession }) {
|
|
const qualificationRounds = Math.max(0, Math.floor(config.qualificationRounds || 0));
|
|
const heatRounds = Math.max(0, Math.floor(config.heatRounds || 0));
|
|
const finalRounds = Math.max(0, Math.floor(config.finalRounds || 0));
|
|
const durationMin = Math.max(1, Math.floor(config.durationMin || 5));
|
|
|
|
for (let i = 1; i <= qualificationRounds; i += 1) {
|
|
state.sessions.push(buildTrackSession(eventId, `${t("session.qualification")} ${i}`, "qualification", durationMin));
|
|
}
|
|
for (let i = 1; i <= heatRounds; i += 1) {
|
|
state.sessions.push(buildTrackSession(eventId, `${t("session.heat")} ${i}`, "heat", durationMin));
|
|
}
|
|
for (let i = 1; i <= finalRounds; i += 1) {
|
|
state.sessions.push(buildTrackSession(eventId, `${t("session.final")} ${i}`, "final", durationMin));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function buildRaceSessionsFromWizard(event, draft, { t, buildRaceSession }) {
|
|
const driverIds = event.raceConfig.driverIds || [];
|
|
const created = [];
|
|
const practiceCount = Math.max(0, Number(draft.practiceSessions || 0) || 0);
|
|
const qualRounds = Math.max(0, Number(draft.qualifyingRounds || 0) || 0);
|
|
if (draft.createPractice && practiceCount > 0) {
|
|
for (let index = 1; index <= practiceCount; index += 1) {
|
|
created.push(
|
|
buildRaceSession(event.id, `${t("session.practice")} ${index}`, "practice", event.raceConfig.qualDurationMin, {
|
|
driverIds,
|
|
startMode: "mass",
|
|
followUpSec: 0,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
if (draft.createQualifying && qualRounds > 0) {
|
|
for (let index = 1; index <= qualRounds; index += 1) {
|
|
created.push(
|
|
buildRaceSession(event.id, `${t("session.qualification")} ${index}`, "qualification", event.raceConfig.qualDurationMin, {
|
|
driverIds,
|
|
startMode: event.raceConfig.qualStartMode,
|
|
seedBestLapCount: event.raceConfig.qualSeedLapCount,
|
|
seedMethod: event.raceConfig.qualSeedMethod,
|
|
followUpSec: event.raceConfig.followUpSec,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
if (draft.createTeamRace) {
|
|
created.push(
|
|
buildRaceSession(event.id, t("events.team_race"), "team_race", Math.max(1, Number(draft.teamRaceDurationMin || event.raceConfig.finalDurationMin || 1)), {
|
|
driverIds,
|
|
startMode: "mass",
|
|
followUpSec: event.raceConfig.followUpSec,
|
|
})
|
|
);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
|
|
|
|
export function getRaceWizardSessionPlan(draft, { t }) {
|
|
const plan = [];
|
|
if (draft.createPractice) {
|
|
plan.push(`${Math.max(1, Number(draft.practiceSessions || 1))} x ${t("session.practice")}`);
|
|
}
|
|
if (draft.createQualifying) {
|
|
plan.push(`${Math.max(1, Number(draft.qualifyingRounds || 1))} x ${t("session.qualification")}`);
|
|
}
|
|
if (draft.createTeamRace) {
|
|
plan.push(`${t("events.team_race")} ${Math.max(1, Number(draft.teamRaceDurationMin || 1))} min`);
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
|
|
|
|
export function getEventDrivers(event, { state }) {
|
|
const classDrivers = state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId);
|
|
if (!event?.raceConfig?.participantsConfigured) {
|
|
return classDrivers;
|
|
}
|
|
return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id));
|
|
}
|
|
|
|
|
|
|
|
export function getEventTeams(event, { normalizeRaceTeam }) {
|
|
return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [];
|
|
}
|
|
|
|
|
|
|
|
export function getTeamDriverPool(event, { getEventDrivers, state }) {
|
|
return {
|
|
drivers: Array.isArray(state?.drivers) ? [...state.drivers] : [],
|
|
fallback: false,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function findEventTeamForPassing(event, driverId, carId, { getEventTeams }) {
|
|
return getEventTeams(event).find((team) => {
|
|
const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId);
|
|
const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId);
|
|
return Boolean(driverMatch || carMatch);
|
|
}) || null;
|
|
}
|
|
|
|
|
|
|
|
export function generateQualifyingForRace(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }) {
|
|
const sourceRows = buildPracticeStandings(event);
|
|
const fallbackRows =
|
|
sourceRows.length > 0
|
|
? sourceRows
|
|
: getEventDrivers(event).map((driver, index) => ({
|
|
rank: index + 1,
|
|
driverId: driver.id,
|
|
driverName: driver.name,
|
|
}));
|
|
|
|
if (!fallbackRows.length) {
|
|
return 0;
|
|
}
|
|
|
|
clearGeneratedQualifying(event.id);
|
|
|
|
const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1);
|
|
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
|
|
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
|
|
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
|
|
const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0);
|
|
const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase())
|
|
? String(event.raceConfig.qualSeedMethod).toLowerCase()
|
|
: "best_sum";
|
|
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
|
const heats = chunkArray(fallbackRows, carsPerHeat);
|
|
let created = 0;
|
|
|
|
heats.forEach((heatRows, heatIndex) => {
|
|
const heatNumber = heatIndex + 1;
|
|
const driverIds = heatRows.map((row) => row.driverId).filter(Boolean);
|
|
for (let round = 1; round <= qualifyingRounds; round += 1) {
|
|
state.sessions.push(
|
|
normalizeSession({
|
|
id: uid("session"),
|
|
eventId: event.id,
|
|
name: `Q${round} H${heatNumber}`,
|
|
type: "qualification",
|
|
durationMin: qualDurationMin,
|
|
maxCars: carsPerHeat,
|
|
mode: "race",
|
|
status: "ready",
|
|
startedAt: null,
|
|
endedAt: null,
|
|
finishedByTimer: false,
|
|
followUpSec,
|
|
followUpStartedAt: null,
|
|
startMode: qualStartMode,
|
|
seedBestLapCount: qualSeedLapCount,
|
|
seedMethod: qualSeedMethod,
|
|
staggerGapSec: 3,
|
|
driverIds,
|
|
manualGridIds: [...driverIds],
|
|
gridCustomized: false,
|
|
generated: true,
|
|
assignments: [],
|
|
})
|
|
);
|
|
created += 1;
|
|
}
|
|
});
|
|
|
|
return created;
|
|
}
|
|
|
|
|
|
|
|
export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }) {
|
|
const standings = buildQualifyingStandings(event);
|
|
const sourceRows =
|
|
standings.length > 0
|
|
? standings
|
|
: buildPracticeStandings(event).length > 0
|
|
? buildPracticeStandings(event)
|
|
: getEventDrivers(event).map((driver, index) => ({
|
|
rank: index + 1,
|
|
driverId: driver.id,
|
|
driverName: driver.name,
|
|
}));
|
|
|
|
if (!sourceRows.length) {
|
|
return 0;
|
|
}
|
|
|
|
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
|
|
const heats = chunkArray(sourceRows, carsPerHeat);
|
|
const sessionsByRound = new Map();
|
|
getSessionsForEvent(event.id)
|
|
.filter((session) => session.type === "qualification" && session.generated && session.status === "ready")
|
|
.forEach((session) => {
|
|
const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i);
|
|
const round = match ? Number(match[1]) : 0;
|
|
if (!sessionsByRound.has(round)) {
|
|
sessionsByRound.set(round, []);
|
|
}
|
|
sessionsByRound.get(round).push(session);
|
|
});
|
|
|
|
let updated = 0;
|
|
let locked = 0;
|
|
[...sessionsByRound.entries()].forEach(([, roundSessions]) => {
|
|
roundSessions.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
roundSessions.forEach((session, heatIndex) => {
|
|
if (session.gridCustomized) {
|
|
locked += 1;
|
|
return;
|
|
}
|
|
const heatRows = heats[heatIndex] || [];
|
|
session.driverIds = heatRows.map((row) => row.driverId).filter(Boolean);
|
|
session.manualGridIds = [...session.driverIds];
|
|
updated += 1;
|
|
});
|
|
});
|
|
|
|
return { updated, locked };
|
|
}
|
|
|
|
|
|
|
|
export function generateFinalsForRace(event, { buildPracticeStandings, buildQualifyingStandings, clearGeneratedFinals, state, normalizeStartMode, normalizeSession, uid, chunkArray, t }) {
|
|
const sourceRows =
|
|
event.raceConfig?.finalsSource === "practice"
|
|
? buildPracticeStandings(event)
|
|
: buildQualifyingStandings(event);
|
|
|
|
if (!sourceRows.length) {
|
|
return 0;
|
|
}
|
|
|
|
clearGeneratedFinals(event.id);
|
|
|
|
const finalLegs = Math.max(1, Number(event.raceConfig?.finalLegs || 1) || 1);
|
|
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
|
|
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
|
|
const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position");
|
|
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
|
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
|
|
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
|
|
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
|
|
const mains = chunkArray(sourceRows, seededSlotsPerMain);
|
|
let created = 0;
|
|
|
|
mains.forEach((mainRows, mainIndex) => {
|
|
const mainLetter = String.fromCharCode(65 + mainIndex);
|
|
const driverIds = mainRows.map((row) => row.driverId).filter(Boolean);
|
|
for (let leg = 1; leg <= finalLegs; leg += 1) {
|
|
state.sessions.push(
|
|
normalizeSession({
|
|
id: uid("session"),
|
|
eventId: event.id,
|
|
name: `${mainLetter} ${t("session.final")} ${leg}`,
|
|
type: "final",
|
|
durationMin: finalDurationMin,
|
|
maxCars: carsPerFinal,
|
|
mode: "race",
|
|
status: "ready",
|
|
startedAt: null,
|
|
endedAt: null,
|
|
finishedByTimer: false,
|
|
followUpSec,
|
|
followUpStartedAt: null,
|
|
startMode: finalStartMode,
|
|
seedBestLapCount: 0,
|
|
staggerGapSec: 0,
|
|
driverIds,
|
|
manualGridIds: [...driverIds],
|
|
gridCustomized: false,
|
|
reservedBumpSlots: reserveBumpSlots && mainIndex < mains.length - 1 ? bumpCount : 0,
|
|
generated: true,
|
|
assignments: [],
|
|
})
|
|
);
|
|
created += 1;
|
|
}
|
|
});
|
|
|
|
return created;
|
|
}
|
|
|
|
|
|
|
|
export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEvent }) {
|
|
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
|
|
if (bumpCount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const standings = buildFinalStandings(event);
|
|
if (!standings.length) {
|
|
return 0;
|
|
}
|
|
|
|
const grouped = new Map();
|
|
standings.forEach((row) => {
|
|
const mainKey = String(row.rank || "").charAt(0).toUpperCase();
|
|
if (!grouped.has(mainKey)) {
|
|
grouped.set(mainKey, []);
|
|
}
|
|
grouped.get(mainKey).push(row);
|
|
});
|
|
|
|
const mainKeys = [...grouped.keys()].sort();
|
|
let applied = 0;
|
|
|
|
for (let index = 1; index < mainKeys.length; index += 1) {
|
|
const upperMainKey = mainKeys[index - 1];
|
|
const lowerMainKey = mainKeys[index];
|
|
const lowerRows = grouped.get(lowerMainKey) || [];
|
|
const bumpRows = lowerRows.slice(0, bumpCount);
|
|
if (!bumpRows.length) {
|
|
continue;
|
|
}
|
|
|
|
const upperSessions = getSessionsForEvent(event.id).filter((session) => {
|
|
return session.type === "final" && String(session.name || "").toUpperCase().startsWith(upperMainKey) && session.status === "ready";
|
|
});
|
|
|
|
if (!upperSessions.length) {
|
|
continue;
|
|
}
|
|
|
|
const bumpDriverIds = bumpRows.map((row) => row.driverId).filter(Boolean);
|
|
if (!bumpDriverIds.length) {
|
|
continue;
|
|
}
|
|
|
|
upperSessions.forEach((session) => {
|
|
session.driverIds = Array.isArray(session.driverIds) ? session.driverIds : [];
|
|
session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds];
|
|
const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0);
|
|
const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0);
|
|
const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity;
|
|
let addedHere = 0;
|
|
bumpDriverIds.forEach((driverId) => {
|
|
if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) {
|
|
session.driverIds.push(driverId);
|
|
if (!session.manualGridIds.includes(driverId)) {
|
|
session.manualGridIds.push(driverId);
|
|
}
|
|
applied += 1;
|
|
addedHere += 1;
|
|
}
|
|
});
|
|
session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere);
|
|
});
|
|
}
|
|
|
|
return applied;
|
|
}
|