Files
ark-stronghold-protocol/server/sim/battle/lifecycle.js
T

241 lines
13 KiB
JavaScript

// server/sim/battle/lifecycle.js — Battle methods: lifecycle: start (the battle-start deployment: by column, operators
// then summons; 联防 forced exits; `battleStart`), step (the tick order of the Battle.js header), the end checks, timeout
// / forced / cleared finishes and the BattleResult.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { FORCED_EXIT, MAX_BATTLE_TIME } from '../constants.js';
import { updateAlly, updateEnemy } from '../ai.js';
export class BattleLifecycle {
start() {
if (this.started) return;
this.started = true;
this._safe(() => this._spawnStageDevices(), 'stageDevices');
// PRTS 卫戍协议/帮助 §作战阶段: "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)", noted "自卫戍协议:
// 盟约 下半(2026/3/14)起,部署顺序由“从左到右>从下到上”更改为“从上到下>从左到右”" — a scanning order: down each column
// (top first), the columns from left to right; act 1's along each row, the rows from the bottom. It is the order PRTS
// gives the 阿戈尔 devour ("从最先部署(更靠左和靠上的)的【阿戈尔】干员开始", content/bonds/core.js egirOrder) and the one
// act-2 videos show. Per player the operators, left column first, top to bottom within a column (the mirrored right
// boss side from its own left, i.e. the highest field column — PRTS "部署顺序…左右镜像", research 01 §4.3), then the
// summon pieces in the same order. Until 0.1.3 the top row came first (row-major). [ASSUMED] On a shared field (联防,
// boss) the players' fields deploy at the same time (PRTS: one unit after another with a fixed delay, from the battle
// start), so the i-th operators of all players come in together (in `players` order), then the summons likewise
const seq0 = this._deploySeq;
const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
(ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || b.homeR - a.homeR || a.id - b.id));
// a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone
// piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js
// dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it
// (_startDeploying: a board piece content brings in during this deployment — a tactician's 流形 / 狼群 comes with
// its owner's deploy — takes its 联防 carry on that first deployment too)
this._startDeploying = true;
for (const kind of ['op', 'token']) {
const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy));
const n = Math.max(0, ...per.map((l) => l.length));
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
}
this._startDeploying = false;
// 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the
// initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon)
// — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came
const summons = this.allyUnits.filter((u) => u.kind === 'token' && u.alive && u.deployed && u.deploySeq > seq0).sort((a, b) => a.deploySeq - b.deploySeq);
for (const t of summons) t.aggroSeq = ++this._deploySeq;
// 联防 (PRTS 卫戍协议/帮助 "部署完成后…上一阶段为退场状态的干员强制退场"): an operator knocked out at the end of its
// own combat is withdrawn right after the deployment — down on its tile, its redeploy timer starting now (re-read
// after battleStart below; DESIGN §5.5). Its HP ratio is the end-of-phase one (0, as after kill()) until it
// redeploys at full HP.
for (const u of this.allyUnits) {
if (u.kind === 'op' && u.alive && u.carry && u.carry.down === true) {
this._safe(() => { u.hp = 0; this.retreat(u, { reason: FORCED_EXIT }); }, 'forcedExit', u);
}
}
this.emit('battleStart', {});
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
for (const u of this.allyUnits) {
if (u.kind !== 'op' || u.alive || u.removed || u.removeReason !== FORCED_EXIT) continue;
u.respawnAt = u.deathAt + Math.max(0, u.base.respawnTime * u.persist.redeployMul * u.s.redeployMul);
}
}
step() {
if (this.finished || this._stepping) return; // re-entrant step() from a hook is a no-op
// forceEnd() requested while stepping (content hook, repeated engine errors) is deferred to the end of the
// current phase: the remaining phases are skipped and the result is built once, so nothing mutates it later.
this._stepping = true;
try {
if (!this.started) this._phase('start', () => this.start());
const dt = this.dt;
this._phase('scheduled', () => this._runScheduled());
this._phase('spawns', () => this._processSpawns());
this._phase('dp', () => {
for (const ps of this.players) ps.dp = Math.min(this.flags.dpMax, ps.dp + this.flags.dpPerSec * dt);
});
this._phase('buffs', () => this._tickBuffs(dt));
this._phase('enemies', () => {
// enemies spawned by hooks during this loop start moving next tick (a leak hook that spawns an enemy which
// leaks at once would otherwise loop forever inside a single tick)
const list = this.enemies;
for (let i = 0, n = list.length; i < n && !this._endReq; i++) {
const e = list[i];
if (!e.alive) continue;
try { updateEnemy(this, e, dt); } catch (err) { this._internalError('updateEnemy', err); }
this._clampPos(e);
}
});
this._compactEnemies();
this._phase('enemyIndex', () => this._buildEnemyIndex());
this._phase('allies', () => {
const list = this.allyUnits;
for (let i = 0; i < list.length && !this._endReq; i++) {
const u = list[i];
if (!u.alive || !u.deployed) continue;
try {
if (u.skill) u.skill.tick(dt);
if (u.alive && u.kind !== 'device') updateAlly(this, u, dt);
} catch (err) { this._internalError('updateAlly', err); }
}
});
this._phase('projectiles', () => this.projectiles.update(dt));
this._phase('redeploy', () => this._checkRedeploys());
this._phase('boss', () => this._bossSync());
if (this._hooks.tick) this._phase('tickHook', () => this.emit('tick', { dt }));
this._compactEnemies();
if (this._toRelease.length) this._releaseRemoved();
if (!this._endReq && !this.finished) {
this.tickCount++;
this.time = this.tickCount * dt; // no floating drift over long battles
this._phase('endCheck', () => this._checkEnd());
}
} finally {
this._stepping = false;
}
if (this._endReq && !this.finished) {
try { this.forceEnd(this._endReq); } catch (e) { this._handlerError('internal:forceEnd', null, e); this._hardFinish(this._endReq); }
}
}
/** Last-resort finish when building the normal result threw (never throws itself). */
_hardFinish(reason) {
if (this.finished) return;
this.finished = true;
this.reason = reason === 'timeout' ? 'timeout' : 'forced';
this._endReq = null;
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; }
this.projectiles.clear();
}
/** Run until finished or `maxSeconds` of game time elapsed. Returns the result when finished. */
runToEnd(maxSeconds = MAX_BATTLE_TIME) {
const limit = this.time + maxSeconds;
while (!this.finished && this.time < limit) this.step();
return this.finished ? this.result() : null;
}
_phase(name, fn) {
if (this.finished || this._endReq) return;
try { fn(); } catch (e) { this._internalError(name, e); }
}
_checkEnd() {
if (this.finished) return;
if (this.sharedBoss && this.sharedBoss.hp <= 0) { this._finish('cleared'); return; }
// A boss / hidden field with a shared pool ends only when the pool is empty or the match forces an end (DESIGN
// §5.5) — never merely because it emptied: the h07_04 pair leader (official route: no wait) can walk into the
// objective, and the field then idles while another field may still empty the pool or the overtime drain ends it.
const poolHolds = !!this.sharedBoss && (this.kind === 'boss' || this.kind === 'hidden');
if (this.autoFinish && !poolHolds && !this._pending.length && !this.enemies.some((e) => e.alive) && this.started) {
if (!this._hasPendingEnemySchedules()) { this._finish('cleared'); return; }
}
if (this.time >= this.timeLimit - 1e-9) { this._timeout(); return; }
if (this.time >= MAX_BATTLE_TIME) { this._timeout(); return; }
}
_hasPendingEnemySchedules() {
for (const s of this._sched) if (!s.cancelled && s.holdsBattle) return true;
return false;
}
_timeout() {
// Remaining non-boss enemies on the field count as leaked (DESIGN §5.5). Spawns that never happened before the
// limit are dropped: they are removed from `total` and reported in `result().unspawned` (not leaks). A content-made
// neutral (`mem.noLeak`: 隐德来希's 心烛, which follows its original) is never a leak — it is in no spawn schedule, so
// a client result listing it would be rejected (fields.js validateClientResult 'leak key').
for (const e of this.enemies) {
if (!e.alive || e.isBoss || e.mem.noLeak) continue;
this._recordLeak(e, true);
}
this.unspawned = [];
for (const p of this._pending) {
if (p.precounted) {
this.total--;
const pp = this._pp(p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start));
if (pp) pp.total--;
}
this.unspawned.push({ enemyKey: p.enemyKey, time: p.time, tag: p.tag ?? null, sourcePlayerId: p.sourcePlayerId ?? null });
}
this._pending = [];
this._finish('timeout');
}
forceEnd(reason = 'forced') {
if (this.finished) return;
if (this._stepping) { if (!this._endReq) this._endReq = reason; return; } // finalised when step() unwinds
this._endReq = null;
if (!this.started) this.start();
if (this.finished) return;
if (reason === 'timeout') this._timeout();
else this._finish('forced');
}
_finish(reason) {
if (this.finished) return;
this.finished = true;
this.reason = reason;
this._endReq = null;
const res = this._buildResult();
this._result = res;
this.emit('battleEnd', { result: res });
// re-sync fields that battleEnd handlers may have changed (layer gains / coins live in perPlayer objects)
for (const pid of Object.keys(res.perPlayer)) {
const pp = res.perPlayer[pid];
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
}
this.projectiles.clear();
}
_buildResult() {
const perPlayer = {};
for (const ps of this.players) {
const pp = this._perPlayer[ps.playerId];
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
// `skillActive` is reported, never carried.
pp.unitsEnd = ps.units.filter((u) => u.kind === 'op' || (u.kind === 'token' && u.uid != null)).map((u) => ({
uid: u.uid, id: u.id, defId: u.defId,
hpPct: u.alive ? Math.max(0, Math.min(1, u.hp / u.s.maxHp)) : 0,
sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.spTotal * 100) / 100 : 0,
skillActive: !!(u.skill && u.skill.active && u.skill.kind !== 'passive'),
alive: !!u.alive,
}));
pp.unitStats = ps.units.map((u) => ({
id: u.id, uid: u.uid, defId: u.defId, name: u.name, kind: u.kind,
dmg: Math.round(u.stats.dmg), kills: u.stats.kills, heal: Math.round(u.stats.heal), taken: Math.round(u.stats.taken), attacks: u.stats.attacks,
}));
perPlayer[ps.playerId] = pp;
}
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount };
if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned;
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
return res;
}
/** BattleResult (valid once finished; a provisional result before). */
result() {
if (this._result) return this._result;
return this._buildResult();
}
}