Files
sendou.ink/scripts/scanner/build-localized-entries.ts
2026-09-09 21:46:10 +03:00

478 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Generates the localized closed sets from the splat3 repo's language dumps
* (https://github.com/Leanny/splat3, data/language/<Lang>_full.json) so
* ingestion works in any game language: detectors OCR what is on screen, snap
* it against every language's entries, and emit the sendou.ink id.
*
* Sources per language:
* CommonMsg/VS/VSRuleName modes (+ _2L two-line intro-splash wraps, "Muschel-\nchaos")
* CommonMsg/VS/VSStageName stages (keyed via the USen values)
* CommonMsg/MatchMode lobby tags (XMatch / Private)
* LayoutMsg/Lobby_MenuMode_00 the intro splash's "MODE" label
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
* LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on
* line 1 or 2 by language, so it becomes a per-language template
* LayoutMsg/VS_BeatMessage_00 (000) kill-feed row ("Splatted <name>!"); the name placeholder
* splits it into a per-language pre/post text pair
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to canonical entries via USen
*
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
* Writes app/features/scanner/core/localized-entries.ts
* and app/features/scanner/core/detectors/death/localized-messages.ts
* and app/features/scanner/core/detectors/kill/localized-messages.ts
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import type { ScannerLobby } from "../../app/features/scanner/scanner-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 LANG_DIR = join(SPLAT3_DIR, "data", "language");
const OUT_ENTRIES = new URL(
"../../app/features/scanner/core/localized-entries.ts",
import.meta.url,
).pathname;
const OUT_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/death/localized-messages.ts",
import.meta.url,
).pathname;
const OUT_KILL_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/kill/localized-messages.ts",
import.meta.url,
).pathname;
const CANONICAL_LANG = "USen";
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",
};
/** MatchMode key -> lobby code; USen values validate against these names. */
const LOBBY_KEYS: Record<string, ScannerLobby> = {
XMatch: "X",
Bankara: "SERIES",
BankaraOpen: "OPEN",
Private: "PRIVATE",
};
/** the English lobby tags as the game shows them, for USen validation */
const LOBBY_ENGLISH: Record<ScannerLobby, string> = {
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;
}
/** Drop [size=...]/[color=...]-style markup and collapse whitespace. */
function clean(s: string): string {
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, "");
}
/** sendou.ink locales use the ASCII apostrophe; the game dumps use . */
function normalizeApostrophes(s: string): string {
return s.replace(/[]/g, "'");
}
const languages = [
...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}`,
);
}
const dumps = new Map<string, LangDump>(languages.map((l) => [l, loadLang(l)]));
const usen = dumps.get(CANONICAL_LANG)!;
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}"`);
}
for (const [key, lobby] of Object.entries(LOBBY_KEYS)) {
const value = clean(usen["CommonMsg/MatchMode"]![key]!);
const expected = LOBBY_ENGLISH[lobby as ScannerLobby];
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]),
);
/** 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);
}
for (const [name, stageId] of stageIdByEnglishName) {
if (![...stageKeys.values()].includes(stageId)) {
throw new Error(
`stage "${name}" (id ${stageId}) not found in USen VSStageName`,
);
}
}
interface LocalizedLobby {
text: string;
lobby: ScannerLobby;
}
interface LocalizedMode {
text: string;
mode: ModeShort;
}
interface LocalizedStage {
text: string;
stageId: StageId;
}
interface LanguageEntries {
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,
})),
});
}
// 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 PLACEHOLDER = /\[group=[^\]]*\]/;
/** stands in for the weapon placeholder while splitting the message */
const SENTINEL = "\u0000";
interface DeathTemplate {
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,
});
}
interface KillTemplate {
langs: string[];
pre: string;
post: string;
}
// the name placeholder is the first group; KRko carries a second (a particle
// chosen by the name's final syllable) that clean() drops along with markup
const killTemplates: KillTemplate[] = [];
for (const lang of languages) {
const raw = dumps.get(lang)!["LayoutMsg/VS_BeatMessage_00"]!["000"]!;
const line = raw.replace(PLACEHOLDER, SENTINEL).replace(/\[[^\]]*\]/g, "");
if (!line.includes(SENTINEL) || line.includes("\n"))
throw new Error(
`${lang}: kill message is not one line with a name: ${raw}`,
);
const [pre, post] = line
.split(SENTINEL)
.map((s) => s.replace(/[ \t]+/g, " ")) as [string, string];
const existing = killTemplates.find((t) => t.pre === pre && t.post === post);
if (existing) existing.langs.push(lang);
else killTemplates.push({ langs: [lang], pre, post });
}
const WEAPON_MSGS = [
"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);
}
}
const unmapped = [...canonicalWeaponNames].filter(
(n) => ![...weaponCodenames.values()].includes(n),
);
if (unmapped.length > 0) {
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 }[]> =
{};
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 banner = (extra: string) =>
`/**
* GENERATED by scripts/scanner/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
* 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 { ScannerLobby } from "../scanner-types";
export interface LocalizedLobby {
text: string;
lobby: ScannerLobby;
}
export interface LocalizedMode {
text: string;
mode: ModeShort;
}
export interface LocalizedStage {
text: string;
stageId: StageId;
}
export interface LanguageEntries {
lang: string;
/** the intro splash's constant "MODE" label */
modeLabel: string;
/** replay-browser winner/loser panel tags */
victory: string;
defeat: string;
lobbies: LocalizedLobby[];
modes: LocalizedMode[];
/** two-line intro-splash wrap variants ("Muschel-\\nchaos"), space-joined */
modeWraps: LocalizedMode[];
stages: LocalizedStage[];
}
export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = ${JSON.stringify(
languageEntries,
null,
2,
)};
`,
);
writeFileSync(
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 */
langs: readonly string[];
/** which burst line carries the weapon name */
weaponLine: 1 | 2;
/** the constant text on the other line (parse-time confirmation) */
constText: string;
/** constant text around the weapon name on its own line */
weaponPre: string;
weaponPost: string;
}
export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = ${JSON.stringify(
templates,
null,
2,
)};
export interface LocalizedWeaponName {
text: string;
/** canonical English entry name (weapon-names.ts) */
name: string;
}
/**
* Per-language weapon names that differ from the canonical English name
* (identical ones are omitted; match against the canonical set too).
*/
export const LOCALIZED_WEAPON_NAMES: Readonly<
Record<string, readonly LocalizedWeaponName[]>
> = ${JSON.stringify(localizedWeaponNames, null, 2)};
`,
);
writeFileSync(
OUT_KILL_MESSAGES,
`${banner(
`Per-language kill-feed row templates: the "Splatted <name>!" row
* wraps the splatted player's name in language-specific text on either side
* (spaces kept as rendered; either side may be empty).`,
)}
export interface KillMessageTemplate {
/** languages sharing this exact template */
langs: readonly string[];
/** constant text before the name (may end with a space, or be empty) */
pre: string;
/** constant text after the name (may start with a space, or be empty) */
post: string;
}
export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = ${JSON.stringify(
killTemplates,
null,
2,
)};
`,
);
console.info(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
);
console.info(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names, ` +
`${killTemplates.length} kill templates`,
);