diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index cb0606853..d1c8629ea 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -339,7 +339,10 @@ sequenceDiagram timeline also holds history screens to a 0.75 confidence floor (clean reads score 0.81+). 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 + re-armed) joins the last match. A no-contest results screen (the host + ended the game or a disconnect did early: DRAW tags on both team boxes, + drawn grey, and "No contest." for a score — `noContest`) discards the game + being gathered, so the replay of that map is its only card. 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/detectors/scoreboard/index.ts b/app/features/scanner/core/detectors/scoreboard/index.ts index 19bf997b2..ed1a48225 100644 --- a/app/features/scanner/core/detectors/scoreboard/index.ts +++ b/app/features/scanner/core/detectors/scoreboard/index.ts @@ -11,6 +11,7 @@ import type { ScannerLobby } from "../../../scanner-types"; import type { Mat } from "../../cv"; import { type GlyphSet, scaleGlyphSet } from "../../glyphs"; import { + copyRoi, cropRoi, frameGray, frameRgb, @@ -36,6 +37,9 @@ import { gateDarkProbe, MATCH_SCORE_DIGIT_HEIGHTS, MATCH_SCORE_ROIS, + NO_CONTEST_GREY_VALUE_RANGE, + NO_CONTEST_INK_MIN_SATURATION, + NO_CONTEST_MAX_INK_SHARE, nameRoi, PAINT_DIGIT_HEIGHT, paintRoi, @@ -44,6 +48,7 @@ import { ROW_CENTERS, specialIconRoi, statRoi, + TEAM_BOX_BAND_ROIS, TEAM_DIGIT_HEIGHT, TEAM_SCORE_ROIS, weaponRoi, @@ -70,6 +75,11 @@ export interface ScoreboardData { stage: StageId | null; /** "Score:" banner scores, [winner, loser]; a knockout's winner reports 100 (the burst hides the banner) */ matchScores: [number | null, number | null]; + /** + * Results screen only: the game ended before it was decided (host ended it, + * early disconnect) — DRAW on both grey team boxes, "No contest." for a score + */ + noContest?: boolean; /** 8 players: rows 0-3 winning team, rows 4-7 losing team */ players: ScoreboardPlayer[]; /** index into `players` of the yellow-arrow row; null when none (spectator footage) */ @@ -320,6 +330,7 @@ export function createScoreboardDetector( mode: header?.mode ?? null, stage: header?.stage ?? null, matchScores, + noContest: isNoContest(rgb), players, povIndex, }, @@ -342,3 +353,28 @@ export function createScoreboardDetector( parseSteps, }; } + +function isNoContest(rgb: Mat): boolean { + return TEAM_BOX_BAND_ROIS.every((roi) => { + const band = copyRoi(rgb, roi); + const pixels = band.data as Uint8Array; + const channels = band.channels(); + let ink = 0; + let valueSum = 0; + for (let i = 0; i < pixels.length; i += channels) { + const value = Math.max(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!); + const spread = + value - Math.min(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!); + if (value > 0 && spread >= NO_CONTEST_INK_MIN_SATURATION * value) ink++; + valueSum += value; + } + band.delete(); + const count = pixels.length / channels; + const meanValue = valueSum / count; + return ( + ink / count <= NO_CONTEST_MAX_INK_SHARE && + meanValue >= NO_CONTEST_GREY_VALUE_RANGE[0] && + meanValue <= NO_CONTEST_GREY_VALUE_RANGE[1] + ); + }); +} diff --git a/app/features/scanner/core/detectors/scoreboard/rois.ts b/app/features/scanner/core/detectors/scoreboard/rois.ts index 0c5774d04..2f21d1bfa 100644 --- a/app/features/scanner/core/detectors/scoreboard/rois.ts +++ b/app/features/scanner/core/detectors/scoreboard/rois.ts @@ -52,6 +52,25 @@ export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [ { x: 1530, y: 684, w: 132, h: 44 }, ]; +/** + * Team box header bands between the result tag and the total: team ink on a + * decided game; on a no contest (the host ended it, an early disconnect) both + * boxes are flat grey under DRAW tags, with no totals. + */ +export const TEAM_BOX_BAND_ROIS: readonly [Roi, Roi] = [ + { x: 1260, y: 330, w: 240, h: 34 }, + { x: 1260, y: 684, w: 240, h: 34 }, +]; + +/** + * No-contest box band: saturated share at most this (inked boxes read ~1.0, + * the grey ones 0), at a mean value in the box grey's range (~103) rather than + * the darker panel a box still sliding in would leave. + */ +export const NO_CONTEST_MAX_INK_SHARE = 0.05; +export const NO_CONTEST_INK_MIN_SATURATION = 0.35; +export const NO_CONTEST_GREY_VALUE_RANGE: readonly [number, number] = [80, 140]; + /** * "Score:" banner sides: left from x~946, right ending x~1691 (x~1712 mid-pop); digits bounce * 28-39px as they land. diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index eb08e619b..be218e2d4 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -253,8 +253,10 @@ export type MatchBuildCache = WeakMap< * match of its own and the game being gathered stays open. A history screen * with its stage unread and no built match forms none. A map intro right * after a replay-browser entry of its stage and mode is that replay played - * back: its gameplay joins the entry's match. Every input event ends up in at - * most one match's `sources`. + * back: its gameplay joins the entry's match. A results screen of a no contest + * (DRAW tags: the host ended the game, an early disconnect) discards the game + * being gathered: it has no result, and its replay forms the real match. Every + * input event ends up in at most one match's `sources`. * * `unbacked` also emits, flagged, the stretches with kill reads no scoreboard, * minimap or played counter backed (a results screen missed, the map never @@ -342,6 +344,12 @@ export function buildScannerMatches( vote(open.stageVotes, (board!.data as ScoreboardData).stage); } } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + if ((event.data as ScoreboardData).noContest) { + // ended undecided: the game being gathered has no result to keep + open = null; + dropOrphans(); + continue; + } if (HISTORY_SCOREBOARD_EVENT_TYPES.includes(event.type)) { lastHistoryScreen = event; } diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index 74e270693..e57d25b6d 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -91,6 +91,8 @@ interface ExpectedScoreboard { replayCode?: string; /** the "Score:" banner game scores, [winner, loser]; KO winner = 100 */ matchScores?: [number | null, number | null]; + /** Scoreboard only: the game ended before it was decided (DRAW tags, "No contest."); absent = false */ + noContest?: boolean; players?: ExpectedPlayer[]; /** index of the yellow POV-arrow row in `players`; null = no arrow */ povIndex?: number | null; diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/expected.json new file mode 100644 index 000000000..0f9b9043a --- /dev/null +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/expected.json @@ -0,0 +1,25 @@ +{ + "event": "Scoreboard", + "data": { + "lobby": "PRIVATE", + "mode": "CB", + "stage": 8, + "stageLabel": "Inkblot Art Academy", + "matchScores": [null, null], + "noContest": true, + "povIndex": 1, + "players": [ + { "name": "Mini Kagu", "paint": 4, "ka": 0, "d": 0, "s": 0 }, + { "name": "Sendou", "weaponId": 4001, "paint": 0, "ka": 0, "d": 0, "s": 0 }, + { "paint": 41, "ka": 0, "d": 0, "s": 0 }, + { "name": "Moonluck?", "paint": 0, "ka": 0, "d": 0, "s": 0 }, + { "paint": 0, "ka": 0, "d": 0, "s": 0 }, + { "paint": 8, "ka": 0, "d": 0, "s": 0 }, + { "name": "lidloite", "paint": 0, "ka": 0, "d": 0, "s": 0 }, + { "paint": 0, "ka": null, "d": null, "s": null } + ] + }, + "options": { + "notes": "gacha-tournament VoD, t=4149.5 (1:09:09). The host ended the battle ~10s in (\"Mini Kagu has ended the battle.\"), so the results screen is a no contest: \"No contest.\" where the Score: banner goes and both team boxes flat grey with DRAW tags instead of team ink and VICTORY/DEFEAT, no team totals. The game was then replayed on the same map and built as a duplicate card. Row 7's stats show \"x --\" (unread by the game). Weapons other than the POV's and the decorated names are left unlabeled." + } +} diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/frame.png b/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/frame.png new file mode 100644 index 000000000..e9f31d313 Binary files /dev/null and b/app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/frame.png differ diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 752e451cb..81fa6924f 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -119,6 +119,7 @@ function scoreboard( matchScores = [100, 47] as [number | null, number | null], paints = [] as (number | null)[], names = NAMES, + noContest = false, } = {}, ): DetectedEvent { const data: ScoreboardData = { @@ -126,6 +127,7 @@ function scoreboard( mode, stage, matchScores, + noContest, players: weaponIds.map((weaponId, i) => ({ name: names[i] ?? `p${i}`, weaponId, @@ -982,6 +984,21 @@ test("a scoreless match replayed on the same map is a disconnect", () => { assert.equal(skipped.get(built[1]!), undefined); }); +test("a game ended as a no contest builds no match", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(13, { time: 287, score: [100, 100] }), + death(14, "l1"), + scoreboard(35, { matchScores: [null, null], noContest: true }), + scoreboard(38, { matchScores: [null, null], noContest: true }), + mapStart(140), + scoreboard(440), + ]); + assert.equal(built.length, 1); + assert.equal(built[0]!.match.startsAt, 140); + assert.deepEqual(built[0]!.match.matchScores, [100, 47]); +}); + test("a full-length match whose score went unread is kept when the same map follows", () => { const built = buildScannerMatches([ mapStart(0), diff --git a/app/features/scanner/tests/scoreboard.test.ts b/app/features/scanner/tests/scoreboard.test.ts index d15158fa1..3dcb41afd 100644 --- a/app/features/scanner/tests/scoreboard.test.ts +++ b/app/features/scanner/tests/scoreboard.test.ts @@ -62,6 +62,10 @@ for (const fixture of fixtures) { }, ); + await t.test("noContest", fieldTestOptions(fixture, "noContest"), () => { + assert.equal(event.data.noContest, expected.noContest ?? false); + }); + await t.test( "header", fieldTestOptions(fixture, "header", expected.mode !== undefined), diff --git a/changelog/2026-10-18-scanner-no-contest.md b/changelog/2026-10-18-scanner-no-contest.md new file mode 100644 index 000000000..6e128b095 --- /dev/null +++ b/changelog/2026-10-18-scanner-no-contest.md @@ -0,0 +1,5 @@ +--- +navItem: scanner +type: bug +--- +Fixed the scanner showing a game twice when the host ended it early and it was replayed, private battles ended early by participant as "No contest" are not ignored