Files
ark-stronghold-protocol/test/render/boardart.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

315 lines
18 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/boardart.test.js — the real-board-art pipeline and the new pure pieces of the renderer:
// * tools/crop-board-atlas.mjs rects/materials are consistent with the renderer's material list, stay inside
// their source textures, and (when the local art is installed) cover real, non-empty pixels,
// * high-ground plate connectivity, textured device boxes, the backdrop ground plane,
// * every fx kind the sim emits has an explicit visual; junk fx / status keys never throw,
// * the impostor atlas shelf allocator (with a fake PIXI),
// * the optional local-art manifest helpers.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { MATERIALS as CROPS, SOURCES, BACKDROP, decodePng, encodePng, rectStats, pngSize } from '../../tools/crop-board-atlas.mjs';
import { MATERIALS } from '../../public/js/render/textures.js';
import { parseStage, buildTileQuads, boxQuads, buildPlaneQuads, deviceBoxOf, DEVICE_BOX, splitQuadGroups } from '../../public/js/render/tiles.js';
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';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const ART_DIR = path.join(ROOT, 'public/assets/local/map/autochess');
const hasArt = existsSync(path.join(ART_DIR, SOURCES.D.file));
describe('board atlas crops (tools/crop-board-atlas.mjs)', () => {
test('every cropped material exists in the renderer atlas; rects stay inside their sources', () => {
for (const [name, layers] of Object.entries(CROPS)) {
assert.ok(MATERIALS.includes(name), `${name} is a renderer material`);
assert.ok(Array.isArray(layers) && layers.length, name);
for (const ly of layers) {
if (ly.proc) { assert.equal(ly.proc, 'rim'); continue; }
const S = SOURCES[ly.src];
assert.ok(S, `${name}: source ${ly.src}`);
const [x, y, w, h] = ly.rect;
assert.ok(x >= 0 && y >= 0 && w > 8 && h > 8 && x + w <= S.w && y + h <= S.h, `${name}: rect ${ly.rect} inside ${ly.src}`);
if (ly.rot != null) assert.ok([0, 90, 180, 270].includes(ly.rot), `${name}: rot`);
if (ly.tint != null) assert.match(ly.tint, /^#[0-9a-f]{6}$/i);
}
}
assert.equal(BACKDROP.src, 'BG');
assert.ok(MATERIALS.length <= 64, 'atlas holds at most 64 cells');
});
test('the real atlas (when installed): sizes match and every first layer covers opaque, non-flat pixels', { skip: !hasArt && 'local art not extracted' }, () => {
const imgs = {};
for (const [k, s] of Object.entries(SOURCES)) {
const f = path.join(ART_DIR, s.file);
if (!existsSync(f)) continue;
const buf = readFileSync(f);
assert.deepEqual(pngSize(buf), { w: s.w, h: s.h }, s.file);
imgs[k] = decodePng(buf);
}
for (const [name, layers] of Object.entries(CROPS)) {
const ly = layers[0];
if (!imgs[ly.src]) continue;
const st = rectStats(imgs[ly.src], ly.rect);
assert.ok(st.opaque > 0.9, `${name}: opaque ${st.opaque}`);
assert.ok(st.std > 2, `${name}: contrast ${st.std}`);
}
const tiles = path.join(ART_DIR, 'tiles.json');
if (existsSync(tiles)) {
const j = JSON.parse(readFileSync(tiles, 'utf8'));
assert.equal(j.cell, 256);
assert.deepEqual(Object.keys(j.materials).sort(), Object.keys(CROPS).sort(), 'tiles.json is up to date (node tools/crop-board-atlas.mjs)');
for (const s of Object.values(j.source)) assert.match(s.path, /^\/assets\/local\/map\/autochess\/TX_autochessi_\w+\.png$/);
}
});
test('PNG codec round-trips RGBA', () => {
const w = 5, h = 3, px = Buffer.alloc(w * h * 4);
for (let i = 0; i < px.length; i++) px[i] = (i * 37) & 255;
const back = decodePng(encodePng(w, h, px));
assert.equal(back.w, w); assert.equal(back.h, h);
assert.deepEqual([...back.rgba], [...px]);
});
});
describe('board geometry', () => {
const uv = Object.fromEntries(MATERIALS.map((m, i) => [m, [i, i, i + 1, i + 1]]));
test('high-ground plates pick their frame piece by connectivity (texture top = far edge)', () => {
const rows = Array(19).fill('#'.repeat(21));
const put = (r, c, g) => { rows[r] = rows[r].slice(0, c) + g + rows[r].slice(c + 1); };
for (const c of [3, 4, 5]) put(10, c, 'h'); // horizontal strip
for (const r of [9, 10, 11]) put(r, 8, 'h'); // vertical strip
put(12, 1, 'h'); // single
for (let c = 0; c < 21; c++) put(11, c, rows[11][c] === '#' ? 'r' : rows[11][c]);
const G = parseStage({ rows, tiles: {} });
assert.equal(G[10][3].top, 'wallL'); assert.equal(G[10][4].top, 'wallM'); assert.equal(G[10][5].top, 'wallR');
assert.equal(G[9][8].top, 'wallB'); assert.equal(G[10][8].top, 'wallVM'); assert.equal(G[11][8].top, 'wallT');
assert.equal(G[12][1].top, 'wall');
assert.equal(G[10][4].mat, 'wall', 'material class unchanged (sides, heights)');
const q = buildTileQuads(G, uv).find((x) => x.row === 10 && x.col === 4 && x.order === 0);
assert.deepEqual(q.u, uv.wallM);
});
test('act2 stages: every drawn wall tile resolves to a known plate material', () => {
for (const id of ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04']) {
const G = parseStage(stages[id]);
for (const row of G) for (const t of row) if (t.glyph === 'h' && t.drawn) assert.ok(MATERIALS.includes(t.top), `${id} ${t.r},${t.c} ${t.top}`);
}
});
test('device boxes: 4 faces (top + S/E/W), E/W carry their face x; roles map from sim device keys', () => {
const q = boxQuads([], uv, 5, 10, 0, 0.84, 0.74, 'crateTop', 'crateSide', { row: 10, col: 5, rot: 1 });
assert.equal(q.length, 4);
assert.deepEqual(q.map((x) => x.faceDir), ['S', 'E', 'W', 0]);
assert.ok(Math.abs(q[1].fx - 5.42) < 1e-9 && Math.abs(q[2].fx - 4.58) < 1e-9);
assert.equal(q[3].rot, 1);
assert.deepEqual(q[3].u, uv.crateTop);
assert.ok(q.every((x) => x.kind === 'block' && x.row === 10));
const top = q[3].pts.map((p) => p[2]);
assert.ok(top.every((z) => Math.abs(z - 0.74) < 1e-9));
assert.equal(deviceBoxOf('trap_1105_accrate'), DEVICE_BOX.crate);
assert.equal(deviceBoxOf('trap_1104_aclasert'), DEVICE_BOX.turret);
assert.equal(deviceBoxOf('trap_9999_unknown'), null);
for (const b of Object.values(DEVICE_BOX)) assert.ok(MATERIALS.includes(b.top) && MATERIALS.includes(b.side));
});
test('ground plane: covers the island with baked contact shadows in [0, 1], never in a block row', () => {
const G = parseStage(stages.act2autochess_m01, [6, 18], [6, 13]);
const plane = buildPlaneQuads(G, 8);
assert.ok(plane.length > 500);
for (const q of plane) {
assert.equal(q.kind, 'plane');
for (const a of q.ao) assert.ok(a > 0 && a <= 1);
assert.ok(q.pts.every((p) => p[2] < 0));
}
const { ground } = splitQuadGroups(plane);
assert.equal(ground.length, plane.length);
// darker right at the island's foot than far away
const at = (x, y) => plane.find((q) => Math.abs(q.pts[3][0] - x) < 0.01 && Math.abs(q.pts[3][1] - y) < 0.01);
assert.ok(at(-4.5, 12.5).ao[3] < at(-12.5, 12.5).ao[3] + 0.3);
});
});
describe('sim fx coverage', () => {
// every literal fx kind in the sim sources: battle.fx('kind' | b.fx('kind' | fx(battle, 'kind' | unitFx(…, 'kind'
const kinds = new Set();
const walk = (dir) => {
for (const f of readdirSync(dir)) {
const p = path.join(dir, f);
if (statSync(p).isDirectory()) walk(p);
else if (f.endsWith('.js')) {
const src = readFileSync(p, 'utf8');
for (const m of src.matchAll(/\b(?:battle|b|b2|this)\.fx\(\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\bfx\((?:battle|b), '([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/[fF]x\(\s*(?:[\w.[\]]+\s*,\s*){0,2}'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\bfxOn\(\s*\w+\s*,\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
// kinds passed as options / defaults (tokens.js burst(…, { fx: 'kind' }), `fx = 'kind'`) and ternaries
for (const m of src.matchAll(/\bfx\s*[:=]\s*'([A-Za-z]+)'/g)) kinds.add(m[1]);
for (const m of src.matchAll(/\.fx\(\s*[\w.!]+\s*\?\s*'([A-Za-z]+)'\s*:\s*'([A-Za-z]+)'/g)) { kinds.add(m[1]); kinds.add(m[2]); }
}
}
};
walk(path.join(ROOT, 'server/sim'));
test('every fx kind literal in server/sim has an explicit visual', () => {
assert.ok(kinds.size > 50, `found ${kinds.size} kinds`);
const missing = [...kinds].filter((k) => !FX_KINDS[k]);
assert.deepEqual(missing, [], 'add these to render/fx.js FX_KINDS');
for (const k of ['aoe', 'zone', 'counter', 'crit', 'dp', 'heal', 'taunt', 'summon', 'pull', 'sonic', 'shield', 'overload', 'takeoff', 'sleep', 'buff', 'reveal', 'dodge', 'burst', 'displace', 'appear', 'disappear', 'substitute', 'swap', 'sandChains', 'sandChainsCharged', 'summonBurst', 'summonStun', 'radiantSword', 'paperDoll']) {
assert.ok(kinds.has(k), `the scan finds ${k}`);
assert.ok(FX_KINDS[k], k);
}
});
test('fxSpec: unknown kinds guess or fall back to generic; junk never throws; element recolours', () => {
assert.equal(fxSpec('weirdHealWave').a, 'heal');
assert.equal(fxSpec('zzzz').a, 'generic');
for (const junk of [null, undefined, 42, {}, [], '']) assert.ok(fxSpec(junk, junk).a);
assert.notEqual(fxSpec('explode', { element: 'burn' }).c, fxSpec('explode', {}).c + 1);
assert.equal(fxSpec('explode', { element: 'burn' }).c, 0xff7a33);
});
test('tilesAround: box / disc / explicit lists, bounded', () => {
assert.equal(tilesAround(5, 10, 1.5, 'box').length, 9);
assert.equal(tilesAround(5, 10, 1, 'disc').length, 5);
assert.deepEqual(tilesAround(0, 0, 1, [[1, 2], ['x'], [3, 4]]), [[1, 2], [3, 4]]);
assert.ok(tilesAround(5, 10, 99, 'box').length <= 169);
});
test('status icons: namespaced content keys resolve to drawn icons; unknown → none', () => {
assert.equal(statusIconKey('ab:frost'), 'cold');
assert.equal(statusIconKey('reed2:scorch'), 'burn');
assert.equal(statusIconKey('skill:shotst_shred'), 'fragile');
assert.equal(statusIconKey('lumen:resist'), 'shield');
assert.equal(statusIconKey('stun'), 'stun');
assert.equal(statusIconKey('talent:angel_bless_ally'), null);
for (const junk of [null, 1, '', {}]) assert.equal(statusIconKey(junk), null);
for (const k of ['ab:frost', 'reed2:scorch', 'sluggish', 'tremble', 'weaken']) assert.ok(STATUS_KEYS.includes(statusIconKey(k)), k);
});
});
describe('impostor atlas allocator', () => {
test('shelf packing, reuse of freed slots, clip pages separate, full ⇒ null', async () => {
class Obj { constructor() { this.children = []; this.parent = null; this.visible = true; this.position = { set: (x, y) => { this.x = x; this.y = y; } }; this.scale = { set: () => {} }; }
addChild(...cs) { for (const c of cs) { c.parent?.removeChild?.(c); c.parent = this; this.children.push(c); } return cs[0]; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parent = null; }
destroy() {} }
const rendered = [];
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: { id: Math.random() }, framebuffer: { enableStencil() {} }, destroy() {} }) },
BLEND_MODES: { ERASE: 26 },
};
try {
const { ImpostorAtlas } = await import('../../public/js/render/impostor.js');
const atlas = new ImpostorAtlas({ resolution: 1, render: (obj, o) => rendered.push(o.clear) });
const a = atlas.alloc(100, 110);
const b = atlas.alloc(100, 110);
assert.ok(a && b && a.page === b.page);
assert.deepEqual([a.x, a.y, a.w, a.h], [0, 0, 112, 112]);
assert.deepEqual([b.x, b.y], [112, 0]);
atlas.free(a);
const c = atlas.alloc(90, 100);
assert.deepEqual([c.x, c.y], [0, 0], 'freed slot reused');
const k = atlas.alloc(100, 110, { clip: true });
assert.ok(k.clip && k.page !== a.page && k.page.kind === 'clip');
// queue + flush: one render per page with work, erasers hidden again, bodies parked
const body = new Obj();
atlas.park(body);
atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
assert.equal(body.parent, c.page.bodies);
atlas.flush();
assert.deepEqual(rendered, [true]);
assert.equal(body.parent, atlas.parked);
assert.equal(body.visible, false);
atlas.draw(c, body, { a: 0.3, d: 0.3, tx: 40, ty: 90 });
atlas.flush();
assert.deepEqual(rendered, [true, false], 'later passes only erase their slots');
// fill everything: eventually null, never throws
let n = 0;
while (atlas.alloc(500, 500) && n < 1000) n++;
assert.ok(n > 0 && n < 1000);
assert.equal(atlas.alloc(4000, 10), null, 'larger than a page');
atlas.destroy();
} finally {
delete globalThis.PIXI;
}
});
test('zoom churn (the enemy pen camera: 50 figures grow and shrink again and again) never exhausts the atlas; freeing every slot resets the pages', async () => {
class Obj { constructor() { this.children = []; this.parent = null; this.visible = true; this.position = { set: () => {} }; this.scale = { set: () => {} }; }
addChild(...cs) { for (const c of cs) { c.parent?.removeChild?.(c); c.parent = this; this.children.push(c); } return cs[0]; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parent = null; }
destroy() { this.parent?.removeChild(this); } }
globalThis.PIXI = {
Container: Obj,
Sprite: class extends Obj { constructor(t) { super(); this.texture = t; } },
Texture: class { constructor(b, f) { this.baseTexture = b; this.frame = f; } destroy() {} static WHITE = {} },
Rectangle: class { constructor(x, y, w, h) { Object.assign(this, { x, y, width: w, height: h }); } },
RenderTexture: { create: (o) => ({ ...o, baseTexture: {}, framebuffer: { enableStencil() {} }, destroy() {} }) },
BLEND_MODES: { ERASE: 26 },
};
try {
const { ImpostorAtlas } = await import('../../public/js/render/impostor.js');
const atlas = new ImpostorAtlas({ resolution: 2, render() {} });
// 50 figures, each re-allocating whenever its size leaves [0.6, 1] of its slot (render/units.js rule)
const figs = Array.from({ length: 50 }, (_, i) => ({ w0: 40 + (i % 5) * 4, h0: 60 + (i % 7) * 3, slot: null }));
let failed = 0;
const frame = (k) => {
for (const f of figs) {
const w = Math.ceil(f.w0 * k), h = Math.ceil(f.h0 * k);
const s = f.slot;
if (!s || w > s.w || h > s.h || w < s.w * 0.6 || h < s.h * 0.6) {
if (s) atlas.free(s);
f.slot = atlas.alloc(w, h);
if (!f.slot) failed++;
}
}
};
for (let cycle = 0; cycle < 8; cycle++) {
for (let k = 1; k <= 2.2; k += 0.05) frame(k); // pan into the pen: figures ≈ 2.2× bigger
for (let k = 2.2; k >= 1; k -= 0.05) frame(k); // and back
}
const failedChurn = failed;
frame(1);
assert.equal(figs.filter((f) => !f.slot).length, 0, `after 8 pen visits every prep-size figure has an atlas slot (${failedChurn} misses during the zooms)`);
// combat start: every view is destroyed → every page empties and starts over (no stale shelves for the battle)
for (const f of figs) { atlas.free(f.slot); f.slot = null; }
for (const page of atlas.pages) { assert.equal(page.nextY, 0, `${page.kind} page reset`); assert.equal(page.shelves.length, 0); }
const crowd = [];
for (let i = 0; i < 120; i++) crowd.push(atlas.alloc(40 + (i % 6) * 5, 70 + (i % 4) * 6));
assert.equal(crowd.filter((x) => !x).length, 0, 'a 120-unit battle crowd fits after the pen visits');
atlas.destroy();
} finally {
delete globalThis.PIXI;
}
});
});
describe('local-art manifest helpers', () => {
test('localAssetUrl / assets.local(): optional, never throws', async () => {
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: '/assets/local/map/autochess/TX_autochessi_D.png', w: 2048, h: 2048 } } } };
assert.equal(localAssetUrl(m, 'map/autochess', 'TX_autochessi_D'), '/assets/local/map/autochess/TX_autochessi_D.png');
assert.equal(localAssetUrl(m, 'map/autochess', 'nope'), null);
assert.equal(localAssetUrl(null, 'x', 'y'), null);
assert.equal(localAssetUrl(m, '__proto__', 'constructor'), null);
const ok = createAssets({ manifest: {}, fetch: async () => ({ ok: true, json: async () => m }) });
assert.deepEqual(await ok.local(), m);
assert.equal(ok.localUrl('map/autochess', 'TX_autochessi_D'), m.groups['map/autochess'].TX_autochessi_D.path);
const missing = createAssets({ manifest: {}, fetch: async () => ({ ok: false, status: 404 }) });
assert.equal(await missing.local(), null);
assert.equal(missing.localUrl('map/autochess', 'TX_autochessi_D'), null);
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
});