卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -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];
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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; });
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+240
@@ -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; });
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
UnityPy>=1.25
|
||||
lz4>=4
|
||||
Pillow>=10
|
||||
@@ -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;
|
||||
@@ -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)}`);
|
||||
Executable
+487
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user