- GitHub #140: a player tested 瑕光's S2 in the official mode — she slept 4 enemies and still blocked the next ones, after the skill no more were (「被沉睡的敌人是不占用阻挡数的」). PRTS 异常效果: SLEEPING 沉睡 = 「无法行动+无敌+不可阻挡」, BLOCK_FREE 不可阻挡 = 「无法阻挡/被阻挡,自动解除阻挡」. Until now an enemy that fell asleep while blocked stayed blocked and kept its blocker's slot. - Battle.applyStatus releases an enemy that falls asleep (as for 浮空 / 恐惧 / 不可阻挡), Battle._checkBlock never blocks a sleeper, and ai.js updateEnemy lets a plain-buff sleeper go on its next update. The sleeper stays put; awake, it is blocked again only by a blocker with a free slot, else it walks on. A stun, a freeze or a refused sleep keeps the block; sleeping operators are unchanged. - Readers of the block follow: Battle.blockedTargets (blocked-first targeting, the DEFAULT trigger, hitAllBlocked) drops the sleeper — of the hitSleep attackers only 瑕光 blocks, and her 仁慈 reads her range; a 要塞 holding only a sleeper no longer stalls; “庞贝”'s self-blast clock restarts when a sleep ends its block [ASSUMED]; the snapshot's blocked bit follows. - Tests: new test/sim/feedback4-sleep-unblock.test.js (5 cases, each fails without the change: the slot frees and the next enemy is blocked, the sleeper stays put; awake with no room it walks on; never blocked while asleep, re-blocked on waking beside a free blocker; a plain-buff sleep; the real 瑕光 S2). 1274 targeted tests (blocking, statuses, 小满 / 瑕光 / 缇缇 kits, garrisons, professions, enemies, kits t1–t6), docs-consistency and GOLDEN_FULL pass. - Docs: DESIGN §24.9 (and the §24 intro, §5.5 Blocking, §22.8's release list), SIM.md (Blocking, the `sleep` row, plain buffs), the buffs.js / Battle.js / ai.js / 瑕光 kit comments. - Golden (every moved scenario sleeps blocked enemies; DESIGN §24.9 gives each move): roster-003 / roster-005 (小满 S2: 82 kills instead of 83 / cleared at 136.73 s instead of the 140 s timeout), roster-013 (缇缇 S3: only the blocked count and the snapshot hashes), roster-032 (缇缇 S1: 7 knock-outs instead of 5), roster-038 (elite 缇缇 S2: ends at 197.43 s), roster-039 (瑕光 S2 normal + elite: 41 of 52 killed instead of 47 — the sleepers walk on when they wake), roster-042 (缇缇 S2: ends at 161.17 s instead of 166.97 s), bond-maniShip-high (小满: only the blocked count and the snapshot hashes), coop4-FUNNY-6 (ai_0's 缇缇: layers and boss damage from round 9), coop4-ABYSS-7 (ai_2's 小满: rounds 11–12), coop2-NORMAL-8-serverrun (ai_1's 缇缇: one more leak in round 10, the team eliminated in round 13 instead of winning). fields unchanged.
159 lines
8.9 KiB
JavaScript
159 lines
8.9 KiB
JavaScript
// 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);
|
|
});
|