Files
ark-stronghold-protocol/test/render/board3d.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

367 lines
21 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,
} 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('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();
}
});
});