Files
ark-stronghold-protocol/server/match/match/platform.js
T
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

214 lines
9.3 KiB
JavaScript

// server/match/match/platform.js — Match methods: the platform interface of the server/match/Match.js header
// (server/lobby.js ⇄ Match) — start, handle (validation, error isolation, flush; the intents themselves: intents.js
// _handle), setLoadout (INFO_CHECK only), onDisconnect / onReconnect and the full resync of one human, onLeave and 中途退出
// = elimination (_quit), dispose.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { C2S } from '../../../shared/protocol.js';
import { PHASE, ERR } from '../../../shared/constants.js';
import { syntheticResult } from '../fields.js';
import { FLOW_TICKER_PRIORITY, OK, fail } from './common.js';
import { msg } from '../../../shared/i18n.js';
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.'))));
export class MatchPlatform {
start() {
if (this.disposed || this.ended || this.phase !== PHASE.LOBBY) return;
this.guard(() => {
if (!this.gd.visibleChess.length || this.pool.entries.size === 0) {
this.log.error?.(`[match ${this.roomCode}] game data unusable (no chess pool) — ending the match`);
this.phase = PHASE.INFO_CHECK;
this.markPublic();
this.flush(true);
this.finish({ victory: false, reason: 'error' });
return;
}
this.enterInfoCheck();
});
}
/**
* @param {string} playerId
* @param {{ t: string }} msg validated intent
* @returns {{ ok: true } | { error: string, detail?: string }}
*/
handle(playerId, msg) {
const ps = this.players.get(playerId) || this.spectators.get(playerId);
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
// a spectator seat only watches (the platform routes nothing else of it)
if (ps.spectator && (!msg || msg.t !== 'g.watch')) return fail(ERR.SPECTATOR);
if (this.disposed || this.ended) {
// a battle report that crossed the match end (the last b.progress of a field) is stale: ignored, never an error
// (DESIGN §14 — an error frame without a rid would surface as a toast in the browser)
return this.clientCombat && msg && (msg.t === 'b.progress' || msg.t === 'b.result') ? OK : fail(ERR.WRONG_PHASE);
}
if (!msg || typeof msg !== 'object' || typeof msg.t !== 'string' || !GAME_TYPES.has(msg.t)) return fail(ERR.BAD_MSG);
let res;
try {
res = this._handle(ps, msg);
} catch (e) {
this.reportError(`handle ${msg.t}`, e);
res = fail(ERR.INTERNAL);
}
try { this.flush(); } catch (e) { this.reportError('flush', e); }
if (res && typeof res === 'object' && res.error) return res;
return OK;
}
/**
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
* already checked it against the data (PlayerState.setLoadout re-checks it). `ops` (0.2.2): the per-operator 潜能 /
* 练度 that come with it (undefined = unchanged).
* @param {string} playerId
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
* @param {Record<string, { potential: number, cultivate: number }> | null} [ops]
* @returns {{ ok: true } | { error: string, detail?: string }}
*/
setLoadout(playerId, loadout, ops = undefined) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
let res = OK;
this.guard(() => {
if (!ps.setLoadout(loadout, ops)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
this.markPrivate(ps);
});
return res;
}
onDisconnect(playerId) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || this.disposed) return;
this.guard(() => {
ps.connected = false;
// A disconnected human's silence is never consent to a new setup.
this.cancelSetupVote();
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
this._resume();
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
this.markPublic();
});
}
onReconnect(playerId) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || this.disposed) return;
this.guard(() => {
const was = ps.connected;
ps.connected = true;
this._resync(ps);
if (!was) this.markPublic();
});
}
/**
* The full state of one human (a reconnect, a resync, a spectator seat): m.public, its m.private (players only), the
* field it is on / watches — a spectator, like an eliminated player, the field of the player it follows (item 56),
* else the first; in a prep phase that player's board — or the result once ended.
*/
_resync(ps) {
const playerId = ps.playerId;
this.sendTo(playerId, this.publicView());
if (!this.ended) {
if (!ps.spectator) {
ps._lastPriv = null;
this._sendPrivate(ps, true);
}
if (this.clientCombat) this._resendBattle(ps);
else if (!this.fields.length && this._follows(ps)) this._followScout(ps, { keep: true });
else {
let fid = this.watchers.get(playerId);
if (!fid && ps.spectator && this.fields.length) { fid = (this._watchTargetField(ps, this.fields) || this.fields[0]).fieldId; this.watchers.set(playerId, fid); }
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
this.sendTo(playerId, { ...this.lastResultMsg, playerId });
}
}
onLeave(playerId) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || this.disposed) return;
this.guard(() => {
ps.left = true;
this.cancelSetupVote();
ps.connected = false;
ps.autoplay = false;
this.watchers.delete(playerId);
if (this.ended) return;
this._resume();
if (this.clientCombat) this._authorityLost(ps, 'left');
this.markPublic();
if (!this.order.some((p) => !p.isBot && !p.left)) {
this.finish({ victory: false, reason: 'abandoned' });
return;
}
this._quit(ps);
});
}
/**
* 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 / §10.3): every copy the player holds goes back
* to the shared pool at once, and the seat has no place in later rounds, the Final Assault pairing or the boss pool
* (bloodPoint per player alive at the fight's start, DESIGN §25.13.4). Rounds passed = the rounds the player had
* survived when leaving.
*/
_quit(ps) {
this.maybeEndInfo();
if (!ps.alive) return;
const phase = this.phase;
const d = this.draft;
if (phase === PHASE.BAND_DRAFT && d && !d.picks[ps.playerId]) {
// the departed seat passes its turn with the default band (never one a teammate holds — defaultBand)
const turn = this.draftTurn() === ps.playerId;
d.picks[ps.playerId] = this.defaultBand(ps.playerId);
ps.bandId = d.picks[ps.playerId];
if (turn) this.startDraftTurn();
}
const passedRound = phase === PHASE.SETTLE ? this.round + 1 : Math.max(1, this.round);
// its own normal battle has nobody left to fight for
for (const f of this.fields) {
if (f.live && f.kind === 'normal' && f.players.length === 1 && f.players[0] === ps.playerId) {
if (this.clientCombat && f.cc) {
if (!f.result) f.result = syntheticResult(f.players);
this._fieldDone(f);
continue;
}
try { f.battle.forceEnd('left'); } catch (e) { this.reportError('quit forceEnd', e); }
}
}
ps.lp = 0;
ps.eliminate(passedRound);
this.tickerText(msg('{name}博士中途退出了模拟', { name: ps.name }), FLOW_TICKER_PRIORITY);
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
this._planBossWaves();
for (const p of this.alivePlayers()) p.recompute();
}
// whoever scouted the departed player's board follows the next player still in (item 56)
for (const v of this._viewers()) if (this.watchers.get(v.playerId) === `n:${ps.playerId}`) this._followScout(v);
this.markPublic();
if (this.teamLp != null) this._syncTeamLp();
if (!this.alivePlayers().length) {
// only eliminated spectators are left
this.finish({ victory: false, reason: 'eliminated' });
return;
}
if (phase === PHASE.SP_DRAFT && this.sp && this.spTurn() === ps.playerId) this.startSpTurn();
else if (phase === PHASE.PREP) this.maybeEndPrep();
}
dispose() {
if (this.disposed) return;
this.disposed = true;
if (this.runner) { try { this.runner.stop(); } catch { /* ignore */ } }
this._stopClientCombat();
for (const h of this._timers) { try { this.sched.clearTimeout(h); } catch { /* ignore */ } }
this._timers.clear();
if (this._pubTimer) { try { this.sched.clearTimeout(this._pubTimer); } catch { /* ignore */ } this._pubTimer = null; }
if (this.ownsScheduler) this.sched.dispose();
}
}