卫戍协议:盟约 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

+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);
});
});