feedback5: merge feedback4 (7ee815e) — W4D ported: a sleeping enemy cannot be blocked and takes no block slot (_checkBlock in server/sim/battle/blocking.js, the applyStatus release in battle/status.js, the plain-buff release re-applied to ai.js updateEnemy's cannot-act branch after WE1's swing cut, 瑕光's comment in kits/ops/chess_char_3_12-blemsh.js, buffs.js; 酒神's 迷狂牢笼 skips a sleeper too); golden: roster-003 / 005 / 013 / 032 / 038 / 039 / 042, bond-maniShip-high, solo-FUNNY-1, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-NORMAL-70-diy, diy-131 / 132 moved

New on feedback4 since the last merge: W4D (bbe59b9, merged as 7ee815e) and the 0.1.4 version commit (30e7ace).
feedback5 keeps its own package.json / package-lock.json / APP_VERSION / README / DEPLOY version lines (0.2.0 sets
its own version later) and takes feedback4's CHANGELOG 0.1.4 entry, with W4D's line in it.

- Port (PRTS 异常效果 SLEEPING 沉睡 = 无法行动+无敌+不可阻挡; GitHub #140; the owner's decision of 2026-10-06):
  `_checkBlock` refuses `flags.sleep` (battle/blocking.js) and `applyStatus` releases an enemy that falls asleep
  (battle/status.js). Battle.js stays feedback5's façade, and tier3.js stays deleted (the 瑕光 comment is in her kits/ops
  file). ai.js: WE1 (89f730d) cuts a swing before enemyAttack on a stun / freeze / sleep. The cannot-act branch is still
  there, and a plain-buff `flags.sleep` still makes the enemy `stun` (units.js), so the plain-buff release stays in that
  branch, after the cut, with its comment rewritten for the new order.
- New on feedback5: 酒神's 迷狂牢笼 (kits/ops/op-phatm2.js cageBlock, O22) duplicated the old three-flag 不可阻挡 check.
  It now skips a sleeper too. No golden scenario spawns a 牢笼 over a sleeper: the corpus is identical with that line
  reverted. 予愿安洁莉娜's S3 helper is unchanged, since canTargetEnemy already drops sleepers.
- Readers that follow without code: blockedTargets (blocked-first targeting, the DEFAULT trigger, hitAllBlocked), the 要塞
  branch of acquireTargets (u.blocking), “庞贝”'s clock (blockedBy), the snapshot's UF.BLOCKED and the client's block
  marker (render/units.js). 死芒 S2 折朽 (自选) lets go a linked enemy that her 悲叹的仆役 held.
