Files
ark-stronghold-protocol/test/sim/feedback5-attack-clip-rest.test.js
T
sganggs ef25c03525 feedback5 R19E: an enemy finishes its attack clip after its block ends — the 鸭爵 swap's 流泪小子 no longer walks on the tick its strike stuns its blocker (community report of 2026-10-07 「哭狗瞬间隐匿没有后摇」, GitHub #214 by @FrogThai); golden: 216 scenarios move
ai.js enemyAttack set the post-strike stand only for an unblocked ranged enemy (`!e.blockedBy && radius > 0`), so a
melee enemy released by its own strike walked on that very tick. 流泪小子 (enemy_2034_sythef_2, the strategy's version;
「泪眼汪汪」 / 「哭狗」 in the reports) stuns its blocker with every hit, the stunned blocker lets it go and its 隐匿 is
back 0 s later: on 0255afd it walked off the tick of each strike (four non-attacking walls in a lane: 8 stuns, 19.8 s
隐匿 / 9.0 s revealed, leaked at 28.9 s). PRTS 状态机: an enemy's ATTACK state 「每帧检查异常状态…攻击结束后回退到MOVE状态」
and COMBAT 「逻辑同ATTACK」 — only a character's COMBAT 「检测到不存在阻挡对象时强行切回IDLE状态」 — so an enemy finishes its
attack when its blocker goes. Now every enemy stands for the rest of its clip after a strike (attackStand; a blocked one
never walks, so it shows only when the block ends meanwhile: its blocker stunned or knocked out by that strike, or
gone); a stun / freeze / sleep / 浮空, leaving the field and, new, a displacement (失衡, Battle.displace) end the stand.
流泪小子 now stands 0.8 s (its 1.567 s clip, strike at 0.767), 隐匿, and a blocker whose stun ended often takes it over
again: the same lane 13 stuns, 29.1 s 隐匿 / 12.8 s revealed, leaked at 42.0 s.

Checked and official, unchanged: a stunned operator blocks nothing (gamedata_const ba.stun 「无法移动、阻挡、攻击及使用技能」,
PRTS 异常效果 STUNNED 「无法攻击、释放技能、阻挡敌人类单位」 — the keeps-blocking hypothesis is wrong); 隐匿 back 0 s after a
block (PRTS 流泪小子); moveSpeed 0.8 × the level's moveMultiplier 0.5, interval 2.0 s, stun 3.5 s, MELEE (enemy_database;
PRTS 盟约记录 鸭爵 备注: 4500 HP, 3.5 s stun, 隐匿 kept); the cooldown runs on the walk, so a new blocker sees the swing at
once and the strike after the 0.767 s wind-up; a bot teammate's 鸭爵 swaps into a human's battles, the Final Assault and
the Hidden Core too (「你和队友遭遇的部分敌人」), on the replaced enemy's route.

Golden — traced in lockstep against a copy of the parent: in each of the 199 battle scenarios the first differing tick is
an enemy standing out its clip after a strike where the parent walked on; every moved match has such stands in an
earlier or the same round. roster 41 of 49 (all but -003, -005, -009, -018, -019, -025, -035, -049); bonds 21
(deputShip-low, emptyShip-low / -high, indomShip-low / -high, investShip-low, kazimierzShip-low / -high,
kjeragShip-high, lateranoShip-high, miraShip-high, raidShip-low, sargonShip-high, siracusaShip-low, soloShip-low /
-high, suntShip-low / -high, swiftShip-high, visiShip-low, yanShip-low); fields 13 (all but boss-boss_1-solo,
boss-boss_2-pair / -solo, boss-boss_6-solo, boss-boss_7-pair / -solo, hidden-boss_8-solo, hidden-boss_10-solo,
unite-1); matches 17 (all but solo-HARD-9-boosted); standins 10 of 10; diy 114 of 138 (all but -011, -012, -018, -020,
-025, -027, -029, -031, -033, -047, -053, -077, -080 … -082, -086 … -088, -091 … -093, -098, -108, -123). The
displacement rule alone moves one roster scenario.

Tests: test/sim/feedback5-attack-clip-rest.test.js (5; the four behaviour tests fail on the parent, the data pin passes
on both); test/sim (745), test/content + feedback1-ducklord (2030), test/match (593) pass. Docs: CHANGELOG 0.2.0 规则
(按官方) and 核实后不是问题, docs/history/0.2.0.md §25.22.6, docs/SIM.md, design/engine.md, DATA.md.
2026-10-07 12:56:12 +08:00

