Files
ark-stronghold-protocol/test/render/attack-recovery-interp.test.js
T
ZiyueW1116 8a64edc9e9 feedback7 R22-RENDER: an enemy standing out its attack recovery is drawn standing — the snapshot carries stand / standCut and the interpolator holds the position until the recovery ends (PR #381 by @ZiyueW1116, supersedes #307 / #314); golden: unchanged
Since 0.2.0 the sim stands an unblocked enemy for the rest of its attack clip (ai.js attackStand → atkStandUntil),
but render/interp.js lerped x/y over the whole snapshot gap, so the view slid through a recovery the sim held
(repro: a real 隐形弩手, snapshots 4.9 / 5.0, stand until 4.934 — sampled at 4.917 it slid at vx −0.2).

The snapshot now carries, only when non-empty, `stand: [[id, until]]` (atkStandUntil of enemies alive, deployed,
visible, neither feared nor stunned) and `standCut: [[id, at]]` (the latest time each listed enemy's recovery was cut
or ignored: a displacement, fear, a stun, hiding, leaving — Battle._cutAttackStand, display metadata the battle never
reads). sample() holds the older position until `until` and interpolates the rest (HP / SP keep the whole interval),
stays linear when the newer snapshot records a cut inside the interval, and extrapolates nothing for a newest snapshot
that shows a stand, a death, a stun or a block. Teleports and redeploys still snap first; old snapshots without the
fields interpolate as before.

Substantially @ZiyueW1116's code (PR #381), authored as theirs. Changed on review: the docs (SIM §9, design/network.md)
and code comments in English like the rest of them; the snapshot builds its listed-id set only when an enemy has a cut
to report.

Golden: unchanged — the digest canon hashes neither field and the sim's timing does not move (all 283 scenarios match).

Tests: test/render/attack-recovery-interp.test.js (new, 25: metadata validation, hold-then-slide, cuts, legacy
snapshots, extrapolation guards, real 隐形弩手 / Weedy push-kill / stun / freeze / sleep / levitate / block / fear
battles) 25/25; render/interp 16/16; sim battle, perf, robustness, downelem 69/69.
2026-10-08 18:54:54 +08:00

429 lines
18 KiB
JavaScript

// 敌人普攻后摇:可选快照元数据的生产、校验与位置插值;不改动战斗规则。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
import { ANIM, UF } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { snapFrame } from '../../server/match/fields.js';
import { TICK } from '../../server/sim/constants.js';
import { rupture } from '../../server/sim/content/kits/ops/op-weedy.js';
const tuple = (x, anim = ANIM.ATTACK) => [1, x, 9, 100, 100, 0, 0, 0, anim];
const frame = (t, x, until, anim) => ({ t, units: [tuple(x, anim)], ...(until === undefined ? {} : { stand: [[1, until]] }) });
const near = (got, want) => assert.ok(Math.abs(got - want) < 1e-8, `${got} ≠ ${want}`);
test('stand:仅保留快照中存在的单位与有限、未来的结束时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], stand: [
null, 'x', [1], [2, 2], [1, '2'], [1, NaN], [1, Infinity], [1, 1], [1, 0.9], [1, 1.08],
] });
assert.deepEqual([...valid.stand], [[1, 1.08]]);
for (const stand of [undefined, null, {}, [], [[1, 1]], [[2, 2]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], stand }).stand, null);
}
});
test('standCut:仅保留已有单位的有限、非负且不晚于快照的时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], standCut: [
null, 'x', [1], [2, 0.9], [1, '0.9'], [1, NaN], [1, Infinity], [1, -1], [1, 1.01], [1, 0.9],
] });
assert.deepEqual([...valid.standCut], [[1, 0.9]]);
for (const standCut of [undefined, null, {}, [], [[1, -1]], [[2, 0]], [[1, 1.01]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut }).standCut, null);
}
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut: [[1, 0], [1, 1]] }).standCut.get(1), 1);
});
test('后摇结束前保持位置,结束后插值剩余区间;HP/SP 仍按整段时间插值', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
const next = frame(1.1, 0.02, undefined, ANIM.MOVE);
next.units[0][3] = 50;
next.units[0][5] = 10;
b.push(next, 0.05);
for (const t of [1, 1.04, 1.079]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
assert.equal(s.anim, ANIM.ATTACK);
}
const middle = b.sample(1.05).get(1);
near(middle.hp, 75);
near(middle.sp, 5);
near(b.sample(1.09).get(1).x, 0.01);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.1).get(1).x, 0.02);
});
test('旧快照、过期元数据及攻击→移动动画不推断停步', () => {
for (const until of [undefined, 0.9, 1, NaN, Infinity]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, until), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.05).get(1).vx, 1);
}
});
test('后摇结束时间恰为下一帧时,位置在该帧之前保持不动', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.1), 0);
b.push(frame(1.1, 0.02), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, 0.02);
});
test('后摇尚未到期但新帧已移动时,不阻止权威位置更新', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 2), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
});
test('后摇结束时间落在两帧之间,但该区间有打断时,插值和外推均使用完整区间', () => {
for (const cutAt of [1, 1.04, 1.1]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.1, undefined, ANIM.MOVE), standCut: [[1, cutAt]] }, 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.15);
}
});
test('旧的打断记录不影响后续未被打断的后摇', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.02), standCut: [[1, 0.9]] }, 0.05);
assert.equal(b.sample(1.04).get(1).x, 0);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.15).get(1).vx, 1);
});
test('后摇恢复段进入外推时速度连续,零长度移动区间不产生无穷速度', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.07);
const edge = new SnapshotBuffer();
edge.push(frame(1, 0, 1.1), 0);
edge.push(frame(1.1, 0.02), 0.05);
near(edge.sample(1.15).get(1).x, 0.02);
assert.equal(edge.sample(1.15).get(1).vx, 0);
});
test('停步期间及网络停顿越过结束时间后,都不使用攻击前的速度外推', () => {
const b = new SnapshotBuffer({ maxExtrapolate: 0.2 });
b.push(frame(0.9, -0.1), 0);
b.push(frame(1, 0, 1.08), 0.05);
for (const t of [1.04, 1.09, 5]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
assert.ok(b.sample(1.15).get(1).x > 0.02, '新位置确认后恢复原有外推');
});
test('瞬移与重新部署的跳转优先于后摇插值', () => {
for (const [x, anim] of [[8, ANIM.MOVE], [0.1, ANIM.DEPLOY]]) {
const b = new SnapshotBuffer({ teleport: 2 });
b.push(frame(1, 0, 1.04), 0);
b.push(frame(1.1, x, undefined, anim), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, x);
assert.equal(b.sample(1.09).get(1).vx, 0);
near(b.sample(1.15).get(1).x, x);
assert.equal(b.sample(1.15).get(1).vx, 0);
}
});
test('死亡或眩晕的最新帧不沿用旧速度外推;恢复移动后仍可外推', () => {
for (const anim of [ANIM.DIE, ANIM.STUN]) {
const b = new SnapshotBuffer();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
const stopped = frame(1, 0, undefined, anim);
if (anim === ANIM.DIE) stopped.units[0][3] = 0;
b.push(stopped, 0.05);
for (const t of [1.01, 1.24, 5]) {
assert.equal(b.sample(t).get(1).x, 0);
assert.equal(b.sample(t).get(1).vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
near(b.sample(1.15).get(1).x, 0.03);
}
});
test('最新帧中已消失的单位不外推,并从复用的采样结果中删除', () => {
const b = new SnapshotBuffer(), out = new Map();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
b.push(frame(1, 0, 1.08), 0.05);
b.push({ t: 1.1, units: [] }, 0.1);
assert.equal(b.sample(1.05, out).get(1).x, 0);
assert.equal(b.sample(1.05, out).get(1).vx, 0);
assert.equal(b.sample(1.2, out).has(1), false);
});
function arena(key, wallRow = 10) {
return makeBattle({
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 1e6 }, skill: null }) } },
units: [{ chessId: 't_wall', row: wallRow, col: 6 }],
enemies: [{ key, time: 0, route: 0 }],
content: 'none', autoFinish: false, timeLimit: 60,
});
}
test('真实隐形弩手:Battle → b.snap → SnapshotBuffer 在后摇结束前不滑动', () => {
const h = arena('enemy_1019_jshoot');
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
const until = prev?.stand?.find((v) => v[0] === e.id)?.[1];
if (a && z && until > prev.t && until < next.t && (a[1] !== z[1] || a[2] !== z[2])) pair = { a, z, until, prev, next };
prev = next;
}
assert.ok(pair, '真实攻击后摇跨越两帧且随后恢复移动');
const { a, z, until, prev: A, next: B } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const held = b.sample((A.t + until) / 2).get(a[0]);
assert.equal(held.x, a[1]);
assert.equal(held.y, a[2]);
assert.equal(held.vx, 0);
const moved = b.sample((until + B.t) / 2).get(a[0]);
near(moved.x, (a[1] + z[1]) / 2);
near(moved.y, (a[2] + z[2]) / 2);
near(b.sample(B.t - 1e-6).get(a[0]).vx, b.sample(B.t + 1e-6).get(a[0]).vx);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:失衡跨帧打断时不按旧后摇时间冻结和集中追赶', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.6) > 0);
h.step(2);
const B = h.b.snapshot();
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut.find((s) => s[0] === e.id)[1], cutAt);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
near(b.sample(B.t + 1e-6).get(e.id).vx, (z[1] - a[1]) / (B.t - A.t));
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:后摇中击退并被移动创伤击杀,死亡帧保留打断且尸体不外推', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
// 温蒂的真实移动创伤实现:击退后下一次伤害结算在同一个快照间隔内击杀目标。
rupture(h.b, h.unit('t_wall'), e, { duration: 5, perTile: 1e6, interval: TICK });
near(h.b.push(e, e.s.massLevel + 1, { dir: { x: -1, y: 0 }, fixed: true }), 2.14);
h.step(2);
const B = h.b.snapshot();
assert.equal(e.alive, false);
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut?.find((s) => s[0] === e.id)?.[1], cutAt);
assert.equal(B.stand?.some((s) => s[0] === e.id) ?? false, false);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.DIE);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
for (const t of [B.t, B.t + 0.01, B.t + 0.24, B.t + 1]) {
assert.equal(b.sample(t).get(e.id).x, z[1]);
assert.equal(b.sample(t).get(e.id).vx, 0);
}
b.update(0.05);
for (let i = 1; i <= 90; i++) b.update(0.05 + i / 120);
assert.ok(b.renderT > B.t);
assert.equal(b.sample().get(e.id).x, z[1]);
h.run(0.9);
const C = h.b.snapshot();
assert.equal(C.units.some((u) => u[0] === e.id), false);
assert.equal(C.standCut?.some((s) => s[0] === e.id) ?? false, false);
b.push(snapFrame('test', C), 0.5);
assert.equal(b.sample(C.t + 0.1).has(e.id), false);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:AI 更新前死亡也记录后摇终止,隐藏和死亡窗口外不发送记录', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const at = Math.round(h.b.time * 1000) / 1000;
h.b.kill(e, h.unit('t_wall'));
assert.equal(Math.round(e.atkStandCutAt * 1000) / 1000, at);
assert.deepEqual(h.b.snapshot().standCut, [[e.id, at]]);
e.hidden = true;
assert.equal(h.b.snapshot().standCut, undefined);
e.hidden = false;
h.run(0.9);
assert.equal(h.b.snapshot().standCut, undefined);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
for (const status of ['stun', 'freeze', 'sleep', 'levitate']) {
test(`真实隐形弩手:移动后${status}的最新帧不继续滑动,解除后恢复移动`, () => {
const h = arena('enemy_1019_jshoot');
h.step(3);
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
h.step();
assert.equal(h.b.applyStatus(e, status, { duration: 1, force: true, source: h.unit('t_wall') }), true);
h.step(2);
const B = h.b.snapshot(), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.STUN);
assert.notEqual(A.units.find((u) => u[0] === e.id)[1], z[1]);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.1).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.1).get(e.id).vx, 0);
h.run(1.1);
const C = h.b.snapshot();
assert.equal(e.s.flags.stun, false);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.6);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
}
test('真实隐形弩手:刚被阻挡的最新帧不继续滑动,阻挡者离场后恢复移动', () => {
const h = arena('enemy_1019_jshoot', 9);
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot(), e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && (z[7] & UF.BLOCKED) && a[1] !== z[1] && !next.stand?.some((s) => s[0] === e.id)) pair = { A: prev, B: next, e, z };
prev = next;
}
assert.ok(pair, '移动与首次阻挡发生于同一个快照间隔,且没有后摇元数据');
const { A, B, e, z } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.24).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.24).get(e.id).vx, 0);
h.b.retreat(h.unit('t_wall'), { permanent: true });
h.step(3);
const C = h.b.snapshot();
assert.equal(e.blockedBy, null);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.1);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:恐惧打断在延迟帧之后仍恢复线性插值', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > 4 * TICK && e.atkStandUntil - h.b.time < 5 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
assert.equal(h.b.applyStatus(e, 'fear', { duration: 1, source: h.unit('t_wall') }), true);
// 本地 runner 在卡帧时会推进多个 tick 后再发出一帧。
h.step(5);
const B = h.b.snapshot();
assert.ok(e.s.flags.fear && until > A.t && until < B.t);
assert.ok(B.standCut.some((s) => s[0] === e.id && s[1] >= A.t));
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), (B.t - A.t) / 2);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
const s = b.sample((A.t + until) / 2).get(e.id);
const alpha = (until - A.t) / (2 * (B.t - A.t));
near(s.x, a[1] + (z[1] - a[1]) * alpha);
assert.ok(s.x > a[1]);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实枯朽之种:命中帧即动画末尾,没有后摇时仍连续移动', () => {
const h = arena('enemy_1269_nhfly');
let prev, checked = false;
for (let i = 0; i < 1200 && !checked; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1269_nhfly');
assert.ok(!next.stand?.some((s) => s[0] === e.id), '零后摇不发送 stand');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && a[8] === ANIM.ATTACK && z[8] === ANIM.MOVE && (a[1] !== z[1] || a[2] !== z[2])) {
const b = new SnapshotBuffer();
b.push(prev, 0);
b.push(next, 0.05);
const s = b.sample((prev.t + next.t) / 2).get(e.id);
near(s.x, (a[1] + z[1]) / 2);
near(s.y, (a[2] + z[2]) / 2);
checked = true;
}
prev = next;
}
assert.ok(checked, '真实攻击→移动帧对');
assert.equal(h.b.errors.length, 0);
});
test('快照不泄漏无效后摇:盟友、隐藏、未部署、死亡、恐惧、眩晕、到期及失衡打断', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const has = () => h.b.snapshot().stand?.some((s) => s[0] === e.id) ?? false;
assert.ok(has());
const ally = h.unit('t_wall');
ally.atkStandUntil = e.atkStandUntil;
assert.ok(!h.b.snapshot().stand.some((s) => s[0] === ally.id));
for (const key of ['hidden', 'alive', 'deployed']) {
const old = e[key];
e[key] = key === 'hidden';
assert.equal(has(), false, key);
e[key] = old;
}
for (const key of ['fear', 'stun']) {
e.s.flags[key] = true;
assert.equal(has(), false, key);
e.s.flags[key] = false;
}
for (const until of [h.b.time, -Infinity, NaN, Infinity]) {
e.atkStandUntil = until;
assert.equal(has(), false);
}
e.atkStandUntil = h.b.time + 1;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.2) > 0, '实际发生位移');
assert.equal(has(), false, '失衡已清除后摇');
});