卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
// Animation-role resolver for Arknights battle Spine models (research 07 §5.4).
//
// Model animation names vary a lot (Attack vs Attack_Begin/Loop/End vs Combat,
// Skill_2_Loop vs Skill2_Loop vs Skill_2, Die missing on Back models, enemies
// with several "forms" such as A_Idle/B_Idle or Idle_grey/Idle_red, …). The
// renderer should not guess at runtime, so tools/fetch-assets.mjs resolves each
// model once and stores the result in data/assets.json.
//
// A "clip" is { begin: string|null, loop: string, end: string|null, via?: string }:
// play `begin` once (if any), then `loop` (once per attack / repeatedly while a
// skill or move lasts), then `end` (if any). `via` is set when the clip is a
// fallback borrowed from another role ('combat', 'attackAny', 'skill', 'idle',
// 'attack'), so the renderer can add e.g. a flash when an attack is just Idle.
//
// Resolution order (names matched exactly first, then case-insensitively; the
// research order comes first, the generic patterns after it are extensions for
// models the research table did not cover):
// idle : Idle → first idle-like (Idle_A, A_Idle, Idle01; not Skill/Stun; animated ones first when
// durations are given) → Default → first animation
// deploy : Start → <form>Start → idle
// attack : Attack → Attack_Start|Attack_Begin + Attack_Loop + Attack_End
// → Attack_Begin + first numbered Attack_A + Attack_End (via attackAny) → Attack_Begin alone
// → Combat → <form>Attack → first non-Down /^Attack/ (Attack_01, Attack_A) (via attackAny)
// → first non-skill …_Attack (B_Attack) (via attackAny)
// → Skill_1_Loop → Skill_Loop → Skill…Attack → Skill…_Loop (via skill) → idle (via idle)
// attackDown: Attack_Down → Attack_Down_Begin/Loop/End → Combat_Down → first /^Attack.*Down/ → null
// skill (0-based skill index i, n = i + 1), for each prefix P in Skill_n, Skilln, Skill_0n, then Skill:
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
// idle = P_Idle (replaces idle while the skill is active);
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
// else attack (via attack)
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
// → Run → first /^Move/ → null
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
/**
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
*/
function makeFinder(names) {
const exact = new Set(names);
const lower = new Map();
for (const n of names) {
const k = n.toLowerCase();
if (!lower.has(k)) lower.set(k, n);
}
return (name) => (name && exact.has(name) ? name : lower.get(String(name).toLowerCase()) ?? null);
}
function clip(begin, loop, end, via) {
const c = { begin: begin ?? null, loop, end: end ?? null };
if (via) c.via = via;
return c;
}
const isDown = (n) => /down/i.test(n);
const isEdge = (n) => /_(begin|start|end)$/i.test(n);
/** First name (sorted) matching `re` and passing `ok`. */
function firstLike(names, re, ok = () => true) {
return names.filter((n) => re.test(n) && ok(n)).sort()[0] ?? null;
}
/**
* begin/loop/end triple for a prefix, e.g. prefix 'Move' → Move_Begin|Move_Start, Move_Loop (else the
* plain 'Move'), Move_End. Begin-only models play the begin clip as the action.
*/
function triple(find, prefix) {
const begin = find(`${prefix}_Begin`) ?? find(`${prefix}_Start`);
const loop = find(`${prefix}_Loop`) ?? find(prefix);
const end = find(`${prefix}_End`);
if (loop) return clip(begin, loop, end);
if (begin) return clip(null, begin, end);
return null;
}
/**
* Directional variants as virtual names: 'Skill_Right_Loop' → 'Skill_Loop', 'Skill_Loop_Up' → 'Skill_Loop'
* (Right preferred over Up; `_Down` variants are never aliased). Used only as a last resort for skills
* of models that have no undirected skill clips (char_279_excu, char_431_ashlok Back).
* @param {string[]} names
* @returns {{ names: string[], real: (name:string|null)=>string|null }}
*/
function directionalAliases(names) {
const have = new Set(names.map((n) => n.toLowerCase()));
const alias = new Map();
for (const dir of ['Right', 'Up']) {
const re = new RegExp(`_${dir}(?=_|$)`, 'i');
for (const n of names) {
if (isDown(n) || !re.test(n)) continue;
const k = n.replace(re, '');
const lk = k.toLowerCase();
if (!have.has(lk) && !alias.has(lk)) alias.set(lk, { virtual: k, real: n });
}
}
const virtuals = [...alias.values()].map((a) => a.virtual);
return {
names: [...names, ...virtuals],
real: (name) => (name == null ? null : alias.get(name.toLowerCase())?.real ?? name),
};
}
/** Form tag of multi-form enemies, from the idle name: 'A_Idle' → A_x, 'Idle_grey' → x_grey. */
function formFinder(find, idle) {
if (!idle || /^idle$/i.test(idle)) return () => null;
let m = /^(.+)_idle$/i.exec(idle);
if (m) { const pre = m[1]; return (base) => find(`${pre}_${base}`); }
m = /^idle(_?)(.+)$/i.exec(idle);
if (m) { const sep = m[1]; const suf = m[2]; return (base) => find(`${base}${sep}${suf}`); }
return () => null;
}
function resolveIdle(names, find, durations) {
// Among generic idle-like clips prefer an animated one: 'Idle_A' of enemy_9014_acstma is a 0 s pose.
const idleLike = (animated) => firstLike(names, /(^|_)idle/i,
(n) => !/skill|stun|down/i.test(n) && (!animated || !(Number(durations?.[n]) <= 0)));
return find('Idle')
?? (durations ? idleLike(true) : null)
?? idleLike(false)
?? find('Default')
?? firstLike(names, /^default/i)
?? names[0] ?? null;
}
function resolveAttack(names, find, form, idle) {
const a = find('Attack');
if (a) return clip(null, a, null);
const begin = find('Attack_Begin') ?? find('Attack_Start');
const end = find('Attack_End');
const loop = find('Attack_Loop');
if (loop) return clip(begin, loop, end);
const numbered = firstLike(names, /^attack/i, (n) => !isDown(n) && !isEdge(n));
if (begin) {
// Attack_Begin + Attack_A/B/C + Attack_End (char_1045_svash2): the numbered clips are the attacks.
return numbered ? clip(begin, numbered, end, 'attackAny') : clip(null, begin, end);
}
const c = find('Combat');
if (c) return clip(null, c, null, 'combat');
const fa = form('Attack') ?? form('Combat');
if (fa) return clip(null, fa, null, 'attackAny');
const any = numbered
?? firstLike(names, /(^|_)attack/i, (n) => !isDown(n) && !isEdge(n) && !/skill/i.test(n));
if (any) return clip(null, any, null, 'attackAny');
const s = find('Skill_1_Loop') ?? find('Skill1_Loop') ?? find('Skill_Loop')
?? firstLike(names, /^skill.*attack/i, (n) => !isDown(n) && !isEdge(n))
?? firstLike(names, /^skill.*_loop$/i, (n) => !isDown(n));
if (s) return clip(null, s, null, 'skill');
return idle ? clip(null, idle, null, 'idle') : null;
}
function resolveAttackDown(names, find) {
const a = find('Attack_Down');
if (a) return clip(null, a, null);
const t = triple(find, 'Attack_Down');
if (t) return t;
const c = find('Combat_Down');
if (c) return clip(null, c, null, 'combat');
const any = firstLike(names, /^attack.*down/i, (n) => !isEdge(n));
if (any) return clip(null, any, null, 'attackAny');
return null;
}
/** Skill clip for one prefix (Skill_2, Skill2, Skill_02, Skill), or null. */
function skillFamily(names, find, P, numbered) {
const begin = find(`${P}_Begin`) ?? find(`${P}_Start`);
const end = find(`${P}_End`);
const idle = find(`${P}_Idle`);
const attack = find(`${P}_Attack`)
?? firstLike(names, new RegExp(`^${P}_Attack`, 'i'), (n) => !isDown(n) && !isEdge(n));
const single = find(P);
// Numbered prefixes only: any other P_* variant (e.g. Skill_1_A). Never for plain 'Skill',
// which would otherwise grab another skill's animations.
const variant = numbered
? firstLike(names, new RegExp(`^${P}_`, 'i'), (n) => !isDown(n) && !isEdge(n) && !/_idle$/i.test(n))
: null;
const loop = find(`${P}_Loop`) ?? attack ?? single ?? variant ?? idle ?? begin;
if (!loop) return null;
return { ...clip(loop === begin ? null : begin, loop, loop === end ? null : end), idle: idle ?? null };
}
function resolveSkill(names, find, index, attack) {
const n = index + 1;
const pad = String(n).padStart(2, '0');
const prefixes = [`Skill_${n}`, `Skill${n}`, `Skill_${pad}`, 'Skill'];
for (const P of prefixes) {
const c = skillFamily(names, find, P, P !== 'Skill');
if (c) {
const { idle, ...rest } = c;
return { ...rest, index, idle };
}
}
// Last resort: directional-only skill clips (Skill_Right_Loop, Skill_Loop_Up).
const dir = directionalAliases(names);
if (dir.names.length > names.length) {
const findDir = makeFinder(dir.names);
for (const P of prefixes) {
const c = skillFamily(dir.names, findDir, P, P !== 'Skill');
if (c) {
return { begin: dir.real(c.begin), loop: dir.real(c.loop), end: dir.real(c.end), index, idle: dir.real(c.idle) };
}
}
}
if (attack) return { ...attack, via: 'attack', index, idle: null };
return null;
}
function resolveMove(names, find, form) {
const t = triple(find, 'Move');
if (t) return t;
const m = find('Move') ?? form('Move');
if (m) return clip(null, m, null);
const r = triple(find, 'Run');
if (r) return r;
const run = find('Run') ?? form('Run');
if (run) return clip(null, run, null);
const any = firstLike(names, /^move/i, (n) => !isEdge(n)) ?? firstLike(names, /(^|_)move/i, (n) => !isEdge(n));
return any ? clip(null, any, null) : null;
}
function resolveStun(find) {
const stun = find('Stun');
const begin = find('Stun_Begin');
if (stun) return clip(begin, stun, find('Stun_End'));
if (begin) return clip(null, begin, null);
return null;
}
function resolveDie(names, find, form) {
return find('Die') ?? form('Die')
?? firstLike(names, /^die/i)
?? firstLike(names, /_die$/i, (n) => !/stun/i.test(n))
?? null;
}
/**
* Resolve animation roles for one Spine model.
* @param {string[]} animationNames all animation names in the skeleton
* @param {object} [opts]
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
* @returns {Roles}
*/
export function resolveRoles(animationNames, opts = {}) {
const names = Array.isArray(animationNames) ? animationNames.filter((n) => typeof n === 'string' && n.length) : [];
const find = makeFinder(names);
const durations = opts.durations && typeof opts.durations === 'object' ? opts.durations : null;
const idle = resolveIdle(names, find, durations);
const form = formFinder(find, idle);
const deploy = find('Start') ?? form('Start') ?? idle;
const attack = resolveAttack(names, find, form, idle);
const attackDown = resolveAttackDown(names, find);
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
if (!indices.length) indices.push(0);
/** @type {Record<string, SkillClip>} */
const skills = {};
for (const i of indices) {
const s = resolveSkill(names, find, i, attack);
if (s) skills[String(i)] = s;
}
const roles = {
idle,
deploy,
attack,
attackDown,
skill: skills[String(indices[0])] ?? null,
die: resolveDie(names, find, form),
move: resolveMove(names, find, form),
stun: resolveStun(find),
};
if (indices.length > 1) roles.skills = skills;
return roles;
}
/**
* All animation names referenced by a Roles object (for validation).
* @param {Roles} roles
* @returns {string[]}
*/
export function roleAnimationNames(roles) {
const out = new Set();
const addClip = (c) => {
if (!c) return;
for (const k of ['begin', 'loop', 'end', 'idle']) if (typeof c[k] === 'string') out.add(c[k]);
};
if (!roles) return [];
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
return [...out];
}
+130
View File
@@ -0,0 +1,130 @@
// Spine (3.8-style) texture-atlas parsing and normalization.
//
// Semantics follow pixi-spine 4's TextureAtlas reader (the runtime we ship):
// a page starts at the first non-blank line after a blank line (or file start);
// the page name is followed by `key: value` page fields (size/format/filter/
// repeat/pma); every other non-blank line starts a region, whose `key: value`
// lines follow it. Normalization (research 07 §5.3):
// - insert `size: W,H` right after the page name when missing (fexli atlases
// omit it; spine-ts divides by 0 and pixi-spine warns), or correct it when it
// disagrees with the real PNG size;
// - insert `pma: true` for premultiplied-alpha textures (Ark-Models enemies).
// Normalization is idempotent: running it twice yields the same text.
/**
* @typedef {{ name: string, line: number, fields: Record<string,string>, fieldLines: Record<string,number>, lastFieldLine: number, regions: string[] }} AtlasPage
*/
function splitEntry(line) {
const t = line.trim();
if (!t) return null;
const colon = t.indexOf(':');
if (colon === -1) return null;
return [t.slice(0, colon).trim(), t.slice(colon + 1).trim()];
}
/**
* Parse an atlas into pages (with page fields and region names).
* @param {string} text
* @returns {{ lines: string[], pages: AtlasPage[] }}
*/
export function parseAtlas(text) {
const lines = String(text).replace(/^/, '').replace(/\r\n?/g, '\n').split('\n');
/** @type {AtlasPage[]} */
const pages = [];
let page = null;
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { page = null; i++; continue; }
if (page === null) {
page = { name: line.trim(), line: i, fields: {}, fieldLines: {}, lastFieldLine: i, regions: [] };
pages.push(page);
i++;
while (i < lines.length) {
const e = splitEntry(lines[i]);
if (!e) break;
page.fields[e[0]] = e[1];
page.fieldLines[e[0]] = i;
page.lastFieldLine = i;
i++;
}
continue;
}
// Region: name line followed by entries.
page.regions.push(line);
i++;
while (i < lines.length && splitEntry(lines[i])) i++;
}
return { lines, pages };
}
/**
* Normalize an atlas text.
* @param {string} text original atlas text
* @param {object} opts
* @param {(pageName:string)=>({width:number,height:number}|null)} opts.pageSize real texture size lookup
* @param {boolean} [opts.pma] add `pma: true` to every page
* @param {(pageName:string)=>string} [opts.renamePage] local file name for a page (rewrites the page line)
* @returns {{ text: string, changed: boolean, pages: string[], missingSize: string[], fixedSize: string[] }}
*/
export function normalizeAtlas(text, { pageSize, pma = false, renamePage = null }) {
const { lines, pages } = parseAtlas(text);
/** insertions after a given line index: Map<lineIndex, string[]> */
const inserts = new Map();
const replace = new Map();
const missingSize = [];
const fixedSize = [];
const addAfter = (idx, s) => { if (!inserts.has(idx)) inserts.set(idx, []); inserts.get(idx).push(s); };
for (const p of pages) {
if (renamePage) {
const local = renamePage(p.name);
if (typeof local === 'string' && local && local !== p.name) replace.set(p.line, local);
}
const real = pageSize ? pageSize(p.name) : null;
if (p.fields.size === undefined) {
if (real) addAfter(p.line, `size: ${real.width},${real.height}`);
else missingSize.push(p.name);
} else if (real) {
const m = /^(\d+)\s*,\s*(\d+)$/.exec(p.fields.size);
if (!m || Number(m[1]) !== real.width || Number(m[2]) !== real.height) {
replace.set(p.fieldLines.size, `size: ${real.width},${real.height}`);
fixedSize.push(p.name);
}
}
if (pma && p.fields.pma === undefined) {
// Append after the last page field (or after the inserted size line).
addAfter(p.lastFieldLine, 'pma: true');
}
}
const out = [];
for (let i = 0; i < lines.length; i++) {
out.push(replace.has(i) ? replace.get(i) : lines[i]);
const extra = inserts.get(i);
if (extra) {
// size must come right after the page name; pma after the other fields.
extra.sort((a, b) => (a.startsWith('size:') ? -1 : 0) - (b.startsWith('size:') ? -1 : 0));
out.push(...extra);
}
}
const result = out.join('\n');
const original = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
return { text: result, changed: result !== original, pages: pages.map((p) => (replace.get(p.line) ?? p.name)), missingSize, fixedSize };
}
/**
* List page image names and region names of an atlas.
* @param {string} text
* @returns {{ pages: string[], regions: Set<string>, hasSize: boolean, hasPma: boolean }}
*/
export function atlasInfo(text) {
const { pages } = parseAtlas(text);
const regions = new Set();
for (const p of pages) for (const r of p.regions) regions.add(r);
return {
pages: pages.map((p) => p.name),
regions,
hasSize: pages.length > 0 && pages.every((p) => /^\d+\s*,\s*\d+$/.test(p.fields.size || '')),
hasPma: pages.length > 0 && pages.every((p) => p.fields.pma === 'true'),
};
}
+215
View File
@@ -0,0 +1,215 @@
// Audio bank resolution from the official excel/audio_data.json.
//
// - Unit combat SFX banks are named `battle.<EVENT>.<unitId>[.<ability>[.<n>…]]`
// (e.g. battle.ON_ABILITY_START.char_102_texas.attack); skill banks are
// `battle.ON_SKILL_START.<skillId>`. pickUnitSfx() turns them into the roles the
// client plays: attack (swing/cast), hit (impact), skill (activation), die, born.
// - BGM banks (`battle.ON_GAME_READY.<event>`, `sys.ON_ACTIVITY_LOADED.<act>`)
// carry an optional intro and a loop.
// - UI SFX: autochess banks (ui./battle.ON_ACT1AUTOCHESS_*) mapped to event
// names used by public/js/audio.js, plus a few generic UI sounds where the
// autochess mode has no dedicated bank (buy/refresh/error/pick/drop).
// Every sound is addressed by its path under sound_beta_2 (lower-case, .mp3).
const PREFIX_RE = /^audio\/sound_beta_2\//i;
/**
* Convert an audio_data asset id to its lower-case path under sound_beta_2.
* @param {string} asset e.g. 'Audio/Sound_Beta_2/Player/p_atk/p_atk_sword_n'
* @returns {string|null} e.g. 'player/p_atk/p_atk_sword_n.mp3'
*/
export function assetToPath(asset) {
if (typeof asset !== 'string' || !asset) return null;
const p = asset.replace(PREFIX_RE, '').replace(/\\/g, '/').toLowerCase();
if (!p || p.includes('..')) return null;
return p.endsWith('.mp3') ? p : p + '.mp3';
}
/**
* Index an audio_data.json object.
* @param {any} audioData parsed excel/audio_data.json
* @returns {{ bank: (name:string)=>string[], bgm: (name:string)=>({intro:string|null, loop:string}|null),
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>> }}
*/
export function indexAudio(audioData) {
const banks = new Map();
for (const b of Array.isArray(audioData?.soundFXBanks) ? audioData.soundFXBanks : []) {
if (!b || typeof b.name !== 'string') continue;
const paths = (Array.isArray(b.sounds) ? b.sounds : []).map((s) => assetToPath(s?.asset)).filter(Boolean);
if (!banks.has(b.name)) banks.set(b.name, []);
const list = banks.get(b.name);
for (const p of paths) if (!list.includes(p)) list.push(p);
}
const alias = audioData?.bankAlias && typeof audioData.bankAlias === 'object' ? audioData.bankAlias : {};
const bank = (name, depth = 0) => {
if (banks.has(name) && banks.get(name).length) return banks.get(name);
if (depth < 4 && typeof alias[name] === 'string') return bank(alias[name], depth + 1);
return [];
};
const bgmBanks = new Map();
for (const b of Array.isArray(audioData?.bgmBanks) ? audioData.bgmBanks : []) {
if (b && typeof b.name === 'string' && typeof b.loop === 'string') {
bgmBanks.set(b.name, { intro: assetToPath(b.intro), loop: assetToPath(b.loop) });
}
}
const bgm = (name, depth = 0) => {
if (bgmBanks.has(name)) return bgmBanks.get(name);
if (depth < 4 && typeof alias[name] === 'string') return bgm(alias[name], depth + 1);
return null;
};
// Per-unit and per-skill bank tables.
const unitBanks = new Map();
const skillBanks = new Map();
const addUnit = (name, paths) => {
const parts = name.split('.');
if (parts.length < 3 || parts[0] !== 'battle') return;
if (/^ON_SKILL_(START|FINISH|SPECIAL_POINT)$/.test(parts[1])) {
const skillId = parts.slice(2).join('.');
if (!skillBanks.has(skillId)) skillBanks.set(skillId, new Map());
skillBanks.get(skillId).set(parts[1], paths);
return;
}
const unit = parts[2];
if (!/^(char|enemy|token|trap)_/.test(unit)) return;
const key = parts[1] + (parts.length > 3 ? '.' + parts.slice(3).join('.') : '');
if (!unitBanks.has(unit)) unitBanks.set(unit, new Map());
unitBanks.get(unit).set(key, paths);
};
for (const [name, paths] of banks) if (paths.length) addUnit(name, paths);
for (const name of Object.keys(alias)) if (!banks.has(name)) { const p = bank(name); if (p.length) addUnit(name, p); }
return { bank, bgm, unitBanks, skillBanks };
}
/** Sort key for ability sub-keys: plain first, then numeric suffixes ascending. */
function abilityOrder(a, b) {
const na = a.split('.').length; const nb = b.split('.').length;
if (na !== nb) return na - nb;
return a.localeCompare(b, 'en', { numeric: true });
}
function firstMatching(banks, event, abilities) {
const keys = [...banks.keys()].filter((k) => k.startsWith(event + '.'));
for (const ab of abilities) {
const exact = `${event}.${ab}`;
if (banks.get(exact)?.length) return banks.get(exact);
const numbered = keys.filter((k) => k.startsWith(exact + '.')).sort(abilityOrder);
for (const k of numbered) if (banks.get(k)?.length) return banks.get(k);
}
return null;
}
/**
* Pick role → candidate sound paths for one unit.
* @param {Map<string,string[]>|undefined} banks unit bank table (from indexAudio().unitBanks)
* @returns {{ attack?: string[], hit?: string[], die?: string[], born?: string[] }}
*/
export function pickUnitSfx(banks) {
const out = {};
if (!banks || !banks.size) return out;
// Looser pass: abilities whose name mentions attack/combat (PowerAttack, StunCombat, CrossAttack…).
// Other abilities (skills, talents 'T.*', mode switches) are not normal attacks.
const attackLike = (event) => {
const keys = [...banks.keys()]
.filter((k) => k.startsWith(event + '.') && /attack|combat/i.test(k.slice(event.length + 1).split('.')[0]))
.sort(abilityOrder);
for (const k of keys) if (banks.get(k)?.length) return banks.get(k);
return null;
};
const attack = firstMatching(banks, 'ON_ABILITY_START', ['attack', 'combat'])
?? firstMatching(banks, 'ON_ABILITY_ON', ['attack', 'combat'])
?? attackLike('ON_ABILITY_START') ?? attackLike('ON_ABILITY_ON');
const hit = firstMatching(banks, 'ON_ABILITY_HIT', ['attack', 'combat']) ?? attackLike('ON_ABILITY_HIT');
if (attack) out.attack = attack;
if (hit) out.hit = hit;
if (banks.get('ON_UNIT_DEAD')?.length) out.die = banks.get('ON_UNIT_DEAD');
if (banks.get('ON_UNIT_BORN')?.length) out.born = banks.get('ON_UNIT_BORN');
return out;
}
/**
* UI / battle-flow SFX map: name → { bank } or { path } (path under sound_beta_2).
* Bank names are the official act1autochess banks (shared by act2autochess).
*/
export const UI_SFX = Object.freeze({
click: { path: 'general/g_ui/g_ui_btn_h.mp3' },
back: { path: 'general/g_ui/g_ui_btn_u.mp3' },
confirm: { path: 'general/g_ui/g_ui_confirm_h.mp3' },
tab: { path: 'general/g_ui/g_ui_tabswitch.mp3' },
pick: { path: 'general/g_ui/g_ui_pick.mp3' },
drop: { path: 'general/g_ui/g_ui_unpick.mp3' },
error: { path: 'general/g_ui/g_ui_scwarning.mp3' },
buy: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_HAND' },
sell: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
income: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
refresh: { path: 'general/g_ui/g_ui_rtargetrefresh.mp3' },
freeze: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_LOCK' },
levelup: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_UPGRADE' },
merge: { bank: 'battle.ON_ACT1AUTOCHESS_CHAR_BONUS' },
equip: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_DONE' },
itemMerge: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_BONUS' },
bondUp: { bank: 'battle.ON_ACT1AUTOCHESS_ADD_BOND' },
artPlace: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_BATTLE' },
ready: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_READY' },
timer: { bank: 'ui.ON_ACT1AUTOCHESS_COUNTDOWN' },
draft: { bank: 'ui.ON_ACT1AUTOCHESS_STRATEGY' },
yourTurn: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN' },
yourTurnCircle: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN_CIRCLE' },
target: { bank: 'ui.ON_ACT1AUTOCHESS_TARGET' },
broadcast: { bank: 'ui.ON_ACT1AUTOCHESS_BROADCASTHINT' },
danger: { bank: 'battle.ON_ACT1AUTOCHESS_ENTER_DANGER' },
emote: { bank: 'ui.ON_ACT1AUTOCHESS_EMOJIDIALOGUE' },
roundStart: { bank: 'ui.ON_ACT1AUTOCHESS_ROUNDSTART' },
rest: { bank: 'ui.ON_ACT1AUTOCHESS_REST' },
battleStart: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART' },
battleStartBoss: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART_BOSS' },
bossRoundTeam: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_TEAM' },
bossRoundSingle: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SINGLE' },
bossRoundSecret: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SECRET' },
killBoss: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS' },
killBossAll: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_ALL' },
killBossNormal: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_NORMAL' },
defenceStart: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_START' },
defenceUnite: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_UNITE' },
battleOverReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_REDUCE' },
battleOverNoReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NOREDUCE' },
battleOverNormal: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NORMAL' },
goFirst: { bank: 'ui.ON_ACT1AUTOCHESS_GOFIRST' },
disconnect: { bank: 'ui.ON_ACT1AUTOCHESS_DISCONNECT' },
settlementSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_SUCCEED' },
settlementFail: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_FAIL' },
settlementTeam: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_TEAM' },
settlementBossSign: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_BOSSSIGN' },
goodEvaluation: { bank: 'ui.ON_ACT1AUTOCHESS_GOODEVALUATION' },
load: { bank: 'ui.ON_ACT1AUTOCHESS_LOAD' },
start: { bank: 'ui.ON_ACT1AUTOCHESS_START' },
matchSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_SUCCEED' },
matchFail: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_FAIL' },
matchCancel: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_CANCEL' },
joinRoom: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_JOINROOM' },
});
/** In-battle generic SFX: name → { bank } or { path }. */
export const BATTLE_SFX = Object.freeze({
deploy: { path: 'battle/b_char/b_char_set.mp3' },
tokenDeploy: { path: 'battle/b_char/b_char_tokenset.mp3' },
charDie: { path: 'battle/b_char/b_char_dead.mp3' },
enemyDie: { path: 'battle/b_enemy/b_enemy_dead_n.mp3' },
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
win: { path: 'battle/b_ui/b_ui_win.mp3' },
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },
});
/**
* Resolve a { bank } / { path } spec to candidate sound paths.
* @param {{bank?:string, path?:string}} spec
* @param {(name:string)=>string[]} bank bank lookup from indexAudio()
* @returns {string[]}
*/
export function resolveSpec(spec, bank) {
if (spec?.path) return [spec.path];
if (spec?.bank) return bank(spec.bank);
return [];
}
+70
View File
@@ -0,0 +1,70 @@
// Cached upstream JSON indexes needed by the asset pipeline:
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
// Downloaded once when missing (or with --refresh-index), then reused.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { RAW, mirrorUrl } from './sources.mjs';
/**
* Read a cached JSON file, downloading it first when missing or unparsable.
* @param {object} o
* @param {string} o.cacheFile absolute cache path
* @param {string} o.url raw GitHub URL
* @param {boolean} [o.refresh] force re-download
* @param {boolean} [o.offline] never download (throw when the cache is missing)
* @param {(m:string)=>void} [o.log]
* @returns {Promise<any>} parsed JSON
*/
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log }) {
if (!refresh || offline) {
try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) {
if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`);
}
}
let lastErr = null;
for (const src of [url, mirrorUrl(url)].filter(Boolean)) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
log(`[cache] downloading ${src}`);
const res = await fetch(src, { signal: AbortSignal.timeout(180000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const json = JSON.parse(text);
await mkdir(dirname(cacheFile), { recursive: true });
await writeFile(cacheFile + '.tmp', text);
await rename(cacheFile + '.tmp', cacheFile);
return json;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
}
throw new Error(`cannot fetch ${url}: ${lastErr?.message}`);
}
/**
* Load audio_data.json (official) and Ark-Models models_data.json.
* @param {string} root project root
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void}} [opts]
* @returns {Promise<{ audioData: any, modelsData: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const audioData = await cachedJson({
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'audio_data.json'),
url: RAW.gamedata + 'excel/audio_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
const modelsData = await cachedJson({
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
url: RAW.arkModels + 'models_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
return { audioData, modelsData };
}
+219
View File
@@ -0,0 +1,219 @@
// Concurrent, idempotent, verifying downloader for the asset pipeline.
//
// - Bounded concurrency (default 16) over a job list; each job writes exactly one
// file under the output root, atomically (temp file + rename), so a partial
// download never appears at the final path.
// - Per source: up to `retries` attempts with exponential backoff on network
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
// a 404/410 moves on immediately. Sources: each candidate URL, then its
// jsDelivr mirror (sources.mirrorUrl).
// - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in
// .cache/assets-ledger.json.
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { mirrorUrl } from './sources.mjs';
import { validate } from './formats.mjs';
/**
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
* @typedef {{ status: 'ok'|'skip'|'miss'|'error', bytes: number, url?: string, error?: string, sizeChanged?: boolean }} JobResult
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class HttpError extends Error {
constructor(status, url) { super(`HTTP ${status} ${url}`); this.status = status; }
}
function fmtMB(n) { return (n / 1048576).toFixed(1) + ' MB'; }
export class Downloader {
/**
* @param {object} o
* @param {string} o.root absolute output directory (public/assets)
* @param {string} o.ledgerPath absolute path of the ledger JSON
* @param {number} [o.concurrency]
* @param {number} [o.retries]
* @param {number} [o.timeoutMs]
* @param {boolean} [o.force] re-download even when files exist
* @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
this.root = root;
this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
this.retries = Math.max(1, Number(retries) || 3);
this.timeoutMs = timeoutMs;
this.force = force;
this.log = log;
this.fetch = fetchImpl;
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
this.ledger = { files: {} };
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
this.dirty = 0;
this.saving = false;
}
/** Load the ledger (missing or corrupt ledger → empty). */
async loadLedger() {
try {
const j = JSON.parse(await readFile(this.ledgerPath, 'utf8'));
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') this.ledger = j;
} catch { /* first run */ }
}
/** Persist the ledger. */
async saveLedger() {
await mkdir(dirname(this.ledgerPath), { recursive: true });
const tmp = this.ledgerPath + '.tmp';
await writeFile(tmp, JSON.stringify(this.ledger));
await rename(tmp, this.ledgerPath);
this.dirty = 0;
}
/**
* Decide whether an existing file can be kept.
* @param {Job} job
* @returns {Promise<number>} size of the kept file, or -1 to (re)download
*/
async existingSize(job) {
if (this.force) return -1;
const abs = join(this.root, job.rel);
let st;
try { st = await stat(abs); } catch { return -1; }
if (!st.isFile() || st.size <= 0) return -1;
const led = this.ledger.files[job.rel];
if (job.mutable) {
// Post-processed files (atlases) change size; validate content instead.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
if (led && led.bytes === st.size) return st.size;
if (job.bytes && job.bytes === st.size) return st.size;
if (led && led.bytes !== st.size) return -1; // changed since we wrote it
// Unknown provenance (e.g. ledger deleted): keep it only if it validates.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
/**
* Fetch one URL with retries.
* @returns {Promise<{buf:Buffer}|{notFound:true}|{error:string}>}
*/
async fetchWithRetries(url, kind) {
let lastErr = 'unknown error';
for (let attempt = 1; attempt <= this.retries; attempt++) {
try {
const res = await this.fetch(url, {
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
signal: AbortSignal.timeout(this.timeoutMs),
redirect: 'follow',
});
if (res.status === 404 || res.status === 410) {
try { await res.body?.cancel(); } catch { /* ignore */ }
return { notFound: true };
}
if (!res.ok) {
try { await res.body?.cancel(); } catch { /* ignore */ }
throw new HttpError(res.status, url);
}
const buf = Buffer.from(await res.arrayBuffer());
const len = Number(res.headers.get('content-length'));
const enc = res.headers.get('content-encoding');
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
}
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
return { buf };
} catch (e) {
lastErr = e?.message || String(e);
if (attempt < this.retries && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
}
}
return { error: lastErr };
}
/**
* Download one job (all candidate URLs and mirrors).
* @param {Job} job
* @returns {Promise<JobResult>}
*/
async runJob(job) {
const kept = await this.existingSize(job);
if (kept >= 0) return { status: 'skip', bytes: kept };
let lastError = null;
for (const url of job.urls) {
const sources = [url, mirrorUrl(url)].filter(Boolean);
for (const src of sources) {
const r = await this.fetchWithRetries(src, job.kind);
if (r.notFound) continue;
if (r.error) { lastError = r.error; continue; }
const abs = join(this.root, job.rel);
await mkdir(dirname(abs), { recursive: true });
const tmp = `${abs}.part${process.pid}`;
try {
await writeFile(tmp, r.buf);
await rename(tmp, abs);
} catch (e) {
try { await unlink(tmp); } catch { /* ignore */ }
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
}
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
this.dirty++;
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
}
}
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
}
/**
* Run a list of jobs with bounded concurrency. Duplicate `rel`s are run once.
* @param {Job[]} jobs
* @param {string} label progress label
* @returns {Promise<Map<string, JobResult>>}
*/
async run(jobs, label = 'download') {
const unique = [];
const seen = new Set();
for (const j of jobs) { if (!seen.has(j.rel)) { seen.add(j.rel); unique.push(j); } }
const results = new Map();
const t0 = Date.now();
let done = 0; let bytes = 0; let lastLog = 0;
const counts = { ok: 0, skip: 0, miss: 0, error: 0 };
const progress = (force) => {
const now = Date.now();
if (!force && now - lastLog < 2000) return;
lastLog = now;
const secs = Math.max(0.001, (now - t0) / 1000);
this.log(`[${label}] ${done}/${unique.length} ok=${counts.ok} skip=${counts.skip} miss=${counts.miss} err=${counts.error} ` +
`${fmtMB(bytes)} ${(bytes / 1048576 / secs).toFixed(2)} MB/s`);
};
let next = 0;
const worker = async () => {
while (next < unique.length) {
const job = unique[next++];
let r;
try { r = await this.runJob(job); } catch (e) { r = { status: 'error', bytes: 0, error: e?.message || String(e) }; }
results.set(job.rel, r);
counts[r.status]++;
this.totals[r.status]++;
if (r.status === 'ok') { bytes += r.bytes; this.totals.bytesDownloaded += r.bytes; }
if (r.sizeChanged) this.totals.sizeChanged++;
done++;
if (this.dirty >= 200 && !this.saving) {
this.saving = true;
try { await this.saveLedger(); } catch { /* retried at the end */ } finally { this.saving = false; }
}
progress(false);
}
};
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
if (unique.length) progress(true);
await this.saveLedger();
return results;
}
}
+86
View File
@@ -0,0 +1,86 @@
// Self-hosted UI fonts (research 07 §8): Bender (numerals/HUD) and Novecento
// Wide (Latin headers), from TimWangZi/The-font-of-Arknights. Downloaded next
// to the site as public/fonts/*.{otf,ttf}, converted to .woff2 (tools/assets/
// woff2.mjs) and exposed through public/fonts/fonts.css. Noto Sans/Serif SC are
// NOT self-hosted (Google Fonts with system fallback, see DESIGN §10).
import { readFile, writeFile, mkdir, rename, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { RAW, joinUrl } from './sources.mjs';
import { encodeWoff2, decodeWoff2Tables, readSfnt } from './woff2.mjs';
/** Font files: family, weight, source path in the font repo, local base name. */
export const FONTS = Object.freeze([
{ family: 'Bender', weight: 400, style: 'normal', src: 'Bender/BENDER.OTF', name: 'bender-regular', ext: 'otf', bytes: 52536 },
{ family: 'Bender', weight: 300, style: 'normal', src: 'Bender/BenderLight.woff.ttf', name: 'bender-light', ext: 'ttf', bytes: 68256 },
{ family: 'Novecento Wide', weight: 400, style: 'normal', src: 'Novecento-Wide-Normal-2.otf', name: 'novecento-wide-normal', ext: 'otf', bytes: 45356 },
]);
/**
* Download jobs for the font files (rel paths are relative to public/fonts).
* @returns {{rel:string, urls:string[], kind:string, bytes:number}[]}
*/
export function fontJobs() {
return FONTS.map((f) => ({ rel: `${f.name}.${f.ext}`, urls: [joinUrl(RAW.fonts, f.src)], kind: 'font', bytes: f.bytes }));
}
async function exists(p) { try { return (await stat(p)).isFile(); } catch { return false; } }
/**
* Convert downloaded fonts to WOFF2 (skipping up-to-date outputs) and write fonts.css.
* @param {string} fontsDir absolute public/fonts directory
* @param {(m:string)=>void} log
* @returns {Promise<{ files: Record<string, {family:string, weight:number, woff2?:string, original?:string}>, css: string|null, errors: string[] }>}
*/
export async function buildFonts(fontsDir, log = console.log) {
await mkdir(fontsDir, { recursive: true });
const files = {};
const errors = [];
const faces = [];
for (const f of FONTS) {
const orig = join(fontsDir, `${f.name}.${f.ext}`);
const woff2 = join(fontsDir, `${f.name}.woff2`);
if (!(await exists(orig))) { errors.push(`missing font ${f.name}.${f.ext}`); continue; }
const entry = { family: f.family, weight: f.weight, original: `/fonts/${f.name}.${f.ext}` };
try {
const src = await readFile(orig);
readSfnt(src); // validate
let ok = false;
if (await exists(woff2)) {
// Up to date when it decodes to exactly the source tables.
try {
const dec = decodeWoff2Tables(await readFile(woff2));
const s = readSfnt(src);
ok = dec.tables.length === s.tables.length && s.tables.every((t) => dec.tables.find((d) => d.tag === t.tag)?.data.equals(t.data));
} catch { ok = false; }
}
if (!ok) {
const out = encodeWoff2(src);
const tmp = woff2 + '.tmp';
await writeFile(tmp, out);
await rename(tmp, woff2);
log(`[fonts] ${f.name}.woff2 ${src.length} → ${out.length} B`);
}
entry.woff2 = `/fonts/${f.name}.woff2`;
} catch (e) {
errors.push(`${f.name}: ${e.message}`);
}
files[f.name] = entry;
const srcs = [];
if (entry.woff2) srcs.push(`url('${entry.woff2}') format('woff2')`);
srcs.push(`url('${entry.original}') format('${f.ext === 'ttf' ? 'truetype' : 'opentype'}')`);
faces.push(`@font-face {\n font-family: '${f.family}';\n font-style: ${f.style};\n font-weight: ${f.weight};\n` +
` font-display: swap;\n src: ${srcs.join(',\n ')};\n}`);
}
let css = null;
if (faces.length) {
css = `/* Generated by tools/fetch-assets.mjs — do not edit.\n` +
` * Bender (c) Jovanny Lemonad / Oleg Zhuravlev, Gladkikh Ivan — free font.\n` +
` * Novecento Wide (c) Jan Tonellato / Synthview — free font.\n` +
` * Usage: font-family: Bender, Rajdhani, 'Noto Sans SC', sans-serif;\n` +
` * font-family: 'Novecento Wide', 'Saira Condensed', 'Noto Sans SC', sans-serif; */\n\n` +
faces.join('\n\n') + '\n';
await writeFile(join(fontsDir, 'fonts.css'), css);
}
return { files, css, errors };
}
+121
View File
@@ -0,0 +1,121 @@
// Binary format sniffing/validation for downloaded assets (PNG, MP3, fonts,
// Spine skel/atlas, JSON). Used to reject truncated or error-page downloads and
// to decide whether an existing file on disk can be kept (idempotent re-runs).
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Read width/height from a PNG IHDR chunk.
* @param {Buffer|Uint8Array} buf
* @returns {{width:number,height:number}|null} null when not a PNG
*/
export function pngSize(buf) {
if (!buf || buf.length < 24) return null;
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
if (!b.subarray(0, 8).equals(PNG_SIG)) return null;
if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
const width = b.readUInt32BE(16);
const height = b.readUInt32BE(20);
if (!width || !height) return null;
return { width, height };
}
/**
* True when the buffer is a complete PNG (signature + IHDR + trailing IEND chunk).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isCompletePng(buf) {
if (!pngSize(buf)) return false;
// IEND chunk: length(4)=0, 'IEND', CRC(4) → the last 12 bytes.
return buf.length >= 12 && buf.toString('latin1', buf.length - 8, buf.length - 4) === 'IEND';
}
/**
* True when the buffer looks like an MP3 (ID3v2 tag or MPEG audio frame sync).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isMp3(buf) {
if (!buf || buf.length < 128) return false;
if (buf.toString('latin1', 0, 3) === 'ID3') return true;
return buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0;
}
/**
* True when the buffer is an OpenType/TrueType font (sfnt).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isSfnt(buf) {
if (!buf || buf.length < 12) return false;
const tag = buf.readUInt32BE(0);
return tag === 0x00010000 || tag === 0x4f54544f /* OTTO */ || tag === 0x74727565 /* true */;
}
/**
* True when the buffer is a WOFF2 file.
* @param {Buffer} buf
* @returns {boolean}
*/
export function isWoff2(buf) {
return !!buf && buf.length >= 48 && buf.readUInt32BE(0) === 0x774f4632;
}
/**
* True when the text looks like a Spine atlas (has at least one page image line).
* @param {Buffer|string} data
* @returns {boolean}
*/
export function isAtlasText(data) {
const s = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
if (s.includes('\u0000')) return false;
return /^[^\s:][^\r\n:]*\.(png|webp|jpg)\s*$/im.test(s);
}
/**
* True when the buffer looks like a Spine binary skeleton (not an HTML/text error page).
* Spine 3.8 binaries start with a varint-prefixed hash string.
* @param {Buffer} buf
* @returns {boolean}
*/
export function isSkelBinary(buf) {
if (!buf || buf.length < 32) return false;
const head = buf.toString('latin1', 0, 16).toLowerCase();
return !head.startsWith('<!doctype') && !head.startsWith('<html') && !head.startsWith('404');
}
/**
* Validate a buffer for the given asset kind.
* @param {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'} kind
* @param {Buffer} buf
* @returns {boolean}
*/
export function validate(kind, buf) {
switch (kind) {
case 'png': return isCompletePng(buf);
case 'mp3': return isMp3(buf);
case 'font': return isSfnt(buf);
case 'atlas': return isAtlasText(buf);
case 'skel': return isSkelBinary(buf);
case 'json':
try { JSON.parse(buf.toString('utf8')); return true; } catch { return false; }
default: return !!buf && buf.length > 0;
}
}
/**
* Infer the asset kind from a file name.
* @param {string} name
* @returns {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'}
*/
export function kindOf(name) {
const ext = String(name).toLowerCase().split('.').pop();
if (ext === 'png') return 'png';
if (ext === 'mp3') return 'mp3';
if (ext === 'otf' || ext === 'ttf') return 'font';
if (ext === 'atlas') return 'atlas';
if (ext === 'skel') return 'skel';
if (ext === 'json') return 'json';
return 'bin';
}
+137
View File
@@ -0,0 +1,137 @@
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
// the single-file download alternatives, and resolves the template against the
// files that actually exist on disk into data/assets.json. Entries whose files
// are missing are dropped (never emitted as broken URLs).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { assetUrl, mirrorUrl } from './sources.mjs';
/** Manifest schema version (bump on breaking shape changes). */
export const MANIFEST_VERSION = 1;
const isLeaf = (n) => !!n && typeof n === 'object' && Array.isArray(n.alts);
const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'string' && Object.keys(n).length === 1;
/**
* Collect every leaf of the template with its dotted path.
* @param {any} node
* @param {string} [path]
* @param {{path:string, leaf:any}[]} [out]
* @returns {{path:string, leaf:any}[]}
*/
export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); return out; }
if (isModelRef(node) || !node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out;
}
/**
* Download leaves round by round: round r tries each unsatisfied leaf's r-th alternative.
* A leaf only moves on to its next alternative after a definitive miss (404 on every source);
* a transient failure (network error, 5xx, invalid payload after all retries) stops that leaf, so
* a fallback file (e.g. the base enemy's icon) never lands on the primary's path by accident and
* the next run retries the primary.
* @param {{path:string, leaf:any}[]} leaves
* @param {import('./downloader.mjs').Downloader} dl
* @param {string} root public/assets
* @param {string} [label]
* @returns {Promise<string[]>} dotted paths of leaves that stopped on a transient error
*/
export async function downloadLeaves(leaves, dl, root, label = 'files') {
const tried = new Map(leaves.map((l) => [l, 0]));
const blocked = new Set();
const satisfied = (l) => l.leaf.alts.slice(0, tried.get(l)).some((a) => existsSync(join(root, a.rel)));
let last = new Map();
for (let round = 0; round < 8; round++) {
const jobs = [];
for (const l of leaves) {
const t = tried.get(l);
if (blocked.has(l)) continue;
if (t > 0 && satisfied(l)) continue;
if (t > 0 && last.get(l.leaf.alts[t - 1].rel)?.status === 'error') { blocked.add(l); continue; }
if (t >= l.leaf.alts.length) continue;
jobs.push(l.leaf.alts[t]);
tried.set(l, t + 1);
}
if (!jobs.length) break;
last = await dl.run(jobs, round === 0 ? label : `${label} fallback#${round}`);
}
return [...blocked].map((l) => l.path);
}
/**
* Resolve the template against the disk.
* @param {any} template
* @param {object} o
* @param {string} o.root public/assets
* @param {Map<string, any>} o.spine resolved Spine entries by model key
* @param {(rel:string)=>string|undefined} [o.sourceOf] URL a file was downloaded from (ledger), to report
* fallbacks that share the primary's path (e.g. an enemy icon taken from its base id)
* @returns {{ value: any, misses: string[], fallbacks: string[], files: Set<string> }}
*/
export function resolveTemplate(template, { root, spine, sourceOf = () => undefined }) {
const misses = [];
const fallbacks = [];
const files = new Set();
const walk = (node, path) => {
if (node === null || node === undefined) return undefined;
if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i];
if (existsSync(join(root, a.rel))) {
const src = sourceOf(a.rel);
const primary = node.alts[0].urls.flatMap((u) => [u, mirrorUrl(u)]);
if (i > 0) fallbacks.push(`${path} ← ${src || a.urls[0]}`);
else if (src && !primary.includes(src)) fallbacks.push(`${path} ← ${src}`);
files.add(a.rel);
return assetUrl(a.rel);
}
}
misses.push(path);
return undefined;
}
if (isModelRef(node)) {
const e = spine.get(node.model);
if (!e) { misses.push(`${path} (spine ${node.model})`); return undefined; }
for (const u of [e.skel, e.atlas, ...e.textures]) files.add(u.replace(/^\/assets\//, ''));
return e;
}
if (Array.isArray(node)) return node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
if (typeof node !== 'object') return node;
const out = {};
for (const [k, v] of Object.entries(node)) {
const r = walk(v, path ? `${path}.${k}` : k);
if (r === undefined) continue;
if (r && typeof r === 'object' && !Array.isArray(r) && !Object.keys(r).length && isContainer(v)) continue;
out[k] = r;
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}
/**
* Total size in bytes of files under root.
* @param {string} root
* @param {Iterable<string>} rels
* @returns {number}
*/
export function totalBytes(root, rels) {
let n = 0;
for (const r of rels) { try { n += statSync(join(root, r)).size; } catch { /* missing */ } }
return n;
}
/**
* Deterministic short content hash of a JSON value (for cache busting).
* @param {any} value
* @returns {string}
*/
export function contentHash(value) {
return createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12);
}
+463
View File
@@ -0,0 +1,463 @@
// Builds the asset plan: which files the game needs, where they come from and
// where they go, as a *manifest template* that mirrors data/assets.json.
//
// Template leaves:
// { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative
// that ends up on disk wins (later alts are fallbacks: other URLs for the
// same file, or other sounds of the same bank);
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models.
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json.
//
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
// operators), the 20 pool tokens, every enemy that can appear in an
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
// the 23 bonds, 59 shop items, 40 bands,
// default-skill icons, profession icons, autochess UI sprites, BGM and SFX.
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
/** Enemies with no Spine anywhere: render them with another enemy's model (research 07 §5.6). */
export const ENEMY_SPINE_ALIAS = Object.freeze({
enemy_1305_mhslim: 'enemy_1007_slime',
enemy_1305_mhslim_2: 'enemy_1007_slime',
});
/** Loading illustrations referenced by act2autochess modeDataDict (non-training). */
const LOADING_USED = new Set(['loading_ac_core', 'loading_ac_prototype', 'loading_ac_hard', 'loading_ac_abyss']);
const PROFESSIONS = ['caster', 'medic', 'pioneer', 'sniper', 'special', 'support', 'tank', 'warrior'];
/** Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn]. */
const UI_EXTRAS = (() => {
const L = [];
const mc = 'ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/';
for (const m of ['normal', 'hard', 'abyss', 'funny']) L.push(['modeChoice', `${m}_rhodes_island`, `${mc}${m}_rhodes_island.png`]);
const br = 'ui/autochess/[uc]autochessouter/battleready/auto_chess_battle_ready_state/';
for (const k of ['bg_mountain1', 'bg_mountain2', 'bg_terrain', 'bg_ring', 'rhodes', 'rhodes_white', 'rhodes_outline']) L.push(['battleReady', k, `${br}${k}.png`]);
const bn = 'arts/ui/[uc]battlecommon/ui_battle_new/';
for (const k of ['btn_speed_1x', 'btn_speed_2x', 'btn_pause', 'slider_hp_back', 'slider_hp_fill', 'attack_range_attack', 'attack_range_stand']) L.push(['battleUi', k, `${bn}${k}.png`]);
L.push(['battleUi', 'boss_avatar_bg', `${bn}enemybossinfo/sprite_enemy_boss_avatar_bg.png`]);
L.push(['battleUi', 'skill_ready', 'arts/ui/[uc]battlecommon/ui_battle/sprite_skill_ready.png']);
L.push(['skillIcon', 'empty', 'arts/ui/[uc]charcommon/skills/empty_skill.png']);
L.push(['skillIcon', 'empty_large', 'arts/ui/[uc]charcommon/skills/empty_skill_large.png']);
L.push(['entry', 'season_logo_settle', 'activity/[uc]act2autochess/arts/seasonlogo/season_logo_settle_game.png']);
const ec = 'ui/autochess/[uc]autochessbattle/effectchoose/';
for (const k of ['bg_circle', 'bg_grad', 'bottom', 'deco_glow_top', 'plus', 'square_1', 'square_2', 'square_fill_1', 'square_fill_2', 'tip_glow', 'tip_wait', 'title']) {
L.push(['effectChoose', k, `${ec}autochess_battle_effect_choose_panel/${k}.png`]);
}
for (const k of ['bg_normal', 'bg_selected', 'btn_bg', 'btn_icon', 'select_frame', 'select_glow']) L.push(['effectChoose', `card_${k}`, `${ec}card_small_item/${k}.png`]);
for (const k of ['head_bg', 'head_outline']) L.push(['effectChoose', `avatar_${k}`, `${ec}card_avatar_item/${k}.png`]);
const er = 'ui/autochess/[uc]autochessbattle/equipreplace/equip_replace_dialog/equip_replace_';
for (const k of ['bg', 'arrow_head', 'arrow_line', 'avatart_bg', 'avatart_frame', 'close', 'replace_icon', 'opiton_bg']) L.push(['equipReplace', k, `${er}${k}.png`]);
const bd = 'ui/autochess/[uc]autochessbattle/hud/dialog/autochess_hud_bond_detail_dialog/';
for (const k of ['active_icon', 'active_stack_bg', 'arrow', 'detail_bg', 'matte_circle', 'title_char_icon']) L.push(['bondDetail', k, `${bd}${k}.png`]);
const pr = 'ui/autochess/[uc]autochessbattle/prepready/';
for (const k of ['cancel_bg', 'cancel_icon', 'player_bg', 'player_frame', 'player_ready', 'ready_bg', 'ready_frame', 'ready_icon']) L.push(['prepReady', k, `${pr}panel_prep_ready/${k}.png`]);
L.push(['prepReady', 'countdown_arrow', `${pr}countdown_arrow.png`]);
const si = 'ui/autochess/[uc]autochessouter/stageinfo/auto_chess_stage_info_state/';
for (const k of ['img_title_mode_abyss', 'img_title_mode_funny', 'img_title_mode_hard', 'img_title_mode_normal', 'btn_confirm', 'btn_confirmed']) L.push(['stageInfo', k, `${si}${k}.png`]);
return L;
})();
/** Renames of research 07 `arts` groups to manifest UI groups. */
const ARTS_GROUPS = Object.freeze({
rarityStars: 'rarity', rarityStarsYellow: 'rarityYellow', eliteIcon: 'elite', eliteIconLarge: 'eliteLarge',
campLogo: 'campLogo', loadingIllust: 'loading', battleCommon: 'battle', act2Entry: 'entry', itemRarityBg: 'itemRarity',
});
// ---------------------------------------------------------------------------
// helpers
/**
* One download alternative.
* @param {string} rel path under public/assets
* @param {string|string[]} urls
* @param {number} [bytes] expected size
*/
function alt(rel, urls, bytes) {
const a = { rel, urls: [].concat(urls).filter((u) => typeof u === 'string' && u), kind: kindOf(rel) };
if (Number.isInteger(bytes) && bytes > 0) a.bytes = bytes;
return a;
}
/** A leaf with the given alternatives (null alternatives are dropped). */
function leaf(...alts) {
const list = alts.flat().filter((a) => a && a.urls && a.urls.length);
return list.length ? { alts: list } : null;
}
/** Sound path under sound_beta_2 → alternative under public/assets/audio/<sub>. */
function soundAlt(path, sub = 'sfx') {
const rel = `audio/${sub}/` + path.split('/').map(safeName).join('/');
return alt(rel, joinUrl(RAW.aa2voice, path));
}
function soundLeaf(paths, sub = 'sfx', max = 4) {
return leaf((paths || []).slice(0, max).map((p) => soundAlt(p, sub)));
}
/** Expected bytes for a 07 {url, bytes} record when it matches `url`. */
function bytesOf(rec, url) {
return rec && typeof rec === 'object' && rec.url === url && Number.isInteger(rec.bytes) ? rec.bytes : undefined;
}
/** Local file stem for a skeleton URL or file name ('…/char_102_texas.skel' → 'char_102_texas'). */
function skelStem(urlOrName) {
return safeName(urlBase(urlOrName).replace(/\.skel$/i, ''));
}
/** Collect every enemy key referenced under a wave/branch structure. */
function walkKeys(node, add) {
if (Array.isArray(node)) { for (const x of node) walkKeys(x, add); return; }
if (node && typeof node === 'object') {
if (typeof node.key === 'string') add(node.key);
for (const v of Object.values(node)) walkKeys(v, add);
}
}
// ---------------------------------------------------------------------------
// id sets
/**
* Per-charId skill indices used by the pool (0-based; primary first).
* @param {any} ops03 docs/research/03-operators.json
* @returns {Map<string, number[]>}
*/
export function skillIndicesByChar(ops03) {
const primary = new Map();
const all = new Map();
const add = (id, idx, isPrimary) => {
if (typeof id !== 'string' || !Number.isInteger(idx) || idx < 0) return;
if (!all.has(id)) all.set(id, new Set());
all.get(id).add(idx);
if (isPrimary && !primary.has(id)) primary.set(id, idx);
};
const chess = Array.isArray(ops03?.chess) ? ops03.chess : [];
for (const c of chess) if (c.charId) add(c.charId, c.defaultSkillIndex, !c.isGolden);
for (const c of chess) if (c.backup?.charId) add(c.backup.charId, c.backup.skillIndex, true);
const out = new Map();
for (const [id, set] of all) {
const p = primary.has(id) ? primary.get(id) : Math.min(...set);
out.set(id, [p, ...[...set].filter((i) => i !== p).sort((a, b) => a - b)]);
}
return out;
}
/**
* Every enemy id that can appear in an act2autochess match.
* @param {{ assets07:any, enemies05:any, maps05:any, ops03?:any }} r
* ops03 (optional): enemy units spawned by operator kits (e.g. 隐德来希's S3 summons
* `enemy_5601_entlec` 心烛 via a talent blackboard `take_extra_enemy_key`)
* @returns {string[]} sorted ids
*/
export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
const known = (id) => !!(enemies05?.enemies?.[id] || assets07?.enemies?.[id]);
const set = new Set(Object.keys(assets07?.enemies || {}));
const add = (k) => { if (typeof k === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(k)) set.add(k); };
for (const c of Array.isArray(ops03?.chess) ? ops03.chess : []) {
const kit = JSON.stringify([c?.skill ?? null, c?.talents ?? null, c?.tokens ?? null]);
for (const m of kit.match(/enemy_\d+_[a-z0-9_]+/gi) || []) add(m);
}
for (const lv of Object.values(maps05?.roundLevels || {})) {
const usedBy = Array.isArray(lv?.usedBy) ? lv.usedBy : [];
if (usedBy.length && usedBy.every((u) => String(u).startsWith('mode_training'))) continue; // tutorial is out of scope
for (const ref of lv?.enemyDbRefs || []) add(Array.isArray(ref) ? ref[0] : ref);
walkKeys(lv?.waves, add);
walkKeys(lv?.branches, add);
}
for (const b of Object.values(enemies05?.bosses || {})) { add(b?.enemyId); for (const s of b?.spawns || []) add(s); }
// Closure over summons: randomEnemyAttribute spawns + `enemy_key` blackboard references.
let changed = true;
while (changed) {
changed = false;
for (const id of [...set]) {
const e = enemies05?.enemies?.[id];
if (!e) continue;
const refs = new Set(e.ac?.rand?.spawns || []);
for (const m of JSON.stringify([e.skills ?? null, e.talent ?? null]).match(/enemy_\d+_[a-z0-9_]+/gi) || []) refs.add(m);
for (const r of refs) if (!set.has(r) && known(r)) { set.add(r); changed = true; }
}
}
return [...set].sort();
}
// ---------------------------------------------------------------------------
// plan
/**
* Build the asset plan.
* @param {object} p
* @param {any} p.assets07 docs/research/07-assets.json
* @param {any} p.ops03 docs/research/03-operators.json
* @param {any} p.enemies05 docs/research/05-enemies.json
* @param {any} p.maps05 docs/research/05-maps.json
* @param {ReturnType<import('./audio.mjs').indexAudio>} p.audio indexed audio_data.json
* @param {any} p.modelsData Ark-Models models_data.json
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {} }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
const skillIdx = skillIndicesByChar(ops03);
// --- Spine model helpers -------------------------------------------------
const addModel = (key, def) => { if (!models.has(key)) models.set(key, { key, ...def }); return { model: key }; };
const fexliModel = (key, kind, dir, rec, skillIndices) => {
// rec: 07 { skel:{url,bytes}|url|url[], atlas, png } — arrays are candidate folders (same order for all three)
const us = (x) => (Array.isArray(x) ? x : [typeof x === 'string' ? x : x?.url]).filter((v) => typeof v === 'string' && v);
const b = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x.bytes : undefined);
if (!us(rec?.skel).length || !us(rec?.atlas).length || !us(rec?.png).length) return null;
const stem = skelStem(us(rec.skel)[0]);
return addModel(key, {
kind, dir, pma: false, skillIndices,
baseUrl: urlDir(us(rec.atlas)[0]),
skel: alt(`${dir}${stem}.skel`, us(rec.skel), b(rec.skel)),
// pixi-spine finds the atlas by swapping the .skel extension: keep the same stem.
atlas: { ...alt(`${dir}${stem}.atlas`, us(rec.atlas), b(rec.atlas)), mutable: true },
pngs: [alt(dir + safeName(urlBase(us(rec.png)[0])), us(rec.png), b(rec.png))],
});
};
// --- operators -----------------------------------------------------------
const chars = {};
const skills = {};
const skillsById = {};
const unitsSfx = {};
const charIds = Object.keys(assets07?.operators || {}).sort();
for (const id of charIds) {
const o = assets07.operators[id];
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
// clips of every skill index (the pool's primary index first, as before)
const idx0 = skillIdx.get(id) || [0];
const idx = [...idx0, ...(o.skills || []).map((k) => k.index).filter((i) => Number.isInteger(i) && i >= 0 && !idx0.includes(i)).sort((a, b) => a - b)];
const c = {};
c.avatar = leaf(alt(`char/avatar/${id}.png`, o.avatar?.e0e1?.url, o.avatar?.e0e1?.bytes));
if (o.avatar?.e2?.url) c.avatarE2 = leaf(alt(`char/avatar/${id}_2.png`, o.avatar.e2.url, o.avatar.e2.bytes));
c.portrait = leaf(alt(`char/portrait/${id}_1.png`, o.portrait?.e0e1?.url, o.portrait?.e0e1?.bytes));
if (o.portrait?.e2?.url) c.portraitE2 = leaf(alt(`char/portrait/${id}_2.png`, o.portrait.e2.url, o.portrait.e2.bytes));
c.spine = {};
const front = fexliModel(`op:${id}:front`, 'op', `spine/op/${id}/front/`, o.battleSpine?.front, idx);
if (front) c.spine.front = front; else notes.push(`${id}: no Front battle Spine in research data`);
const back = fexliModel(`op:${id}:back`, 'op', `spine/op/${id}/back/`, o.battleSpine?.back, idx);
if (back) c.spine.back = back;
chars[id] = c;
// skill icons (+ skill SFX) of every skill index
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const skillSfx = {};
for (const i of idx) {
const s = (o.skills || []).find((k) => k.index === i);
if (!s) { notes.push(`${id}: skill index ${i} missing in research data`); continue; }
const iconId = s.iconId || s.skillId;
if (s.icon?.url && !skills[iconId]) skills[iconId] = leaf(alt(`skill/${safeName(iconId)}.png`, s.icon.url, s.icon.bytes));
if (s.skillId) skillsById[s.skillId] = iconId;
const ss = audio.skillBanks.get(s.skillId)?.get('ON_SKILL_START');
if (ss?.length) skillSfx[String(i)] = soundLeaf(ss);
}
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- tokens --------------------------------------------------------------
const chessById = new Map((ops03?.chess || []).map((c) => [c.chessId, c]));
const tokens = {};
const tokenIds = new Set(Object.keys(assets07?.tokens || {}));
for (const id of extraTokenIds) if (typeof id === 'string' && /^token_\d+_[a-z0-9_]+$/i.test(id)) tokenIds.add(id);
for (const id of [...tokenIds].sort()) {
// Tokens unknown to research 07: default avatar/Spine locations (misses are tolerated).
const t = assets07?.tokens?.[id] ?? {
avatar: { url: `${RAW.yuanyan}avatar/${id}.png` },
battleSpineDefault: null,
battleSpineSkinVariantsOnly: [id],
};
const usedBy = ops03?.tokensUsedByPool?.[id]?.usedByChess || [];
const ownerChess = usedBy.map((cid) => chessById.get(cid)).find(Boolean);
const entry = { owner: ownerChess?.charId || ownerChess?.backup?.charId || null };
entry.avatar = t.avatar?.url ? leaf(alt(`token/avatar/${id}.png`, t.avatar.url, t.avatar.bytes)) : null;
let model = null;
if (t.battleSpineDefault) {
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, t.battleSpineDefault, [0]);
} else if (Array.isArray(t.battleSpineSkinVariantsOnly) && t.battleSpineSkinVariantsOnly.length) {
// Skin-variant models live in a `Spine/` folder, or `Front/` for directional ones.
const v = t.battleSpineSkinVariantsOnly[0];
const bases = ['Spine', 'Front'].map((f) => `${RAW.fexli}spine/${encodeURIComponent(id)}/${encodeURIComponent(v)}/${f}/${encodeURIComponent(v)}`);
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, {
skel: bases.map((b) => b + '.skel'), atlas: bases.map((b) => b + '.atlas'), png: bases.map((b) => b + '.png'),
}, [0]);
if (model) entry.spineVariant = v;
}
entry.spine = model;
tokens[id] = entry;
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- enemies -------------------------------------------------------------
const handbookOf = new Map();
for (const b of Object.values(enemies05?.bosses || {})) if (b?.enemyId && b.handbookId && b.handbookId !== b.enemyId) handbookOf.set(b.enemyId, b.handbookId);
for (const [eid, hb] of Object.entries(extraHandbook || {})) if (typeof hb === 'string' && hb !== eid && !handbookOf.has(eid)) handbookOf.set(eid, hb);
const mdData = modelsData?.data || {};
const enemyDir = modelsData?.storageDirectory?.Enemy || 'models_enemies';
const pick = (v) => (Array.isArray(v) ? (v.find((x) => typeof x === 'string' && !x.includes('$')) ?? v[0]) : v);
const arkModel = (eid) => {
const key = eid.replace(/^enemy_/, '');
const al = mdData[key]?.assetList;
const skel = pick(al?.['.skel']); const atlas = pick(al?.['.atlas']); const png = pick(al?.['.png']);
if (![skel, atlas, png].every((x) => typeof x === 'string' && x)) return null;
const base = `${RAW.arkModels}${enemyDir}/${encodeURIComponent(key)}/`;
const rec07 = assets07?.enemies?.[eid]?.battleSpine;
const dir = `spine/enemy/${eid}/`;
const stem = skelStem(skel);
const mk = (file, r, rel) => alt(rel ?? dir + safeName(file), base + encodeURIComponent(file), bytesOf(r, base + encodeURIComponent(file)));
return addModel(`enemy:${eid}`, {
kind: 'enemy', dir, pma: true, skillIndices: [0], baseUrl: base,
skel: mk(skel, rec07?.skel, `${dir}${stem}.skel`),
atlas: { ...mk(atlas, rec07?.atlas, `${dir}${stem}.atlas`), mutable: true },
pngs: [mk(png, rec07?.png)],
});
};
const baseIdOf = (eid) => { const m = /^(enemy_\d+_[a-z0-9]+?)_\d+$/i.exec(eid); return m ? m[1] : null; };
const enemyIdSet = new Set(collectEnemyIds({ assets07, enemies05, maps05, ops03 }));
for (const id of extraEnemyIds) if (typeof id === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(id)) enemyIdSet.add(id);
// Boss handbook ids (their own icon/model ids) are addressable too.
for (const hb of handbookOf.values()) if (/^enemy_\d+_[a-z0-9_]+$/i.test(hb)) enemyIdSet.add(hb);
const enemyIds = [...enemyIdSet].sort();
const enemies = {};
for (const id of enemyIds) {
const e = {};
const icon07 = assets07?.enemies?.[id]?.icon;
const iconAlts = [alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${id}.png`, bytesOf(icon07, `${RAW.yuanyan}enemy/${id}.png`))];
for (const other of [handbookOf.get(id), baseIdOf(id)]) if (other) iconAlts.push(alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${other}.png`));
e.icon = leaf(iconAlts);
// Spine: own model, else alias chain (research 07 §5.6).
let spine = arkModel(id);
if (!spine) {
const seen = new Set([id]);
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean);
while (!spine && queue.length) {
const cand = queue.shift();
if (seen.has(cand)) continue;
seen.add(cand);
spine = arkModel(cand);
if (spine) e.spineAliasOf = cand;
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean));
}
if (!spine) notes.push(`${id}: no enemy Spine upstream (client draws the icon, if any, or a glyph)`);
else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
}
e.spine = spine;
enemies[id] = e;
let banks = audio.unitBanks.get(id);
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
if (banks?.size) break;
if (other) banks = audio.unitBanks.get(other);
}
const sfx = pickUnitSfx(banks);
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- bonds / items / bands -------------------------------------------------
const bonds = {};
for (const [bondId, b] of Object.entries(assets07?.bonds || {})) {
bonds[bondId] = leaf(alt(`bond/${bondId}.png`, b.icon?.url, b.icon?.bytes), b.fallbackCampLogo ? alt(`bond/${bondId}.png`, b.fallbackCampLogo) : null);
}
const items = {};
for (const [trapId, it] of Object.entries(assets07?.items || {})) items[trapId] = leaf(alt(`item/${trapId}.png`, it.icon?.url, it.icon?.bytes));
const bands = {};
for (const [bandId, b] of Object.entries(assets07?.bands || {})) bands[bandId] = leaf(alt(`band/${bandId}.png`, b.icon?.url, b.icon?.bytes));
// --- professions ----------------------------------------------------------
const prof = { icon: {}, large: {}, battlecard: {}, sub: {} };
for (const p of PROFESSIONS) {
const i = assets07?.arts?.professionIcon?.[p];
if (i) prof.icon[p] = leaf(alt(`prof/icon_${p}.png`, i));
const l = assets07?.arts?.professionIconLargeWhite?.[p];
if (l) prof.large[p] = leaf(alt(`prof/large_${p}.png`, l));
}
for (const p of [...PROFESSIONS, 'token']) {
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
}
for (const o of Object.values(assets07?.operators || {})) {
const sub = o.subProfessionId;
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
}
// --- UI ------------------------------------------------------------------
const ui = {};
const addUi = (group, key, url) => {
const k = safeName(key);
const name = `${group}/${k}`;
if (!ui[name] && url) ui[name] = leaf(alt(`ui/${group}/${k}.png`, url));
};
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
}
const nations = new Set(Object.values(assets07?.operators || {}).map((o) => o.nationId).filter(Boolean));
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
for (const [src, group] of Object.entries(ARTS_GROUPS)) {
for (const [key, url] of Object.entries(assets07?.arts?.[src] || {})) {
if (src === 'campLogo' && !logos.has(key)) continue;
if (src === 'loadingIllust' && !LOADING_USED.has(key)) continue;
addUi(group, key, url);
}
}
for (const [group, key, path] of UI_EXTRAS) addUi(group, key, joinUrl(RAW.aa2, path));
// --- audio ----------------------------------------------------------------
const bgmLeaf = (bankName) => {
const b = audio.bgm(bankName);
if (!b?.loop) { notes.push(`BGM bank ${bankName} not found`); return null; }
const out = { loop: soundLeaf([b.loop], 'bgm', 1) };
if (b.intro) out.intro = soundLeaf([b.intro], 'bgm', 1);
return out;
};
// BGM files are flattened to audio/bgm/<basename>.
const flatBgm = (node) => {
if (!node) return node;
for (const k of Object.keys(node)) {
const l = node[k];
if (l?.alts) for (const a of l.alts) a.rel = 'audio/bgm/' + safeName(a.rel.split('/').pop());
}
return node;
};
const bgm = {
lobby: flatBgm(bgmLeaf('sys.ON_ACTIVITY_LOADED.act2autochess')),
prep: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
combat: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
boss: flatBgm(bgmLeaf('battle.ON_GAME_READY.rglk1phantomcastle')),
};
const bossBgm = {};
for (const lv of Object.values(maps05?.roundLevels || {})) {
if (typeof lv?.bgm !== 'string') continue;
for (const u of lv.usedBy || []) {
const m = /\(boss:(boss_\d+)\)/.exec(String(u));
if (m && !bossBgm[m[1]] && !String(u).startsWith('mode_training')) bossBgm[m[1]] = flatBgm(bgmLeaf(`battle.ON_GAME_READY.${lv.bgm}`));
}
}
const sfxUi = {};
for (const [name, spec] of Object.entries(UI_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxUi[name] = l; else notes.push(`UI SFX ${name}: no sound`); }
const sfxBattle = {};
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof,
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
};
return { template, models, notes };
}
+98
View File
@@ -0,0 +1,98 @@
// Spine 3.8 binary skeleton (.skel) inspection in Node, using the same parser
// the client ships (@pixi-spine/runtime-3.8, a dependency of pixi-spine).
//
// We only need metadata: animation names + durations, event names, the times
// of `OnAttack` events per animation (hit timing, research 07 §5.4) and the
// skeleton bounds. Attachments are created without textures, so no atlas or
// canvas is needed; attachment paths are checked against the atlas region names
// to detect mismatched downloads.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const require = createRequire(join(root, 'package.json'));
let runtime = null;
/**
* Lazily load @pixi-spine/runtime-3.8.
* @returns {any} the runtime module
* @throws when pixi-spine is not installed (run `npm install`)
*/
function loadRuntime() {
if (runtime) return runtime;
runtime = require('@pixi-spine/runtime-3.8');
return runtime;
}
/**
* True when the Spine parser can be loaded.
* @returns {boolean}
*/
export function skelParserAvailable() {
try { loadRuntime(); return true; } catch { return false; }
}
function makeLoader(r, regions, missing) {
const check = (path) => { if (regions && !regions.has(path)) missing.add(path); };
return {
newRegionAttachment(skin, name, path) { check(path); return new r.RegionAttachment(name); },
newMeshAttachment(skin, name, path) { check(path); return new r.MeshAttachment(name); },
newBoundingBoxAttachment(skin, name) { return new r.BoundingBoxAttachment(name); },
newPathAttachment(skin, name) { return new r.PathAttachment(name); },
newPointAttachment(skin, name) { return new r.PointAttachment(name); },
newClippingAttachment(skin, name) { return new r.ClippingAttachment(name); },
};
}
const round3 = (x) => Math.round(Number(x) * 1000) / 1000;
/**
* @typedef {{ version: string, animations: string[], durations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: {x:number,y:number,width:number,height:number}|null,
* missingRegions: string[] }} SkelInfo
*/
/**
* Parse a Spine 3.8 binary skeleton.
* @param {Uint8Array} bytes .skel file contents
* @param {Set<string>} [atlasRegions] region names of the matching atlas (optional, for validation)
* @returns {SkelInfo}
* @throws on malformed data
*/
export function parseSkel(bytes, atlasRegions) {
const r = loadRuntime();
const missing = new Set();
const bin = new r.SkeletonBinary(makeLoader(r, atlasRegions, missing));
const data = bin.readSkeletonData(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
const animations = data.animations.map((a) => a.name);
const durations = {};
const hits = {};
for (const a of data.animations) {
durations[a.name] = round3(a.duration);
for (const t of a.timelines) {
if (!t || !Array.isArray(t.events)) continue;
for (const e of t.events) {
const nm = e?.data?.name;
if (typeof nm === 'string' && /^onattack$/i.test(nm)) {
(hits[a.name] ||= []).push(round3(e.time));
}
}
}
if (hits[a.name]) hits[a.name].sort((x, y) => x - y);
}
const w = Number(data.width); const h = Number(data.height);
const bounds = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
? { x: round3(data.x), y: round3(data.y), width: round3(w), height: round3(h) }
: null;
return {
version: String(data.version ?? ''),
animations,
durations,
events: data.events.map((e) => e.name),
hits,
bounds,
missingRegions: [...missing].sort(),
};
}
+94
View File
@@ -0,0 +1,94 @@
// Upstream asset sources (GitHub dumps of the official client), URL helpers and
// local-path helpers shared by the asset pipeline (tools/fetch-assets.mjs).
//
// Every URL we download from is a raw.githubusercontent.com URL; mirrorUrl()
// maps it to the equivalent jsDelivr URL used as a fallback when GitHub raw
// fails. jsDelivr 404s on the ArknightsAssets2 `voice` branch, so no mirror is
// offered for audio. See docs/ASSETS.md for the full source list and credits.
/** Raw base URLs (always end with '/'). */
export const RAW = Object.freeze({
yuanyan: 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/',
fexli: 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/',
arkModels: 'https://raw.githubusercontent.com/isHarryh/Ark-Models/main/',
aa2: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/',
aa2voice: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/dyn/audio/sound_beta_2/',
fonts: 'https://raw.githubusercontent.com/TimWangZi/The-font-of-Arknights/master/font/',
gamedata: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/',
});
const RAW_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
/**
* Map a raw.githubusercontent.com URL to its jsDelivr mirror.
* @param {string} url
* @returns {string|null} mirror URL, or null when no usable mirror exists
*/
export function mirrorUrl(url) {
const m = RAW_RE.exec(String(url));
if (!m) return null;
const [, owner, repo, branch, path] = m;
// jsDelivr does not serve the (huge) voice branch of ArknightsAssets2.
if (owner === 'ArknightsAssets' && branch === 'voice') return null;
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}`;
}
/**
* Percent-encode each segment of a repo-relative path ('[uc]x/a b.png' → '%5Buc%5Dx/a%20b.png').
* @param {string} relPath
* @returns {string}
*/
export function encodePath(relPath) {
return String(relPath).split('/').map((s) => encodeURIComponent(s)).join('/');
}
/**
* Join a base URL and a *decoded* repo-relative path, encoding the path.
* @param {string} base base URL ending with '/'
* @param {string} relPath decoded path
* @returns {string}
*/
export function joinUrl(base, relPath) {
return base + encodePath(relPath);
}
/**
* Sanitize a single file-name component for local storage / URLs:
* keeps [A-Za-z0-9._-], turns everything else (spaces, brackets, '#') into '_'.
* @param {string} name
* @returns {string}
*/
export function safeName(name) {
const s = String(name).replace(/[^A-Za-z0-9._-]/g, '_');
return s.length ? s : '_';
}
/**
* Site-root URL path for a file under public/assets.
* @param {string} rel path relative to public/assets (forward slashes)
* @returns {string} e.g. '/assets/char/avatar/char_002_amiya.png'
*/
export function assetUrl(rel) {
return '/assets/' + rel;
}
/**
* Directory part of a URL (everything up to and including the last '/').
* @param {string} url
* @returns {string}
*/
export function urlDir(url) {
const i = String(url).lastIndexOf('/');
return i >= 0 ? url.slice(0, i + 1) : '';
}
/**
* Last path component of a URL, percent-decoded.
* @param {string} url
* @returns {string}
*/
export function urlBase(url) {
const s = String(url).split('?')[0];
const last = s.slice(s.lastIndexOf('/') + 1);
try { return decodeURIComponent(last); } catch { return last; }
}
+130
View File
@@ -0,0 +1,130 @@
// Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages,
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
// Produces the per-model `spine` entries of data/assets.json.
import { readFile, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { assetUrl, safeName, urlDir } from './sources.mjs';
import { kindOf, pngSize } from './formats.mjs';
import { atlasInfo, normalizeAtlas } from './atlas.mjs';
import { parseSkel } from './skel.mjs';
import { resolveRoles } from './anim-roles.mjs';
async function fileStat(p) { try { const s = await stat(p); return s.isFile() ? s : null; } catch { return null; } }
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry
*/
/**
* Run the whole Spine pipeline for every planned model.
* @param {Map<string, any>} models plan.models
* @param {object} o
* @param {string} o.root absolute public/assets directory
* @param {import('./downloader.mjs').Downloader} o.dl
* @param {string} o.cachePath parse cache JSON path
* @param {boolean} [o.download] false = only post-process what is on disk
* @param {(m:string)=>void} [o.log]
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
*/
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) {
const problems = [];
const list = [...models.values()];
if (download) {
const jobs = [];
for (const m of list) jobs.push(m.skel, m.atlas, ...m.pngs);
await dl.run(jobs, 'spine');
// Atlases may reference more pages than the index lists.
const extra = [];
for (const m of list) {
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
if (!text) continue;
for (const page of atlasInfo(text).pages) {
const rel = m.dir + safeName(page);
if (!m.pngs.some((p) => p.rel === rel)) {
// Same folder as the atlas actually came from (ledger), then the planned folders.
const got = dl.ledger.files[m.atlas.rel]?.url;
const dirs = [...new Set([got ? urlDir(got) : null, m.baseUrl, ...m.atlas.urls.map(urlDir)].filter(Boolean))];
const a = { rel, urls: dirs.map((d) => d + encodeURIComponent(page)), kind: kindOf(page) };
m.pngs.push(a);
extra.push(a);
}
}
}
if (extra.length) await dl.run(extra, 'spine pages');
}
let cache = {};
try { cache = JSON.parse(await readFile(cachePath, 'utf8')) || {}; } catch { cache = {}; }
const nextCache = {};
const entries = new Map();
let parsed = 0; let cached = 0;
let ledgerDirty = false;
const t0 = Date.now();
for (const m of list) {
const skelAbs = join(root, m.skel.rel);
const atlasAbs = join(root, m.atlas.rel);
const skelSt = await fileStat(skelAbs);
const atlasText = await readFile(atlasAbs, 'utf8').catch(() => null);
if (!skelSt || !atlasText) { problems.push(`${m.key}: missing ${!skelSt ? 'skel' : 'atlas'}`); continue; }
// Normalize the atlas against the real page sizes.
const info0 = atlasInfo(atlasText);
const sizes = new Map();
let pagesOk = info0.pages.length > 0;
for (const page of info0.pages) {
const buf = await readFile(join(root, m.dir + safeName(page))).catch(() => null);
const sz = buf ? pngSize(buf) : null;
if (!sz) { pagesOk = false; problems.push(`${m.key}: missing/invalid page ${page}`); continue; }
sizes.set(page, sz);
}
if (!pagesOk) continue;
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
if (norm.changed) {
await writeFile(atlasAbs + '.tmp', norm.text);
await rename(atlasAbs + '.tmp', atlasAbs);
}
const atlasSt = await fileStat(atlasAbs);
const info = atlasInfo(norm.text);
// Parse the skeleton (cached by file size + mtime).
const ck = `${m.skel.rel}|${skelSt.size}|${Math.floor(skelSt.mtimeMs)}|${atlasSt?.size}|${Math.floor(atlasSt?.mtimeMs ?? 0)}`;
let sk = cache[m.skel.rel]?.key === ck ? cache[m.skel.rel].info : null;
if (sk) cached++;
else {
try {
sk = parseSkel(await readFile(skelAbs), info.regions);
parsed++;
} catch (e) {
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
// delete it and forget it so the next online run downloads it again.
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
try { await unlink(skelAbs); } catch { /* ignore */ }
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
ledgerDirty = true;
continue;
}
}
nextCache[m.skel.rel] = { key: ck, info: sk };
if (sk.missingRegions?.length) problems.push(`${m.key}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
const anims = resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations });
entries.set(m.key, {
skel: assetUrl(m.skel.rel),
atlas: assetUrl(m.atlas.rel),
textures: info.pages.map((p) => assetUrl(m.dir + p)),
pma: !!m.pma,
anims,
animations: sk.durations,
events: sk.events,
hits: sk.hits,
bounds: sk.bounds,
});
if (Date.now() - t0 > 0 && (parsed + cached) % 100 === 0) log(`[spine] processed ${parsed + cached}/${list.length}`);
}
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, JSON.stringify(nextCache));
if (ledgerDirty && dl?.saveLedger) { try { await dl.saveLedger(); } catch { /* next run re-validates */ } }
log(`[spine] models ok=${entries.size}/${list.length} (parsed ${parsed}, cached ${cached})`);
return { entries, problems };
}
+168
View File
@@ -0,0 +1,168 @@
// Minimal WOFF2 encoder/decoder (W3C WOFF 2.0) with *null* table transforms,
// built on Node's zlib Brotli. Used to convert the self-hosted OTF/TTF fonts
// (Bender, Novecento) to .woff2 without adding a dependency.
//
// Encoder: every table is stored untransformed (glyf/loca with transform
// version 3 = null, all other tables version 0 = null), concatenated in
// directory order (tags ascending, loca directly after glyf) and compressed as
// one Brotli stream (font mode, quality 11). The file is padded to 4 bytes.
// Decoder: only what our own encoder produces (null transforms, no metadata),
// used by tests to verify lossless round trips.
import zlib from 'node:zlib';
const KNOWN_TAGS = [
'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ',
'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS',
'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc',
'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop',
'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill',
];
const round4 = (n) => (n + 3) & ~3;
/**
* Encode an unsigned integer as WOFF2 UIntBase128.
* @param {number} n 0 ≤ n < 2^32
* @returns {number[]}
*/
export function uintBase128(n) {
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new RangeError(`UIntBase128 out of range: ${n}`);
const bytes = [];
let v = n;
do { bytes.unshift(v % 128); v = Math.floor(v / 128); } while (v > 0);
for (let i = 0; i < bytes.length - 1; i++) bytes[i] |= 0x80;
return bytes;
}
function readBase128(buf, pos) {
let v = 0;
for (let i = 0; i < 5; i++) {
const b = buf[pos.i++];
if (b === undefined) throw new Error('truncated UIntBase128');
if (i === 0 && b === 0x80) throw new Error('UIntBase128 leading zero');
v = v * 128 + (b & 0x7f);
if (v > 0xffffffff) throw new Error('UIntBase128 overflow');
if (!(b & 0x80)) return v;
}
throw new Error('UIntBase128 too long');
}
/**
* Parse the table directory of an sfnt (OTF/TTF) font.
* @param {Buffer} sfnt
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
*/
export function readSfnt(sfnt) {
if (!Buffer.isBuffer(sfnt) || sfnt.length < 12) throw new Error('not an sfnt font');
const flavor = sfnt.readUInt32BE(0);
if (![0x00010000, 0x4f54544f, 0x74727565].includes(flavor)) throw new Error('unsupported sfnt flavor (TTC/WOFF?)');
const numTables = sfnt.readUInt16BE(4);
if (12 + numTables * 16 > sfnt.length) throw new Error('truncated table directory');
const tables = [];
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
const tag = sfnt.toString('latin1', rec, rec + 4);
const offset = sfnt.readUInt32BE(rec + 8);
const length = sfnt.readUInt32BE(rec + 12);
if (offset + length > sfnt.length) throw new Error(`table ${tag} out of bounds`);
tables.push({ tag, data: sfnt.subarray(offset, offset + length) });
}
return { flavor, tables };
}
function orderTables(tables) {
const sorted = [...tables].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
const li = sorted.findIndex((t) => t.tag === 'loca');
const gi = sorted.findIndex((t) => t.tag === 'glyf');
if (li >= 0 && gi >= 0 && li !== gi + 1) {
const [loca] = sorted.splice(li, 1);
sorted.splice(sorted.findIndex((t) => t.tag === 'glyf') + 1, 0, loca);
}
return sorted;
}
/**
* Convert an OTF/TTF font to WOFF2 (null transforms).
* @param {Buffer} sfnt
* @returns {Buffer}
*/
export function encodeWoff2(sfnt) {
const { flavor, tables } = readSfnt(sfnt);
const ordered = orderTables(tables);
const hasGlyf = ordered.some((t) => t.tag === 'glyf');
const hasLoca = ordered.some((t) => t.tag === 'loca');
if (hasGlyf !== hasLoca) throw new Error('glyf and loca must both be present');
const dir = [];
for (const t of ordered) {
const idx = KNOWN_TAGS.indexOf(t.tag);
const transform = t.tag === 'glyf' || t.tag === 'loca' ? 3 : 0; // null transform
dir.push((transform << 6) | (idx >= 0 ? idx : 63));
if (idx < 0) for (let i = 0; i < 4; i++) dir.push(t.tag.charCodeAt(i) & 0xff);
dir.push(...uintBase128(t.data.length));
}
const stream = Buffer.concat(ordered.map((t) => t.data));
const compressed = zlib.brotliCompressSync(stream, {
params: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_FONT,
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: stream.length,
},
});
const totalSfntSize = 12 + 16 * ordered.length + ordered.reduce((s, t) => s + round4(t.data.length), 0);
const unpadded = 48 + dir.length + compressed.length;
const length = round4(unpadded);
const out = Buffer.alloc(length);
out.writeUInt32BE(0x774f4632, 0); // 'wOF2'
out.writeUInt32BE(flavor, 4);
out.writeUInt32BE(length, 8);
out.writeUInt16BE(ordered.length, 12);
out.writeUInt16BE(0, 14);
out.writeUInt32BE(totalSfntSize, 16);
out.writeUInt32BE(compressed.length, 20);
out.writeUInt16BE(1, 24); // majorVersion
out.writeUInt16BE(0, 26); // minorVersion
// meta/priv offsets and lengths stay 0 (bytes 28..47).
Buffer.from(dir).copy(out, 48);
compressed.copy(out, 48 + dir.length);
return out;
}
/**
* Decode a WOFF2 produced by encodeWoff2 back to its tables (verification helper).
* @param {Buffer} woff2
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
* @throws when the file uses transforms/metadata or is malformed
*/
export function decodeWoff2Tables(woff2) {
if (!Buffer.isBuffer(woff2) || woff2.length < 48 || woff2.readUInt32BE(0) !== 0x774f4632) throw new Error('not a WOFF2 file');
const flavor = woff2.readUInt32BE(4);
const length = woff2.readUInt32BE(8);
if (length !== woff2.length || length % 4 !== 0) throw new Error('bad WOFF2 length');
const numTables = woff2.readUInt16BE(12);
const compressedLength = woff2.readUInt32BE(20);
const pos = { i: 48 };
const entries = [];
for (let n = 0; n < numTables; n++) {
const flags = woff2[pos.i++];
const idx = flags & 0x3f;
const transform = flags >> 6;
let tag;
if (idx === 63) { tag = woff2.toString('latin1', pos.i, pos.i + 4); pos.i += 4; } else tag = KNOWN_TAGS[idx];
const origLength = readBase128(woff2, pos);
const isGlyfLoca = tag === 'glyf' || tag === 'loca';
if ((isGlyfLoca && transform !== 3) || (!isGlyfLoca && transform !== 0)) throw new Error(`transformed table ${tag} not supported`);
entries.push({ tag, origLength });
}
const end = pos.i + compressedLength;
if (end > woff2.length || round4(end) !== length) throw new Error('bad compressed block size');
const stream = zlib.brotliDecompressSync(woff2.subarray(pos.i, end));
let off = 0;
const tables = entries.map((e) => {
const data = stream.subarray(off, off + e.origLength);
off += e.origLength;
return { tag: e.tag, data };
});
if (off !== stream.length) throw new Error('stream length mismatch');
return { flavor, tables };
}
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env node
// tools/balance.mjs — difficulty MEASUREMENT: how a COMPETENT player's board fares against the official waves of every
// round (docs/BALANCE.md has the method, the curves' sources and the tables). Since research 08 the game uses the
// official numbers only (waves, counts, stat table, leader pool); this tool reports, it no longer tunes anything.
//
// For every mode (solo / multi) × difficulty × round r it builds representative boards for round r, samples many
// matches (stage, factions, bans, boss and lineups all vary with the seed), simulates the real wave of that round with
// the real Battle and FULL content (kits, talents, bonds incl. layers, IN_BATTLE garrisons, items, bands, tokens) and
// reports the leak distribution and clear time. Boss rounds (R14 / solo 标准 R9) and the Hidden Core (R15) run the
// real Final Assault fields (pairs, `_s` template for a lone player, shared boss HP pool = bloodPoint (co-op; solo
// ×0.25 [ASSUMED]) — GameData.bossPoolHp —, team LP, overtime drain) and report the pool damage by 150 s, the kill time and the team LP spent.
//
// The competent board of round r (curves below, profile-scaled): shop level, deployed units, elites (精锐), equipment,
// 2–3 active bonds built around a core bond (3 members early, 6 from R11 when the pool allows it) and the layers those
// bonds carry (core ≈ 20 at R5, 80 at R10, 150 at R14; add-ons ≈ 60 % of that — the solo Hidden Core needs Σ active
// layers > 350 at R14, research 02 §2.3, which this pace reaches). Tier mix per shop level, role needs read from the
// wave preview (2 blockers, anti-air by the share of flyers, arts dealers by the enemies' armour — arts anti-air for
// armoured flyers such as 寒霜 —, one medic from R5, ≤ 2 healers) and item choice (passive combat equipment of tier
// ≤ shop level, golden from R7) follow the income / upgrade curve of research 00-INDEX §3. Co-op rounds field 4 boards
// against the same wave and run 联防 exactly like the match (server/match/unite.js). Placement = the bot's layout planner over the
// round's preview (server/match/bot.js arrange) with rehearsal of the best variants (a good player adapts the layout
// to the wave). Bounties are off; 机变 tactic cards and prep-only effects are not modeled (their value is folded
// into the curves).
//
// Usage:
// node tools/balance.mjs [--mode solo|multi|all] [--difficulty FUNNY|NORMAL|HARD|ABYSS|ALL] [--rounds 1-15|3,8,12]
// [--samples N] [--boss-samples N] [--seed S] [--rehearsal N] [--profile weak|competent|strong|<x>]
// [--tuning on|off] [--boss-lp N] [--no-boss] [--boss-only] [--json] [--quiet] [--bots N]
// --samples N sampled matches per normal round (default solo 16, multi 6 — a multi match fields 4 boards, then
// 联防 runs like the match; leak columns are per board, "LP/rd" is the LP loss after 联防)
// --group N boards per multi match (default 4; 1 = no 联防)
// --boss-samples N sampled matches per boss / hidden round (default 8)
// --rehearsal N layout variants a board rehearses (default 5 = every bot variant; 0 = heuristic planner only)
// --profile curve multiplier for elites, items and layers (weak 0.6, competent 1, strong 1.4, or a number)
// --tuning off ignore data/tuning.json (it only holds title rules now: no effect on the numbers)
// --legacy-time read the combat limits as game seconds (the reading before the fix of docs/BALANCE.md §2.1)
// --boss-lp N team LP per alive player entering the Final Assault (default 15)
// --bots N also run N bot matches per mode × difficulty (tools/matchrun.mjs logic, 4 AI in multi, the
// match's default bot rehearsal) and print rounds survived / wins
// --json machine-readable output (the tables' numbers)
// Examples:
// node tools/balance.mjs --mode multi --difficulty NORMAL
// node tools/balance.mjs --mode all --difficulty ALL --samples 8 --json > /tmp/balance.json
// node tools/balance.mjs --mode multi --difficulty HARD --rounds 12-15 --tuning off
import { pathToFileURL } from 'node:url';
import { Match } from '../server/match/Match.js';
import { VirtualScheduler } from '../server/match/scheduler.js';
import { getData } from '../server/data.js';
import { buildNormalWave, buildBossWave } from '../server/match/waves.js';
import { arrange } from '../server/match/bot.js';
import { computeBonds } from '../server/match/bondsMeta.js';
import { positionClass } from '../server/match/board.js';
import { pairPlayers, bossPoolHp, SharedBossPool } from '../server/match/finalAssault.js';
import { planUnite, uniteBattleOpts, uniteSurvivors } from '../server/match/unite.js';
import { createRng, deriveSeed } from '../server/sim/rng.js';
import { GEO, PHASE } from '../shared/constants.js';
import { TICK } from '../server/sim/constants.js';
export const DIFFS = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
export const DIFF_NAMES = { FUNNY: '标准', NORMAL: '险境', HARD: '绝境', ABYSS: '终极' };
// ---- the competent-player model (index = round; see the header and docs/BALANCE.md §1.1) --------------------------
export const CURVES = Object.freeze({
// R0 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 R11 R12 R13 R14 R15
level: [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
units: [0, 2, 4, 5, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
elites: [0, 0, 0, 0, 0.5, 1, 1.5, 2, 3, 3.5, 4, 4.5, 5, 6, 6.5, 7],
items: [0, 0, 0.5, 1, 1.5, 2, 3, 3.5, 4, 5, 5.5, 6, 7, 8, 9, 9],
coreCount: [0, 0, 0, 3, 3, 3, 3, 3, 3, 4, 5, 6, 6, 6, 6, 6],
addons: [0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
coreLayers: [0, 0, 2, 6, 12, 20, 28, 38, 50, 65, 80, 95, 110, 130, 150, 170],
addonLayers: [0, 0, 1, 4, 8, 12, 17, 23, 30, 38, 46, 55, 65, 75, 90, 100],
});
/** Tier weights (T1…T6) of the units a competent player fields at each shop level. */
export const TIER_MIX = Object.freeze({
1: [1],
2: [0.5, 0.5],
3: [0.2, 0.35, 0.45],
4: [0.08, 0.2, 0.37, 0.35],
5: [0.04, 0.08, 0.23, 0.35, 0.3],
6: [0.02, 0.05, 0.15, 0.25, 0.3, 0.23],
});
/** Add-on bonds with a battle effect (economy / special counting bonds are left to chance). */
export const BATTLE_ADDONS = Object.freeze(['preciShip', 'swiftShip', 'skillfulShip', 'arcaneShip', 'steadShip', 'deputShip', 'raidShip', 'indomShip']);
export const PROFILES = Object.freeze({ weak: 0.6, competent: 1, strong: 1.4 });
const QUIET_LOG = { info() {}, debug() {}, warn() {}, error() {} };
const curve = (name, r) => { const a = CURVES[name]; return a[Math.max(0, Math.min(a.length - 1, r))]; };
const frac = (x, rng) => Math.max(0, Math.floor(x + rng()));
// ---- chess helpers ----------------------------------------------------------------------------------------------
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none' && !isHealer(c);
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
const isDealer = (c) => !!c && !isHealer(c) && c.attackKind !== 'none';
/** Data view without data/tuning.json (title rules only; kept for the --tuning flag and the tests). */
export function withoutTuning(data) {
const { tuning, ...rest } = data; // eslint-disable-line no-unused-vars
return Object.freeze(rest);
}
export function makeMatch({ data, mode, difficulty, seed, players = 1, rehearsal = 3 }) {
const seats = [];
for (let i = 0; i < players; i++) seats.push({ seat: i, playerId: `p${i}`, name: `P${i + 1}`, isBot: false, connected: true });
const m = new Match({
roomCode: 'BAL', mode, difficulty, seats, seed, data, log: QUIET_LOG, scheduler: new VirtualScheduler(),
send: () => true, broadcast: () => {}, onEnd: () => {}, botRehearsal: rehearsal,
});
return m;
}
/**
* What a player reads from the wave preview: the share of flyers (anti-air needs) and the HP-weighted armour of the
* ground enemies (arts needs). `spawns` = SpawnSpec[] of the round (normal wave or the player's boss field).
*/
export function waveNeeds(gd, spawns) {
let fly = 0;
let total = 0;
let defHp = 0;
let hp = 0;
let flyDefHp = 0;
let flyHp = 0;
for (const s of spawns || []) {
const e = gd.enemy(s.enemyKey);
if (!e || s.tag === 'part') continue;
const n = Math.max(1, s.count || 1);
const isFly = !!((e.stats && e.stats.motion === 'FLY') || e.isFlyEnemy);
const h = (e.stats?.maxHp || 1000) * (s.mods?.hpMul || 1) * n;
total += n;
if (isFly) { fly += n; flyHp += h; flyDefHp += h * (e.stats?.def || 0); } else { hp += h; defHp += h * (e.stats?.def || 0); }
}
return { flyShare: total ? fly / total : 0, armour: hp ? defHp / hp : 0, flyArmour: flyHp ? flyDefHp / flyHp : 0 };
}
/**
* Lineup of a competent player for round r: base chess ids + which ones are elite, the bond plan and the items.
* @returns {{ level: number, units: string[], elites: Set<string>, core: string|null, addons: string[], items: string[] }}
*/
export function planBoard(m, r, rng, { profile = 1, spawns = null } = {}) {
const gd = m.gd;
const level = Math.min(gd.maxShopLevel, curve('level', r));
const mix = TIER_MIX[level] || TIER_MIX[6];
const rec = (id) => gd.chess(id);
const avail = gd.visibleChess.filter((id) => m.pool.has(id) && gd.tierOf(id) <= level);
const n = Math.min(curve('units', r), gd.deployCap, avail.length);
const chosen = [];
const bondsOf = (id) => (rec(id) && Array.isArray(rec(id).bonds) ? rec(id).bonds : []);
const members = (b) => avail.filter((id) => bondsOf(id).includes(b));
const count = (b) => chosen.filter((id) => bondsOf(id).includes(b)).length;
const tierW = (id) => mix[gd.tierOf(id) - 1] ?? 0.01;
const planned = new Set();
// role needs from the wave preview (a competent player reads the enemies before buying)
const needs = waveNeeds(gd, spawns || (m.wave ? m.wave.spawns : []));
const isArts = (c) => isDealer(c) && (c.dmgType === 'arts' || c.profession === 'CASTER');
const roleNeed = [
[isBlocker, n >= 4 ? 2 : 1],
[hitsFly, needs.flyShare > 0 ? Math.max(1, Math.min(n - 2, Math.round(n * (0.2 + 0.6 * needs.flyShare)))) : 0],
[isArts, needs.armour > 900 ? 3 : needs.armour > 450 ? 2 : n >= 6 ? 1 : 0],
// armoured flyers (寒霜 DEF 600 …) need arts anti-air
[(c) => isArts(c) && hitsFly(c), needs.flyShare > 0 && needs.flyArmour > 350 ? Math.min(2, Math.max(1, n - 4)) : 0],
[isHealer, r >= 5 && n >= 6 ? 1 : 0],
];
const unmet = (id) => roleNeed.filter(([pred, k]) => k > 0 && pred(rec(id)) && chosen.filter((x) => pred(rec(x))).length < k).length;
const synergy = (id) => (1 + 2 * bondsOf(id).filter((b) => planned.has(b)).length) * (1 + 1.5 * unmet(id));
const add = (list, w = () => 1) => {
const pool = list.filter((id) => !chosen.includes(id));
if (!pool.length || chosen.length >= n) return null;
const id = rng.weighted(pool, (x) => tierW(x) * w(x));
if (id) chosen.push(id);
return id;
};
// 1. core bond
let core = null;
const coreTarget = Math.min(n, curve('coreCount', r));
if (coreTarget > 0) {
const cores = gd.bondIds.filter((b) => gd.bond(b).isCore && !gd.modeInactiveBonds.has(b) && members(b).length >= 3);
core = cores.length ? rng.weighted(cores, (b) => members(b).length) : null;
if (core) {
planned.add(core);
const target = Math.min(coreTarget, members(core).length);
for (let g = 0; g < 20 && count(core) < target; g++) if (!add(members(core), synergy)) break;
}
}
// 2. add-on bonds (their first threshold; the second one late when it exists)
const addons = [];
for (let k = 0; k < curve('addons', r); k++) {
const cands = BATTLE_ADDONS.filter((b) => gd.bond(b) && !gd.modeInactiveBonds.has(b) && !addons.includes(b) && members(b).length >= 2);
if (!cands.length) break;
const b = rng.weighted(cands, (x) => 1 + 3 * count(x));
addons.push(b);
planned.add(b);
const th = gd.bond(b).thresholds || [2];
const target = r >= 9 && th.length > 1 ? th[1] : th[0];
for (let g = 0; g < 10 && count(b) < target && chosen.length < n; g++) if (!add(members(b), synergy)) break;
}
// 3. roles, then fill (bond synergy preferred)
const need = (pred, k, list = avail) => { for (let g = 0; g < 10 && chosen.filter((id) => pred(rec(id))).length < k && chosen.length < n; g++) if (!add(list.filter((id) => pred(rec(id))), synergy)) break; };
for (const [pred, k] of roleNeed) need(pred, Math.min(k, n - 1));
for (let g = 0; g < 30 && chosen.length < n; g++) {
const healers = chosen.filter((id) => isHealer(rec(id))).length;
if (!add(avail, (id) => (isHealer(rec(id)) && healers >= (r >= 9 ? 2 : 1) ? 0.01 : synergy(id)))) break;
}
// 4. elites (easier to triple at lower tiers; the core bond's carries first)
const nElite = Math.min(chosen.length, frac(curve('elites', r) * profile, rng));
const elites = new Set();
for (let g = 0; g < nElite; g++) {
const pool = chosen.filter((id) => !elites.has(id) && gd.goldenIdOf(id));
if (!pool.length) break;
elites.add(rng.weighted(pool, (id) => (core && bondsOf(id).includes(core) ? 2 : 1) * (gd.tierOf(id) <= Math.max(1, level - 1) ? 1 : 0.35)));
}
// 5. equipment: passive combat items of tier ≤ shop level (higher tiers preferred), golden from R7
const itemPool = [];
for (let t = 1; t <= level; t++) {
for (const id of gd.shopItemsByTier[t] || []) {
const it = gd.item(id);
if (!it || it.canGiveBond || !(it.kind === 'passive' || it.kind === 'passive_counter')) continue;
itemPool.push(id);
}
}
const items = [];
const nItems = Math.min(chosen.length * 2, frac(curve('items', r) * profile, rng));
for (let i = 0; i < nItems && itemPool.length; i++) {
let id = rng.weighted(itemPool, (x) => gd.tierOf(x));
const golden = r >= 7 && rng() < 0.25 ? gd.item(id.replace(/_a$/, '_b')) : null;
if (golden) id = golden.id;
items.push(id);
}
return { level, units: chosen, elites, core, addons, items };
}
/**
* Put a planned board on a player: pieces into the hand, items equipped on the damage dealers, layers on the active
* bonds, band, then the bot's placement (with rehearsal when the match allows it).
*/
export function applyBoard(m, ps, plan, r, rng, { profile = 1 } = {}) {
const gd = m.gd;
m.phase = PHASE.PREP;
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.effects = [];
ps.bounties = [];
ps.layers = {};
ps.shop.level = plan.level;
const bands = gd.bandIds();
ps.bandId = bands.length ? rng.pick(bands) : gd.defaultBandId;
const pieces = plan.units.map((id, i) => {
const cid = plan.elites.has(id) ? gd.goldenIdOf(id) || id : id;
const p = ps.newPiece('chess', cid);
ps.hand[i] = p;
return p;
});
// items: best carriers are damage dealers (elites / higher tiers first), two each
const carriers = pieces.filter((p) => isDealer(gd.chess(p.id))).sort((a, b) => gd.tierOf(b.id) + (gd.isGolden(b.id) ? 2 : 0) - gd.tierOf(a.id) - (gd.isGolden(a.id) ? 2 : 0));
const order = carriers.concat(pieces.filter((p) => !carriers.includes(p)));
let ci = 0;
for (const id of plan.items) {
while (ci < order.length && order[ci].items.length >= gd.equipPerChess) ci++;
if (ci >= order.length) break;
order[ci].items.push(ps.newPiece('item', id));
}
ps.recompute();
// layers: the core bond on the core curve, every other bond the full lineup activates on the add-on curve (±20 %
// jitter); computed with every planned piece on the board (they are still in the hand here)
const jit = () => 0.8 + 0.4 * rng();
const all = new Map(pieces.map((p, i) => [`x${i}`, p]));
const planned = computeBonds(gd, { board: all, hand: [], layers: {}, bondCountBonus: {} });
for (const [id, b] of Object.entries(planned)) {
if (!b.active) continue;
const base = id === plan.core ? curve('coreLayers', r) : curve('addonLayers', r);
const v = Math.round(base * profile * jit());
if (v > 0) ps.layers[id] = v;
}
ps.recompute();
arrange(m, ps, { final: true });
ps.recompute();
return ps;
}
// ---- one normal round -------------------------------------------------------------------------------------------
function stepToEnd(b, limit) {
const cap = Math.ceil(((limit || 60) + 10) / TICK);
for (let t = 0; t < cap && !b.finished; t++) b.step();
if (!b.finished) b.forceEnd('timeout');
return b.result();
}
/**
* One normal round of one match with `players` competent boards (co-op: 4 — every board fights the same wave, then
* 联防 runs exactly like the match: ≤ 2 perfect helpers keep HP / SP and fight the union of the leaks; only the
* survivors cost their source LP). Returns one row per player.
*/
export function runRound({ data, mode, difficulty, round, seed, rehearsal = 5, profile = 1, players = 1 }) {
const n = mode === 'solo' ? 1 : Math.max(1, Math.min(4, players));
const m = makeMatch({ data, mode, difficulty, seed, players: n, rehearsal });
const rng = createRng(deriveSeed(seed, `board:${round}`));
m.round = round;
m.wave = buildNormalWave(m.gd, m.rngWaves, m.factions, round);
m.lastResults = new Map();
const cap = m.gd.lpCapPerRound;
const rows = [];
for (const ps of m.order) {
ps.lp = 20;
const plan = planBoard(m, round, rng, { profile });
applyBoard(m, ps, plan, round, rng, { profile });
let goal = 0;
const b = m._normalBattle(ps);
b.on('enemyLeak', () => { goal++; }, { priority: -1000, owner: 'balance' });
const res = stepToEnd(b, m.wave.timeLimit);
const pp = res.perPlayer[ps.playerId] || { leaked: [], killed: 0, total: 0, deaths: 0, perfect: true };
m.lastResults.set(ps.playerId, pp);
const leakedList = (pp.leaked || []).filter((l) => l && l.counted !== false);
const leaks = leakedList.length;
const leakKeys = {};
for (const l of leakedList) leakKeys[l.enemyKey] = (leakKeys[l.enemyKey] || 0) + 1;
rows.push({
round, seed, playerId: ps.playerId, stageId: m.stageId, factions: m.factions.slice(), bandId: ps.bandId, level: plan.level,
units: ps.deployCount, elites: [...ps.board.values()].filter((p) => p.kind === 'chess' && m.gd.isGolden(p.id)).length,
items: [...ps.board.values()].reduce((sum, p) => sum + (p.items ? p.items.length : 0), 0),
bonds: Object.entries(ps.bonds).filter(([, x]) => x.active).map(([id, x]) => ({ id, count: x.count, tier: x.tier, layers: x.layers })),
activatedLayers: ps.activatedLayers(),
leaks, lpLoss: Math.min(leaks, cap), goalLeaks: goal, killed: pp.killed, total: pp.total,
time: res.time, timeLimit: m.wave.timeLimit, cleared: res.reason === 'cleared', deaths: pp.deaths || 0, leakKeys, unite: false,
});
}
// 联防 (co-op): the Match's own planning / battle options / LP attribution
const plan = n > 1 ? planUnite(m, m.lastResults) : null;
if (plan) {
const limit = m.wave.timeLimit;
const { wave, players: inputs } = uniteBattleOpts(m, plan, limit);
const b = m.newBattle({
seed: deriveSeed(m.seed, `u:${round}`), kind: 'unite', modeId: m.modeId, round, stageId: m.stageId, rect: { ...GEO.UNITE_RECT },
timeLimit: limit, players: inputs, spawns: m._sanitizeSpawns(wave.spawns), routes: wave.routes, sharedBoss: null,
flags: { layerGainsEnabled: false, ...m.gd.dp }, fieldId: 'u', enemyOverrides: m.wave.overrides || {}, waveId: wave.templateId,
});
const survivors = uniteSurvivors(plan, stepToEnd(b, limit));
for (const row of rows) {
if (!plan.leakers.some((p) => p.playerId === row.playerId)) continue;
row.unite = true;
row.lpLoss = Math.min(cap, survivors.get(row.playerId) || 0);
}
}
m.dispose();
return rows;
}
/** One competent board, one normal round (solo, or one co-op seat without 联防). */
export function runNormal(opts) {
return runRound({ ...opts, players: 1 })[0];
}
// ---- boss / hidden round ----------------------------------------------------------------------------------------
/**
* Final Assault (or Hidden Core) with competent boards: every alive player (4 in multi, 1 in solo) fields a board of
* the round, fields are built exactly like Match.startFinalAssault, stepped in lockstep until the pool dies, the team
* LP runs out (leaks' lifePointReduce + 1 LP/s overtime after 150 s) or `tMax`.
*/
export function runBoss({ data, mode, difficulty, round, seed, profile = 1, bossLp = 15, tMax = 420, bossId = null }) {
const players = mode === 'solo' ? 1 : 4;
const m = makeMatch({ data, mode, difficulty, seed, players, rehearsal: 0 });
const gd = m.gd;
const hidden = round === gd.hiddenRound && round !== gd.bossRound;
const id = bossId || (hidden ? m.hiddenBossId : m.bossId);
const rng = createRng(deriveSeed(seed, `boss:${round}`));
m.round = round;
m.wave = null;
// the boss fields are known during the prep (Match.startRound), so the boards are placed against them
const groups = pairPlayers(m.alivePlayers());
m.bossWaves = groups.map((g) => ({ players: g.map((p) => p.playerId), wave: buildBossWave(gd, m.rngWaves, m.factions, round, { bossId: id, solo: m.isSolo || g.length === 1 }) }));
for (const [i, g] of groups.entries()) {
for (const ps of g) {
ps.lp = bossLp;
applyBoard(m, ps, planBoard(m, round, rng, { profile, spawns: m.bossWaves[i].wave.spawns }), round, rng, { profile });
}
}
const alive = m.alivePlayers();
// the official pool (co-op = bloodPoint, no alive scaling) — finalAssault.bossPoolHp until the match adopts it
const pool = new SharedBossPool(typeof gd.bossPoolHp === 'function' ? gd.bossPoolHp(id) : bossPoolHp(gd, id, alive.length));
let teamLp = alive.reduce((s, p) => s + p.lp, 0);
const teamLp0 = teamLp;
let leakLp = 0;
const battles = groups.map((g, i) => {
const wave = m.bossWaves[i].wave;
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
const inputs = g.map((ps, j) => {
const input = ps.battleInput({ side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8 });
input.lpForBoss = teamLp;
const ev = { input, kind: hidden ? 'hidden' : 'boss', round, spawns };
m.dispatch(ps, 'onBattleStart', ev);
return ev.input && typeof ev.input === 'object' ? ev.input : input;
});
const fieldId = `b${i + 1}`;
const b = m.newBattle({
seed: deriveSeed(m.seed, `${fieldId}:${round}`), kind: hidden ? 'hidden' : 'boss', modeId: m.modeId, round, stageId: m.stageId,
rect: { ...GEO.BOSS_RECT }, timeLimit: Infinity, players: inputs, spawns: m._sanitizeSpawns(spawns), routes: wave.routes,
sharedBoss: pool, flags: { layerGainsEnabled: false, ...gd.dp }, fieldId, enemyOverrides: wave.overrides, waveId: wave.templateId, bossId: id,
});
b.on('enemyLeak', (ctx) => {
const e = ctx && ctx.enemy;
const lpr = e && Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1;
if (lpr > 0) { teamLp -= lpr; leakLp += lpr; }
}, { priority: -1000, owner: 'balance' });
return b;
});
const after = gd.bossOvertimeAfter;
let dmg150 = null;
let killTime = null;
let t = 0;
let drained = 0;
const cap = Math.ceil(tMax / TICK);
for (let k = 0; k < cap; k++) {
for (const b of battles) if (!b.finished) b.step();
t = (k + 1) * TICK;
if (dmg150 == null && t >= after) dmg150 = pool.maxHp - pool.hp;
const over = t - after;
if (over >= 1) { const due = Math.floor(over) * gd.bossOvertimeDrain; if (due > drained) { teamLp -= due - drained; drained = due; } }
if (pool.hp <= 0) { killTime = t; break; }
if (teamLp <= 0) break;
if (battles.every((b) => b.finished)) break;
}
if (dmg150 == null) dmg150 = pool.maxHp - pool.hp;
const dmg = pool.maxHp - pool.hp;
const out = {
round, seed, hidden, bossId: id, stageId: m.stageId, players: alive.length, pool: pool.maxHp, dmg150, dmg,
ratio150: dmg150 / pool.maxHp, killTime, victory: pool.hp <= 0 && teamLp > 0, teamLp0, teamLpEnd: Math.max(0, teamLp), leakLp,
time: t, dps: dmg / Math.max(1, killTime ?? t),
};
for (const b of battles) if (!b.finished) b.forceEnd('forced');
m.dispose();
return out;
}
// ---- aggregation -------------------------------------------------------------------------------------------------
const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
const pct = (a, p) => { if (!a.length) return 0; const s = a.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(p * (s.length - 1) + 0.5))]; };
export function summarizeNormal(rows) {
const leaks = rows.map((x) => x.leaks);
return {
round: rows[0]?.round, n: rows.length, level: rows[0]?.level,
elites: avg(rows.map((x) => x.elites)), items: avg(rows.map((x) => x.items)), layers: avg(rows.map((x) => x.activatedLayers)),
leaksAvg: avg(leaks), leaksP50: pct(leaks, 0.5), leaksP90: pct(leaks, 0.9), lpAvg: avg(rows.map((x) => x.lpLoss)),
// what a board alone would lose: leaks capped at the per-round LP cap (the balance targets use this)
leaksCapped: avg(rows.map((x) => Math.min(x.leaks, 10))),
uniteShare: rows.filter((x) => x.unite).length / Math.max(1, rows.length),
perfect: rows.filter((x) => x.leaks === 0).length / Math.max(1, rows.length),
goalShare: avg(rows.map((x) => (x.leaks ? x.goalLeaks / x.leaks : 0))),
killRate: avg(rows.map((x) => (x.total ? x.killed / x.total : 1))),
clearTime: avg(rows.filter((x) => x.cleared).map((x) => x.time)), cleared: rows.filter((x) => x.cleared).length / Math.max(1, rows.length),
timeLimit: rows[0]?.timeLimit, deaths: avg(rows.map((x) => x.deaths)),
topLeakers: topLeakers(rows),
};
}
/** The enemies that leak most often over a set of samples: [[enemyKey, leaks per sample]] (top 4). */
function topLeakers(rows) {
const t = {};
for (const x of rows) for (const [k, n] of Object.entries(x.leakKeys || {})) t[k] = (t[k] || 0) + n;
return Object.entries(t).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, n]) => [k, n / Math.max(1, rows.length)]);
}
export function summarizeBoss(rows) {
const kills = rows.filter((x) => x.killTime != null);
return {
round: rows[0]?.round, n: rows.length, hidden: rows[0]?.hidden,
ratio150: avg(rows.map((x) => x.ratio150)), ratio150Min: Math.min(...rows.map((x) => x.ratio150)),
killRate: kills.length / Math.max(1, rows.length), killTime: avg(kills.map((x) => x.killTime)),
victory: rows.filter((x) => x.victory).length / Math.max(1, rows.length),
leakLp: avg(rows.map((x) => x.leakLp)), pool: avg(rows.map((x) => x.pool)),
byBoss: Object.fromEntries([...new Set(rows.map((x) => x.bossId))].sort().map((id) => {
const rs = rows.filter((x) => x.bossId === id);
return [id, { n: rs.length, pool: rs[0].pool, ratio150: avg(rs.map((x) => x.ratio150)), victory: rs.filter((x) => x.victory).length / rs.length, killTime: avg(rs.filter((x) => x.killTime != null).map((x) => x.killTime)) }];
})),
};
}
/** Run the model for one mode × difficulty. */
export function runMode({ data, mode, difficulty, rounds = null, samples = 12, bossSamples = 8, seed = 1, rehearsal = 5, profile = 1, bossLp = 15, boss = true, normal = true, groupSize = 4, onRow = null }) {
const modeKey = mode === 'solo' ? 'solo' : 'coop';
const probe = makeMatch({ data, mode: modeKey, difficulty, seed: 1, players: 1, rehearsal: 0 });
const gd = probe.gd;
const last = gd.hiddenRound || gd.lastRound;
const bossRound = gd.bossRound;
const hiddenRound = gd.hiddenRound;
probe.dispose();
const list = (rounds || Array.from({ length: last }, (_, i) => i + 1)).filter((r) => r >= 1 && r <= last);
const out = { mode: modeKey, modeId: gd.modeId, difficulty, normal: [], boss: [] };
for (const r of list) {
const isBoss = r === bossRound || r === hiddenRound;
if (isBoss && !boss) continue;
if (!isBoss && !normal) continue;
const rows = [];
const n = isBoss ? bossSamples : samples;
for (let i = 0; i < n; i++) {
const s = deriveSeed(seed, `${gd.modeId}:${r}:${i}`) || 1;
if (isBoss) rows.push(runBoss({ data, mode: modeKey, difficulty, round: r, seed: s, profile, bossLp }));
else rows.push(...runRound({ data, mode: modeKey, difficulty, round: r, seed: s, rehearsal, profile, players: modeKey === 'solo' ? 1 : groupSize }));
}
const sum = isBoss ? summarizeBoss(rows) : summarizeNormal(rows);
(isBoss ? out.boss : out.normal).push(sum);
if (onRow) onRow(isBoss ? 'boss' : 'normal', sum, out);
}
return out;
}
// ---- bots (tools/matchrun.mjs essentials) -------------------------------------------------------------------------
export function runBots({ data, mode, difficulty, seeds = 10, seed = 1, rehearsal = undefined }) {
const players = mode === 'solo' ? 1 : 4;
const res = [];
for (let i = 0; i < seeds; i++) {
const sched = new VirtualScheduler();
let summary = null;
const seats = [];
for (let k = 0; k < players; k++) seats.push({ seat: k, playerId: `ai_${k}`, name: `AI-${k + 1}`, isBot: true, connected: true });
const m = new Match({
roomCode: 'BOT', mode: mode === 'solo' ? 'solo' : 'coop', difficulty, seats, seed: seed + i, data, log: QUIET_LOG, scheduler: sched,
botRehearsal: rehearsal, send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
});
m.start();
sched.runUntil(() => summary != null, { maxSteps: 5e6 });
m.dispose();
res.push({ victory: !!summary?.victory, roundsPassed: summary?.roundsPassed ?? 0, hidden: !!summary?.hiddenReached });
}
return {
n: res.length, wins: res.filter((x) => x.victory).length, hidden: res.filter((x) => x.hidden).length,
roundsAvg: avg(res.map((x) => x.roundsPassed)), rounds: res.map((x) => x.roundsPassed),
};
}
// ---- CLI --------------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const opt = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
}
return opt;
}
function parseRounds(s) {
if (!s || s === true) return null;
const out = new Set();
for (const part of String(s).split(',')) {
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
if (!m) continue;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
for (let r = Math.min(a, b); r <= Math.max(a, b); r++) out.add(r);
}
return [...out].sort((a, b) => a - b);
}
let ENEMY_NAME = (k) => k;
const f1 = (x) => (Number.isFinite(x) ? x.toFixed(1) : '-');
const f2 = (x) => (Number.isFinite(x) ? x.toFixed(2) : '-');
const p0 = (x) => (Number.isFinite(x) ? `${Math.round(x * 100)}%` : '-');
const k0 = (x) => (x >= 1e6 ? `${(x / 1e6).toFixed(2)}M` : `${Math.round(x / 1000)}k`);
function printNormalHeader() {
console.log(' R lv el it layers | leaks capped avg p50 p90 perfect LP/rd 联防% | kill% goal% cleared clear s / limit deaths top leakers');
}
function printNormal(s) {
console.log(` ${String(s.round).padStart(2)} ${s.level} ${f1(s.elites).padStart(3)} ${f1(s.items).padStart(3)} ${String(Math.round(s.layers)).padStart(6)} | ${f2(s.leaksCapped).padStart(12)} ${f2(s.leaksAvg).padStart(5)} ${String(s.leaksP50).padStart(4)} ${String(s.leaksP90).padStart(4)} ${p0(s.perfect).padStart(8)} ${f2(s.lpAvg).padStart(6)} ${p0(s.uniteShare).padStart(4)} | ${p0(s.killRate).padStart(5)} ${p0(s.goalShare).padStart(6)} ${p0(s.cleared).padStart(8)} ${f1(s.clearTime).padStart(6)} / ${String(s.timeLimit).padStart(3)} ${f1(s.deaths).padStart(5)} ${(s.topLeakers || []).map(([k, v]) => `${ENEMY_NAME(k)} ${f1(v)}`).join(', ')}`);
}
function printBoss(s) {
console.log(` R${s.round}${s.hidden ? ' (隐秘核心)' : ' (最终攻势)'} pool ${k0(s.pool)} · dmg by 150 s ${p0(s.ratio150)} of the pool (min ${p0(s.ratio150Min)}) · killed ${p0(s.killRate)} in ${f1(s.killTime)} s · victory ${p0(s.victory)} · leak LP ${f1(s.leakLp)}`);
for (const [id, b] of Object.entries(s.byBoss)) console.log(` ${id.padEnd(8)} n${b.n} pool ${k0(b.pool).padStart(6)} · @150 s ${p0(b.ratio150).padStart(5)} · victory ${p0(b.victory).padStart(4)} · kill ${f1(b.killTime)} s`);
}
async function main() {
const opt = parseArgs(process.argv.slice(2));
if (opt.help) {
const fs = await import('node:fs');
console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n'));
return;
}
const full = getData({ log: QUIET_LOG });
ENEMY_NAME = (k) => (full.enemies && full.enemies[k] ? full.enemies[k].name : k);
let data = opt.tuning === 'off' ? withoutTuning(full) : full;
// --legacy-time: combat limits read as game seconds (the pre-fix reading, docs/BALANCE.md §2.1)
if (opt['legacy-time']) data = Object.freeze({ ...data, config: { ...data.config, combatTimeScale: 1 } });
const modes = opt.mode === 'all' ? ['solo', 'multi'] : [opt.mode === 'solo' ? 'solo' : 'multi'];
const dArg = String(opt.difficulty || 'NORMAL').toUpperCase();
const diffs = dArg === 'ALL' ? DIFFS : [DIFFS.includes(dArg) ? dArg : 'NORMAL'];
const profile = PROFILES[opt.profile] ?? (Number(opt.profile) > 0 ? Number(opt.profile) : 1);
const common = {
data, rounds: parseRounds(opt.rounds), samples: Number(opt.samples) > 0 ? Number(opt.samples) : null, bossSamples: Math.max(1, Number(opt['boss-samples']) || 8),
groupSize: Number(opt.group) > 0 ? Math.min(4, Number(opt.group)) : 4,
seed: Number(opt.seed) || 1, rehearsal: opt.rehearsal != null && opt.rehearsal !== true ? Math.max(0, Math.floor(Number(opt.rehearsal) || 0)) : 5,
profile, bossLp: Number(opt['boss-lp']) > 0 ? Number(opt['boss-lp']) : 15, boss: !opt['no-boss'], normal: !opt['boss-only'],
};
const all = [];
for (const mode of modes) {
for (const difficulty of diffs) {
const t0 = Date.now();
if (!opt.json) {
console.log(`\n== ${mode} ${difficulty} (${DIFF_NAMES[difficulty]}) · profile ${profile} · samples ${common.samples ?? (mode === 'solo' ? 16 : 6)}${mode === 'solo' ? '' : `×${common.groupSize} boards`}/${common.bossSamples} · rehearsal ${common.rehearsal} · tuning ${opt.tuning === 'off' ? 'off' : 'on'}`);
printNormalHeader();
}
const res = runMode({
...common, mode, difficulty, samples: common.samples ?? (mode === 'solo' ? 16 : 6),
onRow: opt.json || opt.quiet ? null : (kind, s) => (kind === 'boss' ? printBoss(s) : printNormal(s)),
});
if (Number(opt.bots) > 0) {
res.bots = runBots({ data, mode, difficulty, seeds: Number(opt.bots), seed: common.seed });
if (!opt.json) console.log(` bots (${mode === 'solo' ? 1 : 4} AI): wins ${res.bots.wins}/${res.bots.n} · rounds passed avg ${f1(res.bots.roundsAvg)} [${res.bots.rounds.join(' ')}] · hidden ${res.bots.hidden}`);
}
if (!opt.json) {
const late = res.normal.filter((s) => s.round >= 8);
console.log(` → capped leaks per round: avg ${f2(avg(res.normal.map((s) => s.leaksCapped)))} · R8+ ${f2(avg(late.map((s) => s.leaksCapped)))} · worst round ${f2(Math.max(0, ...res.normal.map((s) => s.leaksCapped)))} · LP/rd ${f2(avg(res.normal.map((s) => s.lpAvg)))} · ${((Date.now() - t0) / 1000).toFixed(1)} s`);
}
all.push(res);
}
}
if (opt.json) console.log(JSON.stringify(all, null, 1));
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
main().catch((e) => { console.error(e); process.exitCode = 1; });
}
+2684
View File
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// tools/build-emotes.mjs — data/emotes.json: the 36 official in-match emotes of 卫戍协议:盟约 (research 09 §4).
//
// Sources (official gamedata, cached under .cache/gamedata/ like tools/build-data.mjs and downloaded from the same
// Kengxxiao zh_CN mirror when missing):
// excel/display_meta_table.json emoticonData: emojiDataDict (id, type, sortId, picId, desc), emoticonThemeDataDict
// (theme → emoji ids), emoticonThemeTypeDict (theme sortId, isBasic)
// excel/activity_table.json autoChessData.enabledEmoticonThemeIdList (the 6 themes of the mode = page order),
// autoChessData.constData.chatCD / chatTime (cooldown / bubble seconds)
// excel/item_table.json theme item names ("表情套组:虫动", …; optional)
//
// Output (deterministic, no timestamps):
// { version, source, chatCD, chatTime,
// themes: [{ themeId, dir, sortId, isBasic, name, emotes: [id…] }], pages, enabled-list order
// emotes: [{ id, themeId, sortId, picId, art, label }] } 36, page order then sortId
// `art` = /assets/local/emoticon/<dir>/<picId>.png (tools/local-extract; key by picId — fooldoctor_03…06 use pics
// 04/05/06/08). Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
// shared/constants.js EMOTE_THEMES mirrors this file (test/ui/emotes.test.js keeps them identical).
//
// Usage: node tools/build-emotes.mjs [--offline] [--check] [--out data/emotes.json] [--cache .cache/gamedata]
// --check compare with the existing output file instead of writing it (exit 1 when it would change)
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const GAMEDATA_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
const SCENE = 'AUTOCHESS_BATTLE';
/** Output dir per theme (tools/local-extract/extract.py EMOTE_THEMES). */
export const THEME_DIRS = Object.freeze({
emoticon_autochess_basic: 'basic',
emoticon_originium_slug: 'slug',
emoticon_autochess_basic_2: 'basic_2',
emoticon_foolsday_doctor: 'fooldoctor',
emoticon_foolsday_amiya: 'foolamiya',
emoticon_foolsday_wisdel: 'foolwisdel',
});
/** Our aria-labels (research 09 §4.2: BWIKI wording where it exists, else ours). Never displayed. */
export const LABELS = Object.freeze({
autochess_battle_happy: '开心', autochess_battle_scared: '害怕', autochess_battle_sorry: '对不起',
autochess_battle_thanks: '谢谢', autochess_battle_thinking: '思考', autochess_battle_nice_cooperate: '合作愉快',
autochess_battle_noproblem: '没问题!', autochess_battle_respect: '敬礼!', autochess_battle_call: '欢呼!',
autochess_battle_playingcool: '酷!', autochess_battle_sad: '伤心', autochess_battle_dying: '快死了',
slug_autochess_battle_nice_work: '合作愉快!', slug_autochess_battle_thanks: '谢谢!', slug_autochess_battle_sorry: '对不起!',
slug_autochess_battle_bye: '再见!', slug_autochess_battle_distrust: '???', slug_autochess_battle_very_soon: '很快就好!',
});
const themeDir = (themeId) => THEME_DIRS[themeId] || themeId.replace(/^emo?ticon_(autochess_)?/, '');
const shortName = (name, themeId) => String(name || themeId).replace(/^表情套组[::]/, '');
/**
* Build the emote catalog from the official tables (pure; exported for tests).
* @param {{ display: any, activity: any, items?: any }} src parsed display_meta_table / activity_table / item_table
* @returns {{ doc: object, warnings: string[] }}
*/
export function buildEmotes({ display, activity, items }) {
const warnings = [];
const emo = display?.emoticonData;
const ac = activity?.autoChessData;
if (!emo?.emojiDataDict || !emo?.emoticonThemeDataDict) throw new Error('display_meta_table: emoticonData missing');
if (!Array.isArray(ac?.enabledEmoticonThemeIdList)) throw new Error('activity_table: autoChessData.enabledEmoticonThemeIdList missing');
const themes = [];
const emotes = [];
const seenIds = new Set();
const seenDirs = new Set();
const own = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
for (const themeId of ac.enabledEmoticonThemeIdList) {
if (themes.some((t) => t.themeId === themeId)) { warnings.push(`theme ${themeId}: listed twice, kept once`); continue; }
const ids = own(emo.emoticonThemeDataDict, themeId) ? emo.emoticonThemeDataDict[themeId] : null;
if (!Array.isArray(ids)) { warnings.push(`theme ${themeId}: not in emoticonThemeDataDict`); continue; }
const type = (own(emo.emoticonThemeTypeDict, themeId) && emo.emoticonThemeTypeDict[themeId]) || {};
const recs = ids.map((id) => (own(emo.emojiDataDict, id) ? emo.emojiDataDict[id] : null))
.filter((e) => e && e.type === SCENE && typeof e.id === 'string' && typeof e.picId === 'string' && e.picId)
.filter((e) => {
// one wheel cell and one protocol id per emoji: an emoji listed by two themes stays on the first page only
if (seenIds.has(e.id)) { warnings.push(`${e.id}: already on an earlier page, not repeated in ${themeId}`); return false; }
seenIds.add(e.id);
return true;
})
.sort((a, b) => a.sortId - b.sortId || (a.id < b.id ? -1 : 1));
if (!recs.length) { warnings.push(`theme ${themeId}: no ${SCENE} emoji`); continue; }
const dir = themeDir(themeId);
if (seenDirs.has(dir)) throw new Error(`theme ${themeId}: art dir emoticon/${dir} is already used by another theme`);
seenDirs.add(dir);
const name = items?.items?.[themeId]?.name || themeId;
themes.push({ themeId, dir, sortId: type.sortId ?? null, isBasic: !!type.isBasic, name, emotes: recs.map((e) => e.id) });
recs.forEach((e, i) => {
if (e.desc) warnings.push(`${e.id}: has a desc (${e.desc}); the UI still shows the picture only`);
emotes.push({
id: e.id, themeId, sortId: e.sortId, picId: e.picId, art: `/assets/local/emoticon/${dir}/${e.picId}.png`,
label: LABELS[e.id] || `${shortName(name, themeId)} ${i + 1}`,
});
});
}
const cd = ac.constData || {};
const doc = {
version: 1,
source: 'display_meta_table.emoticonData (type AUTOCHESS_BATTLE) × activity_table autoChessData.enabledEmoticonThemeIdList',
chatCD: Number.isFinite(cd.chatCD) ? cd.chatCD : null,
chatTime: Number.isFinite(cd.chatTime) ? cd.chatTime : null,
themes,
emotes,
};
return { doc, warnings };
}
/** Pretty JSON with one emote / theme per line (stable, diff-friendly). */
export function formatEmotes(doc) {
const line = (o) => JSON.stringify(o);
return `{\n "version": ${doc.version},\n "source": ${line(doc.source)},\n "chatCD": ${line(doc.chatCD)},\n "chatTime": ${line(doc.chatTime)},\n`
+ ` "themes": [\n${doc.themes.map((t) => ` ${line(t)}`).join(',\n')}\n ],\n`
+ ` "emotes": [\n${doc.emotes.map((e) => ` ${line(e)}`).join(',\n')}\n ]\n}\n`;
}
async function ensureGamedata(cache, rel, { offline, optional = false }) {
const abs = join(cache, rel);
if (existsSync(abs)) return abs;
if (offline) { if (optional) return null; throw new Error(`missing cached file ${rel} (offline mode)`); }
await mkdir(dirname(abs), { recursive: true });
let lastErr;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(GAMEDATA_URL + rel, { signal: AbortSignal.timeout(180_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
JSON.parse(text); // never cache a truncated file
const tmp = `${abs}.tmp-${process.pid}`;
await writeFile(tmp, text);
await rename(tmp, abs);
return abs;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
if (optional) return null;
throw new Error(`download failed for ${rel}: ${lastErr?.message}`);
}
async function main(argv) {
const opts = { offline: false, check: false, out: join(ROOT, 'data', 'emotes.json'), cache: join(ROOT, '.cache', 'gamedata') };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--offline') opts.offline = true;
else if (a === '--check') opts.check = true;
else if (a === '--out' || a === '--cache') {
const v = argv[++i];
if (!v || v.startsWith('--')) throw new Error(`${a} needs a path`);
opts[a.slice(2)] = resolve(v);
} else throw new Error(`unknown option ${a}\nusage: node tools/build-emotes.mjs [--offline] [--check] [--out <file>] [--cache <dir>]`);
}
const read = async (p) => (p ? JSON.parse(await readFile(p, 'utf8')) : null);
const display = await read(await ensureGamedata(opts.cache, 'excel/display_meta_table.json', opts));
const activity = await read(await ensureGamedata(opts.cache, 'excel/activity_table.json', opts));
const items = await read(await ensureGamedata(opts.cache, 'excel/item_table.json', { ...opts, optional: true }));
if (!items) console.warn('build-emotes: item_table unavailable, theme names fall back to theme ids');
const { doc, warnings } = buildEmotes({ display, activity, items });
for (const w of warnings) console.warn(`build-emotes: ${w}`);
const text = formatEmotes(doc);
if (opts.check) {
const old = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : null;
if (old !== text) { console.error(`build-emotes: ${opts.out} is out of date (run node tools/build-emotes.mjs)`); return 1; }
console.log(`build-emotes: ${opts.out} is up to date (${doc.themes.length} themes, ${doc.emotes.length} emotes)`);
return 0;
}
await writeFile(opts.out, text);
console.log(`build-emotes: wrote ${opts.out} (${doc.themes.length} themes, ${doc.emotes.length} emotes)`);
return 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`build-emotes: ${e.message}`); process.exitCode = 2; });
}
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env node
// tools/crop-board-atlas.mjs — tile crops of the real 卫戍协议 board atlas (local-client art, DESIGN §13).
//
// The local client's autochess map textures (extracted by tools/local-extract into public/assets/local/map/autochess):
// TX_autochessi_D.png 2048² diffuse atlas: 256 px floor cells (concrete slabs, hazard / chevron variants),
// gold-framed high-ground plates (a left / middle / right strip + a single plate),
// REINFORCEMENTS / EQUIPMENTS bench pads (512²), side panels, hatches, crates …
// TX_autochessi_common_D.png 1024² device plates (hazard X, heal, shield, target, blade, blast, ▶▶, arrows)
// TX_autochessi_BG.png 1024² dark concrete backdrop (the ground around the board)
//
// This script is the source of truth for WHICH pixels of those textures make each battlefield material of the
// renderer (public/js/render/textures.js MATERIALS). It validates every rect against the real images (bounds,
// coverage, contrast) and writes public/assets/local/map/autochess/tiles.json:
//
// { version, cell, source: { D|common|BG: { path, w, h } },
// materials: { <material>: [ layer… ] }, layers drawn in order into one square atlas cell
// backdrop: { src: 'BG', tilesPerRepeat, crop? } } the ground plane texture (crop avoids the lane stripes)
// layer = { proc: 'rim' } (procedural bevel) | { src, rect: [x, y, w, h], rot?: 0|90|180|270 (clockwise), flipX?, flipY?, scale?: 0..1 (centred
// decal, fraction of the cell), alpha?, tint?: '#rrggbb' (multiply), bright?: number (× brightness) }
//
// Side-face crops have the aspect of the face they cover (1 : block height), so the stretched cell maps back to
// undistorted proportions on the 3D face. Materials missing from tiles.json keep the procedural look, and the
// renderer falls back entirely when the atlas or this file is absent (the art is optional at runtime).
//
// The official 3D board (DESIGN §15, public/js/render/board3d) samples the same textures directly: its surface table
// (board3d/atlas.js SURFACES — rects of TX_autochessi_D / _common_D mapped onto tile tops, block sides, pads, devices)
// is validated here too and written into tiles.json → `board3d` (the renderer prefers it over its built-in copy, so
// a re-crop needs no code change).
//
// Usage: node tools/crop-board-atlas.mjs [--dir public/assets/local/map/autochess] [--preview out.png] [--check]
// --check validate only (exit 1 on problems), write nothing
// --preview also write a contact sheet PNG of every material (for eyeballing the crops)
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { SURFACES as BOARD3D } from '../public/js/render/board3d/atlas.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const opt = {};
for (let i = 0; i < args.length; i++) {
if (!args[i].startsWith('--')) continue;
const k = args[i].slice(2);
opt[k] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
}
const DIR = path.resolve(ROOT, typeof opt.dir === 'string' ? opt.dir : 'public/assets/local/map/autochess');
const URL_BASE = '/' + path.relative(path.join(ROOT, 'public'), DIR).split(path.sep).join('/');
export const SOURCES = Object.freeze({
D: { file: 'TX_autochessi_D.png', w: 2048, h: 2048 },
common: { file: 'TX_autochessi_common_D.png', w: 1024, h: 1024 },
BG: { file: 'TX_autochessi_BG.png', w: 1024, h: 1024 },
});
// ---- rects (atlas pixels; picked by eye from the 2048² atlas, 256 px per floor cell) --------------------------
const R = {
concrete: [256, 512, 256, 256], // plain 2×2-slab concrete
concreteFrame: [256, 256, 256, 256], // concrete with a steel edge rail on top + a red ✕ marker
concreteStripe: [512, 256, 256, 256], // concrete, diagonal hazard stripe band on the right, red tag
concreteArrow: [768, 256, 256, 256], // concrete, yellow ‹ chevrons + red stripes (path marker)
concreteRed: [768, 512, 256, 256], // concrete, broad red diagonal no-deploy stripes
plateL: [0, 0, 256, 256], // gold-framed high-ground plate: left end of a horizontal strip
plateM: [256, 0, 256, 256], // middle (gold rails top & bottom only)
plateR: [512, 0, 256, 256], // right end
plateS: [768, 0, 256, 256], // single plate (framed on all four sides, corner bolts)
goldSide: [0, 1664, 272, 114], // gold ribbed panel (high-ground faces, 1 : 0.42)
graySide: [272, 1664, 272, 82], // grey ribbed panel (forbidden-block faces, 1 : 0.3)
sepSide: [196, 768, 300, 160], // dark blue-grey wall panel with a REST AREA tag (separator faces, 1 : 0.55)
mech: [1088, 705, 304, 304], // dark machinery hatch (grey-scale; tinted)
slats: [1032, 477, 226, 226], // horizontal grille slats (grey-scale)
hatch: [556, 1788, 236, 236], // hazard-framed glass hatch (fence line: deployable, not walkable)
lift: [804, 1790, 234, 234], // lift plate with ▲▼ arrows (teleport in / out)
ringHatch: [1290, 1400, 210, 210], // round hatch with a gold ring (protection objective)
padReinf: [0, 1024, 512, 512], // REINFORCEMENTS bench pad (hand slots)
padEquip: [512, 1024, 512, 512], // EQUIPMENTS bench pad (temp slots)
benchRail: [0, 1538, 512, 82], // bench edge rails (1 : 0.16)
crateFace: [1087, 1938, 134, 108], // orange wooden crate side with an ✕ brace
crateFace2: [1222, 1938, 134, 108], // the neighbouring crate panel (lid)
};
const C = {
hazardX: [2, 2, 250, 248], // hazard-striped plate with a steel ✕ (sealed floor)
heal: [264, 2, 256, 250],
shield: [538, 2, 256, 250],
target: [2, 262, 250, 250], // crosshair + wings (turret)
blast: [538, 262, 250, 250],
enemyMark: [792, 258, 232, 262], // red shield / clock roundel (enemy gate marker)
fast: [2, 520, 250, 250], // ▶▶ plate (blower airflow)
};
const L = (src, rect, o = {}) => ({ src, rect, ...o });
/** material → layers (names match public/js/render/textures.js MATERIALS). */
export const MATERIALS = {
road: [L('D', R.concrete)],
road2: [L('D', R.concrete, { rot: 90 })],
road3: [L('D', R.concrete, { rot: 180, bright: 0.97 })],
roadN: [L('D', R.concrete, { rot: 270, bright: 0.93 })],
roadN2: [L('D', R.concrete, { flipX: true, bright: 0.93 })],
floor: [L('D', R.concreteRed)],
floor2: [L('D', R.concreteRed, { flipX: true })],
preview: [L('D', R.concreteStripe, { bright: 0.9 })],
wall: [L('D', R.plateS)],
wallL: [L('D', R.plateL)],
wallM: [L('D', R.plateM)],
wallR: [L('D', R.plateR)],
// vertical strips: the horizontal pieces turned (texture top = far edge of the tile)
wallB: [L('D', R.plateL, { rot: 270 })],
wallVM: [L('D', R.plateM, { rot: 90 })],
wallT: [L('D', R.plateL, { rot: 90 })],
wallSide: [L('D', R.goldSide, { bright: 0.92 })],
forbid: [L('D', R.concrete, { tint: '#6a7075' }), { proc: 'rim' }],
forbid2: [L('D', R.concrete, { rot: 90, tint: '#646a6f' }), { proc: 'rim' }],
forbidSide: [L('D', R.graySide, { tint: '#6d767b' })],
sep: [L('D', R.slats, { tint: '#5a6266' }), { proc: 'rim' }],
sepSide: [L('D', R.sepSide, { bright: 0.8 })],
fence: [L('D', R.hatch)],
start: [L('D', R.concreteArrow), L('common', C.enemyMark, { scale: 0.5, alpha: 0.85 })],
end: [L('D', R.ringHatch), L('common', C.shield, { scale: 0.46, alpha: 0.9, tint: '#6fc3ff' })],
telin: [L('D', R.lift)],
telout: [L('D', R.lift, { rot: 180 })],
hand: [L('D', R.padReinf)],
temp: [L('D', R.padEquip)],
benchSide: [L('D', R.benchRail)],
benchSideTemp: [L('D', R.benchRail, { flipX: true })],
smog: [L('D', R.slats, { tint: '#7c8589' })],
crateSide: [L('D', R.crateFace)],
crateTop: [L('D', R.crateFace2, { rot: 90, bright: 1.08 })],
blowerTop: [L('D', R.mech, { tint: '#6b7478' }), L('common', C.fast, { scale: 0.92 })],
sealed: [L('common', C.hazardX)],
turretTop: [L('D', R.mech, { rot: 180, tint: '#737c80' }), L('common', C.target, { scale: 0.9 })],
platformTop: [L('D', R.plateS, { bright: 1.06 })],
};
export const BACKDROP = { src: 'BG', tilesPerRepeat: 9, crop: [0, 0, 1024, 440] };
// ---- minimal PNG codec (8-bit RGB/RGBA/grey, non-interlaced — what the extractor writes) ------------------------
export function pngSize(buf) {
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
}
export function decodePng(buf) {
const { w, h } = pngSize(buf);
let off = 8, depth = 0, ctype = 0, inter = 0, plte = null;
const idat = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off), type = buf.toString('ascii', off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') { depth = data[8]; ctype = data[9]; inter = data[12]; }
else if (type === 'PLTE') plte = data;
else if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
off += 12 + len;
}
if (depth !== 8 || inter !== 0) throw new Error(`unsupported PNG (depth ${depth}, interlace ${inter})`);
const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ctype];
if (!ch) throw new Error(`unsupported colour type ${ctype}`);
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = w * ch;
const out = Buffer.alloc(w * h * 4);
const prev = Buffer.alloc(stride), cur = Buffer.alloc(stride);
for (let y = 0; y < h; y++) {
const f = raw[y * (stride + 1)];
raw.copy(cur, 0, y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let i = 0; i < stride; i++) {
const a = i >= ch ? cur[i - ch] : 0, b = prev[i], c = i >= ch ? prev[i - ch] : 0;
let v = cur[i];
if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
cur[i] = v & 255;
}
for (let x = 0; x < w; x++) {
const o = (y * w + x) * 4, s = x * ch;
if (ctype === 6) { out[o] = cur[s]; out[o + 1] = cur[s + 1]; out[o + 2] = cur[s + 2]; out[o + 3] = cur[s + 3]; }
else if (ctype === 2) { out[o] = cur[s]; out[o + 1] = cur[s + 1]; out[o + 2] = cur[s + 2]; out[o + 3] = 255; }
else if (ctype === 3) { const p = cur[s] * 3; out[o] = plte[p]; out[o + 1] = plte[p + 1]; out[o + 2] = plte[p + 2]; out[o + 3] = 255; }
else if (ctype === 4) { out[o] = out[o + 1] = out[o + 2] = cur[s]; out[o + 3] = cur[s + 1]; }
else { out[o] = out[o + 1] = out[o + 2] = cur[s]; out[o + 3] = 255; }
}
cur.copy(prev);
}
return { w, h, rgba: out };
}
function crc32(buf) {
let c, crc = 0xffffffff;
for (let n = 0; n < buf.length; n++) {
c = (crc ^ buf[n]) & 0xff;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xffffffff) >>> 0;
}
export function encodePng(w, h, rgba) {
const raw = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
/** Coverage / contrast of a rect: { opaque (0..1), mean (0..255), std }. */
export function rectStats(img, [x, y, w, h]) {
let n = 0, op = 0, sum = 0, sq = 0;
for (let yy = y; yy < y + h; yy += 2) for (let xx = x; xx < x + w; xx += 2) {
const o = (yy * img.w + xx) * 4;
const l = 0.299 * img.rgba[o] + 0.587 * img.rgba[o + 1] + 0.114 * img.rgba[o + 2];
n++; if (img.rgba[o + 3] > 200) op++; sum += l; sq += l * l;
}
const mean = sum / n;
return { opaque: op / n, mean, std: Math.sqrt(Math.max(0, sq / n - mean * mean)) };
}
// ---- main ----------------------------------------------------------------------------------------------------
function main() {
const problems = [];
const source = {};
const images = {};
for (const [key, s] of Object.entries(SOURCES)) {
const file = path.join(DIR, s.file);
if (!fs.existsSync(file)) { problems.push(`missing ${path.relative(ROOT, file)}`); continue; }
const buf = fs.readFileSync(file);
const { w, h } = pngSize(buf);
if (w !== s.w || h !== s.h) problems.push(`${s.file}: ${w}×${h}, expected ${s.w}×${s.h}`);
source[key] = { path: `${URL_BASE}/${s.file}`, w, h };
try { images[key] = decodePng(buf); } catch (err) { problems.push(`${s.file}: ${err.message}`); }
}
if (!source.D) {
console.error(`[crop-board-atlas] ${problems.join('; ')} — run tools/local-extract first (the art is optional).`);
process.exit(opt.check ? 1 : 0);
}
const report = [];
for (const [name, layers] of Object.entries(MATERIALS)) {
for (const ly of layers) {
if (ly.proc) continue;
const img = images[ly.src];
const [x, y, w, h] = ly.rect;
const S = SOURCES[ly.src];
if (!(x >= 0 && y >= 0 && w > 8 && h > 8 && x + w <= S.w && y + h <= S.h)) { problems.push(`${name}: rect ${ly.rect} outside ${ly.src}`); continue; }
if (!img) continue;
const st = rectStats(img, ly.rect);
report.push(`${name.padEnd(14)} ${ly.src.padEnd(6)} [${ly.rect.join(',')}] opaque ${(st.opaque * 100).toFixed(0)}% mean ${st.mean.toFixed(0)} std ${st.std.toFixed(1)}`);
const decal = ly !== layers[0] || ly.scale != null;
if (!decal && st.opaque < 0.9) problems.push(`${name}: rect ${ly.rect} of ${ly.src} is ${(100 - st.opaque * 100).toFixed(0)}% transparent`);
if (decal && st.opaque < 0.05) problems.push(`${name}: decal rect ${ly.rect} of ${ly.src} is empty`);
if (st.std < 2) problems.push(`${name}: rect ${ly.rect} of ${ly.src} is flat (std ${st.std.toFixed(1)})`);
}
}
// the 3D board's surfaces: inside their source, opaque (D) / non-empty (common decals), not flat
for (const [name, sf] of Object.entries(BOARD3D)) {
const S = SOURCES[sf.src];
const [x, y, w, h] = sf.rect;
if (!S || !(x >= 0 && y >= 0 && w > 8 && h > 8 && x + w <= S.w && y + h <= S.h)) { problems.push(`board3d ${name}: rect ${sf.rect} outside ${sf.src}`); continue; }
const img = images[sf.src];
if (!img) continue;
const st = rectStats(img, sf.rect);
report.push(`3d ${name.padEnd(12)} ${sf.src.padEnd(6)} [${sf.rect.join(',')}] opaque ${(st.opaque * 100).toFixed(0)}% mean ${st.mean.toFixed(0)} std ${st.std.toFixed(1)}`);
if (sf.src === 'D' && st.opaque < 0.9) problems.push(`board3d ${name}: rect ${sf.rect} is ${(100 - st.opaque * 100).toFixed(0)}% transparent`);
if (sf.src === 'common' && st.opaque < 0.05) problems.push(`board3d ${name}: decal rect ${sf.rect} is empty`);
if (st.std < 2) problems.push(`board3d ${name}: rect ${sf.rect} is flat (std ${st.std.toFixed(1)})`);
}
console.log(report.join('\n'));
if (problems.length) console.warn('[crop-board-atlas] problems:\n ' + problems.join('\n '));
if (opt.check) process.exit(problems.length ? 1 : 0);
const board3d = Object.fromEntries(Object.entries(BOARD3D).map(([k, v]) => [k, { ...v, rect: [...v.rect] }]));
const out = { version: 2, generatedBy: 'tools/crop-board-atlas.mjs', cell: 256, source, materials: MATERIALS, backdrop: source.BG ? BACKDROP : null, board3d };
const file = path.join(DIR, 'tiles.json');
fs.writeFileSync(file, JSON.stringify(out, null, 1) + '\n');
console.log(`wrote ${path.relative(ROOT, file)} (${Object.keys(MATERIALS).length} materials, ${Object.keys(board3d).length} 3D surfaces)`);
if (typeof opt.preview === 'string') {
// contact sheet: every material's first layer, nearest-neighbour scaled into 128 px cells (no rotation)
const names = Object.keys(MATERIALS), cols = 8, S = 128;
const W = cols * S, H = Math.ceil(names.length / cols) * S;
const sheet = Buffer.alloc(W * H * 4, 0);
names.forEach((n, i) => {
const ly = MATERIALS[n][0], img = images[ly.src];
if (!img || !ly.rect) return;
const [x, y, w, h] = ly.rect, ox = (i % cols) * S, oy = Math.floor(i / cols) * S;
for (let yy = 2; yy < S - 2; yy++) for (let xx = 2; xx < S - 2; xx++) {
const sx = x + Math.floor(((xx - 2) / (S - 4)) * w), sy = y + Math.floor(((yy - 2) / (S - 4)) * h);
const so = (sy * img.w + sx) * 4, dO = ((oy + yy) * W + ox + xx) * 4;
img.rgba.copy(sheet, dO, so, so + 4);
}
});
fs.writeFileSync(path.resolve(opt.preview), encodePng(W, H, sheet));
console.log(`preview → ${opt.preview}`);
}
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('crop-board-atlas.mjs')) main();
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
// tools/doctor.mjs — diagnose an install (docs/DEPLOY.md「排错」). Read-only: changes nothing.
//
// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0]
//
// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, downloaded art/audio, optional local-client art,
// Python (only needed for the optional extraction), the port (free / our server running → /healthz / another
// program), LAN addresses friends can use (virtual adapters and VPNs labelled), firewall hints per OS, tunnel tools
// (Tailscale, ZeroTier, cloudflared) and the env vars the server reads.
// Exit code 1 when something essential is missing (the same rule as tools/setup.mjs --check).
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
ROOT, MIN_NODE, IS_WIN, IS_MAC, c, mark, capture, padDisplay, displayWidth,
checkNode, checkDeps, checkVendor, checkData, checkAssets, checkLocal, findClient, findPython,
} from './setup.mjs';
// ---------------------------------------------------------------------------------------------------
// LAN addresses (also used by scripts/launch.mjs)
// ---------------------------------------------------------------------------------------------------
const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback)/i;
const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap)/i;
function ipv4ToInt(ip) { return ip.split('.').reduce((n, x) => (n << 8) + Number(x), 0) >>> 0; }
function inCidr(ip, base, bits) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask); }
/**
* Classify every non-internal IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier /
* CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox … — usually not reachable from other machines),
* 'public' (a public address directly on this machine), 'linklocal' (169.254 — no DHCP, useless).
* @returns {{ name: string, address: string, kind: string }[]} best first
*/
export function classifyAddresses(ifaces = os.networkInterfaces()) {
const out = [];
for (const [name, addrs] of Object.entries(ifaces)) {
for (const a of addrs || []) {
if (!(a.family === 'IPv4' || a.family === 4) || a.internal) continue;
const ip = a.address;
let kind;
if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal';
else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn';
else if (VIRTUAL_IF.test(name)) kind = 'virtual';
else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan';
else kind = 'public';
out.push({ name, address: ip, kind });
}
}
const rank = { lan: 0, vpn: 1, public: 2, virtual: 3, linklocal: 4 };
return out.sort((x, y) => rank[x.kind] - rank[y.kind]);
}
export const KIND_LABEL = { lan: '局域网', vpn: 'VPN/Tailscale/ZeroTier', public: '公网 IP', virtual: '虚拟网卡(通常无法从别的电脑访问)', linklocal: '无效地址(未获取到 IP)' };
// ---------------------------------------------------------------------------------------------------
// Port probe
// ---------------------------------------------------------------------------------------------------
function getJson(url, timeoutMs = 1500) {
return new Promise((resolve) => {
const req = http.get(url, { timeout: timeoutMs }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });
res.on('end', () => { let json = null; try { json = JSON.parse(body); } catch { /* not ours */ } resolve({ status: res.statusCode, json }); });
});
req.on('timeout', () => { req.destroy(); resolve({ error: 'timeout' }); });
req.on('error', (e) => resolve({ error: e.code || e.message }));
});
}
function canListen(port, host) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.once('error', (e) => resolve({ ok: false, code: e.code }));
srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve({ ok: true })));
});
}
/** 'ours' (our server answers /healthz), 'free', 'busy' (another program) or 'denied'. */
export async function probePort(port, host = '0.0.0.0') {
const r = await getJson(`http://127.0.0.1:${port}/healthz`);
if (r.json && r.json.ok === true && 'uptimeSec' in r.json) return { state: 'ours', health: r.json };
const l = await canListen(port, host);
if (l.ok) return { state: 'free' };
if (l.code === 'EACCES') return { state: 'denied', code: l.code };
return { state: 'busy', code: l.code, http: r.status };
}
// ---------------------------------------------------------------------------------------------------
// Firewall hints
// ---------------------------------------------------------------------------------------------------
function firewallHints(port) {
const lines = [];
if (IS_WIN) {
const rule = capture('netsh', ['advfirewall', 'firewall', 'show', 'rule', 'name=Stronghold Protocol'], { timeout: 10000 });
if (rule.ok) lines.push([mark.ok, '已存在防火墙入站规则「Stronghold Protocol」']);
else {
lines.push([mark.warn, `未找到规则「Stronghold Protocol」。朋友连不上时,用「管理员」PowerShell 运行:`]);
lines.push(['', c.cyan(`netsh advfirewall firewall add rule name="Stronghold Protocol" dir=in action=allow protocol=TCP localport=${port} profile=private,domain`)]);
lines.push(['', c.dim('(或首次启动时在 Windows 弹窗里勾选「专用网络」并允许 Node.js;scripts\\install-service-windows.ps1 也会自动添加)')]);
}
const prof = capture('powershell', ['-NoProfile', '-NonInteractive', '-Command',
"Get-NetConnectionProfile | ForEach-Object { $_.InterfaceAlias + '|' + $_.NetworkCategory }"], { timeout: 15000 });
if (prof.ok && prof.out) {
for (const l of prof.out.split(/\r?\n/).filter(Boolean)) {
const [alias, cat] = l.split('|');
if (/public/i.test(cat || '')) {
lines.push([mark.warn, `网络「${alias}」是「公用网络」:Windows 默认拦截公用网络的入站连接。家里的网络建议改为「专用」:`]);
lines.push(['', c.cyan(`Set-NetConnectionProfile -InterfaceAlias "${alias}" -NetworkCategory Private`) + c.dim('(管理员 PowerShell)')]);
} else lines.push([mark.ok, `网络「${alias}」类型:${cat}`]);
}
}
} else if (IS_MAC) {
const fw = capture('/usr/libexec/ApplicationFirewall/socketfilterfw', ['--getglobalstate'], { timeout: 5000 });
if (/enabled/i.test(fw.out) && !/disabled/i.test(fw.out)) {
lines.push([mark.warn, 'macOS 防火墙已开启:首次启动时请在弹窗中「允许」node 接受传入连接,或运行:']);
lines.push(['', c.cyan(`sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "${process.execPath}" --unblockapp "${process.execPath}"`)]);
} else if (fw.out) lines.push([mark.ok, 'macOS 防火墙未开启(局域网可直接访问)']);
} else {
lines.push([c.dim('i'), `Linux:若启用了 ufw/firewalld,请放行端口:sudo ufw allow ${port}/tcp 或 sudo firewall-cmd --add-port=${port}/tcp --permanent && sudo firewall-cmd --reload`]);
}
return lines;
}
function tool(cmd, args) {
const r = capture(cmd, args, { timeout: 5000, shell: IS_WIN });
return r.ok ? (r.out.split(/\r?\n/)[0] || 'ok') : null;
}
// ---------------------------------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', help: false };
for (let i = 0; i < argv.length; i++) {
const [k, v] = argv[i].split('=');
const val = () => (v !== undefined ? v : argv[++i]);
if (k === '--port') o.port = Number(val()) || o.port;
else if (k === '--host') o.host = val() || o.host;
else if (k === '-h' || k === '--help') o.help = true;
else throw new Error(`unknown option ${argv[i]}`);
}
return o;
}
async function main() {
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; }
if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] — 只读诊断,不修改任何文件'); return 0; }
const rows = [];
let bad = false;
const row = (state, label, detail = '') => { rows.push([state, label, detail]); if (state === 'err') bad = true; };
const section = (title) => rows.push([null, title]);
console.log(c.bold('\n卫戍协议:盟约 · doctor') + c.dim(` ${os.type()} ${os.release()} ${process.arch} · ${ROOT}`));
section('运行环境');
const node = checkNode();
row(node.ok ? (node.recommended ? 'ok' : 'warn') : 'err', 'Node.js', `v${node.version}` + (node.ok ? (node.recommended ? '' : '(推荐 22 LTS)') : `(需要 ≥ ${MIN_NODE}:https://nodejs.org/zh-cn/download)`));
const npmV = tool(IS_WIN ? 'npm.cmd' : 'npm', ['--version']);
row(npmV ? 'ok' : 'warn', 'npm', npmV ? `v${npmV}` : '未找到(安装 Node.js 时会自带)');
section('安装');
const deps = checkDeps();
row(deps.ok ? 'ok' : 'err', '依赖 node_modules', deps.ok ? '' : `缺少 ${deps.missing.join(', ')} → npm install`);
const vendor = checkVendor();
row(vendor.ok ? 'ok' : 'err', '前端库 public/vendor', vendor.ok ? (vendor.optionalMissing.length ? 'three.js 缺失(3D 棋盘回退 2D)' : '') : `缺少 ${vendor.missing.join(', ')} → node tools/vendor.mjs`);
const data = checkData();
row(data.ok ? 'ok' : 'err', '游戏数据 data/*.json', data.ok ? '' : `缺少/损坏:${[...data.missing, ...data.broken].join(', ')}`);
const assets = checkAssets();
row(assets.ok ? 'ok' : 'warn', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件`
: !assets.present ? '未下载 → node tools/setup.mjs(游戏仍可运行,使用占位图)' : `缺 ${assets.missing}/${assets.total}(例:${assets.sample.join(' ')})→ node tools/setup.mjs`);
const fonts = fs.existsSync(path.join(ROOT, 'public', 'fonts', 'fonts.css'));
row(fonts ? 'ok' : 'warn', '字体 public/fonts', fonts ? '' : '未生成(随素材下载一起生成;缺失时用系统字体)');
const local = checkLocal();
const client = findClient(null);
row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest
? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : '未提取(不影响游戏)');
if (client || local.manifest) {
const py = findPython();
row(py ? 'ok' : 'skip', 'Python(仅提取用)', py ? `${py.cmd} ${py.version}` : '未找到 Python 3.8+');
}
section('服务器');
const port = await probePort(opts.port, opts.host);
if (port.state === 'ours') {
const h = port.health;
row('ok', `端口 ${opts.port}`, `服务器正在运行:运行 ${h.uptimeSec}s · 房间 ${h.rooms ?? '?'} · 对局 ${h.matches ?? '?'} · 连接 ${h.sockets ?? '?'}`);
} else if (port.state === 'free') row('ok', `端口 ${opts.port}`, '空闲(服务器未运行;npm start 启动)');
else if (port.state === 'denied') row('err', `端口 ${opts.port}`, '没有权限监听(Linux 上 < 1024 的端口需要 root)→ 换一个 PORT');
else row('err', `端口 ${opts.port}`, `被其他程序占用(${port.code})→ 关闭它或换端口:${IS_WIN ? '$env:PORT=3001; npm start' : 'PORT=3001 npm start'}`);
const env = ['PORT', 'HOST', 'SP_COMBAT', 'SP_VERIFY', 'TRUST_PROXY', 'DEBUG'].filter((k) => process.env[k] != null && process.env[k] !== '');
row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=0.0.0.0 SP_COMBAT=client SP_VERIFY=off)');
section('朋友如何访问');
const addrs = classifyAddresses();
if (!addrs.length) row('warn', '网络', '没有可用的 IPv4 地址(未联网?)');
for (const a of addrs) {
const usable = a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public';
row(usable ? 'ok' : 'skip', `http://${a.address}:${opts.port}`, `${KIND_LABEL[a.kind]} · ${a.name}`);
}
if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 0.0.0.0)`);
section('防火墙');
for (const [m, text] of firewallHints(opts.port)) rows.push([m === '' ? 'raw' : 'mark', text, '', m]);
section('公网联机工具(可选)');
const ts = tool('tailscale', ['version']);
const zt = tool('zerotier-cli', ['-v']);
const cf = tool('cloudflared', ['--version']);
row(ts ? 'ok' : 'skip', 'Tailscale', ts || '未安装(推荐:https://tailscale.com/download )');
row(zt ? 'ok' : 'skip', 'ZeroTier', zt || '未安装');
row(cf ? 'ok' : 'skip', 'cloudflared', cf || '未安装(临时公网链接:cloudflared tunnel --url http://localhost:' + opts.port + ')');
// print
const width = Math.max(...rows.filter((r) => r[0] && r[0] !== 'raw' && r[0] !== 'mark').map((r) => displayWidth(r[1]))) + 2;
for (const r of rows) {
if (r[0] === null) { console.log(c.bold(`\n${r[1]}`)); continue; }
if (r[0] === 'raw') { console.log(` ${r[1]}`); continue; }
if (r[0] === 'mark') { console.log(`${r[3]} ${r[1]}`); continue; }
console.log(`${mark[r[0]]} ${padDisplay(r[1], width)}${r[2] ? c.dim(r[2]) : ''}`);
}
console.log(bad ? c.err('\n有必须解决的问题(✘)。') + ' 大多数情况运行 node tools/setup.mjs 即可修复。' : c.ok('\n基本环境正常。'));
return bad ? 1 : 0;
}
function isMain() {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
}
if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
// Downloads, post-processes and indexes every art/audio/font asset the game
// needs, from community dumps of the official client (research 07):
// public/assets/** images, Spine models, audio (git-ignored)
// public/fonts/** Bender / Novecento (.otf/.ttf + .woff2) and fonts.css
// data/assets.json manifest used by the client (schema: docs/ASSETS.md)
//
// Idempotent: existing files with the right size are skipped, so re-running is
// cheap. Downloads use ~16 parallel connections, 3 retries per source and a
// jsDelivr mirror fallback. Spine atlases get `size:` (and `pma: true` for
// enemies); every skeleton is parsed to resolve animation roles.
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Downloader } from './assets/downloader.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
import { processModels } from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, MANIFEST_VERSION } from './assets/manifest.mjs';
import { fontJobs, buildFonts } from './assets/fonts.mjs';
import { skelParserAvailable } from './assets/skel.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const ASSETS = join(ROOT, 'public', 'assets');
const FONTS = join(ROOT, 'public', 'fonts');
const CACHE = join(ROOT, '.cache');
const MANIFEST = join(ROOT, 'data', 'assets.json');
const REPORT = join(CACHE, 'assets-report.json');
const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16)
--force re-download files even when present
--offline no network: post-process what is on disk and rebuild data/assets.json
--dry-run print the plan and exit
--refresh-index re-download audio_data.json / models_data.json indexes
--prune delete files under public/assets that the manifest no longer references
--help this text`;
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, help:boolean}}
*/
function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, help: false };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
else if (k === '--force') o.force = true;
else if (k === '--offline') o.offline = true;
else if (k === '--dry-run') o.dryRun = true;
else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true;
else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`);
}
return o;
}
const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
const log = (m) => console.log(m);
async function readJson(rel) {
const p = join(ROOT, rel);
try { return JSON.parse(await readFile(p, 'utf8')); } catch (e) { throw new Error(`cannot read ${rel}: ${e.message}`); }
}
async function writeJsonAtomic(path, value, indent) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path + '.tmp', JSON.stringify(value, null, indent) + '\n');
await rename(path + '.tmp', path);
}
/** All files under a directory, as forward-slash paths relative to it. */
async function listFiles(dir, base = dir, out = []) {
let entries = [];
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) await listFiles(p, base, out);
else if (e.isFile()) out.push(relative(base, p).split(sep).join('/'));
}
return out;
}
/** Remove fields that only make sense next to a resolved spine. */
function tidyManifest(m) {
for (const e of Object.values(m.enemies || {})) if (!e.spine) delete e.spineAliasOf;
for (const t of Object.values(m.tokens || {})) if (!t.spine) delete t.spineVariant;
if (m.skillsById) {
for (const [sid, iconId] of Object.entries(m.skillsById)) if (!m.skills?.[iconId]) delete m.skillsById[sid];
}
for (const c of Object.values(m.chars || {})) if (c.spine && !Object.keys(c.spine).length) delete c.spine;
}
function countStats(m, bytes, files) {
const vals = (o) => Object.values(o || {});
const spines = new Set();
for (const c of vals(m.chars)) for (const s of vals(c.spine)) spines.add(s.skel);
for (const e of vals(m.enemies)) if (e.spine) spines.add(e.spine.skel);
for (const t of vals(m.tokens)) if (t.spine) spines.add(t.spine.skel);
return {
files,
bytes,
chars: Object.keys(m.chars || {}).length,
charsWithBack: vals(m.chars).filter((c) => c.spine?.back).length,
enemies: Object.keys(m.enemies || {}).length,
enemiesWithSpine: vals(m.enemies).filter((e) => e.spine).length,
tokens: Object.keys(m.tokens || {}).length,
tokensWithSpine: vals(m.tokens).filter((t) => t.spine).length,
spineModels: spines.size,
bonds: Object.keys(m.bonds || {}).length,
items: Object.keys(m.items || {}).length,
bands: Object.keys(m.bands || {}).length,
skills: Object.keys(m.skills || {}).length,
ui: Object.keys(m.ui || {}).length,
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
};
}
/** Required assets whose absence makes the run fail (exit code 1). */
function requiredMisses(m, charIds) {
const out = [];
for (const id of charIds) {
const c = m.chars?.[id];
if (!c?.avatar) out.push(`${id}.avatar`);
if (!c?.portrait) out.push(`${id}.portrait`);
if (!c?.spine?.front) out.push(`${id}.spine.front`);
}
return out;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) { log(HELP); return 0; }
const t0 = Date.now();
log(`[assets] root ${ROOT}`);
if (!skelParserAvailable()) throw new Error('@pixi-spine/runtime-3.8 not found — run `npm install` first');
const [assets07, ops03, enemies05, maps05] = await Promise.all([
readJson('docs/research/07-assets.json'),
readJson('docs/research/03-operators.json'),
readJson('docs/research/05-enemies.json'),
readJson('docs/research/05-maps.json'),
]);
const { audioData, modelsData } = await loadIndexes(ROOT, { refresh: opts.refreshIndex && !opts.offline, offline: opts.offline, log });
const audio = indexAudio(audioData);
// The game data built by tools/build-data.mjs (when present) may reference more
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
const [dataEnemies, dataTokens, dataBosses] = await Promise.all(
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}),
extraHandbook,
});
const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
`(${Object.keys(plan.template.chars).length} chars, ${Object.keys(plan.template.enemies).length} enemies, ` +
`${Object.keys(plan.template.tokens).length} tokens, ${Object.keys(plan.template.ui).length} UI sprites, ` +
`${Object.keys(plan.template.audio.sfx.units).length} units with SFX)`);
if (opts.dryRun) {
for (const n of plan.notes) log(` note: ${n}`);
return 0;
}
const dl = new Downloader({
root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'),
concurrency: opts.concurrency, force: opts.force, log,
});
await dl.loadLedger();
const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files');
// Fonts
let fontErrors = [];
if (!opts.offline) {
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log });
await fdl.loadLedger();
await fdl.run(fontJobs(), 'fonts');
dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded;
for (const k of ['ok', 'skip', 'miss', 'error']) dl.totals[k] += fdl.totals[k];
}
const fonts = await buildFonts(FONTS, log);
fontErrors = fonts.errors;
// Spine
const spine = await processModels(plan.models, {
root: ASSETS, dl, cachePath: join(CACHE, 'spine-info.json'), download: !opts.offline, log,
});
// Manifest
const resolved = resolveTemplate(plan.template, { root: ASSETS, spine: spine.entries, sourceOf: (rel) => dl.ledger.files[rel]?.url });
const body = resolved.value;
tidyManifest(body);
const fontFaces = {};
for (const [name, f] of Object.entries(fonts.files)) fontFaces[name] = f;
body.fonts = existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
const bytes = totalBytes(ASSETS, resolved.files);
const manifest = {
version: MANIFEST_VERSION,
hash: contentHash(body),
generator: 'tools/fetch-assets.mjs',
stats: countStats(body, bytes, resolved.files.size),
...body,
};
await writeJsonAtomic(MANIFEST, manifest);
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change).
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r));
if (opts.prune) for (const r of orphans) { try { await unlink(join(ASSETS, r)); } catch { /* ignore */ } }
const charIds = Object.keys(assets07.operators || {});
const required = requiredMisses(manifest, charIds);
const report = {
downloadedBytes: dl.totals.bytesDownloaded,
totals: dl.totals,
stats: manifest.stats,
requiredMisses: required,
misses: resolved.misses,
downloadErrors, // leaves whose primary failed transiently (fallbacks not tried; re-run to retry)
fallbacks: resolved.fallbacks,
spineProblems: spine.problems,
fontErrors,
orphans: opts.prune ? [] : orphans,
pruned: opts.prune ? orphans : [],
notes: plan.notes,
};
await writeJsonAtomic(REPORT, report, 1);
// Summary
const s = manifest.stats;
log('');
log('=== fetch-assets summary ===');
log(`downloaded this run : ${mb(dl.totals.bytesDownloaded)} (ok ${dl.totals.ok}, skipped ${dl.totals.skip}, missing ${dl.totals.miss}, errors ${dl.totals.error}` +
`${dl.totals.sizeChanged ? `, ${dl.totals.sizeChanged} changed upstream` : ''})`);
log(`on disk (manifest) : ${mb(s.bytes)} in ${s.files} files`);
log(`chars ${s.chars} (Back model ${s.charsWithBack}) · enemies ${s.enemies} (Spine ${s.enemiesWithSpine}) · tokens ${s.tokens} (Spine ${s.tokensWithSpine}) · Spine models ${s.spineModels}`);
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
if (downloadErrors.length) log(`download errors (${downloadErrors.length}, re-run to retry): ${downloadErrors.slice(0, 10).join(', ')}`);
if (resolved.misses.length) {
log(`missing (${resolved.misses.length}, omitted from manifest; client uses fallbacks):`);
for (const m of resolved.misses.slice(0, 40)) log(` ${m}`);
if (resolved.misses.length > 40) log(` … see ${REPORT}`);
}
if (spine.problems.length) { log(`spine notes (${spine.problems.length}):`); for (const p of spine.problems.slice(0, 20)) log(` ${p}`); }
for (const e of fontErrors) log(`font error: ${e}`);
if (orphans.length) log(opts.prune ? `pruned ${orphans.length} unreferenced files` : `${orphans.length} unreferenced files on disk (run with --prune to delete)`);
log(`manifest: ${MANIFEST} · report: ${REPORT} · ${((Date.now() - t0) / 1000).toFixed(0)} s`);
if (required.length) {
log(`ERROR: ${required.length} required assets missing: ${required.slice(0, 10).join(', ')}`);
return 1;
}
return 0;
}
main().then((code) => { process.exitCode = code; }, (e) => {
console.error(`[assets] FAILED: ${process.env.DEBUG ? e?.stack || e : e?.message || e}`);
process.exitCode = 1;
});
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// tools/kit-coverage.mjs — which selectable skills of every visible chess have a hand-authored skill spec (DESIGN §16).
//
// Operator loadouts let the player pick any skill unlocked for both the normal and the elite chess (shared/protocol.js
// loadoutOptions — exactly what room.loadout accepts). The kit loader (server/sim/content/index.js selectSkillSpec) uses, for the SELECTED skill:
// skills[skillId] of the kit → 'skills' (hand-authored per skill)
// kit.skill, default skill only → 'kit' (hand-authored default)
// otherwise → 'generic' (content/generic.js from the blackboard — an approximation)
// A skill counts as covered when both its normal and its elite def get a hand-authored spec ('skills' | 'kit').
//
// Usage: node tools/kit-coverage.mjs [--json] [--missing] [--tier N] [--strict]
// --json machine-readable report { summary, chess: [{ chessId, name, tier, skills: [{ index, skillId, name,
// isDefault, normal, elite, covered }] }] } on stdout
// --missing list only the chess with at least one uncovered skill
// --tier N only tier N (1–6)
// --strict exit code 1 when any selectable skill is uncovered (CI once every kit is authored)
// Unknown options are errors (exit code 2).
import { getDefaultSource } from '../server/sim/simdata.js';
import { loadoutOptions } from '../shared/protocol.js';
import { KITS, skillSpecSource } from '../server/sim/content/index.js';
const USAGE = 'usage: node tools/kit-coverage.mjs [--json] [--missing] [--tier N] [--strict]';
function parseArgs(argv) {
const o = { json: false, missing: false, tier: null, strict: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') o.json = true;
else if (a === '--missing') o.missing = true;
else if (a === '--strict') o.strict = true;
else if (a === '--tier') {
const t = Number(argv[++i]);
if (!Number.isInteger(t) || t < 1 || t > 6) return null;
o.tier = t;
} else return null;
}
return o;
}
/**
* Coverage report of every visible chess (optionally one tier).
* @param {{ tier?: number|null, kits?: object, ds?: object }} [opts]
*/
export function kitCoverage({ tier = null, kits = KITS, ds = getDefaultSource() } = {}) {
const chessMap = ds.raw?.chess ?? {};
const rows = [];
const bases = Object.values(chessMap)
.filter((c) => c && !c.isGolden && c.visible && (tier == null || c.tier === tier))
.sort((a, b) => a.tier - b.tier || String(a.chessId).localeCompare(String(b.chessId), 'en', { numeric: true }));
for (const base of bases) {
if (!Array.isArray(base.skills)) continue;
const choices = loadoutOptions(base, base.goldenId ? chessMap[base.goldenId] ?? null : null);
const skills = choices.skills.map((index) => {
const rec = base.skills.find((s) => s.index === index);
const src = (id) => {
const def = chessMap[id] ? ds.getChess(id, { skillIndex: index }) : null;
return def ? skillSpecSource(def, kits) : 'none';
};
const normal = src(base.chessId);
const elite = base.goldenId ? src(base.goldenId) : normal;
const authored = (s) => s === 'skills' || s === 'kit';
return { index, skillId: rec.skillId, name: rec.name, isDefault: !!rec.isDefault, normal, elite, covered: authored(normal) && authored(elite) };
});
rows.push({ chessId: base.chessId, name: base.name, tier: base.tier, skills });
}
const all = rows.flatMap((r) => r.skills);
const summary = {
chess: rows.length,
skills: all.length,
covered: all.filter((s) => s.covered).length,
defaultCovered: all.filter((s) => s.isDefault && s.covered).length,
defaults: all.filter((s) => s.isDefault).length,
chessFullyCovered: rows.filter((r) => r.skills.every((s) => s.covered)).length,
};
return { summary, chess: rows };
}
const isMain = import.meta.url === new URL(process.argv[1] ?? '', 'file://').href || process.argv[1]?.endsWith('kit-coverage.mjs');
if (isMain) {
const o = parseArgs(process.argv.slice(2));
if (!o) {
console.error(USAGE);
process.exit(2);
}
const rep = kitCoverage({ tier: o.tier });
const list = o.missing ? rep.chess.filter((r) => r.skills.some((s) => !s.covered)) : rep.chess;
if (o.json) {
process.stdout.write(JSON.stringify({ summary: rep.summary, chess: list }, null, 1) + '\n');
} else {
const mark = (s) => (s.covered ? '✓' : s.normal === s.elite ? `· ${s.normal}` : `· ${s.normal}/${s.elite}`);
for (const r of list) {
const cells = r.skills.map((s) => `S${s.index + 1}${s.isDefault ? '*' : ''} ${s.name} ${mark(s)}`);
console.log(`T${r.tier} ${r.name.padEnd(8, ' ')} ${r.chessId.padEnd(18)} ${cells.join(' | ')}`);
}
const s = rep.summary;
console.log(`\n${s.covered}/${s.skills} selectable skills hand-authored (defaults ${s.defaultCovered}/${s.defaults}); ` +
`${s.chessFullyCovered}/${s.chess} chess fully covered. (* = default skill; ✓ = normal+elite authored; ` +
`· = falls back to: generic | none (no kit))`);
}
if (o.strict && rep.summary.covered < rep.summary.skills) process.exitCode = 1;
}
+30
View File
@@ -0,0 +1,30 @@
# LZ4AK decompressor for Arknights bundles (algorithm from isHarryh/Ark-Unpacker, BSD-3, via MooncellWiki/UnityPy)
import lz4.block
from UnityPy.helpers import CompressionHelper
from UnityPy.enums.BundleFile import CompressionFlags
def _xlen(d, p, m):
l = 0
while p < m:
b = d[p]; l += b; p += 1
if b != 0xFF: break
return l, p
def decompress_lz4ak(data, usize):
ip = op = 0
d = bytearray(data); n = len(d)
while ip < n:
lit = d[ip] & 0xF; mat = (d[ip] >> 4) & 0xF
d[ip] = (lit << 4) | mat; ip += 1
if lit == 0xF:
l, ip = _xlen(d, ip, n); lit += l
ip += lit; op += lit
if op >= usize: break
off = (d[ip] << 8) | d[ip + 1]
d[ip] = off & 0xFF; d[ip + 1] = (off >> 8) & 0xFF; ip += 2
if mat == 0xF:
l, ip = _xlen(d, ip, n); mat += l
op += mat + 4
return lz4.block.decompress(bytes(d), usize)
CompressionHelper.DECOMPRESSION_MAP[CompressionFlags.LZHAM] = decompress_lz4ak
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""Extract autochess art from a locally installed Arknights client (optional, host-side).
The official CN client ships Unity AssetBundles compressed with a custom LZ4 variant ("LZ4AK");
aklz4.py registers a decoder for it. This script pulls the art the web sources lack:
- map_autochess / map_autochesssand textures (the real board tile atlas)
- every Sprite of the three autochess UI bundles (HUD frames, badges, banners, icons)
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader
names resolved through the shaders/*.ab bundles), the background / device meshes as Wavefront OBJ
(mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
placement (mesh/<bundle>/prefab.json)
- the standard gate / objective effects of every battle map (arts/effects/[pack]map.ab → map/fx: the [opt]start_box /
[opt]end_box / [opt]start_fly meshes, their merged additive textures, materials and prefab), the blower's texture
(arts/maps/common/res.ab → map/common: TX_wind_device of the s_wind_device mesh) and the shared water / noise
textures (arts/maps/effect.ab → map/water) used for deep-sea / mire / smog tiles
- derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0)
and metallic/gloss with the smoothness in A; the board renderer needs an RGB normal map (Z rebuilt) and a
roughness map in G (1 − smoothness; metalness B = 0), written next to the source as <name>_rgb.png / _rough.png
Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
Writes <out>/**.png|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just the
jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing manifest
(every other group is kept as is).
Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
ROOT = Path(__file__).resolve().parents[2]
HOME = Path.home()
CANDIDATES = [
HOME / 'Library/Application Support/CrossOver/Bottles/Arknights/drive_c/Program Files/Hypergryph Launcher/games/Arknights/Arknights_Data/StreamingAssets/AB/Windows',
HOME / 'Library/Containers/com.hypergryph.arknights/Data/Documents/Bundles',
# native Windows installs of the official launcher (default and x86 locations)
Path('C:/Program Files/Hypergryph Launcher/games/Arknights/Arknights_Data/StreamingAssets/AB/Windows'),
Path('C:/Program Files (x86)/Hypergryph Launcher/games/Arknights/Arknights_Data/StreamingAssets/AB/Windows'),
]
# In-match emote themes (display_meta_table emoticonData, scene AUTOCHESS_BATTLE), in the order of
# activity_table autoChessData.enabledEmoticonThemeIdList: (themeId, output dir under emoticon/). The dirs are the
# ones of shared/constants.js EMOTE_THEMES / data/emotes.json. Only the *_battle sprites are exported: the bundles also
# hold room / pick pics of other modes (and the autochess room pics are not in the local client at all).
EMOTE_THEMES = [
('emoticon_autochess_basic', 'basic'),
('emoticon_originium_slug', 'slug'),
('emoticon_autochess_basic_2', 'basic_2'),
('emoticon_foolsday_doctor', 'fooldoctor'),
('emoticon_foolsday_amiya', 'foolamiya'),
('emoticon_foolsday_wisdel', 'foolwisdel'),
]
BATTLE_EMOTE = r'^pic_.+_battle$'
# Meshes of the official board scene (DESIGN §15): (bundle, output name under mesh/).
MESH_BUNDLES = [
('arts/maps/map_autochess/bkg_mesh.ab', 'map_autochess_bkg'),
('arts/maps/common/meshes/s_background_common.ab', 's_background_common'),
('arts/maps/common/meshes/s_common_box_01.ab', 's_common_box_01'),
('arts/maps/common/meshes/s_wind_device.ab', 's_wind_device'),
]
# (bundle path relative to the AB root, output subdir, which object types to export[, name regex to keep])
JOBS = [
('arts/maps/map_autochess/res.ab', 'map/autochess', {'Texture2D', 'Material'}),
('arts/maps/map_autochesssand/res.ab', 'map/autochesssand', {'Texture2D', 'Material'}),
('ui/autochess/[uc]autochesscommon.ab', 'ui/common', {'Sprite'}),
('ui/autochess/[uc]autochessbattle.ab', 'ui/battle', {'Sprite', 'TextAsset'}),
('ui/autochess/[uc]autochessouter.ab', 'ui/outer', {'Sprite'}),
*[(f'ui/emoticon/theme/[uc]{theme}.ab', f'emoticon/{sub}', {'Sprite'}, BATTLE_EMOTE) for theme, sub in EMOTE_THEMES],
('arts/guidebookpages/[pack]autochess.ab', 'guide', {'Sprite', 'Texture2D'}),
('battle/prefabs/[uc]projectiles.ab', 'projectiles', {'Sprite', 'Texture2D'}),
# official module (uniequip) type icons, keyed by lower-case type name (e.g. 'mar-x')
('spritepack/ui_equip_type_hub_h2_0.ab', 'module', {'Sprite'}),
('skinpack/token_10039_ulpia_block.ab', 'spine/token_10039_ulpia_block', {'TextAsset', 'Texture2D'}),
*[(rel, f'mesh/{sub}', {'Mesh', 'GameObject', 'Material', 'Texture2D'}) for rel, sub in MESH_BUNDLES],
('arts/effects/[pack]map.ab', 'map/fx', {'Mesh', 'GameObject', 'Material', 'Texture2D'}),
('arts/maps/common/res.ab', 'map/common', {'Material', 'Texture2D'}, r'^(TX|MT)_wind_device$'),
('arts/maps/effect.ab', 'map/water', {'Texture2D'}, r'water_normal|Caustics|WaterNoise|noise_clouds|Water_Foam|SmoothWaves'),
]
# Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel
# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in
# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture.
DERIVED = [
('map/autochess', 'TX_autochessi_N', 'normal_rg', 'TX_autochessi_N_rgb'),
('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'),
]
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
# from them.
SHADER_DEPS = 'shaders/*.ab'
# the root shader bundle ([uc]shaders.ab) holds the Torappu particle shaders of the map effects (map/fx)
SHADER_DEPS_ROOT = '[uc]shaders.ab'
SAFE = re.compile(r'[^A-Za-z0-9_.\-\[\]]+')
def safe_name(name):
name = SAFE.sub('_', name).strip('._') or 'unnamed'
return name[:120]
def job_parts(job):
"""(rel, sub, kinds, keep) of a JOBS entry; keep is a compiled name filter or None."""
rel, sub, kinds = job[:3]
keep = re.compile(job[3]) if len(job) > 3 and job[3] else None
return rel, sub, kinds, keep
def select_jobs(only):
"""The jobs whose output subdir starts with one of the `only` prefixes (all jobs when `only` is empty)."""
if not only:
return list(JOBS)
return [j for j in JOBS if any(j[1] == p.rstrip('/') or j[1].startswith(p.rstrip('/') + '/') for p in only)]
def _num(v, nd=6):
"""JSON-safe rounded float (NaN / inf → 0)."""
try:
f = float(v)
except (TypeError, ValueError):
return 0
return round(f, nd) + 0.0 if f == f and abs(f) != float('inf') else 0 # + 0.0: no '-0.0'
def vec(v, keys='xyz'):
"""[x, y, z(, w)] of a UnityPy vector / quaternion (missing components → 0)."""
return [_num(getattr(v, k, 0)) for k in keys]
def _pptr_name(pptr):
"""Name of the object a PPtr points to, or None (null pointer or an unloaded external bundle)."""
try:
if not getattr(pptr, 'm_PathID', 0):
return None
return getattr(pptr.read(), 'm_Name', None) or None
except Exception: # external dependency not loaded, unsupported type, …
return None
def material_info(mat):
"""JSON summary of a Material: shader, keywords, textures (by name, with tiling), floats and colours."""
shader = None
try:
sh = mat.m_Shader.read()
shader = getattr(getattr(sh, 'm_ParsedForm', None), 'm_Name', None) or getattr(sh, 'm_Name', None)
except Exception:
shader = None
props = getattr(mat, 'm_SavedProperties', None)
textures, floats, colors = {}, {}, {}
for k, env in (getattr(props, 'm_TexEnvs', None) or []):
name = _pptr_name(env.m_Texture)
if name:
textures[k] = {'texture': name, 'scale': vec(env.m_Scale, 'xy'), 'offset': vec(env.m_Offset, 'xy')}
for k, v in (getattr(props, 'm_Floats', None) or []):
floats[k] = _num(v)
for k, c in (getattr(props, 'm_Colors', None) or []):
colors[k] = vec(c, 'rgba')
kw = getattr(mat, 'm_ValidKeywords', None) or getattr(mat, 'm_ShaderKeywords', None) or []
if isinstance(kw, str):
kw = kw.split()
return {'shader': shader, 'keywords': sorted(kw), 'textures': dict(sorted(textures.items())),
'floats': dict(sorted(floats.items())), 'colors': dict(sorted(colors.items()))}
def prefab_node(go, mesh_keys=None):
"""JSON summary of a GameObject: local transform (Unity space), parent, mesh and material names. `mesh_keys`
({mesh path id: manifest key}) names the exported OBJ when two meshes of a bundle share a name."""
node = {'name': getattr(go, 'm_Name', '') or '', 'parent': None, 'pos': [0, 0, 0], 'rot': [0, 0, 0, 1],
'scale': [1, 1, 1], 'mesh': None, 'materials': []}
for c in getattr(go, 'm_Component', None) or []:
pptr = getattr(c, 'component', c)
try:
comp = pptr.read()
except Exception:
continue
kind = type(comp).__name__
if kind in ('Transform', 'RectTransform'):
node['pos'] = vec(comp.m_LocalPosition)
node['rot'] = vec(comp.m_LocalRotation, 'xyzw')
node['scale'] = vec(comp.m_LocalScale)
try:
father = comp.m_Father.read() if getattr(comp.m_Father, 'm_PathID', 0) else None
node['parent'] = father.m_GameObject.read().m_Name if father else None
except Exception:
node['parent'] = None
elif kind == 'MeshFilter':
node['mesh'] = _pptr_name(comp.m_Mesh)
pid = getattr(comp.m_Mesh, 'm_PathID', 0)
if mesh_keys and pid in mesh_keys and not getattr(comp.m_Mesh, 'm_FileID', 0):
node['mesh'] = mesh_keys[pid]
elif kind in ('MeshRenderer', 'SkinnedMeshRenderer'):
node['materials'] = [_pptr_name(m) for m in getattr(comp, 'm_Materials', None) or []]
return node
def dep_bundles(ab_root, kinds):
"""Bundles to load next to a job so its references resolve: the shader bundles when it exports Materials."""
if 'Material' not in kinds:
return []
root = Path(ab_root)
extra = [root / SHADER_DEPS_ROOT] if (root / SHADER_DEPS_ROOT).is_file() else []
return sorted(p for p in root.glob(SHADER_DEPS) if p.is_file()) + extra
_NORMAL_Z = None
def _normal_z_table():
"""Z byte of a unit normal for every (x byte, y byte) pair: 128 + 127·sqrt(max(0, 1 − x² − y²))."""
global _NORMAL_Z
if _NORMAL_Z is None:
t = bytearray(65536)
for xb in range(256):
x = xb / 127.5 - 1.0
for yb in range(256):
y = yb / 127.5 - 1.0
z = max(0.0, 1.0 - x * x - y * y) ** 0.5
t[(xb << 8) | yb] = min(255, int(round(127.5 + 127.5 * z)))
_NORMAL_Z = bytes(t)
return _NORMAL_Z
def derive_normal_rg(img):
"""RGB tangent-space normal map from a two-channel (BC5: R = X, G = Y, B unused) one: Z rebuilt per pixel."""
from PIL import Image
rgb = img.convert('RGB')
r, g, _ = rgb.split()
table = _normal_z_table()
z = bytes(table[(x << 8) | y] for x, y in zip(r.tobytes(), g.tobytes()))
return Image.merge('RGB', (r, g, Image.frombytes('L', rgb.size, z)))
def derive_rough_from_gloss(img):
"""three.js roughness / metalness map (G = 1 − smoothness from Unity's A, B = metalness 0, R = 1) from a
Unity metallic/gloss map."""
from PIL import Image
rgba = img.convert('RGBA')
rough = rgba.getchannel('A').point(lambda v: 255 - v)
return Image.merge('RGB', (Image.new('L', rgba.size, 255), rough, Image.new('L', rgba.size, 0)))
DERIVERS = {'normal_rg': derive_normal_rg, 'rough_from_gloss': derive_rough_from_gloss}
def run_derived(out_root, sub, manifest, log):
"""Write the DERIVED maps of output subdir `sub` (from its exported PNGs) and record them in the manifest."""
from PIL import Image
n = 0
for dsub, src, kind, name in DERIVED:
if dsub != sub:
continue
path = Path(out_root) / sub / f'{src}.png'
if not path.exists():
log(f' derived {name}: source {src} missing')
continue
try:
img = DERIVERS[kind](Image.open(path))
img.save(Path(out_root) / sub / f'{name}.png')
except Exception as e: # a broken source only drops the derived map (the renderer falls back)
log(f' warn derived {name}: {e}')
continue
manifest.setdefault(sub, {})[name] = {'path': f'/assets/local/{sub}/{name}.png', 'w': img.width, 'h': img.height,
'kind': 'Derived', 'from': src, 'derive': kind}
n += 1
return n
def merge_manifest(old_groups, new_groups, ran_subs):
"""Groups of the previous manifest minus the subdirs that were re-extracted, plus the new ones (sorted keys)."""
out = {g: v for g, v in (old_groups or {}).items() if g not in ran_subs}
out.update(new_groups)
return {g: dict(sorted(out[g].items())) for g in sorted(out)}
def export_bundle(ab_root, job, out_root, manifest, log):
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
rel, sub, kinds, keep = job_parts(job)
src = ab_root / rel
if not src.exists():
log(f'skip (missing) {rel}')
return 0
try:
env = UnityPy.load(str(src))
except Exception as e: # corrupt or unsupported bundle: report and continue
log(f'FAIL load {rel}: {e}')
return 0
objects = list(env.objects) # the job's own objects, listed before any dependency joins the environment
for dep in dep_bundles(ab_root, kinds):
try:
env.load_file(str(dep))
except Exception as e: # a missing shader only leaves that material's shader name null
log(f' warn dependency {dep.name}: {e}')
out_dir = out_root / sub
out_dir.mkdir(parents=True, exist_ok=True)
seen, n = set(), 0
materials, prefab, gos, mesh_keys = {}, [], [], {}
for obj in objects:
t = obj.type.name
if t not in kinds:
continue
try:
data = obj.read()
raw_name = getattr(data, 'm_Name', '') or ''
if keep is not None and not keep.search(raw_name):
continue
name = safe_name(raw_name or f'obj_{obj.path_id}')
if t in ('Sprite', 'Texture2D'):
img = data.image
if img is None or img.width < 2 or img.height < 2:
continue
fname = name + '.png'
if fname in seen: # same name twice: a Sprite (true aspect) beats its padded Texture2D
if t == 'Texture2D' or manifest.get(sub, {}).get(name, {}).get('kind') == 'Sprite':
continue
img.save(out_dir / fname)
seen.add(fname)
manifest.setdefault(sub, {})[name] = {
'path': f'/assets/local/{sub}/{fname}', 'w': img.width, 'h': img.height, 'kind': t}
n += 1
elif t == 'TextAsset':
raw = data.m_Script
blob = raw.encode('utf-8', 'surrogateescape') if isinstance(raw, str) else bytes(raw)
if name.endswith('.atlas') or name.endswith('.skel'):
fname = name
elif blob[:1] in (b'\n', b'') or b'size:' in blob[:200]:
fname = name + '.atlas'
else:
fname = name + '.skel'
(out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': t}
n += 1
elif t == 'Mesh':
text = data.export()
if not text or 'v ' not in text:
continue
fname = name + '.obj'
if fname in seen:
name, fname = f'{name}_{obj.path_id}', f'{name}_{obj.path_id}.obj'
(out_dir / fname).write_text(text, encoding='utf-8')
seen.add(fname)
verts = sum(1 for line in text.splitlines() if line.startswith('v '))
manifest.setdefault(sub, {})[name] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'Mesh', 'verts': verts}
mesh_keys[obj.path_id] = name
n += 1
elif t == 'Material':
materials[raw_name or name] = material_info(data)
elif t == 'GameObject':
gos.append(data) # summarised after every mesh got its manifest key
except Exception as e:
log(f' warn {rel} #{obj.path_id}: {e}')
for go in gos:
try:
prefab.append(prefab_node(go, mesh_keys))
except Exception as e:
log(f' warn {rel} GameObject: {e}')
for key, fname, payload in (('materials', 'materials.json', materials), ('prefab', 'prefab.json', prefab)):
if not payload:
continue
if isinstance(payload, list):
payload = sorted(payload, key=lambda nd: (nd['parent'] or '', nd['name']))
else:
payload = dict(sorted(payload.items()))
(out_dir / fname).write_text(json.dumps(payload, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
manifest.setdefault(sub, {})[key] = {'path': f'/assets/local/{sub}/{fname}', 'kind': key.capitalize(), 'count': len(payload)}
n += 1
n += run_derived(out_root, sub, manifest, log)
log(f'{rel}: {n} files -> {sub}')
return n
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--game', help='AssetBundle root (…/StreamingAssets/AB/Windows or …/Documents/Bundles)')
ap.add_argument('--out', default=str(ROOT / 'public/assets/local'))
ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json'))
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
args = ap.parse_args()
if args.print_jobs:
jobs = [{'bundle': rel, 'sub': sub, 'kinds': sorted(kinds), 'keep': keep.pattern if keep else None}
for rel, sub, kinds, keep in map(job_parts, JOBS)]
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived}, ensure_ascii=False))
return 0
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
if not ab_root or not ab_root.exists():
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
return 2
jobs = select_jobs(args.only)
if not jobs:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
out_root.mkdir(parents=True, exist_ok=True)
manifest, total = {}, 0
print(f'AB root: {ab_root}')
for job in jobs:
total += export_bundle(ab_root, job, out_root, manifest, print)
old = {}
if args.only and Path(args.manifest).exists():
try:
old = json.loads(Path(args.manifest).read_text(encoding='utf-8')).get('groups') or {}
except (OSError, ValueError) as e:
print(f'warn: previous manifest unreadable ({e}); writing only the re-extracted groups', file=sys.stderr)
groups = merge_manifest(old, manifest, set(manifest)) # a missing bundle keeps its previous group
count = sum(len(v) for v in groups.values())
doc = {'version': 1, 'source': 'local-client', 'count': count, 'groups': groups}
Path(args.manifest).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
print(f'done: {total} files extracted, manifest {args.manifest} ({count} entries)')
return 0 if total else 1
if __name__ == '__main__':
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
UnityPy>=1.25
lz4>=4
Pillow>=10
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
// tools/matchrun.mjs — run whole bot matches headlessly (virtual time, real simulation) and print a per-round
// balancing summary: LP over time, shop level, deployed units, kills/leaks, 联防, Final Assault outcome.
//
// Usage:
// node tools/matchrun.mjs [--mode solo|coop] [--difficulty FUNNY|NORMAL|HARD|ABYSS|ALL] [--players 1..4] [--seed 1]
// [--seeds N] [--lp N] [--humans N] [--content full|generic|none] [--check] [--errors]
// [--odds] [--json] [--quiet] [--rehearsal N]
// --players co-op seats (default 2; solo is always 1)
// --humans N the first N seats are human seats on "AI 托管" (exercises the human views / m.private paths)
// --seeds N run N consecutive seeds and print an aggregate (rounds survived, LP by round, outcomes)
// --lp N override every player's starting LP (reach later rounds / the Final Assault while balancing)
// --layers N at the boss round's prep, add N layers to every bond of every player (reach the Hidden Core)
// --difficulty ALL sweep all four difficulties
// --content sim content mode (default full)
// --rehearsal N layouts each bot rehearses per prep with the real sim (default: the Match default, 3; 0 = off,
// faster sweeps with the heuristic placement only)
// --check audit every match: engine invariants at every phase change + rule checks (server/match/audit.js:
// income, upgrade price, freeze, shop odds/bans, drafts, 联防 helpers, settle LP, final assault
// pairing/pool, hidden core, titles, deadlines). Exit code 1 when anything is violated.
// --errors per-source error summary over all runs: sim/content errors grouped by content module + handler label
// (+ unit) with occurrence and battle counts, meta handler errors by registry key, match engine errors
// --odds shop tier distribution of rolled chess slots per shop level (all runs)
// --json print machine-readable results
// --tuning off play on the research-faithful numbers (data/tuning.json ignored; docs/BALANCE.md)
// --legacy-time read combat limits as game seconds (the reading before docs/BALANCE.md §2.1)
// Examples:
// node tools/matchrun.mjs --mode solo --difficulty FUNNY --seed 3
// node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20
// node tools/matchrun.mjs --mode coop --difficulty ALL --players 4 --humans 2 --seeds 10 --check --errors
import { Match } from '../server/match/Match.js';
import { VirtualScheduler } from '../server/match/scheduler.js';
import { attachAudit } from '../server/match/audit.js';
import { Battle } from '../server/sim/Battle.js';
import { getData } from '../server/data.js';
const argv = process.argv.slice(2);
const opt = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
}
if (opt.help) {
console.log(Buffer.from(await (await import('node:fs')).promises.readFile(new URL(import.meta.url))).toString().split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n'));
process.exit(0);
}
const DIFFS = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
const mode = opt.mode === 'solo' ? 'solo' : 'coop';
const diffArg = String(opt.difficulty || 'NORMAL').toUpperCase();
const difficulties = diffArg === 'ALL' ? DIFFS : [DIFFS.includes(diffArg) ? diffArg : 'NORMAL'];
const players = mode === 'solo' ? 1 : Math.max(1, Math.min(4, Number(opt.players) || 2));
const humans = mode === 'solo' ? (opt.humans != null ? 1 : 0) : Math.max(0, Math.min(players, Number(opt.humans) || 0));
const seed0 = Number(opt.seed) || 1;
const seeds = Math.max(1, Number(opt.seeds) || 1);
const lpOverride = Number(opt.lp) > 0 ? Number(opt.lp) : null;
const layerBoost = Number(opt.layers) > 0 ? Math.floor(Number(opt.layers)) : null;
const quiet = !!opt.quiet || seeds > 1 || difficulties.length > 1;
const rehearsal = opt.rehearsal != null && opt.rehearsal !== true ? Math.max(0, Math.floor(Number(opt.rehearsal) || 0)) : undefined;
let data = getData({ log: { warn() {}, error() {}, info() {} } });
if (opt.tuning === 'off') { const { tuning, ...rest } = data; void tuning; data = Object.freeze(rest); }
if (opt['legacy-time']) data = Object.freeze({ ...data, config: { ...data.config, combatTimeScale: 1 } });
// ---- error collection (--errors) ------------------------------------------------------------------------------
// Battle errors are logged once per unique key inside the sim; to count every occurrence the tool runs a Battle
// subclass that observes the sim's error sink. If that internal hook ever disappears the summary falls back to the
// unique records each match collects in m.simErrorLog.
const simErrors = new Map();
let current = { seed: 0, difficulty: '', battle: 0 };
const moduleOf = (stack) => {
const lines = String(stack || '').split('\n').slice(1);
const pick = (re) => { for (const l of lines) { const mm = re.exec(l); if (mm) return mm[1]; } return null; };
return pick(/server\/sim\/(content\/[^\s):]+:\d+)/) || pick(/server\/(sim\/[^\s):]+:\d+)/) || pick(/server\/(match\/[^\s):]+:\d+)/) || '?';
};
function noteSimError(label, who, message, stack, n = 1, battleKey = null) {
const src = moduleOf(stack);
const key = `${src}|${label}|${who}|${message}`;
let r = simErrors.get(key);
if (!r) { r = { src, label, who, message, count: 0, battles: new Set(), seeds: new Set() }; simErrors.set(key, r); }
r.count += n;
r.battles.add(battleKey ?? `${current.difficulty}:${current.seed}:${current.battle}`);
r.seeds.add(`${current.difficulty[0] || ''}${current.seed}`);
}
const canCount = typeof Battle.prototype._handlerError === 'function';
class CountingBattle extends Battle {
constructor(o) { current.battle++; super(o); }
_handlerError(label, owner, e, internal) {
try {
const who = owner && owner.defId ? owner.defId : (owner && owner.name) || '';
noteSimError(String(label), String(who), String(e && e.message), e && e.stack);
} catch { /* diagnostics only */ }
return super._handlerError(label, owner, e, internal);
}
}
// ---- one match ------------------------------------------------------------------------------------------------
function runOne(seed, difficulty) {
current = { seed, difficulty, battle: 0 };
const sched = new VirtualScheduler();
const errors = [];
const log = { info() {}, debug() {}, warn() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
const seats = [];
for (let i = 0; i < players; i++) {
const human = i < humans;
seats.push({ seat: i, playerId: human ? `p_${i}` : `ai_${i}`, name: human ? `P${i + 1}` : `AI-${i + 1}`, isBot: !human, connected: true });
}
let summary = null;
let frames = 0;
const m = new Match({
roomCode: 'RUN', mode, difficulty, seats, seed, data, log, scheduler: sched,
battleContent: opt.content || 'full',
botRehearsal: rehearsal,
BattleClass: opt.errors && canCount ? CountingBattle : undefined,
send: () => { frames++; return true; }, broadcast: () => {}, onEnd: (s) => { summary = s; },
});
for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true;
const audit = opt.check || opt.odds ? attachAudit(m, { invariants: !!opt.check }) : null;
const rounds = [];
let last = '';
const t0 = Date.now();
m.start();
sched.runUntil(() => {
const key = `${m.phase}:${m.round}`;
if (key !== last) {
last = key;
if (lpOverride && m.phase === 'PREP' && m.round === 1) for (const ps of m.players.values()) ps.lp = lpOverride;
if (layerBoost && m.phase === 'PREP' && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) { for (const id of m.gd.bondIds) ps.layers[id] = (ps.layers[id] || 0) + layerBoost; ps.recompute(); }
}
if (m.phase === 'SETTLE' || ((m.phase === 'RESULT') && (m.round === m.gd.bossRound || m.round === m.gd.hiddenRound))) {
const row = { round: m.round, unite: !!m.unitePlan, players: {} };
for (const ps of m.order) {
const r = m.lastResults.get(ps.playerId);
row.players[ps.playerId] = {
lp: ps.lp, alive: ps.alive, level: ps.shop.level, units: ps.deployCount,
killed: r ? r.killed : null, total: r ? r.total : null, leaks: r ? (r.leaked || []).filter((l) => l.counted !== false).length : null,
bonds: Object.entries(ps.bonds).filter(([, b]) => b.active).map(([id, b]) => `${data.bonds[id]?.name ?? id}${b.tier > 1 ? b.tier : ''}`).join(' '),
};
}
if (m.teamLp != null) row.teamLp = m.teamLp;
if (m.bossPool) row.boss = { hp: Math.round(m.bossPool.hp), max: m.bossPool.maxHp };
rounds.push(row);
}
}
return summary != null;
}, { maxSteps: 5e6 });
if (!summary) errors.push(`match did not end (stuck in ${m.phase} R${m.round})`);
if (opt.errors) {
if (!canCount) for (const r of m.simErrorLog.values()) noteSimError(r.label, r.who, r.message, r.stack, r.count, `${difficulty}:${seed}`);
for (const e of m.errors) noteSimError(`match ${e.label}`, '', e.message, e.stack || '', 1, `${difficulty}:${seed}:match`);
for (const [k, r] of m.dispatcher.errorsByKey) {
const i = k.indexOf(': ');
noteSimError(`meta ${i > 0 ? k.slice(0, i) : k}`, '', i > 0 ? k.slice(i + 2) : '', r.stack, r.count, `${difficulty}:${seed}:meta`);
}
}
m.dispose();
return {
seed, difficulty, summary, rounds, errors, matchErrors: m.errorCount, simErrors: m.simErrors, metaErrors: m.dispatcher.errors,
ms: Date.now() - t0, stageId: m.stageId, bossId: m.bossId, frames, audit,
};
}
// ---- run ------------------------------------------------------------------------------------------------------
const results = [];
for (const difficulty of difficulties) {
for (let i = 0; i < seeds; i++) {
const r = runOne(seed0 + i, difficulty);
results.push(r);
if (!quiet && !opt.json) {
const s = r.summary || {};
console.log(`== ${mode} ${difficulty} · ${players} seat(s)${humans ? ` (${humans} human on AI 托管)` : ''} · seed ${r.seed} · stage ${r.stageId} · boss ${r.bossId}`);
for (const row of r.rounds) {
const cells = Object.entries(row.players).map(([id, p]) => `${id.replace(/^(ai|p)_/, 'P')}:${p.alive ? `LP${String(p.lp).padStart(3)}` : ' dead '} L${p.level} u${p.units} ${p.killed != null ? `${p.killed}/${p.total}` : '-'}${p.leaks ? ` −${p.leaks}` : ''}`).join(' | ');
const extra = [row.unite ? '联防' : '', row.teamLp != null ? `teamLP ${row.teamLp}` : '', row.boss ? `boss ${row.boss.hp}/${row.boss.max}` : ''].filter(Boolean).join(' ');
console.log(`R${String(row.round).padStart(2)} ${cells}${extra ? ' · ' + extra : ''}`);
}
console.log(`=> ${s.victory ? 'VICTORY' : 'DEFEAT'} · rounds passed ${s.roundsPassed}${s.hiddenReached ? ` · hidden ${s.hiddenCleared ? 'cleared' : 'failed'}` : ''} · reason ${s.reason} · ${r.ms} ms · match errors ${r.matchErrors} · meta errors ${r.metaErrors} · sim errors ${r.simErrors}`);
for (const p of s.players || []) console.log(` ${p.name}: band ${data.bands[p.bandId]?.name ?? p.bandId} · rounds ${p.roundsPassed} · title ${p.title ? p.title.name : '-'} · dmg ${p.stats.dmgDealt} · kills ${p.stats.kills} · merges ${p.stats.merges} · spent ${p.stats.gold}`);
if (r.errors.length) console.log(' errors:', r.errors.slice(0, 5));
if (r.audit && r.audit.violations.length) console.log(` audit: ${r.audit.violations.length} violation(s)\n ${r.audit.violations.slice(0, 10).join('\n ')}`);
}
}
}
let exitCode = 0;
if (opt.json) {
console.log(JSON.stringify(results.map((r) => ({
seed: r.seed, difficulty: r.difficulty, stageId: r.stageId, bossId: r.bossId, victory: r.summary?.victory, roundsPassed: r.summary?.roundsPassed,
reason: r.summary?.reason, rounds: r.rounds, errors: r.errors.length + r.matchErrors, simErrors: r.simErrors, metaErrors: r.metaErrors,
violations: r.audit ? r.audit.violations : undefined,
})), null, 1));
} else if (quiet) {
for (const difficulty of difficulties) {
const rs = results.filter((r) => r.difficulty === difficulty);
const passed = rs.map((r) => r.summary?.roundsPassed ?? 0);
const wins = rs.filter((r) => r.summary?.victory).length;
const avg = passed.reduce((a, b) => a + b, 0) / passed.length;
const hist = {};
for (const p of passed) hist[p] = (hist[p] || 0) + 1;
console.log(`${mode} ${difficulty} · ${players} seat(s)${humans ? ` (${humans} human)` : ''} · seeds ${seed0}..${seed0 + seeds - 1}`);
console.log(` rounds passed: avg ${avg.toFixed(2)} · min ${Math.min(...passed)} · max ${Math.max(...passed)} · wins ${wins}/${rs.length} · hidden ${rs.filter((r) => r.summary?.hiddenReached).length}`);
console.log(` distribution: ${Object.entries(hist).map(([k, v]) => `${k}:${v}`).join(' ')}`);
const maxR = Math.max(...rs.map((r) => (r.rounds.length ? r.rounds[r.rounds.length - 1].round : 0)));
const line = [];
for (let rr = 1; rr <= maxR; rr++) {
const lps = [];
for (const r of rs) {
const row = r.rounds.find((x) => x.round === rr);
if (!row) continue;
for (const p of Object.values(row.players)) if (p.alive) lps.push(p.lp);
}
if (lps.length) line.push(`R${rr}:${(lps.reduce((a, b) => a + b, 0) / lps.length).toFixed(1)}(${lps.length})`);
}
console.log(` avg LP of alive players after each round: ${line.join(' ')}`);
const errs = rs.reduce((n, r) => n + r.errors.length + r.matchErrors, 0);
console.log(` engine errors: ${errs} · meta errors: ${rs.reduce((n, r) => n + r.metaErrors, 0)} · sim errors: ${rs.reduce((n, r) => n + r.simErrors, 0)} · total ${rs.reduce((n, r) => n + r.ms, 0)} ms`);
}
}
if (opt.check) {
const bad = results.filter((r) => r.audit && r.audit.violations.length);
const stuck = results.filter((r) => !r.summary);
const checks = results.reduce((n, r) => n + (r.audit ? r.audit.checks : 0), 0);
console.log(`\ncheck: ${results.length} match(es), ${checks} rule checks, ${results.reduce((n, r) => n + (r.audit ? r.audit.phases : 0), 0)} invariant passes → ${bad.length ? `${bad.length} match(es) with violations` : 'no violations'}${stuck.length ? `, ${stuck.length} stuck` : ''}`);
for (const r of bad.slice(0, 10)) console.log(` ${r.difficulty} seed ${r.seed}:\n ${r.audit.violations.slice(0, 8).join('\n ')}`);
if (bad.length || stuck.length || results.some((r) => r.matchErrors > 0)) exitCode = 1;
}
if (opt.odds) {
const tot = {};
for (const r of results) for (const [lv, row] of Object.entries(r.audit?.odds || {})) for (const [t, n] of Object.entries(row)) { (tot[lv] ||= {})[t] = (tot[lv][t] || 0) + n; }
console.log('\nshop odds (rolled chess slots per shop level → tier shares):');
for (const lv of Object.keys(tot).sort()) {
const n = Object.values(tot[lv]).reduce((a, b) => a + b, 0);
console.log(` L${lv} (${n} rolls): ${Object.keys(tot[lv]).sort().map((t) => `T${t} ${((tot[lv][t] / n) * 100).toFixed(1)}%`).join(' ')}`);
}
}
if (opt.errors) {
const list = [...simErrors.values()].sort((a, b) => b.count - a.count);
const total = list.reduce((n, r) => n + r.count, 0);
console.log(`\nerrors by source: ${total} occurrence(s), ${list.length} unique${canCount ? '' : ' (unique records only: the sim error sink was not observable)'}`);
if (list.length) {
console.log(' count battles seeds source label (unit) — message');
for (const r of list.slice(0, Number(opt.errors) > 0 ? Number(opt.errors) : 40)) {
const seedsStr = [...r.seeds].slice(0, 4).join(',') + (r.seeds.size > 4 ? '…' : '');
console.log(` ${String(r.count).padStart(6)} ${String(r.battles.size).padStart(7)} ${seedsStr.padEnd(12)} ${r.src.padEnd(34)} ${r.label}${r.who ? ` (${r.who})` : ''} — ${String(r.message).slice(0, 140)}`);
}
const byModule = new Map();
for (const r of list) byModule.set(r.src.replace(/:\d+$/, ''), (byModule.get(r.src.replace(/:\d+$/, '')) || 0) + r.count);
console.log(` by module: ${[...byModule.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
console.log(` repro: node tools/matchrun.mjs --mode ${mode} --difficulty <D> --players ${players} --seed <n> --errors (seeds are listed per row, prefixed by the difficulty initial)`);
}
}
process.exitCode = exitCode;
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
// tools/record-battle.mjs — run real battles headlessly with the simulation engine (server/sim, docs/SIM.md) and
// record exactly what a watching client receives, for the render demo (public/dev/render-demo.html):
//
// { version: 1, name, title, kind, stageId, modeId, round, tick: 1/30, snapEvery: 3, duration,
// field: <m.field payload>, frames: [ { snap: <b.snap payload>, ev: [ …b.ev tuples… ] } ], result: {…} }
//
// One frame per 3 ticks (= one b.snap, 20 Hz real at the forced 2× speed); `ev` holds the events drained at
// that point (on the wire the match sends that `b.ev` right BEFORE its `b.snap`, both stamped with the snapshot's
// game time `gt`; the demo replays them the same way). Scenarios: a normal round with a real lineup vs the real
// wave template on each of the active stages, a 联防 unite field (2 players, escaped-multi template) and a
// Final Assault boss field (2 players, shared boss pool). Late rounds (`wave: 'official'`) use the match's own wave
// generator (server/match/waves.js setupMatchWaves + buildNormalWave: the official per-round composition and counts,
// research 08 §2) and also store the round's prep preview (`preview` = m.private.nextEnemies, previewOf) for the
// enemy-pen demo (render-demo ?pen=<name>). Also writes public/dev/recordings/index.json.
//
// Usage: node tools/record-battle.mjs [--only name,name] [--out dir] [--seed n] [--max seconds]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Battle } from '../server/sim/Battle.js';
import { getDefaultSource, spawnsFromTemplate } from '../server/sim/simdata.js';
import { getData } from '../server/data.js';
import { GameData } from '../server/match/gamedata.js';
import { setupMatchWaves, buildNormalWave, previewOf } from '../server/match/waves.js';
import { createRng } from '../server/sim/rng.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const opt = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
opt[k] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
}
const OUT = path.resolve(ROOT, typeof opt.out === 'string' ? opt.out : 'public/dev/recordings');
const ONLY = typeof opt.only === 'string' ? new Set(opt.only.split(',')) : null;
const SEED = Number(opt.seed ?? 20260927) >>> 0;
const MAX_SECONDS = Number(opt.max ?? 90);
const quiet = { warn() {}, error() {}, info() {}, debug() {} };
const ds = getDefaultSource();
const data = getData({ log: quiet });
const config = data.config ?? {};
function chessByName(name, golden = false) {
for (const id of ds.chessIds()) {
const r = ds.rawChess(id);
if (r && r.name === name && !r.isGolden && r.visible !== false) return golden ? (r.goldenId || id.replace(/_a$/, '_b')) : id;
}
throw new Error(`unknown chess ${name}`);
}
/**
* Place a lineup on the stage's deployable tiles: melee operators on melee tiles closest to the enemy lane
* (row 9, towards the gate), ranged operators on high ground first, then the remaining tiles.
* `names`: ['name', 'name*' (elite)…] → PlayerBattleInput units (board coordinates).
*/
function lineup(stageId, names, uid0 = 1) {
const st = data.stages?.[stageId] ?? ds.getStage?.(stageId);
const dt = st?.deployTiles?.normal || { melee: [], rangedOnly: [] };
const melee = [...dt.melee].sort((a, b) => (a[0] - b[0]) || (b[1] - a[1]));
const ranged = [...(dt.rangedOnly || [])];
const taken = new Set();
const units = [];
let uid = uid0;
for (const raw of names) {
const golden = raw.endsWith('*');
const name = golden ? raw.slice(0, -1) : raw;
const chessId = chessByName(name, golden);
const rec = ds.rawChess(chessId);
const isMelee = rec?.position === 'MELEE';
const pool = isMelee ? melee : [...ranged, ...melee.slice().reverse()];
const tile = pool.find(([r, c]) => !taken.has(`${r},${c}`));
if (!tile) continue;
taken.add(`${tile[0]},${tile[1]}`);
units.push({ uid: uid++, kind: 'chess', chessId, row: tile[0], col: tile[1] });
}
return units;
}
function modsFor(modeId, round) {
const sc = config.modes?.[modeId]?.enemyScale?.[String(round)];
return { hpMul: sc?.hp ?? 1, atkMul: sc?.atk ?? 1, speedMul: sc?.speed ?? 1 };
}
const SCENARIOS = [
{
name: 'normal-m01', title: '常规作战 · 源石流发生装置 · R5', kind: 'normal', stageId: 'act2autochess_m01', modeId: 'mode_multi_normal', round: 5,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['角峰', '德克萨斯', '艾丝黛尔*', '隐现', '格雷伊', '赫默', '能天使']) }],
},
{
name: 'normal-m02', title: '常规作战 · 沼泽控制 · R8', kind: 'normal', stageId: 'act2autochess_m02', modeId: 'mode_multi_hard', round: 8,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m02', ['星熊', '银灰', '斯卡蒂', '莫斯提马', '白面鸮', '寒芒克洛丝', '阿罗玛', '远牙']) }],
},
{
name: 'normal-m03', title: '常规作战 · 排气格栅 · R11', kind: 'normal', stageId: 'act2autochess_m03', modeId: 'mode_multi_hard', round: 11,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m03', ['塞雷娅*', '山', '史尔特尔', '夕', '缇缇', '烛煌', '铃兰', '号角*']) }],
},
{
name: 'normal-m04', title: '常规作战 · 涨潮控制 · R6', kind: 'normal', stageId: 'act2autochess_m04', modeId: 'mode_multi_normal', round: 6,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m04', ['蛇屠箱', '忍冬', '断崖', '流星', '空弦', '调香师', '初雪']) }],
},
// late rounds with the official composition and counts (perf profiling, research 08 §2.7; 57 / 55 enemies)
{
name: 'late-m03-r12', title: '常规作战 · 排气格栅 · R12(官方波次)', kind: 'normal', stageId: 'act2autochess_m03', modeId: 'mode_multi_hard', round: 12,
wave: 'official', waveSeed: 3,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m03', ['塞雷娅*', '星熊*', '山*', '史尔特尔*', '夕*', '莫斯提马*', '铃兰*', '号角*']) }],
},
{
name: 'late-m01-r13', title: '常规作战 · 源石流发生装置 · R13(官方波次)', kind: 'normal', stageId: 'act2autochess_m01', modeId: 'mode_multi_hard', round: 13,
wave: 'official', waveSeed: 5,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['星熊*', '银灰*', '斯卡蒂*', '莫斯提马*', '白面鸮', '寒芒克洛丝*', '阿罗玛', '远牙*']) }],
},
{
name: 'unite-m01', title: '联防 · 源石流发生装置 · R9', kind: 'unite', stageId: 'act2autochess_m01', modeId: 'mode_multi_normal', round: 9,
wave: 'act1autochess_escaped_multi', timeLimit: 60,
players: () => [
{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['星熊', '德克萨斯', '能天使', '赫默', '格雷伊', '银灰']) },
{ playerId: 'P2', seat: 1, side: 'L', colOffset: 8, units: lineup('act2autochess_m01', ['塞雷娅', '拉普兰德', '莫斯提马', '白面鸮', '远牙', '缇缇'], 101) },
],
},
{
name: 'boss-m02', title: '最终攻势 · 卢西恩,“猩红血钻” · R14', kind: 'boss', stageId: 'act2autochess_m02', modeId: 'mode_multi_normal', round: 14,
wave: 'act2autochess_h07_05', timeLimit: 75, bossId: 'boss_5',
players: () => [
{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m02', ['塞雷娅*', '山*', '史尔特尔*', '夕*', '缇缇', '烛煌*', '铃兰', '异客']) },
{ playerId: 'P2', seat: 1, side: 'R', colOffset: 0, units: lineup('act2autochess_m02', ['余*', '耀骑士临光', '锏*', '蕾缪安*', '焰影苇草', '迷迭香*', '流明', '纯烬艾雅法拉'], 101) },
],
},
];
/** The match's own round composition (server/match/waves.js): spawns, routes, overrides, time limit, preview. */
function officialWave(sc) {
const gd = new GameData(data, sc.modeId);
const setup = setupMatchWaves(gd, createRng(sc.waveSeed ?? 1));
const w = buildNormalWave(gd, createRng((sc.waveSeed ?? 1) + sc.round), setup.factions, sc.round);
if (!w.templateId || !w.spawns.length) throw new Error(`no official wave for ${sc.name}`);
return { waveId: w.templateId, conv: { spawns: w.spawns, routes: w.routes, overrides: w.overrides, maxPlayTime: w.timeLimit }, preview: previewOf(w.spawns) };
}
function record(sc) {
const modeId = sc.modeId;
const round = sc.round;
const roundCfg = config.modes?.[modeId]?.rounds?.[String(round)];
let waveId, conv, preview = null;
if (sc.wave === 'official') ({ waveId, conv, preview } = officialWave(sc));
else {
waveId = sc.wave || roundCfg?.template;
const tpl = ds.getWave(waveId);
if (!tpl) throw new Error(`unknown wave ${waveId}`);
conv = spawnsFromTemplate(tpl, { mods: modsFor(modeId, round) });
}
let sharedBoss = null;
if (sc.kind === 'boss') {
const boss = data.bosses?.[sc.bossId];
const hp = Math.round((boss?.bloodPoint?.NORMAL ?? 400000) * 0.5);
sharedBoss = { hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
}
const timeLimit = sc.timeLimit ?? conv.maxPlayTime ?? roundCfg?.combatTimeLimit ?? 60;
const b = new Battle({
seed: SEED ^ hash(sc.name), kind: sc.kind, modeId, round, stageId: sc.stageId,
timeLimit: sc.kind === 'boss' ? Infinity : timeLimit, routes: conv.routes, spawns: conv.spawns,
players: sc.players(), sharedBoss, enemyOverrides: conv.overrides ?? {}, fieldId: sc.kind === 'normal' ? 'n:P1' : sc.kind === 'unite' ? 'u' : 'b1',
flags: { layerGainsEnabled: sc.kind === 'normal', dpInit: 10, dpPerSec: 1, dpMax: 99 }, logger: quiet,
});
const frames = [];
let field = null;
let ticks = 0;
const maxT = Math.min(MAX_SECONDS, sc.kind === 'boss' ? timeLimit : timeLimit + 1);
while (!b.finished) {
b.step();
ticks++;
if (!field) field = b.fieldMeta();
if (ticks % 3 === 0) {
frames.push({ snap: b.snapshot(), ev: b.drainEvents() });
}
if (b.time >= maxT) b.forceEnd(sc.kind === 'boss' ? 'forced' : 'timeout');
}
// final frame (end state); when the battle ended on a snapshot tick, only its trailing events are appended
const tail = { snap: b.snapshot(), ev: b.drainEvents() };
const last = frames[frames.length - 1];
if (!last || tail.snap.t > last.snap.t) frames.push(tail);
else last.ev.push(...tail.ev);
const r = b.result();
const counts = {};
for (const f of frames) for (const e of f.ev) counts[e[0]] = (counts[e[0]] || 0) + 1;
return {
version: 1, name: sc.name, title: sc.title, kind: sc.kind, stageId: sc.stageId, modeId, round, wave: waveId,
tick: 1 / 30, snapEvery: 3, duration: Math.round(b.time * 100) / 100, recordedAt: '2026-09-27',
field,
...(preview ? { preview } : {}),
frames,
result: {
reason: r.reason, time: Math.round(r.time * 10) / 10, killed: r.killed, total: r.total, bossHpLeft: r.bossHpLeft ?? null,
perPlayer: Object.fromEntries(Object.entries(r.perPlayer).map(([k, v]) => [k, { killed: v.killed, total: v.total, leaked: v.leaked.length, deaths: v.deaths }])),
events: counts, errors: b.errors?.length ?? 0,
},
};
}
function hash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
fs.mkdirSync(OUT, { recursive: true });
const index = [];
const prev = fs.existsSync(path.join(OUT, 'index.json')) ? JSON.parse(fs.readFileSync(path.join(OUT, 'index.json'), 'utf8')) : [];
for (const sc of SCENARIOS) {
if (ONLY && !ONLY.has(sc.name)) { const p = prev.find((x) => x.name === sc.name); if (p) index.push(p); continue; }
const t0 = performance.now();
const rec = record(sc);
const file = path.join(OUT, `${sc.name}.json`);
const json = JSON.stringify(rec);
fs.writeFileSync(file, json);
const maxUnits = Math.max(...rec.frames.map((f) => f.snap.units.length));
index.push({ name: sc.name, title: sc.title, kind: sc.kind, stageId: sc.stageId, duration: rec.duration, frames: rec.frames.length, maxUnits, file: `${sc.name}.json`, bytes: json.length, result: { reason: rec.result.reason, killed: rec.result.killed, total: rec.result.total } });
console.log(`${sc.name.padEnd(12)} ${rec.kind.padEnd(7)} ${rec.duration.toFixed(1).padStart(5)} s frames ${String(rec.frames.length).padStart(4)} units≤${String(maxUnits).padStart(3)} ${(json.length / 1024).toFixed(0).padStart(5)} KB ${rec.result.reason} ${rec.result.killed}/${rec.result.total} errors ${rec.result.errors} (${(performance.now() - t0).toFixed(0)} ms) events ${JSON.stringify(rec.result.events)}`);
}
// recordings made by other scenario lists (kept files) stay listed
for (const p of prev) if (p && !index.some((x) => x.name === p.name) && fs.existsSync(path.join(OUT, p.file || `${p.name}.json`))) index.push(p);
fs.writeFileSync(path.join(OUT, 'index.json'), JSON.stringify(index, null, 1));
console.log(`wrote ${index.length} recordings → ${path.relative(ROOT, OUT)}`);
+487
View File
@@ -0,0 +1,487 @@
#!/usr/bin/env node
// tools/setup.mjs — prepare a fresh clone in one command (docs/DEPLOY.md).
//
// node tools/setup.mjs [options]
//
// Steps (each one is skipped when already done, so re-running is cheap — the start scripts run it on every start):
// 1. Node.js ≥ 18 check (clear message + download link otherwise).
// 2. Dependencies: `npm ci` (falls back to `npm install`) when node_modules is missing or incomplete.
// 3. Client libraries in public/vendor (tools/vendor.mjs) when any is missing.
// 4. Game data (data/*.json, committed) present and parseable.
// 5. Art/audio (tools/fetch-assets.mjs, ~250 MB into public/assets, resumable, mirror fallback) when public/assets
// is missing or data/assets.json lists files that are not on disk. A failure is a warning: the game still runs
// with fallback visuals and the next run resumes.
// 6. Optional: official board/UI art from a locally installed Arknights client (Windows native install, CrossOver
// bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python
// venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a
// terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal.
//
// Options:
// --check report only, change nothing (exit 1 when something essential is missing)
// --no-assets skip the art/audio download
// --no-local skip the local-client detection and extraction
// --local extract from the local client without asking (re-extracts when already done)
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
// -y, --yes answer "yes" to every question
// --quiet fewer lines (used by scripts/launch.mjs)
// -h, --help
//
// Exit code: 0 = ready to `npm start` (optional parts may have been skipped), 1 = something essential is missing.
// Helpers are exported for tools/doctor.mjs and scripts/launch.mjs; main() only runs when executed directly.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MIN_NODE = 18;
export const IS_WIN = process.platform === 'win32';
export const IS_MAC = process.platform === 'darwin';
const NODE_URL = 'https://nodejs.org/zh-cn/download';
/** Data files the server expects (server/data.js DATA_FILES) + the emote catalogue used by the client. */
export const DATA_FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices',
'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'emotes'];
/** Runtime packages that must be installed (package.json dependencies). */
export const RUNTIME_PACKAGES = ['ws', 'pixi.js', 'pixi-spine', 'preact', 'htm'];
/** Vendor files the client cannot run without (tools/vendor.mjs; three.js is optional there). */
export const VENDOR_REQUIRED = ['pixi.min.js', 'pixi-spine.js', 'preact.module.js', 'hooks.module.js', 'htm.module.js'];
export const VENDOR_OPTIONAL = ['three.core.js', 'three.module.js'];
const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json');
export const VENV_DIR = path.join(ROOT, '.venv-extract');
const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py');
const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt');
const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json');
const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png');
const LOCAL_BOARD_TILES = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'tiles.json');
// ---------------------------------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------------------------------
const color = process.stdout.isTTY && !process.env.NO_COLOR;
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
export const c = { ok: paint('32'), warn: paint('33'), err: paint('31'), dim: paint('2'), bold: paint('1'), cyan: paint('36') };
export const mark = { ok: c.ok('✔'), warn: c.warn('!'), err: c.err('✘'), skip: c.dim('–') };
export const nodeMajor = () => Number(process.versions.node.split('.')[0]);
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
const mb = (n) => `${(n / 1048576).toFixed(0)} MB`;
/** Terminal display width (CJK / full-width characters take two columns). */
export const displayWidth = (s) => [...String(s)].reduce((n, ch) => n + (ch.codePointAt(0) >= 0x2e80 ? 2 : 1), 0);
export const padDisplay = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));
/** Run a command with inherited stdio. `.cmd` shims (npm) need a shell on Windows (Node ≥ 18.20 refuses them otherwise). */
function run(cmd, args, { cwd = ROOT, env, shell = false } = {}) {
const r = spawnSync(cmd, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env }, shell });
if (r.error) return { ok: false, code: -1, error: r.error };
return { ok: r.status === 0, code: r.status };
}
/** Run a command and capture its output (never throws). */
export function capture(cmd, args, { timeout = 15000, shell = false, env } = {}) {
try {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, shell, windowsHide: true, env: env ? { ...process.env, ...env } : process.env });
return { ok: !r.error && r.status === 0, status: r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
} catch (e) {
return { ok: false, status: -1, out: String(e?.message || e) };
}
}
function npmCommand() {
const execPath = process.env.npm_execpath; // set when started through `npm run …`
if (execPath && /npm-cli\.js$/i.test(execPath) && exists(execPath)) return { cmd: process.execPath, pre: [execPath], shell: false };
return { cmd: IS_WIN ? 'npm.cmd' : 'npm', pre: [], shell: IS_WIN };
}
function loadState() { return readJson(STATE_FILE) || {}; }
function saveState(patch) {
try {
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...loadState(), ...patch }, null, 1) + '\n');
} catch { /* best effort */ }
}
/** Ask a yes/no question on a TTY (default on Enter / timeout = `def`). Non-TTY → null (nobody to ask). */
async function ask(question, def, timeoutMs = 60000) {
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const hint = def ? '[Y/n]' : '[y/N]';
try {
return await new Promise((resolve) => {
const t = setTimeout(() => { process.stdout.write(c.dim(`\n (${timeoutMs / 1000} 秒无输入,按默认处理)\n`)); resolve(def); }, timeoutMs);
rl.question(` ${question} ${hint} `, (a) => {
clearTimeout(t);
const s = String(a || '').trim().toLowerCase();
resolve(s === '' ? def : /^(y|yes|是|好|1)$/.test(s));
});
});
} finally {
rl.close();
}
}
// ---------------------------------------------------------------------------------------------------
// Checks (exported for tools/doctor.mjs)
// ---------------------------------------------------------------------------------------------------
/** Node.js version check. */
export function checkNode() {
const major = nodeMajor();
return { ok: major >= MIN_NODE, version: process.versions.node, major, recommended: major >= 20 };
}
/** Runtime dependencies installed? */
export function checkDeps() {
const missing = RUNTIME_PACKAGES.filter((p) => !exists(path.join(ROOT, 'node_modules', ...p.split('/'), 'package.json')));
return { ok: missing.length === 0, missing, hasNodeModules: exists(path.join(ROOT, 'node_modules')) };
}
/** public/vendor files present? */
export function checkVendor() {
const dir = path.join(ROOT, 'public', 'vendor');
const missing = VENDOR_REQUIRED.filter((f) => !exists(path.join(dir, f)));
const optionalMissing = VENDOR_OPTIONAL.filter((f) => !exists(path.join(dir, f)));
return { ok: missing.length === 0, missing, optionalMissing };
}
/** data/*.json present and parseable? */
export function checkData() {
const missing = [];
const broken = [];
for (const name of DATA_FILES) {
const p = path.join(ROOT, 'data', `${name}.json`);
if (!exists(p)) { missing.push(name); continue; }
if (readJson(p) == null) broken.push(name);
}
return { ok: !missing.length && !broken.length, missing, broken };
}
/** Every '/assets/…' or '/fonts/…' URL of the manifest (same walk as test/assets.test.js). */
function manifestUrls(node, out = []) {
if (typeof node === 'string') { if (/^\/(assets|fonts)\//.test(node)) out.push(node); }
else if (Array.isArray(node)) for (const x of node) manifestUrls(x, out);
else if (node && typeof node === 'object') for (const x of Object.values(node)) manifestUrls(x, out);
return out;
}
/**
* Downloaded art/audio complete? Compares data/assets.json with public/.
* @returns {{ ok: boolean, present: boolean, manifest: boolean, total: number, missing: number, sample: string[], bytes: number }}
*/
export function checkAssets() {
const pub = path.join(ROOT, 'public');
const present = exists(path.join(pub, 'assets'));
const m = readJson(path.join(ROOT, 'data', 'assets.json'));
if (!m) return { ok: false, present, manifest: false, total: 0, missing: 0, sample: [], bytes: 0 };
const urls = [...new Set(manifestUrls(m))];
const missing = [];
for (const u of urls) {
let st = null;
try { st = fs.statSync(path.join(pub, ...u.split('/').filter(Boolean).map(decodeURIComponent))); } catch { /* missing */ }
if (!st || !st.size) missing.push(u);
}
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 };
}
/** Local-client art (optional): manifest entry count and whether the 3D board atlas is on disk. */
export function checkLocal() {
const m = readJson(LOCAL_MANIFEST);
const count = m && m.groups ? Object.values(m.groups).reduce((n, g) => n + Object.keys(g || {}).length, 0) : 0;
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
}
/**
* Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board
* renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure.
*/
function cropBoardTiles(log) {
const r = capture(process.execPath, [path.join(ROOT, 'tools', 'crop-board-atlas.mjs')], { timeout: 120000 });
if (!r.ok) log(c.warn(` 棋盘贴图裁切失败(node tools/crop-board-atlas.mjs):${r.out.split(/\r?\n/).slice(-3).join(' ')}`));
return r.ok && exists(LOCAL_BOARD_TILES);
}
// ---------------------------------------------------------------------------------------------------
// Local Arknights client detection
// ---------------------------------------------------------------------------------------------------
const AB_TAIL = ['Arknights_Data', 'StreamingAssets', 'AB', 'Windows'];
/** Candidate AssetBundle roots for this OS (existence not checked). */
export function clientCandidates() {
const home = os.homedir();
const out = [];
const add = (p, kind) => out.push({ path: p, kind });
if (IS_WIN) {
const drives = ['C', 'D', 'E', 'F', 'G', 'H'];
const bases = [
['Program Files', 'Hypergryph Launcher', 'games', 'Arknights'],
['Program Files (x86)', 'Hypergryph Launcher', 'games', 'Arknights'],
['Hypergryph Launcher', 'games', 'Arknights'],
['Games', 'Hypergryph Launcher', 'games', 'Arknights'],
['Program Files', 'Hypergryph', 'Arknights'],
['Arknights'],
];
for (const d of drives) for (const b of bases) add(path.win32.join(`${d}:\\`, ...b, ...AB_TAIL), 'Windows');
}
if (IS_MAC) {
const bottles = path.join(home, 'Library', 'Application Support', 'CrossOver', 'Bottles');
let names = [];
try { names = fs.readdirSync(bottles); } catch { /* no CrossOver */ }
names.sort((a, b) => (b === 'Arknights') - (a === 'Arknights'));
for (const n of names) {
for (const pf of ['Program Files', 'Program Files (x86)']) {
add(path.join(bottles, n, 'drive_c', pf, 'Hypergryph Launcher', 'games', 'Arknights', ...AB_TAIL), `CrossOver (${n})`);
}
}
add(path.join(home, 'Library', 'Containers', 'com.hypergryph.arknights', 'Data', 'Documents', 'Bundles'), 'PlayCover');
}
if (!IS_WIN) {
for (const prefix of [path.join(home, '.wine'), path.join(home, 'Games', 'arknights')]) {
add(path.join(prefix, 'drive_c', 'Program Files', 'Hypergryph Launcher', 'games', 'Arknights', ...AB_TAIL), 'Wine');
}
}
return out;
}
/** Does the directory look like an AssetBundle root holding the autochess bundles? */
export function inspectClientRoot(dir) {
if (!dir || !exists(dir)) return { exists: false, autochess: false };
const autochess = exists(path.join(dir, 'ui', 'autochess')) || exists(path.join(dir, 'arts', 'maps', 'map_autochess'));
return { exists: true, autochess };
}
/** First installed client (explicit dir first). */
export function findClient(explicit) {
const list = explicit ? [{ path: path.resolve(explicit), kind: '--game' }] : clientCandidates();
let partial = null;
for (const cand of list) {
const info = inspectClientRoot(cand.path);
if (info.exists && info.autochess) return { ...cand, ...info };
if (info.exists && !partial) partial = { ...cand, ...info };
}
return partial;
}
// ---------------------------------------------------------------------------------------------------
// Python (optional)
// ---------------------------------------------------------------------------------------------------
/** A usable Python ≥ 3.8 launcher: { cmd, args, version } or null. Windows Store stubs are skipped (no version). */
export function findPython() {
const cands = IS_WIN ? [['py', ['-3']], ['python', []], ['python3', []]] : [['python3', []], ['python', []]];
for (const [cmd, pre] of cands) {
const r = capture(cmd, [...pre, '--version'], { timeout: 10000 });
const m = /Python (\d+)\.(\d+)(?:\.(\d+))?/.exec(r.out);
if (!r.ok || !m) continue;
const major = Number(m[1]);
const minor = Number(m[2]);
if (major === 3 && minor >= 8) return { cmd, args: pre, version: `${m[1]}.${m[2]}${m[3] ? '.' + m[3] : ''}`, minor };
}
return null;
}
export function venvPython() {
return IS_WIN ? path.join(VENV_DIR, 'Scripts', 'python.exe') : path.join(VENV_DIR, 'bin', 'python');
}
const PY_ENV = { PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', PYTHONDONTWRITEBYTECODE: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1' };
/** Create .venv-extract and install tools/local-extract/requirements.txt (skipped when the imports already work). */
function ensureVenv(py, log) {
const vpy = venvPython();
if (!exists(vpy)) {
log(` 创建 Python 虚拟环境 ${path.relative(ROOT, VENV_DIR)} …`);
const r = run(py.cmd, [...py.args, '-m', 'venv', VENV_DIR], { env: PY_ENV });
if (!r.ok || !exists(vpy)) return { ok: false, why: 'python -m venv 失败(Debian/Ubuntu 需要 `sudo apt install python3-venv`)' };
}
if (capture(vpy, ['-c', 'import UnityPy, lz4, PIL'], { env: PY_ENV }).ok) return { ok: true, python: vpy };
log(' 安装 UnityPy / lz4 / Pillow(首次约 1–3 分钟)…');
const r = run(vpy, ['-m', 'pip', 'install', '-r', EXTRACT_REQ], { env: PY_ENV });
if (!r.ok || !capture(vpy, ['-c', 'import UnityPy, lz4, PIL'], { env: PY_ENV }).ok) {
return { ok: false, why: 'pip 安装依赖失败(若 Python 版本过新导致没有预编译包,请安装 Python 3.12 后删除 .venv-extract 重试)' };
}
return { ok: true, python: vpy };
}
// ---------------------------------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--check') o.check = true;
else if (a === '--no-assets') o.assets = false;
else if (a === '--no-local') o.local = 'no';
else if (a === '--local') o.local = 'force';
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; }
else if (a === '-y' || a === '--yes') o.yes = true;
else if (a === '--quiet' || a === '-q') o.quiet = true;
else if (a === '-h' || a === '--help') o.help = true;
else throw new Error(`未知参数 / unknown option: ${a}(--help 查看用法)`);
}
return o;
}
function helpText() {
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
const start = src.findIndex((l) => l.startsWith('// node tools/setup.mjs'));
const end = src.findIndex((l, i) => i > start && l.startsWith('// Exit code'));
return src.slice(start, end + 1).map((l) => l.replace(/^\/\/ ?/, '')).join('\n');
}
async function main() {
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; }
if (opts.help) { console.log(helpText()); return 0; }
const log = (s = '') => console.log(s);
const say = (s) => { if (!opts.quiet) log(s); };
const summary = [];
const add = (state, label, detail = '') => summary.push({ state, label, detail });
let fatal = false;
say(c.bold('\n卫戍协议:盟约 · setup') + c.dim(` (${ROOT})`));
// 1. Node
const node = checkNode();
if (!node.ok) {
log(`${mark.err} Node.js ${node.version} 太旧:需要 ${MIN_NODE} 或更高(推荐 22 LTS)。`);
log(` 下载:${NODE_URL}` + (IS_WIN ? ' 或在终端运行:winget install OpenJS.NodeJS.LTS' : IS_MAC ? ' 或:brew install node@22' : ''));
return 1;
}
add('ok', 'Node.js', `v${node.version}${node.recommended ? '' : '(可用;推荐升级到 22 LTS)'}`);
// 2. npm dependencies
let deps = checkDeps();
if (!deps.ok && !opts.check) {
log(`\n${c.cyan('▶')} 安装依赖(npm ci)…`);
const npm = npmCommand();
let r = run(npm.cmd, [...npm.pre, 'ci', '--no-audit', '--no-fund'], { shell: npm.shell });
if (!r.ok) {
log(c.warn(' npm ci 失败,改用 npm install …'));
r = run(npm.cmd, [...npm.pre, 'install', '--no-audit', '--no-fund'], { shell: npm.shell });
}
deps = checkDeps();
}
if (deps.ok) add('ok', '依赖 node_modules');
else { add('err', '依赖 node_modules', `缺少 ${deps.missing.join(', ')} → 运行 npm install`); fatal = true; }
// 3. vendor
let vendor = checkVendor();
if (!vendor.ok && deps.ok && !opts.check) {
log(`\n${c.cyan('▶')} 复制前端库到 public/vendor …`);
run(process.execPath, [path.join(ROOT, 'tools', 'vendor.mjs')]);
vendor = checkVendor();
}
if (vendor.ok) add('ok', '前端库 public/vendor', vendor.optionalMissing.length ? '(three.js 缺失:3D 棋盘回退为 2D)' : '');
else { add('err', '前端库 public/vendor', `缺少 ${vendor.missing.join(', ')} → 运行 node tools/vendor.mjs`); fatal = true; }
// 4. data
const data = checkData();
if (data.ok) add('ok', '游戏数据 data/*.json');
else {
add('err', '游戏数据 data/*.json', [data.missing.length && `缺少 ${data.missing.join(', ')}`, data.broken.length && `无法解析 ${data.broken.join(', ')}`].filter(Boolean).join(';') + ' → git checkout -- data/ 或 node tools/build-data.mjs');
if (data.missing.some((n) => n !== 'assets' && n !== 'emotes') || data.broken.length > 0) fatal = true;
}
// 5. assets
let assets = checkAssets();
if (!opts.assets) add(assets.ok ? 'ok' : 'skip', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件` : '已跳过(--no-assets)');
else if (!assets.ok && deps.ok && !opts.check) {
const what = !assets.present ? `首次下载约 ${assets.bytes ? mb(assets.bytes) : '250 MB'},可随时中断,重新运行会续传`
: `补全缺失的 ${assets.missing} 个文件`;
log(`\n${c.cyan('▶')} 下载美术与音频素材(${what})…`);
const r = run(process.execPath, [path.join(ROOT, 'tools', 'fetch-assets.mjs')]);
assets = checkAssets();
if (!r.ok && !assets.ok) log(c.warn(' 素材下载未完成(网络问题?)。游戏仍可运行(使用占位图),稍后重新运行 setup 即可续传。'));
}
if (opts.assets) {
if (assets.ok) add('ok', '美术/音频 public/assets', `${assets.total} 个文件`);
else if (!assets.present) add('warn', '美术/音频 public/assets', '未下载(游戏会用占位图)→ node tools/fetch-assets.mjs');
else add('warn', '美术/音频 public/assets', `缺 ${assets.missing}/${assets.total} 个文件 → 重新运行 setup 续传`);
}
// 6. local client (optional)
const local = checkLocal();
const state = loadState();
if (opts.local === 'no') add(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest ? `已提取 ${local.count} 项` : '已跳过(--no-local)');
else {
const client = findClient(opts.game);
const already = local.manifest && local.dirPresent;
if (!client) {
if (already && local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add(already ? 'ok' : 'skip', '本地客户端美术(可选)', already ? `已提取 ${local.count} 项` : (opts.game ? `找不到 ${opts.game}` : '未检测到本机明日方舟客户端(不影响游戏)'));
} else if (!client.autochess) {
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}(重新提取:--local)`);
} else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else {
const py = findPython();
if (!py) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,但没有 Python 3.8+(${IS_WIN ? 'winget install Python.Python.3.12' : 'https://www.python.org/downloads/'})`);
} else {
let go = opts.local === 'force' || opts.yes;
if (go || !state.localDeclined) {
log(`\n${c.cyan('▶')} 检测到本机明日方舟客户端(${client.kind}):\n ${c.dim(client.path)}`);
log(' 可以从中提取官方棋盘贴图、UI 图标、表情等(仅本机使用;通常 1–5 分钟,Python 依赖约 40 MB,装在项目内的 .venv-extract)。');
}
let unattended = false;
if (!go && !state.localDeclined) {
const answer = await ask('现在提取吗?', true);
if (answer === null) unattended = true; // no terminal (service, pipe): don't install Python packages unasked
else if (!answer) { saveState({ localDeclined: true }); log(c.dim(' 已记住选择,之后不再询问;需要时运行 node tools/setup.mjs --local')); }
go = answer === true;
}
if (!go) add('skip', '本地客户端美术(可选)', unattended ? '无终端,未询问、未提取(需要时运行 node tools/setup.mjs --local)' : '已跳过(需要时运行 node tools/setup.mjs --local)');
else {
const venv = ensureVenv(py, log);
if (!venv.ok) add('warn', '本地客户端美术(可选)', venv.why);
else {
log(` ${c.cyan('▶')} 提取中(python tools/local-extract/extract.py --game …)`);
const r = run(venv.python, [EXTRACT_PY, '--game', client.path], { env: PY_ENV });
if (r.ok) cropBoardTiles(log);
const after = checkLocal();
if (r.ok && after.manifest) { add('ok', '本地客户端美术(可选)', `提取 ${after.count} 项${after.board3d ? ',3D 棋盘可用' : ''}`); saveState({ localDeclined: false, localExtractedFrom: client.path }); }
else add('warn', '本地客户端美术(可选)', `提取未成功(退出码 ${r.code}),游戏不受影响;可稍后重试 node tools/setup.mjs --local`);
}
}
}
}
}
// summary
log(c.bold('\n── 准备情况 ──────────────────────────────'));
const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2;
for (const s of summary) log(`${mark[s.state]} ${padDisplay(s.label, width)}${s.detail ? c.dim(s.detail) : ''}`);
if (fatal) {
log(c.err('\n还不能启动:请先解决上面标 ✘ 的问题(node tools/doctor.mjs 可做更详细的诊断)。'));
return 1;
}
if (!opts.quiet) {
log(`\n${c.ok('可以开始了:')} npm start ${c.dim('(Windows 可直接双击 scripts\\start-windows.bat)')}`);
log(c.dim('浏览器打开 http://localhost:3000 ;同一局域网的朋友用终端里打印的 LAN 地址。'));
}
return 0;
}
function isMain() {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
}
if (isMain()) {
main().then((code) => { process.exitCode = code; }, (e) => {
console.error(`${mark.err} setup 出错:${e?.stack || e}`);
process.exitCode = 1;
});
}
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
// tools/simrun.mjs — run one battle headlessly and print a balancing summary (+ optional ASCII frames).
//
// Usage:
// node tools/simrun.mjs scenario.json [--ascii 5] [--json]
// node tools/simrun.mjs --mode mode_multi_normal --round 5 [--stage act2autochess_m01] [--boss boss_1]
// --lineup "chess_char_1_02_a@9,7 隐现@10,4 角峰*@9,5" [--seed 1] [--content full|generic|none]
// [--hpMul 1 --atkMul 1 --speedMul 1] [--wave act1autochess_05] [--time 60] [--ascii 5] [--json] [--events]
//
// Lineup entries: `<chessId|name>[*]@row,col` in board coordinates (rows 9–12, cols 2–10); `*` = elite (精锐 _b).
// --mode/--round pick the wave template, time limit and enemy multipliers from data/config.json (placeholder
// template enemies are NOT replaced by factions — that is the match's job; pass --wave to force a template).
// Scenario JSON: { stageId, kind, seed, wave|waveTemplate, round, modeId, timeLimit, mods, content, units:[…]|players:[…],
// enemies:[{ key, time, route, count, interval, mods, pos }], sharedBossHp }
// Output: reason, time, kills/total, leaks, per-unit damage / DPS / kills / heals / skill casts / deaths.
import fs from 'node:fs';
import { Battle } from '../server/sim/Battle.js';
import { getDefaultSource, spawnsFromTemplate } from '../server/sim/simdata.js';
import { getData } from '../server/data.js';
import { COLS } from '../server/sim/constants.js';
const argv = process.argv.slice(2);
const opt = {};
const pos = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
} else pos.push(a);
}
if (opt.help || (!pos.length && !opt.mode && !opt.wave && !opt.lineup)) {
console.log(fs.readFileSync(new URL(import.meta.url)).toString().split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n'));
process.exit(0);
}
const ds = getDefaultSource();
const data = getData({ log: { warn() {}, error() {}, info() {} } });
const config = data.config ?? {};
function findChess(q) {
let golden = false;
if (q.endsWith('*')) { golden = true; q = q.slice(0, -1); }
let id = null;
if (ds.rawChess(q)) id = q;
else {
for (const cid of ds.chessIds()) {
const r = ds.rawChess(cid);
if (r && r.name === q && !r.isGolden && r.visible !== false && !r.isHidden) { id = cid; break; }
}
if (!id) for (const cid of ds.chessIds()) { const r = ds.rawChess(cid); if (r && r.name === q && !r.isGolden) { id = cid; break; } }
}
if (!id) throw new Error(`unknown chess ${q}`);
if (golden) id = id.replace(/_a$/, '_b');
return id;
}
function parseLineup(s) {
return String(s).trim().split(/\s+/).filter(Boolean).map((tok, i) => {
const m = tok.match(/^(.+)@(\d+),(\d+)$/);
if (!m) throw new Error(`bad lineup entry ${tok} (want id@row,col)`);
return { uid: i + 1, kind: 'chess', chessId: findChess(m[1]), row: +m[2], col: +m[3] };
});
}
// ---- build scenario
let sc = {};
if (pos[0]) sc = JSON.parse(fs.readFileSync(pos[0], 'utf8'));
const modeId = opt.mode ?? sc.modeId ?? 'mode_multi_normal';
const round = Number(opt.round ?? sc.round ?? 1);
const mode = config.modes?.[modeId];
const roundCfg = mode?.rounds?.[String(round)];
let kind = opt.kind ?? sc.kind ?? (roundCfg?.isBoss ? 'boss' : roundCfg?.isHidden ? 'hidden' : 'normal');
let waveId = opt.wave ?? sc.wave ?? sc.waveTemplate ?? roundCfg?.template ?? null;
if (!waveId && roundCfg?.bossTemplates) {
const bossId = opt.boss ?? Object.keys(roundCfg.bossTemplates)[0];
waveId = roundCfg.bossTemplates[bossId];
if (/_s$/.test(waveId) === false && modeId.includes('single')) waveId = waveId + '_s';
}
const stageId = opt.stage ?? sc.stageId ?? mode?.stages?.[0] ?? 'act2autochess_m01';
const scale = mode?.enemyScale?.[String(round)] ?? null;
const mods = sc.mods ?? {
hpMul: Number(opt.hpMul ?? scale?.hp ?? 1), atkMul: Number(opt.atkMul ?? scale?.atk ?? 1), speedMul: Number(opt.speedMul ?? scale?.speed ?? 1),
};
let routes = [], spawns = [], timeLimit = null, overrides = null;
if (waveId) {
const tpl = ds.getWave(waveId);
if (!tpl) throw new Error(`unknown wave ${waveId}`);
const conv = spawnsFromTemplate(tpl, { mods });
routes = conv.routes; spawns = conv.spawns; timeLimit = conv.maxPlayTime; overrides = conv.overrides;
if (tpl.kind === 'boss' || tpl.kind === 'hidden') kind = tpl.kind;
}
for (const e of sc.enemies ?? []) {
spawns.push({ time: e.time ?? 0, enemyKey: e.key, routeIndex: typeof e.route === 'number' ? e.route : 0, route: typeof e.route === 'object' ? e.route : null, count: e.count ?? 1, interval: e.interval ?? 0, mods: e.mods ?? mods, pos: e.pos ?? null, tag: e.tag ?? null });
}
if (sc.routes) routes = sc.routes;
if (opt.time) timeLimit = Number(opt.time);
else if (sc.timeLimit != null) timeLimit = sc.timeLimit;
if (kind === 'boss' || kind === 'hidden') timeLimit = opt.time ? Number(opt.time) : (sc.timeLimit ?? 300);
const players = sc.players ?? [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: sc.units ?? parseLineup(opt.lineup ?? '') }];
const bossHp = Number(opt.bossHp ?? sc.sharedBossHp ?? 0);
const sharedBoss = bossHp > 0 ? { hp: bossHp, maxHp: bossHp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } } : null;
const seed = Number(opt.seed ?? sc.seed ?? 1);
const b = new Battle({
seed, kind, modeId, round, stageId, timeLimit: timeLimit ?? 60, routes, spawns, players, sharedBoss, enemyOverrides: overrides ?? {},
content: opt.content ?? sc.content ?? 'full', recordEvents: !!opt.events, verbose: !!opt.verbose, fieldId: 'sim',
});
// ---- ASCII frames
const asciiEvery = opt.ascii ? Number(opt.ascii === true ? 5 : opt.ascii) : 0;
const legend = new Map();
function frame() {
const R = b.rect;
const lines = [];
const occ = new Map();
for (const u of b.units) {
if (!u.alive || !u.deployed || u.hidden) continue;
const k = Math.round(u.y) * COLS + Math.round(u.x);
if (!occ.has(k)) occ.set(k, []);
occ.get(k).push(u);
}
for (let r = R.r1; r >= R.r0; r--) {
let line = String(r).padStart(2) + ' ';
for (let c = R.c0; c <= R.c1; c++) {
const us = occ.get(r * COLS + c) || [];
const ally = us.find((u) => u.side === 'ally');
const ens = us.filter((u) => u.side === 'enemy');
if (ally && ally.kind === 'device') line += ens.length ? '!' : '■';
else if (ally) {
if (!legend.has(ally.id)) legend.set(ally.id, String.fromCharCode(65 + legend.size % 26));
line += ens.length ? legend.get(ally.id).toLowerCase() : legend.get(ally.id);
} else if (ens.length) line += ens.length > 9 ? '+' : ens.some((e) => e.isFlying) ? '^' : String(ens.length);
else {
const t = b.grid.tile(r, c);
line += t.special === 'end' ? 'E' : t.special === 'start' ? 'S' : t.pass === 'ALL' ? '.' : t.height === 'HIGH' ? '#' : ',';
}
}
lines.push(line);
}
const alive = b.enemies.filter((e) => e.alive);
console.log(`--- t=${b.time.toFixed(1)}s enemies alive ${alive.length} killed ${b.killed}/${b.total} dp ${Math.floor(b.players[0]?.dp ?? 0)}`);
console.log(lines.join('\n'));
const ops = b.allyUnits.filter((u) => u.kind !== 'device' && legend.has(u.id));
console.log(ops.map((u) => `${legend.get(u.id)}=${u.name}${u.def.golden ? '*' : ''} ${u.alive ? Math.round(u.hpRatio * 100) + '%' : 'dead'}${u.skill?.active ? ' [S]' : ''}`).join(' '));
}
const t0 = performance.now();
let n = 0;
let nextFrame = 0;
while (!b.finished) {
if (asciiEvery && b.time + 1e-9 >= nextFrame) { frame(); nextFrame += asciiEvery; }
b.step();
n++;
if (opt.events && n % 3 === 0) for (const ev of b.drainEvents()) if (ev[0] !== 'dmg' && ev[0] !== 'atk' && ev[0] !== 'heal') console.log(b.time.toFixed(2), JSON.stringify(ev));
if ((kind === 'boss' || kind === 'hidden') && b.time >= timeLimit) b.forceEnd('forced');
}
const ms = performance.now() - t0;
if (asciiEvery) frame();
const r = b.result();
if (opt.json) {
console.log(JSON.stringify(r, null, 2));
process.exit(0);
}
console.log(`\n== ${kind} battle · stage ${stageId} · wave ${waveId ?? '(custom)'} · ${modeId} R${round} · seed ${seed}`);
console.log(` enemy mods: hp×${mods.hpMul ?? 1} atk×${mods.atkMul ?? 1} speed×${mods.speedMul ?? 1}`);
console.log(` result: ${r.reason} at ${r.time.toFixed(1)} s · killed ${r.killed}/${r.total} · ${n} ticks in ${ms.toFixed(1)} ms (${(ms / n * 1000).toFixed(1)} µs/tick)${b.errorCount ? ' · ERRORS ' + b.errorCount : ''}`);
if (r.bossHpLeft != null) console.log(` boss pool left: ${Math.round(r.bossHpLeft)}`);
for (const [pid, pp] of Object.entries(r.perPlayer)) {
const leaks = {};
for (const l of pp.leaked) leaks[l.enemyKey] = (leaks[l.enemyKey] ?? 0) + 1;
console.log(`\n player ${pid}: killed ${pp.killed}/${pp.total} · leaked ${pp.leaked.length}${pp.leaked.length ? ' ' + JSON.stringify(leaks) : ''} · perfect ${pp.perfect} · deaths ${pp.deaths} · coins ${pp.coins}`);
console.log(` damage ${Math.round(pp.damageDealt)} · healing ${Math.round(pp.healingDone)} · boss ${Math.round(pp.bossDamage)}${Object.keys(pp.layerGains).length ? ' · layers ' + JSON.stringify(pp.layerGains) : ''}`);
const rows = pp.unitStats.filter((u) => u.kind === 'op' || u.dmg > 0 || u.heal > 0);
console.log(' ' + ['unit'.padEnd(18), 'dmg'.padStart(8), 'dps'.padStart(7), 'kills'.padStart(6), 'heal'.padStart(7), 'taken'.padStart(7), 'atks'.padStart(5), 'casts'.padStart(6)].join(' '));
for (const u of rows) {
const unit = b.unitById(u.id);
const casts = unit?.skill?.activations ?? 0;
console.log(' ' + [(u.name + (unit?.def?.golden ? '*' : '')).slice(0, 16).padEnd(18), String(u.dmg).padStart(8), (u.dmg / Math.max(1, r.time)).toFixed(0).padStart(7), String(u.kills).padStart(6), String(u.heal).padStart(7), String(u.taken).padStart(7), String(u.attacks).padStart(5), String(casts).padStart(6)].join(' '));
}
}
if (b.errors.length) { console.log('\n errors:'); for (const e of b.errors.slice(0, 10)) console.log(' - ' + e.label + ' ' + e.who + ': ' + e.message); }
+59
View File
@@ -0,0 +1,59 @@
// Copies client vendor libraries from node_modules into public/vendor (run by `npm run vendor` / postinstall).
//
// three.js (the official 3D board scene, DESIGN §15) ships as two ES modules: three.module.js imports
// './three.core.js', so both are copied side by side and the renderer imports '/vendor/three.module.js' directly
// (no import map, no CDN — LAN play works offline). Optional packages that are not installed are skipped with a
// warning instead of failing the install.
// Preact's hooks build imports the bare specifier "preact"; it is rewritten to the sibling './preact.module.js' (the
// same URL the import map resolves "preact" to, so one Preact instance) — module graphs then load without import-map
// support (Safari < 16.4).
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const out = join(root, 'public', 'vendor');
/** Bare specifiers inside vendored ES modules → their sibling vendored file. */
export const VENDOR_REWRITES = Object.freeze({ 'hooks.module.js': { preact: './preact.module.js' } });
/**
* Rewrite `from"x"` / `from 'x'` / `import("x")` of the mapped bare specifiers in a vendored module source.
* @param {string} src
* @param {Record<string, string>} map
*/
export function rewriteBare(src, map) {
let res = src;
for (const [bare, rel] of Object.entries(map)) {
const esc = bare.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
res = res.replace(new RegExp(`(\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\bimport\\s*)(["'])${esc}\\2`, 'g'), (_, pre, q) => `${pre}${q}${rel}${q}`);
}
return res;
}
/** [source under the repo root, file name in public/vendor, optional] */
export const VENDOR_FILES = Object.freeze([
['node_modules/pixi.js/dist/pixi.min.js', 'pixi.min.js'],
['node_modules/pixi-spine/dist/pixi-spine.js', 'pixi-spine.js'],
['node_modules/preact/dist/preact.module.js', 'preact.module.js'],
['node_modules/preact/hooks/dist/hooks.module.js', 'hooks.module.js'],
['node_modules/htm/dist/htm.module.js', 'htm.module.js'],
['node_modules/three/build/three.core.js', 'three.core.js', true],
['node_modules/three/build/three.module.js', 'three.module.js', true],
]);
export function vendor({ log = console.log, warn = console.warn } = {}) {
mkdirSync(out, { recursive: true });
const done = [];
for (const [src, dst, optional] of VENDOR_FILES) {
const from = join(root, src);
if (optional && !existsSync(from)) { warn(`vendor: ${dst} skipped (${src} not installed; the 3D board falls back to 2D)`); continue; }
if (VENDOR_REWRITES[dst]) writeFileSync(join(out, dst), rewriteBare(readFileSync(from, 'utf8'), VENDOR_REWRITES[dst]));
else copyFileSync(from, join(out, dst));
done.push(dst);
log('vendor:', dst);
}
return done;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) vendor();