Extract event and race setup logic into module

This commit is contained in:
larssand
2026-03-25 19:51:03 +01:00
parent 7d6f75422f
commit 84e391cf7f
2 changed files with 815 additions and 735 deletions

785
src/event_race_logic.js Normal file
View File

@@ -0,0 +1,785 @@
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 });
}
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),
};
}
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) : [],
},
};
}
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 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;
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 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 }) {
const scopedDrivers = getEventDrivers(event);
if (scopedDrivers.length) {
return { drivers: scopedDrivers, fallback: false };
}
return {
drivers: [...state.drivers],
fallback: state.drivers.length > 0,
};
}
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;
}