卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+452
View File
@@ -0,0 +1,452 @@
// test/render/assets.test.js — public/js/assets.js: URL helpers (synthetic + real manifest), RefLru spine cache
// (refcount, LRU eviction, failures, timeout, concurrency), store (fetch, preload, fallbacks). No PIXI, no DOM.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
SPINE_IDLE_GRACE_MS,
} from '../../public/js/assets.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
/** Fake clock + timers for the spine LRU memory budget (RefLru `now` / `timers`). */
const clock = () => {
const c = { t: 0, pending: [] };
c.now = () => c.t;
c.timers = { set: (fn, ms) => { const h = { fn, at: c.t + ms }; c.pending.push(h); return h; }, clear: (h) => { c.pending = c.pending.filter((x) => x !== h); } };
c.advance = (ms) => { c.t += ms; for (const h of c.pending.filter((x) => x.at <= c.t)) { c.pending = c.pending.filter((x) => x !== h); h.fn(); } };
return c;
};
const SP = (id, extra = {}) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], pma: false, anims: { idle: 'Idle', attack: { loop: 'Attack' } }, animations: { Idle: 1 }, events: [], hits: {}, bounds: null, ...extra });
const M = {
chars: {
char_002_amiya: { avatar: '/a/amiya.png', avatarE2: '/a/amiya_2.png', portrait: '/p/amiya_1.png', portraitE2: '/p/amiya_2.png', spine: { front: SP('amiya_f'), back: SP('amiya_b') } },
char_010_chen: { avatar: '/a/chen.png', portrait: '/p/chen_1.png', spine: { front: SP('chen_f') } },
},
enemies: {
enemy_1007_slime: { icon: '/e/slime.png', spine: SP('slime', { pma: true }) },
enemy_1305_mhslim: { icon: '/e/mh.png', spineAliasOf: 'enemy_1007_slime' },
enemy_9016_acstmr: { icon: '/e/acstmr.png' },
},
tokens: {
token_a: { owner: 'char_010_chen', avatar: '/t/a.png', spine: SP('tok_a') },
token_b: { owner: 'char_010_chen' },
token_c: { owner: null },
},
bonds: { yanShip: '/b/yan.png' },
bands: { band_bldsk: '/band/bldsk.png' },
items: { trap_1041_acarm041: '/i/1041.png' },
skills: { skchr_x: '/s/x.png' },
skillsById: { skill_y: 'skchr_x' },
ui: { 'skillIcon/empty': '/ui/empty.png', 'battle/sprite_shadow': '/ui/shadow.png' },
prof: { icon: { sniper: '/prof/sniper.png' }, battlecard: { token: '/prof/token.png' }, sub: { fastshot: '/prof/sub/fastshot.png' } },
audio: {
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
},
};
describe('URL helpers (synthetic manifest)', () => {
test('avatars & portraits with E2 handling', () => {
assert.equal(avatarUrl(M, 'char_002_amiya'), '/a/amiya.png');
assert.equal(avatarUrl(M, 'char_002_amiya', { e2: true }), '/a/amiya_2.png');
assert.equal(avatarUrl(M, 'char_002_amiya_2'), '/a/amiya_2.png', 'chess.json asset id with _2');
assert.equal(avatarUrl(M, 'char_010_chen', { e2: true }), '/a/chen.png', 'no E2 ⇒ base');
assert.equal(avatarUrl(M, 'char_010_chen_2'), '/a/chen.png');
assert.equal(avatarUrl(M, 'char_999_none'), null);
assert.equal(avatarUrl(M, ''), null);
assert.equal(avatarUrl(M, 42), null);
assert.equal(avatarUrl(null, 'char_002_amiya'), null);
assert.equal(portraitUrl(M, 'char_002_amiya_1'), '/p/amiya_1.png');
assert.equal(portraitUrl(M, 'char_002_amiya_2'), '/p/amiya_2.png');
assert.equal(portraitUrl(M, 'char_010_chen', { e2: true }), '/p/chen_1.png');
assert.equal(baseCharId('char_002_amiya_2'), 'char_002_amiya');
assert.equal(baseCharId(null), null);
});
test('prototype keys never resolve', () => {
assert.equal(avatarUrl(M, '__proto__'), null);
assert.equal(avatarUrl(M, 'constructor'), null);
assert.equal(uiUrl(M, 'toString'), null);
assert.equal(spineEntry(M, 'hasOwnProperty'), null);
});
test('enemy, token, bond, band, item, skill, ui, prof icons', () => {
assert.equal(enemyIconUrl(M, 'enemy_1007_slime'), '/e/slime.png');
assert.equal(enemyIconUrl(M, 'enemy_x'), null);
assert.equal(tokenAvatarUrl(M, 'token_a'), '/t/a.png');
assert.equal(tokenAvatarUrl(M, 'token_b'), '/a/chen.png', 'owner avatar fallback');
assert.equal(tokenAvatarUrl(M, 'token_c'), '/prof/token.png', 'battlecard fallback');
assert.equal(tokenAvatarUrl(M, 'token_none'), null);
assert.equal(bondIconUrl(M, 'yanShip'), '/b/yan.png');
assert.equal(bandIconUrl(M, 'band_bldsk'), '/band/bldsk.png');
assert.equal(itemIconUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
assert.equal(itemIconUrl(M, { trapId: 'trap_1041_acarm041' }), '/i/1041.png');
assert.equal(itemIconUrl(M, { iconId: 'nope', trapId: 'trap_1041_acarm041' }), null);
assert.equal(skillIconUrl(M, 'skchr_x'), '/s/x.png');
assert.equal(skillIconUrl(M, 'skill_y'), '/s/x.png', 'via skillsById');
assert.equal(skillIconUrl(M, 'nope'), '/ui/empty.png');
assert.equal(skillIconUrl(M, 'nope', { fallback: false }), null);
assert.equal(skillIconUrl(M, 'nope', null), '/ui/empty.png', 'null options tolerated');
assert.equal(avatarUrl(M, 'char_002_amiya', null), '/a/amiya.png');
assert.equal(spineEntry(M, 'char_002_amiya', null).skel, '/assets/spine/amiya_f.skel');
assert.equal(uiUrl(M, 'battle/sprite_shadow'), '/ui/shadow.png');
assert.equal(profIconUrl(M, 'SNIPER'), '/prof/sniper.png');
assert.equal(profIconUrl(M, 'SNIPER', 'large'), null);
assert.equal(subProfIconUrl(M, 'sub_fastshot_icon'), '/prof/sub/fastshot.png');
assert.equal(subProfIconUrl(M, 'fastshot'), '/prof/sub/fastshot.png');
});
test('spine entries: front/back, tokens, enemies, aliases, missing', () => {
assert.equal(spineEntry(M, 'char_002_amiya').skel, '/assets/spine/amiya_f.skel');
assert.equal(spineEntry(M, 'char_002_amiya', { back: true }).skel, '/assets/spine/amiya_b.skel');
assert.equal(spineEntry(M, 'char_010_chen', { back: true }).skel, '/assets/spine/chen_f.skel', 'no Back ⇒ Front');
assert.equal(spineEntry(M, 'char_002_amiya_2').skel, '/assets/spine/amiya_f.skel');
assert.ok(hasBackSpine(M, 'char_002_amiya') && !hasBackSpine(M, 'char_010_chen'));
assert.equal(spineEntry(M, 'token_a').skel, '/assets/spine/tok_a.skel');
assert.equal(spineEntry(M, 'token_b'), null);
assert.equal(spineEntry(M, 'enemy_1007_slime').pma, true);
assert.equal(spineEntry(M, 'enemy_1305_mhslim').skel, '/assets/spine/slime.skel', 'alias');
assert.equal(spineEntry(M, 'enemy_9016_acstmr'), null);
assert.equal(spineEntry(M, 'enemy_5601_entlec'), null, 'no manifest entry at all');
assert.equal(validSpine({ skel: 'javascript:alert(1).skel', atlas: 'x', anims: {} }), false);
assert.equal(validSpine({ skel: '/x.skel', atlas: '/x.atlas', anims: {} }), true);
assert.equal(unitPictureUrl(M, 'enemy_9016_acstmr'), '/e/acstmr.png');
assert.equal(unitPictureUrl(M, 'trap_1041_acarm041'), '/i/1041.png');
assert.equal(unitPictureUrl(M, 'trap_x'), null);
});
test('audio', () => {
assert.equal(bgmEntry(M, 'prep').loop, '/bgm/prep.mp3');
assert.equal(bgmEntry(M, 'boss_1').loop, '/bgm/b1.mp3');
assert.equal(bgmEntry(M, 'x'), null);
assert.equal(sfxUrl(M, 'ui', 'buy'), '/sfx/buy.mp3');
assert.equal(sfxUrl(M, 'ui', 'nope'), null);
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack'), '/sfx/c_atk.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
});
});
describe('URL helpers (real data/assets.json)', () => {
const file = path.join(ROOT, 'data/assets.json');
const hasManifest = existsSync(file);
test('every pool chess resolves avatar + front spine; enemies resolve icon; paths are site-absolute', { skip: !hasManifest }, () => {
const m = JSON.parse(readFileSync(file, 'utf8'));
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
let n = 0;
for (const c of Object.values(chess)) {
if (!c.visible || !c.assets?.spine) continue;
n++;
const av = avatarUrl(m, c.assets.avatar);
assert.ok(av && av.startsWith('/assets/'), `avatar ${c.chessId} ${c.assets.avatar}`);
const sp = spineEntry(m, c.assets.spine);
assert.ok(sp && sp.skel.endsWith('.skel') && sp.anims && sp.anims.idle, `spine ${c.chessId}`);
if (c.skill?.iconId) assert.ok(skillIconUrl(m, c.skill.iconId, { fallback: false }), `skill icon ${c.chessId}`);
}
assert.ok(n >= 200, `checked ${n}`);
const enemies = JSON.parse(readFileSync(path.join(ROOT, 'data/enemies.json'), 'utf8'));
let withSpine = 0, total = 0;
for (const e of Object.values(enemies)) {
total++;
if (spineEntry(m, e.spine || e.key)) withSpine++;
}
assert.ok(withSpine / total > 0.9, `enemy spines ${withSpine}/${total}`);
assert.equal(spineEntry(m, 'enemy_5601_entlec'), null);
assert.ok(uiUrl(m, 'battle/sprite_shadow'));
for (const b of Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8')))) assert.ok(bondIconUrl(m, b), b);
});
});
describe('RefLru', () => {
const tick = () => new Promise((r) => setImmediate(r));
test('dedupes concurrent loads, refcounts, evicts LRU idle entries above max', async () => {
const loads = [], unloads = [];
const lru = new RefLru({ max: 2, load: async (k) => { loads.push(k); return `v:${k}`; }, unload: (k, v) => unloads.push([k, v]) });
const [a1, a2] = await Promise.all([lru.acquire('a'), lru.acquire('a')]);
assert.equal(a1, 'v:a'); assert.equal(a2, 'v:a');
assert.deepEqual(loads, ['a']);
await lru.acquire('b');
await lru.acquire('c');
assert.equal(lru.size, 3, 'all referenced ⇒ nothing evicted');
lru.release('a'); lru.release('a');
assert.deepEqual(unloads, [['a', 'v:a']], 'a evicted once idle');
assert.equal(lru.peek('a'), null);
lru.release('b');
lru.release('c');
assert.equal(lru.size, 2);
await lru.acquire('d');
assert.equal(lru.size, 2);
assert.deepEqual(unloads.map((u) => u[0]), ['a', 'b'], 'least recently used idle entry goes first');
lru.release('zzz'); lru.release('d'); lru.release('d');
assert.equal(lru.stats().refs, 0);
lru.clear();
assert.equal(lru.size, 0);
});
test('failures reject, are remembered for failTtl, then retried', async () => {
let now = 0, calls = 0;
const lru = new RefLru({ failTtl: 1000, now: () => now, load: async () => { calls++; throw new Error('404'); } });
await assert.rejects(lru.acquire('x'), /404/);
await assert.rejects(lru.acquire('x'), /404/);
assert.equal(calls, 1);
now = 5000;
await assert.rejects(lru.acquire('x'), /404/);
assert.equal(calls, 2);
assert.equal(lru.stats().failed, 1);
});
test('timeout rejects slow loads', async () => {
const lru = new RefLru({ timeout: 20, load: () => new Promise(() => {}) });
await assert.rejects(lru.acquire('slow'), /timeout/);
});
test('concurrency cap queues loads', async () => {
let active = 0, peak = 0;
const lru = new RefLru({ concurrency: 2, max: 100, load: async (k) => { active++; peak = Math.max(peak, active); await tick(); await tick(); active--; return k; } });
const all = await Promise.all(['a', 'b', 'c', 'd', 'e'].map((k) => lru.acquire(k)));
assert.deepEqual(all, ['a', 'b', 'c', 'd', 'e']);
assert.equal(peak, 2);
});
test('an entry evicted while loading unloads the late value', async () => {
let resolve;
const unloads = [];
const lru = new RefLru({ max: 1, load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k) });
const p = lru.acquire('a');
lru.clear();
resolve('late');
assert.equal(await p, 'late');
assert.deepEqual(unloads, ['a']);
});
test('requires a loader', () => {
assert.throws(() => new RefLru({}), TypeError);
});
// perf hunt: the count cap alone (60) kept ~115–150 MB of idle parsed skeletons pinned, also in the lobby / room
const W = { a: 30, b: 30, c: 30, d: 5 };
test('memory budget: idle weight above maxIdleWeight goes LRU-first once past the grace; referenced never', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
maxIdleWeight: 50, idleGrace: 1000, now: c.now, timers: c.timers });
for (const k of ['a', 'b', 'c', 'd']) await lru.acquire(k);
lru.release('a'); lru.release('b'); // idle 60 > 50, but both inside the grace (a prep ⇄ battle switch)
assert.deepEqual(unloads, []);
assert.equal(lru.stats().idleWeight, 60);
assert.equal(c.pending.length, 1, 'a sweep is scheduled for the end of the grace');
await lru.acquire('b'); // re-acquired inside the grace: kept, never reloaded
c.advance(1000);
assert.deepEqual(unloads, [], 'idle 30 ≤ 50 after b came back');
lru.release('b'); // idle a+b = 60: a (idle for the whole grace) goes, b is fresh
assert.deepEqual(unloads, ['a']);
lru.release('c'); // idle b+c = 60, both inside their grace
c.advance(400);
assert.deepEqual(unloads, ['a']);
c.advance(700);
assert.deepEqual(unloads, ['a', 'b'], 'the sweep drops b (least recently used); c alone (30) fits the budget');
assert.ok(lru.peek('c') && lru.peek('d'));
assert.equal(lru.stats().weight, 35);
lru.clear();
assert.equal(c.pending.length, 0, 'clear cancels the sweep');
});
test('memory budget: nothing referenced (no scene) ⇒ quietWeight after the grace; unweighted caches unchanged', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => W[k],
maxIdleWeight: 1000, quietWeight: 0, idleGrace: 1000, now: c.now, timers: c.timers });
for (const k of ['a', 'd']) await lru.acquire(k);
lru.release('a');
c.advance(5000);
assert.deepEqual(unloads, [], 'd still referenced: the 1000 budget holds a');
lru.release('d'); // the view is gone (lobby / room)
assert.deepEqual(unloads, ['a'], 'quiet budget 0: a (idle past its grace) goes at once, d is inside its grace');
c.advance(1100);
assert.deepEqual(unloads, ['a', 'd'], 'the quiet sweep empties the cache');
assert.equal(lru.size, 0);
// without `weigh` the cache behaves as before (count cap only, no timers)
const plain = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), maxIdleWeight: 0, quietWeight: 0, now: c.now, timers: c.timers });
await plain.acquire('x'); plain.release('x');
assert.equal(plain.size, 1);
assert.equal(c.pending.length, 0);
});
test('a released-while-loading entry past its grace is dropped as soon as it is ready when over budget', async () => {
const c = clock(), unloads = [];
let resolve;
const lru = new RefLru({ load: () => new Promise((r) => { resolve = r; }), unload: (k) => unloads.push(k), weigh: () => 10,
maxIdleWeight: 0, idleGrace: 100, now: c.now, timers: c.timers });
const p = lru.acquire('a');
lru.release('a');
c.advance(200);
resolve('v');
await p;
assert.deepEqual(unloads, ['a']);
});
});
describe('spineDataWeight', () => {
test('counts timelines, deform frames, draw orders and mesh arrays; never throws', () => {
const f32 = (n) => new Float32Array(n);
const data = {
bones: [{}, {}], slots: [{}],
skins: [{ attachments: [{ body: { vertices: f32(100), regionUVs: f32(50), triangles: [1, 2, 3] }, head: { offset: f32(8) } }, null] }],
animations: [{ timelines: [{ frames: f32(10000), curves: f32(19 * 3333) }, { frames: f32(10), frameVertices: [f32(2000), f32(2000)] }, { frames: f32(4), drawOrders: [[0, 1], null] }] }],
};
const w = spineDataWeight(data);
const arrays = 4 * (100 + 50 + 8 + 10000 + 19 * 3333 + 10 + 2000 + 2000 + 4) + 8 * (3 + 2);
assert.ok(w > arrays && w < arrays * 2, `${w} vs ${arrays}`);
const bigger = { ...data, animations: [...data.animations, { timelines: [{ frames: f32(100000) }] }] };
assert.ok(spineDataWeight(bigger) - w >= 400000, 'grows with the animation data');
for (const junk of [null, undefined, 3, 'x', {}, { animations: 5, skins: [{ attachments: 'x' }] }, { animations: [null, { timelines: [7] }] }]) {
assert.equal(spineDataWeight(junk), SPINE_WEIGHT_MIN);
}
});
test('the store budgets idle skeletons by weight (defaults: SPINE_IDLE_BYTES, drain when no scene, 15 s grace)', async () => {
const d = createAssets({ manifest: M, loadSpine: async () => ({ animations: [] }), unloadSpine: () => {} });
assert.equal(d.spine.cache.maxIdleWeight, SPINE_IDLE_BYTES);
assert.equal(d.spine.cache.quietWeight, 0);
assert.equal(d.spine.cache.idleGrace, SPINE_IDLE_GRACE_MS);
assert.ok(SPINE_IDLE_BYTES <= 64 * 1024 * 1024 && SPINE_IDLE_GRACE_MS >= 5000);
const c = clock(), unloads = [];
const big = { animations: [{ timelines: [{ frames: new Float32Array(5 * 1024 * 1024) }] }] }; // ~20 MB parsed
const a = createAssets({ manifest: M, loadSpine: async () => big, unloadSpine: (e) => unloads.push(e.skel), spineNow: c.now, spineTimers: c.timers });
const e1 = a.spineEntry('char_002_amiya'), e2 = a.spineEntry('char_010_chen'), e3 = a.spineEntry('enemy_1007_slime'), keep = a.spineEntry('token_a');
for (const e of [e1, e2, e3, keep]) await a.spine.acquire(e);
for (const e of [e1, e2, e3]) a.spine.release(e);
c.advance(SPINE_IDLE_GRACE_MS + 10);
assert.deepEqual(unloads, [e1.skel], '3 × 20 MB idle > 48 MB: the oldest goes although the count cap (60) is far');
assert.ok(a.spine.stats().idleWeight <= SPINE_IDLE_BYTES);
a.spine.release(keep);
c.advance(SPINE_IDLE_GRACE_MS + 10);
assert.equal(a.spine.stats().size, 0, 'no scene references any skeleton (lobby / room): all of them go');
});
});
describe('createAssets store', () => {
test('fetches the manifest once; helpers bind to it; missing manifest ⇒ nulls', async () => {
let fetches = 0;
const a = createAssets({ fetch: async () => { fetches++; return { ok: true, json: async () => M }; } });
assert.equal(a.avatar('char_002_amiya'), null, 'before ready');
await Promise.all([a.ready(), a.ready()]);
assert.equal(fetches, 1);
assert.equal(a.avatar('char_002_amiya'), '/a/amiya.png');
assert.equal(a.spineEntry('enemy_1305_mhslim').skel, '/assets/spine/slime.skel');
assert.equal(a.audio.sfx('battle', 'deploy'), '/sfx/dep.mp3');
const warn = console.warn; console.warn = () => {};
try {
const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) });
await b.ready();
assert.equal(b.avatar('char_002_amiya'), null);
assert.deepEqual(b.manifest, {});
} finally { console.warn = warn; }
});
test('image cache + preload progress (failures resolve to null)', async () => {
const loaded = [];
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad')) throw new Error('x'); return { src: u }; } });
const progress = [];
const r = await a.preload(['/a.png', '/bad.png', '/a.png', null, ''], (d, t) => progress.push([d, t]));
assert.deepEqual(r, { ok: 1, failed: 1, total: 2 });
assert.deepEqual(progress.map((p) => p[1]), [2, 2]);
assert.deepEqual((await a.image('/a.png')).src, '/a.png');
assert.equal(await a.image('/bad.png'), null);
assert.equal(loaded.filter((u) => u === '/a.png').length, 1, 'cached');
assert.equal(a.imageNow('/a.png').src, '/a.png');
assert.equal(await a.image(null), null);
});
test('spine acquire/release through the store with an injected loader', async () => {
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), unloadSpine: () => {}, spineMax: 1 });
const e = a.spineEntry('char_002_amiya');
const d = await a.spine.acquire(e);
assert.equal(d.from, e.skel);
assert.equal(a.spine.peek(e), d);
a.spine.release(e);
await assert.rejects(a.spine.acquire(null), /no spine/);
assert.equal(a.spine.stats().refs, 0);
});
});
describe('spine unload frees the atlas page images too', () => {
// pixi-spine's atlas parser loads every page with loader.load({ src }): the decoded ImageBitmap stays in
// PIXI.Assets.loader.promiseCache unless the page URL itself is unloaded (evicted models leaked ~0.8 MB each)
function fakePixi() {
const calls = { assets: [], loader: [] };
const prev = globalThis.PIXI;
globalThis.PIXI = {
spine: {},
Assets: { unload: async (u) => { calls.assets.push(u); }, loader: { unload: async (u) => { calls.loader.push(u); } } },
};
return { calls, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
}
test('spinePages: manifest textures, else <skel>.png', () => {
assert.deepEqual(spinePages(SP('x')), ['/assets/spine/x.png']);
assert.deepEqual(spinePages({ skel: '/s/a.skel', textures: ['/s/a.png', '/s/a2.png'] }), ['/s/a.png', '/s/a2.png']);
assert.deepEqual(spinePages({ skel: '/s/b.skel' }), ['/s/b.png']);
for (const junk of [null, 1, {}, { textures: [null, 3] }]) assert.deepEqual(spinePages(junk), []);
});
test('unloadSpineData unloads skeleton, atlas and every page (pages straight from the loader), minus kept ones', () => {
const f = fakePixi();
try {
unloadSpineData({ skel: '/s/a.skel', atlas: '/s/a.atlas', textures: ['/s/a.png', '/s/a2.png'] });
assert.deepEqual(f.calls.assets, ['/s/a.skel', '/s/a.atlas']);
assert.deepEqual(f.calls.loader, ['/s/a.png', '/s/a2.png']);
f.calls.loader.length = 0;
unloadSpineData({ skel: '/s/b.skel', textures: ['/s/b.png', '/s/shared.png'] }, null, new Set(['/s/shared.png']));
assert.deepEqual(f.calls.loader, ['/s/b.png']);
assert.doesNotThrow(() => unloadSpineData(null));
} finally { f.restore(); }
});
test('LRU eviction through the store unloads the evicted pages, never pages another cached skeleton uses', async () => {
const f = fakePixi();
try {
const E = (id, pages) => ({ ...SP(id), textures: pages });
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1 });
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
await a.spine.acquire(ea);
await a.spine.acquire(eb);
a.spine.release(ea); // a evicted (max 1) while b still uses common.png
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
a.spine.release(eb);
a.spine.clear();
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png', '/assets/spine/b.png', '/assets/spine/common.png']);
} finally { f.restore(); }
});
test('a late value of a dropped load does not unload the URLs a newer load of the same skeleton uses', async () => {
const f = fakePixi();
try {
const pending = [];
const a = createAssets({ manifest: M, loadSpine: (e) => new Promise((r) => pending.push(() => r({ animations: [], from: e.skel }))), spineMax: 5 });
const e = SP('late');
const p1 = a.spine.acquire(e);
a.spine.clear(); // dropped while loading
const p2 = a.spine.acquire(e); // loading again
pending.shift()();
await p1;
assert.deepEqual(f.calls.loader, [], 'the newer entry keeps its pages');
assert.deepEqual(f.calls.assets, []);
pending.shift()();
assert.equal((await p2).from, e.skel);
} finally { f.restore(); }
});
});
+125
View File
@@ -0,0 +1,125 @@
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng } from '../../tools/crop-board-atlas.mjs';
import { PACK_IMAGES, PACK_MESHES, GATE_NODES } from '../../public/js/render/board3d/load.js';
import { VENDOR_FILES } from '../../tools/vendor.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
describe('extract.py board jobs', { skip: !PY && 'no python3' }, () => {
test('job table: gate effects, blower texture and water maps; derived maps for the theme', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { jobs, derived } = JSON.parse(r.stdout);
const by = Object.fromEntries(jobs.map((j) => [j.sub, j]));
assert.equal(by['map/fx'].bundle, 'arts/effects/[pack]map.ab');
assert.deepEqual(by['map/fx'].kinds, ['GameObject', 'Material', 'Mesh', 'Texture2D']);
assert.equal(by['map/common'].bundle, 'arts/maps/common/res.ab');
assert.ok(new RegExp(by['map/common'].keep).test('TX_wind_device') && !new RegExp(by['map/common'].keep).test('TX_R6_bomb'));
assert.ok(new RegExp(by['map/water'].keep).test('[ucp]TX_water_normal'));
// no new mesh/ groups (the board meshes of DESIGN §15 stay the four of the mesh job table)
assert.equal(jobs.filter((j) => j.sub.startsWith('mesh/')).length, 4);
assert.deepEqual(derived.map((d) => [d.sub, d.from, d.derive, d.name]), [
['map/autochess', 'TX_autochessi_N', 'normal_rg', 'TX_autochessi_N_rgb'],
['map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'],
]);
});
test('derived maps: Z of a two-channel normal map is rebuilt; roughness = 1 − smoothness', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`
from PIL import Image
n = Image.new('RGB', (3, 1))
n.putpixel((0, 0), (128, 128, 0)); n.putpixel((1, 0), (255, 128, 0)); n.putpixel((2, 0), (128, 20, 0))
rn = e.derive_normal_rg(n)
m = Image.new('RGBA', (2, 1)); m.putpixel((0, 0), (0, 0, 0, 0)); m.putpixel((1, 0), (0, 0, 0, 200))
rm = e.derive_rough_from_gloss(m)
print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list(rm.getpixel((i, 0))) for i in range(2)], 'mode': [rn.mode, rm.mode]}))`);
assert.deepEqual(out.mode, ['RGB', 'RGB']);
assert.ok(out.n[0][2] >= 254, 'flat normal → z = 1');
assert.ok(Math.abs(out.n[1][2] - 128) <= 1, 'x = 1 → z = 0 (byte 128)');
assert.deepEqual(out.n[0].slice(0, 2), [128, 128], 'XY kept');
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
const out = py(`
from types import SimpleNamespace as NS
class P:
def __init__(self, obj=None, pid=1, fid=0): self.m_PathID, self.m_FileID, self._o = pid, fid, obj
def read(self): return self._o
class MeshFilter:
def __init__(self, **k): self.__dict__.update(k)
go = NS(m_Name='Start_back', m_Component=[NS(component=P(MeshFilter(m_Mesh=P(NS(m_Name='Start_back'), pid=42))))])
print(json.dumps([e.prefab_node(go)['mesh'], e.prefab_node(go, {42: 'Start_back_42'})['mesh'], e.prefab_node(go, {7: 'x'})['mesh']]))`);
assert.deepEqual(out, ['Start_back', 'Start_back_42', 'Start_back']);
});
});
describe('vendored three.js', () => {
test('tools/vendor.mjs copies both ES modules of three (module imports ./three.core.js)', () => {
const names = VENDOR_FILES.map((f) => f[1]);
assert.ok(names.includes('three.module.js') && names.includes('three.core.js'));
const mod = path.join(ROOT, 'public/vendor/three.module.js');
if (existsSync(mod)) {
assert.match(readFileSync(mod, 'utf8').slice(0, 4000), /from '\.\/three\.core\.js'/);
assert.ok(existsSync(path.join(ROOT, 'public/vendor/three.core.js')));
}
const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
assert.ok(pkg.dependencies.three, 'three is a dependency');
});
});
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
for (const [slot, [g, n]] of [...Object.entries(PACK_IMAGES), ...Object.entries(PACK_MESHES)]) {
const e = manifest.groups[g]?.[n];
assert.ok(e && e.path, `${slot}: ${g}/${n}`);
if (hasFiles) assert.ok(existsSync(onDisk(e.path)), `${slot} file`);
}
const prefab = JSON.parse(readFileSync(onDisk(manifest.groups['map/fx'].prefab.path), 'utf8'));
for (const node of Object.values(GATE_NODES)) {
const rec = prefab.find((p) => p.name === node && (node !== 'Start_back' || p.parent === '[opt]start_box'));
assert.ok(rec && manifest.groups['map/fx'][rec.mesh], `${node} → ${rec?.mesh}`);
}
const mats = JSON.parse(readFileSync(onDisk(manifest.groups['map/fx'].materials.path), 'utf8'));
assert.equal(mats['[opt]start_end_add'].shader, 'Torappu/Particles/Additive');
assert.equal(mats['[opt]start_end_ab'].shader, 'Torappu/Particles/AlphaBlend');
assert.equal(mats['[opt]start_end_add'].textures._MainTex.texture, '[opt]merged_textures');
});
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
const g = manifest.groups['map/autochess'];
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
let zSum = 0, cnt = 0;
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
assert.deepEqual([r.w, r.h], [m.w, m.h]);
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
});
});
+234
View File
@@ -0,0 +1,234 @@
// test/render/board3d-lifecycle.test.js — lifecycle and framing regressions of the official 3D board (DESIGN §15,
// public/js/render/board3d + render/app.js glue), found in the board3d review:
// * battle device meshes (render/units.js DeviceView crates) survive the area / stage rebuilds that a camera
// flight triggers mid-battle (prep → Final Assault rebuilt the scene and dropped every crate);
// * GPU resources: every BufferGeometry the scene creates is disposed again on rebuilds and on destroy;
// * the background plane covers every official / fitted framing (no visible edge at 21:9, 4:3, portrait);
// * a 'prep' camera on the boss rows (Final Assault prep) builds / draws / lights the boss field (viewKind);
// * device boxes follow the board layer (3D ⇄ 2D switch mid-battle: lost context) — switchableBox;
// * fitted (portrait) Final Assault prep camera frames the right half for the right-hand player;
// * three.js is only fetched when the local-art manifest lists the board atlas (boardArtListed).
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 * as THREE from 'three';
import { BoardScene, extendPlane, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { AREAS, unionAreas, areaFor } from '../../public/js/render/board3d/layout.js';
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { parseObj } from '../../public/js/render/board3d/obj.js';
import { boardArtListed } from '../../public/js/render/board3d/load.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { viewKind, switchableBox } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
function stubRenderer() {
return {
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() {},
};
}
// the official background plane as exported by tools/local-extract (mesh/s_background_common/pPlane1.obj)
const PLANE_OBJ = ['g pPlane1', 'v -50 0 50', 'v -50 0 -50', 'v 50 0 -50', 'v 50 0 50', 'vt 0 0', 'vt 0 1', 'vt 1 1', 'vt 1 0',
'vn -0 1 0', 'vn -0 1 0', 'vn -0 1 0', 'vn -0 1 0', 'g pPlane1_0', 'f 3/3/3 2/2/2 1/1/1', 'f 4/4/4 3/3/3 1/1/1'].join('\n');
function fakePack() {
const img = { width: 4, height: 4 };
const cube = parseObj(['v -50 -50 -50', 'v 50 -50 -50', 'v 50 50 -50', 'v -50 50 -50', 'vt 0 0', 'vt 1 0', 'vt 1 1', 'vt 0 1', 'vn 0 0 -1', 'f 1/1/1 2/2/1 3/3/1 4/4/1'].join('\n'));
return {
key: 'test', images: { D: img, N: img, R: img, E: img, common: img, gate: img, BG: img, wind: img },
meshes: { crate: null, blower: cube, bgPlane: parseObj(PLANE_OBJ), gate: { startDown: { mesh: cube }, startUp: { mesh: cube }, startBack: { mesh: cube }, endDown: { mesh: cube }, endUp: { mesh: cube } } },
tiles: null, uv: resolveUvTable(null), materials: {},
};
}
const inScene = (board, obj) => { let found = false; board.scene.traverse((o) => { if (o === obj) found = true; }); return found; };
describe('BoardScene lifecycle', () => {
test('battle device meshes survive the area / stage rebuilds of a camera flight (prep → Final Assault)', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act1autochess_m01);
board.setBattleRect({ r0: 0, r1: 5, c0: 0, c1: 20 });
const h = board.createDevice();
h.update(null, { x: 5, y: 4, z: 0, size: 0.9, height: 0.675, alpha: 1 });
const mesh = board.dynamic.children.at(-1);
let disposed = 0;
mesh.geometry.addEventListener('dispose', () => { disposed++; });
// the flight: union of both areas, then the boss field only; a stage swap for good measure
assert.equal(board.setArea(unionAreas(areaFor('prep'), areaFor('boss'))), true);
assert.equal(board.setArea(AREAS.boss), true);
board.setStage(stages.act2autochess_m03);
assert.ok(inScene(board, mesh), 'device mesh still in the scene');
assert.equal(disposed, 0, 'device geometry not disposed by the rebuilds');
assert.equal(board.devices.size, 1);
h.update(null, { x: 6, y: 3, z: 0, size: 0.9, height: 0.675, alpha: 1 });
assert.equal(mesh.position.x, 6, 'the handle still drives its mesh');
h.destroy();
h.destroy(); // idempotent (DeviceView + the board teardown may both destroy it)
assert.equal(disposed, 1);
assert.ok(!inScene(board, mesh));
assert.equal(h.destroyed, true);
board.destroy();
});
test('every BufferGeometry created is disposed again (stage / area rebuilds, crates, devices, destroy)', () => {
const live = new Set();
class CountingGeometry extends THREE.BufferGeometry {
constructor() { super(); live.add(this); this.addEventListener('dispose', () => live.delete(this)); }
}
const T = { ...THREE, BufferGeometry: CountingGeometry };
const board = new BoardScene(T, fakePack(), { renderer: stubRenderer() });
const meshCount = () => { let n = 0; board.scene.traverse((o) => { if (o.isMesh) n++; }); return n; };
const devices = [];
for (const id of ['act2autochess_m01', 'act1autochess_m01', 'act2autochess_m04', 'act1autochess_m03', 'act2autochess_m01']) {
board.setStage(stages[id]);
for (const area of [AREAS.boss, AREAS.unite, AREAS.normal]) board.setArea(area);
board.setBattleRect({ r0: 9, r1: 12, c0: 0, c1: 10 });
board.setBattleRect(null);
devices.push(board.createDevice());
if (devices.length > 2) devices.shift().destroy();
assert.equal(live.size, meshCount(), `${id}: live geometries = meshes in the scene`);
}
assert.ok(live.size < 40, `bounded (${live.size})`);
board.destroy();
assert.equal(live.size, 0, 'destroy frees everything');
});
test('the background plane covers every framing (official 16:9 / 21:9 / 4:3 and fitted portrait); clear = fog colour', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act2autochess_m04);
const g = board.meshes.bg.geometry;
g.computeBoundingBox();
const bb = g.boundingBox;
const B = LIGHTING.bg;
assert.ok(bb.max.x - bb.min.x >= B.size * B.tiles - 1e-3 && bb.max.y - bb.min.y >= B.size * B.tiles - 1e-3, 'extended plane');
assert.ok(Math.abs(bb.max.z - B.z) < 1e-6 && Math.abs(bb.min.z - B.z) < 1e-6, 'flat at the official depth');
const uv = g.getAttribute('uv').array;
assert.ok(Math.min(...uv) <= -0.99 && Math.max(...uv) >= 1.99, 'texel density kept: UVs span the mirrored copies');
assert.equal(board.tex.BG.wrapS, THREE.MirroredRepeatWrapping);
assert.equal(board.tex.BG.wrapT, THREE.MirroredRepeatWrapping);
assert.equal(LIGHTING.clear, LIGHTING.fog.color, 'the void beyond the fogged plane has the fog colour');
// every screen corner of every preset hits the plane, or lies beyond the fog (fully fogged → no edge)
const cx = (bb.min.x + bb.max.x) / 2, cy = (bb.min.y + bb.max.y) / 2, half = (bb.max.x - bb.min.x) / 2;
const bad = [];
for (const [W, H] of [[1920, 1080], [2560, 1080], [3440, 1440], [1024, 768], [900, 1600], [800, 800], [390, 844], [1280, 720]]) {
for (const [kind, o] of [['prep', {}], ['prep', { shop: false }], ['normal', {}], ['unite', {}], ['unite', { half: true, side: 'R' }],
['boss', {}], ['boss', { half: true, side: 'L' }], ['bossPrep', { side: 'R' }], ['bossPrep', {}], ['pen', {}]]) {
const cam = presetCamera(kind, { width: W, height: H }, o);
const P = cam.position();
for (const [sx, sy] of [[0, 0], [W, 0], [0, H], [W, H], [W / 2, 0]]) {
const p = cam.unproject(sx, sy, B.z);
const inside = !!p && Math.abs(p.x - cx) <= half && Math.abs(p.y - cy) <= half;
const far = !!p && Math.hypot(p.x - P.x, p.y - P.y, B.z - P.z) >= LIGHTING.fog.far;
if (!inside && !far) bad.push(`${W}×${H} ${kind} ${JSON.stringify(o)} (${sx},${sy})`);
}
}
}
assert.deepEqual(bad, []);
board.destroy();
});
test('settings quality: low drops the key-light shadow, back to high restores it (shadow map re-rendered)', () => {
const R = stubRenderer();
const board = new BoardScene(THREE, fakePack(), { renderer: R });
board.setStage(stages.act2autochess_m01);
R.shadowMap.needsUpdate = false;
assert.equal(board.setQuality('low'), true);
assert.equal(R.shadowMap.enabled, false);
assert.equal(board.key.castShadow, false);
assert.equal(board.setQuality('low'), false, 'no-op');
assert.equal(board.setQuality('high'), true);
assert.equal(R.shadowMap.enabled, true);
assert.equal(board.key.castShadow, true);
assert.equal(R.shadowMap.needsUpdate, true);
board.destroy();
});
test('extendPlane grows a plane about its centre with UVs scaled about the texture centre', () => {
const src = { position: new Float32Array([0, 0, -8, 2, 0, -8, 2, 2, -8]), uv: new Float32Array([0, 0, 1, 0, 1, 1]), index: new Uint16Array([0, 1, 2]) };
const out = extendPlane(src, 1, 1, 3);
assert.deepEqual([...out.position], [-2, -2, -8, 4, -2, -8, 4, 4, -8]);
assert.deepEqual([...out.uv], [-1, -1, 2, -1, 2, 2]);
assert.equal(extendPlane(src, 1, 1, 1), src, 'k ≤ 1: unchanged');
});
});
describe('render/app.js glue', () => {
test('viewKind: the field a camera request shows (Final Assault prep = the boss field)', () => {
assert.equal(viewKind('prep', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 } }), 'prep');
assert.equal(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 }, side: 'L' }), 'bossPrep');
assert.equal(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 10, c1: 20 }, side: 'R' }), 'bossPrep');
assert.equal(viewKind('prep', {}), 'prep');
assert.equal(viewKind('hidden', {}), 'boss');
assert.equal(viewKind('pen'), 'pen');
assert.equal(viewKind('bogus', null), 'normal');
// the built 3D area follows (the boss field, not the normal field + pen)
assert.equal(areaFor(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })), AREAS.boss);
assert.equal(areaFor(viewKind('prep', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 } })), AREAS.normal);
});
test('switchableBox: device boxes follow the board layer (3D scene ⇄ 2D Pixi box), never a dead scene', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act1autochess_m01);
let current = board;
const pixiBoxes = [];
const pixi = () => { const b = { mesh: { parent: null }, destroyed: false, updates: 0, update() { this.updates++; }, destroy() { this.destroyed = true; } }; pixiBoxes.push(b); return b; };
const box = switchableBox({ board: () => current, pixi });
const B = { x: 5, y: 10, z: 0, size: 0.9, height: 0.675, alpha: 1 };
assert.equal(box.mesh, null, 'nothing before the first update');
box.update(null, B);
assert.equal(box.mesh, null, '3D: no Pixi mesh');
assert.equal(board.devices.size, 1);
// the WebGL context is lost: the scene is destroyed and the view falls back to the 2D board
board.destroy();
current = null;
box.update(null, B);
assert.equal(pixiBoxes.length, 1);
assert.equal(box.mesh, pixiBoxes[0].mesh, '2D: the Pixi box mesh (DeviceView parents it)');
assert.equal(pixiBoxes[0].updates, 1);
// back to 3D (setBoardMode('3d')): the Pixi box goes, a device of the new scene takes over
const board2 = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board2.setStage(stages.act1autochess_m01);
current = board2;
box.update(null, B);
assert.equal(pixiBoxes[0].destroyed, true);
assert.equal(board2.devices.size, 1);
box.destroy();
assert.equal(board2.devices.size, 0);
box.destroy();
board2.destroy();
});
test('fitted Final Assault prep camera (portrait) frames the right half for the right-hand player', () => {
const vp = { width: 900, height: 1600 };
const L = presetCamera('bossPrep', vp, { side: 'L' }), R = presetCamera('bossPrep', vp, { side: 'R' });
assert.ok(L.tx < 10 && R.tx > 10, `L ${L.tx} R ${R.tx}`);
assert.ok(Math.abs(L.tx + R.tx - 20) < 1e-6, 'mirrored about col 10');
// landscape keeps the official left / right boss prepare params
const oL = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side: 'L' }), oR = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side: 'R' });
assert.ok(oL.tx < 10 && oR.tx > 10);
});
});
describe('asset pack gate', () => {
test('boardArtListed: three.js is fetched only when the manifest lists the board atlas', async () => {
const store = (groups, fail = false) => ({
local: async () => { if (fail) throw new Error('offline'); return { groups }; },
localUrl: (g, n) => groups?.[g]?.[n]?.path || null,
});
assert.equal(await boardArtListed(store({ 'map/autochess': { TX_autochessi_D: { path: '/assets/local/map/autochess/TX_autochessi_D.png' } } })), true);
assert.equal(await boardArtListed(store({ 'map/autochess': {} })), false);
assert.equal(await boardArtListed(store(null)), false);
assert.equal(await boardArtListed(store({}, true)), false);
assert.equal(await boardArtListed(null), false);
assert.equal(await boardArtListed({ local: 1 }), false);
});
});
+272
View File
@@ -0,0 +1,272 @@
// test/render/board3d.browser.test.js — the official 3D board scene (DESIGN §15) in headless Chrome through the dev
// demo (public/dev/render-demo.html): all 8 active stages in prep and in combat (recorded battles), at 1920×1080 and
// 1280×720 → screenshots test/e2e/out/board3d-<stage>-<prep|combat>-<1080|720>.png; the Pixi layer (units, drag,
// hit-testing) and the three.js layer project every tile centre to the same pixel; the 2D fallback (?board=2d, a
// lost WebGL context) keeps rendering; FPS / draw calls → test/e2e/out/board3d-perf.json.
//
// Opt-in (starts Chrome, needs the local-client art): RENDER_E2E=1 node --test test/render/board3d.browser.test.js
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const hasArt = existsSync(path.join(ROOT, 'public/assets/local/map/autochess/TX_autochessi_D.png'));
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')) && hasArt;
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome, downloaded assets and the local-client board art)';
const STAGES = [
['act1autochess_m01', 'normal-a1m01'], ['act1autochess_m02', 'normal-a1m02'], ['act1autochess_m03', 'normal-a1m03'], ['act1autochess_m04', 'normal-a1m04'],
['act2autochess_m01', 'normal-m01'], ['act2autochess_m02', 'normal-m02'], ['act2autochess_m03', 'normal-m03'], ['act2autochess_m04', 'normal-m04'],
];
const SIZES = [[1920, 1080], [1280, 720]];
describe('3D board in headless Chrome', { skip }, () => {
let srv, browser;
const perf = { generated: new Date().toISOString(), views: [] };
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
try { writeFileSync(path.join(OUT, 'board3d-perf.json'), JSON.stringify(perf, null, 1)); } catch { /* ignore */ }
await browser?.close();
await srv?.close();
});
async function open(query, w, h) {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?${query}&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return { page, problems };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const short = (id) => id.replace('autochess_', '-');
for (const [stageId, rec] of STAGES) {
for (const [w, h] of SIZES) {
for (const phase of ['prep', 'combat']) {
test(`${stageId} ${phase} ${w}×${h}: 3D board on, clean console, few draw calls`, async () => {
const q = phase === 'prep' ? `scene=prep&stage=${stageId}` : `scene=${rec}&t=30`;
const { page, problems } = await open(q, w, h);
await wait(2600);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, `board3d-${short(stageId)}-${phase}-${h}.png`) });
await page.close();
perf.views.push({ stageId, phase, w, h, fps: st.fps, frameMs: st.frameMs, cpuMs: st.cpuMs, renderMs: st.renderMs, calls: st.board3d?.calls, triangles: st.board3d?.triangles, board3dMs: st.board3d?.cpuMs });
assert.deepEqual(problems, []);
assert.equal(st.board3d?.on, true, `3D board on (${st.board3d?.error || ''})`);
assert.ok(st.board3d.calls > 3 && st.board3d.calls <= 20, `draw calls ${st.board3d.calls}`);
assert.ok(st.fps > 30, `fps ${st.fps}`);
assert.equal(st.mode, phase === 'prep' ? 'prep' : 'battle');
});
}
}
}
test('both layers project every tile centre to the same pixel (units / drag / hit-testing stay aligned)', async () => {
const { page, problems } = await open('scene=prep&stage=act2autochess_m01', 1600, 900);
await wait(1500);
const out = await page.evaluate(async () => {
const THREE = await import('/vendor/three.module.js');
const v = window.__demo.view;
const res = [];
for (const kind of ['prep', 'normal', 'unite', 'boss', 'pen']) {
v.setCamera(kind, { instant: true });
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const cam = v.debug.cam, b = v.debug.board3d;
const canvas = b.renderer.domElement;
const W = canvas.clientWidth, H = canvas.clientHeight;
b.camera.updateMatrixWorld(true);
let worst = 0;
const p = new THREE.Vector3();
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) {
const z = v.debug.tiles.heightAt(r, c);
if (cam.depthOf(c, r, z) < 0.5) continue;
const a = cam.project(c, r, z);
p.set(c, r, z).project(b.camera);
worst = Math.max(worst, Math.abs((p.x + 1) / 2 * W - a.x), Math.abs((1 - p.y) / 2 * H - a.y));
}
res.push({ kind, worst, W, H, pixi: [v.debug.app.view.clientWidth, v.debug.app.view.clientHeight] });
}
return res;
});
await page.close();
assert.deepEqual(problems, []);
for (const r of out) {
assert.ok(r.worst < 0.01, `${r.kind}: ${r.worst} px`);
assert.deepEqual(r.pixi, [r.W, r.H], 'both canvases cover the same box');
}
});
test('camera transitions rebuild the built areas (normal field → Final Assault boss field → back)', async () => {
const { page, problems } = await open('scene=prep&stage=act2autochess_m02', 1280, 720);
await wait(1200);
const keys = await page.evaluate(async () => {
const v = window.__demo.view, b = () => v.debug.board3d;
const k = [b().areaKey];
v.setCamera('boss');
await new Promise((r) => setTimeout(r, 200));
k.push(b().areaKey); // union while flying
await new Promise((r) => setTimeout(r, 1200));
k.push(b().areaKey); // the boss field only
v.setCamera('prep', { instant: true });
k.push(b().areaKey);
return k;
});
await page.screenshot({ path: path.join(OUT, 'board3d-transition.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(keys[0], keys[3], 'back to the normal area');
// user playtest #2 item 6: the prep board no longer builds the enemy pen block (only the pen camera does), so the
// union while flying is the prep field + the boss field
assert.equal(keys[0], '6,12,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,12,0,10', `union while flying: ${keys[1]}`);
assert.equal(keys[2], '0,6,0,20');
});
test('Final Assault prep builds the boss field; battle crates survive a camera flight and a lost context (→ 2D boxes)', async () => {
const { page, problems } = await open('scene=prep&stage=act1autochess_m01', 1280, 720);
await wait(1200);
const r = await page.evaluate(async () => {
const v = window.__demo.view, b = () => v.debug.board3d;
const out = {};
// a 'prep' camera on the boss rows (the right-hand player's half): the boss field is built, drawn and lit
v.setCamera('prep', { rect: { r0: 0, r1: 5, c0: 10, c1: 20 }, side: 'R', instant: true });
out.faArea = b().areaKey; out.faBand = v.debug.tiles.band; out.faCamTx = v.debug.cam.tx;
v.setCamera('prep', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
out.prepArea = b().areaKey;
// boss battle with two crate devices entered while the camera flies prep → boss (two area rebuilds)
v.enterBattle({ fieldId: 'fa', kind: 'boss', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, stageId: 'act1autochess_m01', units: [
{ id: 1, kind: 'device', side: 'enemy', defId: 'trap_1105_accrate', x: 5, y: 4, maxHp: 100 },
{ id: 2, kind: 'device', side: 'enemy', defId: 'trap_1105_accrate', x: 5, y: 3, maxHp: 100 },
] });
v.setCamera('boss', { rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, side: 'L' });
v.pushEvents({ ev: [['deploy', 1], ['deploy', 2]], gt: 0 });
for (let i = 0; i < 16; i++) {
v.pushSnapshot({ fieldId: 'fa', gt: i * 0.1, units: [[1, 5, 4, 100, 100, 0, 0, 0, 0], [2, 5, 3, 100, 100, 0, 0, 0, 0]] });
await new Promise((res) => setTimeout(res, 100));
}
out.bossArea = b().areaKey;
out.devices = b().devices.size;
out.attached = b().dynamic.children.filter((m) => m.visible).length;
// lost context → 2D board: the crates become Pixi boxes (parented in the unit layer)
b().renderer.forceContextLoss();
await new Promise((res) => setTimeout(res, 600));
out.on = !!v.debug.board3d;
out.pixiBoxes = [1, 2].map((id) => { const dv = v.debug.views.get(id); return !!(dv && dv.box && dv.box.mesh && dv.box.mesh.parent); });
return out;
});
await page.screenshot({ path: path.join(OUT, 'board3d-fa-crates-2d.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.faArea, '0,6,0,20', 'FA prep builds the boss field');
assert.deepEqual(r.faBand, [0, 13]);
assert.ok(r.faCamTx > 10, `right-hand boss prep camera (tx ${r.faCamTx})`);
assert.equal(r.prepArea, '6,12,0,10', 'prep board without the pen block (user playtest #2 item 6)');
assert.equal(r.bossArea, '0,6,0,20');
assert.equal(r.devices, 2);
assert.equal(r.attached, 2, 'both crate meshes still in the scene after the flight');
assert.equal(r.on, false);
assert.deepEqual(r.pixiBoxes, [true, true]);
});
test('fallbacks: ?board=2d draws the atlas board; a lost WebGL context switches to 2D without errors', async () => {
const a = await open('scene=prep&stage=act2autochess_m03&board=2d', 1280, 720);
await wait(1500);
const s2 = await a.page.evaluate(() => window.__demo.stats());
await a.page.screenshot({ path: path.join(OUT, 'board3d-fallback-2d.png') });
await a.page.close();
assert.deepEqual(a.problems, []);
assert.equal(s2.board3d.on, false);
assert.equal(s2.boardArt, true, '2D atlas board with the real art');
const b = await open('scene=normal-m03&t=20', 1280, 720);
await wait(1500);
const lost = await b.page.evaluate(async () => {
const v = window.__demo.view;
const before = v.stats().board3d.on;
v.debug.board3d.renderer.forceContextLoss();
await new Promise((r) => setTimeout(r, 500));
const after = v.stats().board3d;
const back = await v.setBoardMode('3d');
return { before, after: after.on, back };
});
await wait(800);
await b.page.screenshot({ path: path.join(OUT, 'board3d-context-restored.png') });
await b.page.close();
assert.deepEqual(b.problems, []);
assert.deepEqual(lost, { before: true, after: false, back: true });
});
test('no local-art manifest: 2D board, three.js is never downloaded; settings quality toggles the 3D shadows', async () => {
const page = await browser.newPage();
const problems = [], three = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setRequestInterception(true);
page.on('request', (r) => {
if (r.url().includes('/vendor/three')) three.push(r.url());
if (r.url().endsWith('/data/local-assets.json')) r.respond({ status: 404, body: '' }); else r.continue();
});
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=prep&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await wait(1200);
const s = await page.evaluate(() => window.__demo.stats());
await page.close();
assert.deepEqual(problems, []);
assert.equal(s.board3d.on, false);
assert.deepEqual(three, [], 'three.js not fetched without the board art');
const q = await open('scene=prep&stage=act2autochess_m02', 1280, 720);
await wait(1200);
const sh = await q.page.evaluate(async () => {
const v = window.__demo.view, out = [];
for (const quality of ['low', 'high']) {
v.setSettings({ quality });
await new Promise((r) => setTimeout(r, 300));
out.push(v.debug.board3d.renderer.shadowMap.enabled);
}
return out;
});
await q.page.close();
assert.deepEqual(q.problems, []);
assert.deepEqual(sh, [false, true]);
});
test('stress: 120 units over the 3D board keep 60 fps-class frame times', async () => {
const { page, problems } = await open('scene=stress', 1920, 1080);
await wait(6000);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'board3d-stress.png') });
await page.close();
perf.stress = { fps: st.fps, frameMs: st.frameMs, cpuMs: st.cpuMs, renderMs: st.renderMs, units: st.units, calls: st.board3d?.calls, board3dMs: st.board3d?.cpuMs };
assert.deepEqual(problems, []);
assert.equal(st.board3d.on, true);
assert.equal(st.units, 120);
assert.ok(st.fps > 45, `fps ${st.fps}`);
assert.ok(st.board3d.cpuMs < 3, `3D board JS per frame ${st.board3d.cpuMs} ms`);
});
test('summary written', () => {
if (perf.views.length) {
const fps = perf.views.map((v) => v.fps);
perf.summary = { views: perf.views.length, minFps: Math.min(...fps), meanFps: Math.round(fps.reduce((a, b) => a + b, 0) / fps.length * 10) / 10, maxCalls: Math.max(...perf.views.map((v) => v.calls || 0)) };
}
writeFileSync(path.join(OUT, 'board3d-perf.json'), JSON.stringify(perf, null, 1));
assert.ok(JSON.parse(readFileSync(path.join(OUT, 'board3d-perf.json'), 'utf8')));
});
});
+342
View File
@@ -0,0 +1,342 @@
// test/render/board3d.test.js — the official 3D board scene (DESIGN §15, public/js/render/board3d): the atlas surface
// table, the OBJ reader, the geometry builder (heights agree with the Pixi layers, areas per phase, gates, devices,
// fences, terrain, sane buffers), the board-space conversion of the official meshes, the asset pack loader (fake
// store), and the three.js scene graph built with the real three package and a stub renderer (no WebGL in Node).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import { SURFACES, SOURCES, surfaceUV, resolveUvTable, cleanSurface, sideRect, tintRgb } from '../../public/js/render/board3d/atlas.js';
import { parseObj, mapMesh } from '../../public/js/render/board3d/obj.js';
import {
buildBoard, classifyStage, heightOf, AREAS, areaFor, unionAreas, objToBoard, boxProjectUV, tube, Geom, uvAt, ROWS, COLS,
} from '../../public/js/render/board3d/layout.js';
import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { loadBoardPack, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
import { parseStage } from '../../public/js/render/tiles.js';
import { TILE_H } from '../../public/js/render/style.js';
import { presetCamera, syncThreeCamera } from '../../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const ACTIVE = Object.values(stages).filter((s) => s.active).map((s) => s.id);
const LOCAL = path.join(ROOT, 'public/assets/local');
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
describe('atlas surfaces', () => {
test('every surface is valid and inside its source; tiles.json overrides merge, junk is ignored', () => {
for (const [k, v] of Object.entries(SURFACES)) {
const c = cleanSurface(v);
assert.ok(c, k);
const S = SOURCES[c.src];
assert.ok(c.rect[0] + c.rect[2] <= S.w && c.rect[1] + c.rect[3] <= S.h, k);
}
const t = resolveUvTable({ board3d: { concrete: { src: 'D', rect: [0, 0, 64, 64], rot: 90 }, bogus: { src: 'X', rect: [0, 0, 1, 1] }, hatch: { src: 'D', rect: [2040, 0, 64, 64] } } });
assert.deepEqual(t.concrete.rect, [0, 0, 64, 64]);
assert.equal(t.concrete.rot, 90);
assert.equal(t.bogus, undefined);
assert.deepEqual(t.hatch.rect, SURFACES.hatch.rect, 'out-of-bounds override rejected');
assert.deepEqual(Object.keys(resolveUvTable(null)).sort(), Object.keys(SURFACES).sort());
});
test('surfaceUV: corner order BL, BR, TR, TL with the texture top on the far edge; rotation and flip permute', () => {
const s = { src: 'D', rect: [256, 512, 256, 256] };
const uv = surfaceUV(s, null, 0);
// BL = (x0, bottom) → v = 1 − 768/2048; TL = (x0, top) → v = 1 − 512/2048
assert.deepEqual(uv.map((x) => +x.toFixed(6)), [0.125, 0.625, 0.25, 0.625, 0.25, 0.75, 0.125, 0.75]);
const r90 = surfaceUV({ ...s, rot: 90 }, null, 0);
assert.deepEqual(r90.slice(0, 2), uv.slice(2, 4), 'rot 90: BL samples the texture BR');
const f = surfaceUV({ ...s, flipX: true }, null, 0);
assert.deepEqual(f.slice(0, 2), uv.slice(2, 4));
const inset = surfaceUV(s, null, 2);
assert.ok(inset[0] > uv[0] && inset[2] < uv[2], 'mip inset');
// side panels keep their aspect: a 1 × 0.42 face uses the top 0.42·272/384 of the gold panel
const sr = sideRect(SURFACES.goldSide, 1, 0.42);
assert.ok(near(sr[3], 0.42 / (384 / 272), 1e-9));
assert.deepEqual(sideRect(SURFACES.goldSide, 1, 5), [0, 0, 1, 1]);
assert.deepEqual(tintRgb('#ff0000'), [1, 0, 0]);
assert.deepEqual(tintRgb('nope'), [1, 1, 1]);
assert.deepEqual(uvAt([0, 0, 1, 0, 1, 1, 0, 1], 0.25, 0.5), [0.25, 0.5]);
});
});
describe('OBJ reader', () => {
test('polygons fan into triangles; relative indices; groups; per-corner de-duplication', () => {
const m = parseObj(['# quad', 'v 0 0 0', 'v 1 0 0', 'v 1 1 0', 'v 0 1 0', 'vt 0 0', 'vt 1 0', 'vt 1 1', 'vt 0 1', 'vn 0 0 1',
'g a', 'f 1/1/1 2/2/1 3/3/1 4/4/1', 'g b', 'f -4/-4/-1 -2/-2/-1 -1/-1/-1'].join('\n'));
assert.equal(m.index.length, 9);
assert.equal(m.position.length / 3, 4, 'shared corners reused');
assert.deepEqual(m.groups.map((g) => [g.name, g.count]), [['a', 6], ['b', 3]]);
assert.deepEqual(m.bounds, { min: [0, 0, 0], max: [1, 1, 0] });
assert.ok(m.normal && m.uv);
assert.equal(parseObj(''), null);
assert.equal(parseObj('v 1 2 3\nf 1 2 9'), null, 'bad indices dropped');
const moved = mapMesh(m, (x, y, z) => [x + 1, y, z]);
assert.equal(moved.position[0], 1);
});
test('vertex colours (v x y z r g b) are read', () => {
const m = parseObj('v 0 0 0 1 0 0\nv 1 0 0 0 1 0\nv 0 1 0 0 0 1\nf 1 2 3');
assert.deepEqual([...m.color], [1, 0, 0, 0, 1, 0, 0, 0, 1]);
});
test('the extracted official meshes parse (when the local client was extracted)', { skip: !existsSync(path.join(LOCAL, 'mesh/s_common_box_01/pCube2.obj')) && 'not extracted' }, () => {
const crate = parseObj(readFileSync(path.join(LOCAL, 'mesh/s_common_box_01/pCube2.obj'), 'utf8'));
assert.deepEqual(crate.bounds.max.map(Math.round), [45, 68, 45]);
const b = objToBoard(crate, 0.01);
// Y-up Unity → board z-up: the crate stands 0.675 tall on the ground, 0.9 wide
let z1 = -Infinity, z0 = Infinity;
for (let i = 2; i < b.position.length; i += 3) { z1 = Math.max(z1, b.position[i]); z0 = Math.min(z0, b.position[i]); }
assert.ok(near(z0, 0, 1e-6) && near(z1, 0.675, 1e-4), `${z0}..${z1}`);
const blower = parseObj(readFileSync(path.join(LOCAL, 'mesh/s_wind_device/S_wild_wind_device.obj'), 'utf8'));
assert.ok(blower.index.length > 100 && blower.uv);
const gate = parseObj(readFileSync(path.join(LOCAL, 'map/fx/Start_up.obj'), 'utf8'));
assert.deepEqual(gate.bounds.min.map(Math.round), [-50, -50, -50], 'the gate box is a 1-tile cube at scale 0.01');
});
});
describe('board geometry (layout.js)', () => {
test('heights agree with the Pixi tile field (units stand on the same tops) on every stage', () => {
for (const st of Object.values(stages)) {
const G = classifyStage(st, AREAS.all);
const P = parseStage(st, [0, 18], [0, 18]);
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const a = G[r][c], b = P[r][c];
assert.equal(a.glyph, b.glyph, `${st.id} ${r},${c}`);
if (a.drawn) assert.ok(near(a.h, b.h), `${st.id} ${r},${c} h ${a.h} vs ${b.h}`);
}
assert.equal(heightOf(G, -1, 3), 0);
}
assert.equal(classifyStage(stages.act2autochess_m01)[10][4].h, TILE_H.wall, 'high ground');
});
test('areas: the normal phase builds the own field + pen, 联防 both halves, the Final Assault the boss field', () => {
const st = stages.act2autochess_m01;
const drawnRows = (area) => { const G = classifyStage(st, area); const rows = new Set(); for (const row of G) for (const t of row) if (t.drawn) rows.add(t.r); return [...rows].sort((a, b) => a - b); };
const n = drawnRows(AREAS.normal), b = drawnRows(AREAS.boss);
assert.ok(n.includes(7) && n.includes(12) && n.includes(16) && !n.includes(3), `normal rows ${n}`);
assert.ok(b.includes(0) && b.includes(5) && !b.includes(9), `boss rows ${b}`);
const G = classifyStage(st, AREAS.normal);
assert.ok(!G[9][14].drawn, 'the partner half is not built in the normal phase');
assert.ok(classifyStage(st, AREAS.unite)[9][14].drawn, '联防 builds the partner half');
assert.equal(areaFor('prep'), AREAS.normal);
assert.equal(areaFor('pen'), AREAS.normal);
assert.equal(areaFor('unite'), AREAS.unite);
assert.equal(areaFor('hidden'), AREAS.boss);
assert.equal(unionAreas(AREAS.normal, AREAS.normal, AREAS.boss).length, 3);
});
test('every active stage: finite buffers, unit normals, UVs inside the atlas, gates / fences / devices / terrain', () => {
for (const id of ACTIVE) {
const st = stages[id];
for (const area of [AREAS.normal, AREAS.unite, AREAS.boss]) {
const b = buildBoard(st, { area });
for (const [name, g] of Object.entries(b.buckets)) {
const n = g.position.length / 3;
assert.equal(g.normal.length, n * 3, `${id} ${name}`);
for (const v of g.position) assert.ok(Number.isFinite(v), `${id} ${name} position`);
for (let i = 0; i < g.normal.length; i += 3) assert.ok(near(Math.hypot(g.normal[i], g.normal[i + 1], g.normal[i + 2]), 1, 1e-4), `${id} ${name} normal`);
for (const u of g.uv) assert.ok(u >= 0 && u <= 1, `${id} ${name} uv ${u}`);
for (const i of g.index) assert.ok(i < n, `${id} ${name} index`);
}
// top faces wind counter-clockwise seen from above (front faces for three.js)
const g = b.buckets.board, P = g.position, I = g.index;
let tops = 0;
for (let t = 0; t < I.length; t += 3) {
const [a, bb, c] = [I[t], I[t + 1], I[t + 2]];
if (g.normal[a * 3 + 2] < 0.99) continue;
const ux = P[bb * 3] - P[a * 3], uy = P[bb * 3 + 1] - P[a * 3 + 1], vx = P[c * 3] - P[a * 3], vy = P[c * 3 + 1] - P[a * 3 + 1];
assert.ok(ux * vy - uy * vx > 0, `${id}: top triangle faces up`);
tops++;
}
assert.ok(tops > 100, `${id} tops`);
const G = b.grid;
const expected = [];
for (const row of G) for (const t of row) if (t.drawn && (t.glyph === 'S' || t.glyph === 'E')) expected.push(`${t.r},${t.c}`);
assert.deepEqual(b.gates.map((x) => `${x.r},${x.c}`).sort(), expected.sort(), `${id} gates`);
const fenced = G.flat().some((t) => t.drawn && t.glyph === 'b');
assert.equal(b.buckets.pipe.vertexCount > 0, fenced, `${id} railings iff fenced tiles`);
for (const d of b.devices) assert.ok(G[d.r][d.c].drawn, `${id} device on a built tile`);
assert.ok(b.edges.length > 0, `${id} field edge glow`);
}
}
// terrain lists
assert.ok(buildBoard(stages.act2autochess_m04).terrain.water.length > 0);
assert.ok(buildBoard(stages.act2autochess_m02).terrain.mire.length > 0);
assert.ok(buildBoard(stages.act2autochess_m03).terrain.smog.length > 0);
assert.ok(buildBoard(stages.act1autochess_m04).terrain.infection.length > 0);
// blowers are active devices of act2 m01; inactive crates of act1 m02 are not built
assert.ok(buildBoard(stages.act2autochess_m01).devices.some((d) => d.kind === 'blower' && d.dir === 'DOWN'));
assert.ok(!buildBoard(stages.act1autochess_m02).devices.some((d) => d.kind === 'crate'));
});
test('malformed stages never throw and still build an island', () => {
for (const st of [null, {}, { rows: ['xyz'] }, { rows: Array(19).fill('r'.repeat(21)), devices: [{ role: 'crate', pos: [3, 3] }, { role: 'crate', pos: 'x' }] }]) {
const b = buildBoard(st, { area: AREAS.all });
assert.ok(b.buckets.board.index.length >= 0);
}
});
test('objToBoard is a proper rotation (normals stay unit, outward); boxProjectUV maps tops and sides', () => {
const box = { position: new Float32Array([0, 1, 0, 1, 1, 0, 1, 1, 1]), normal: new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]), index: new Uint16Array([0, 1, 2]) };
const b = objToBoard(box, 1);
assert.deepEqual([...b.normal.slice(0, 3)], [-0, 0, 1], 'Unity +Y is board +z');
assert.deepEqual([...b.position.slice(3, 6)], [-1, 0, 1]);
const crate = boxProjectUV({ ...boxData(0.9, 0.675), color: null }, SURFACES.crateTop, SURFACES.crateSide);
const top = surfaceUV(SURFACES.crateTop), side = surfaceUV(SURFACES.crateSide);
const inRange = (u, v, uv) => u >= Math.min(uv[0], uv[2], uv[4]) - 1e-6 && u <= Math.max(uv[0], uv[2], uv[4]) + 1e-6 && v >= Math.min(uv[1], uv[5]) - 1e-6 && v <= Math.max(uv[1], uv[5]) + 1e-6;
for (let i = 0; i < crate.uv.length / 2; i++) {
const up = crate.normal[i * 3 + 2] > 0.6;
assert.ok(inRange(crate.uv[i * 2], crate.uv[i * 2 + 1], up ? top : side), `vertex ${i}`);
}
const g = tube(new Geom(), [0, 0, 0], [1, 0, 0], 0.05);
assert.ok(g.vertexCount > 0 && g.triangleCount > 0);
});
});
// ---- the three.js scene with a stub renderer ---------------------------------------------------------------------
function stubRenderer() {
const calls = [];
return {
calls,
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio(d) { calls.push(['dpr', d]); }, setSize(w, h) { calls.push(['size', w, h]); },
render(scene, camera) { calls.push(['render', scene, camera]); },
dispose() { calls.push(['dispose']); },
};
}
function fakePack() {
const img = { width: 4, height: 4 };
const crate = existsSync(path.join(LOCAL, 'mesh/s_common_box_01/pCube2.obj')) ? parseObj(readFileSync(path.join(LOCAL, 'mesh/s_common_box_01/pCube2.obj'), 'utf8')) : null;
const cube = parseObj(['v -50 -50 -50', 'v 50 -50 -50', 'v 50 50 -50', 'v -50 50 -50', 'vt 0 0', 'vt 1 0', 'vt 1 1', 'vt 0 1', 'vn 0 0 -1', 'f 1/1/1 2/2/1 3/3/1 4/4/1'].join('\n'));
return {
key: 'test', images: { D: img, N: img, R: img, E: img, common: img, gate: img, BG: img, wind: img },
meshes: { crate, blower: cube, bgPlane: null, gate: { startDown: { mesh: cube }, startUp: { mesh: cube }, startBack: { mesh: cube }, endDown: { mesh: cube }, endUp: { mesh: cube } } },
tiles: null, uv: resolveUvTable(null), materials: {},
};
}
describe('BoardScene (three.js scene graph, stub renderer)', () => {
test('builds merged meshes per material, few draw objects, shadows once; areas rebuild', () => {
const R = stubRenderer();
const board = new BoardScene(THREE, fakePack(), { renderer: R });
board.setStage(stages.act2autochess_m01);
const meshes = board.root.children;
assert.ok(meshes.length >= 5 && meshes.length <= 16, `draw objects ${meshes.length}`);
for (const m of meshes) assert.ok(m.geometry.getAttribute('position').count > 0);
assert.ok(board.meshes.board && board.meshes.gateStart && board.meshes.gateEndAb && board.meshes.blowers && board.meshes.bg, Object.keys(board.meshes).join());
assert.equal(R.shadowMap.needsUpdate, true);
const before = board.meshes.board.geometry.getAttribute('position').count;
assert.equal(board.setArea(AREAS.boss), true);
assert.notEqual(board.meshes.board.geometry.getAttribute('position').count, before);
assert.equal(board.setArea(AREAS.boss), false, 'same area: no rebuild');
board.destroy();
assert.ok(R.calls.some((c) => c[0] === 'dispose'));
});
test('battle rect hides the static crates inside it (the sim spawns them); device handles follow DeviceView', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act1autochess_m01);
const crates = () => board.meshes.crates ? board.meshes.crates.geometry.getAttribute('position').count : 0;
const all = crates();
assert.ok(all > 0);
board.setBattleRect({ r0: 9, r1: 12, c0: 0, c1: 10 });
assert.ok(crates() < all, 'field crates hidden in battle');
board.setBattleRect(null);
assert.equal(crates(), all);
const h = board.createDevice();
assert.equal(h.mesh, null, 'DeviceView adds nothing to Pixi');
h.update(null, { x: 5, y: 10, z: 0, size: 0.45, height: 0.3, alpha: 0.5 });
const m = [...board.dynamic.children].at(-1);
assert.ok(near(m.position.x, 5) && near(m.scale.x, 0.5, 0.02) && m.material.opacity === 0.5);
h.destroy();
assert.equal(board.devices.size, 0);
board.destroy();
});
test('render syncs the three camera from the projection camera (identical pixels) and animates the gates', () => {
const R = stubRenderer();
const board = new BoardScene(THREE, fakePack(), { renderer: R });
board.setStage(stages.act2autochess_m03);
board.resize(1280, 720, 2);
const cam = presetCamera('normal', { width: 1280, height: 720 });
assert.equal(board.render(cam, 1.25), true);
const ref = syncThreeCamera(cam, new THREE.PerspectiveCamera(), 1280, 720);
board.camera.updateMatrixWorld(true);
for (const [x, y, z] of [[2, 9, 0], [10, 12, 0.42], [6, 10.5, 0]]) {
const a = new THREE.Vector3(x, y, z).project(board.camera), b = new THREE.Vector3(x, y, z).project(ref);
assert.ok(near(a.x, b.x, 1e-9) && near(a.y, b.y, 1e-9));
}
const p0 = board.mat.gateStartAdd.uniforms.uPulse.value;
board.render(cam, 2.25);
assert.notEqual(board.mat.gateStartAdd.uniforms.uPulse.value, p0, 'gate pulse animates');
board.flashObjective(9, 2);
board.render(cam, 2.4);
assert.ok(board.mat.gateEndAb.uniforms.uFlash.value.r > 0.5, 'leak flash');
board.setFocus({ r0: 9, r1: 12, c0: 0, c1: 10 });
for (let i = 0; i < 60; i++) board.render(cam, 2.4 + i / 30);
const F = board.focus.uFocus.value;
assert.ok(near(F.x, -0.5, 0.05) && near(F.w, 12.5, 0.05), 'focus eases to the field rect');
assert.equal(R.calls.filter((c) => c[0] === 'render').length, 63);
assert.deepEqual(board.stats().calls, 7);
board.lost = true;
assert.equal(board.render(cam, 5), false, 'lost context: no draw');
board.destroy();
});
test('gate pulse follows the official 2 s curve; device outlets turn with their dir', () => {
let lo = Infinity, hi = -Infinity;
for (let t = 0; t < 2; t += 0.01) { const v = gatePulse(t); lo = Math.min(lo, v); hi = Math.max(hi, v); }
assert.ok(near(lo, 0.134, 1e-3) && near(hi, 0.229, 1e-3));
assert.ok(near(gatePulse(0.3), gatePulse(2.3), 1e-9), 'periodic');
assert.deepEqual(new Set(Object.values(DIR_TURNS)), new Set([0, 1, 2, 3]));
assert.ok(LIGHTING.key.intensity > 0 && LIGHTING.bg.z < 0);
});
});
describe('asset pack loader (fake store)', () => {
test('loads images through the store and meshes through fetch; null without the diffuse atlas', async () => {
resetBoardPack();
const manifest = { groups: {} };
for (const [slot, [g, n]] of Object.entries(PACK_IMAGES)) (manifest.groups[g] ||= {})[n] = { path: `/assets/local/${g}/${n}.png` };
manifest.groups['mesh/s_common_box_01'] = { pCube2: { path: '/assets/local/mesh/s_common_box_01/pCube2.obj' } };
manifest.groups['map/fx'] = { ...manifest.groups['map/fx'], Start_up: { path: '/assets/local/map/fx/Start_up.obj' }, prefab: { path: '/assets/local/map/fx/prefab.json' } };
const requested = [];
const store = {
local: async () => manifest,
localUrl: (g, n) => manifest.groups[g]?.[n]?.path || null,
image: async (u) => { requested.push(u); return { width: 8, height: 8 }; },
};
const realFetch = globalThis.fetch;
globalThis.fetch = async (u) => {
const s = String(u);
if (s.endsWith('.obj')) return { ok: true, text: async () => 'v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3' };
if (s.endsWith('prefab.json')) return { ok: true, json: async () => [{ name: 'Start_up', parent: 'Start', mesh: 'Start_up', materials: ['[opt]start_end_add'] }] };
return { ok: false, json: async () => null, text: async () => '' };
};
try {
const pack = await loadBoardPack(store);
assert.ok(pack && pack.images.D && pack.images.gate, 'images');
assert.ok(requested.some((u) => u.includes('%5Bopt%5Dmerged_textures')), 'bracketed names are URL-encoded');
assert.ok(pack.meshes.crate && pack.meshes.gate.startUp, 'meshes');
assert.equal(pack.meshes.gate.startUp.material, '[opt]start_end_add');
assert.ok(pack.uv.concrete);
resetBoardPack();
delete manifest.groups['map/autochess'].TX_autochessi_D;
assert.equal(await loadBoardPack(store), null, 'no diffuse atlas → no 3D board');
resetBoardPack();
assert.equal(await loadBoardPack(null), null);
} finally {
globalThis.fetch = realFetch;
resetBoardPack();
}
});
});
+314
View File
@@ -0,0 +1,314 @@
// test/render/boardart.test.js — the real-board-art pipeline and the new pure pieces of the renderer:
// * tools/crop-board-atlas.mjs rects/materials are consistent with the renderer's material list, stay inside
// their source textures, and (when the local art is installed) cover real, non-empty pixels,
// * high-ground plate connectivity, textured device boxes, the backdrop ground plane,
// * every fx kind the sim emits has an explicit visual; junk fx / status keys never throw,
// * the impostor atlas shelf allocator (with a fake PIXI),
// * the optional local-art manifest helpers.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { MATERIALS as CROPS, SOURCES, BACKDROP, decodePng, encodePng, rectStats, pngSize } from '../../tools/crop-board-atlas.mjs';
import { MATERIALS } from '../../public/js/render/textures.js';
import { parseStage, buildTileQuads, boxQuads, buildPlaneQuads, deviceBoxOf, DEVICE_BOX, splitQuadGroups } from '../../public/js/render/tiles.js';
import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const ART_DIR = path.join(ROOT, 'public/assets/local/map/autochess');
const hasArt = existsSync(path.join(ART_DIR, SOURCES.D.file));
describe('board atlas crops (tools/crop-board-atlas.mjs)', () => {
test('every cropped material exists in the renderer atlas; rects stay inside their sources', () => {
for (const [name, layers] of Object.entries(CROPS)) {
assert.ok(MATERIALS.includes(name), `${name} is a renderer material`);
assert.ok(Array.isArray(layers) && layers.length, name);
for (const ly of layers) {
if (ly.proc) { assert.equal(ly.proc, 'rim'); continue; }
const S = SOURCES[ly.src];
assert.ok(S, `${name}: source ${ly.src}`);
const [x, y, w, h] = ly.rect;
assert.ok(x >= 0 && y >= 0 && w > 8 && h > 8 && x + w <= S.w && y + h <= S.h, `${name}: rect ${ly.rect} inside ${ly.src}`);
if (ly.rot != null) assert.ok([0, 90, 180, 270].includes(ly.rot), `${name}: rot`);
if (ly.tint != null) assert.match(ly.tint, /^#[0-9a-f]{6}$/i);
}
}
assert.equal(BACKDROP.src, 'BG');
assert.ok(MATERIALS.length <= 64, 'atlas holds at most 64 cells');
});
test('the real atlas (when installed): sizes match and every first layer covers opaque, non-flat pixels', { skip: !hasArt && 'local art not extracted' }, () => {
const imgs = {};
for (const [k, s] of Object.entries(SOURCES)) {
const f = path.join(ART_DIR, s.file);
if (!existsSync(f)) continue;
const buf = readFileSync(f);
assert.deepEqual(pngSize(buf), { w: s.w, h: s.h }, s.file);
imgs[k] = decodePng(buf);
}
for (const [name, layers] of Object.entries(CROPS)) {
const ly = layers[0];
if (!imgs[ly.src]) continue;
const st = rectStats(imgs[ly.src], ly.rect);
assert.ok(st.opaque > 0.9, `${name}: opaque ${st.opaque}`);
assert.ok(st.std > 2, `${name}: contrast ${st.std}`);
}
const tiles = path.join(ART_DIR, 'tiles.json');
if (existsSync(tiles)) {
const j = JSON.parse(readFileSync(tiles, 'utf8'));
assert.equal(j.cell, 256);
assert.deepEqual(Object.keys(j.materials).sort(), Object.keys(CROPS).sort(), 'tiles.json is up to date (node tools/crop-board-atlas.mjs)');
for (const s of Object.values(j.source)) assert.match(s.path, /^\/assets\/local\/map\/autochess\/TX_autochessi_\w+\.png$/);
}
});
test('PNG codec round-trips RGBA', () => {
const w = 5, h = 3, px = Buffer.alloc(w * h * 4);
for (let i = 0; i < px.length; i++) px[i] = (i * 37) & 255;
const back = decodePng(encodePng(w, h, px));
assert.equal(back.w, w); assert.equal(back.h, h);
assert.deepEqual([...back.rgba], [...px]);
});
});
describe('board geometry', () => {
const uv = Object.fromEntries(MATERIALS.map((m, i) => [m, [i, i, i + 1, i + 1]]));
test('high-ground plates pick their frame piece by connectivity (texture top = far edge)', () => {
const rows = Array(19).fill('#'.repeat(21));
const put = (r, c, g) => { rows[r] = rows[r].slice(0, c) + g + rows[r].slice(c + 1); };
for (const c of [3, 4, 5]) put(10, c, 'h'); // horizontal strip
for (const r of [9, 10, 11]) put(r, 8, 'h'); // vertical strip
put(12, 1, 'h'); // single
for (let c = 0; c < 21; c++) put(11, c, rows[11][c] === '#' ? 'r' : rows[11][c]);
const G = parseStage({ rows, tiles: {} });
assert.equal(G[10][3].top, 'wallL'); assert.equal(G[10][4].top, 'wallM'); assert.equal(G[10][5].top, 'wallR');
assert.equal(G[9][8].top, 'wallB'); assert.equal(G[10][8].top, 'wallVM'); assert.equal(G[11][8].top, 'wallT');
assert.equal(G[12][1].top, 'wall');
assert.equal(G[10][4].mat, 'wall', 'material class unchanged (sides, heights)');
const q = buildTileQuads(G, uv).find((x) => x.row === 10 && x.col === 4 && x.order === 0);
assert.deepEqual(q.u, uv.wallM);
});
test('act2 stages: every drawn wall tile resolves to a known plate material', () => {
for (const id of ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04']) {
const G = parseStage(stages[id]);
for (const row of G) for (const t of row) if (t.glyph === 'h' && t.drawn) assert.ok(MATERIALS.includes(t.top), `${id} ${t.r},${t.c} ${t.top}`);
}
});
test('device boxes: 4 faces (top + S/E/W), E/W carry their face x; roles map from sim device keys', () => {
const q = boxQuads([], uv, 5, 10, 0, 0.84, 0.74, 'crateTop', 'crateSide', { row: 10, col: 5, rot: 1 });
assert.equal(q.length, 4);
assert.deepEqual(q.map((x) => x.faceDir), ['S', 'E', 'W', 0]);
assert.ok(Math.abs(q[1].fx - 5.42) < 1e-9 && Math.abs(q[2].fx - 4.58) < 1e-9);
assert.equal(q[3].rot, 1);
assert.deepEqual(q[3].u, uv.crateTop);
assert.ok(q.every((x) => x.kind === 'block' && x.row === 10));
const top = q[3].pts.map((p) => p[2]);
assert.ok(top.every((z) => Math.abs(z - 0.74) < 1e-9));
assert.equal(deviceBoxOf('trap_1105_accrate'), DEVICE_BOX.crate);
assert.equal(deviceBoxOf('trap_1104_aclasert'), DEVICE_BOX.turret);
assert.equal(deviceBoxOf('trap_9999_unknown'), null);
for (const b of Object.values(DEVICE_BOX)) assert.ok(MATERIALS.includes(b.top) && MATERIALS.includes(b.side));
});
test('ground plane: covers the island with baked contact shadows in [0, 1], never in a block row', () => {
const G = parseStage(stages.act2autochess_m01, [6, 18], [6, 13]);
const plane = buildPlaneQuads(G, 8);
assert.ok(plane.length > 500);
for (const q of plane) {
assert.equal(q.kind, 'plane');
for (const a of q.ao) assert.ok(a > 0 && a <= 1);
assert.ok(q.pts.every((p) => p[2] < 0));
}
const { ground } = splitQuadGroups(plane);
assert.equal(ground.length, plane.length);
// darker right at the island's foot than far away
const at = (x, y) => plane.find((q) => Math.abs(q.pts[3][0] - x) < 0.01 && Math.abs(q.pts[3][1] - y) < 0.01);
assert.ok(at(-4.5, 12.5).ao[3] < at(-12.5, 12.5).ao[3] + 0.3);
});
});
describe('sim fx coverage', () => {
// every literal fx kind in the sim sources: battle.fx('kind' | b.fx('kind' | fx(battle, 'kind' | unitFx(…, 'kind'
const kinds = new Set();
const walk = (dir) => {
for (const f of readdirSync(dir)) {
const p = path.join(dir, f);
if (statSync(p).isDirectory()) walk(p);
else if (f.endsWith('.js')) {
const src = readFileSync(p, 'utf8');
for (const m of src.matchAll(/\b(?:battle|b|b2|this)\.fx\(\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\bfx\((?:battle|b), '([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/[fF]x\(\s*(?:[\w.[\]]+\s*,\s*){0,2}'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\bfxOn\(\s*\w+\s*,\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
// kinds passed as options / defaults (tokens.js burst(…, { fx: 'kind' }), `fx = 'kind'`) and ternaries
for (const m of src.matchAll(/\bfx\s*[:=]\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\.fx\(\s*[\w.!]+\s*\?\s*'([A-Za-z]+)'\s*:\s*'([A-Za-z]+)'/g)) { kinds.add(m[1]); kinds.add(m[2]); }
}
}
};
walk(path.join(ROOT, 'server/sim'));
test('every fx kind literal in server/sim has an explicit visual', () => {
assert.ok(kinds.size > 50, `found ${kinds.size} kinds`);
const missing = [...kinds].filter((k) => !FX_KINDS[k]);
assert.deepEqual(missing, [], 'add these to render/fx.js FX_KINDS');
for (const k of ['aoe', 'zone', 'counter', 'crit', 'dp', 'heal', 'taunt', 'summon', 'pull', 'sonic', 'shield', 'overload', 'takeoff', 'sleep', 'buff', 'reveal', 'dodge', 'burst', 'displace', 'appear', 'disappear', 'substitute', 'swap', 'sandChains', 'sandChainsCharged', 'summonBurst', 'summonStun', 'radiantSword', 'paperDoll']) {
assert.ok(kinds.has(k), `the scan finds ${k}`);
assert.ok(FX_KINDS[k], k);
}
});
test('fxSpec: unknown kinds guess or fall back to generic; junk never throws; element recolours', () => {
assert.equal(fxSpec('weirdHealWave').a, 'heal');
assert.equal(fxSpec('zzzz').a, 'generic');
for (const junk of [null, undefined, 42, {}, [], '']) assert.ok(fxSpec(junk, junk).a);
assert.notEqual(fxSpec('explode', { element: 'burn' }).c, fxSpec('explode', {}).c + 1);
assert.equal(fxSpec('explode', { element: 'burn' }).c, 0xff7a33);
});
test('tilesAround: box / disc / explicit lists, bounded', () => {
assert.equal(tilesAround(5, 10, 1.5, 'box').length, 9);
assert.equal(tilesAround(5, 10, 1, 'disc').length, 5);
assert.deepEqual(tilesAround(0, 0, 1, [[1, 2], ['x'], [3, 4]]), [[1, 2], [3, 4]]);
assert.ok(tilesAround(5, 10, 99, 'box').length <= 169);
});
test('status icons: namespaced content keys resolve to drawn icons; unknown → none', () => {
assert.equal(statusIconKey('ab:frost'), 'cold');
assert.equal(statusIconKey('reed2:scorch'), 'burn');
assert.equal(statusIconKey('skill:shotst_shred'), 'fragile');
assert.equal(statusIconKey('lumen:resist'), 'shield');
assert.equal(statusIconKey('stun'), 'stun');
assert.equal(statusIconKey('talent:angel_bless_ally'), null);
for (const junk of [null, 1, '', {}]) assert.equal(statusIconKey(junk), null);
for (const k of ['ab:frost', 'reed2:scorch', 'sluggish', 'tremble', 'weaken']) assert.ok(STATUS_KEYS.includes(statusIconKey(k)), k);
});
});
describe('impostor atlas allocator', () => {
test('shelf packing, reuse of freed slots, clip pages separate, full ⇒ null', async () => {
class Obj { constructor() { this.children = []; this.parent = null; this.visible = true; this.position = { set: (x, y) => { this.x = x; this.y = y; } }; this.scale = { set: () => {} }; }
addChild(...cs) { for (const c of cs) { c.parent?.removeChild?.(c); c.parent = this; this.children.push(c); } return cs[0]; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parent = null; }
destroy() {} }
const rendered = [];
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: { id: Math.random() }, framebuffer: { enableStencil() {} }, destroy() {} }) },
BLEND_MODES: { ERASE: 26 },
};
try {
const { ImpostorAtlas } = await import('../../public/js/render/impostor.js');
const atlas = new ImpostorAtlas({ resolution: 1, render: (obj, o) => rendered.push(o.clear) });
const a = atlas.alloc(100, 110);
const b = atlas.alloc(100, 110);
assert.ok(a && b && a.page === b.page);
assert.deepEqual([a.x, a.y, a.w, a.h], [0, 0, 112, 112]);
assert.deepEqual([b.x, b.y], [112, 0]);
atlas.free(a);
const c = atlas.alloc(90, 100);
assert.deepEqual([c.x, c.y], [0, 0], 'freed slot reused');
const k = atlas.alloc(100, 110, { clip: true });
assert.ok(k.clip && k.page !== a.page && k.page.kind === 'clip');
// queue + flush: one render per page with work, erasers hidden again, bodies parked
const body = new Obj();
atlas.park(body);
atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
assert.equal(body.parent, c.page.bodies);
atlas.flush();
assert.deepEqual(rendered, [true]);
assert.equal(body.parent, atlas.parked);
assert.equal(body.visible, false);
atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
atlas.flush();
assert.deepEqual(rendered, [true, false], 'later passes only erase their slots');
// fill everything: eventually null, never throws
let n = 0;
while (atlas.alloc(500, 500) && n < 1000) n++;
assert.ok(n > 0 && n < 1000);
assert.equal(atlas.alloc(4000, 10), null, 'larger than a page');
atlas.destroy();
} finally {
delete globalThis.PIXI;
}
});
test('zoom churn (the enemy pen camera: 50 figures grow and shrink again and again) never exhausts the atlas; freeing every slot resets the pages', async () => {
class Obj { constructor() { this.children = []; this.parent = null; this.visible = true; this.position = { set: () => {} }; this.scale = { set: () => {} }; }
addChild(...cs) { for (const c of cs) { c.parent?.removeChild?.(c); c.parent = this; this.children.push(c); } return cs[0]; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parent = null; }
destroy() { this.parent?.removeChild(this); } }
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: {}, framebuffer: { enableStencil() {} }, destroy() {} }) },
BLEND_MODES: { ERASE: 26 },
};
try {
const { ImpostorAtlas } = await import('../../public/js/render/impostor.js');
const atlas = new ImpostorAtlas({ resolution: 2, render() {} });
// 50 figures, each re-allocating whenever its size leaves [0.6, 1] of its slot (render/units.js rule)
const figs = Array.from({ length: 50 }, (_, i) => ({ w0: 40 + (i % 5) * 4, h0: 60 + (i % 7) * 3, slot: null }));
let failed = 0;
const frame = (k) => {
for (const f of figs) {
const w = Math.ceil(f.w0 * k), h = Math.ceil(f.h0 * k);
const s = f.slot;
if (!s || w > s.w || h > s.h || w < s.w * 0.6 || h < s.h * 0.6) {
if (s) atlas.free(s);
f.slot = atlas.alloc(w, h);
if (!f.slot) failed++;
}
}
};
for (let cycle = 0; cycle < 8; cycle++) {
for (let k = 1; k <= 2.2; k += 0.05) frame(k); // pan into the pen: figures ≈ 2.2× bigger
for (let k = 2.2; k >= 1; k -= 0.05) frame(k); // and back
}
const failedChurn = failed;
frame(1);
assert.equal(figs.filter((f) => !f.slot).length, 0, `after 8 pen visits every prep-size figure has an atlas slot (${failedChurn} misses during the zooms)`);
// combat start: every view is destroyed → every page empties and starts over (no stale shelves for the battle)
for (const f of figs) { atlas.free(f.slot); f.slot = null; }
for (const page of atlas.pages) { assert.equal(page.nextY, 0, `${page.kind} page reset`); assert.equal(page.shelves.length, 0); }
const crowd = [];
for (let i = 0; i < 120; i++) crowd.push(atlas.alloc(40 + (i % 6) * 5, 70 + (i % 4) * 6));
assert.equal(crowd.filter((x) => !x).length, 0, 'a 120-unit battle crowd fits after the pen visits');
atlas.destroy();
} finally {
delete globalThis.PIXI;
}
});
});
describe('local-art manifest helpers', () => {
test('localAssetUrl / assets.local(): optional, never throws', async () => {
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: '/assets/local/map/autochess/TX_autochessi_D.png', w: 2048, h: 2048 } } } };
assert.equal(localAssetUrl(m, 'map/autochess', 'TX_autochessi_D'), '/assets/local/map/autochess/TX_autochessi_D.png');
assert.equal(localAssetUrl(m, 'map/autochess', 'nope'), null);
assert.equal(localAssetUrl(null, 'x', 'y'), null);
assert.equal(localAssetUrl(m, '__proto__', 'constructor'), null);
const ok = createAssets({ manifest: {}, fetch: async () => ({ ok: true, json: async () => m }) });
assert.deepEqual(await ok.local(), m);
assert.equal(ok.localUrl('map/autochess', 'TX_autochessi_D'), m.groups['map/autochess'].TX_autochessi_D.path);
const missing = createAssets({ manifest: {}, fetch: async () => ({ ok: false, status: 404 }) });
assert.equal(await missing.local(), null);
assert.equal(missing.localUrl('map/autochess', 'TX_autochessi_D'), null);
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
});
+418
View File
@@ -0,0 +1,418 @@
// test/render/browser.test.js — headless Chrome checks of the battlefield renderer through the dev demo
// (public/dev/render-demo.html): every scene renders without console errors, Spine models load, drag & drop
// round-trips, camera transitions work, per-frame JS cost stays bounded. Screenshots → test/e2e/out/render-*.png.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/browser.test.js
// Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
describe('render engine in headless Chrome', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, w = 1920, h = 1080) {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?${query}`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const err = await page.evaluate(() => window.__demo.error || null);
assert.equal(err, null, 'demo boot');
return { page, problems };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const SCENES = [
['prep', 'scene=prep', 1920, 1080],
['prep-720', 'scene=prep', 1280, 720],
['combat', 'scene=normal-m01&t=18', 1920, 1080],
['combat-720', 'scene=normal-m03&t=30', 1280, 720],
['unite', 'scene=unite-m01&t=25', 1920, 1080],
['boss', 'scene=boss-m02&t=30', 1920, 1080],
['boss-720', 'scene=boss-m02&t=12', 1280, 720],
];
for (const [name, query, w, h] of SCENES) {
test(`${name} ${w}×${h} renders cleanly`, async () => {
const { page, problems } = await open(query + '&panel=0', w, h);
await wait(3500);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, `render-${name}.png`) });
await page.close();
assert.deepEqual(problems, []);
assert.ok(st.units > 0, 'units on screen');
assert.equal(st.spine.failed, 0, 'no Spine load failures');
assert.ok(st.fps > 20, `fps ${st.fps}`);
if (name.startsWith('combat') || name.startsWith('unite') || name.startsWith('boss')) {
assert.equal(st.mode, 'battle');
assert.ok(Math.abs(st.rate - 2) < 0.3, `2× clock (${st.rate})`);
}
});
}
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s)
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
await wait(Math.min(45000, kinds * 260 + 1500));
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.mode, 'battle');
assert.ok(st.units >= 10, `units ${st.units}`);
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
});
test('stress: 120 units + FX keep the per-frame JS cost bounded', async () => {
const { page, problems } = await open('scene=stress&panel=0');
await wait(6000);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.units, 120);
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
assert.ok(st.cpuMs + st.renderMs < 16, `JS per frame ${st.cpuMs} + ${st.renderMs} ms`);
});
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const r = v.pieceScreenRect(uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r, target: { x: t.x, y: t.y } };
});
assert.ok(info.r && info.r.width > 10, 'pieceScreenRect');
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (info.target.x - cx) * i / 10, cy + (info.target.y - cy) * i / 10);
await page.screenshot({ path: path.join(OUT, 'render-drag.png') });
await page.mouse.up();
await wait(700);
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
assert.ok(placed, 'piece moved to the board');
assert.deepEqual([placed.row, placed.col], [10, 5]);
const r2 = await page.evaluate(() => window.__demo.view.pieceScreenRect(window.__demo.scene.state.board[1].uid));
await page.mouse.click(r2.left + r2.width / 2, r2.top + r2.height / 2, { button: 'right' });
await wait(150);
const log = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log, /pieceClick .*"detail":true/);
await page.close();
assert.deepEqual(problems, []);
});
test('API survives junk input, calls in any order, and double destroy', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1000);
const res = await page.evaluate(async () => {
const { createFieldView } = await import('/js/render/app.js');
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:360px;z-index:9';
document.body.appendChild(host);
const v = await createFieldView(host, { data: null, assets: null });
const out = [];
const tryit = (name, fn) => { try { out.push([name, fn()]); } catch (e) { out.push([name, 'THROW ' + e.message]); } };
tryit('pushSnapshot before battle', () => v.pushSnapshot({ t: 1, units: [[1, 2, 3, 4, 5, 6, 7, 8, 9]] }));
tryit('setStage junk', () => v.setStage({ rows: 5 }));
tryit('setStage null', () => v.setStage(null));
tryit('setCamera junk', () => v.setCamera(null, null));
tryit('setCamera weird', () => v.setCamera('zzz', { rect: { r0: 'a' } }));
tryit('setPrep null', () => v.setPrep(null));
tryit('setPrep junk', () => v.setPrep({ hand: 'x', temp: [null, 5, { uid: 'q' }], board: [{ uid: 1, row: 99, col: 1, kind: 'chess', id: 'nope' }, { uid: 2, row: 10, col: 4, kind: 'chess', id: 'nope' }] }, { editable: true, canPlace: () => { throw new Error('x'); } }));
tryit('highlight junk', () => v.highlightTiles('x', 'y'));
tryit('highlight mixed', () => v.highlightTiles([[1, 2], { row: 3, col: 4 }, null, [99, 99], ['a']], { color: 0xff0000 }));
tryit('highlight clear', () => v.highlightTiles(null));
tryit('enterBattle junk', () => v.enterBattle({ units: [null, { id: {} }, { id: 5, x: 'q' }] }));
tryit('pushSnapshot junk', () => [v.pushSnapshot(null), v.pushSnapshot({ t: NaN }), v.pushSnapshot({ t: 1, units: [[5, 1, 1, 10, 10, 0, 0, 0, 0], 'x', [7]] })]);
tryit('pushEvents junk', () => [v.pushEvents(null), v.pushEvents({ ev: 'x' }), v.pushEvents([['atk', 5, 999, 'arrow'], ['dmg', 5, 'x', 'phys'], ['heal'], ['status', 5], ['fx', 'burst', 'a', null, null], ['layer', 1, 'yanShip', 3], ['bounty', 1, 2], ['leak', 5], ['nope'], 7])]);
await new Promise((r) => setTimeout(r, 400));
tryit('snapshot after', () => v.pushSnapshot({ t: 1.2, units: [[5, 1.5, 1, 10, 10, 0, 0, 16, 2]] }));
await new Promise((r) => setTimeout(r, 300));
tryit('pieceScreenRect', () => [v.pieceScreenRect(-1), v.pieceScreenRect(undefined)]);
tryit('settings', () => { v.setSettings(null); v.setSettings({ quality: 'low', damageNumbers: false }); v.setSettings({ quality: 'nope' }); return true; });
tryit('on junk', () => typeof v.on('x', null));
tryit('stats', () => v.stats().mode);
tryit('destroy', () => { v.destroy(); v.destroy(); return true; });
tryit('after destroy', () => [v.setPrep({}), v.pushSnapshot({ t: 9, units: [] }), v.enterBattle({})]);
host.remove();
return out;
});
await wait(300);
await page.close();
const thrown = res.filter(([, r]) => typeof r === 'string' && r.startsWith('THROW'));
assert.deepEqual(thrown, []);
assert.deepEqual(problems.filter((p) => !/\[render\]/.test(p)), []);
});
test('raised blocks share the unit layer and sort around units; operators on walls stand on top', async () => {
// the 2D atlas board's block rows (the 3D board, default when the local art is installed, has none: board=2d)
const { page, problems } = await open('scene=normal-m01&t=4&panel=0&board=2d', 1600, 900);
await wait(2500);
const r = await page.evaluate(() => {
const { tiles, views } = window.__demo.view.debug;
const out = { rows: [...tiles.rowMeshes.keys()], shared: true, order: [], walls: [] };
for (const [id, v] of views) {
if (!v.root || v.destroyed || !v.alive) continue;
const row = Math.round(v.y);
if (Math.round(v.x) === 4 && row >= 10 && row <= 12) {
out.walls.push({ id, z: v.z, shadowOnSurface: v.shadow?.parent === tiles.rowSurfaces.get(row) });
}
for (const [br, { mesh: m }] of tiles.rowMeshes) {
if (m.parent !== v.root.parent) out.shared = false;
const kids = m.parent.children;
// after a render the layer is sorted: the unit is behind the row's blocks iff it stands further back
out.order.push({ id, y: v.y, br, before: kids.indexOf(v.root) < kids.indexOf(m) });
}
}
return out;
});
await page.screenshot({ path: path.join(OUT, 'render-walls.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.rows.length > 0, 'raised rows have block meshes');
assert.ok(r.shared, 'block rows live in the unit layer');
const wrong = r.order.filter((o) => o.before !== (o.y > o.br + 0.5));
assert.deepEqual(wrong, [], 'units behind a block row are drawn before it, others after');
assert.ok(r.walls.length >= 2, `operators on the col-4 walls (${r.walls.length})`);
for (const w of r.walls) {
assert.ok(w.z > 0.12, `unit ${w.id} stands on the wall (z ${w.z})`);
assert.ok(w.shadowOnSurface, `unit ${w.id} shadow lies on the wall top`);
}
});
test('attack wind-up starts before the atk event reaches the render clock', async () => {
const { page, problems } = await open('scene=normal-m01&t=6&panel=0', 1280, 720);
await wait(800);
const log = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const log = [];
const hook = () => {
for (const [id, v] of views) {
if (v._hooked || !v.windUp) continue;
v._hooked = true;
const w = v.windUp.bind(v), a = v.onAttack.bind(v);
v.windUp = (lead) => { const ok = w(lead); if (ok) log.push(['wind', id, interp.renderT, lead]); return ok; };
v.onAttack = (tg, now) => { log.push(['atk', id, interp.renderT]); return a(tg, now); };
}
};
for (let i = 0; i < 80; i++) { hook(); await new Promise((r) => setTimeout(r, 50)); }
return log;
});
await page.close();
assert.deepEqual(problems, []);
const winds = log.filter((e) => e[0] === 'wind');
assert.ok(winds.length >= 3, `wind-ups started (${winds.length})`);
let paired = 0;
for (const [, id, t, lead] of winds) {
assert.ok(lead > 0 && lead <= 1.5, `lead ${lead}`);
const hit = log.find((e) => e[0] === 'atk' && e[1] === id && e[2] >= t);
if (hit && hit[2] - t > 0.02) paired++;
}
assert.ok(paired >= 2, `wind-ups followed by their attack (${paired})`);
});
test('a leaked enemy fades once and is not recreated from the trailing snapshots', async () => {
const { page, problems } = await open('scene=normal-m02&t=33.5&panel=0', 1280, 720);
const seen = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const seen = [];
for (let i = 0; i < 120; i++) {
seen.push([interp.renderT, views.has(18)]);
await new Promise((r) => setTimeout(r, 25));
}
return seen;
});
await page.close();
assert.deepEqual(problems, []);
const first = seen.findIndex(([, has]) => has);
assert.ok(first >= 0, 'enemy 18 shown before its leak');
const gone = seen.findIndex(([, has], i) => i > first && !has);
assert.ok(gone > first && seen[gone][0] > 35, `removed after the leak at 35 (at ${seen[gone]?.[0]})`);
const back = seen.slice(gone).filter(([, has]) => has);
assert.deepEqual(back, [], 'not resurrected by the snapshots that still list it');
});
test('prep scouting board (prep:true + one gt:0 snapshot) stays visible and static', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1200);
const r = await page.evaluate(async () => {
const rec = await fetch('/dev/recordings/normal-m01.json').then((x) => x.json());
const v = window.__demo.view;
const snap = rec.frames[0].snap;
v.enterBattle({ fieldId: 'scout', kind: 'normal', rect: rec.field.rect, units: rec.field.units, prep: true });
v.pushSnapshot({ t: 'b.snap', fieldId: 'scout', gt: 0, units: snap.units });
const pos = () => [...v.debug.views].filter(([, u]) => u.root && !u.destroyed).map(([id, u]) => [id, +u.x.toFixed(3), +u.y.toFixed(3), u.root.visible && u.root.alpha > 0.5]);
await new Promise((res) => setTimeout(res, 1500));
const a = pos();
await new Promise((res) => setTimeout(res, 1500));
return { a, b: pos(), n: rec.field.units.length, mode: v.stats().mode };
});
await page.screenshot({ path: path.join(OUT, 'render-scout.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.a.length, r.n, 'every unit has a view');
assert.ok(r.a.every((u) => u[3]), 'all visible');
assert.deepEqual(r.b, r.a, 'nothing moved or vanished');
});
test('facing hooks: tileScreen / holdPiece / setPieceDir; board pieces and battle allies are built with their dir', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const out = {};
// tileScreen = the camera projection of the tile-top centre, in client px (the wheel's geometry)
const ts = v.tileScreen(10, 5);
const cr = v.debug.app.view.getBoundingClientRect();
const c = v.debug.cam.project(5, 10, v.debug.tiles.heightAt(10, 5));
out.ts = ts && [Math.abs(ts.x - (cr.left + c.x)) < 0.01, Math.abs(ts.y - (cr.top + c.y)) < 0.01, ts.poly.length, ts.s > 0];
out.tsJunk = [v.tileScreen('a', 1), v.tileScreen(1.5, 2)];
// holdPiece pins a bench piece on a board tile across setPrep calls until released
const uid = st.hand[0].uid;
const pv = () => v.debug.views.get('p:' + uid);
v.holdPiece(uid, { row: 10, col: 5 });
await new Promise((res) => setTimeout(res, 450));
out.held = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 1600)); // longer than the drop-pending grace
out.heldAfterPrep = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.holdPiece(uid, null);
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 450));
out.released = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
// setPieceDir re-orients a prep view
out.setDir = [v.setPieceDir(uid, 'UP'), pv().dir, v.setPieceDir(-5, 'UP'), v.setPieceDir(uid, 7)];
v.setPieceDir(uid, 'RIGHT');
// a board piece carrying dir is built facing it (after the round's rebuild: enterPrepMode clears every view)
const b0 = st.board[0];
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
const next = JSON.parse(JSON.stringify(st));
next.board[0].dir = 'LEFT';
if (next.board[1]) next.board[1].dir = 'UP';
v.setPrep(next, { editable: true });
const bv = (p) => v.debug.views.get('p:' + p.uid);
out.built = [bv(b0).dir, bv(b0).facing, next.board[1] ? bv(next.board[1]).dir : 'UP'];
// battle / scouting allies keep UnitInfo.dir (model + ground wedge); a unit without it derives RIGHT, no wedge
v.enterBattle({ fieldId: 'dirs', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', defId: b0.id, x: 3, y: 10, facing: 1, dir: 'UP', maxHp: 100 },
{ id: 2, kind: 'op', side: 'ally', defId: b0.id, x: 4, y: 10, facing: -1, dir: 'LEFT', maxHp: 100 },
{ id: 3, kind: 'op', side: 'ally', defId: b0.id, x: 5, y: 10, facing: 1, maxHp: 100 },
] });
v.pushSnapshot({ fieldId: 'dirs', gt: 0, units: [[1, 3, 10, 100, 100, 0, 0, 0, 0], [2, 4, 10, 100, 100, 0, 0, 0, 0], [3, 5, 10, 100, 100, 0, 0, 0, 0]] });
await new Promise((res) => setTimeout(res, 500));
const u = (id) => v.debug.views.get(id);
out.battle = [1, 2, 3].map((id) => u(id) && [u(id).dir, u(id).hasDir, u(id).facing]);
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(r.ts, [true, true, 4, true], 'tileScreen = projected tile centre + 4 corners');
assert.deepEqual(r.tsJunk, [null, null]);
assert.deepEqual(r.held, [5, 10], 'held on the tile');
assert.deepEqual(r.heldAfterPrep, [5, 10], 'setPrep and the drop grace keep a held piece on its tile');
assert.notDeepEqual(r.released, [5, 10], 'released: back on its bench slot');
assert.deepEqual(r.setDir, [true, 'UP', false, false]);
assert.deepEqual(r.built, ['LEFT', -1, 'UP'], 'stored dir applied at build time');
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
});
test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
window.__demo.refuseDrops = true;
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const pv = v.debug.views.get('p:' + uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r: v.pieceScreenRect(uid), home: [pv.x, pv.y], target: { x: t.x, y: t.y } };
});
const drag = async (tx, ty) => {
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (tx - cx) * i / 10, cy + (ty - cy) * i / 10);
await page.mouse.up();
};
await drag(8, 8);
await wait(500);
const log1 = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log1, /pieceDrop .*"area":"outside","clientX":8,"clientY":8/);
await drag(info.target.x, info.target.y);
await wait(250);
const mid = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
await wait(1800);
const end = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
const onBoard = await page.evaluate((uid) => window.__demo.scene.state.board.some((p) => p.uid === uid), info.uid);
await page.close();
assert.deepEqual(problems, []);
assert.equal(onBoard, false, 'demo refused the move');
assert.ok(Math.hypot(mid[0] - 5, mid[1] - 10) < 0.3, `held at the drop tile while pending (${mid})`);
assert.ok(Math.hypot(end[0] - info.home[0], end[1] - info.home[1]) < 0.05, `back home (${end} vs ${info.home})`);
});
test('DPR change and resize re-fit the canvas', async () => {
const { page, problems } = await open('scene=normal-m02&t=10&panel=0', 1280, 720);
await wait(1000);
const size = () => page.evaluate(() => { const c = window.__demo.view.debug.app.view; return [c.width, c.height]; });
const a = await size();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
await wait(700);
const b = await size();
await page.setViewport({ width: 900, height: 700, deviceScaleFactor: 1 });
await wait(700);
const c = await size();
await page.screenshot({ path: path.join(OUT, 'render-resize.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(b[0] > a[0] * 1.3, `DPR 2 raises the backing resolution (${a} → ${b})`);
assert.ok(c[0] < b[0] && Math.abs(c[0] / c[1] - 900 / 700) < 0.05, `resized to 900×700 aspect (${c})`);
});
test('camera flies prep → boss → unite without errors', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1500);
await page.evaluate(() => window.__demo.view.setCamera('boss'));
await wait(350);
await page.screenshot({ path: path.join(OUT, 'render-camera-mid.png') });
await wait(700);
const cam1 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.evaluate(() => window.__demo.view.setCamera('unite'));
await wait(1000);
const cam2 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.close();
assert.deepEqual(problems, []);
assert.ok(cam1.ty < 5, 'boss camera looks at rows 0–5');
assert.ok(cam2.tx > 9 && cam2.ty > 9, 'unite camera centred on the wide field');
});
});
+236
View File
@@ -0,0 +1,236 @@
// test/render/drag.test.js — drop target resolution and the pointer state machine (no DOM, fake timers).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { GEO } from '../../shared/constants.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController } from '../../public/js/render/drag.js';
describe('slots & targets', () => {
test('tileSlot classifies hand, temp, board and everything else', () => {
assert.deepEqual(tileSlot({ row: 7, col: 0 }), { area: 'hand', idx: 0 });
assert.deepEqual(tileSlot({ row: 7, col: 9 }), { area: 'hand', idx: 9 });
assert.equal(tileSlot({ row: 7, col: 10 }), null);
assert.deepEqual(tileSlot({ row: 8, col: 4 }), { area: 'temp', idx: 0 });
assert.deepEqual(tileSlot({ row: 8, col: 8 }), { area: 'temp', idx: 4 });
assert.equal(tileSlot({ row: 8, col: 3 }), null);
assert.deepEqual(tileSlot({ row: 9, col: 2 }), { area: 'board', row: 9, col: 2 });
assert.deepEqual(tileSlot({ row: 12, col: 10 }), { area: 'board', row: 12, col: 10 });
assert.equal(tileSlot({ row: 12, col: 11 }), null, 'partner half is not ours');
assert.equal(tileSlot({ row: 9, col: 1 }), null);
assert.equal(tileSlot({ row: 13, col: 5 }), null);
assert.equal(tileSlot({ row: 3, col: 5 }), null, 'boss field is not a prep target');
assert.equal(tileSlot(null), null);
assert.equal(tileSlot({ row: 9.5, col: 3 }), null);
});
test('pieceSlot / pieceTile for hand, temp, board', () => {
assert.deepEqual(pieceSlot({ uid: 1, area: 'hand', idx: 3 }), { area: 'hand', idx: 3 });
assert.deepEqual(pieceTile({ uid: 1, area: 'hand', idx: 3 }), { row: GEO.HAND_ROW, col: 3 });
assert.deepEqual(pieceSlot({ uid: 1, area: 'temp', idx: 2 }), { area: 'temp', idx: 2 });
assert.deepEqual(pieceTile({ uid: 1, area: 'temp', idx: 2 }), { row: GEO.TEMP_ROW, col: GEO.TEMP_C0 + 2 });
assert.deepEqual(pieceSlot({ uid: 1, area: 'board', row: 10, col: 4 }), { area: 'board', row: 10, col: 4 });
assert.deepEqual(pieceTile({ uid: 1, row: 10, col: 4 }), { row: 10, col: 4 });
assert.equal(pieceSlot(null), null);
assert.equal(pieceTile({ uid: 1 }), null);
assert.ok(sameSlot({ area: 'hand', idx: 1 }, { area: 'hand', idx: 1 }));
assert.ok(!sameSlot({ area: 'hand', idx: 1 }, { area: 'board', row: 1, col: 1 }));
});
test('resolveDrop: board, hand, outside, cancel cases', () => {
const piece = { uid: 5, area: 'hand', idx: 2 };
const from = pieceSlot(piece);
const base = { piece, from, overCanvas: true, clientX: 10, clientY: 20 };
assert.deepEqual(resolveDrop({ ...base, tile: { row: 10, col: 5 } }), { kind: 'drop', target: { area: 'board', row: 10, col: 5 } });
assert.deepEqual(resolveDrop({ ...base, tile: { row: 7, col: 6 } }), { kind: 'drop', target: { area: 'hand', idx: 6 } });
assert.equal(resolveDrop({ ...base, tile: { row: 7, col: 2 } }).reason, 'same');
assert.equal(resolveDrop({ ...base, tile: { row: 8, col: 5 } }).reason, 'temp');
assert.equal(resolveDrop({ ...base, tile: { row: 15, col: 9 } }).reason, 'notTarget');
assert.deepEqual(resolveDrop({ ...base, tile: null }), { kind: 'drop', target: { area: 'outside', clientX: 10, clientY: 20 } });
assert.deepEqual(resolveDrop({ ...base, tile: { row: 10, col: 5 }, overCanvas: false }).target.area, 'outside');
const canPlace = (p, r, c) => !(r === 10 && c === 5);
assert.equal(resolveDrop({ ...base, tile: { row: 10, col: 5 }, canPlace }).reason, 'illegal');
assert.equal(resolveDrop({ ...base, tile: { row: 10, col: 6 }, canPlace }).kind, 'drop');
// hand targets ask canPlace with (HAND_ROW, idx)
const seen = [];
resolveDrop({ ...base, tile: { row: 7, col: 8 }, canPlace: (p, r, c, t) => { seen.push([r, c, t.area]); return true; } });
assert.deepEqual(seen, [[GEO.HAND_ROW, 8, 'hand']]);
});
test('isLegal: throwing callback ⇒ illegal; no callback ⇒ legal; temp/outside never', () => {
assert.equal(isLegal(() => { throw new Error('x'); }, {}, { area: 'board', row: 9, col: 3 }), false);
assert.equal(isLegal(null, {}, { area: 'board', row: 9, col: 3 }), true);
assert.equal(isLegal(null, {}, { area: 'temp', idx: 0 }), false);
assert.equal(isLegal(null, {}, null), false);
});
});
/** Harness: pieces at canvas points, tiles from a simple 100px grid (col = x/100, row = 12 - y/100). */
function harness(opts = {}) {
const events = [];
const timers = new Map();
let nextTimer = 1;
const pieces = opts.pieces || [
{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }, // tile (7,0)
{ uid: 2, area: 'board', row: 10, col: 4, x: 450, y: 250 },
{ uid: 3, area: 'hand', idx: 1, x: 150, y: 550, draggable: false },
];
const c = createDragController({
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
canPlace: opts.canPlace,
emit: (name, p) => events.push([name, p]),
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id; },
clearTimeout: (id) => timers.delete(id),
});
const ev = (type, x, y, extra = {}) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y, ...extra });
const fire = () => { for (const [id, t] of [...timers]) { timers.delete(id); t.fn(); } };
const names = () => events.map((e) => e[0]).filter((n) => n !== 'pieceDragMove' && n !== 'tileHover');
return { c, events, ev, fire, names, timers };
}
describe('drag controller', () => {
test('click without movement emits pieceClick (not a drag)', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 52, 551);
h.ev('pointerUp', 52, 551);
assert.deepEqual(h.names(), ['pieceClick']);
assert.equal(h.events[0][1].uid, 1);
assert.equal(h.events[0][1].detail, false);
});
test('drag hand → board emits start, moves, drop with board target, end', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 70, 540);
assert.ok(h.c.dragging);
h.ev('pointerMove', 350, 250);
h.ev('pointerUp', 350, 250);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDrop', 'pieceDragEnd']);
const drop = h.events.find((e) => e[0] === 'pieceDrop')[1];
assert.deepEqual(drop.target, { area: 'board', row: 10, col: 3 });
assert.deepEqual(drop.from, { area: 'hand', idx: 0 });
const moves = h.events.filter((e) => e[0] === 'pieceDragMove');
assert.ok(moves.length >= 2);
assert.equal(moves[moves.length - 1][1].legal, true);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 3));
assert.equal(h.c.dragging, false);
});
test('illegal tile (canPlace false) cancels; legality shown during the move', () => {
const h = harness({ canPlace: (p, r, c) => c !== 3 });
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 350, 250);
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.equal(mv.legal, false);
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 3 });
h.ev('pointerUp', 350, 250);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDragEnd']);
assert.equal(h.events.pop()[1].cancelled, true);
});
test('release over DOM covering the canvas ⇒ outside target with client coords', () => {
const h = harness({ domCover: true });
h.c.setEditable(true);
h.ev('pointerDown', 450, 250);
h.ev('pointerMove', 470, 950);
h.ev('pointerUp', 470, 950);
const drop = h.events.find((e) => e[0] === 'pieceDrop')[1];
assert.deepEqual(drop.target, { area: 'outside', clientX: 470, clientY: 950 });
assert.deepEqual(drop.from, { area: 'board', row: 10, col: 4 });
});
test('not editable: press+move does not drag; release gives no click; hover still works', () => {
const h = harness();
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300);
h.ev('pointerUp', 300, 300);
assert.deepEqual(h.names(), []);
h.ev('pointerMove', 450, 250);
assert.deepEqual(h.names(), ['pieceHover']);
assert.equal(h.events.find((e) => e[0] === 'pieceHover')[1].uid, 2);
h.ev('pointerMove', 1000, 250);
assert.equal(h.events.filter((e) => e[0] === 'pieceHover').pop()[1].uid, null);
});
test('non-draggable pieces never start a drag', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 150, 550);
h.ev('pointerMove', 400, 300);
h.ev('pointerUp', 400, 300);
assert.deepEqual(h.names(), []);
});
test('right click ⇒ pieceClick(detail) + pieceDetail, no drag', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { button: 2 });
assert.deepEqual(h.names(), ['pieceClick', 'pieceDetail']);
assert.equal(h.events[0][1].detail, true);
h.ev('pointerMove', 600, 300);
assert.equal(h.c.dragging, false);
});
test('touch long-press ⇒ detail; the later release is not a click', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.fire();
h.ev('pointerUp', 450, 250, { pointerType: 'touch' });
assert.deepEqual(h.names(), ['pieceClick', 'pieceDetail']);
assert.equal(h.events[0][1].detail, true);
});
test('touch drag cancels the long-press timer and uses a larger threshold', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 457, 250, { pointerType: 'touch' });
assert.equal(h.c.dragging, false, 'below touch threshold');
h.ev('pointerMove', 470, 250, { pointerType: 'touch' });
assert.equal(h.c.dragging, true);
assert.equal(h.timers.size, 0, 'long-press timer cleared');
h.ev('pointerUp', 550, 350, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 5 });
});
test('setEditable(false) during a drag cancels it; pointerCancel cancels; other pointers ignored', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300);
h.c.setEditable(false);
assert.equal(h.c.dragging, false);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDragEnd']);
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300, { pointerId: 2 });
assert.equal(h.c.dragging, false, 'second pointer ignored');
h.ev('pointerMove', 300, 300);
h.c.pointerCancel({ pointerId: 1 });
assert.equal(h.c.dragging, false);
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
});
test('a throwing listener or hit-test never breaks the controller', () => {
const c = createDragController({
hitPiece: () => { throw new Error('boom'); },
pickTile: () => { throw new Error('boom'); },
emit: () => { throw new Error('listener'); },
});
c.setEditable(true);
const orig = console.error; console.error = () => {};
try {
assert.equal(c.pointerDown({ pointerId: 1, x: 1, y: 1, button: 0 }), false);
c.pointerMove({ pointerId: 1, x: 5, y: 5 });
c.pointerUp({ pointerId: 1, x: 5, y: 5 });
c.pointerLeave({});
c.reset();
} finally { console.error = orig; }
});
});
+145
View File
@@ -0,0 +1,145 @@
// test/render/fakepixi.js — a minimal headless stand-in for PIXI 7 + pixi-spine + a 2D canvas document, enough to
// construct and update render/units.js views and the procedural textures of render/textures.js in Node.
// Records every canvas made (`canvases`) and every BaseTexture created / destroyed, so tests can assert what a
// view allocated. Install before the first render call; `restore()` puts the previous globals back.
const point = (x = 0, y = 0) => ({ x, y, set(a = 0, b = a) { this.x = a; this.y = b; } });
// every CanvasRenderingContext2D method is a no-op; gradients / measurements return plausible objects
function context2d(canvas) {
const store = {};
return new Proxy(store, {
get(t, k) {
if (k in t) return t[k];
if (k === 'canvas') return canvas;
if (k === 'measureText') return (s) => ({ width: String(s).length * 10, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 });
if (k === 'createLinearGradient' || k === 'createRadialGradient' || k === 'createConicGradient' || k === 'createPattern') return () => ({ addColorStop() {} });
if (k === 'getImageData' || k === 'createImageData') return (x, y, w, h) => { const W = w ?? x?.width ?? 1, H = h ?? x?.height ?? 1; return { width: W, height: H, data: new Uint8ClampedArray(W * H * 4) }; };
if (k === 'getTransform') return () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
if (typeof k === 'symbol') return undefined;
return () => {};
},
set(t, k, v) { t[k] = v; return true; },
});
}
export function installFakePixi() {
const canvases = [];
const baseTextures = [];
const prev = { PIXI: globalThis.PIXI, document: globalThis.document, had: { PIXI: 'PIXI' in globalThis, document: 'document' in globalThis } };
class DisplayObject {
constructor() {
this.children = []; this.parent = null; this.visible = true; this.renderable = true; this.alpha = 1; this.tint = 0xffffff;
this.position = point(); this.scale = point(1, 1); this.anchor = point(); this.pivot = point(); this.skew = point();
this.rotation = 0; this.zIndex = 0; this.destroyed = false; this.blendMode = 0; this.sortableChildren = false;
this._w = null; this._h = null;
}
get x() { return this.position.x; } set x(v) { this.position.x = v; }
get y() { return this.position.y; } set y(v) { this.position.y = v; }
get width() { return this._w ?? (this.texture ? this.texture.width * Math.abs(this.scale.x) : 0); }
set width(v) { this._w = v; }
get height() { return this._h ?? (this.texture ? this.texture.height * Math.abs(this.scale.y) : 0); }
set height(v) { this._h = v; }
addChild(...cs) { for (const c of cs) { if (c.parent && c.parent !== this) c.parent.removeChild(c); if (!this.children.includes(c)) this.children.push(c); c.parent = this; } return cs[0]; }
addChildAt(c) { return this.addChild(c); }
removeChild(...cs) { for (const c of cs) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parent = null; } return cs[0]; }
removeChildren() { const cs = this.children.slice(); for (const c of cs) this.removeChild(c); return cs; }
getLocalBounds() { return { x: -50, y: -100, width: 100, height: 100 }; }
getBounds() { return { x: 0, y: 0, width: 10, height: 10 }; }
toGlobal(p) { return { x: p.x, y: p.y }; }
on() { return this; } off() { return this; } once() { return this; }
destroy() { this.destroyed = true; this.parent?.removeChild(this); }
}
class Container extends DisplayObject {}
class BaseTexture {
constructor(resource = null) {
this.resource = resource; this.valid = true; this.destroyed = false; this.width = resource?.width || 1; this.height = resource?.height || 1;
this._glTextures = {}; baseTextures.push(this);
}
static from(src) { return new BaseTexture(src); }
update() {} setStyle() {} dispose() {} on() { return this; } once() { return this; } off() { return this; }
destroy() { this.destroyed = true; }
}
class Rectangle { constructor(x = 0, y = 0, width = 0, height = 0) { Object.assign(this, { x, y, width, height }); } }
class Texture {
constructor(base, frame) {
this.baseTexture = base || new BaseTexture();
this.frame = frame || new Rectangle(0, 0, this.baseTexture.width, this.baseTexture.height);
this.width = this.frame.width; this.height = this.frame.height; this.destroyed = false; this.orig = this.frame;
}
static from(src) { return new Texture(BaseTexture.from(src)); }
get valid() { return this.baseTexture.valid; }
update() {} on() { return this; } once() { return this; }
destroy(base) { this.destroyed = true; if (base) this.baseTexture.destroy(); }
}
Texture.EMPTY = new Texture(new BaseTexture({ width: 1, height: 1 }));
Texture.WHITE = new Texture(new BaseTexture({ width: 16, height: 16 }));
class Sprite extends Container { constructor(tex) { super(); this.texture = tex || Texture.EMPTY; } }
class TilingSprite extends Sprite {}
class Graphics extends Container {
constructor() { super(); this.geometry = { graphicsData: [] }; }
clear() { return this; } beginFill() { return this; } endFill() { return this; } lineStyle() { return this; } moveTo() { return this; } lineTo() { return this; }
drawRect() { return this; } drawCircle() { return this; } drawEllipse() { return this; } drawPolygon() { return this; } drawRoundedRect() { return this; } closePath() { return this; }
quadraticCurveTo() { return this; } bezierCurveTo() { return this; } arc() { return this; }
}
class Text extends Sprite { constructor(text = '', style = {}) { super(); this.text = text; this.style = style; } }
class BitmapText extends Text {}
class Matrix { constructor() { this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.tx = 0; this.ty = 0; } set() { return this; } translate() { return this; } scale() { return this; } identity() { return this; } }
class RenderTexture extends Texture { static create({ width = 1, height = 1 } = {}) { return new RenderTexture(new BaseTexture({ width, height })); } resize() {} }
const track = () => ({ timeScale: 1, trackTime: 0, animationEnd: 1, animationStart: 0, loop: false, mixDuration: 0, animation: { duration: 1, name: '' }, listener: null });
class Spine extends Container {
constructor(data) {
super();
this.spineData = { findAnimation: () => ({ duration: 1 }), ...data };
this.autoUpdate = true;
this.stateData = { defaultMix: 0, setMix() {} };
this.skeleton = { slots: [], setToSetupPose() {}, setSlotsToSetupPose() {}, updateWorldTransform() {}, findSlot: () => null };
const tracks = [];
this.state = {
tracks, timeScale: 1,
setAnimation: (i, name, loop) => { const e = { ...track(), loop, animation: { duration: 1, name } }; tracks[i] = e; return e; },
addAnimation: (i, name, loop) => { const e = { ...track(), loop, animation: { duration: 1, name } }; return e; },
setEmptyAnimation: () => track(), addEmptyAnimation: () => track(), clearTrack() {}, clearTracks() {}, addListener() {}, update() {}, apply() {},
};
}
update() {}
}
const modes = new Proxy({}, { get: () => 0 });
const P = {
Container, Sprite, TilingSprite, Graphics, Text, BitmapText, Texture, BaseTexture, Rectangle, Matrix, RenderTexture, DisplayObject,
ParticleContainer: Container,
BLEND_MODES: { NORMAL: 0, ADD: 1, MULTIPLY: 2, SCREEN: 3 }, MIPMAP_MODES: modes, SCALE_MODES: modes, WRAP_MODES: modes, ALPHA_MODES: modes,
BitmapFont: { from() {} },
utils: { TextureCache: {}, BaseTextureCache: {} },
spine: { Spine },
};
globalThis.PIXI = P;
globalThis.document = {
createElement(tag) {
const c = { tag, width: 300, height: 150, style: {} };
if (tag === 'canvas') { c.getContext = () => (c._ctx || (c._ctx = context2d(c))); canvases.push(c); }
return c;
},
fonts: undefined,
};
return {
P, canvases, baseTextures,
restore() {
if (prev.had.PIXI) globalThis.PIXI = prev.PIXI; else delete globalThis.PIXI;
if (prev.had.document) globalThis.document = prev.document; else delete globalThis.document;
},
};
}
/** Layers + context for a UnitView (render/units.js ViewCtx). */
export function fakeViewCtx(P, extra = {}) {
const layers = {};
for (const k of ['ground', 'anim', 'overlay', 'shadow', 'groundFx', 'fxNormal', 'units', 'fxAdd', 'bars', 'text', 'screen']) layers[k] = new P.Container();
return {
P, layers, settings: { damageNumbers: true, quality: 'high' }, fx: null,
heightAt: () => 0, animRate: () => 1, timeScale: () => 1, lookupDef: () => null, crowded: () => false,
frameNo: () => 0, impostorInterval: () => 0, clipAllowed: () => true, surfaceLayer: () => null,
...extra,
};
}
+323
View File
@@ -0,0 +1,323 @@
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
// drop targets / directions stay in board space, the right half mirrored), damage numbers that never touch, the
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/fieldview.browser.test.js
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const hasArt = existsSync(path.join(ROOT, 'public/assets/local/map/autochess/TX_autochessi_D.png'));
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
describe('field view features in headless Chrome', { skip }, () => {
let srv, browser;
const perf = { generated: new Date().toISOString(), throttle: 4, views: [] };
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
try { if (perf.views.length) writeFileSync(path.join(OUT, 'fieldview-perf.json'), JSON.stringify(perf, null, 1)); } catch { /* ignore */ }
await browser?.close();
await srv?.close();
});
async function open(query, w = 1920, h = 1080) {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?${query}&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 40000 });
assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot');
return { page, problems };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (const board of ['3d', '2d']) {
test(`enemy preview pen (${board}): R13 official preview idles in the pen, facing left, no bars; 0.25 s pan there and back`, async () => {
if (board === '3d' && !hasArt) return;
const { page, problems } = await open(`scene=prep&pen=late-m01-r13&board=${board}`);
await wait(2500);
await page.screenshot({ path: path.join(OUT, `pen-prep-${board}.png`) });
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const out = { board3d: v.stats().board3d.on, pen: v.stats().pen, total: st.nextEnemies.reduce((s, e) => s + e.count, 0) };
const pens = [...v.debug.penViews.values()];
out.rows = [...new Set(pens.map((p) => Math.round(p.y)))].sort();
out.cols = [Math.min(...pens.map((p) => Math.round(p.x))), Math.max(...pens.map((p) => Math.round(p.x)))];
out.left = pens.every((p) => p.visFacing === -1);
out.bars = pens.some((p) => p.hud.visible && (p.hpFill.visible || p.spFill.visible));
out.upper = st.nextEnemies.filter((e) => e.gate === 'upper').length;
out.upperOk = pens.filter((p) => p.info.enemyKey && Math.round(p.y) >= 17).length > 0 === out.upper > 0;
const before = v.debug.cam.params();
const t0 = performance.now();
v.setCamera('pen', { side: 'L' });
await new Promise((res) => { const f = () => (v.debug.camKind === 'pen' && performance.now() - t0 > 20 && v.debug.cam.params().ty === v.debug.cam.params().ty ? res() : requestAnimationFrame(f)); f(); });
let settled = null;
let last = JSON.stringify(v.debug.cam.params());
while (performance.now() - t0 < 1500) {
await new Promise((res) => requestAnimationFrame(res));
const now = JSON.stringify(v.debug.cam.params());
if (now === last && settled == null && performance.now() - t0 > 60) settled = performance.now() - t0;
if (now !== last) settled = null;
last = now;
}
out.panMs = settled;
out.penCam = v.debug.cam.params();
out.inPenView = pens.filter((p) => !p.culled).length;
v.setCamera('prep');
await new Promise((res) => setTimeout(res, 500));
const back = v.debug.cam.params();
out.backSame = Object.keys(before).every((k) => Math.abs(before[k] - back[k]) < 1e-6);
return out;
});
assert.equal(r.board3d, board === '3d', 'board layer');
assert.ok(r.total > 50 && r.pen <= 50 + 8 && r.pen >= 45, `thinned to ~50 of ${r.total}: ${r.pen}`);
assert.ok(r.rows.every((row) => row >= 14 && row <= 18 && row !== 16), `pen rows ${r.rows}`);
assert.ok(r.cols[0] >= 7 && r.cols[1] <= 13, `pen cols ${r.cols}`);
assert.ok(r.left, 'every pen enemy faces left');
assert.equal(r.bars, false, 'no HP / SP bars in the pen');
assert.ok(r.upperOk, 'upper-gate enemies in rows 17–18');
assert.ok(r.panMs != null && r.panMs < 450, `pan to the pen ≈ 0.25 s (${r.panMs} ms)`);
assert.ok(r.inPenView >= 40, `the pen view shows the pen (${r.inPenView} on screen)`);
assert.ok(r.backSame, 'setCamera(prevKind) returns to the exact camera used before the pen');
await page.evaluate(() => window.__demo.view.setCamera('pen', { instant: true }));
await wait(700);
await page.screenshot({ path: path.join(OUT, `pen-view-${board}.png`) });
await page.close();
assert.deepEqual(problems, []);
});
}
test('pen lifecycle: a real battle empties it; a scouted teammate board shows their preview; tapping an enemy emits its key', async () => {
const { page, problems } = await open('scene=prep', 1600, 900);
await wait(2000);
const r = await page.evaluate(async () => {
const v = window.__demo.view;
const out = { prep: v.stats().pen };
v.setCamera('pen', { instant: true });
await new Promise((res) => setTimeout(res, 400));
const clicks = [];
const off = v.on('pieceClick', (e) => clicks.push(e));
const pv = [...v.debug.penViews.values()].find((p) => !p.culled);
const b = pv.bounds();
const rect = v.debug.app.view.getBoundingClientRect();
out.click = { x: rect.left + b.x + b.width / 2, y: rect.top + b.y + b.height * 0.6, key: pv.info.enemyKey };
window.__clicks = clicks; window.__offClick = off;
return out;
});
await page.mouse.click(r.click.x, r.click.y);
await wait(200);
const r2 = await page.evaluate(async () => {
const v = window.__demo.view;
const clicks = window.__clicks.map((e) => ({ enemyKey: e.enemyKey, preview: e.preview, side: e.unit?.side }));
window.__offClick();
const scout = [{ enemyKey: 'enemy_1007_slime', count: 4, gate: 'upper', t: 1, fly: false, elite: false, source: 'wave' }];
v.enterBattle({ fieldId: 'n:mate', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', prep: true, units: [], nextEnemies: scout });
const scouted = { pen: v.stats().pen, keys: [...new Set([...v.debug.penViews.values()].map((p) => p.info.enemyKey))], rows: [...new Set([...v.debug.penViews.values()].map((p) => Math.round(p.y)))] };
v.enterBattle({ fieldId: 'n:me', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units: [] });
return { clicks, scouted, battle: v.stats().pen };
});
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.prep > 0, 'the demo prep shows its preview');
assert.ok(r2.clicks.some((c) => c.enemyKey === r.click.key && c.preview === true && c.side === 'enemy'), JSON.stringify(r2.clicks));
assert.equal(r2.scouted.pen, 4);
assert.deepEqual(r2.scouted.keys, ['enemy_1007_slime']);
assert.ok(r2.scouted.rows.every((row) => row >= 17), 'upper gate → rows 17–18');
assert.equal(r2.battle, 0, 'cleared at combat start');
});
for (const side of ['L', 'R']) {
test(`Final Assault prep on the ${side} half: pieces on the boss field, drops and directions in board space${side === 'R' ? ', mirrored' : ''}`, async () => {
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
await wait(2500);
const info = await page.evaluate(() => {
const v = window.__demo.view, st = window.__demo.scene.state;
const stage = window.__demo.scene.stageId;
const pf = v.prepField();
const b = st.board[0];
const view = v.debug.views.get('p:' + b.uid);
const hand = st.hand[0];
const hr = v.pieceScreenRect(hand.uid);
// a free melee tile of the own board (board space)
const taken = new Set(st.board.map((p) => `${p.row},${p.col}`));
const melee = (window.__deploy || null);
void melee; void stage;
return {
pf, boardPiece: { row: b.row, col: b.col, x: view.x, y: view.y, dir: view.dir }, hand: { uid: hand.uid, r: hr }, taken: [...taken],
ts: v.tileScreen(9, 2), camTx: v.debug.cam.tx,
};
});
assert.deepEqual(info.pf, { kind: 'bossPrep', side, mirror: side === 'R' });
// the board piece stands on its boss-field tile: row − 7, col mirrored on the right half, facing mirrored
assert.equal(info.boardPiece.y, info.boardPiece.row - 7);
assert.equal(info.boardPiece.x, side === 'R' ? 20 - info.boardPiece.col : info.boardPiece.col);
assert.equal(info.boardPiece.dir, side === 'R' ? 'LEFT' : 'RIGHT');
assert.equal(info.ts.mirror, side === 'R');
assert.ok(side === 'R' ? info.camTx > 10 : info.camTx < 10, `camera frames the own half (tx ${info.camTx})`);
await page.screenshot({ path: path.join(OUT, `fa-prep-${side}.png`) });
// drag the first hand piece onto a free legal board tile: the drop target comes back in board coordinates
const target = await page.evaluate(async () => {
const v = window.__demo.view;
const st = window.__demo.scene.state;
const taken = new Set(st.board.map((p) => `${p.row},${p.col}`));
const stage = (await import('/js/data.js')).data.lookup('stages', window.__demo.scene.stageId);
const tiles = (stage.deployTiles.normal.melee || []).filter(([r, c]) => !taken.has(`${r},${c}`));
const [row, col] = tiles[0];
const t = v.tileScreen(row, col);
window.__drops = [];
v.on('pieceDrop', (e) => window.__drops.push(e.target));
return { row, col, x: t.x, y: t.y };
});
const r = info.hand.r;
const cx = r.left + r.width / 2, cy = r.top + r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 12; i++) await page.mouse.move(cx + (target.x - cx) * i / 12, cy + (target.y - cy) * i / 12);
await page.mouse.up();
await wait(400);
const drops = await page.evaluate(() => window.__drops);
const dirs = await page.evaluate((uid) => {
const v = window.__demo.view;
const b = window.__demo.scene.state.board[0];
v.setPieceDir(b.uid, 'RIGHT');
const a = v.debug.views.get('p:' + b.uid).dir;
v.setPieceDir(b.uid, 'UP');
const u = v.debug.views.get('p:' + b.uid).dir;
void uid;
return { a, u };
}, info.hand.uid);
// review regression: a range reaching past the field has no place on the boss field (a DOWN range below the
// bench used to land on the boss rows 6 / 5 — the wall and the own top row); the HUD padding of this prep is
// the prep one (shop bar below)
const past = await page.evaluate(async () => {
const v = window.__demo.view;
const { hudPadding } = await import('/js/ui/fieldHost.js');
const sz = { width: 1024, height: 1366 };
return {
field: !!v.tileScreen(9, 5), wallBelow: v.tileScreen(6, 5), below: v.tileScreen(5, 5), wallAbove: !!v.tileScreen(13, 5), pen: v.tileScreen(14, 5),
pad: JSON.stringify(hudPadding('bossPrep', sz)) === JSON.stringify(hudPadding('prep', sz)),
};
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(drops[0], { area: 'board', row: target.row, col: target.col }, `drop target in board space: ${JSON.stringify(drops)}`);
assert.deepEqual(dirs, { a: side === 'R' ? 'LEFT' : 'RIGHT', u: 'UP' });
assert.deepEqual(past, { field: true, wallBelow: null, below: null, wallAbove: true, pen: null, pad: true });
});
}
test('damage numbers: a boss and a knot of minions under rapid mixed hits never show touching numbers', async () => {
const { page, problems } = await open('scene=numbers', 1920, 1080);
await wait(2500);
const r = await page.evaluate(async () => {
const v = window.__demo.view;
let frames = 0, bad = 0, maxLive = 0;
const t0 = performance.now();
while (performance.now() - t0 < 4000) {
await new Promise((res) => requestAnimationFrame(res));
// the digits' box of every readable number (BitmapText layout metrics × scale; anchor 0.5 / 1)
const live = v.debug.fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35).map((n) => {
const t = n.text, sc = t.scale.x, w = t.textWidth * sc, h = t.textHeight * sc;
return { x0: t.position.x - w / 2, x1: t.position.x + w / 2, y0: t.position.y - h * 0.85, y1: t.position.y - h * 0.1 };
});
maxLive = Math.max(maxLive, live.length);
frames++;
let hit = false;
for (let i = 0; i < live.length && !hit; i++) for (let j = i + 1; j < live.length; j++) {
const a = live[i], b = live[j];
// 2 px apart at least: numbers side by side must never read as one ('41509')
if (a.x0 - 2 < b.x1 && b.x0 - 2 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1) { hit = true; break; }
}
if (hit) bad++;
}
return { frames, bad, maxLive, stats: v.stats() };
});
await page.screenshot({ path: path.join(OUT, 'dmgnum-1080.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.maxLive >= 8, `busy scene (${r.maxLive} numbers)`);
assert.ok(r.bad <= r.frames * 0.03, `${r.bad}/${r.frames} frames with touching numbers`);
});
test('lost WebGL context: the 2D board takes over, then the 3D board rebuilds itself', async () => {
if (!hasArt) return;
const { page, problems } = await open('scene=normal-m03&t=20', 1280, 720);
await wait(1800);
const r = await page.evaluate(async () => {
const v = window.__demo.view;
const out = { before: v.stats().board3d.on };
v.debug.board3d.renderer.forceContextLoss();
await new Promise((res) => setTimeout(res, 400));
out.during = v.stats().board3d;
await new Promise((res) => setTimeout(res, 2600));
out.after = v.stats().board3d;
out.units = v.stats().units;
return out;
});
await page.screenshot({ path: path.join(OUT, 'board3d-context-recovered.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.before, true);
assert.equal(r.during.on, false, 'the 2D board while the context is gone');
assert.equal(r.during.recovering, true, 'a rebuild is scheduled');
assert.equal(r.after.on, true, 'rebuilt on a fresh context');
assert.equal(r.after.losses, 1);
assert.ok(r.units > 0);
});
const PERF = [
['late-r13', 'scene=late-m01-r13&t=50'], ['late-r12', 'scene=late-m03-r12&t=40'], ['unite', 'scene=unite-m01&t=25'], ['boss', 'scene=boss-m02&t=30'], ['prep-pen', 'scene=prep&pen=late-m01-r13'],
];
for (const board of ['3d', '2d']) {
test(`4× throttled CPU at 1920×1080 (${board}): typical late-round / 联防 / boss / pen scenes hold ≥ 50 fps`, async () => {
if (board === '3d' && !hasArt) return;
const worst = [];
for (const [name, q] of PERF) {
const { page, problems } = await open(`${q}&board=${board}`, 1920, 1080);
await wait(2500);
const cdp = await page.target().createCDPSession();
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await wait(1500);
const r = await page.evaluate(async () => {
const times = [];
let last = performance.now();
const t0 = last;
await new Promise((res) => { const f = (now) => { times.push(now - last); last = now; if (now - t0 < 4000) requestAnimationFrame(f); else res(); }; requestAnimationFrame(f); });
times.shift();
const mean = times.reduce((a, b) => a + b, 0) / times.length;
const st = window.__demo.stats();
return { fps: Math.round((1000 / mean) * 10) / 10, cpuMs: st.cpuMs, renderMs: st.renderMs, units: st.units, pen: st.pen, lod: st.lod, culled: st.culled };
});
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
await page.close();
assert.deepEqual(problems, []);
perf.views.push({ name, board, ...r });
worst.push(`${name} ${r.fps}`);
assert.ok(r.fps >= 50, `${name} (${board}): ${r.fps} fps at 4× throttle (${JSON.stringify(r)})`);
}
void worst;
});
}
});
+305
View File
@@ -0,0 +1,305 @@
// test/render/fxnumbers.test.js — damage-number layout of render/fx.js (FxSystem.number / _updateNums) without PIXI:
// numbers of different styles landing on one unit in quick succession must never be drawn on top of each other
// (an arts '41' and a phys '509' 0.1 s apart used to read as one '41509').
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem } from '../../public/js/render/fx.js';
import { presetCamera } from '../../public/js/render/projection.js';
class FakeText {
constructor(text) {
this.text = text;
this.visible = true;
this.alpha = 1;
this.anchor = { set() {} };
this.scale = { x: 1, set: (v) => { this.scale.x = v; } };
this.position = { x: 0, y: 0, set: (x, y) => { this.position.x = x; this.position.y = y; } };
}
destroy() { this.destroyed = true; }
}
function fakeFx(cam) {
const fx = Object.create(FxSystem.prototype);
Object.assign(fx, {
P: { BitmapText: FakeText },
ctx: { cam: () => cam, layers: { text: { addChild() {} } }, settings: {} },
nums: [], numFree: [], time: 0,
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 },
});
return fx;
}
// screen box of a number (BitmapText, anchor 0.5/1, fontSize 24 × scale): digits ≈ 0.55 em wide, ≈ 0.8 em tall
function box(t) {
const sc = t.text.scale.x;
const w = String(t.text.text).length * 24 * 0.55 * sc, h = 24 * 0.8 * sc;
const { x, y } = t.text.position;
return { x0: x - w / 2, x1: x + w / 2, y0: y - h, y1: y };
}
const overlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
function run(fx, hits, dur = 1.2, dt = 1 / 60) {
const sorted = [...hits].sort((a, b) => a.t - b.t);
let worst = null;
for (let t = 0; t <= dur; t += dt) {
while (sorted.length && sorted[0].t <= t + 1e-9) {
const h = sorted.shift();
fx.number(h.view, h.n, h.style, !!h.big);
}
fx._updateNums(dt);
fx.time += dt;
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
for (let i = 0; i < live.length; i++) {
for (let j = i + 1; j < live.length; j++) {
if (live[i].unit !== live[j].unit) continue;
if (overlap(box(live[i]), box(live[j]))) worst = worst || { t: +t.toFixed(3), a: live[i].text.text, b: live[j].text.text, A: box(live[i]), B: box(live[j]) };
}
}
}
return worst;
}
describe('damage numbers on one unit', () => {
for (const [label, vp] of [['1920×1080', { width: 1920, height: 1080 }], ['1280×720', { width: 1280, height: 720 }]]) {
test(`an arts and a phys number 0.05–0.3 s apart never overlap (${label})`, () => {
const cam = presetCamera('normal', vp);
for (let i = 0; i <= 10; i++) {
const gap = 0.05 + i * 0.025;
const fx = fakeFx(cam);
const drone = { x: 9, y: 9, z: 0, _headTiles: 1.2, maxHp: 3000 };
const bad = run(fx, [{ view: drone, n: 41, style: 'arts', t: 0 }, { view: drone, n: 509, style: 'phys', t: gap }]);
assert.equal(bad, null, `gap ${gap.toFixed(3)} s: ${JSON.stringify(bad)}`);
}
});
}
test('a burst of mixed styles (phys/arts/true/elem/heal) stays readable', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
const styles = ['phys', 'arts', 'true', 'elem', 'heal', 'phys', 'arts'];
const bad = run(fx, styles.map((style, i) => ({ view: u, n: 100 + i * 37, style, t: i * 0.09 })));
assert.equal(bad, null, JSON.stringify(bad));
});
test('a unit under heavy mixed fire (≈ 8 numbers/s, big hits included) keeps its numbers apart', () => {
for (const [kind, vp] of [['normal', { width: 1920, height: 1080 }], ['normal', { width: 1280, height: 720 }], ['boss', { width: 1920, height: 1080 }]]) {
const cam = presetCamera(kind, vp);
const fx = fakeFx(cam);
const u = { x: 8, y: kind === 'boss' ? 4 : 9, z: 0, _headTiles: 1.2, maxHp: 3000 };
let seed = 7;
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
const dt = 1 / 60;
let frames = 0, bad = 0;
const saved = Math.random;
Math.random = rnd;
try {
for (let t = 0; t < 20; t += dt) {
if (rnd() < dt / 0.3) fx.number(u, Math.floor(rnd() * 3000) + 1, 'phys', rnd() < 0.2);
if (rnd() < dt / 0.45) fx.number(u, Math.floor(rnd() * 900) + 1, 'arts', rnd() < 0.1);
if (rnd() < dt / 0.8) fx.number(u, Math.floor(rnd() * 400) + 1, 'heal', false);
if (rnd() < dt / 1.1) fx.number(u, Math.floor(rnd() * 12000) + 1, 'elem', rnd() < 0.5);
fx._updateNums(dt);
fx.time += dt;
frames++;
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
let hit = false;
for (let i = 0; i < live.length && !hit; i++) for (let j = i + 1; j < live.length; j++) if (overlap(box(live[i]), box(live[j]))) { hit = true; break; }
if (hit) bad++;
}
} finally { Math.random = saved; }
assert.ok(bad <= frames * 0.01, `${kind} ${vp.width}: ${bad}/${frames} frames with overlapping numbers`);
}
});
test('same-style hits within the merge window still merge into one number', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
fx.number(u, 120, 'phys', false);
fx._updateNums(0.05); fx.time += 0.05;
fx.number(u, 80, 'phys', false);
assert.equal(fx.nums.length, 1);
assert.equal(fx.nums[0].text.text, '200');
});
test('numbers on different units are independent (no stacking lift)', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const a = { x: 4, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 }, b = { x: 8, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
fx.number(a, 10, 'phys', false);
fx.number(b, 10, 'arts', false);
assert.equal(fx.nums[0].z0, fx.nums[1].z0);
});
test('a long column is capped: it never climbs more than a few lines above the head', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
for (let i = 0; i < 30; i++) {
fx.number(u, 10 + i, ['phys', 'arts', 'true', 'elem'][i % 4], false);
fx._updateNums(0.03); fx.time += 0.03;
}
const s = cam.project(u.x, u.y, 0).s;
const line = 24 * 0.95 * Math.min(1.05, Math.max(0.42, s / 115)) * 1.35;
for (const n of fx.nums) assert.ok(-n.oy <= line * 5 + 1e-6, `oy ${n.oy} (line ${line})`);
assert.ok(fx.nums.filter((n) => !n.fading).length <= 4, 'at most 4 live numbers per unit');
});
});
// every live, readable number against every other one (any unit): boxes must not touch (a 2 px margin — two numbers
// side by side read as one, the '41509' / '201625' screenshots)
function overlapsAll(fx, margin = 2) {
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
for (let i = 0; i < live.length; i++) {
for (let j = i + 1; j < live.length; j++) {
const A = box(live[i]), B = box(live[j]);
if (A.x0 - margin < B.x1 && B.x0 - margin < A.x1 && A.y0 < B.y1 && B.y0 < A.y1) return { a: live[i].text.text, b: live[j].text.text, A, B };
}
}
return null;
}
describe('damage numbers across units (screen-space layout)', () => {
for (const [label, kind, vp] of [['boss 1920', 'boss', { width: 1920, height: 1080 }], ['normal 1280', 'normal', { width: 1280, height: 720 }]]) {
test(`a knot of units on one spot (a boss and 4 minions) under rapid mixed hits: numbers never touch (${label})`, () => {
const cam = presetCamera(kind, vp);
const fx = fakeFx(cam);
const y = kind === 'boss' ? 3 : 10;
const units = [
{ x: 7.2, y, z: 0, _headTiles: 2.2, maxHp: 400000 },
{ x: 7.35, y: y - 0.1, z: 0, _headTiles: 1.0, maxHp: 6000 },
{ x: 7.1, y: y - 0.2, z: 0, _headTiles: 1.0, maxHp: 6000 },
{ x: 7.5, y: y + 0.1, z: 0, _headTiles: 1.2, maxHp: 6000 },
{ x: 8.1, y, z: 0, _headTiles: 1.2, maxHp: 12000 },
];
let seed = 3;
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
const dt = 1 / 60;
let frames = 0, bad = 0, first = null;
for (let t = 0; t < 15; t += dt) {
for (let k = 0; k < 3; k++) {
if (rnd() > dt / 0.1) continue;
const i = (rnd() * units.length) | 0;
const boss = i === 0;
fx.number(units[i], boss ? 2000 + ((rnd() * 200000) | 0) : 40 + ((rnd() * 900) | 0), ['phys', 'arts', 'true', 'elem', 'heal'][(rnd() * 5) | 0], rnd() < 0.15);
}
fx._updateNums(dt);
fx.time += dt;
frames++;
const o = overlapsAll(fx);
if (o) { bad++; first = first || { t: +t.toFixed(2), ...o }; }
}
assert.ok(bad <= frames * 0.01, `${bad}/${frames} frames with touching numbers; first ${JSON.stringify(first)}`);
});
}
test("two neighbours hit at once: '41' and '509' are placed apart (never side by side as '41509')", () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
for (let i = 0; i <= 8; i++) {
const fx = fakeFx(cam);
const a = { x: 7, y: 10, z: 0, _headTiles: 1.2, maxHp: 3000 }, b = { x: 7 + i * 0.1, y: 10, z: 0, _headTiles: 1.2, maxHp: 3000 };
fx.number(a, 41, 'arts', false);
fx._updateNums(0.02); fx.time += 0.02;
fx.number(b, 509, 'phys', false);
for (let f = 0; f < 60; f++) {
fx._updateNums(1 / 60); fx.time += 1 / 60;
const o = overlapsAll(fx, 4);
assert.equal(o, null, `offset ${i * 0.1}: ${JSON.stringify(o)}`);
}
}
});
test('rapid same-style hits merge into a running total that stays up while the hits go on', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 50000 };
let sum = 0;
for (let i = 0; i < 5; i++) {
fx.number(u, 100 + i, 'phys', false);
sum += 100 + i;
for (let f = 0; f < 6; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
}
const phys = fx.nums.filter((n) => n.style === 'phys');
assert.equal(phys.length, 1);
assert.equal(phys[0].text.text, String(sum));
assert.ok(phys[0].end > 0.9, `life extended (${phys[0].end})`);
// a pause longer than the merge gap starts a new number
for (let f = 0; f < 24; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
fx.number(u, 7, 'phys', false);
assert.equal(fx.nums.filter((n) => n.style === 'phys').length, 2);
});
test('crowded spots fade faster; the per-font pools are reused (no new text objects once warm)', () => {
const cam = presetCamera('unite', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const units = Array.from({ length: 8 }, (_, i) => ({ x: 8 + (i % 4) * 0.4, y: 10 + (i >> 2) * 0.4, z: 0, _headTiles: 1.2, maxHp: 5000 }));
for (let i = 0; i < 8; i++) fx.number(units[i], 100 + i, 'arts', false);
const lives = fx.nums.map((n) => n.end);
assert.ok(Math.min(...lives) < Math.max(...lives), `shorter lives when crowded: ${lives}`);
let made = 0;
const Orig = fx.P.BitmapText;
fx.P.BitmapText = class extends Orig { constructor(...a) { super(...a); made++; } };
for (let f = 0; f < 120; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
for (let i = 0; i < 8; i++) fx.number(units[i], 50 + i, 'arts', false);
assert.equal(made, 0, 'recycled from the arts pool');
});
});
describe('damage numbers under heavy AoE (review regression: cost per hit, readability)', () => {
// a projection-counting camera: the layout of the live numbers must happen once per frame, not once per hit
function countingCam(kind, vp) {
const cam = presetCamera(kind, vp);
const project = cam.project.bind(cam);
cam.calls = 0;
cam.project = (...a) => { cam.calls++; return project(...a); };
return cam;
}
for (const layout of ['spread', 'knot']) {
test(`60 enemies (${layout}) hit twice each in one frame, every 0.1 s: O(hits) projections per frame, numbers never touch`, () => {
const cam = countingCam('unite', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const units = [];
for (let i = 0; i < 60; i++) {
units.push(layout === 'knot' ? { x: 12 + (i % 5) * 0.2, y: 10 + ((i / 5) | 0) * 0.1, z: 0, _headTiles: 1.2 } : { x: 1 + (i % 20), y: 9 + ((i / 20) | 0) + 0.3, z: 0, _headTiles: 1.2 });
}
const dt = 1 / 60;
let worstPerHit = 0, bad = 0, frames = 0, maxLive = 0;
for (let f = 0; f < 240; f++) {
if (f % 6 === 0) {
const before = cam.calls;
for (const u of units) { fx.number(u, 100 + ((f * 7) % 900), 'arts', false); fx.number(u, 50 + ((f * 3) % 500), 'phys', false); }
worstPerHit = Math.max(worstPerHit, (cam.calls - before) / 120);
}
fx._updateNums(dt);
fx.time += dt;
frames++;
maxLive = Math.max(maxLive, fx.nums.length);
if (overlapsAll(fx)) bad++;
// every entry of the list is a live number (displaced ones are released at once, never left as obstacles)
assert.ok(fx.nums.every((n) => n.text.visible), 'no released number stays in the live list');
}
// 2 projections for the hit itself + one layout of ≤ 90 live numbers per frame (spread over 120 hits) — the
// per-hit relayout this replaces cost ≈ 2 + 90 per hit
assert.ok(worstPerHit < 4, `projections per hit ${worstPerHit.toFixed(2)}`);
assert.ok(maxLive <= 90, `live numbers ${maxLive}`);
assert.ok(bad <= frames * 0.02, `${bad}/${frames} frames with touching numbers`);
});
}
test('a merged running total grows its box at once: the next hit of the same frame does not land on its digits', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const fx = fakeFx(cam);
const a = { x: 6, y: 10, z: 0, _headTiles: 1.2 }, b = { x: 6.35, y: 10, z: 0, _headTiles: 1.2 };
fx.number(a, 9, 'phys', false);
fx._updateNums(0.02); fx.time += 0.02;
const w0 = fx.nums[0]._w;
fx.number(a, 99990, 'phys', false); // merges: '99999' (5 digits instead of 1)
assert.equal(fx.nums.length, 1);
assert.ok(fx.nums[0]._w > w0 * 2, 'box re-sized with the merge');
fx.number(b, 777, 'arts', false); // same frame: laid out against the grown box
fx._updateNums(0.001); fx.time += 0.001;
assert.equal(overlapsAll(fx), null);
});
});
+38
View File
@@ -0,0 +1,38 @@
// test/render/fxwall.test.js — 余 S3 fire wall visual (render/fx.js): the sim's `firewall` fx carries `axis` ('col' for
// a RIGHT / LEFT facing, 'row' for UP / DOWN — sim/content/kits/tier6.js); the renderer lights that straight line of
// tiles through his tile, clipped to the battle rect (it used to be a round zone whatever the facing).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem, FX_KINDS, wallTiles } from '../../public/js/render/fx.js';
import { presetCamera } from '../../public/js/render/projection.js';
const RECT = { r0: 9, r1: 12, c0: 0, c1: 10 };
test('wallTiles: his column (axis col) or his row (axis row), clipped to the field rect', () => {
assert.deepEqual(wallTiles(4, 10, 'col', RECT), [[9, 4], [10, 4], [11, 4], [12, 4]]);
assert.deepEqual(wallTiles(4.2, 10.4, 'row', RECT), [...Array(11)].map((_, c) => [10, c]));
assert.deepEqual(wallTiles(20, 3, 'row', { r0: 0, r1: 5, c0: 10, c1: 20 }).map(([, c]) => c), [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
// no rect: ±4 tiles, never off the stage
assert.deepEqual(wallTiles(1, 10, 'row', null), [[10, 0], [10, 1], [10, 2], [10, 3], [10, 4], [10, 5]]);
assert.deepEqual(wallTiles('x', 10, 'col', RECT), []);
});
test('the firewall fx flashes the wall tiles along the event axis', () => {
assert.equal(FX_KINDS.firewall.a, 'wall');
const cam = presetCamera('normal', { width: 1280, height: 720 });
const flashes = [];
const fx = Object.create(FxSystem.prototype);
Object.assign(fx, {
ctx: { cam: () => cam, heightAt: () => 0, fieldRect: () => RECT, timeScale: () => 2 },
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 },
tileFlash: (tiles) => flashes.push(tiles),
zone() {},
});
fx.simFx('firewall', 4, 10, { id: 7, dir: 'UP', axis: 'row' });
fx.simFx('firewall', 4, 10, { id: 7, dir: 'RIGHT', axis: 'col' });
fx.simFx('firewall', 4, 10, { id: 7 }); // legacy event without an axis: his column
assert.deepEqual(flashes[0], wallTiles(4, 10, 'row', RECT));
assert.deepEqual(flashes[1], [[9, 4], [10, 4], [11, 4], [12, 4]]);
assert.deepEqual(flashes[2], flashes[1]);
});
+214
View File
@@ -0,0 +1,214 @@
// test/render/interp.test.js — snapshot interpolation buffer: clock, lerp, spawn/die mid-buffer, teleports,
// extrapolation guard, rate estimation, event stamping, robustness against junk.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot, isCosmeticEvent, frameTime } from '../../public/js/render/interp.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const snap = (t, units) => ({ fieldId: 'n:1', t, units, dp: 10, killed: 0, total: 5 });
const U = (id, x, y, hp = 100, anim = 0, flags = 0) => [id, x, y, hp, 100, 5, 10, flags, anim];
/** Feed snapshots every 0.1 game s at 2× (every 50 ms real) starting at real time 0. */
function feed(buf, frames, t0 = 0, realStep = 0.05, gameStep = 0.1) {
frames.forEach((units, i) => buf.push(snap(t0 + i * gameStep, units), i * realStep));
return (frames.length - 1) * realStep;
}
describe('normalizeSnapshot', () => {
test('rejects junk, keeps valid tuples, fills defaults', () => {
assert.equal(normalizeSnapshot(null), null);
assert.equal(normalizeSnapshot({ t: 'x' }), null);
assert.equal(normalizeSnapshot({ t: NaN, units: [] }), null);
const s = normalizeSnapshot({ t: 1, units: [U(1, 2, 3), [2, NaN, 1], 'x', [3], [{}, 1, 1], [4, 1, 2, 'hp']] });
assert.deepEqual([...s.units.keys()], [1, 4]);
assert.deepEqual(s.units.get(4), [4, 1, 2, 0, 0, 0, 0, 0, 0]);
});
test('wire frames: game time comes from `gt` (the frame `t` is the message type)', () => {
const wire = { t: 'b.snap', fieldId: 'n:a', gt: 12.5, units: [U(1, 2, 9)], killed: 1, total: 3 };
assert.equal(frameTime(wire), 12.5);
assert.equal(normalizeSnapshot(wire).t, 12.5);
assert.equal(frameTime({ t: 3.5 }), 3.5, 'raw Battle snapshot / recording');
assert.equal(frameTime({ t: 3.5, gt: 4 }), 4, 'gt wins');
assert.ok(Number.isNaN(frameTime({ t: 'b.snap' })));
assert.ok(Number.isNaN(frameTime({ t: 'b.ev', gt: Infinity })));
assert.ok(Number.isNaN(frameTime(null)));
assert.equal(normalizeSnapshot({ t: 'b.snap', units: [] }), null, 'a wire frame without gt is unusable');
// a buffer fed wire frames and gt-stamped events delivers the events at their snapshot time
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
for (let i = 0; i <= 10; i++) {
b.pushEvents([['dmg', 1, 5, 'phys']], i * 0.05, frameTime({ t: 'b.ev', gt: i * 0.1 }));
assert.equal(b.push({ t: 'b.snap', gt: i * 0.1, units: [U(1, i * 0.1, 10)] }, i * 0.05), true);
}
assert.ok(near(b.newestT, 1.0));
const due = b.takeEvents(0.55);
assert.equal(due.length, 6, 'events stamped 0.0 … 0.5 are due at 0.55');
});
});
describe('SnapshotBuffer', () => {
test('render clock trails the newest snapshot by delay × rate', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
const frames = [];
for (let i = 0; i <= 20; i++) frames.push([U(1, i * 0.1, 10)]);
const now = feed(b, frames);
b.update(now);
assert.ok(near(b.rate, 2, 0.05), `rate ${b.rate}`);
assert.ok(near(b.renderT, b.newestT - 0.2, 0.03), `renderT ${b.renderT} newest ${b.newestT}`);
// steady state: snapshots keep arriving every 50 ms real, frames every 16 ms → stays ~0.2 game s behind
let t = 2, real = now;
for (let i = 0; i < 60; i++) {
real += 0.05; t += 0.1;
b.push(snap(t, [U(1, t, 10)]), real);
for (let f = 1; f <= 3; f++) b.update(real + f * 0.0166);
}
const lag = b.newestT - b.renderT;
assert.ok(lag > 0.05 && lag < 0.25, `steady lag ${lag} (renderT ${b.renderT} newest ${b.newestT})`);
});
test('lerps positions / hp between bracketing snapshots; flags & anim from the older one', () => {
const b = new SnapshotBuffer();
b.push(snap(1.0, [U(1, 0, 10, 100, 1, 0)]), 0);
b.push(snap(1.1, [U(1, 1, 10.5, 50, 2, 16)]), 0.05);
const out = b.sample(1.05);
const s = out.get(1);
assert.ok(near(s.x, 0.5) && near(s.y, 10.25) && near(s.hp, 75));
assert.equal(s.anim, 1);
assert.equal(s.flags, 0);
assert.ok(near(s.vx, 10) && near(s.vy, 5));
const s2 = b.sample(1.1).get(1);
assert.equal(s2.anim, 2);
assert.equal(s2.flags, 16);
});
test('spawn mid-buffer appears only once renderT reaches it; die mid-buffer holds then disappears', () => {
const b = new SnapshotBuffer();
b.push(snap(0, [U(1, 0, 10), U(2, 5, 10)]), 0);
b.push(snap(0.1, [U(1, 1, 10), U(3, 9, 12)]), 0.05);
let out = b.sample(0.05);
assert.ok(out.has(1) && out.has(2), 'dying unit still shown before the newer snapshot');
assert.ok(!out.has(3), 'spawned unit not shown early');
assert.ok(near(out.get(2).x, 5), 'holds last position');
out = b.sample(0.1, out);
assert.ok(out.has(3) && !out.has(2), 'sample map reused and pruned');
});
test('teleports (> teleport tiles between snapshots) snap instead of sliding', () => {
const b = new SnapshotBuffer({ teleport: 2 });
b.push(snap(0, [U(1, 0, 10)]), 0);
b.push(snap(0.1, [U(1, 8, 10)]), 0.05);
assert.equal(b.sample(0.05).get(1).x, 0);
assert.equal(b.sample(0.1).get(1).x, 8);
});
test('extrapolation guard: never more than maxExtrapolate past the newest; then freezes', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2, maxExtrapolate: 0.1 });
const frames = [];
for (let i = 0; i <= 10; i++) frames.push([U(1, i * 0.1, 10)]); // 1 tile/s game
const now = feed(b, frames);
b.update(now);
for (let k = 1; k <= 200; k++) b.update(now + k * 0.02); // stream stalls for 4 s
assert.ok(b.renderT <= b.newestT + 0.1 * b.rate + 1e-9, `renderT ${b.renderT}`);
const s = b.sample();
assert.ok(s.get(1).x <= 1.0 + 0.2 + 1e-6 && s.get(1).x >= 1.0, `extrapolated x ${s.get(1).x}`);
});
test('large lag snaps the clock; a stream restart (t jumps back) resets the buffer', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2, snapAfter: 0.5 });
b.push(snap(0, [U(1, 0, 10)]), 0);
b.update(0);
b.push(snap(30, [U(1, 3, 10)]), 0.1); // server jumped ahead
b.update(0.11);
assert.ok(b.renderT > 29, `snapped to ${b.renderT}`);
b.pushEvents([['dmg', 1, 5, 'phys']], 0.12);
assert.ok(b.push(snap(0.5, [U(9, 1, 1)]), 0.2), 'accepted after reset');
assert.equal(b.size, 1);
assert.equal(b.events.length, 0, 'old events dropped on restart');
});
test('duplicate / out-of-order / junk snapshots are ignored', () => {
const b = new SnapshotBuffer();
assert.ok(b.push(snap(1, [U(1, 0, 0)]), 0));
assert.ok(!b.push(snap(1, [U(1, 9, 9)]), 0.01));
assert.ok(!b.push(snap(0.9, [U(1, 9, 9)]), 0.02));
assert.ok(!b.push({ t: 'no' }, 0.03));
assert.ok(!b.push(null, 0.03));
assert.equal(b.size, 1);
assert.equal(b.sample(1).get(1).x, 0);
});
test('rate estimation follows the stream (1× and 4×) within clamps', () => {
for (const rate of [1, 4]) {
const b = new SnapshotBuffer({ rate: 2 });
for (let i = 0; i < 80; i++) b.push(snap(i * 0.1, [U(1, 0, 0)]), i * 0.1 / rate);
assert.ok(near(b.rate, rate, 0.1), `estimated ${b.rate} for ${rate}`);
}
});
test('old snapshots are trimmed even without frames (hidden tab); events queue is bounded', () => {
const b = new SnapshotBuffer({ keep: 1 });
for (let i = 0; i < 200; i++) {
b.push(snap(i * 0.1, [U(1, i, 0)]), i * 0.05);
b.pushEvents([['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['dmg', 1, 5, 'phys'], ['die', i]], i * 0.05);
}
assert.ok(b.size < 40, `size ${b.size}`);
assert.ok(b.events.length <= 6000, `events ${b.events.length}`);
assert.equal(b.events.filter((e) => e.ev[0] === 'die').length, 200, 'state events never shed');
b.update(10);
const s = b.sample();
assert.ok(s.get(1), 'sample available');
});
test('events: stamped inside the latest interval, delivered in order when renderT passes', () => {
const b = new SnapshotBuffer({ delay: 0.1, rate: 2 });
b.push(snap(0, [U(1, 0, 0)]), 0);
b.push(snap(0.1, [U(1, 0, 0)]), 0.05);
b.pushEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 50, 'phys'], 'junk', [5]], 0.05);
b.push(snap(0.2, [U(1, 0, 0)]), 0.1);
b.pushEvents([['die', 2]], 0.1);
assert.equal(b.events.length, 3);
assert.deepEqual(b.takeEvents(0.0), []);
const first = b.takeEvents(0.06);
assert.deepEqual(first.map((e) => e[0]), ['atk', 'dmg']);
assert.deepEqual(b.takeEvents(0.2).map((e) => e[0]), ['die']);
// server-provided stamp
b.pushEvents([['skill', 1, 1]], 0.2, 0.15);
b.pushEvents([['skill', 1, 0]], 0.2, 5);
assert.deepEqual(b.takeEvents(0.16).map((e) => e[2]), [1]);
});
test('takeEvents can drop stale cosmetic events but keeps state events', () => {
const b = new SnapshotBuffer();
b.pushEvents([['dmg', 1, 5, 'phys'], ['die', 1], ['fx', 'burst', 1, 1, {}], ['spawn', { id: 3 }]], 0, 1);
const out = b.takeEvents(10, [], 5);
assert.deepEqual(out.map((e) => e[0]), ['die', 'spawn']);
assert.ok(isCosmeticEvent(['atk']) && !isCosmeticEvent(['leak']) && !isCosmeticEvent(null));
b.pushEvents([['leak', 4]], 0, 50);
assert.deepEqual(b.flushEvents().map((e) => e[0]), ['leak']);
});
test('sample before any snapshot / with NaN time is empty; update before snapshots is NaN', () => {
const b = new SnapshotBuffer();
assert.ok(Number.isNaN(b.update(1)));
assert.equal(b.sample().size, 0);
b.push(snap(2, [U(1, 1, 1)]), 1);
assert.equal(b.sample(NaN).size, 0);
assert.equal(b.sample(0).get(1).x, 1, 'time before the oldest clamps to the oldest');
});
test('no NaN anywhere through a noisy stream', () => {
const b = new SnapshotBuffer();
let now = 0, t = 0;
let seed = 7;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const out = new Map();
for (let i = 0; i < 500; i++) {
now += 0.03 + rnd() * 0.05;
if (rnd() < 0.8) { t += 0.1; b.push(snap(t, [U(1, rnd() * 20, rnd() * 18, rnd() * 100), U(2 + (i % 5), 3, 3)]), now); }
b.update(now);
b.sample(undefined, out);
for (const s of out.values()) for (const k of ['x', 'y', 'hp', 'sp', 'vx', 'vy']) assert.ok(Number.isFinite(s[k]), k);
}
});
});
+93
View File
@@ -0,0 +1,93 @@
// test/render/loadout-skill.test.js — the equipped skill (DESIGN §16 operator loadout, user playtest #2 item 1) reaches
// the battle presentation: the sim's UnitInfo carries `skillIndex`, the Spine actor plays THAT skill's clip
// (`anims.skills[index]`, e.g. 史尔特尔 on S1 never shows the S3 黄昏 transformation) and the audio plays that skill's
// own ON_SKILL_START sound (`sfx.units[id].skills[index]`). Integration regressions (the loadout batch had the sim,
// data and UI, but every battle still animated / sounded the default skill).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { AudioManager } from '../../public/js/audio.js';
const REAL = { skip: !hasGeneratedData() };
describe('UnitInfo.skillIndex (sim)', () => {
test('spawn events carry the equipped skill index: the loadout one, else the default', REAL, () => {
const h = makeBattle({
units: [
{ chessId: 'chess_char_5_07_a', row: 10, col: 5, uid: 1, skillIndex: 0 }, // 史尔特尔 on S1 (default S3)
{ chessId: 'chess_char_1_01_a', row: 11, col: 5, uid: 2 }, // 隐现 on its default S2
],
enemies: [], autoFinish: false, timeLimit: 10,
});
h.step();
const spawns = h.eventsOf('spawn').map((e) => e[1]).filter((u) => u.side === 'ally');
const byUid = new Map(spawns.map((u) => [u.uid, u]));
assert.equal(byUid.get(1)?.skillIndex, 0, '史尔特尔: S1');
assert.equal(byUid.get(2)?.skillIndex, 1, '隐现: default S2');
});
});
describe('Spine actor + audio use the equipped skill', () => {
let fake, UnitView;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
const S1 = { begin: null, loop: 'Skill_1', end: null, index: 0, idle: null };
const S3 = { begin: 'Skill_3_Begin', loop: 'Skill_3_Loop', end: null, index: 2, idle: 'Skill_3_Idle' };
const entry = {
skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'],
anims: { idle: 'Idle', attack: { begin: null, loop: 'Attack', end: null }, skill: S3, skills: { 0: S1, 2: S3 } },
animations: { Idle: 1, Attack: 1, Skill_1: 1, Skill_3_Begin: 0.6, Skill_3_Loop: 1, Skill_3_Idle: 1 },
};
const names = Object.keys(entry.animations).map((name) => ({ name }));
const assets = {
picture: () => null, image: async () => null, spineEntry: () => entry,
spine: { acquire: async () => ({ animations: names }), release() {} },
};
async function actorFor(skillIndex) {
const ctx = fakeViewCtx(fake.P, { assets, cam });
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr', tier: 5, x: 5, y: 10, maxHp: 1000, skillIndex });
await tick(); await tick();
assert.ok(v.actor, 'spine actor built');
return v.actor;
}
test('S1 equipped: the skill clip is S1 (not the default S3 transformation)', async () => {
const a = await actorFor(0);
assert.equal(a.roles.skill.loop, 'Skill_1');
a.setSkill(true);
assert.equal(a._attackClip().loop, 'Skill_1', 'attacks during the skill use the S1 clip');
assert.notEqual(a.current, 'Skill_3_Begin', 'no S3 transformation');
const d = await actorFor(undefined);
d.setSkill(true);
assert.equal(d.current, 'Skill_3_Begin', 'control: the default S3 starts with its begin clip');
});
test('default / unknown index: the primary skill clip', async () => {
assert.equal((await actorFor(undefined)).roles.skill.loop, 'Skill_3_Loop');
assert.equal((await actorFor(2)).roles.skill.loop, 'Skill_3_Loop');
assert.equal((await actorFor(1)).roles.skill.loop, 'Skill_3_Loop', 'no clip for S2 in the model: primary');
});
test('audio: the equipped skill’s own ON_SKILL_START sound', () => {
const manifest = { audio: { sfx: { units: { char_350_surtr: { skill: '/sfx/s3.mp3', skills: { 0: '/sfx/s1.mp3', 2: '/sfx/s3.mp3' } } } } } };
const a = new AudioManager({ win: null, getManifest: () => manifest });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.setFieldUnits([
{ id: 1, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr', skillIndex: 0 },
{ id: 2, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr' },
]);
a.handleBattleEvents([['skill', 1, 1], ['skill', 2, 1]]);
assert.deepEqual(played, ['/sfx/s1.mp3', '/sfx/s3.mp3']);
});
});
+173
View File
@@ -0,0 +1,173 @@
// test/render/occlusion.test.js — pure parts of the depth / occlusion model and the attack wind-up:
// raised blocks are split per row and keyed against units, units stand on block tops, the wind-up plan lands
// the strike frame on the event, and the interp buffer clamps hp and looks ahead without consuming.
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 { parseStage, buildTileQuads, splitQuadGroups, sortQuadOrder, rowDepthKey, boxDepthKey, ROW_KEY } from '../../public/js/render/tiles.js';
import { unitDepthKey, groundZ, placeOnGround } from '../../public/js/render/units.js';
import { windUpPlan, MAX_WIND_SPEEDUP } from '../../public/js/render/spine.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
describe('block quads per row', () => {
test('every raised block goes to its own row group; nothing raised stays in the ground mesh', () => {
for (const [id, st] of Object.entries(stages)) {
const G = parseStage(st);
const quads = buildTileQuads(G, null);
const { ground, rows } = splitQuadGroups(quads);
assert.equal(ground.length + [...rows.values()].reduce((n, l) => n + l.length, 0), quads.length, id);
assert.ok(ground.every((q) => q.kind !== 'block'), `${id}: block quad in ground`);
for (const [r, list] of rows) {
assert.ok(list.length > 0 && list.every((q) => q.row === r && q.kind === 'block'), `${id} row ${r}`);
}
// the hand bench row (7) is raised on every stage
assert.ok(rows.has(7), `${id}: hand row has blocks`);
}
});
test('sortQuadOrder: far rows first, then sides before tops within a tile', () => {
const qs = [
{ kind: 'block', row: 9, col: 4, order: 0 },
{ kind: 'block', row: 10, col: 4, order: 0 },
{ kind: 'block', row: 9, col: 4, order: 1 },
];
const order = sortQuadOrder([0, 1, 2], qs, 4);
assert.deepEqual(order, [1, 2, 0]);
});
});
describe('depth keys', () => {
test('a unit behind a block row sorts before it; a unit in or in front of the row sorts after', () => {
for (let r = 7; r <= 12; r++) {
const block = rowDepthKey(cam, r) + ROW_KEY.blocks;
for (const c of [0, 4, 9]) {
assert.ok(unitDepthKey(cam, c, r + 1) < block, `unit behind row ${r} col ${c}`);
assert.ok(unitDepthKey(cam, c, r + 0.55) < block, `unit just behind row ${r}`);
assert.ok(unitDepthKey(cam, c, r) > block, `unit in row ${r}`);
assert.ok(unitDepthKey(cam, c, r + 0.45) > block, `unit at back of row ${r}`);
assert.ok(unitDepthKey(cam, c, r - 1) > block, `unit in front of row ${r}`);
}
}
});
test('row sub-keys: blocks < devices < surface; gate boxes after their row, before units in them', () => {
for (let r = 0; r <= 12; r++) {
const k = rowDepthKey(cam, r);
assert.ok(k + ROW_KEY.blocks < k + ROW_KEY.devices && k + ROW_KEY.devices < k + ROW_KEY.surface);
const box = boxDepthKey(cam, r);
assert.ok(box > k + ROW_KEY.surface, `box after row ${r}`);
assert.ok(box < unitDepthKey(cam, 4, r + 0.3), `box before unit in row ${r}`);
assert.ok(box < rowDepthKey(cam, r - 1), `box before the next row in front`);
}
});
test('a lifted (dragged) piece sorts above its ground neighbours only within its own depth band', () => {
assert.ok(unitDepthKey(cam, 4, 10, 1) > unitDepthKey(cam, 4, 10, 0));
assert.ok(unitDepthKey(cam, 4, 10, 1) > unitDepthKey(cam, 4, 10.5, 0));
// the lift bonus (units.js: 40) is below one row of depth at the official 30° pitch (100·sin 30° = 50): a
// dragged piece at row 10 never ties with (nor passes) a ground unit one row in front
assert.ok(unitDepthKey(cam, 4, 10, 1) < unitDepthKey(cam, 4, 9, 0) - 1);
assert.ok(unitDepthKey(cam, 4, 10, 1) < unitDepthKey(cam, 4, 8.9, 0));
});
});
describe('standing on raised tiles', () => {
test('groundZ reads the tile height at the rounded cell; junk → 0', () => {
const ctx = { heightAt: (r, c) => (r === 10 && c === 4 ? 0.3 : r === 5 ? NaN : 0) };
assert.equal(groundZ(ctx, 4.2, 9.6), 0.3);
assert.equal(groundZ(ctx, 5, 10), 0);
assert.equal(groundZ(ctx, 1, 5), 0);
assert.equal(groundZ({}, 4, 10), 0);
});
test('placeOnGround: raised z goes to the row surface layer, else to the fallback; only reparents on change', () => {
const mk = (name) => ({ name, kids: [], addChild(o) { o.parent?.kids.splice(o.parent.kids.indexOf(o), 1); o.parent = this; this.kids.push(o); } });
const flat = mk('flat'), surf10 = mk('s10');
const ctx = { surfaceLayer: (row) => (row === 10 ? surf10 : null) };
const obj = { parent: null };
placeOnGround(ctx, obj, flat, 10.2, 0.3);
assert.equal(obj.parent, surf10);
placeOnGround(ctx, obj, flat, 10.2, 0.3);
assert.equal(surf10.kids.length, 1);
placeOnGround(ctx, obj, flat, 10.2, 0);
assert.equal(obj.parent, flat);
placeOnGround(ctx, obj, flat, 8, 0.3); // row without blocks → fallback
assert.equal(obj.parent, flat);
placeOnGround({}, obj, flat, 10, 0.3);
assert.equal(obj.parent, flat);
});
test('act2 walls (h) are raised so operators on them get z > 0', () => {
const G = parseStage(stages.act2autochess_m01);
const walls = [];
for (let r = 9; r <= 12; r++) for (let c = 0; c < 21; c++) if (G[r][c].glyph === 'h') walls.push(G[r][c]);
assert.ok(walls.length > 0);
for (const t of walls) assert.ok(t.h > 0.12, `wall ${t.r},${t.c} h ${t.h}`);
});
});
describe('attack wind-up', () => {
test('the strike frame lands exactly on the event whenever the wind-up can start from the clip start', () => {
for (const loop of [0.6, 1, 1.7]) for (const hit of [0.1, 0.35, 0.5]) for (const iv of [0.4, 1, 2.5]) {
for (const lead of [0.01, 0.05, 0.1, 0.2, 0.4]) {
const h = hit * loop;
const { ts, tsWind, start } = windUpPlan(loop, h, iv, lead);
assert.ok(Number.isFinite(ts) && Number.isFinite(tsWind) && Number.isFinite(start));
assert.ok(ts >= 0.35 && ts <= 4, `ts ${ts}`);
assert.ok(tsWind >= ts - 1e-12 && tsWind <= ts * MAX_WIND_SPEEDUP + 1e-12, `tsWind ${tsWind}`);
assert.ok(start >= 0 && start <= h + 1e-12, `start ${start}`);
if (lead * ts <= h) assert.ok(near(start + lead * tsWind, h, 1e-9), `strike at event: ${start}+${lead}*${tsWind} vs ${h}`);
}
}
});
test('a comfortable lead plays the whole wind-up at normal speed', () => {
const p = windUpPlan(1, 0.4, 1, 0.4);
assert.equal(p.start, 0);
assert.ok(near(p.tsWind, 1));
});
test('a short lead speeds up (capped) and skips only what it must', () => {
const p = windUpPlan(1, 0.4, 1, 0.05);
assert.ok(near(p.tsWind, MAX_WIND_SPEEDUP));
assert.ok(near(p.start, 0.4 - 0.05 * MAX_WIND_SPEEDUP));
});
});
describe('interp: hp clamp and look-ahead', () => {
const snap = (t, units) => ({ fieldId: 'n:1', t, units });
test('snapshot hp = maxHp + 1 (sim rounding) is clamped; negative hp → 0', () => {
const b = new SnapshotBuffer();
b.push(snap(0, [[1, 0, 10, 101, 100, 0, 10, 0, 0], [2, 1, 10, -3, 100, 0, 10, 0, 0]]), 0);
b.push(snap(0.1, [[1, 0, 10, 101, 100, 0, 10, 0, 0], [2, 1, 10, -3, 100, 0, 10, 0, 0]]), 0.05);
for (const t of [0, 0.05, 0.1, 0.2]) {
const out = b.sample(t);
assert.equal(out.get(1).hp, 100);
assert.equal(out.get(2).hp, 0);
}
});
test('forEachUpcoming visits (from, to] in order without consuming; junk-safe', () => {
const b = new SnapshotBuffer();
b.push(snap(0, []), 0);
b.pushEvents([['atk', 1]], 0, 0.5);
b.pushEvents([['atk', 2]], 0, 1.0);
b.pushEvents([['atk', 3]], 0, 2.0);
const seen = [];
b.forEachUpcoming(0.5, 1.5, (ev, t) => seen.push([ev[1], t]));
assert.deepEqual(seen, [[2, 1.0]]);
assert.equal(b.events.length, 3);
assert.deepEqual(b.takeEvents(1.0).map((e) => e[1]), [1, 2]);
assert.equal(b.events.length, 1);
assert.doesNotThrow(() => b.forEachUpcoming(NaN, NaN, () => { throw new Error('called'); }));
});
});
+169
View File
@@ -0,0 +1,169 @@
// test/render/pen.test.js — the enemy preview pen layout (render/pen.js, research 09 §2.2 / 08 §4.2) and the Final
// Assault prep field transform (render/prepfield.js, research 09 §1.2).
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 { layoutPen, penZones, thinPreview, penEntries, penSignature, parsePenRect, MAX_PREVIEW, PER_TILE, inPen } from '../../public/js/render/pen.js';
import { bossPrepField, IDENTITY, mirrorDir, tilesToDisp } from '../../public/js/render/prepfield.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const STAGES = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const stageList = Object.values(STAGES.stages || STAGES);
const stage = (STAGES.stages || STAGES).act2autochess_m01;
const E = (enemyKey, count, gate, t, extra = {}) => ({ enemyKey, count, gate, t, fly: false, elite: false, boss: false, source: 'wave', ...extra });
describe('enemy preview pen: zones', () => {
test('every stage: two zones of 13 tiles (rows 14–15 / 17–18), anchors (15,7) and (18,7), row 16 never used', () => {
for (const st of stageList) {
const z = penZones(st);
assert.deepEqual(z.rect, { r0: 14, r1: 18, c0: 7, c1: 13 }, st.id);
assert.deepEqual(z.lower.anchor, [15, 7]);
assert.deepEqual(z.upper.anchor, [18, 7]);
assert.equal(z.lower.tiles.length, 13);
assert.equal(z.upper.tiles.length, 13);
for (const [r] of z.lower.tiles) assert.ok(r === 14 || r === 15);
for (const [r] of z.upper.tiles) assert.ok(r === 17 || r === 18);
// row-major, as the client lists them (research 08 §4.2 step 3 [DATA]: low row first, col 7 → 13)
const rowMajor = (rows) => rows.flatMap((r) => [7, 8, 9, 10, 11, 12, 13].map((c) => [r, c])).filter(([r, c]) => !(c === 7 && (r === 15 || r === 18)));
assert.deepEqual(z.lower.tiles, rowMajor([14, 15]), st.id);
assert.deepEqual(z.upper.tiles, rowMajor([17, 18]), st.id);
}
assert.deepEqual(penZones(null).lower.tiles, penZones(stage).lower.tiles, 'defaults = the official layout');
assert.deepEqual(parsePenRect('((14,7),(18,13))'), { r0: 14, r1: 18, c0: 7, c1: 13 });
assert.equal(parsePenRect('nope'), null);
});
});
describe('enemy preview pen: layout', () => {
test('upper-gate enemies stand in rows 17–18, lower-gate ones in rows 14–15; one figure per enemy; row 16 empty', () => {
const list = [E('enemy_a', 3, 'lower', 3), E('enemy_b', 2, 'upper', 5), E('enemy_c', 4, 'lower', 12, { fly: true })];
const pen = layoutPen(list, { stage });
assert.equal(pen.figures.length, 9);
assert.equal(pen.total, 9);
for (const f of pen.figures) {
assert.ok(inPen(f.row, f.col), `${f.row},${f.col}`);
assert.notEqual(f.row, 16);
if (f.enemyKey === 'enemy_b') assert.ok(f.row >= 17, 'upper gate'); else assert.ok(f.row <= 15, 'lower gate');
assert.ok(Math.abs(f.x - f.col) < 0.5 && Math.abs(f.y - f.row) < 0.5, 'stays on its tile');
}
assert.ok(pen.figures.filter((f) => f.enemyKey === 'enemy_c').every((f) => f.fly));
});
test('spawn-time order: earlier spawns stand nearer the gate (col 7)', () => {
const list = [E('late', 4, 'lower', 40), E('early', 4, 'lower', 2)];
const pen = layoutPen(list, { stage });
const meanCol = (k) => { const f = pen.figures.filter((x) => x.enemyKey === k); return f.reduce((s, x) => s + x.col, 0) / f.length; };
assert.ok(meanCol('early') < meanCol('late'), `${meanCol('early')} < ${meanCol('late')}`);
assert.equal(pen.figures[0].enemyKey, 'early');
// the client's index divisor is totalShowCnt = min(50, Σ count) (both zones): a small upper group of a big wave
// stays at the start of its row-major list — row 17 next to the (18,7) gate — instead of spreading over the zone
const small = layoutPen([E('n', 40, 'lower', 1), E('u', 5, 'upper', 2)], { stage });
for (const f of small.figures.filter((x) => x.enemyKey === 'u')) assert.ok(f.row === 17 && f.col <= 9, `${f.row},${f.col}`);
});
test('at most 3 figures per tile, several may share one; deterministic for the same preview', () => {
const list = [E('enemy_a', 30, 'lower', 1), E('enemy_b', 20, 'upper', 2)];
const a = layoutPen(list, { stage }), b = layoutPen(JSON.parse(JSON.stringify(list)), { stage });
assert.deepEqual(a, b);
const load = new Map();
for (const f of a.figures) load.set(`${f.row},${f.col}`, (load.get(`${f.row},${f.col}`) || 0) + 1);
assert.ok(Math.max(...load.values()) <= PER_TILE);
assert.ok([...load.values()].some((n) => n > 1), 'clusters');
// figures on a shared tile do not stand on the same spot
const pts = a.figures.map((f) => [f.x, f.y]);
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) assert.ok(Math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1]) > 0.15, 'no exact overlap');
});
test('cap 50: thinning keeps the ratio, elites and bosses are always shown in full', () => {
const list = [E('n1', 40, 'lower', 1), E('n2', 30, 'upper', 2), E('boss', 1, 'lower', 3, { boss: true, elite: true }), E('el', 4, 'upper', 9, { elite: true })];
const pen = layoutPen(list, { stage });
assert.equal(pen.total, 75);
assert.ok(pen.figures.length <= MAX_PREVIEW + 5);
assert.equal(pen.figures.filter((f) => f.enemyKey === 'boss').length, 1);
assert.equal(pen.figures.filter((f) => f.enemyKey === 'el').length, 4);
const n1 = pen.figures.filter((f) => f.enemyKey === 'n1').length, n2 = pen.figures.filter((f) => f.enemyKey === 'n2').length;
assert.ok(n1 + n2 <= MAX_PREVIEW - 5 + 1e-9, `${n1} + ${n2}`);
assert.ok(Math.abs(n1 / n2 - 40 / 30) < 0.35, 'ratio kept');
// an entry never disappears through rounding
const tiny = thinPreview(penEntries([E('a', 200, 'lower', 1), E('b', 1, 'lower', 2)]));
assert.ok(tiny.find((p) => p.entry.enemyKey === 'b'), 'a single-enemy action still shows');
// capacity overflow: a zone whose tiles all hold 3 stacks the rest on its own least-loaded tiles — never in the
// other gate's zone (research 08 §4.2 step 6 "keep the grouping exact: one zone per gate"), never in row 16
const big = layoutPen([E('x', 50, 'lower', 1)], { stage });
assert.equal(big.figures.length, 50);
for (const f of big.figures) assert.ok(f.row === 14 || f.row === 15, `lower gate only: ${f.row},${f.col}`);
const perTile = new Map();
for (const f of big.figures) perTile.set(`${f.row},${f.col}`, (perTile.get(`${f.row},${f.col}`) || 0) + 1);
assert.equal(perTile.size, 13, 'every lower tile used');
assert.ok(Math.max(...perTile.values()) <= 4, 'the overflow is spread evenly (≤ 4 per tile for 50 on 13 tiles)');
const pts = big.figures.map((f) => [f.x, f.y]);
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) assert.ok(Math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1]) > 0.15, 'no exact overlap in a 4-figure cluster');
const up = layoutPen([E('a', 10, 'lower', 1), E('u', 45, 'upper', 2)], { stage });
for (const f of up.figures) if (f.enemyKey === 'u') assert.ok(f.row >= 17, `upper-gate overflow stays in rows 17–18: ${f.row}`);
});
test('garbage in: ignored entries, empty list, signature follows the content', () => {
assert.deepEqual(layoutPen(null).figures, []);
assert.deepEqual(layoutPen([null, 1, { count: 3 }, { enemyKey: '' }]).figures, []);
assert.equal(penSignature([E('a', 2, 'lower', 1)]), penSignature([E('a', 2, 'lower', 1)]));
assert.notEqual(penSignature([E('a', 2, 'lower', 1)]), penSignature([E('a', 3, 'lower', 1)]));
assert.equal(layoutPen([{ enemyKey: 'a', count: 1e9 }]).figures.length, MAX_PREVIEW);
});
});
describe('Final Assault prep field (board ⇄ boss field)', () => {
test('matches the sim mapping (finalAssault.bossFieldPlacement) for every board / bench / temp tile, both sides', () => {
for (const side of ['L', 'R']) {
const xf = bossPrepField(side);
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const want = bossFieldPlacement(side, r, c, dir);
const got = xf.toDisp(r, c);
assert.deepEqual([got.row, got.col, xf.dirToDisp(dir)], [want.row, want.col, want.dir], `${side} ${r},${c} ${dir}`);
assert.equal(xf.dirToBoard(xf.dirToDisp(dir)), dir);
}
const d = xf.toDisp(r, c);
assert.deepEqual(xf.toBoard(d.row, d.col), { row: r, col: c }, 'round trip');
}
}
});
test('board rows 9–12 land on boss rows 2–5; bench row 7 → 0, temp row 8 → 1; the right side is mirrored', () => {
const L = bossPrepField('L'), R = bossPrepField('R');
assert.deepEqual(L.toDisp(9, 2), { row: 2, col: 2 });
assert.deepEqual(L.toDisp(12, 8), { row: 5, col: 8 });
assert.deepEqual(R.toDisp(12, 8), { row: 5, col: 12 });
assert.deepEqual(R.toDisp(7, 0), { row: 0, col: 20 });
assert.deepEqual(R.toDisp(8, 4), { row: 1, col: 16 });
assert.equal(R.mirror, true);
assert.equal(L.mirror, false);
assert.equal(R.toBoard(3, 5), null, 'the left half is not the right-hand player’s');
assert.equal(L.toBoard(8, 4), null, 'rows beyond the boss field');
assert.equal(mirrorDir('RIGHT'), 'LEFT');
assert.equal(mirrorDir('up'), 'UP');
assert.deepEqual(tilesToDisp(R, [[9, 2], { row: 10, col: 3 }, null]), [[2, 18], [3, 17]]);
assert.deepEqual(IDENTITY.toDisp(9, 2), { row: 9, col: 2 });
});
test('range tiles past the field: a DOWN range below the bench never lands on the boss rows; an UP range past the top wall is dropped', () => {
for (const side of ['L', 'R']) {
const xf = bossPrepField(side);
const m = (c) => (side === 'R' ? 20 - c : c);
// a DOWN-facing sniper on board row 9: range rows 8, 7 (bench / temp: filtered by the view), 6 and 5 — on the
// boss field those are rows −1 / −2 (off the grid), not the boss rows 6 / 5 (the wall and the own top row)
assert.deepEqual(xf.toDisp(6, 4), { row: -1, col: m(4) });
assert.deepEqual(tilesToDisp(xf, [[9, 4], [6, 4], [5, 4]]), [[2, m(4)]], side);
// UP from board row 12: 13 (the wall above the field → boss wall row 6) is kept, 14 / 15 (the pen → the normal
// board's bench / temp rows 7 / 8 of the boss view) are not
assert.deepEqual(tilesToDisp(xf, [[12, 7], [13, 7], [14, 7], [15, 7]]), [[5, m(7)], [6, m(7)]], side);
// a range reaching into the partner's half stays (the boss field is shared in battle)
assert.deepEqual(tilesToDisp(xf, [[10, 12]]), [[3, m(12)]]);
}
assert.deepEqual(tilesToDisp(IDENTITY, [[6, 4], [14, 7]]), [[6, 4], [14, 7]], 'the own board keeps every tile');
});
});
+297
View File
@@ -0,0 +1,297 @@
// test/render/projection.test.js — camera math: projection ↔ unprojection round trips, perspective
// properties, framing presets (the official configBlackBoard cameras, fitted against official screenshots), lerp,
// tile picking with raised tiles, and the three.js PerspectiveCamera equivalence the 3D board relies on (every tile
// centre projected through both layers lands on the same pixel).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
Camera, fitCamera, presetCamera, lerpCamera, pickTile, tileQuad, normRect, CAMERA_PRESETS, easeInOutCubic,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera,
} from '../../public/js/render/projection.js';
import * as THREE from 'three';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
describe('Camera', () => {
test('project → unproject round trip on several planes and tilts', () => {
for (const tilt of [0, 20, 36, 55]) {
for (const dist of [8, 16, 40]) {
const cam = new Camera({ tx: 7, ty: 10, tilt, dist, scale: 70, cx: 640, cy: 360 });
for (let i = 0; i < 200; i++) {
const x = -2 + (i * 7.31) % 24, y = -1 + (i * 3.77) % 20, z = [0, 0.18, 0.42, 1.3][i % 4];
if (cam.depthOf(x, y, z) < 0.5) continue; // behind / at the camera: not invertible (depth is clamped)
const p = cam.project(x, y, z);
const w = cam.unproject(p.x, p.y, z);
assert.ok(w, `unproject null for ${x},${y},${z}`);
assert.ok(near(w.x, x, 1e-6) && near(w.y, y, 1e-6), `round trip ${x},${y},${z} tilt ${tilt} → ${w.x},${w.y}`);
}
}
}
});
test('target maps to (cx, cy) and scale is px per unit at the target', () => {
const cam = new Camera({ tx: 5, ty: 10, tilt: 36, dist: 16, scale: 50, cx: 300, cy: 200 });
const p = cam.project(5, 10, 0);
assert.ok(near(p.x, 300) && near(p.y, 200));
assert.ok(near(p.s, 50));
const q = cam.project(6, 10, 0);
assert.ok(near(q.x - p.x, 50, 1e-9));
});
test('farther rows are narrower and higher on screen; heights go up', () => {
const cam = new Camera({ tx: 5, ty: 10, tilt: 36, dist: 16, scale: 60, cx: 500, cy: 400 });
const w = (row) => cam.project(5.5, row, 0).x - cam.project(4.5, row, 0).x;
assert.ok(w(12) < w(9), 'row 12 narrower than row 9');
assert.ok(w(9) < w(7), 'row 9 narrower than row 7');
const ratio = w(7) / w(12);
assert.ok(ratio > 1.1 && ratio < 1.35, `row 7 / row 12 width ratio ${ratio} ≈ 1.2 like the original`);
assert.ok(cam.project(5, 12, 0).y < cam.project(5, 9, 0).y, 'far rows are higher');
assert.ok(cam.project(5, 10, 1).y < cam.project(5, 10, 0).y, 'z up is screen up');
// tile depth foreshortening: a tile is shorter than it is wide
const h = cam.project(5, 9.5, 0).y - cam.project(5, 10.5, 0).y;
assert.ok(h > 0.6 * w(10) && h < 0.95 * w(10), `tile aspect ${h / w(10)}`);
});
test('top-down (tilt 0) is an orthographic-like square grid at the target depth', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 0, dist: 10, scale: 40, cx: 0, cy: 0 });
const a = cam.project(1, 0, 0), b = cam.project(0, 1, 0);
assert.ok(near(a.x, 40) && near(a.y, 0));
assert.ok(near(b.x, 0) && near(b.y, -40));
});
test('unproject returns null for rays that miss the plane (above the horizon)', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 60, dist: 10, scale: 40, cx: 0, cy: 0 });
// far above the screen: ray goes above the horizon
assert.equal(cam.unproject(0, -1e6, 0), null);
// plane above the camera is never hit looking down
assert.equal(cam.unproject(0, 0, 100), null);
});
test('points behind the camera do not produce NaN', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 36, dist: 5, scale: 40, cx: 0, cy: 0 });
const p = cam.project(0, -50, 0);
assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.s));
});
test('constructor sanitises bad input; copy/clone/params', () => {
const cam = new Camera({ tx: NaN, tilt: 999, dist: -3, scale: 0 });
assert.ok(Number.isFinite(cam.tx));
assert.ok(cam.tilt <= 80 && cam.dist >= 1 && cam.scale > 0);
const b = new Camera({ tx: 3, ty: 4, scale: 9, cx: 1, cy: 2 });
const c = cam.clone().copy(b);
assert.deepEqual(c.params(), b.params());
assert.notEqual(c, b);
});
});
describe('framing', () => {
const vp = { width: 1920, height: 1080 };
function bounds(cam, rect, margin = 0, headroom = 0) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const x of [rect.c0 - 0.5 - margin, rect.c1 + 0.5 + margin]) for (const y of [rect.r0 - 0.5 - margin, rect.r1 + 0.5 + margin]) for (const z of [0, headroom]) {
const p = cam.project(x, y, z);
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
return { minX, maxX, minY, maxY };
}
test('fitCamera keeps the rect (+headroom) inside the padded viewport and touches one axis', () => {
for (const [w, h] of [[1920, 1080], [1280, 720], [800, 1200], [3440, 1440]]) {
for (const kind of Object.keys(CAMERA_PRESETS)) {
const P = CAMERA_PRESETS[kind];
const pad = { top: 90, bottom: 180, left: 120, right: 40 };
const cam = fitCamera(P.rect, { width: w, height: h, padding: pad }, { margin: P.margin, headroom: P.headroom, tilt: P.tilt, dist: P.dist });
const b = bounds(cam, P.rect, P.margin, P.headroom);
const tol = 0.5;
assert.ok(b.minX >= pad.left - tol && b.maxX <= w - pad.right + tol, `${kind} ${w}x${h} x ${b.minX}..${b.maxX}`);
assert.ok(b.minY >= pad.top - tol && b.maxY <= h - pad.bottom + tol, `${kind} ${w}x${h} y ${b.minY}..${b.maxY}`);
const fillX = (b.maxX - b.minX) / (w - pad.left - pad.right);
const fillY = (b.maxY - b.minY) / (h - pad.top - pad.bottom);
if (cam.scale < 360) assert.ok(Math.max(fillX, fillY) > 0.99, `${kind} ${w}x${h} fills one axis (${fillX}, ${fillY})`);
// centred on the free axis
const cxMid = (b.minX + b.maxX) / 2, cyMid = (b.minY + b.maxY) / 2;
assert.ok(near(cxMid, pad.left + (w - pad.left - pad.right) / 2, 1e-6));
assert.ok(near(cyMid, pad.top + (h - pad.top - pad.bottom) / 2, 1e-6));
}
}
});
test('presets (official framing): unite is wider than normal; boss shows its field; prep includes the hand row', () => {
const n = presetCamera('normal', vp), u = presetCamera('unite', vp), b = presetCamera('boss', vp), p = presetCamera('prep', vp);
assert.ok(u.scale < n.scale);
const inView = (cam, x, y) => { const q = cam.project(x, y, 0); return q.x >= 0 && q.x <= vp.width && q.y >= 0 && q.y <= vp.height; };
// 全景 of the boss field: the playable rows 1–5 × cols 2–18 (the official view crops the outer bench corners)
for (let c = 2; c <= 18; c++) for (let r = 1; r <= 5; r++) assert.ok(inView(b, c, r), `boss ${r},${c}`);
// 联防 全景 = mid_battle_camera_param (0,0,−4.22) exactly like the official client: at 16:9 the view ends at col
// ≈1.73 / 18.27 on row 9, so the E (9,2) and the gate (9,18) sit at the screen edges (centres x ≈ 31 / 1889 of
// 1920) with the outer part of their cubes cut — official framing, not to be "fixed" by re-fitting the rect
assert.ok(near(u.project(2, 9, 0).x, 31.3, 0.5) && near(u.project(18, 9, 0).x, 1888.7, 0.5));
for (let c = 2; c <= 18; c++) for (const r of [9, 12]) assert.ok(inView(u, c, r), `unite ${r},${c}`);
for (let c = 0; c <= 10; c++) for (const r of [7, 8, 9, 12]) assert.ok(inView(p, c, r), `prep ${r},${c}`);
// the official battle camera frames the playable part of the field (cols 2–10: E at col 2, the gates at col 10)
for (let c = 2; c <= 10; c++) for (const r of [9, 12]) assert.ok(inView(n, c, r), `normal ${r},${c}`);
assert.ok(!inView(n, 5, 2), 'normal camera does not show the boss field');
// unknown kind falls back to normal; explicit rect overrides
assert.deepEqual(presetCamera('???', vp).params(), n.params());
const half = presetCamera('boss', vp, { half: true, side: 'R' });
const q = half.project(18, 3, 0);
assert.ok(q.x > 0 && q.x < vp.width);
assert.ok(half.project(0, 3, 0).x < 0, 'left half off screen when framing the right half');
// the preview pen camera shows the whole pen (rows 14–18 × cols 7–13)
const pen = presetCamera('pen', vp);
for (let c = 7; c <= 13; c++) for (const r of [14, 18]) assert.ok(inView(pen, c, r), `pen ${r},${c}`);
// shop collapsed: the prepare camera is closer than the shop one
assert.ok(presetCamera('prep', vp, { shop: false }).scale > p.scale);
// Final Assault prep on the boss rows uses the boss shop cameras (left / right)
const bp = presetCamera('prep', vp, { rect: { r0: 0, r1: 5, c0: 0, c1: 10 }, side: 'R' });
assert.ok(inView(bp, 15, 3) && bp.tx > 14 && !inView(bp, 3, 3), 'right boss prep camera frames the right half');
});
test('official cameras reproduce the official screenshots (tile seams of a 1920×1079 shop view)', () => {
// act1autochess_m06 (上半, same configBlackBoard), shop panel open → left_shop_camera_param; seams measured by
// eye on the official screenshot (bahamut a7a712…): (x = col boundary, y = row boundary) → screen px
const cam = presetCamera('prep', { width: 1920, height: 1079 });
const seams = [[6.5, 11.5, 1099.3, 326], [7.5, 11.5, 1199.3, 326], [8.5, 11.5, 1299.3, 326], [6.5, 10.5, 1101.7, 408],
[7.5, 10.5, 1205, 409], [8.5, 10.5, 1306.7, 410], [4.5, 9.5, 888, 501], [6.5, 9.5, 1104, 501], [3.5, 9.5, 776, 501]];
let sq = 0;
for (const [x, y, sx, sy] of seams) { const p = cam.project(x, y, 0); sq += (p.x - sx) ** 2 + (p.y - sy) ** 2; }
const rms = Math.sqrt(sq / seams.length);
assert.ok(rms < 5, `rms ${rms.toFixed(2)} px`);
// the camera itself: pitch 30°, vertical FOV 40°, principal point at the centre, standard view + shop param
assert.equal(cam.tilt, OFFICIAL.pitch);
assert.ok(near(2 * Math.atan(1079 / 2 / (cam.scale * cam.dist)) / (Math.PI / 180), OFFICIAL.fovY, 1e-9));
assert.deepEqual([cam.cx, cam.cy], [960, 539.5]);
const pos = cam.position();
assert.ok(near(pos.x, 5.17, 1e-9) && near(pos.y, 2.44, 1e-9) && near(pos.z, 11.53, 1e-9), JSON.stringify(pos));
});
test('officialCamera / parseCameraParam: data-driven params, narrow screens back off, garbage is safe', () => {
assert.deepEqual(parseCameraParam('(-4.67,0.3,-2.46,1)'), [-4.67, 0.3, -2.46, 1]);
assert.deepEqual(parseCameraParam([1, 2, 3]), [1, 2, 3, 1]);
assert.equal(parseCameraParam('(a,b)'), null);
assert.equal(parseCameraParam(null), null);
for (const k of Object.keys(OFFICIAL_PARAMS)) assert.ok(parseCameraParam(OFFICIAL_PARAMS[k]), k);
// stage.config overrides the defaults (same keys as configBlackBoard)
const moved = presetCamera('normal', vp, { config: { left_battle_camera_param: '(0,1.91,-0.6,1)' } });
assert.ok(near(moved.tx, 10), 'param x = col − 10');
// 4:3 backs the camera off (smaller tiles than a 16:9 screen of the same height)
const wide = officialCamera(OFFICIAL_PARAMS.left_battle_camera_param, { width: 1440, height: 810 });
const tall = officialCamera(OFFICIAL_PARAMS.left_battle_camera_param, { width: 1080, height: 810 });
assert.ok(tall.scale < wide.scale && tall.position().z > wide.position().z);
// portrait: the fitted fallback keeps the whole field on screen
const port = presetCamera('normal', { width: 700, height: 1400 });
for (let c = 0; c <= 10; c++) assert.ok(port.project(c, 9, 0).x > 0 && port.project(c, 9, 0).x < 700, `portrait col ${c}`);
for (const v of Object.values(officialCamera('garbage', { width: NaN, height: 0 }).params())) assert.ok(Number.isFinite(v));
});
test('tiny / degenerate viewports never produce NaN', () => {
const cam = fitCamera({ r0: 9, r1: 12, c0: 0, c1: 10 }, { width: 0, height: NaN, padding: { top: 5000 } });
for (const v of Object.values(cam.params())) assert.ok(Number.isFinite(v));
});
test('normRect swaps and clamps', () => {
assert.deepEqual(normRect({ r0: 12, r1: 9, c0: 30, c1: -4 }), { r0: 9, r1: 12, c0: 0, c1: 20 });
assert.deepEqual(normRect(null), { r0: 0, r1: 18, c0: 0, c1: 20 });
});
test('lerpCamera endpoints and midpoint; ease', () => {
const a = presetCamera('prep', vp), b = presetCamera('boss', vp);
const s0 = lerpCamera(a, b, 0).params(), ap = a.params();
for (const k of Object.keys(ap)) assert.ok(near(s0[k], ap[k], 1e-9), k);
const e = lerpCamera(a, b, 1).params(), bp = b.params();
for (const k of Object.keys(bp)) assert.ok(near(e[k], bp[k], 1e-9), k);
const m = lerpCamera(a, b, 0.5);
assert.ok(near(m.scale, Math.sqrt(a.scale * b.scale), 1e-9), 'scale lerps in log space');
assert.equal(easeInOutCubic(0), 0); assert.equal(easeInOutCubic(1), 1); assert.ok(near(easeInOutCubic(0.5), 0.5));
assert.ok(easeInOutCubic(0.25) < 0.25 && easeInOutCubic(0.75) > 0.75);
});
});
describe('pickTile / tileQuad', () => {
const cam = presetCamera('prep', { width: 1600, height: 900 });
const heights = (r, c) => (r === 7 ? 0.18 : r === 10 && c === 4 ? 0.42 : 0);
const levels = [0.42, 0.18, 0];
test('centres of every tile pick back the same tile (low, bench and raised)', () => {
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const z = heights(r, c);
const p = cam.project(c, r, z);
const t = pickTile(cam, p.x, p.y, heights, levels);
assert.ok(t, `tile ${r},${c}`);
assert.deepEqual([t.row, t.col], [r, c], `pick ${r},${c}`);
}
});
test('the top face of a raised tile wins over the ground tile behind it', () => {
// a point on the raised top near its far edge projects over the ground of the row behind
const p = cam.project(4, 10.45, 0.42);
const ground = cam.unproject(p.x, p.y, 0);
assert.equal(Math.round(ground.y), 11, 'ground ray hits the row behind');
const t = pickTile(cam, p.x, p.y, heights, levels);
assert.deepEqual([t.row, t.col], [10, 4]);
});
test('off-grid points return null', () => {
const far = cam.project(-5, 10, 0);
assert.equal(pickTile(cam, far.x, far.y, heights, levels), null);
assert.equal(pickTile(cam, 800, -1e6, heights, levels), null);
});
test('tileQuad is a trapezoid narrower at the far edge', () => {
const q = tileQuad(cam, 10, 5);
const nearW = q[1].x - q[0].x, farW = q[2].x - q[3].x;
assert.ok(nearW > farW && farW > 0);
assert.ok(q[3].y < q[0].y);
});
});
describe('three.js PerspectiveCamera equivalence (the 3D board layer)', () => {
const kinds = ['prep', 'normal', 'unite', 'boss', 'pen', 'bossPrep'];
test('every tile centre (ground and raised) projects to the same pixel through Camera and THREE', () => {
for (const [W, H] of [[1920, 1080], [1280, 720], [1600, 1000], [900, 1300]]) {
for (const kind of kinds) {
const cam = presetCamera(kind, { width: W, height: H, padding: { top: 90, bottom: 180, left: 120, right: 40 } });
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), W, H);
let worst = 0;
const v = new THREE.Vector3();
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) for (const z of [0, 0.16, 0.42]) {
if (cam.depthOf(c, r, z) < 0.5) continue;
const p = cam.project(c, r, z);
v.set(c, r, z).project(three);
const sx = (v.x + 1) / 2 * W, sy = (1 - v.y) / 2 * H;
worst = Math.max(worst, Math.abs(sx - p.x), Math.abs(sy - p.y));
}
assert.ok(worst < 1e-6, `${kind} ${W}×${H}: ${worst} px`);
}
}
});
test('screen → world agrees too (three ray vs Camera.unproject), lens shift included', () => {
const W = 1280, H = 720;
const cam = fitCamera({ r0: 9, r1: 12, c0: 0, c1: 10 }, { width: W, height: H, padding: { top: 200, left: 300 } });
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), W, H);
const ray = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const hit = new THREE.Vector3();
for (const [sx, sy] of [[640, 360], [100, 600], [1200, 150], [333, 444]]) {
ray.setFromCamera(new THREE.Vector2((sx / W) * 2 - 1, 1 - (sy / H) * 2), three);
assert.ok(ray.ray.intersectPlane(plane, hit));
const w = cam.unproject(sx, sy, 0);
assert.ok(near(w.x, hit.x, 1e-6) && near(w.y, hit.y, 1e-6), `${sx},${sy}`);
}
});
test('threeCameraParams: fov / aspect / position / view offset', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const p = threeCameraParams(cam, 1920, 1080);
assert.ok(near(p.fov, OFFICIAL.fovY, 1e-9));
assert.ok(near(p.aspect, 1920 / 1080));
assert.deepEqual(p.view, { fullWidth: 1920, fullHeight: 1080, offsetX: 0, offsetY: 0, width: 1920, height: 1080 });
const pos = cam.position();
assert.deepEqual(p.position.map((x) => Math.round(x * 1e9) / 1e9), [pos.x, pos.y, pos.z].map((x) => Math.round(x * 1e9) / 1e9));
assert.ok(p.near > 0 && p.far > p.near);
});
});
+99
View File
@@ -0,0 +1,99 @@
// test/render/recordings.test.js — the recorded battles (tools/record-battle.mjs → public/dev/recordings) follow
// the wire format of DESIGN §8.2 exactly, and replay cleanly through the interpolation buffer at real speed.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { EV } from '../../shared/protocol.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const DIR = path.join(ROOT, 'public/dev/recordings');
const indexFile = path.join(DIR, 'index.json');
const has = existsSync(indexFile);
const index = has ? JSON.parse(readFileSync(indexFile, 'utf8')) : [];
const EV_KINDS = new Set(Object.values(EV));
describe('recordings', { skip: !has && 'no recordings (node tools/record-battle.mjs)' }, () => {
test('index lists normal fields on ≥ 3 stages, a unite and a boss field', () => {
const kinds = index.map((r) => r.kind);
assert.ok(new Set(index.filter((r) => r.kind === 'normal').map((r) => r.stageId)).size >= 3);
assert.ok(kinds.includes('unite'));
assert.ok(kinds.includes('boss'));
for (const r of index) assert.ok(existsSync(path.join(DIR, r.file)), r.file);
});
for (const meta of index) {
test(`${meta.name}: wire format (m.field, b.snap, b.ev)`, () => {
const rec = JSON.parse(readFileSync(path.join(DIR, meta.file), 'utf8'));
const f = rec.field;
assert.equal(typeof f.fieldId, 'string');
assert.equal(f.kind, rec.kind);
for (const k of ['r0', 'r1', 'c0', 'c1']) assert.ok(Number.isInteger(f.rect[k]));
assert.ok(Array.isArray(f.units));
const info = (u) => {
for (const k of ['id', 'kind', 'side', 'defId', 'x', 'y', 'facing', 'maxHp']) assert.ok(u[k] !== undefined, `UnitInfo.${k}`);
assert.ok(u.side === 'ally' || u.side === 'enemy');
};
f.units.forEach(info);
let lastT = -Infinity;
const ids = new Set(f.units.map((u) => u.id));
for (const fr of rec.frames) {
const s = fr.snap;
assert.equal(s.fieldId, f.fieldId);
assert.ok(s.t > lastT, 'snapshot time strictly increases');
lastT = s.t;
for (const u of s.units) {
assert.equal(u.length, 9);
for (const v of u) assert.ok(Number.isFinite(v), 'finite tuple values');
// server/sim/snapshot.js sends ceil(hp) but round(maxHp): hp may exceed maxHp by 1 (the renderer clamps)
assert.ok(u[3] >= 0 && u[3] <= u[4] + 1, 'hp within maxHp');
assert.ok(u[1] >= f.rect.c0 - 1 && u[1] <= f.rect.c1 + 1 && u[2] >= f.rect.r0 - 1 && u[2] <= f.rect.r1 + 1, 'inside the rect');
}
for (const e of fr.ev) {
assert.ok(EV_KINDS.has(e[0]), `known event ${e[0]}`);
if (e[0] === 'spawn') { info(e[1]); ids.add(e[1].id); }
}
for (const u of s.units) assert.ok(ids.has(u[0]), `unit ${u[0]} announced (m.field or spawn) before its first snapshot`);
}
});
test(`${meta.name}: replays through SnapshotBuffer at real speed without NaN or jumps`, () => {
const rec = JSON.parse(readFileSync(path.join(DIR, meta.file), 'utf8'));
const buf = new SnapshotBuffer();
const out = new Map();
const prev = new Map();
let fi = 0, events = 0;
const t0 = rec.frames[0].snap.t;
const end = (rec.frames[rec.frames.length - 1].snap.t - t0) / 2 + 0.5;
let maxStep = 0;
for (let real = 0; real < end; real += 1 / 60) {
while (fi < rec.frames.length && (rec.frames[fi].snap.t - t0) / 2 <= real) {
buf.push(rec.frames[fi].snap, real);
buf.pushEvents(rec.frames[fi].ev, real);
fi++;
}
const rt = buf.update(real);
if (!Number.isFinite(rt)) continue;
events += buf.takeEvents(rt).length;
buf.sample(rt, out);
for (const [id, s] of out) {
assert.ok(Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.hp), 'finite sample');
const p = prev.get(id);
if (p) {
const d = Math.hypot(s.x - p.x, s.y - p.y);
if (d < 2.4) maxStep = Math.max(maxStep, d); // teleports / redeploys excluded
}
prev.set(id, { x: s.x, y: s.y });
}
}
assert.ok(events > 0);
assert.ok(Math.abs(buf.rate - 2) < 0.2, `rate ≈ 2 (${buf.rate})`);
// walking is ≤ ~0.1 tiles per 60 Hz frame; displacements (push/pull, dashes) move up to ~1 tile per snapshot
// and are spread over ~3 frames by the lerp — anything bigger (but below the teleport cut) would be a bug
assert.ok(maxStep < 0.6, `smooth motion (max step ${maxStep.toFixed(3)} tiles/frame)`);
});
}
});
+138
View File
@@ -0,0 +1,138 @@
// test/render/tiles.test.js — pure parts of the tile renderer: stage parsing (glyph legend, heights, bands,
// margin fading, scenery rows), style helpers. Importing render/tiles.js must not need PIXI.
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 { parseStage, mixColor } from '../../public/js/render/tiles.js';
import { GLYPH, TILE_H, dmgStyleKey, STATUS_ICON, PROJ } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
describe('parseStage', () => {
test('every data stage parses into a 19×21 grid with known materials and sane heights', () => {
for (const [id, st] of Object.entries(stages)) {
const g = parseStage(st);
assert.equal(g.length, 19, id);
for (const row of g) {
assert.equal(row.length, 21, id);
for (const t of row) {
assert.ok(typeof t.mat === 'string' && t.mat, `${id} ${t.r},${t.c} mat`);
assert.ok(Number.isFinite(t.h) && t.h >= 0 && t.h <= 1, `${id} height`);
assert.ok(t.alpha > 0 && t.alpha <= 1);
}
}
// the own board is always drawn and playable; hand slots are raised benches
for (let r = 9; r <= 12; r++) for (let c = 3; c <= 8; c++) assert.ok(g[r][c].drawn, `${id} board ${r},${c}`);
for (let c = 0; c < 10; c++) {
assert.equal(g[7][c].glyph, 'a', `${id} hand ${c}`);
assert.equal(g[7][c].h, TILE_H.bench);
}
assert.equal(g[9][2].glyph, 'E', `${id} objective`);
}
});
test('glyph legend: heights and materials follow DATA.md §12', () => {
const st = stages.act2autochess_m02;
const g = parseStage(st);
const find = (glyph) => g.flat().find((t) => t.glyph === glyph);
assert.equal(find('h').h, TILE_H.wall);
assert.equal(find('h').mat, 'wall');
assert.equal(find('X').h, TILE_H.sep);
assert.equal(find('m').mat, 'mire');
assert.equal(find('m').h, 0);
assert.equal(find('S').mat, 'start');
assert.equal(find('E').mat, 'end');
for (const [glyph, def] of Object.entries(GLYPH)) assert.ok(def.mat, glyph);
});
test('bands: rows outside the band are not drawn; pen rows are scenery; far margins skipped', () => {
const st = stages.act2autochess_m01;
const field = parseStage(st, [6, 18]);
for (let c = 0; c < 21; c++) for (let r = 0; r <= 5; r++) assert.equal(field[r][c].drawn, false, `boss rows hidden ${r},${c}`);
assert.ok(field[15][7].scenery && field[15][7].drawn, 'pen gate drawn as scenery');
assert.equal(field[15][7].playable, false);
const boss = parseStage(st, [0, 6]);
for (let r = 7; r <= 18; r++) for (let c = 0; c < 21; c++) assert.equal(boss[r][c].drawn, false);
assert.ok(boss[2][2].drawn && boss[2][2].glyph === 'E');
// margin '#' far from anything playable fades then disappears
const full = parseStage(st);
const margin = full.flat().filter((t) => t.glyph === '#' && t.drawn && t.dist === 2);
assert.ok(margin.length > 0 && margin.every((t) => t.alpha < 1 && t.mat === 'margin'));
});
test('junk / missing stage data never throws', () => {
for (const bad of [null, undefined, {}, { rows: 'x' }, { rows: [null, 42, 'zz??'] }, { rows: Array(19).fill('Q'.repeat(21)), tiles: { Q: { tileKey: 'tile_road', height: 'LOW' } } }]) {
const g = parseStage(bad);
assert.equal(g.length, 19);
assert.equal(g[0].length, 21);
}
const q = parseStage({ rows: Array(19).fill('Q'.repeat(21)), tiles: { Q: { tileKey: 'tile_road', height: 'LOW' } } });
assert.equal(q[10][5].glyph, 'r', 'unknown glyph mapped through its tileKey');
});
});
describe('style helpers', () => {
test('damage style keys and status icons', () => {
assert.equal(dmgStyleKey('phys'), 'phys');
assert.equal(dmgStyleKey('arts'), 'arts');
assert.equal(dmgStyleKey('true'), 'true');
assert.equal(dmgStyleKey('burn'), 'elem');
assert.equal(dmgStyleKey('necrosis'), 'elem');
assert.equal(dmgStyleKey('???'), 'phys');
for (const k of Object.values(STATUS_ICON)) assert.ok(STATUS_KEYS.includes(k), `status icon ${k} drawn in the atlas`);
for (const p of Object.values(PROJ)) assert.ok(p.speed > 0 && p.width > 0);
});
test('mixColor', () => {
assert.equal(mixColor(0x000000, 0xffffff, 0), 0x000000);
assert.equal(mixColor(0x000000, 0xffffff, 1), 0xffffff);
assert.equal(mixColor(0xff0000, 0x0000ff, 0.5), 0x800080);
assert.equal(mixColor(0x123456, 0x654321, -5), 0x123456);
});
});
describe('TileField external board (the 3D board layer draws the tiles)', () => {
test('external mode builds no Pixi board, keeps heights / levels / highlights; switching back rebuilds the 2D board', async () => {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
// minimal mesh stack for the 2D board (not part of the shared fake)
P.Buffer = class { constructor(data) { this.data = data; } update() {} };
P.Geometry = class { addAttribute() { return this; } addIndex() { return this; } };
P.Shader = { from: (v, f, u) => ({ uniforms: { ...u } }) };
P.Mesh = class extends P.Container { constructor(g, s) { super(); this.geometry = g; this.shader = s; } };
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setView([6, 18], { r0: 7, r1: 12, c0: 0, c1: 10 }, [6, 13]);
f.setExternal(true);
f.setStage(stages.act2autochess_m01);
assert.equal(f.meshes.length, 0, 'no board meshes');
assert.equal(f.rowMeshes.size, 0);
assert.equal(f.animSprites.length, 0);
assert.equal(f.boxes.length, 0);
assert.equal(f.surfaceLayer(10), null, 'units on raised tops use their ground layer');
assert.equal(f.heightAt(10, 4), TILE_H.wall, 'high ground height still known (units / picking)');
assert.ok(f.levels.includes(TILE_H.wall) && f.levels.includes(0));
assert.ok(f.blowers.length > 0, 'blower airflow streaks stay in Pixi');
f.setHighlights([[10, 5]], 'legal');
assert.equal(f.highlights.size, 1);
f.setExternal(false);
assert.ok(f.meshes.length > 0 && f.rowMeshes.size > 0, '2D board rebuilt');
assert.ok(f.boxes.length > 0, 'gate boxes back');
f.setExternal(true);
assert.equal(f.meshes.length, 0);
f.destroy();
} finally {
fake.restore();
}
});
});
+228
View File
@@ -0,0 +1,228 @@
// test/render/unitview.test.js — render/units.js UnitView and render/textures.js caches against a headless fake PIXI
// (test/render/fakepixi.js): tier chips (tokens have no tier), fallback-portrait allocation (no throw-away
// placeholder canvases, nothing built when the Spine model is already there), and the per-mount texture helpers
// (mountain silhouette, tier-chip redraw) that must not allocate a new canvas each time.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView, T;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../../public/js/render/units.js'));
T = await import('../../public/js/render/textures.js');
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
const diamonds = () => fake.canvases.filter((c) => c.width === 160 && c.height === 160);
/** Asset store stub: avatar image + (optionally) a Spine model, both resolved asynchronously. */
function store({ image = true, spine = false, imageDelay = 0 } = {}) {
const img = { width: 180, height: 180 };
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle' }, animations: { Idle: 1 } };
return {
picture: (id) => (id ? `/pic/${id}.png` : null),
image: (u) => new Promise((r) => (imageDelay ? setTimeout(() => r(image ? img : null), imageDelay) : r(image ? img : null))),
spineEntry: () => (spine ? entry : null),
spine: { acquire: async () => ({ animations: [{ name: 'Idle' }] }), release() {} },
};
}
function view(info, opts = {}, assets = store()) {
const ctx = fakeViewCtx(fake.P, { assets, cam: cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 12, maxHp: 1000, ...info }, opts);
}
describe('tier chips', () => {
test('summon tokens in the hand show no tier chip (tokens have no tier)', async () => {
const v = view({ kind: 'token', defId: 'token_10028_vigil_wolf', avatar: 'token_10028_vigil_wolf' }, { prep: true });
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
assert.ok(!v.chip || !v.chip.visible, 'no chip on a token');
});
test('operators keep their chip in prep and in battle; enemies never have one', () => {
const p = view({ kind: 'chess', tier: 4 }, { prep: true });
p.update(1 / 60, cam(), 0);
assert.ok(p.chip && p.chip.visible);
const b = view({ kind: 'chess', tier: 2 });
b.update(1 / 60, cam(), 0);
assert.ok(b.chip && b.chip.visible);
const e = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime' });
e.update(1 / 60, cam(), 0);
assert.equal(e.chip, null);
});
});
describe('fallback portraits (avatar diamonds)', () => {
test('an avatar that loads builds one diamond — no image-less placeholder first', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_a', avatar: 'char_a' });
v.update(1 / 60, cam(), 0);
await tick(); await tick();
v.update(1 / 60, cam(), 1 / 60);
assert.equal(diamonds().length - before, 1, 'one 160×160 canvas');
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY, 'the diamond shows');
});
test('a unit whose Spine model is ready before its first frame builds no diamond at all', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_b', avatar: 'char_b' }, {}, store({ spine: true }));
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
for (let i = 0; i < 30; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(diamonds().length - before, 0);
});
test('a missing avatar falls back to the procedural placeholder (once)', async () => {
const before = diamonds().length;
const v = view({ defId: 'char_c', avatar: 'char_c' }, {}, store({ image: false }));
await tick(); await tick();
for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(diamonds().length - before, 1);
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY);
});
test('a slow avatar shows the placeholder meanwhile, then the picture', async () => {
const before = diamonds().length;
let release;
const gate = new Promise((r) => { release = r; });
const assets = { ...store(), image: () => gate };
const v = view({ defId: 'char_d', avatar: 'char_d' }, {}, assets);
v.update(1 / 60, cam(), 0);
assert.equal(diamonds().length - before, 0, 'nothing built while the avatar may still arrive quickly');
const t0 = Date.now();
while (v.fallback.texture === fake.P.Texture.EMPTY && Date.now() - t0 < 5000) { await new Promise((r) => setTimeout(r, 40)); v.update(1 / 60, cam(), 0); }
assert.ok(Date.now() - t0 >= 300, 'placeholder only after the wait');
assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY, 'placeholder while waiting');
release({ width: 180, height: 180 });
await tick(); await tick();
v.update(1 / 60, cam(), 0);
assert.equal(diamonds().length - before, 2, 'placeholder + picture');
});
test('two views of the same unit share the cached diamond', async () => {
const a = view({ defId: 'char_e', avatar: 'char_e' });
a.update(1 / 60, cam(), 0);
await tick(); await tick();
a.update(1 / 60, cam(), 0);
const before = diamonds().length;
const b = view({ defId: 'char_e', avatar: 'char_e' });
b.update(1 / 60, cam(), 0);
await tick(); await tick();
b.update(1 / 60, cam(), 0);
assert.equal(diamonds().length, before);
assert.equal(a.fallback.texture, b.fallback.texture);
});
});
describe('diamond cache', () => {
test('bounded LRU; eviction never destroys a texture a view may still show', () => {
const first = T.diamondTexture('lru_0', null, 0xffffff);
for (let i = 1; i < 400; i++) {
T.diamondTexture(`lru_${i}`, null, 0xffffff);
if (i % 50 === 0) assert.equal(T.diamondTexture('lru_0', null, 0xffffff), first, 'recently used stays cached');
}
assert.ok(!first.destroyed && !first.baseTexture.destroyed);
const n0 = diamonds().length;
T.diamondTexture('lru_1', null, 0xffffff);
assert.equal(diamonds().length, n0 + 1, 'the least recently used ones were evicted');
});
});
describe('per-mount textures', () => {
test('silhouetteTexture is cached per image (the field view builds it on every mount)', () => {
const img = { width: 1024, height: 236 };
const n0 = fake.canvases.length;
const t1 = T.silhouetteTexture(img);
const t2 = T.silhouetteTexture(img);
assert.equal(t1, t2);
assert.equal(fake.canvases.length - n0, 1);
assert.notEqual(T.silhouetteTexture({ width: 512, height: 100 }), t1);
});
test('refreshTierChips redraws the chip atlas in place (no new canvas, chips handed out stay valid)', () => {
const chip = T.tierChip(3, false);
const n0 = fake.canvases.length;
const b0 = fake.baseTextures.length;
for (let i = 0; i < 5; i++) T.refreshTierChips();
const again = T.tierChip(3, false);
assert.equal(fake.canvases.length, n0, 'no new canvas');
assert.equal(fake.baseTextures.length, b0, 'no new base texture');
assert.equal(again.baseTexture, chip.baseTexture);
assert.ok(!chip.destroyed && !chip.baseTexture.destroyed);
});
});
describe('field view teardown (app.js releaseGl)', () => {
test('drops the dead renderer’s GL copies of shared textures, buffers, geometries and cached programs', async () => {
const { releaseGl } = await import('../../public/js/render/app.js');
const UID = 7;
const listeners = [];
const mkBt = () => ({ _glTextures: { [UID]: { texture: {} }, 3: { texture: {} } } });
const shared = [mkBt(), mkBt()];
const deleted = [];
const ts = {
managedTextures: shared.slice(),
destroyTexture(bt, skipRemove) {
deleted.push(bt);
delete bt._glTextures[UID];
listeners.push('off');
if (!skipRemove) this.managedTextures.splice(this.managedTextures.indexOf(bt), 1);
},
};
const disposed = [];
const sys = (name) => ({ disposeAll(lost) { disposed.push([name, lost]); } });
const prog = { glPrograms: { [UID]: { program: 'p7' }, 3: { program: 'p3' } } };
const gl = { deleted: [], deleteProgram(p) { this.deleted.push(p); } };
const prevPixi = globalThis.PIXI;
globalThis.PIXI = { ...prevPixi, utils: { ...(prevPixi?.utils || {}), ProgramCache: { src: prog } } };
try {
releaseGl({ CONTEXT_UID: UID, gl, texture: ts, geometry: sys('geometry'), buffer: sys('buffer'), framebuffer: sys('framebuffer') });
} finally { globalThis.PIXI = prevPixi; }
assert.deepEqual(deleted, shared);
assert.equal(ts.managedTextures.length, 0);
for (const bt of shared) assert.deepEqual(Object.keys(bt._glTextures), ['3'], 'other contexts untouched');
assert.deepEqual(disposed.map((d) => d[0]).sort(), ['buffer', 'framebuffer', 'geometry']);
assert.ok(disposed.every((d) => d[1] === false));
assert.deepEqual(Object.keys(prog.glPrograms), ['3']);
assert.deepEqual(gl.deleted, ['p7']);
assert.doesNotThrow(() => releaseGl(null));
assert.doesNotThrow(() => releaseGl({}));
});
});
describe('enemy preview pen figures (lod idle)', () => {
/** A UnitView of a pen figure with a Spine model, an impostor atlas (full or not) and a counting renderer. */
async function penFigure(full) {
let frame = 0;
const renders = [];
const atlas = {
alloc: (w, h) => (full ? null : { w, h, tex: new fake.P.Texture(), clip: false }),
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {},
};
const ctx = fakeViewCtx(fake.P, {
assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas,
renderer: { resolution: 1, render: (obj, o) => renders.push(o?.renderTexture || null) },
});
const v = new UnitView(ctx, { id: 'e:0', side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' });
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
let steps = 0;
const upd = v.actor.update.bind(v.actor);
v.actor.update = (dt) => { steps++; return upd(dt); };
const step = () => { v.update(1 / 60, cam(), frame / 60); frame++; };
return { v, step, renders, steps: () => steps };
}
test('with room in the shared atlas: an impostor refreshed every 3rd frame, never a private render target', async () => {
const { v, step, renders, steps } = await penFigure(false);
for (let i = 0; i < 30; i++) step();
assert.ok(v.imp && v.imp.slot, 'atlas slot');
assert.equal(renders.length, 0, 'drawn by the atlas flush, no per-figure render call');
assert.ok(steps() <= 12, `idle loop stepped ≈ every 3rd frame (${steps()} of 30)`);
});
});