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).
612 lines
35 KiB
JavaScript
612 lines
35 KiB
JavaScript
// 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`).
|
|
|
|
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 } from '../tools/assets/plan.mjs';
|
|
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
|
|
|
|
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 });
|
|
});
|
|
|
|
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('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.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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
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)));
|
|
});
|
|
});
|