Files
Live_RC/src/timing_logic.js

836 lines
28 KiB
JavaScript

export function getSessionTypeLabel(type, { t }) {
const key = `session.${String(type || "").toLowerCase()}`;
const translated = t(key);
return translated === key ? String(type || "") : translated;
}
export function getStatusLabel(status, { t }) {
const key = `status.${String(status || "").toLowerCase()}`;
const translated = t(key);
return translated === key ? String(status || "") : translated;
}
export function isUntimedSession(session) {
return String(session?.type || "").toLowerCase() === "open_practice";
}
export function getActiveSession({ state }) {
return state.sessions.find((s) => s.id === state.activeSessionId) || null;
}
export function getSessionTargetMs(session, { isUntimedSession }) {
if (isUntimedSession(session)) {
return null;
}
return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000;
}
export function getSessionLapWindow(session, { state }) {
const event = state.events.find((item) => item.id === session?.eventId);
if (event?.mode !== "race") {
return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY };
}
const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0);
const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000);
const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY;
return { minLapMs, maxLapMs };
}
export function isCountedPassing(passing) {
return passing?.validLap !== false;
}
export function getVisiblePassings(result, { isCountedPassing }) {
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
}
export function getPassingValidationLabel(passing, { t }) {
if (passing?.validLap === false) {
if (passing.invalidReason === "below_min") {
return t("timing.invalid_short");
}
if (passing.invalidReason === "manual_invalid") {
return t("timing.invalid_manual");
}
return t("timing.invalid_long");
}
return t("timing.valid_passing");
}
export function getSessionTiming(session, nowTs = Date.now(), { getSessionTargetMs }) {
const targetMs = getSessionTargetMs(session);
const startedAt = Number(session?.startedAt || 0);
const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0;
const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000;
const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0;
const followUpActive = Boolean(followUpStartedAt && followUpMs > 0);
const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0;
return {
targetMs,
elapsedMs,
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
untimed: targetMs === null,
followUpActive,
followUpRemainingMs,
};
}
export function ensureSessionResult(sessionId, { state }) {
if (!state.resultsBySession[sessionId]) {
state.resultsBySession[sessionId] = {
passings: [],
competitors: {},
adjustments: [],
};
}
if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) {
state.resultsBySession[sessionId].adjustments = [];
}
return state.resultsBySession[sessionId];
}
export function buildLeaderboard(session, { ensureSessionResult, getSessionTargetMs, getCompetitorPassings, isCountedPassing, getCompetitorElapsedMs, getCompetitorSeedMetric, getSessionLapWindow, getPassingValidationLabel, formatSeedMetric, formatRaceClock, formatLeaderboardGap, formatLapDelta, formatLap, t }) {
const result = ensureSessionResult(session.id);
const sessionType = String(session.type || "").toLowerCase();
const targetMs = getSessionTargetMs(session);
const useTargetTieBreak = session.status === "finished";
const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0;
const isFreePractice = sessionType === "free_practice";
const isOpenPractice = sessionType === "open_practice";
const isRollingPractice = isFreePractice || isOpenPractice;
const { maxLapMs } = getSessionLapWindow(session);
const nowTs = Date.now();
const rows = Object.values(result.competitors).map((row) => {
const allPassings = getCompetitorPassings(session, row, { includeInvalid: true });
const passings = allPassings.filter((passing) => isCountedPassing(passing));
const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null;
const latestPassing = passings.length ? passings[passings.length - 1] : null;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
const rawElapsedMs = lastPassingTs
? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs))
: getCompetitorElapsedMs(session, row);
const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0;
const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0;
const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs);
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
const seedMetric = getCompetitorSeedMetric(session, row);
const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null;
const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0;
const bestLapMs = Number(row.bestLapMs || 0) || 0;
const lapDeltaMs =
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
const predictionBaseMs =
lastLapMs > 0
? lastLapMs
: bestLapMs > 0
? bestLapMs
: null;
const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0;
const predictionStaleMs = predictionBaseMs
? Math.max(
predictionBaseMs * 2,
Number.isFinite(maxLapMs) && maxLapMs > 0 ? maxLapMs : 0,
15000
)
: 0;
const predictionActive = Boolean(predictionBaseMs && lastPassingTs && currentLapElapsedMs <= predictionStaleMs);
const predictedRemainingMs = predictionActive ? predictionBaseMs - currentLapElapsedMs : null;
const predictedProgress = predictionActive ? currentLapElapsedMs / predictionBaseMs : 0;
const predictionTone =
!predictionActive || predictedProgress <= 0.85
? "good"
: predictedProgress <= 1
? "warn"
: "late";
const invalidPending = latestAnyPassing?.validLap === false;
return {
...row,
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
lastLapMs,
bestLapMs,
lastTimestamp: lastPassingTs || row.lastTimestamp,
totalElapsedMs,
manualLapAdjustment,
manualTimeAdjustmentMs,
distanceToTargetMs,
seedMetric,
previousLapMs,
lapDeltaMs,
predictedRemainingMs,
predictedProgress,
predictionTone,
invalidPending,
invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "",
invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0,
comparisonMs:
isRollingPractice
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
: useSeedRanking && seedMetric
? seedMetric.comparableMs
: totalElapsedMs,
resultDisplay:
isRollingPractice
? formatLap(bestLapMs || lastLapMs)
: useSeedRanking && seedMetric
? formatSeedMetric(seedMetric)
: `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`,
};
});
rows.sort((a, b) => {
if (isRollingPractice) {
if (a.comparisonMs !== b.comparisonMs) {
return a.comparisonMs - b.comparisonMs;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
return (b.lastTimestamp || 0) - (a.lastTimestamp || 0);
}
if (useSeedRanking) {
if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) {
return a.seedMetric.comparableMs - b.seedMetric.comparableMs;
}
if (a.seedMetric && !b.seedMetric) {
return -1;
}
if (!a.seedMetric && b.seedMetric) {
return 1;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
return a.totalElapsedMs - b.totalElapsedMs;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) {
return a.distanceToTargetMs - b.distanceToTargetMs;
}
return a.totalElapsedMs - b.totalElapsedMs;
});
const leader = rows[0];
return rows.map((row, index) => {
if (!leader) {
return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" };
}
return {
...row,
gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
gapAhead: formatLeaderboardGap(row, rows[index - 1], {
useSeedRanking,
useTargetTieBreak,
isFreePractice: isRollingPractice,
selfLabel: t("status.leader"),
}),
lapDelta: formatLapDelta(row.lapDeltaMs),
};
});
}
export function formatLapDelta(deltaMs, { formatLap }) {
if (!deltaMs && deltaMs !== 0) {
return "-";
}
const sign = deltaMs <= 0 ? "-" : "+";
return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`;
}
export function formatLeaderboardGap(row, referenceRow, options = {}, { t }) {
if (!referenceRow) {
return "-";
}
if (row.key === referenceRow.key) {
if (options.isFreePractice) {
return t("status.free_practice");
}
return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader"));
}
if (options.isFreePractice) {
if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) {
return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`;
}
return "-";
}
if (options.useSeedRanking) {
if (referenceRow.seedMetric && row.seedMetric) {
const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs);
return `+${(seedGap / 1000).toFixed(3)}s`;
}
return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`;
}
const lapDiff = (referenceRow.laps || 0) - (row.laps || 0);
if (lapDiff > 0) {
return `+${lapDiff}L`;
}
const referenceValue = options.useTargetTieBreak ? referenceRow.distanceToTargetMs : referenceRow.totalElapsedMs;
const rowValue = options.useTargetTieBreak ? row.distanceToTargetMs : row.totalElapsedMs;
const timeGap = Math.max(0, rowValue - referenceValue);
return `+${(timeGap / 1000).toFixed(3)}s`;
}
export function getCompetitorElapsedMs(session, row) {
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
if (!startTs || !row?.lastTimestamp) {
return 0;
}
return Math.max(0, row.lastTimestamp - startTs);
}
export function getCompetitorPassings(session, row, options = {}, { ensureSessionResult, isCountedPassing }) {
const result = ensureSessionResult(session.id);
return result.passings
.filter((passing) => {
if (!options.includeInvalid && !isCountedPassing(passing)) {
return false;
}
if (passing.competitorKey) {
return passing.competitorKey === row.key;
}
return (
String(passing.transponder || "") === String(row.transponder || "") &&
String(passing.driverId || "") === String(row.driverId || "") &&
String(passing.carId || "") === String(row.carId || "")
);
})
.sort((a, b) => a.timestamp - b.timestamp);
}
export function getCompetitorSeedMetric(session, row, { getCompetitorPassings }) {
const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0);
if (lapCount <= 0) {
return null;
}
const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase())
? String(session.seedMethod).toLowerCase()
: "best_sum";
const laps = getCompetitorPassings(session, row)
.map((passing) => Number(passing.lapMs || 0))
.filter((lapMs) => lapMs > 500);
if (laps.length < lapCount) {
return null;
}
let selected = [];
if (method === "consecutive") {
let bestWindow = null;
for (let index = 0; index <= laps.length - lapCount; index += 1) {
const window = laps.slice(index, index + lapCount);
const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0);
if (!bestWindow || totalMs < bestWindow.totalMs) {
bestWindow = { laps: window, totalMs };
}
}
if (!bestWindow) {
return null;
}
selected = bestWindow.laps;
} else {
selected = [...laps].sort((a, b) => a - b).slice(0, lapCount);
}
const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0);
const averageMs = totalMs / lapCount;
return {
lapCount,
method,
totalMs,
averageMs,
comparableMs: method === "average" ? averageMs : totalMs,
laps: selected,
};
}
export function getSessionEntrants(session, { state, getEventDrivers }) {
const event = state.events.find((item) => item.id === session.eventId);
const eventDrivers = event ? getEventDrivers(event) : state.drivers;
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
return eventDrivers;
}
return eventDrivers.filter((driver) => session.driverIds.includes(driver.id));
}
export function buildPracticeStandings(event, { getSessionsForEvent, buildLeaderboard, getCompetitorSeedMetric, formatSeedMetric, formatRaceClock }) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice");
const competitorMap = new Map();
sessions.forEach((session) => {
buildLeaderboard(session).forEach((row) => {
const key = row.driverId || row.key;
const seedMetric = getCompetitorSeedMetric(session, row);
const comparableMs = seedMetric?.comparableMs ?? row.bestLapMs ?? row.totalElapsedMs;
const current = competitorMap.get(key);
if (!current || comparableMs < current.comparableMs) {
competitorMap.set(key, {
key,
driverId: row.driverId,
driverName: row.driverName,
comparableMs,
resultDisplay: seedMetric ? formatSeedMetric(seedMetric) : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`,
sourceSessionName: session.name,
});
}
});
});
return [...competitorMap.values()].sort((a, b) => a.comparableMs - b.comparableMs).map((row, index) => ({
...row,
rank: index + 1,
score: row.resultDisplay,
}));
}
export function getQualifyingPointsValue(place, fieldSize, tableType) {
const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase())
? String(tableType).toLowerCase()
: "rank_low";
if (normalized === "field_desc") {
return Math.max(1, Number(fieldSize || 0) - place + 1);
}
if (normalized === "ifmar") {
const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
return scale[place - 1] ?? 0;
}
return place;
}
export function isHighPointsTable(tableType) {
return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase());
}
export function compareNumberSet(left, right, highWins = false) {
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
const leftValue = left[i] ?? (highWins ? -Infinity : Infinity);
const rightValue = right[i] ?? (highWins ? -Infinity : Infinity);
if (leftValue !== rightValue) {
return highWins ? rightValue - leftValue : leftValue - rightValue;
}
}
return 0;
}
export function buildQualifyingTieBreakNote(row, tieBreak, { t, formatLap }) {
if (tieBreak === "best_lap") {
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")}${formatLap(row.bestSingleLapMs)}`;
}
if (tieBreak === "best_round") {
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")}${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`;
}
return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")}${(row.ranks || []).join(" / ") || "-"}`;
}
export function hasQualifyingPrimaryTie(left, right, scoringMode) {
if (!left || !right) {
return false;
}
if (scoringMode === "points") {
return left.totalScore === right.totalScore;
}
return left.bestRank === right.bestRank;
}
export function buildQualifyingStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, isHighPointsTable, getQualifyingPointsValue, compareNumberSet, buildQualifyingTieBreakNote, formatLap, formatRaceClock, t, hasQualifyingPrimaryTie }) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification");
const scoringMode = event.raceConfig?.qualifyingScoring || "points";
const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1);
const pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low";
const highPointsWin = isHighPointsTable(pointsTable);
const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds";
const competitorMap = new Map();
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0);
rows.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {
key,
driverId: row.driverId,
driverName: row.driverName,
points: [],
ranks: [],
roundMetrics: [],
bestLaps: [],
});
}
const entry = competitorMap.get(key);
entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable));
entry.ranks.push(index + 1);
entry.roundMetrics.push(row.comparisonMs);
if (Number.isFinite(row.bestLapMs)) {
entry.bestLaps.push(row.bestLapMs);
}
if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) {
entry.bestRoundMetricValue = row.comparisonMs;
entry.bestRoundDisplay = row.resultDisplay;
}
entry.bestResultDisplay = row.resultDisplay;
entry.lastSessionName = session.name;
entry.sessionCount = (entry.sessionCount || 0) + 1;
entry.maxFieldSize = entrantCount;
});
});
const rows = [...competitorMap.values()].map((entry) => {
const sortedPoints = [...entry.points].sort((a, b) => (highPointsWin ? b - a : a - b));
const counted = sortedPoints.slice(0, countedRounds);
const totalScore = counted.reduce((sum, value) => sum + value, 0);
const bestRank = Math.min(...entry.ranks);
const bestRoundMetric = Math.min(...entry.roundMetrics);
const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER;
return {
key: entry.key,
driverId: entry.driverId,
driverName: entry.driverName,
ranks: [...entry.ranks].sort((a, b) => a - b),
countedPoints: counted,
totalScore,
bestRank,
bestRoundMetric,
bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric),
bestSingleLapMs,
score:
scoringMode === "points"
? `${totalScore} (${counted.join("+")})`
: `${bestRank} / ${formatRaceClock(bestRoundMetric)}`,
};
});
rows.sort((a, b) => {
if (scoringMode === "points") {
if (a.totalScore !== b.totalScore) {
return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore;
}
if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) {
return a.bestSingleLapMs - b.bestSingleLapMs;
}
if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) {
return a.bestRoundMetric - b.bestRoundMetric;
}
const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin);
if (pointDiff !== 0) {
return pointDiff;
}
return a.bestRoundMetric - b.bestRoundMetric;
}
if (a.bestRank !== b.bestRank) {
return a.bestRank - b.bestRank;
}
if (tieBreak === "rounds") {
const rankDiff = compareNumberSet(a.ranks, b.ranks, false);
if (rankDiff !== 0) {
return rankDiff;
}
}
if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) {
return a.bestSingleLapMs - b.bestSingleLapMs;
}
return a.bestRoundMetric - b.bestRoundMetric;
});
rows.forEach((row, index) => {
row.tieBreakWonAgainst = "";
row.tieBreakLostAgainst = "";
if (index === 0) {
return;
}
const previous = rows[index - 1];
if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) {
return;
}
previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver");
row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver");
});
return rows.map((row, index) => ({
...row,
rank: index + 1,
scoreNote: [
buildQualifyingTieBreakNote(row, tieBreak),
row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "",
row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "",
]
.filter(Boolean)
.join(" • "),
}));
}
export function formatTeamActiveMemberLabel(rowOrPassing) {
if (!rowOrPassing) {
return "-";
}
const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean);
return parts.join(" • ") || rowOrPassing.subLabel || "-";
}
export function buildTeamRaceStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionSortWeight }) {
return getSessionsForEvent(event.id)
.filter((session) => session.type === "team_race")
.sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name)))
.map((session) => ({
session,
rows: buildLeaderboard(session),
}));
}
export function buildTeamStintLog(session, row, { getCompetitorPassings, getSessionLapWindow, formatTeamActiveMemberLabel }) {
const passings = getCompetitorPassings(session, row);
if (!passings.length) {
return [];
}
const { maxLapMs } = getSessionLapWindow(session);
const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY;
const stints = [];
let current = null;
passings.forEach((passing) => {
const memberLabel = formatTeamActiveMemberLabel(passing);
const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`;
const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs;
if (!current || current.memberKey !== memberKey || gapBreak) {
current = {
memberKey,
memberLabel,
driverName: passing.driverName || "-",
carName: passing.carName || "-",
startTs: passing.timestamp,
endTs: passing.timestamp,
laps: 1,
};
stints.push(current);
return;
}
current.endTs = passing.timestamp;
current.laps += 1;
});
return stints.map((stint, index) => ({
...stint,
index: index + 1,
durationMs: Math.max(0, stint.endTs - stint.startTs),
}));
}
export function getSessionGridEntries(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder }) {
if (!session) {
return [];
}
if (session.mode === "track") {
return (session.assignments || []).map((assignment, index) => {
const driver = state.drivers.find((item) => item.id === assignment.driverId);
const car = state.cars.find((item) => item.id === assignment.carId);
return {
slot: index + 1,
name: driver?.name || t("common.unknown_driver"),
meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"),
};
});
}
if (session.type === "team_race") {
const event = state.events.find((item) => item.id === session.eventId);
return getEventTeams(event).map((team, index) => ({
slot: index + 1,
name: team.name,
meta:
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") ||
team.carIds
.map((carId) => {
const car = state.cars.find((item) => item.id === carId);
return car ? `${car.name} (${car.transponder || "-"})` : "";
})
.filter(Boolean)
.join(", ") ||
"-",
}));
}
return getSessionGridOrder(session).map((driverId, index) => {
const driver = state.drivers.find((item) => item.id === driverId);
return {
slot: index + 1,
name: driver?.name || t("common.unknown_driver"),
meta: driver?.transponder || "-",
};
});
}
export function getSessionGridOrder(session) {
if (!session) {
return [];
}
if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) {
return session.manualGridIds;
}
return Array.isArray(session.driverIds) ? session.driverIds : [];
}
export function ensureSessionDriverOrder(session, { getSessionEntrants }) {
if (!session || session.mode !== "race") {
return [];
}
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
session.driverIds = getSessionEntrants(session)
.map((driver) => driver.id)
.filter(Boolean);
}
if (!Array.isArray(session.manualGridIds) || !session.manualGridIds.length) {
session.manualGridIds = [...session.driverIds];
}
return session.manualGridIds;
}
export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard }) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final");
const groupedByMain = new Map();
const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1);
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
const mainMatch = String(session.name || "").match(/^([A-Z])/i);
const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A";
if (!groupedByMain.has(mainKey)) {
groupedByMain.set(mainKey, new Map());
}
const competitorMap = groupedByMain.get(mainKey);
rows.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {
key,
mainKey,
driverId: row.driverId,
driverName: row.driverName,
legRanks: [],
bestElapsedMs: [],
});
}
const entry = competitorMap.get(key);
entry.legRanks.push(index + 1);
entry.bestElapsedMs.push(row.totalElapsedMs);
});
});
const mains = [...groupedByMain.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const rows = [];
mains.forEach(([mainKey, competitorMap], mainIndex) => {
const mainRows = [...competitorMap.values()].map((entry) => {
const sortedRanks = [...entry.legRanks].sort((a, b) => a - b);
const countedRanks = sortedRanks.slice(0, countedFinalLegs);
return {
mainKey,
driverId: entry.driverId,
driverName: entry.driverName,
finalScore: countedRanks.reduce((sum, value) => sum + value, 0),
legRanks: sortedRanks,
bestElapsedMs: Math.min(...entry.bestElapsedMs),
};
});
mainRows.sort((a, b) => {
if (a.finalScore !== b.finalScore) {
return a.finalScore - b.finalScore;
}
for (let i = 0; i < Math.max(a.legRanks.length, b.legRanks.length); i += 1) {
const left = a.legRanks[i] ?? 999;
const right = b.legRanks[i] ?? 999;
if (left !== right) {
return left - right;
}
}
return a.bestElapsedMs - b.bestElapsedMs;
});
mainRows.forEach((row, rowIndex) => {
rows.push({
rank: `${mainKey}${rowIndex + 1}`,
driverId: row.driverId,
driverName: row.driverName,
score: `${row.finalScore} (${row.legRanks.join("+")})`,
orderingGroup: mainIndex,
orderingIndex: rowIndex,
});
});
});
return rows.sort((a, b) => {
if (a.orderingGroup !== b.orderingGroup) {
return a.orderingGroup - b.orderingGroup;
}
return a.orderingIndex - b.orderingIndex;
});
}