// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15): // tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the // derived three.js maps (BC5 / DXT5nm normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures // the renderer downloads (extract.py WEBP), the prefab mesh keys, and — // when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { decodePng } from '../../tools/crop-board-atlas.mjs'; import { PACK_IMAGES, PACK_MESHES, GATE_NODES } from '../../public/js/render/board3d/load.js'; import { VENDOR_FILES } from '../../tools/vendor.mjs'; 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 HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0; 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\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${code}`; const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV }); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); } describe('extract.py board jobs', { skip: !PY && 'no python3' }, () => { test('job table: gate effects, blower texture and water maps; derived maps for the theme', () => { const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }); assert.equal(r.status, 0, r.stderr); const { jobs, derived } = JSON.parse(r.stdout); const by = Object.fromEntries(jobs.map((j) => [j.sub, j])); assert.equal(by['map/fx'].bundle, 'arts/effects/[pack]map.ab'); assert.deepEqual(by['map/fx'].kinds, ['GameObject', 'Material', 'Mesh', 'Texture2D']); assert.equal(by['map/common'].bundle, 'arts/maps/common/res.ab'); assert.ok(new RegExp(by['map/common'].keep).test('TX_wind_device') && !new RegExp(by['map/common'].keep).test('TX_R6_bomb')); assert.ok(new RegExp(by['map/water'].keep).test('[ucp]TX_water_normal')); // no new mesh/ groups (the board meshes of DESIGN §15 stay the four of the mesh job table) assert.equal(jobs.filter((j) => j.sub.startsWith('mesh/')).length, 4); assert.deepEqual(derived.map((d) => [d.sub, d.from, d.derive, d.name]), [ ['map/autochess', 'TX_autochessi_N', 'normal_rg', 'TX_autochessi_N_rgb'], ['map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'], ]); }); test('derived maps: Z of a two-channel normal map is rebuilt; roughness = 1 − smoothness', { skip: !HAS_PIL && 'no Pillow' }, () => { const out = py(` from PIL import Image n = Image.new('RGB', (3, 1)) n.putpixel((0, 0), (128, 128, 0)); n.putpixel((1, 0), (255, 128, 0)); n.putpixel((2, 0), (128, 20, 0)) rn = e.derive_normal_rg(n) m = Image.new('RGBA', (2, 1)); m.putpixel((0, 0), (0, 0, 0, 0)); m.putpixel((1, 0), (0, 0, 0, 200)) rm = e.derive_rough_from_gloss(m) print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list(rm.getpixel((i, 0))) for i in range(2)], 'mode': [rn.mode, rm.mode]}))`); assert.deepEqual(out.mode, ['RGB', 'RGB']); assert.ok(out.n[0][2] >= 254, 'flat normal → z = 1'); assert.ok(Math.abs(out.n[1][2] - 128) <= 1, 'x = 1 → z = 0 (byte 128)'); assert.deepEqual(out.n[0].slice(0, 2), [128, 128], 'XY kept'); assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]); }); test('derived normal map: the DXT5nm layout (R = 1, G = Y, A = X) gives the same normals as BC5 (R = X, G = Y)', { skip: !HAS_PIL && 'no Pillow' }, () => { const out = py(` from PIL import Image xy = [(128, 128), (255, 128), (128, 20), (90, 200), (0, 128), (37, 37)] bc5 = Image.new('RGB', (len(xy), 1)); nm = Image.new('RGBA', (len(xy), 1)); opaque = Image.new('RGBA', (len(xy), 1)) for i, (x, y) in enumerate(xy): bc5.putpixel((i, 0), (x, y, 0)); nm.putpixel((i, 0), (255, y, y, x)); opaque.putpixel((i, 0), (x, y, 0, 255)) px = lambda im: [list(im.getpixel((i, 0))) for i in range(len(xy))] print(json.dumps({'bc5': px(e.derive_normal_rg(bc5)), 'nm': px(e.derive_normal_rg(nm)), 'opaque': px(e.derive_normal_rg(opaque)), 'mode': e.derive_normal_rg(nm).mode, 'xy': xy}))`); assert.equal(out.mode, 'RGB'); assert.deepEqual(out.nm, out.bc5, 'X read from A when R is 1'); assert.deepEqual(out.opaque, out.bc5, 'a BC5 map decoded with an opaque alpha'); assert.deepEqual(out.nm.map((p) => p.slice(0, 2)), out.xy, 'XY kept'); assert.ok(out.nm[0][2] >= 254 && out.nm[3][2] > 128, 'z rebuilt, not the 128 of x = 1'); }); test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => { const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }); assert.equal(r.status, 0, r.stderr); const { webp } = JSON.parse(r.stdout); assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort()); const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode])); for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n); for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n); }); test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => { const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-')); try { const out = py(` from pathlib import Path from PIL import Image root = Path(${JSON.stringify(dir)}) sub = root / 'map' / 'autochess' sub.mkdir(parents=True) d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows for x in range(32): for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255)) d.save(sub / 'TX_autochessi_D.png') n = Image.new('RGB', (64, 64)) for x in range(64): for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56)) n.save(sub / 'TX_autochessi_N_rgb.png') entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind} groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'), 'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'), 'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}} mf = root / 'local-assets.json' mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups})) logs = [] code = e.webp_only(root, mf, logs.append) doc = json.loads(mf.read_text()) g = doc['groups']['map/autochess'] px = lambda p: list(Image.open(p).convert('RGBA').getdata()) a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp') hidden = [q for p, q in zip(a, b) if p[3] == 0] print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()}, 'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(), 'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)), 'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'), 'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)], 'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`); assert.equal(out.code, 0); const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`; // TX_autochessi_E is listed without a PNG on disk: its entry stays as it was assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') }); assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null }); assert.equal(out.count, 3); assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)'); assert.ok(out.alphaSame, 'alpha lossless'); assert.ok(out.normalSame, 'normal map lossless'); out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`)); assert.equal(out.again, 2, 'a second run rewrites both copies'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => { const out = py(` from types import SimpleNamespace as NS class P: def __init__(self, obj=None, pid=1, fid=0): self.m_PathID, self.m_FileID, self._o = pid, fid, obj def read(self): return self._o class MeshFilter: def __init__(self, **k): self.__dict__.update(k) go = NS(m_Name='Start_back', m_Component=[NS(component=P(MeshFilter(m_Mesh=P(NS(m_Name='Start_back'), pid=42))))]) print(json.dumps([e.prefab_node(go)['mesh'], e.prefab_node(go, {42: 'Start_back_42'})['mesh'], e.prefab_node(go, {7: 'x'})['mesh']]))`); assert.deepEqual(out, ['Start_back', 'Start_back_42', 'Start_back']); }); }); describe('vendored three.js', () => { test('tools/vendor.mjs copies both ES modules of three (module imports ./three.core.js)', () => { const names = VENDOR_FILES.map((f) => f[1]); assert.ok(names.includes('three.module.js') && names.includes('three.core.js')); const mod = path.join(ROOT, 'public/vendor/three.module.js'); if (existsSync(mod)) { assert.match(readFileSync(mod, 'utf8').slice(0, 4000), /from '\.\/three\.core\.js'/); assert.ok(existsSync(path.join(ROOT, 'public/vendor/three.core.js'))); } const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')); assert.ok(pkg.dependencies.three, 'three is a dependency'); }); }); describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => { const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p)); const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx')); test('every pack slot the renderer loads is in the manifest (and on disk)', () => { for (const [slot, [g, n]] of [...Object.entries(PACK_IMAGES), ...Object.entries(PACK_MESHES)]) { const e = manifest.groups[g]?.[n]; assert.ok(e && e.path, `${slot}: ${g}/${n}`); if (hasFiles) assert.ok(existsSync(onDisk(e.path)), `${slot} file`); } const prefab = JSON.parse(readFileSync(onDisk(manifest.groups['map/fx'].prefab.path), 'utf8')); for (const node of Object.values(GATE_NODES)) { const rec = prefab.find((p) => p.name === node && (node !== 'Start_back' || p.parent === '[opt]start_box')); assert.ok(rec && manifest.groups['map/fx'][rec.mesh], `${node} → ${rec?.mesh}`); } const mats = JSON.parse(readFileSync(onDisk(manifest.groups['map/fx'].materials.path), 'utf8')); assert.equal(mats['[opt]start_end_add'].shader, 'Torappu/Particles/Additive'); assert.equal(mats['[opt]start_end_ab'].shader, 'Torappu/Particles/AlphaBlend'); assert.equal(mats['[opt]start_end_add'].textures._MainTex.texture, '[opt]merged_textures'); }); test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => { const g = manifest.groups['map/autochess']; const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path)))); let zSum = 0, cnt = 0; for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; } assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt} (128 = made by an extract.py that read a DXT5nm map as BC5: re-extract with --only map/autochess)`); const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path))); const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path)))); assert.deepEqual([r.w, r.h], [m.w, m.h]); for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]); }); test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => { for (const [g, entries] of Object.entries(manifest.groups)) { for (const [n, e] of Object.entries(entries)) { if (!/\.webp$/.test(e?.path || '')) continue; assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`); assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`); assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`); } } }); });