Files
ark-stronghold-protocol/server/match/match/combat.js
T
sganggs e8d2b45ad4 feedback5 CB5: an eliminated player (and a spectator seat) follows a player through every phase reset — the one it last watched, else the first player still in — instead of being sent back to its own empty board each round (community report of 2026-10-06, item 56; the idea of PR #189)
Official: research 09 §3.1 — after elimination the client keeps observing (self_dead_dialog KEEP_WATCH,
DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget). Until 0.2.0 every phase reset cleared
an eliminated player's watch: in 回合开始 / 机变 / 休整期 it saw its own empty board, in battle the first field.

Server (server/match/match/watch.js): Match.watchPref (viewer → the player of its last manual g.watch: a prep
scout, a single-player field, or the new optional g.watch playerId on a shared 联防 / boss pair field — PR
#189's review point); _watchTargetOf = that player while still in, else the first player still in (seat
order, AI seats included); startRound scouts its board (_followScout, kept through 机变 and 休整期), both
combat modes and the boss fields start on its field, a resync / a spectator joining in prep gets the board,
a followed player who quits hands over at once. Client: the screen adopts the pushed board like a 前往查看 tap
(battle/observe.js followedScout; the stale-field fix of PR #189), keeps it through the prep phases (no camera
flight home and back), a spectator no longer asks for the first player itself; g.watch sends the tapped row.
Overlap with PR #189 (not merged): the same preference chain; differences — the fallback includes AI seats
(a lone human with AI teammates had nobody to follow), shared fields record the tapped playerId.

Golden unchanged. Tests: test/match/feedback5-watch-follow.test.js (new, 9), spectator.test.js updated (the
spectator follows the player it scouted). Docs: design/network.md, PLAYING.md.
2026-10-07 02:06:10 +08:00

169 lines
7.9 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 field
// of the player they follow — the one they last watched, else the first player still in (item 56) — else the first
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;
const f = this._watchTargetField(ps, fields) || first;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { 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);
}
}