mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 06:06:28 -05:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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() },
|
||||
]);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(",")}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user