The own half was already the official one (the right-hand player on the right half with the
right_boss_*_camera_param camera, its layout mirrored col c -> 20 - c with RIGHT <-> LEFT, so its operators
face the leader as before: research 09 §1.2, DESIGN §15); the partner's half stood empty and nothing framed
the pair.
Server: in a boss round's prep m.private.bossMate { playerId, side, units } = the partner's board as UnitInfo
on its half (Match.bossMateView, finalAssault.js bossFieldPlacement); markPrivate marks the partner's
m.private with the mover's, so it follows live; a scout of either player (Match.prepFieldMeta) carries both
halves (`mate`), and its signature covers the partner's board.
Client: render/app.js syncMates draws the partner's units read-only ('m:<uid>' views: never picked, dragged
or swept with the own pieces) and the whole boss field is lit; the team panel frames the pair's avatars
(gameLogic teamFrameIds: the players still in by seat, two by two, as prepCamera — the boss field in the
fight) from the round start through the Final Assault / Hidden Core, with the official ui/battle
bg_team_border tinted the autochess green (#4ed8af; a plain ring without the local art); none for a lone
player, an eliminated player, a spectator seat, or 联防.
Overlap with PR #191 (same-field frames, issue #190; not merged): it published m.public bossPairing and
framed the partner alone in gold, 联防 included, plus a banner naming the partner; here the pairing is read
as the prep camera already does, both avatars get the official green frame, boss rounds only.
[ASSUMED] the partner's bench is not shown; one frame per avatar.
Golden unchanged. Tests: test/match/feedback5-boss-pair-prep.test.js (new, 4), the item 55 test now checks
the scouted player's own pieces (the partner's stand on the other half); SP_E2E
test/ui/feedback5-boss-pair.e2e.test.js (new, 2: both halves and the frames in prep and fight; item 55's
eliminated guest on the boss field with the leader). Docs: design/client.md §10 / §15, design/network.md,
PLAYING.md.
289 lines
14 KiB
JavaScript
289 lines
14 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). Eliminated humans and spectator seats follow a player through every phase reset (community
|
|
// report of 2026-10-06, item 56; the official DeadAutoObDn { obIndex, state, preparation }, research 09 §3.1; the idea
|
|
// of PR #189): the player they last watched with a manual g.watch (`watchPref`), else the first player still in.
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* g.watch { fieldId, playerId? } — `playerId`: the player the viewer tapped (a shared 联防 / boss pair field shows two,
|
|
* its id does not say which; recorded as the watch preference when that player is on the field).
|
|
*/
|
|
watch(ps, fieldId, playerId = null) {
|
|
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, playerId);
|
|
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._watchPrefSet(ps, f, playerId);
|
|
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);
|
|
if (target !== ps) this.watchPref.set(ps.playerId, target.playerId);
|
|
this._notifyPrepScouts(target, { to: ps.playerId });
|
|
return OK;
|
|
}
|
|
return fail(ERR.BAD_TARGET);
|
|
}
|
|
|
|
/**
|
|
* Record the player a manual watch of field `f` names: `playerId` when it is one of the field's players, else the
|
|
* field's only player — never the viewer itself; a shared field without `playerId` names nobody (PR #189's review).
|
|
* Only manual watches write it: the automatic assignments below read it.
|
|
*/
|
|
_watchPrefSet(ps, f, playerId = null) {
|
|
const players = Array.isArray(f?.players) ? f.players : [];
|
|
const pid = typeof playerId === 'string' && players.includes(playerId) ? playerId : players.length === 1 ? players[0] : null;
|
|
if (pid && pid !== ps.playerId) this.watchPref.set(ps.playerId, pid);
|
|
}
|
|
|
|
/**
|
|
* The player a viewer follows (an eliminated human, a spectator seat): its watch preference while that player is
|
|
* still in, else the first player still in (seat order — the official FindFirstAvailObTarget, AI teammates included:
|
|
* the remake's AI seats are players), never the viewer itself; null when nobody is left.
|
|
*/
|
|
_watchTargetOf(ps) {
|
|
const ok = (pid) => { const t = typeof pid === 'string' && pid !== ps.playerId ? this.players.get(pid) : null; return !!t && t.alive && !t.left; };
|
|
const pref = this.watchPref.get(ps.playerId);
|
|
if (ok(pref)) return pref;
|
|
return this.order.find((q) => ok(q.playerId))?.playerId ?? null;
|
|
}
|
|
|
|
/** The field of the player `ps` follows among `fields` (null when that player has none this phase). */
|
|
_watchTargetField(ps, fields) {
|
|
const pid = this._watchTargetOf(ps);
|
|
return pid ? fields.find((f) => Array.isArray(f.players) && f.players.includes(pid)) || null : null;
|
|
}
|
|
|
|
/** Does `ps` follow a player (an eliminated human or a spectator seat, still in the room)? */
|
|
_follows(ps) {
|
|
return !!ps && !ps.isBot && !ps.left && (ps.spectator || !ps.alive);
|
|
}
|
|
|
|
/**
|
|
* A prep phase (回合开始 / 机变 / 休整期): an eliminated human / spectator seat scouts the board of the player it follows
|
|
* — `keep`: a scout it already has this phase stays (a manual pick, a resync). Until 0.2.0 a phase reset dropped it
|
|
* onto its own empty board every round (community report of 2026-10-06, item 56).
|
|
*/
|
|
_followScout(ps, { keep = false } = {}) {
|
|
if (!this._follows(ps) || this.fields.length) return;
|
|
const cur = keep ? this.watchers.get(ps.playerId) : null;
|
|
const kept = typeof cur === 'string' && cur.startsWith('n:') ? this.players.get(cur.slice(2)) : null;
|
|
const target = kept && kept.alive && !kept.left ? kept : this.players.get(this._watchTargetOf(ps));
|
|
if (!target) return;
|
|
this.watchers.set(ps.playerId, `n:${target.playerId}`);
|
|
if (ps.connected) this._notifyPrepScouts(target, { to: ps.playerId });
|
|
}
|
|
|
|
/**
|
|
* 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, playerId = null) {
|
|
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._watchPrefSet(ps, f, playerId);
|
|
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); an eliminated human /
|
|
* spectator seat the field it watches, else the one of the player it follows. With no client field up (a prep phase):
|
|
* the board of the player it follows (item 56; PR #189's review — a resync in prep used to show nothing).
|
|
*/
|
|
_resendBattle(ps) {
|
|
if (!this.fields.some((f) => f.cc)) { this._followScout(ps, { keep: true }); 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._watchTargetField(ps, this.fields) || 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));
|
|
// an eliminated human / spectator seat: the field of the player it follows (item 56), else the first
|
|
const f = own || this._watchTargetField(ps, this.fields) || 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 field of
|
|
* the player they follow (item 56), else the first.
|
|
*/
|
|
_watchBossFields(fields) {
|
|
for (const ps of this._viewers()) {
|
|
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
|
|
const f = own || this._watchTargetField(ps, fields) || 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). In a boss round's prep the pair partner's board is part of the view (item 51). */
|
|
_prepScoutSig(ps) {
|
|
const parts = [];
|
|
const boardSig = (q) => {
|
|
for (const { r, c, piece } of boardOrder(q.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}`);
|
|
}
|
|
};
|
|
boardSig(ps);
|
|
const m = this._bossMateOf(ps);
|
|
if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); }
|
|
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);
|
|
}
|
|
}
|