CV housekeeping: biome, knip, docs

- biome sweep over app/features/cv + scripts/cv (tabs, a11y button types,
  CLI scripts get the repo's noConsole ignore convention); fixture corpus
  excluded from biome
- knip: vendored scripts/dicts ignored (knip's vite plugin started
  analyzing it once scripts/cv/vite-node.config.ts appeared), dead
  exports pruned
- app/features/cv/README.md carries the detector/atlas/fixture/gotcha
  documentation; AGENTS.md points to it and pins the fixture ground-truth
  and id-types rules
- assets/fonts/ gitignored (proprietary atlas-builder fonts)
This commit is contained in:
Kalle
2026-07-19 14:22:06 +03:00
parent abc36e3978
commit a8f8dd9a43
110 changed files with 24817 additions and 22968 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Build glyph atlases by rendering the Splatoon fonts at the exact
* scoreboard render sizes, calibrated against the reference fixture:
@@ -21,19 +22,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import {
DEATH_MESSAGE_TEMPLATES,
LOCALIZED_WEAPON_NAMES,
DEATH_MESSAGE_TEMPLATES,
LOCALIZED_WEAPON_NAMES,
} from "../../app/features/cv/core/detectors/death/localized-messages";
import { ALL_WEAPON_ENTRIES } from "../../app/features/cv/core/detectors/death/weapon-names";
import type { AtlasMeta } from "../../app/features/cv/core/glyphs";
import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir";
import {
ALL_LOBBY_ENTRIES,
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
RESULT_TAG_ENTRIES,
ALL_LOBBY_ENTRIES,
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
RESULT_TAG_ENTRIES,
} from "../../app/features/cv/core/localized";
import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir";
import { readImage, writePng } from "../../app/features/cv/node/image-io";
import { readFontCoverage } from "./otf-cmap";
@@ -49,12 +50,12 @@ const FONTS_DIR = new URL("../../assets/fonts", import.meta.url).pathname;
const OUT_DIR = join(CV_ASSETS_DIR, "glyphs");
const FONT_FILES = {
BlitzMain: "BlitzMain.otf",
BlitzBold: "BlitzBold.otf",
/** replay-browser code line and VICTORY/DEFEAT panel tags */
Rowdy: "FOT-RowdyStd-EB.otf",
/** JA death message (together with Rowdy — see death-weapon-ja) */
Kurokane: "FOT-KurokaneStd-EB.otf",
BlitzMain: "BlitzMain.otf",
BlitzBold: "BlitzBold.otf",
/** replay-browser code line and VICTORY/DEFEAT panel tags */
Rowdy: "FOT-RowdyStd-EB.otf",
/** JA death message (together with Rowdy — see death-weapon-ja) */
Kurokane: "FOT-KurokaneStd-EB.otf",
} as const;
/**
@@ -67,35 +68,35 @@ const FONT_FILES = {
const fontCoverage: Record<string, (codepoint: number) => boolean> = {};
for (const [family, file] of Object.entries(FONT_FILES)) {
const path = join(FONTS_DIR, file);
if (!existsSync(path)) {
console.error(
[
`font not found: ${path}`,
"",
"The Splatoon fonts are proprietary and not committed. Get",
"Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the",
"splatoon3-fonts repo) into assets/fonts/, or fall back to",
" pnpm cv:bootstrap-atlas",
].join("\n"),
);
process.exit(1);
}
GlobalFonts.registerFromPath(path, family);
fontCoverage[family] = readFontCoverage(path);
const path = join(FONTS_DIR, file);
if (!existsSync(path)) {
console.error(
[
`font not found: ${path}`,
"",
"The Splatoon fonts are proprietary and not committed. Get",
"Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the",
"splatoon3-fonts repo) into assets/fonts/, or fall back to",
" pnpm cv:bootstrap-atlas",
].join("\n"),
);
process.exit(1);
}
GlobalFonts.registerFromPath(path, family);
fontCoverage[family] = readFontCoverage(path);
}
const DIGITS = "0123456789";
function nameCharset(): string[] {
const chars: string[] = [];
for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII
for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1
for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana
for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana
chars.push("・", "ー", "、", "。", "「", "」");
chars.push("★", "☆", "●", "♪"); // name decorations
return [...new Set(chars)];
const chars: string[] = [];
for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII
for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1
for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana
for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana
chars.push("・", "ー", "、", "。", "「", "」");
chars.push("★", "☆", "●", "♪"); // name decorations
return [...new Set(chars)];
}
/**
@@ -121,7 +122,8 @@ const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symb
* duel it on ranking noise, and FF5E is the form the fixture labels attest.
* Like NAME_GREEK, scoreboard-names only (not death-tag) until attested.
*/
const NAME_SYMBOLS = "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~";
const NAME_SYMBOLS =
"′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~";
/**
* Render the key char's glyph but emit it as the value char: in-game names
@@ -131,32 +133,33 @@ const NAME_SYMBOLS = "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞
const RENDER_ALIASES: Record<string, string> = { "●": "•" };
interface GlyphBitmap {
char: string;
w: number;
h: number;
/** grayscale, white-on-black (alpha coverage) */
data: Uint8Array;
source: "fixture" | "font";
char: string;
w: number;
h: number;
/** grayscale, white-on-black (alpha coverage) */
data: Uint8Array;
source: "fixture" | "font";
}
/** Carry fixture-harvested glyphs over from the existing atlas, if any. */
async function readFixtureGlyphs(name: string): Promise<GlyphBitmap[]> {
const pngPath = join(OUT_DIR, `${name}.png`);
const jsonPath = join(OUT_DIR, `${name}.json`);
if (!existsSync(pngPath) || !existsSync(jsonPath)) return [];
const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta;
const png = await readImage(pngPath);
return meta.glyphs
.filter((g) => g.source === "fixture")
.map((g) => {
const data = new Uint8Array(g.w * g.h);
for (let y = 0; y < g.h; y++) {
for (let x = 0; x < g.w; x++) {
data[y * g.w + x] = png.data[((g.y + y) * png.width + (g.x + x)) * 4]!;
}
}
return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const };
});
const pngPath = join(OUT_DIR, `${name}.png`);
const jsonPath = join(OUT_DIR, `${name}.json`);
if (!existsSync(pngPath) || !existsSync(jsonPath)) return [];
const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta;
const png = await readImage(pngPath);
return meta.glyphs
.filter((g) => g.source === "fixture")
.map((g) => {
const data = new Uint8Array(g.w * g.h);
for (let y = 0; y < g.h; y++) {
for (let x = 0; x < g.w; x++) {
data[y * g.w + x] =
png.data[((g.y + y) * png.width + (g.x + x)) * 4]!;
}
}
return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const };
});
}
/**
@@ -164,145 +167,168 @@ async function readFixtureGlyphs(name: string): Promise<GlyphBitmap[]> {
* condenses the glyph horizontally (the JA death message renders its font
* squeezed to ~3/4 width in-game).
*/
function renderGlyph(family: string, px: number, char: string, xScale = 1): GlyphBitmap | null {
const size = px * 4;
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.font = `${px}px ${family}`;
ctx.textBaseline = "middle";
ctx.scale(xScale, 1);
ctx.fillText(char, px / xScale, size / 2);
const img = ctx.getImageData(0, 0, size, size);
let x0 = size;
let x1 = -1;
let y0 = size;
let y1 = -1;
let ink = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (img.data[(y * size + x) * 4 + 3]! > 128) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
ink++;
}
}
}
if (x1 < 0 || ink < 4) return null;
const pad = 1;
const w = x1 - x0 + 1 + 2 * pad;
const h = y1 - y0 + 1 + 2 * pad;
const data = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x0 - pad + x;
const sy = y0 - pad + y;
if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue;
data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!;
}
}
return { char, w, h, data, source: "font" };
function renderGlyph(
family: string,
px: number,
char: string,
xScale = 1,
): GlyphBitmap | null {
const size = px * 4;
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.font = `${px}px ${family}`;
ctx.textBaseline = "middle";
ctx.scale(xScale, 1);
ctx.fillText(char, px / xScale, size / 2);
const img = ctx.getImageData(0, 0, size, size);
let x0 = size;
let x1 = -1;
let y0 = size;
let y1 = -1;
let ink = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (img.data[(y * size + x) * 4 + 3]! > 128) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
ink++;
}
}
}
if (x1 < 0 || ink < 4) return null;
const pad = 1;
const w = x1 - x0 + 1 + 2 * pad;
const h = y1 - y0 + 1 + 2 * pad;
const data = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x0 - pad + x;
const sy = y0 - pad + y;
if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue;
data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!;
}
}
return { char, w, h, data, source: "font" };
}
/** Pack glyphs into rows (wrapping at maxWidth) and write PNG + JSON. */
function writeAtlas(name: string, height: number, glyphs: GlyphBitmap[]): void {
const spacing = 2;
const maxWidth = 1600;
const meta: AtlasMeta = { height, glyphs: [] };
let x = spacing;
let y = spacing;
let rowH = 0;
let atlasW = 0;
for (const g of glyphs) {
if (x + g.w + spacing > maxWidth) {
x = spacing;
y += rowH + spacing;
rowH = 0;
}
meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source });
x += g.w + spacing;
rowH = Math.max(rowH, g.h);
atlasW = Math.max(atlasW, x);
}
const atlasH = y + rowH + spacing;
const spacing = 2;
const maxWidth = 1600;
const meta: AtlasMeta = { height, glyphs: [] };
let x = spacing;
let y = spacing;
let rowH = 0;
let atlasW = 0;
for (const g of glyphs) {
if (x + g.w + spacing > maxWidth) {
x = spacing;
y += rowH + spacing;
rowH = 0;
}
meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source });
x += g.w + spacing;
rowH = Math.max(rowH, g.h);
atlasW = Math.max(atlasW, x);
}
const atlasH = y + rowH + spacing;
const pixels = new Uint8ClampedArray(atlasW * atlasH * 4);
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
meta.glyphs.forEach((m, i) => {
const g = glyphs[i]!;
for (let gy = 0; gy < g.h; gy++) {
for (let gx = 0; gx < g.w; gx++) {
const v = g.data[gy * g.w + gx]!;
const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4;
pixels[o] = v;
pixels[o + 1] = v;
pixels[o + 2] = v;
}
}
});
writePng(join(OUT_DIR, `${name}.png`), { width: atlasW, height: atlasH, data: pixels });
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
console.log(`${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`);
const pixels = new Uint8ClampedArray(atlasW * atlasH * 4);
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
meta.glyphs.forEach((m, i) => {
const g = glyphs[i]!;
for (let gy = 0; gy < g.h; gy++) {
for (let gx = 0; gx < g.w; gx++) {
const v = g.data[gy * g.w + gx]!;
const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4;
pixels[o] = v;
pixels[o + 1] = v;
pixels[o + 2] = v;
}
}
});
writePng(join(OUT_DIR, `${name}.png`), {
width: atlasW,
height: atlasH,
data: pixels,
});
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
console.info(
`${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`,
);
}
interface AtlasPart {
family: string;
/** in-game render size varies by ±1px; multiple template sizes per char */
pxs: number[];
chars: string[];
/** horizontal condensation applied in-game (default 1 = none) */
xScale?: number;
family: string;
/** in-game render size varies by ±1px; multiple template sizes per char */
pxs: number[];
chars: string[];
/** horizontal condensation applied in-game (default 1 = none) */
xScale?: number;
}
async function build(name: string, height: number, parts: AtlasPart[]): Promise<void> {
const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name);
const fixtureCount = glyphs.length;
const missing: string[] = [];
for (const { family, pxs, chars, xScale } of parts) {
for (const ch of chars) {
let found = false;
for (const px of pxs) {
const g = renderGlyph(family, px, ch, xScale ?? 1);
if (g) {
glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch });
found = true;
}
}
if (!found) missing.push(ch);
}
}
if (missing.length > 0) {
console.log(`${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`);
}
console.log(`${name}: carrying over ${fixtureCount} fixture-harvested glyphs`);
writeAtlas(name, height, glyphs);
async function build(
name: string,
height: number,
parts: AtlasPart[],
): Promise<void> {
const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name);
const fixtureCount = glyphs.length;
const missing: string[] = [];
for (const { family, pxs, chars, xScale } of parts) {
for (const ch of chars) {
let found = false;
for (const px of pxs) {
const g = renderGlyph(family, px, ch, xScale ?? 1);
if (g) {
glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch });
found = true;
}
}
if (!found) missing.push(ch);
}
}
if (missing.length > 0) {
console.info(
`${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`,
);
}
console.info(
`${name}: carrying over ${fixtureCount} fixture-harvested glyphs`,
);
writeAtlas(name, height, glyphs);
}
/** NAME_SYMBOLS chars the font actually maps (see the constant's comment). */
function nameSymbols(family: string): string[] {
const covers = fontCoverage[family]!;
return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!));
const covers = fontCoverage[family]!;
return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!));
}
/** Unique non-space characters across a set of known strings. */
function charsOf(entries: readonly string[]): string[] {
return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))];
return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))];
}
mkdirSync(OUT_DIR, { recursive: true });
await build("scoreboard-paint-digits", 29, [
{ family: "BlitzMain", pxs: [34], chars: [...DIGITS] },
{ family: "BlitzMain", pxs: [34], chars: [...DIGITS] },
]);
await build("scoreboard-stat-digits", 17, [
{ family: "BlitzMain", pxs: [20], chars: [...DIGITS] },
]);
await build("scoreboard-team-digits", 28, [
{ family: "BlitzBold", pxs: [36], chars: [...DIGITS] },
]);
await build("scoreboard-stat-digits", 17, [{ family: "BlitzMain", pxs: [20], chars: [...DIGITS] }]);
await build("scoreboard-team-digits", 28, [{ family: "BlitzBold", pxs: [36], chars: [...DIGITS] }]);
await build("scoreboard-names", 17, [
{
family: "BlitzMain",
pxs: [19, 20],
chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")],
},
{
family: "BlitzMain",
pxs: [19, 20],
chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")],
},
]);
/**
* Localized closed-set charsets (all 14 game languages; the canonical
@@ -316,11 +342,11 @@ await build("scoreboard-names", 17, [
* substitute into the atlas.
*/
function localizedChars(texts: readonly string[], family: string): string[] {
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => {
const cp = ch.codePointAt(0)!;
return cp < 0x250 && covers(cp);
});
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => {
const cp = ch.codePointAt(0)!;
return cp < 0x250 && covers(cp);
});
}
const lobbyTexts = ALL_LOBBY_ENTRIES.map((e) => e.text);
@@ -331,19 +357,39 @@ const resultTexts = RESULT_TAG_ENTRIES.map((e) => e.text);
// header: lobby tag is BlitzMain ~24px; the mode/stage line mixes bold mode
// text with regular stage text, so its atlas carries both faces
await build("scoreboard-header-lobby", 19, [
{ family: "BlitzMain", pxs: [23, 24], chars: localizedChars(lobbyTexts, "BlitzMain") },
{
family: "BlitzMain",
pxs: [23, 24],
chars: localizedChars(lobbyTexts, "BlitzMain"),
},
]);
await build("scoreboard-header-line", 24, [
{ family: "BlitzBold", pxs: [34, 35], chars: localizedChars(modeTexts, "BlitzBold") },
{ family: "BlitzMain", pxs: [22, 23], chars: localizedChars(stageTexts, "BlitzMain") },
{
family: "BlitzBold",
pxs: [34, 35],
chars: localizedChars(modeTexts, "BlitzBold"),
},
{
family: "BlitzMain",
pxs: [22, 23],
chars: localizedChars(stageTexts, "BlitzMain"),
},
]);
// replay browser: the code line and the VICTORY/DEFEAT panel tags render in
// FOT-RowdyStd-EB (tight height 25px at ~32px, 30px at ~38px)
await build("scoreboard-replay-code", 25, [
{ family: "Rowdy", pxs: [31, 32, 33], chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"] },
{
family: "Rowdy",
pxs: [31, 32, 33],
chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"],
},
]);
await build("scoreboard-replay-result", 30, [
{ family: "Rowdy", pxs: [37, 38], chars: localizedChars(resultTexts, "Rowdy") },
{
family: "Rowdy",
pxs: [37, 38],
chars: localizedChars(resultTexts, "Rowdy"),
},
]);
// map-start intro splash: the big mode title on the center splat is
// BlitzBold (~76px tight caps at 1080p; a px sweep against the fixture reads
@@ -352,14 +398,18 @@ await build("scoreboard-replay-result", 30, [
// too and is read with the stage atlas rescaled to its ~48px height, so its
// chars ride along.
await build("map-start-mode", 76, [
{ family: "BlitzBold", pxs: [99, 100, 101], chars: localizedChars(modeTexts, "BlitzBold") },
{
family: "BlitzBold",
pxs: [99, 100, 101],
chars: localizedChars(modeTexts, "BlitzBold"),
},
]);
await build("map-start-stage", 40, [
{
family: "BlitzMain",
pxs: [46, 47, 48],
chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"),
},
{
family: "BlitzMain",
pxs: [46, 47, 48],
chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"),
},
]);
// death screen: the localized "Splatted by <weapon>!" burst message (tight
// caps ~28px; the face reads between the two Blitz cuts at capture
@@ -367,13 +417,27 @@ await build("map-start-stage", 40, [
// splash-tag name, which is BlitzBold for latin but the angular Rowdy face
// for kana
const deathWeaponTexts = [
...ALL_WEAPON_ENTRIES.map((e) => e.name),
...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) => names.map((n) => n.text)),
...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]),
...ALL_WEAPON_ENTRIES.map((e) => e.name),
...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) =>
names.map((n) => n.text),
),
...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [
t.constText,
t.weaponPre,
t.weaponPost,
]),
];
await build("death-weapon", 34, [
{ family: "BlitzMain", pxs: [40, 41], chars: localizedChars(deathWeaponTexts, "BlitzMain") },
{ family: "BlitzBold", pxs: [43, 44], chars: localizedChars(deathWeaponTexts, "BlitzBold") },
{
family: "BlitzMain",
pxs: [40, 41],
chars: localizedChars(deathWeaponTexts, "BlitzMain"),
},
{
family: "BlitzBold",
pxs: [43, 44],
chars: localizedChars(deathWeaponTexts, "BlitzBold"),
},
]);
// JA death message, a separate atlas read only by the JA line ROIs: mixing
// kana into the Latin set would shift its median width (breaking wide-
@@ -385,30 +449,33 @@ await build("death-weapon", 34, [
// (LACT-450, .52ガロン) ride along in both faces. Attested-fixture rule as
// everywhere: KO/ZH names stay in the closed sets without an atlas.
const deathJaTexts = [
...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text),
...DEATH_MESSAGE_TEMPLATES.filter((t) => t.langs.some((l) => l.endsWith("ja"))).flatMap((t) => [
t.constText,
t.weaponPre,
t.weaponPost,
]),
...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text),
...DEATH_MESSAGE_TEMPLATES.filter((t) =>
t.langs.some((l) => l.endsWith("ja")),
).flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]),
];
function coveredChars(texts: readonly string[], family: string): string[] {
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!));
const covers = fontCoverage[family]!;
return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!));
}
await build("death-weapon-ja", 40, [
{
family: "Kurokane",
pxs: [44, 46],
xScale: 0.75,
chars: coveredChars(deathJaTexts, "Kurokane"),
},
{ family: "Rowdy", pxs: [38, 40], xScale: 0.8, chars: coveredChars(deathJaTexts, "Rowdy") },
{
family: "Kurokane",
pxs: [44, 46],
xScale: 0.75,
chars: coveredChars(deathJaTexts, "Kurokane"),
},
{
family: "Rowdy",
pxs: [38, 40],
xScale: 0.8,
chars: coveredChars(deathJaTexts, "Rowdy"),
},
]);
// nominal 42 with the detector scaling to 46 reads strictly better than a
// native-46 render (the cubic upscale slightly fattens strokes the way the
// in-game compositing does; a native render splits か into two segments)
await build("death-tag-name", 42, [
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
]);

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Generate the localized closed sets from the splat3 repo's language dumps
* (https://github.com/Leanny/splat3, data/language/<Lang>_full.json), so
@@ -26,21 +27,22 @@
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import gameMisc from "../../locales/en/game-misc.json";
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types";
import { ALL_WEAPON_ENTRIES } from "../../app/features/cv/core/detectors/death/weapon-names";
import type { CvLobby } from "../../app/features/cv/cv-types";
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types";
import gameMisc from "../../locales/en/game-misc.json";
const SPLAT3_DIR = process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname;
const SPLAT3_DIR =
process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname;
const LANG_DIR = join(SPLAT3_DIR, "data", "language");
const OUT_ENTRIES = new URL(
"../../app/features/cv/core/localized-entries.ts",
import.meta.url,
"../../app/features/cv/core/localized-entries.ts",
import.meta.url,
).pathname;
const OUT_MESSAGES = new URL(
"../../app/features/cv/core/detectors/death/localized-messages.ts",
import.meta.url,
"../../app/features/cv/core/detectors/death/localized-messages.ts",
import.meta.url,
).pathname;
const CANONICAL_LANG = "USen";
@@ -48,66 +50,70 @@ const misc = gameMisc as Record<string, string>;
/** VSRuleName key -> ModeShort; USen values validate against the en locale. */
const RULE_KEYS: Record<string, ModeShort> = {
Pnt: "TW",
Var: "SZ",
Vlf: "TC",
Vgl: "RM",
Vcl: "CB",
Pnt: "TW",
Var: "SZ",
Vlf: "TC",
Vgl: "RM",
Vcl: "CB",
};
/** MatchMode key -> lobby code; USen values validate against these names. */
const LOBBY_KEYS: Record<string, CvLobby> = {
XMatch: "X",
Bankara: "SERIES",
BankaraOpen: "OPEN",
Private: "PRIVATE",
XMatch: "X",
Bankara: "SERIES",
BankaraOpen: "OPEN",
Private: "PRIVATE",
};
/** the English lobby tags as the game shows them, for USen validation */
const LOBBY_ENGLISH: Record<CvLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
type LangDump = Record<string, Record<string, string>>;
function loadLang(lang: string): LangDump {
return JSON.parse(readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8")) as LangDump;
return JSON.parse(
readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8"),
) as LangDump;
}
/** Drop [size=...]/[color=...]-style markup and collapse whitespace. */
function clean(s: string): string {
return s
.replace(/\[[^\]]*\]/g, "")
.replace(/[ \t]+/g, " ")
.trim();
return s
.replace(/\[[^\]]*\]/g, "")
.replace(/[ \t]+/g, " ")
.trim();
}
/** The case/space/diacritic-insensitive key entries are deduped on (mirrors text.ts). */
function foldKey(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "");
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "");
}
/** sendou.ink locales use the ASCII apostrophe; the game dumps use . */
function normalizeApostrophes(s: string): string {
return s.replace(/[]/g, "'");
return s.replace(/[]/g, "'");
}
const languages = [
...new Set(
readdirSync(LANG_DIR)
.filter((f) => f.endsWith("_full.json"))
.map((f) => f.replace("_full.json", "")),
),
...new Set(
readdirSync(LANG_DIR)
.filter((f) => f.endsWith("_full.json"))
.map((f) => f.replace("_full.json", "")),
),
].sort();
if (!languages.includes(CANONICAL_LANG)) {
throw new Error(`canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`);
throw new Error(
`canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`,
);
}
const dumps = new Map<string, LangDump>(languages.map((l) => [l, loadLang(l)]));
@@ -116,108 +122,115 @@ const usen = dumps.get(CANONICAL_LANG)!;
// ---- validate the canonical sets against USen ------------------------------
for (const [key, mode] of Object.entries(RULE_KEYS)) {
const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!);
const expected = misc[`MODE_LONG_${mode}`]!;
if (value !== expected)
throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`);
const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!);
const expected = misc[`MODE_LONG_${mode}`]!;
if (value !== expected)
throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`);
}
for (const [key, lobby] of Object.entries(LOBBY_KEYS)) {
const value = clean(usen["CommonMsg/MatchMode"]![key]!);
const expected = LOBBY_ENGLISH[lobby as CvLobby];
if (value !== expected)
throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`);
const value = clean(usen["CommonMsg/MatchMode"]![key]!);
const expected = LOBBY_ENGLISH[lobby as CvLobby];
if (value !== expected)
throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`);
}
/** English stage name (per the sendou.ink en locale) -> StageId. */
const stageIdByEnglishName = new Map<string, StageId>(
stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]),
stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]),
);
/** VSStageName key -> StageId, via the USen values. */
const stageKeys = new Map<string, StageId>();
for (const [key, value] of Object.entries(usen["CommonMsg/VS/VSStageName"]!)) {
const stageId = stageIdByEnglishName.get(value);
if (stageId !== undefined) stageKeys.set(key, stageId);
const stageId = stageIdByEnglishName.get(value);
if (stageId !== undefined) stageKeys.set(key, stageId);
}
for (const [name, stageId] of stageIdByEnglishName) {
if (![...stageKeys.values()].includes(stageId)) {
throw new Error(`stage "${name}" (id ${stageId}) not found in USen VSStageName`);
}
if (![...stageKeys.values()].includes(stageId)) {
throw new Error(
`stage "${name}" (id ${stageId}) not found in USen VSStageName`,
);
}
}
// ---- per-language closed sets ----------------------------------------------
interface LocalizedLobby {
text: string;
lobby: CvLobby;
text: string;
lobby: CvLobby;
}
interface LocalizedMode {
text: string;
mode: ModeShort;
text: string;
mode: ModeShort;
}
interface LocalizedStage {
text: string;
stageId: StageId;
text: string;
stageId: StageId;
}
interface LanguageEntries {
lang: string;
modeLabel: string;
victory: string;
defeat: string;
lobbies: LocalizedLobby[];
modes: LocalizedMode[];
modeWraps: LocalizedMode[];
stages: LocalizedStage[];
lang: string;
modeLabel: string;
victory: string;
defeat: string;
lobbies: LocalizedLobby[];
modes: LocalizedMode[];
modeWraps: LocalizedMode[];
stages: LocalizedStage[];
}
const languageEntries: LanguageEntries[] = [];
for (const lang of languages) {
const d = dumps.get(lang)!;
const rules = d["CommonMsg/VS/VSRuleName"]!;
const modes: LocalizedMode[] = [];
const modeWraps: LocalizedMode[] = [];
for (const [key, mode] of Object.entries(RULE_KEYS)) {
modes.push({ text: clean(rules[key]!), mode });
// the intro splash renders the _2L wrap variant, hyphens included
const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " "));
if (foldKey(wrap) !== foldKey(rules[key]!)) modeWraps.push({ text: wrap, mode });
}
languageEntries.push({
lang,
modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!),
victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!),
defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!),
lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({
text: clean(d["CommonMsg/MatchMode"]![key]!),
lobby,
})),
modes,
modeWraps,
stages: [...stageKeys.entries()].map(([key, stageId]) => ({
text: clean(d["CommonMsg/VS/VSStageName"]![key]!),
stageId,
})),
});
const d = dumps.get(lang)!;
const rules = d["CommonMsg/VS/VSRuleName"]!;
const modes: LocalizedMode[] = [];
const modeWraps: LocalizedMode[] = [];
for (const [key, mode] of Object.entries(RULE_KEYS)) {
modes.push({ text: clean(rules[key]!), mode });
// the intro splash renders the _2L wrap variant, hyphens included
const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " "));
if (foldKey(wrap) !== foldKey(rules[key]!))
modeWraps.push({ text: wrap, mode });
}
languageEntries.push({
lang,
modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!),
victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!),
defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!),
lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({
text: clean(d["CommonMsg/MatchMode"]![key]!),
lobby,
})),
modes,
modeWraps,
stages: [...stageKeys.entries()].map(([key, stageId]) => ({
text: clean(d["CommonMsg/VS/VSStageName"]![key]!),
stageId,
})),
});
}
// A localized string that means one thing in language A and another in
// language B would snap ambiguously in the flattened unions — reject.
for (const category of ["lobbies", "modes", "modeWraps", "stages"] as const) {
const seen = new Map<string, string | number>();
for (const entries of languageEntries) {
for (const entry of entries[category]) {
const value =
"lobby" in entry ? entry.lobby : "mode" in entry ? entry.mode : entry.stageId;
const k = foldKey(entry.text);
const prior = seen.get(k);
if (prior !== undefined && prior !== value) {
throw new Error(
`${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`,
);
}
seen.set(k, value);
}
}
const seen = new Map<string, string | number>();
for (const entries of languageEntries) {
for (const entry of entries[category]) {
const value =
"lobby" in entry
? entry.lobby
: "mode" in entry
? entry.mode
: entry.stageId;
const k = foldKey(entry.text);
const prior = seen.get(k);
if (prior !== undefined && prior !== value) {
throw new Error(
`${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`,
);
}
seen.set(k, value);
}
}
}
// ---- death message templates -----------------------------------------------
@@ -227,94 +240,110 @@ const PLACEHOLDER = /\[group=[^\]]*\]/;
const SENTINEL = "\u0000";
interface DeathTemplate {
langs: string[];
weaponLine: 1 | 2;
constText: string;
weaponPre: string;
weaponPost: string;
langs: string[];
weaponLine: 1 | 2;
constText: string;
weaponPre: string;
weaponPost: string;
}
const templates: DeathTemplate[] = [];
for (const lang of languages) {
const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!;
const lines = raw.split("\n").map((l) => clean(l.replace(PLACEHOLDER, SENTINEL)));
if (lines.length !== 2) throw new Error(`${lang}: death message is not two lines: ${raw}`);
const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL));
if (weaponIndex < 0) throw new Error(`${lang}: no weapon placeholder: ${raw}`);
const weaponLine = (weaponIndex + 1) as 1 | 2;
const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [string, string];
const constText = lines[weaponLine % 2]!;
const existing = templates.find(
(t) =>
t.weaponLine === weaponLine &&
t.constText === constText &&
t.weaponPre === pre &&
t.weaponPost === post,
);
if (existing) existing.langs.push(lang);
else templates.push({ langs: [lang], weaponLine, constText, weaponPre: pre, weaponPost: post });
const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!;
const lines = raw
.split("\n")
.map((l) => clean(l.replace(PLACEHOLDER, SENTINEL)));
if (lines.length !== 2)
throw new Error(`${lang}: death message is not two lines: ${raw}`);
const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL));
if (weaponIndex < 0)
throw new Error(`${lang}: no weapon placeholder: ${raw}`);
const weaponLine = (weaponIndex + 1) as 1 | 2;
const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [
string,
string,
];
const constText = lines[weaponLine % 2]!;
const existing = templates.find(
(t) =>
t.weaponLine === weaponLine &&
t.constText === constText &&
t.weaponPre === pre &&
t.weaponPost === post,
);
if (existing) existing.langs.push(lang);
else
templates.push({
langs: [lang],
weaponLine,
constText,
weaponPre: pre,
weaponPost: post,
});
}
// ---- localized weapon names --------------------------------------------------
const WEAPON_MSGS = [
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
"CommonMsg/Weapon/WeaponName_Special",
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
"CommonMsg/Weapon/WeaponName_Special",
];
const canonicalWeaponNames = new Set(ALL_WEAPON_ENTRIES.map((e) => e.name));
/** codename -> canonical entry name, via the USen value. */
const weaponCodenames = new Map<string, string>();
for (const msg of WEAPON_MSGS) {
for (const [codename, value] of Object.entries(usen[msg]!)) {
const name = normalizeApostrophes(value);
if (canonicalWeaponNames.has(name)) weaponCodenames.set(`${msg} ${codename}`, name);
}
for (const [codename, value] of Object.entries(usen[msg]!)) {
const name = normalizeApostrophes(value);
if (canonicalWeaponNames.has(name))
weaponCodenames.set(`${msg} ${codename}`, name);
}
}
const unmapped = [...canonicalWeaponNames].filter(
(n) => ![...weaponCodenames.values()].includes(n),
(n) => ![...weaponCodenames.values()].includes(n),
);
if (unmapped.length > 0) {
console.warn(
`WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`,
);
console.warn(
`WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`,
);
}
/** lang -> localized names that differ from the canonical English name. */
const localizedWeaponNames: Record<string, { text: string; name: string }[]> = {};
const localizedWeaponNames: Record<string, { text: string; name: string }[]> =
{};
for (const lang of languages) {
const d = dumps.get(lang)!;
const seen = new Set<string>();
const entries: { text: string; name: string }[] = [];
for (const [key, name] of weaponCodenames) {
const [msg, codename] = key.split(" ") as [string, string];
const text = clean(d[msg]![codename] ?? "");
if (!text || text === "-") continue;
const k = foldKey(text);
if (seen.has(k) || k === foldKey(name)) continue;
seen.add(k);
entries.push({ text, name });
}
if (entries.length > 0) localizedWeaponNames[lang] = entries;
const d = dumps.get(lang)!;
const seen = new Set<string>();
const entries: { text: string; name: string }[] = [];
for (const [key, name] of weaponCodenames) {
const [msg, codename] = key.split(" ") as [string, string];
const text = clean(d[msg]![codename] ?? "");
if (!text || text === "-") continue;
const k = foldKey(text);
if (seen.has(k) || k === foldKey(name)) continue;
seen.add(k);
entries.push({ text, name });
}
if (entries.length > 0) localizedWeaponNames[lang] = entries;
}
// ---- emit --------------------------------------------------------------------
const banner = (extra: string) =>
`/**
`/**
* GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's
* language dumps — do not edit by hand; regenerate when the game adds
* content. ${extra}
*/`;
writeFileSync(
OUT_ENTRIES,
`${banner(
`Localized versus-UI strings for every game language, each
OUT_ENTRIES,
`${banner(
`Localized versus-UI strings for every game language, each
* mapped to the sendou.ink id it means (core/localized.ts derives the
* flattened match sets detectors snap OCR output against).`,
)}
)}
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CvLobby } from "../cv-types";
@@ -348,20 +377,20 @@ export interface LanguageEntries {
}
export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = ${JSON.stringify(
languageEntries,
null,
2,
)};
languageEntries,
null,
2,
)};
`,
);
writeFileSync(
OUT_MESSAGES,
`${banner(
`Per-language death-burst message templates and localized
OUT_MESSAGES,
`${banner(
`Per-language death-burst message templates and localized
* weapon names: the "Splatted by\\n<weapon>!" burst puts the weapon on line
* 1 or 2 depending on language, with language-specific text around it.`,
)}
)}
export interface DeathMessageTemplate {
/** languages sharing this exact template */
@@ -376,10 +405,10 @@ export interface DeathMessageTemplate {
}
export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = ${JSON.stringify(
templates,
null,
2,
)};
templates,
null,
2,
)};
export interface LocalizedWeaponName {
text: string;
@@ -397,11 +426,11 @@ export const LOCALIZED_WEAPON_NAMES: Readonly<
`,
);
console.log(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
console.info(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
);
console.log(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
console.info(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
);

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Build the planner signature atlas from the assets repo's planner renders.
*
@@ -17,84 +18,94 @@ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV } from "../../app/features/cv/core/cv";
import {
PLANNER_SIG_H,
PLANNER_SIG_W,
type PlannerManifest,
plannerSignature,
PLANNER_SIG_H,
PLANNER_SIG_W,
type PlannerManifest,
plannerSignature,
} from "../../app/features/cv/core/detectors/minimap/stage";
import { type FrameData, normalizeFrame, toMat } from "../../app/features/cv/core/image";
import {
type FrameData,
normalizeFrame,
toMat,
} from "../../app/features/cv/core/image";
import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir";
import { readImage, writePng } from "../../app/features/cv/node/image-io";
/** which render variant the signatures are built from */
const PLANNER_TYPE = process.env.CV_PLANNER_TYPE ?? "MINI";
const SRC_DIR =
process.env.CV_PLANNER_MAPS_DIR ??
new URL("../../../assets/assets/planner-maps", import.meta.url).pathname;
const OUT_DIR = process.env.CV_PLANNER_OUT_DIR ?? join(CV_ASSETS_DIR, "planner");
process.env.CV_PLANNER_MAPS_DIR ??
new URL("../../../assets/assets/planner-maps", import.meta.url).pathname;
const OUT_DIR =
process.env.CV_PLANNER_OUT_DIR ?? join(CV_ASSETS_DIR, "planner");
const COLS = 5;
await loadOpenCV();
const files = readdirSync(SRC_DIR)
.filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`))
.sort((a, b) => {
const [sa, ma] = a.split("-");
const [sb, mb] = b.split("-");
return Number(sa) - Number(sb) || ma!.localeCompare(mb!);
});
.filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`))
.sort((a, b) => {
const [sa, ma] = a.split("-");
const [sb, mb] = b.split("-");
return Number(sa) - Number(sb) || ma!.localeCompare(mb!);
});
if (files.length === 0) {
throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`);
throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`);
}
const rows = Math.ceil(files.length / COLS);
const atlasW = COLS * PLANNER_SIG_W;
const atlasH = rows * PLANNER_SIG_H;
const atlas: FrameData = {
width: atlasW,
height: atlasH,
data: new Uint8ClampedArray(atlasW * atlasH * 4),
width: atlasW,
height: atlasH,
data: new Uint8ClampedArray(atlasW * atlasH * 4),
};
const keys: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i]!;
const key = file.replace(`-${PLANNER_TYPE}.png`, "");
keys.push(key);
const file = files[i]!;
const key = file.replace(`-${PLANNER_TYPE}.png`, "");
keys.push(key);
const img = toMat(await readImage(join(SRC_DIR, file)));
const norm = normalizeFrame(img);
const sig = plannerSignature(norm);
img.delete();
norm.delete();
const img = toMat(await readImage(join(SRC_DIR, file)));
const norm = normalizeFrame(img);
const sig = plannerSignature(norm);
img.delete();
norm.delete();
// quantize to uint8 (load re-normalizes to unit L2, so the scale is free)
let max = 0;
for (const v of sig) if (v > max) max = v;
const scale = max > 0 ? 255 / max : 0;
// quantize to uint8 (load re-normalizes to unit L2, so the scale is free)
let max = 0;
for (const v of sig) if (v > max) max = v;
const scale = max > 0 ? 255 / max : 0;
const tx = (i % COLS) * PLANNER_SIG_W;
const ty = Math.floor(i / COLS) * PLANNER_SIG_H;
for (let y = 0; y < PLANNER_SIG_H; y++) {
for (let x = 0; x < PLANNER_SIG_W; x++) {
const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale);
const o = ((ty + y) * atlasW + (tx + x)) * 4;
atlas.data[o] = v;
atlas.data[o + 1] = v;
atlas.data[o + 2] = v;
atlas.data[o + 3] = 255;
}
}
const tx = (i % COLS) * PLANNER_SIG_W;
const ty = Math.floor(i / COLS) * PLANNER_SIG_H;
for (let y = 0; y < PLANNER_SIG_H; y++) {
for (let x = 0; x < PLANNER_SIG_W; x++) {
const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale);
const o = ((ty + y) * atlasW + (tx + x)) * 4;
atlas.data[o] = v;
atlas.data[o + 1] = v;
atlas.data[o + 2] = v;
atlas.data[o + 3] = 255;
}
}
}
mkdirSync(OUT_DIR, { recursive: true });
writePng(join(OUT_DIR, "signatures.png"), atlas);
const manifest: PlannerManifest = {
width: PLANNER_SIG_W,
height: PLANNER_SIG_H,
cols: COLS,
keys,
width: PLANNER_SIG_W,
height: PLANNER_SIG_H,
cols: COLS,
keys,
};
writeFileSync(join(OUT_DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(
join(OUT_DIR, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
console.log(`packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`);
console.info(
`packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`,
);

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* ROI calibration helper: normalize a frame to 1080p, then dump crops
* (optionally scaled up) and/or a grid overlay for visual inspection.
@@ -9,15 +10,20 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV } from "../../app/features/cv/core/cv";
import { cropRoi, matToFrameData, normalizeFrame, toMat } from "../../app/features/cv/core/image";
import {
cropRoi,
matToFrameData,
normalizeFrame,
toMat,
} from "../../app/features/cv/core/image";
import { readImage, writePng } from "../../app/features/cv/node/image-io";
const [imagePath, outDir, ...specs] = process.argv.slice(2);
if (!imagePath || !outDir || specs.length === 0) {
console.error(
"usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/dump-crops.ts <image> <outdir> (grid | x,y,w,h[,scale][:label])...",
);
process.exit(1);
console.error(
"usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/dump-crops.ts <image> <outdir> (grid | x,y,w,h[,scale][:label])...",
);
process.exit(1);
}
const cv = await loadOpenCV();
@@ -28,53 +34,60 @@ const frame = normalizeFrame(src);
src.delete();
for (const spec of specs) {
if (spec === "grid") {
const overlay = frame.clone();
for (let x = 0; x < overlay.cols; x += 20) {
const major = x % 100 === 0;
cv.line(
overlay,
new cv.Point(x, 0),
new cv.Point(x, overlay.rows),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
for (let y = 0; y < overlay.rows; y += 20) {
const major = y % 100 === 0;
cv.line(
overlay,
new cv.Point(0, y),
new cv.Point(overlay.cols, y),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
writePng(join(outDir, "grid.png"), matToFrameData(overlay));
overlay.delete();
continue;
}
const [rect = "", label] = spec.split(":");
const [x, y, w, h, scale = 1] = rect.split(",").map(Number);
if ([x, y, w, h].some((v) => !Number.isFinite(v))) {
console.error(`bad spec: ${spec}`);
continue;
}
const cx = Math.max(0, Math.min(x!, frame.cols - 1));
const cy = Math.max(0, Math.min(y!, frame.rows - 1));
const crop = cropRoi(frame, {
x: cx,
y: cy,
w: Math.min(w!, frame.cols - cx),
h: Math.min(h!, frame.rows - cy),
});
const out = new cv.Mat();
cv.resize(crop, out, new cv.Size(0, 0), Number(scale), Number(scale), cv.INTER_NEAREST);
const name = label ?? `crop-${x}-${y}-${w}x${h}`;
writePng(join(outDir, `${name}.png`), matToFrameData(out));
crop.delete();
out.delete();
if (spec === "grid") {
const overlay = frame.clone();
for (let x = 0; x < overlay.cols; x += 20) {
const major = x % 100 === 0;
cv.line(
overlay,
new cv.Point(x, 0),
new cv.Point(x, overlay.rows),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
for (let y = 0; y < overlay.rows; y += 20) {
const major = y % 100 === 0;
cv.line(
overlay,
new cv.Point(0, y),
new cv.Point(overlay.cols, y),
new cv.Scalar(255, 0, major ? 0 : 255, 255),
major ? 2 : 1,
);
}
writePng(join(outDir, "grid.png"), matToFrameData(overlay));
overlay.delete();
continue;
}
const [rect = "", label] = spec.split(":");
const [x, y, w, h, scale = 1] = rect.split(",").map(Number);
if ([x, y, w, h].some((v) => !Number.isFinite(v))) {
console.error(`bad spec: ${spec}`);
continue;
}
const cx = Math.max(0, Math.min(x!, frame.cols - 1));
const cy = Math.max(0, Math.min(y!, frame.rows - 1));
const crop = cropRoi(frame, {
x: cx,
y: cy,
w: Math.min(w!, frame.cols - cx),
h: Math.min(h!, frame.rows - cy),
});
const out = new cv.Mat();
cv.resize(
crop,
out,
new cv.Size(0, 0),
Number(scale),
Number(scale),
cv.INTER_NEAREST,
);
const name = label ?? `crop-${x}-${y}-${w}x${h}`;
writePng(join(outDir, `${name}.png`), matToFrameData(out));
crop.delete();
out.delete();
}
frame.delete();
console.log(`wrote crops to ${outDir}`);
console.info(`wrote crops to ${outDir}`);

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Minimal OpenType cmap reader: which codepoints does a font actually map?
*
@@ -11,49 +12,50 @@
import { readFileSync } from "node:fs";
export function readFontCoverage(path: string): (codepoint: number) => boolean {
const buf = readFileSync(path);
const numTables = buf.readUInt16BE(4);
let cmapOffset = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
if (buf.toString("latin1", rec, rec + 4) === "cmap") {
cmapOffset = buf.readUInt32BE(rec + 8);
break;
}
}
if (cmapOffset < 0) throw new Error(`${path}: no cmap table`);
const buf = readFileSync(path);
const numTables = buf.readUInt16BE(4);
let cmapOffset = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
if (buf.toString("latin1", rec, rec + 4) === "cmap") {
cmapOffset = buf.readUInt32BE(rec + 8);
break;
}
}
if (cmapOffset < 0) throw new Error(`${path}: no cmap table`);
// prefer a full-repertoire format-12 subtable, else the BMP format 4
const encodingCount = buf.readUInt16BE(cmapOffset + 2);
let best = -1;
let bestFormat = -1;
for (let i = 0; i < encodingCount; i++) {
const rec = cmapOffset + 4 + i * 8;
const subtable = cmapOffset + buf.readUInt32BE(rec + 4);
const format = buf.readUInt16BE(subtable);
if (format === 12 || (format === 4 && bestFormat !== 12)) {
best = subtable;
bestFormat = format;
}
}
if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`);
// prefer a full-repertoire format-12 subtable, else the BMP format 4
const encodingCount = buf.readUInt16BE(cmapOffset + 2);
let best = -1;
let bestFormat = -1;
for (let i = 0; i < encodingCount; i++) {
const rec = cmapOffset + 4 + i * 8;
const subtable = cmapOffset + buf.readUInt32BE(rec + 4);
const format = buf.readUInt16BE(subtable);
if (format === 12 || (format === 4 && bestFormat !== 12)) {
best = subtable;
bestFormat = format;
}
}
if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`);
const ranges: [number, number][] = [];
if (bestFormat === 12) {
const nGroups = buf.readUInt32BE(best + 12);
for (let g = 0; g < nGroups; g++) {
const rec = best + 16 + g * 12;
ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]);
}
} else {
const segCount = buf.readUInt16BE(best + 6) / 2;
const endCodes = best + 14;
const startCodes = endCodes + segCount * 2 + 2;
for (let s = 0; s < segCount; s++) {
const start = buf.readUInt16BE(startCodes + s * 2);
const end = buf.readUInt16BE(endCodes + s * 2);
if (start !== 0xffff) ranges.push([start, end]);
}
}
return (codepoint) => ranges.some(([start, end]) => codepoint >= start && codepoint <= end);
const ranges: [number, number][] = [];
if (bestFormat === 12) {
const nGroups = buf.readUInt32BE(best + 12);
for (let g = 0; g < nGroups; g++) {
const rec = best + 16 + g * 12;
ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]);
}
} else {
const segCount = buf.readUInt16BE(best + 6) / 2;
const endCodes = best + 14;
const startCodes = endCodes + segCount * 2 + 2;
for (let s = 0; s < segCount; s++) {
const start = buf.readUInt16BE(startCodes + s * 2);
const end = buf.readUInt16BE(endCodes + s * 2);
if (start !== 0xffff) ranges.push([start, end]);
}
}
return (codepoint) =>
ranges.some(([start, end]) => codepoint >= start && codepoint <= end);
}

View File

@@ -1,3 +1,4 @@
/** 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/cv/vite-node.config.ts scripts/cv/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay]
@@ -8,15 +9,21 @@ import * as mapStart from "../../app/features/cv/core/detectors/map-start/rois";
import * as minimap from "../../app/features/cv/core/detectors/minimap/rois";
import * as sb from "../../app/features/cv/core/detectors/scoreboard/rois";
import * as replay from "../../app/features/cv/core/detectors/scoreboard-replay/rois";
import { matToFrameData, normalizeFrame, type Roi, toMat } from "../../app/features/cv/core/image";
import {
matToFrameData,
normalizeFrame,
type Roi,
toMat,
} from "../../app/features/cv/core/image";
import { readImage, writePng } from "../../app/features/cv/node/image-io";
const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] = process.argv.slice(2);
const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
process.argv.slice(2);
if (!imagePath) {
console.error(
"usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]",
);
process.exit(1);
console.error(
"usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]",
);
process.exit(1);
}
const cv = await loadOpenCV();
@@ -25,87 +32,92 @@ const frame = normalizeFrame(src);
src.delete();
function rect(m: Mat, roi: Roi, color: [number, number, number]) {
cv.rectangle(
m,
new cv.Point(roi.x, roi.y),
new cv.Point(roi.x + roi.w, roi.y + roi.h),
new cv.Scalar(...color, 255),
1,
);
cv.rectangle(
m,
new cv.Point(roi.x, roi.y),
new cv.Point(roi.x + roi.w, roi.y + roi.h),
new cv.Scalar(...color, 255),
1,
);
}
if (detector === "scoreboard") {
for (const cy of sb.ROW_CENTERS) {
rect(frame, sb.weaponRoi(cy), [255, 0, 0]);
rect(frame, sb.nameRoi(cy), [0, 255, 0]);
rect(frame, sb.paintRoi(cy), [0, 128, 255]);
for (const i of [0, 1, 2] as const) rect(frame, sb.statRoi(cy, i), [255, 0, 255]);
rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]);
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]);
for (const cy of sb.ROW_CENTERS) {
rect(frame, sb.weaponRoi(cy), [255, 0, 0]);
rect(frame, sb.nameRoi(cy), [0, 255, 0]);
rect(frame, sb.paintRoi(cy), [0, 128, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, sb.statRoi(cy, i), [255, 0, 255]);
rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]);
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]);
} else if (detector === "scoreboard-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]);
rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]);
rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]);
rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]);
for (const i of [0, 1, 2] as const) rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]);
rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]);
}
rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]);
rect(frame, replay.resultTagRoi(dx), [255, 128, 0]);
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]);
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]);
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]);
rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]);
rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]);
rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]);
rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]);
}
rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]);
rect(frame, replay.resultTagRoi(dx), [255, 128, 0]);
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]);
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 === "death") {
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(frame, death.abilityMainRoi(row), [0, 128, 255]);
for (const slot of [0, 1, 2]) rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]);
}
rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]);
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
rect(frame, roi, [255, 255, 0]);
}
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(frame, death.abilityMainRoi(row), [0, 128, 255]);
for (const slot of [0, 1, 2])
rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]);
}
rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]);
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
rect(frame, roi, [255, 255, 0]);
}
} else if (detector === "map-start") {
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);
rect(frame, mapStart.STAGE_ROI, [0, 128, 255]);
rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]);
for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]);
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);
rect(frame, mapStart.STAGE_ROI, [0, 128, 255]);
rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]);
for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]);
} else if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(frame, card.name, [0, 255, 0]);
rect(frame, card.weapon, [255, 0, 0]);
rect(frame, card.subTile, [0, 255, 255]);
for (const [cx, cy] of card.badges) rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, card.cross, [255, 0, 255]);
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]);
rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]);
for (const cx of minimap.ENEMY_BADGE_XS) rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]);
}
for (const roi of [
minimap.GATE_CLOSE_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(frame, roi, [255, 255, 0]);
}
for (const card of minimap.CARD_LAYOUTS) {
rect(frame, card.name, [0, 255, 0]);
rect(frame, card.weapon, [255, 0, 0]);
rect(frame, card.subTile, [0, 255, 255]);
for (const [cx, cy] of card.badges)
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, card.cross, [255, 0, 255]);
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]);
rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]);
for (const cx of minimap.ENEMY_BADGE_XS)
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]);
}
for (const roi of [
minimap.GATE_CLOSE_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(frame, roi, [255, 255, 0]);
}
} else {
console.error(`unknown detector "${detector}"`);
process.exit(1);
console.error(`unknown detector "${detector}"`);
process.exit(1);
}
writePng(outPath, matToFrameData(frame));
frame.delete();
console.log(`wrote ${outPath}`);
console.info(`wrote ${outPath}`);

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Accuracy report across all fixtures — separate from pass/fail testing.
* Runs each detector over its own fixture directory and prints aggregate
@@ -7,437 +8,506 @@
* Usage: pnpm cv:report
*/
import { loadOpenCV } from "../../app/features/cv/core/cv";
import { createDeathDetector, type DeathData } from "../../app/features/cv/core/detectors/death/index";
import {
createMapStartDetector,
type MapStartData,
createDeathDetector,
type DeathData,
} from "../../app/features/cv/core/detectors/death/index";
import {
createMapStartDetector,
type MapStartData,
} from "../../app/features/cv/core/detectors/map-start/index";
import { createMinimapDetector, type MinimapData } from "../../app/features/cv/core/detectors/minimap/index";
import {
createMinimapDetector,
type MinimapData,
} from "../../app/features/cv/core/detectors/minimap/index";
import { createScoreboardDetector } from "../../app/features/cv/core/detectors/scoreboard/index";
import {
createScoreboardOwnDetector,
type ScoreboardOwnData,
createScoreboardOwnDetector,
type ScoreboardOwnData,
} from "../../app/features/cv/core/detectors/scoreboard-own/index";
import {
createScoreboardReplayDetector,
type ScoreboardReplayData,
createScoreboardReplayDetector,
type ScoreboardReplayData,
} from "../../app/features/cv/core/detectors/scoreboard-replay/index";
import type { Detector } from "../../app/features/cv/core/detectors/types";
import { loadFixtures, runDetectorOnFixture } from "../../app/features/cv/node/fixtures";
import {
loadFixtures,
runDetectorOnFixture,
} from "../../app/features/cv/node/fixtures";
import { loadScoreboardResources } from "../../app/features/cv/node/resources";
await loadOpenCV();
const resources = await loadScoreboardResources();
interface Config {
label: string;
detector: Detector<Partial<ScoreboardReplayData>>;
fixturesDir: string;
event: string;
label: string;
detector: Detector<Partial<ScoreboardReplayData>>;
fixturesDir: string;
event: string;
}
const configs: Config[] = [
{
label: "scoreboard",
detector: createScoreboardDetector(resources),
fixturesDir: "scoreboard",
event: "Scoreboard",
},
{
label: "scoreboard-replay",
detector: createScoreboardReplayDetector(resources),
fixturesDir: "scoreboard-replay",
event: "ScoreboardReplay",
},
{
label: "scoreboard",
detector: createScoreboardDetector(resources),
fixturesDir: "scoreboard",
event: "Scoreboard",
},
{
label: "scoreboard-replay",
detector: createScoreboardReplayDetector(resources),
fixturesDir: "scoreboard-replay",
event: "ScoreboardReplay",
},
];
interface Tally {
ok: number;
total: number;
ok: number;
total: number;
}
/** Levenshtein distance for CER. */
function editDistance(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => {
const row = new Array<number>(b.length + 1).fill(0);
row[0] = i;
return row;
});
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i]![j] = Math.min(
dp[i - 1]![j]! + 1,
dp[i]![j - 1]! + 1,
dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return dp[a.length]![b.length]!;
const dp = Array.from({ length: a.length + 1 }, (_, i) => {
const row = new Array<number>(b.length + 1).fill(0);
row[0] = i;
return row;
});
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i]![j] = Math.min(
dp[i - 1]![j]! + 1,
dp[i]![j - 1]! + 1,
dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return dp[a.length]![b.length]!;
}
function pct(t: Tally): string {
if (t.total === 0) return " n/a";
return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`;
if (t.total === 0) return " n/a";
return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`;
}
for (const config of configs) {
// Shared negatives count toward gate accuracy (expectPositive is false).
const fixtures = [...loadFixtures(config.fixturesDir), ...loadFixtures("negative")];
// Shared negatives count toward gate accuracy (expectPositive is false).
const fixtures = [
...loadFixtures(config.fixturesDir),
...loadFixtures("negative"),
];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
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,
paint: { ok: 0, total: 0 } as Tally,
stats: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const nameMisses: string[] = [];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
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,
paint: { ok: 0, total: 0 } as Tally,
stats: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const nameMisses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture(config.detector, fixture);
const expectPositive = fixture.expected.event === config.event;
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture(
config.detector,
fixture,
);
const expectPositive = fixture.expected.event === config.event;
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
}
}
if (expected.timestamp !== undefined) {
tally.timestamp.total++;
if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++;
}
if (expected.replayCode !== undefined) {
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 (JSON.stringify(event.data.matchScores) === JSON.stringify(expected.matchScores)) {
tally.matchScores.ok++;
}
}
(expected.players ?? []).forEach((want, i) => {
const got = event.data.players?.[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
}
if (want.name !== undefined) {
tally.names.total++;
const dist = editDistance(got.name, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else nameMisses.push(`${fixture.name} row${i}: "${got.name}" != "${want.name}"`);
}
if (want.paint !== undefined) {
tally.paint.total++;
if (got.paint === want.paint) tally.paint.ok++;
}
if (want.ka !== undefined) {
tally.stats.total++;
if (got.ka === want.ka && got.d === (want.d ?? null) && got.s === (want.s ?? null)) {
tally.stats.ok++;
}
}
});
}
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
}
}
if (expected.timestamp !== undefined) {
tally.timestamp.total++;
if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++;
}
if (expected.replayCode !== undefined) {
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 (
JSON.stringify(event.data.matchScores) ===
JSON.stringify(expected.matchScores)
) {
tally.matchScores.ok++;
}
}
(expected.players ?? []).forEach((want, i) => {
const got = event.data.players?.[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
}
if (want.name !== undefined) {
tally.names.total++;
const dist = editDistance(got.name, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else
nameMisses.push(
`${fixture.name} row${i}: "${got.name}" != "${want.name}"`,
);
}
if (want.paint !== undefined) {
tally.paint.total++;
if (got.paint === want.paint) tally.paint.ok++;
}
if (want.ka !== undefined) {
tally.stats.total++;
if (
got.ka === want.ka &&
got.d === (want.d ?? null) &&
got.s === (want.s ?? null)
) {
tally.stats.ok++;
}
}
});
}
console.log(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
console.log(`gate ${pct(tally.gate)}`);
console.log(`header ${pct(tally.header)}`);
if (config.event === "ScoreboardReplay") {
console.log(`timestamp ${pct(tally.timestamp)}`);
console.log(`replayCode ${pct(tally.replayCode)}`);
}
console.log(`scores ${pct(tally.scores)}`);
if (config.event === "ScoreboardReplay") {
console.log(`matchScores ${pct(tally.matchScores)}`);
}
console.log(`weapons ${pct(tally.weapons)}`);
console.log(`names ${pct(tally.names)}`);
console.log(`paint ${pct(tally.paint)}`);
console.log(`stats ${pct(tally.stats)}`);
console.log(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (nameMisses.length > 0) {
console.log("name misses:");
for (const m of nameMisses) console.log(` ${m}`);
}
console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`header ${pct(tally.header)}`);
if (config.event === "ScoreboardReplay") {
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(`weapons ${pct(tally.weapons)}`);
console.info(`names ${pct(tally.names)}`);
console.info(`paint ${pct(tally.paint)}`);
console.info(`stats ${pct(tally.stats)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (nameMisses.length > 0) {
console.info("name misses:");
for (const m of nameMisses) console.info(` ${m}`);
}
}
// Map-start fixtures only carry mode + stage, so they get their own pass.
{
const detector = createMapStartDetector(resources);
const fixtures = loadFixtures("map-start");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
mode: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
const detector = createMapStartDetector(resources);
const fixtures = loadFixtures("map-start");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
mode: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MapStartData>(detector, fixture);
const expectPositive = fixture.expected.event === "MapStart";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.mode.total++;
if (event.data.mode === expected.mode) tally.mode.ok++;
else misses.push(`${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`);
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else misses.push(`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`);
}
}
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MapStartData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "MapStart";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.mode.total++;
if (event.data.mode === expected.mode) tally.mode.ok++;
else
misses.push(
`${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`,
);
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else
misses.push(
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
);
}
}
console.log(`\n=== map-start (${fixtures.length} fixtures) ===`);
console.log(`gate ${pct(tally.gate)}`);
console.log(`mode ${pct(tally.mode)}`);
console.log(`stage ${pct(tally.stage)}`);
if (misses.length > 0) {
console.log("misses:");
for (const m of misses) console.log(` ${m}`);
}
console.info(`\n=== map-start (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`mode ${pct(tally.mode)}`);
console.info(`stage ${pct(tally.stage)}`);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Scoreboard-own fixtures carry header + own weapon + ability grid.
{
const detector = createScoreboardOwnDetector(resources);
const fixtures = [...loadFixtures("scoreboard-own"), ...loadFixtures("negative")];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
header: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
const detector = createScoreboardOwnDetector(resources);
const fixtures = [
...loadFixtures("scoreboard-own"),
...loadFixtures("negative"),
];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
header: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
};
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<ScoreboardOwnData>(detector, fixture);
const expectPositive = fixture.expected.event === "ScoreboardOwn";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
} else {
misses.push(
`${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`,
);
}
}
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else misses.push(`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`);
});
});
}
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<ScoreboardOwnData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "ScoreboardOwn";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.mode !== undefined) {
tally.header.total++;
if (
event.data.lobby === (expected.lobby ?? null) &&
event.data.mode === (expected.mode ?? null) &&
event.data.stage === (expected.stage ?? null)
) {
tally.header.ok++;
} else {
misses.push(
`${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`,
);
}
}
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else
misses.push(
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
);
});
});
}
console.log(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`);
console.log(`gate ${pct(tally.gate)}`);
console.log(`header ${pct(tally.header)}`);
console.log(`weapon ${pct(tally.weapon)}`);
console.log(`abilities ${pct(tally.abilities)}`);
if (misses.length > 0) {
console.log("misses:");
for (const m of misses) console.log(` ${m}`);
}
console.info(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`header ${pct(tally.header)}`);
console.info(`weapon ${pct(tally.weapon)}`);
console.info(`abilities ${pct(tally.abilities)}`);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Minimap fixtures carry teammates/enemies, so they get their own pass.
{
const detector = createMinimapDetector(resources);
const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapons: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
const detector = createMinimapDetector(resources);
const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")];
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapons: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MinimapData>(detector, fixture);
const expectPositive = fixture.expected.event === "Minimap";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<MinimapData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Minimap";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
const sides: [
string,
{ name?: string | null; weaponId?: number | null; abilities?: (string | null)[] }[],
{ name?: string | null; weaponId: number | null; abilities: (string | null)[] }[],
][] = [
["teammate", expected.teammates ?? [], event.data.teammates],
["enemy", expected.enemies ?? [], event.data.enemies],
];
for (const [side, wants, gots] of sides) {
wants.forEach((want, i) => {
const got = gots[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`,
);
}
if (want.name !== undefined && want.name !== null) {
tally.names.total++;
const gotName = got.name ?? "";
const dist = editDistance(gotName, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`);
}
(want.abilities ?? []).forEach((wantId, slot) => {
tally.abilities.total++;
const gotId = got.abilities[slot] ?? null;
if (gotId === wantId) tally.abilities.ok++;
else
misses.push(`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`);
});
});
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else misses.push(`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`);
}
}
const sides: [
string,
{
name?: string | null;
weaponId?: number | null;
abilities?: (string | null)[];
}[],
{
name?: string | null;
weaponId: number | null;
abilities: (string | null)[];
}[],
][] = [
["teammate", expected.teammates ?? [], event.data.teammates],
["enemy", expected.enemies ?? [], event.data.enemies],
];
for (const [side, wants, gots] of sides) {
wants.forEach((want, i) => {
const got = gots[i];
if (!got) return;
if (want.weaponId !== undefined) {
tally.weapons.total++;
if (got.weaponId === want.weaponId) tally.weapons.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`,
);
}
if (want.name !== undefined && want.name !== null) {
tally.names.total++;
const gotName = got.name ?? "";
const dist = editDistance(gotName, want.name);
charEdits += dist;
charTotal += want.name.length;
if (dist === 0) tally.names.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`,
);
}
(want.abilities ?? []).forEach((wantId, slot) => {
tally.abilities.total++;
const gotId = got.abilities[slot] ?? null;
if (gotId === wantId) tally.abilities.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`,
);
});
});
}
if (expected.stage !== undefined) {
tally.stage.total++;
if (event.data.stage === expected.stage) tally.stage.ok++;
else
misses.push(
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
);
}
}
console.log(`\n=== minimap (${fixtures.length} fixtures) ===`);
console.log(`gate ${pct(tally.gate)}`);
console.log(`weapons ${pct(tally.weapons)}`);
console.log(`names ${pct(tally.names)}`);
console.log(`abilities ${pct(tally.abilities)}`);
console.log(`stage ${pct(tally.stage)}`);
console.log(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.log("misses:");
for (const m of misses) console.log(` ${m}`);
}
console.info(`\n=== minimap (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`weapons ${pct(tally.weapons)}`);
console.info(`names ${pct(tally.names)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`stage ${pct(tally.stage)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}
// Death fixtures carry a different data shape (weapon text, ability grid,
// splash-tag name), so they get their own pass instead of a Config entry.
{
const detector = createDeathDetector(resources);
const fixtures = loadFixtures("death");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
const detector = createDeathDetector(resources);
const fixtures = loadFixtures("death");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
weapon: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<DeathData>(detector, fixture);
const expectPositive = fixture.expected.event === "Death";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else misses.push(`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`);
});
});
if (expected.name !== undefined) {
tally.names.total++;
const got = event.data.name ?? "";
const dist = editDistance(got, expected.name);
charEdits += dist;
charTotal += expected.name.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`);
}
}
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<DeathData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Death";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.weaponId !== undefined) {
tally.weapon.total++;
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
else
misses.push(
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
);
}
(expected.abilities ?? []).forEach((wantRow, row) => {
wantRow.forEach((want, slot) => {
tally.abilities.total++;
const got = event.data.abilities[row]?.[slot];
if (got === want) tally.abilities.ok++;
else
misses.push(
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
);
});
});
if (expected.name !== undefined) {
tally.names.total++;
const got = event.data.name ?? "";
const dist = editDistance(got, expected.name);
charEdits += dist;
charTotal += expected.name.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`);
}
}
console.log(`\n=== death (${fixtures.length} fixtures) ===`);
console.log(`gate ${pct(tally.gate)}`);
console.log(`weapon ${pct(tally.weapon)}`);
console.log(`abilities ${pct(tally.abilities)}`);
console.log(`names ${pct(tally.names)}`);
console.log(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.log("misses:");
for (const m of misses) console.log(` ${m}`);
}
console.info(`\n=== death (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`weapon ${pct(tally.weapon)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`names ${pct(tally.names)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* CLI harness: run the ScoreboardDetector over fixtures and print everything
* it saw — gate result, parsed fields, per-field scores, top weapon candidates.
@@ -6,10 +7,13 @@
*/
import { loadOpenCV } from "../../app/features/cv/core/cv";
import {
createScoreboardDetector,
type ScoreboardRowDebug,
createScoreboardDetector,
type ScoreboardRowDebug,
} from "../../app/features/cv/core/detectors/scoreboard/index";
import { loadFixtures, runDetectorOnFixture } from "../../app/features/cv/node/fixtures";
import {
loadFixtures,
runDetectorOnFixture,
} from "../../app/features/cv/node/fixtures";
import { loadScoreboardResources } from "../../app/features/cv/node/resources";
const filter = process.argv[2];
@@ -17,28 +21,32 @@ const filter = process.argv[2];
await loadOpenCV();
const detector = createScoreboardDetector(await loadScoreboardResources());
const fixtures = loadFixtures("scoreboard").filter((f) => !filter || f.name.includes(filter));
const fixtures = loadFixtures("scoreboard").filter(
(f) => !filter || f.name.includes(filter),
);
if (fixtures.length === 0) {
console.error("no fixtures matched");
process.exit(1);
console.error("no fixtures matched");
process.exit(1);
}
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture(detector, fixture);
console.log(`\n=== ${fixture.name}`);
console.log(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`);
for (const event of events) {
console.log(`event confidence=${event.confidence.toFixed(3)}`);
console.log(`scores: ${JSON.stringify(event.data.scores)}`);
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
event.data.players.forEach((p, i) => {
const dbg = rows[i];
const top = dbg?.weapon?.top.map((t) => `${t.id}:${t.score.toFixed(2)}`).join(" ");
console.log(
` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` +
`weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` +
`ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`,
);
});
}
const { gate, events } = await runDetectorOnFixture(detector, fixture);
console.info(`\n=== ${fixture.name}`);
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)}`);
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
event.data.players.forEach((p, i) => {
const dbg = rows[i];
const top = dbg?.weapon?.top
.map((t) => `${t.id}:${t.score.toFixed(2)}`)
.join(" ");
console.info(
` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` +
`weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` +
`ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`,
);
});
}
}