feedback1 final: merge the client fixes for GitHub issues #5 (prep camera follows the folded shop) and #8 item 5 (operators no longer stay placeholders)

Conflicts resolved keeping both sides:
- CHANGELOG 0.1.1 界面: the three gaps lines, then the issue #5 and #8 lines.
- DESIGN: the branch's "§21.26 Client fixes for GitHub issues #5 and #8" follows the gaps' §21.26 as §21.27; its
  references in §9 (setCamera `shop`, the model fallback), §17.1 (the quiet budget) and §19.8 (the collapsed shop)
  now point at §21.27.
This commit is contained in:
sganggs committed 2026-10-03 18:49:39 +08:00
commit c61167e3aa
14 files changed
+1089 -101

No files matched your search

+5 -2
View File
@@ -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; }
});
+341
View File
@@ -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\)\);/);
});
});
+212
View File
@@ -0,0 +1,212 @@
// Public issue #5 (v0.1.1): "准备阶段,收起商店界面时,界面并不会进行缩放(观战及作战时无此现象)" — folding the shop (收起) in prep
// left the own board at the shop camera. Now the own prep board takes the official shop-collapsed camera
// (configBlackBoard left_prepare_camera_param / *_boss_prepare_*) while the shop bar is shown folded, keeping the bench
// above the folded shop's HUD band (the tab and the corner buttons' hit areas), and returns to the shop camera when it is
// unfolded; never over the pen, a teammate's board or a battle; deferred during a drag or the direction step.
// gameLogic prepCameraFor / foldCamera (the choice), ui/fieldHost.js hudBands(kind, size, { shop: false }) (the band),
// render/app.js (the band follows the request's `shop`), screens/game.js (the effect), css/devices.css (one-row corner).
// Browser measurement: the headless-Chrome checks of DESIGN §21 (fold / unfold at 1920×1080, 844×390, 756×366).
import { test, describe } 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 { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { prepCameraFor, foldCamera, prepCamera } = await import('../../public/js/ui/gameLogic.js');
const { hudBands, HUD_REM } = await import('../../public/js/ui/fieldHost.js');
const { presetCamera, tileQuad } = await import('../../public/js/render/projection.js');
const pl = (id, seat, alive = true) => ({ playerId: id, seat, alive });
const pubAt = (round, players = [pl('a', 0), pl('b', 1)]) => ({ round, bossRound: 14, hiddenRound: 15, players });
describe('the camera choice (gameLogic prepCameraFor / foldCamera)', () => {
test('prepCameraFor: the own prep camera with the shop state — open = shop camera, folded = shop-collapsed', () => {
assert.deepEqual(prepCameraFor(pubAt(6), 'a'), { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: true } });
assert.deepEqual(prepCameraFor(pubAt(6), 'a', true), { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false } });
// the Final Assault prep: the player's half of the boss field, with the same flag
assert.deepEqual(prepCameraFor(pubAt(14), 'b', true), { kind: 'bossPrep', opts: { side: 'R', shop: false } });
assert.deepEqual(prepCameraFor(pubAt(14), 'a'), { kind: 'bossPrep', opts: { side: 'L', shop: true } });
// everything but the flag is prepCamera's
for (const [pub, me] of [[pubAt(3), 'a'], [pubAt(15), 'b'], [null, 'a']]) {
const { shop, ...rest } = prepCameraFor(pub, me, true).opts;
assert.equal(shop, false);
assert.deepEqual(rest, prepCamera(pub, me).opts);
}
});
const base = { pub: pubAt(6), myId: 'a', ownPrep: true, pen: false, busy: false };
const open = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: true } };
const folded = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false } };
test('foldCamera: a fold asks for the shop-collapsed camera, an unfold for the shop camera again, else nothing', () => {
assert.deepEqual(foldCamera({ ...base, folded: true, current: open }), folded);
assert.deepEqual(foldCamera({ ...base, folded: false, current: folded }), open);
assert.equal(foldCamera({ ...base, folded: true, current: folded }), null, 'already folded');
assert.equal(foldCamera({ ...base, folded: false, current: open }), null, 'already open');
// a request made before this rule (no shop flag) counts as the shop camera
assert.equal(foldCamera({ ...base, folded: false, current: { kind: 'prep', opts: { side: 'L' } } }), null);
assert.deepEqual(foldCamera({ ...base, folded: true, current: { kind: 'prep', opts: { side: 'L' } } }), folded);
});
test('foldCamera: own prep board only — not a teammate\'s board, a battle, the pen', () => {
assert.equal(foldCamera({ ...base, ownPrep: false, folded: true, current: open }), null, 'scouting / battle / observer');
assert.equal(foldCamera({ ...base, pen: true, folded: true, current: open }), null, 'the pen folds the shop itself');
assert.equal(foldCamera(null), null);
});
test('foldCamera: deferred while a piece is dragged or its direction is chosen, applied once that ends', () => {
const s = { ...base, folded: true, current: open };
assert.equal(foldCamera({ ...s, busy: true }), null, 'drag / wheel in progress: the camera stays');
assert.deepEqual(foldCamera({ ...s, busy: false }), folded, 'the effect runs again when busy clears');
});
test('foldCamera: the Final Assault prep half follows the fold too', () => {
const want = foldCamera({ ...base, pub: pubAt(14), myId: 'b', folded: true, current: { kind: 'bossPrep', opts: { side: 'R', shop: true } } });
assert.deepEqual(want, { kind: 'bossPrep', opts: { side: 'R', shop: false } });
});
});
// ---- the folded shop's HUD band (ui/fieldHost.js hudBands) ----------------------------------------------------------
/** Root font size of css/theme.css: clamp(40px, min(100vw / 19.2, 100vh / 10.8), 240px). */
const remAt = (w, h) => Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
/**
* Run `fn` with a stubbed DOM: root font size, the HUD layer's rect (safe-area insets), the corner buttons (client-px
* rects, or none) and whether the pointer is coarse (`sp-coarse`, --tap-min 44 px).
*/
function withDom({ rem, hudTop = 0, hudBottom = null, corner = null, coarse = false }, fn) {
const g = globalThis;
const saved = { document: g.document, getComputedStyle: g.getComputedStyle };
const root = { classList: { contains: (c) => c === 'sp-coarse' && coarse } };
g.getComputedStyle = (el) => ({ fontSize: `${rem}px`, getPropertyValue: (p) => (el === root && p === '--tap-min' ? ' 44px' : '') });
g.document = {
documentElement: root,
querySelector: (s) => (s === '.gm__hud' ? { getBoundingClientRect: () => ({ top: hudTop, bottom: hudBottom ?? 0 }) } : null),
querySelectorAll: (s) => (s.includes('.gm__corner') && corner ? corner.map((r) => ({ getBoundingClientRect: () => r })) : []),
};
try { return fn(); } finally { g.document = saved.document; g.getComputedStyle = saved.getComputedStyle; }
}
/** A one-row corner of `n` buttons `size` px high at bottom .24rem (css/screens/game.css .gm__corner). */
const cornerRow = (h, rem, size, n = 4) => Array.from({ length: n }, () => ({ top: h - 0.24 * rem - size, height: size }));
describe('hudBands: the folded shop\'s band', () => {
test('HUD_REM mirrors the CSS of the folded tab and the corner', () => {
const shop = read('public/css/screens/game-shop.css');
const game = read('public/css/screens/game.css');
// tab: bottom .2rem + padding .08rem × 2 + the .44rem button + 1 px + 2 px borders
assert.match(shop, /\.shopbar-tab \{ position: absolute; right: \.26rem; bottom: \.2rem;[^}]*padding: \.08rem;[^}]*border: 1px solid var\(--line-2\); border-top: 2px solid var\(--mint-700\); \}/);
assert.match(shop, /\.shopbar-tab__btn \{[^}]*height: \.44rem;/);
assert.equal(HUD_REM.shopTabTop, 0.2 + 0.08 * 2 + 0.44);
assert.equal(HUD_REM.shopTabBorderPx, 3);
// corner (fallback when it cannot be measured): bottom .24rem + a .56rem desktop row
assert.match(game, /\.gm__corner \{ position: absolute; left: \.24rem; bottom: \.24rem;/);
assert.match(game, /\.gm__gear \{\n {2}width: \.56rem; height: \.56rem;/);
assert.ok(Math.abs(HUD_REM.cornerTop - (0.24 + 0.56)) < 1e-9);
// folded, the narrow phones' corner is one row again (the bar that made it wrap is gone)
assert.match(read('public/css/devices.css'), /@media \(max-width: 767px\) \{\n {2}\.gm\.is-collapsed \.gm__corner \{ flex-wrap: nowrap; width: auto; \}\n\}/);
});
test('prep / Final Assault prep only; open = the bar (unchanged), folded = the higher of the tab and the corner', () => {
withDom({ rem: 40 }, () => {
for (const k of ['normal', 'unite', 'boss', 'pen']) assert.equal(hudBands(k, { width: 844, height: 390 }, { shop: false }), null, k);
for (const k of ['prep', 'bossPrep']) {
const open = hudBands(k, { width: 844, height: 390 });
assert.deepEqual(hudBands(k, { width: 844, height: 390 }, { shop: true }), open);
assert.ok(Math.abs(open.bottom - 108.6) < 1e-9, 'the bar: 2.64rem + 3 px');
const f = hudBands(k, { width: 844, height: 390 }, { shop: false });
assert.equal(f.top, open.top, 'the top band does not change');
assert.ok(Math.abs(f.bottom - 35) < 1e-9, `no corner on the page: the tab .8rem + 3 px (${f.bottom})`);
}
});
withDom({ rem: 100 }, () => assert.deepEqual(hudBands('prep', { width: 1920, height: 1080 }, { shop: false }), { top: 216, bottom: 83 }));
});
test('the corner buttons are measured, touch hit areas included; the tab sits above the bottom safe-area inset', () => {
const h = 390, rem = 40;
// desktop-like pointer: the buttons' own top
withDom({ rem, corner: cornerRow(h, rem, 34) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (9.6 + 34)) < 1e-9));
// touch: a 44 px hit area centred on a 34 px button reaches 5 px higher
withDom({ rem, corner: cornerRow(h, rem, 34), coarse: true }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (9.6 + 34 + 5)) < 1e-9));
// two rows (a narrow phone with the bar open would wrap them): the higher row decides
const two = [...cornerRow(366, rem, 34, 1), { top: 366 - 9.6 - 34 - 4 - 34, height: 34 }];
withDom({ rem, corner: two, coarse: true }, () => assert.ok(Math.abs(hudBands('prep', { width: 756, height: 366 }, { shop: false }).bottom - (9.6 + 72 + 5)) < 1e-9));
// a low corner: the tab wins; a notched phone's bottom inset lifts the tab (it lives inside the HUD layer)
withDom({ rem, corner: cornerRow(h, rem, 10) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - 35) < 1e-9));
withDom({ rem, hudBottom: h - 21, corner: cornerRow(h - 21, rem, 10) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (21 + 35)) < 1e-9));
// clamped like the bar's band
withDom({ rem, corner: [{ top: 10, height: 34 }] }, () => assert.equal(hudBands('prep', { width: 640, height: 200 }, { shop: false }).bottom, 80));
});
});
// ---- the camera itself (render/projection.js presetCamera with the folded band) ---------------------------------------
describe('the folded board grows and every bench / temp tile stays clear of the folded HUD', () => {
const stage = JSON.parse(read('data/stages.json')).act2autochess_m01;
const H = { h: 0.42, '#': 0.3, X: 0.55, a: 0.16, A: 0.16 }; // render/style.js TILE_H by glyph
const heightAt = (r, c) => H[stage.rows[r]?.[c]] ?? 0;
const tileAt = (cam, row) => cam.project(5.5, row - 0.5).x - cam.project(4.5, row - 0.5).x;
/** Screen y extent of the bench (row 7 / boss row 0), temp (8 / 1) and back row (12 / 5) tile tops. */
function extents(cam, rows) {
const ys = (r, c0, c1) => { const out = []; for (let c = c0; c <= c1; c++) for (const p of tileQuad(cam, r, c, heightAt(r, c))) out.push(p.y); return out; };
return { benchBottom: Math.max(...ys(rows.bench, 0, 9)), tempBottom: Math.max(...ys(rows.temp, 4, 8)), backTop: Math.min(...ys(rows.back, 4, 10)) };
}
// [w, h, coarse pointer, corner rows when folded (css/devices.css: one row again)]
const VIEWPORTS = [[1920, 1080, false], [1280, 720, false], [1680, 1050, false], [844, 390, true], [756, 366, true], [800, 360, true], [915, 412, true], [932, 430, true]];
test('fold → shop-collapsed camera (bigger tiles), bench above the band, back row under the bond strip; unfold → the shop camera', () => {
for (const [w, h, coarse] of VIEWPORTS) {
const rem = remAt(w, h);
const btn = coarse ? Math.max(0.56 * rem, 34) : 0.56 * rem;
for (const [kind, rows, opts] of [['prep', { bench: 7, temp: 8, back: 12 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L' }],
['bossPrep', { bench: 0, temp: 1, back: 5 }, { side: 'L' }], ['bossPrep', { bench: 0, temp: 1, back: 5 }, { side: 'R' }]]) {
const dom = { rem, corner: cornerRow(h, rem, btn), coarse };
const openHud = withDom(dom, () => hudBands(kind, { width: w, height: h }, { shop: true }));
const foldHud = withDom(dom, () => hudBands(kind, { width: w, height: h }, { shop: false }));
const openCam = presetCamera(kind, { width: w, height: h }, { ...opts, shop: true, hud: openHud });
const foldCam = presetCamera(kind, { width: w, height: h }, { ...opts, shop: false, hud: foldHud });
const tag = `${kind}${opts.side} ${w}×${h}`;
const midRow = rows.back - 2;
assert.ok(tileAt(foldCam, midRow) > tileAt(openCam, midRow) * 1.15, `${tag}: grows (${tileAt(openCam, midRow).toFixed(1)} → ${tileAt(foldCam, midRow).toFixed(1)})`);
const e = extents(foldCam, rows);
// (projection.js clearHud keeps an official camera that overlaps a band by ≤ 0.5 px: float noise)
assert.ok(e.benchBottom <= h - foldHud.bottom + 0.5 + 1e-6, `${tag}: bench ${e.benchBottom} above the folded band ${h - foldHud.bottom}`);
assert.ok(e.tempBottom < e.benchBottom, `${tag}: temp row above the bench`);
assert.ok(e.backTop >= foldHud.top - 1e-6, `${tag}: back row ${e.backTop} under the bond strip ${foldHud.top}`);
// unfolding asks for exactly the shop camera again
assert.deepEqual(presetCamera(kind, { width: w, height: h }, { ...opts, shop: true, hud: openHud }).params(), openCam.params());
}
}
});
test('1920×1080: the official shop-collapsed camera at its own scale, panned up just enough to clear the folded tab', () => {
const off = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false });
const fit = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false, hud: { top: 216, bottom: 83 } });
const shop = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', hud: { top: 216, bottom: 267 } });
const { cy: cyFit, ...restFit } = fit.params();
const { cy: cyOff, ...restOff } = off.params();
assert.deepEqual(restFit, restOff, 'same optics and scale (a 2D pan of the image)');
assert.ok(cyFit < cyOff && cyOff - cyFit < 60, `panned up ${cyOff - cyFit} px`);
assert.ok(Math.abs(tileAt(fit, 10) - 130.7) < 0.2, `≈ 131 px per tile at row 10 (${tileAt(fit, 10)})`);
assert.ok(Math.abs(tileAt(shop, 10) - 109.8) < 0.2, `the shop camera: ≈ 110 px (${tileAt(shop, 10)})`);
});
});
describe('wiring', () => {
test('the view passes the request\'s shop state to the HUD bands; the game asks for the camera of the fold state', () => {
const app = read('public/js/render/app.js');
assert.match(app, /hud: hudBands\(vk, sz, \{ shop: o\.shop !== false \}\),/);
assert.match(app, /return opts\.hud\(kind, sz, o\) \|\| null;/);
const game = read('public/js/screens/game.js');
assert.match(game, /const shopFolded = showShop && \(pen \? penRef\.current\.collapsed : collapsed\);/);
// entering prep and the boss-prep re-frame take the fold state; a fold / unfold re-frames through foldCamera
assert.equal((game.match(/const pc = prepCameraFor\(pub, myId, shopFolded\);/g) || []).length, 2);
assert.match(game, /const next = foldCamera\(\{\n\s+pub, myId, folded: shopFolded, ownPrep: !!view && viewModeRef\.current === 'prep' && showPrep,\n\s+pen, busy: !!drag \|\| !!facing, current: camRef\.current,\n\s+\}\);\n\s+if \(next\) setCam\(next\.kind, next\.opts\);\n\s+\}, \[view, shopFolded, showPrep, pen, !!drag, !!facing, prepCamKey\]\);/);
});
});
+41 -1
View File
@@ -11,7 +11,7 @@
// item onto a unit (equip), 机变 pick, band pick, briefing ready, emote, exit → 暂离 → 返回.
// 3. Regressions: tooltip after a click, toasts / reconnect banner placement, refused watch targets, battle unit
// panel closing at the next prep, no Hidden Core medal before R15, equipment dropped on an operator's upper body
// (engine), illegal drops toasting their reason.
// (engine), illegal drops toasting their reason, the folded shop's prep camera (public issue #5).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -518,6 +518,46 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
for (const [w, h] of [[1920, 1080], [844, 390]]) {
test(`fold (public issue #5): the own prep board grows with the shop folded, every bench pad stays free, unfolding returns (${w}×${h})`, async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP', { w, h });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.raw?.debug?.cam, { timeout: 15000 });
await sleep(900);
const look = () => page.evaluate(() => {
const V = globalThis.__SP_VIEW__; const raw = V.raw; const cam = raw.debug.cam;
const tile = cam.project(5.5, 9.5).x - cam.project(4.5, 9.5).x;
let worst = 1;
for (let c = 0; c <= 9; c++) {
const q = V.tileScreen(7, c);
if (!q) continue;
const cx = q.poly.reduce((s, p) => s + p[0], 0) / 4, cy = q.poly.reduce((s, p) => s + p[1], 0) / 4;
const pts = [[cx, cy], ...q.poly.map(([x, y]) => [x + (cx - x) * 0.12, y + (cy - y) * 0.12])];
const free = pts.filter(([x, y]) => document.elementFromPoint(x, y) === raw.debug.app.view).length / pts.length;
worst = Math.min(worst, free);
}
return { tile, worst, kind: raw.debug.camKind, params: JSON.stringify(cam.params()) };
});
const open1 = await look();
await page.click('.funds__collapse');
await page.waitForSelector('.shopbar-tab', { timeout: 3000 });
await sleep(1200); // the 0.75 s camera flight
const folded = await look();
assert.equal(folded.kind, 'prep');
assert.ok(folded.tile > open1.tile * 1.15, `tile ${open1.tile.toFixed(1)} → ${folded.tile.toFixed(1)} px`);
assert.equal(folded.worst, 1, 'every bench pad fully pressable under the folded camera');
await page.screenshot({ path: path.join(OUT, `ui-prep-folded-${w}.png`) });
await page.click('.shopbar-tab__btn');
await page.waitForSelector('.shopbar', { timeout: 3000 });
await sleep(1200);
const back = await look();
assert.equal(back.params, open1.params, 'unfolded: exactly the shop camera again');
assert.equal(back.worst, 1);
assert.deepEqual(problems, []);
await page.close();
});
}
for (const [w, h] of [[1920, 1080], [1366, 768]]) {
test(`toasts clear the top bar capsule; the reconnect banner clears the view switcher (${w}×${h})`, async () => {
let { page, problems } = await open('phase=PREP', { w, h });
+2 -1
View File
@@ -148,7 +148,8 @@ describe('9: the prep camera keeps the bench clear of the shop bar on phones in
test('wiring: the game hands hudBands to the view, the view to the prep cameras', () => {
assert.match(read('public/js/ui/fieldHost.js'), /padding: hudPadding, hud: hudBands \}/);
const app = read('public/js/render/app.js');
assert.match(app, /hud: hudBands\(vk, sz\),/);
// (the folded shop's band since public issue #5: test/ui/issue5-fold-camera.test.js)
assert.match(app, /hud: hudBands\(vk, sz, \{ shop: o\.shop !== false \}\),/);
assert.match(app, /presetCamera\('prep', \{ width: s0\.width, height: s0\.height, padding: defaultPadding\('prep', s0\) \}, \{ hud: hudBands\('prep', s0\) \}\)/);
});