Files
ark-stronghold-protocol/tools/assets/plan.mjs
T
sganggs a0a7660287 feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand:
- #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃
  chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝
  chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines
  are identical to #109's tier1/2/3/4/6 diffs.
- #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes
  it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the
  skill's own mods), and W5D's 冲锋号令 DP start effect is kept.
- #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty
  name (the Chinese text is the PR's), plus its English line in public/i18n/en.json.
- data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and
  bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/
  file changed.
- docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are
  always on"), SIM §7.4 (both generic passive cases).
- Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js /
  snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js /
  plan.mjs, #74 plan.mjs / docs.

Where the PRs met feedback5 work:
- 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a
  #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this
  change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so.
- Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test
  (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the
  generic:passive buff).

Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and
all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed
and fields the unit that explains it.
- #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low,
  bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low,
  bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1,
  琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype).
- #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退):
  roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high,
  bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun.
- #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3,
  coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted.
- #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low,
  bond-visiShip-high.
- #66 + #160: roster-008, roster-027.
- #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06,
  coop2-HARD-4, coop2-NORMAL-14-standins.
- all three: roster-010, roster-012, roster-031.
69 scenarios. Every other digest is unchanged.
2026-10-05 23:59:05 +08:00

587 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
// 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 自选 owned-6★ picks of data/backups.json (`extraOperators`:
// research 07 does not list them — their URLs follow its patterns) and their
// summons, the module type icons of every module the data offers
// (`moduleTypes`), 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 (the 36 battle
// emotes and the 19 玩法说明 pages included: UI_EXTRAS), 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';
import { literal } from './manifest.mjs';
import { EMOTE_CATALOG } from '../../shared/constants.js';
/**
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
* optional `spineLocal` overlay, which the client draws when data/local-assets.json lists its files (user feedback
* after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 were drawn as the plain 源石虫 everywhere).
*
* Why no dump carries them: isHarryh/Ark-Models *indexes* `1305_mhslim` / `1305_mhslim_2` but with an EMPTY
* `assetList` — registered, never uploaded — so `arkModel()` returns nothing for them and the alias chain below falls
* back to `enemy_1007_slime`: a different enemy rather than a variant of it, which is why the renderer tints it
* (render/units.js ALIAS_TINT). The *mobile* build does ship their own model
* (`enemy_spine/<enemyId>/<enemyId>.{skel,atlas,png}`), but the only public mirror of that build is a community wiki,
* not a GitHub dump, so downloading from it would add a source the project deliberately does not use (docs/ASSETS.md
* "Enemy aliases"). The tinted alias therefore stays the web model until a GitHub dump carries these two.
*/
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'];
/**
* The 19 official 玩法说明 (tutorial) pages, in the reading order of public/js/ui/guide.js GUIDE_CHAPTERS (a test keeps
* the two identical): 基础规则 home 1–9, 调度手册 shop 1–6, 进阶图鉴 handbook 1–4.
*/
export const GUIDE_PAGES = Object.freeze([
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `autochess_home_${i}`),
...[1, 2, 3, 4, 5, 6].map((i) => `autochess_shop_${i}`),
...[1, 2, 3, 4].map((i) => `autochess_handbook_${i}`),
]);
/**
* Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn].
* The last two blocks are art that used to come from the local client only (tools/local-extract, DESIGN §13) and that
* the mirror carries too (GitHub issue #42: a server without the client showed default emote icons): the 36 battle
* emotes (shared/constants.js EMOTE_CATALOG; the bundle ui/emoticon/theme/[uc]<themeId>.ab → its icon/<picId>.png) and
* the 19 玩法说明 pages (arts/guidebookpages/[pack]autochess.ab, 1024² like the local copies: displayed at 16:9). Their
* manifest keys are the data/local-assets.json group and name — ui['emoticon/<dir>/<picId>'], ui['guide/<key>'] — so
* the client looks both up by the same names (public/js/data.js artUrls: the local file first, then this copy).
*/
export 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`]);
for (const e of EMOTE_CATALOG) L.push([`emoticon/${e.dir}`, e.picId, `ui/emoticon/theme/[uc]${e.themeId}/icon/${e.picId}.png`]);
for (const k of GUIDE_PAGES) L.push(['guide', k, `arts/guidebookpages/[pack]autochess/${k}.png`]);
return Object.freeze(L.map((x) => Object.freeze(x)));
})();
/** 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)));
}
/**
* A unit's SFX roles (pickUnitSfx) → { roles: { attack?, hit?, die?, born? } sound leaves, mix: { [role]: { p?, vol? } }
* | null } — the official play chance / volume of each role's bank (audio.mjs bankMix; community report #30: 猎狗's
* attack bank is 80 % silence). The caller stores `mix` last in the unit's entry (sfx.units[id].mix).
*/
function unitSounds(audio, sfx) {
const roles = {};
let mix = null;
for (const r of ['attack', 'hit', 'die', 'born']) {
if (!sfx[r]) continue;
roles[r] = soundLeaf(sfx[r]);
const m = typeof audio.mixOf === 'function' ? audio.mixOf(sfx[r]) : null;
if (m) (mix || (mix = {}))[r] = m;
}
return { roles, mix };
}
/** 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);
}
}
/**
* A research-07-shaped operator record of a character research 07 does not list (the 自选 owned-6★ picks): every URL
* from the patterns of 07-assets.json `meta.patterns` — avatar / portrait (E0–E1 and E2), the default-skin battle Spine
* Front / Back, the skill icons, the sub-profession icon. No expected byte counts (the downloader validates the files);
* a file the mirrors lack is a miss the client falls back from (the E2 art to the E0–E1 one, a missing Back to Front).
* @param {string} id charId
* @param {{ subProfessionId?: string|null, nationId?: string|null, skills?: Array<{ index: number, skillId: string, iconId?: string|null }> }} x
*/
export function patternOperator(id, x = {}) {
const sp = (side) => {
const b = `${RAW.fexli}spine/${id}/${id}/${side}/${id}`;
return { skel: `${b}.skel`, atlas: `${b}.atlas`, png: `${b}.png` };
};
return {
name: x.name ?? null, subProfessionId: x.subProfessionId ?? null, nationId: x.nationId ?? null, chess: [],
avatar: { e0e1: { url: `${RAW.yuanyan}avatar/${id}.png` }, e2: { url: `${RAW.yuanyan}avatar/${id}_2.png` } },
portrait: { e0e1: { url: `${RAW.yuanyan}portrait/${id}_1.png` }, e2: { url: `${RAW.yuanyan}portrait/${id}_2.png` } },
skills: (x.skills || []).map((s) => ({
index: s.index, skillId: s.skillId, iconId: s.iconId || s.skillId,
icon: { url: `${RAW.yuanyan}skill/skill_icon_${encodeURIComponent(s.iconId || s.skillId)}.png` },
})),
battleSpine: { front: sp('Front'), back: sp('Back'), note: null },
subProfessionIcon: x.subProfessionId ? joinUrl(RAW.aa2, `arts/ui/subprofessionicon/sub_${x.subProfessionId}_icon.png`) : null,
};
}
// ---------------------------------------------------------------------------
// 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)
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
* operators from patternOperator
* @param {string[]} [p.moduleTypes] module type icon ids (`typeIcon`, e.g. 'sol-x') → manifest `modules[typeIcon]`, the
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
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 known = assets07?.operators || {};
const extraOps = {};
for (const [id, x] of Object.entries(extraOperators || {})) if (/^char_\d+_[a-z0-9]+$/i.test(id) && !known[id]) extraOps[id] = patternOperator(id, x);
const operators = { ...known, ...extraOps };
const charIds = Object.keys(operators).sort();
for (const id of charIds) {
const o = 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; normal-mode attack / impact sounds only (a ranged operator's own
// projectile banks projectile_chr_<name> as fallbacks — user playtest #4 item 6, audio.mjs pickUnitSfx)
const short = id.replace(/^char_\d+_/, '');
const sfx = pickUnitSfx(audio.unitBanks.get(id), { operator: true, projectile: {
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const { roles: u, mix } = unitSounds(audio, sfx);
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 (mix) u.mix = mix;
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 { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
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;
// the official model from the local client, drawn instead of `spine` when the extraction is installed (optional)
const loc = localEnemySpines && Object.hasOwn(localEnemySpines, id) ? localEnemySpines[id] : null;
if (loc && typeof loc === 'object') {
e.spineLocal = literal({ group: `spine/enemy/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
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 { roles: u, mix } = unitSounds(audio, pickUnitSfx(banks));
if (mix) u.mix = mix;
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(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(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;
};
// 联防 BGM: the official 联防 levels (`escaped_single` / `escaped_multi`) declare `bgmEvent = corrosion` — a
// 卡西米尔 act13d5d0 battle track — so the rescue phase does NOT reuse the 作战's track. Kept out of `bgm` when the
// bank is missing (the client then falls back to `bgm.combat`; public/js/audio.js resolveBgm), so the manifest stays
// valid for an older audio_data.
const unite = flatBgm(bgmLeaf('battle.ON_GAME_READY.corrosion'));
// 开战 BGM: the two official battle tracks of the mode's own 卡西米尔 act (music_act13side_1 骑士之日 /
// music_act13side_2 无畏者, 塞壬唱片). The client does not draw them: the round decides (audio.js combatTrackFor —
// 无畏者 for rounds 1–7, 骑士之日 from round 8 on), so index 0 must stay `bat_kazimierz2_1` and index 1
// `bat_kazimierz2_2` (test/ui/audio.test.js pins both the order and the round table).
// Kept out of `bgm` below when the index has neither bank, so the manifest stays valid for an older audio_data.
const combatAlts = ['battle.ON_GAME_READY.bat_kazimierz2_1', 'battle.ON_GAME_READY.bat_kazimierz2_2']
.map((n) => flatBgm(bgmLeaf(n))).filter(Boolean);
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')),
...(combatAlts.length ? { combatAlts } : {}),
...(unite ? { unite } : {}),
};
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`); }
// --- module type icons ----------------------------------------------------
const modules = {};
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png)
modules[t] = leaf(alt(`module/${safeName(t)}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, 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 };
}