A reload (a phone browser or Chrome's Memory Saver discarding the background tab) made the asset store download
/data/assets.json a second time; createFieldView waited at most 4 s for it, every view looked its model and avatar up
once, and a failed download kept an empty manifest for the page. Now the field host seeds the store with the game
data's copies (seedAssets); without one, ready() retries like data.js, never keeps a failure and tries again with a
bounded backoff, and onChange makes every view re-resolve (UnitView.retryAssets, ItemView.setIcon). A failed or
timed-out Spine load is retried by its view (SPINE_RETRY_MS, acquire { retry }) and at once when the tab is shown.
Battle mode holds the Spine cache (spine.hold()), so a battle that began in a hidden tab keeps its models; the idle
budget still applies and the quiet budget still frees everything in the lobby, room and result.
342 lines
19 KiB
JavaScript
342 lines
19 KiB
JavaScript
// Public issue #8 item 5 (v0.1.1): "游览器切换页面后所有干员会变成方块+水滴的图标" — after the tab came back every operator was
|
|
// the image-less placeholder (diamond + droplet glyph). A plain tab switch did not do it; a reload did (phone browsers
|
|
// and Chrome's Memory Saver discard background tabs and reload them on return; the game resumes into the match): the
|
|
// asset store fetched /data/assets.json a second time, createFieldView waited ≤ 4 s for it, every view looked its model
|
|
// and avatar up once, and a failed fetch kept an empty manifest for the page. Without a reload, a battle that began
|
|
// in a hidden tab referenced no skeleton, so the quiet budget emptied the Spine cache ≈ 18 s later.
|
|
// public/js/assets.js: seed / ready retries + backoff / onChange / spine acquire { retry } / spine hold;
|
|
// public/js/render/units.js: UnitView.retryAssets, bounded Spine retries (SPINE_RETRY_MS), ItemView.setIcon;
|
|
// public/js/ui/fieldHost.js seedAssets; public/js/render/app.js (wiring: onChange, visibilitychange, battle hold).
|
|
|
|
import { test, describe, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
|
import { presetCamera } from '../../public/js/render/projection.js';
|
|
import {
|
|
createAssets, RefLru, MANIFEST_RETRY_MS, MANIFEST_BACKOFF_MS, SPINE_IDLE_GRACE_MS, SPINE_QUIET_DELAY_MS, SPINE_EVICT_DELAY_MS,
|
|
} from '../../public/js/assets.js';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
|
const tick = () => new Promise((r) => setImmediate(r));
|
|
const quiet = async (fn) => { const w = console.warn; console.warn = () => {}; try { return await fn(); } finally { console.warn = w; } };
|
|
|
|
/** Fake clock + timers (RefLru `now` / `timers`, the store's backoff `timers`). */
|
|
const clock = () => {
|
|
const c = { t: 0, pending: [] };
|
|
c.now = () => c.t;
|
|
c.timers = { set: (fn, ms) => { const h = { fn, at: c.t + ms, 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) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], anims: { idle: 'Idle' }, animations: { Idle: 1 } });
|
|
const M = {
|
|
chars: { char_002_amiya: { avatar: '/a/amiya.png', spine: { front: SP('amiya_f') } }, char_010_chen: { avatar: '/a/chen.png', spine: { front: SP('chen_f') } } },
|
|
enemies: { enemy_1305_mhslim: { icon: '/e/mh.png', spineLocal: { group: 'spine/enemy/mh', skel: 'mh.skel', atlas: 'mh.atlas', textures: ['mh.png'], anims: { idle: 'Idle' } } } },
|
|
items: { trap_1: '/i/1.png' },
|
|
ui: {},
|
|
};
|
|
const LOCAL = { groups: { 'spine/enemy/mh': { 'mh.skel': { path: '/l/mh.skel' }, 'mh.atlas': { path: '/l/mh.atlas' }, 'mh.png': { path: '/l/mh.png' } } } };
|
|
const ok = (json) => ({ ok: true, status: 200, json: async () => json });
|
|
|
|
describe('the manifest: seeded, retried, never kept empty (assets.js)', () => {
|
|
test('seed: the game data\'s copy is adopted — no download, ready() resolves it, listeners hear it', async () => {
|
|
let fetches = 0;
|
|
const a = createAssets({ fetch: async () => { fetches++; return ok(M); } });
|
|
const heard = [];
|
|
a.onChange((w) => heard.push(w));
|
|
assert.equal(a.seed(M), true);
|
|
assert.equal(await a.ready(), M);
|
|
assert.equal(fetches, 0, 'nothing downloaded');
|
|
assert.equal(a.avatar('char_002_amiya'), '/a/amiya.png');
|
|
assert.deepEqual(heard, ['manifest']);
|
|
assert.equal(a.seed({ chars: {} }), false, 'a loaded manifest is kept');
|
|
assert.equal(a.seed(null), false);
|
|
});
|
|
|
|
test('a transient failure is fetched again within the same ready() (MANIFEST_RETRY_MS, like data.js)', async () => {
|
|
assert.deepEqual([...MANIFEST_RETRY_MS], [600, 2000]);
|
|
const waits = [];
|
|
let n = 0;
|
|
const a = createAssets({ wait: async (ms) => { waits.push(ms); }, fetch: async () => { n++; if (n < 3) throw new TypeError('Failed to fetch'); return ok(M); } });
|
|
assert.equal(await a.ready(), M);
|
|
assert.equal(n, 3);
|
|
assert.deepEqual(waits, [600, 2000]);
|
|
});
|
|
|
|
test('every try failing: ready() resolves {} (fallbacks), the failure is not kept — the next ready() fetches again', async () => {
|
|
let n = 0, up = false;
|
|
const c = clock();
|
|
const a = createAssets({ wait: async () => {}, timers: c.timers, fetch: async () => { n++; if (!up) return { ok: false, status: 503 }; return ok(M); } });
|
|
const heard = [];
|
|
a.onChange((w) => heard.push(w));
|
|
assert.deepEqual(await quiet(() => a.ready()), {});
|
|
assert.equal(n, 3, 'the first try + 2 retries');
|
|
assert.equal(a.manifest, null);
|
|
assert.equal(a.loaded, false);
|
|
assert.equal(a.avatar('char_002_amiya'), null, 'helpers fall back meanwhile');
|
|
up = true;
|
|
assert.equal(await a.ready(), M, 'asked again: fetched again (no empty manifest kept for the page)');
|
|
assert.equal(n, 4);
|
|
assert.deepEqual(heard, ['manifest']);
|
|
assert.equal(c.pending.length, 0, 'the background try was cancelled by the success');
|
|
});
|
|
|
|
test('after a failed ready() the store tries again by itself, with a bounded backoff', async () => {
|
|
assert.ok(MANIFEST_BACKOFF_MS.length >= 3 && MANIFEST_BACKOFF_MS.every((ms, i) => i === 0 || ms >= MANIFEST_BACKOFF_MS[i - 1]));
|
|
const c = clock();
|
|
let n = 0, up = false;
|
|
const a = createAssets({ wait: async () => {}, timers: c.timers, backoff: [100, 200], retryDelays: [], fetch: async () => { n++; if (!up) throw new TypeError('offline'); return ok(M); } });
|
|
const heard = [];
|
|
a.onChange((w) => heard.push(w));
|
|
await quiet(() => a.ready());
|
|
assert.equal(n, 1);
|
|
assert.deepEqual(c.pending.map((h) => h.ms), [100]);
|
|
await quiet(async () => { c.advance(100); await tick(); await tick(); });
|
|
assert.equal(n, 2, 'tried again after 100 ms');
|
|
assert.deepEqual(c.pending.map((h) => h.ms), [200]);
|
|
up = true;
|
|
c.advance(200); await tick(); await tick();
|
|
assert.equal(n, 3);
|
|
assert.equal(a.manifest, M, 'arrived late');
|
|
assert.deepEqual(heard, ['manifest'], 'listeners (render/app.js) re-resolve their views now');
|
|
// bounded: a store that never gets it stops after the backoff list
|
|
const d = clock();
|
|
let m = 0;
|
|
const b = createAssets({ wait: async () => {}, timers: d.timers, backoff: [10, 20], retryDelays: [], fetch: async () => { m++; throw new TypeError('offline'); } });
|
|
await quiet(async () => {
|
|
await b.ready();
|
|
for (let i = 0; i < 6; i++) { d.advance(50); await tick(); await tick(); }
|
|
});
|
|
assert.equal(m, 3, 'the first try + 2 background tries');
|
|
assert.equal(d.pending.length, 0);
|
|
});
|
|
|
|
test('a 404 or a body that is no JSON: no retry loop, but still not kept (the next ready() asks again)', async () => {
|
|
const c = clock();
|
|
let n = 0;
|
|
const a = createAssets({ wait: async () => {}, timers: c.timers, fetch: async () => { n++; return n === 1 ? { ok: false, status: 404 } : { ok: true, status: 200, json: async () => { throw new SyntaxError('x'); } }; } });
|
|
await quiet(() => a.ready());
|
|
assert.equal(n, 1);
|
|
assert.equal(c.pending.length, 0, 'no background tries for a definite failure');
|
|
await quiet(() => a.ready());
|
|
assert.equal(n, 2, 'asked again');
|
|
assert.equal(a.manifest, null);
|
|
});
|
|
|
|
test('a seed while a fetch is still retrying wins; the late fetch changes nothing', async () => {
|
|
let release;
|
|
const gate = new Promise((r) => { release = r; });
|
|
const a = createAssets({ wait: () => gate, fetch: async () => { throw new TypeError('slow network'); } });
|
|
const p = a.ready();
|
|
a.seed(M);
|
|
release();
|
|
assert.equal(await p, M);
|
|
assert.equal(await a.ready(), M);
|
|
});
|
|
|
|
test('seedLocal: the local-client manifest from the game data (an enemy\'s local model resolves with it)', async () => {
|
|
let fetches = 0;
|
|
const a = createAssets({ manifest: M, fetch: async () => { fetches++; return ok(LOCAL); } });
|
|
const heard = [];
|
|
a.onChange((w) => heard.push(w));
|
|
assert.equal(a.spineEntry('enemy_1305_mhslim'), null, 'no web model, no local manifest yet');
|
|
assert.equal(a.seedLocal(LOCAL), true);
|
|
assert.equal(await a.local(), LOCAL);
|
|
assert.equal(fetches, 0);
|
|
assert.equal(a.spineEntry('enemy_1305_mhslim').skel, '/l/mh.skel');
|
|
assert.deepEqual(heard, ['local']);
|
|
assert.equal(a.seedLocal({ groups: {} }), false, 'a known one is kept');
|
|
assert.equal(createAssets({ manifest: M }).seedLocal({ nope: 1 }), false, 'not a local manifest');
|
|
});
|
|
});
|
|
|
|
describe('Spine cache: retries and the scene hold (RefLru)', () => {
|
|
test('a remembered failure is shared until failTtl — acquire { retry } loads again once nobody references it', async () => {
|
|
let calls = 0, fail = true;
|
|
const lru = new RefLru({ failTtl: 60000, now: () => 0, load: async (k) => { calls++; if (fail) throw new Error('timeout'); return k; } });
|
|
await assert.rejects(lru.acquire('a'));
|
|
lru.release('a');
|
|
await assert.rejects(lru.acquire('a'), 'remembered: no second download for every new view');
|
|
lru.release('a');
|
|
assert.equal(calls, 1);
|
|
fail = false;
|
|
assert.equal(await lru.acquire('a', null, { retry: true }), 'a', 'a retry loads again');
|
|
assert.equal(calls, 2);
|
|
lru.release('a');
|
|
// a failure some caller still references is shared (dropping it would make that caller's release hit the new entry)
|
|
const b = new RefLru({ load: async () => { throw new Error('x'); } });
|
|
const p1 = b.acquire('k');
|
|
await assert.rejects(p1);
|
|
await assert.rejects(b.acquire('k', null, { retry: true }));
|
|
assert.equal(b.map.get('k').refs, 2);
|
|
});
|
|
|
|
test('hold(): no quiet budget while a scene holds the cache; the idle budget still applies; released → quiet again', async () => {
|
|
const c = clock(), unloads = [];
|
|
const lru = new RefLru({ load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => (k === 'big' ? 900 : 100),
|
|
maxIdleWeight: 1000, quietWeight: 0, idleGrace: SPINE_IDLE_GRACE_MS, quietDelay: SPINE_QUIET_DELAY_MS, evictDelay: SPINE_EVICT_DELAY_MS,
|
|
now: c.now, timers: c.timers });
|
|
for (const k of ['op1', 'op2', 'big']) await lru.acquire(k);
|
|
// battle mode: the prep views go before the battle's views are built — in a hidden tab, not for a long while
|
|
const release = lru.hold();
|
|
for (const k of ['op1', 'op2', 'big']) lru.release(k);
|
|
c.advance(SPINE_IDLE_GRACE_MS + SPINE_QUIET_DELAY_MS + 10000);
|
|
assert.deepEqual(unloads, ['op1'], 'held: only the idle budget (1100 > 1000: the least recently used goes), not the quiet one');
|
|
assert.equal(lru.stats().holds, 1);
|
|
// the battle's views come back (the tab is shown): their models are still cached
|
|
assert.equal(lru.peek('op2'), 'op2');
|
|
release();
|
|
release(); // twice: no effect
|
|
assert.equal(lru.stats().holds, 0);
|
|
c.advance(SPINE_QUIET_DELAY_MS - 100);
|
|
assert.deepEqual(unloads, ['op1'], 'the quiet delay counts from the release');
|
|
c.advance(200 + SPINE_EVICT_DELAY_MS);
|
|
assert.deepEqual(unloads.sort(), ['big', 'op1', 'op2'], 'no scene: every idle skeleton goes (the phone gets its memory back)');
|
|
});
|
|
|
|
test('without a hold the old behaviour stays: nothing referenced for the quiet delay → the cache empties', async () => {
|
|
const c = clock(), unloads = [];
|
|
const lru = new RefLru({ load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 100, maxIdleWeight: 1e9, quietWeight: 0,
|
|
idleGrace: SPINE_IDLE_GRACE_MS, quietDelay: SPINE_QUIET_DELAY_MS, evictDelay: SPINE_EVICT_DELAY_MS, now: c.now, timers: c.timers });
|
|
await lru.acquire('op');
|
|
lru.release('op');
|
|
c.advance(SPINE_IDLE_GRACE_MS + SPINE_QUIET_DELAY_MS + 10);
|
|
assert.deepEqual(unloads, ['op']);
|
|
});
|
|
|
|
test('the store exposes them: spine.acquire(entry, { retry }) and spine.hold()', async () => {
|
|
let calls = 0, fail = true;
|
|
const a = createAssets({ manifest: M, loadSpine: async (e) => { calls++; if (fail) throw new Error('x'); return { animations: [], from: e.skel }; }, unloadSpine: () => {} });
|
|
const e = a.spineEntry('char_002_amiya');
|
|
await assert.rejects(a.spine.acquire(e)); a.spine.release(e);
|
|
fail = false;
|
|
await assert.rejects(a.spine.acquire(e)); a.spine.release(e);
|
|
assert.equal((await a.spine.acquire(e, { retry: true })).from, e.skel);
|
|
assert.equal(calls, 2);
|
|
const r = a.spine.hold();
|
|
assert.equal(a.spine.stats().holds, 1);
|
|
r();
|
|
assert.equal(a.spine.stats().holds, 0);
|
|
});
|
|
});
|
|
|
|
describe('views re-resolve and retry (render/units.js)', () => {
|
|
let fake, UnitView, ItemView, SPINE_RETRY_MS;
|
|
before(async () => {
|
|
fake = installFakePixi();
|
|
({ UnitView, ItemView, SPINE_RETRY_MS } = await import('../../public/js/render/units.js'));
|
|
});
|
|
after(() => fake.restore());
|
|
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
|
|
const IMG = { width: 180, height: 180 };
|
|
const unit = (assets, info = {}) => new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_002_amiya', spine: 'char_002_amiya', avatar: 'char_002_amiya', tier: 3, x: 5, y: 12, maxHp: 1000, ...info }, { prep: true });
|
|
|
|
test('a view built before the manifest (a slow reload) is a placeholder; when it arrives retryAssets loads the model and avatar', async () => {
|
|
const a = createAssets({ fetch: async () => ({ ok: false, status: 503 }), wait: async () => {}, retryDelays: [], backoff: [], loadImage: async () => IMG,
|
|
loadSpine: async () => ({ animations: [{ name: 'Idle' }] }), unloadSpine: () => {} });
|
|
await quiet(() => a.ready());
|
|
const v = unit(a);
|
|
await tick(); await tick();
|
|
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
|
|
assert.equal(v.actor, null);
|
|
assert.equal(v._pic.state, 'none', 'no avatar URL without the manifest');
|
|
assert.equal(v._pic.shown, 'placeholder', 'the image-less diamond of the report');
|
|
// the manifest arrives (render/app.js calls retryAssets on onChange)
|
|
let heard = 0;
|
|
a.onChange(() => { heard++; v.retryAssets(); });
|
|
a.seed(M);
|
|
await tick(); await tick(); await tick();
|
|
assert.equal(heard, 1);
|
|
assert.ok(v.actor && v.spineReady, 'the Spine model is shown');
|
|
assert.equal(v._pic.state, 'img', 'and its avatar is known for the diamond');
|
|
});
|
|
|
|
test('a failed / timed-out model is loaded again after SPINE_RETRY_MS (bounded); a success ends the retries', async () => {
|
|
assert.deepEqual([...SPINE_RETRY_MS], [2000, 6000, 15000, 30000]);
|
|
let calls = 0, fail = true;
|
|
const a = createAssets({ manifest: M, loadImage: async () => IMG, unloadSpine: () => {},
|
|
loadSpine: async () => { calls++; if (fail) throw new Error('load timeout'); return { animations: [{ name: 'Idle' }] }; } });
|
|
const v = unit(a);
|
|
await tick(); await tick();
|
|
assert.equal(calls, 1);
|
|
assert.equal(v.actor, null);
|
|
assert.ok(v._retryAt > 0, 'a retry is due later');
|
|
v.update(1 / 60, cam(), 0);
|
|
await tick();
|
|
assert.equal(calls, 1, 'not before its wait');
|
|
// every retry fails until the list runs out
|
|
for (let i = 0; i < SPINE_RETRY_MS.length; i++) {
|
|
v._retryAt = 1; // due
|
|
v.update(1 / 60, cam(), 0);
|
|
await tick(); await tick();
|
|
}
|
|
assert.equal(calls, 1 + SPINE_RETRY_MS.length, 'bypassing the remembered failure (acquire { retry })');
|
|
assert.equal(v._retryAt, 0, 'bounded: no more retries');
|
|
for (let i = 0; i < 5; i++) v.update(1 / 60, cam(), 0);
|
|
await tick();
|
|
assert.equal(calls, 1 + SPINE_RETRY_MS.length);
|
|
// the tab shown again (render/app.js visibilitychange → retryAssets): loads at once
|
|
fail = false;
|
|
v.retryAssets();
|
|
await tick(); await tick();
|
|
assert.ok(v.actor && v.spineReady);
|
|
assert.equal(v._spineTries, 0);
|
|
assert.equal(a.spine.stats().refs, 1, 'every acquire paired with one release');
|
|
// nothing to do for a view that shows its model
|
|
v.retryAssets();
|
|
await tick();
|
|
assert.equal(calls, 2 + SPINE_RETRY_MS.length);
|
|
v.destroy();
|
|
assert.equal(a.spine.stats().refs, 0);
|
|
});
|
|
|
|
test('retryAssets never doubles a load in flight; a destroyed view retries nothing', async () => {
|
|
let resolve, calls = 0;
|
|
const a = createAssets({ manifest: M, loadImage: async () => IMG, unloadSpine: () => {}, loadSpine: () => { calls++; return new Promise((r) => { resolve = r; }); } });
|
|
const v = unit(a);
|
|
v.retryAssets();
|
|
v.retryAssets();
|
|
assert.equal(calls, 1);
|
|
resolve({ animations: [{ name: 'Idle' }] });
|
|
await tick(); await tick();
|
|
assert.ok(v.actor);
|
|
const w = unit(createAssets({ manifest: {}, loadImage: async () => IMG }));
|
|
w.destroy();
|
|
assert.doesNotThrow(() => w.retryAssets());
|
|
});
|
|
|
|
test('ItemView.setIcon: an icon the manifest named late replaces the plain plate', async () => {
|
|
const a = createAssets({ manifest: M, loadImage: async () => IMG });
|
|
const it = new ItemView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 'p:9', uid: 9, kind: 'item', defId: 'trap_1', icon: null, color: 0xffffff });
|
|
const plain = it.plate.texture;
|
|
it.setIcon('/i/1.png');
|
|
await tick(); await tick();
|
|
assert.notEqual(it.plate.texture, plain);
|
|
assert.equal(it.info.icon, '/i/1.png');
|
|
});
|
|
});
|
|
|
|
describe('wiring (render/app.js, ui/fieldHost.js)', () => {
|
|
test('the field view re-resolves its views on a late manifest and on a shown tab, and holds the cache in battle mode', () => {
|
|
const app = read('public/js/render/app.js');
|
|
assert.match(app, /const offAssets = typeof assets\.onChange === 'function' \? assets\.onChange\(onAssets\) : null;/);
|
|
assert.match(app, /for \(const v of views\.values\(\)\) v\.retryAssets\?\.\(\);/);
|
|
assert.match(app, /globalThis\.document\?\.addEventListener\?\.\('visibilitychange', onVisible\);/);
|
|
assert.match(app, /globalThis\.document\?\.removeEventListener\?\.\('visibilitychange', onVisible\);/);
|
|
assert.match(app, /function enterBattle\(meta\) \{\n {4}if \(destroyed \|\| !meta \|\| typeof meta !== 'object'\) return false;\n {4}holdScene\(true\);/);
|
|
assert.match(app, /holdScene\(false\); \/\/ the prep pieces reference their models now/);
|
|
assert.match(app, /holdScene\(false\); \/\/ no scene any more/);
|
|
});
|
|
|
|
test('the field host seeds the asset store with the game data\'s copies', () => {
|
|
const fh = read('public/js/ui/fieldHost.js');
|
|
assert.match(fh, /opts\.assets = am\.assets;\n {8}seedAssets\(am\.assets\);/);
|
|
assert.match(fh, /give\('assets', store\.seed\?\.bind\(store\)\);\n {2}give\('local', store\.seedLocal\?\.bind\(store\)\);/);
|
|
});
|
|
});
|