Files
ark-stronghold-protocol/server/match/match/combat.js
T

167 lines
7.7 KiB
JavaScript

// server/match/match/combat.js — Match methods: the battles of a normal round — Battle construction (newBattle, the
// spawn list sanitised, a player's normal field after the onBattleStart handlers), the COMBAT phase in both modes
// (server-run streaming, client-side combat), the per-field results and the sim-error log, then 联防 or SETTLE.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { withBounties } from '../waves.js';
import { planUnite } from '../unite.js';
import { FieldRunner, DeadBattle } from '../fields.js';
import { DELAYS } from './common.js';
export class MatchCombat {
/** Construct a battle; a constructor failure yields a finished stand-in (clean result) and is logged. */
newBattle(opts) {
const full = { data: this.ds, content: this.battleContent, logger: this.log, ...opts };
try {
return new this.BattleClass(full);
} catch (e) {
this.reportError(`battle ${opts.fieldId} construct`, e);
return new DeadBattle(full, 'forced');
}
}
/**
* Spawn list of a field after the onBattleStart handlers (which may edit `ev.spawns`): malformed entries are dropped
* and a kill bounty is copied into `mods.bountyCoins`, so a leaked bounty enemy keeps paying its killer in 联防.
*/
_sanitizeSpawns(list, ownerPlayerId = null) {
const out = [];
for (const s of Array.isArray(list) ? list : []) {
if (!s || typeof s !== 'object' || typeof s.enemyKey !== 'string' || !this.gd.enemy(s.enemyKey)) continue;
const spec = { ...s, mods: s.mods && typeof s.mods === 'object' ? { ...s.mods } : {} };
if (ownerPlayerId && spec.ownerPlayerId == null) spec.ownerPlayerId = ownerPlayerId;
const coins = spec.bounty && typeof spec.bounty === 'object' ? Math.trunc(Number(spec.bounty.coins) || 0) : 0;
if (coins > 0 && spec.mods.bountyId == null && spec.mods.bountyCoins == null) spec.mods.bountyCoins = coins;
out.push(spec);
}
return out;
}
_normalBattle(ps) { return this.newBattle(this._normalOpts(ps)); }
/** Battle options of a player's normal field (after the onBattleStart handlers). */
_normalOpts(ps) {
const wave = this.wave;
const input = ps.battleInput({ side: 'L', colOffset: 0 });
// the client's list: bounty units inserted among their host action's own units, which are re-timed around them
const spawns = withBounties(this.gd, this.round, wave, ps.bounties, ps.playerId).map((s) => ({ ...s, ownerPlayerId: ps.playerId }));
const ev = { input, kind: 'normal', round: this.round, spawns };
this.dispatch(ps, 'onBattleStart', ev);
return {
seed: deriveSeed(this.seed, `n:${this.round}:${ps.seat}`),
kind: 'normal',
modeId: this.modeId,
round: this.round,
stageId: this.stageId,
rect: { ...GEO.NORMAL_RECT },
timeLimit: wave.timeLimit,
players: [ev.input && typeof ev.input === 'object' ? ev.input : input],
spawns: this._sanitizeSpawns(Array.isArray(ev.spawns) ? ev.spawns : spawns, ps.playerId),
routes: wave.routes,
sharedBoss: null,
flags: { layerGainsEnabled: true, ...this.gd.dp },
fieldId: `n:${ps.playerId}`,
enemyOverrides: wave.overrides,
waveId: wave.templateId,
};
}
startCombat() {
if (this.clientCombat) { this._startCombatClient(); return; }
this.phase = PHASE.COMBAT;
const alive = this.alivePlayers();
this.lastResults = new Map();
this.fields = alive.map((ps) => ({ fieldId: `n:${ps.playerId}`, kind: 'normal', players: [ps.playerId], battle: this._normalBattle(ps), live: true }));
const limit = this.wave ? this.wave.timeLimit : 60;
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._defaultWatch();
this.markPublic();
this.runner = new FieldRunner(this, this.fields, { onDone: (runner) => this.combatDone(runner) });
this.runner.start();
}
_startCombatClient() {
this.phase = PHASE.COMBAT;
const alive = this.alivePlayers();
this.lastResults = new Map();
this.watchers.clear();
const fields = alive.map((ps) => this._ccField({ fieldId: `n:${ps.playerId}`, kind: 'normal', players: [ps.playerId], opts: this._normalOpts(ps) }));
const limit = this.wave ? this.wave.timeLimit : 60;
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._launch(fields);
// every fighting human runs its own field; eliminated humans (and spectator seats) keep watching (research 09 §3.1
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the first field
for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left) continue;
this.watchers.set(pid, f.fieldId);
this._sendStart(pid, f);
}
const first = fields.find((f) => f.mode === 'client') || fields[0] || null;
if (first) {
for (const ps of this._viewers()) {
if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true });
}
}
this.markPublic();
}
/** Aggregate a finished field's content/engine errors (battle.errors: unique records) for diagnostics. */
_collectSimErrors(f, res) {
this.simErrors += Number(res && res.errors) || 0;
const list = f && f.battle && Array.isArray(f.battle.errors) ? f.battle.errors : [];
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const key = `${e.label}|${e.who || ''}|${e.message}`;
let rec = this.simErrorLog.get(key);
if (!rec) {
if (this.simErrorLog.size >= 500) continue;
rec = { label: String(e.label), who: String(e.who || ''), message: String(e.message), stack: e.stack ? String(e.stack) : null, battles: 0, count: 0 };
this.simErrorLog.set(key, rec);
}
rec.battles++;
rec.count++;
}
}
combatDone(runner) { this._finishCombat((f) => runner.resultOf(f)); }
/** Every normal field has its result: record them, then 联防 or SETTLE. */
_finishCombat(resultOf) {
if (this.phase !== PHASE.COMBAT) return;
this._stopClientCombat();
for (const f of this.fields) {
const res = resultOf(f);
this._collectSimErrors(f, res);
for (const pid of f.players) {
const pp = res.perPlayer && res.perPlayer[pid];
this.lastResults.set(pid, pp || { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, unitsEnd: [], unitStats: [] });
// the views show the layers the battle reached until settle() makes them persistent (DESIGN §20.15)
const ps = this.players.get(pid);
const gains = pp && pp.layerGains && typeof pp.layerGains === 'object' ? pp.layerGains : null;
if (ps && gains && Object.keys(gains).length) { ps.pendingLayerGains = { ...gains }; ps.dirty(); }
}
}
for (const f of this.fields) f.live = false;
this.deadline = 0;
this.markPublic();
this.later(this.scaled(DELAYS.COMBAT_END), () => this._afterCombat());
}
/**
* After the COMBAT_END pause: 联防 or SETTLE. The plan is made now, from the players still in — a player who quit
* during the last field or the pause (中途退出 = eliminated, board cleared) is neither a helper nor a leaker whose
* enemies re-enter.
*/
_afterCombat() {
if (this.phase !== PHASE.COMBAT) return;
const plan = planUnite(this, this.lastResults);
if (plan) this.startUnite(plan);
else this.settle(null, null);
}
}