diff --git a/bindings/CreateTimerRequest.ts b/bindings/CreateTimerRequest.ts index 9ffc58b4..0e8d70ea 100644 --- a/bindings/CreateTimerRequest.ts +++ b/bindings/CreateTimerRequest.ts @@ -32,4 +32,10 @@ node_count?: number, * The new timer's **available channels** in raw MHz (race redesign Slice 4a). Optional; * defaults to empty (none configured). */ -available_channels?: Array, }; +available_channels?: Array, +/** + * The new timer's **gate-bounce window** in µs (#517) — see + * [`Timer::same_pass_window_micros`]. Optional; omit it (or send `0`) for no bounce rule. + * [`DEFAULT_SAME_PASS_WINDOW_MICROS`] is what the console offers, not what the server assumes. + */ +same_pass_window_micros?: number, }; diff --git a/bindings/CrossingDisposition.ts b/bindings/CrossingDisposition.ts index ff3863c4..1c06eb35 100644 --- a/bindings/CrossingDisposition.ts +++ b/bindings/CrossingDisposition.ts @@ -10,6 +10,6 @@ * So a disposition is a **position in the corrected pass chain**, or the removal record the fold * already keeps — never a new logged fact. * - * The two removal-side variants map 1:1 onto the only two [`VoidReason`]s that exist. + * Every removal-side variant maps 1:1 onto the [`VoidReason`] that produced it. */ -export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "VoidedByMarshal" | "RejectedAfterRaceEnd"; +export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "RejectedSamePass" | "VoidedByMarshal" | "RejectedAfterRaceEnd"; diff --git a/bindings/Event.ts b/bindings/Event.ts index 000194b8..a18b476e 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -112,7 +112,16 @@ heat: HeatId, * unbounded grace (the engine's `GraceWindow::UntilScored`): the heat then closes only * on the all-crossed rule or the RD's `ForceEnd`. */ -deadline?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, +deadline?: number, } } | { "HeatDetectionPinned": { +/** + * The heat this config was pinned for (it is entering `Armed`). + */ +heat: HeatId, +/** + * The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the + * same competitor closer together than this are one physical pass. `None` for no rule. + */ +same_pass_window_micros?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, /** * The heat the inserted lap belongs to. Unlike a raw [`Pass`] (an untagged wire * observation attributed positionally), an insertion is an RD statement **about a diff --git a/bindings/Timer.ts b/bindings/Timer.ts index c3b1f9dc..a17d0617 100644 --- a/bindings/Timer.ts +++ b/bindings/Timer.ts @@ -186,4 +186,30 @@ calibration: Array, * an empty record is omitted entirely, so a pre-#413 `timers.json` restores with none and a * console (or a test fixture) written before this existed still parses a `Timer`. */ -node_channels?: Array, }; +node_channels?: Array, +/** + * The **gate-bounce window** (µs): two lap-gate crossings by the same competitor closer + * together than this are **the same physical pass**, detected twice (#517). + * + * A quad in the gate's near field fires the detector several times per crossing — antenna + * reflections milliseconds apart. That is a property of *this gate*: its antenna, its RSSI + * thresholds, where it sits on the track. So it lives on the timer, not on the round. + * + * **Not the same thing as the min-lap floor.** `RoundDef::min_lap_secs` is a *competition + * rule* — "a lap shorter than this does not count" — and it is the round's to set. This is a + * *hardware fact* — "that was not a second crossing at all". Conflating them is what made a + * reflection burst indistinguishable from a genuinely short lap in the marshaling list. + * + * `None` (the default, and what an older `timers.json` restores with) means no bounce rule: + * every crossing stands on its own and only the round's floor judges it. + * + * Capped at [`MAX_SAME_PASS_WINDOW_MICROS`] and normalized so `0` reads as `None`. The cap is + * what makes the setting safe to change: no real lap can be that short, so the window can only + * ever collapse a multi-detection of one pass, never eat a lap somebody flew. + * + * **Read at the arm, not at the fold.** The value in force when a heat arms is stamped onto + * the log ([`Event::HeatDetectionPinned`](gridfpv_events::Event::HeatDetectionPinned)) and the + * corrected fold reads it from there — so editing this never re-judges a race that has already + * run. See #518 for the general rule. + */ +same_pass_window_micros?: number, }; diff --git a/bindings/UpdateTimerRequest.ts b/bindings/UpdateTimerRequest.ts index 0416a8f5..9f1394eb 100644 --- a/bindings/UpdateTimerRequest.ts +++ b/bindings/UpdateTimerRequest.ts @@ -32,4 +32,10 @@ node_count?: number, * A new **available-channels** set in raw MHz (race redesign Slice 4a), or `None` to leave it * unchanged. */ -available_channels?: Array, }; +available_channels?: Array, +/** + * A new **gate-bounce window** in µs (#517), or `None` to leave it unchanged. Send `0` to turn + * the bounce rule off — unlike the node-count override this needs no separate clear route, + * because zero already spells "no window" (the same idiom `RoundDef::min_lap_secs` uses). + */ +same_pass_window_micros?: number, }; diff --git a/bindings/VoidReason.ts b/bindings/VoidReason.ts index c556800a..95c0b7b6 100644 --- a/bindings/VoidReason.ts +++ b/bindings/VoidReason.ts @@ -3,4 +3,4 @@ /** * Why a pass sits on the removal record instead of the lap chain. */ -export type VoidReason = "Marshal" | "UnderMinLap" | "AfterRaceEnd"; +export type VoidReason = "Marshal" | "UnderMinLap" | "SamePassBounce" | "AfterRaceEnd"; diff --git a/crates/adapters/tests/rh_live.rs b/crates/adapters/tests/rh_live.rs index 84a3a401..55517a1e 100644 --- a/crates/adapters/tests/rh_live.rs +++ b/crates/adapters/tests/rh_live.rs @@ -58,6 +58,7 @@ fn event_kind(e: &Event) -> &'static str { Event::HeatStarting { .. } => "HeatStarting", Event::HeatFinalizing { .. } => "HeatFinalizing", Event::RaceExpired { .. } => "RaceExpired", + Event::HeatDetectionPinned { .. } => "HeatDetectionPinned", Event::DetectionVoided { .. } => "DetectionVoided", Event::LapInserted { .. } => "LapInserted", Event::LapAdjusted { .. } => "LapAdjusted", diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 99115de1..ef6ab65d 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -2328,6 +2328,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // The timer reports four nodes; the RD has switched off the third one ("Node 3" = index 2). @@ -2440,6 +2441,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // Select only the RotorHazard timer for Practice. @@ -2493,6 +2495,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -3530,6 +3533,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() .id @@ -3619,6 +3623,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() .id; diff --git a/crates/app/src/source/failover.rs b/crates/app/src/source/failover.rs index ba05d351..54d013f9 100644 --- a/crates/app/src/source/failover.rs +++ b/crates/app/src/source/failover.rs @@ -92,6 +92,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let event = EventId( diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs index 3aa35c4a..8e2f3286 100644 --- a/crates/app/src/source/rh_connections.rs +++ b/crates/app/src/source/rh_connections.rs @@ -735,6 +735,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id @@ -1150,6 +1151,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs index 600d5ada..00442a6d 100644 --- a/crates/app/tests/rh_connect_live.rs +++ b/crates/app/tests/rh_connect_live.rs @@ -156,6 +156,7 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); // A freshly-configured RH timer rests at `Configured` until the Director connects it. @@ -550,6 +551,7 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); @@ -718,6 +720,7 @@ async fn editing_a_live_timers_url_in_place_re_dials_without_a_restart() { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer at the wrong URL"); registry diff --git a/crates/app/tests/rh_no_plugin_live.rs b/crates/app/tests/rh_no_plugin_live.rs index 6c595a56..f4a0a0f3 100644 --- a/crates/app/tests/rh_no_plugin_live.rs +++ b/crates/app/tests/rh_no_plugin_live.rs @@ -194,6 +194,7 @@ async fn a_plugin_less_rotorhazard_connects_but_grid_refuses_to_race_it_and_says channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create the RH timer"); diff --git a/crates/app/tests/rh_restart_live.rs b/crates/app/tests/rh_restart_live.rs index ec5478e6..1e607970 100644 --- a/crates/app/tests/rh_restart_live.rs +++ b/crates/app/tests/rh_restart_live.rs @@ -143,6 +143,7 @@ async fn restarting_a_timer_reexecutes_rotorhazard_and_the_director_reconnects_a channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer"); registry diff --git a/crates/app/tests/rh_tune_write_live.rs b/crates/app/tests/rh_tune_write_live.rs index c8e39d51..4b821c57 100644 --- a/crates/app/tests/rh_tune_write_live.rs +++ b/crates/app/tests/rh_tune_write_live.rs @@ -142,6 +142,7 @@ async fn bench(port: u16) -> Bench { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("create RH timer") .id; diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 6ab4727a..9c7c8342 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -833,6 +833,37 @@ pub enum Event { #[ts(optional, type = "number")] deadline: Option, }, + /// The **detection config in force for this run**, pinned at the arm (#517). + /// + /// The gate-bounce window (`Timer::same_pass_window_micros`) is *timer* config, and a timer is + /// editable at any time — unlike a round, which freezes the moment it races. Read live, the + /// corrected fold would therefore re-judge finished races every time an RD nudged a slider on + /// the Timers page. So the value in force is written here, **once, when the heat arms**, and + /// the fold reads it from the log instead: an edit changes the next run, never a past one. + /// + /// Same shape as [`HeatStarting`](Event::HeatStarting) / [`HeatFinalizing`](Event::HeatFinalizing) + /// / [`RaceExpired`](Event::RaceExpired) — the runtime resolves a value once, at emission time, + /// and logs it as a fact so a replay reads the same answer instead of re-deriving it. Same idea + /// as [`HeatLayoutSet`](Event::HeatLayoutSet), too, which pins the resolved channel layout at + /// the *stage* for exactly this reason (#478). The general rule is #518. + /// + /// **Why the arm and not the stage:** arming is the moment the gate opens to detections, and it + /// is the last point before a pass can arrive. `(Staged, Start) -> Armed` is the only arm in the + /// heat FSM (`heat::apply`), so every run passes through here exactly once — and a re-arm after + /// an Abort or Restart re-pins, which is correct: it is a new run. + /// + /// **Absent means no bounce rule** — a heat armed before this existed, or one whose timer has + /// no window set. The fold then judges crossings on the round's min-lap floor alone, exactly as + /// it did before, so an older log folds bit-identically. + HeatDetectionPinned { + /// The heat this config was pinned for (it is entering `Armed`). + heat: HeatId, + /// The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the + /// same competitor closer together than this are one physical pass. `None` for no rule. + #[serde(default)] + #[ts(optional, type = "number")] + same_pass_window_micros: Option, + }, /// Marshaling: void a previously-detected pass, referenced by log offset. The /// projection folds it out as if it never happened — the raw [`Pass`] stays in /// the log untouched. diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs index 7a7a25ed..0e551b78 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -146,9 +146,23 @@ pub enum VoidReason { #[default] Marshal, /// The corrected fold suppressed it: it would close a lap under the round's minimum lap - /// time (D26 — a gate reflection / double-detection; timers are dumb emitters, GridFPV - /// owns lap semantics). + /// time (D26 — timers are dumb emitters, GridFPV owns lap semantics). A **real** crossing + /// that came too soon to count as a lap — the pilot did cross the gate. UnderMinLap, + /// The corrected fold suppressed it as a **gate bounce** (#517): it landed within the timer's + /// `same_pass_window` of the last surviving crossing, so it is not a second crossing at all — + /// it is the *same physical pass*, detected twice by a gate whose near field caught the quad + /// more than once. + /// + /// **Distinct from [`UnderMinLap`](Self::UnderMinLap) on purpose.** That one is a competition + /// rule ("too soon to count"); this is a hardware fact ("that did not happen twice"). They were + /// one variant until #517, which is exactly why a reflection burst and a genuinely short lap + /// were indistinguishable in the marshaling list — and why the console could not collapse the + /// former without hiding the latter. + /// + /// Checked **before** the floor, because it is the narrower claim. Marshal-restorable like the + /// others; an explicit ruling always outranks the rule. + SamePassBounce, /// The corrected fold suppressed it under the **grace rule** (#505): the competitor had /// already taken their one allowed crossing after the run's `RaceExpired` marker ("finish /// the lap you had started; once you cross after the end-of-race tone, no more laps @@ -458,12 +472,13 @@ where pub fn corrected_passes_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> Vec<(u64, Pass)> where I: IntoIterator, { - corrected_and_voided_passes_with_floor(events, min_lap_micros, race_expired).0 + corrected_and_voided_passes_with_floor(events, min_lap_micros, same_pass_window, race_expired).0 } /// The offset of `heat`'s standing **`RaceExpired` marker** within `window` — the grace rule's @@ -487,6 +502,44 @@ where found } +/// The **gate-bounce window** (µs) pinned for `heat`'s current run, or `None` for no bounce rule +/// (#517). +/// +/// Reads the latest [`Event::HeatDetectionPinned`] tagged for this heat: **last one wins**, so a +/// heat aborted and re-armed races under the window in force at the *new* arm, and every earlier +/// pin is dead. A heat that never armed, or one armed before the pin existed, resolves `None` and +/// the fold judges crossings on the round's min-lap floor alone — bit-identical to before. +/// +/// # Why the whole log, not the run window +/// +/// The pin is appended at the **arm**, and `current_run_start` opens a run's window at `Running` — +/// so the pin sits *below* the window and a window-scoped scan would never see it. That is fine, +/// because this resolves a **value**, not a boundary: unlike [`race_expired_offset`], whose answer +/// *is* a log position and therefore has to come from the window, a window here would be an +/// obstacle rather than a scope. +/// +/// So it is resolved the way the D26 floor is resolved — at the call site, from the full log, and +/// handed to the fold as a parameter (`min_lap_micros_of` ∘ `round_def_of_heat` does exactly this). +/// One resolver, every scope, same answer. +pub fn same_pass_window_of_heat<'a, I>(events: I, heat: &gridfpv_events::HeatId) -> Option +where + I: IntoIterator, +{ + let mut found = None; + for event in events { + if let Event::HeatDetectionPinned { + heat: h, + same_pass_window_micros, + } = event + { + if h == heat { + found = *same_pass_window_micros; + } + } + } + found.filter(|w| *w > 0) +} + /// One removed pass as the fold emits it: /// `(pass offset, restore-target offset, pass, why)`. pub type VoidedEmit = (u64, u64, Pass, VoidReason); @@ -730,6 +783,11 @@ where /// After the marshaling corrections fold, each competitor's surviving chain is walked /// chronologically: /// +/// - **The bounce window** (#517): a **raw, unruled** pass landing within `same_pass_window` of +/// the last surviving crossing is AUTO-SUPPRESSED as [`VoidReason::SamePassBounce`] — not a +/// crossing that came too soon, but the *same physical pass* seen twice. Checked **before** the +/// floor because it is the narrower claim, and reported separately so the console can collapse a +/// reflection burst without also hiding a genuinely short lap. /// - **The floor**: a **raw, unruled** pass that would close a lap shorter than /// `min_lap_micros` is AUTO-SUPPRESSED — moved onto the removal record with /// [`VoidReason::UnderMinLap`] (its restore target is itself; a marshal re-time exempts it). @@ -748,6 +806,7 @@ where pub fn corrected_and_voided_passes_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> (Vec<(u64, Pass)>, Vec) where @@ -759,7 +818,8 @@ where let pairs: Vec<(u64, &Event)> = events.into_iter().collect(); let (surviving, mut voided) = corrected_and_voided_passes(pairs.iter().copied()); let floor = min_lap_micros.filter(|f| *f > 0); - if floor.is_none() && race_expired.is_none() { + let bounce = same_pass_window.filter(|w| *w > 0); + if floor.is_none() && bounce.is_none() && race_expired.is_none() { return (surviving, voided); } @@ -795,9 +855,19 @@ where let mut post_expiry_taken = false; for (offset, pass) in chain { let ruled = exempt.contains(&offset); - let too_close = floor.is_some_and(|floor| { - last_kept.is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor) - }); + // Both rules measure from the LAST SURVIVING crossing, never from the previous + // observation: a suppressed pass does not extend the window it was suppressed by, so a + // burst collapses against one anchor instead of ratcheting forward off its own echoes. + let since_kept = last_kept.map(|prev| pass.at.micros.saturating_sub(prev.micros)); + // Bounce first — it is the narrower claim, and a pass inside the bounce window is not a + // short lap to be judged by the floor, it is not a separate crossing at all. + let bounced = bounce.is_some_and(|w| since_kept.is_some_and(|since| since < w)); + if bounced && !ruled { + voided.push((offset, offset, pass, VoidReason::SamePassBounce)); + continue; + } + let too_close = + floor.is_some_and(|floor| since_kept.is_some_and(|since| since < floor)); if too_close && !ruled { voided.push((offset, offset, pass, VoidReason::UnderMinLap)); continue; @@ -843,21 +913,23 @@ pub fn lap_list_marshaled<'a, I>(events: I) -> LapList where I: IntoIterator, { - lap_list_marshaled_with_floor(events, None, None) + lap_list_marshaled_with_floor(events, None, None, None) } -/// [`lap_list_marshaled`] under a round's **auto-suppression rules**: the minimum-lap floor -/// (D26) and the grace rule against `race_expired` (#505) — suppressed passes land on each -/// competitor's removal record with [`VoidReason::UnderMinLap`] / [`VoidReason::AfterRaceEnd`]. +/// [`lap_list_marshaled`] under a run's **auto-suppression rules**: the gate-bounce window +/// (#517), the minimum-lap floor (D26) and the grace rule against `race_expired` (#505) — +/// suppressed passes land on each competitor's removal record with +/// [`VoidReason::SamePassBounce`] / [`VoidReason::UnderMinLap`] / [`VoidReason::AfterRaceEnd`]. pub fn lap_list_marshaled_with_floor<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> LapList where I: IntoIterator, { - CorrectedWindow::of(events, min_lap_micros, race_expired).into_lap_list() + CorrectedWindow::of(events, min_lap_micros, same_pass_window, race_expired).into_lap_list() } /// One window's **correction fold, folded once** — the shared input to both views of it: the @@ -881,9 +953,15 @@ pub struct CorrectedWindow { } impl CorrectedWindow { - /// Fold `events` (a window of `(offset, event)` pairs) under the D26 floor and the #505 - /// grace rule (`race_expired` — the run's marker offset, [`race_expired_offset`]). - pub fn of<'a, I>(events: I, min_lap_micros: Option, race_expired: Option) -> Self + /// Fold `events` (a window of `(offset, event)` pairs) under the #517 bounce window + /// (`same_pass_window` — the run's pinned value, [`same_pass_window_of_heat`]), the D26 floor, + /// and the #505 grace rule (`race_expired` — the run's marker offset, [`race_expired_offset`]). + pub fn of<'a, I>( + events: I, + min_lap_micros: Option, + same_pass_window: Option, + race_expired: Option, + ) -> Self where I: IntoIterator, { @@ -898,6 +976,7 @@ impl CorrectedWindow { let (surviving, voided) = corrected_and_voided_passes_with_floor( pairs.iter().copied(), min_lap_micros, + same_pass_window, race_expired, ); Self { @@ -975,7 +1054,7 @@ fn lap_list_of_corrected( /// So a disposition is a **position in the corrected pass chain**, or the removal record the fold /// already keeps — never a new logged fact. /// -/// The two removal-side variants map 1:1 onto the only two [`VoidReason`]s that exist. +/// Every removal-side variant maps 1:1 onto the [`VoidReason`] that produced it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub enum CrossingDisposition { @@ -986,10 +1065,19 @@ pub enum CrossingDisposition { /// A crossing that **closed a lap** (chain position `n >= 1` closes lap `n`). Counted, /// The corrected fold **auto-suppressed** it under the round's minimum-lap floor (D26 — - /// [`VoidReason::UnderMinLap`]): a gate reflection / double-detection. It records no lap and - /// today reaches no live consumer at all, which is the gap #397 exists to close — a - /// too-sensitive gate is as broken as an insensitive one, and nothing surfaces it live. + /// [`VoidReason::UnderMinLap`]): a real crossing that came too soon to close a lap. It records + /// no lap, but it still **tones** (#397) — a too-sensitive gate is as broken as an insensitive + /// one, and a table of laps will never show an RD the difference. RejectedTooShort, + /// The corrected fold **auto-suppressed** it as a **gate bounce** (#517 — + /// [`VoidReason::SamePassBounce`]): the same physical pass, detected twice. + /// + /// This is the one disposition that is deliberately **silent**. Every other crossing tones, + /// because every other crossing is a thing that happened; a bounce is the gate stuttering over + /// a thing that happened once. Before #517 the console suppressed these with its own private + /// timer (`CROSSING_TONE_COOLDOWN_MICROS`) — one concept, two owners, and no way for the + /// marshaling list to agree with the speaker. Now the fold decides once and both read it. + RejectedSamePass, /// A marshal explicitly removed it after the fact ([`VoidReason::Marshal`]). It was a real /// observed crossing when it happened; the removal is a later ruling over it. VoidedByMarshal, @@ -1006,6 +1094,7 @@ impl CrossingDisposition { match reason { VoidReason::Marshal => Self::VoidedByMarshal, VoidReason::UnderMinLap => Self::RejectedTooShort, + VoidReason::SamePassBounce => Self::RejectedSamePass, VoidReason::AfterRaceEnd => Self::RejectedAfterRaceEnd, } } @@ -1066,12 +1155,13 @@ pub struct DispositionedPass { pub fn dispositioned_passes<'a, I>( events: I, min_lap_micros: Option, + same_pass_window: Option, race_expired: Option, ) -> Vec where I: IntoIterator, { - CorrectedWindow::of(events, min_lap_micros, race_expired).crossings(None) + CorrectedWindow::of(events, min_lap_micros, same_pass_window, race_expired).crossings(None) } impl CorrectedWindow { @@ -2239,6 +2329,172 @@ mod marshaling_tests { assert!(cl.voided.is_empty()); } + /// A pin event for `heat`, as the arm appends it (#517). + fn pinned(heat: &str, window: Option) -> Event { + Event::HeatDetectionPinned { + heat: gridfpv_events::HeatId(heat.into()), + same_pass_window_micros: window, + } + } + + #[test] + fn the_pinned_window_is_the_latest_one_for_that_heat() { + // Last one wins, per heat. A heat armed, aborted, the RD retuned the gate, re-armed: the + // run races under the window set for THAT arm, and the earlier pin is dead. A pin for a + // different heat never leaks across. + let heat = gridfpv_events::HeatId("q-1".into()); + let log = [ + pinned("q-1", Some(1_000_000)), + pinned("q-2", Some(2_000_000)), + pinned("q-1", Some(400_000)), + ]; + assert_eq!(same_pass_window_of_heat(log.iter(), &heat), Some(400_000)); + } + + #[test] + fn an_unpinned_heat_resolves_to_no_bounce_rule() { + // Three ways to have no rule, all of which must read the same: never armed, armed before + // #517 existed (no pin on the log at all), and armed on a timer with the setting unset + // (a pin carrying `None`). A `0` is normalized away for the same reason. + let heat = gridfpv_events::HeatId("q-1".into()); + assert_eq!(same_pass_window_of_heat([].iter(), &heat), None); + assert_eq!( + same_pass_window_of_heat([pinned("q-2", Some(1_000_000))].iter(), &heat), + None, + "another heat's pin is not this heat's" + ); + assert_eq!( + same_pass_window_of_heat([pinned("q-1", None)].iter(), &heat), + None + ); + assert_eq!( + same_pass_window_of_heat([pinned("q-1", Some(0))].iter(), &heat), + None + ); + } + + #[test] + fn the_bounce_window_labels_a_reflection_burst_apart_from_a_short_lap() { + // #517, and the whole reason the window exists. The RD runs a 10s floor and a 1s bounce + // window. Two suppressions land in the same run and they are NOT the same thing: + // + // * a 132ms echo — the gate stuttering over one physical pass; + // * a 3s crossing — the pilot really did cross the gate again, far too soon to score. + // + // Before this both read `UnderMinLap`, so the console could not collapse the first without + // also hiding the second. Now the fold says which is which. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — holeshot, kept + pass("rh", "A", 1_132_000, Some(2)), // offset 1 — +132ms: bounce + pass("rh", "A", 4_000_000, Some(3)), // offset 2 — +3s: real, but under the floor + pass("rh", "A", 21_000_000, Some(4)), // offset 3 — +20s: a genuine lap + ]; + let (surviving, voided) = corrected_and_voided_passes_with_floor( + tagged(&events), + Some(10_000_000), + Some(1_000_000), + None, + ); + // Only the holeshot and the genuine lap survive; both suppressions measured from the + // holeshot, because a suppressed pass never becomes the anchor. + assert_eq!( + surviving.iter().map(|(o, _)| *o).collect::>(), + vec![0, 3] + ); + assert_eq!( + voided + .iter() + .map(|(offset, _, _, reason)| (*offset, *reason)) + .collect::>(), + vec![ + (1, VoidReason::SamePassBounce), + (2, VoidReason::UnderMinLap), + ], + "the echo is a bounce; the 3s crossing is a real pass under the floor" + ); + } + + #[test] + fn the_bounce_window_measures_from_the_last_surviving_pass_not_the_last_crossing() { + // A burst walks forward in ~0.9s steps. Measured crossing-to-crossing every one would be + // "within 1s of the previous" and the whole burst would collapse forever. Measured from the + // last SURVIVING pass — the rule the audio's cooldown already used, anchoring on the last + // *sounded* tone — the third step is 1.8s off the anchor and stops being a bounce. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept, the anchor + pass("rh", "A", 1_900_000, Some(2)), // offset 1 — +0.9s: bounce + pass("rh", "A", 2_800_000, Some(3)), // offset 2 — +1.8s from the ANCHOR: not a bounce + ]; + let (_, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!( + voided + .iter() + .map(|(offset, _, _, reason)| (*offset, *reason)) + .collect::>(), + vec![(1, VoidReason::SamePassBounce)], + "an absorbed crossing must not ratchet the window forward off its own echo" + ); + } + + #[test] + fn the_bounce_window_works_with_no_min_lap_floor_at_all() { + // The floor is a per-round competition rule and an RD may simply not set one. The bounce + // window is hardware truth and must still apply — this is what lets the console stop + // keeping its own private cooldown, which used to be the ONLY bounce rule in that case. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept + pass("rh", "A", 1_050_000, Some(2)), // offset 1 — 50ms echo + ]; + let (surviving, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!(surviving.len(), 1); + assert_eq!(voided.len(), 1); + assert_eq!(voided[0].3, VoidReason::SamePassBounce); + } + + #[test] + fn a_marshal_ruling_exempts_a_pass_from_the_bounce_window() { + // Same exemption the floor honours: an explicit ruling outranks an automatic rule. An RD + // who re-times a pass into the bounce window has decided it is a real crossing, and the + // fold does not get to overrule them. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), // offset 0 — kept + pass("rh", "A", 1_100_000, Some(2)), // offset 1 — would bounce… + adjusted(1, 1_100_000), // offset 2 — …but the RD re-asserted it + ]; + let (surviving, voided) = + corrected_and_voided_passes_with_floor(tagged(&events), None, Some(1_000_000), None); + assert_eq!(surviving.len(), 2, "the ruled pass stands"); + assert!(voided.is_empty()); + } + + #[test] + fn no_bounce_window_folds_bit_identically_to_before() { + // The additive guarantee: a run with no pinned window (every heat armed before #517, and + // every timer with the setting unset) must fold exactly as it always did. + let events = vec![ + pass("rh", "A", 1_000_000, Some(1)), + pass("rh", "A", 1_004_000, Some(2)), + pass("rh", "A", 30_000_000, Some(3)), + ]; + let with_none = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), None, None); + let with_zero = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), Some(0), None); + assert_eq!(with_none, with_zero, "0 normalizes to no rule"); + let reasons: Vec = with_none + .competitors + .iter() + .flat_map(|c| c.voided.iter().map(|v| v.reason)) + .collect(); + assert_eq!( + reasons, + vec![VoidReason::UnderMinLap], + "without a window the echo is still judged by the floor alone, as before" + ); + } + #[test] fn min_lap_floor_suppresses_the_phantom_double_detection() { // The live bug (Audit Shakedown): every pilot got TWO passes 4ms apart at race start — @@ -2251,7 +2507,7 @@ mod marshaling_tests { pass("vd", "A", 7_208_000, Some(3)), // offset 2 — real lap 1 pass("vd", "A", 13_500_000, Some(4)), // offset 3 — real lap 2 ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2273,7 +2529,7 @@ mod marshaling_tests { }] ); // No floor ⇒ bit-identical to the plain fold (rounds predating the setting). - let unfloored = lap_list_marshaled_with_floor(tagged(&events), None, None); + let unfloored = lap_list_marshaled_with_floor(tagged(&events), None, None, None); let plain = lap_list_marshaled(tagged(&events)); assert_eq!(unfloored, plain); assert_eq!(plain.competitors[0].laps.len(), 3); @@ -2297,7 +2553,7 @@ mod marshaling_tests { // The marker is resolved from the window exactly as the server call sites do. let marker = race_expired_offset(tagged(&log), &heat); assert_eq!(marker, Some(2)); - let result = lap_list_marshaled_with_floor(tagged(&log), None, marker); + let result = lap_list_marshaled_with_floor(tagged(&log), None, None, marker); let cl = result .competitors .iter() @@ -2319,7 +2575,7 @@ mod marshaling_tests { ); // No marker ⇒ bit-identical to the plain fold (a run that never expired). assert_eq!( - lap_list_marshaled_with_floor(tagged(&log), None, None), + lap_list_marshaled_with_floor(tagged(&log), None, None, None), lap_list_marshaled(tagged(&log)) ); } @@ -2338,7 +2594,7 @@ mod marshaling_tests { // spend A's one allowed crossing (that was offset 4). inserted("rh", "A", 10_000_000), ]; - let result = lap_list_marshaled_with_floor(tagged(&events), None, Some(3)); + let result = lap_list_marshaled_with_floor(tagged(&events), None, None, Some(3)); let a = result .competitors .iter() @@ -2375,7 +2631,8 @@ mod marshaling_tests { pass("rh", "A", 35_193_000, Some(5)), // offset 4 — reflection: UnderMinLap pass("rh", "A", 52_000_000, Some(6)), // offset 5 — real next lap: AfterRaceEnd ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), Some(1)); + let result = + lap_list_marshaled_with_floor(tagged(&events), Some(10_000_000), None, Some(1)); let cl = result .competitors .iter() @@ -2432,7 +2689,7 @@ mod marshaling_tests { pass("vd", "A", 9_000_000, Some(3)), // offset 2 adjusted(1, 3_000_000), // offset 3 — marshal: "that 2s lap is real" ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2458,7 +2715,7 @@ mod marshaling_tests { pass("vd", "A", 10_000_000, Some(2)), // offset 1 inserted("vd", "A", 2_500_000), // offset 2 — a 1.5s lap, by ruling ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2479,7 +2736,7 @@ mod marshaling_tests { pass("vd", "A", 1_030_000, Some(4)), // echo — suppressed pass("vd", "A", 8_000_000, Some(5)), // real — kept (7s from last kept) ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -2506,7 +2763,7 @@ mod marshaling_tests { pass("vd", "A", 8_000_000, Some(3)), // offset 2 — real lap voided(0), // offset 3 ]; - let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None); + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000), None, None); let cl = result .competitors .iter() @@ -3845,7 +4102,7 @@ mod marshaling_tests { log.push(voided(10)); let floor = Some(5_000_000); - let window = CorrectedWindow::of(tagged(&log), floor, None); + let window = CorrectedWindow::of(tagged(&log), floor, None, None); let full = window.crossings(None); assert!( full.len() > 8, @@ -3877,7 +4134,7 @@ mod marshaling_tests { assert!(window.crossings(Some(0)).is_empty()); // And the free function still answers the whole run. - assert_eq!(dispositioned_passes(tagged(&log), floor, None), full); + assert_eq!(dispositioned_passes(tagged(&log), floor, None, None), full); } /// **One fold, two views.** The lap list and the crossing feed are now read off a single @@ -3896,14 +4153,14 @@ mod marshaling_tests { pass("rh", "node-0", 60_000_000, None), ]; let floor = Some(5_000_000); - let window = CorrectedWindow::of(tagged(&log), floor, None); + let window = CorrectedWindow::of(tagged(&log), floor, None, None); assert_eq!( window.crossings(None), - dispositioned_passes(tagged(&log), floor, None) + dispositioned_passes(tagged(&log), floor, None, None) ); assert_eq!( window.into_lap_list(), - lap_list_marshaled_with_floor(tagged(&log), floor, None) + lap_list_marshaled_with_floor(tagged(&log), floor, None, None) ); } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index dce008ad..4e5cc67c 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -2571,6 +2571,15 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 window.push((offset, event.clone())); } } + // The pinned detection config (#517), by TAG rather than by the positional cursor. + // It would land here anyway — the pin is appended while its heat is active — but + // leaning on that would make a fold input depend on cursor bookkeeping, which is the + // shape of bug `heat_window_offsets`'s pass-tagging rule exists to end. + Event::HeatDetectionPinned { heat, .. } => { + if class_heats.contains(heat) { + window.push((offset, event.clone())); + } + } // Untagged passes and adjudications belong to whichever heat is currently active. _ if active => window.push((offset, event.clone())), _ => {} @@ -2638,6 +2647,10 @@ async fn snapshot_heat( HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled_with_floor( heat_offsets.iter().map(|(o, e)| (*o, e)), min_lap_micros, + // The bounce window (#517): the value pinned at THIS heat's arm. Resolved from the + // full log rather than the window — the pin is appended at the arm, which sits below + // `current_run_start`. Never read live off the timer: that is the whole point. + gridfpv_projection::same_pass_window_of_heat(&events, &heat), // The grace rule's boundary (#505): the heat's standing RaceExpired marker, // resolved from the same window the fold reads — like the floor, it must reach // the laps, live, and result folds identically or the surfaces disagree. @@ -2814,6 +2827,14 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, active = h == heat; active } + // The run's pinned detection config (#517): by tag, and deliberately WITHOUT the + // `run_start` gate. The pin is appended at the ARM and the window opens at `Running`, + // so it sits below `run_start` by construction — gating it would drop the very thing + // the fold needs, and the heat-scope live view would then disagree with the lap list + // about a suppressed pass. `same_pass_window_of_heat` takes the LAST one, so an + // abandoned run's pin is superseded by the re-arm's rather than competing with it. + // (Same shape as `HeatScheduled` above, which is also in-by-tag and un-gated.) + Event::HeatDetectionPinned { heat: h, .. } => h == heat, // Heat-tagged marshaling events: by tag, never by position. Event::HeatVoided { heat: h } | Event::PenaltyApplied { heat: h, .. } @@ -2886,6 +2907,10 @@ pub(crate) fn score_heat_window( let corrected = gridfpv_projection::corrected_passes_with_floor( heat_offsets.iter().map(|(o, e)| (*o, e)), min_lap_micros, + // The bounce window (#517) applies to the SCORE too — the scored chain and the marshaling + // list must be the same chain. Resolved from the log's pin, so re-scoring a finished heat + // (a standings recompute, a seeding draw) can never pick up a since-edited timer setting. + gridfpv_projection::same_pass_window_of_heat(events, heat), // The grace rule (#505) applies to the SCORE exactly as to the lap list: the pass // chain the scorer ranks is the one the marshaling view shows, marker rule included. gridfpv_projection::race_expired_offset(heat_offsets.iter().map(|(o, e)| (*o, e)), heat), @@ -4626,6 +4651,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); @@ -4702,6 +4728,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -4803,6 +4830,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -4989,6 +5017,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, _) = post_timer_connection(registry.clone(), &rh.id.0, "restart").await; @@ -5599,6 +5628,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, bytes) = post_calibration( @@ -6194,6 +6224,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); let (status, bytes) = @@ -6469,6 +6500,7 @@ mod tests { }), node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); registry @@ -6778,6 +6810,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (status, raw) = post_timer(registry.clone(), &body, None).await; assert_eq!(status, StatusCode::OK); @@ -6801,6 +6834,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (status, _) = post_timer(registry, &body, None).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -6858,6 +6892,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -7036,6 +7071,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }; let (_, raw) = post_timer(registry.clone(), &body, None).await; let extra: Timer = serde_json::from_slice(&raw).unwrap(); @@ -7130,6 +7166,7 @@ mod tests { channel_capability: None, node_count: Some(0), available_channels: None, + same_pass_window_micros: None, }; let (status, _) = post_timer(registry, &body, None).await; // A 0-node timer caps every heat to no pilots — rejected as a 400, not silently created. diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 1568c976..1c6fdfb4 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -320,10 +320,11 @@ pub fn apply_command_in_event( // `Start` is the **arm** (it opens the gate to detections). It is the last moment Grid can // refuse before RotorHazard is driving a live race, so it carries the GridFPV-plugin // backstop (#405) — see [`refuse_arm_without_plugin`]. - Command::Start { heat } => match refuse_arm_without_plugin(registry, event_id) { - Some(err) => CommandAck::failed(err), - None => apply_command(state, Command::Start { heat }), - }, + // `Start` is the **arm** (it opens the gate to detections). It is the last moment Grid can + // refuse before RotorHazard is driving a live race, and — for the same reason — the last + // moment to pin the detection config this run will be judged by (#517). Both need the + // registry, so it runs here rather than in the log-only `apply_command`. + Command::Start { heat } => apply_start_heat(registry, event_id, state, heat), // `ScheduleHeat` also needs the event meta + timer registry (the channel cap + assignment), // so it is handled here rather than in the log-only `apply_command`. Command::ScheduleHeat { @@ -1300,6 +1301,63 @@ fn apply_stage_heat( } } +/// Handle [`Command::Start`] — the **arm**, and the moment the gate opens to detections. +/// +/// Two things have to happen here and nowhere else. +/// +/// **The plugin backstop (#405).** Arming is the last point Grid can refuse before RotorHazard is +/// driving a live race — see [`refuse_arm_without_plugin`]. +/// +/// **The detection pin (#517).** The gate-bounce window is *timer* config, and a timer stays +/// editable forever, unlike a round — which freezes the moment it races precisely so results cannot +/// shift under it. Read live by the fold, an RD nudging the Timers page would silently re-judge +/// every race that timer ever ran. So the value in force is written to the log **here**, once, and +/// the corrected fold reads it from there: an edit changes the next run and never a past one. +/// +/// `(Staged, Start) -> Armed` is the only arm in the heat FSM ([`heat::apply`]), so every run +/// passes through this exactly once — and a re-arm after an Abort or Restart re-pins, which is +/// right: that is a new run, and it should race under the window set for it. +/// +/// Mirrors [`apply_stage_heat`], which pins the resolved channel layout at the *stage* for exactly +/// the same reason (#478). The general rule is #518. +fn apply_start_heat( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: HeatId, +) -> CommandAck { + if let Some(err) = refuse_arm_without_plugin(registry, event_id) { + return CommandAck::failed(err); + } + let _guard = state.command_guard(); + // Validate the transition FIRST: an illegal arm must leave the log completely untouched, so a + // refusal can never leave a pin behind on a heat that did not arm. + let transition = match heat_transition(state, heat.clone(), HeatCommand::Start) { + Ok(event) => event, + Err(err) => return CommandAck::failed(err), + }; + // The pin goes down BEFORE the transition, so the arm is never observable without the config it + // armed under. An unresolvable timer pins `None` — no bounce rule, the pre-#517 behaviour — + // rather than failing the arm: a race must not be blocked because a setting could not be read. + let window = registry + .meta_of(event_id) + .and_then(|meta| crate::round_engine::assignment_timer(&meta, ®istry.timers())) + .and_then(|timer| timer.same_pass_window_micros); + if let Err(err) = state.append( + Event::HeatDetectionPinned { + heat: heat.clone(), + same_pass_window_micros: window, + }, + None, + ) { + return CommandAck::failed(err); + } + match state.append(transition, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } +} + /// The layout id [`apply_stage_heat`] should stamp onto `heat`, or `None` when there is nothing to /// stamp — see that function's doc for the three cases (already bound, no layout, no round). /// @@ -5503,6 +5561,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["alpha", "bravo"]); @@ -5554,6 +5613,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["alpha", "bravo"]); @@ -5598,6 +5658,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(2), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, &["a", "b", "c", "d"]); @@ -5640,6 +5701,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }; let (registry, event_id, round) = event_with_timer_and_round(timer_req, pilots); let meta = registry.meta_of(&event_id).unwrap(); @@ -5883,6 +5945,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }) .unwrap(); let class = registry @@ -6055,6 +6118,7 @@ mod tests { channel_capability: Some(ChannelCapability::Flexible), node_count: Some(8), available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + same_pass_window_micros: None, }) .unwrap(); let class = registry @@ -6981,6 +7045,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); // Selected while healthy — a legitimate selection under the #405 gate. diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 945bcc2b..7feb812d 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -4410,6 +4410,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap(); rh_id = rh.id.clone(); @@ -5954,6 +5955,7 @@ mod tests { channel_capability: None, node_count: Some(4), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_timers(event, vec![timer.id.clone()]).unwrap(); diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index bb9ba366..51bdafde 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -573,7 +573,17 @@ fn live_state_core( // of the window, so a Restarted heat can never inherit a stale marker). let race_expired = gridfpv_projection::race_expired_offset(run_window.iter().copied(), ¤t_heat); - let corrected = CorrectedWindow::of(run_window.iter().copied(), min_lap_micros, race_expired); + // The bounce window (#517): the value pinned at this heat's arm. Resolved over the WHOLE log, + // not `run_window` — the pin is appended at the arm and `current_run_start` opens the window at + // Running, so a window-scoped scan would never find it. Unlike the marker above, this resolves + // a value rather than a boundary, so a wider scan costs nothing and a narrower one loses it. + let same_pass_window = gridfpv_projection::same_pass_window_of_heat(events, ¤t_heat); + let corrected = CorrectedWindow::of( + run_window.iter().copied(), + min_lap_micros, + same_pass_window, + race_expired, + ); // The crossing feed (#397) — the same run window, read as *crossings* rather than *laps*, so // the holeshot and a floor-rejected pass (neither of which derives a lap) are visible live. // Bounded to the most recent `MAX_LIVE_CROSSINGS`: the tail is kept and the head dropped, so diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 6b8043dc..19888d8a 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -2614,6 +2614,7 @@ mod tests { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + same_pass_window_micros: None, } } diff --git a/crates/server/src/timers.rs b/crates/server/src/timers.rs index 2800c2ab..9814242c 100644 --- a/crates/server/src/timers.rs +++ b/crates/server/src/timers.rs @@ -72,6 +72,23 @@ pub const DEFAULT_NODE_COUNT: u32 = 8; /// connect spends the count back to zero. pub const CONNECT_ATTEMPT_CAP: u32 = 3; +/// The **default gate-bounce window** (µs) offered for a new timer: 1 second (#517). +/// +/// Comfortably over a reflection burst's spread (tens to low hundreds of milliseconds) and +/// comfortably under any lap anybody has ever flown, so it collapses a multi-detection without +/// coming near a real crossing. It is only a *default* — the RD tunes it per gate, which is the +/// whole point of the setting living on the timer. +pub const DEFAULT_SAME_PASS_WINDOW_MICROS: i64 = 1_000_000; + +/// The **hard cap** on [`Timer::same_pass_window_micros`] (µs): 2 seconds (#517). +/// +/// This cap is what makes the window safe to edit at all. A lap under two seconds is not a lap — +/// no course is that short and no quad is that fast — so whatever the RD types here, the rule can +/// only ever collapse repeat detections of one physical pass. It can never swallow a lap somebody +/// actually flew, which is what lets the setting stay editable instead of freezing like the +/// round's min-lap floor does (`events.rs`, the raced-round freeze). +pub const MAX_SAME_PASS_WINDOW_MICROS: i64 = 2_000_000; + /// The file name (under the data dir) the timer registry is persisted to (issue #73). pub const TIMERS_FILE: &str = "timers.json"; @@ -615,6 +632,34 @@ pub struct Timer { #[serde(default, skip_serializing_if = "Vec::is_empty")] #[ts(as = "Option>", optional)] pub node_channels: Vec, + /// The **gate-bounce window** (µs): two lap-gate crossings by the same competitor closer + /// together than this are **the same physical pass**, detected twice (#517). + /// + /// A quad in the gate's near field fires the detector several times per crossing — antenna + /// reflections milliseconds apart. That is a property of *this gate*: its antenna, its RSSI + /// thresholds, where it sits on the track. So it lives on the timer, not on the round. + /// + /// **Not the same thing as the min-lap floor.** `RoundDef::min_lap_secs` is a *competition + /// rule* — "a lap shorter than this does not count" — and it is the round's to set. This is a + /// *hardware fact* — "that was not a second crossing at all". Conflating them is what made a + /// reflection burst indistinguishable from a genuinely short lap in the marshaling list. + /// + /// `None` (the default, and what an older `timers.json` restores with) means no bounce rule: + /// every crossing stands on its own and only the round's floor judges it. + /// + /// Capped at [`MAX_SAME_PASS_WINDOW_MICROS`] and normalized so `0` reads as `None`. The cap is + /// what makes the setting safe to change: no real lap can be that short, so the window can only + /// ever collapse a multi-detection of one pass, never eat a lap somebody flew. + /// + /// **Read at the arm, not at the fold.** The value in force when a heat arms is stamped onto + /// the log ([`Event::HeatDetectionPinned`](gridfpv_events::Event::HeatDetectionPinned)) and the + /// corrected fold reads it from there — so editing this never re-judges a race that has already + /// run. See #518 for the general rule. + // A window is capped at 2s, so it is bounded far below 2^53: render it as a plain TS `number` + // rather than a `bigint`, the same call `Pass::sequence` and `RaceExpired::deadline` make. + #[serde(default)] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The words one Tune write's refusals use — the only thing that differs between the three @@ -1140,6 +1185,12 @@ pub struct CreateTimerRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub available_channels: Option>, + /// The new timer's **gate-bounce window** in µs (#517) — see + /// [`Timer::same_pass_window_micros`]. Optional; omit it (or send `0`) for no bounce rule. + /// [`DEFAULT_SAME_PASS_WINDOW_MICROS`] is what the console offers, not what the server assumes. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The body of `PUT /timers/{id}` — the editable fields of a timer (issue #73). @@ -1173,6 +1224,12 @@ pub struct UpdateTimerRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub available_channels: Option>, + /// A new **gate-bounce window** in µs (#517), or `None` to leave it unchanged. Send `0` to turn + /// the bounce rule off — unlike the node-count override this needs no separate clear route, + /// because zero already spells "no window" (the same idiom `RoundDef::min_lap_secs` uses). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub same_pass_window_micros: Option, } /// The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73), and @@ -2171,6 +2228,9 @@ impl TimerRegistry { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + // No bounce rule: the Mock synthesizes clean passes, so there are no reflections to + // collapse and a window here would only ever be a lie about a gate that does not exist. + same_pass_window_micros: None, }; timers.insert(sim.id.clone(), sim); @@ -2266,6 +2326,7 @@ impl TimerRegistry { manual_connect: false, calibration: Vec::new(), node_channels: Vec::new(), + same_pass_window_micros: normalize_same_pass_window(request.same_pass_window_micros), }; reg.timers.insert(id, timer.clone()); reg.persist()?; @@ -2293,6 +2354,12 @@ impl TimerRegistry { return Err(TimerError(refusal)); } } + if let Some(window) = request.same_pass_window_micros { + // #517: refused here, with the same before-any-write discipline as the width override. + if let Err(refusal) = validate_same_pass_window(window) { + return Err(TimerError(refusal)); + } + } if let Some(name) = &request.name { let trimmed = name.trim(); if !trimmed.is_empty() { @@ -2322,6 +2389,11 @@ impl TimerRegistry { if let Some(available) = &request.available_channels { timer.available_channels = available.clone(); } + if let Some(window) = request.same_pass_window_micros { + // `0` spells "no bounce rule" and normalizes to `None`, so the one field both sets and + // clears the window — see [`UpdateTimerRequest::same_pass_window_micros`]. + timer.same_pass_window_micros = normalize_same_pass_window(Some(window)); + } let updated = timer.clone(); reg.persist()?; Ok(updated) @@ -3316,6 +3388,36 @@ pub const MAX_MOCK_LAPS: u32 = 1000; /// [`MAX_MOCK_LAPS`] (a runaway sim). `node_count` is passed in so the merged value can be checked /// on a partial edit; `None` (#412 — follow whatever the timer reports) is always fine, since a /// discovered width can never be zero. +/// Refuse a **gate-bounce window** outside its allowed range (#517). +/// +/// Negative is meaningless (a window is a duration) and above +/// [`MAX_SAME_PASS_WINDOW_MICROS`] is refused rather than clamped: silently accepting a number +/// and then applying a different one is how a setting stops meaning what it says. `0` is legal +/// and spells "no bounce rule" — [`normalize_same_pass_window`] turns it into `None`. +/// +/// Mirrors `events::validate_min_lap`, deliberately: the two settings are siblings, and an RD who +/// mistypes one should get the same shape of answer from the other. +pub fn validate_same_pass_window(micros: i64) -> Result<(), String> { + if micros < 0 { + return Err("same_pass_window_micros cannot be negative".to_string()); + } + if micros > MAX_SAME_PASS_WINDOW_MICROS { + return Err(format!( + "same_pass_window_micros must be at most {MAX_SAME_PASS_WINDOW_MICROS} \ + ({}s) — a longer window could suppress a lap somebody actually flew", + MAX_SAME_PASS_WINDOW_MICROS / 1_000_000 + )); + } + Ok(()) +} + +/// `0` (and anything below it, which [`validate_same_pass_window`] has already refused) means +/// **no bounce rule**, stored as `None` so the fold's "is there a window at all" test is a plain +/// `Option` check rather than a magic zero. Same normalization `RoundDef::min_lap_secs` applies. +pub fn normalize_same_pass_window(micros: Option) -> Option { + micros.filter(|m| *m > 0) +} + pub fn validate_timer_config(kind: &TimerKind, node_count: Option) -> Result<(), String> { if node_count == Some(0) { return Err( @@ -3397,6 +3499,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; @@ -3772,6 +3875,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .expect("timer created") .id; @@ -4040,6 +4144,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, } } @@ -4052,6 +4157,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, } } @@ -4386,6 +4492,7 @@ mod tests { channel_capability: Some(fixed.clone()), node_count: Some(4), available_channels: Some(vec![5658, 5695, 5732, 5769]), + same_pass_window_micros: None, }) .unwrap(); assert_eq!(created.channel_capability, fixed); @@ -4456,6 +4563,7 @@ mod tests { channel_capability: None, node_count: None, available_channels: None, + same_pass_window_micros: None, }) .unwrap() } @@ -4496,6 +4604,7 @@ mod tests { channel_capability: None, node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_reported_nodes(&rh.id, 4); @@ -4672,6 +4781,7 @@ mod tests { channel_capability: None, node_count: Some(8), available_channels: None, + same_pass_window_micros: None, }) .unwrap(); reg.set_reported_nodes(&rh.id, 4); diff --git a/crates/server/tests/live_crossings.rs b/crates/server/tests/live_crossings.rs index 5353f84d..16b7c7cc 100644 --- a/crates/server/tests/live_crossings.rs +++ b/crates/server/tests/live_crossings.rs @@ -88,12 +88,25 @@ fn shape(state: &LiveRaceState) -> Vec<(u64, CrossingDisposition, Option)> /// A heat running with `passes` already on the log, plus the offset the first pass sits at. fn running_heat(passes: Vec) -> (Vec, u64) { + running_heat_pinned(passes, None) +} + +/// As [`running_heat`], with a gate-bounce window pinned at the arm (#517) — the shape the real +/// `Start` command writes: the pin lands immediately before the `Armed` transition, which is +/// *below* the run window's start, so this also exercises the window-inclusion rule. +fn running_heat_pinned(passes: Vec, window: Option) -> (Vec, u64) { let mut events = vec![ scheduled("q-1", &["A", "B"]), changed("q-1", HeatTransition::Staged), - changed("q-1", HeatTransition::Armed), - changed("q-1", HeatTransition::Running), ]; + if let Some(w) = window { + events.push(Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(w), + }); + } + events.push(changed("q-1", HeatTransition::Armed)); + events.push(changed("q-1", HeatTransition::Running)); let first_pass_offset = events.len() as u64; events.extend(passes); (events, first_pass_offset) @@ -149,6 +162,7 @@ fn counted_crossings_carry_the_lap_number_the_lap_list_gives_them() { with_offsets(&events).iter().map(|(o, e)| (*o, e)), None, None, + None, ); let a = laps .competitors @@ -479,3 +493,150 @@ fn the_bound_never_re_announces_a_crossing_that_fell_out_of_the_window() { "every crossing announced once, none re-announced after the window slid past it" ); } + +// --- The gate-bounce window (#517) --------------------------------------------------------- + +/// **A bounce is its own disposition, and it reaches the live feed as one.** +/// +/// The whole point of #517: before it, this crossing and a genuinely short one were both +/// `RejectedTooShort`, so the console could not tell a reflection burst from a lap the pilot +/// really flew too fast. Under a pinned 1s window the 61ms echo is `RejectedSamePass` and the 3s +/// crossing — a real pass, under the 10s floor — is still `RejectedTooShort`. +#[test] +fn a_bounce_and_a_short_lap_reach_the_feed_as_different_dispositions() { + let (events, base) = running_heat_pinned( + vec![ + pass("A", 0), + pass("A", 61_000), // +61ms — the gate stuttering: a bounce + pass("A", 3 * SECOND), // +3s — a real crossing, but under the floor + ], + Some(SECOND), + ); + let state = folded_with_floor(&events, Some(10 * SECOND)); + + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::RejectedSamePass, None), + (base + 2, CrossingDisposition::RejectedTooShort, None), + ], + "the echo is a bounce; the 3s crossing is a real pass the floor refused" + ); +} + +/// **The pin is read from the log, so the heat-scope window must carry it.** +/// +/// `current_run_start` opens a run's window at `Running`, and the pin is appended at the *arm* — +/// below that boundary. If the window dropped it, the live view would apply no bounce rule while +/// the lap list applied one, and the two surfaces would disagree about a suppressed pass. That is +/// exactly the D26 class of bug (#409); this asserts it cannot recur for the bounce window. +#[test] +fn the_arm_time_pin_survives_the_run_window_and_the_lap_list_agrees() { + let (events, base) = running_heat_pinned( + vec![pass("A", 0), pass("A", 61_000), pass("A", 30 * SECOND)], + Some(SECOND), + ); + let state = folded_with_floor(&events, None); + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::RejectedSamePass, None), + (base + 2, CrossingDisposition::Counted, Some(1)), + ], + "the pin sits below run_start; the live fold must still see it" + ); + + // And the lap projection, read over the same log, reaches the same verdict. + let laps = lap_list_marshaled_with_floor( + with_offsets(&events).iter().map(|(o, e)| (*o, e)), + None, + gridfpv_projection::same_pass_window_of_heat(events.iter(), &HeatId("q-1".into())), + None, + ); + let a = laps + .competitors + .iter() + .find(|c| c.competitor.competitor == CompetitorRef("A".into())) + .expect("A has laps"); + assert_eq!( + a.laps.len(), + 1, + "one counted lap, holeshot to the 30s crossing" + ); + assert_eq!( + a.voided.iter().map(|v| v.reason).collect::>(), + vec![gridfpv_projection::VoidReason::SamePassBounce] + ); +} + +/// **A re-arm re-pins, and the latest pin is the one that governs.** +/// +/// Abort, retune the gate, re-arm: the new run must race under the window set for *it*. The old +/// pin also sits below `run_start`, so "last one wins" is the only rule that can separate them. +#[test] +fn a_re_arm_races_under_the_window_pinned_at_the_new_arm() { + let mut events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(SECOND), + }, + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + pass("A", 0), + changed("q-1", HeatTransition::Aborted), + changed("q-1", HeatTransition::Staged), + // The RD decided the gate was over-filtering and dialed the window right down. + Event::HeatDetectionPinned { + heat: HeatId("q-1".into()), + same_pass_window_micros: Some(100_000), + }, + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + ]; + let base = events.len() as u64; + // 400ms apart: a bounce under the FIRST window, a real crossing under the second. + events.extend(vec![pass("A", 0), pass("A", 400_000)]); + + let state = folded_with_floor(&events, None); + assert_eq!( + shape(&state), + vec![ + (base, CrossingDisposition::Holeshot, None), + (base + 1, CrossingDisposition::Counted, Some(1)), + ], + "the second arm's 100ms window governs — the abandoned run's 1s pin is dead" + ); +} + +/// **No pin folds exactly as before.** Every heat armed before #517 existed, and every timer with +/// the setting unset, must be judged by the round's floor alone. +#[test] +fn an_unpinned_run_is_bit_identical_to_the_pre_bounce_fold() { + let passes = vec![pass("A", 0), pass("A", 61_000), pass("A", 30 * SECOND)]; + let (unpinned, _) = running_heat(passes.clone()); + let (pinned_off, _) = running_heat_pinned(passes, Some(0)); + let floor = Some(10 * SECOND); + // Dispositions, not offsets: the pinned log carries one extra event, so every append offset + // shifts by one. What must be identical is the VERDICT on each crossing. + let verdicts = |events: &[Event]| -> Vec<(CrossingDisposition, Option)> { + shape(&folded_with_floor(events, floor)) + .into_iter() + .map(|(_, d, lap)| (d, lap)) + .collect() + }; + assert_eq!( + verdicts(&unpinned), + verdicts(&pinned_off), + "a pinned 0 is no rule at all, exactly like no pin" + ); + assert!( + verdicts(&unpinned) + .iter() + .any(|(d, _)| *d == CrossingDisposition::RejectedTooShort), + "and without a window the echo is still judged by the floor, as it always was" + ); +} diff --git a/crates/server/tests/multi_round_event_live.rs b/crates/server/tests/multi_round_event_live.rs index 6a108547..3193d0e4 100644 --- a/crates/server/tests/multi_round_event_live.rs +++ b/crates/server/tests/multi_round_event_live.rs @@ -576,6 +576,7 @@ async fn build_event( channel_capability: Some(ChannelCapability::Flexible), node_count: Some(CALLSIGNS.len() as u32), available_channels: Some(MEMBER_CHANNELS.to_vec()), + same_pass_window_micros: None, }, ) .await; diff --git a/docker/rotorhazard/race_day.py b/docker/rotorhazard/race_day.py index 64fcc027..ab7ef0ca 100644 --- a/docker/rotorhazard/race_day.py +++ b/docker/rotorhazard/race_day.py @@ -91,6 +91,39 @@ def scenario_messy(nodes, rng): return out +def scenario_bouncy(nodes, rng): + """A GATE THAT RE-DETECTS (#517): every real crossing is followed by a burst of echoes. + + This is the failure the same-pass window exists for. A quad sitting in the gate's near field + trips the detector several times per pass — reflections tens to a couple of hundred + milliseconds apart, at a lower peak than the real crossing. RotorHazard reports all of them + (GridFPV neutralises RH's own min-lap filter on purpose), so without a bounce rule the + marshaling list gets three or four removal rows between every pair of real laps. + + Node 0 is the worst offender (3 echoes per pass), node 1 bounces occasionally, and the rest of + the field is clean — so one screen shows the collapsed burst, the untouched laps, and the + difference between them. One pilot also gets a genuine sub-floor crossing 3 s after a lap: far + outside any sane bounce window, so it must stay a full `under min lap` row and keep its tone. + """ + out = [] + for i, n in enumerate(nodes): + laps = _laps(n, 4, 7.0, 0.05, STRONG, rng=rng) + out += laps + if i == 0: + # The bad gate: three echoes after every crossing, 60–260 ms out. + for t, node, _peak in laps: + for gap in (0.061, 0.193, 0.254): + out.append((t + gap, node, FALSE_PEAK + rng.randint(-8, 8))) + elif i == 1: + # An intermittent one: a single echo after the holeshot and after lap 2. + for t, node, _peak in (laps[0], laps[2]): + out.append((t + 0.088, node, FALSE_PEAK)) + # …and a REAL crossing 3 s after lap 1 — the pilot cut the course. Under a 10 s floor + # this is `under min lap`, NOT a bounce: it keeps its own row and it still tones. + out.append((laps[1][0] + 3.0, node, STRONG - 10)) + return out + + def scenario_pack(nodes, rng): out = [] base_pace = 6.5 @@ -105,6 +138,7 @@ def scenario_pack(nodes, rng): "clean": scenario_clean, "varied": scenario_varied, "messy": scenario_messy, + "bouncy": scenario_bouncy, "pack": scenario_pack, } diff --git a/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts index 32dcbba0..688bd603 100644 --- a/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts +++ b/frontend/apps/rd-console/src/lib/lapCallouts.svelte.ts @@ -147,14 +147,6 @@ export function useLapCallouts( }); } -/** - * How long after a competitor's SOUNDED tone their further crossings are absorbed (#503), in - * source-clock µs. Well under any real lap (the min-lap floor's field default is 10s) and well - * over a reflection burst's spread (tens to hundreds of ms), so it can only ever collapse a - * multi-detection of one physical pass, never silence a genuine next lap. - */ -export const CROSSING_TONE_COOLDOWN_MICROS = 1_000_000; - /** * **New-crossing detection** for the per-crossing tone (#397) — the sibling of * {@link useLapCallouts} above, and the reason this module is no longer only about laps. @@ -171,18 +163,28 @@ export const CROSSING_TONE_COOLDOWN_MICROS = 1_000_000; * too-sensitive gate, and it is exactly what a table of laps will never show them. Nothing here * filters toward "meaningful" crossings — but repeats are rate-limited, see the cooldown below. * - * ── The cooldown: one physical pass is ONE tone (#503) ─────────────────────────────────────── + * ── One physical pass is ONE tone — and the FOLD decides that (#503, then #517) ───────────── * A quad sitting in the gate's near field fires the detector several times per pass (antenna * reflections milliseconds apart), and on the field that rendered as a pip storm per lap — the - * tone stopped answering "did the gate see me?" and started drowning the RD. So: a crossing by - * the SAME competitor within {@link CROSSING_TONE_COOLDOWN_MICROS} of the last one *sounded* for - * them is absorbed. Measured from the last sounded tone (an absorbed crossing does not extend - * the window), keyed **per competitor** — two pilots crossing near-simultaneously must both - * tone, that is the gate telling the RD it saw both. The window is source-clock (`at`), the axis - * bursts are adjacent on; a crossing carrying an *older* source time than the last sounded one - * (a marshal insert) is never absorbed. Absorbed crossings still advance the watermark — they - * are seen, just not sounded — and the min-lap floor still voids them on its own axis; this - * cooldown only de-duplicates the NOISE of them. + * tone stopped answering "did the gate see me?" and started drowning the RD. + * + * #503 fixed that here, with a private 1s per-competitor cooldown. It worked, but it made this + * module a second owner of a rule the server also had an opinion about: the same physical fact — + * *the gate stutters over one pass* — was a hardcoded client constant AND (conflated with the + * min-lap floor) a server-side suppression, with no way for the two to agree. The marshaling list + * could not collapse a reflection burst without also hiding a genuinely short lap, because by the + * time it saw them they were both `RejectedTooShort`. + * + * So #517 moved the rule to the one place that already judges crossings: the corrected fold. The + * window is per-timer config (`Timer.same_pass_window_micros`), pinned onto the log at the arm so + * a later edit cannot re-judge a finished race, and a crossing inside it folds to the disposition + * `RejectedSamePass`. This detector simply does not sound that one. **Behaviour is unchanged** — + * the fold measures from the last *surviving* pass exactly as the cooldown measured from the last + * *sounded* tone, and a suppressed crossing extends neither — but there is now one window, one + * owner, and the speaker and the lap list can no longer disagree. + * + * Everything else still tones, including a crossing on a seat nobody is flying: a bounce is the + * gate stuttering over something that happened once, and that is the ONLY thing being filtered. * * ── The watermark: fire on IDENTITY, never on a frame arriving ─────────────────────────────── * Every `LiveCrossing` carries `pass_ref` — its **global append offset**, stable across every @@ -232,9 +234,6 @@ export function useCrossingTones( let scopeKey: string | undefined; let baselined = false; let watermark = -1; - // The source time (µs) of the last crossing SOUNDED per competitor — the cooldown's anchor - // (#503). Absorbed crossings never land here, so the window measures from the last tone. - const lastTonedAt = new Map(); $effect(() => { const scope = getScope(); @@ -248,7 +247,6 @@ export function useCrossingTones( scopeKey = scope; baselined = false; watermark = -1; - lastTonedAt.clear(); } // First sight of this scope BASELINES: retire everything on offer, announce none of it. const announce = baselined && audible; @@ -261,15 +259,10 @@ export function useCrossingTones( if (crossing.pass_ref <= previous) continue; if (crossing.pass_ref > next) next = crossing.pass_ref; if (!announce) continue; - // The cooldown (#503): absorb a same-competitor crossing inside the window of their last - // SOUNDED tone. A negative delta (a marshal insert carrying an older source time) is a - // different case entirely and always tones; exactly-at-the-boundary tones too. - const last = lastTonedAt.get(crossing.competitor); - if (last !== undefined) { - const since = crossing.at - last; - if (since >= 0 && since < CROSSING_TONE_COOLDOWN_MICROS) continue; - } - lastTonedAt.set(crossing.competitor, crossing.at); + // The bounce (#517): the fold already decided this crossing was the same physical pass as + // the last surviving one, so there is nothing here for the RD to hear. It still advances the + // watermark — it was seen, just not sounded — exactly as the old client-side cooldown did. + if (crossing.disposition === 'RejectedSamePass') continue; onCrossing(crossing); } watermark = next; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 34840439..67bb90ea 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -34,6 +34,7 @@ Pilot, PilotProgress, SignalTraceView, + VoidedPass, VoidReason } from '@gridfpv/types'; import { formatMicros, Select, toast } from '@gridfpv/components'; @@ -442,9 +443,9 @@ } /** RESTORE a removed pass. A marshal-voided pass is undone by void-the-void (targeting the - * standing removal event); an AUTO-suppressed pass (UnderMinLap, D26; AfterRaceEnd, #505) is - * BLESSED by an AdjustLap re-asserting its own raw instant — an explicit ruling outranks - * both rules, so the fold exempts it and the pass returns to the chain. */ + * standing removal event); an AUTO-suppressed pass (SamePassBounce, #517; UnderMinLap, D26; + * AfterRaceEnd, #505) is BLESSED by an AdjustLap re-asserting its own raw instant — an explicit + * ruling outranks all three rules, so the fold exempts it and the pass returns to the chain. */ function doRestorePass(v: { void_ref: number; pass_ref: number; @@ -461,10 +462,16 @@ }); } - /** The removal record's row text: WHY the pass is off the chain, by its `VoidReason`. */ + /** The removal record's row text: WHY the pass is off the chain, by its `VoidReason`. + * + * Every arm is spelled out rather than leaning on the `default:` — a new reason landing here + * silently would read as "stays removed", which is a different claim about a different kind of + * removal, and the RD would have no way to tell it was wrong. */ function voidedRowLabel(v: { at: number; reason: VoidReason }): string { const at = `${formatMicros(v.at)}s`; switch (v.reason) { + case 'SamePassBounce': + return `crossing at ${at} — same pass (gate bounce), auto-removed`; case 'UnderMinLap': return `crossing at ${at} — under min lap, auto-removed`; case 'AfterRaceEnd': @@ -474,6 +481,44 @@ } } + /** Is this removal a gate bounce — the fold's "same physical pass, seen twice" (#517)? + * + * The one removal kind that gets COLLAPSED in the lap list rather than given its own row. A + * bouncy gate puts three or four of these between every pair of real laps, and they carry no + * information individually: the RD wants to know a burst happened, not to read it out. Every + * other reason — a real crossing under the floor, a marshal void, a post-race crossing — keeps + * its full row, because each of those is a distinct thing that happened. */ + function isBounce(v: { reason: VoidReason }): boolean { + return v.reason === 'SamePassBounce'; + } + + /** The removals that belong in `lap`'s slot: after the previous lap, before this one. Hoisted + * out of the markup because the burst and the full rows both need it, and `{@const}` cannot + * live everywhere it would have to. */ + function removalsBefore(voided: VoidedPass[], laps: Lap[], lap: Lap): VoidedPass[] { + return voided.filter( + (v) => v.at < lap.at && !laps.some((o) => o.number < lap.number && o.at > v.at) + ); + } + + /** The removals after the LAST lap — and, for a competitor with no laps at all, their whole + * record. Both cases need the burst indicator: a run whose every crossing bounced is exactly + * the finding an RD came to marshaling to see. */ + function removalsAfterLast(voided: VoidedPass[], laps: Lap[]): VoidedPass[] { + return voided.filter((v) => laps.length === 0 || v.at >= laps[laps.length - 1].at); + } + + /** Which lap rows have their bounce burst expanded. Keyed by the burst's owning lap `end_ref` + * (and `-1` for a burst that trails the last lap), so it survives a refold — a re-fold keeps + * each pass's offset, which is exactly why the removal record is keyed on offsets too. */ + let expandedBursts = $state(new Set()); + + function toggleBurst(key: number): void { + const next = new Set(expandedBursts); + if (!next.delete(key)) next.add(key); + expandedBursts = next; + } + /** Remove (void) a lap straight from its row — the one-click removal on the lap list. */ function doVoidLap(competitor: CompetitorRef, lap: Lap): Promise { return submitCorrection(async () => { @@ -1285,25 +1330,60 @@

No laps yet.

{:else} {@const voidedSorted = [...(cl.voided ?? [])].sort((a, b) => a.at - b.at)} + {#snippet removalRow(v: VoidedPass)} +
  • + + {voidedRowLabel(v)} + {#if canCorrect} + + {/if} +
  • + {/snippet} + + {#snippet bounceBurst(bounces: VoidedPass[], key: number)} + {#if bounces.length > 0} +
  • + + +
  • + {#if expandedBursts.has(key)} + {#each bounces as v (v.pass_ref)} + {@render removalRow(v)} + {/each} + {/if} + {/if} + {/snippet}
      {#each cl.laps as lap (lap.end_ref)} - {#each voidedSorted.filter((v) => v.at < lap.at && !cl.laps.some((o) => o.number < lap.number && o.at > v.at)) as v (v.pass_ref)} -
    1. - - {voidedRowLabel(v)} - {#if canCorrect} - - {/if} -
    2. + {@const slot = removalsBefore(voidedSorted, cl.laps, lap)} + {#each slot.filter((v) => !isBounce(v)) as v (v.pass_ref)} + {@render removalRow(v)} {/each} + {@render bounceBurst(slot.filter(isBounce), lap.end_ref)}
    3. - {/if} -
    4. + + {#each removalsAfterLast(voidedSorted, cl.laps).filter((v) => !isBounce(v)) as v (v.pass_ref)} + {@render removalRow(v)} {/each} + {@render bounceBurst( + removalsAfterLast(voidedSorted, cl.laps).filter(isBounce), + -1 + )}
    {/if} {#if canCorrect} @@ -2078,6 +2150,36 @@ list-style: none; padding: var(--gf-space-1) 0; } + /* The collapsed gate-bounce burst (#517). Deliberately the QUIETEST thing in the lap list: it + is an annotation on the run, not an event in it, and the whole point of collapsing was that + these were drowning the laps. No strike-through — nothing here is a removed lap; it is a + count of detections that were never separate crossings in the first place. */ + .bounce-row { + display: flex; + align-items: center; + gap: var(--gf-space-2); + color: var(--gf-text-faint); + list-style: none; + padding: 0; + font-size: var(--gf-font-size-sm); + } + .bounce-toggle { + background: none; + border: 0; + padding: var(--gf-space-1) 0; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; + } + .bounce-toggle:hover, + .bounce-toggle:focus-visible { + color: var(--gf-text); + text-decoration: underline; + } + .bounce-row .chev { + margin-left: var(--gf-space-1); + } /* Region heads: the two scopes of this page (pilot vs heat), RD-requested after the lap-editor consolidation made the seam invisible. The divider is deliberately HEAVY — sunlit-laptop legible — and the heat region reads as a distinct zone, not more pilot UI. */ diff --git a/frontend/apps/rd-console/src/screens/TimerManager.svelte b/frontend/apps/rd-console/src/screens/TimerManager.svelte index 165a1a8d..732e6d08 100644 --- a/frontend/apps/rd-console/src/screens/TimerManager.svelte +++ b/frontend/apps/rd-console/src/screens/TimerManager.svelte @@ -322,6 +322,18 @@ let saving = $state(false); let formError = $state(undefined); + /** + * The gate-bounce window this form OFFERS a new timer, and the ceiling it accepts — in seconds + * (#517). + * + * **The server is authoritative** (`timers::MAX_SAME_PASS_WINDOW_MICROS`, and it refuses rather + * than clamps); these are the field's affordances — a spinner that stops in the right place and + * a hint that says why — exactly as `maxNodeCount` is for the width. They are not on the wire, so + * a change to the Rust cap must be echoed here; the refusal message is the backstop if it is not. + */ + const DEFAULT_SAME_PASS_WINDOW_SECS = 1; + const MAX_SAME_PASS_WINDOW_SECS = 2; + // ── Channel config (race redesign Slice 4b) ────────────────────────────────── // The capability (Fixed | Flexible), node count, and the chosen available channels. The chosen set // is held as a `Set` of raw MHz (catalog picks + custom entries); for a Fixed timer it is @@ -339,6 +351,14 @@ * live in the Nodes dialog, which is the surface that can express them. */ let seededNodeCount = $state(String(DEFAULT_NODE_COUNT)); + /** + * The gate-bounce window, in **seconds** for the field (the wire is µs) — #517. + * + * Seconds because that is how an RD thinks about it and how the sibling setting (a round's min + * lap) is already expressed; the conversion happens once, on submit. `'0'` is a real value here, + * not an empty field: it spells "no bounce rule", which is what a timer that never bounces wants. + */ + let formSamePassWindow = $state('0'); let formChannels = $state>(new Set()); // A Fixed timer's built-in allowed set (its physically-supported channels); the picker offers // exactly these, and `formChannels` is the subset the RD makes available. Empty ⇒ all catalog. @@ -368,6 +388,12 @@ formChannels = new Set(timer?.available_channels ?? []); formFixedAllowed = fixedAllowed(timer?.channel_capability); formCustomMhz = ''; + // A NEW timer is offered the 1s default; an existing one shows what it actually has, including + // `0` for a timer the RD deliberately left without a rule. Unlike the node-count override there + // is nothing to disambiguate — the field always sends, because `0` is a meaningful answer. + formSamePassWindow = timer + ? String((timer.same_pass_window_micros ?? 0) / 1_000_000) + : String(DEFAULT_SAME_PASS_WINDOW_SECS); } export function openAdd() { @@ -545,6 +571,14 @@ // Only carried when the RD actually set a width — see `seededNodeCount`. Omitted otherwise, so // an unrelated edit never pins an override the RD did not ask for. const node_count = String(formNodeCount) === seededNodeCount ? undefined : nodeCount; + // Seconds on screen, µs on the wire. Always sent: unlike the width override, `0` here is a real + // answer ("this gate does not bounce"), so there is no "untouched" state to preserve. + const windowSecs = Number(formSamePassWindow); + if (!Number.isFinite(windowSecs) || windowSecs < 0 || windowSecs > MAX_SAME_PASS_WINDOW_SECS) { + formError = `Same-pass window must be between 0 and ${MAX_SAME_PASS_WINDOW_SECS} seconds (0 turns it off).`; + return; + } + const same_pass_window_micros = Math.round(windowSecs * 1_000_000); saving = true; formError = undefined; try { @@ -554,7 +588,8 @@ kind: built.kind, channel_capability, node_count, - available_channels + available_channels, + same_pass_window_micros }; const updated = await session.updateTimer(editing.id, req); if (!updated) { @@ -568,7 +603,8 @@ kind: built.kind, channel_capability, node_count, - available_channels + available_channels, + same_pass_window_micros }; const created = await session.createTimer(req); if (!created) { @@ -855,6 +891,24 @@ + + + + + { expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 13, at: 43_000_000 } }); }); + it('collapses a gate-bounce burst to one line, and expands to per-crossing Restore (#517)', async () => { + // The complaint this issue came from: a bouncy gate put a row per reflection between every + // pair of real laps, and the laps became the minority of the list. Collapsed, the burst is + // one muted line; the individual crossings — and their Restores — are one click away. + const bouncy: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } + ], + voided: [ + { at: 41_061_000, pass_ref: 13, void_ref: 13, reason: 'SamePassBounce' as const }, + { at: 41_193_000, pass_ref: 14, void_ref: 14, reason: 'SamePassBounce' as const }, + { at: 41_254_000, pass_ref: 15, void_ref: 15, reason: 'SamePassBounce' as const } + ] + } + ] + }; + const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: bouncy }); + render(Marshaling, { session }); + + // Collapsed: one summary, and none of the three crossings has a row of its own. + expect(screen.getByText(/3 same-pass crossings/)).toBeInTheDocument(); + expect(screen.queryByText(/crossing at 41\.061s/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Restore removed pass at 41\.061s/ }) + ).not.toBeInTheDocument(); + + // Expanded: the ordinary removal rows, each with its own Restore. No bulk action exists. + await fireEvent.click(screen.getByRole('button', { name: /3 same-pass crossings/ })); + expect( + screen.getByText(/crossing at 41\.061s — same pass \(gate bounce\), auto-removed/) + ).toBeInTheDocument(); + await fireEvent.click( + screen.getByRole('button', { name: /Restore removed pass at 41\.061s/ }) + ); + expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 13, at: 41_061_000 } }); + }); + + it('a bounce burst does NOT collapse a genuinely short crossing beside it (#517)', async () => { + // The distinction the whole issue turns on. Both were `UnderMinLap` before #517, so the + // console could not hide one without hiding the other. The 3s crossing is a real pass the + // pilot made — it keeps its own row and its own Restore, at full prominence. + const mixed: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 41_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 } + ], + voided: [ + { at: 41_061_000, pass_ref: 13, void_ref: 13, reason: 'SamePassBounce' as const }, + { at: 44_000_000, pass_ref: 14, void_ref: 14, reason: 'UnderMinLap' as const } + ] + } + ] + }; + const { session } = makeTestSession({ live: liveRunning, laps: mixed }); + render(Marshaling, { session }); + expect(screen.getByText(/1 same-pass crossing\b/)).toBeInTheDocument(); + expect( + screen.getByText(/crossing at 44\.000s — under min lap, auto-removed/) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Restore removed pass at 44\.000s/ }) + ).toBeInTheDocument(); + }); + it('Restore on a removed pass sends void-the-void at the STANDING removal event', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: voidedLapList }); render(Marshaling, { session }); diff --git a/frontend/apps/rd-console/tests/TimersPage.test.ts b/frontend/apps/rd-console/tests/TimersPage.test.ts index e1ec8a49..1f17c59c 100644 --- a/frontend/apps/rd-console/tests/TimersPage.test.ts +++ b/frontend/apps/rd-console/tests/TimersPage.test.ts @@ -39,6 +39,16 @@ const RH: Timer = { disabled_nodes: [] }; +/** + * The gate-bounce window the add/edit form sends (#517), in µs. + * + * The field always sends — unlike the node-count override there is no "the RD did not touch this" + * state to preserve, because `0` is itself a real answer ("this gate does not bounce"). A NEW + * timer is offered the 1s default; the fixtures here have the setting unset, so an EDIT of one + * sends `0`. Both are covered below. + */ +const SAME_PASS_DEFAULT_MICROS = 1_000_000; + describe('TimersPage (app-level timer registry)', () => { it('lists the registry on mount, with the built-in Mock undeletable', async () => { const listTimersImpl = vi.fn(async () => [MOCK, RH]); @@ -113,7 +123,8 @@ describe('TimersPage (app-level timer registry)', () => { kind: { Mock: { laps: 3, lap_ms: 30000 } }, channel_capability: 'Flexible', node_count: undefined, - available_channels: [] + available_channels: [], + same_pass_window_micros: SAME_PASS_DEFAULT_MICROS }, 'tok' ); @@ -149,7 +160,8 @@ describe('TimersPage (app-level timer registry)', () => { kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, channel_capability: 'Flexible', node_count: undefined, - available_channels: [] + available_channels: [], + same_pass_window_micros: 0 // the fixture timer has no window set; the field sends 0 = no rule }, 'tok' ); @@ -208,7 +220,8 @@ describe('TimersPage (app-level timer registry)', () => { channel_capability: 'Flexible', node_count: 6, // Catalog channels in catalog order, then the custom MHz. - available_channels: [5658, 5800, 5685] + available_channels: [5658, 5800, 5685], + same_pass_window_micros: SAME_PASS_DEFAULT_MICROS }, 'tok' ); @@ -261,7 +274,8 @@ describe('TimersPage (app-level timer registry)', () => { channel_capability: { Fixed: { channels: [5658, 5695] } }, // Untouched, so the width override is left exactly as it was (#412). node_count: undefined, - available_channels: [5658, 5695] + available_channels: [5658, 5695], + same_pass_window_micros: 0 // the fixture timer has no window set; the field sends 0 = no rule }, 'tok' ); diff --git a/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts index c02cedfc..6adc4718 100644 --- a/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts +++ b/frontend/apps/rd-console/tests/lapCallouts.svelte.test.ts @@ -408,24 +408,29 @@ describe('useCrossingTones', () => { }; } - it('tones on EVERY crossing — holeshot, counted, rejected-too-short and marshal-voided alike', () => { + it('tones on every crossing EXCEPT a fold-declared bounce', () => { + // #397's rule and #517's single exception, in one assertion. Everything the gate reports is + // audible — a holeshot, a counted lap, a pass under the floor, a marshal-voided one, and a + // crossing after the race ended — because each of those is a thing that happened. The one + // silence is `RejectedSamePass`: not a second crossing, the same one seen twice. const h = harness(); const holeshot = crossing(10, 'maverick-1', 'Holeshot'); const counted = crossing(11, 'maverick-1', 'Counted', 1); const rejected = crossing(12, 'maverick-1', 'RejectedTooShort'); const voided = crossing(13, 'goose-2', 'VoidedByMarshal'); + const afterEnd = crossing(14, 'goose-2', 'RejectedAfterRaceEnd'); + const bounce = crossing(15, 'maverick-1', 'RejectedSamePass'); - h.set({ crossings: [holeshot] }); - h.set({ crossings: [holeshot, counted] }); - h.set({ crossings: [holeshot, counted, rejected] }); - h.set({ crossings: [holeshot, counted, rejected, voided] }); + const feed = [holeshot, counted, rejected, voided, afterEnd, bounce]; + for (let i = 1; i <= feed.length; i++) h.set({ crossings: feed.slice(0, i) }); - expect(h.toned).toEqual([holeshot, counted, rejected, voided]); + expect(h.toned).toEqual([holeshot, counted, rejected, voided, afterEnd]); expect(h.toned.map((c) => c.disposition)).toEqual([ 'Holeshot', 'Counted', 'RejectedTooShort', - 'VoidedByMarshal' + 'VoidedByMarshal', + 'RejectedAfterRaceEnd' ]); h.cleanup(); }); @@ -570,45 +575,61 @@ describe('useCrossingTones', () => { h.cleanup(); }); - it('absorbs a same-competitor reflection burst into ONE tone (#503)', () => { + it('stays silent on a reflection burst the fold called RejectedSamePass (#503, #517)', () => { // One physical pass, five detections milliseconds apart (a quad in the gate's near field). - // The field rendered this as a pip storm per lap; the tone must answer "did the gate see - // me?" once. Explicit `at` values — the cooldown runs on the source clock, not offsets. + // The field rendered this as a pip storm per lap; the tone must answer "did the gate see me?" + // once. #503 did that here with a private 1s timer; #517 moved the judgement into the fold, + // so this detector now reads the disposition rather than re-deriving the rule from `at`. const h = harness(); const burst = [ { ...crossing(80, 'maverick-1', 'Counted', 1), at: 10_000_000 }, - { ...crossing(81, 'maverick-1', 'RejectedTooShort'), at: 10_061_000 }, - { ...crossing(82, 'maverick-1', 'RejectedTooShort'), at: 10_193_000 }, - { ...crossing(83, 'maverick-1', 'RejectedTooShort'), at: 10_254_000 }, - { ...crossing(84, 'maverick-1', 'RejectedTooShort'), at: 10_487_000 } + { ...crossing(81, 'maverick-1', 'RejectedSamePass'), at: 10_061_000 }, + { ...crossing(82, 'maverick-1', 'RejectedSamePass'), at: 10_193_000 }, + { ...crossing(83, 'maverick-1', 'RejectedSamePass'), at: 10_254_000 }, + { ...crossing(84, 'maverick-1', 'RejectedSamePass'), at: 10_487_000 } ]; h.set({ crossings: burst }); expect(h.toned).toEqual([burst[0]]); - // The next genuine lap — 17s later, far past the cooldown — tones again. + // The next genuine lap tones again — the bounce absorbs the burst, not the gate. const nextLap = { ...crossing(85, 'maverick-1', 'Counted', 2), at: 27_500_000 }; h.set({ crossings: [...burst, nextLap] }); expect(h.toned).toEqual([burst[0], nextLap]); h.cleanup(); }); - it('measures the cooldown from the last SOUNDED tone — an absorbed crossing does not extend it', () => { + it('STILL tones a genuinely short crossing — RejectedTooShort is not a bounce', () => { + // The distinction #517 exists to make, and the one the old client-side cooldown could not: + // a pass 3s after the last one, under a 10s floor, is a real crossing the pilot really made. + // It scores nothing, but the RD must hear that the gate fired — that is the whole of #397. + // Under the pre-#517 cooldown this depended on `at` arithmetic; now the fold has already + // ruled, and a crossing it did NOT call a bounce is audible whatever its spacing. + const h = harness(); + const holeshot = { ...crossing(100, 'maverick-1', 'Counted', 1), at: 10_000_000 }; + const tooShort = { ...crossing(101, 'maverick-1', 'RejectedTooShort'), at: 13_000_000 }; + const bounce = { ...crossing(102, 'maverick-1', 'RejectedSamePass'), at: 13_090_000 }; + h.set({ crossings: [holeshot] }); + h.set({ crossings: [holeshot, tooShort] }); + h.set({ crossings: [holeshot, tooShort, bounce] }); + expect(h.toned).toEqual([holeshot, tooShort]); + h.cleanup(); + }); + + it('a bounce still advances the watermark — it was seen, just not sounded', () => { + // The absorbed crossing must not be re-offered later. Silence is not the same as unseen: if a + // bounce left the watermark behind, a re-push of the same frame would sound it. const h = harness(); - const t0 = { ...crossing(90, 'maverick-1', 'Counted', 1), at: 10_000_000 }; - const absorbed = { ...crossing(91, 'maverick-1', 'RejectedTooShort'), at: 10_800_000 }; - // 1.2s after t0 but only 0.4s after the absorbed crossing: the window anchors on the TONE, - // so this fires. (A storm that never pauses must not silence the gate indefinitely.) - const clear = { ...crossing(92, 'maverick-1', 'RejectedTooShort'), at: 11_200_000 }; - h.set({ crossings: [t0] }); - h.set({ crossings: [t0, absorbed] }); - h.set({ crossings: [t0, absorbed, clear] }); - expect(h.toned).toEqual([t0, clear]); + const kept = { ...crossing(90, 'maverick-1', 'Counted', 1), at: 10_000_000 }; + const bounce = { ...crossing(91, 'maverick-1', 'RejectedSamePass'), at: 10_800_000 }; + h.set({ crossings: [kept, bounce] }); + h.set({ crossings: [kept, bounce] }); + expect(h.toned).toEqual([kept]); h.cleanup(); }); - it('cools down PER COMPETITOR — two pilots crossing near-simultaneously both tone', () => { - // The gate telling the RD it saw both pilots is the point of the feature; only repeats of - // the SAME competitor are noise. + it('two pilots crossing near-simultaneously both tone', () => { + // The gate telling the RD it saw both pilots is the point of the feature. The fold keys the + // bounce window per competitor for the same reason, so neither of these is ever absorbed. const h = harness(); const mav = { ...crossing(95, 'maverick-1', 'Counted', 1), at: 10_000_000 }; const goose = { ...crossing(96, 'goose-2', 'Counted', 1), at: 10_050_000 }; diff --git a/src-tauri/README.md b/src-tauri/README.md index 1a6c1db1..cfd75e44 100644 --- a/src-tauri/README.md +++ b/src-tauri/README.md @@ -63,7 +63,7 @@ mount, or `current_exe()` can't be resolved), the app falls back to the OS per-u | OS | Fallback path | | ------- | ------------------------------------------------------------ | | Linux | `~/.local/share/org.gridfpv.desktop/` | -| Windows | `%APPDATA%\org.gridfpv.desktop\` (deferred — not yet built) | +| Windows | `%APPDATA%\org.gridfpv.desktop\` | | macOS | `~/Library/Application Support/org.gridfpv.desktop/` | On startup the app prints one of: @@ -161,7 +161,21 @@ wins; this never overwrites it. ## Windows -Windows packaging is **deferred**. The crate is Windows-ready in principle (the -`windows_subsystem = "windows"` attribute is set for release builds), but no Windows artifact -is produced here and **no cross-compile is attempted** — it needs a Windows build host with -the WebView2 runtime and the MSVC toolchain. +The Windows portable **is** built — by the `windows-latest` leg of +`.github/workflows/release-builds.yml`, which produces the +`gridfpv-windows-portable` artifact (`src-tauri/target/release/gridfpv-desktop.exe`) +and smoke-tests that it starts. Windows needs none of the Linux system deps above: +it builds against the MSVC toolchain and loads the **bundled WebView2 runtime**, +which ships with Windows 11. + +**No cross-compile is attempted, and that is deliberate.** Building the `.exe` from +Linux (`cargo-xwin` and friends) is feasible — WebView2 is a runtime COM dependency, +not a build-time one — but it cannot *launch* the result, and the startup smoke-test +is the point: #58 is done when the single binary launches on all three OSes, not when +it compiles for them. So a Windows artifact comes from a Windows host, which in +practice means the CI leg. + +Same for the **macOS** leg (`macos-latest`, Apple Silicon): Apple's licensing keeps +macOS builds on Apple hardware, and the maintainer has no Mac — so CI's smoke-test is +the only thing standing between that artifact and shipping unverified. Both legs stay +in Actions. diff --git a/xtask/src/race_day.rs b/xtask/src/race_day.rs index 7879c185..ed309224 100644 --- a/xtask/src/race_day.rs +++ b/xtask/src/race_day.rs @@ -44,6 +44,10 @@ const SCENARIOS: &[(&str, &str)] = &[ "messy", "marshaling practice: a missed lap, a false/extra pass, and a DNF", ), + ( + "bouncy", + "a gate that re-detects: echo bursts per pass, plus one genuine sub-floor lap (#517)", + ), ( "pack", "a tight field crossing close together (close finishes)",