From ba55e0f41db12c69f72fdce841a25915110f5ad1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:43:21 +0300 Subject: [PATCH] Fix match dedupe --- app/features/scanner/README.md | 4 +- app/features/scanner/core/match-builder.ts | 47 ++++++++++++++----- .../scanner/tests/logic/match-builder.test.ts | 9 +++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 8eef0403d..8b85997df 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -199,7 +199,9 @@ sequenceDiagram order-free paint/K+A/deaths/specials lines), recording time within 20 min of its play time — joins that match's sources instead of forming a new one, so browsing the log after playing neither adds a card nor re-uploads - (the match was already sent). An event belongs to at most one match; deaths + (the match was already sent). Likewise a results screen read again with no + match opened since (a lost-connection dialog hid it and the detector + re-armed) joins the last match. An event belongs to at most one match; deaths reveal enemy builds (`ability-harvest.ts`), the personal results screen (`ScoreboardOwn`, seen within `OWN_RESULTS_WINDOW_SECONDS` of a closed match's scoreboard) completes the POV player's full build, and minimap diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index b5d90ba16..260be64bc 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -43,7 +43,10 @@ import { } from "./detectors/objective/strip-weapons"; import { QUICK_SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "./detectors/quick-scoreboard-battle-log/index"; import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry"; -import type { ScoreboardData } from "./detectors/scoreboard/index"; +import { + SCOREBOARD_EVENT_TYPE, + type ScoreboardData, +} from "./detectors/scoreboard/index"; import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE, type ScoreboardBattleLogData, @@ -175,7 +178,8 @@ export interface BuiltMatch { * player's build on the match whose results screen it follows. A battle * history screen showing an already built game (same scoreboard fingerprint, * recording time not contradicting it) joins that match's `sources` instead of - * forming a new one. Every input event ends up in at most one match's `sources`. + * forming a new one, as does a results screen read again with no match + * opened since. Every input event ends up in at most one match's `sources`. */ export function buildScannerMatches( events: readonly E[], @@ -212,7 +216,9 @@ export function buildScannerMatches( orphanStripWeapons = []; orphanKills = []; } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - const revisited = revisitedMatch(built, event); + const revisited = + revisitedMatch(built, event) ?? + (open ? undefined : reshownResultsMatch(built, event)); if (revisited) { // the game already has its match, and the one being played (if // any) keeps gathering events @@ -1381,15 +1387,7 @@ function revisitedMatch( const recordedAt = playedAt(event); return built.findLast((candidate) => { - const board = candidate.sources.find((source) => - SCOREBOARD_EVENT_TYPES.includes(source.type), - ); - if ( - !board || - scoreboardFingerprint(board.data as ScoreboardData) !== fingerprint - ) { - return false; - } + if (closingBoardFingerprint(candidate) !== fingerprint) return false; return ( recordedAt === null || candidate.match.playedAt === null || @@ -1399,6 +1397,31 @@ function revisitedMatch( }); } +/** + * The last match again when its results screen is read a second time with no + * match opened since: an overlay (e.g. a lost-connection dialog) hid the screen + * long enough for the detector to re-arm. + */ +function reshownResultsMatch( + built: readonly BuiltMatch[], + event: E, +): BuiltMatch | undefined { + if (event.type !== SCOREBOARD_EVENT_TYPE) return undefined; + const last = built.at(-1); + const fingerprint = scoreboardFingerprint(event.data as ScoreboardData); + if (!last || fingerprint === null) return undefined; + return closingBoardFingerprint(last) === fingerprint ? last : undefined; +} + +function closingBoardFingerprint( + built: BuiltMatch, +): string | null { + const board = built.sources.find((source) => + SCOREBOARD_EVENT_TYPES.includes(source.type), + ); + return board ? scoreboardFingerprint(board.data as ScoreboardData) : null; +} + /** * A game's identity off its board: each team's stat lines (paint, K+A, deaths, * specials) as an order-free multiset, teams order-free too (a history screen diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 39139ee3b..ef5051ef7 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -97,7 +97,7 @@ function scoreboard( players: weaponIds.map((weaponId, i) => ({ name: NAMES[i] ?? `p${i}`, weaponId, - paint: paints.length > 0 ? (paints[i] ?? null) : 1000, + paint: paints.length > 0 ? (paints[i] ?? null) : 1000 + t, ka: 10, d: 5, s: 2, @@ -816,6 +816,13 @@ test("a battle log view does not close the match still gathering events", () => ); }); +test("a results screen read again with no match opened since joins its match", () => { + const reread = scoreboard(345, { paints: GAME_PAINTS }); + const built = buildScannerMatches([...playedGame(), reread]); + assert.equal(built.length, 1); + assert.equal(built[0]!.sources.at(-1), reread); +}); + test("a results screen repeating an earlier board is a new game", () => { const built = buildScannerMatches([ ...playedGame(),