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:
sganggs committed 2026-10-06 15:21:24 +08:00
1 parent 8f62f3e360
commit 89f730d05c
14 files changed
+5975 -5833

No files matched your search

+11 -5
View File
@@ -11,6 +11,7 @@ import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { attackWindup } from '../../server/sim/ai.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
@@ -291,8 +292,9 @@ for (const [key, n] of Object.entries(DEATH_SPAWN)) {
for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) {
// PRTS 天赋 "攻击力+X%,持有4个【断刃】 / 每次成功攻击后消耗1个【断刃】,攻击结束时若已耗尽【断刃】,则立刻切换为无断刃模式并失去攻击力
// 加成" (popup: "清空当次攻击间隔" — it attacks again at once); GitHub #107: until 0.2.0 each attack stacked another layer
test(`${nm(key)}: one ATK layer while it holds a blade, gone with the 4th attack (which is followed at once by the next); unspent blades become 矛头 on death (at least 1)`, () => {
// 加成" (popup: "清空当次攻击间隔" — it attacks again at once: since 0.2.0 its next swing starts at once and strikes at the
// clip's damage frame, ai.js attackWindup); GitHub #107: until 0.2.0 each attack stacked another layer
test(`${nm(key)}: one ATK layer while it holds a blade, gone with the 4th attack (which is followed at once by the next swing); unspent blades become 矛头 on death (at least 1)`, () => {
const per = tb(key, 'Atkup.atk') ?? tb(key, 'AtkUp.atk'), cnt = tb(key, 'DeadSpawn.cnt');
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
@@ -304,8 +306,10 @@ for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) {
h.runUntil(() => e.stats.attacks >= cnt, 20);
approx(e.s.atk, base, 1e-6, 'the last blade spent: 无断刃模式, no bonus');
const tLast = h.b.time;
assert.ok(h.runUntil(() => e.stats.attacks >= cnt + 1, 0.2), 'the mode switch clears the attack interval: the next attack at once');
assert.ok(h.b.time - tLast < e.s.interval / 2, `${h.b.time - tLast} s after the 4th attack`);
const wind = attackWindup(e);
assert.ok(wind > 0 && h.runUntil(() => e.stats.attacks >= cnt + 1, wind + 0.2), 'the mode switch clears the attack interval: the next swing at once');
assert.ok(Math.abs(h.b.time - tLast - wind) <= 3 * h.TICK, `${h.b.time - tLast} s after the 4th attack: its wind-up (${wind} s)`);
assert.ok(h.b.time - tLast < e.s.interval / 2);
const child = E[key].talents.bbStr['DeadSpawn.enemy_key'];
killed(h, e, null);
assert.equal(alive(h, child).length, 1, 'no blade left: still one 矛头');
@@ -1289,6 +1293,7 @@ test(`${nm('enemy_10027_vtsk')}: entrance barrage (${skb('enemy_10027_vtsk', 'Ap
assert.ok(barrage.every((c) => c.target === h.unit('t_wall')), 'the highest-HP unit');
approx(barrage[0].amount, e.s.atk);
h.runUntil(() => e.stats.attacks >= 1, 10);
h.run(1); // (its shot lands: since 0.2.0 the first strike comes at the clip's damage frame, after the barrage window)
const d = h.hooksOf('damaged').find((c) => c.source === e && c.dmg.isAttack);
approx(d.amount, e.s.atk * tb('enemy_10027_vtsk', 'range.attack@atk_scale_range'));
h.run(skb('enemy_10027_vtsk', 'MultiCombat').initCooldown + 6);
@@ -2412,7 +2417,8 @@ test('“斩胄之剑” / “破胄之锤” 初始模式 attack: every ally in
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 9 }, { chessId: 't_wall2', row: 10, col: 7 }] });
h.step();
put(h, key, [3, 8], { tag: 'part' });
h.run(1);
// (the first attack: its 0.8 / 0.867 s wind-up, then the shots — since 0.2.0 an attack strikes at its damage frame)
h.runUntil(() => h.unit('t_wall').stats.taken > 0 && h.unit('t_wall2').stats.taken > 0, 3);
approx(h.unit('t_wall').stats.taken, E[key].stats.atk * scale, 1e-6, key);
approx(h.unit('t_wall2').stats.taken, E[key].stats.atk * scale, 1e-6, key);
}