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

234 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/match/match/clientCombat.js — Match methods: client-side combat bookkeeping (DESIGN §14) — field records (the
// JSON BattleSpec + the authority / result state), spec battles on the server, the field clock, the authority (the
// lowest connected seat), b.start per recipient, result deadlines and releases, server runs (headless slices,
// takeovers), field completion → the phase end, and the timers of it all.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE } from '../../../shared/constants.js';
import { DeadBattle, syntheticResult, RESULT_GRACE_MS, HARD_CAP_SECONDS, HeadlessJob } from '../fields.js';
import { buildBattleSpec, createBattleFromSpec } from '../../sim/spec.js';
export class MatchClientCombat {
/** Stop every client-combat timer and the headless pacer (phase end, finish, dispose). */
_stopClientCombat() {
if (this._progressTimer) { this.cancel(this._progressTimer); this._progressTimer = null; }
if (this._bossClock) { this.cancel(this._bossClock); this._bossClock = null; }
this._bossClockOn = false;
// a pending throttled boss refresh: the merged team LP is written back now (m.result / settlement read it)
if (this._bossPubTimer) { this.cancel(this._bossPubTimer); this._bossPubTimer = null; this._syncTeamLp(); this.markPublic(); }
this._clearPause();
if (this._poolTimer) { this.cancel(this._poolTimer); this._poolTimer = null; }
if (this.pacer) { try { this.pacer.stop(); } catch { /* ignore */ } this.pacer = null; }
for (const f of this.fields) this._clearFieldTimers(f);
}
_clearFieldTimers(f) {
if (!f || !f.cc) return;
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
f.job = null;
}
/** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */
_ccField({ fieldId, kind, players, opts, boss = null }) {
const seq = `${this.battlePrefix}.${this.round}.${++this._battleSeq}`;
// protocol ids are ≤ 64 chars (shared/protocol.js isId): the field id is informational, the sequence is unique
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
let total = 0;
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
return {
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
};
}
/** A battle built from a spec on the server (headless / takeover / verification); never throws. */
_specBattle(spec, { sharedBoss = null } = {}) {
try {
return createBattleFromSpec(spec, this.ds, { BattleClass: this.BattleClass, sharedBoss, logger: this.log, recordEvents: false });
} catch (e) {
this.reportError(`battle ${spec && spec.fieldId} construct`, e);
return new DeadBattle({ fieldId: spec && spec.fieldId, kind: spec && spec.kind, players: (spec && spec.players) || [], rect: spec && spec.rect, stageId: spec && spec.stageId }, 'forced');
}
}
/** Game seconds a field has run (its clock; a finished field: its final time). */
_fieldElapsed(f) {
if (f.done && f.result && Number.isFinite(f.result.time)) return f.result.time;
const gt = Math.max(0, ((this._clockNow() - f.startAt) / 1000) * this.gameSpeed);
if (f.endGt != null) return Math.min(gt, f.endGt);
const lim = f.spec && f.spec.timeLimit > 0 ? f.spec.timeLimit : HARD_CAP_SECONDS;
return Math.min(gt, lim);
}
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
_authorityFor(f, exclude = null) {
let best = null;
for (const pid of f.players) {
if (pid === exclude || (f.demoted && f.demoted.has(pid))) continue;
const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left || !ps.connected) continue;
if (!best || ps.seat < best.seat) best = ps;
}
return best ? best.playerId : null;
}
/** b.start of a field for one recipient (`watch`: not a player of the field). */
_startMsg(f, pid, { watch = false } = {}) {
return {
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind,
spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec,
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
};
}
_sendStart(pid, f, opts = {}) {
const ps = this.players.get(pid) || this.spectators.get(pid);
if (!ps || ps.isBot || ps.left || !ps.connected) return false;
return this.sendTo(pid, this._startMsg(f, pid, opts));
}
/** Give every field its authority (a connected human) or run it on the server. */
_launch(fields) {
const now = this.sched.now();
this.fields = fields;
for (const f of fields) { f.startAt = now; f.lastProgressAt = now; }
for (const f of fields) {
const auth = this._authorityFor(f);
if (auth) this._assignClient(f, auth);
else this._runOnServer(f, 'no-human');
}
}
_assignClient(f, pid) {
f.mode = 'client';
f.authority = pid;
f.lastProgressAt = this.sched.now();
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
if (f.kind === 'boss' || f.kind === 'hidden') return; // the pool / team LP / silence watchdog end those
if (this.paused) { f.rearmDeadline = true; return; }
this._armDeadline(f);
}
/** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */
_armDeadline(f) {
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60;
const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS;
f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => {
f.deadlineTimer = null;
if (f.done || f.mode !== 'client') return;
this._runOnServer(f, 'timeout');
});
}
/** A server-run normal / 联防 field's result is released at the battle's natural end on the field clock. */
_armRelease(f) {
if (f.doneTimer) { this.cancel(f.doneTimer); f.doneTimer = null; }
if (f.done || f.result == null) return;
if (this.paused) { f.rearmRelease = true; return; }
const doneAt = f.startAt + Math.round(((f.endGt || 0) / this.gameSpeed) * 1000);
const wait = this.sched.instant ? 0 : Math.max(0, doneAt - this.sched.now());
f.doneTimer = this.later(wait, () => { f.doneTimer = null; this._fieldDone(f); });
}
/**
* The server simulates a field: normal / 联防 headlessly at once (the result is released at the battle's natural end
* on the field's clock, so the teammates' progress UI and the round pacing stay as if it ran live); boss fields in
* real time on the pacer (they share the pool), fast-forwarded to the field's clock on a takeover.
*/
_runOnServer(f, reason) {
const prev = f.mode === 'client' ? f.authority : null;
if (prev) {
this.verifyStats.takeovers++;
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId} R${this.round}: server takeover from ${prev} (${reason})`);
}
f.mode = 'server';
f.authority = null;
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
// the former authority (still online after a timeout / an invalid result) stops reporting and keeps its view
if (prev) this.sendTo(prev, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason: 'takeover' });
if (f.kind === 'boss' || f.kind === 'hidden') { this._bossServerRun(f); return; }
const job = new HeadlessJob(this._specBattle(f.spec), { onError: (e) => this.reportError(`field ${f.fieldId} step`, e), players: f.players });
f.job = job;
f.battle = job.battle;
f.timeline = job.timeline; // grows while the job runs (the teammates' progress UI reads it on the field clock)
if (f.sliceTimer) { this.cancel(f.sliceTimer); f.sliceTimer = null; }
const complete = () => {
if (f.job !== job || f.done) return;
f.job = null;
const run = job.output();
f.battle = run.battle;
f.result = run.result;
f.resultSource = 'server';
f.timeline = run.timeline;
f.endGt = Number(run.battle.time) || 0;
this._armRelease(f);
};
if (!Number.isFinite(this.headlessSliceMs)) {
job.run(Infinity);
complete();
} else {
// a real host: wall-clock-bounded slices in callbacks of their own (3 bot fields at combat start would otherwise
// block the event loop for ~0.2–0.5 s here, seconds on a low-power mini PC)
const slice = () => {
f.sliceTimer = null;
if (f.job !== job || f.done) return;
if (job.run(this.headlessSliceMs)) complete();
else f.sliceTimer = this.later(0, slice);
};
f.sliceTimer = this.later(0, slice);
}
this._armProgressTicker();
}
/** m.public ~1 Hz while server-run fields progress along their timelines. */
_armProgressTicker() {
if (this._progressTimer || this.sched.instant) return;
const tick = () => {
this._progressTimer = null;
if (!this.fields.some((f) => f.cc && f.mode === 'server' && !f.done && f.timeline)) return;
this.markPublic();
this._progressTimer = this.later(1000, tick);
};
this._progressTimer = this.later(1000, tick);
}
/** A field has its final result. */
_fieldDone(f) {
if (!f || f.done) return;
f.done = true;
f.live = false;
this._clearFieldTimers(f);
if (!f.result) f.result = syntheticResult(f.players, { bossBy: f.bossBy });
f.progress.done = true;
this.markPublic();
this._maybeFieldsDone();
}
_maybeFieldsDone() {
if (!this.fields.length || this.fields.some((f) => f.cc && !f.done)) return;
if (!this.fields.every((f) => f.cc)) return;
if (this.phase === PHASE.COMBAT) this._finishCombat((f) => f.result);
else if (this.phase === PHASE.UNITE) this._finishUniteClient();
else if (this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE) this._finishFinal(this.phase === PHASE.HIDDEN_CORE, (f) => f.result);
}
/** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */
_authorityLost(ps, why) {
for (const f of this.fields) {
if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue;
if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why);
else this._runOnServer(f, why);
}
}
}