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).
235 lines
13 KiB
JavaScript
235 lines
13 KiB
JavaScript
// 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);
|
||
});
|
||
});
|