- Docs: DESIGN §24.9, its §24 intro entry, the §5.5 Blocking sentence (on feedback5's 阻挡半径倍率 text) and §22.8's
  "(since §24.9)", all as on feedback4; SIM.md as on feedback4 (the plain-buff sentence merged with feedback5's
  noNewBlock / undying text).
- Golden: a probe (counting successful sleeps on a blocked enemy, plain-buff sleeps and 牢笼 spawns over sleepers) ran
  over all 283 scenarios. Every scenario that moved sleeps blocked enemies; the counts are in brackets:
  roster-003 (小满 S2 [5]: same fight, blocked 27 → 29, snapshot hashes); roster-005 (elite 小满 S2 [3]: the 140 s
  timeout with 83 kills and 1 leak, where it used to clear at 135.43 s with 84); roster-013 (缇缇 S3 [11]: same kills,
  blocked 22 → 33); roster-032 (缇缇 S1 [10]: 71 kills instead of 68, 8 leaks instead of 11, 4 knock-outs instead of 8,
  萨尔贡 layers 41 → 35); roster-038 (elite 缇缇 S2 [1]: ends at 197.40 s instead of 197.27 s); roster-039 (瑕光 S2
  normal + elite [31 + 12]: same kills and counted leaks, the 窃贼 gets away, blocked 53 → 87); roster-042 (缇缇 S2 [3]:
  ends at 168.77 s instead of 166.93 s); bond-maniShip-high (小满 [1]: blocked 29 → 30, snapshot hashes); solo-FUNNY-1
  (ai_0's 缇缇 [3]: only ai_0's damage dealt); coop4-FUNNY-6 (ai_0's 缇缇 [15 + 56]: from round 9 ai_0's 精准 / 萨尔贡
  layers and boss damage move, ai_1 loses no LP from round 11, team LP 124 → 130); coop4-ABYSS-7 (ai_2's 小满 [18]: ai_2
  one kill fewer, ai_0 one more LP lost in round 9); coop2-NORMAL-8-serverrun (ai_1's 缇缇 [83]: only ai_1's damage dealt;
  WE1 had already turned this match into a loss); coop2-NORMAL-70-diy (the 自选 seat h_0's 缇缇 [171], ai_0's 缇缇 [74]
  and 小满 [19 + 21]: h_0's layers move from round 4, then its AI 托管 buys differently (boards from round 7); ai_0 246
  kills instead of 233, same end); diy-131 (死芒 S2 at tiers 5 and 6 [1 + 1]: snapshot hashes only); diy-132 (elite
  死芒 S2 [2]: ends at 199.50 s instead of 198.13 s, same kills). One scenario sleeps a blocked enemy and did not move:
  coop2-HARD-4 [1]. ai_1's 小满 lets go the enemy it holds; another enemy takes the slot; the sleeper wakes with no room
  and is blocked further on by 2_16_a. The match digest has no snapshot hashes, and its kills, leaks, damage and funds
  are identical. fields and standins did not move. Most outcomes differ from feedback4's list because feedback5's
  baselines differ (WE1's damage frame, etc.).
- Tests: test/sim/feedback4-sleep-unblock.test.js (from feedback4) passes 5/5 on the split layout. Every case fails with
  the three sim hunks reverted, and case 3 (plain buff) fails with only the ai.js release reverted. Targeted sim tests
  pass 184/184 (blocking, block targets, enemy swing, feedback2-enemy-attack-stand, combat / statuses, robustness,
  stun SP, professions, healers, fidelity …). Content tests plus kits_layout and docs-consistency pass 658/658 (kits
  t1t2 / t3 / t5 and alt t2 / t3 / t5 for 小满 / 瑕光 / 缇缇, 酒神, 死芒, garrisons, bosses, items). Stealth / liftoff /
  enemies pass 36/36. npm run golden: all 283 match. GOLDEN_FULL=1 test/golden.test.js passes 9/9 (no stored engine
  error). lint 0 errors, check:imports and typecheck clean.
This commit is contained in:
sganggs committed 2026-10-06 17:49:10 +08:00
commit ac6454d171
14 files changed
+558 -261

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
// GitHub #140 (a player who tested the official mode; the owner's decision of 2026-10-06): 瑕光's S2 put 4 enemies to
// sleep and she could still block the next one; once the skill ended they could no longer be blocked. PRTS 异常效果:
// SLEEPING 沉睡 = 「无法行动+无敌+不可阻挡」, and BLOCK_FREE 不可阻挡 = 「无法阻挡/被阻挡,自动解除阻挡」. So a sleeping enemy
// is let go by its blocker (the slot frees for the next enemy), is never blocked while asleep, stays where it is (it
// cannot move), and when it wakes it is blocked again only by a blocker with a free slot — else it walks on, like any
// unblocked enemy (DESIGN §24.9). Sleeping operators already released what they block (unchanged).
//
// Flat stage: enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns (0.5 tile/s).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const OP = 'test_sleep_blocker';
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
/** A block-1 operator at (9,6) that never attacks; enemies A and B (B immune to sleep where a test needs it). */
function lane(enemies, enemyDefs = {}) {
return makeBattle({
defs: {
chess: { [OP]: chessRec({ id: OP, stats: { atk: 0, blockCnt: 1, maxHp: 1e6, respawnTime: 999 }, skill: null }) },
enemies: { enemy_a: foe('enemy_a'), enemy_b: foe('enemy_b'), ...enemyDefs },
},
units: [{ chessId: OP, row: 9, col: 6 }], enemies, timeLimit: 120, content: 'none',
});
}
const kill = (h, e) => h.b.loseHp(e, 1e12);
test('沉睡 frees the block slot: the sleeper is let go at once and stays put, the next enemy is blocked; awake with no room it walks on', () => {
const h = lane([{ key: 'enemy_a', time: 0 }, { key: 'enemy_b', time: 1 }]);
const u = h.unit(OP);
const A = () => h.enemy('enemy_a'), B = () => h.enemy('enemy_b');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30), 'A is blocked');
const ax = A().x, ay = A().y;
assert.ok(h.b.applyStatus(A(), 'sleep', { duration: 6, source: u }), 'A falls asleep');
assert.equal(A().blockedBy, null, 'released at once (不可阻挡)');
assert.deepEqual(u.blocking, [], 'the slot is free');
assert.ok(h.runUntil(() => B()?.blockedBy === u, 10), 'B is blocked in the freed slot');
assert.ok(A().s.flags.sleep, 'A still asleep');
assert.equal(A().blockedBy, null, 'the sleeper is not blocked and takes no slot');
assert.deepEqual(u.blocking, [B()]);
assert.ok(A().x === ax && A().y === ay, 'the sleeper stays where it fell asleep');
checkInvariants(h.b);
// A wakes while B holds the only slot: it is not blocked and walks on past the operator
assert.ok(h.runUntil(() => !A().s.flags.sleep, 10), 'A wakes');
h.step();
assert.equal(A().blockedBy, null, 'no room: A is not blocked');
assert.equal(B().blockedBy, u, 'B keeps the slot');
h.run(2);
assert.ok(A().x < ax - 0.5, `A walked on (x ${A().x.toFixed(2)} from ${ax.toFixed(2)})`);
assert.equal(A().blockedBy, null);
assert.equal(B().blockedBy, u);
checkInvariants(h.b);
});
test('沉睡: never blocked while asleep, even beside a blocker with room — blocked again where it stands once it wakes', () => {
const h = lane([{ key: 'enemy_a', time: 0 }, { key: 'enemy_b', time: 1 }]);
const u = h.unit(OP);
const A = () => h.enemy('enemy_a'), B = () => h.enemy('enemy_b');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30));
const ax = A().x;
assert.ok(h.b.applyStatus(A(), 'sleep', { duration: 5, source: u }));
assert.ok(h.runUntil(() => B()?.blockedBy === u, 10), 'B takes the slot');
kill(h, B());
h.step(2);
assert.ok(!B().alive && u.blocking.length === 0, 'the slot is free again');
h.run(1);
assert.ok(A().s.flags.sleep, 'A still asleep');
assert.equal(A().blockedBy, null, 'a sleeper is not blocked although the operator has room');
assert.equal(u.blocking.length, 0);
assert.ok(h.runUntil(() => !A().s.flags.sleep, 10), 'A wakes');
h.step();
assert.equal(A().blockedBy, u, 'blocked again the moment it wakes (a free slot, still in contact)');
assert.equal(A().x, ax, 'it never moved');
h.run(2);
assert.equal(A().blockedBy, u);
assert.equal(A().x, ax);
checkInvariants(h.b);
});
test('沉睡 through a plain buff (flags.sleep) is let go on the enemy\'s next update; 晕眩 / a refused sleep keep the block', () => {
// a custom sleep buff: no applyStatus release — ai.js updateEnemy lets it go on its next update
const h = lane([{ key: 'enemy_a', time: 0 }]);
const u = h.unit(OP);
const A = () => h.enemy('enemy_a');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30));
h.b.addBuff(A(), { key: 'test:sleep', duration: 2, flags: { sleep: true } });
h.step();
assert.equal(A().blockedBy, null, 'released');
assert.deepEqual(u.blocking, []);
h.run(2.2);
assert.ok(!A().s.flags.sleep && A().blockedBy === u, 'awake beside a free blocker: blocked again');
checkInvariants(h.b);
// 晕眩 is not 不可阻挡: a stunned enemy stays blocked; a sleep-immune enemy refuses the sleep and stays blocked
const h2 = lane([{ key: 'enemy_a', time: 0 }, { key: 'enemy_x', time: 8 }], { enemy_x: foe('enemy_x', { immunities: { sleepImmune: true } }) });
const u2 = h2.unit(OP);
const A2 = () => h2.enemy('enemy_a'), X = () => h2.enemy('enemy_x');
assert.ok(h2.runUntil(() => A2()?.blockedBy === u2, 30));
assert.ok(h2.b.applyStatus(A2(), 'stun', { duration: 2 }));
h2.step(3);
assert.equal(A2().blockedBy, u2, 'stunned: still blocked');
kill(h2, A2());
assert.ok(h2.runUntil(() => X()?.blockedBy === u2, 30));
assert.equal(h2.b.applyStatus(X(), 'sleep', { duration: 3 }), false, 'immune');
h2.step(3);
assert.equal(X().blockedBy, u2, 'no sleep, still blocked');
checkInvariants(h2.b);
});
test('沉睡: the blocker\'s blocked-target list (Battle.blockedTargets — blocked-first targeting, the DEFAULT trigger, hitAllBlocked) drops the sleeper and takes the next enemy', () => {
const h = lane([{ key: 'enemy_a', time: 0 }, { key: 'enemy_b', time: 1 }]);
const u = h.unit(OP);
const A = () => h.enemy('enemy_a'), B = () => h.enemy('enemy_b');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30));
h.b.applyStatus(A(), 'sleep', { duration: 6 });
assert.deepEqual(h.b.blockedTargets(u, u.profile), [], 'no blocked target while A sleeps and B is not there yet');
assert.ok(h.runUntil(() => B()?.blockedBy === u, 10));
assert.deepEqual(h.b.blockedTargets(u, u.profile), [B()], 'B is the blocked target');
});
test('#140: 瑕光 S2 puts the 3 enemies she blocks to sleep and still blocks the next 3; when S2 ends the sleepers wake with no room and walk on', () => {
const ds = getDefaultSource();
const id = 'chess_char_3_12_a';
const rec = ds.rawChess(id);
assert.equal(rec.stats.blockCnt, 3, 'block 3');
const skillIndex = rec.skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
// (a MANUAL 重装 skill: cast by a blocked enemy's hit — TAKE_DAMAGE)
defs: { enemies: { enemy_x: foe('enemy_x', { atk: 100, bat: 1 }), enemy_y: foe('enemy_y', { atk: 100, bat: 1 }) } }, timeLimit: 90,
units: [{ chessId: id, row: 9, col: 8, skillIndex }],
enemies: [{ key: 'enemy_x', time: 0, count: 3, interval: 0.5 }, { key: 'enemy_y', time: 6, count: 3, interval: 0.5 }],
});
const u = h.unit(id);
const xs = () => h.b.enemies.filter((e) => e.defId === 'enemy_x');
const ys = () => h.b.enemies.filter((e) => e.defId === 'enemy_y');
assert.ok(h.runUntil(() => xs().length === 3 && xs().every((e) => e.blockedBy === u), 30), 'she blocks the first three');
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5), 'S2 cast');
h.step();
const pos = new Map(xs().map((e) => [e, e.x]));
assert.ok(xs().every((e) => e.s.flags.sleep && !e.blockedBy), 'the three sleep and are let go');
assert.equal(u.blocking.length, 0, 'all three slots free');
// the next three walk in during S2 and are blocked
assert.ok(h.runUntil(() => ys().length === 3 && ys().every((e) => e.blockedBy === u), 12), 'she blocks the next three during S2');
assert.ok(u.skill.active, 'S2 still running');
assert.ok(xs().every((e) => e.s.flags.sleep && !e.blockedBy && e.x === pos.get(e)), 'the sleepers stay put, unblocked');
checkInvariants(h.b);
// S2 ends: the sleepers wake beside her, her three slots full — not blocked, they walk on (the reporter's "技能结束后
// 则无法被阻挡")
assert.ok(h.runUntil(() => !u.skill.active, 12), 'S2 ends');
h.step();
assert.ok(xs().every((e) => !e.s.flags.sleep), 'awake');
assert.ok(xs().every((e) => !e.blockedBy), 'no room: none of them is blocked');
assert.deepEqual(new Set(u.blocking), new Set(ys()), 'she keeps the three she holds');
h.run(3);
for (const e of xs()) assert.ok(e.x < pos.get(e) - 1, `walked on (x ${e.x.toFixed(2)})`);
checkInvariants(h.b);
});