diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts index 2da75b526..447e3df63 100644 --- a/app/features/scanner-ingest/core/Matches.test.ts +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -33,18 +33,12 @@ function testMatch(partial: Partial = {}): ScannerMatch { lobby: "PRIVATE", mode: "SZ", stage: 0, - matchScores: null, + matchScores: [100, 52], replayCode: null, cast: false, teams: [ - { - score: 100, - players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)), - }, - { - score: 52, - players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)), - }, + { players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) }, + { players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) }, ], winner: 0, pov: null, @@ -103,10 +97,8 @@ describe("isSameMatch", () => { }); const b = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO", - teams: [ - { score: null, players: [] }, - { score: null, players: [] }, - ], + matchScores: null, + teams: [{ players: [] }, { players: [] }], winner: null, }); expect(Matches.isSameMatch(a, b)).toBe(true); @@ -128,10 +120,8 @@ describe("isSameMatch", () => { const a = testMatch({ playedAt: 1_700_000_000_000 }); const b = testMatch({ playedAt: 1_700_000_000_000 + 5 * 60 * 1000, - teams: [ - { score: null, players: [] }, - { score: null, players: [] }, - ], + matchScores: null, + teams: [{ players: [] }, { players: [] }], winner: null, }); expect(Matches.isSameMatch(a, b)).toBe(true); @@ -175,15 +165,10 @@ describe("isSameMatch", () => { const minimap = testMatch({ winner: null, lobby: null, + matchScores: null, teams: [ - { - score: null, - players: WEAPONS.slice(0, 4).map((w) => player(null, w)), - }, - { - score: null, - players: WEAPONS.slice(4).map((w) => player(null, w)), - }, + { players: WEAPONS.slice(0, 4).map((w) => player(null, w)) }, + { players: WEAPONS.slice(4).map((w) => player(null, w)) }, ], }); expect(Matches.isSameMatch(testMatch(), minimap)).toBe(true); @@ -191,15 +176,14 @@ describe("isSameMatch", () => { it("unrelated matches are not the same", () => { const other = testMatch({ + matchScores: [88, 12], teams: [ { - score: 88, players: ["a", "b", "c", "d"].map((n, i) => player(n, (100 + 10 * i) as MainWeaponId), ), }, { - score: 12, players: ["e", "f", "g", "h"].map((n, i) => player(n, (200 + 10 * i) as MainWeaponId), ), @@ -237,12 +221,12 @@ describe("mergeMatches", () => { it("aligns a side-swapped incoming match before merging", () => { const existing = testMatch({ winner: null, matchScores: null }); const incoming = sideSwapped( - testMatch({ matchScores: [3, 1], playedAt: 1_700_000_000_000 }), + testMatch({ matchScores: [84, 71], playedAt: 1_700_000_000_000 }), ); const { merged } = Matches.mergeMatches(existing, incoming); expect(merged.winner).toBe(0); - expect(merged.matchScores).toEqual([3, 1]); + expect(merged.matchScores).toEqual([84, 71]); expect(merged.teams[0].players.map((p) => p.name)).toEqual( NAMES.slice(0, 4), ); @@ -269,10 +253,8 @@ describe("mergeMatches", () => { it("fills empty teams from the incoming match", () => { const existing = testMatch({ winner: null, - teams: [ - { score: null, players: [] }, - { score: null, players: [] }, - ], + matchScores: null, + teams: [{ players: [] }, { players: [] }], replayCode: "RABC-DEFG-HIJK-LMNO", }); const incoming = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); @@ -283,6 +265,6 @@ describe("mergeMatches", () => { expect(merged.teams[0].players.map((p) => p.name)).toEqual( NAMES.slice(0, 4), ); - expect(merged.teams[0].score).toBe(100); + expect(merged.matchScores).toEqual([100, 52]); }); }); diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index 4ab69649c..b67e07000 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -144,7 +144,6 @@ export function normalizeInGameName(name: string): string { function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam { return { - score: team.score, players: team.players.map(canonicalPlayer), }; } @@ -319,7 +318,7 @@ function mergeTeam( players.push(entry.player); } - return { score: existing.score ?? incoming.score, players }; + return { players }; } function mergePlayer( diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 6671de103..a079174b4 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -73,13 +73,10 @@ function testMatch({ lobby, mode, stage, - matchScores: null, + matchScores: [100, 52], replayCode: null, cast: false, - teams: [ - { score: 100, players: players.slice(0, 4) }, - { score: 52, players: players.slice(4) }, - ], + teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }], winner: 0, pov: povIndex === null @@ -94,6 +91,10 @@ function swapSides(match: ScannerMatch): ScannerMatch { ...match, teams: [match.teams[1], match.teams[0]], winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], pov: match.pov === null ? null diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index b74ff87b6..e27ff803c 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -71,6 +71,7 @@ export interface IngestedScoreboardPlayer { } export interface IngestedScoreboardData { + /** game scores [winner, loser] (0-100; a knockout's winner is 100) */ scores: [number | null, number | null]; /** in scoreboard order: rows 0-3 winning team, rows 4-7 losing team */ players: IngestedScoreboardPlayer[]; @@ -198,6 +199,7 @@ interface WinnerFirstView { lobby: ScannerLobby | null; mode: ModeShort | null; stage: StageId | null; + /** game scores [winner, loser] from the match's "Score:" banner */ scores: [number | null, number | null]; players: WinnerFirstPlayer[]; povIndex: number | null; @@ -233,7 +235,10 @@ function winnerFirstView( lobby: match.lobby, mode: match.mode, stage: match.stage, - scores: [winners.score, losers.score], + scores: [ + match.matchScores?.[match.winner] ?? null, + match.matchScores?.[match.winner === 0 ? 1 : 0] ?? null, + ], players: [...winners.players, ...losers.players].map((player) => ({ ...player, name: player.name ?? "", diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 145044772..34bfaa6e5 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -61,9 +61,6 @@ export function MatchCard({ modeLabel(match.mode), ingestable ? null : lobbyLabel(match.lobby), match.startsAt !== null ? timeRangeLabel(match) : null, - match.matchScores - ? `set ${match.matchScores[0] ?? "?"}–${match.matchScores[1] ?? "?"}` - : null, match.replayCode, match.cast ? "cast" : null, ] @@ -150,20 +147,19 @@ function timeRangeLabel(match: ScannerMatch): string { } function Score({ match }: { match: ScannerMatch }) { - const [alpha, bravo] = match.teams; - if (alpha.score === null && bravo.score === null) return null; + if (match.matchScores === null) return null; + const [alpha, bravo] = match.matchScores; + // a 100 only happens on a knockout — show it the way players say it + const label = (score: number | null) => + score === 100 ? "KO" : (score ?? "?"); // scoreboard-sourced matches list the winners first const winnerKnown = match.winner !== null; return (
- - {alpha.score ?? "?"} - + {label(alpha)} - - {bravo.score ?? "?"} - + {label(bravo)}
); } diff --git a/app/features/scanner/components/ScoreboardCard.tsx b/app/features/scanner/components/ScoreboardCard.tsx index bae2878c6..f5d488b24 100644 --- a/app/features/scanner/components/ScoreboardCard.tsx +++ b/app/features/scanner/components/ScoreboardCard.tsx @@ -55,9 +55,8 @@ function PlayerRows({ } function teamHeading(label: string, data: CardData, side: 0 | 1): string { - const score = `${label} — ${data.scores[side] ?? "?"}p`; - const match = data.matchScores?.[side]; - return match != null ? `${score} · score ${match}` : score; + const score = data.matchScores[side]; + return score !== null ? `${label} — ${score}` : label; } export function ScoreboardCard(props: { diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index f40cfc6c9..3a7e0762b 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -160,9 +160,10 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { rect(minimap.enemyCrossRoi(cy), "#e879f9"); } for (const roi of [ - minimap.GATE_CLOSE_BRIGHT, + ...minimap.GATE_CLOSE_X_BRIGHT, minimap.GATE_SPAWN_BRIGHT, ...minimap.GATE_CLOSE_DARK_PROBES, + ...minimap.GATE_CLOSE_X_DARK, ...minimap.GATE_SPAWN_DARK_PROBES, ]) { rect(roi, "#facc15"); @@ -197,6 +198,7 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { rect(sb.gateDarkProbe(cy), "#facc15"); } for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa"); + for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c"); for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15"); rect(sb.HEADER_LOBBY_BAND, "#34d399"); rect(sb.HEADER_LINE_BAND, "#34d399"); @@ -426,7 +428,9 @@ export function ScreenshotPage() { {" · "}confidence{" "} {((result.events[0].confidence ?? 0) * 100).toFixed(1)}% · scores{" "} - {JSON.stringify((result.events[0].data as CardData).scores)} + {JSON.stringify( + (result.events[0].data as CardData).matchScores, + )} {" · "} {(() => { const data = result.events[0].data as CardData; diff --git a/app/features/scanner/components/events-csv.ts b/app/features/scanner/components/events-csv.ts index 136dedb41..72efb4cda 100644 --- a/app/features/scanner/components/events-csv.ts +++ b/app/features/scanner/components/events-csv.ts @@ -204,8 +204,8 @@ function eventCells(event: CsvEvent): Cell[] { lobbyLabel(d.lobby), modeLabel(d.mode), stageLabel(d.stage), - d.scores[0], - d.scores[1], + d.matchScores[0], + d.matchScores[1], d.povIndex === null ? "" : d.players[d.povIndex]?.name, "", "", diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index 1d8cef22e..ddaa21a9a 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -26,9 +26,7 @@ import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels"; /** Scoreboard data with the replay extras present when the event has them. */ export type CardData = ScoreboardData & - Partial< - Pick - >; + Partial>; /** Any detector's event payload that can prefill a fixture. */ export type FixtureData = @@ -160,10 +158,7 @@ function buildExpectedJson( }), ...(card.timestamp != null && { timestamp: card.timestamp }), ...(card.replayCode != null && { replayCode: card.replayCode }), - scores: card.scores, - ...(card.matchScores !== undefined && { - matchScores: card.matchScores, - }), + matchScores: card.matchScores, players: card.players.map((p) => ({ name: p.name, weaponId: p.weaponId, diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 612f32adb..ca97a0c85 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -59,8 +59,9 @@ import { enemySubTileRoi, enemyWeaponRoi, GATE_BRIGHT_MIN_MAX, - GATE_CLOSE_BRIGHT, GATE_CLOSE_DARK_PROBES, + GATE_CLOSE_X_BRIGHT, + GATE_CLOSE_X_DARK, GATE_DARK_MAX_MEAN, GATE_SPAWN_BRIGHT, GATE_SPAWN_DARK_PROBES, @@ -224,8 +225,12 @@ export function createMinimapDetector( function overlayGate(gray: Mat): GateResult { return probeGate( gray, - [...GATE_CLOSE_DARK_PROBES, ...GATE_SPAWN_DARK_PROBES], - [GATE_CLOSE_BRIGHT, GATE_SPAWN_BRIGHT], + [ + ...GATE_CLOSE_DARK_PROBES, + ...GATE_CLOSE_X_DARK, + ...GATE_SPAWN_DARK_PROBES, + ], + [...GATE_CLOSE_X_BRIGHT, GATE_SPAWN_BRIGHT], ); } diff --git a/app/features/scanner/core/detectors/minimap/rois.ts b/app/features/scanner/core/detectors/minimap/rois.ts index 54eef557e..e588ce5ca 100644 --- a/app/features/scanner/core/detectors/minimap/rois.ts +++ b/app/features/scanner/core/detectors/minimap/rois.ts @@ -200,8 +200,29 @@ export const SPECIAL_READY_WEAPON_MIN_SCORE = 0.42; */ export const PRESENCE_MIN_LAPLACIAN = 8; -/** Gate probes (overlay variant): close-button disc + Spawn Point pill shapes. */ -export const GATE_CLOSE_BRIGHT: Roi = { x: 90, y: 90, w: 16, h: 16 }; +/** + * Gate probes (overlay variant): close-button disc + Spawn Point pill + * shapes. The close button is a white ✕ glyph on a dark disc (center + * (110,92), ±4px across fixtures), traced like the spectator X: bright + * probes on the crossing point and the four stroke arms, dark probes in + * the cardinal gaps between them. A mere bright blob at the same spot — + * the results screen's splat counter puts white digits exactly there — + * lights the gaps or misses the arms and fails. + */ +export const GATE_CLOSE_X_BRIGHT: readonly Roi[] = [ + { x: 104, y: 88, w: 12, h: 10 }, + { x: 88, y: 72, w: 8, h: 10 }, + { x: 124, y: 72, w: 8, h: 10 }, + { x: 88, y: 108, w: 8, h: 10 }, + { x: 124, y: 108, w: 8, h: 10 }, +]; +export const GATE_CLOSE_X_DARK: readonly Roi[] = [ + { x: 104, y: 62, w: 12, h: 8 }, + { x: 104, y: 116, w: 12, h: 8 }, + { x: 76, y: 88, w: 8, h: 10 }, + { x: 136, y: 88, w: 8, h: 10 }, +]; +/** Dark ring/background just outside the close-button disc. */ export const GATE_CLOSE_DARK_PROBES: readonly Roi[] = [ { x: 88, y: 50, w: 20, h: 14 }, { x: 88, y: 132, w: 20, h: 14 }, diff --git a/app/features/scanner/core/detectors/scoreboard-replay/index.ts b/app/features/scanner/core/detectors/scoreboard-replay/index.ts index a7f7a6afa..840073750 100644 --- a/app/features/scanner/core/detectors/scoreboard-replay/index.ts +++ b/app/features/scanner/core/detectors/scoreboard-replay/index.ts @@ -5,9 +5,9 @@ * * Layout differs from the live scoreboard: the two team panels sit side by * side and the replay owner's team may be on either side, so the - * VICTORY/DEFEAT panel tags are read to keep `players`/`scores` ordered - * winners-first like the live event. Field parsing reuses the scoreboard - * helpers with glyph sets rescaled to this screen's text sizes. + * VICTORY/DEFEAT panel tags are read to keep `players`/`matchScores` + * ordered winners-first like the live event. Field parsing reuses the + * scoreboard helpers with glyph sets rescaled to this screen's text sizes. */ import { getCV, type Mat } from "../../cv"; import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs"; @@ -20,6 +20,11 @@ import { } from "../../image"; import { RESULT_TAG_ENTRIES } from "../../localized"; import { closestBy } from "../../text"; +import { + FULL_COUNT_TEAM_SCORE, + KO_MATCH_SCORE, + MATCH_SCORE_MIN_CONF, +} from "../scoreboard/banner"; import { type ParsedNumber, parseNumber } from "../scoreboard/digits"; import type { ScoreboardData, @@ -71,11 +76,6 @@ export interface ScoreboardReplayData extends ScoreboardData { timestamp: string | null; /** "XXXX-XXXX-XXXX-XXXX" */ replayCode: string | null; - /** - * the colored "Score:" banner values, [winner, loser] like `scores`; - * a knockout's winner reports 100 (the burst hides the real banner) - */ - matchScores: [number | null, number | null]; } export const SCOREBOARD_REPLAY_EVENT_TYPE = "ScoreboardReplay"; @@ -90,25 +90,6 @@ const REPLAY_INK_THRESHOLD = 90; */ const BANNER_BIN_THRESHOLD = 190; -/** - * On a knockout the winner's "Score:" banner is replaced by the localized - * "KNOCKOUT!" burst, whose letters overlap the score ROI and weakly match - * digit templates (an O reads as a ~0.42 zero; real digits score 0.9+). - * Reads below this floor are discarded rather than trusted as a score — the - * knockout that put the burst there is recovered from the team count below. - */ -const MATCH_SCORE_MIN_CONF = 0.6; - -/** The count a knockout wins at — the burst hides it, so it is never read. */ -const KO_MATCH_SCORE = 100; - -/** - * The team box prints the count times five ("440 p" alongside a 88 banner), - * so a knockout's full 100 count shows as 500 — a total only a knockout - * reaches, which is what separates a burst-covered banner from an unread one. - */ -const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5; - /** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */ type PanelResult = "VICTORY" | "DEFEAT"; const RESULT_MIN_SCORE = 0.6; @@ -268,6 +249,9 @@ export function createScoreboardReplayDetector( rows.push(row.debug); } + // The panel's point total is read only to recognize a knockout below + // (the count times five: only a knockout's full count reaches 500); + // it is never emitted as a score. let teamScore: ParsedNumber | null = null; if (teamDigits) { const crop = cropRoi(gray, teamScoreRoi(dx)); @@ -284,7 +268,10 @@ export function createScoreboardReplayDetector( matchScore = parseNumber(crop, matchScoreDigits, { binThreshold: BANNER_BIN_THRESHOLD, }); - if (matchScore.confidence < MATCH_SCORE_MIN_CONF) { + if ( + matchScore.confidence < MATCH_SCORE_MIN_CONF || + (matchScore.value !== null && matchScore.value > KO_MATCH_SCORE) + ) { matchScore = { ...matchScore, value: null }; } crop.delete(); @@ -402,10 +389,6 @@ export function createScoreboardReplayDetector( stage: header?.stage ?? null, timestamp: header?.timestamp ?? null, replayCode: code?.code ?? null, - scores: [ - winner.teamScore?.value ?? null, - loser.teamScore?.value ?? null, - ], matchScores: [ winner.matchScore?.value ?? null, loser.matchScore?.value ?? null, diff --git a/app/features/scanner/core/detectors/scoreboard/banner.ts b/app/features/scanner/core/detectors/scoreboard/banner.ts new file mode 100644 index 000000000..7fa47c656 --- /dev/null +++ b/app/features/scanner/core/detectors/scoreboard/banner.ts @@ -0,0 +1,214 @@ +/** + * "Score:" banner parsing for the results screens. Each side of the colored + * banner shows one team's game score (0-100) as white BlitzBold digits after + * a localized label — some languages render no label at all, so the digits' + * x position is not fixed. A knockout replaces the winning side's value with + * the localized KNOCKOUT! burst, whose letters only weakly match digit + * templates (real digits score 0.9+); the knockout itself is recognized from + * the winner's team total instead (the box prints the count times five, and + * only a knockout's full 100 count reaches 500). The score value bounces as + * it lands, so a frame may catch the digits settled or mid-pop — callers + * parse at both sizes and this module keeps the more confident read. + */ +import { getCV, type Mat } from "../../cv"; +import { + type GlyphSet, + type RecognizedChar, + type RecognizedText, + recognizeText, +} from "../../glyphs"; +import { copyRoi, type Roi } from "../../image"; + +/** The count a knockout wins at — the burst hides it, so it is never read. */ +export const KO_MATCH_SCORE = 100; + +/** + * The team box prints the count times five ("440 p" alongside a 88 banner), + * so a knockout's full 100 count shows as 500 — a total only a knockout + * reaches, which is what separates a burst-covered banner from an unread one. + */ +export const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5; + +/** + * Replay-screen reads below this floor are discarded rather than trusted as + * a score — burst/label letters overlapping a score ROI match digit + * templates at ~0.4 there. + */ +export const MATCH_SCORE_MIN_CONF = 0.6; + +/** + * Char floor for the live banner's trailing-digit run. KNOCKOUT! letters + * have matched digit templates at up to 0.62 (the ko-hagglefish fixture's + * "07"), while genuine banner digits score 0.79+ across every fixture — + * including 720p upscales. + */ +const DIGIT_MIN_CONF = 0.75; + +/** White banner digits on saturated team color (yellow ink grays at ~170). */ +const BANNER_SCORE_BIN_THRESHOLD = 205; + +/** + * Digits of one number nearly touch; anything further apart than this + * fraction of a digit width is the label (or an unreadable glyph) ending + * the run. + */ +const DIGIT_GAP_MAX_RATIO = 0.55; + +/** + * A score digit spans the set's full height; the label's lowercase letters + * top out ~0.75 of it, so they cannot pass as digits even when their shapes + * correlate. + */ +const DIGIT_MIN_HEIGHT_RATIO = 0.82; + +/** + * The banner's bright wave-crest highlight can dip into the score line as a + * wide ~12px-tall streak whose columns merge into the digits' segments and + * ruin their ink extents. Every real digit is at least ~26px tall, so ink + * components shorter than this are wiped before recognition. + */ +const MIN_COMPONENT_HEIGHT = 20; + +export interface BannerScoreRead { + /** the side's score; null when unread (knockout burst, blur, label-only) */ + value: number | null; + /** min glyph score across the accepted digits (0 when none) */ + confidence: number; + /** best raw reading, for debugging */ + reading: string; +} + +const EMPTY_READ: BannerScoreRead = { value: null, confidence: 0, reading: "" }; + +/** + * Reads one banner side's score from `roi`: recognizes with each digit set + * (one per on-screen text size) and keeps the best read. The score is the + * trailing run of full-height, confidently-matched digits — everything the + * localized label or the KNOCKOUT! burst leaves in the ROI fails at least + * one of those tests. + */ +export function parseBannerScore( + gray: Mat, + roi: Roi, + sets: readonly GlyphSet[], +): BannerScoreRead { + const crop = copyRoi(gray, roi); + clearShortBlobs(crop); + let best = EMPTY_READ; + for (const set of sets) { + const raw = recognizeText(crop, set, { + binThreshold: BANNER_SCORE_BIN_THRESHOLD, + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + }); + const read = trailingScore(raw, set); + if ( + (read.value !== null) === (best.value !== null) + ? read.confidence > best.confidence + : read.value !== null + ) { + best = read; + } + } + crop.delete(); + return best; +} + +/** + * Winner-first score pair from the two banner sides. A confirmed knockout + * (winner team total = 500) dominates: the winner reports the full count no + * matter what was read off the burst-covered side, and the loser is the + * more confident read (genuine digits score well clear of burst letters + * that survive the floor). Without a knockout, ranked scores never tie, so + * when both sides read the higher value is the winner's; one unreadable + * side cannot be attributed to a team, so nothing is reported. + */ +export function resolveMatchScores({ + left, + right, + knockout, +}: { + left: BannerScoreRead; + right: BannerScoreRead; + knockout: boolean; +}): [number | null, number | null] { + if (knockout) { + const loser = + left.value !== null && right.value !== null + ? left.confidence >= right.confidence + ? left + : right + : left.value !== null + ? left + : right; + return [KO_MATCH_SCORE, loser.value]; + } + if (left.value !== null && right.value !== null) { + return left.value >= right.value + ? [left.value, right.value] + : [right.value, left.value]; + } + return [null, null]; +} + +/** Zero out ink components shorter than any digit (see MIN_COMPONENT_HEIGHT). */ +function clearShortBlobs(band: Mat): void { + const cv = getCV(); + const bin = new cv.Mat(); + cv.threshold(band, bin, BANNER_SCORE_BIN_THRESHOLD, 255, cv.THRESH_BINARY); + const labels = new cv.Mat(); + const stats = new cv.Mat(); + const centroids = new cv.Mat(); + const count = cv.connectedComponentsWithStats( + bin, + labels, + stats, + centroids, + 8, + ); + bin.delete(); + centroids.delete(); + const s = stats.data32S; + const short = new Uint8Array(count); + for (let i = 1; i < count; i++) { + short[i] = s[i * 5 + cv.CC_STAT_HEIGHT]! < MIN_COMPONENT_HEIGHT ? 1 : 0; + } + stats.delete(); + const lab = labels.data32S; + const out = band.data; + for (let i = 0; i < out.length; i++) { + if (short[lab[i]!]!) out[i] = 0; + } + labels.delete(); +} + +function trailingScore(raw: RecognizedText, set: GlyphSet): BannerScoreRead { + const maxGap = Math.max(4, Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO)); + const isScoreDigit = (c: RecognizedChar) => + c.score >= DIGIT_MIN_CONF && + c.y1 - c.y0 >= set.height * DIGIT_MIN_HEIGHT_RATIO; + + const run: RecognizedChar[] = []; + let i = raw.chars.length - 1; + for (; i >= 0; i--) { + const c = raw.chars[i]!; + if (!isScoreDigit(c)) break; + if (run.length > 0 && run[0]!.x0 - c.x1 > maxGap) break; + run.unshift(c); + } + if (run.length === 0) return { ...EMPTY_READ, reading: raw.text }; + // A further digit left of the run means an unreadable glyph split the + // number (turf war percentages read "48", ".", "7") — the tail is not + // the score. + if (i >= 0 && isScoreDigit(raw.chars[i]!)) { + return { ...EMPTY_READ, reading: raw.text }; + } + + const value = Number.parseInt(run.map((c) => c.char).join(""), 10); + if (value > KO_MATCH_SCORE) return { ...EMPTY_READ, reading: raw.text }; + return { + value, + confidence: Math.min(...run.map((c) => c.score)), + reading: raw.text, + }; +} diff --git a/app/features/scanner/core/detectors/scoreboard/index.ts b/app/features/scanner/core/detectors/scoreboard/index.ts index e33f6f7dd..831dd3a63 100644 --- a/app/features/scanner/core/detectors/scoreboard/index.ts +++ b/app/features/scanner/core/detectors/scoreboard/index.ts @@ -12,6 +12,11 @@ import { getCV, type Mat } from "../../cv"; import { type GlyphSet, scaleGlyphSet } from "../../glyphs"; import { cropRoi, maxBrightness, meanBrightness } from "../../image"; import type { DetectedEvent, Detector, GateResult } from "../types"; +import { + FULL_COUNT_TEAM_SCORE, + parseBannerScore, + resolveMatchScores, +} from "./banner"; import { parseNumber } from "./digits"; import { type ParsedHeader, parseHeader } from "./header"; import { findPovIndex } from "./pov"; @@ -21,6 +26,8 @@ import { GATE_PANEL_PROBES, GATE_TEXT_MIN_MAX, gateDarkProbe, + MATCH_SCORE_DIGIT_HEIGHTS, + MATCH_SCORE_ROIS, nameRoi, PAINT_DIGIT_HEIGHT, paintRoi, @@ -53,8 +60,11 @@ export interface ScoreboardData { lobby: ScannerLobby | null; mode: ModeShort | null; stage: StageId | null; - /** [winning team total, losing team total] as shown ("500 p") */ - scores: [number | null, number | null]; + /** + * the "Score:" banner game scores, [winner, loser]; a knockout's winner + * reports 100 (the burst hides the real banner) + */ + matchScores: [number | null, number | null]; /** 8 players: rows 0-3 winning team, rows 4-7 losing team */ players: ScoreboardPlayer[]; /** @@ -178,6 +188,11 @@ export function createScoreboardDetector( TEAM_DIGIT_HEIGHT / PAINT_DIGIT_HEIGHT, ) : null); + const matchScoreSets = teamDigits + ? MATCH_SCORE_DIGIT_HEIGHTS.map((height) => + scaleGlyphSet(teamDigits, height / teamDigits.height), + ) + : []; function gate(frame: Mat): GateResult { const gray = new cv.Mat(); @@ -248,19 +263,32 @@ export function createScoreboardDetector( confidences.push(header.confidence); } - const scores: [number | null, number | null] = [null, null]; - const teamScoreConf: number[] = []; + // The winner's team total is read only to recognize a knockout: the + // box prints the count times five, and only a knockout's full 100 + // count reaches 500 (the banner value it would confirm is hidden + // under the KNOCKOUT! burst). + let knockout = false; + let winnerTotalConf = 0; if (teamDigits) { - for (const i of [0, 1] as const) { - // Team totals sit on the team-colored box (light swirl pattern), - // so binarize more aggressively than on the black pills. - const crop = cropRoi(gray, TEAM_SCORE_ROIS[i]); - const parsed = parseNumber(crop, teamDigits, { binThreshold: 175 }); - crop.delete(); - scores[i] = parsed.value; - teamScoreConf.push(parsed.confidence); - confidences.push(parsed.confidence); - } + // The total sits on the team-colored box (light swirl pattern), + // so binarize more aggressively than on the black pills. + const crop = cropRoi(gray, TEAM_SCORE_ROIS[0]); + const winnerTotal = parseNumber(crop, teamDigits, { + binThreshold: 175, + }); + crop.delete(); + knockout = winnerTotal.value === FULL_COUNT_TEAM_SCORE; + winnerTotalConf = winnerTotal.confidence; + } + + let matchScores: [number | null, number | null] = [null, null]; + let bannerDebug: object | undefined; + if (matchScoreSets.length > 0) { + const left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets); + const right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets); + matchScores = resolveMatchScores({ left, right, knockout }); + confidences.push(left.confidence, right.confidence); + bannerDebug = { left, right, knockout, winnerTotalConf }; } gray.delete(); @@ -280,11 +308,15 @@ export function createScoreboardDetector( lobby: header?.lobby ?? null, mode: header?.mode ?? null, stage: header?.stage ?? null, - scores, + matchScores, players, povIndex, }, - debug: { rows: rowDebug, teamScoreConf, header: header?.debug }, + debug: { + rows: rowDebug, + matchScore: bannerDebug, + header: header?.debug, + }, }, ]; } diff --git a/app/features/scanner/core/detectors/scoreboard/rois.ts b/app/features/scanner/core/detectors/scoreboard/rois.ts index 694c7e184..baa8e6689 100644 --- a/app/features/scanner/core/detectors/scoreboard/rois.ts +++ b/app/features/scanner/core/detectors/scoreboard/rois.ts @@ -69,12 +69,32 @@ export function povArrowRoi(cy: number): Roi { return { x: 930, y: cy - 32, w: 58, h: 56 }; } -/** Team score totals ("500 p"), larger digits, right-aligned ending at x=1658. */ +/** + * Team point totals ("500 p"), larger digits, right-aligned ending at + * x=1658. Only read to recognize a knockout (winner total 500) — the + * totals are the count times five, not the match score. + */ export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [ { x: 1530, y: 330, w: 132, h: 44 }, { x: 1530, y: 684, w: 132, h: 44 }, ]; +/** + * The two sides of the colored "Score:" banner above the team boxes. The + * left side's text is left-aligned from x~946 (digits directly there when + * the language renders no label), the right side's right-aligned ending at + * x~1691 — or x~1712 mid-pop, the digits bounce between ~28px and ~39px as + * the value lands (MATCH_SCORE_DIGIT_HEIGHTS). Boxes cover both sizes plus + * the label overlap the trailing-digit parse tolerates. + */ +export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [ + { x: 935, y: 231, w: 230, h: 50 }, + { x: 1600, y: 231, w: 125, h: 50 }, +]; + +/** Banner score digit sizes: settled, and the landing bounce's peak. */ +export const MATCH_SCORE_DIGIT_HEIGHTS = [28, 39] as const; + /** * Gate probe: the strip between the paint "p" suffix (ends 1424) and the * first stat "x" (starts 1477) is always empty pill background (near-black). diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index ad939d4e1..e15f75b34 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -237,7 +237,9 @@ function toBuiltMatch( lobby: board?.lobby ?? null, mode: board?.mode ?? start?.mode ?? null, stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes), - matchScores: replay?.matchScores ?? null, + matchScores: board?.matchScores.some((score) => score !== null) + ? board.matchScores + : null, replayCode: replay?.replayCode ?? null, cast: open.minimaps.some((event) => (event.data as MinimapData).spectator), teams: board @@ -303,8 +305,8 @@ function teamsFromScoreboard( }; }); return [ - { score: board.scores[0], players: players.slice(0, PLAYERS_PER_TEAM) }, - { score: board.scores[1], players: players.slice(PLAYERS_PER_TEAM) }, + { players: players.slice(0, PLAYERS_PER_TEAM) }, + { players: players.slice(PLAYERS_PER_TEAM) }, ]; } @@ -328,8 +330,8 @@ function teamsFromMinimaps( }); return [ - { score: null, players: withAbilities.slice(0, alpha.length) }, - { score: null, players: withAbilities.slice(alpha.length) }, + { players: withAbilities.slice(0, alpha.length) }, + { players: withAbilities.slice(alpha.length) }, ]; } diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index 9861bba99..098891818 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -26,8 +26,6 @@ export interface ScannerMatchPlayer { } export interface ScannerMatchTeam { - /** the team's game score; null when no results screen was seen */ - score: number | null; /** up to 4 players; slots the scan never saw are absent */ players: ScannerMatchPlayer[]; } @@ -45,7 +43,11 @@ export interface ScannerMatch { lobby: ScannerLobby | null; mode: ModeShort | null; stage: StageId | null; - /** set score from the replay screen, in `teams` order */ + /** + * the "Score:" banner game scores (0-100) in `teams` order, from a + * results/replay screen; a knockout's winner reports 100. Null when no + * such screen was seen. + */ matchScores: [number | null, number | null] | null; replayCode: string | null; /** spectator/casted footage (the 8-player spectator map screen was seen) */ diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index 5930ccd1c..936a30423 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -71,9 +71,8 @@ interface ExpectedScoreboard { timestamp?: string; /** ScoreboardReplay only */ replayCode?: string; - scores?: [number, number]; - /** ScoreboardReplay only: the "Score:" banner values */ - matchScores?: [number, number]; + /** the "Score:" banner game scores, [winner, loser]; KO winner = 100 */ + matchScores?: [number | null, number | null]; players?: ExpectedPlayer[]; /** index of the yellow POV-arrow row in `players`; null = no arrow */ povIndex?: number | null; diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 1fbf1086f..b5d54b537 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -48,7 +48,6 @@ const scannerMatchPlayerSchema = z.object({ }); const scannerMatchTeamSchema = z.object({ - score: z.number().nullable(), players: z.array(scannerMatchPlayerSchema).max(4), }); diff --git a/app/features/scanner/tests/banner.test.ts b/app/features/scanner/tests/banner.test.ts new file mode 100644 index 000000000..4bf35d798 --- /dev/null +++ b/app/features/scanner/tests/banner.test.ts @@ -0,0 +1,68 @@ +/** + * resolveMatchScores is pure — the parse side of banner.ts is covered by + * the scoreboard fixture suite, this pins the winner/loser resolution. + */ + +import assert from "node:assert/strict"; +import { + type BannerScoreRead, + resolveMatchScores, +} from "../core/detectors/scoreboard/banner"; +import test from "./node-test-compat"; + +function read(value: number | null, confidence = 0.9): BannerScoreRead { + return { value, confidence, reading: value === null ? "" : String(value) }; +} + +test("both sides read: higher value is the winner's, either way around", () => { + assert.deepEqual( + resolveMatchScores({ left: read(71), right: read(88), knockout: false }), + [88, 71], + ); + assert.deepEqual( + resolveMatchScores({ left: read(88), right: read(71), knockout: false }), + [88, 71], + ); +}); + +test("a knockout reports the full count over anything read off the burst", () => { + // burst letters surviving the floor read as a junk low-confidence value + assert.deepEqual( + resolveMatchScores({ + left: read(0, 0.88), + right: read(7, 0.62), + knockout: true, + }), + [100, 0], + ); +}); + +test("a knockout's loser is the side that read", () => { + assert.deepEqual( + resolveMatchScores({ left: read(null), right: read(0), knockout: true }), + [100, 0], + ); + assert.deepEqual( + resolveMatchScores({ left: read(0), right: read(null), knockout: true }), + [100, 0], + ); + assert.deepEqual( + resolveMatchScores({ left: read(null), right: read(null), knockout: true }), + [100, null], + ); +}); + +test("one unreadable side without a knockout reports nothing", () => { + assert.deepEqual( + resolveMatchScores({ left: read(71), right: read(null), knockout: false }), + [null, null], + ); + assert.deepEqual( + resolveMatchScores({ + left: read(null), + right: read(null), + knockout: false, + }), + [null, null], + ); +}); diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/anarchy-open-rainmaker-knockout-museum/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/anarchy-open-rainmaker-knockout-museum/expected.json index c92f3229e..6f5cf8235 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/anarchy-open-rainmaker-knockout-museum/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/anarchy-open-rainmaker-knockout-museum/expected.json @@ -6,10 +6,6 @@ "stage": 6, "timestamp": "6/7/2026 19:04", "replayCode": "RWYQ-4X37-M1EL-EGGQ", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/low-res-2/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/low-res-2/expected.json index 82983afa8..6aa1fb6ab 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/low-res-2/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/low-res-2/expected.json @@ -6,10 +6,6 @@ "stage": 17, "timestamp": "3/7/2026 21:22", "replayCode": "R0M1-UCVK-F5AK-VUL2", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/low-res/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/low-res/expected.json index 5ffb3a6ee..1dd2cce52 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/low-res/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/low-res/expected.json @@ -5,10 +5,6 @@ "mode": "SZ", "stage": 2, "replayCode": "RVRM-XXEL-0573-Q45U", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-crableg-capital/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-crableg-capital/expected.json index 19e62cce2..c2795adf8 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-crableg-capital/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-crableg-capital/expected.json @@ -6,10 +6,6 @@ "stage": 18, "timestamp": "9/7/2026 18:13", "replayCode": "R1V4-PAHW-GGM2-PD9S", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-hagglefish/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-hagglefish/expected.json index cafe44298..c882f9a3d 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-hagglefish/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-hagglefish/expected.json @@ -6,10 +6,6 @@ "stage": 2, "timestamp": "3/7/2026 22:28", "replayCode": "R6KE-D064-3CXD-XVKL", - "scores": [ - 440, - 385 - ], "matchScores": [ 88, 77 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-marlin/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-marlin/expected.json index 1af9f98e3..73ed80e99 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-marlin/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/private-battle-splat-zones-marlin/expected.json @@ -5,10 +5,6 @@ "mode": "SZ", "stage": 22, "timestamp": "9/7/2026 18:17", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1404/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1404/expected.json index 2382af15c..4d571f2ef 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1404/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1404/expected.json @@ -6,10 +6,6 @@ "stage": 13, "timestamp": "9/7/2026 14:04", "replayCode": "R80B-00DL-WF4X-V3CA", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1411/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1411/expected.json index d225ecdd4..6f12c753e 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1411/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1411/expected.json @@ -4,10 +4,6 @@ "lobby": "X", "mode": "RM", "replayCode": "R17L-PFKK-C1Q2-143L", - "scores": [ - 265, - 135 - ], "matchScores": [ 53, 27 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1416/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1416/expected.json index 35393a063..e968f3b82 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1416/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1416/expected.json @@ -6,10 +6,6 @@ "stage": 13, "timestamp": "9/7/2026 14:16", "replayCode": "RUH3-3NEF-F5FY-PAJL", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, null diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1422/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1422/expected.json index eed2d4f8e..80a08f16b 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1422/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-brinewater-1422/expected.json @@ -6,10 +6,6 @@ "stage": 13, "timestamp": "9/7/2026 14:22", "replayCode": "RGX0-QWEM-E3VS-AVG6", - "scores": [ - 500, - 0 - ], "matchScores": [ 100, 0 diff --git a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-urchin-1434/expected.json b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-urchin-1434/expected.json index ed518c3fa..3385f50c4 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-urchin-1434/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-replay/x-battle-rainmaker-urchin-1434/expected.json @@ -6,10 +6,6 @@ "stage": 24, "timestamp": "9/7/2026 14:34", "replayCode": "RUCT-5HNH-XWDC-J51U", - "scores": [ - 160, - 135 - ], "matchScores": [ 32, 7 diff --git a/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json b/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json index a7f4ad769..2aa63ea22 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json @@ -4,9 +4,9 @@ "lobby": "PRIVATE", "mode": "CB", "stage": 0, - "scores": [ - 480, - 370 + "matchScores": [ + 96, + 74 ], "povIndex": 0, "players": [ diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json new file mode 100644 index 000000000..a6de364a8 --- /dev/null +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json @@ -0,0 +1,86 @@ +{ + "event": "Scoreboard", + "options": { + "skipFields": [ + "players.3.name" + ], + "notes": "KNOCKOUT! burst on the RIGHT banner side (loser's Score: 0 on the left) - regression guard for the burst reading as a 7-0 score. players.3 truth is AHOOUUUUUU but reads AHOOLUUUUU (U misread as L). players.6 name uses dotless i (as parsed) - verify against source if it ever fails." + }, + "data": { + "lobby": "PRIVATE", + "mode": "SZ", + "stage": 2, + "stageLabel": "Hagglefish Market", + "matchScores": [ + 100, + 0 + ], + "povIndex": 7, + "players": [ + { + "name": "nwrm", + "weaponId": 50, + "paint": 381, + "ka": 8, + "d": 2, + "s": 0 + }, + { + "name": "slord", + "weaponId": 20, + "paint": 613, + "ka": 4, + "d": 1, + "s": 2 + }, + { + "name": "Olise", + "weaponId": 2070, + "paint": 456, + "ka": 3, + "d": 1, + "s": 2 + }, + { + "name": "AHOOUUUUUU", + "weaponId": 1015, + "paint": 287, + "ka": 1, + "d": 2, + "s": 1 + }, + { + "name": "★Bønk★•PE•", + "weaponId": 1001, + "paint": 327, + "ka": 3, + "d": 3, + "s": 1 + }, + { + "name": "Enamel", + "weaponId": 2070, + "paint": 408, + "ka": 1, + "d": 1, + "s": 2 + }, + { + "name": "Rιppιng_H", + "weaponId": 8001, + "paint": 270, + "ka": 3, + "d": 3, + "s": 1 + }, + { + "name": "Sendou", + "weaponId": 10, + "paint": 354, + "ka": 0, + "d": 3, + "s": 1 + } + ] + } +} diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/frame.png b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/frame.png new file mode 100644 index 000000000..0b1d0b137 Binary files /dev/null and b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/frame.png differ diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera-2/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera-2/expected.json index e7cc60996..106383957 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera-2/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera-2/expected.json @@ -4,9 +4,9 @@ "lobby": "PRIVATE", "mode": "SZ", "stage": 3, - "scores": [ - 425, - 410 + "matchScores": [ + 85, + 82 ], "povIndex": 3, "players": [ diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera/expected.json index 8848e6699..78d8b557a 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-kera/expected.json @@ -4,8 +4,8 @@ "lobby": "PRIVATE", "mode": "SZ", "stage": 6, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "povIndex": 3, diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-triton-cup-154-sendou/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-triton-cup-154-sendou/expected.json index d400892ec..d2fed7b91 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/private-battle-triton-cup-154-sendou/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-triton-cup-154-sendou/expected.json @@ -4,8 +4,8 @@ "lobby": "PRIVATE", "mode": "SZ", "stage": 17, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "povIndex": 2, diff --git a/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json b/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json index 7e23285d6..98c6a9589 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json @@ -4,8 +4,8 @@ "lobby": "PRIVATE", "mode": "SZ", "stage": 4, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "players": [ diff --git a/app/features/scanner/tests/fixtures/scoreboard/special-symbols/expected.json b/app/features/scanner/tests/fixtures/scoreboard/special-symbols/expected.json index 7c15a97c1..00707e68e 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/special-symbols/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/special-symbols/expected.json @@ -4,8 +4,8 @@ "lobby": "SERIES", "mode": "SZ", "stage": 8, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "players": [ diff --git a/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json b/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json index 9790552ee..3960bd54e 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json @@ -9,8 +9,8 @@ "lobby": "PRIVATE", "mode": "SZ", "stage": 13, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "povIndex": 1, diff --git a/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko-capture/expected.json b/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko-capture/expected.json index 2b71a0b4b..bbe61405a 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko-capture/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko-capture/expected.json @@ -4,8 +4,8 @@ "lobby": "X", "mode": "SZ", "stage": 0, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "povIndex": 0, diff --git a/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko/expected.json b/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko/expected.json index 2b71a0b4b..bbe61405a 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/xbattle-splat-zones-ko/expected.json @@ -4,8 +4,8 @@ "lobby": "X", "mode": "SZ", "stage": 0, - "scores": [ - 500, + "matchScores": [ + 100, 0 ], "povIndex": 0, diff --git a/app/features/scanner/tests/match-builder.test.ts b/app/features/scanner/tests/match-builder.test.ts index 70283f503..df19118a1 100644 --- a/app/features/scanner/tests/match-builder.test.ts +++ b/app/features/scanner/tests/match-builder.test.ts @@ -58,7 +58,7 @@ function scoreboard( lobby, mode, stage, - scores: [100, 47], + matchScores: [100, 47], players: weapons.map((weaponId, i) => ({ name: NAMES[i] ?? `p${i}`, weaponId, @@ -84,7 +84,7 @@ function replayScoreboard( ...base, timestamp, replayCode, - matchScores: [3, 1], + matchScores: [88, 71], }; return { type: "ScoreboardReplay", t, confidence: 0.9, data }; } @@ -162,10 +162,7 @@ test("scoreboard fields land on the match", () => { assert.equal(match.stage, 0); assert.equal(match.winner, 0); assert.deepEqual(match.pov, { team: 0, index: 0 }); - assert.deepEqual( - match.teams.map((team) => team.score), - [100, 47], - ); + assert.deepEqual(match.matchScores, [100, 47]); assert.deepEqual( match.teams.map((team) => team.players.map((p) => p.name)), [ @@ -175,7 +172,6 @@ test("scoreboard fields land on the match", () => { ); assert.deepEqual(weapons(match), ALL); assert.equal(match.cast, false); - assert.equal(match.matchScores, null); assert.equal(match.replayCode, null); }); @@ -340,7 +336,7 @@ test("a replay scoreboard supplies replay code, set score and recording time", ( const built = buildScannerMatches([event]); const match = built[0]!.match; assert.equal(match.replayCode, "RABC-DEFG-HIJK-LMNO"); - assert.deepEqual(match.matchScores, [3, 1]); + assert.deepEqual(match.matchScores, [88, 71]); assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime()); }); @@ -357,10 +353,7 @@ test("a minimap-only match has no playedAt and no winner", () => { assert.equal(match.playedAt, null); assert.equal(match.winner, null); assert.equal(match.pov, null); - assert.deepEqual( - match.teams.map((team) => team.score), - [null, null], - ); + assert.equal(match.matchScores, null); }); test("a spectator map's minimaps become one cast match: weapons + stage from the minimap, mode unread", () => { diff --git a/app/features/scanner/tests/scoreboard.test.ts b/app/features/scanner/tests/scoreboard.test.ts index 1531a82f4..861abd759 100644 --- a/app/features/scanner/tests/scoreboard.test.ts +++ b/app/features/scanner/tests/scoreboard.test.ts @@ -49,8 +49,15 @@ for (const fixture of fixtures) { const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; const expected = fixture.expected.data ?? {}; - await t.test("scores", { skip: skip(fixture, "scores") }, () => { - assert.deepEqual(event.data.scores, expected.scores); + await t.test("matchScores", { skip: skip(fixture, "matchScores") }, () => { + const dbg = event.debug?.matchScore as + | { left?: { reading?: string }; right?: { reading?: string } } + | undefined; + assert.deepEqual( + event.data.matchScores, + expected.matchScores, + `matchScores mismatch (readings: "${dbg?.left?.reading}" / "${dbg?.right?.reading}")`, + ); }); await t.test( diff --git a/app/features/scanner/tests/suites/scoreboard-replay.ts b/app/features/scanner/tests/suites/scoreboard-replay.ts index c5381519b..65296b73c 100644 --- a/app/features/scanner/tests/suites/scoreboard-replay.ts +++ b/app/features/scanner/tests/suites/scoreboard-replay.ts @@ -67,16 +67,9 @@ export async function runScoreboardReplaySuite( const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; const expected = fixture.expected.data ?? {}; - await t.test("scores", { skip: skip(fixture, "scores") }, () => { - assert.deepEqual(event.data.scores, expected.scores); - }); - await t.test( "matchScores", - { - skip: - expected.matchScores === undefined || skip(fixture, "matchScores"), - }, + { skip: skip(fixture, "matchScores") }, () => { assert.deepEqual(event.data.matchScores, expected.matchScores); }, diff --git a/scripts/scanner/report.ts b/scripts/scanner/report.ts index e51fdf2fb..3ff1b0fd9 100644 --- a/scripts/scanner/report.ts +++ b/scripts/scanner/report.ts @@ -103,7 +103,6 @@ for (const config of configs) { header: { ok: 0, total: 0 } as Tally, timestamp: { ok: 0, total: 0 } as Tally, replayCode: { ok: 0, total: 0 } as Tally, - scores: { ok: 0, total: 0 } as Tally, matchScores: { ok: 0, total: 0 } as Tally, weapons: { ok: 0, total: 0 } as Tally, names: { ok: 0, total: 0 } as Tally, @@ -144,11 +143,6 @@ for (const config of configs) { tally.replayCode.total++; if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++; } - if (expected.scores) { - tally.scores.total++; - if (JSON.stringify(event.data.scores) === JSON.stringify(expected.scores)) - tally.scores.ok++; - } if (expected.matchScores) { tally.matchScores.total++; if ( @@ -200,10 +194,7 @@ for (const config of configs) { console.info(`timestamp ${pct(tally.timestamp)}`); console.info(`replayCode ${pct(tally.replayCode)}`); } - console.info(`scores ${pct(tally.scores)}`); - if (config.event === "ScoreboardReplay") { - console.info(`matchScores ${pct(tally.matchScores)}`); - } + console.info(`matchScores ${pct(tally.matchScores)}`); console.info(`weapons ${pct(tally.weapons)}`); console.info(`names ${pct(tally.names)}`); console.info(`paint ${pct(tally.paint)}`); diff --git a/scripts/scanner/run-fixtures.ts b/scripts/scanner/run-fixtures.ts index a7c4e00e4..9cafb9222 100644 --- a/scripts/scanner/run-fixtures.ts +++ b/scripts/scanner/run-fixtures.ts @@ -35,7 +35,7 @@ for (const fixture of fixtures) { console.info(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`); for (const event of events) { console.info(`event confidence=${event.confidence.toFixed(3)}`); - console.info(`scores: ${JSON.stringify(event.data.scores)}`); + console.info(`matchScores: ${JSON.stringify(event.data.matchScores)}`); const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; event.data.players.forEach((p, i) => { const dbg = rows[i];