feedback5: merge R19E — every enemy finishes its attack clip after a strike before it moves on (PRTS 状态机: ATTACK → MOVE after the attack ends; the 鸭爵 swap's 流泪小子 no longer walks off on its strike tick, community report and GitHub #214), and the 鸭爵 strategy's swapped-in 流泪小子 hides again 1 s after a block ends instead of 0 s (the owner's decision of 2026-10-07, a deliberate deviation for that copy only); golden as R19E's commits (216 scenarios + solo-HARD-2), the merged tree differs from the branch only in R19G's three 蕾缪安 scenarios
This commit is contained in:
commit
11fd65a5a5
19 files changed
+4061
-3741
No files matched your search
@@ -0,0 +1,142 @@
|
||||
// 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})`);
|
||||
});
|
||||
Reference in new issue
Block a user