diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..80930f36 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) -- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. +- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, stand?, standCut? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show @@ -1309,6 +1309,19 @@ Unknown subprofessions fall back to the profession default (test `professions.te knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). + `stand: [[id, until]]` (only when non-empty) = the game time each enemy's attack recovery ends (`atkStandUntil`: it + stands for the rest of its attack clip, §1.2 `attackStand`) — enemies alive, deployed, visible, neither feared nor stunned; + display metadata the battle never reads (PR #381). When `until` falls between two snapshots the renderer holds the older + position until then and interpolates the rest of the interval (HP / SP keep the whole interval); a newer snapshot that + moved before `until` is interpolated linearly; while the newest snapshot still holds a stand nothing is extrapolated + (the next one confirms the move). A snapshot without the field interpolates over the whole interval, a teleport or a + redeploy still snaps first, and nothing is inferred from the attack animation. `standCut: [[id, at]]` (only when + non-empty) = the latest game time each enemy's recovery was cut or ignored (a displacement — 失衡 —, fear, a stun, hiding, + leaving the field), one per enemy: an interval whose newer snapshot carries a cut inside it is interpolated linearly even + when the older stand ended inside it too, and an older cut leaves a later stand alone. It covers the enemies listed in + `units` (the 0.8 s death window included), never a hidden or unlisted one. The newest snapshot showing a death, a stun + (freeze, sleep, 浮空 included) or a block is not extrapolated until a later snapshot shows it moving again; the + extrapolated velocity uses the same moving span as the interpolation (the stand is not in its denominator). - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind, diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..c1db75f0 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -23,7 +23,14 @@ S→C: - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display `0/0`. Snapshot display capacity does not change runtime SP/cost. -- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) +- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) + Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's + attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls + between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and + extrapolates nothing while the newest snapshot still holds a stand; `standCut: [[id, at]]` — the latest time each + listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays + linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them + interpolate linearly; nothing is inferred from the attack animation. - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) diff --git a/public/js/render/interp.js b/public/js/render/interp.js index a3d99c8a..1df0d9de 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -8,7 +8,8 @@ // advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it // is more than `snapAfter` real seconds off), // * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot; -// positions are extrapolated along the last velocity for at most that long, then freeze, +// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the +// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`), // * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from // the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position // until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer) @@ -20,18 +21,24 @@ // game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx // included — are always delivered, and a full queue sheds only cosmetic ones. // -// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along -// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): +// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along +// (server/sim/battle/events.js snapshot; user playtest #4 items 8 / 9, PR #381): // * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's // normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older // snapshot, like flags); // * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no // longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when -// both are integers): downAt(time) returns the list of the snapshot at `time`. -// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. +// both are integers): downAt(time) returns the list of the snapshot at `time`; +// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position +// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a +// snapshot without it interpolates linearly; +// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside +// it stays linear. +// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares +// them with renderT. import { fxForm } from '../../../shared/protocol.js'; -import { ANIM } from '../../../shared/constants.js'; +import { ANIM, UF } from '../../../shared/constants.js'; export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 }); /** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */ @@ -43,6 +50,15 @@ const MAX_EVENTS = 6000; const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); +/** + * Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it): + * the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`. + */ +function movementStart(a, b, id) { + const until = a.stand?.get(id), cutAt = b.standCut?.get(id); + return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t; +} + /** Cosmetic event kinds that may be dropped when far behind (never state-changing). */ export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']); /** @@ -66,8 +82,9 @@ export function frameTime(msg) { /** * Validate & normalise a b.snap payload. Returns `{ t, units: Map, down: [[id, respawnAt, respawnTime, - * state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default + * state, row?, col?]] | null, stand: Map | null, standCut: Map | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default * to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped. + * `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite times in [0, t] of units in it. */ export function normalizeSnapshot(snap) { if (!snap || typeof snap !== 'object') return null; @@ -99,7 +116,21 @@ export function normalizeSnapshot(snap) { (down || (down = [])).push(e); } } - return { t, units, down, raw: snap }; + let stand = null; + if (Array.isArray(snap.stand)) { + for (const e of snap.stand) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue; + (stand || (stand = new Map())).set(e[0], e[1]); + } + } + let standCut = null; + if (Array.isArray(snap.standCut)) { + for (const e of snap.standCut) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue; + (standCut || (standCut = new Map())).set(e[0], e[1]); + } + } + return { t, units, down, stand, standCut, raw: snap }; } export class SnapshotBuffer { @@ -288,22 +319,30 @@ export class SnapshotBuffer { const dx = b[1] - a[1], dy = b[2] - a[2]; // (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide) const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b); - o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha; - o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha; - o.vx = !tele && span > 0 ? dx / span : 0; - o.vy = !tele && span > 0 ? dy / span : 0; + const start = movementStart(A, B, id); + const moveSpan = B.t - start; + const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0; + o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha; + o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha; + o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0; + o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0; o.hp = a[3] + (b[3] - a[3]) * alpha; o.maxHp = b[4] || a[4]; o.sp = a[5] + (b[5] - a[5]) * alpha; o.spMax = b[6] || a[6]; } else { let vx = 0, vy = 0; - if (P && ext > 0) { + // a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes + // `until` — the next snapshot confirms the position + if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) { const p = P.units.get(id); const dtp = A.t - P.t; - if (p && dtp > 0) { - vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp; - if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; } + const moveSpan = A.t - movementStart(P, A, id); + if (p && dtp > 0 && moveSpan > 0) { + const dx = a[1] - p[1], dy = a[2] - p[2]; + if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) { + vx = dx / moveSpan; vy = dy / moveSpan; + } } } o.x = a[1] + vx * ext; diff --git a/server/sim/ai.js b/server/sim/ai.js index c9a9f0eb..00f08b9e 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -535,6 +535,7 @@ export function updateEnemy(b, e, dt) { if (!e.alive) return; // hidden (teleporting) enemies only advance wait legs const stunned = e.s.flags.stun; + if (stunned || e.hidden || e.s.flags.fear) b._cutAttackStand(e); const prevCd = e.atkCd; if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt); // a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage @@ -644,6 +645,7 @@ function advanceRoute(b, e, dt, R, standing = false) { continue; } if (leg.t === 'disappear') { + b._cutAttackStand(e); b._setHidden(e, true); e.atkStandUntil = -Infinity; standing = false; // off the field: its attack clip is over R.legIdx++; R.pts = null; diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 3f991d9d..72a6a6c4 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -97,6 +97,7 @@ export class BattleDeploy { } _remove(unit, reason, killer = null, permanent = false, dying = false) { + this._cutAttackStand(unit); unit.alive = false; unit.removeReason = reason; unit.deployed = false; diff --git a/server/sim/battle/displacement.js b/server/sim/battle/displacement.js index 12a2cfc7..6c409430 100644 --- a/server/sim/battle/displacement.js +++ b/server/sim/battle/displacement.js @@ -141,6 +141,7 @@ export class BattleDisplacement { if (moved > 0) { this._unblock(e); // 失衡 ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE) + this._cutAttackStand(e); e.atkStandUntil = -Infinity; if (e.route) e.route.pts = null; this.fx('displace', { x: e.x, y: e.y, id: e.id }); diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js index 690b4c54..4a5a3199 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -32,12 +32,26 @@ export class BattleEvents { return ev; } + /** + * Record that an enemy's attack recovery (atkStandUntil) is cut or ignored now — snapshot `standCut`, display metadata + * (render/interp.js); never changes the attack's timing or state. + */ + _cutAttackStand(unit) { + if (unit.side === 'enemy' && Number.isFinite(unit.atkStandUntil) && unit.atkStandUntil > this.time) { + unit.atkStandCutAt = this.time; + } + } + /** * Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty): * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and * come back on: _layBody — where they fell, or their home); * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). + * stand: [[id, until]] — when each enemy's attack recovery ends (atkStandUntil; alive, deployed, visible, not + * feared or stunned) — display metadata (render/interp.js holds the position until then); + * standCut: [[id, at]] — the latest time each listed enemy's recovery was cut or ignored (_cutAttackStand), the + * dying ones in their death window included. */ snapshot() { const snap = { @@ -60,13 +74,24 @@ export class BattleEvents { (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); } if (down) snap.down = down; - let elem = null; + let elem = null, stand = null, standCut = null; + let listed = null; // the ids in snap.units, built for the first enemy with a cut to report for (const u of this.units) { + if (u.side === 'enemy' && u.atkStandCutAt >= 0) { + const cutAt = Math.round(u.atkStandCutAt * 1000) / 1000; + if (cutAt <= snap.t && (listed || (listed = new Set(snap.units.map((x) => x[0])))).has(u.id)) (standCut || (standCut = [])).push([u.id, cutAt]); + } if (!u.alive || !u.deployed || u.hidden) continue; + const until = Math.round(u.atkStandUntil * 1000) / 1000; + if (u.side === 'enemy' && !u.s.flags.fear && !u.s.flags.stun && Number.isFinite(until) && until > snap.t) { + (stand || (stand = [])).push([u.id, until]); + } const v = elementView(u, this.time); if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); } if (elem) snap.elem = elem; + if (stand) snap.stand = stand; + if (standCut) snap.standCut = standCut; return snap; } diff --git a/server/sim/battle/spawns.js b/server/sim/battle/spawns.js index 17c1c5fd..761e162f 100644 --- a/server/sim/battle/spawns.js +++ b/server/sim/battle/spawns.js @@ -132,6 +132,7 @@ export class BattleSpawns { e.atkCd = 0; e.pauseUntil = -Infinity; // content holds (暴鸰's drop) e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand) + e.atkStandCutAt = -Infinity; // display metadata: when its stand was last cut or ignored (snapshot standCut) e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack) // every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast); // `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack); `blockFree` = its 索敌不受阻挡 diff --git a/test/render/attack-recovery-interp.test.js b/test/render/attack-recovery-interp.test.js new file mode 100644 index 00000000..d317cd1c --- /dev/null +++ b/test/render/attack-recovery-interp.test.js @@ -0,0 +1,428 @@ +// 敌人普攻后摇:可选快照元数据的生产、校验与位置插值;不改动战斗规则。 +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, '失衡已清除后摇'); +}); diff --git a/types/core.js b/types/core.js index effe5f3a..f4ac108f 100644 --- a/types/core.js +++ b/types/core.js @@ -91,7 +91,9 @@ /** * `snapshot()` (SIM §9). On the wire this object is the payload of `b.snap`, * and `t` is sent as `gt` because the frame's `t` is the message type (DESIGN §8.2). - * `dp` is one number. `down` and `elem` are omitted when empty. + * `dp` is one number. `down`, `elem`, `stand` and `standCut` are omitted when empty: `stand` [id, until] = the game + * time an enemy's attack recovery ends, `standCut` [id, at] = the latest time it was cut or ignored (enemies in the + * snapshot's death window included). * * @typedef {Object} FieldSnapshot * @property {string} fieldId @@ -104,6 +106,8 @@ * @property {Object} [boss] * @property {DownSnap[]} [down] * @property {ElemSnap[]} [elem] + * @property {[number, number][]} [stand] + * @property {[number, number][]} [standCut] */ /**