Files
ark-stronghold-protocol/server/sim/battle/deploy.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

258 lines
13 KiB
JavaScript

// server/sim/battle/deploy.js — Battle methods: deployment, death and redeploy: _deploy (home, landing or rest tile; 联防
// carry), kill / retreat and the removal bookkeeping (_remove, hooks of removed units released at step end), redeploy /
// automatic redeploys, leaks.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { COLS } from '../constants.js';
import { unitInfo } from '../snapshot.js';
import { fin } from './util.js';
export class BattleDeploy {
/**
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile; an
* operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges }
* restored right after the skill reset, before the `deploy` hook fires.
*/
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) {
if ((u.alive && u.deployed) || u._removing) return false;
const R0 = tile ? tile[0] : u.homeR, C0 = tile ? tile[1] : u.homeC;
if (!this.grid.inRect(R0, C0)) {
this.log(`${u} at ${R0},${C0} is outside the field rect; not deployed`);
if (!tile) u.removed = true;
return false;
}
const k = R0 * COLS + C0;
const occ = this._occ[k];
if (occ && occ !== u && occ.alive && occ.deployed) { this.log(`tile ${R0},${C0} occupied; ${u} not deployed`); return false; }
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助 §作战阶段 单位部署)
if (this.downOn(R0, C0, u)) { this.log(`a knocked-out operator lies on ${R0},${C0}; ${u} not deployed`); return false; }
const first = u.deploySeq === 0;
u.alive = true;
u.deployed = true;
u.removed = false;
u.hidden = false;
u.body = null;
u.countdown = null; // a countdown summon's content starts its new life in the deploy hook (content/tokens.js startCountdown)
u.downAtHome = false; // a new deployment: a later knock-out lies where it falls again (Battle._layBody)
u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0;
u.blocking = [];
u.deploySeq = ++this._deploySeq;
u.aggroSeq = u.deploySeq;
u.deployedAt = this.time;
u.atkCd = 0;
u.lastAttackAt = -Infinity;
u.elem.burn = u.elem.neural = u.elem.necrosis = u.elem.apoptosis = u.elem.erosion = 0;
u.ground = this.grid.isLow(R0, C0) && !this._elevated?.has(k);
u.markDirty();
u.hp = u.s.maxHp;
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"):
// an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only
const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null;
if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
this._occ[k] = u;
this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend)
if (!u.skill) this._setupUnit(u);
u.skill.reset(cs);
const sk = u.skill;
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) {
sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0))));
sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0)));
if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost;
}
if (first) this._ev(['spawn', unitInfo(u)]);
this._ev(['deploy', u.id]);
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial });
// 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is
// done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts
if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
return true;
}
/** Mark `unit` dead (hp reached 0). Fires `kill` then `death`. */
kill(unit, killer = null) {
if (!unit || !unit.alive) return;
unit.hp = 0;
if (this._hooks.kill) this.emit('kill', { killer, victim: unit });
// a nested kill/retreat inside the handlers already removed it: a later handler's hp write must not stick
if (!unit.alive) { if (!unit.bossPool) unit.hp = 0; return; }
if (unit.hp > 0 && !unit.bossPool) { // revived by a kill handler (clamped: a handler may write any number)
unit.hp = Math.min(unit.hp, unit.s.maxHp);
return;
}
unit.hp = 0; // NaN / negative writes from handlers
this._remove(unit, 'killed', killer);
}
/**
* Withdraw an ally without a kill (it may redeploy after its respawn time): an operator lies down where it stood and
* comes back there (isDown, GitHub #60) — unless `permanent`, or the 突袭 retreat ('raid') that redeploys it at once.
* `dying`: a forced exit that plays the operator's death animation — a knock-out put off by its own effect (史尔特尔's
* 余烬, 骑士戒律 + 竞技旗); the `death` hook carries it (Touch's 超脱 counts such an exit: content/tokens.js).
*/
retreat(unit, { reason = 'retreat', permanent = false, dying = false } = {}) {
if (!unit || !unit.alive || unit.side !== 'ally') return;
this._remove(unit, reason, null, permanent, dying);
}
_remove(unit, reason, killer = null, permanent = false, dying = false) {
this._cutAttackStand(unit);
unit.alive = false;
unit.removeReason = reason;
unit.deployed = false;
unit.deathAt = this.time;
// while the removal bookkeeping runs, a skill onEnd handler must not redeploy the unit (it would come back
// alive but without its tile in _occ, its buffs wiped and a respawn timer pending) — redeploy from `death` instead
unit._removing = true;
if (unit.skill && unit.skill.active) {
this._safe(() => unit.skill.end('death'), 'skill.end', unit);
unit.skill.active = false;
}
unit._removing = false;
if (unit.side === 'ally') {
this.releaseBlocked(unit);
const k = unit.tileR * COLS + unit.tileC;
if (this._occ[k] === unit) this._occ[k] = null;
// keep persistent buffs only
const kept = [];
for (const b of unit.buffs) {
if (b.persist) kept.push(b);
else if (b.visible || b.status) this._ev(['status', unit.id, b.status ?? b.key, 0]);
}
unit.buffs = kept;
unit.markDirty();
if (unit.kind === 'op' && !permanent) {
if (reason === 'killed') { const pp = this._pp(unit.ownerId); if (pp) pp.deaths++; }
const mul = unit.persist.redeployMul * unit.s.redeployMul;
unit.respawnAt = this.time + Math.max(0, unit.base.respawnTime * mul);
if (this.isDown(unit)) this._layBody(unit); // before `die` / `death`: the body's tile is final for them
} else {
unit.removed = true;
}
if (unit.kind === 'device' && unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind);
} else {
unit.removed = true;
this._unblock(unit);
this._enemiesDirty = true;
if (reason === 'killed') {
if (unit.counted) {
this.killed++;
const pp = this._pp(unit.ownerId);
if (pp) pp.killed++;
}
if (killer) killer.stats.kills++;
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
}
}
// the reason ('killed' | 'retreat' | 'expired' | …) lets the client keep the knock-down sound for real knock-outs
if (reason !== 'leak') this._ev(['die', unit.id, reason]);
if (this._hooks.death) this.emit('death', { unit, reason, killer, dying: !!dying });
if (unit.removed) this._toRelease.push(unit);
}
/**
* Drop hooks and periodic timers (`every`) owned by permanently removed units (killed/leaked enemies, expired
* tokens, destroyed devices). Runs at the end of the step, so the unit's own death/leak handlers still fire;
* one-shot `after` callbacks are kept (they run once and are gone). Without this, per-enemy content hooks pile up
* and every emit walks the handlers of every enemy that ever spawned.
*/
_releaseRemoved() {
const set = new Set();
for (const u of this._toRelease) if (u.removed && !u.alive) set.add(u);
this._toRelease = [];
if (!set.size) return;
for (const name of Object.keys(this._hooks)) {
const list = this._hooks[name];
let w = 0;
for (let i = 0; i < list.length; i++) {
const h = list[i];
if (h.owner != null && set.has(h.owner)) h.removed = true;
else list[w++] = h;
}
list.length = w;
if (!w) delete this._hooks[name];
}
for (const sc of this._sched) if (sc.interval > 0 && sc.owner != null && set.has(sc.owner)) sc.cancelled = true;
}
/**
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where an operator that left the field
* lies, else its home tile). opts:
* free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP)
* tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is
* outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback
* keepSp keep the SP / charges the unit had (保留技力): restored before the `deploy` hook fires
* Returns true when the unit was deployed (full HP, `deploy {initial:false}` fires).
*/
redeploy(unit, { free = true, tile = null, keepSp = false } = {}) {
if (!unit || unit.side !== 'ally' || unit.alive || unit.removed) return false;
let at = null;
if (tile != null) {
if (!Array.isArray(tile) || !Number.isInteger(tile[0]) || !Number.isInteger(tile[1]) || !this.grid.inRect(tile[0], tile[1])) return false;
at = [tile[0], tile[1]];
} else {
const rest = this.restTile(unit);
if (rest[0] !== unit.homeR || rest[1] !== unit.homeC) at = rest;
}
const k = at ? at[0] * COLS + at[1] : unit.homeR * COLS + unit.homeC;
const occ = this._occ[k];
if (occ && occ.alive && occ !== unit) return false;
if (at && this.downOn(at[0], at[1], unit)) return false;
const ps = this.getPlayer(unit.ownerId);
const cost = unit.base.cost;
let paid = 0;
if (!free) {
if (!ps || ps.dp + 1e-9 < cost) return false;
paid = Math.min(ps.dp, cost);
ps.dp = Math.max(0, ps.dp - cost); // paid before `deploy` fires (handlers see the new DP)
}
const sk = unit.skill;
const keep = keepSp && sk && !sk.noSkill && sk.kind !== 'passive' ? { sp: sk.sp, charges: sk.charges } : null;
if (this._deploy(unit, { initial: false, tile: at, keepSp: keep })) return true;
if (paid > 0 && ps) ps.dp = Math.min(this.flags.dpMax, ps.dp + paid);
return false;
}
/**
* Automatic redeploys (DESIGN §5.5): an operator that left the field and whose timer is done comes back on its rest
* tile — where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile
* is free and its player has the DP.
*/
_checkRedeploys() {
for (const u of this.allyUnits) {
if (u.alive || u.removed || u.kind !== 'op') continue;
if (this.time + 1e-9 < u.respawnAt) continue;
// the DP first: an operator past its timer mostly waits for DP, and the tile checks scan every ally
const ps = this.getPlayer(u.ownerId);
const cost = u.base.cost;
if (!ps || ps.dp + 1e-9 < cost) continue;
const [r, c] = this.restTile(u);
const occ = this._occ[r * COLS + c];
if (occ && occ.alive && occ !== u) continue;
if (this.downOn(r, c, u)) continue;
ps.dp = Math.max(0, ps.dp - cost);
this._deploy(u, { initial: false, tile: r === u.homeR && c === u.homeC ? null : [r, c] });
}
}
/** Leak: an enemy reached its goal. */
leak(e) {
if (!e || !e.alive || e.side !== 'enemy') return;
this._recordLeak(e, false);
this._remove(e, 'leak');
this._ev(['leak', e.id]);
if (this._hooks.enemyLeak) this.emit('enemyLeak', { enemy: e });
}
_recordLeak(e, timeout) {
const owner = timeout ? e.ownerId : this._ownerForTile([Math.round(e.y), Math.round(e.x)]) ?? e.ownerId;
const pp = this._pp(owner) ?? this._pp(e.ownerId);
if (pp) {
pp.leaked.push({ enemyKey: e.defId, mods: e.mods ?? null, lpr: e.lpr ?? 1, sourcePlayerId: e.sourcePlayerId ?? e.ownerId, tag: e.tag ?? null, counted: !!e.counted || e.isBoss, boss: e.isBoss || undefined, spawned: true });
if (e.counted || e.isBoss) pp.perfect = false;
}
if (e.counted) this.leakedCount++;
}
}