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

218 lines
10 KiB
JavaScript

// server/match/match/watch.js — Match methods: watching (research 09 §3.1 / §6.3) — spectator seats (community report
// #26: stand-ins shown fields like an eliminated player), g.watch in both combat modes (the boss-group rule, prep
// scouting of a teammate's board), who is shown which field (the default watch, the boss fields, the resend on a
// reconnect, the spectator's spec without the funds), a field's m.field + b.snap (server-run mode) and the prep-scout
// pushes (GitHub #87).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, ERR } from '../../../shared/constants.js';
import { boardOrder, pieceDir } from '../board.js';
import { snapFrame } from '../fields.js';
import { OK, fail } from './common.js';
export class MatchWatch {
/**
* A spectator seat (community report #26; server/lobby.js spectate) joined during the match, came back or asked for a
* resync: registered once, then resent what an eliminated player watching sees (_resync — never an m.private).
*/
addSpectator(playerId) {
if (this.disposed) return;
const s = this._spectator(playerId);
if (s) this.guard(() => this._resync(s));
}
/** The spectator left (room.leave / g.leave, removed by the host, reconnect window expired). */
removeSpectator(playerId) {
if (this.spectators.delete(playerId)) this.watchers.delete(playerId);
}
/** The stand-in of a spectator seat, created once (null for a player's id or a bad id). */
_spectator(playerId) {
if (typeof playerId !== 'string' || !playerId || this.players.has(playerId)) return null;
let s = this.spectators.get(playerId);
if (!s) {
s = Object.freeze({ playerId, isBot: false, left: false, alive: false, connected: true, spectator: true });
this.spectators.set(playerId, s);
}
return s;
}
/** Everyone shown fields: the seated humans still in (seat order), then the spectator seats' stand-ins. */
_viewers() {
const out = this.order.filter((ps) => !ps.isBot && !ps.left);
for (const s of this.spectators.values()) out.push(s);
return out;
}
watch(ps, fieldId) {
if (typeof fieldId !== 'string') return fail(ERR.BAD_TARGET);
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId);
const f = this.fields.find((x) => x.fieldId === fieldId);
if (f) {
// 最终攻势 / 隐秘核心: "两名参与者会处于同一个战场,但无法查看另一组队友的战场情况" — a fighting player sees its own
// boss field only (eliminated / departed players spectate freely)
const own = this.fieldOf(ps);
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f.fieldId) return fail(ERR.BAD_TARGET, 'other group hidden');
this.watchers.set(ps.playerId, fieldId);
this._sendField(ps.playerId, fieldId);
return OK;
}
if (fieldId.startsWith('n:')) {
// prep scouting, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:<pid>' id
// must name a live field (else the boss-group rule above could be bypassed, and the viewer would stop receiving
// its own field's snapshots). The scout stays in `watchers` so a later board change pushes prepFieldMeta again
// (GitHub #87); combat start clears the map and reassigns live fields, spectators included.
if (this.fields.length) return fail(ERR.BAD_TARGET, 'no such field');
const target = this.players.get(fieldId.slice(2));
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
this.watchers.set(ps.playerId, fieldId);
this._notifyPrepScouts(target, { to: ps.playerId });
return OK;
}
return fail(ERR.BAD_TARGET);
}
/**
* g.watch under client-side combat: the watcher gets the field's spec (b.start, display only) and runs a local
* replica fast-forwarded to the field's clock. No watching while the own normal battle runs; the other pair's boss
* field is never shown to a fighting player; eliminated players may watch anything.
*/
_watchClient(ps, fieldId) {
const f = this.fields.find((x) => x.fieldId === fieldId) || null;
if (!f) return fail(ERR.BAD_TARGET, 'no such field');
const own = this.fields.find((x) => x.players.includes(ps.playerId)) || null;
if (ps.alive) {
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f) return fail(ERR.BAD_TARGET, 'other group hidden');
if (f.kind === 'normal' && own && own !== f && own.live) return fail(ERR.WRONG_PHASE, 'own battle running');
}
this.watchers.set(ps.playerId, f.fieldId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
return OK;
}
/** Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client). */
_resendBattle(ps) {
if (!this.fields.some((f) => f.cc)) return;
const fid = this.watchers.get(ps.playerId);
let f = fid ? this.fields.find((x) => x.fieldId === fid) : null;
if (!f) f = this.fields.find((x) => x.players.includes(ps.playerId)) || (this.phase === PHASE.UNITE ? this.fields[0] : null);
if (!f && !ps.alive) f = this.fields[0] || null;
if (!f) return;
this.watchers.set(ps.playerId, f.fieldId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
}
_defaultWatch() {
this.watchers.clear();
for (const ps of this._viewers()) {
const own = this.fields.find((f) => f.players.includes(ps.playerId));
const f = own || this.fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
if (ps.connected) this._sendField(ps.playerId, f.fieldId);
}
}
/** b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the first. */
_watchBossFields(fields) {
for (const ps of this._viewers()) {
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
const f = own || fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { watch: !own });
}
}
/**
* The spec a spectator seat is shown: the field's own, minus the players' `contentInfo.funds` — a private number (the
* player's funds at the battle start) that no battle effect reads, so the replica still plays the same battle. The
* other contentInfo counters stay: battle effects read them (sim/content: handUnits, roundStats.gainedChess).
*/
_spectatorSpec(f) {
if (!f.spectatorSpec) {
const s = f.spec;
const strip = (p) => {
if (!p || !p.contentInfo || !Object.hasOwn(p.contentInfo, 'funds')) return p;
const { funds, ...contentInfo } = p.contentInfo;
void funds;
return { ...p, contentInfo };
};
f.spectatorSpec = s && Array.isArray(s.players) ? { ...s, players: s.players.map(strip) } : s;
}
return f.spectatorSpec;
}
/** Humans currently shown a field (its players and its watchers, spectator seats included). */
_humansShowing(f) {
const out = new Set();
for (const pid of f.players) out.add(pid);
for (const [pid, fid] of this.watchers) if (fid === f.fieldId) out.add(pid);
return [...out].filter((pid) => { const ps = this.players.get(pid) || this.spectators.get(pid); return ps && !ps.isBot && !ps.left; });
}
watchersOf(fieldId) {
const out = [];
for (const [pid, fid] of this.watchers) if (fid === fieldId) out.push(pid);
return out;
}
_sendField(playerId, fieldId) {
const f = this.fields.find((x) => x.fieldId === fieldId);
if (f) {
let meta;
try { meta = f.battle.fieldMeta(); } catch (e) { this.reportError('fieldMeta', e); return; }
this.sendTo(playerId, { t: 'm.field', ...meta, fieldId: f.fieldId, kind: f.kind, live: !!f.live });
try { this.sendTo(playerId, snapFrame(f.fieldId, f.battle.snapshot())); } catch (e) { this.reportError('snapshot', e); }
return;
}
if (typeof fieldId === 'string' && fieldId.startsWith('n:')) {
const target = this.players.get(fieldId.slice(2));
if (target && target.alive) this.sendTo(playerId, this.prepFieldMeta(target));
}
}
/** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change).
* Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a
* change (review of PR #129). */
_prepScoutSig(ps) {
const parts = [];
for (const { r, c, piece } of boardOrder(ps.board)) {
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
}
for (let i = 0; i < ps.hand.length; i++) {
const piece = ps.hand[i];
if (!piece) continue;
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`);
}
for (let i = 0; i < ps.temp.length; i++) {
const piece = ps.temp[i];
if (!piece) continue;
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`);
}
return parts.join(';');
}
/**
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
*/
_notifyPrepScouts(ps, { to = null } = {}) {
if (!ps || !ps.alive || this.fields.length) return;
const fid = `n:${ps.playerId}`;
const watchers = this.watchersOf(fid);
if (!watchers.length) return;
const sig = this._prepScoutSig(ps);
const changed = sig !== ps._prepScoutSig;
ps._prepScoutSig = sig;
const dest = changed ? watchers : (to ? [to] : []);
if (!dest.length) return;
const meta = this.prepFieldMeta(ps);
for (const pid of dest) this.sendTo(pid, meta);
}
}