diff --git a/bindings/Placement.ts b/bindings/Placement.ts index 36131b8..9b81374 100644 --- a/bindings/Placement.ts +++ b/bindings/Placement.ts @@ -29,6 +29,16 @@ laps: number, * The condition-specific deciding metric for this competitor. */ metric: Metric, +/** + * The competitor's **fastest single completed lap** in this heat, in microseconds, or + * `None` if they completed no timed lap. Computed from the run's lap durations + * **independently of the win condition** (so e.g. a [`WinCondition::FirstToLaps`] + * placement still carries a real best-lap, not the win metric), with thrown-out laps + * excluded. Cross-heat rankings use it to break ties — round-robin breaks pilots equal + * on points by their faster best lap. Defaults to `None` so older snapshots that + * predate the field still deserialise. + */ +best_lap_micros: number | null, /** * Whether this competitor was **disqualified** by an adjudication * ([`gridfpv_events::Penalty::Disqualify`] via diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs index a09f74d..aaff07b 100644 --- a/crates/engine/src/round_robin.rs +++ b/crates/engine/src/round_robin.rs @@ -258,6 +258,10 @@ impl Generator for RoundRobin { // ranking. The accumulated score is "more is better", so we negate it into a // smaller-is-better rank key for `rank_by`. let mut totals: BTreeMap = BTreeMap::new(); + // Per competitor, the **fastest single lap** they flew across every heat (the min of each + // heat's `best_lap_micros`, ignoring heats where they completed no lap). Breaks ties on + // points/laps: equal score → the faster best lap ranks higher. + let mut best_lap: BTreeMap = BTreeMap::new(); for competitor in &self.field { totals.entry(competitor.clone()).or_insert(0); } @@ -280,14 +284,27 @@ impl Generator for RoundRobin { *totals .entry(place.competitor.competitor.clone()) .or_insert(0) += gain; + // Track the competitor's fastest lap across heats (smaller is better). A heat with + // no completed lap contributes nothing. + if let Some(lap) = place.best_lap_micros { + best_lap + .entry(place.competitor.competitor.clone()) + .and_modify(|m| *m = (*m).min(lap)) + .or_insert(lap); + } } } - // Rank key: negate the accumulated score so more points / laps = smaller key = - // better; `rank_by` adds the competitor ref as the final, total tie-break. - let rows: Vec<(CompetitorRef, i64)> = totals + // Rank key: negate the accumulated score so more points / laps = smaller key = better, then + // break ties by fastest lap ascending (smaller = better; a pilot with no lap sorts after one + // with a lap via `i64::MAX`). `rank_by` adds the competitor ref as the final, total + // tie-break. + let rows: Vec<(CompetitorRef, (i64, i64))> = totals .into_iter() - .map(|(competitor, score)| (competitor, -score)) + .map(|(competitor, score)| { + let fastest = best_lap.get(&competitor).copied().unwrap_or(i64::MAX); + (competitor, (-score, fastest)) + }) .collect(); rank_by(rows) } @@ -335,6 +352,28 @@ mod tests { } } + /// Build a `HeatResult` from `(name, position, laps, best_lap_micros)` rows — the best-lap + /// variant of [`result`], for exercising the fastest-lap tie-break. + fn result_bl(rows: &[(&str, u32, u32, Option)]) -> HeatResult { + HeatResult { + places: rows + .iter() + .map(|(name, position, laps, best)| Placement { + competitor: CompetitorKey { + adapter: AdapterId(ADAPTER.into()), + competitor: cref(name), + }, + position: *position, + laps: *laps, + metric: Metric::LastLapAt(None), + best_lap_micros: *best, + ..Default::default() + }) + .collect(), + ..Default::default() + } + } + /// The set of competitor names a heat plan lines up. fn lineup_names(plan: &HeatPlan) -> Vec { plan.lineup.iter().map(|c| c.0.clone()).collect() @@ -624,6 +663,74 @@ mod tests { assert_eq!(ranking[0].position, 1); } + #[test] + fn ranking_breaks_equal_points_by_fastest_single_lap() { + // X and Y finish equal on points across the round-robin (each wins one heat, loses one), + // but X flew a faster fastest-lap, so X ranks ahead of Y. + let g = RoundRobin::new(field(&["X", "Y", "Z", "W"]), 2, 2, RrMetric::Points); + let completed = vec![ + // Round 1: X beats Z; Y beats W. X best lap 2.0s, Y best lap 2.5s. + CompletedHeat::new( + "rr-r1-h1", + result_bl(&[("X", 1, 5, Some(2_000_000)), ("Z", 2, 4, Some(2_400_000))]), + ), + CompletedHeat::new( + "rr-r1-h2", + result_bl(&[("Y", 1, 5, Some(2_500_000)), ("W", 2, 4, Some(2_600_000))]), + ), + // Round 2: X and Y both lose their heats, so each ends on the same points. X still has + // the faster fastest-lap (2.1s vs Y's 2.5s). + CompletedHeat::new( + "rr-r2-h1", + result_bl(&[("Z", 1, 5, Some(2_300_000)), ("X", 2, 4, Some(2_100_000))]), + ), + CompletedHeat::new( + "rr-r2-h2", + result_bl(&[("W", 1, 5, Some(2_700_000)), ("Y", 2, 4, Some(2_500_000))]), + ), + ]; + // Every pilot ends on 3 points (each won one heat, lost one), so the standing is decided + // entirely by fastest lap: X 2.0s, Z 2.3s, Y 2.5s, W 2.6s. + let ranking = g.ranking(&completed); + assert_eq!(names(&ranking), vec!["X", "Z", "Y", "W"]); + let x = ranking + .iter() + .position(|e| e.competitor == cref("X")) + .unwrap(); + let y = ranking + .iter() + .position(|e| e.competitor == cref("Y")) + .unwrap(); + assert!( + x < y, + "X (faster best lap) must rank ahead of Y on the same points" + ); + // The fastest-lap tie-break splits the equal-points pilots into distinct positions. + assert!(ranking[x].position < ranking[y].position); + } + + #[test] + fn ranking_fastest_lap_tiebreak_sorts_a_no_lap_pilot_behind_one_with_a_lap() { + // M and N tie on points; M completed a lap, N never did (no best_lap). M ranks ahead. + let g = RoundRobin::new(field(&["M", "N"]), 1, 2, RrMetric::Points); + let completed = vec![CompletedHeat::new( + "rr-r1-h1", + // Genuine points tie: both share position 1 (e.g. an all-tied heat), M has a lap. + result_bl(&[("M", 1, 1, Some(3_000_000)), ("N", 1, 0, None)]), + )]; + let ranking = g.ranking(&completed); + assert_eq!( + ranking[0].competitor, + cref("M"), + "the pilot with a lap ranks first" + ); + assert_eq!( + ranking[1].competitor, + cref("N"), + "the no-lap pilot sorts behind" + ); + } + #[test] fn ranking_by_total_laps_sums_across_heats() { let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::TotalLaps); diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs index c40bcfb..401e07e 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -104,6 +104,16 @@ pub struct Placement { pub laps: u32, /// The condition-specific deciding metric for this competitor. pub metric: Metric, + /// The competitor's **fastest single completed lap** in this heat, in microseconds, or + /// `None` if they completed no timed lap. Computed from the run's lap durations + /// **independently of the win condition** (so e.g. a [`WinCondition::FirstToLaps`] + /// placement still carries a real best-lap, not the win metric), with thrown-out laps + /// excluded. Cross-heat rankings use it to break ties — round-robin breaks pilots equal + /// on points by their faster best lap. Defaults to `None` so older snapshots that + /// predate the field still deserialise. + #[serde(default)] + #[ts(type = "number | null")] + pub best_lap_micros: Option, /// Whether this competitor was **disqualified** by an adjudication /// ([`gridfpv_events::Penalty::Disqualify`] via /// [`gridfpv_events::Event::PenaltyApplied`]). A disqualified competitor is ranked @@ -136,6 +146,7 @@ impl Default for Placement { position: 0, laps: 0, metric: Metric::LastLapAt(None), + best_lap_micros: None, disqualified: false, } } @@ -578,19 +589,20 @@ pub fn score_events( /// distinct group's position skipping past them (1, 2, 2, 4). DQ'd placements carry /// [`Placement::disqualified`] `= true`. fn rank( - rows: Vec<(CompetitorKey, u32, Metric, K)>, + rows: Vec<(CompetitorKey, u32, Metric, Option, K)>, adj: &Adjudications, ) -> HeatResult { // Pair each row with its DQ flag; the flag is the *primary* sort key (false < true), // so every disqualified competitor ranks after every non-disqualified one. - let mut rows: Vec<(bool, CompetitorKey, u32, Metric, K)> = rows + let mut rows: Vec<(bool, CompetitorKey, u32, Metric, Option, K)> = rows .into_iter() - .map(|(competitor, laps, metric, key)| { + .map(|(competitor, laps, metric, best_lap_micros, key)| { ( adj.is_dq(&competitor.competitor), competitor, laps, metric, + best_lap_micros, key, ) }) @@ -599,7 +611,7 @@ fn rank( // deterministic tie-break so two rows are never "equal" for sorting purposes. rows.sort_by(|a, b| { a.0.cmp(&b.0) - .then_with(|| a.4.cmp(&b.4)) + .then_with(|| a.5.cmp(&b.5)) .then_with(|| a.1.cmp(&b.1)) }); @@ -608,7 +620,9 @@ fn rank( // plus the rank key. A DQ'd competitor never shares a position with a non-DQ'd one. let mut prev_group: Option<(bool, K)> = None; let mut position = 0u32; - for (index, (disqualified, competitor, laps, metric, key)) in rows.into_iter().enumerate() { + for (index, (disqualified, competitor, laps, metric, best_lap_micros, key)) in + rows.into_iter().enumerate() + { let group = (disqualified, key); if prev_group.as_ref() != Some(&group) { position = (index as u32) + 1; @@ -619,6 +633,7 @@ fn rank( position, laps, metric, + best_lap_micros, disqualified, }); } @@ -628,6 +643,15 @@ fn rank( } } +/// The competitor's **fastest single completed lap** (smallest duration, µs) among `laps`, or +/// `None` if they completed none. Win-condition-independent: every per-condition scorer reports +/// it on its [`Placement`] so a cross-heat ranking can break ties on best lap regardless of how +/// the heat was won. `laps` are the **counted** laps (thrown-out laps already excluded), so a +/// thrown-out lap can never be a competitor's best lap. +fn fastest_lap_micros(laps: &[&ScoredLap]) -> Option { + laps.iter().map(|lap| lap.duration_micros).min() +} + /// Timed: count laps whose completing pass is strictly before the cutoff, rank by /// count desc then earlier last-counted-lap completion. /// @@ -648,8 +672,12 @@ fn score_timed( // HARD cutoff: strictly-before. A lap completing exactly at the cutoff // (or after) does not count — no finishing the in-progress lap. Thrown-out laps are // already excluded by `counted` before the cutoff filter. - let counted: Vec<&ScoredLap> = run - .counted(adj) + let all_counted = run.counted(adj); + // Best single lap is win-condition-independent: a lap the competitor actually flew is + // a real lap even if it landed outside the timed window, so it is taken over every + // counted lap, not just the windowed ones. + let best_lap = fastest_lap_micros(&all_counted); + let counted: Vec<&ScoredLap> = all_counted .into_iter() .filter(|lap| lap.at.micros < cutoff) .collect(); @@ -666,7 +694,13 @@ fn score_timed( .map(|t| t.micros.saturating_add(added)) .unwrap_or(i64::MAX), ); - (run.competitor, count, Metric::LastLapAt(last_at), key) + ( + run.competitor, + count, + Metric::LastLapAt(last_at), + best_lap, + key, + ) }) .collect(); rank(rows, adj) @@ -685,6 +719,8 @@ fn score_first_to_laps(runs: Vec, n: u32, adj: &Adjudications) -> HeatResul // reacher below `n`, exactly as if the lap had not been flown for scoring purposes. let laps = run.counted(adj); let count = laps.len() as u32; + // Best single lap, independent of the first-to-N win metric (which is a reach-time). + let best_lap = fastest_lap_micros(&laps); // `n` laps means the n-th completed lap (1-based) — index n-1. let reached_at = if n >= 1 && count >= n { Some(laps[(n - 1) as usize].at) @@ -707,7 +743,13 @@ fn score_first_to_laps(runs: Vec, n: u32, adj: &Adjudications) -> HeatResul .unwrap_or(i64::MAX), ), }; - (run.competitor, count, Metric::ReachedAt(reached_at), key) + ( + run.competitor, + count, + Metric::ReachedAt(reached_at), + best_lap, + key, + ) }) .collect(); rank(rows, adj) @@ -749,6 +791,8 @@ fn score_best_lap(runs: Vec, adj: &Adjudications) -> HeatResult { run.competitor, count, Metric::BestLapMicros(best_micros), + // The win metric here *is* the fastest single lap, so report it directly. + best_micros, key, ) }) @@ -771,6 +815,8 @@ fn score_best_consecutive(runs: Vec, n: u32, adj: &Adjudications) -> HeatRe // of consecutiveness (it is excluded, not skipped-over), matching the lap-count model. let laps = run.counted(adj); let count = laps.len() as u32; + // Best single lap, independent of the consecutive-sum win metric. + let best_lap = fastest_lap_micros(&laps); // Slide an `n`-wide window over the laps; pick the smallest sum, tie- // broken by the earlier window-end (last lap's completion time). let best = laps @@ -793,6 +839,7 @@ fn score_best_consecutive(runs: Vec, n: u32, adj: &Adjudications) -> HeatRe run.competitor, count, Metric::BestConsecutiveMicros(best_sum), + best_lap, key, ) }) @@ -990,6 +1037,53 @@ mod tests { assert_eq!(place(&r, "C").metric, Metric::ReachedAt(None)); } + // --- best_lap_micros (win-condition-independent fastest single lap) ------ + + #[test] + fn first_to_laps_placement_carries_fastest_single_lap_not_the_win_metric() { + // A's laps: 3s, 2s, 4s ⇒ fastest single lap 2s. The win metric is ReachedAt (a time), + // so best_lap_micros must be the 2s lap duration, independent of the win condition. + let passes = run("A", &[0, 3_000_000, 5_000_000, 9_000_000]); + let r = score(&passes, WinCondition::FirstToLaps { n: 3 }, start()); + assert_eq!(place(&r, "A").best_lap_micros, Some(2_000_000)); + // And the win metric is still the reach-time, not the best lap. + assert_eq!( + place(&r, "A").metric, + Metric::ReachedAt(Some(SourceTime::from_micros(9_000_000))) + ); + } + + #[test] + fn timed_placement_carries_fastest_single_lap_even_outside_the_window() { + // Window 10s. A's laps complete at 5s (dur 5s) and 12s (dur 7s); the 2nd lands past the + // cutoff so it does not COUNT, but it is a real flown lap. Fastest single lap is still the + // 5s one. (Here the windowed lap is also the fastest, but the point is best lap is taken + // over every counted lap, not just the windowed ones.) + let passes = run("A", &[0, 5_000_000, 12_000_000]); + let r = score( + &passes, + WinCondition::Timed { + window_micros: 10_000_000, + }, + start(), + ); + assert_eq!(place(&r, "A").laps, 1, "only the windowed lap counts"); + assert_eq!( + place(&r, "A").best_lap_micros, + Some(5_000_000), + "best lap is the fastest of every flown lap" + ); + } + + #[test] + fn placement_with_no_completed_lap_has_no_best_lap() { + // Z has a single pass (no completed lap) ⇒ best_lap_micros is None under any condition. + let passes = run("Z", &[1_000_000]); + let r = score(&passes, WinCondition::FirstToLaps { n: 1 }, start()); + assert_eq!(place(&r, "Z").best_lap_micros, None); + assert_eq!(place(&r, "Z").laps, 0); + } + // --- BestLap ------------------------------------------------------------ #[test] diff --git a/crates/engine/tests/double_elim_live.rs b/crates/engine/tests/double_elim_live.rs index 02bb2ad..9c94473 100644 --- a/crates/engine/tests/double_elim_live.rs +++ b/crates/engine/tests/double_elim_live.rs @@ -150,6 +150,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + best_lap_micros: place.best_lap_micros, disqualified: place.disqualified, } } diff --git a/crates/engine/tests/full_event_live.rs b/crates/engine/tests/full_event_live.rs index 703c1f0..5de2bf6 100644 --- a/crates/engine/tests/full_event_live.rs +++ b/crates/engine/tests/full_event_live.rs @@ -70,6 +70,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + best_lap_micros: place.best_lap_micros, disqualified: place.disqualified, } } diff --git a/crates/engine/tests/multi_main_live.rs b/crates/engine/tests/multi_main_live.rs index 9e6fa5e..a0b67f7 100644 --- a/crates/engine/tests/multi_main_live.rs +++ b/crates/engine/tests/multi_main_live.rs @@ -69,6 +69,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + best_lap_micros: place.best_lap_micros, disqualified: place.disqualified, } } diff --git a/crates/engine/tests/single_elim_live.rs b/crates/engine/tests/single_elim_live.rs index 59256a6..79e558e 100644 --- a/crates/engine/tests/single_elim_live.rs +++ b/crates/engine/tests/single_elim_live.rs @@ -167,6 +167,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement { position: place.position, laps: place.laps, metric: place.metric, + best_lap_micros: place.best_lap_micros, disqualified: place.disqualified, } } diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index ea46a15..5c343ad 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -443,7 +443,10 @@ competitor: { adapter: '', competitor: ref }, position: i + 1, laps: p.laps_completed, - metric: { BestLapMicros: p.last_lap_micros ?? null } + metric: { BestLapMicros: p.last_lap_micros ?? null }, + // No per-pilot fastest lap in the live progress stream; this provisional board does not + // tie-break on it, so leave it absent. + best_lap_micros: null }; }) }; diff --git a/frontend/apps/rd-console/tests/brackets.test.ts b/frontend/apps/rd-console/tests/brackets.test.ts index a088590..3c5fc57 100644 --- a/frontend/apps/rd-console/tests/brackets.test.ts +++ b/frontend/apps/rd-console/tests/brackets.test.ts @@ -193,7 +193,8 @@ describe('chaseWinTally — counts race winners, finds the champion', () => { competitor: { adapter: 'rh-1', competitor }, position, laps: 3, - metric: { BestLapMicros: 1 } + metric: { BestLapMicros: 1 }, + best_lap_micros: 1 })) }; } diff --git a/frontend/apps/rd-console/tests/fixtures.ts b/frontend/apps/rd-console/tests/fixtures.ts index 7428772..f42b3cd 100644 --- a/frontend/apps/rd-console/tests/fixtures.ts +++ b/frontend/apps/rd-console/tests/fixtures.ts @@ -32,13 +32,15 @@ export const heatResult: HeatResult = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_250_000 } + metric: { BestLapMicros: 41_250_000 }, + best_lap_micros: 41_250_000 }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 42_100_000 } + metric: { BestLapMicros: 42_100_000 }, + best_lap_micros: 42_100_000 } ] }; @@ -134,13 +136,15 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_000_000 } + metric: { BestLapMicros: 41_000_000 }, + best_lap_micros: 41_000_000 }, { competitor: { adapter: 'rh-1', competitor: 'DANA' }, position: 2, laps: 3, - metric: { BestLapMicros: 45_000_000 } + metric: { BestLapMicros: 45_000_000 }, + best_lap_micros: 45_000_000 } ] } @@ -153,13 +157,15 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 1, laps: 3, - metric: { BestLapMicros: 42_000_000 } + metric: { BestLapMicros: 42_000_000 }, + best_lap_micros: 42_000_000 }, { competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, position: 2, laps: 3, - metric: { BestLapMicros: 46_000_000 } + metric: { BestLapMicros: 46_000_000 }, + best_lap_micros: 46_000_000 } ] } @@ -172,13 +178,15 @@ export const eventOutcome: EventOutcome = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 40_000_000 } + metric: { BestLapMicros: 40_000_000 }, + best_lap_micros: 40_000_000 }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 41_500_000 } + metric: { BestLapMicros: 41_500_000 }, + best_lap_micros: 41_500_000 } ] } diff --git a/frontend/apps/rd-console/tests/results.test.ts b/frontend/apps/rd-console/tests/results.test.ts index 7fe46e6..7671995 100644 --- a/frontend/apps/rd-console/tests/results.test.ts +++ b/frontend/apps/rd-console/tests/results.test.ts @@ -12,7 +12,8 @@ function chaseHeat(id: string, rows: [string, number][]): CompletedHeat { competitor: { adapter: 'rh-1', competitor }, position, laps: 3, - metric: { BestLapMicros: 1 } + metric: { BestLapMicros: 1 }, + best_lap_micros: 1 })) } }; diff --git a/frontend/apps/rd-console/tests/roundRobin.test.ts b/frontend/apps/rd-console/tests/roundRobin.test.ts index 3f6a100..94dfb24 100644 --- a/frontend/apps/rd-console/tests/roundRobin.test.ts +++ b/frontend/apps/rd-console/tests/roundRobin.test.ts @@ -15,7 +15,8 @@ function result(rows: [string, number, number?][]): HeatResult { competitor: { adapter: 'rh-1', competitor }, position, laps: 3, - metric: { BestLapMicros: bestLap ?? null } + metric: { BestLapMicros: bestLap ?? null }, + best_lap_micros: bestLap ?? null })) }; } diff --git a/frontend/packages/components/tests/fixtures.ts b/frontend/packages/components/tests/fixtures.ts index 416168d..f30052d 100644 --- a/frontend/packages/components/tests/fixtures.ts +++ b/frontend/packages/components/tests/fixtures.ts @@ -20,19 +20,22 @@ export const heatResult: HeatResult = { competitor: { adapter: 'rh-1', competitor: 'ALICE' }, position: 1, laps: 3, - metric: { BestLapMicros: 41_250_000 } + metric: { BestLapMicros: 41_250_000 }, + best_lap_micros: 41_250_000 }, { competitor: { adapter: 'rh-1', competitor: 'BOB' }, position: 2, laps: 3, - metric: { BestLapMicros: 42_100_000 } + metric: { BestLapMicros: 42_100_000 }, + best_lap_micros: 42_100_000 }, { competitor: { adapter: 'rh-1', competitor: 'CARMEN' }, position: 3, laps: 2, - metric: { BestLapMicros: null } + metric: { BestLapMicros: null }, + best_lap_micros: null } ] };