feedback5 WE1: enemy attacks strike at their damage frame; a short stun before it cuts the swing (GitHub #187 point 1, #170)
PRTS 状态机: ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态"; PRTS 异常效果 STUNNED (the state machine leaves the attack), DISARMED "正在进行的普通攻击将被中断". The strike frame is data/enemies.json attackAnim.hit. - ai.js: attackWindup = the clip's strike frame (half the clip when none is named), shortened with the clip like the stand; the cooldown counts down to the strike and the swing (enemy.swing) starts when it reaches the wind-up with a target in reach (attackTargets, shared with the frame) — steady-state strikes stay one interval apart. An enemy whose cooldown ran out before it had a target (walking, just blocked, a cut swing) swings from the start: the whole wind-up. - A stun / freeze / sleep / 浮空 / hiding, 缴械, 恐惧, 战栗 while blocked or losing every target cuts a swing short of its frame: no strike, the next swing starts from its wind-up. A stun in the cooldown only pauses it; a strike already made stays made. No clip known (36 of 249 kinds): no wind-up, the old rule. [ASSUMED] targets re-taken at the frame, a cut swing does not spend the cooldown, enemy heals keep no wind-up. Golden: 275 of 283 scenarios moved — roster all 49; bonds 41 of 46 (unchanged: bond-arcaneShip-high, bond-kjeragShip-low, bond-maniShip-low, bond-skillfulShip-high, bond-swiftShip-low); fields all 22; matches all 18; standins all 10; diy 135 of 138 (unchanged: diy-027, diy-029, diy-087) — every battle where a clip-carrying enemy meets an operator: the first strike of a contact waits its wind-up, stuns cut swings (the cut alone moves 55). With every attackAnim stripped from the data the corpus is byte-identical to the previous commit (nothing else moved). Bot matches: solo-HARD-2 now wins, coop2-NORMAL-8-serverrun now loses; the other 16 keep their result. Tests: test/sim/feedback5-enemy-swing.test.js (new, 6). Updated on purpose: feedback2-enemy-attack-stand.test.js (it locked the instant first strike), enemies_bosses.test.js (沉沙 / 新硎's cleared interval, 帝国的甲胄, 斩胄之剑 / 破胄之锤 wait for the frame). test/sim, test/content, test/match pass. Docs: SIM.md, DATA.md (attackAnim), ASSETS.md.
This commit is contained in:
1 parent
8f62f3e360
commit
89f730d05c
14 files changed
+5975
-5833
No files matched your search
@@ -4,6 +4,9 @@
|
||||
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
|
||||
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
|
||||
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
|
||||
// Since 0.2.0 (GitHub #187 / #170) every attack has its wind-up — the strike comes at the clip's damage frame after the
|
||||
// swing starts, also the first one of an enemy whose cooldown ran out before it had a target (it used to strike at once)
|
||||
// — and a stun before the frame cuts the swing (test/sim/feedback5-enemy-swing.test.js).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -101,9 +104,10 @@ test('a 「不停止移动」 attacker (attackMoves) keeps walking through its a
|
||||
};
|
||||
const moving = run(true), standing = run(false);
|
||||
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
|
||||
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
|
||||
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
|
||||
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
|
||||
for (const t of moving.atks.slice(0, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
|
||||
// (since 0.2.0 the first attack too: its cooldown long over as the target comes into range, it swings from the start —
|
||||
// the whole 0.5 s wind-up stood before the strike)
|
||||
for (const t of standing.atks.slice(0, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
|
||||
});
|
||||
|
||||
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
|
||||
@@ -116,8 +120,13 @@ test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand;
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_slow');
|
||||
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
|
||||
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
|
||||
// (0.2.0: a target in range at once, its cooldown over — it swings at once and strikes at the 0.5 s damage frame; it
|
||||
// used to strike in the first tick)
|
||||
assert.equal(e.lastAttackAt, -Infinity, 'swinging, not struck yet');
|
||||
h.runUntil(() => e.lastAttackAt >= 0, 2);
|
||||
assert.ok(Math.abs(e.lastAttackAt - (TICK + 0.5)) < TICK + 1e-6, `struck at the damage frame (${e.lastAttackAt.toFixed(3)})`);
|
||||
h.step();
|
||||
assert.ok(Math.abs(e.atkStandUntil - (e.lastAttackAt + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
|
||||
h.run(0.5);
|
||||
const x0 = e.x;
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.5 });
|
||||
@@ -156,8 +165,9 @@ test('a ranged enemy with a target in range on a WAIT checkpoint leaves when the
|
||||
assert.equal(free.attacks, 0);
|
||||
assert.ok(sniper.attacks >= 2 && quick.attacks >= 5);
|
||||
assert.ok(Math.abs(sniper.waited - free.waited) < 1e-9 && Math.abs(quick.waited - free.waited) < 1e-9, `the wait ends at ${free.waited.toFixed(2)} s for all three`);
|
||||
// it walks on once the attack clip running at the end of the wait is over (the attack at 2.7 s: until 3.2 s)
|
||||
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.2 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
|
||||
// it walks on once the attack clip running at the end of the wait is over (since 0.2.0 the first strike at its 0.5 s
|
||||
// damage frame, the next one 2.7 s later at 3.2 s: its clip until 3.7 s; it used to strike at once — until 3.2 s)
|
||||
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.7 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
|
||||
assert.equal(quick.moved, null, 'an enemy attacking quicker than its clip stands while the target stays in range');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// test/sim/feedback5-enemy-swing.test.js — an enemy's normal attack strikes at its clip's damage frame, and a stun before the
|
||||
// frame cuts the swing (GitHub #187 point 1 「短轴眩晕无法打断敌人攻击,比如:六卡西米尔、异德三、忍冬三」, #170; PRTS 状态机
|
||||
// ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态", PRTS 异常效果 STUNNED / DISARMED "正在进行的普通攻击将被中断") —
|
||||
// server/sim/ai.js updateEnemy / enemyAttack / attackWindup. The damage frame is data/enemies.json `attackAnim.hit` (the
|
||||
// clip's strike frame), shortened with the clip when the attacks come quicker than it; no clip known ⇒ no wind-up.
|
||||
// Run: node --test test/sim/feedback5-enemy-swing.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { TICK } from '../../server/sim/constants.js';
|
||||
import { attackWindup } from '../../server/sim/ai.js';
|
||||
|
||||
const HIT = 0.4; // the clip's strike frame
|
||||
const BAT = 2; // attack interval
|
||||
const near = (a, b, msg, ticks = 2) => assert.ok(Math.abs(a - b) <= ticks * TICK + 1e-9, `${msg}: ${a.toFixed(3)} vs ${b.toFixed(3)}`);
|
||||
|
||||
/** A wall operator and one enemy (melee on the wall's tile — blocked at once —, or ranged 3 tiles away). */
|
||||
function field({ ranged = false, clip = true } = {}) {
|
||||
const rec = {
|
||||
...enemyRec({ key: 'enemy_sw', hp: 1e9, atk: 100, bat: BAT, speed: 0, ...(ranged ? { range: 3 } : null) }),
|
||||
...(clip ? { attackAnim: { clip: 'Attack', dur: 1, hit: HIT } } : null),
|
||||
};
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 0 }, skill: null }) }, enemies: { enemy_sw: rec } },
|
||||
units: [{ chessId: 't_wall', row: 10, col: 6 }], enemies: [{ key: 'enemy_sw', pos: ranged ? [10, 9] : [10, 6] }],
|
||||
content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack', 'damaged'], captureNoisy: true,
|
||||
});
|
||||
h.step();
|
||||
return { h, e: h.enemy('enemy_sw'), wall: h.unit('t_wall') };
|
||||
}
|
||||
const strikes = (h, e) => h.hooksOf('attack').filter((c) => c.attacker === e).map((c) => c.t);
|
||||
const hits = (h, e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.t);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
|
||||
test('attackWindup: the clip\'s strike frame, shortened with the clip when the attacks come quicker than it; 0 with no clip', () => {
|
||||
const w = (anim, interval) => attackWindup({ def: { attackAnim: anim }, s: { interval } });
|
||||
assert.equal(w({ dur: 1, hit: 0.4 }, 2), 0.4);
|
||||
assert.equal(w({ dur: 2, hit: 1 }, 1), 0.5);
|
||||
assert.equal(w({ dur: 1 }, 2), 0.5, 'no strike frame named: half the clip');
|
||||
assert.equal(w(undefined, 2), 0);
|
||||
});
|
||||
|
||||
test('a blocked enemy swings when its cooldown is over and strikes at the damage frame: the first strike one wind-up after the block (its cooldown long over), then one per attack interval', () => {
|
||||
const { h, e } = field();
|
||||
const t0 = h.b.time; // blocked in the first tick
|
||||
h.run(4.6);
|
||||
const s = strikes(h, e);
|
||||
assert.ok(s.length >= 3, `${s.length} strikes`);
|
||||
near(s[0], t0 + HIT, 'the first strike: the whole wind-up from the block (it used to land at once)');
|
||||
near(s[1] - s[0], BAT, 'then one per interval', 1);
|
||||
near(s[2] - s[1], BAT, 'then one per interval', 1);
|
||||
assert.deepEqual(hits(h, e), s, 'melee: the damage at the strike');
|
||||
done(h);
|
||||
});
|
||||
|
||||
for (const dur of [0.1, 0.2]) {
|
||||
test(`a ${dur} s stun before the damage frame cuts the swing: no strike at that frame, the attack starts again from its wind-up after the stun (卡西米尔 6's pulse, 忍冬 S3)`, () => {
|
||||
const { h, e } = field();
|
||||
assert.ok(h.runUntil(() => strikes(h, e).length === 1, 2));
|
||||
const t1 = strikes(h, e)[0];
|
||||
// its next swing starts at t1 + BAT − HIT; the stun lands 0.2 s into it
|
||||
h.runUntil(() => h.b.time >= t1 + BAT - HIT + 0.2 - 1e-9, 3);
|
||||
assert.ok(e.swing, 'swinging');
|
||||
const tStun = h.b.time;
|
||||
h.b.applyStatus(e, 'stun', { duration: dur });
|
||||
h.run(dur + HIT + 0.3);
|
||||
const s = strikes(h, e);
|
||||
assert.equal(s.filter((t) => t > t1 && t < tStun + dur).length, 0, 'the cut swing never struck');
|
||||
near(s[1], tStun + dur + HIT, 'the next strike: the stun, then the whole wind-up again', 3);
|
||||
assert.ok(s[1] > t1 + BAT + dur + 0.1, `later than a mere pause of the countdown (${(t1 + BAT + dur).toFixed(2)})`);
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
|
||||
test('a stun during the cooldown (before the swing) only pauses it; 缴械 cuts a swing like a stun; a strike already made stays made (a ranged shot in flight lands)', () => {
|
||||
// the cooldown: a 0.5 s stun 0.5 s after a strike delays the next one by 0.5 s
|
||||
const a = field();
|
||||
assert.ok(a.h.runUntil(() => strikes(a.h, a.e).length === 1, 2));
|
||||
const t1 = strikes(a.h, a.e)[0];
|
||||
a.h.run(0.5);
|
||||
a.h.b.applyStatus(a.e, 'stun', { duration: 0.5 });
|
||||
a.h.run(BAT + 0.3);
|
||||
near(strikes(a.h, a.e)[1], t1 + BAT + 0.5, 'paused by the stun, no new wind-up', 3);
|
||||
done(a.h);
|
||||
// 缴械 mid-swing: the swing is cut, a new one starts once it ends
|
||||
const b = field();
|
||||
assert.ok(b.h.runUntil(() => strikes(b.h, b.e).length === 1, 2));
|
||||
const u1 = strikes(b.h, b.e)[0];
|
||||
b.h.runUntil(() => b.h.b.time >= u1 + BAT - HIT + 0.2 - 1e-9, 3);
|
||||
const tD = b.h.b.time;
|
||||
b.h.b.addBuff(b.e, { key: 'test:disarm', duration: 0.1, flags: { disarm: true } });
|
||||
b.h.run(HIT + 0.5);
|
||||
near(strikes(b.h, b.e)[1], tD + 0.1 + HIT, '缴械 "正在进行的普通攻击将被中断": the whole wind-up after it', 3);
|
||||
done(b.h);
|
||||
// ranged: stunned just after the strike — the shot flies on and lands
|
||||
const c = field({ ranged: true });
|
||||
assert.ok(c.h.runUntil(() => strikes(c.h, c.e).length === 1, 3));
|
||||
assert.equal(hits(c.h, c.e).length, 0, 'the shot is in flight');
|
||||
c.h.b.applyStatus(c.e, 'stun', { duration: 1 });
|
||||
c.h.run(0.6);
|
||||
assert.equal(hits(c.h, c.e).length, 1, 'a strike already made is not undone');
|
||||
done(c.h);
|
||||
});
|
||||
|
||||
test('no attack clip known: no wind-up — it strikes as its swing starts, so a stun only pauses its cooldown (the rule before 0.2.0)', () => {
|
||||
const { h, e } = field({ clip: false });
|
||||
assert.equal(attackWindup(e), 0);
|
||||
h.step();
|
||||
assert.equal(strikes(h, e).length, 1, 'struck in its first tick blocked');
|
||||
const t1 = strikes(h, e)[0];
|
||||
h.runUntil(() => h.b.time >= t1 + BAT - 0.1 - 1e-9, 3);
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.1 });
|
||||
h.run(0.5);
|
||||
near(strikes(h, e)[1], t1 + BAT + 0.1, 'paused by the stun', 3);
|
||||
done(h);
|
||||
});
|
||||
Reference in new issue
Block a user