feedback5: merge CB4 — community reports (enemies / bosses): a 突袭 member lands only on its own player's board (never a boss field's hand / 临时整备区 rows nor the other half of a 联防 or boss field), a silenced 萨卡兹枯朽战士 leaves no 污染秽蚀, 萨卡兹穿刺手's acceleration ends with a stun or 束缚 and its first hit after a block adds move speed × 600 as its own instance, 自制投石机 ignores its block when it picks a target (no attack on the 排气格栅's hidden blocker), the briefing types' 7–9 waves, 新硎's 断刃, a freed prisoner in 联防, 鸭爵's 0–2 swaps and the moving leaders' routes pinned, four reports not reproduced; §25.18; golden: vs feedback5 CB4's own rules — roster-016, boss-boss_2/3/4/5/6-pair, hidden-boss_8/10-pair, standin-04/06/08, diy-028, diy-124, matches solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, coop2-HARD-4, coop4-ABYSS-7, coop2-NORMAL-8-serverrun; vs CB4 exactly what feedback5 changed since its base (CB2's roster 13 / bonds 7 / fields 6, CB5's 18 bot matches)

Merge fix: test/sim/facing-invariance.test.js — the 突袭 rotation check put its enemy on the board's top row (9, 9),
so the facing-RIGHT landing tile it expected, (8, 8), lies above the player's board, where CB4's onOwnBoard rule (the
official 再部署 goes where the player deploys; community reports 40 / 16.3) now refuses it; the member landed on
(10, 8). The enemy stands at (10, 6), inside rows 9–12 / cols 2–10, so all four diagonal tiles are candidates in every
direction and the test checks the facing tie-break as before. It failed on fb5-cb4 itself (the stream did not run the
file). The §25 intro's stream lines joined into one sentence again.

