feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged

Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new
  public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into
  ui/gameLogic/shortcuts.js.
- i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json
  (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands
  them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole
  msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN
  terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone,
  Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is
  byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages.
- public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo /
  fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is
  played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick
  handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only,
  so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name).
- public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers.
- public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear
  button's aria-label / title are t('设置') (en.json already has "Settings").
- tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's
  --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the
  voice plan unchanged.
- data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files
  6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash
  (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields.
- docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md,
  DEPLOY.md, PLAYING.md auto-merged.
- Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js,
  textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests.

Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15,
board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313,
docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283
scenarios match, nothing regenerated.
This commit is contained in:
sganggs committed 2026-10-06 14:41:40 +08:00
commit 7059528c47
40 files changed
+1944 -97

No files matched your search

+81 -4
View File
@@ -1,12 +1,14 @@
// 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 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
// derived three.js maps (BC5 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 } from 'node:fs';
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';
@@ -18,6 +20,7 @@ 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) {
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
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
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
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)', () => {
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
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(g.TX_autochessi_N_rgb.path)));
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}`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.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}`);
}
}
});
});
+23
View File
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
const asked = [];
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
const realFetch = globalThis.fetch;
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
try {
resetBoardArt();
const art = await loadBoardArt(store);
assert.ok(art?.images.D && art.images.common && art.images.BG);
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
} finally {
globalThis.fetch = realFetch;
resetBoardArt();
}
});
});
+79
View File
@@ -0,0 +1,79 @@
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const style = read('public/js/render/style.js');
const textures = read('public/js/render/textures.js');
const materials = read('public/js/render/board3d/materials.js');
const tiles = read('public/js/render/tiles.js');
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
function fnSource(src, name) {
const at = src.indexOf(`export function ${name}(`);
assert.ok(at > 0, `${name} not found`);
const end = src.indexOf('\n}\n', at);
return src.slice(src.lastIndexOf('/**', at), end + 2);
}
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
assert.match(textures, /ORIGINIUM\.vein/);
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
// so does the 3D shader, through the vec3 helper
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
});
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
const atlas = read('public/js/render/board3d/atlas.js');
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
assert.ok(m1 && m2, 'both define the slab');
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
});
test('活性源石: neither board fades the tile out at its own border', () => {
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
// their own fade — this is about 活性源石 only.
const inf = fnSource(materials, 'infectionMaterial');
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
const mire = fnSource(materials, 'mireMaterial');
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
});
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
});
+133
View File
@@ -0,0 +1,133 @@
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const f3 = (v) => Math.round(v * 1000) / 1000;
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
let configs = []; // [{ name, art, infection, infection2, road, palette }]
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
const page = await browser.newPage();
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
configs = await page.evaluate(async () => {
const m = await import('/js/render/textures.js');
const st = await import('/js/render/style.js');
const { assets } = await import('/js/assets.js');
const { loadBoardArt } = await import('/js/render/boardArt.js');
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
await assets.load?.().catch?.(() => null);
const art = await loadBoardArt(assets).catch(() => null);
const read = (atlas, name) => {
const uv = atlas.uv[name];
if (!uv) return null;
const W = atlas.canvas.width, H = atlas.canvas.height;
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
const px = [];
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const k = (y * w + x) * 4;
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
}
return { w, h, px, data: Array.from(img.data) };
};
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
const hex = st.linearToHex(st.ORIGINIUM.vein);
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
return out.map((c) => ({ ...c, palette }));
});
await page.close();
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
function stats(c) {
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
const hot = c.px.filter(veinish);
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
// the brightest few percent are exactly those, whichever base the cell has
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
return {
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
// layer this change drew — are what shows whether the tile joins the next one)
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
};
}
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
const { gr, br, veinHex } = cfg.palette;
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
}
});
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
if (!cfg.art) {
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
}
}
}
});
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
for (const cfg of configs) {
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
const a = stats(cfg.infection), b = stats(cfg.infection2);
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
}
});
});