Files
ark-stronghold-protocol/test/render/feedback5-prisoners.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

118 lines
6.2 KiB
JavaScript

// 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);
}
});