From b7ac003e098213b2738ea54ba8546df6145aaf28 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 16:15:01 +0800 Subject: [PATCH] feedback1 ui: operators no longer stay placeholders after a reload or a battle begun in a hidden tab (GitHub #8) 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. --- public/js/assets.js | 214 ++++++++++++--- public/js/render/app.js | 79 +++++- public/js/render/units.js | 71 +++-- public/js/ui/fieldHost.js | 28 +- test/render/assets.test.js | 7 +- test/render/issue8-placeholders.test.js | 341 ++++++++++++++++++++++++ 6 files changed, 676 insertions(+), 64 deletions(-) create mode 100644 test/render/issue8-placeholders.test.js diff --git a/public/js/assets.js b/public/js/assets.js index 11e399ce..2112d42e 100644 --- a/public/js/assets.js +++ b/public/js/assets.js @@ -2,10 +2,30 @@ // Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer. A // skeleton is never handed out while its unload is in flight (RefLru; user playtest #3 item 1: invisible models). // +// Manifest (public issue #8 item 5: after a page reload every operator was drawn as the image-less placeholder): seeded +// from the game data's copy when there is one (`seed`, ui/fieldHost.js), else fetched by `ready()` with retries; a +// failure is never kept as an empty manifest (the next `ready()` and a bounded background backoff fetch again), and +// `onChange` listeners hear when it arrives late (render/app.js re-resolves its views' models then). +// +// Spine memory policy (phones have little memory; a parsed skeleton holds ~0.1–12 MB, `spineDataWeight`): +// * a skeleton a view references is never evicted; +// * idle (unreferenced) skeletons are kept up to SPINE_IDLE_BYTES while a scene is on screen, each safe for +// SPINE_IDLE_GRACE_MS after its release (a prep ⇄ battle switch takes its models back); beyond the budget the least +// recently used go, in one pass SPINE_EVICT_DELAY_MS after the release; +// * quiet — no scene on screen (lobby, room, result: nothing referenced and no scene holding the cache for +// SPINE_QUIET_DELAY_MS) — every idle skeleton goes after its grace: the memory goes back to the phone; +// * a match view in battle mode holds the cache (`spine.hold()`, render/app.js): its battle views are built in +// animation frames, which a hidden tab does not run, so a battle that began in a background tab referenced nothing +// and the quiet budget emptied the cache ≈ 18 s later — back in the tab every unit was an avatar diamond until its +// model downloaded again (public issue #8 item 5). The idle budget still applies while it holds. +// A model that fails or times out is retried by its view (bounded, render/units.js), `acquire(entry, { retry: true })`. +// // Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file). // -// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use) -// await assets.ready(); // manifest loaded (or failed → every helper returns null) +// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use, or seeded) +// await assets.ready(); // manifest loaded (or failed for now → every helper returns null) +// assets.seed(data.get('assets')); // adopt an already loaded copy of /data/assets.json +// assets.onChange((what) => …); // 'manifest' | 'local' arrived (late) // assets.avatar('char_002_amiya', { e2: true }) // URL or null // const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout // assets.spine.release(entry); @@ -215,12 +235,17 @@ export function unitSfxUrl(m, id, kind, skillIndex) { * LRU cache of refcounted async resources. `load(key, arg)` → Promise; `unload(key, value, record)` frees it * (`record` is the cache record the value belonged to) and may return a promise that settles once it is freed. * Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use. - * Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown. + * Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown — except for + * `acquire(key, arg, { retry: true })`, which drops a remembered failure nobody references and loads again (a view + * retrying a failed or timed-out model: render/units.js). * Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted, * least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` once nothing at all - * has been referenced for `quietDelay` ms (no scene on screen; the instant zero in the middle of a scene switch is not - * quiet) — once they have been idle for `idleGrace` ms (a scene switch releases and re-acquires its models within that - * window, so it never reloads them); a timer sweeps what the grace held back. + * has been referenced and no scene holds the cache (`hold()`) for `quietDelay` ms (no scene on screen; the instant zero + * in the middle of a scene switch is not quiet) — once they have been idle for `idleGrace` ms (a scene switch releases + * and re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back. + * `hold()` → release function: a scene that will reference its values again although it references none right now + * (render/app.js in battle mode: the battle's views are built in animation frames, which a hidden tab does not run) + * keeps the quiet budget off while it holds; the `maxIdleWeight` budget still applies. * `evictDelay` > 0: a release (or a finished load) schedules the eviction pass that much later instead of running it * at once, so a scene rebuilt in one go (every view destroyed, then the next scene's views built) never drops a value * it takes again. @@ -255,7 +280,8 @@ export class RefLru { /** @type {Map} */ this.map = new Map(); this._refs = 0; // Σ refs of every entry - this._quietAt = this.now(); // since when nothing has been referenced (null while something is) + this._holds = 0; // scenes holding the cache (hold()): never quiet meanwhile + this._quietAt = this.now(); // since when nothing has been referenced nor held (null while something is) /** @type {Map>} key → unload still in flight (settles, never rejects) */ this._unloading = new Map(); this._active = 0; @@ -272,10 +298,14 @@ export class RefLru { has(key) { return this.map.has(key); } get size() { return this.map.size; } - /** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */ - acquire(key, arg) { + /** + * Acquire a reference; resolves to the value (or rejects). Always pair with release(key). `opts.retry`: a remembered + * failure that nobody references any more is dropped and the value loaded again (a failure still referenced — a + * caller that has not released it yet — is shared: dropping it would make that release hit the new entry). + */ + acquire(key, arg, opts) { let e = this.map.get(key); - if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; } + if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; } if (!e) { e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null }; this.map.set(key, e); @@ -313,12 +343,29 @@ export class RefLru { if (!e) return; if (e.refs > 0) { if (--e.refs === 0) e.idleSince = this.now(); - if (--this._refs <= 0) { this._refs = 0; this._quietAt = this.now(); } + if (--this._refs <= 0) { this._refs = 0; if (this._holds === 0) this._quietAt = this.now(); } } e.used = ++this._tick; this._requestEvict(); } + /** + * Hold the cache for a scene that will reference its values again (see the header): no quiet budget until the + * returned function is called (once; later calls do nothing). The quiet delay then counts from the release. + * @returns {() => void} + */ + hold() { + this._holds++; + this._quietAt = null; + let held = true; + return () => { + if (!held) return; + held = false; + if (--this._holds <= 0) { this._holds = 0; if (this._refs === 0) this._quietAt = this.now(); } + this._requestEvict(); + }; + } + /** Is an unload of `key` still in flight (a new load of it waits for it)? */ unloading(key) { return this._unloading.has(key); } @@ -350,7 +397,7 @@ export class RefLru { const now = this.now(); let budget = this.maxIdleWeight; let wait = Infinity; - if (this._refs === 0) { + if (this._refs === 0 && this._holds === 0) { const quietFor = now - (this._quietAt ?? now); if (quietFor >= this.quietDelay) budget = this.quietWeight; else if (idleWeight > this.quietWeight) wait = this.quietDelay - quietFor; // re-check once the quiet has lasted @@ -393,13 +440,13 @@ export class RefLru { this._sweep = { timer, at }; } - /** Unload everything (refs ignored). */ + /** Unload everything (refs ignored; a scene's hold stays). */ clear() { if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; } const all = [...this.map.values()]; this.map.clear(); this._refs = 0; - this._quietAt = this.now(); + this._quietAt = this._holds > 0 ? null : this.now(); for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e); } @@ -411,7 +458,7 @@ export class RefLru { weight += e.weight || 0; if (e.refs === 0) idleWeight += e.weight || 0; } - return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size }; + return { size: this.map.size, ready, loading, failed, refs, holds: this._holds, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size }; } _schedule(fn) { @@ -561,16 +608,38 @@ export const SPINE_IDLE_GRACE_MS = 15000; * before anything is dropped (user playtest #3 item 1: the bench models were unloaded and re-loaded in one go). */ export const SPINE_EVICT_DELAY_MS = 1000; -/** Nothing referenced for this long = no scene on screen (lobby / room): the idle skeletons may all go. */ +/** Nothing referenced (and no scene holding the cache) for this long = no scene on screen (lobby / room): the idle skeletons may all go. */ export const SPINE_QUIET_DELAY_MS = 3000; +/** Waits (ms) before the manifest is fetched again within one `ready()` after a transient failure (like data.js). */ +export const MANIFEST_RETRY_MS = Object.freeze([600, 2000]); +/** Waits (ms) before the store tries again by itself after a `ready()` whose fetches all failed transiently. */ +export const MANIFEST_BACKOFF_MS = Object.freeze([5000, 15000, 30000, 60000]); + +/** A fetch failure worth retrying (data.js): network error, HTTP 5xx / 408 / 429 — not a 404 or a body that is no JSON. */ +const transientFetch = (err) => { + if (!err || err.badJson) return false; + const s = err.status; + return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429); +}; /** * Create an asset store. - * @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise, + * + * Manifest (public issue #8 item 5: after a page reload — a phone browser discarding a background tab, Chrome's Memory + * Saver — every operator was drawn as the image-less placeholder): the game data already holds /data/assets.json (the + * match screen waits for it, ui/gameComponents.js GAME_FILES), so `seed(manifest)` adopts that copy (ui/fieldHost.js) and + * the store fetches nothing. Fetched by `ready()` (no seed yet): a transient failure is retried after MANIFEST_RETRY_MS; + * when every try fails `ready()` resolves with an empty object — every helper returns null meanwhile, the views draw + * their fallbacks — but the failure is never kept: the next `ready()` fetches again and the store tries again by itself + * after MANIFEST_BACKOFF_MS (bounded; a 404 or a body that is no JSON only on the next `ready()`). `onChange(fn)` is told + * when the manifest (`'manifest'`) or the optional local-client manifest (`'local'`) arrives, so views built without it + * resolve their models then (render/app.js → UnitView.retryAssets). + * @param {{ url?: string, fetch?: typeof fetch, manifest?: object, localManifest?: object, loadImage?: (url) => Promise, * loadSpine?: (entry) => Promise, unloadSpine?: (entry, value, keepPages:Set) => (Promise|void), * spineMax?: number, spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number, * spineQuietBytes?: number, spineIdleGrace?: number, spineEvictDelay?: number, spineQuietDelay?: number, - * spineTimers?: { set, clear }, spineNow?: () => number }} [opts] + * spineTimers?: { set, clear }, spineNow?: () => number, retryDelays?: number[], backoff?: number[], + * wait?: (ms: number) => Promise, timers?: { set, clear } }} [opts] */ export function createAssets(options) { const opts = options && typeof options === 'object' ? options : {}; @@ -583,20 +652,68 @@ export function createAssets(options) { let readyPromise = manifest ? Promise.resolve(manifest) : null; const loadImage = opts.loadImage || loadImageElement; const images = new Map(); // url → { promise, value } + const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : MANIFEST_RETRY_MS; + const backoff = Array.isArray(opts.backoff) ? opts.backoff : MANIFEST_BACKOFF_MS; + const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const timers = opts.timers && typeof opts.timers.set === 'function' ? opts.timers : { + set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; }, + clear: (t) => clearTimeout(t), + }; + const listeners = new Set(); + let warned = false; + let backoffN = 0; // background tries used since the last success + let backoffTimer = null; + + const notify = (what) => { + for (const fn of [...listeners]) { try { fn(what); } catch (err) { console.warn('[assets] change listener failed', err); } } + }; + + /** Adopt a manifest (fetched or seeded): resolves `ready()`, stops the background tries, tells the listeners. */ + function adopt(m) { + if (manifest || !isObj(m)) return false; + manifest = m; + readyPromise = Promise.resolve(m); + backoffN = 0; + if (backoffTimer != null) { timers.clear(backoffTimer); backoffTimer = null; } + notify('manifest'); + return true; + } + + /** One fetch of the manifest → the JSON object, or throws (err.status / err.badJson as data.js). */ + async function fetchOnce() { + const res = await doFetch(url, { cache: 'no-cache' }); + if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); + let json; + try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); } + if (!isObj(json)) throw Object.assign(new Error('not a manifest'), { badJson: true }); + return json; + } + + function scheduleBackoff() { + if (manifest || backoffTimer != null || backoffN >= backoff.length) return; + const ms = backoff[backoffN++]; + backoffTimer = timers.set(() => { backoffTimer = null; if (!manifest) ready(); }, ms); + } function ready() { + if (manifest) return Promise.resolve(manifest); if (!readyPromise) { - readyPromise = (async () => { - try { - const res = await doFetch(url, { cache: 'no-cache' }); - if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`); - const json = await res.json(); - manifest = isObj(json) ? json : {}; - } catch (err) { - console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`); - manifest = {}; + const p = readyPromise = (async () => { + for (let attempt = 0; ; attempt++) { + try { + const json = await fetchOnce(); + adopt(json); + return manifest; + } catch (err) { + if (manifest) return manifest; // seeded meanwhile + if (transientFetch(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); if (manifest) return manifest; continue; } + if (!warned) { warned = true; console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks until it loads`); } + // not kept: the next ready() fetches again; the store tries again by itself after a transient failure + if (readyPromise === p) readyPromise = null; + if (transientFetch(err)) scheduleBackoff(); + return {}; + } } - return manifest; })(); } return readyPromise; @@ -621,9 +738,9 @@ export function createAssets(options) { timeout: opts.spineTimeout ?? 20000, concurrency: opts.spineConcurrency ?? 6, // memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of - // them once no scene has referenced any for SPINE_QUIET_DELAY_MS (lobby / room), each after a grace that covers a - // prep ⇄ battle switch; evictions run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch - // takes its models back first) + // them once no scene has referenced or held any for SPINE_QUIET_DELAY_MS (lobby / room / result; a field view in + // battle mode holds the cache — see the header), each after a grace that covers a prep ⇄ battle switch; evictions + // run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch takes its models back first) weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)), maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES, quietWeight: opts.spineQuietBytes ?? 0, @@ -650,16 +767,16 @@ export function createAssets(options) { return e.promise; } - /** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */ + /** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once (or seeded). */ function local() { if (!localPromise) { localPromise = (async () => { try { const res = await doFetch(localUrl, { cache: 'no-cache' }); - if (!res || !res.ok) return null; + if (!res || !res.ok) return localManifest; const json = await res.json(); - localManifest = isObj(json) && isObj(json.groups) ? json : null; - } catch { localManifest = null; } + if (!localManifest && isObj(json) && isObj(json.groups)) { localManifest = json; notify('local'); } + } catch { /* optional art: absent */ } return localManifest; })(); } @@ -670,6 +787,25 @@ export function createAssets(options) { return { ready, local, + /** + * Adopt the game data's copy of /data/assets.json (data.js 'assets', already loaded by the match screen) unless a + * manifest is loaded: no second download, nothing to wait for. Returns whether it was adopted. + */ + seed: (mf) => adopt(mf), + /** The same for the optional local-client manifest (data.js 'local'; `{ groups }`), unless one is known. */ + seedLocal(lm) { + if (localManifest || !isObj(lm) || !isObj(lm.groups)) return false; + localManifest = lm; + localPromise = Promise.resolve(lm); + notify('local'); + return true; + }, + /** `fn('manifest' | 'local')` when a manifest arrives (seeded or fetched, also late); returns the unsubscribe. */ + onChange(fn) { + if (typeof fn !== 'function') return () => {}; + listeners.add(fn); + return () => listeners.delete(fn); + }, localUrl: (group, name) => localAssetUrl(localManifest, group, name), get manifest() { return manifest; }, get loaded() { return !!manifest; }, @@ -709,11 +845,15 @@ export function createAssets(options) { }))); return { ok, failed: list.length - ok, total: list.length }; }, - /** Spine data LRU: acquire(entry) → Promise; release(entry); peek(entry). */ + /** + * Spine data LRU: acquire(entry, { retry }) → Promise (`retry`: load again after a remembered failure); + * release(entry); peek(entry); hold() → release function (a scene that will need its skeletons again: RefLru). + */ spine: { - acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))), + acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))), release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); }, peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null), + hold: () => spine.hold(), stats: () => spine.stats(), clear: () => spine.clear(), cache: spine, diff --git a/public/js/render/app.js b/public/js/render/app.js index e4431517..76893c83 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -83,9 +83,12 @@ // context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL // overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }. // three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset -// manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D -// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault -// prep = the boss field). +// manifest (≤ 4 s; the game seeds the store with its own copies, ui/fieldHost.js seedAssets), so an enemy whose model +// only the local client has draws it (assets.js spineEntry). A manifest that arrives later (assets.js onChange) makes +// every view re-resolve its picture and model (UnitView.retryAssets), and so does showing the tab again; battle mode +// holds the Spine cache so a battle begun in a hidden tab keeps its models (holdScene; public issue #8 item 5). The +// built 3D area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final +// Assault prep = the boss field). // Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates. // Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per // frame before the main pass. @@ -446,13 +449,18 @@ export async function createFieldView(host, options = {}) { bgMountains2.alpha = 1; const bgVignette = new P.Sprite(bg.vignette); backdrop.addChild(bgGrad, bgMountains, bgMountains2, bgGrid, bgVignette); - const mountainUrl = assets.ui ? assets.ui('entry/bg_mountains_tiled') : null; - if (mountainUrl && assets.image) { + let mountainsAsked = false; + /** The backdrop's mountain silhouette (optional art; asked again when the manifest arrives late). */ + function loadMountains() { + const mountainUrl = !mountainsAsked && assets.ui ? assets.ui('entry/bg_mountains_tiled') : null; + if (!mountainUrl || !assets.image) return; + mountainsAsked = true; assets.image(mountainUrl).then((img) => { if (!img || destroyed) return; try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ } }, () => {}); } + loadMountains(); // ---- state ---------------------------------------------------------------------------------------------- const listeners = new Map(); @@ -475,6 +483,7 @@ export async function createFieldView(host, options = {}) { let promoBase = []; const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests let battleMeta = null; + let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene) const infos = new Map(); // battle unit id → UnitInfo // battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps // dead units for DIE_ANIM_TIME), which must not bring the view back; a spawn / deploy / live sample clears it @@ -628,15 +637,25 @@ export async function createFieldView(host, options = {}) { const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); await withTimeout(ready, 6000); } - // the official soft shadow sprite replaces the procedural one once loaded (may already be cached) - const shadowUrl = assets.ui ? assets.ui('battle/sprite_shadow') : null; - if (shadowUrl) { + // the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the + // manifest arrives late) + let shadowAsked = false; + function loadShadow() { + const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null; + if (!shadowUrl) return; + shadowAsked = true; try { const t = P.Texture.from(shadowUrl); - const use = () => { if (destroyed) return; ctx.shadowTex = t; for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; }; + const use = () => { + if (destroyed) return; + ctx.shadowTex = t; + for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; + for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; + }; if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use); } catch { /* optional */ } } + loadShadow(); ensureDamageFonts(); // web fonts may land after the first chips were drawn if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {}); @@ -950,6 +969,7 @@ export async function createFieldView(host, options = {}) { } v._home = w; v.dimmed = false; + if (info.kind === 'item' && v.setIcon) v.setIcon(info.icon); // an icon the manifest named late (onAssets) if (v.setCount) v.setCount(e.piece.kind === 'token' ? e.piece.count : 0); if (v.setItems) v.setItems(Array.isArray(e.piece.items) ? e.piece.items.map((it) => { const r = data.item(it?.id); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it?.id) : null; }) : []); v._showFacing = e.area === 'board'; @@ -959,6 +979,7 @@ export async function createFieldView(host, options = {}) { for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } + holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) return true; } @@ -1291,8 +1312,23 @@ export async function createFieldView(host, options = {}) { // ---- battle --------------------------------------------------------------------------------------------- + /** + * Battle mode holds the Spine cache (assets.js `spine.hold()`, its memory policy): the battle's views are built in + * animation frames, which a hidden tab does not run — a battle that began in a background tab referenced no skeleton, and + * the quiet budget emptied the cache ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model + * downloaded again (public issue #8 item 5). Released once the prep pieces reference their models (setPrep) and on + * destroy; the idle budget still applies meanwhile. + */ + function holdScene(on) { + if (on) { if (!sceneHold && typeof assets.spine?.hold === 'function') sceneHold = assets.spine.hold(); return; } + const release = sceneHold; + sceneHold = null; + if (release) { try { release(); } catch { /* ignore */ } } + } + function enterBattle(meta) { if (destroyed || !meta || typeof meta !== 'object') return false; + holdScene(true); // before the prep views go: the cache is never "quiet" across the switch drag.reset(); endDragVisual(false); clearViews(); @@ -1701,6 +1737,28 @@ export async function createFieldView(host, options = {}) { let lastDpr = globalThis.devicePixelRatio || 1; layoutBackdrop(); + // ---- late assets, hidden tabs (public issue #8 item 5: operators drawn as image-less placeholders) ---------------------- + // The asset manifest (or the local-client one) arrived after views were built — a reload whose manifest fetch was slow or + // failed (assets.js onChange): every view re-resolves what it could not draw (UnitView.retryAssets) and the prep pieces + // re-read their item icons. The tab shown again: a manifest still missing is asked for again and views without a model + // load it again at once (their bounded retries never run while hidden: no frames). + function onAssets() { + if (destroyed) return; + loadShadow(); + loadMountains(); + if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o); + for (const v of views.values()) v.retryAssets?.(); + for (const v of penViews.values()) v.retryAssets?.(); + } + const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null; + const onVisible = () => { + if (destroyed || globalThis.document?.visibilityState !== 'visible') return; + if (assets.loaded === false && typeof assets.ready === 'function') assets.ready(); + for (const v of views.values()) v.retryAssets?.(); + for (const v of penViews.values()) v.retryAssets?.(); + }; + globalThis.document?.addEventListener?.('visibilitychange', onVisible); + // ---- public API --------------------------------------------------------------------------------------------- const view = { @@ -1816,6 +1874,8 @@ export async function createFieldView(host, options = {}) { if (destroyed) return; destroyed = true; try { ro?.disconnect(); } catch { /* ignore */ } + try { offAssets?.(); } catch { /* ignore */ } + globalThis.document?.removeEventListener?.('visibilitychange', onVisible); canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); @@ -1830,6 +1890,7 @@ export async function createFieldView(host, options = {}) { drag.reset(); for (const k of [...views.keys()]) dropView(k); clearPen(); + holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result) try { fx.destroy(); } catch { /* ignore */ } try { tiles.destroy(); } catch { /* ignore */ } try { impostors.destroy(); } catch { /* ignore */ } diff --git a/public/js/render/units.js b/public/js/render/units.js index f3ffb97f..e88c92b9 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -3,11 +3,13 @@ // UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or // the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar // with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and -// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the -// game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13: -// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted -// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw -// calls. +// cross-fades to the Spine model when it has loaded (the game never blocks on Spine); an optional local-client model +// (assets.js spineEntry `fallback`, DESIGN §13: 灼热源石虫 / 炽焰源石虫) falls back to the web model first; that web alias +// (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is +// loaded again after SPINE_RETRY_MS (bounded), and `retryAssets()` re-resolves a view's picture and model when the +// asset manifest arrives after the view was built or the tab is shown again (render/app.js; public issue #8 item 5: +// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good). Every +// bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls. // // Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so // chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor @@ -84,6 +86,12 @@ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1] const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); /** How long a view waits for its avatar before showing the image-less placeholder diamond. */ const PIC_WAIT_MS = 400; +/** + * Waits (ms, real time) before a view loads its Spine model again after the load failed or timed out (assets.js, 20 s) + * — bounded: after the last one the view keeps its diamond until `retryAssets` (a manifest that arrived late, the tab + * shown again). Counted from the failure; a hidden tab runs no frames, so nothing is retried while hidden. + */ +export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]); /** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */ const RAISED_Z = 0.12; @@ -317,6 +325,9 @@ export class UnitView { this.body.addChild(this.fallback); this.actor = null; this.spineReady = false; + this._spineBusy = false; // a Spine load of this view is in flight + this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) + this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints this.hud = new P.Container(); @@ -365,7 +376,8 @@ export class UnitView { pic.shown = want; } - _loadSpine() { + /** @param {boolean} [retry] load again even after a remembered failure (assets.js acquire `{ retry }`) */ + _loadSpine(retry = false) { const a = this.ctx.assets; if (!a || !a.spineEntry || !a.spine) return; const id = this.info.spine || this.info.defId; @@ -374,21 +386,38 @@ export class UnitView { const entry = id ? a.spineEntry(id, { back }) : null; if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return; this.entryBack = back; - this._acquireSpine(entry, id); + this._acquireSpine(entry, id, retry); + } + + /** + * Re-resolve what this view could not draw yet (public issue #8 item 5): the picture when it has none (no avatar URL — + * the asset manifest arrived after the view was built — or the image failed) and, at once, the Spine model when none + * is shown and none is loading (no manifest entry then, or a load that failed / timed out). render/app.js calls it for + * every view when a manifest arrives (assets.js onChange) and when the tab is shown again. Nothing to do otherwise. + */ + retryAssets() { + if (this.destroyed) return; + if (!this._pic || this._pic.state === 'none') this._loadPicture(); + if (!this.actor && !this._spineBusy) { this._retryAt = 0; this._loadSpine(true); } } /** * Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy * load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or - * destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional - * local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place. + * destroyed. A model that fails to load falls back to the entry's `fallback` (an optional local-client model, + * assets.js spineEntry: DESIGN §13) when it names one; otherwise the view keeps its fallback diamond and — while no + * model is shown — loads again after SPINE_RETRY_MS (a timed-out or failed download used to stay a diamond for good). */ - _acquireSpine(entry, id) { + _acquireSpine(entry, id, retry = false) { const a = this.ctx.assets; this.entry = entry; const req = this._spineReq = (this._spineReq || 0) + 1; - a.spine.acquire(entry).then((data) => { + this._spineBusy = true; + a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => { + if (req === this._spineReq) this._spineBusy = false; if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; } + this._spineTries = 0; + this._retryAt = 0; let actor = null; try { actor = new SpineActor(data, entry); @@ -421,9 +450,12 @@ export class UnitView { this.actor.setBase(this._baseFromAnim()); } }, () => { + if (req === this._spineReq) this._spineBusy = false; this._releaseEntry(entry); - if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id); - // else keep the fallback diamond + if (this.destroyed || req !== this._spineReq) return; + if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; } + // keep the fallback diamond (or the model already shown: a failed Front ⇄ Back swap) — and try again later + if (!this.actor && this._spineTries < SPINE_RETRY_MS.length) this._retryAt = nowMs() + SPINE_RETRY_MS[this._spineTries++]; }); } @@ -729,6 +761,8 @@ export class UnitView { update(dt, cam, t) { if (this.destroyed) return; const P = this.P; + // a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden) + if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); } if (this.zTarget != null && this.z !== this.zTarget) { const d = this.zTarget - this.z; this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12); @@ -1297,10 +1331,17 @@ export class ItemView { this.plate = new P.Sprite(itemTexture(String(info.defId || 'item'), null, info.color || 0x9aa5a0)); this.plate.anchor.set(0.5, 1); this.root.addChild(this.plate); - const url = info.icon; - if (url && ctx.assets?.image) ctx.assets.image(url).then((img) => { if (!this.destroyed && img) this.plate.texture = itemTexture(String(info.defId), img, info.color || 0x9aa5a0); }, () => {}); + this.info.icon = null; + this.setIcon(info.icon); this.hud = null; } + /** Show the item's icon (an URL; the plain plate until it loads) — again when the asset manifest named it late. */ + setIcon(url) { + if (this.destroyed || !url || url === this.info.icon) return; + this.info.icon = url; + const a = this.ctx.assets; + if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {}); + } setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; } update(dt, cam, t) { const lifted = this.lift > 0; diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index 3fc23810..eeec6551 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -1,6 +1,8 @@ // Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9) // into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times // out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD. +// The shared asset store gets the game data's copy of the asset manifest (`seedAssets`, public issue #8 item 5), and the +// prep cameras the HUD bands they keep clear (`hudBands`; the folded shop's band: public issue #5). // // `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness); // `?render=engine` never falls back silently (errors are logged and the fallback still mounts). @@ -160,6 +162,27 @@ export function guardView(view, kind) { return safe; } +/** + * Hand the game data's copies of /data/assets.json and /data/local-assets.json (data.js 'assets' / 'local': the match + * screen waits for them, gameComponents GAME_FILES) to the asset store, now or when they land: it then never downloads + * the manifest a second time — after a page reload (a phone browser discarding a background tab, Chrome's Memory Saver) + * that second download was the one createFieldView waited ≤ 4 s for, and a slow or failed one left every operator the + * image-less placeholder (public issue #8 item 5). Without a copy the store fetches and retries by itself. + * @param {{ seed?: (m: any) => boolean, seedLocal?: (m: any) => boolean }} store public/js/assets.js store + */ +export function seedAssets(store) { + if (!store) return; + const give = (name, fn) => { + if (typeof fn !== 'function') return; + const now = data.get(name); + if (now) { fn(now); return; } + // still loading (or failed): adopt it if it lands; a later success of the store's own fetch makes this a no-op + Promise.resolve(data.load(name)).then((m) => { if (m) fn(m); }, () => {}); + }; + give('assets', store.seed?.bind(store)); + give('local', store.seedLocal?.bind(store)); +} + /** * Create a field view in `host`: the render engine when available, else the DOM fallback. * @param {HTMLElement} host @@ -172,7 +195,10 @@ export async function mountFieldView(host) { try { // the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect) const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null); - if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets; + if (am?.assets && typeof am.assets.ready === 'function') { + opts.assets = am.assets; + seedAssets(am.assets); + } const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import'); if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing'); const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView'); diff --git a/test/render/assets.test.js b/test/render/assets.test.js index f4a9a6f4..5712952e 100644 --- a/test/render/assets.test.js +++ b/test/render/assets.test.js @@ -485,10 +485,13 @@ describe('createAssets store', () => { assert.equal(a.audio.sfx('battle', 'deploy'), '/sfx/dep.mp3'); const warn = console.warn; console.warn = () => {}; try { + // a failure resolves ready() (fallbacks meanwhile) but is not kept as the manifest (public issue #8 item 5; + // test/render/issue8-placeholders.test.js covers the retries) const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) }); - await b.ready(); + assert.deepEqual(await b.ready(), {}); assert.equal(b.avatar('char_002_amiya'), null); - assert.deepEqual(b.manifest, {}); + assert.equal(b.manifest, null); + assert.equal(b.loaded, false); } finally { console.warn = warn; } }); diff --git a/test/render/issue8-placeholders.test.js b/test/render/issue8-placeholders.test.js new file mode 100644 index 00000000..c2de81ec --- /dev/null +++ b/test/render/issue8-placeholders.test.js @@ -0,0 +1,341 @@ +// 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\)\);/); + }); +});