502 lines
20 KiB
JavaScript
502 lines
20 KiB
JavaScript
function parseRtcTime(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const ts = Date.parse(value);
|
|
return Number.isFinite(ts) ? ts : null;
|
|
}
|
|
|
|
const DECODER_ROLES = {
|
|
main: { urlKey: "wsUrl", connectedKey: "connected", messageKey: "lastMessageAt", errorKey: "lastError" },
|
|
split: { urlKey: "splitWsUrl", connectedKey: "splitConnected", messageKey: "splitLastMessageAt", errorKey: "splitLastError" },
|
|
pit_entry: { urlKey: "pitEntryWsUrl", connectedKey: "pitEntryConnected", messageKey: "pitEntryLastMessageAt", errorKey: "pitEntryLastError" },
|
|
pit_exit: { urlKey: "pitExitWsUrl", connectedKey: "pitExitConnected", messageKey: "pitExitLastMessageAt", errorKey: "pitExitLastError" },
|
|
};
|
|
|
|
function resolveCompetitor(session, transponder, deps) {
|
|
const { state, t, findEventTeamForPassing } = deps;
|
|
const sessionType = String(session?.type || "").toLowerCase();
|
|
const isOpenPractice = sessionType === "open_practice";
|
|
const isFreePractice = sessionType === "free_practice";
|
|
const isOpenMonitoringSession = isOpenPractice || isFreePractice;
|
|
const event = state.events.find((item) => item.id === session?.eventId) || null;
|
|
if (session.mode === "track") {
|
|
const matchingAssignments = (session.assignments || []).filter((a) => {
|
|
const car = state.cars.find((c) => c.id === a.carId);
|
|
return car?.transponder === transponder;
|
|
});
|
|
|
|
if (matchingAssignments.length > 1) {
|
|
return {
|
|
key: `track_ambiguous_${transponder}`,
|
|
driverId: null,
|
|
driverName: `Ambiguous TP ${transponder}`,
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
const assignment = matchingAssignments[0];
|
|
if (assignment) {
|
|
const driver = state.drivers.find((d) => d.id === assignment.driverId);
|
|
const car = state.cars.find((c) => c.id === assignment.carId);
|
|
return {
|
|
key: `track_${assignment.id}`,
|
|
driverId: driver?.id || null,
|
|
driverName: driver?.name || t("common.unknown_driver"),
|
|
speechName: driver?.speechName || driver?.name || "",
|
|
carId: car?.id || null,
|
|
carName: car?.name || t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: `track_tp_${transponder}`,
|
|
driverId: null,
|
|
driverName: t("common.unassigned_driver"),
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
if (session.mode === "race" && sessionType === "team_race") {
|
|
const driver = state.drivers.find((d) => d.transponder === transponder) || null;
|
|
const car = state.cars.find((c) => c.transponder === transponder) || null;
|
|
const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null;
|
|
if (!team) {
|
|
return {
|
|
key: `ignore_team_${transponder}`,
|
|
ignore: true,
|
|
};
|
|
}
|
|
|
|
const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean);
|
|
return {
|
|
key: `team_${team.id}`,
|
|
teamId: team.id,
|
|
teamName: team.name,
|
|
displayName: team.name,
|
|
subLabel: memberBits.join(" • ") || transponder,
|
|
driverId: driver?.id || null,
|
|
driverName: driver?.name || team.name,
|
|
speechName: driver?.speechName || driver?.name || team.name,
|
|
carId: car?.id || null,
|
|
carName: car?.name || t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
const driver = state.drivers.find((d) => d.transponder === transponder);
|
|
if (driver) {
|
|
if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) {
|
|
return {
|
|
key: `ignore_${driver.id}`,
|
|
ignore: true,
|
|
};
|
|
}
|
|
return {
|
|
key: `driver_${driver.id}`,
|
|
driverId: driver.id,
|
|
driverName: driver.name,
|
|
speechName: driver.speechName || driver.name,
|
|
displayName: driver.name,
|
|
subLabel: driver.transponder || "",
|
|
carId: null,
|
|
carName: t("common.driver_car"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: `driver_tp_${transponder}`,
|
|
driverId: null,
|
|
driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
|
displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
|
subLabel: transponder,
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
export function connectDecoderHelper(deps) {
|
|
deps.disconnectDecoder({ silent: true });
|
|
deps.state.decoder.lastError = "";
|
|
deps.state.decoder.splitLastError = "";
|
|
deps.state.decoder.pitEntryLastError = "";
|
|
deps.state.decoder.pitExitLastError = "";
|
|
deps.saveState();
|
|
connectDecoderRole("main", deps);
|
|
if (deps.state.settings.splitDecoderEnabled && deps.state.settings.splitWsUrl) {
|
|
connectDecoderRole("split", deps);
|
|
}
|
|
if (deps.state.settings.pitDecoderEnabled && deps.state.settings.pitEntryWsUrl) {
|
|
connectDecoderRole("pit_entry", deps);
|
|
}
|
|
if (deps.state.settings.pitDecoderEnabled && deps.state.settings.pitExitWsUrl) {
|
|
connectDecoderRole("pit_exit", deps);
|
|
}
|
|
}
|
|
|
|
function connectDecoderRole(role, deps) {
|
|
const {
|
|
state, t, saveState, getWsClient, setWsClient, getReconnectTimer, setReconnectTimer,
|
|
getCurrentView, renderView, updateConnectionBadge, processDecoderMessage,
|
|
} = deps;
|
|
const roleConfig = DECODER_ROLES[role] || DECODER_ROLES.main;
|
|
const { connectedKey, messageKey: lastMessageKey, errorKey: lastErrorKey } = roleConfig;
|
|
const url = state.settings[roleConfig.urlKey];
|
|
if (!url) return;
|
|
|
|
state.decoder[lastErrorKey] = "";
|
|
saveState();
|
|
try {
|
|
setWsClient(role, new WebSocket(url));
|
|
} catch (error) {
|
|
state.decoder[lastErrorKey] = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) });
|
|
saveState();
|
|
renderView();
|
|
return;
|
|
}
|
|
|
|
const wsClient = getWsClient(role);
|
|
wsClient.onopen = () => {
|
|
state.decoder[connectedKey] = true;
|
|
state.decoder[lastErrorKey] = "";
|
|
saveState();
|
|
updateConnectionBadge();
|
|
if (["timing", "overlay"].includes(getCurrentView())) {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onmessage = (event) => {
|
|
state.decoder[lastMessageKey] = Date.now();
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(String(event.data));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false);
|
|
|
|
updateConnectionBadge();
|
|
if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onclose = () => {
|
|
state.decoder[connectedKey] = false;
|
|
saveState();
|
|
updateConnectionBadge();
|
|
|
|
if (state.settings.autoReconnect) {
|
|
clearTimeout(getReconnectTimer(role));
|
|
setReconnectTimer(role, setTimeout(() => {
|
|
const roleEnabled = role === "main"
|
|
|| (role === "split" && state.settings.splitDecoderEnabled)
|
|
|| (["pit_entry", "pit_exit"].includes(role) && state.settings.pitDecoderEnabled);
|
|
if (!state.decoder[connectedKey] && roleEnabled) {
|
|
connectDecoderRole(role, deps);
|
|
}
|
|
}, 2000));
|
|
}
|
|
|
|
if (["timing", "overlay"].includes(getCurrentView())) {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onerror = () => {
|
|
state.decoder[lastErrorKey] = t("error.decoder_connection");
|
|
saveState();
|
|
updateConnectionBadge();
|
|
if (["timing", "overlay"].includes(getCurrentView())) {
|
|
renderView();
|
|
}
|
|
};
|
|
}
|
|
|
|
export function disconnectDecoderHelper(options = {}, deps) {
|
|
const { state, saveState, getWsClient, setWsClient, getReconnectTimer, updateConnectionBadge } = deps;
|
|
Object.keys(DECODER_ROLES).forEach((role) => {
|
|
clearTimeout(getReconnectTimer(role));
|
|
const wsClient = getWsClient(role);
|
|
if (wsClient) {
|
|
wsClient.onopen = null;
|
|
wsClient.onmessage = null;
|
|
wsClient.onclose = null;
|
|
wsClient.onerror = null;
|
|
wsClient.close();
|
|
setWsClient(role, null);
|
|
}
|
|
});
|
|
state.decoder.connected = false;
|
|
state.decoder.splitConnected = false;
|
|
state.decoder.pitEntryConnected = false;
|
|
state.decoder.pitExitConnected = false;
|
|
if (!options.silent) {
|
|
saveState();
|
|
}
|
|
updateConnectionBadge();
|
|
}
|
|
|
|
export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|
const {
|
|
state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow,
|
|
findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard,
|
|
formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession,
|
|
lastOverlayTop3BySession, lastOverlayBestLapByKey, triggerRaceAudio = () => {}, getTrackRecordMs = () => null,
|
|
} = deps;
|
|
|
|
if (!msg || typeof msg !== "object") {
|
|
return;
|
|
}
|
|
|
|
const type = String(msg.msg || msg.type || "").toUpperCase();
|
|
if (type !== "PASSING") {
|
|
return;
|
|
}
|
|
|
|
const session = getActiveSession();
|
|
if (!session || session.status !== "running") {
|
|
return;
|
|
}
|
|
|
|
const timestamp = parseRtcTime(msg.rtc_time) || Date.now();
|
|
const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim();
|
|
if (!transponder) {
|
|
return;
|
|
}
|
|
|
|
const result = ensureSessionResult(session.id);
|
|
const competitor = resolveCompetitor(session, transponder, { state, t, findEventTeamForPassing });
|
|
if (competitor.ignore) {
|
|
triggerRaceAudio("unknown_transponder", { variables: { transponder }, scope: transponder, logData: { raceId: session.eventId, sessionId: session.id } });
|
|
return;
|
|
}
|
|
const key = competitor.key;
|
|
|
|
if (!result.competitors[key]) {
|
|
result.competitors[key] = {
|
|
key,
|
|
teamId: competitor.teamId || null,
|
|
teamName: competitor.teamName || "",
|
|
driverId: competitor.driverId,
|
|
driverName: competitor.driverName,
|
|
speechName: competitor.speechName || competitor.driverName,
|
|
displayName: competitor.displayName || competitor.driverName,
|
|
subLabel: competitor.subLabel || competitor.carName || "",
|
|
carId: competitor.carId,
|
|
carName: competitor.carName,
|
|
transponder,
|
|
laps: 0,
|
|
lastLapMs: null,
|
|
bestLapMs: null,
|
|
startTimestamp: null,
|
|
lastTimestamp: null,
|
|
};
|
|
}
|
|
|
|
const entry = result.competitors[key];
|
|
entry.teamId = competitor.teamId || entry.teamId || null;
|
|
entry.teamName = competitor.teamName || entry.teamName || "";
|
|
entry.displayName = competitor.displayName || entry.displayName || competitor.driverName;
|
|
entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || "";
|
|
entry.driverId = competitor.driverId ?? entry.driverId;
|
|
entry.driverName = competitor.driverName || entry.driverName;
|
|
entry.speechName = competitor.speechName || entry.speechName || entry.driverName;
|
|
entry.carId = competitor.carId ?? entry.carId;
|
|
entry.carName = competitor.carName || entry.carName;
|
|
entry.transponder = transponder;
|
|
const startMode = normalizeStartMode(session.startMode);
|
|
|
|
if (decoderRole === "pit_entry") {
|
|
if (entry.pitActive) return;
|
|
entry.pitEnteredAt = timestamp;
|
|
entry.pitActive = true;
|
|
entry.pitEntryDriverId = entry.driverId || null;
|
|
entry.pitEntryDriverName = entry.driverName || "";
|
|
entry.pitEntryCarId = entry.carId || null;
|
|
entry.pitEntryCarName = entry.carName || "";
|
|
entry.pitEntryTransponder = transponder;
|
|
if (!Array.isArray(result.pitEvents)) result.pitEvents = [];
|
|
result.pitEvents.push({ timestamp, type: "entry", transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId });
|
|
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`);
|
|
triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
|
saveState();
|
|
return true;
|
|
}
|
|
|
|
if (decoderRole === "pit_exit") {
|
|
const enteredAt = Number(entry.pitEnteredAt || 0);
|
|
if (!enteredAt || timestamp <= enteredAt) return;
|
|
const pitStopMs = timestamp - enteredAt;
|
|
const pitDriverId = entry.pitEntryDriverId ?? entry.driverId ?? null;
|
|
const pitDriverName = entry.pitEntryDriverName || entry.driverName || "";
|
|
const pitCarId = entry.pitEntryCarId ?? entry.carId ?? null;
|
|
const pitCarName = entry.pitEntryCarName || entry.carName || "";
|
|
const pitTransponder = entry.pitEntryTransponder || transponder;
|
|
entry.pitActive = false;
|
|
entry.pitEnteredAt = null;
|
|
entry.pitStops = Math.max(0, Number(entry.pitStops || 0)) + 1;
|
|
entry.lastPitStopMs = pitStopMs;
|
|
entry.totalPitMs = Math.max(0, Number(entry.totalPitMs || 0)) + pitStopMs;
|
|
if (!entry.bestPitStopMs || pitStopMs < entry.bestPitStopMs) entry.bestPitStopMs = pitStopMs;
|
|
if (!Array.isArray(result.pitEvents)) result.pitEvents = [];
|
|
if (!Array.isArray(result.pitStops)) result.pitStops = [];
|
|
result.pitEvents.push({ timestamp, type: "exit", transponder, competitorKey: key, driverId: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName, pitStopMs });
|
|
result.pitStops.push({ enteredAt, exitedAt: timestamp, pitStopMs, transponder: pitTransponder, exitTransponder: transponder, competitorKey: key, driverId: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName });
|
|
entry.pitEntryDriverId = null;
|
|
entry.pitEntryDriverName = "";
|
|
entry.pitEntryCarId = null;
|
|
entry.pitEntryCarName = "";
|
|
entry.pitEntryTransponder = "";
|
|
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`);
|
|
triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
|
saveState();
|
|
return true;
|
|
}
|
|
|
|
if (decoderRole === "split") {
|
|
const baseTimestamp = Number(entry.lastTimestamp || (startMode === "mass" ? session.startedAt : 0) || 0);
|
|
if (!baseTimestamp || timestamp <= baseTimestamp) return;
|
|
if (entry.lastSplitTimestamp && timestamp - Number(entry.lastSplitTimestamp) < 1000) return;
|
|
const splitMs = timestamp - baseTimestamp;
|
|
const previousSplitMs = Number(entry.lastSplitMs || 0) || 0;
|
|
entry.previousSplitMs = previousSplitMs || null;
|
|
entry.lastSplitMs = splitMs;
|
|
entry.lastSplitDeltaMs = previousSplitMs ? splitMs - previousSplitMs : null;
|
|
entry.lastSplitTimestamp = timestamp;
|
|
entry.lastSplitLap = Math.max(1, Number(entry.laps || 0) + 1);
|
|
if (!entry.bestSplitMs || splitMs < entry.bestSplitMs) entry.bestSplitMs = splitMs;
|
|
if (!Array.isArray(result.splits)) result.splits = [];
|
|
result.splits.push({
|
|
timestamp,
|
|
transponder,
|
|
driverId: entry.driverId,
|
|
driverName: entry.driverName,
|
|
carId: entry.carId,
|
|
carName: entry.carName,
|
|
competitorKey: key,
|
|
lap: entry.lastSplitLap,
|
|
splitMs,
|
|
splitDeltaMs: entry.lastSplitDeltaMs,
|
|
loopId: String(msg.loop_id || ""),
|
|
decoderRole: "split",
|
|
});
|
|
pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`);
|
|
saveState();
|
|
return true;
|
|
}
|
|
|
|
if (startMode === "staggered" && !entry.startTimestamp) {
|
|
entry.startTimestamp = timestamp;
|
|
entry.lastTimestamp = timestamp;
|
|
announcePassing(entry);
|
|
saveState();
|
|
return true;
|
|
}
|
|
|
|
if (!entry.startTimestamp) {
|
|
entry.startTimestamp = session.startedAt || timestamp;
|
|
}
|
|
|
|
const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp;
|
|
const lapMs = Math.max(0, timestamp - baseTs);
|
|
const { minLapMs, maxLapMs } = getSessionLapWindow(session);
|
|
let validLap = true;
|
|
let invalidReason = "";
|
|
if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) {
|
|
validLap = false;
|
|
invalidReason = "below_min";
|
|
} else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) {
|
|
validLap = false;
|
|
invalidReason = "above_max";
|
|
}
|
|
|
|
const passing = {
|
|
timestamp,
|
|
transponder,
|
|
teamId: entry.teamId,
|
|
teamName: entry.teamName,
|
|
driverId: entry.driverId,
|
|
driverName: entry.driverName,
|
|
speechName: entry.speechName,
|
|
displayName: entry.displayName,
|
|
subLabel: entry.subLabel,
|
|
carId: entry.carId,
|
|
carName: entry.carName,
|
|
competitorKey: key,
|
|
lapMs,
|
|
validLap,
|
|
invalidReason,
|
|
strength: msg.strength,
|
|
loopId: String(msg.loop_id || ""),
|
|
resend: Boolean(msg.resend),
|
|
};
|
|
|
|
if (!validLap) {
|
|
result.passings.push(passing);
|
|
if (invalidReason === "above_max") {
|
|
entry.lastTimestamp = timestamp;
|
|
}
|
|
persistPassingToBackend(session.id, passing);
|
|
saveState();
|
|
return true;
|
|
}
|
|
|
|
const previousPersonalBest = Number(entry.bestLapMs || 0) || null;
|
|
const leaderboardBefore = buildLeaderboard(session);
|
|
const previousPosition = leaderboardBefore.findIndex((row) => row.key === key);
|
|
const previousLeaderKey = leaderboardBefore[0]?.key || null;
|
|
const previousSessionFastest = leaderboardBefore.map((row) => Number(row.bestLapMs || 0)).filter((value) => value > 500).sort((a, b) => a - b)[0] || null;
|
|
const previousTrackRecord = Number(getTrackRecordMs(session) || 0) || null;
|
|
|
|
entry.laps += 1;
|
|
entry.lastLapMs = lapMs;
|
|
entry.lastTimestamp = timestamp;
|
|
|
|
if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) {
|
|
entry.bestLapMs = lapMs;
|
|
}
|
|
|
|
result.passings.push(passing);
|
|
persistPassingToBackend(session.id, passing);
|
|
pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`);
|
|
const leaderboard = buildLeaderboard(session);
|
|
const currentPosition = leaderboard.findIndex((row) => row.key === key);
|
|
const audioVariables = { driverName: entry.speechName || entry.displayName || entry.driverName, lapTime: formatLap(lapMs) };
|
|
if (previousPersonalBest && lapMs < previousPersonalBest) triggerRaceAudio("personal_best", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } });
|
|
if (previousSessionFastest && lapMs < previousSessionFastest) triggerRaceAudio("fastest_lap", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } });
|
|
if (previousTrackRecord && lapMs < previousTrackRecord) triggerRaceAudio("track_record", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } });
|
|
if (previousPosition >= 0 && currentPosition >= 0 && currentPosition < previousPosition) triggerRaceAudio("position_gain", { driverId: entry.driverId, competitorKey: key });
|
|
if (previousPosition >= 0 && currentPosition > previousPosition) triggerRaceAudio("position_loss", { driverId: entry.driverId, competitorKey: key });
|
|
const leader = leaderboard[0];
|
|
if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) {
|
|
lastOverlayLeaderKeyBySession[session.id] = leader.key;
|
|
pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`);
|
|
if (previousLeaderKey && previousLeaderKey !== leader.key) triggerRaceAudio("new_leader", { variables: { driverName: leader.displayName || leader.driverName }, driverId: leader.driverId, competitorKey: leader.key, logData: { raceId: session.eventId, driverId: leader.driverId } });
|
|
}
|
|
if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) {
|
|
const bestKey = `${session.id}:${entry.key}`;
|
|
const previousBest = lastOverlayBestLapByKey[bestKey];
|
|
if (!previousBest || entry.bestLapMs < previousBest) {
|
|
lastOverlayBestLapByKey[bestKey] = entry.bestLapMs;
|
|
pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`);
|
|
}
|
|
}
|
|
const top3Keys = leaderboard.slice(0, 3).map((row) => row.key);
|
|
const previousTop3 = lastOverlayTop3BySession[session.id] || [];
|
|
if (top3Keys.join("|") !== previousTop3.join("|")) {
|
|
lastOverlayTop3BySession[session.id] = top3Keys;
|
|
if (previousTop3.length) {
|
|
pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`);
|
|
}
|
|
}
|
|
announcePassing(entry);
|
|
saveState();
|
|
return true;
|
|
}
|