mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-11 03:46:52 -05:00
Proper score
This commit is contained in:
@@ -33,18 +33,12 @@ function testMatch(partial: Partial<ScannerMatch> = {}): 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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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 (
|
||||
<div className="match-score">
|
||||
<span className={winnerKnown ? "win" : undefined}>
|
||||
{alpha.score ?? "?"}
|
||||
</span>
|
||||
<span className={winnerKnown ? "win" : undefined}>{label(alpha)}</span>
|
||||
<span className="sep"> – </span>
|
||||
<span className={winnerKnown ? "lose" : undefined}>
|
||||
{bravo.score ?? "?"}
|
||||
</span>
|
||||
<span className={winnerKnown ? "lose" : undefined}>{label(bravo)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
"",
|
||||
|
||||
@@ -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<ScoreboardReplayData, "timestamp" | "replayCode" | "matchScores">
|
||||
>;
|
||||
Partial<Pick<ScoreboardReplayData, "timestamp" | "replayCode">>;
|
||||
|
||||
/** 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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
214
app/features/scanner/core/detectors/scoreboard/banner.ts
Normal file
214
app/features/scanner/core/detectors/scoreboard/banner.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -237,7 +237,9 @@ function toBuiltMatch<E extends DetectedEvent>(
|
||||
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) },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,6 @@ const scannerMatchPlayerSchema = z.object({
|
||||
});
|
||||
|
||||
const scannerMatchTeamSchema = z.object({
|
||||
score: z.number().nullable(),
|
||||
players: z.array(scannerMatchPlayerSchema).max(4),
|
||||
});
|
||||
|
||||
|
||||
68
app/features/scanner/tests/banner.test.ts
Normal file
68
app/features/scanner/tests/banner.test.ts
Normal file
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 6,
|
||||
"timestamp": "6/7/2026 19:04",
|
||||
"replayCode": "RWYQ-4X37-M1EL-EGGQ",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 17,
|
||||
"timestamp": "3/7/2026 21:22",
|
||||
"replayCode": "R0M1-UCVK-F5AK-VUL2",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
"mode": "SZ",
|
||||
"stage": 2,
|
||||
"replayCode": "RVRM-XXEL-0573-Q45U",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 18,
|
||||
"timestamp": "9/7/2026 18:13",
|
||||
"replayCode": "R1V4-PAHW-GGM2-PD9S",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 2,
|
||||
"timestamp": "3/7/2026 22:28",
|
||||
"replayCode": "R6KE-D064-3CXD-XVKL",
|
||||
"scores": [
|
||||
440,
|
||||
385
|
||||
],
|
||||
"matchScores": [
|
||||
88,
|
||||
77
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
"mode": "SZ",
|
||||
"stage": 22,
|
||||
"timestamp": "9/7/2026 18:17",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 13,
|
||||
"timestamp": "9/7/2026 14:04",
|
||||
"replayCode": "R80B-00DL-WF4X-V3CA",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
"lobby": "X",
|
||||
"mode": "RM",
|
||||
"replayCode": "R17L-PFKK-C1Q2-143L",
|
||||
"scores": [
|
||||
265,
|
||||
135
|
||||
],
|
||||
"matchScores": [
|
||||
53,
|
||||
27
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 13,
|
||||
"timestamp": "9/7/2026 14:16",
|
||||
"replayCode": "RUH3-3NEF-F5FY-PAJL",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
null
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 13,
|
||||
"timestamp": "9/7/2026 14:22",
|
||||
"replayCode": "RGX0-QWEM-E3VS-AVG6",
|
||||
"scores": [
|
||||
500,
|
||||
0
|
||||
],
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"stage": 24,
|
||||
"timestamp": "9/7/2026 14:34",
|
||||
"replayCode": "RUCT-5HNH-XWDC-J51U",
|
||||
"scores": [
|
||||
160,
|
||||
135
|
||||
],
|
||||
"matchScores": [
|
||||
32,
|
||||
7
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "CB",
|
||||
"stage": 0,
|
||||
"scores": [
|
||||
480,
|
||||
370
|
||||
"matchScores": [
|
||||
96,
|
||||
74
|
||||
],
|
||||
"povIndex": 0,
|
||||
"players": [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 968 KiB |
@@ -4,9 +4,9 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 3,
|
||||
"scores": [
|
||||
425,
|
||||
410
|
||||
"matchScores": [
|
||||
85,
|
||||
82
|
||||
],
|
||||
"povIndex": 3,
|
||||
"players": [
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 6,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 3,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 17,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 2,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 4,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"players": [
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "SERIES",
|
||||
"mode": "SZ",
|
||||
"stage": 8,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"players": [
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 13,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 1,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "X",
|
||||
"mode": "SZ",
|
||||
"stage": 0,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 0,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"lobby": "X",
|
||||
"mode": "SZ",
|
||||
"stage": 0,
|
||||
"scores": [
|
||||
500,
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 0,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user