diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2e106..d076277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.29.1 - 2026-10-03 + +### Fixed + +- Best lap, personal best and session-fastest are now per track layout. When an open practice that runs all season switched layout, every driver's best lap from the old layout stayed. That meant no personal best was called on the new layout unless it beat the old time, and the practice leaderboard ranked on old-layout laps. A new layout now starts every best lap over (laps already driven are kept), and each lap records its layout so judge corrections recalculate the best lap on the current layout only. Best laps were never per stint: a long break does not reset them. +- A track record was called again for every lap that beat the *old* record, even after someone in the same session had already set a faster one. The current session now counts towards the record. + ## 1.29.0 - 2026-09-30 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 51998be..6d03c42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -649,6 +649,25 @@ Verified 2026-09-30: - 50 browser scenarios pass. - Not run end-to-end: the real download through the API. It would have written into the developer's own `piper/` folder. The same Hugging Face URLs were downloaded and played with Piper while the voices were being checked. +## Best lap per layout, track record baseline + +Asked by the user 2026-10-03: does "best lap per driver" work correctly? It seemed per stint, and should be per layout or per new open practice. Also: should it be called out in races and heats at all? + +Checked: +- Personal best (`entry.bestLapMs` in `decoder_runtime.js`) is per session and competitor. It was never reset per stint (a long break is a new statistics stint but keeps the best lap), so a new open practice already starts fresh. +- The real problem was layout switches mid-practice. `addTrackLayout` moved the running practice onto the new layout, but every best lap from the old layout stayed. That blocked personal-best calls on the new track, polluted "session fastest" and ranked the practice leaderboard on old-layout laps. +- Separately, `getTrackRecordMs` excluded the current session. So after driver A beat the old record, driver B was also called a "track record" for any lap between A's time and the old record. +- Races and heats: none of these calls are made during a race by default since 1.28.0 ("Calls during a race": passing tone only, like EVERLAPS, RaceTime and MyRCM at IFMAR-level events). Speaker mode can turn them back on. + +- [x] Competitor entries carry `bestLapLayoutId`, and passings carry `trackLayoutId`. The decoder resets the best lap if the session's layout differs from the entry's, which also catches a layout change made from another client. +- [x] `addTrackLayout` resets `bestLapMs` for every competitor in a running or paused practice it moves, and clears the overlay best-lap marker. +- [x] `recalculateCompetitorFromPassings` (judge corrections) only uses laps on the current layout for the best lap. Untagged older passings still count. +- [x] `getTrackRecordMs` includes the current session. + +Verified 2026-10-03: +- 243 unit tests pass. New tests cover: a personal best survives a new stint; after a layout switch the old 18 s no longer counts and a personal best is called on the new layout; passings carry their layout; the record includes the current session; judge recalculation is per layout. +- The 51 browser scenarios pass. A new one checks that "New layout" during a running open practice moves it and resets the best lap while keeping the laps. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 6ee89ca..b60cb3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.29.0", + "version": "1.29.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.29.0", + "version": "1.29.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index d2af95e..c2e95d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.29.0", + "version": "1.29.1", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index f6c28d6..8b4490e 100644 --- a/src/app.js +++ b/src/app.js @@ -5046,9 +5046,17 @@ function addTrackLayout(label, startedAtMs = Date.now()) { state.trackLayouts = normalizeTrackLayouts(layouts, { uid }); // A practice session that keeps running across the switch moves onto the new layout; // its earlier laps stay on the old one because training laps are tagged per lap. + // Its best laps start over too: personal bests, session fastest and the leaderboard's best + // lap are per layout, so the old layout's times can't block or fake a new best. if (startedAtMs <= Date.now()) { state.sessions.forEach((session) => { - if (isPracticeSessionType(session.type) && ["running", "paused"].includes(session.status)) session.trackLayoutId = layout.id; + if (!isPracticeSessionType(session.type) || !["running", "paused"].includes(session.status)) return; + session.trackLayoutId = layout.id; + Object.values(state.resultsBySession?.[session.id]?.competitors || {}).forEach((entry) => { + entry.bestLapMs = null; + entry.bestLapLayoutId = layout.id; + delete lastOverlayBestLapByKey[`${session.id}:${entry.key}`]; + }); }); } saveState(); diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index f88533f..22f89fc 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -337,6 +337,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { laps: 0, lastLapMs: null, bestLapMs: null, + bestLapLayoutId: String(session.trackLayoutId || ""), startTimestamp: null, lastTimestamp: null, }; @@ -494,6 +495,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { stintBoundary: validationWarning === "above_max_stint", strength: msg.strength, loopId: String(msg.loop_id || ""), + trackLayoutId: String(session.trackLayoutId || ""), resend: Boolean(msg.resend), }; @@ -505,6 +507,12 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { return true; } + // Best laps are per track layout: an open practice that runs all season moves onto a new + // layout when one is added (addTrackLayout resets every best lap then), and this catches a + // layout change made elsewhere. Entries from before this field existed adopt the current one. + const layoutId = String(session.trackLayoutId || ""); + if (entry.bestLapLayoutId === undefined) entry.bestLapLayoutId = layoutId; + else if (entry.bestLapLayoutId !== layoutId) { entry.bestLapMs = null; entry.bestLapLayoutId = layoutId; } const previousPersonalBest = Number(entry.bestLapMs || 0) || null; const leaderboardBefore = buildLeaderboard(session); const previousPosition = leaderboardBefore.findIndex((row) => row.key === key); diff --git a/src/judging_logic.js b/src/judging_logic.js index 7d12409..bbccdad 100644 --- a/src/judging_logic.js +++ b/src/judging_logic.js @@ -83,7 +83,12 @@ export function recalculateCompetitorFromPassings(session, rowKey, { ensureSessi entry.lastTimestamp = validPassings.length ? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null : entry.startTimestamp || session.startedAt || null; - const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500); + // Only laps on the session's current track layout count for the best lap (passings from + // before layouts were tagged have no trackLayoutId and still count). + const layoutId = String(session.trackLayoutId || ""); + const bestLapCandidates = validPassings + .filter((passing) => passing.trackLayoutId === undefined || String(passing.trackLayoutId) === layoutId) + .map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500); entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null; } diff --git a/src/timing_logic.js b/src/timing_logic.js index 4710901..ee088a8 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -165,9 +165,10 @@ export function ensureSessionResult(sessionId, { state }) { export function getTrackRecordMs(session, { state }) { const event = state.events.find((item) => item.id === session?.eventId); if (!event?.classId) return null; + // The current session counts too: once someone beats the old record in this session, the + // next driver must beat that new time - not the old record again - to be called a record. const candidates = state.sessions.filter((item) => - item.id !== session?.id - && String(item.trackLayoutId || "") === String(session?.trackLayoutId || "") + String(item.trackLayoutId || "") === String(session?.trackLayoutId || "") && state.events.find((race) => race.id === item.eventId)?.classId === event.classId); const values = candidates .flatMap((item) => Object.values(state.resultsBySession[item.id]?.competitors || {}).map((row) => Number(row.bestLapMs || 0))) diff --git a/tests/browser_track_layouts.js b/tests/browser_track_layouts.js index 8bf1068..36fa5d4 100644 --- a/tests/browser_track_layouts.js +++ b/tests/browser_track_layouts.js @@ -48,12 +48,31 @@ export async function runTrackLayoutsSmoke({ evaluate, navigate, savedState, wai assert.equal(await layoutButtonVisible(), true, "New layout is offered when Open Practice is in focus"); console.log("PASS Race Day only offers a new layout when a practice session is in focus"); + // A running open practice moves onto the new layout and its best laps start over, so the + // old layout's times can't block or fake a personal best on the rebuilt track. + await patchState({ + sessions: (await savedState()).sessions.map((session) => (session.id === practice.id ? { ...session, status: "running", startedAt: Date.now() - 60000 } : session)), + resultsBySession: { ...(await savedState()).resultsBySession, [practice.id]: { passings: [], competitors: { tl_driver: { key: "tl_driver", driverName: "TL Driver", laps: 3, bestLapMs: 18000, bestLapLayoutId: (await savedState()).trackLayouts[0].id } } } }, + }); + await navigate("training", "view"); + await evaluate(`document.querySelector("[data-track-layout-new]").click()`); + await waitFor(async () => (await savedState()).trackLayouts?.length === 2, "second layout created"); + const afterSwitch = await savedState(); + const newLayoutId = afterSwitch.trackLayouts[1].id; + assert.equal(afterSwitch.sessions.find((session) => session.id === practice.id).trackLayoutId, newLayoutId, "the running practice moved onto the new layout"); + const competitor = afterSwitch.resultsBySession[practice.id].competitors.tl_driver; + assert.equal(competitor.bestLapMs, null, "the old layout's best lap no longer counts"); + assert.equal(competitor.bestLapLayoutId, newLayoutId); + assert.equal(competitor.laps, 3, "laps already driven are kept"); + await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === practice.id ? { ...session, status: "finished", endedAt: Date.now() } : session)) }); + console.log("PASS a new layout during open practice restarts every driver's best lap"); + await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === quali.id ? { ...session, status: "running", startedAt: Date.now() } : session)) }); await navigate("training", "view"); const dialogCount = dialogs.length; await evaluate(`document.querySelector("[data-track-layout-new]").click()`); await waitFor(async () => dialogs.slice(dialogCount).some((dialog) => dialog.type === "alert" && dialog.message.includes("TL Q1")), "blocked with a message naming the running race"); - assert.equal((await savedState()).trackLayouts.length, 1, "no layout was added while the race ran"); + assert.equal((await savedState()).trackLayouts.length, 2, "no layout was added while the race ran"); console.log("PASS a new track layout is refused while a race session is running"); } finally { dialogs.splice(dialogsAtStart); diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index 5bfbad1..fab8d8d 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -136,6 +136,27 @@ test("flying start: a car first seen after the latest clock start gets that cloc assert.equal(byDriver("driver_2").lastLapMs, 14_000); }); +test("personal best is per session and track layout - not per stint, and an old layout's time doesn't count", () => { + const harness = createHarness({ sessionType: "open_practice" }); + harness.session.trackLayoutId = "layout_a"; + const entry = () => Object.values(harness.state.resultsBySession.session_1.competitors)[0]; + const pbs = () => harness.audioEvents.filter((event) => event === "personal_best").length; + harness.passing(21_000, "main"); // 20 s + harness.passing(39_000, "main"); // 18 s -> PB + assert.equal(pbs(), 1); + harness.passing(200_000, "main"); // long break: a new stint, but the 18 s best still stands + harness.passing(219_000, "main"); // 19 s - no PB + assert.equal(pbs(), 1, "a new stint doesn't reset the personal best"); + assert.equal(entry().bestLapMs, 18_000); + + harness.session.trackLayoutId = "layout_b"; // the track was rebuilt mid-practice + harness.passing(244_000, "main"); // 25 s on the new layout - first lap there, no PB call + assert.equal(entry().bestLapMs, 25_000, "the old layout's 18 s no longer counts"); + harness.passing(268_000, "main"); // 24 s -> PB on the new layout + assert.equal(pbs(), 2); + assert.equal(harness.state.resultsBySession.session_1.passings.at(-1).trackLayoutId, "layout_b", "each lap remembers its layout"); +}); + test("pit entry and exit create one timed stop without adding laps", () => { const harness = createHarness(); harness.passing(17_000, "main"); diff --git a/tests/judging_logic.test.js b/tests/judging_logic.test.js index 4719bfd..b44dd03 100644 --- a/tests/judging_logic.test.js +++ b/tests/judging_logic.test.js @@ -72,6 +72,20 @@ function createHarness() { }; } +test("recalculating after a judge correction keeps the best lap on the current track layout", () => { + const harness = createHarness(); + harness.session.trackLayoutId = "layout_b"; + harness.result.passings[0].trackLayoutId = "layout_a"; // the 16.0 s lap was on the old layout + harness.result.passings[1].trackLayoutId = "layout_b"; + harness.result.passings[2].trackLayoutId = "layout_b"; + harness.deps.recalculateCompetitorFromPassings(harness.session, "driver_1"); + assert.equal(harness.row.bestLapMs, 16500); + assert.equal(harness.row.laps, 3, "laps from the old layout still count as laps"); + delete harness.result.passings[0].trackLayoutId; // untagged (from before layouts were tagged) still counts + harness.deps.recalculateCompetitorFromPassings(harness.session, "driver_1"); + assert.equal(harness.row.bestLapMs, 16000); +}); + test("manual corrections accumulate, reset, and undo to the prior state", () => { const harness = createHarness(); diff --git a/tests/track_layouts_logic.test.js b/tests/track_layouts_logic.test.js index 3f3a789..f482205 100644 --- a/tests/track_layouts_logic.test.js +++ b/tests/track_layouts_logic.test.js @@ -86,6 +86,21 @@ test("getTrackRecordMs only compares sessions run on the same track layout once assert.equal(getTrackRecordMs(state.sessions[0], { state }), 15000); }); +test("getTrackRecordMs includes the current session, so a record beaten earlier in it is the new bar", () => { + const state = createTrackRecordState({ + sessions: [ + { id: "current", eventId: "event_1", trackLayoutId: "layout_1" }, + { id: "older", eventId: "event_1", trackLayoutId: "layout_1" }, + ], + results: { + older: { competitors: { a: { bestLapMs: 16000 } } }, + current: { competitors: { b: { bestLapMs: 15000 } } }, + }, + }); + // Driver B already set 15.0 here; a 15.5 must not be called a track record again. + assert.equal(getTrackRecordMs(state.sessions[0], { state }), 15000); +}); + test("getTrackRecordMs does not conflate a layout-tagged session with an untagged legacy one", () => { const state = createTrackRecordState({ sessions: [