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.
This commit is contained in:
1 parent
e1ffcd1b07
commit
6372f2892b
18 files changed
+788
-352
No files matched your search
@@ -0,0 +1,162 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// test/render/feedback5-prisoners.test.js — community report of 2026-10-06 「囚徒类敌人出门就是已解放的模型」, client side.
|
||||
// The 孤岛风云 prisoners' skeletons carry three clip sets whose collar light is grey, blinking orange and red; their
|
||||
// official battle prefabs (local client battle/enm_pfb_6.ab: the Graphic component's anim key → clip table and the
|
||||
// modes' replace pairs) draw 【禁锢】 on the grey set (the Spine starts on it), mode R — the warning before the last
|
||||
// confined attack — on the orange set and 【解放】 (mode L) on the red one. The manifest's default roles used to pick
|
||||
// 普通囚犯 / 老练囚犯's unnumbered set, the red one (tools/assets/spine.mjs PREFAB_SPINE_ROLES fixes it), and no FORMS entry
|
||||
// existed, so no prisoner ever changed. The 3D board draws the same Pixi views over its canvas (render/board3d/scene.js),
|
||||
// so one fix covers both boards. Headless fake PIXI (test/render/fakepixi.js); sim side:
|
||||
// test/content/feedback5-prisoners.test.js.
|
||||
// Run: node --test test/render/feedback5-prisoners.test.js
|
||||
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { ANIM } from '../../shared/constants.js';
|
||||
import { fxSpec } from '../../public/js/render/fx/kinds.js';
|
||||
import { resolveRoles } from '../../tools/assets/anim-roles.mjs';
|
||||
import { PREFAB_SPINE_ROLES, applyRoleFix } from '../../tools/assets/spine.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
|
||||
/**
|
||||
* The official prefabs' idle clip per mode (Default = confined / R = warning / L = liberty) and the clip-name suffix of
|
||||
* that mode's Move / Attack / Die (read from battle/enm_pfb_6.ab; the light colours from the skeletons' slot colour
|
||||
* timelines: 普通囚犯 Idle red / Idle2 orange / Idle3 grey, 强壮囚犯 Idle grey / Idle2 orange / Idle3 red).
|
||||
*/
|
||||
const OFFICIAL = {
|
||||
enemy_1116_liprr: ['3', '2', ''],
|
||||
enemy_1116_liprr_2: ['3', '2', ''],
|
||||
enemy_1119_vofsd: ['', '2', '3'],
|
||||
enemy_1118_lidbox_2: ['_grey', '_orange', '_red'],
|
||||
enemy_1121_lifbos: ['_grey', '_orange', '_red'],
|
||||
enemy_1121_lifbos_2: ['_grey', '_orange', '_red'],
|
||||
};
|
||||
|
||||
let fake, UnitView, FORMS;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
function store(id) {
|
||||
const entry = assets.enemies[id].spine;
|
||||
const names = Object.keys(entry.animations || {});
|
||||
return {
|
||||
picture: () => null,
|
||||
image: async () => null,
|
||||
spineEntry: () => entry,
|
||||
spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} },
|
||||
};
|
||||
}
|
||||
async function view(id, info = {}) {
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(id), cam });
|
||||
const v = new UnitView(ctx, { id: 7, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
|
||||
await tick(); await tick();
|
||||
assert.ok(v.actor, `${id}: Spine model built`);
|
||||
return v;
|
||||
}
|
||||
const sample = (anim = 0) => ({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim, vx: 0 });
|
||||
|
||||
test('the manifest draws every prisoner confined first (the official prefab\'s grey set), and FORMS holds its warning and liberty sets', () => {
|
||||
for (const [id, [conf, warn, free]] of Object.entries(OFFICIAL)) {
|
||||
const sp = assets.enemies[id].spine;
|
||||
assert.equal(sp.anims.idle, `Idle${conf}`, `${id} idle`);
|
||||
assert.equal(sp.anims.move.loop, `Move${conf}`, `${id} move`);
|
||||
assert.equal(sp.anims.attack.loop, `Attack${conf}`, `${id} attack`);
|
||||
assert.equal(sp.anims.die, `Die${conf}`, `${id} die`);
|
||||
for (const [form, suf] of [['warning', warn], ['liberty', free]]) {
|
||||
const r = FORMS[id]?.[form]?.roles;
|
||||
assert.ok(r, `${id}: FORMS.${form}`);
|
||||
assert.deepEqual([r.idle, r.move.loop, r.attack.loop, r.die], [`Idle${suf}`, `Move${suf}`, `Attack${suf}`, `Die${suf}`], `${id} ${form}`);
|
||||
for (const n of [r.idle, r.move.loop, r.attack.loop, r.die]) assert.ok(n in sp.animations, `${id}: ${n} in the skeleton`);
|
||||
assert.equal(FORMS[id][form].change, null, 'the prefab switches the set at once');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a prisoner view: confined clips, the orange set on \'warning\', the red set on \'liberty\'; a view built freed starts red', async () => {
|
||||
for (const [id, [conf, warn, free]] of Object.entries(OFFICIAL)) {
|
||||
const v = await view(id);
|
||||
v.sync(sample(), 1);
|
||||
assert.equal(v.actor.current, `Idle${conf}`, `${id} out of the gate`);
|
||||
v.sync(sample(ANIM.MOVE), 2);
|
||||
assert.equal(v.actor.current, `Move${conf}`, `${id} walking confined`);
|
||||
v.setForm('warning');
|
||||
assert.equal(v.actor.current, `Move${warn}`, `${id} warning`);
|
||||
v.setForm('liberty');
|
||||
assert.equal(v.actor.current, `Move${free}`, `${id} freed`);
|
||||
v.sync(sample(), 3);
|
||||
assert.equal(v.actor.current, `Idle${free}`, `${id} freed idle`);
|
||||
const late = await view(id, { form: 'liberty' });
|
||||
late.sync(sample(), 1);
|
||||
assert.equal(late.actor.current, `Idle${free}`, `${id}: a view built after the liberation (UnitInfo form)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the warning fx draws nothing of its own (the blinking collar is the model\'s); the liberation keeps its white blast', () => {
|
||||
assert.equal(fxSpec('phase', { kind: 'warning', form: 'warning' }).a, 'none');
|
||||
assert.equal(fxSpec('liberate', { form: 'liberty' }).a, 'blast');
|
||||
});
|
||||
|
||||
test('tools/assets/spine.mjs PREFAB_SPINE_ROLES: the committed manifest is what fetch-assets resolves for 普通囚犯 / 老练囚犯', () => {
|
||||
for (const id of ['enemy_1116_liprr', 'enemy_1116_liprr_2']) {
|
||||
const sp = assets.enemies[id].spine;
|
||||
const out = applyRoleFix(resolveRoles(Object.keys(sp.animations), { skillIndices: [0], durations: sp.animations }), PREFAB_SPINE_ROLES[id], sp.animations);
|
||||
assert.deepEqual(out.missing, []);
|
||||
assert.deepEqual(out.roles, sp.anims, id);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user