143 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/sim/feedback5-attack-clip-rest.test.js — community report of 2026-10-07 on the 鸭爵 strategy's 流泪小子 (GitHub #214's
// 「泪眼汪汪」): 「哭狗一摸干员、干员直接晕、哭狗瞬间隐匿没有后摇马上就到下一格干员的脸上秒抬手秒晕」.
// Official, and kept: its strike stuns the blocker for 3.5 s (enemy_database Combat.attack@stun), a stunned operator
// blocks nothing (gamedata_const ba.stun 「无法移动、阻挡、攻击及使用技能」; PRTS 异常效果 STUNNED 「无法攻击、释放技能、阻挡敌人
// 类单位」), its 隐匿 is back 0 s after the block ends (PRTS 流泪小子 「隐匿(解除阻挡0秒后恢复)」), and its cooldown runs on the
// walk, so its first swing at the next operator starts at once and strikes 0.767 s later (the Attack clip's OnAttack).
// The deviation (fixed in 0.2.0): it walked on the very tick of its strike. PRTS 状态机: an enemy's ATTACK / COMBAT state
// checks only 异常状态 every frame and "攻击结束后回退到MOVE状态" (only a character's COMBAT drops when its blocker is gone),
// so any enemy whose block ends after its strike — the strike stunned or knocked out its blocker, the blocker left —
// stands for the rest of its attack clip first (server/sim/ai.js enemyAttack / attackStand); a displacement (失衡) ends
// that stand (Battle.displace). The copies here are spawned without the band's 'duck' tag: the official rules throughout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { TICK } from '../../server/sim/constants.js';
import { attackStand, attackWindup } from '../../server/sim/ai.js';
import { enemyStealthed } from '../../server/sim/targeting.js';
const E = JSON.parse(readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const DOG = 'enemy_2034_sythef_2'; // 流泪小子 (the 鸭爵 strategy's version: 4500 HP, 3.5 s stun)
const ENVOY = 'enemy_1438_dspred'; // 复核洋流使者: a plain melee enemy (stats only) with a 2.133 s clip, strike at 0.733 s
const WALL = (id, hp = 1e7) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: hp, def: 0, blockCnt: 1 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
function arena(units) {
return makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: { w1: WALL('w1'), w2: WALL('w2'), weak: WALL('weak', 100) } }, kits: { w1: NOATK, w2: NOATK, weak: NOATK },
units, hooks: ['statusApplied', 'attack', 'death'],
});
}
/** Walk an enemy down route 0 (row 9, from the gate at col 10 towards col 2) until its first strike; returns its time. */
function untilStrike(h, e) {
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 60), 'it strikes');
return h.b.time;
}
test('流泪小子 numbers (enemy_database / its Spine clip): 0.8 move speed, 2.0 s interval, 3.5 s stun, strike 0.767 s into its 1.567 s Attack clip, 隐匿 back 0 s after a block', () => {
const d = E[DOG];
assert.equal(d.stats.moveSpeed, 0.8);
assert.equal(d.stats.bat, 2);
assert.equal(d.applyWay, 'MELEE');
assert.equal(d.talents.bb['Combat.attack@stun'], 3.5);
assert.deepEqual(d.attackAnim, { clip: 'Attack', dur: 1.567, hit: 0.767 });
const h = arena([]);
h.step();
const e = h.spawn(DOG, { routeIndex: 0 });
assert.ok(Math.abs(attackWindup(e) - 0.767) < 1e-9);
assert.ok(Math.abs(attackStand(e).rest - 0.8) < 1e-9);
assert.equal(e.findBuff('ab:stealth').data.stealthRestore, 0);
});
test('流泪小子: its strike stuns the blocker, which lets it go at once (晕眩 blocks nothing) and its 隐匿 is back at once — then it stands for the rest of its clip (0.8 s) before it walks on', () => {
const h = arena([{ chessId: 'w1', row: 9, col: 6 }]);
h.step();
const wall = h.unit('w1');
const e = h.spawn(DOG, { routeIndex: 0 });
assert.ok(h.runUntil(() => e.blockedBy === wall, 30), 'blocked by the wall');
assert.equal(enemyStealthed(e), false, 'revealed while blocked');
const t0 = h.b.time;
const ts = untilStrike(h, e);
// its cooldown was ready: the swing starts on contact and strikes after the 0.767 s wind-up
assert.ok(Math.abs(ts - t0 - attackWindup(e)) < 2 * TICK + 1e-9, `strike ${ts - t0} s after the contact`);
const stun = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === wall);
assert.ok(stun && Math.abs(stun.duration - 3.5) < 1e-9, 'the wall is stunned for 3.5 s');
assert.equal(e.blockedBy, null, 'a stunned operator blocks nothing');
assert.equal(wall.blocking.length, 0);
assert.equal(enemyStealthed(e), true, '隐匿 0 s after the block ends');
const x0 = e.x;
h.run(attackStand(e).rest - 2 * TICK);
assert.equal(e.x, x0, 'it stands for the rest of its Attack clip (pre-0.2.0: it walked on at once)');
assert.equal(e.moving, false);
h.run(0.2);
assert.ok(e.x < x0 - 0.02 && e.moving, `then it walks on (${x0} → ${e.x})`);
checkInvariants(h.b);
});
test('流泪小子 down a lane of two operators: the second one is reached a stand later, struck 0.767 s after the contact, and the first one takes it over again once its stun ends (contact rule)', () => {
const h = arena([{ chessId: 'w1', row: 9, col: 7 }, { chessId: 'w2', row: 9, col: 6 }]);
h.step();
const [w1, w2] = [h.unit('w1'), h.unit('w2')];
const e = h.spawn(DOG, { routeIndex: 0 });
const ts1 = untilStrike(h, e);
assert.ok(h.runUntil(() => e.blockedBy === w2, 10), 'the second operator blocks it');
const tb2 = h.b.time;
// 0.8 s stand + 1 tile at 0.4 tiles/s (moveSpeed 0.8 × the level's 0.5) between the two contacts
assert.ok(tb2 - ts1 > attackStand(e).rest + 2.4, `${(tb2 - ts1).toFixed(3)} s from the first strike to the second contact`);
assert.ok(h.runUntil(() => e.stats.attacks >= 2, 5));
assert.ok(Math.abs(h.b.time - tb2 - attackWindup(e)) < 2 * TICK + 1e-9, 'its cooldown ran on the walk: struck after the wind-up');
assert.ok(w2.s.flags.stun);
// it stands 0.8 s inside w1's block radius (0.7071): w1, whose 3.5 s stun is over by then, blocks it again
assert.ok(h.runUntil(() => e.blockedBy === w1, 2), 'the first operator takes it over again');
checkInvariants(h.b);
});
test('a plain melee enemy whose strike knocks out its blocker stands for the rest of its clip, then walks on', () => {
const h = arena([{ chessId: 'weak', row: 9, col: 6 }]);
h.step();
const e = h.spawn(ENVOY, { routeIndex: 0 });
const rest = attackStand(e).rest;
assert.ok(Math.abs(rest - (2.133 - 0.733)) < 1e-9);
untilStrike(h, e);
assert.ok(!h.unit('weak').alive && !e.blockedBy, 'the blocker is knocked out');
const x0 = e.x;
h.run(rest - 2 * TICK);
assert.equal(e.x, x0, 'it finishes its clip where it struck');
h.run(0.2);
assert.ok(e.x < x0 - 0.02, 'then it walks on');
});
test('a blocker leaving later in the clip leaves only the rest of it; a displacement ends the stand; a blocked enemy keeps striking every interval', () => {
const h = arena([{ chessId: 'w1', row: 9, col: 6 }]);
h.step();
const wall = h.unit('w1');
const e = h.spawn(ENVOY, { routeIndex: 0 });
const ts = untilStrike(h, e);
// still blocked: the next strike comes one interval later, as before
assert.ok(h.runUntil(() => e.stats.attacks >= 2, 10));
assert.ok(Math.abs(h.b.time - ts - e.s.interval) < 2 * TICK + 1e-9, 'one strike per interval while blocked');
const t2 = h.b.time;
h.run(1); // 1 s into the 1.4 s rest: the wall is gone
h.b.releaseBlocked(wall);
wall.s.flags.noBlock = true; // (keeps it from taking the enemy over again: _blockerFor)
const x0 = e.x;
h.run(t2 + attackStand(e).rest - h.b.time - 2 * TICK);
assert.equal(e.x, x0, 'it stands until its clip is over');
h.run(0.2);
assert.ok(e.x < x0 - 0.02, 'then it walks');
// a displacement (失衡) during the stand ends it: it walks on from where it lands
const h2 = arena([{ chessId: 'weak', row: 9, col: 6 }]);
h2.step();
const e2 = h2.spawn(ENVOY, { routeIndex: 0 });
untilStrike(h2, e2);
assert.ok(h2.b.time < e2.atkStandUntil, 'standing');
assert.ok(h2.b.displace(e2, { x: 1, y: 0 }, 0.3) > 0);
const x1 = e2.x;
h2.run(0.2);
assert.ok(e2.x < x1 - 0.02, `after the push it walks on at once (${x1} → ${e2.x})`);
});