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

395 lines
23 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.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,
DEVICE_H, RAISED_FOOT,
} from '../../public/js/render/board3d/layout.js';
import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { loadBoardPack, loadThree, 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('#569: the device slabs (射击台 / 特制水上平台 / 封印的地面) are repeated for an official scene, their sides reaching under its ground; nothing else is', () => {
const SLAB = new Set(['platform', 'waterPlatform', 'sealedFloor']);
for (const [id, st] of Object.entries(stages)) {
if (!Array.isArray(st?.rows)) continue;
for (const area of [AREAS.normal, AREAS.boss]) {
const b = buildBoard(st, { area });
const slabs = b.devices.filter((d) => SLAB.has(d.kind));
const decals = slabs.filter((d) => d.kind === 'sealedFloor').length;
// per slab four sides, plus its top: a plate (raised) or the hazard decal of a sealed floor (raisedDecal)
assert.equal(b.buckets.raised.vertexCount, (slabs.length * 5 - decals) * 4, `${id}: raised quads`);
assert.equal(b.buckets.raisedDecal.vertexCount, decals * 4, `${id}: raised decal quads`);
const P = b.buckets.raised.position;
for (let i = 0; i < P.length; i += 3) {
const d = slabs.find((x) => Math.abs(P[i] - x.c) <= 0.5 && Math.abs(P[i + 1] - x.r) <= 0.5);
assert.ok(d, `${id}: every raised vertex is on a slab tile`);
const h = b.grid[d.r][d.c].h;
assert.ok(near(P[i + 2], h + DEVICE_H[d.kind]) || near(P[i + 2], h - RAISED_FOOT), `${id}: a slab top or its foot, z ${P[i + 2]}`);
}
}
}
// the report's 射击台 beside the blue gate (战场#03) and the water platforms (战场#05 上半) are there; the 'h' high
// ground of act2 m01 / m02 is the scenes' own geometry and is not repeated
assert.ok(buildBoard(stages.act1autochess_m03, { area: AREAS.normal }).buckets.raised.vertexCount > 0);
assert.equal(buildBoard(stages.act1autochess_m05, { area: AREAS.normal }).devices.filter((d) => d.kind === 'waterPlatform').length, 16);
for (const id of ['act2autochess_m01', 'act2autochess_m02']) assert.equal(buildBoard(stages[id], { area: AREAS.normal }).buckets.raised.vertexCount, 0, id);
});
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('resetBoardPack lets a later three.js import try again after three failures', async () => {
resetBoardPack();
const missing = new URL('./no-such-three.mjs', import.meta.url).href;
for (let i = 0; i < 3; i++) assert.equal(await loadThree(missing), null);
assert.equal(await loadThree(missing), null, 'three failures stop further imports');
resetBoardPack();
const mod = await loadThree(new URL('./fake-three.mjs', import.meta.url).href);
assert.equal(typeof mod?.WebGLRenderer, 'function');
resetBoardPack();
});
test('a transient diffuse-image failure is retried by the next mount instead of cached as permanent 2D', async () => {
resetBoardPack();
let calls = 0;
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => n === 'TX_autochessi_D' ? '/test/atlas.png' : null,
image: async () => ++calls === 1 ? null : { width: 8, height: 8 } };
const original = globalThis.fetch;
globalThis.fetch = async () => ({ ok: false });
try {
assert.equal(await loadBoardPack(store), null);
const pack = await loadBoardPack(store);
assert.ok(pack?.images.D); assert.equal(calls, 2);
assert.equal(await loadBoardPack(store), pack, 'successful pack remains shared');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
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();
}
});
});