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

220 lines
14 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-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}`);
}
}
});
});