- 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.
367 lines
21 KiB
JavaScript
367 lines
21 KiB
JavaScript
// 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();
|
||
}
|
||
});
|
||
});
|