Fix scanner duplicating a game that was ended early

This commit is contained in:
Kalle
2026-10-19 07:33:22 +03:00
parent fd90f734a7
commit c53cb2af6d
10 changed files with 122 additions and 3 deletions

View File

@@ -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

View File

@@ -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]
);
});
}

View File

@@ -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.

View File

@@ -253,8 +253,10 @@ export type MatchBuildCache<E extends DetectedEvent> = 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<E extends DetectedEvent>(
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;
}

View File

@@ -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;

View File

@@ -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."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -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),

View File

@@ -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),

View File

@@ -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