diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 58838c7..e6a0df5 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1774,7 +1774,16 @@ fn now_micros() -> i64 { /// audit fold are keyed on it, and a `LogRef` correction command targets that global offset. An /// earlier bug re-enumerated the window `0,1,2,…`, so a UI-selected lap in a later heat targeted /// the *wrong* pass; folding with the real offsets fixes that. +/// The window additionally folds from the heat's **current run** +/// ([`current_run_start`](crate::live_state::current_run_start) — the latest `Running`, or one +/// past the latest `Aborted`/`Restarted`): everything except the heat-loop events themselves +/// must sit at/after that boundary. A reset abandons the prior run, so its passes — and any +/// rulings made about them — are not part of this heat's result, the same rule the live +/// standings already applied. Without it, a Restarted-and-re-raced heat scored BOTH runs' +/// passes (the ghost run even out-ranked the real one). Heat-loop events stay un-filtered: +/// they carry the lineup and the FSM lineage the folds need. pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> { + let run_start = crate::live_state::current_run_start(events, heat) as u64; let mut window = Vec::new(); // The offsets already claimed by this window — a target-carrying ruling joins iff its // target is one of them (targets always point backwards, so one forward scan suffices). @@ -1792,17 +1801,20 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, // Heat-tagged marshaling events: by tag, never by position. Event::HeatVoided { heat: h } | Event::PenaltyApplied { heat: h, .. } - | Event::ProtestFiled { heat: h, .. } => h == heat, - Event::LapInserted { heat: Some(h), .. } => h == heat, - // Target-carrying rulings: by their target's membership. + | Event::ProtestFiled { heat: h, .. } => h == heat && offset >= run_start, + Event::LapInserted { heat: Some(h), .. } => h == heat && offset >= run_start, + // Target-carrying rulings: by their target's membership (a ruling targeting an + // abandoned run's pass drops out with its target). Event::DetectionVoided { target } | Event::LapAdjusted { target, .. } | Event::LapSplit { target, .. } | Event::LapThrownOut { target } | Event::ProtestResolved { target, .. } - | Event::RulingReversed { target } => claimed.contains(&target.0), + | Event::RulingReversed { target } => { + claimed.contains(&target.0) && offset >= run_start + } // Untagged (passes, legacy insertions, registrations): positional. - _ => active, + _ => active && offset >= run_start, }; if include { claimed.insert(offset); diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index 5e26bb1..833fbf3 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -481,7 +481,11 @@ fn current_heat(events: &[Event]) -> Option { /// (`events.len()`) ⇒ zero live laps — never a prior-heat total. /// /// Pure and order-preserving: it is the *latest* such transition for `heat`. -fn current_run_start(events: &[Event], heat: &HeatId) -> usize { +/// +/// Shared with the heat **window** (`app::heat_window_offsets`): scoring, the marshaling lap +/// list, and the audit fold all window from here too, so a Restarted heat's abandoned run +/// never leaks into its result (the same rule this fold applies to the live standings). +pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { // Default to past-the-end: a heat that has not run yet contributes no live laps (so selecting an // unraced heat as current shows zeros, not its pilots' totals from earlier heats). let mut start = events.len(); diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index b7bfb2f..33b6c22 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2641,6 +2641,80 @@ mod tests { // one. These pin the tag/target routing (app.rs `heat_window_offsets`) and the // corrected-pass fold in `score_heat_window`; each fails on the old positional path. + #[test] + fn a_restarted_heat_scores_only_its_current_run() { + // A heat races (run 1), is RESTARTED, and re-races (run 2). The abandoned run's passes + // — and a ruling made about them before the restart — must not reach the result: before + // the current-run window rule the scorer folded BOTH runs, and the ghost run's laps + // out-ranked the real ones (hit live 2026-07-03: a re-raced qualifier scored 39 ghost + // laps for one pilot AND held two positions at once). + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 5 }); + let heat = "h2h-1"; + let mut log = vec![scheduled(heat, "h2h", "open", &["A", "B"])]; + // Run 1: A banks a pile of laps; a DQ on B lands mid-marshaling — all abandoned below. + log.push(changed(heat, HeatTransition::Staged)); + log.push(changed(heat, HeatTransition::Armed)); + log.push(changed(heat, HeatTransition::Running)); + for (i, t) in [0i64, 1_000_000, 2_000_000, 3_000_000].iter().enumerate() { + log.push(pass("A", *t, i as u64)); + } + log.push(changed(heat, HeatTransition::Finished)); + log.push(penalty_applied( + "h2h-1", + "B", + Penalty::Disqualify { reason: None }, + )); + // The RD abandons the run. + log.push(changed(heat, HeatTransition::Restarted)); + // Run 2: both fly clean — one lap each, A first. + log.push(changed(heat, HeatTransition::Staged)); + log.push(changed(heat, HeatTransition::Armed)); + log.push(changed(heat, HeatTransition::Running)); + log.push(pass("A", 10_000_000, 10)); + log.push(pass("A", 11_000_000, 11)); + log.push(pass("B", 10_100_000, 12)); + log.push(pass("B", 12_000_000, 13)); + log.push(changed(heat, HeatTransition::Finished)); + log.push(changed(heat, HeatTransition::Finalized)); + + let result = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition); + // Only run 2 scores: one lap each (holeshot + one), NOT run 1's ghost pile. + let by_ref: std::collections::BTreeMap<&str, u32> = result + .places + .iter() + .map(|p| (p.competitor.competitor.0.as_str(), p.laps)) + .collect(); + assert_eq!( + by_ref.get("A"), + Some(&1), + "A scores run 2's single lap only" + ); + assert_eq!( + by_ref.get("B"), + Some(&1), + "B scores run 2's single lap only" + ); + // The abandoned run's DQ does not survive the restart (clean slate). + assert!( + result.places.iter().all(|p| !p.disqualified), + "a pre-restart ruling belongs to the abandoned run" + ); + // A post-restart ruling DOES apply. + log.push(penalty_applied( + "h2h-1", + "B", + Penalty::Disqualify { reason: None }, + )); + let ruled = crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition); + assert!( + ruled + .places + .iter() + .any(|p| p.competitor.competitor.0 == "B" && p.disqualified), + "a ruling on the CURRENT run applies" + ); + } + #[test] fn adjudicating_a_non_latest_heat_lands_in_its_own_window() { // Two finished heats of one round; the DQ on heat 1's winner is appended AFTER heat 2's diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index eb00640..18f5f44 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -461,58 +461,17 @@ {/each} - + {#if th.enter != null} {@const y = yOf(th.enter, range)} - {#if onthresholds} - startThresholdDrag(e, ct, 'enter')} - onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'enter', range)} - onpointerup={endThresholdDrag} - onpointercancel={endThresholdDrag} - onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'enter')} - > - - - - EN - - {/if} {/if} {#if th.exit != null} {@const y = yOf(th.exit, range)} - {#if onthresholds} - startThresholdDrag(e, ct, 'exit')} - onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'exit', range)} - onpointerup={endThresholdDrag} - onpointercancel={endThresholdDrag} - onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'exit')} - > - - - EX - - {/if} {/if} @@ -559,6 +518,54 @@ {/each} + + {#if onthresholds && th.enter != null} + {@const y = yOf(th.enter, range)} + startThresholdDrag(e, ct, 'enter')} + onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'enter', range)} + onpointerup={endThresholdDrag} + onpointercancel={endThresholdDrag} + onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'enter')} + > + + + + EN + + {/if} + {#if onthresholds && th.exit != null} + {@const y = yOf(th.exit, range)} + startThresholdDrag(e, ct, 'exit')} + onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'exit', range)} + onpointerup={endThresholdDrag} + onpointercancel={endThresholdDrag} + onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'exit')} + > + + + EX + + {/if} + {#if isHover && hover} diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index cad99e4..df2475a 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -468,9 +468,12 @@ } // Competitors that can be acted on: those in the lap list, else the live lineup. + // DE-DUPLICATED by ref: the same competitor can appear under TWO adapters in one heat + // (a mid-heat source failover — or historically a re-raced heat before the current-run + // window fix), and duplicate refs crashed every keyed {#each} over this list. const competitors = $derived( laps && laps.competitors.length > 0 - ? laps.competitors.map((c) => c.competitor.competitor) + ? [...new Set(laps.competitors.map((c) => c.competitor.competitor))] : (session.liveState?.active_pilots ?? []) ); @@ -550,7 +553,9 @@ // The LIVE preview: re-detect at the tuned levels, diff against the current official passes // (lap 1's opening pass + every lap's closing pass, from the marshaling-corrected lap list). const tuneValid = $derived(tuneEnter > tuneExit); - const shownPilotLaps = $derived(shownLaps?.competitors[0]?.laps ?? []); + // Flatten across entries: the shown pilot can hold several lap-list entries (one per + // adapter after a mid-heat failover) — the tune diff must see ALL their official passes. + const shownPilotLaps = $derived((shownLaps?.competitors ?? []).flatMap((c) => c.laps)); const detectedPassTimes = $derived( tuneTrace && tuneValid && tuneFor === shownPilot ? detectPasses(tuneTrace, tuneEnter, tuneExit) @@ -890,7 +895,9 @@ {#if shownLaps && shownLaps.competitors.length > 0}
- {#each shownLaps.competitors as cl (cl.competitor.competitor)} + + {#each shownLaps.competitors as cl (cl.competitor.adapter + '/' + cl.competitor.competitor)}

{competitorName(cl.competitor.competitor)}

{#if cl.laps.length === 0} diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 74a8905..bfff7d6 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -563,6 +563,40 @@ describe('Marshaling (Slice 3)', () => { // The Marshaling raw-id bug: the screen rendered raw refs (pilot ids / "node-2") for the heat name, // the lap-list headings, the ruling/protest dropdowns, and the audit lines. These assert the screen // resolves them all to friendly names through the shared resolver + heat-name helper. + describe('a competitor under two adapters (mid-heat failover / a re-raced heat)', () => { + // The SAME ref can hold two lap-list entries — one per adapter (a mid-heat source + // failover; historically also a re-raced heat before the current-run window fix). Bare-ref + // {#each} keys collided on it and CRASHED the whole screen (svelte each_key_duplicate) — + // the "can't slide anything anymore" report from live testing 2026-07-03. + const DUP_LAPS: LapList = { + competitors: [ + { + competitor: { adapter: 'sim', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 40_000_000, at: 41_000_000, start_ref: 2, end_ref: 4 } + ] + }, + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 39_000_000, at: 40_000_000, start_ref: 10, end_ref: 12 } + ] + } + ] + }; + + it('renders without crashing and lists the pilot once in the pickers', () => { + const { session } = makeTestSession({ live: liveRunning, laps: DUP_LAPS }); + render(Marshaling, { session }); + // Both adapter entries' lap sections render (keyed by the full adapter/ref key)… + expect(screen.getAllByRole('button', { name: /Lap 1/ }).length).toBe(2); + // …and the marshal-pilot picker lists ALICE exactly once (deduped refs). + const picker = screen.getByLabelText('Marshal pilot') as HTMLSelectElement; + const values = Array.from(picker.options).map((o) => o.value); + expect(values.filter((v) => v === 'ALICE').length).toBe(1); + }); + }); + describe('friendly names (the raw-id bug fix)', () => { // A roster-seeded heat: the competitor refs ARE the pilot ids (the common FromRoster case), so a // callsign must resolve from the directory with NO progress binding. node-2 is an unbound seat.