Files
ark-stronghold-protocol/test/render/board3d-scene.test.js
T

395 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/board3d-scene.test.js — the official battlefield scenes (DESIGN §15): tools/local-extract/scenes.py
// (the scene table, the board-space conversion, the chunk records, the sub-graphic groups, the RGBM lightmap decode;
// run without UnityPy), fetch_scenes.py's bundle selection, the browser side (render/board3d/load.js chunk reader and
// loader with a fake store; scene.js BoardScene with a stub renderer: the scene replaces the procedural tiles and
// background, its parts follow the built areas, a missing scene keeps the procedural board), and — when scenes were
// extracted here — the files the manifest lists.
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 * as THREE from 'three';
import { BoardScene, sceneGroupVisible } from '../../public/js/render/board3d/scene.js';
import { AREAS } from '../../public/js/render/board3d/layout.js';
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
import {
parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES,
} from '../../public/js/render/board3d/load.js';
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 stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
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, struct\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport scenes as s\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
/** One quad as a scene chunk record + its bytes (the layout of scenes.py pack_chunk). */
function quadChunk({ x0 = 0, y0 = 0, z = 0, material = 'M', lightmap = 0, group = '' } = {}) {
const n = 4, idx = [0, 1, 2, 0, 2, 3];
const buf = new ArrayBuffer(n * SCENE_VERTEX_BYTES + idx.length * 2);
new Float32Array(buf, 0, 12).set([x0, y0, z, x0 + 1, y0, z, x0 + 1, y0 + 1, z, x0, y0 + 1, z]);
new Float32Array(buf, 48, 8).set([0, 0, 1, 0, 1, 1, 0, 1]);
new Float32Array(buf, 80, 8).set([0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5]);
new Int8Array(buf, 112, 16).set([0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0]);
new Uint8Array(buf, 128, 16).fill(255);
new Uint16Array(buf, 144, 6).set(idx);
return { rec: { material, lightmap, group, verts: n, tris: 2, index32: false, bounds: [x0, y0, z, x0 + 1, y0 + 1, z] }, bytes: new Uint8Array(buf) };
}
/** scene.json + scene.bin of a few quads. */
function fakeScene(parts) {
const chunks = parts.map(quadChunk);
const total = chunks.reduce((a, c) => a + c.bytes.length, 0);
const bin = new Uint8Array(total);
let off = 0;
const recs = chunks.map((c) => { bin.set(c.bytes, off); const r = { ...c.rec, offset: off }; off += c.bytes.length; return r; });
const doc = {
version: SCENE_VERSION, stage: 'act2autochess_m02', lightmaps: 1, lightRange: 8, binBytes: total,
light: { dir: [0.36, 0.45, 0.82], color: [1, 0.93, 0.87], intensity: 1.1 },
materials: {
M: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: [], colors: { _Color: [1, 1, 1, 1] }, floats: { _ShadowStrength: 0.9 }, textures: {}, kind: 'lit', map: 'TX_A' },
Cut: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: ['_ALPHATEST_ON'], colors: {}, floats: { _Cutoff: 0.4 }, textures: {}, kind: 'lit', map: 'TX_A' },
W: { shader: 'Torappu/Scene/StylizedWater', keywords: [], colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] }, floats: {}, textures: {}, kind: 'water' },
},
chunks: recs,
};
return { doc, bin: bin.buffer };
}
const PARTS = [
{ material: 'M', x0: 9, y0: 15 }, // the base (pen side)
{ material: 'W', x0: 20, y0: 12, lightmap: -1 }, // water of the base
{ material: 'M', x0: 4, y0: 9, group: 'HIDDEN@1' }, // left normal field
{ material: 'Cut', x0: 5, y0: 9, group: 'HIDDEN@1' },
{ material: 'M', x0: 16, y0: 9, group: 'HIDDEN@2' }, // right normal field
{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }, // boss field
];
function fakeScenePack() {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
const img = { width: 4, height: 4 };
return { key: 'scene-test', stage: doc.stage, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: { TX_A: img }, lm: [img] } };
}
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() {}, setSize() {}, render() {}, dispose() { calls.push(['dispose']); },
};
}
function fakePack() {
const img = { width: 4, height: 4 };
return { key: 'test', images: { D: img, common: img, gate: img, BG: img }, meshes: { gate: {} }, tiles: null, uv: resolveUvTable(null), materials: {} };
}
const sceneMeshes = (board) => Object.entries(board.meshes).filter(([k, m]) => k.startsWith('scene') && m).map(([, m]) => m);
describe('scenes.py (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
test('scene table: one prebuilt scene per battlefield of data/stages.json, with the theme bundles of its materials', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { scenes } = JSON.parse(r.stdout);
assert.equal(scenes.sub, 'scene');
assert.equal(scenes.tex, SCENE_TEX_GROUP);
const ids = scenes.stages.map((x) => x.id);
const battlefields = Object.keys(stages).filter((id) => /^act\dautochess_m\d+$/.test(id)).sort();
assert.deepEqual([...ids].sort(), battlefields, 'every battlefield has a scene');
for (const st of scenes.stages) {
const season = st.id.replace(/_m\d+$/, '');
assert.equal(st.bundle, `scenes/activities/${season}/level_${st.id}/level_${st.id}.ab`);
assert.ok(st.deps.length > 0 && st.deps.every((d) => d.startsWith('arts/maps/') && d.endsWith('.ab')), st.id);
}
const sand = scenes.stages.filter((x) => x.deps.includes('arts/maps/map_autochesssand/res.ab')).map((x) => x.id);
assert.deepEqual(sand, ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05',
'act1autochess_m06', 'act1autochess_m07', 'act2autochess_m02', 'act2autochess_m04'], 'the sand-theme battlefields (official asset index)');
assert.ok(!scenes.stages.some((x) => x.deps.includes('arts/maps/map_autochess/res.ab')), 'no battlefield uses the metal theme');
});
test('--only scene / scene/<id> selects scenes; a client without scene bundles skips them', () => {
const out = py(`import extract as e
print(json.dumps({'all': len(e.scene_ids([])), 'scene': len(e.scene_ids(['scene'])), 'one': e.scene_ids(['scene/act2autochess_m02']),
'other': e.scene_ids(['emoticon']), 'missing': s.find_scene('act2autochess_m02', ['/no/such/root']) is None}))`);
assert.deepEqual(out, { all: 11, scene: 11, one: ['act2autochess_m02'], other: [], missing: true });
});
test('board space: Unity anchor-local (x, y, −z); transforms compose; a mirrored transform has a negative determinant', () => {
const out = py(`m = s.mul(s.trs((1, 2, 3), (0, 0, 0, 1), (2, 2, 2)), s.trs((1, 0, 0), (0, 0, 0, 1), (1, 1, 1)))
rot = s.trs((0, 0, 0), (0, 0, 0.7071068, 0.7071068), (1, 1, 1))
print(json.dumps({'board': s.to_board((-6.0, -4.0, -0.4), (-10.0, -9.0, 0.0)), 'm': [m[0][3], m[1][3], m[2][3], m[0][0]],
'rot': [round(rot[0][1], 4), round(rot[1][0], 4)], 'det': [s.det3(s.IDENTITY), s.det3(s.trs((0, 0, 0), (0, 0, 0, 1), (1, 1, -1)))]}))`);
assert.deepEqual(out.board, [4, 5, 0.4], 'tile (row 5, col 4) on high ground');
assert.deepEqual(out.m, [3, 2, 3, 2]);
assert.deepEqual(out.rot, [-1, 1], 'a quarter turn about z');
assert.deepEqual(out.det, [1, -1]);
});
test('pack_chunk: 36 bytes per vertex + 16-bit indices padded to 4 bytes (what load.js parseSceneChunks reads)', () => {
const out = py(`ch = {'pos': [(0, 0, 0), (1, 0, 0), (0, 1, 0.5)], 'uv': [(0, 0), (1, 0), (0, 1)], 'uv1': [(0.1, 0.2), (0.3, 0.2), (0.1, 0.4)],
'nrm': [(0, 0, 1)] * 3, 'col': [(1, 0.5, 0, 1)] * 3, 'idx': [0, 1, 2]}
data, idx32 = s.pack_chunk(ch)
print(json.dumps({'len': len(data), 'idx32': idx32, 'hex': data.hex()}))`);
assert.equal(out.idx32, false);
assert.equal(out.len, 3 * SCENE_VERTEX_BYTES + 8, '3 vertices, 3 indices (6 bytes) padded to 8');
const bin = Uint8Array.from(Buffer.from(out.hex, 'hex')).buffer;
const [c] = parseSceneChunks({ chunks: [{ material: 'M', lightmap: 0, group: '', verts: 3, tris: 1, offset: 0, index32: false, bounds: [0, 0, 0, 1, 1, 0.5] }] }, bin);
assert.deepEqual([...c.position], [0, 0, 0, 1, 0, 0, 0, 1, 0.5]);
assert.deepEqual([...c.uv1].map((v) => Math.round(v * 10) / 10), [0.1, 0.2, 0.3, 0.2, 0.1, 0.4]);
assert.deepEqual([...c.normal.slice(0, 4)], [0, 0, 127, 0]);
assert.deepEqual([...c.color.slice(0, 4)], [255, 128, 0, 255]);
assert.deepEqual([...c.index], [0, 1, 2]);
});
test('hidden_groups: the objects listed after each sub-graphic name of a MapSubGraphicHolder', () => {
const out = py(`def name(t):
b = t.encode(); return struct.pack('<i', len(b)) + b + b'\\0' * (-len(b) % 4)
ptr = lambda pid: struct.pack('<iq', 0, pid)
raw = struct.pack('<i', 2) + name('HIDDEN@1') + struct.pack('<ii', 1, 2) + ptr(11) + ptr(12) + struct.pack('<i', 1) + ptr(21) + struct.pack('<i', 0) \\
+ name('HIDDEN@2') + struct.pack('<ii', 1, 1) + ptr(13) + struct.pack('<i', 0) + ptr(99)
objs = {11: 1, 12: 1, 13: 1, 21: 1}
print(json.dumps({str(k): v for k, v in s.hidden_groups(raw, objs).items()}))`);
assert.deepEqual(out, { 11: 'HIDDEN@1', 12: 'HIDDEN@1', 21: 'HIDDEN@1', 13: 'HIDDEN@2' }, 'pointers to objects of the file; an unknown id is no pointer');
});
test('decode_rgbm: the RGBM light as (L / LIGHT_RANGE)^(1/2.2); a page without alpha is kept', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`from PIL import Image
img = Image.new('RGBA', (2, 1)); img.putdata([(255, 128, 0, 51), (255, 255, 255, 255)])
d = s.decode_rgbm(img)
print(json.dumps({'mode': d.mode, 'px': list(d.getdata()), 'range': s.LIGHT_RANGE, 'plain': s.decode_rgbm(Image.new('RGB', (1, 1), (9, 8, 7))).getpixel((0, 0))}))`);
assert.equal(out.mode, 'RGB');
// texel (1, 0.5, 0, A = 0.2): L = 5^2.2 · 0.2^2.2 · rgb → stored (L / range)^(1/2.2) = (5^2.2 / range)^(1/2.2) · 0.2 · rgb^(1/2.2)
const k = (5 ** 2.2 / out.range) ** (1 / 2.2);
const want = [1, 128 / 255, 0].map((c) => Math.round(255 * Math.min(1, k * 0.2) * c ** (1 / 2.2)));
for (let i = 0; i < 3; i++) assert.ok(Math.abs(out.px[0][i] - want[i]) <= 2, `channel ${i}: ${out.px[0][i]} vs ${want[i]}`);
assert.deepEqual(out.px[1], [255, 255, 255], 'clamped at the range');
assert.deepEqual(out.plain, [9, 8, 7]);
});
test('fetch_scenes.py: CDN file names and the bundles of the scene table', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport fetch_scenes as f\nnames = ['arts/maps/map_autochess/res.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02/lightingdata.ab', 'scenes/activities/act44side/level_act44side_01/level_act44side_01.ab']\nprint(json.dumps({'dat': f.dat_name('scenes/a/level_x/level_x.ab'), 'hash': f.dat_name('avg/a#1.ab'), 'wanted': f.wanted(names, ['act2autochess_m02'])}))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.dat, 'scenes_a_level_x_level_x.dat');
assert.equal(out.hash, 'avg_a__1.dat');
assert.equal(out.wanted.length, 2, 'the scene and its lighting data, nothing else');
});
});
describe('scene chunks (load.js)', () => {
test('parseSceneChunks: typed-array views per chunk; a record that does not fit the buffer rejects the scene', () => {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
assert.equal(chunks.length, PARTS.length);
assert.equal(chunks[0].position.length, 12);
assert.deepEqual([...chunks[2].position.slice(0, 3)], [4, 9, 0]);
assert.deepEqual([...chunks[0].index], [0, 1, 2, 0, 2, 3]);
assert.equal(chunks[1].lightmap, -1);
assert.equal(parseSceneChunks(doc, bin.slice(0, bin.byteLength - 4)), null, 'truncated bin');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], offset: 2 }] }, bin), null, 'unaligned offset');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], verts: 0 }] }, bin), null);
assert.equal(parseSceneChunks(null, bin), null);
assert.equal(parseSceneChunks(doc, null), null);
});
test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => {
const { doc, bin } = fakeScene(PARTS);
const rects = sceneGroupRects(parseSceneChunks(doc, bin));
assert.deepEqual(Object.keys(rects).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.deepEqual(rects['HIDDEN@1'], [4, 9, 6, 10], 'union of the group\'s chunks');
const shown = (areas) => Object.keys(rects).filter((g) => sceneGroupVisible(rects[g], areas)).sort();
assert.deepEqual(shown(AREAS.normal), ['HIDDEN@1'], 'own field');
assert.deepEqual(shown(AREAS.unite), ['HIDDEN@1', 'HIDDEN@2'], 'both normal fields');
assert.deepEqual(shown(AREAS.boss), ['HIDDEN@3'], 'the boss field');
assert.deepEqual(shown(AREAS.all), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(sceneGroupVisible(null, AREAS.normal), true, 'a part without bounds is shown');
});
test('loadScenePack: scene.json + scene.bin + lightmap pages + textures through the store; null when not listed or malformed', async () => {
resetBoardPack();
const { doc, bin } = fakeScene(PARTS);
const group = sceneGroup('act2autochess_m02');
const urls = { [`${group}|scene`]: '/s/scene.json', [`${group}|bin`]: '/s/scene.bin', [`${group}|lm0`]: '/s/lm0.webp', [`${SCENE_TEX_GROUP}|TX_A`]: '/t/TX_A.webp' };
const seen = [];
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => urls[`${g}|${n}`] || null, image: async (u) => { seen.push(u); return { width: 8, height: 8 }; } };
const original = globalThis.fetch;
let json = doc;
globalThis.fetch = async (u) => (u === '/s/scene.json' ? { ok: true, json: async () => json } : u === '/s/scene.bin' ? { ok: true, arrayBuffer: async () => bin } : { ok: false });
try {
assert.equal(await sceneListed(store, 'act2autochess_m02'), true);
assert.equal(await sceneListed(store, 'act2autochess_m01'), false);
assert.equal(await loadScenePack(store, 'act2autochess_m01'), null, 'not extracted');
const pack = await loadScenePack(store, 'act2autochess_m02');
assert.equal(pack.chunks.length, PARTS.length);
assert.deepEqual(seen.sort(), ['/s/lm0.webp', '/t/TX_A.webp']);
assert.ok(pack.images.tex.TX_A && pack.images.lm[0]);
assert.deepEqual(Object.keys(pack.groups).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(await loadScenePack(store, 'act2autochess_m02'), pack, 'cached');
resetBoardPack();
json = { ...doc, version: SCENE_VERSION + 1 };
assert.equal(await loadScenePack(store, 'act2autochess_m02'), null, 'unknown version → the procedural board');
json = doc;
assert.ok(await loadScenePack(store, 'act2autochess_m02'), 'a failed load is not cached');
assert.equal(await loadScenePack(null, 'act2autochess_m02').catch(() => 'threw'), await loadScenePack(store, 'act2autochess_m02'), 'cached per stage');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
});
describe('scene materials', () => {
test('sceneBlend: alpha test / blend by the Unity keywords', () => {
assert.equal(sceneBlend({ keywords: ['_NORMALMAP'] }), 'opaque');
assert.equal(sceneBlend({ keywords: ['_ALPHATEST_ON'] }), 'cutout');
assert.equal(sceneBlend({ keywords: ['_ALPHABLEND_ON', '_EMISSION'] }), 'blend');
assert.equal(sceneBlend(null), 'opaque');
});
test('sceneMaterial: linear albedo tint, lightmap range, key light, cutoff; unlit fallback without a lightmap page', () => {
const focus = focusUniforms(THREE);
const map = new THREE.Texture(), lm = new THREE.Texture();
const info = { keywords: ['_ALPHATEST_ON'], colors: { _Color: [0.5, 1, 1, 1] }, floats: { _Cutoff: 0.4, _ShadowStrength: 0.9 } };
const m = sceneMaterial(THREE, { map, lightMap: lm, info, lightRange: 8, light: { dir: [0, 0, 2], color: [1, 1, 1], intensity: 1.1 } }, focus);
assert.equal(m.uniforms.uMap.value, map);
assert.equal(m.uniforms.uLm.value, lm);
assert.ok(Math.abs(m.uniforms.uColor.value.x - 0.5 ** 2.2) < 1e-6, 'authored in gamma space');
assert.equal(m.uniforms.uCutoff.value, 0.4);
assert.equal(m.uniforms.uShadow.value, 0.9);
assert.equal(m.uniforms.uLmScale.value, 8 * SCENE_LIGHT.indirect);
assert.deepEqual(m.uniforms.uLightDir.value.toArray(), [0, 0, 1], 'normalised');
assert.ok(Math.abs(m.uniforms.uLightColor.value.x - 1.1 * SCENE_LIGHT.direct) < 1e-6);
assert.equal(m.uniforms.uFocus, focus.uFocus, 'the shared focus falloff');
assert.equal(m.transparent, false);
assert.ok(m.defines.SCENE_MAP && m.defines.SCENE_LM);
const bare = sceneMaterial(THREE, { info: { keywords: ['_ALPHABLEND_ON'] } }, focus);
assert.equal(bare.uniforms.uLmScale.value, 0);
assert.equal(bare.uniforms.uAmbient.value, SCENE_LIGHT.ambient);
assert.equal(bare.uniforms.uCutoff.value, 0);
assert.equal(bare.transparent, true);
assert.equal(bare.depthWrite, false);
assert.ok(!bare.defines.SCENE_MAP && !bare.defines.SCENE_LM);
const w = sceneWaterMaterial(THREE, {}, focus, { colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] } });
assert.deepEqual(w.uniforms.uDeep.value.toArray(), [0, 0.27, 0.4, 0.67]);
assert.equal(w.transparent, true);
assert.ok(!w.defines.WATER_MAPS, 'no water maps: a flat tint');
});
});
describe('BoardScene with an official scene (stub renderer)', () => {
const stage = stages.act2autochess_m02;
test('the scene replaces the procedural tiles and background; devices, gates, edges and terrain stay', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stage);
assert.ok(board.meshes.board && board.meshes.bg, 'procedural board first');
assert.equal(board.official, null);
assert.equal(board.setScenePack(stage.id, fakeScenePack()), true);
assert.ok(board.official, 'official scene on');
assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow);
assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
const meshes = sceneMeshes(board);
assert.equal(meshes.length, 4, 'base ×2 + the left normal field ×2 (AREAS.normal)');
for (const m of meshes) {
assert.equal(m.geometry.getAttribute('position').count, 4);
assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized);
assert.equal(m.castShadow, false);
}
const water = meshes.find((m) => m.material.uniforms.uDeep);
assert.ok(water && water.material.transparent);
board.resize(1280, 720, 1);
board.render(presetCamera('normal', { width: 1280, height: 720 }), 2.5);
assert.equal(water.material.uniforms.uTime.value, 2.5, 'water animates');
board.destroy();
});
test('the scene\'s fields follow the built areas; materials and textures are reused across rebuilds', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const pack = fakeScenePack();
board.setScenePack(stage.id, pack);
board.setStage(stage);
const lit = sceneMeshes(board).find((m) => m.material.uniforms.uMap).material;
assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace);
assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace);
board.setArea(AREAS.boss);
assert.equal(sceneMeshes(board).length, 3, 'base ×2 + the boss field');
board.setArea(AREAS.unite);
assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)');
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
assert.equal(board.sceneRes.size, 1);
board.destroy();
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
});
test('another stage, or dropping the scene, shows the procedural board again', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(stage.id, fakeScenePack());
board.setStage(stages.act2autochess_m01);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length, 'a stage without a scene');
board.setStage(stage);
assert.ok(board.official && !board.meshes.board);
assert.equal(board.setScenePack(stage.id, null), true);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length);
assert.equal(board.setScenePack(stage.id, null), false, 'nothing to drop');
assert.equal(board.setScenePack('', fakeScenePack()), false);
board.destroy();
});
test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'k2', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg);
board.destroy();
});
});
describe('extracted scenes (when scene bundles were extracted here)', { skip: !Object.keys(manifest?.groups || {}).some((g) => g.startsWith('scene/act')) && 'no scene in data/local-assets.json' }, () => {
const groups = manifest?.groups || {};
const file = (p) => path.join(ROOT, 'public', p.replace(/^\//, ''));
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
test(`${g}: scene.json records fit scene.bin; lightmap pages and textures are listed and on disk`, { skip: !existsSync(file(groups[g].scene?.path || '/x')) && 'files not on this machine' }, () => {
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
assert.equal(doc.version, SCENE_VERSION);
assert.equal(`scene/${doc.stage}`, g);
assert.equal(doc.binBytes, bin.length);
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
assert.ok(chunks && chunks.length === doc.chunks.length && chunks.length > 0);
assert.ok(chunks.some((c) => !c.group), 'a base part');
for (const c of chunks) {
let max = 0;
for (const i of c.index) if (i > max) max = i;
assert.ok(max < c.position.length / 3, 'indices in range');
assert.ok(doc.materials[c.material], `material ${c.material}`);
if (c.lightmap >= 0) assert.ok(c.lightmap < doc.lightmaps && groups[g][`lm${c.lightmap}`], `lightmap page ${c.lightmap}`);
}
for (const m of Object.values(doc.materials)) if (m.map) assert.ok(groups[SCENE_TEX_GROUP]?.[m.map], `texture ${m.map}`);
const L = doc.light;
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
});
}
});