feedback5: merge W5AA — community reports of 2026-10-06: the 孤岛风云 prisoners leave the gate confined (grey) and change model as their prefabs do, the 4th attack hits freed; a bounty enemy that dies with no operator behind it pays in 联防 too (the helper whose half it fell on); 忍冬's trigger and 枯朽之种's bounty were already fixed (pinned by tests); DESIGN §25.14 after round 18's §25.13; data/enemies.json regenerated offline on the merged code (round 18's model quirks + the prisoners' clips); golden: vs feedback5 exactly W5AA's moves (26 battles, solo-ABYSS-2, coop2-NORMAL-3, coop3-ABYSS-5, solo-HARD-9-boosted), vs W5AA exactly round 18's boss-pool moves

This commit is contained in:
sganggs committed 2026-10-06 22:35:10 +08:00
commit 714af994fc
26 files changed
+1081 -366

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
// test/content/feedback5-prisoners.test.js — community report of 2026-10-06 「囚徒类敌人出门就是已解放的模型」: the 孤岛风云
// prisoners (普通 / 老练 / 拳师 / 强壮囚犯, 重犯, 传奇重犯) follow their official battle prefabs' three modes
// (content/enemies/archetypes.js prisoner): confined, mode R — the warning — before the last confined attack (its
// collar light blinks orange: model form 'warning'), mode L — 【解放】 — before the confinement.times-th attack, which
// already hits freed (PRTS "进行第4次攻击前,切换至解放状态"; buff_template_data enemy_confinement[atk_cnt] /
// enemy_liberty_listener[warning]); model form 'liberty' (render/units.js FORMS; test/render/feedback5-prisoners.test.js).
// Until 0.2.0 the sim freed them after the 4th attack's damage and never told the client: they kept the manifest's
// clips, which for 普通囚犯 / 老练囚犯 were the freed set.
// Run: node --test test/content/feedback5-prisoners.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { unitInfo } from '../../server/sim/snapshot.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const PRISONERS = ['enemy_1116_liprr', 'enemy_1116_liprr_2', 'enemy_1118_lidbox_2', 'enemy_1119_vofsd', 'enemy_1121_lifbos', 'enemy_1121_lifbos_2'];
const tb = (key, k) => E[key].talents.bb[k];
// a wall that blocks and never attacks, DEF 0: a prisoner's hit lands its ATK exactly
const WALL = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e9, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
function arena() {
return makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: { t_wall: WALL } }, kits: { t_wall: () => ({ trait: { noAttack: true } }) },
units: [{ chessId: 't_wall', row: 9, col: 5 }], hooks: ['damaged', 'beforeAttack'], captureNoisy: true,
});
}
const put = (h, key, pos) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0 } });
const fxOf = (h, id) => h.eventsOf('fx').filter((f) => f[4] && f[4].id === id);
for (const key of PRISONERS) {
test(`${key} ${E[key].name}: the warning before attack ${tb(key, 'confinement.times') - 1}, freed before attack ${tb(key, 'confinement.times')} (its hit already freed), model forms 'warning' → 'liberty'`, () => {
const h = arena();
h.step();
const e = put(h, key, [9, 5]);
const times = tb(key, 'confinement.times');
const base = E[key].stats.atk;
const hits = () => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.amount);
assert.equal(e.form ?? null, null, 'spawns confined, on the manifest clips');
assert.equal(unitInfo(e).form, undefined);
// the form each attack starts in: read at its beforeAttack (after the kit's own handler, priority 50)
const formAt = [];
h.b.on('beforeAttack', (c) => { if (c.attacker === e) formAt.push(e.form ?? null); }, { priority: -100 });
const aspdAt = [];
h.b.on('attack', (c) => { if (c.attacker === e) aspdAt.push(e.s.aspd); }, { priority: -100 });
assert.ok(h.runUntil(() => e.stats.attacks >= times + 1, 120), 'attacks');
assert.deepEqual(formAt.slice(0, times + 1), [...Array(times - 2).fill(null), 'warning', 'liberty', 'liberty']);
const dealt = hits();
for (let i = 0; i < times - 1; i++) assert.equal(dealt[i], base, `confined hit ${i + 1}`);
assert.ok(Math.abs(dealt[times - 1] - base * (1 + tb(key, 'liberty.atk'))) < 1e-6, `attack ${times} hits freed: ${dealt[times - 1]}`);
// mode R is still confined: the interval after the warning attack is the confined one
assert.equal(aspdAt[times - 2], 100 + tb(key, 'confinement.attack_speed'), 'the warning attack still confined');
assert.equal(aspdAt[times - 1], 100, 'freed from attack ' + times);
const forms = fxOf(h, e.id).filter((f) => f[4] && Object.hasOwn(f[4], 'form')).map((f) => [f[1], f[4].form]);
assert.deepEqual(forms, [['phase', 'warning'], ['liberate', 'liberty']], 'the two mode changes reach the client');
assert.equal(unitInfo(e).form, 'liberty', 'a view built later draws it freed');
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
});
}
test('重犯 / 传奇重犯: the first liberation frees every prisoner on the field, each switched to its liberty form', () => {
for (const key of ['enemy_1121_lifbos', 'enemy_1121_lifbos_2']) {
const h = arena();
h.step();
const e = put(h, key, [9, 5]);
const other = put(h, 'enemy_1116_liprr', [11, 8]);
assert.ok(h.runUntil(() => e.form === 'liberty', 120));
assert.equal(other.form, 'liberty', `${key}: the other prisoner is drawn freed`);
assert.ok(!other.findBuff('ab:confined'));
assert.ok(fxOf(h, other.id).some((f) => f[1] === 'liberate' && f[4].form === 'liberty'));
}
});
test('拳师囚犯 (PRTS 天赋 "在持有麻痹的情况下,攻击不计数"): an attack 麻痹 interrupts spends no charge', () => {
const key = 'enemy_1118_lidbox_2';
const h = arena();
h.step();
const e = put(h, key, [9, 5]);
h.b.applyStatus(e, 'palsy', { duration: 60, stacks: 2, source: h.unit('t_wall') });
const palsied = e.findBuff('palsy');
assert.ok(palsied, 'palsy on');
const st = e.mem.ab.list.find((a) => a.liberate);
assert.ok(h.runUntil(() => !e.findBuff('palsy'), 60), 'the stacks were spent on interrupted attacks');
assert.equal(st.n, 0, 'no charge spent');
assert.equal(e.stats.attacks, 0);
});