feedback3 WE: spectator seats on the server — up to 2 per co-op room, never players, watch like an eliminated player, no private view (community report #26)

This commit is contained in:
sganggs committed 2026-10-04 17:20:51 +08:00
1 parent bdb0765c55
commit 42766172d1
9 files changed
+628 -56

No files matched your search

+114 -31
View File
@@ -18,6 +18,8 @@
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
// opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26;
// server/lobby.js): never players — see addSpectator below
// opts.seed uint32 master seed for all match randomness
// opts.matchNo integer ≥ 1 (optional) the room's match number (lobby: room.matchCount + 1); with the seed it
// makes this match's battleIds unique within the room (DESIGN §14)
@@ -53,6 +55,15 @@
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
// dispose() Stop every timer/interval and release resources. Idempotent. After dispose the
// platform ignores send/broadcast/onEnd from this instance.
// addSpectator(id) (optional for the platform) A spectator seat joined during the match, came back or asked
// for a resync: register it (idempotent) and resend what an ELIMINATED player watching sees —
// m.public and, while a battle runs, the b.start (watch) of the field it watches (default: the
// first field; server-run mode: m.field + b.snap), or m.result once ended. A spectator gets
// every broadcast through the platform, never an m.private / m.toast / m.unitStats, is never
// a field's player or authority, and is shown fields like an eliminated player in every phase.
// handle(id, msg) answers only its 'g.watch' (anything else → SPECTATOR; the platform routes
// nothing else of it).
// removeSpectator(id) The spectator left (room.leave / g.leave, removed by the host, reconnect window expired).
//
// Bot seats never produce intents or hooks: the match drives bots itself (server/match/bot.js).
// Messages the match emits are the S2C 'm.*' / 'b.*' frames of DESIGN §8.2 (room.* frames are platform-owned).
@@ -296,6 +307,13 @@ export class Match {
}
if (!this.players.size) throw new TypeError('Match: seats required');
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
/**
* Spectator seats (opts.spectators / addSpectator): playerId → a stand-in every watch path treats like an eliminated
* human (alive false; no PlayerState, never a field's player or authority).
* @type {Map<string, { playerId: string, isBot: false, left: false, alive: false, connected: true, spectator: true }>}
*/
this.spectators = new Map();
for (const id of Array.isArray(opts.spectators) ? opts.spectators : []) this._spectator(id);
/**
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
@@ -388,8 +406,10 @@ export class Match {
* @returns {{ ok: true } | { error: string, detail?: string }}
*/
handle(playerId, msg) {
const ps = this.players.get(playerId);
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)
@@ -445,22 +465,67 @@ export class Match {
this.guard(() => {
const was = ps.connected;
ps.connected = true;
this.sendTo(playerId, this.publicView());
if (!this.ended) {
ps._lastPriv = null;
this._sendPrivate(ps, true);
if (this.clientCombat) this._resendBattle(ps);
else {
const fid = this.watchers.get(playerId);
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
this.sendTo(playerId, { ...this.lastResultMsg, playerId });
}
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 first field — 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 {
let fid = this.watchers.get(playerId);
if (!fid && ps.spectator && this.fields.length) { fid = 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 });
}
}
/**
* 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;
}
onLeave(playerId) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || this.disposed) return;
@@ -668,7 +733,7 @@ export class Match {
sendTo(playerId, msg) {
if (this.disposed) return false;
const ps = this.players.get(playerId);
const ps = this.players.get(playerId) || this.spectators.get(playerId);
if (!ps || ps.isBot || ps.left) return false;
try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; }
}
@@ -1767,8 +1832,7 @@ export class Match {
_defaultWatch() {
this.watchers.clear();
for (const ps of this.order) {
if (ps.isBot || ps.left) continue;
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;
@@ -2071,10 +2135,30 @@ export class Match {
return best ? best.playerId : null;
}
/**
* 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;
}
/** 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: f.spec,
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,
@@ -2082,17 +2166,17 @@ export class Match {
}
_sendStart(pid, f, opts = {}) {
const ps = this.players.get(pid);
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));
}
/** Humans currently shown a field (its players and its watchers). */
/** 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); return ps && !ps.isBot && !ps.left; });
return [...out].filter((pid) => { const ps = this.players.get(pid) || this.spectators.get(pid); return ps && !ps.isBot && !ps.left; });
}
/** Give every field its authority (a connected human) or run it on the server. */
@@ -2241,8 +2325,8 @@ export class Match {
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 keep watching (research 09 §3.1 "Keep-watching
// auto-observes the first available field", switching freely with 前往查看): a display replica of the first field
// 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 first field
for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left) continue;
@@ -2251,8 +2335,8 @@ export class Match {
}
const first = fields.find((f) => f.mode === 'client') || fields[0] || null;
if (first) {
for (const ps of this.order) {
if (ps.isBot || ps.left || ps.alive || this.watchers.has(ps.playerId)) continue;
for (const ps of this._viewers()) {
if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true });
}
@@ -2268,9 +2352,8 @@ export class Match {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this.watchers.clear();
this._launch([f]);
// helpers and everyone else (as observers) simulate the same 联防 spec locally
for (const ps of this.order) {
if (ps.isBot || ps.left) continue;
// helpers and everyone else (as observers, spectator seats included) simulate the same 联防 spec locally
for (const ps of this._viewers()) {
this.watchers.set(ps.playerId, 'u');
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
}
@@ -2485,10 +2568,9 @@ export class Match {
this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
}
/** b.start of the boss fields: players get their own pair field, eliminated humans the first field. */
/** 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.order) {
if (ps.isBot || ps.left) continue;
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;
@@ -3060,7 +3142,8 @@ export class Match {
this.lastResultMsg = result;
this.markPublic();
try { this.flush(true); } catch (e) { this.reportError('flush', e); }
for (const ps of this.order) if (!ps.isBot && !ps.left) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId });
// every human still here gets the settlement — the spectator seats the same public rows (none of them their own)
for (const ps of this._viewers()) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId });
const { t, ...summary } = result;
void t;
summary.errors = this.errorCount;