Files

835 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tests for the asset pipeline (tools/assets/*) and the generated manifest
// data/assets.json. The pure helpers (animation-role resolver, atlas
// normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the
// on-disk checks run only when public/assets exists (it is git-ignored and
// produced by `npm run assets`); the optional local-client enemy and token models
// (spineLocal, tools/local-extract) only when data/local-assets.json lists them.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
import { resolveRoles, roleAnimationNames } from '../tools/assets/anim-roles.mjs';
import { normalizeAtlas, atlasInfo, parseAtlas } from '../tools/assets/atlas.mjs';
import { pngSize, isCompletePng, isMp3, validate } from '../tools/assets/formats.mjs';
import { encodeWoff2, decodeWoff2Tables, readSfnt, uintBase128 } from '../tools/assets/woff2.mjs';
import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs';
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
import { collectEnemyIds, skillIndicesByChar, buildPlan, GUIDE_PAGES, UI_EXTRAS } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { spineEntry } from '../public/js/assets.js';
import { EMOTE_CATALOG, emoteArtGroup } from '../shared/constants.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC = join(ROOT, 'public');
const ASSETS = join(PUBLIC, 'assets');
const MANIFEST = join(ROOT, 'data', 'assets.json');
const readJson = (rel) => JSON.parse(readFileSync(join(ROOT, rel), 'utf8'));
// ---------------------------------------------------------------------------
describe('animation-role resolver (research 07 §5.4)', () => {
test('simple Attack model (char_103_angel)', () => {
const r = resolveRoles(['Attack', 'Default', 'Die', 'Idle', 'Start']);
assert.equal(r.idle, 'Idle');
assert.equal(r.deploy, 'Start');
assert.deepEqual(r.attack, { begin: null, loop: 'Attack', end: null });
assert.equal(r.die, 'Die');
assert.equal(r.skill.via, 'attack');
assert.equal(r.skill.loop, 'Attack');
assert.equal(r.move, null);
assert.equal(r.stun, null);
});
test('Attack_Start/Loop/End + plain Skill (char_102_texas, default skill index 1)', () => {
const r = resolveRoles(['Attack_End', 'Attack_Loop', 'Attack_Start', 'Default', 'Die', 'Idle', 'Skill', 'Start'], { skillIndices: [1] });
assert.deepEqual(r.attack, { begin: 'Attack_Start', loop: 'Attack_Loop', end: 'Attack_End' });
assert.deepEqual(r.skill, { begin: null, loop: 'Skill', end: null, index: 1, idle: null });
});
test('Back model without Die/Skill', () => {
const r = resolveRoles(['Attack_End', 'Attack_Loop', 'Attack_Start', 'Default', 'Idle', 'Start']);
assert.equal(r.die, null);
assert.equal(r.skill.via, 'attack');
});
test('Combat + Combat_Down (char_172_svrash)', () => {
const r = resolveRoles(['Attack', 'Combat', 'Combat_Down', 'Default', 'Die', 'Idle', 'Skill', 'Start']);
assert.equal(r.attack.loop, 'Attack');
assert.deepEqual(r.attackDown, { begin: null, loop: 'Combat_Down', end: null, via: 'combat' });
});
test('numbered skills with Begin/Idle/Loop (char_350_surtr)', () => {
const names = ['Attack', 'Die', 'Idle', 'Skill_2', 'Skill_2_Down', 'Skill_3_Begin', 'Skill_3_Idle', 'Skill_3_Loop', 'Start'];
const s3 = resolveRoles(names, { skillIndices: [2] }).skill;
assert.deepEqual(s3, { begin: 'Skill_3_Begin', loop: 'Skill_3_Loop', end: null, index: 2, idle: 'Skill_3_Idle' });
const s2 = resolveRoles(names, { skillIndices: [1] }).skill;
assert.deepEqual(s2, { begin: null, loop: 'Skill_2', end: null, index: 1, idle: null });
const both = resolveRoles(names, { skillIndices: [2, 1] });
assert.equal(both.skill.index, 2);
assert.deepEqual(Object.keys(both.skills).sort(), ['1', '2']);
});
test('SkillN without underscore (char_2015_dusk)', () => {
const r = resolveRoles(['Attack', 'Die', 'Idle', 'Skill2_Begin', 'Skill2_End', 'Skill2_Loop', 'Skill3_Attack', 'Start'], { skillIndices: [1] });
assert.deepEqual(r.skill, { begin: 'Skill2_Begin', loop: 'Skill2_Loop', end: 'Skill2_End', index: 1, idle: null });
});
// PR #275 (@xcdoge): an enemy's manifest only ever had skill index 0 (plan.mjs passes [0]), so a multi-skill boss could
// show one cast clip — 盐风主教昆图斯 has Skill_01..04. With `numberedSkills` (spine.mjs: enemy models) every numbered
// skill clip is resolved after the caller's indices, whose first stays the primary.
test('numbered skill clips of an enemy are all resolved (a multi-skill boss), the primary stays the caller’s first', () => {
const names = ['Attack', 'Default', 'Die', 'Idle', 'Skill_01', 'Skill_02', 'Skill_03', 'Skill_04'];
assert.equal(resolveRoles(names, { skillIndices: [0] }).skills, undefined, 'without the option: index 0 only (operators)');
const r = resolveRoles(names, { skillIndices: [0], numberedSkills: true });
assert.deepEqual(Object.keys(r.skills).sort(), ['0', '1', '2', '3']);
assert.equal(r.skills['1'].loop, 'Skill_02');
assert.equal(r.skills['3'].loop, 'Skill_04');
assert.equal(r.skill.loop, 'Skill_01', 'the first index stays the primary clip');
const first = resolveRoles(names, { skillIndices: [2], numberedSkills: true });
assert.equal(first.skill.index, 2, 'a caller-declared primary still wins');
assert.equal(first.skill.loop, 'Skill_03');
// 自在: Skill_01 / Skill_01_02 are one slot, Skill_02_Begin / Loop / End the next
const xi = resolveRoles(['Attack_01', 'Die', 'Idle', 'Skill_01', 'Skill_01_02', 'Skill_02_Begin', 'Skill_02_End', 'Skill_02_Loop'], { numberedSkills: true });
assert.deepEqual(xi.skills['1'], { begin: 'Skill_02_Begin', loop: 'Skill_02_Loop', end: 'Skill_02_End', index: 1, idle: null });
// phase-style skill clips (no index in the name) stay a single primary skill
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Skill_Begin', 'Skill_Loop', 'Skill_End'], { numberedSkills: true }).skills, undefined);
});
// PR #275: the stun family is spelled Stun / Stun_End, Stun_1 / Stun_2 (巨大的丑东西: first form, second form) and
// Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤); without a role a stunned enemy freezes its clip.
test('stun clips: numbered Stun_N and the Dizzy_* family are recognised', () => {
const mc = resolveRoles(['Attack_1', 'Die', 'Idle_1', 'Idle_2', 'Move_1', 'Move_2', 'Stun_1', 'Stun_2'], {});
assert.deepEqual(mc.stun, { begin: null, loop: 'Stun_1', end: null });
const dz = resolveRoles(['Attack', 'Die', 'Idle_B', 'Move', 'Dizzy_Begin', 'Dizzy_Loop', 'Dizzy_End', 'Dizzy_Die'], {});
assert.deepEqual(dz.stun, { begin: 'Dizzy_Begin', loop: 'Dizzy_Loop', end: 'Dizzy_End' });
assert.deepEqual(resolveRoles(['Attack', 'Die', 'Idle', 'Stun', 'Stun_End'], {}).stun, { begin: null, loop: 'Stun', end: 'Stun_End' });
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).stun, null);
});
// PR #275: a model's own Run cycle is a role of its own (猎狗pro: Move_Loop 0.80 s, Run_Loop 0.53 s); the move choice
// is unchanged, the renderer switches to it for a fast enemy.
test('the model’s own Run cycle becomes a separate run role', () => {
const names = ['Attack', 'Default', 'Die', 'Idle', 'Move_Begin', 'Move_End', 'Move_Loop', 'Run_Begin', 'Run_End', 'Run_Loop'];
const r = resolveRoles(names, {});
assert.deepEqual(r.move, { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' }, 'the move role is unchanged');
assert.deepEqual(r.run, { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' });
assert.ok(roleAnimationNames(r).includes('Run_Loop'), 'the manifest writer validates the Run clips');
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).run, undefined, 'no Run cycle, no role');
});
// The manifest (tools/fetch-assets.mjs --offline with this resolver): every model whose skeleton has a stun / Run clip
// has the role, and every enemy with numbered skill clips has one clip per slot.
describe('data/assets.json roles (PR #275)', () => {
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
const models = [];
for (const [id, e] of Object.entries(manifest.enemies || {})) if (e.spine) models.push([`enemies.${id}`, e.spine]);
for (const [id, e] of Object.entries(manifest.tokens || {})) if (e.spine) models.push([`tokens.${id}`, e.spine]);
for (const [id, e] of Object.entries(manifest.chars || {})) if (e.spine?.front) models.push([`chars.${id}`, e.spine.front]);
const walk = (re, ok) => {
const bad = [];
let seen = 0;
for (const [id, sp] of models) {
if (!sp.animations || !Object.keys(sp.animations).some((n) => re.test(n))) continue;
seen++;
if (!ok(sp.anims || {}, id)) bad.push(id);
}
return { bad, seen };
};
test('every model with a stun clip has a stun role', () => {
const { bad, seen } = walk(/^(stun|dizzy)/i, (a) => !!a.stun);
assert.ok(seen >= 6, `models with a stun clip (${seen})`);
assert.deepEqual(bad, []);
});
test('every model with a Run clip has a run role', () => {
const { bad, seen } = walk(/^run/i, (a) => !!a.run);
assert.ok(seen >= 3, `models with a Run clip (${seen})`);
assert.deepEqual(bad, []);
});
test('every enemy with numbered skill clips has a clip per slot (盐风主教昆图斯: Skill_01..04)', () => {
const { bad, seen } = walk(/^Skill_?0*[1-9](?:$|_)/i, (a, id) => !id.startsWith('enemies.') || !!a.skills);
assert.ok(seen >= 10, `models with numbered skill clips (${seen})`);
assert.deepEqual(bad, []);
const q = manifest.enemies.enemy_1521_dslily.spine.anims;
assert.deepEqual(Object.values(q.skills).map((c) => c.loop), ['Skill_01', 'Skill_02', 'Skill_03', 'Skill_04']);
assert.equal(q.skill.loop, 'Skill_01', 'the primary clip is unchanged');
// operators keep their pool's indices (宴's fix, PREFAB_SPINE_ROLES / DESIGN §25.22.9, stays as it was)
const utage = manifest.chars.char_337_utage.spine.front.anims;
assert.deepEqual(Object.keys(utage.skills).sort(), ['0', '1']);
assert.equal(utage.skills['0'].loop, 'Skill_Loop');
assert.equal(utage.skill.loop, 'Attack');
});
});
test('pure supporter without Attack (char_4134_cetsyr)', () => {
const r = resolveRoles(['Die', 'Idle', 'Skill_1_Begin', 'Skill_1_End', 'Skill_1_Loop', 'Skill_2_Begin', 'Skill_2_Loop', 'Start', 'Stun', 'Stun_Begin'], { skillIndices: [1] });
assert.deepEqual(r.attack, { begin: null, loop: 'Skill_1_Loop', end: null, via: 'skill' });
assert.equal(r.skill.loop, 'Skill_2_Loop');
assert.deepEqual(r.stun, { begin: 'Stun_Begin', loop: 'Stun', end: null });
});
test('enemy move variants', () => {
const slime = resolveRoles(['Attack', 'Default', 'Die', 'Idle', 'Move_Begin', 'Move_End', 'Move_Loop']);
assert.deepEqual(slime.move, { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' });
const crowns = resolveRoles(['Appear', 'Attack', 'Die', 'Disappear', 'Idle', 'Move']);
assert.deepEqual(crowns.move, { begin: null, loop: 'Move', end: null });
const runner = resolveRoles(['Attack', 'Die', 'Idle', 'Run_Begin', 'Run_End', 'Run_Loop']);
assert.deepEqual(runner.move, { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' });
});
test('boss with numbered attacks and Skill_01 (enemy_1517_xi)', () => {
const r = resolveRoles(['Attack_01', 'Attack_02', 'Die', 'Idle', 'Move', 'Revive_01', 'Skill_01', 'Skill_01_02', 'Skill_02_Begin', 'Skill_02_End', 'Skill_02_Loop']);
assert.deepEqual(r.attack, { begin: null, loop: 'Attack_01', end: null, via: 'attackAny' });
assert.equal(r.skill.loop, 'Skill_01');
assert.equal(r.move.loop, 'Move');
});
test('fallbacks: Default idle, idle-as-attack, case-insensitive, empty', () => {
assert.equal(resolveRoles(['Default', 'Die']).idle, 'Default');
const onlyIdle = resolveRoles(['Idle']);
assert.deepEqual(onlyIdle.attack, { begin: null, loop: 'Idle', end: null, via: 'idle' });
assert.equal(onlyIdle.deploy, 'Idle');
const lower = resolveRoles(['idle', 'attack', 'die']);
assert.equal(lower.idle, 'idle');
assert.equal(lower.attack.loop, 'attack');
const empty = resolveRoles([]);
assert.equal(empty.idle, null);
assert.equal(empty.attack, null);
assert.equal(empty.skill, null);
assert.equal(resolveRoles(null).idle, null);
});
test('Attack_Begin + numbered attacks + Attack_End (char_1045_svash2)', () => {
const names = ['Attack_A', 'Attack_B', 'Attack_Begin', 'Attack_C', 'Attack_End', 'Default', 'Die', 'Idle', 'Skill_1_A', 'Skill_2', 'Start'];
assert.deepEqual(resolveRoles(names).attack, { begin: 'Attack_Begin', loop: 'Attack_A', end: 'Attack_End', via: 'attackAny' });
// begin-only models still play the begin clip as the attack
assert.deepEqual(resolveRoles(['Attack_Begin', 'Attack_End', 'Idle']).attack, { begin: null, loop: 'Attack_Begin', end: 'Attack_End' });
});
test('animated idle preferred over a 0 s pose when durations are known (enemy_9014_acstma)', () => {
const names = ['Attack', 'Default', 'Die', 'Idle_A', 'Idle_B', 'Move', 'Skill'];
assert.equal(resolveRoles(names).idle, 'Idle_A');
assert.equal(resolveRoles(names, { durations: { Idle_A: 0, Idle_B: 2.667 } }).idle, 'Idle_B');
// an exact 'Idle' always wins, even when static (tokens such as token_10012_rosmon_shield)
assert.equal(resolveRoles(['Idle', 'Idle_B', 'Start'], { durations: { Idle: 0, Idle_B: 1 } }).idle, 'Idle');
// all idle-like clips static → still the first one
assert.equal(resolveRoles(['Idle_A', 'Idle_B'], { durations: { Idle_A: 0, Idle_B: 0 } }).idle, 'Idle_A');
});
test('Move_Start + Move + Move_End (enemy_1112_emppnt)', () => {
const r = resolveRoles(['Attack', 'Default', 'Die', 'Idle', 'Move', 'Move_End', 'Move_Start']);
assert.deepEqual(r.move, { begin: 'Move_Start', loop: 'Move', end: 'Move_End' });
});
test('directional-only skill clips (char_279_excu, char_431_ashlok Back)', () => {
const excuFront = ['Attack', 'Default', 'Die', 'Idle', 'Skill_Down_Begin', 'Skill_Down_End', 'Skill_Down_Loop', 'Skill_Right_Begin', 'Skill_Right_End', 'Skill_Right_Loop', 'Start'];
assert.deepEqual(resolveRoles(excuFront, { skillIndices: [1] }).skill,
{ begin: 'Skill_Right_Begin', loop: 'Skill_Right_Loop', end: 'Skill_Right_End', index: 1, idle: null });
const excuBack = ['Attack', 'Default', 'Idle', 'Skill_Right_Begin', 'Skill_Right_End', 'Skill_Right_Loop', 'Skill_Up_Begin', 'Skill_Up_End', 'Skill_Up_Loop', 'Start'];
assert.equal(resolveRoles(excuBack, { skillIndices: [1] }).skill.loop, 'Skill_Right_Loop');
const ashlokBack = ['Attack01', 'Attack02', 'Default', 'Idle', 'Skill_Idle_Up', 'Skill_Loop_Up', 'Start'];
assert.deepEqual(resolveRoles(ashlokBack).skill, { begin: null, loop: 'Skill_Loop_Up', end: null, index: 0, idle: 'Skill_Idle_Up' });
// undirected clips always win; Down-only clips are never used for the skill
assert.equal(resolveRoles(['Attack', 'Idle', 'Skill_Loop', 'Skill_Right_Loop']).skill.loop, 'Skill_Loop');
assert.equal(resolveRoles(['Attack', 'Idle', 'Skill_Down_Loop']).skill.via, 'attack');
});
test('every referenced name exists in the input', () => {
const names = ['Attack_Begin', 'Attack_Loop', 'Attack_End', 'Attack_Down_Begin', 'Attack_Down_Loop', 'Idle', 'Die', 'Start', 'Skill_2_Begin', 'Skill_2_Loop', 'Skill_2_End', 'Stun'];
const r = resolveRoles(names, { skillIndices: [1, 0] });
for (const n of roleAnimationNames(r)) assert.ok(names.includes(n), n);
assert.deepEqual(r.attackDown, { begin: 'Attack_Down_Begin', loop: 'Attack_Down_Loop', end: null });
});
});
// ---------------------------------------------------------------------------
describe('atlas normalization', () => {
const fexli = '\nchar_x.png\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nArm\n rotate: false\n xy: 2, 2\n size: 10, 12\n orig: 10, 12\n offset: 0, 0\n index: -1\nLeg\n rotate: 270\n xy: 20, 2\n size: 8, 8\n orig: 8, 8\n offset: 0, 0\n index: -1\n';
const sizeOf = () => ({ width: 256, height: 128 });
test('inserts size right after the page name and is idempotent', () => {
const r = normalizeAtlas(fexli, { pageSize: sizeOf });
assert.ok(r.changed);
const lines = r.text.split('\n');
assert.equal(lines[1], 'char_x.png');
assert.equal(lines[2], 'size: 256,128');
assert.equal(lines[3], 'format: RGBA8888');
const again = normalizeAtlas(r.text, { pageSize: sizeOf });
assert.equal(again.changed, false);
assert.equal(again.text, r.text);
const info = atlasInfo(r.text);
assert.deepEqual(info.pages, ['char_x.png']);
assert.deepEqual([...info.regions], ['Arm', 'Leg']);
assert.equal(info.hasSize, true);
assert.equal(info.hasPma, false);
});
test('adds pma after page fields, fixes a wrong size, renames pages', () => {
const r = normalizeAtlas('page a.png\nsize: 1,1\nformat: RGBA8888\nR\n xy: 0, 0\n', { pageSize: sizeOf, pma: true, renamePage: (p) => p.replace(' ', '_') });
assert.equal(r.text, 'page_a.png\nsize: 256,128\nformat: RGBA8888\npma: true\nR\n xy: 0, 0\n');
assert.deepEqual(r.fixedSize, ['page a.png']);
assert.deepEqual(r.pages, ['page_a.png']);
assert.equal(atlasInfo(r.text).hasPma, true);
assert.equal(normalizeAtlas(r.text, { pageSize: sizeOf, pma: true }).changed, false);
});
test('handles multiple pages and CRLF; reports unsized pages', () => {
const two = 'a.png\r\nformat: RGBA8888\r\nA\r\n xy: 0, 0\r\n\r\nb.png\r\nformat: RGBA8888\r\nB\r\n xy: 1, 1\r\n';
const r = normalizeAtlas(two, { pageSize: (p) => (p === 'a.png' ? { width: 4, height: 8 } : null) });
assert.deepEqual(r.missingSize, ['b.png']);
const { pages } = parseAtlas(r.text);
assert.deepEqual(pages.map((p) => [p.name, p.fields.size ?? null, p.regions]), [['a.png', '4,8', ['A']], ['b.png', null, ['B']]]);
});
});
// ---------------------------------------------------------------------------
describe('format helpers', () => {
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
return Buffer.concat([len, Buffer.from(type, 'latin1'), data, Buffer.alloc(4)]);
}
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(300, 0); ihdr.writeUInt32BE(150, 4); ihdr[8] = 8; ihdr[9] = 6;
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.alloc(10))), chunk('IEND', Buffer.alloc(0))]);
test('PNG size and completeness', () => {
assert.deepEqual(pngSize(png), { width: 300, height: 150 });
assert.equal(isCompletePng(png), true);
assert.equal(isCompletePng(png.subarray(0, png.length - 6)), false);
assert.equal(pngSize(Buffer.from('<html>not found</html>')), null);
assert.equal(validate('png', Buffer.from('404: Not Found')), false);
});
test('MP3 and atlas sniffing', () => {
assert.equal(isMp3(Buffer.concat([Buffer.from('ID3'), Buffer.alloc(200)])), true);
assert.equal(isMp3(Buffer.concat([Buffer.from([0xff, 0xfb]), Buffer.alloc(200)])), true);
assert.equal(isMp3(Buffer.from('404: Not Found'.padEnd(200))), false);
assert.equal(validate('atlas', Buffer.from('\nx.png\nformat: RGBA8888\n')), true);
assert.equal(validate('atlas', Buffer.from('404: Not Found')), false);
});
test('URL helpers', () => {
assert.equal(mirrorUrl('https://raw.githubusercontent.com/fexli/ArknightsResource/main/spine/a/b.skel'), 'https://cdn.jsdelivr.net/gh/fexli/ArknightsResource@main/spine/a/b.skel');
assert.equal(mirrorUrl('https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/x.mp3'), null);
assert.equal(mirrorUrl('https://example.com/x'), null);
assert.equal(encodePath('[uc]a/b c#.png'), '%5Buc%5Da/b%20c%23.png');
assert.equal(safeName('skcom_charge_cost[3]'), 'skcom_charge_cost_3_');
assert.equal(safeName('bg_open 1'), 'bg_open_1');
});
});
// ---------------------------------------------------------------------------
describe('WOFF2 encoder', () => {
test('UIntBase128', () => {
assert.deepEqual(uintBase128(0), [0]);
assert.deepEqual(uintBase128(127), [0x7f]);
assert.deepEqual(uintBase128(128), [0x81, 0x00]);
assert.equal(uintBase128(0xffffffff).length, 5);
assert.throws(() => uintBase128(-1));
});
function makeSfnt(tables) {
const n = tables.length;
const header = Buffer.alloc(12 + 16 * n);
header.writeUInt32BE(0x4f54544f, 0);
header.writeUInt16BE(n, 4);
let off = header.length;
const bodies = [];
tables.forEach(([tag, data], i) => {
const rec = 12 + 16 * i;
header.write(tag, rec, 'latin1');
header.writeUInt32BE(off, rec + 8);
header.writeUInt32BE(data.length, rec + 12);
const padded = Buffer.alloc((data.length + 3) & ~3);
data.copy(padded);
bodies.push(padded);
off += padded.length;
});
return Buffer.concat([header, ...bodies]);
}
test('lossless round trip (synthetic font incl. unknown tag)', () => {
const sfnt = makeSfnt([['CFF ', Buffer.from('cff data '.repeat(50))], ['head', Buffer.alloc(54, 7)], ['zzzz', Buffer.from([1, 2, 3])]]);
const w = encodeWoff2(sfnt);
assert.equal(w.readUInt32BE(0), 0x774f4632);
assert.equal(w.length % 4, 0);
const dec = decodeWoff2Tables(w);
const src = readSfnt(sfnt);
for (const t of src.tables) assert.ok(dec.tables.find((d) => d.tag === t.tag).data.equals(t.data), t.tag);
});
test('shipped fonts decode back to their sources', (t) => {
const dir = join(PUBLIC, 'fonts');
if (!existsSync(join(dir, 'fonts.css'))) { t.skip('fonts not generated (run npm run assets)'); return; }
for (const f of readdirSync(dir).filter((x) => /\.(otf|ttf)$/.test(x))) {
const woff2 = join(dir, f.replace(/\.(otf|ttf)$/, '.woff2'));
assert.ok(existsSync(woff2), `${woff2} exists`);
const src = readSfnt(readFileSync(join(dir, f)));
const dec = decodeWoff2Tables(readFileSync(woff2));
assert.equal(dec.tables.length, src.tables.length);
for (const tb of src.tables) assert.ok(dec.tables.find((d) => d.tag === tb.tag).data.equals(tb.data), `${f} ${tb.tag}`);
}
});
});
// ---------------------------------------------------------------------------
describe('audio banks and plan id sets', () => {
test('asset paths and unit SFX role picking', () => {
assert.equal(assetToPath('Audio/Sound_Beta_2/Player/p_atk/p_atk_sword_n'), 'player/p_atk/p_atk_sword_n.mp3');
assert.equal(assetToPath(null), null);
const idx = indexAudio({
soundFXBanks: [
{ name: 'battle.ON_ABILITY_START.char_1_x.attack.1', sounds: [{ asset: 'Audio/Sound_Beta_2/P/a1' }] },
{ name: 'battle.ON_ABILITY_START.char_1_x.attack.0', sounds: [{ asset: 'Audio/Sound_Beta_2/P/a0' }] },
{ name: 'battle.ON_ABILITY_HIT.char_1_x.attack', sounds: [{ asset: 'Audio/Sound_Beta_2/P/h' }] },
{ name: 'battle.ON_UNIT_DEAD.char_1_x', sounds: [{ asset: 'Audio/Sound_Beta_2/P/d' }] },
{ name: 'battle.ON_SKILL_START.skchr_x_2', sounds: [{ asset: 'Audio/Sound_Beta_2/P/s' }] },
],
bgmBanks: [{ name: 'battle.ON_GAME_READY.x', intro: null, loop: 'Audio/Sound_Beta_2/Music/x_loop' }],
bankAlias: { 'battle.ON_GAME_READY.y': 'battle.ON_GAME_READY.x' },
});
const roles = pickUnitSfx(idx.unitBanks.get('char_1_x'));
assert.deepEqual(roles, { attack: ['p/a0.mp3'], hit: ['p/h.mp3'], die: ['p/d.mp3'] });
assert.deepEqual(idx.skillBanks.get('skchr_x_2').get('ON_SKILL_START'), ['p/s.mp3']);
assert.deepEqual(idx.bgm('battle.ON_GAME_READY.y'), { intro: null, loop: 'music/x_loop.mp3' });
assert.deepEqual(pickUnitSfx(undefined), {});
});
test('non-attack abilities are not used as the attack sound', () => {
const banks = new Map([
['ON_ABILITY_ON.ShieldBurst', ['e/skill.mp3']],
['ON_ABILITY_START.T.1', ['e/talent.mp3']],
['ON_ABILITY_HIT.attack', ['e/hit.mp3']],
['ON_ABILITY_START.PowerAttack', ['e/power.mp3']],
]);
assert.deepEqual(pickUnitSfx(banks), { attack: ['e/power.mp3'], hit: ['e/hit.mp3'] });
banks.delete('ON_ABILITY_START.PowerAttack');
assert.deepEqual(pickUnitSfx(banks), { hit: ['e/hit.mp3'] });
});
test('operators: normal-mode sounds only, their own projectile banks as fallbacks (user playtest #4 item 6)', () => {
// 纯烬艾雅法拉: the numbered ability variant attack.2 is her S3 impact (_s) — never the normal hit
const banks = new Map([['ON_ABILITY_START.attack', ['p/p_atk_x_n.mp3']], ['ON_ABILITY_HIT.attack.2', ['p/p_imp_x_s.mp3']]]);
assert.deepEqual(pickUnitSfx(banks, { operator: true, projectile: { hit: ['p/p_imp_x_n.mp3'] } }), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_n.mp3'] });
assert.deepEqual(pickUnitSfx(banks), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_s.mp3'] }, 'enemies / tokens unchanged');
// a skill-mode plain bank (_d / _h / _s) is skipped; the plain ability wins over any numbered variant
const b2 = new Map([['ON_ABILITY_START.attack', ['p/p_atk_y_h.mp3']], ['ON_ABILITY_START.attack.0', ['p/p_atk_y_n.mp3']], ['ON_ABILITY_ON.attack', ['p/p_atk_y_n2.mp3']]]);
assert.deepEqual(pickUnitSfx(b2, { operator: true }).attack, ['p/p_atk_y_n2.mp3']);
assert.deepEqual(pickUnitSfx(new Map([['ON_ABILITY_START.attack.1', ['p/p_atk_z_s.mp3']]]), { operator: true }), {}, 'only skill-mode files: no attack sound');
assert.deepEqual(pickUnitSfx(undefined, { operator: true, projectile: { born: ['p/b.mp3'] } }), { attack: ['p/b.mp3'] }, 'no unit banks: the projectile bank');
});
test('enemy set covers 07 list, bosses and their summons', () => {
const assets07 = readJson('docs/research/07-assets.json');
const ids = new Set(collectEnemyIds({ assets07, enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'), ops03: readJson('docs/research/03-operators.json') }));
for (const id of Object.keys(assets07.enemies)) assert.ok(ids.has(id), id);
for (const id of ['enemy_9013_acstmk', 'enemy_9016_acstmr', 'enemy_9017_achunt', 'enemy_9021_acduml', 'enemy_9023_acdums', 'enemy_1521_dslily',
'enemy_2016_csphtm', 'enemy_9032_aclionk', 'enemy_10028_vtswd', 'enemy_9033_acdeer', 'enemy_9013_acstmk_2', 'enemy_9012_acloon',
'enemy_5601_entlec' /* 心烛, summoned by 隐德来希's default S3 (talent take_extra_enemy_key) */]) {
assert.ok(ids.has(id), id);
}
});
test('skill indices per char (primary first, backups included)', () => {
const m = skillIndicesByChar(readJson('docs/research/03-operators.json'));
assert.deepEqual(m.get('char_102_texas'), [1]);
assert.equal(m.size, 138);
assert.deepEqual([...m.get('char_603_csnipe')].sort(), [1, 2]);
});
test('template resolution drops missing files and uses fallbacks', () => {
const tpl = { a: { alts: [{ rel: 'nope/x.png', urls: ['u1'] }] }, b: { c: { alts: [{ rel: 'nope/y.png', urls: ['u2'] }] } }, keep: 'v', m: { model: 'k' } };
const r = resolveTemplate(tpl, { root: ROOT, spine: new Map([['k', { skel: '/assets/s.skel', atlas: '/assets/s.atlas', textures: [] }]]) });
assert.deepEqual(Object.keys(r.value).sort(), ['keep', 'm']);
assert.deepEqual(r.misses.sort(), ['a', 'b.c']);
assert.deepEqual(r.droppedLeaves, ['a', 'b.c'], 'a leaf no alternative of which is on disk is a dropped leaf');
assert.equal(collectLeaves(tpl).length, 2);
const ok = resolveTemplate({ p: { alts: [{ rel: 'nope.png', urls: ['x'] }, { rel: 'package.json', urls: ['y'] }] } }, { root: ROOT, spine: new Map() });
assert.equal(ok.value.p, '/assets/package.json');
assert.equal(ok.fallbacks.length, 1);
assert.deepEqual(ok.droppedLeaves, [], 'a fallback on disk is not a drop');
});
test('an array whose lines are all missing is dropped, with the entry it empties (a voice slot never downloaded)', () => {
const line = (rel) => ({ alts: [{ rel, urls: ['u'] }] });
const tpl = {
voice: {
a: { select: [line('nope/1.mp3'), line('nope/2.mp3')], place: [line('nope/3.mp3')] },
b: { select: [line('nope/4.mp3'), line('package.json')] },
},
empty: [],
};
const r = resolveTemplate(tpl, { root: ROOT, spine: new Map() });
assert.deepEqual(r.value.voice, { b: { select: ['/assets/package.json'] } }, 'operator a has no line on disk: no entry at all');
assert.deepEqual(r.value.empty, [], 'an array that was empty in the template stays');
assert.deepEqual(r.misses.sort(), ['voice.a.place[0]', 'voice.a.select[0]', 'voice.a.select[1]', 'voice.b.select[0]']);
assert.deepEqual(r.droppedLeaves.sort(), r.misses.slice().sort(), 'every dropped line is a dropped leaf too');
});
// A unit's attack sound is a leaf like any other: when its file was never downloaded (or a checkout lost it) the whole
// key vanishes from data/assets.json and the client plays nothing — there is no fallback and no URL to retry. The
// resolution therefore reports it as a dropped leaf, which the run prints and --strict fails on
// (tools/fetch-assets.mjs; docs/ASSETS.md "A dropped leaf is reported, never silent").
test('move a resolved sound aside: the leaf leaves the manifest and is reported as dropped, never silently', async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const root = await mkdtemp(join(tmpdir(), 'sp-leaf-drop-'));
t.after(() => rm(root, { recursive: true, force: true }));
const rel = 'audio/sfx/player/p_atk/p_atk_somechar_n.mp3';
const imp = 'audio/sfx/player/p_imp/p_imp_somechar_n.mp3';
const ui = 'audio/sfx/general/g_ui/g_ui_btn_h.mp3';
const leaf = (r) => ({ alts: [{ rel: r, urls: [`https://raw.githubusercontent.com/o/r/main/${r}`], kind: 'mp3' }] });
const tpl = { audio: { sfx: { units: { char_x: { attack: leaf(rel), hit: leaf(imp) } }, ui: { click: leaf(ui) } } } };
for (const r of [rel, imp, ui]) {
await mkdir(join(root, dirname(r)), { recursive: true });
await writeFile(join(root, r), 'ID3');
}
const before = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(JSON.stringify(before.value), JSON.stringify({
audio: {
sfx: {
units: { char_x: { attack: `/assets/${rel}`, hit: `/assets/${imp}` } },
ui: { click: `/assets/${ui}` },
},
},
}), 'every file on disk: the manifest is exactly the plan, byte for byte');
assert.deepEqual([before.misses, before.droppedLeaves, before.fallbacks], [[], [], []]);
await rm(join(root, rel)); // the download never happened / the file went missing
const after = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(Object.hasOwn(after.value.audio.sfx.units.char_x, 'attack'), false, 'the key is gone from the manifest');
assert.equal(after.value.audio.sfx.units.char_x.hit, `/assets/${imp}`, 'the roles whose file is on disk stay');
assert.deepEqual(after.droppedLeaves, ['audio.sfx.units.char_x.attack'], 'and the drop is reported, not silent');
assert.ok(after.misses.includes('audio.sfx.units.char_x.attack'), 'the wider miss list names it too');
});
});
// ---------------------------------------------------------------------------
describe('downloader (fake network)', () => {
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
return Buffer.concat([len, Buffer.from(type, 'latin1'), data, Buffer.alloc(4)]);
}
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(2, 0); ihdr.writeUInt32BE(2, 4);
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IEND', Buffer.alloc(0))]);
const RAW_URL = 'https://raw.githubusercontent.com/o/r/main/a.png';
const MIRROR = 'https://cdn.jsdelivr.net/gh/o/r@main/a.png';
async function setup(routes) {
const { mkdtemp } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { Downloader } = await import('../tools/assets/downloader.mjs');
const dir = await mkdtemp(join(tmpdir(), 'sp-assets-'));
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
const q = routes[url];
const r = Array.isArray(q) ? (q.length > 1 ? q.shift() : q[0]) : q;
if (!r) return new Response('404: Not Found', { status: 404 });
if (r instanceof Error) throw r;
return new Response(r.body, { status: r.status ?? 200, headers: r.headers });
};
const dl = new Downloader({ root: join(dir, 'out'), ledgerPath: join(dir, 'ledger.json'), fetchImpl, backoffMs: 0, log: () => {} });
return { dl, dir, calls };
}
test('mirror fallback after a raw 404, then idempotent skip', async () => {
const { dl, dir, calls } = await setup({ [MIRROR]: { body: PNG } });
const job = { rel: 'x/a.png', urls: [RAW_URL], kind: 'png' };
const r1 = await dl.run([job, { ...job }], 't');
assert.equal(r1.get('x/a.png').status, 'ok');
assert.deepEqual(calls, [RAW_URL, MIRROR]);
assert.ok(readFileSync(join(dir, 'out', 'x', 'a.png')).equals(PNG));
assert.equal(JSON.parse(readFileSync(join(dir, 'ledger.json'), 'utf8')).files['x/a.png'].bytes, PNG.length);
const r2 = await dl.run([job], 't');
assert.equal(r2.get('x/a.png').status, 'skip');
assert.equal(calls.length, 2);
});
test('retries transient errors and rejects invalid payloads', async () => {
const { dl, dir } = await setup({
[RAW_URL]: [new Error('ECONNRESET'), { status: 503, body: 'busy' }, { body: PNG }],
'https://raw.githubusercontent.com/o/r/main/bad.png': { body: '<html>oops</html>' },
'https://raw.githubusercontent.com/o/r/main/short.png': { body: PNG, headers: { 'content-length': String(PNG.length + 5) } },
});
const res = await dl.run([
{ rel: 'a.png', urls: [RAW_URL], kind: 'png' },
{ rel: 'bad.png', urls: ['https://raw.githubusercontent.com/o/r/main/bad.png'], kind: 'png' },
{ rel: 'short.png', urls: ['https://raw.githubusercontent.com/o/r/main/short.png'], kind: 'png' },
{ rel: 'none.png', urls: ['https://raw.githubusercontent.com/o/r/main/none.png'], kind: 'png' },
], 't');
assert.equal(res.get('a.png').status, 'ok');
assert.equal(res.get('bad.png').status, 'error');
assert.equal(res.get('short.png').status, 'error');
assert.equal(res.get('none.png').status, 'miss');
for (const f of ['bad.png', 'short.png', 'none.png']) assert.equal(existsSync(join(dir, 'out', f)), false);
assert.deepEqual(readdirSync(join(dir, 'out')), ['a.png']); // no temp files left behind
});
test('a corrupt skeleton is dropped and deleted so the next run re-downloads it', async () => {
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { processModels } = await import('../tools/assets/spine.mjs');
const dir = await mkdtemp(join(tmpdir(), 'sp-spine-'));
const root = join(dir, 'assets');
await mkdir(join(root, 'spine', 'x'), { recursive: true });
await writeFile(join(root, 'spine', 'x', 'm.skel'), Buffer.alloc(64, 0xff));
await writeFile(join(root, 'spine', 'x', 'm.atlas'), '\nm.png\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nR\n xy: 0, 0\n size: 1, 1\n');
await writeFile(join(root, 'spine', 'x', 'm.png'), PNG);
const job = (rel) => ({ rel, urls: ['u'], kind: rel.split('.').pop() });
const models = new Map([['k', { key: 'k', dir: 'spine/x/', pma: false, skillIndices: [0], skel: job('spine/x/m.skel'), atlas: { ...job('spine/x/m.atlas'), mutable: true }, pngs: [job('spine/x/m.png')] }]]);
let saved = 0;
const dl = { ledger: { files: { 'spine/x/m.skel': { url: 'u', bytes: 64 } } }, saveLedger: async () => { saved++; } };
const r = await processModels(models, { root, dl, cachePath: join(dir, 'cache.json'), download: false, log: () => {} });
assert.equal(r.entries.size, 0);
assert.match(r.problems.join('\n'), /skel parse failed/);
assert.equal(existsSync(join(root, 'spine', 'x', 'm.skel')), false);
assert.equal(dl.ledger.files['spine/x/m.skel'], undefined);
assert.equal(saved, 1);
assert.match(readFileSync(join(root, 'spine', 'x', 'm.atlas'), 'utf8'), /size: 2,2/); // atlas still normalized
});
test('leaf fallbacks follow 404s only, never transient errors', async () => {
const { downloadLeaves } = await import('../tools/assets/manifest.mjs');
const base = 'https://raw.githubusercontent.com/o/r/main/';
const { dl, dir, calls } = await setup({
[`${base}fallback.png`]: { body: PNG },
[`${base}flaky.png`]: { status: 503, body: 'busy' },
'https://cdn.jsdelivr.net/gh/o/r@main/flaky.png': { status: 503, body: 'busy' },
});
const leaves = [
{ path: 'gone', leaf: { alts: [{ rel: 'g.png', urls: [`${base}gone.png`], kind: 'png' }, { rel: 'g.png', urls: [`${base}fallback.png`], kind: 'png' }] } },
{ path: 'flaky', leaf: { alts: [{ rel: 'f.png', urls: [`${base}flaky.png`], kind: 'png' }, { rel: 'f.png', urls: [`${base}fallback.png`], kind: 'png' }] } },
];
const blocked = await downloadLeaves(leaves, dl, join(dir, 'out'));
assert.deepEqual(blocked, ['flaky']);
assert.ok(readFileSync(join(dir, 'out', 'g.png')).equals(PNG), '404 primary → fallback used');
assert.equal(existsSync(join(dir, 'out', 'f.png')), false, 'transient failure → no fallback on the primary path');
assert.equal(calls.filter((u) => u.endsWith('fallback.png')).length, 1);
});
});
// ---------------------------------------------------------------------------
// GitHub issue #42: a source deploy on a server without the Arknights client had no public/assets/local, so the battle
// emotes showed default icons and 玩法说明 showed text tips. The public mirror has both (checked 2026-10-03: all 55
// URLs answer 200 on raw.githubusercontent.com and on jsDelivr, same pictures and sizes as the local extraction), so the
// plan downloads them like any other UI sprite, keyed by the data/local-assets.json group and name.
describe('emotes and 玩法说明 pages from the public mirror (GitHub issue #42)', () => {
const AA2 = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/';
const plan = () => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {} }).template;
test('the plan fetches the 36 battle emotes and the 19 guide pages from ArknightsAssets2 cn, keyed like the local art', () => {
const ui = plan().ui;
assert.equal(EMOTE_CATALOG.length, 36);
for (const e of EMOTE_CATALOG) {
const key = `${emoteArtGroup(e.id)}/${e.picId}`; // the local manifest's group + name (emoticon/<dir>/<picId>)
assert.deepEqual(ui[key]?.alts, [{ rel: `ui/${key}.png`, urls: [`${AA2}ui/emoticon/theme/%5Buc%5D${e.themeId}/icon/${e.picId}.png`], kind: 'png' }], key);
}
assert.ok(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'], 'keyed by picId, not by the emote id (fooldoctor_06 → pic 08)');
assert.notEqual(ui['emoticon/slug/pic_thanks_battle'].alts[0].rel, ui['emoticon/basic/pic_thanks_battle'].alts[0].rel, 'same picId, two themes');
assert.deepEqual(GUIDE_PAGES.slice(0, 2), ['autochess_home_1', 'autochess_home_2']);
assert.equal(GUIDE_PAGES.length, 19);
for (const k of GUIDE_PAGES) {
assert.deepEqual(ui[`guide/${k}`]?.alts, [{ rel: `ui/guide/${k}.png`, urls: [`${AA2}arts/guidebookpages/%5Bpack%5Dautochess/${k}.png`], kind: 'png' }], k);
}
const rels = Object.values(ui).map((l) => l.alts[0].rel);
assert.equal(new Set(rels).size, rels.length, 'no two UI entries share a file');
assert.ok(rels.every((r) => !r.startsWith('local/')), 'public/assets/local belongs to tools/local-extract');
assert.equal(mirrorUrl(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'].alts[0].urls[0]),
'https://cdn.jsdelivr.net/gh/ArknightsAssets/ArknightsAssets2@cn/assets/dyn/ui/emoticon/theme/%5Buc%5Demoticon_foolsday_doctor/icon/pic_fooldoctor_08_battle.png',
'the jsDelivr fallback (downloader) serves the same path');
assert.ok(Object.isFrozen(UI_EXTRAS) && UI_EXTRAS.every(Object.isFrozen));
});
test('a rebuilt manifest lists the copies that are on disk under /assets/ui/…; a missing one is left out', async () => {
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const root = await mkdtemp(join(tmpdir(), 'sp-mirror-art-'));
const ui = plan().ui;
const keys = ['emoticon/basic/pic_happy_battle', 'guide/autochess_home_1', 'emoticon/slug/pic_bye_battle'];
for (const k of keys.slice(0, 2)) {
await mkdir(join(root, dirname(ui[k].alts[0].rel)), { recursive: true });
await writeFile(join(root, ui[k].alts[0].rel), 'x');
}
const r = resolveTemplate({ ui: Object.fromEntries(keys.map((k) => [k, ui[k]])) }, { root, spine: new Map() });
assert.deepEqual(r.value.ui, {
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png',
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
});
assert.deepEqual(r.misses, ['ui.emoticon/slug/pic_bye_battle']);
});
test('the committed data/assets.json lists all 55, so setup on an install made before them downloads them', () => {
// tools/setup.mjs runs fetch-assets when data/assets.json lists a file that is not on disk (checkAssets)
const m = readJson('data/assets.json');
for (const e of EMOTE_CATALOG) assert.equal(m.ui[`emoticon/${e.dir}/${e.picId}`], `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
for (const k of GUIDE_PAGES) assert.equal(m.ui[`guide/${k}`], `/assets/ui/guide/${k}.png`, k);
assert.equal(m.stats.ui, Object.keys(m.ui).length);
assert.ok(!JSON.stringify(m).includes('/assets/local/'), 'never a local-client path');
});
});
// ---------------------------------------------------------------------------
describe('generated manifest data/assets.json', () => {
const haveManifest = existsSync(MANIFEST);
const haveAssets = existsSync(ASSETS);
const manifest = haveManifest ? JSON.parse(readFileSync(MANIFEST, 'utf8')) : null;
const skip = !haveManifest ? 'data/assets.json not generated (run npm run assets)'
: !haveAssets ? 'public/assets missing (run npm run assets)' : false;
/** Every '/assets/…' or '/fonts/…' URL in the manifest. */
function urls(node, out = []) {
if (typeof node === 'string') { if (/^\/(assets|fonts)\//.test(node)) out.push(node); }
else if (Array.isArray(node)) node.forEach((x) => urls(x, out));
else if (node && typeof node === 'object') Object.values(node).forEach((x) => urls(x, out));
return out;
}
test('shape', { skip: !haveManifest && 'data/assets.json not generated' }, () => {
assert.equal(manifest.version, 1);
assert.equal(typeof manifest.hash, 'string');
for (const k of ['chars', 'enemies', 'tokens', 'bonds', 'items', 'bands', 'skills', 'ui', 'prof', 'audio', 'fonts', 'stats']) {
assert.ok(manifest[k] && typeof manifest[k] === 'object', k);
}
});
test('every manifest path exists on disk', { skip }, () => {
const all = [...new Set(urls(manifest))];
assert.ok(all.length > 3000, `manifest lists ${all.length} files`);
const missing = all.filter((u) => !existsSync(join(PUBLIC, u)) || statSync(join(PUBLIC, u)).size === 0);
assert.deepEqual(missing, []);
});
test('every visible chess operator has avatar, portrait and a Front Spine', { skip }, () => {
const ops = readJson('docs/research/03-operators.json');
const ids = new Set(ops.chess.filter((c) => !c.isHidden && c.chessType !== 'DIY' && c.charId).map((c) => c.charId));
assert.ok(ids.size >= 100);
for (const id of ids) {
const c = manifest.chars[id];
assert.ok(c, `${id} in manifest`);
for (const p of [c.avatar, c.portrait, c.spine?.front?.skel, c.spine?.front?.atlas, ...(c.spine?.front?.textures || [])]) {
assert.ok(typeof p === 'string' && existsSync(join(PUBLIC, p)), `${id}: ${p}`);
}
}
});
test('all pool chars, bonds, items and bands are covered', { skip }, () => {
const a07 = readJson('docs/research/07-assets.json');
for (const id of Object.keys(a07.operators)) assert.ok(manifest.chars[id]?.avatar && manifest.chars[id]?.spine?.front, id);
for (const id of Object.keys(a07.bonds)) assert.ok(manifest.bonds[id], id);
for (const id of Object.keys(a07.items)) assert.ok(manifest.items[id], id);
for (const id of Object.keys(a07.bands)) assert.ok(manifest.bands[id], id);
for (const id of Object.keys(a07.enemies)) assert.ok(manifest.enemies[id]?.icon, id);
});
test('DESIGN §16 loadouts: every selectable skill has its icon; a multi-skill operator model has a clip per skill index', { skip }, () => {
const chess = readJson('data/chess.json');
const miss = [];
const noClip = [];
let n = 0;
for (const r of Object.values(chess)) {
if (!r?.visible) continue;
for (const s of r.skills || []) {
n++;
const icon = manifest.skills[s.iconId || s.skillId] || manifest.skills[manifest.skillsById?.[s.skillId]];
if (!icon || !existsSync(join(PUBLIC, icon))) miss.push(`${r.chessId} ${s.skillId}`);
if ((r.skills || []).length > 1 && !manifest.chars[r.charId]?.spine?.front?.anims?.skills?.[String(s.index)]) noClip.push(`${r.chessId} S${s.index + 1}`);
}
}
assert.ok(n >= 500, `${n} selectable skill records`);
assert.deepEqual(miss, [], 'skill icons (the loadout screen, shop badges and the detail card show the chosen skill)');
assert.deepEqual(noClip, [], 'Spine skill clips per index (a non-default skill never plays the default skill\'s animation)');
});
test('every atlas on disk has size: lines (and pma: true for enemies)', { skip }, () => {
const atlases = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p); else if (e.name.endsWith('.atlas')) atlases.push(p);
}
};
const spineDir = join(ASSETS, 'spine');
if (existsSync(spineDir)) walk(spineDir);
assert.ok(atlases.length > 400, `${atlases.length} atlases`);
for (const p of atlases) {
const info = atlasInfo(readFileSync(p, 'utf8'));
assert.ok(info.hasSize, `${p} has size`);
if (p.includes(`${join('spine', 'enemy')}`)) assert.ok(info.hasPma, `${p} has pma`);
for (const page of info.pages) assert.ok(existsSync(join(dirname(p), page)), `${p} page ${page}`);
}
});
test('Spine animation roles resolve (idle + attack or fallback)', { skip }, () => {
const entries = [];
for (const [id, c] of Object.entries(manifest.chars)) for (const [side, s] of Object.entries(c.spine || {})) entries.push([`${id}.${side}`, s]);
for (const [id, e] of Object.entries(manifest.enemies)) if (e.spine) entries.push([id, e.spine]);
for (const [id, t] of Object.entries(manifest.tokens)) if (t.spine) entries.push([id, t.spine]);
assert.ok(entries.length > 400);
for (const [id, s] of entries) {
const names = Object.keys(s.animations || {});
assert.ok(names.length > 0, `${id} has animations`);
assert.ok(typeof s.anims?.idle === 'string' && names.includes(s.anims.idle), `${id} idle`);
assert.ok(s.anims.attack && names.includes(s.anims.attack.loop), `${id} attack`);
for (const n of roleAnimationNames(s.anims)) assert.ok(names.includes(n), `${id} role anim ${n}`);
}
});
test('every Spine model loads like the client does (pixi-spine atlas reader + AtlasAttachmentLoader)', { skip }, async () => {
const { createRequire } = await import('node:module');
const require = createRequire(join(ROOT, 'package.json'));
const core = require('@pixi/core');
const base = require('@pixi-spine/base');
const r38 = require('@pixi-spine/runtime-3.8');
const models = new Map();
for (const c of Object.values(manifest.chars)) for (const s of Object.values(c.spine || {})) models.set(s.skel, s);
for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine);
for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine);
assert.ok(models.size > 400);
// the official enemy and token models of the local client, as the client resolves them (DESIGN §13: only when listed)
const localPath = join(ROOT, 'data', 'local-assets.json');
const local = existsSync(localPath) ? JSON.parse(readFileSync(localPath, 'utf8')) : null;
for (const id of [...Object.keys(manifest.enemies), ...Object.keys(manifest.tokens)]) {
const s = local ? spineEntry(manifest, id, { local }) : null;
if (s?.local && [s.skel, s.atlas, ...s.textures].every((u) => existsSync(join(PUBLIC, u)))) models.set(s.skel, s);
}
for (const s of models.values()) {
const dir = dirname(s.atlas);
// pixi-spine finds the atlas by swapping the extension of the skel URL
assert.equal(s.skel.replace(/\.skel$/, '.atlas'), s.atlas, s.skel);
let done = false;
const atlas = new base.TextureAtlas(readFileSync(join(PUBLIC, s.atlas), 'utf8'), (page, cb) => {
const size = pngSize(readFileSync(join(PUBLIC, dir, page)));
assert.ok(size, `${s.atlas}: page ${page}`);
const bt = new core.BaseTexture(null, { width: size.width, height: size.height });
bt.setRealSize(size.width, size.height);
bt.valid = true;
cb(bt); // regions outside the page make PIXI.Texture throw
}, () => { done = true; });
assert.ok(done, `${s.atlas} parsed`);
assert.deepEqual(atlas.pages.map((p) => `${dir}/${p.name}`), s.textures, s.atlas);
for (const p of atlas.pages) assert.ok(p.width > 0 && p.height > 0 && !!p.pma === s.pma, `${s.atlas} page ${p.name} size/pma`);
const data = new r38.SkeletonBinary(new r38.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(join(PUBLIC, s.skel))));
const skel = new r38.Skeleton(data);
const state = new r38.AnimationState(new r38.AnimationStateData(data));
for (const n of roleAnimationNames(s.anims)) {
state.setAnimation(0, n, true);
state.update(0.25);
state.apply(skel);
skel.updateWorldTransform();
}
}
});
test('audio: BGM phases and UI SFX', { skip }, () => {
const { bgm, sfx } = manifest.audio;
for (const k of ['lobby', 'prep', 'combat', 'boss']) assert.ok(bgm[k]?.loop, `bgm ${k}`);
for (const k of ['buy', 'sell', 'refresh', 'levelup', 'merge', 'ready', 'timer', 'error', 'draft']) assert.ok(sfx.ui[k], `sfx.ui.${k}`);
const units = Object.values(sfx.units);
assert.ok(units.length > 300);
for (const u of units) for (const v of Object.values(u)) assert.ok(typeof v === 'string' || (v && typeof v === 'object'));
assert.ok(manifest.fonts.css && existsSync(join(PUBLIC, manifest.fonts.css)));
});
});