From c0d778e6cd14ff240d658709824e207aa1aedebf Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:17:16 +0300 Subject: [PATCH] Handle endgame better --- app/features/scanner/README.md | 6 +- app/features/scanner/core/match-builder.ts | 71 +++++++++++++++++-- .../scanner/tests/match-builder.test.ts | 48 ++++++++++++- 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index d63a118b5..c5158874c 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -84,7 +84,11 @@ sequenceDiagram + casted 8-player spectator variant), `objective` (ranked counter overlay: counts, penalties, holder, match timer — a mode-discriminated union with only the SZ member so far). Objective reads land on `ScannerMatch` as - progress samples anchored to the game clock; a displayed count only ever + progress samples anchored to the game clock; broadcast replay wipes re-run + an earlier moment with the counter intact, so the builder keeps only the + dominant cluster of clock-zero projections (`t + time`) and drops replay + reads outright (timerless reads follow their preceding anchored + neighbor). A displayed count only ever ticks down, so the builder keeps each side's longest non-increasing score run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a per-side team ink color (`core/ink-color.ts` — the plate fill in diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index f1c5d061d..f704fe939 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -79,6 +79,15 @@ const EARLY_END_MARGIN_SECONDS = 10; */ const MIN_TEAM_HUE_SEPARATION = 30; +/** + * A counter read whose projected clock zero (`t + time`) sits further than + * this from the match's dominant projection was taken off a broadcast + * replay of another moment, not the live game. Live projections jitter by + * a couple of seconds (wall clock and match timer both round to whole + * seconds); attested replay wipes land a minute or more away. + */ +const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10; + export interface BuiltMatch { match: ScannerMatch; /** @@ -414,7 +423,11 @@ function floorOrNull(t: number | undefined): number | null { * the left plate is the POV/alpha side for the whole game, but casted * footage reorders the plates to follow the specced player — so each read * is first oriented by its sides' team ink hues (clustered against the - * first read that saw both), making the series side-stable. A displayed + * first read that saw both), making the series side-stable. Broadcast + * replay wipes re-run an earlier moment with the counter overlay intact, + * so reads are first anchored by their projected clock zero (`t + time`), + * and only the dominant anchor cluster — the live game — survives; + * timerless reads follow their preceding anchored neighbor. A displayed * count never increases (it shows the team's best remaining), so each * side's series then keeps only its longest non-increasing run of scores — * surviving OCR blips are voided rather than charted. The whole @@ -431,10 +444,9 @@ function buildObjective( ): ScannerMatchObjective | null { if (objectives.length === 0) return null; - const clusterHues = seedClusterHues(objectives); - const oriented = withMonotonicScores( - orientByTeamColor(objectives, clusterHues), - ); + const live = withoutReplayReads(objectives); + const clusterHues = seedClusterHues(live); + const oriented = withMonotonicScores(orientByTeamColor(live, clusterHues)); const swap = board ? board.povIndex !== null @@ -569,6 +581,55 @@ function minimapTeamColors( return sides[0] === null && sides[1] === null ? null : sides; } +/** + * Drops reads taken off broadcast replay wipes: `t + time` projects the + * wall-clock moment the match timer reaches zero, which stays constant + * across a live game but lands far away when the broadcast re-runs an + * earlier moment, clock and all. Only reads near the dominant projection + * (the live series always outnumbers ~30s replay clips) are kept; a + * timerless read shares the fate of its preceding anchored neighbor (the + * following one for a timerless head), so an unreadable — or as yet + * unattested overtime — timer display never voids live reads. + */ +function withoutReplayReads( + objectives: readonly T[], +): T[] { + const anchored = objectives.flatMap((read, i) => + read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [], + ); + if (anchored.length === 0) return [...objectives]; + + const dominant = dominantAnchor(anchored.map((read) => read.anchor)); + const keptAnchored = new Map( + anchored.map(({ i, anchor }) => [ + i, + Math.abs(anchor - dominant) <= REPLAY_ANCHOR_TOLERANCE_SECONDS, + ]), + ); + + let previousKept = keptAnchored.get(anchored[0]!.i)!; + return objectives.filter((_, i) => { + previousKept = keptAnchored.get(i) ?? previousKept; + return previousKept; + }); +} + +/** The clock-zero projection supported by the most reads within tolerance. */ +function dominantAnchor(anchors: readonly number[]): number { + const sorted = anchors.toSorted((a, b) => a - b); + let best = sorted[0]!; + let bestCount = 0; + let lo = 0; + for (let hi = 0; hi < sorted.length; hi++) { + while (sorted[hi]! - sorted[lo]! > REPLAY_ANCHOR_TOLERANCE_SECONDS) lo++; + if (hi - lo + 1 > bestCount) { + bestCount = hi - lo + 1; + best = sorted[Math.floor((lo + hi) / 2)]!; + } + } + return best; +} + /** * Voids score reads that contradict SZ's countdown: per side, only the * longest non-increasing subsequence of the readable scores is kept and diff --git a/app/features/scanner/tests/match-builder.test.ts b/app/features/scanner/tests/match-builder.test.ts index 4dd19751b..cd543a665 100644 --- a/app/features/scanner/tests/match-builder.test.ts +++ b/app/features/scanner/tests/match-builder.test.ts @@ -51,10 +51,12 @@ function death( return { type: "Death", t, confidence: 0.9, data }; } +// default timer stays consistent with t (clock zero projected at 300s of +// footage) so reads register as one live game to the replay filter function objective( t: number, { - time = 215 as number | null, + time = (300 - Math.round(t)) as number | null, score = [95, 53] as [number | null, number | null], penalty = [null, null] as [number | null, number | null], control = [true, false] as [boolean, boolean], @@ -260,7 +262,7 @@ test("a losing-side pov swaps objective samples into teams order", () => { ]); assert.deepEqual(built[0]!.match.objective!.samples[0], { t: 120, - time: 215, + time: 180, score: [53, 95], penalty: [null, 4], control: [false, true], @@ -398,6 +400,48 @@ test("a transient score dip is voided against the surrounding countdown", () => assert.deepEqual(samples[1]!.penalty, [4, null]); }); +test("post-game replay wipes are dropped by their clock projection", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60, { score: [80, 6], penalty: [null, 56] }), + objective(61, { score: [78, 6], penalty: [null, 56] }), + objective(62, { score: [77, 6], penalty: [null, 56] }), + // broadcast re-runs the opening moments, clock jumped back to 4:51 + objective(90, { time: 291, score: [100, 100] }), + objective(91, { time: 290, score: [99, 100] }), + // then the closing moments again + objective(100, { time: 62, score: [6, 77], penalty: [56, null] }), + objective(101, { time: 61, score: [6, 75], penalty: [56, null] }), + scoreboard(300), + ]); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual( + samples.map((sample) => sample.t), + [60, 61, 62], + ); + assert.deepEqual(samples.at(-1)!.penalty, [null, 56]); +}); + +test("timerless reads share their live neighbor's replay-filter fate", () => { + const built = buildScannerMatches([ + mapStart(0), + // timerless head inherits from the first anchored read + objective(59, { time: null, score: [82, 6] }), + objective(60, { score: [80, 6] }), + objective(61, { score: [79, 6] }), + objective(62, { time: null, score: [78, 6] }), + // replay wipe, including a timerless read inside it + objective(90, { time: 291, score: [100, 100] }), + objective(91, { time: null, score: [99, 100] }), + objective(92, { time: 289, score: [97, 100] }), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.objective!.samples.map((sample) => sample.t), + [59, 60, 61, 62], + ); +}); + test("a stray full-count blip is voided against the surrounding countdown", () => { const built = buildScannerMatches([ mapStart(0),