Files
ark-stronghold-protocol/test/sim/feedback5-frozen-reblock.test.js
T
sganggs 9123e76509 feedback5 R19A: a stunned or frozen enemy is blocked by contact where it stands — an operator redeployed beside a frozen 隐匿 enemy blocks it and so reveals it (GitHub #232, @ccyl; the repro and the cause by @kukiC); golden: 52 scenarios move
ai.js updateEnemy returned on `stunned && !e.hidden` before the contact check (Battle._checkBlock), and 冻结 / 沉睡 / 浮空
set `stun` too, so a held enemy was never checked: an operator redeployed or deployed beside it, a slot freeing up or a
push / pull into contact with a stun all waited for the status to end (0.1.4 had it too). PRTS 异常效果: STUNNED / FROZEN
stop the enemy's moves and attacks and hold no 不可阻挡 (BLOCK_FREE), which SLEEPING / LEVITATE do; 术语释义 冻结 names no
block; 作战机制 §碰撞体积与位置识别: an operator deployed onto an enemy blocks it, a standing one included. The held branch now
runs the same check before it returns (no move, no attack, as before): _checkBlock still refuses 沉睡 / 浮空 / 恐惧 /
不可阻挡, an enemy held short of contact is blocked only once it walks in (the same point as without the status), and the
3 s 隐匿 window after a block's end is unchanged. A block the held enemy already holds is still not re-checked (a reborn()
重生 keeps its blocker, as in 0.1.4: noted as open in §25.22.1).

Golden — in each scenario the first divergence is a stunned or frozen enemy blocked during its status by an operator or
summon it touched with a free slot (traced one by one with an instrumented copy): roster-003, -014, -015, -016, -017,
-018, -019, -031, -036, -038, -040, -041, -045, -046, -047; bond-indomShip-high, bond-kazimierzShip-high,
bond-raidShip-low, bond-siracusaShip-low, bond-suntShip-low; boss-boss_3-solo, boss-boss_4-pair, boss-boss_7-solo,
hidden-boss_8-pair, hidden-boss_9-pair, hidden-boss_10-pair, unite-2; matches solo-FUNNY-1, solo-NORMAL-2, solo-HARD-2,
solo-ABYSS-1, coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop2-NORMAL-8-serverrun, coop2-NORMAL-70-diy; diy-003, -004,
-026, -034, -035, -056, -057, -065, -066, -070, -071, -094, -095, -101, -102, -103. standins unchanged.

Tests: test/sim/feedback5-frozen-reblock.test.js (9; the four #232 cases and the 晕眩 / 冻结 capacity cases fail on the
parent). Docs: CHANGELOG 0.2.0 规则(按官方), docs/history/0.2.0.md §25.22.1, docs/SIM.md (§1.2 Blocking, stun, freeze),
docs/design/engine.md, docs/PLAYING.md.
2026-10-07 11:22:27 +08:00

155 lines
8.7 KiB
JavaScript

