Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion crates/server/src/live_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,11 @@ fn current_heat(events: &[Event]) -> Option<HeatId> {
/// (`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();
Expand Down
74 changes: 74 additions & 0 deletions crates/server/src/round_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 52 additions & 45 deletions frontend/apps/rd-console/src/lib/RssiGraph.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -461,58 +461,17 @@
<rect class="crossing" x={x1} y={PAD_T} width={Math.max(1, x2 - x1)} height={plotH} />
{/each}

<!-- Threshold lines (horizontal). With `onthresholds` wired they carry draggable,
keyboard-nudgeable handles emitting the tuned levels; display-only otherwise. -->
<!-- Threshold lines (horizontal). Display-only strokes here; the draggable handles
render at the END of the svg (painted last = TOPMOST) so a dense heat's lap
markers can never sit over the grab bands and eat the pointer — dead handles on
lap-heavy pilots were exactly the bug (live 2026-07-03). -->
{#if th.enter != null}
{@const y = yOf(th.enter, range)}
<line class="enter-line" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
{#if onthresholds}
<g
class="th-handle enter"
role="slider"
tabindex="0"
aria-label={`Enter threshold for ${who}`}
aria-orientation="vertical"
aria-valuenow={th.enter}
aria-valuemin={Math.floor(range.lo)}
aria-valuemax={Math.ceil(range.hi)}
onpointerdown={(e: PointerEvent) => startThresholdDrag(e, ct, 'enter')}
onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'enter', range)}
onpointerup={endThresholdDrag}
onpointercancel={endThresholdDrag}
onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'enter')}
>
<!-- Wide invisible grab band for the field (sunlit laptop, finger/trackpad). -->
<line class="grab" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
<rect class="knob" x={W - PAD_R - 30} y={y - 6} width="30" height="12" rx="3" />
<text class="knob-label" x={W - PAD_R - 26} y={y + 3.5}>EN</text>
</g>
{/if}
{/if}
{#if th.exit != null}
{@const y = yOf(th.exit, range)}
<line class="exit-line" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
{#if onthresholds}
<g
class="th-handle exit"
role="slider"
tabindex="0"
aria-label={`Exit threshold for ${who}`}
aria-orientation="vertical"
aria-valuenow={th.exit}
aria-valuemin={Math.floor(range.lo)}
aria-valuemax={Math.ceil(range.hi)}
onpointerdown={(e: PointerEvent) => startThresholdDrag(e, ct, 'exit')}
onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'exit', range)}
onpointerup={endThresholdDrag}
onpointercancel={endThresholdDrag}
onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'exit')}
>
<line class="grab" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
<rect class="knob" x={W - PAD_R - 30} y={y - 6} width="30" height="12" rx="3" />
<text class="knob-label" x={W - PAD_R - 26} y={y + 3.5}>EX</text>
</g>
{/if}
{/if}

<!-- The sample line -->
Expand Down Expand Up @@ -559,6 +518,54 @@
</g>
{/each}

<!-- Draggable threshold handles — LAST in paint order (topmost), so nothing (signal
line, lap markers, preview markers) can intercept their pointer events. -->
{#if onthresholds && th.enter != null}
{@const y = yOf(th.enter, range)}
<g
class="th-handle enter"
role="slider"
tabindex="0"
aria-label={`Enter threshold for ${who}`}
aria-orientation="vertical"
aria-valuenow={th.enter}
aria-valuemin={Math.floor(range.lo)}
aria-valuemax={Math.ceil(range.hi)}
onpointerdown={(e: PointerEvent) => startThresholdDrag(e, ct, 'enter')}
onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'enter', range)}
onpointerup={endThresholdDrag}
onpointercancel={endThresholdDrag}
onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'enter')}
>
<!-- Wide invisible grab band for the field (sunlit laptop, finger/trackpad). -->
<line class="grab" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
<rect class="knob" x={W - PAD_R - 30} y={y - 6} width="30" height="12" rx="3" />
<text class="knob-label" x={W - PAD_R - 26} y={y + 3.5}>EN</text>
</g>
{/if}
{#if onthresholds && th.exit != null}
{@const y = yOf(th.exit, range)}
<g
class="th-handle exit"
role="slider"
tabindex="0"
aria-label={`Exit threshold for ${who}`}
aria-orientation="vertical"
aria-valuenow={th.exit}
aria-valuemin={Math.floor(range.lo)}
aria-valuemax={Math.ceil(range.hi)}
onpointerdown={(e: PointerEvent) => startThresholdDrag(e, ct, 'exit')}
onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'exit', range)}
onpointerup={endThresholdDrag}
onpointercancel={endThresholdDrag}
onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'exit')}
>
<line class="grab" x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} />
<rect class="knob" x={W - PAD_R - 30} y={y - 6} width="30" height="12" rx="3" />
<text class="knob-label" x={W - PAD_R - 26} y={y + 3.5}>EX</text>
</g>
{/if}

<!-- Hover crosshair + time/RSSI readout: a vertical guide at the cursor, with a small
dark, high-contrast chip reading the exact race-relative time + RSSI there. -->
{#if isHover && hover}
Expand Down
13 changes: 10 additions & 3 deletions frontend/apps/rd-console/src/screens/Marshaling.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompetitorRef[]>(
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 ?? [])
);

Expand Down Expand Up @@ -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<Lap[]>(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<Lap[]>((shownLaps?.competitors ?? []).flatMap((c) => c.laps));
const detectedPassTimes = $derived<number[]>(
tuneTrace && tuneValid && tuneFor === shownPilot
? detectPasses(tuneTrace, tuneEnter, tuneExit)
Expand Down Expand Up @@ -890,7 +895,9 @@

{#if shownLaps && shownLaps.competitors.length > 0}
<div class="laps">
{#each shownLaps.competitors as cl (cl.competitor.competitor)}
<!-- Keyed by the FULL competitor key: the same ref can appear under two adapters
(mid-heat failover), and a bare-ref key crashed the whole screen on it. -->
{#each shownLaps.competitors as cl (cl.competitor.adapter + '/' + cl.competitor.competitor)}
<div class="comp">
<h4>{competitorName(cl.competitor.competitor)}</h4>
{#if cl.laps.length === 0}
Expand Down
34 changes: 34 additions & 0 deletions frontend/apps/rd-console/tests/MarshalingScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down