Golden moves outside CB4's commit list are its rules on feedback5's paths (instrumented): solo-HARD-1 and solo-ABYSS-1
now have silenced 枯朽战士 deaths (8 / 15) and 突袭 candidates the own-board filter refuses; coop2-ABYSS-10-boosted,
coop2-NORMAL-3 and coop2-NORMAL-14-standins (in CB4's list) have no silenced 枯朽战士 death on this path, and their
digests are identical with the own-board filter switched off (the refused tiles were never the chosen ones).

Gate: non-browser suites 4968 / 4968, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok, docs-consistency + docs-paths 31 / 31.
This commit is contained in:
sganggs committed 2026-10-07 03:21:57 +08:00
commit 6ed3aed335
23 files changed
+1364 -598

No files matched your search

+92 -11
View File
@@ -70,12 +70,17 @@ test('stats-only enemies attach no ability and fight with their data stats', ()
}
});
test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy)', () => {
// Abilities the client makes silenceable although their handbook line is NORMAL (the battle prefab's buff template checks
// SILENCED): 萨卡兹枯朽战士 / 组长's PollutedDie = template projectile_on_killed, the 死亡爆炸 template of 高能源石虫's
// SILENCE-flagged blast too (community report of 2026-10-06, item 10; DESIGN §25.18.2)
const SILENCE_BY_TEMPLATE = new Set(['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']);
test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy), plus the ones whose client template checks it', () => {
const h = arena();
h.step();
for (const key of [...Object.keys(KITS), ...Object.keys(BOSS_KITS)]) {
const e = put(h, key, [10, 7]);
const data = E[key].abilities.some((a) => a.format === 'SILENCE');
const data = E[key].abilities.some((a) => a.format === 'SILENCE') || SILENCE_BY_TEMPLATE.has(key);
const kit = !!(e.mem.ab && e.mem.ab.list.some((a) => a && (a.sil || a.silAware)));
assert.equal(kit, data, `${nm(key)}: ${E[key].abilities.map((a) => `[${a.format}]${a.text}`).join(' / ')}`);
h.b.kill(e, null);
@@ -674,6 +679,30 @@ for (const key of ['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']) {
h.run(3.05);
approx(h.unit('t_wall').stats.taken, 3 * tb(key, 'PollutedDie.polluted_damage_low'));
});
// PRTS 特殊机制 死亡爆炸 "默认可沉默…若不处于沉默状态,将会…释放"; client template projectile_on_killed: CheckAbnormalFlag
// SILENCED (unset) before EmitProjectile (community report of 2026-10-06, item 10: a silenced one still poisoned)
test(`${nm(key)}: silenced when it dies, it releases no 污染秽蚀; a silence that ran out does not stop it`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
h.step();
const e = put(h, key, [10, 7]);
h.b.applyStatus(e, 'silence', { duration: 30 });
h.step();
assert.ok(e.s.flags.silence);
killed(h, e, null);
h.run(3.05);
assert.equal(h.unit('t_wall').stats.taken, 0);
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'zone' && x[4] && x[4].kind === 'pollution').length, 0, 'no zone');
const h2 = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
h2.step();
const e2 = put(h2, key, [10, 7]);
h2.b.applyStatus(e2, 'silence', { duration: 1 });
h2.run(1.5);
assert.ok(!e2.s.flags.silence);
killed(h2, e2, null);
h2.run(3.05);
approx(h2.unit('t_wall').stats.taken, 3 * tb(key, 'PollutedDie.polluted_damage_low'));
});
}
for (const key of ['enemy_1270_nhstlk', 'enemy_1270_nhstlk_2']) {
@@ -1042,16 +1071,68 @@ for (const key of ['enemy_1116_liprr', 'enemy_1116_liprr_2', 'enemy_1118_lidbox_
});
}
test(`${nm('enemy_1072_dlancer')}: accelerates while walking; the first hit after being blocked scales with the build-up`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 3 }], captureNoisy: true, hooks: ['damaged'] });
// PRTS 天赋 + the client's templates dlancer_t_listener[a/b/c], dlancer_t[trigger], dlancer_t_atk (DESIGN §25.18): every
// 0.1 s 晕眩 / 束缚 end the acceleration (its layers with it), not blocked starts it; a layer of move speed +50 % every
// 0.5 s, at most 25 tries, blocked or not; a hit while it accelerates adds 当前移动速度 × 600 phys (its own instance) and
// ends it (community report of 2026-10-06, item 36: until 0.2.0 a stun kept the speed, and the hit was multiplied)
const DL = 'enemy_1072_dlancer';
const dlBase = () => E[DL].stats.moveSpeed;
const dlMul = (n) => 1 + tb(DL, 'rush.dlancer_t[trigger].move_speed') * n;
const DL_ROUTE = { motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] };
test(`${nm(DL)}: a layer of move speed +${tb(DL, 'rush.dlancer_t[trigger].move_speed') * 100} % every ${tb(DL, 'rush.dlancer_t[trigger].interval')} s while it walks, at most ${tb(DL, 'rush.dlancer_t[trigger].trig_cnt')}`, () => {
const h = arena();
h.step();
const e = put(h, 'enemy_1072_dlancer', [9, 10], { move: true });
h.run(3);
assert.ok(e.s.moveSpeed > E.enemy_1072_dlancer.stats.moveSpeed * 2);
h.runUntil(() => e.stats.attacks >= 2, 120);
const hits = h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.amount);
assert.ok(hits[0] > hits[1] * 1.5, `${hits[0]} vs ${hits[1]}`);
approx(hits[1], e.s.atk);
const e = h.spawn(DL, { route: DL_ROUTE });
h.run(0.45);
approx(e.s.moveSpeed, dlBase(), 1e-9, 'the first layer 0.5 s after the start');
h.run(2.6);
approx(e.s.moveSpeed, dlBase() * dlMul(6), 1e-9, '6 layers after 3 s');
// the cap: a pinned one (speed ×0, never blocked) keeps trying — 25 tries, then the layers stay
const p = put(h, DL, [11, 12]);
h.run(14);
const max = tb(DL, 'rush.dlancer_t[trigger].trig_cnt');
assert.equal(p.mem.ab.list[0].n, max);
approx(p.findBuff('ab:rush').mods.moveMul, dlMul(max), 1e-9, 'capped at 25 layers');
});
test(`${nm(DL)}: a stun or a 束缚 ends the acceleration — its speed back to the base, building up again only afterwards; a slow, a freeze or a sleep does not`, () => {
for (const [status, value, resets] of [['stun', undefined, true], ['bind', undefined, true], ['slow', 0.5, false], ['freeze', undefined, false], ['sleep', undefined, false]]) {
const h = arena();
h.step();
const e = h.spawn(DL, { route: DL_ROUTE });
h.run(3.05);
approx(e.s.moveSpeed, dlBase() * dlMul(6), 1e-9, status);
h.b.applyStatus(e, status, { duration: 2, value });
h.run(2.2);
if (resets) {
approx(e.s.moveSpeed, dlBase(), 1e-9, `${status}: reset`);
h.run(1.05);
approx(e.s.moveSpeed, dlBase() * dlMul(2), 1e-9, `${status}: a fresh build-up`);
} else assert.ok(e.s.moveSpeed > dlBase() * dlMul(6) - 1e-9, `${status}: kept (${e.s.moveSpeed})`);
}
});
test(`${nm(DL)}: blocked, its first hit adds 当前移动速度 × ${tb(DL, 'firstattack.atk_scale')} physical damage (a second instance) and ends the acceleration; a stun between the block and the hit takes it away`, () => {
const hitsOf = (stunAtBlock) => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 4 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const e = h.spawn(DL, { route: DL_ROUTE });
let speedAtHit = null;
h.b.on('damaged', (c) => { if (c.source === e && c.dmg.isAttack && speedAtHit === null) speedAtHit = e.s.moveSpeed; }, { priority: 100 });
assert.ok(h.runUntil(() => e.blockedBy, 120), 'blocked');
if (stunAtBlock) h.b.applyStatus(e, 'stun', { duration: 1 });
assert.ok(h.runUntil(() => e.stats.attacks >= 2, 60));
const own = h.hooksOf('damaged').filter((c) => c.source === e);
return { e, speedAtHit, attacks: own.filter((c) => c.dmg.isAttack).map((c) => c.amount), extra: own.filter((c) => !c.dmg.isAttack).map((c) => c.amount) };
};
const r = hitsOf(false);
assert.ok(r.speedAtHit > dlBase() * 4, `built up: ${r.speedAtHit}`);
assert.deepEqual(r.attacks.map((a) => Math.round(a)), [r.e.s.atk, r.e.s.atk], 'the attacks themselves are plain');
assert.equal(r.extra.length, 1, 'one extra instance, on the first hit only');
approx(r.extra[0], r.speedAtHit * tb(DL, 'firstattack.atk_scale'), 1e-6, 'speed × 600 (DEF 0)');
approx(r.e.s.moveSpeed, dlBase(), 1e-9, 'the hit ended the acceleration');
const s = hitsOf(true);
assert.deepEqual(s.extra, [], 'stunned after the block: no extra damage');
});
test(`${nm('enemy_1320_wdrrl_2')}: only blockers with block ≥3; first attack splashes ATK×${tb('enemy_1320_wdrrl_2', 'AOEAttack.atk_scale')} around the target`, () => {