Files
ark-stronghold-protocol/test/render/feedback5-prisoners.browser.test.js
T
sganggs 6372f2892b feedback5 WAA: the 孤岛风云 prisoners leave the gate confined and change model as their official prefabs do — grey while 【禁锢】, the blinking orange warning before the last confined attack, red once freed; the 4th attack already hits freed (community report of 2026-10-06 「囚徒类敌人出门就是已解放的模型」)
- Cause, client: the prisoner skeletons carry three clip sets whose collar light is grey, blinking orange
  and red. The official battle prefabs (local client battle/enm_pfb_6.ab: the Graphic component's anim key
  -> clip table, the modes' replace pairs, the clip the Spine starts on) draw mode Default (【禁锢】) on the
  grey set, mode R (the warning) on the orange set and mode L (【解放】) on the red one. The name-based role
  resolver gave 普通囚犯 / 老练囚犯 their unnumbered set — the red one — so they came out of the gate freed;
  no FORMS entry existed, so no prisoner ever changed (强壮囚犯 / 拳师囚犯 / 重犯 / 传奇重犯 stayed grey).
- Cause, sim: buff_template_data enemy_confinement[atk_cnt] spends a charge ON_BEFORE_ATTACK (not while
  麻痹) and switches to mode R when one is left; enemy_liberty_listener[warning] switches to mode L before
  the next attack (PRTS "进行第4次攻击前,切换至解放状态"). archetypes.js prisoner() counted on the 'attack'
  hook, after the damage, so the 4th attack still hit confined, and it sent no form.
- Now: prisoner() counts in `before` (an attack 麻痹 interrupts never gets there, PRTS 拳师囚犯), setForm
  'warning' (fx 'phase', drawn silently: kinds.js SILENT_PHASES) before attack times-1 and 'liberty' (fx
  'liberate') before attack times; 重犯's free-all and 杰斯顿's KO switch the freed ones too.
  render/units.js FORMS maps 'warning' / 'liberty' for the six skeletons (普通 / 老练囚犯 *2 / unnumbered,
  强壮囚犯 *2 / *3, 拳师囚犯 / 重犯 / 传奇重犯 *_orange / *_red). tools/assets/spine.mjs PREFAB_SPINE_ROLES gives
  普通囚犯 / 老练囚犯 their grey *3 set; data/assets.json regenerated (fetch-assets --offline --add-only, JSON
  compare: only those two anims and the hash), data/enemies.json too (build-data --offline: only their
  attackAnim.clip Attack -> Attack3, same length and strike frame). The 3D board draws the same Pixi views.
- Docs: ASSETS.md (PREFAB_SPINE_ROLES, the prisoner forms), SIM.md (the form fx), design/client.md (FORMS).
- Tests: test/content/feedback5-prisoners.test.js (the six: warning before attack 3, attack 4 hits with
  ATK +50 %, the forms reach the client and UnitInfo; free-all; 麻痹), test/render/feedback5-prisoners.test.js
  (manifest = the prefab's grey set, FORMS sets, view clips, PREFAB_SPINE_ROLES = the committed manifest) —
  both fail on the parent but the 麻痹 pin; test/render/feedback5-prisoners.browser.test.js (RENDER_E2E=1:
  three prisoners on the 2D and the 3D board, each model's set follows its sim form).
- Golden: 28 scenarios. Only the warning's new 'phase' fx (the prisoner fell before its 4th attack):
  roster-001, roster-003, roster-007, roster-024, roster-033, bond-miraShip-low. The 4th attack now hits
  freed, one attack earlier than before, so the fight after it moves: roster-004, roster-022, roster-026,
  bond-kazimierzShip-low, bond-raidShip-low, bond-soloShip-low, bond-victoriaShip-low, bond-visiShip-low,
  standin-01, diy-024, diy-061, diy-065, diy-066, diy-094, diy-097, diy-104, diy-105, diy-127; only the
  per-second snapshots besides the fx: roster-021, diy-017; and the bot matches whose battles field
  prisoners: solo-ABYSS-2, solo-HARD-9-boosted.
2026-10-06 21:57:51 +08:00

163 lines
9.6 KiB
JavaScript

// test/render/feedback5-prisoners.browser.test.js — community report of 2026-10-06 「囚徒类敌人出门就是已解放的模型」 in
// headless Chrome, on the 2D and the 3D board: the real sim runs in the page (the client runner's loadBrowserSim) and
// feeds the render demo's field view. Three operators block three 孤岛风云 prisoners — 普通囚犯, 强壮囚犯 and 拳师囚犯, one
// of each clip-naming family — and each model follows its official prefab's modes: the grey confined set out of the gate, the
// blinking orange set from the warning (before the last confined attack), the red set once freed (sim fx 'phase' /
// 'liberate' with their `form` → render/units.js FORMS). 普通囚犯 used to come out on its red (freed) set and none ever
// changed. Screenshots (cropped to the prisoners) → test/e2e/out/prisoners-<board>-<confined|warning|freed>.png.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback5-prisoners.browser.test.js
// Needs the downloaded assets; the 3D board also the local-client board art and WebGL2 (else that half is skipped).
// Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** Each prisoner's clip suffix per mode (confined / warning / freed), as test/render/feedback5-prisoners.test.js. */
const SETS = {
enemy_1116_liprr: ['3', '2', ''],
enemy_1119_vofsd: ['', '2', '3'],
enemy_1118_lidbox_2: ['_grey', '_orange', '_red'],
};
const KEYS = Object.keys(SETS);
// three block-1 operators along act2 m01's road (9,10) → (9,8) → (12,7) → (12,5) → (9,5) → (9,2): 山 on (9,8), 宴 on
// (12,6), 斯卡蒂 on (10,5), each facing the coming enemies; the prisoners walk in 1 s apart, harmless and tanky, and each
// is blocked by its own operator — they stand apart and keep attacking until freed
const SPEC = buildBattleSpec({
battleId: 'e2e.prisoners', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 12, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_5_17_a', row: 9, col: 8, dir: 'RIGHT' },
{ uid: 2, kind: 'chess', chessId: 'chess_char_1_18_a', row: 12, col: 6, dir: 'RIGHT' },
{ uid: 3, kind: 'chess', chessId: 'chess_char_3_05_a', row: 10, col: 5, dir: 'DOWN' },
], bonds: {}, playerEffects: [] }],
spawns: KEYS.map((enemyKey, i) => ({ time: i, enemyKey, routeIndex: 0, count: 1, mods: { hpMul: 200, atkMul: 0.01 } })),
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
});
const setOf = (key, clip) => {
const m = /^(?:Idle|Move|Attack|Die|Default)(.*)$/.exec(clip || '');
return m ? SETS[key].indexOf(m[1]) : -1;
};
describe('the 孤岛风云 prisoners draw confined → warning → freed (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
for (const board of ['2d', '3d']) {
test(`${board} board: grey out of the gate, orange from the warning, red once freed — each model as its sim form`, async (t) => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1600, height: 900 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0&board=${board}`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 40000 });
const on3d = await page.evaluate(() => !!window.__demo.view.stats().board3d?.on);
if (board === '3d' && !on3d) { t.skip('no 3D board here (local board art or WebGL2 missing)'); return; }
assert.equal(on3d, board === '3d', 'the board mode asked for');
await page.evaluate(async (spec) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
window.__pr = { b, v, meta, log: [] };
}, SPEC);
/** Step the sim `ticks` ticks, feeding the view; log each prisoner's sim form and drawn clip. */
const run = (ticks) => page.evaluate(async (ticks, keys) => {
const { b, v, meta, log } = window.__pr;
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
for (let i = 0; i < ticks; i++) {
b.step();
if (i % 3 !== 2) continue;
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
for (const e of b.enemies) {
if (!keys.includes(e.defId) || !e.alive) continue;
const view = v.debug.views.get(e.id);
if (view?.spineReady) log.push({ t: +b.time.toFixed(2), key: e.defId, form: e.form ?? null, clip: view.actor?.current ?? null, attacks: e.stats.attacks });
}
}
return b.time;
}, ticks, KEYS);
/** Screenshot cropped to the prisoners' drawn bodies. */
const shot = async (name) => {
const r = await page.evaluate((keys) => {
const { b, v } = window.__pr;
const rects = b.enemies.filter((e) => keys.includes(e.defId) && e.alive).map((e) => v.debug.views.get(e.id)?.bounds()).filter(Boolean);
if (!rects.length) return null;
const x0 = Math.min(...rects.map((q) => q.x)), y0 = Math.min(...rects.map((q) => q.y));
const x1 = Math.max(...rects.map((q) => q.x + q.width)), y1 = Math.max(...rects.map((q) => q.y + q.height));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}, KEYS);
const pad = 60;
const clip = r ? { x: Math.max(0, r.x - pad), y: Math.max(0, r.y - pad), width: Math.min(1600, r.w + 2 * pad), height: Math.min(900, r.h + 2 * pad) } : undefined;
await page.screenshot({ path: path.join(OUT, `prisoners-${board}-${name}.png`), ...(clip ? { clip } : {}) });
};
await run(30 * 3);
await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
await run(30 * 3);
await shot('confined');
// until 普通囚犯 shows its warning, then until every prisoner is freed
for (let k = 0; k < 20; k++) {
await run(30);
const lp = await page.evaluate(() => window.__pr.b.enemies.find((e) => e.defId === 'enemy_1116_liprr')?.form ?? null);
if (lp === 'warning') break;
}
await shot('warning');
for (let k = 0; k < 60; k++) {
await run(30);
const done = await page.evaluate((keys) => keys.every((key) => window.__pr.b.enemies.find((e) => e.defId === key)?.form === 'liberty'), KEYS);
if (done) break;
}
await run(30);
await shot('freed');
const log = await page.evaluate(() => window.__pr.log);
for (const key of KEYS) {
const rows = log.filter((x) => x.key === key && x.clip);
for (const [i, form] of [[0, null], [1, 'warning'], [2, 'liberty']]) {
// the view draws the sim's state a moment later (the render delay): rows within 1 s of the change are skipped
const first = rows.find((x) => x.form === form)?.t ?? Infinity;
const seen = rows.filter((x) => x.form === form && (form === null || x.t >= first + 1));
assert.ok(seen.length > 0, `${key}: drawn while its sim form is ${form ?? 'confined'}`);
const wrong = seen.filter((x) => setOf(key, x.clip) !== i);
assert.deepEqual(wrong.slice(0, 3), [], `${key} ${form ?? 'confined'}: the ${['grey', 'orange', 'red'][i]} set (${[...new Set(seen.map((x) => x.clip))]})`);
}
}
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
}
});