mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 04:54:25 -05:00
Fix scanner duplicating a game that was ended early
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/scoreboard/private-battle-clam-blitz-no-contest-draw/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
5
changelog/2026-10-18-scanner-no-contest.md
Normal file
5
changelog/2026-10-18-scanner-no-contest.md
Normal 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
|
||||
Reference in New Issue
Block a user