// GitHub #232 (reporter @ccyl; a minimal engine repro and the cause by @kukiC): a 隐匿 enemy kept frozen beside an
// operator who was knocked out hides again 3 s after the block ends; the operator redeploys on its tile, the enemy in its
// block radius and a slot free, and still cannot block it — nor so reveal it — until the freeze ends; the same with a
// stun. ai.js updateEnemy returned before the contact check (Battle._checkBlock) while the enemy held the `stun` flag
// (冻结 / 沉睡 / 浮空 are folded into it, units.js). Official: PRTS 异常效果 — STUNNED / FROZEN switch the state machine
// (no attack, no skill, enemies cannot move); only 不可阻挡 (BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」, also held by
// SLEEPING and LEVITATE) refuses a block; 术语释义 冻结 「无法移动、攻击及使用技能」 names no block, 沉睡 adds 「无法阻挡/被
// 阻挡」; 作战机制 §碰撞体积与位置识别: an operator deployed onto an enemy blocks it, one standing still included. Now a
// stunned / frozen enemy is blocked by contact where it stands, like a standing enemy: never one short of contact, never
// 沉睡 / 浮空 / 恐惧 / 不可阻挡; the 隐匿 timings are unchanged.
//
// Flat stage: enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { enemyStealthed, canTargetEnemy } from '../../server/sim/targeting.js';
import { STEALTH_RESTORE, BLOCK_RADIUS } from '../../server/sim/constants.js';
const OP = 'test_reblock'; // block 2, comes back 4 s after a knock-out (on its tile: GitHub #60)
const OP1 = 'test_reblock_1'; // block 1
const foe = (key) => enemyRec({ key, hp: 1e7, speed: 1 });
const op = (id, blockCnt) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 4 }, skill: null });
function lane({ unit = OP, enemies = [{ key: 'enemy_a', time: 0 }] } = {}) {
return makeBattle({
defs: { chess: { [OP]: op(OP, 2), [OP1]: op(OP1, 1) }, enemies: { enemy_a: foe('enemy_a'), enemy_b: foe('enemy_b') } },
units: [{ chessId: unit, row: 9, col: 6 }], enemies, timeLimit: 120, content: 'none',
});
}
/** A 隐匿 source with the default restore (STEALTH_RESTORE s after a block ends). */
const hide = (h, e) => h.b.addBuff(e, { key: 'test:stealth', persist: true, flags: { stealth: true } });
const dist = (e, u) => Math.hypot(e.x - u.x, e.y - u.y);
/** Knock the operator out and wait for its redeploy on the same tile. */
function knockOutAndBack(h, u) {
const r = u.tileR, c = u.tileC;
h.b.kill(u);
assert.ok(!u.alive, 'knocked out');
assert.ok(h.runUntil(() => u.alive && u.deployed, 10), 'redeployed after its timer');
assert.deepEqual([u.tileR, u.tileC], [r, c], 'on its own tile');
}
for (const status of ['freeze', 'stun']) {
for (const hidden of [true, false]) {
test(`#232: ${status}${hidden ? ' + 隐匿' : ''} — the operator redeployed beside the held enemy blocks it while it is still held${hidden ? ', which reveals it; the 隐匿 timings stay' : ''}`, () => {
const h = lane();
const u = h.unit(OP);
const A = () => h.enemy('enemy_a');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30), 'blocked at contact');
const e = A();
if (hidden) hide(h, e);
assert.equal(enemyStealthed(e), false, 'blocked: its 隐匿 is off');
assert.ok(h.b.applyStatus(e, status, { duration: 100 }), status);
h.step(3);
assert.equal(e.blockedBy, u, `${status}: the block it holds stays`);
const x0 = e.x, y0 = e.y;
assert.ok(dist(e, u) < BLOCK_RADIUS.ground, 'in the block radius');
// the reporter's steps: the blocker falls, the 3 s window runs out, the enemy hides again
h.b.kill(u);
assert.equal(e.blockedBy, null, 'the knock-out ends the block');
h.run(STEALTH_RESTORE + 0.2);
assert.equal(enemyStealthed(e), hidden, hidden ? 'hidden again 3 s after the block ended' : 'no 隐匿');
assert.ok(h.runUntil(() => u.alive && u.deployed, 10), 'redeployed after its timer');
const blocks = h.hooksOf('blocked').length;
h.step();
assert.equal(e.blockedBy, u, `blocked by the redeployed operator while still ${status === 'freeze' ? 'frozen' : 'stunned'}`);
assert.deepEqual(u.blocking, [e], 'one slot taken');
assert.equal(h.hooksOf('blocked').length, blocks + 1, 'the blocked hook fires');
assert.ok(e.s.flags[status] && e.s.flags.stun, `the ${status} goes on`);
assert.equal(enemyStealthed(e), false, 'revealed by the block');
assert.ok(canTargetEnemy(u, e, u.profile), 'targetable');
assert.ok(e.x === x0 && e.y === y0, 'it never moved');
checkInvariants(h.b);
if (hidden) {
// the window after a block is the usual one: off for STEALTH_RESTORE s from the block's end, then hidden again
h.b.kill(u);
h.run(STEALTH_RESTORE - 0.1);
assert.equal(enemyStealthed(e), false, 'revealed through the window');
h.run(0.2);
assert.equal(enemyStealthed(e), true, 'hidden again once it is over');
}
});
}
}
test('contact only: an enemy stunned on its way, short of the blocker, is not blocked meanwhile; afterwards it walks in and is blocked at the same point as one never stunned', () => {
const blockedAt = (stunAt) => {
const h = lane();
const u = h.unit(OP);
const A = () => h.enemy('enemy_a');
if (stunAt != null) {
assert.ok(h.runUntil(() => A() && A().x <= stunAt, 30));
const e = A(), x = e.x;
assert.ok(dist(e, u) > BLOCK_RADIUS.ground + 0.2, `short of contact (${dist(e, u).toFixed(3)} tile)`);
assert.ok(h.b.applyStatus(e, 'stun', { duration: 3 }));
h.run(2.9);
assert.equal(e.blockedBy, null, 'not blocked while stunned short of contact');
assert.equal(e.x, x, 'it stands');
}
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30), 'blocked');
return { x: A().x, d: dist(A(), u) };
};
const free = blockedAt(null);
const stunned = blockedAt(7.0);
assert.ok(free.d < BLOCK_RADIUS.ground && free.d > BLOCK_RADIUS.ground - 0.05, `stops at contact (${free.d.toFixed(3)} tile)`);
assert.ok(Math.abs(stunned.x - free.x) < 1e-9, `the same contact point (${stunned.x} vs ${free.x})`);
});
for (const status of ['bind', 'stun', 'freeze']) {
test(`capacity: an enemy held by ${status} where it passes a full blocker is blocked the moment the slot frees${status === 'bind' ? ' (束缚 does not stun: the reference)' : ', while still held — like the 束缚 one'}`, () => {
const h = lane({ unit: OP1, enemies: [{ key: 'enemy_a', time: 0 }, { key: 'enemy_b', time: 1 }] });
const u = h.unit(OP1);
const A = () => h.enemy('enemy_a'), B = () => h.enemy('enemy_b');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30), 'A takes the only slot');
assert.ok(h.runUntil(() => B() && B().x <= 6.2, 30), 'B walks on through the full blocker');
const b = B();
assert.equal(b.blockedBy, null, 'no room: B is not blocked');
assert.ok(dist(b, u) < BLOCK_RADIUS.ground, 'B in the block radius');
assert.ok(h.b.applyStatus(b, status, { duration: 20 }), status);
h.step(2);
const x = b.x;
assert.equal(b.blockedBy, null, 'still no room');
h.b.loseHp(A(), 1e12);
h.step(2);
assert.ok(!A().alive, 'A falls: the slot frees');
assert.equal(b.blockedBy, u, `B is blocked where it stands (${status})`);
assert.ok(b.findBuff(status), `the ${status} goes on`);
assert.equal(b.x, x, 'it never moved');
checkInvariants(h.b);
});
}
test('refusals unchanged: 沉睡 / 浮空 / 恐惧 + 冻结 / 不可阻挡 + 晕眩 beside the redeployed operator with room — none is blocked', () => {
const cases = {
sleep: (h, e) => h.b.applyStatus(e, 'sleep', { duration: 60 }),
levitate: (h, e) => h.b.applyStatus(e, 'levitate', { duration: 60 }),
'fear + freeze': (h, e) => h.b.applyStatus(e, 'fear', { duration: 60 }) && h.b.applyStatus(e, 'freeze', { duration: 60 }),
'unblockable + stun': (h, e) => { h.b.addBuff(e, { key: 'test:free', duration: 60, flags: { unblockable: true } }); return h.b.applyStatus(e, 'stun', { duration: 60 }); },
};
for (const [name, put] of Object.entries(cases)) {
const h = lane();
const u = h.unit(OP);
const A = () => h.enemy('enemy_a');
assert.ok(h.runUntil(() => A()?.blockedBy === u, 30), `${name}: blocked first`);
const e = A();
assert.ok(put(h, e), `${name}: applied`);
knockOutAndBack(h, u);
h.step(2);
assert.ok(e.s.flags.stun, `${name}: the enemy is held`);
assert.equal(e.blockedBy, null, `${name}: not blocked`);
assert.deepEqual(u.blocking, [], `${name}: no slot taken`);
checkInvariants(h.b);
}
});