- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
593 lines
32 KiB
JavaScript
593 lines
32 KiB
JavaScript
// test/render/assets.test.js — public/js/assets.js: URL helpers (synthetic + real manifest), RefLru spine cache
|
||
// (refcount, LRU eviction, failures, timeout, concurrency), store (fetch, preload, fallbacks). No PIXI, no DOM.
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
|
||
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
|
||
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
|
||
SPINE_IDLE_GRACE_MS, SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS,
|
||
} from '../../public/js/assets.js';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
|
||
/** Fake clock + timers for the spine LRU memory budget (RefLru `now` / `timers`). */
|
||
const clock = () => {
|
||
const c = { t: 0, pending: [] };
|
||
c.now = () => c.t;
|
||
c.timers = { set: (fn, ms) => { const h = { fn, at: c.t + ms }; c.pending.push(h); return h; }, clear: (h) => { c.pending = c.pending.filter((x) => x !== h); } };
|
||
c.advance = (ms) => { c.t += ms; for (const h of c.pending.filter((x) => x.at <= c.t)) { c.pending = c.pending.filter((x) => x !== h); h.fn(); } };
|
||
return c;
|
||
};
|
||
|
||
const SP = (id, extra = {}) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], pma: false, anims: { idle: 'Idle', attack: { loop: 'Attack' } }, animations: { Idle: 1 }, events: [], hits: {}, bounds: null, ...extra });
|
||
const M = {
|
||
chars: {
|
||
char_002_amiya: { avatar: '/a/amiya.png', avatarE2: '/a/amiya_2.png', portrait: '/p/amiya_1.png', portraitE2: '/p/amiya_2.png', spine: { front: SP('amiya_f'), back: SP('amiya_b') } },
|
||
char_010_chen: { avatar: '/a/chen.png', portrait: '/p/chen_1.png', spine: { front: SP('chen_f') } },
|
||
},
|
||
enemies: {
|
||
enemy_1007_slime: { icon: '/e/slime.png', spine: SP('slime', { pma: true }) },
|
||
enemy_1305_mhslim: { icon: '/e/mh.png', spineAliasOf: 'enemy_1007_slime' },
|
||
enemy_9016_acstmr: { icon: '/e/acstmr.png' },
|
||
},
|
||
tokens: {
|
||
token_a: { owner: 'char_010_chen', avatar: '/t/a.png', spine: SP('tok_a') },
|
||
token_b: { owner: 'char_010_chen' },
|
||
token_c: { owner: null },
|
||
},
|
||
bonds: { yanShip: '/b/yan.png' },
|
||
bands: { band_bldsk: '/band/bldsk.png' },
|
||
items: { trap_1041_acarm041: '/i/1041.png' },
|
||
skills: { skchr_x: '/s/x.png' },
|
||
skillsById: { skill_y: 'skchr_x' },
|
||
ui: { 'skillIcon/empty': '/ui/empty.png', 'battle/sprite_shadow': '/ui/shadow.png' },
|
||
prof: { icon: { sniper: '/prof/sniper.png' }, battlecard: { token: '/prof/token.png' }, sub: { fastshot: '/prof/sub/fastshot.png' } },
|
||
audio: {
|
||
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
|
||
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
|
||
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
|
||
},
|
||
};
|
||
|
||
describe('URL helpers (synthetic manifest)', () => {
|
||
test('avatars & portraits with E2 handling', () => {
|
||
assert.equal(avatarUrl(M, 'char_002_amiya'), '/a/amiya.png');
|
||
assert.equal(avatarUrl(M, 'char_002_amiya', { e2: true }), '/a/amiya_2.png');
|
||
assert.equal(avatarUrl(M, 'char_002_amiya_2'), '/a/amiya_2.png', 'chess.json asset id with _2');
|
||
assert.equal(avatarUrl(M, 'char_010_chen', { e2: true }), '/a/chen.png', 'no E2 ⇒ base');
|
||
assert.equal(avatarUrl(M, 'char_010_chen_2'), '/a/chen.png');
|
||
assert.equal(avatarUrl(M, 'char_999_none'), null);
|
||
assert.equal(avatarUrl(M, ''), null);
|
||
assert.equal(avatarUrl(M, 42), null);
|
||
assert.equal(avatarUrl(null, 'char_002_amiya'), null);
|
||
assert.equal(portraitUrl(M, 'char_002_amiya_1'), '/p/amiya_1.png');
|
||
assert.equal(portraitUrl(M, 'char_002_amiya_2'), '/p/amiya_2.png');
|
||
assert.equal(portraitUrl(M, 'char_010_chen', { e2: true }), '/p/chen_1.png');
|
||
assert.equal(baseCharId('char_002_amiya_2'), 'char_002_amiya');
|
||
assert.equal(baseCharId(null), null);
|
||
});
|
||
|
||
test('prototype keys never resolve', () => {
|
||
assert.equal(avatarUrl(M, '__proto__'), null);
|
||
assert.equal(avatarUrl(M, 'constructor'), null);
|
||
assert.equal(uiUrl(M, 'toString'), null);
|
||
assert.equal(spineEntry(M, 'hasOwnProperty'), null);
|
||
});
|
||
|
||
test('enemy, token, bond, band, item, skill, ui, prof icons', () => {
|
||
assert.equal(enemyIconUrl(M, 'enemy_1007_slime'), '/e/slime.png');
|
||
assert.equal(enemyIconUrl(M, 'enemy_x'), null);
|
||
assert.equal(tokenAvatarUrl(M, 'token_a'), '/t/a.png');
|
||
assert.equal(tokenAvatarUrl(M, 'token_b'), '/a/chen.png', 'owner avatar fallback');
|
||
assert.equal(tokenAvatarUrl(M, 'token_c'), '/prof/token.png', 'battlecard fallback');
|
||
assert.equal(tokenAvatarUrl(M, 'token_none'), null);
|
||
assert.equal(bondIconUrl(M, 'yanShip'), '/b/yan.png');
|
||
assert.equal(bandIconUrl(M, 'band_bldsk'), '/band/bldsk.png');
|
||
assert.equal(itemIconUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
|
||
assert.equal(itemIconUrl(M, { trapId: 'trap_1041_acarm041' }), '/i/1041.png');
|
||
assert.equal(itemIconUrl(M, { iconId: 'nope', trapId: 'trap_1041_acarm041' }), null);
|
||
assert.equal(skillIconUrl(M, 'skchr_x'), '/s/x.png');
|
||
assert.equal(skillIconUrl(M, 'skill_y'), '/s/x.png', 'via skillsById');
|
||
assert.equal(skillIconUrl(M, 'nope'), '/ui/empty.png');
|
||
assert.equal(skillIconUrl(M, 'nope', { fallback: false }), null);
|
||
assert.equal(skillIconUrl(M, 'nope', null), '/ui/empty.png', 'null options tolerated');
|
||
assert.equal(avatarUrl(M, 'char_002_amiya', null), '/a/amiya.png');
|
||
assert.equal(spineEntry(M, 'char_002_amiya', null).skel, '/assets/spine/amiya_f.skel');
|
||
assert.equal(uiUrl(M, 'battle/sprite_shadow'), '/ui/shadow.png');
|
||
assert.equal(profIconUrl(M, 'SNIPER'), '/prof/sniper.png');
|
||
assert.equal(profIconUrl(M, 'SNIPER', 'large'), null);
|
||
assert.equal(subProfIconUrl(M, 'sub_fastshot_icon'), '/prof/sub/fastshot.png');
|
||
assert.equal(subProfIconUrl(M, 'fastshot'), '/prof/sub/fastshot.png');
|
||
});
|
||
|
||
test('spine entries: front/back, tokens, enemies, aliases, missing', () => {
|
||
assert.equal(spineEntry(M, 'char_002_amiya').skel, '/assets/spine/amiya_f.skel');
|
||
assert.equal(spineEntry(M, 'char_002_amiya', { back: true }).skel, '/assets/spine/amiya_b.skel');
|
||
assert.equal(spineEntry(M, 'char_010_chen', { back: true }).skel, '/assets/spine/chen_f.skel', 'no Back ⇒ Front');
|
||
assert.equal(spineEntry(M, 'char_002_amiya_2').skel, '/assets/spine/amiya_f.skel');
|
||
assert.ok(hasBackSpine(M, 'char_002_amiya') && !hasBackSpine(M, 'char_010_chen'));
|
||
assert.equal(spineEntry(M, 'token_a').skel, '/assets/spine/tok_a.skel');
|
||
assert.equal(spineEntry(M, 'token_b'), null);
|
||
assert.equal(spineEntry(M, 'enemy_1007_slime').pma, true);
|
||
assert.equal(spineEntry(M, 'enemy_1305_mhslim').skel, '/assets/spine/slime.skel', 'alias');
|
||
assert.equal(spineEntry(M, 'enemy_9016_acstmr'), null);
|
||
assert.equal(spineEntry(M, 'enemy_5601_entlec'), null, 'no manifest entry at all');
|
||
assert.equal(validSpine({ skel: 'javascript:alert(1).skel', atlas: 'x', anims: {} }), false);
|
||
assert.equal(validSpine({ skel: '/x.skel', atlas: '/x.atlas', anims: {} }), true);
|
||
assert.equal(unitPictureUrl(M, 'enemy_9016_acstmr'), '/e/acstmr.png');
|
||
assert.equal(unitPictureUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
|
||
assert.equal(unitPictureUrl(M, 'trap_x'), null);
|
||
});
|
||
|
||
test('audio', () => {
|
||
assert.equal(bgmEntry(M, 'prep').loop, '/bgm/prep.mp3');
|
||
assert.equal(bgmEntry(M, 'boss_1').loop, '/bgm/b1.mp3');
|
||
assert.equal(bgmEntry(M, 'x'), null);
|
||
assert.equal(sfxUrl(M, 'ui', 'buy'), '/sfx/buy.mp3');
|
||
assert.equal(sfxUrl(M, 'ui', 'nope'), null);
|
||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack'), '/sfx/c_atk.mp3');
|
||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
|
||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
|
||
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
|
||
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
|
||
});
|
||
});
|
||
|
||
describe('URL helpers (real data/assets.json)', () => {
|
||
const file = path.join(ROOT, 'data/assets.json');
|
||
const hasManifest = existsSync(file);
|
||
test('every pool chess resolves avatar + front spine; enemies resolve icon; paths are site-absolute', { skip: !hasManifest }, () => {
|
||
const m = JSON.parse(readFileSync(file, 'utf8'));
|
||
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||
let n = 0;
|
||
for (const c of Object.values(chess)) {
|
||
if (!c.visible || !c.assets?.spine) continue;
|
||
n++;
|
||
const av = avatarUrl(m, c.assets.avatar);
|
||
assert.ok(av && av.startsWith('/assets/'), `avatar ${c.chessId} ${c.assets.avatar}`);
|
||
const sp = spineEntry(m, c.assets.spine);
|
||
assert.ok(sp && sp.skel.endsWith('.skel') && sp.anims && sp.anims.idle, `spine ${c.chessId}`);
|
||
if (c.skill?.iconId) assert.ok(skillIconUrl(m, c.skill.iconId, { fallback: false }), `skill icon ${c.chessId}`);
|
||
}
|
||
assert.ok(n >= 200, `checked ${n}`);
|
||
const enemies = JSON.parse(readFileSync(path.join(ROOT, 'data/enemies.json'), 'utf8'));
|
||
let withSpine = 0, total = 0;
|
||
for (const e of Object.values(enemies)) {
|
||
total++;
|
||
if (spineEntry(m, e.spine || e.key)) withSpine++;
|
||
}
|
||
assert.ok(withSpine / total > 0.9, `enemy spines ${withSpine}/${total}`);
|
||
assert.equal(spineEntry(m, 'enemy_5601_entlec'), null);
|
||
assert.ok(uiUrl(m, 'battle/sprite_shadow'));
|
||
for (const b of Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8')))) assert.ok(bondIconUrl(m, b), b);
|
||
});
|
||
});
|
||
|
||
describe('RefLru', () => {
|
||
const tick = () => new Promise((r) => setImmediate(r));
|
||
|
||
test('dedupes concurrent loads, refcounts, evicts LRU idle entries above max', async () => {
|
||
const loads = [], unloads = [];
|
||
const lru = new RefLru({ max: 2, load: async (k) => { loads.push(k); return `v:${k}`; }, unload: (k, v) => unloads.push([k, v]) });
|
||
const [a1, a2] = await Promise.all([lru.acquire('a'), lru.acquire('a')]);
|
||
assert.equal(a1, 'v:a'); assert.equal(a2, 'v:a');
|
||
assert.deepEqual(loads, ['a']);
|
||
await lru.acquire('b');
|
||
await lru.acquire('c');
|
||
assert.equal(lru.size, 3, 'all referenced ⇒ nothing evicted');
|
||
lru.release('a'); lru.release('a');
|
||
assert.deepEqual(unloads, [['a', 'v:a']], 'a evicted once idle');
|
||
assert.equal(lru.peek('a'), null);
|
||
lru.release('b');
|
||
lru.release('c');
|
||
assert.equal(lru.size, 2);
|
||
await lru.acquire('d');
|
||
assert.equal(lru.size, 2);
|
||
assert.deepEqual(unloads.map((u) => u[0]), ['a', 'b'], 'least recently used idle entry goes first');
|
||
lru.release('zzz'); lru.release('d'); lru.release('d');
|
||
assert.equal(lru.stats().refs, 0);
|
||
lru.clear();
|
||
assert.equal(lru.size, 0);
|
||
});
|
||
|
||
test('failures reject, are remembered for failTtl, then retried', async () => {
|
||
let now = 0, calls = 0;
|
||
const lru = new RefLru({ failTtl: 1000, now: () => now, load: async () => { calls++; throw new Error('404'); } });
|
||
await assert.rejects(lru.acquire('x'), /404/);
|
||
await assert.rejects(lru.acquire('x'), /404/);
|
||
assert.equal(calls, 1);
|
||
now = 5000;
|
||
await assert.rejects(lru.acquire('x'), /404/);
|
||
assert.equal(calls, 2);
|
||
assert.equal(lru.stats().failed, 1);
|
||
});
|
||
|
||
test('timeout rejects slow loads', async () => {
|
||
const lru = new RefLru({ timeout: 20, load: () => new Promise(() => {}) });
|
||
await assert.rejects(lru.acquire('slow'), /timeout/);
|
||
});
|
||
|
||
test('concurrency cap queues loads', async () => {
|
||
let active = 0, peak = 0;
|
||
const lru = new RefLru({ concurrency: 2, max: 100, load: async (k) => { active++; peak = Math.max(peak, active); await tick(); await tick(); active--; return k; } });
|
||
const all = await Promise.all(['a', 'b', 'c', 'd', 'e'].map((k) => lru.acquire(k)));
|
||
assert.deepEqual(all, ['a', 'b', 'c', 'd', 'e']);
|
||
assert.equal(peak, 2);
|
||
});
|
||
|
||
test('an entry evicted while loading unloads the late value', async () => {
|
||
let resolve;
|
||
const unloads = [];
|
||
const lru = new RefLru({ max: 1, load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k) });
|
||
const p = lru.acquire('a');
|
||
lru.clear();
|
||
resolve('late');
|
||
assert.equal(await p, 'late');
|
||
assert.deepEqual(unloads, ['a']);
|
||
});
|
||
|
||
test('requires a loader', () => {
|
||
assert.throws(() => new RefLru({}), TypeError);
|
||
});
|
||
|
||
// perf hunt: the count cap alone (60) kept ~115–150 MB of idle parsed skeletons pinned, also in the lobby / room
|
||
const W = { a: 30, b: 30, c: 30, d: 5 };
|
||
|
||
test('memory budget: idle weight above maxIdleWeight goes LRU-first once past the grace; referenced never', async () => {
|
||
const c = clock(), unloads = [];
|
||
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
|
||
maxIdleWeight: 50, idleGrace: 1000, now: c.now, timers: c.timers });
|
||
for (const k of ['a', 'b', 'c', 'd']) await lru.acquire(k);
|
||
lru.release('a'); lru.release('b'); // idle 60 > 50, but both inside the grace (a prep ⇄ battle switch)
|
||
assert.deepEqual(unloads, []);
|
||
assert.equal(lru.stats().idleWeight, 60);
|
||
assert.equal(c.pending.length, 1, 'a sweep is scheduled for the end of the grace');
|
||
await lru.acquire('b'); // re-acquired inside the grace: kept, never reloaded
|
||
c.advance(1000);
|
||
assert.deepEqual(unloads, [], 'idle 30 ≤ 50 after b came back');
|
||
lru.release('b'); // idle a+b = 60: a (idle for the whole grace) goes, b is fresh
|
||
assert.deepEqual(unloads, ['a']);
|
||
lru.release('c'); // idle b+c = 60, both inside their grace
|
||
c.advance(400);
|
||
assert.deepEqual(unloads, ['a']);
|
||
c.advance(700);
|
||
assert.deepEqual(unloads, ['a', 'b'], 'the sweep drops b (least recently used); c alone (30) fits the budget');
|
||
assert.ok(lru.peek('c') && lru.peek('d'));
|
||
assert.equal(lru.stats().weight, 35);
|
||
lru.clear();
|
||
assert.equal(c.pending.length, 0, 'clear cancels the sweep');
|
||
});
|
||
|
||
test('memory budget: nothing referenced (no scene) ⇒ quietWeight after the grace; unweighted caches unchanged', async () => {
|
||
const c = clock(), unloads = [];
|
||
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
|
||
maxIdleWeight: 1000, quietWeight: 0, idleGrace: 1000, now: c.now, timers: c.timers });
|
||
for (const k of ['a', 'd']) await lru.acquire(k);
|
||
lru.release('a');
|
||
c.advance(5000);
|
||
assert.deepEqual(unloads, [], 'd still referenced: the 1000 budget holds a');
|
||
lru.release('d'); // the view is gone (lobby / room)
|
||
assert.deepEqual(unloads, ['a'], 'quiet budget 0: a (idle past its grace) goes at once, d is inside its grace');
|
||
c.advance(1100);
|
||
assert.deepEqual(unloads, ['a', 'd'], 'the quiet sweep empties the cache');
|
||
assert.equal(lru.size, 0);
|
||
// without `weigh` the cache behaves as before (count cap only, no timers)
|
||
const plain = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), maxIdleWeight: 0, quietWeight: 0, now: c.now, timers: c.timers });
|
||
await plain.acquire('x'); plain.release('x');
|
||
assert.equal(plain.size, 1);
|
||
assert.equal(c.pending.length, 0);
|
||
});
|
||
|
||
test('a released-while-loading entry past its grace is dropped as soon as it is ready when over budget', async () => {
|
||
const c = clock(), unloads = [];
|
||
let resolve;
|
||
const lru = new RefLru({ load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k), weigh: () => 10,
|
||
maxIdleWeight: 0, idleGrace: 100, now: c.now, timers: c.timers });
|
||
const p = lru.acquire('a');
|
||
lru.release('a');
|
||
c.advance(200);
|
||
resolve('v');
|
||
await p;
|
||
assert.deepEqual(unloads, ['a']);
|
||
});
|
||
|
||
// user playtest #3 item 1 (invisible operator models): an unload is asynchronous; a new load of the same key while
|
||
// it is in flight must not get the doomed value
|
||
test('a load of a key whose unload is still in flight waits for that unload (never the doomed value)', async () => {
|
||
const order = [];
|
||
let finishUnload;
|
||
let n = 0;
|
||
const lru = new RefLru({ max: 1,
|
||
load: async (k) => { order.push(`load ${k}`); return `${k}#${++n}`; },
|
||
unload: (k, v) => { order.push(`unload ${v}`); return new Promise((r) => { finishUnload = () => { order.push(`unloaded ${v}`); r(); }; }); } });
|
||
assert.equal(await lru.acquire('a'), 'a#1');
|
||
await lru.acquire('b');
|
||
lru.release('a'); // over max: a goes, its unload is in flight
|
||
assert.ok(lru.unloading('a') && lru.stats().unloading === 1);
|
||
const again = lru.acquire('a'); // same task: taken again
|
||
await tick();
|
||
assert.deepEqual(order, ['load a', 'load b', 'unload a#1'], 'the new load waits');
|
||
finishUnload();
|
||
assert.equal(await again, 'a#3', 'a fresh value once the old one is gone (b was #2)');
|
||
assert.deepEqual(order.slice(3), ['unloaded a#1', 'load a']);
|
||
await tick();
|
||
assert.equal(lru.unloading('a'), false);
|
||
// an unloader that throws or returns nothing never blocks a later load
|
||
const lru2 = new RefLru({ max: 1, load: async (k) => k, unload: () => { throw new Error('x'); } });
|
||
await lru2.acquire('a'); await lru2.acquire('b'); lru2.release('a');
|
||
assert.equal(await lru2.acquire('a'), 'a');
|
||
});
|
||
|
||
test('evictDelay: releases schedule one eviction pass; a scene rebuilt in one go keeps what it takes back', async () => {
|
||
const c = clock(), unloads = [];
|
||
let loads = 0;
|
||
const lru = new RefLru({ max: 2, load: async (k) => { loads++; return k; }, unload: (k) => unloads.push(k), evictDelay: 500, now: c.now, timers: c.timers });
|
||
for (const k of ['a', 'b', 'c']) await lru.acquire(k);
|
||
// every view destroyed, then the next scene's views built (same task): nothing is dropped meanwhile
|
||
for (const k of ['a', 'b', 'c']) lru.release(k);
|
||
for (const k of ['a', 'b']) await lru.acquire(k);
|
||
assert.deepEqual(unloads, []);
|
||
assert.equal(c.pending.length, 1, 'one pass pending');
|
||
c.advance(510);
|
||
assert.deepEqual(unloads, ['c'], 'the pass drops only what nobody took back');
|
||
assert.equal(loads, 3, 'a and b were never reloaded');
|
||
});
|
||
|
||
test('quietDelay: the instant zero of a scene switch is not "no scene"; a lasting one is', async () => {
|
||
const c = clock(), unloads = [];
|
||
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 10,
|
||
maxIdleWeight: 100, quietWeight: 0, idleGrace: 5000, quietDelay: 3000, now: c.now, timers: c.timers });
|
||
await lru.acquire('bench'); await lru.acquire('op');
|
||
lru.release('bench'); // prep → battle: the bench model idles for the whole battle
|
||
c.advance(20000);
|
||
lru.release('op'); // battle → prep: nothing referenced for an instant …
|
||
assert.deepEqual(unloads, [], 'the budget of a shown scene applies (10 + 10 ≤ 100)');
|
||
await lru.acquire('bench'); await lru.acquire('op'); // … the prep views take their models back
|
||
c.advance(5000);
|
||
assert.deepEqual(unloads, []);
|
||
lru.release('bench'); lru.release('op'); // the view is gone for good (lobby)
|
||
c.advance(1000);
|
||
assert.deepEqual(unloads, [], 'not quiet long enough yet');
|
||
c.advance(2100);
|
||
assert.deepEqual(unloads, [], 'quiet, but both are still inside their grace');
|
||
c.advance(2000);
|
||
assert.deepEqual(unloads.sort(), ['bench', 'op'], 'the quiet budget empties the cache');
|
||
});
|
||
});
|
||
|
||
describe('Spine store: unload / reload of the same skeleton (user playtest #3 item 1)', () => {
|
||
const tick = () => new Promise((r) => setImmediate(r));
|
||
/**
|
||
* PIXI.Assets semantics that caused the invisible models: `unload` frees an asset a microtask after the call
|
||
* (Loader.unload awaits the cached promise, then deletes the cache entry and destroys the asset), and `load` of that
|
||
* URL meanwhile returns the cached — doomed — asset.
|
||
*/
|
||
function pixiAssets() {
|
||
const cache = new Map();
|
||
let seq = 0;
|
||
const loader = {
|
||
async unload(url) {
|
||
const hit = cache.get(url);
|
||
if (!hit) return;
|
||
const asset = await hit;
|
||
cache.delete(url);
|
||
asset.destroyed = true;
|
||
},
|
||
};
|
||
const Assets = {
|
||
loader,
|
||
load(url) {
|
||
if (!cache.has(url)) cache.set(url, Promise.resolve({ url, id: ++seq, destroyed: false, animations: [] }));
|
||
return cache.get(url);
|
||
},
|
||
unload(url) { return loader.unload(url); },
|
||
};
|
||
const prev = globalThis.PIXI;
|
||
globalThis.PIXI = { Assets, spine: {} };
|
||
return { Assets, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
|
||
}
|
||
|
||
test('the hazard: PIXI.Assets hands out an asset whose unload is in flight', async () => {
|
||
const f = pixiAssets();
|
||
try {
|
||
await f.Assets.load('/x.skel');
|
||
f.Assets.unload('/x.skel');
|
||
const again = await f.Assets.load('/x.skel');
|
||
await tick();
|
||
assert.equal(again.destroyed, true, 'the model a view would build from it has no textures');
|
||
} finally { f.restore(); }
|
||
});
|
||
|
||
test('battle → prep after a long battle: the bench models come back alive (defaults), with or without an eviction', async () => {
|
||
for (const eager of [false, true]) {
|
||
const f = pixiAssets();
|
||
try {
|
||
const c = clock();
|
||
// eager = the old timing (evict at once, no quiet delay): the bench model IS dropped at the instant zero —
|
||
// the re-acquire must then wait for its unload and load a fresh skeleton
|
||
const a = createAssets({ manifest: M, spineNow: c.now, spineTimers: c.timers, ...(eager ? { spineEvictDelay: 0, spineQuietDelay: 0 } : {}) });
|
||
const bench = a.spineEntry('char_010_chen'), op = a.spineEntry('char_002_amiya'), foe = a.spineEntry('enemy_1007_slime');
|
||
const bench0 = await a.spine.acquire(bench);
|
||
await a.spine.acquire(op);
|
||
// prep → battle: the prep views go (the bench model idles), the battle views take the board model and the foe
|
||
a.spine.release(bench); a.spine.release(op);
|
||
await a.spine.acquire(op); await a.spine.acquire(foe);
|
||
c.advance(SPINE_IDLE_GRACE_MS + 20000); // a long battle: the bench model idles past its grace
|
||
// battle → prep in one go (render/app.js enterPrepMode + setPrep): every battle view destroyed, prep views built
|
||
a.spine.release(op); a.spine.release(foe);
|
||
const [b1, o1] = await Promise.all([a.spine.acquire(bench), a.spine.acquire(op)]);
|
||
await tick(); await tick();
|
||
c.advance(SPINE_EVICT_DELAY_MS + SPINE_QUIET_DELAY_MS + 10);
|
||
await tick(); await tick();
|
||
assert.equal(b1.destroyed, false, `${eager ? 'eager' : 'default'}: the bench model is alive`);
|
||
assert.equal(o1.destroyed, false, `${eager ? 'eager' : 'default'}: the board model is alive`);
|
||
if (eager) assert.notEqual(b1, bench0, 'eager: dropped, then loaded afresh');
|
||
else assert.equal(b1, bench0, 'default: never dropped, never reloaded');
|
||
assert.equal(a.spine.stats().unloading, 0);
|
||
} finally { f.restore(); }
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('spineDataWeight', () => {
|
||
test('counts timelines, deform frames, draw orders and mesh arrays; never throws', () => {
|
||
const f32 = (n) => new Float32Array(n);
|
||
const data = {
|
||
bones: [{}, {}], slots: [{}],
|
||
skins: [{ attachments: [{ body: { vertices: f32(100), regionUVs: f32(50), triangles: [1, 2, 3] }, head: { offset: f32(8) } }, null] }],
|
||
animations: [{ timelines: [{ frames: f32(10000), curves: f32(19 * 3333) }, { frames: f32(10), frameVertices: [f32(2000), f32(2000)] }, { frames: f32(4), drawOrders: [[0, 1], null] }] }],
|
||
};
|
||
const w = spineDataWeight(data);
|
||
const arrays = 4 * (100 + 50 + 8 + 10000 + 19 * 3333 + 10 + 2000 + 2000 + 4) + 8 * (3 + 2);
|
||
assert.ok(w > arrays && w < arrays * 2, `${w} vs ${arrays}`);
|
||
const bigger = { ...data, animations: [...data.animations, { timelines: [{ frames: f32(100000) }] }] };
|
||
assert.ok(spineDataWeight(bigger) - w >= 400000, 'grows with the animation data');
|
||
for (const junk of [null, undefined, 3, 'x', {}, { animations: 5, skins: [{ attachments: 'x' }] }, { animations: [null, { timelines: [7] }] }]) {
|
||
assert.equal(spineDataWeight(junk), SPINE_WEIGHT_MIN);
|
||
}
|
||
});
|
||
|
||
test('the store budgets idle skeletons by weight (defaults: SPINE_IDLE_BYTES, drain when no scene, 15 s grace)', async () => {
|
||
const d = createAssets({ manifest: M, loadSpine: async () => ({ animations: [] }), unloadSpine: () => {} });
|
||
assert.equal(d.spine.cache.maxIdleWeight, SPINE_IDLE_BYTES);
|
||
assert.equal(d.spine.cache.quietWeight, 0);
|
||
assert.equal(d.spine.cache.idleGrace, SPINE_IDLE_GRACE_MS);
|
||
assert.ok(SPINE_IDLE_BYTES <= 64 * 1024 * 1024 && SPINE_IDLE_GRACE_MS >= 5000);
|
||
const c = clock(), unloads = [];
|
||
const big = { animations: [{ timelines: [{ frames: new Float32Array(5 * 1024 * 1024) }] }] }; // ~20 MB parsed
|
||
const a = createAssets({ manifest: M, loadSpine: async () => big, unloadSpine: (e) => unloads.push(e.skel), spineNow: c.now, spineTimers: c.timers });
|
||
const e1 = a.spineEntry('char_002_amiya'), e2 = a.spineEntry('char_010_chen'), e3 = a.spineEntry('enemy_1007_slime'), keep = a.spineEntry('token_a');
|
||
for (const e of [e1, e2, e3, keep]) await a.spine.acquire(e);
|
||
for (const e of [e1, e2, e3]) a.spine.release(e);
|
||
c.advance(SPINE_IDLE_GRACE_MS + 10);
|
||
assert.deepEqual(unloads, [e1.skel], '3 × 20 MB idle > 48 MB: the oldest goes although the count cap (60) is far');
|
||
assert.ok(a.spine.stats().idleWeight <= SPINE_IDLE_BYTES);
|
||
a.spine.release(keep);
|
||
c.advance(SPINE_IDLE_GRACE_MS + 10);
|
||
assert.equal(a.spine.stats().size, 0, 'no scene references any skeleton (lobby / room): all of them go');
|
||
});
|
||
});
|
||
|
||
describe('createAssets store', () => {
|
||
test('fetches the manifest once; helpers bind to it; missing manifest ⇒ nulls', async () => {
|
||
let fetches = 0;
|
||
const a = createAssets({ fetch: async () => { fetches++; return { ok: true, json: async () => M }; } });
|
||
assert.equal(a.avatar('char_002_amiya'), null, 'before ready');
|
||
await Promise.all([a.ready(), a.ready()]);
|
||
assert.equal(fetches, 1);
|
||
assert.equal(a.avatar('char_002_amiya'), '/a/amiya.png');
|
||
assert.equal(a.spineEntry('enemy_1305_mhslim').skel, '/assets/spine/slime.skel');
|
||
assert.equal(a.audio.sfx('battle', 'deploy'), '/sfx/dep.mp3');
|
||
const warn = console.warn; console.warn = () => {};
|
||
try {
|
||
const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) });
|
||
await b.ready();
|
||
assert.equal(b.avatar('char_002_amiya'), null);
|
||
assert.deepEqual(b.manifest, {});
|
||
} finally { console.warn = warn; }
|
||
});
|
||
|
||
test('image cache + preload progress (failures resolve to null)', async () => {
|
||
const loaded = [];
|
||
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad')) throw new Error('x'); return { src: u }; } });
|
||
const progress = [];
|
||
const r = await a.preload(['/a.png', '/bad.png', '/a.png', null, ''], (d, t) => progress.push([d, t]));
|
||
assert.deepEqual(r, { ok: 1, failed: 1, total: 2 });
|
||
assert.deepEqual(progress.map((p) => p[1]), [2, 2]);
|
||
assert.deepEqual((await a.image('/a.png')).src, '/a.png');
|
||
assert.equal(await a.image('/bad.png'), null);
|
||
assert.equal(loaded.filter((u) => u === '/a.png').length, 1, 'cached');
|
||
assert.equal(a.imageNow('/a.png').src, '/a.png');
|
||
assert.equal(await a.image(null), null);
|
||
});
|
||
|
||
test('spine acquire/release through the store with an injected loader', async () => {
|
||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), unloadSpine: () => {}, spineMax: 1 });
|
||
const e = a.spineEntry('char_002_amiya');
|
||
const d = await a.spine.acquire(e);
|
||
assert.equal(d.from, e.skel);
|
||
assert.equal(a.spine.peek(e), d);
|
||
a.spine.release(e);
|
||
await assert.rejects(a.spine.acquire(null), /no spine/);
|
||
assert.equal(a.spine.stats().refs, 0);
|
||
});
|
||
});
|
||
|
||
describe('spine unload frees the atlas page images too', () => {
|
||
// pixi-spine's atlas parser loads every page with loader.load({ src }): the decoded ImageBitmap stays in
|
||
// PIXI.Assets.loader.promiseCache unless the page URL itself is unloaded (evicted models leaked ~0.8 MB each)
|
||
function fakePixi() {
|
||
const calls = { assets: [], loader: [] };
|
||
const prev = globalThis.PIXI;
|
||
globalThis.PIXI = {
|
||
spine: {},
|
||
Assets: { unload: async (u) => { calls.assets.push(u); }, loader: { unload: async (u) => { calls.loader.push(u); } } },
|
||
};
|
||
return { calls, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
|
||
}
|
||
|
||
test('spinePages: manifest textures, else <skel>.png', () => {
|
||
assert.deepEqual(spinePages(SP('x')), ['/assets/spine/x.png']);
|
||
assert.deepEqual(spinePages({ skel: '/s/a.skel', textures: ['/s/a.png', '/s/a2.png'] }), ['/s/a.png', '/s/a2.png']);
|
||
assert.deepEqual(spinePages({ skel: '/s/b.skel' }), ['/s/b.png']);
|
||
for (const junk of [null, 1, {}, { textures: [null, 3] }]) assert.deepEqual(spinePages(junk), []);
|
||
});
|
||
|
||
test('unloadSpineData unloads skeleton, atlas and every page (pages straight from the loader), minus kept ones', () => {
|
||
const f = fakePixi();
|
||
try {
|
||
unloadSpineData({ skel: '/s/a.skel', atlas: '/s/a.atlas', textures: ['/s/a.png', '/s/a2.png'] });
|
||
assert.deepEqual(f.calls.assets, ['/s/a.skel', '/s/a.atlas']);
|
||
assert.deepEqual(f.calls.loader, ['/s/a.png', '/s/a2.png']);
|
||
f.calls.loader.length = 0;
|
||
unloadSpineData({ skel: '/s/b.skel', textures: ['/s/b.png', '/s/shared.png'] }, null, new Set(['/s/shared.png']));
|
||
assert.deepEqual(f.calls.loader, ['/s/b.png']);
|
||
assert.doesNotThrow(() => unloadSpineData(null));
|
||
} finally { f.restore(); }
|
||
});
|
||
|
||
test('LRU eviction through the store unloads the evicted pages, never pages another cached skeleton uses', async () => {
|
||
const f = fakePixi();
|
||
try {
|
||
const E = (id, pages) => ({ ...SP(id), textures: pages });
|
||
const c = clock();
|
||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1, spineNow: c.now, spineTimers: c.timers });
|
||
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
|
||
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
|
||
await a.spine.acquire(ea);
|
||
await a.spine.acquire(eb);
|
||
a.spine.release(ea); // a evicted (max 1) while b still uses common.png …
|
||
assert.deepEqual(f.calls.assets, [], '… in the eviction pass a moment later (SPINE_EVICT_DELAY_MS)');
|
||
c.advance(SPINE_EVICT_DELAY_MS + 10);
|
||
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
|
||
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
|
||
a.spine.release(eb);
|
||
a.spine.clear();
|
||
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png', '/assets/spine/b.png', '/assets/spine/common.png']);
|
||
} finally { f.restore(); }
|
||
});
|
||
|
||
test('a late value of a dropped load does not unload the URLs a newer load of the same skeleton uses', async () => {
|
||
const f = fakePixi();
|
||
try {
|
||
const pending = [];
|
||
const a = createAssets({ manifest: M, loadSpine: (e) => new Promise((r) => pending.push(() => r({ animations: [], from: e.skel }))), spineMax: 5 });
|
||
const e = SP('late');
|
||
const p1 = a.spine.acquire(e);
|
||
a.spine.clear(); // dropped while loading
|
||
const p2 = a.spine.acquire(e); // loading again
|
||
pending.shift()();
|
||
await p1;
|
||
assert.deepEqual(f.calls.loader, [], 'the newer entry keeps its pages');
|
||
assert.deepEqual(f.calls.assets, []);
|
||
pending.shift()();
|
||
assert.equal((await p2).from, e.skel);
|
||
} finally { f.restore(); }
|
||
});
|
||
});
|