mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-09 15:13:53 -05:00
Battle log initial
This commit is contained in:
parent
9b5a3ef028
commit
eaade46354
|
|
@ -74,8 +74,10 @@ sequenceDiagram
|
|||
assumes a browser, so the client tree loads via `React.lazy` after
|
||||
`useHydrated`. Nothing from `core/worker/capture/store` may be imported at
|
||||
route-module top level.
|
||||
- Seven detectors: `scoreboard` (results screen), `scoreboard-replay`
|
||||
(replay-browser detail), `scoreboard-own` (personal results), `death`
|
||||
- Eight detectors: `scoreboard` (results screen), `scoreboard-replay`
|
||||
(replay-browser detail), `battle-log` (Recent Battles detail — the same
|
||||
data as the replay screen sans the replay code, panels stacked instead of
|
||||
side by side), `scoreboard-own` (personal results), `death`
|
||||
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay,
|
||||
plus the casted 8-player spectator map as a gated variant), `objective`
|
||||
(the ranked in-match counter overlay: per-team counts, penalties, who
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import {
|
||||
CircleHelp,
|
||||
History,
|
||||
type LucideIcon,
|
||||
Map as MapIcon,
|
||||
Play,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
Trophy,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
|
||||
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
|
||||
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
|
||||
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
|
|
@ -29,6 +31,7 @@ const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
|||
[OBJECTIVE_EVENT_TYPE]: Target,
|
||||
[SCOREBOARD_EVENT_TYPE]: Trophy,
|
||||
[SCOREBOARD_REPLAY_EVENT_TYPE]: RotateCcw,
|
||||
[BATTLE_LOG_EVENT_TYPE]: History,
|
||||
[SCOREBOARD_OWN_EVENT_TYPE]: User,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* entirely once everything lives in a match.
|
||||
*/
|
||||
|
||||
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
|
||||
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
|
||||
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
|
||||
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
|
|
@ -21,6 +22,7 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
|
|||
[OBJECTIVE_EVENT_TYPE]: "objective",
|
||||
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
|
||||
[SCOREBOARD_REPLAY_EVENT_TYPE]: "replay scoreboard",
|
||||
[BATTLE_LOG_EVENT_TYPE]: "battle log",
|
||||
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { WeaponImage } from "~/components/Image";
|
||||
import type { PlayerAbilityMap } from "../core/ability-harvest";
|
||||
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
|
||||
import type {
|
||||
ScoreboardData,
|
||||
ScoreboardPlayer,
|
||||
|
|
@ -77,6 +78,7 @@ export function ScoreboardCard(props: {
|
|||
const eventType = props.eventType ?? "Scoreboard";
|
||||
const data = props.data as CardData;
|
||||
const isReplay = eventType === SCOREBOARD_REPLAY_EVENT_TYPE;
|
||||
const isBattleLog = eventType === BATTLE_LOG_EVENT_TYPE;
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
|
|
@ -84,7 +86,9 @@ export function ScoreboardCard(props: {
|
|||
t={t}
|
||||
confidence={confidence}
|
||||
type={eventType}
|
||||
label={isReplay ? "replay" : "scoreboard"}
|
||||
label={
|
||||
isReplay ? "replay" : isBattleLog ? "battle log" : "scoreboard"
|
||||
}
|
||||
/>
|
||||
{(data.mode !== null || data.stage !== null) && (
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
|||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { mainWeaponImageUrl } from "~/utils/urls";
|
||||
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
|
||||
import * as bl from "../core/detectors/battle-log/rois";
|
||||
import type { DeathData } from "../core/detectors/death/index";
|
||||
import * as death from "../core/detectors/death/rois";
|
||||
import type { MapStartData } from "../core/detectors/map-start/index";
|
||||
|
|
@ -77,6 +78,26 @@ function scoreboardRows(): RowRois[] {
|
|||
}));
|
||||
}
|
||||
|
||||
/** winnerSide comes from the event debug: players are ordered winners-first. */
|
||||
function battleLogRows(winnerSide: string): RowRois[] {
|
||||
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
|
||||
return panels.flatMap((dy) =>
|
||||
bl.ROW_CENTERS.map((base) => {
|
||||
const cy = base + dy;
|
||||
return {
|
||||
weapon: bl.weaponRoi(cy),
|
||||
name: bl.nameRoi(cy),
|
||||
paint: bl.paintRoi(cy),
|
||||
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
|
||||
Roi,
|
||||
Roi,
|
||||
Roi,
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** winnerSide comes from the event debug: players are ordered winners-first. */
|
||||
function replayRows(winnerSide: string): RowRois[] {
|
||||
const panels =
|
||||
|
|
@ -170,6 +191,25 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (detector === "battle-log") {
|
||||
for (const dy of bl.PANEL_DYS) {
|
||||
for (const base of bl.ROW_CENTERS) {
|
||||
const cy = base + dy;
|
||||
rect(bl.weaponRoi(cy), "#f87171");
|
||||
rect(bl.nameRoi(cy), "#4ade80");
|
||||
rect(bl.paintRoi(cy), "#60a5fa");
|
||||
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
|
||||
rect(bl.gateDarkProbe(cy), "#facc15");
|
||||
}
|
||||
rect(bl.teamScoreRoi(dy), "#60a5fa");
|
||||
rect(bl.resultTagRoi(dy), "#fb923c");
|
||||
}
|
||||
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
|
||||
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
|
||||
rect(bl.HEADER_TOP_BAND, "#34d399");
|
||||
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
|
||||
return;
|
||||
}
|
||||
if (detector === "scoreboard-replay") {
|
||||
for (const dx of replay.PANEL_XS) {
|
||||
for (const cy of replay.ROW_CENTERS) {
|
||||
|
|
@ -297,12 +337,17 @@ export function ScreenshotPage() {
|
|||
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
|
||||
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
|
||||
const isReplay = activeDetector === "scoreboard-replay";
|
||||
const isBattleLog = activeDetector === "battle-log";
|
||||
const isDeath = activeDetector === "death";
|
||||
const isMapStart = activeDetector === "map-start";
|
||||
const isOwn = activeDetector === "scoreboard-own";
|
||||
const isMinimap = activeDetector === "minimap";
|
||||
const winnerSide = String(event?.debug?.winnerSide ?? "left");
|
||||
const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows();
|
||||
const rowRois = isReplay
|
||||
? replayRows(winnerSide)
|
||||
: isBattleLog
|
||||
? battleLogRows(winnerSide)
|
||||
: scoreboardRows();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -462,6 +507,17 @@ export function ScreenshotPage() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{frame && event && isBattleLog && (
|
||||
<p>
|
||||
timestamp <b>{event.data.timestamp ?? "?"}</b>
|
||||
{" · "}match scores {JSON.stringify(event.data.matchScores)}
|
||||
{" · "}winner panel <b>{winnerSide}</b>
|
||||
<br />
|
||||
<RoiCrop frame={frame} roi={bl.HEADER_TOP_BAND} />{" "}
|
||||
<RoiCrop frame={frame} roi={bl.HEADER_BOTTOM_BAND} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
{frame && event && isDeath && (
|
||||
<p>
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ import {
|
|||
SCOREBOARD_OWN_EVENT_TYPE,
|
||||
type ScoreboardOwnData,
|
||||
} from "../core/detectors/scoreboard-own/index";
|
||||
import {
|
||||
SCOREBOARD_REPLAY_EVENT_TYPE,
|
||||
type ScoreboardReplayData,
|
||||
} from "../core/detectors/scoreboard-replay/index";
|
||||
import type { ScoreboardReplayData } from "../core/detectors/scoreboard-replay/index";
|
||||
import { formatClock, formatTime } from "./format";
|
||||
import {
|
||||
lobbyLabel,
|
||||
|
|
@ -221,9 +218,8 @@ function eventCells(event: CsvEvent): Cell[] {
|
|||
];
|
||||
}
|
||||
default: {
|
||||
// Scoreboard and ScoreboardReplay share the base shape
|
||||
// Scoreboard, ScoreboardReplay and BattleLog share the base shape
|
||||
const d = event.data as ScoreboardData & Partial<ScoreboardReplayData>;
|
||||
const isReplay = event.type === SCOREBOARD_REPLAY_EVENT_TYPE;
|
||||
return [
|
||||
...base,
|
||||
lobbyLabel(d.lobby),
|
||||
|
|
@ -236,8 +232,8 @@ function eventCells(event: CsvEvent): Cell[] {
|
|||
"",
|
||||
"",
|
||||
formatPlayers(d),
|
||||
isReplay ? d.replayCode : "",
|
||||
isReplay ? d.timestamp : "",
|
||||
d.replayCode ?? "",
|
||||
d.timestamp ?? "",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
395
app/features/scanner/core/detectors/battle-log/index.ts
Normal file
395
app/features/scanner/core/detectors/battle-log/index.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
/**
|
||||
* BattleLogDetector: parses the Recent Battles detail screen (battle log) —
|
||||
* the same match data as the live scoreboard (header, team scores, 8 player
|
||||
* rows) plus the recording timestamp, but no replay code.
|
||||
*
|
||||
* The two team panels sit STACKED (observed winner on top, confirmed by the
|
||||
* VICTORY/DEFEAT tags) and the row text renders at the live scoreboard's
|
||||
* sizes, so field parsing reuses the scoreboard helpers with the shared
|
||||
* glyph sets unscaled — only the ROI geometry is this screen's own. The
|
||||
* header is the replay browser's (timestamp + stage / lobby + mode tags),
|
||||
* parsed with battle-log bands.
|
||||
*/
|
||||
import { getCV, type Mat } from "../../cv";
|
||||
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
|
||||
import {
|
||||
cropRoi,
|
||||
maxBrightness,
|
||||
maxChannel,
|
||||
meanBrightness,
|
||||
} from "../../image";
|
||||
import { RESULT_TAG_ENTRIES } from "../../localized";
|
||||
import { closestBy } from "../../text";
|
||||
import {
|
||||
type BannerScoreRead,
|
||||
FULL_COUNT_TEAM_SCORE,
|
||||
parseBannerScore,
|
||||
resolveMatchScores,
|
||||
} from "../scoreboard/banner";
|
||||
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
|
||||
import type {
|
||||
ScoreboardData,
|
||||
ScoreboardPlayer,
|
||||
ScoreboardResources,
|
||||
ScoreboardRowDebug,
|
||||
} from "../scoreboard/index";
|
||||
import { findPovIndex } from "../scoreboard/pov";
|
||||
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
|
||||
import {
|
||||
type ParsedReplayHeader,
|
||||
parseReplayHeader,
|
||||
} from "../scoreboard-replay/header";
|
||||
import type { DetectedEvent, Detector, GateResult } from "../types";
|
||||
import {
|
||||
GATE_COLOR_MIN_SATURATION,
|
||||
GATE_COLOR_PROBES,
|
||||
GATE_DARK_MAX_MEAN,
|
||||
GATE_TEXT_MIN_MAX,
|
||||
gateDarkProbe,
|
||||
HEADER_BOTTOM_BAND,
|
||||
HEADER_LINE_HEIGHT,
|
||||
HEADER_TAG_COLUMN_FRACTION,
|
||||
HEADER_TAG_LEAD_IN_MAX,
|
||||
HEADER_TIMESTAMP_HEIGHT,
|
||||
HEADER_TOP_BAND,
|
||||
MATCH_SCORE_DIGIT_HEIGHT,
|
||||
MATCH_SCORE_ROIS,
|
||||
nameRoi,
|
||||
PANEL_DYS,
|
||||
paintRoi,
|
||||
paintSuffixRoi,
|
||||
povArrowRoi,
|
||||
RESULT_TAG_TEXT_HEIGHT,
|
||||
ROW_CENTERS,
|
||||
resultTagRoi,
|
||||
specialIconRoi,
|
||||
statRoi,
|
||||
teamScoreRoi,
|
||||
weaponRoi,
|
||||
} from "./rois";
|
||||
|
||||
export interface BattleLogData extends ScoreboardData {
|
||||
/** recording timestamp as shown, e.g. "5/8/2026 19:16"; locale-formatted */
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
export const BATTLE_LOG_EVENT_TYPE = "BattleLog";
|
||||
|
||||
/**
|
||||
* White outlined team totals on the panel's saturated color band — a yellow
|
||||
* band grays at ~190, so binarize just above it (the digit cores are ~250).
|
||||
*/
|
||||
const TEAM_SCORE_BIN_THRESHOLD = 205;
|
||||
|
||||
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
|
||||
type PanelResult = "VICTORY" | "DEFEAT";
|
||||
const RESULT_MIN_SCORE = 0.6;
|
||||
/**
|
||||
* The tag letters render in the team's ink color on the gray stamp box
|
||||
* (~75 gray, trailing status icons ~120), so binarize the max-channel
|
||||
* image just above the icons — every ink color's brightest channel
|
||||
* clears this.
|
||||
*/
|
||||
const RESULT_TAG_BIN_THRESHOLD = 140;
|
||||
|
||||
interface PanelParse {
|
||||
players: ScoreboardPlayer[];
|
||||
rows: ScoreboardRowDebug[];
|
||||
teamScore: ParsedNumber | null;
|
||||
result: PanelResult | null;
|
||||
resultReading: string;
|
||||
resultScore: number;
|
||||
confidences: number[];
|
||||
}
|
||||
|
||||
export function createBattleLogDetector(
|
||||
resources: ScoreboardResources,
|
||||
): Detector<BattleLogData> {
|
||||
const cv = getCV();
|
||||
|
||||
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
|
||||
set ? scaleGlyphSet(set, height / set.height) : null;
|
||||
|
||||
const teamDigits = resources.teamDigits ?? resources.paintDigits;
|
||||
const matchScoreSets = teamDigits
|
||||
? [scaleGlyphSet(teamDigits, MATCH_SCORE_DIGIT_HEIGHT / teamDigits.height)]
|
||||
: [];
|
||||
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
|
||||
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
|
||||
const headerBottomGlyphs = scaled(
|
||||
resources.headerLineGlyphs,
|
||||
HEADER_LINE_HEIGHT,
|
||||
);
|
||||
// The tags render in FOT-RowdyStd — use the dedicated atlas when present;
|
||||
// the BlitzMain-based fallback reads them only roughly.
|
||||
const resultGlyphs =
|
||||
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
|
||||
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
|
||||
|
||||
/** Mean-RGB saturation (max minus min channel) of a probe ROI. */
|
||||
function probeSaturation(frame: Mat, roi: (typeof GATE_COLOR_PROBES)[0]) {
|
||||
const view = cropRoi(frame, roi);
|
||||
const cont = new cv.Mat();
|
||||
view.copyTo(cont);
|
||||
view.delete();
|
||||
const d = cont.data;
|
||||
const n = cont.rows * cont.cols;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
r += d[i * 4]!;
|
||||
g += d[i * 4 + 1]!;
|
||||
b += d[i * 4 + 2]!;
|
||||
}
|
||||
cont.delete();
|
||||
if (n === 0) return 0;
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) / n;
|
||||
}
|
||||
|
||||
function gate(frame: Mat): GateResult {
|
||||
const gray = new cv.Mat();
|
||||
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
|
||||
|
||||
let darkOk = 0;
|
||||
let suffixOk = 0;
|
||||
for (const dy of PANEL_DYS) {
|
||||
for (const base of ROW_CENTERS) {
|
||||
const cy = base + dy;
|
||||
if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN)
|
||||
darkOk++;
|
||||
if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX)
|
||||
suffixOk++;
|
||||
}
|
||||
}
|
||||
let colorOk = 0;
|
||||
for (const roi of GATE_COLOR_PROBES) {
|
||||
if (probeSaturation(frame, roi) >= GATE_COLOR_MIN_SATURATION) colorOk++;
|
||||
}
|
||||
gray.delete();
|
||||
|
||||
const rowCount = PANEL_DYS.length * ROW_CENTERS.length;
|
||||
const score =
|
||||
(darkOk / rowCount +
|
||||
suffixOk / rowCount +
|
||||
colorOk / GATE_COLOR_PROBES.length) /
|
||||
3;
|
||||
const pass = darkOk >= 7 && suffixOk >= 7 && colorOk === 3;
|
||||
return { pass, score };
|
||||
}
|
||||
|
||||
function parsePanel(gray: Mat, rgb: Mat, dy: number): PanelParse {
|
||||
const players: ScoreboardPlayer[] = [];
|
||||
const rows: ScoreboardRowDebug[] = [];
|
||||
const confidences: number[] = [];
|
||||
|
||||
const rowRois: RowRois = {
|
||||
weapon: weaponRoi,
|
||||
specialIcon: specialIconRoi,
|
||||
paint: paintRoi,
|
||||
name: nameRoi,
|
||||
stat: statRoi,
|
||||
povArrow: povArrowRoi,
|
||||
};
|
||||
for (const base of ROW_CENTERS) {
|
||||
const row = parseScoreboardRow(
|
||||
gray,
|
||||
rgb,
|
||||
base + dy,
|
||||
rowRois,
|
||||
resources,
|
||||
confidences,
|
||||
);
|
||||
players.push(row.player);
|
||||
rows.push(row.debug);
|
||||
}
|
||||
|
||||
// The panel's point total is read only to recognize a knockout (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(dy));
|
||||
teamScore = parseNumber(crop, teamDigits, {
|
||||
binThreshold: TEAM_SCORE_BIN_THRESHOLD,
|
||||
});
|
||||
crop.delete();
|
||||
}
|
||||
|
||||
let result: PanelParse["result"] = null;
|
||||
let resultReading = "";
|
||||
let resultScore = 0;
|
||||
if (resultGlyphs) {
|
||||
const bright = maxChannel(rgb, resultTagRoi(dy));
|
||||
const raw = recognizeText(bright, resultGlyphs, {
|
||||
binThreshold: RESULT_TAG_BIN_THRESHOLD,
|
||||
spaceGap: Number.POSITIVE_INFINITY,
|
||||
minCharScore: 0.25,
|
||||
});
|
||||
bright.delete();
|
||||
resultReading = raw.text;
|
||||
if (resultReading) {
|
||||
const match = closestBy(
|
||||
resultReading,
|
||||
RESULT_TAG_ENTRIES,
|
||||
(e) => e.text,
|
||||
);
|
||||
if (match) {
|
||||
resultScore = match.score;
|
||||
if (match.score >= RESULT_MIN_SCORE)
|
||||
result = match.entry.canonical as PanelResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
players,
|
||||
rows,
|
||||
teamScore,
|
||||
result,
|
||||
resultReading,
|
||||
resultScore,
|
||||
confidences,
|
||||
};
|
||||
}
|
||||
|
||||
function parse(frame: Mat, t: number): DetectedEvent<BattleLogData>[] {
|
||||
const gray = new cv.Mat();
|
||||
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
|
||||
const rgb = new cv.Mat();
|
||||
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
|
||||
|
||||
const [top, bottom] = PANEL_DYS.map((dy) => parsePanel(gray, rgb, dy)) as [
|
||||
PanelParse,
|
||||
PanelParse,
|
||||
];
|
||||
|
||||
let left: BannerScoreRead | null = null;
|
||||
let right: BannerScoreRead | null = null;
|
||||
if (matchScoreSets.length > 0) {
|
||||
left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
|
||||
right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
|
||||
}
|
||||
|
||||
const swapped = decideSwapped(top, bottom, left, right);
|
||||
const [winner, loser] = swapped ? [bottom, top] : [top, bottom];
|
||||
// POV arrow row, indexed into the winners-first players ordering
|
||||
const povIndex = findPovIndex(
|
||||
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
|
||||
);
|
||||
|
||||
const knockout = winner.teamScore?.value === FULL_COUNT_TEAM_SCORE;
|
||||
let matchScores: [number | null, number | null] = [null, null];
|
||||
let bannerDebug: object | undefined;
|
||||
if (left && right) {
|
||||
matchScores = resolveMatchScores({ left, right, knockout });
|
||||
winner.confidences.push(left.confidence, right.confidence);
|
||||
bannerDebug = { left, right, knockout };
|
||||
}
|
||||
|
||||
let header: ParsedReplayHeader | null = null;
|
||||
if (headerTopGlyphs && headerBottomGlyphs) {
|
||||
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs, {
|
||||
top: HEADER_TOP_BAND,
|
||||
bottom: HEADER_BOTTOM_BAND,
|
||||
tagLeadInMax: HEADER_TAG_LEAD_IN_MAX,
|
||||
tagColumnFraction: HEADER_TAG_COLUMN_FRACTION,
|
||||
});
|
||||
}
|
||||
|
||||
gray.delete();
|
||||
rgb.delete();
|
||||
|
||||
const confidences = [
|
||||
...winner.confidences,
|
||||
...loser.confidences,
|
||||
...(header ? [header.confidence] : []),
|
||||
];
|
||||
const confidence =
|
||||
confidences.length > 0
|
||||
? confidences.reduce((a, b) => a + b, 0) / confidences.length
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
type: BATTLE_LOG_EVENT_TYPE,
|
||||
t,
|
||||
confidence,
|
||||
data: {
|
||||
lobby: header?.lobby ?? null,
|
||||
mode: header?.mode ?? null,
|
||||
stage: header?.stage ?? null,
|
||||
timestamp: header?.timestamp ?? null,
|
||||
matchScores,
|
||||
players: [...winner.players, ...loser.players],
|
||||
povIndex,
|
||||
},
|
||||
debug: {
|
||||
rows: [...winner.rows, ...loser.rows],
|
||||
teamScoreConf: [
|
||||
winner.teamScore?.confidence ?? 0,
|
||||
loser.teamScore?.confidence ?? 0,
|
||||
],
|
||||
matchScore: bannerDebug,
|
||||
header: header?.debug,
|
||||
winnerSide: swapped ? "bottom" : "top",
|
||||
resultTags: {
|
||||
top: {
|
||||
reading: top.resultReading,
|
||||
score: top.resultScore,
|
||||
result: top.result,
|
||||
},
|
||||
bottom: {
|
||||
reading: bottom.resultReading,
|
||||
score: bottom.resultScore,
|
||||
result: bottom.result,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// no rearm cooldown — distinct battles browsed in quick succession are
|
||||
// told apart by content (same as the replay browser)
|
||||
return {
|
||||
id: "battle-log",
|
||||
sufficientConfidence: 0.8,
|
||||
gate,
|
||||
parse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the winner sits in the bottom panel. A confident VICTORY/DEFEAT
|
||||
* tag decides (the distressed tag texture usually reads below the floor);
|
||||
* otherwise the panel totals are checked against the banner scores — the
|
||||
* total is the count times five, and only a knockout winner's reaches 500.
|
||||
* Default: winner on top, which every observed battle-log screen shows.
|
||||
*/
|
||||
function decideSwapped(
|
||||
top: PanelParse,
|
||||
bottom: PanelParse,
|
||||
left: BannerScoreRead | null,
|
||||
right: BannerScoreRead | null,
|
||||
): boolean {
|
||||
if (top.result !== null || bottom.result !== null) {
|
||||
return top.result === "DEFEAT" || bottom.result === "VICTORY";
|
||||
}
|
||||
const topTotal = top.teamScore?.value ?? null;
|
||||
const bottomTotal = bottom.teamScore?.value ?? null;
|
||||
if (topTotal === FULL_COUNT_TEAM_SCORE) return false;
|
||||
if (bottomTotal === FULL_COUNT_TEAM_SCORE) return true;
|
||||
if (
|
||||
left?.value != null &&
|
||||
right?.value != null &&
|
||||
topTotal !== null &&
|
||||
bottomTotal !== null &&
|
||||
topTotal !== bottomTotal
|
||||
) {
|
||||
const hi = Math.max(left.value, right.value) * 5;
|
||||
const lo = Math.min(left.value, right.value) * 5;
|
||||
if (topTotal === lo && bottomTotal === hi) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
161
app/features/scanner/core/detectors/battle-log/rois.ts
Normal file
161
app/features/scanner/core/detectors/battle-log/rois.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* ALL battle-log ROI coordinates, in canonical 1920x1080 space.
|
||||
* Calibrated against battle-log/private-battle-splat-zones-makomart and
|
||||
* x-battle-clam-blitz-lemuria via scripts/scanner/dump-crops.ts and
|
||||
* column-projection measurement.
|
||||
*
|
||||
* The Recent Battles detail screen (battle log) shows the two team panels
|
||||
* STACKED (top panel first, bottom panel = top shifted by PANEL_DY), four
|
||||
* near-black pill rows each on a dark panel whose top band and border carry
|
||||
* the team's ink color. Text sizes match the live results scoreboard, so
|
||||
* the row glyph sets are reused unscaled; only the column positions differ.
|
||||
* Above the panels sit the split "Score:"/KNOCKOUT! banner and the
|
||||
* stage-photo header with black auto-sized tags (line 1 = recording
|
||||
* timestamp + stage, line 2 = lobby + mode, like the replay browser).
|
||||
* There is no replay code line.
|
||||
*/
|
||||
import type { Roi } from "../../canonical";
|
||||
|
||||
/** Vertical centers of the 4 player rows within the top panel. */
|
||||
export const ROW_CENTERS = [453, 519, 585, 651] as const;
|
||||
|
||||
/** Vertical shift from a top-panel ROI to its bottom-panel twin. */
|
||||
export const PANEL_DY = 366;
|
||||
|
||||
/** dy per panel: [top (index 0), bottom (index 1)]. */
|
||||
export const PANEL_DYS = [0, PANEL_DY] as const;
|
||||
|
||||
/**
|
||||
* Weapon icon search region within a row — icons render at the live
|
||||
* scoreboard's sizes, and the 56px height intentionally excludes the larger
|
||||
* replay-browser templates (matchTemplate silently skips taller ones).
|
||||
*/
|
||||
export function weaponRoi(cy: number): Roi {
|
||||
return { x: 1040, y: cy - 28, w: 76, h: 56 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's special-weapon icon, drawn on the pill above the third stat
|
||||
* counter (measured art ~x 1645-1692, y cy-20..cy+2 across fixtures).
|
||||
* Only read to break near-tied weapon-icon matches whose kits carry
|
||||
* different specials. Bounded below at cy+2 so the counter digits
|
||||
* (starting cy+5) stay out of the binarized shape.
|
||||
*/
|
||||
export function specialIconRoi(cy: number): Roi {
|
||||
return { x: 1642, y: cy - 24, w: 52, h: 26 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Player name text region (white text, left-aligned starting x=1114).
|
||||
* Long names run into the paint column; parse paint first and trim this
|
||||
* region at the leftmost paint digit.
|
||||
*/
|
||||
export function nameRoi(cy: number): Roi {
|
||||
return { x: 1110, y: cy - 14, w: 208, h: 32 };
|
||||
}
|
||||
|
||||
/** Paint amount digits, right-aligned ending at x=1396 (the "p" suffix is excluded). */
|
||||
export function paintRoi(cy: number): Roi {
|
||||
return { x: 1312, y: cy - 17, w: 88, h: 34 };
|
||||
}
|
||||
|
||||
/** The constant white "p" after the paint number — used as a gate anchor. */
|
||||
export function paintSuffixRoi(cy: number): Roi {
|
||||
return { x: 1398, y: cy - 14, w: 18, h: 28 };
|
||||
}
|
||||
|
||||
/** Stat counter digits (two, zero-padded; the small "x" prefix is excluded). */
|
||||
export function statRoi(cy: number, index: 0 | 1 | 2): Roi {
|
||||
const x = [1530, 1593, 1656][index]!;
|
||||
return { x, y: cy + 3, w: 30, h: 22 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POV arrow probe: the yellow arrow marking the recording player's row sits
|
||||
* on the panel left of the pill (measured x 977-1024, y cy-31..cy+31 on the
|
||||
* fixtures). Right edge stays short of the pill's rounded cap (~x 1032).
|
||||
*/
|
||||
export function povArrowRoi(cy: number): Roi {
|
||||
return { x: 968, y: cy - 32, w: 60, h: 62 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team point totals ("500 p") on the panel's colored top band, digits
|
||||
* right-aligned ending at x=1704. Only read to recognize a knockout
|
||||
* (winner total 500) — the totals are the count times five, not the score.
|
||||
*/
|
||||
export function teamScoreRoi(dy: number): Roi {
|
||||
return { x: 1614, y: 371 + dy, w: 96, h: 38 };
|
||||
}
|
||||
|
||||
/**
|
||||
* VICTORY / DEFEAT tag on each panel's top band — read to confirm which
|
||||
* panel won (observed always the top one; the tag letters render in the
|
||||
* team's ink color on the gray stamp).
|
||||
*/
|
||||
export function resultTagRoi(dy: number): Roi {
|
||||
return { x: 990, y: 358 + dy, w: 215, h: 52 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two sides of the colored "Score:" banner wave above the panels. The
|
||||
* left side's digits follow the localized label (left-aligned from x~985),
|
||||
* the right side's are right-aligned ending at x~1744. A knockout replaces
|
||||
* the winning side's value with the KNOCKOUT! burst.
|
||||
*/
|
||||
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
|
||||
{ x: 985, y: 278, w: 265, h: 40 },
|
||||
{ x: 1648, y: 278, w: 100, h: 40 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Header bands on the stage-photo banner. Line 1 holds the recording
|
||||
* timestamp and the stage tag (led by a rank icon on ranked lobbies, which
|
||||
* shifts the text left edge); line 2 the lobby (bold, from x=809) and mode.
|
||||
* The bands hug the tag rows tightly — the stage photo directly below the
|
||||
* boxes has bright pixels that would ink every glyph's bottom otherwise —
|
||||
* and start on the photo left of the boxes, so readTagBand scans for the
|
||||
* tag start (HEADER_TAG_LEAD_IN_MAX) instead of anchoring at the edge.
|
||||
*/
|
||||
export const HEADER_TOP_BAND: Roi = { x: 800, y: 74, w: 668, h: 32 };
|
||||
export const HEADER_BOTTOM_BAND: Roi = { x: 800, y: 122, w: 728, h: 46 };
|
||||
export const HEADER_TAG_LEAD_IN_MAX = 40;
|
||||
/** see TagBandOptions.tagColumnFraction — the tags are subtly tilted */
|
||||
export const HEADER_TAG_COLUMN_FRACTION = 0.75;
|
||||
|
||||
/**
|
||||
* Gate probe: the strip between the paint "p" suffix (ends 1413) and the
|
||||
* first stat "x" (starts 1517) is always empty pill background (near-black
|
||||
* ~20, like the live scoreboard's pills).
|
||||
*/
|
||||
export function gateDarkProbe(cy: number): Roi {
|
||||
return { x: 1422, y: cy - 10, w: 78, h: 20 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink-color probes, the discriminator against the two lookalike results
|
||||
* screens: the panels' colored top bands (right of the result tag, left of
|
||||
* the totals) and the score banner are always saturated team color here
|
||||
* (measured saturation 109+ across fixtures), while the live scoreboard is
|
||||
* neutral panel gray at all three spots and the replay browser only at the
|
||||
* first (its own banner) — its panel area is flat mid-gray.
|
||||
*/
|
||||
export const GATE_COLOR_PROBES: readonly Roi[] = [
|
||||
{ x: 1300, y: 382, w: 100, h: 16 },
|
||||
{ x: 1300, y: 748, w: 100, h: 16 },
|
||||
{ x: 1400, y: 292, w: 60, h: 14 },
|
||||
];
|
||||
|
||||
/** Mean-RGB saturation (max minus min channel) floor for the color probes. */
|
||||
export const GATE_COLOR_MIN_SATURATION = 60;
|
||||
|
||||
/** The strip between p suffix and stats must stay near-black. */
|
||||
export const GATE_DARK_MAX_MEAN = 45;
|
||||
/** The paint "p" suffix region must contain bright (white) pixels. */
|
||||
export const GATE_TEXT_MIN_MAX = 180;
|
||||
|
||||
/** Text metrics measured on the fixtures, used for glyph scaling / tooling. */
|
||||
export const MATCH_SCORE_DIGIT_HEIGHT = 28;
|
||||
export const HEADER_TIMESTAMP_HEIGHT = 21;
|
||||
export const HEADER_LINE_HEIGHT = 26;
|
||||
export const RESULT_TAG_TEXT_HEIGHT = 30;
|
||||
|
|
@ -4,6 +4,10 @@
|
|||
* picked up by the analyzer worker.
|
||||
*/
|
||||
|
||||
import {
|
||||
BATTLE_LOG_EVENT_TYPE,
|
||||
createBattleLogDetector,
|
||||
} from "./battle-log/index";
|
||||
import { createDeathDetector } from "./death/index";
|
||||
import { createMapStartDetector } from "./map-start/index";
|
||||
import { createMinimapDetector } from "./minimap/index";
|
||||
|
|
@ -22,11 +26,13 @@ import type { Detector } from "./types";
|
|||
|
||||
/**
|
||||
* Event types whose data is the full 8-player scoreboard shape
|
||||
* (ScoreboardData): the results screen and the replay-browser detail.
|
||||
* (ScoreboardData): the results screen, the replay-browser detail, and the
|
||||
* battle-log detail.
|
||||
*/
|
||||
export const SCOREBOARD_EVENT_TYPES: readonly string[] = [
|
||||
SCOREBOARD_EVENT_TYPE,
|
||||
SCOREBOARD_REPLAY_EVENT_TYPE,
|
||||
BATTLE_LOG_EVENT_TYPE,
|
||||
];
|
||||
|
||||
export function createAllDetectors(
|
||||
|
|
@ -35,6 +41,7 @@ export function createAllDetectors(
|
|||
return [
|
||||
createScoreboardDetector(resources) as Detector<unknown>,
|
||||
createScoreboardReplayDetector(resources) as Detector<unknown>,
|
||||
createBattleLogDetector(resources) as Detector<unknown>,
|
||||
createScoreboardOwnDetector(resources) as Detector<unknown>,
|
||||
createDeathDetector(resources) as Detector<unknown>,
|
||||
createMapStartDetector(resources) as Detector<unknown>,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { ScannerLobby } from "../../../scanner-types";
|
||||
import type { Roi } from "../../canonical";
|
||||
import type { Mat } from "../../cv";
|
||||
import type { GlyphSet } from "../../glyphs";
|
||||
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
|
||||
|
|
@ -49,10 +50,12 @@ const TAG_DARK_MAX_LIFTED = 120;
|
|||
* (ja) — so any . / - separated triple followed by a time is accepted.
|
||||
* Adjacent skinny time digits can read with a spurious gap on compressed
|
||||
* captures ("14:1 1"), so a lone space is tolerated between them and
|
||||
* stripped when the timestamp is assembled.
|
||||
* stripped when the timestamp is assembled. Not left-anchored: the
|
||||
* battle-log line leads with a rank icon on ranked lobbies, which reads as
|
||||
* a junk glyph before the date.
|
||||
*/
|
||||
const TIMESTAMP_RE =
|
||||
/^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
|
||||
/(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
|
||||
|
||||
interface TopBandParse {
|
||||
reading: string;
|
||||
|
|
@ -86,27 +89,49 @@ function parseTopBand(reading: string): TopBandParse {
|
|||
return { reading, timestamp, stage, stageScore };
|
||||
}
|
||||
|
||||
/** The two header tag bands; the battle log passes its own coordinates. */
|
||||
export interface ReplayHeaderBands {
|
||||
top: Roi;
|
||||
bottom: Roi;
|
||||
/** see TagBandOptions.tagLeadInMax; the battle-log tags are not left-anchored */
|
||||
tagLeadInMax?: number;
|
||||
/** see TagBandOptions.tagColumnFraction; the battle-log tags are tilted */
|
||||
tagColumnFraction?: number;
|
||||
}
|
||||
|
||||
const REPLAY_BANDS: ReplayHeaderBands = {
|
||||
top: HEADER_TOP_BAND,
|
||||
bottom: HEADER_BOTTOM_BAND,
|
||||
};
|
||||
|
||||
export function parseReplayHeader(
|
||||
gray: Mat,
|
||||
topGlyphs: GlyphSet,
|
||||
bottomGlyphs: GlyphSet,
|
||||
bands: ReplayHeaderBands = REPLAY_BANDS,
|
||||
): ParsedReplayHeader {
|
||||
let top = parseTopBand(readTagBand(gray, HEADER_TOP_BAND, topGlyphs));
|
||||
const leadIn = {
|
||||
tagLeadInMax: bands.tagLeadInMax,
|
||||
tagColumnFraction: bands.tagColumnFraction,
|
||||
};
|
||||
let top = parseTopBand(readTagBand(gray, bands.top, topGlyphs, leadIn));
|
||||
if (top.stage === null) {
|
||||
const retry = parseTopBand(
|
||||
readTagBand(gray, HEADER_TOP_BAND, topGlyphs, {
|
||||
readTagBand(gray, bands.top, topGlyphs, {
|
||||
...leadIn,
|
||||
tagDarkMax: TAG_DARK_MAX_LIFTED,
|
||||
}),
|
||||
);
|
||||
if (retry.stageScore >= top.stageScore) top = retry;
|
||||
}
|
||||
|
||||
let bottomReading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs);
|
||||
let bottomReading = readTagBand(gray, bands.bottom, bottomGlyphs, leadIn);
|
||||
let bottomMatch = bottomReading
|
||||
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
|
||||
: null;
|
||||
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
|
||||
const reading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs, {
|
||||
const reading = readTagBand(gray, bands.bottom, bottomGlyphs, {
|
||||
...leadIn,
|
||||
tagDarkMax: TAG_DARK_MAX_LIFTED,
|
||||
});
|
||||
const match = reading
|
||||
|
|
|
|||
|
|
@ -48,27 +48,50 @@ const TAG_BRIGHT_MIN = 165;
|
|||
const TAG_GAP_TOLERANCE = 6;
|
||||
|
||||
/**
|
||||
* Trim a band crop to the black-tag extent starting from its left edge.
|
||||
* Returns the trimmed width (0 when no tag is present at all).
|
||||
* Trim a band crop to the black-tag extent: the longest run of tag columns
|
||||
* starting within `maxLeadIn` of the left edge (a dark photo edge can fake
|
||||
* a short run before the real tag), each run extended right until the tag
|
||||
* ends. Returns a zero-width range when no tag is present at all.
|
||||
*/
|
||||
function tagExtent(crop: Mat, darkMax: number): number {
|
||||
function tagExtent(
|
||||
crop: Mat,
|
||||
darkMax: number,
|
||||
maxLeadIn: number,
|
||||
columnFraction: number,
|
||||
): { start: number; end: number } {
|
||||
const { cols, rows, data } = crop;
|
||||
let best = { start: 0, end: 0 };
|
||||
let start = -1;
|
||||
let end = 0;
|
||||
let gap = 0;
|
||||
const takeRun = () => {
|
||||
if (start !== -1 && end - start > best.end - best.start) {
|
||||
best = { start, end };
|
||||
}
|
||||
start = -1;
|
||||
gap = 0;
|
||||
};
|
||||
for (let x = 0; x < cols; x++) {
|
||||
let tagLike = 0;
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const v = data[y * cols + x]!;
|
||||
if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++;
|
||||
}
|
||||
if (tagLike / rows >= TAG_COLUMN_FRACTION) {
|
||||
if (tagLike / rows >= columnFraction) {
|
||||
if (start === -1) {
|
||||
if (x > maxLeadIn) break;
|
||||
start = x;
|
||||
}
|
||||
end = x + 1;
|
||||
gap = 0;
|
||||
} else if (++gap > TAG_GAP_TOLERANCE) {
|
||||
} else if (start !== -1 && ++gap > TAG_GAP_TOLERANCE) {
|
||||
takeRun();
|
||||
} else if (start === -1 && x > maxLeadIn) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return end;
|
||||
takeRun();
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface TagBandOptions extends RecognizeOptions {
|
||||
|
|
@ -79,6 +102,18 @@ export interface TagBandOptions extends RecognizeOptions {
|
|||
* snap fails retry with a lifted ceiling.
|
||||
*/
|
||||
tagDarkMax?: number;
|
||||
/**
|
||||
* Non-tag columns tolerated before the tag begins. The battle-log tags
|
||||
* are not left-anchored (a leading rank icon shifts line 1 per lobby
|
||||
* type), so its bands start on the stage photo and scan for the tag.
|
||||
*/
|
||||
tagLeadInMax?: number;
|
||||
/**
|
||||
* Tag-like row fraction a column must reach. The battle-log tags are
|
||||
* subtly tilted, so a horizontal band always catches a few photo rows
|
||||
* above or below the box — those bands pass a looser fraction.
|
||||
*/
|
||||
tagColumnFraction?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,13 +128,18 @@ export function readTagBand(
|
|||
options: TagBandOptions = {},
|
||||
): string {
|
||||
const crop = copyRoi(gray, band);
|
||||
const width = tagExtent(crop, options.tagDarkMax ?? TAG_DARK_MAX);
|
||||
if (width < 12) {
|
||||
const { start, end } = tagExtent(
|
||||
crop,
|
||||
options.tagDarkMax ?? TAG_DARK_MAX,
|
||||
options.tagLeadInMax ?? TAG_GAP_TOLERANCE,
|
||||
options.tagColumnFraction ?? TAG_COLUMN_FRACTION,
|
||||
);
|
||||
if (end - start < 12) {
|
||||
crop.delete();
|
||||
return "";
|
||||
}
|
||||
const cv = getCV();
|
||||
const view = crop.roi(new cv.Rect(0, 0, width, crop.rows));
|
||||
const view = crop.roi(new cv.Rect(start, 0, end - start, crop.rows));
|
||||
const trimmed = new cv.Mat();
|
||||
view.copyTo(trimmed);
|
||||
view.delete();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
*/
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import { harvestAbilities } from "./ability-harvest";
|
||||
import {
|
||||
BATTLE_LOG_EVENT_TYPE,
|
||||
type BattleLogData,
|
||||
} from "./detectors/battle-log/index";
|
||||
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
|
||||
import {
|
||||
MAP_START_EVENT_TYPE,
|
||||
|
|
@ -349,9 +353,12 @@ function toBuiltMatch<E extends DetectedEvent>(
|
|||
|
||||
const board = open.scoreboard?.data as ScoreboardData | undefined;
|
||||
const start = open.mapStart?.data as MapStartData | undefined;
|
||||
const replay =
|
||||
open.scoreboard?.type === SCOREBOARD_REPLAY_EVENT_TYPE
|
||||
? (open.scoreboard.data as ScoreboardReplayData)
|
||||
// the replay-browser and battle-log screens both carry the recording
|
||||
// timestamp; only the former a replay code
|
||||
const timestamped =
|
||||
open.scoreboard?.type === SCOREBOARD_REPLAY_EVENT_TYPE ||
|
||||
open.scoreboard?.type === BATTLE_LOG_EVENT_TYPE
|
||||
? (open.scoreboard.data as BattleLogData & Partial<ScoreboardReplayData>)
|
||||
: undefined;
|
||||
const deaths = open.deaths.map((event) => event.data as DeathData);
|
||||
const objectives = open.objectives.map((event) => ({
|
||||
|
|
@ -365,14 +372,14 @@ function toBuiltMatch<E extends DetectedEvent>(
|
|||
startsAt:
|
||||
sources.length > 0 ? Math.max(0, Math.floor(sources[0]!.t)) : null,
|
||||
endsAt: floorOrNull(open.scoreboard?.t ?? open.minimaps.at(-1)?.t),
|
||||
playedAt: playedAt(open.scoreboard, replay),
|
||||
playedAt: playedAt(open.scoreboard, timestamped),
|
||||
lobby: board?.lobby ?? null,
|
||||
mode,
|
||||
stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes),
|
||||
matchScores: board?.matchScores.some((score) => score !== null)
|
||||
? board.matchScores
|
||||
: null,
|
||||
replayCode: replay?.replayCode ?? null,
|
||||
replayCode: timestamped?.replayCode ?? null,
|
||||
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
|
||||
// only the SZ counter is parsed — reads on a known other-mode match
|
||||
// are misreads of a lookalike overlay, not progress data
|
||||
|
|
@ -446,20 +453,20 @@ function bestCount(
|
|||
}
|
||||
|
||||
/**
|
||||
* The wall-clock time the match was played: a replay scoreboard's on-screen
|
||||
* recording timestamp (anchored to when the screen was seen, not a possibly
|
||||
* much later send), else the closing scoreboard's detection time. Detection
|
||||
* times ride richer event records (StoredEvent) and are read structurally so
|
||||
* the builder stays generic.
|
||||
* The wall-clock time the match was played: a replay/battle-log screen's
|
||||
* on-screen recording timestamp (anchored to when the screen was seen, not
|
||||
* a possibly much later send), else the closing scoreboard's detection
|
||||
* time. Detection times ride richer event records (StoredEvent) and are
|
||||
* read structurally so the builder stays generic.
|
||||
*/
|
||||
function playedAt(
|
||||
scoreboard: DetectedEvent | null,
|
||||
replay: ScoreboardReplayData | undefined,
|
||||
timestamped: BattleLogData | undefined,
|
||||
): number | null {
|
||||
if (!scoreboard) return null;
|
||||
const detectedAt = (scoreboard as { detectedAt?: number }).detectedAt ?? null;
|
||||
if (replay?.timestamp) {
|
||||
const recorded = parseReplayTimestamp(replay.timestamp, {
|
||||
if (timestamped?.timestamp) {
|
||||
const recorded = parseReplayTimestamp(timestamped.timestamp, {
|
||||
now: detectedAt ?? undefined,
|
||||
});
|
||||
if (recorded !== null) return recorded;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* Same-type events within a merge window collapse into one, keeping the
|
||||
* highest-confidence version; events below a confidence floor are dropped.
|
||||
*/
|
||||
import { BATTLE_LOG_EVENT_TYPE } from "../detectors/battle-log/index";
|
||||
import {
|
||||
OBJECTIVE_EVENT_TYPE,
|
||||
sameObjectiveData,
|
||||
|
|
@ -45,6 +46,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
|||
sameEventDataByType: {
|
||||
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[SCOREBOARD_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
|
||||
},
|
||||
minConfidence: 0.6,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ interface ExpectedScoreboard {
|
|||
event:
|
||||
| "Scoreboard"
|
||||
| "ScoreboardReplay"
|
||||
| "BattleLog"
|
||||
| "ScoreboardOwn"
|
||||
| "Death"
|
||||
| "MapStart"
|
||||
|
|
@ -68,7 +69,7 @@ interface ExpectedScoreboard {
|
|||
stage?: StageId;
|
||||
/** informational for the human corrector; tests compare `stage` */
|
||||
stageLabel?: string;
|
||||
/** ScoreboardReplay only */
|
||||
/** ScoreboardReplay + BattleLog only */
|
||||
timestamp?: string;
|
||||
/** ScoreboardReplay only */
|
||||
replayCode?: string;
|
||||
|
|
|
|||
231
app/features/scanner/tests/battle-log.test.ts
Normal file
231
app/features/scanner/tests/battle-log.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Golden-file suite for the BattleLogDetector over every fixture in
|
||||
* battle-log/, mirroring tests/suites/scoreboard-replay.ts (the battle log
|
||||
* shows the same data sans the replay code), plus cross-negative sweeps:
|
||||
* the battle-log gate must stay quiet on both lookalike results screens.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { loadOpenCV } from "../core/cv";
|
||||
import {
|
||||
BATTLE_LOG_EVENT_TYPE,
|
||||
createBattleLogDetector,
|
||||
} from "../core/detectors/battle-log/index";
|
||||
import type {
|
||||
ScoreboardPlayer,
|
||||
ScoreboardRowDebug,
|
||||
} from "../core/detectors/scoreboard/index";
|
||||
import {
|
||||
type Fixture,
|
||||
isFieldSkipped,
|
||||
loadFixtures,
|
||||
runDetectorOnFixture,
|
||||
} from "../node/fixtures";
|
||||
import { loadScoreboardResources } from "../node/resources";
|
||||
import test from "./node-test-compat";
|
||||
|
||||
await loadOpenCV();
|
||||
const detector = createBattleLogDetector(await loadScoreboardResources());
|
||||
const fixtures = loadFixtures("battle-log");
|
||||
|
||||
test("battle-log fixtures exist", () => {
|
||||
assert.ok(fixtures.length > 0, "no fixtures found under battle-log/");
|
||||
});
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
test(`battle-log/${fixture.name}`, async (t) => {
|
||||
const { gate, events } = await runDetectorOnFixture(detector, fixture);
|
||||
const expectPositive = fixture.expected.event === BATTLE_LOG_EVENT_TYPE;
|
||||
|
||||
await t.test("gate", () => {
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
expectPositive,
|
||||
`gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (!expectPositive) return;
|
||||
const event = events[0];
|
||||
assert.ok(event, "gate passed but no event parsed");
|
||||
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
|
||||
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(
|
||||
"header",
|
||||
{ skip: expected.mode === undefined || skip(fixture, "header") },
|
||||
() => {
|
||||
const dbg = event.debug?.header as
|
||||
| { topReading?: string; bottomReading?: string }
|
||||
| undefined;
|
||||
assert.deepEqual(
|
||||
{
|
||||
lobby: event.data.lobby,
|
||||
mode: event.data.mode,
|
||||
stage: event.data.stage,
|
||||
},
|
||||
{
|
||||
lobby: expected.lobby ?? null,
|
||||
mode: expected.mode ?? null,
|
||||
stage: expected.stage ?? null,
|
||||
},
|
||||
`header mismatch (readings: "${dbg?.topReading}" / "${dbg?.bottomReading}")`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"timestamp",
|
||||
{
|
||||
skip: expected.timestamp === undefined || skip(fixture, "timestamp"),
|
||||
},
|
||||
() => {
|
||||
const dbg = event.debug?.header as { topReading?: string } | undefined;
|
||||
assert.equal(
|
||||
event.data.timestamp,
|
||||
expected.timestamp,
|
||||
`timestamp mismatch (reading: "${dbg?.topReading}")`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"povIndex",
|
||||
{ skip: expected.povIndex === undefined || skip(fixture, "povIndex") },
|
||||
() => {
|
||||
const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(",");
|
||||
assert.equal(
|
||||
event.data.povIndex,
|
||||
expected.povIndex,
|
||||
`povIndex mismatch (yellow fractions=${fractions})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const players = expected.players ?? [];
|
||||
|
||||
await t.test(
|
||||
"player count",
|
||||
{ skip: expected.players === undefined || skip(fixture, "players") },
|
||||
() => {
|
||||
assert.equal(event.data.players.length, players.length);
|
||||
},
|
||||
);
|
||||
|
||||
for (const [i, want] of players.entries()) {
|
||||
const got: ScoreboardPlayer | undefined = event.data.players[i];
|
||||
const dbg = rows[i];
|
||||
assert.ok(got, `row ${i} missing from parse`);
|
||||
|
||||
await t.test(
|
||||
`row ${i} weapon`,
|
||||
{
|
||||
skip:
|
||||
want.weaponId === undefined ||
|
||||
skip(fixture, `players.${i}.weaponId`),
|
||||
},
|
||||
() => {
|
||||
const top = dbg?.weapon?.top
|
||||
.map((c) => `${c.id}:${c.score.toFixed(3)}`)
|
||||
.join(" ");
|
||||
assert.equal(
|
||||
got.weaponId,
|
||||
want.weaponId,
|
||||
`weapon mismatch (candidates: ${top})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
`row ${i} name`,
|
||||
{
|
||||
skip: want.name === undefined || skip(fixture, `players.${i}.name`),
|
||||
},
|
||||
() => {
|
||||
assert.equal(
|
||||
got.name,
|
||||
want.name,
|
||||
`name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
`row ${i} paint`,
|
||||
{
|
||||
skip: want.paint === undefined || skip(fixture, `players.${i}.paint`),
|
||||
},
|
||||
() => {
|
||||
assert.equal(
|
||||
got.paint,
|
||||
want.paint,
|
||||
`paint mismatch (score=${dbg?.paintScore.toFixed(3)})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
`row ${i} stats`,
|
||||
{
|
||||
skip: want.ka === undefined || skip(fixture, `players.${i}.stats`),
|
||||
},
|
||||
() => {
|
||||
const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(",");
|
||||
assert.deepEqual(
|
||||
{ ka: got.ka, d: got.d, s: got.s },
|
||||
{ ka: want.ka, d: want.d ?? null, s: want.s ?? null },
|
||||
`stat mismatch (scores=${scores})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The three scoreboard-shaped screens must not trigger each other's
|
||||
// detectors; the mirror sweeps live in scoreboard.test.ts and
|
||||
// suites/scoreboard-replay.ts.
|
||||
for (const fixture of [
|
||||
...loadFixtures("scoreboard").filter(
|
||||
(f) => f.expected.event === "Scoreboard",
|
||||
),
|
||||
...loadFixtures("scoreboard-replay").filter(
|
||||
(f) => f.expected.event === "ScoreboardReplay",
|
||||
),
|
||||
]) {
|
||||
test(`battle-log gate stays quiet on ${fixture.name}`, async () => {
|
||||
const { gate } = await runDetectorOnFixture(detector, fixture);
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
false,
|
||||
`battle-log gate fired (score=${gate.score.toFixed(3)})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Shared negatives (tests/fixtures/negative/): frames no detector may fire on.
|
||||
for (const fixture of loadFixtures("negative")) {
|
||||
test(`battle-log gate stays quiet on negative/${fixture.name}`, async () => {
|
||||
const { gate } = await runDetectorOnFixture(detector, fixture);
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
false,
|
||||
`battle-log gate fired (score=${gate.score.toFixed(3)})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function skip(fixture: Fixture, field: string): boolean | string {
|
||||
return isFieldSkipped(fixture, field) ? "skipFields" : false;
|
||||
}
|
||||
88
app/features/scanner/tests/fixtures/battle-log/private-battle-splat-zones-makomart/expected.json
vendored
Normal file
88
app/features/scanner/tests/fixtures/battle-log/private-battle-splat-zones-makomart/expected.json
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"event": "BattleLog",
|
||||
"data": {
|
||||
"lobby": "PRIVATE",
|
||||
"mode": "SZ",
|
||||
"stage": 10,
|
||||
"timestamp": "5/8/2026 19:16",
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 3,
|
||||
"players": [
|
||||
{
|
||||
"name": "BDAYBOY",
|
||||
"paint": 1955,
|
||||
"ka": 13,
|
||||
"d": 5,
|
||||
"s": 8,
|
||||
"weaponId": 60
|
||||
},
|
||||
{
|
||||
"name": "∴Columbina",
|
||||
"paint": 1430,
|
||||
"ka": 8,
|
||||
"d": 7,
|
||||
"s": 6,
|
||||
"weaponId": 3020
|
||||
},
|
||||
{
|
||||
"name": "xtsy",
|
||||
"paint": 956,
|
||||
"ka": 9,
|
||||
"d": 4,
|
||||
"s": 4,
|
||||
"weaponId": 50
|
||||
},
|
||||
{
|
||||
"name": "Sendou",
|
||||
"paint": 917,
|
||||
"ka": 6,
|
||||
"d": 9,
|
||||
"s": 3,
|
||||
"weaponId": 211
|
||||
},
|
||||
{
|
||||
"name": "Bronx Thug",
|
||||
"paint": 1512,
|
||||
"ka": 13,
|
||||
"d": 7,
|
||||
"s": 7,
|
||||
"weaponId": 3011
|
||||
},
|
||||
{
|
||||
"name": "mr 2how",
|
||||
"paint": 1380,
|
||||
"ka": 11,
|
||||
"d": 9,
|
||||
"s": 7,
|
||||
"weaponId": 8002
|
||||
},
|
||||
{
|
||||
"name": "Katfesh",
|
||||
"paint": 1473,
|
||||
"ka": 3,
|
||||
"d": 3,
|
||||
"s": 7,
|
||||
"weaponId": 2071
|
||||
},
|
||||
{
|
||||
"name": "-Artemis->",
|
||||
"paint": 1031,
|
||||
"ka": 10,
|
||||
"d": 8,
|
||||
"s": 5,
|
||||
"weaponId": 1002
|
||||
}
|
||||
],
|
||||
"stageLabel": "MakoMart"
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"players.0.name",
|
||||
"players.1.name"
|
||||
],
|
||||
"notes": "720p stream capture with horizontal banding. players.0.name (BDAYBOY) reads BOAuBOY and players.1.name (∴Columbina) reads ∴Columhina — the banding cuts glyph cores at this size. Weapons row order: N-ZAP '85, Sloshing Machine, .52 Gal, Custom Blaster, Tri-Slosher Nouveau, Stickerz Splatana Stamper, Snipewriter 5B, Carbon Roller ANG-L."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/battle-log/private-battle-splat-zones-makomart/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/battle-log/private-battle-splat-zones-makomart/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 785 KiB |
87
app/features/scanner/tests/fixtures/battle-log/x-battle-clam-blitz-lemuria/expected.json
vendored
Normal file
87
app/features/scanner/tests/fixtures/battle-log/x-battle-clam-blitz-lemuria/expected.json
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"event": "BattleLog",
|
||||
"data": {
|
||||
"lobby": "X",
|
||||
"mode": "CB",
|
||||
"stage": 23,
|
||||
"timestamp": "3/8/2026 18:11",
|
||||
"matchScores": [
|
||||
30,
|
||||
23
|
||||
],
|
||||
"povIndex": 5,
|
||||
"players": [
|
||||
{
|
||||
"name": "Pony",
|
||||
"paint": 1680,
|
||||
"ka": 11,
|
||||
"d": 6,
|
||||
"s": 7,
|
||||
"weaponId": 60
|
||||
},
|
||||
{
|
||||
"name": "Charms",
|
||||
"paint": 1367,
|
||||
"ka": 9,
|
||||
"d": 10,
|
||||
"s": 6,
|
||||
"weaponId": 0
|
||||
},
|
||||
{
|
||||
"name": "Mesh Cap",
|
||||
"paint": 857,
|
||||
"ka": 4,
|
||||
"d": 6,
|
||||
"s": 2,
|
||||
"weaponId": 2030
|
||||
},
|
||||
{
|
||||
"name": "Retro",
|
||||
"paint": 1034,
|
||||
"ka": 9,
|
||||
"d": 8,
|
||||
"s": 4,
|
||||
"weaponId": 251
|
||||
},
|
||||
{
|
||||
"name": "Invisifloats",
|
||||
"paint": 1762,
|
||||
"ka": 14,
|
||||
"d": 6,
|
||||
"s": 5,
|
||||
"weaponId": 60
|
||||
},
|
||||
{
|
||||
"name": "Beanie",
|
||||
"paint": 1194,
|
||||
"ka": 11,
|
||||
"d": 8,
|
||||
"s": 2,
|
||||
"weaponId": 252
|
||||
},
|
||||
{
|
||||
"name": "Headphones",
|
||||
"paint": 859,
|
||||
"ka": 4,
|
||||
"d": 5,
|
||||
"s": 1,
|
||||
"weaponId": 2030
|
||||
},
|
||||
{
|
||||
"name": "Slugger",
|
||||
"paint": 1023,
|
||||
"ka": 11,
|
||||
"d": 6,
|
||||
"s": 3,
|
||||
"weaponId": 1010
|
||||
}
|
||||
],
|
||||
"stageLabel": "Lemuria Hub"
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"players.4.name"
|
||||
],
|
||||
"notes": "players.4.name (Invisifloats) reads lnvisifloats — I and l are pixel-identical bars in this face. Weapons row order: N-ZAP '85, Sploosh-o-matic, E-liter 4K, Rapid Blaster Pro Deco, N-ZAP '85, Rapid Blaster Pro WNT-R, E-liter 4K, Splat Roller (Big Bubbler special confirms the near-tied Flingza off)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/battle-log/x-battle-clam-blitz-lemuria/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/battle-log/x-battle-clam-blitz-lemuria/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { BattleLogData } from "../core/detectors/battle-log/index";
|
||||
import type { DeathData } from "../core/detectors/death/index";
|
||||
import type {
|
||||
MinimapData,
|
||||
|
|
@ -108,6 +109,19 @@ function replayScoreboard(
|
|||
return { type: "ScoreboardReplay", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
function battleLogScoreboard(
|
||||
t: number,
|
||||
{ timestamp = null as string | null } = {},
|
||||
): DetectedEvent & { detectedAt?: number } {
|
||||
const base = scoreboard(t).data as ScoreboardData;
|
||||
const data: BattleLogData = {
|
||||
...base,
|
||||
timestamp,
|
||||
matchScores: [100, 0],
|
||||
};
|
||||
return { type: "BattleLog", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
|
||||
return {
|
||||
slot: SPECTATOR_SLOTS[i]!,
|
||||
|
|
@ -485,6 +499,16 @@ test("a replay scoreboard supplies replay code, set score and recording time", (
|
|||
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
|
||||
});
|
||||
|
||||
test("a battle-log scoreboard closes a match and supplies the recording time without a replay code", () => {
|
||||
const event = battleLogScoreboard(300, { timestamp: "25.12.2025 21:30" });
|
||||
event.detectedAt = Date.UTC(2025, 11, 26, 12, 0);
|
||||
const built = buildScannerMatches([event]);
|
||||
const match = built[0]!.match;
|
||||
assert.equal(match.replayCode, null);
|
||||
assert.deepEqual(match.matchScores, [100, 0]);
|
||||
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
|
||||
});
|
||||
|
||||
test("without a replay timestamp, playedAt falls back to the scoreboard's detection time", () => {
|
||||
const event = scoreboard(300) as DetectedEvent & { detectedAt?: number };
|
||||
event.detectedAt = 1_700_000_000_000;
|
||||
|
|
|
|||
|
|
@ -163,12 +163,16 @@ for (const fixture of fixtures) {
|
|||
});
|
||||
}
|
||||
|
||||
// Mirror of the cross-negative sweep in suites/scoreboard-replay.ts: the live
|
||||
// gate must stay quiet on every replay-browser positive.
|
||||
for (const fixture of loadFixtures("scoreboard-replay").filter(
|
||||
(f) => f.expected.event === "ScoreboardReplay",
|
||||
)) {
|
||||
test(`scoreboard gate stays quiet on scoreboard-replay/${fixture.name}`, async () => {
|
||||
// Mirror of the cross-negative sweeps in suites/scoreboard-replay.ts and
|
||||
// battle-log.test.ts: the live gate must stay quiet on every replay-browser
|
||||
// and battle-log positive.
|
||||
for (const fixture of [
|
||||
...loadFixtures("scoreboard-replay").filter(
|
||||
(f) => f.expected.event === "ScoreboardReplay",
|
||||
),
|
||||
...loadFixtures("battle-log").filter((f) => f.expected.event === "BattleLog"),
|
||||
]) {
|
||||
test(`scoreboard gate stays quiet on ${fixture.name}`, async () => {
|
||||
const { gate } = await runDetectorOnFixture(detector, fixture);
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
|
|
|
|||
|
|
@ -226,12 +226,17 @@ export async function runScoreboardReplaySuite(
|
|||
});
|
||||
}
|
||||
|
||||
// The two scoreboard screens must not trigger each other's detectors; the
|
||||
// mirror sweep (live gate over replay fixtures) lives in scoreboard.test.ts.
|
||||
for (const fixture of mine(
|
||||
loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"),
|
||||
)) {
|
||||
test(`replay gate stays quiet on scoreboard/${fixture.name}`, async () => {
|
||||
// The scoreboard-shaped screens must not trigger each other's detectors;
|
||||
// the mirror sweeps live in scoreboard.test.ts and battle-log.test.ts.
|
||||
for (const fixture of mine([
|
||||
...loadFixtures("scoreboard").filter(
|
||||
(f) => f.expected.event === "Scoreboard",
|
||||
),
|
||||
...loadFixtures("battle-log").filter(
|
||||
(f) => f.expected.event === "BattleLog",
|
||||
),
|
||||
])) {
|
||||
test(`replay gate stays quiet on ${fixture.name}`, async () => {
|
||||
const { gate } = await runDetectorOnFixture(detector, fixture);
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Draw all scoreboard ROIs on a (normalized) frame for visual calibration.
|
||||
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay]
|
||||
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|battle-log]
|
||||
*/
|
||||
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
|
||||
import * as bl from "../../app/features/scanner/core/detectors/battle-log/rois";
|
||||
import * as death from "../../app/features/scanner/core/detectors/death/rois";
|
||||
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
|
||||
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
|
||||
|
|
@ -21,7 +22,7 @@ const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
|
|||
process.argv.slice(2);
|
||||
if (!imagePath) {
|
||||
console.error(
|
||||
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]",
|
||||
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|battle-log|death|map-start|minimap]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -71,6 +72,27 @@ if (detector === "scoreboard") {
|
|||
rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]);
|
||||
rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]);
|
||||
rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]);
|
||||
} else if (detector === "battle-log") {
|
||||
for (const dy of bl.PANEL_DYS) {
|
||||
for (const base of bl.ROW_CENTERS) {
|
||||
const cy = base + dy;
|
||||
rect(frame, bl.weaponRoi(cy), [255, 0, 0]);
|
||||
rect(frame, bl.nameRoi(cy), [0, 255, 0]);
|
||||
rect(frame, bl.paintRoi(cy), [0, 128, 255]);
|
||||
rect(frame, bl.paintSuffixRoi(cy), [0, 255, 255]);
|
||||
for (const i of [0, 1, 2] as const)
|
||||
rect(frame, bl.statRoi(cy, i), [255, 0, 255]);
|
||||
rect(frame, bl.gateDarkProbe(cy), [255, 255, 0]);
|
||||
rect(frame, bl.povArrowRoi(cy), [255, 128, 0]);
|
||||
rect(frame, bl.specialIconRoi(cy), [255, 0, 0]);
|
||||
}
|
||||
rect(frame, bl.teamScoreRoi(dy), [0, 128, 255]);
|
||||
rect(frame, bl.resultTagRoi(dy), [255, 128, 0]);
|
||||
}
|
||||
for (const roi of bl.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
|
||||
for (const roi of bl.GATE_COLOR_PROBES) rect(frame, roi, [255, 255, 0]);
|
||||
rect(frame, bl.HEADER_TOP_BAND, [0, 255, 0]);
|
||||
rect(frame, bl.HEADER_BOTTOM_BAND, [0, 255, 0]);
|
||||
} else if (detector === "death") {
|
||||
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
|
||||
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* Usage: pnpm scanner:report
|
||||
*/
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import { createBattleLogDetector } from "../../app/features/scanner/core/detectors/battle-log/index";
|
||||
import {
|
||||
createDeathDetector,
|
||||
type DeathData,
|
||||
|
|
@ -59,6 +60,12 @@ const configs: Config[] = [
|
|||
fixturesDir: "scoreboard-replay",
|
||||
event: "ScoreboardReplay",
|
||||
},
|
||||
{
|
||||
label: "battle-log",
|
||||
detector: createBattleLogDetector(resources),
|
||||
fixturesDir: "battle-log",
|
||||
event: "BattleLog",
|
||||
},
|
||||
];
|
||||
|
||||
interface Tally {
|
||||
|
|
@ -190,8 +197,10 @@ for (const config of configs) {
|
|||
console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`header ${pct(tally.header)}`);
|
||||
if (config.event === "ScoreboardReplay") {
|
||||
if (tally.timestamp.total > 0) {
|
||||
console.info(`timestamp ${pct(tally.timestamp)}`);
|
||||
}
|
||||
if (tally.replayCode.total > 0) {
|
||||
console.info(`replayCode ${pct(tally.replayCode)}`);
|
||||
}
|
||||
console.info(`matchScores ${pct(tally.matchScores)}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user