From 42766172d1c3edf53c620bcbd1056873110827ca Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:20:51 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WE:=20spectator=20seats=20on=20the?= =?UTF-8?q?=20server=20=E2=80=94=20up=20to=202=20per=20co-op=20room,=20nev?= =?UTF-8?q?er=20players,=20watch=20like=20an=20eliminated=20player,=20no?= =?UTF-8?q?=20private=20view=20(community=20report=20#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lobby.js | 130 +++++++++++++++++--- server/match/Match.js | 145 ++++++++++++++++++----- server/net.js | 7 +- shared/constants.js | 10 +- shared/protocol.js | 5 + test/lobby.test.js | 171 ++++++++++++++++++++++++++- test/match/harness.js | 6 +- test/match/lobby-integration.test.js | 65 ++++++++++ test/match/spectator.test.js | 145 +++++++++++++++++++++++ 9 files changed, 628 insertions(+), 56 deletions(-) create mode 100644 test/match/spectator.test.js diff --git a/server/lobby.js b/server/lobby.js index 725df373..56073f29 100644 --- a/server/lobby.js +++ b/server/lobby.js @@ -56,9 +56,21 @@ // (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to // match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing) // and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match). +// * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none): +// room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its +// match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players +// or the start gate, never host, never keeps a room alive (a room whose last human leaves closes with room.closed +// {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match +// broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators / +// addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may +// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout (stored for its session, +// never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any +// time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same +// code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for +// a player seat (the seat is kept and given back on resume). import { randomBytes, randomInt } from 'node:crypto'; -import { ERR, MAX_SEATS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; +import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; import { checkLoadout } from '../shared/protocol.js'; import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js'; import { getData as defaultGetData, lookup } from './data.js'; @@ -110,6 +122,8 @@ export class Room { this.hostId = null; /** @type {(Seat | null)[]} */ this.seats = new Array(MAX_SEATS).fill(null); + /** @type {{ playerId: string, name: string, connected: boolean }[]} spectator seats, ≤ MAX_SPECTATORS (header) */ + this.spectators = []; /** @type {any} running Match instance */ this.match = null; /** @type {{ live: boolean, ended: boolean, disposed: boolean, match: any } | null} */ @@ -136,6 +150,9 @@ export class Room { return null; } + /** @param {string} playerId @returns {{ playerId: string, name: string, connected: boolean } | null} */ + spectatorOf(playerId) { return this.spectators.find((s) => s.playerId === playerId) || null; } + /** Lowest free seat index, or -1. */ freeSeat() { return this.seats.indexOf(null); } @@ -154,6 +171,7 @@ export class Room { seats: this.seats.map((s) => (s ? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left } : null)), + spectators: this.spectators.map((s) => ({ playerId: s.playerId, name: s.name, connected: s.connected })), }; } } @@ -197,11 +215,13 @@ export class Lobby { let matches = 0; let humans = 0; let bots = 0; + let spectators = 0; for (const r of this.rooms.values()) { if (r.match) matches++; for (const s of r.seats) if (s && !s.left) (s.isBot ? bots++ : humans++); + spectators += r.spectators.length; } - return { rooms: this.rooms.size, matches, humans, bots }; + return { rooms: this.rooms.size, matches, humans, bots, spectators }; } // --------------------------------------------------------------------------------------------------- @@ -229,7 +249,8 @@ export class Lobby { } session.notice = null; session.pendingResult = null; - const seat = room.seatOf(session.playerId); + // a player seat, or a spectator seat (header): both carry `connected` / `name` + const seat = room.seatOf(session.playerId) || room.spectatorOf(session.playerId); this.clearGrace(session.playerId); // Only a visible change (reconnect, rename, new host) is broadcast; a plain resync (repeated hello on a // live socket) answers the requester alone, so hello spam cannot amplify into room-wide traffic. @@ -259,6 +280,8 @@ export class Lobby { case 'room.removeBot': return this.removeBot(session, msg); case 'room.start': return this.start(session); case 'room.loadout': return this.loadout(session, msg); + case 'room.spectate': return this.spectate(session, msg); + case 'room.removeSpectator': return this.removeSpectator(session, msg); default: if (typeof msg.t === 'string' && msg.t.startsWith('g.')) return this.routeGame(session, msg); return fail(ERR.BAD_MSG, `unhandled type ${String(msg.t).slice(0, 32)}`); @@ -272,10 +295,11 @@ export class Lobby { // a solo run may be resumed within singleReconnectTime (24 h); everything else keeps the registry's window session.resumeWindowMs = room && room.match && room.mode === 'solo' ? this.soloResumeWindowMs() : null; if (!room) return; - const seat = room.seatOf(session.playerId); + const player = room.seatOf(session.playerId); + const seat = player || room.spectatorOf(session.playerId); seat.connected = false; - if (room.match) this.callMatch(room, 'onDisconnect', session.playerId); - else this.startGrace(room, seat); + // a spectator's seat is kept like a player's (nothing to tell the match: it plays no field) + if (room.match) { if (player) this.callMatch(room, 'onDisconnect', session.playerId); } else this.startGrace(room, seat); this.broadcastState(room); } @@ -312,8 +336,8 @@ export class Lobby { if (this.rooms.size >= this.opts.maxRooms) return fail(ERR.INTERNAL, 'too many rooms'); const key = session.limitKey || null; if (key && this.opts.maxRoomsPerAddr > 0) { - // The room being left disappears with this create when the creator is its only human. - const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 ? 1 : 0; + // The room being left disappears with this create when the creator is its only human (a spectator is none). + const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 && !cur.spectatorOf(session.playerId) ? 1 : 0; if (this.countRooms((r) => r.ownerKey === key) - leaving >= this.opts.maxRoomsPerAddr) { this.limitWarn(`room limit (${this.opts.maxRoomsPerAddr}) reached for ${session.addr}`); return fail(ERR.RATE, 'too many rooms from your network'); @@ -340,7 +364,8 @@ export class Lobby { const room = norm.length === ROOM_CODE_LEN ? this.rooms.get(norm) : undefined; if (!room) return fail(ERR.ROOM_NOT_FOUND); const cur = this.roomOf(session); - if (cur === room) { this.sendState(room, session); return OK; } + // idempotent for members; a spectator of this room goes on below: it may take a free player seat (header) + if (cur === room && !room.spectatorOf(session.playerId)) { this.sendState(room, session); return OK; } if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first'); if (room.match) return fail(ERR.ROOM_STARTED); if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room'); @@ -363,9 +388,52 @@ export class Lobby { return OK; } + /** + * room.spectate: one of a co-op room's MAX_SPECTATORS spectator seats, in its lobby or during its match (header). In a + * running match the match registers the spectator and resends what it may see (Match.addSpectator). + */ + spectate(session, { code }) { + const norm = String(code).trim().toUpperCase(); + const room = norm.length === ROOM_CODE_LEN ? this.rooms.get(norm) : undefined; + if (!room) return fail(ERR.ROOM_NOT_FOUND); + const cur = this.roomOf(session); + if (cur === room) { + if (!room.spectatorOf(session.playerId)) return fail(ERR.ALREADY, 'seated as a player'); + this.sendState(room, session); + return OK; + } + if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first'); + if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room'); + if (room.spectators.length >= MAX_SPECTATORS) return fail(ERR.ROOM_FULL, 'no free spectator seat'); + if (cur) this.removeMember(cur, session.playerId); + room.spectators.push({ playerId: session.playerId, name: session.name, connected: session.connected }); + session.roomCode = room.code; + session.notice = null; + session.pendingResult = null; + this.broadcastState(room); + if (room.match) this.callMatch(room, 'addSpectator', session.playerId); + return OK; + } + + /** room.removeSpectator (host, any time): the spectator gets room.closed {kicked} and its seat is freed. */ + removeSpectator(session, { playerId }) { + const room = this.roomOf(session); + if (!room) return fail(ERR.NOT_IN_ROOM); + if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST); + if (!room.spectatorOf(playerId)) return fail(ERR.BAD_TARGET, 'not a spectator of this room'); + const target = this.registry.byId(playerId); + if (target && target.roomCode === room.code) { + if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' }); + else target.notice = 'kicked'; + } + this.removeMember(room, playerId); + return OK; + } + ready(session, { ready }) { const room = this.roomOf(session); if (!room) return fail(ERR.NOT_IN_ROOM); + if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR); if (room.match) return fail(ERR.ROOM_STARTED); this.dropReplay(room, session.playerId); const seat = room.seatOf(session.playerId); @@ -456,7 +524,7 @@ export class Lobby { if (!room) return OK; const seat = room.seatOf(session.playerId); if (seat) seat.loadout = loadout; - if (!room.match) return OK; + if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match'); let r; try { @@ -495,6 +563,8 @@ export class Lobby { difficulty: room.difficulty, modeId: modeIdFor(room.mode, room.difficulty), seats, + // the spectator seats (header): watched like eliminated players, never players + spectators: room.spectators.map((s) => s.playerId), seed, // the room's match number: with the seed it keeps battleIds unique across the room's matches (DESIGN §14) matchNo: room.matchCount + 1, @@ -542,6 +612,7 @@ export class Lobby { s.ready = false; if (!s.connected) this.startGrace(room, s); } + for (const s of room.spectators) if (!s.connected) this.startGrace(room, s); const host = room.hostId ? room.seatOf(room.hostId) : null; if (!host || host.isBot || host.left) this.migrateHost(room); if (room.activeHumans().length === 0) this.disposeRoom(room, 'empty'); @@ -572,7 +643,7 @@ export class Lobby { */ buildReplay(room, ctx) { const frames = new Map(); - for (const s of room.seats) { + for (const s of [...room.seats, ...room.spectators]) { if (!s || s.isBot || s.left) continue; const frame = ctx.results.get(s.playerId) || ctx.sharedResult; if (frame) frames.set(s.playerId, frame); @@ -626,7 +697,7 @@ export class Lobby { if (!room) return; session.resyncAt = this.now(); if (room.match) { - this.callMatch(room, 'onReconnect', session.playerId); + this.callMatch(room, room.spectatorOf(session.playerId) ? 'addSpectator' : 'onReconnect', session.playerId); return; } const frames = this.replayFor(room, session.playerId); @@ -664,6 +735,8 @@ export class Lobby { this.removeMember(room, session.playerId); return OK; } + // a spectator only watches (header): nothing else of it ever reaches the match + if (msg.t !== 'g.watch' && room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR); let res; try { res = room.match.handle(session.playerId, msg); @@ -726,6 +799,7 @@ export class Lobby { if (!session.roomCode) return null; const room = this.rooms.get(session.roomCode); const seat = room ? room.seatOf(session.playerId) : null; + if (room && !seat && room.spectatorOf(session.playerId)) return room; // a spectator seat if (!room || !seat || seat.left || seat.isBot) { session.roomCode = null; return null; } return room; } @@ -748,6 +822,7 @@ export class Lobby { if (session && session.roomCode === room.code) session.roomCode = null; this.clearGrace(playerId); this.dropReplay(room, playerId); + if (this.freeSpectatorSeat(room, playerId)) return; const seat = room.seatOf(playerId); if (!seat || seat.isBot || seat.left || room.disposed) return; if (room.match) { @@ -764,6 +839,20 @@ export class Lobby { else this.broadcastState(room); } + /** + * Free a spectator seat (removeMember): the match forgets the spectator; never a host change or a disposal — a + * spectator neither holds the host nor keeps a room alive. @returns {boolean} true when it was a spectator seat + */ + freeSpectatorSeat(room, playerId) { + const i = room.spectators.findIndex((s) => s.playerId === playerId); + if (i < 0) return false; + room.spectators.splice(i, 1); + if (room.disposed) return true; + this.callMatch(room, 'removeSpectator', playerId); + this.broadcastState(room); + return true; + } + /** Lowest-seat connected human becomes host (else lowest-seat human, else null). */ migrateHost(room) { const humans = room.activeHumans(); @@ -779,7 +868,7 @@ export class Lobby { const t = setTimeout(() => { this.graceTimers.delete(playerId); if (room.disposed || room.match) return; - const s = room.seatOf(playerId); + const s = room.seatOf(playerId) || room.spectatorOf(playerId); if (!s || s.connected) return; const session = this.registry.byId(playerId); if (session && session.roomCode === room.code) { @@ -820,6 +909,15 @@ export class Lobby { if (session.connected) sendSession(session, { t: 'room.closed', reason }); else session.notice = reason; } + // spectators did not leave: they are told whatever closed the room (its last human leaving included) + for (const s of room.spectators) { + this.clearGrace(s.playerId); + const session = this.registry.byId(s.playerId); + if (!session || session.roomCode !== room.code) continue; + session.roomCode = null; + if (session.connected) sendSession(session, { t: 'room.closed', reason }); + else session.notice = reason; + } if (ctx) this.disposeMatchCtx(ctx); this.log.info(`[lobby] ${room.code} disposed (${reason})`); } @@ -837,9 +935,9 @@ export class Lobby { // Sending // --------------------------------------------------------------------------------------------------- - /** Connected, non-departed human sessions of a room. */ + /** Connected, non-departed human sessions of a room — its spectators included (room.state, match broadcasts). */ *memberSessions(room) { - for (const s of room.seats) { + for (const s of [...room.seats, ...room.spectators]) { if (!s || s.isBot || s.left) continue; const session = this.registry.byId(s.playerId); if (session && session.connected && session.roomCode === room.code) yield session; @@ -869,7 +967,7 @@ export class Lobby { /** Match unicast. @returns {boolean} */ sendToPlayer(room, playerId, msg) { if (room.disposed) return false; - const seat = room.seatOf(playerId); + const seat = room.seatOf(playerId) || room.spectatorOf(playerId); if (!seat || seat.isBot || seat.left) return false; const session = this.registry.byId(playerId); if (!session || session.roomCode !== room.code) return false; diff --git a/server/match/Match.js b/server/match/Match.js index f0d0af65..dc0917bd 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -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} + */ + 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; diff --git a/server/net.js b/server/net.js index 08f5b6e6..092f40ff 100644 --- a/server/net.js +++ b/server/net.js @@ -59,10 +59,11 @@ export const NET_DEFAULTS = Object.freeze({ }); /** - * Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field) and - * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits). + * Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field), + * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits) and room.spectate + * (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate). */ -export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout']); +export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.spectate']); /** Close codes (see header). */ export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 }); diff --git a/shared/constants.js b/shared/constants.js index d7efe67f..5d0ae569 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -6,6 +6,13 @@ export const PROTOCOL_VERSION = 1; export const APP_VERSION = '0.1.2'; export const MAX_SEATS = 4; +/** + * Spectator seats of a co-op room (community report #26, owner's decision 2026-10-04) — a remake feature: the official + * room has 1–4 players and no spectator seat (there only eliminated players and 联防 bystanders watch, research 09 §3.1). + * A spectator never counts as a player, may not act, and watches like an eliminated player (server/lobby.js spectate, + * server/match/Match.js addSpectator). + */ +export const MAX_SPECTATORS = 2; export const ROOM_CODE_LEN = 4; export const NAME_MAX_LEN = 12; @@ -133,6 +140,7 @@ export const ERR = Object.freeze({ ALREADY: 'ALREADY', TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY', ELIMINATED: 'ELIMINATED', + SPECTATOR: 'SPECTATOR', // a spectator seat only watches (MAX_SPECTATORS) INTERNAL: 'INTERNAL', }); @@ -142,7 +150,7 @@ export const ERR_TEXT = { WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限', BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级', NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰', - INTERNAL: '服务器内部错误', + SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误', }; // ---- Emotes (交流, research 09 §4) ----------------------------------------------------------------------------- diff --git a/shared/protocol.js b/shared/protocol.js index 206ee7bc..cca931db 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -248,6 +248,11 @@ export const C2S = { 'room.start': {}, // operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK 'room.loadout': { entries: isLoadoutEntries }, + // spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats — + // in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets + // room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat. + 'room.spectate': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) }, + 'room.removeSpectator': { playerId: isId }, // match 'g.infoReady': {}, diff --git a/test/lobby.test.js b/test/lobby.test.js index b69d6a22..86dccd23 100644 --- a/test/lobby.test.js +++ b/test/lobby.test.js @@ -19,7 +19,7 @@ import { sanitizeName, TokenBucket, SessionRegistry, clientAddress, normalizeIp, import { StubMatch as Match } from '../server/match/StubMatch.js'; import { Match as RealMatch } from '../server/match/Match.js'; import { TestClient } from './helpers/wsClient.js'; -import { ERR, MAX_SEATS, PHASE } from '../shared/constants.js'; +import { ERR, MAX_SEATS, MAX_SPECTATORS, PHASE, EMOTES } from '../shared/constants.js'; const CODE_RE = new RegExp(`^[${CODE_ALPHABET}]{4}$`); const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); @@ -606,6 +606,111 @@ describe('websocket lobby', () => { await expectError(solo, { t: 'room.addBot' }, ERR.ROOM_FULL); }); + // community report #26 (a remake feature): spectator seats — server/lobby.js header + test('spectator seats: join / cap / leave / host removal; never a player, never acting, never keeping a room', async () => { + const host = await pool.player('Host'); + const st = await createRoom(host); + assert.deepEqual(st.spectators, []); + const guest = await pool.player('Guest'); + await joinRoom(guest, st.code); + const s1 = await pool.player('Watcher1'); + await expectOk(s1, { t: 'room.spectate', code: st.code.toLowerCase() }); + const v1 = await s1.waitFor('room.state', (s) => s.spectators.some((x) => x.playerId === s1.id)); + assert.deepEqual(v1.spectators, [{ playerId: s1.id, name: 'Watcher1', connected: true }]); + assert.ok(!v1.seats.some((x) => x && x.playerId === s1.id), 'never in a player seat'); + await host.waitFor('room.state', (s) => s.spectators.length === 1); + await expectOk(s1, { t: 'room.spectate', code: st.code }); // idempotent + await expectError(guest, { t: 'room.spectate', code: st.code }, ERR.ALREADY); // a player never switches in place + // a spectator may not act + await expectError(s1, { t: 'room.ready', ready: true }, ERR.SPECTATOR); + for (const msg of [{ t: 'room.start' }, { t: 'room.addBot' }, { t: 'room.setDifficulty', difficulty: 'HARD' }, { t: 'room.removeSpectator', playerId: s1.id }]) { + await expectError(s1, msg, ERR.NOT_HOST); + } + await expectError(s1, { t: 'g.ready', ready: true }, ERR.WRONG_PHASE); + // the cap; spectators never count toward the players: two player seats still free + const s2 = await pool.player('Watcher2'); + await expectOk(s2, { t: 'room.spectate', code: st.code }); + assert.equal(MAX_SPECTATORS, 2); + const s3 = await pool.player('Watcher3'); + await expectError(s3, { t: 'room.spectate', code: st.code }, ERR.ROOM_FULL); + await joinRoom(s3, st.code); + await expectOk(host, { t: 'room.addBot' }); + const full = await host.waitFor('room.state', (s) => s.seats.every(Boolean)); + assert.equal(full.spectators.length, 2); + await expectOk(s3, { t: 'room.ready', ready: true }); + await expectOk(guest, { t: 'room.ready', ready: true }); + // a spectator in the lobby takes a free player seat with room.join of the same code + await expectError(s2, { t: 'room.join', code: st.code }, ERR.ROOM_FULL); + await expectOk(host, { t: 'room.removeBot', seat: 3 }); + const sat = await joinRoom(s2, st.code); + assert.equal(seatOf(sat, s2.id).seat, 3); + assert.deepEqual(sat.spectators.map((x) => x.playerId), [s1.id]); + // leave + host.clearInbox(); + await expectOk(s1, { t: 'room.leave' }); + await host.waitFor('room.state', (s) => s.spectators.length === 0); + await expectError(s1, { t: 'room.leave' }, ERR.NOT_IN_ROOM); + await expectError(s1, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM); + // host removal: room.closed {kicked} + const s4 = await pool.player('Watcher4'); + await expectOk(s4, { t: 'room.spectate', code: st.code }); + await host.waitFor('room.state', (s) => s.spectators.length === 1); + await expectError(host, { t: 'room.removeSpectator', playerId: guest.id }, ERR.BAD_TARGET); + await expectError(guest, { t: 'room.removeSpectator', playerId: s4.id }, ERR.NOT_HOST); + await expectOk(host, { t: 'room.removeSpectator', playerId: s4.id }); + assert.equal((await s4.waitFor('room.closed')).reason, 'kicked'); + await host.waitFor('room.state', (s) => s.spectators.length === 0); + await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM); + // solo rooms have no spectator seat; unknown codes + const solo = await pool.player('Solo'); + const soloSt = await createRoom(solo, 'solo', 'FUNNY'); + await expectError(s4, { t: 'room.spectate', code: soloSt.code }, ERR.ROOM_FULL); + await expectError(s4, { t: 'room.spectate', code: st.code === 'ZZZZ' ? 'YYYY' : 'ZZZZ' }, ERR.ROOM_NOT_FOUND); + // a spectator never keeps a room alive: its last human leaving closes it for the spectator + const lone = await pool.player('Lone'); + const loneSt = await createRoom(lone); + await expectOk(s4, { t: 'room.spectate', code: loneSt.code }); + await lone.waitFor('room.state', (s) => s.spectators.length === 1); + await expectOk(lone, { t: 'room.leave' }); + assert.equal((await s4.waitFor('room.closed')).reason, 'empty'); + await expectError(s4, { t: 'room.spectate', code: loneSt.code }, ERR.ROOM_NOT_FOUND); + }); + + test('spectators during a match: broadcasts but no private view, join mid-match, only watching, reconnect gets the seat back', async () => { + const host = await pool.player('Host'); + const st = await createRoom(host); + const spec = await pool.player('Spec'); + await expectOk(spec, { t: 'room.spectate', code: st.code }); + await expectOk(host, { t: 'room.start' }); // the spectator is no player to wait for + const pub = await spec.waitFor('m.public'); + assert.deepEqual(pub.players.map((p) => p.playerId), [host.id], 'never one of the match\'s players'); + const late = await pool.player('Late'); + await expectError(late, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED); + await expectOk(late, { t: 'room.spectate', code: st.code }); // a running match: a spectator seat is still free + const lateState = await late.waitFor('room.state', (s) => s.inMatch); + assert.deepEqual(lateState.spectators.map((x) => x.playerId), [spec.id, late.id]); + for (const msg of [{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }]) { + await expectError(spec, msg, ERR.SPECTATOR); + } + await expectOk(spec, { t: 'room.loadout', entries: {} }); // kept for its session, never handed to the match + // drop and resume with the token: the seat is kept meanwhile and given back + await late.terminate(); + await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false); + const back = await pool.connect(); + const w = await back.hello('Late', late.token); + assert.equal(w.resumed, true); + const rs = await back.waitFor('room.state'); + assert.deepEqual(rs.spectators.find((x) => x.playerId === late.id), { playerId: late.id, name: 'Late', connected: true }); + await expectError(back, { t: 'g.infoReady' }, ERR.SPECTATOR); + // the match ends: the spectators see the result (a broadcast) and stay in the room, which returns to its lobby + await expectOk(host, { t: 'g.infoReady' }); + await spec.waitFor('m.result'); + const lobby = await spec.waitFor('room.state', (s) => !s.inMatch && s.spectators.length === 2); + assert.deepEqual(lobby.spectators.map((x) => x.playerId), [spec.id, late.id]); + assert.equal(spec.log.filter((x) => x.t === 'm.private').length, 0, 'no private view, ever'); + assert.equal(back.log.filter((x) => x.t === 'm.private').length, 0); + }); + test('host-only commands, difficulty change un-readies guests, host migration on leave', async () => { const host = await pool.player('Host'); const st = await createRoom(host, 'coop', 'NORMAL'); @@ -873,6 +978,12 @@ describe('websocket lobby', () => { if (m.t === 'g.emote' && rnd() < 0.5) m.id = 'autochess_battle_happy'; return m; } + // spectator seats (community report #26) + if (r < 0.95) { + return rnd() < 0.7 + ? { t: 'room.spectate', code: pick([...codes(), 'ZZZZ', ...codes().map((c) => c.toLowerCase())]) || 'QQQQ' } + : { t: 'room.removeSpectator', playerId: pick([...clients.map((x) => x.id), 'p_nope', 1]) }; + } return pick([{ t: 'hello', name: pick(['Re', '', 'x'.repeat(20)]) }, { t: 'ping', c: pick([1, 'x']) }, { t: pick(['nope', '__proto__', 'toString']) }, { t: 'room.join', code: pick(junk) }]); }; const okCounts = {}; @@ -910,9 +1021,20 @@ describe('websocket lobby', () => { assert.ok(sess, 'seated session exists'); assert.equal(sess.roomCode, room.code); } + // spectator seats: capped, none in solo rooms, never also a player (here or elsewhere), never the host + assert.ok(room.spectators.length <= MAX_SPECTATORS, `room ${room.code} spectator cap`); + if (room.mode === 'solo') assert.equal(room.spectators.length, 0, 'no spectator seat in a solo room'); + for (const sp of room.spectators) { + assert.ok(!seen.has(sp.playerId), 'a spectator is seated (or spectating) twice'); + seen.add(sp.playerId); + assert.ok(!room.seatOf(sp.playerId) && room.hostId !== sp.playerId); + assert.equal(srv.registry.byId(sp.playerId)?.roomCode, room.code, 'the spectator\'s session points at its room'); + } } + if (okCounts['room.spectate'] == null) assert.fail('fuzz never took a spectator seat'); for (const sess of srv.registry.all()) { - if (sess.roomCode) assert.ok(srv.lobby.rooms.get(sess.roomCode)?.seatOf(sess.playerId), 'session points at its seat'); + const r = sess.roomCode ? srv.lobby.rooms.get(sess.roomCode) : null; + if (sess.roomCode) assert.ok(r?.seatOf(sess.playerId) || r?.spectatorOf(sess.playerId), 'session points at its seat'); } const h = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString()); assert.equal(h.ok, true); @@ -1012,6 +1134,8 @@ class RecordingMatch extends Match { onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); } onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(playerId); } onLeave(playerId) { this.calls.push(['onLeave', playerId]); super.onLeave(playerId); } + addSpectator(playerId) { this.calls.push(['addSpectator', playerId]); } + removeSpectator(playerId) { this.calls.push(['removeSpectator', playerId]); } dispose() { this.calls.push(['dispose']); super.dispose(); } } @@ -1063,6 +1187,49 @@ describe('lobby timers and match interface', () => { await expectOk(guest, { t: 'room.addBot' }); }); + test('spectator seats: lobby grace like a player; never the host; the match hooks (opts.spectators, addSpectator / removeSpectator, only g.watch)', async () => { + const host = await pool.player('Host'); + const st = await createRoom(host); + const spec = await pool.player('Spec'); + await expectOk(spec, { t: 'room.spectate', code: st.code }); + // a dropped spectator keeps its seat for the lobby grace, then is freed and told room.closed {timeout} on resume + const gone = await pool.player('Gone'); + await expectOk(gone, { t: 'room.spectate', code: st.code }); + await gone.terminate(); + await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === gone.id)?.connected === false); + host.clearInbox(); + await host.waitFor('room.state', (s) => s.spectators.length === 1, GRACE + 1000); + const goneBack = await pool.connect(); + await goneBack.hello('Gone', gone.token); + assert.equal((await goneBack.waitFor('room.closed')).reason, 'timeout'); + // the match: the spectator is passed apart from the seats + RecordingMatch.instances.length = 0; + await expectOk(host, { t: 'room.start' }); + const m = RecordingMatch.instances[0]; + assert.deepEqual(m.opts.spectators, [spec.id]); + assert.deepEqual(m.opts.seats.map((s) => s.playerId), [host.id]); + // only g.watch of a spectator reaches the match + await expectError(spec, { t: 'g.levelUp' }, ERR.SPECTATOR); + await spec.request({ t: 'g.watch', fieldId: `n:${host.id}` }); + assert.deepEqual(m.calls.filter((c) => c[0] === 'handle' && c[1] === spec.id).map((c) => c[2]), ['g.watch']); + // joining mid-match, a drop (the match is not told), a resume (resync) → addSpectator; leaving → removeSpectator + const late = await pool.player('Late'); + await expectOk(late, { t: 'room.spectate', code: st.code }); + await late.terminate(); + await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false); + host.clearInbox(); + const back = await pool.connect(); + await back.hello('Late', late.token); + await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === true); + await expectOk(back, { t: 'g.leave' }); + await host.waitFor('room.state', (s) => s.spectators.length === 1); + const hooks = m.calls.filter((c) => c[1] === late.id).map((c) => c[0]); + assert.deepEqual(hooks, ['addSpectator', 'addSpectator', 'removeSpectator'], 'never onDisconnect / onReconnect / onLeave'); + // the host leaves mid-match: a spectator never becomes the host and never keeps the room + await expectOk(host, { t: 'room.leave' }); + assert.equal((await spec.waitFor('room.closed')).reason, 'empty'); + }); + test('match number: every match of a room gets the next matchNo (battleIds stay unique even with the same seed)', async () => { const solo = await pool.player('Solo'); await createRoom(solo, 'solo', 'FUNNY'); diff --git a/test/match/harness.js b/test/match/harness.js index b85e9983..76d6b9cc 100644 --- a/test/match/harness.js +++ b/test/match/harness.js @@ -3,8 +3,8 @@ // const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true }); // h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants(); // -// Options: mode, difficulty, humans (count) | seats (explicit), bots, seed, matchNo (the room's match number: part of -// the battleId prefix), data (default: real data/*.json), +// Options: mode, difficulty, humans (count) | seats (explicit), bots, spectators (spectator seat ids, opts.spectators), +// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json), // fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual // scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0), // botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time). @@ -55,7 +55,7 @@ export function makeMatch(o = {}) { if (o.script) FakeBattle.script = o.script; } h.m = new Match({ - roomCode: 'TEST', mode, difficulty, seats, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log, + roomCode: 'TEST', mode, difficulty, seats, spectators: o.spectators, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log, send: (id, msg) => { for (const fn of h.onSend) fn(id, msg); if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; } diff --git a/test/match/lobby-integration.test.js b/test/match/lobby-integration.test.js index 40355db4..e33fc75a 100644 --- a/test/match/lobby-integration.test.js +++ b/test/match/lobby-integration.test.js @@ -127,6 +127,71 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match assert.deepEqual(errors, []); }); +// community report #26 (a remake feature): a spectator seat — server/lobby.js header, test/match/spectator.test.js +test('co-op spectator over websockets: watches the real match like an eliminated player, never a private view, cannot act, resumes', async () => { + FakeBattle.reset(); + FakeBattle.script = () => ({ duration: 1 }); + const watcher = async (name, token) => { + const s = await server(); + const c = await TestClient.connect(`ws://127.0.0.1:${s.port}/ws`); // no simulated browser: it only watches + clients.push(c); + const w = await c.hello(name, token); + c.id = w.playerId; + c.token = w.token; + return c; + }; + const a = await player('A'); + await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' }); + const st = await a.waitFor('room.state'); + await ok(a, { t: 'room.addBot' }); + const s = await watcher('Spec'); + await ok(s, { t: 'room.spectate', code: st.code }); + await ok(a, { t: 'room.start' }); + const info = await s.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); + assert.ok(!info.players.some((p) => p.playerId === s.id), 'never a player'); + for (const msg of [{ t: 'g.infoReady' }, { t: 'g.band', bandId: 'band_bldsk' }, { t: 'g.emote', id: EMOTES[0] }]) { + const r = await s.request(msg); + assert.equal(r.code, 'SPECTATOR', msg.t); + } + await ok(a, { t: 'g.infoReady' }); + for (let i = 0; i < 40; i++) { // every draft frame in order: pick on the own turn (the AI picks at once) + const p = await a.waitFor('m.public', (x) => x.phase !== 'INFO_CHECK', 10000); + if (p.phase !== 'BAND_DRAFT') break; + if (p.draft && p.draft.turn === a.id && !p.draft.picks[a.id]) await ok(a, { t: 'g.band', bandId: 'band_bldsk' }); + } + await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000); + for (const msg of [{ t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.refresh' }]) { + const r = await s.request(msg); + assert.equal(r.code, 'SPECTATOR', msg.t); + } + // prep: the player's board, read-only (what a teammate scouting it gets) + await ok(s, { t: 'g.watch', fieldId: `n:${a.id}` }); + const scout = await s.waitFor('m.field', (f) => f.prep === true); + assert.equal(scout.fieldId, `n:${a.id}`); + await ok(a, { t: 'g.ready', ready: true }); + const start = await s.waitFor('b.start', (x) => x.kind === 'normal', 10000); + assert.equal(start.watch, true); + assert.equal(start.authoritative, false); + assert.ok(!start.spec.players.some((p) => Object.hasOwn(p.contentInfo || {}, 'funds')), 'no player funds in a spectator\'s spec'); + // drop and resume with the token: the seat comes back with the match state + await s.terminate(); + await a.waitFor('room.state', (x) => x.spectators.some((y) => y.playerId === s.id && !y.connected)); + const back = await watcher('Spec', s.token); + assert.equal(back.id, s.id); + const rs = await back.waitFor('room.state'); + assert.deepEqual(rs.spectators, [{ playerId: s.id, name: 'Spec', connected: true }]); + await back.waitFor('m.public'); + await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000); + for (const c of [s, back]) { + assert.equal(c.log.filter((x) => x.t === 'm.private' || x.t === 'm.toast' || x.t === 'm.unitStats').length, 0, 'no private frame'); + assert.ok(!c.log.some((x) => /"(funds|hand|shop|temp)":/.test(JSON.stringify(x))), 'no private player data'); + } + // the last player leaves: the match ends and the room closes for its spectator + await ok(a, { t: 'g.leave' }); + assert.equal((await back.waitFor('room.closed', () => true, 5000)).reason, 'empty'); + assert.deepEqual(errors, []); +}); + test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => { FakeBattle.reset(); FakeBattle.script = () => ({ duration: 1 }); diff --git a/test/match/spectator.test.js b/test/match/spectator.test.js new file mode 100644 index 00000000..2e417f88 --- /dev/null +++ b/test/match/spectator.test.js @@ -0,0 +1,145 @@ +// Spectator seats, the match side (community report #26, owner's decision 2026-10-04 — a remake feature: the official +// room has no spectator seat). A spectator (opts.spectators / addSpectator, server/lobby.js spectate) is shown fields like +// an ELIMINATED player in every phase — the first field of each battle (b.start watch), any field on g.watch, the 联防 +// spec, the first boss field, a prep board on g.watch 'n:' — and the settlement; it never receives an m.private +// (shop, hand, funds …), an m.toast or m.unitStats, is never a field's player or authority, and every intent but g.watch +// is refused (SPECTATOR). The platform half (seats, cap, host removal, reconnect): test/lobby.test.js; over sockets with +// the real Match: test/match/lobby-integration.test.js. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ERR, PHASE, EMOTES } from '../../shared/constants.js'; +import { makeMatch } from './harness.js'; + +const S = 's_spec'; +/** Keys only the private player view carries (DESIGN §8.3 m.private): none may reach a spectator, at any depth. */ +const PRIVATE_KEYS = new Set(['funds', 'pendingFunds', 'hand', 'temp', 'shop', 'canReady', 'rewardOffer', 'freeRefreshes', 'loadout']); +function privateKeys(v, path = '', out = []) { + if (Array.isArray(v)) v.forEach((x, i) => privateKeys(x, `${path}[${i}]`, out)); + else if (v && typeof v === 'object') { + for (const [k, x] of Object.entries(v)) { + if (PRIVATE_KEYS.has(k)) out.push(`${path}.${k}`); + privateKeys(x, `${path}.${k}`, out); + } + } + return out; +} +const frames = (h, id) => h.sent.filter(([pid]) => pid === id).map(([, x]) => x); +/** The intents a player has, refused for a spectator (the platform never routes them; the match refuses them too). */ +const REFUSED = [ + { t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.refresh' }, { t: 'g.levelUp' }, { t: 'g.ready', ready: true }, + { t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }, { t: 'g.unitStats', seq: 1 }, { t: 'g.choice', idx: 0 }, + { t: 'g.band', bandId: 'band_bldsk' }, { t: 'b.progress', battleId: 'x', gt: 1, killed: 0, total: 1 }, +]; + +test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势, settlement) like an eliminated player — never a private view', () => { + const h = makeMatch({ + mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 9301, fake: true, clientCombat: true, spectators: [S], + // round 1: p_0 leaks → p_1 / p_2 (perfect) hold a 联防; the Final Assault falls fast + script: (b) => (b.kind === 'boss' ? { bossDps: 20000 } : b.kind === 'normal' && b.round === 1 ? { duration: 1, leaks: { p_0: 4 } } : { duration: 1 }), + }).start(); + const m = h.m; + h.autoHumans(); + assert.ok(m.spectators.has(S) && !m.players.has(S), 'a spectator, never a player'); + for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t); + h.toPrep(1); + for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t); + // prep: a player's board, like a teammate scouting it (m.field prep: true) + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + const scout = h.lastTo(S, 'm.field'); + assert.equal(scout.fieldId, 'n:p_1'); + assert.equal(scout.prep, true); + assert.deepEqual(scout, m.prepFieldMeta(h.ps('p_1')), 'exactly what a teammate scouting the board gets'); + assert.equal(m.handle(S, { t: 'g.watch', fieldId: `n:${S}` }).error, ERR.BAD_TARGET, 'a spectator has no board'); + const end = h.runToEnd(); + assert.equal(end.victory, true); + + const got = frames(h, S); + for (const x of got) assert.ok(['m.public', 'm.field', 'b.start', 'b.end', 'm.result'].includes(x.t), `${x.t} sent to a spectator`); + assert.equal(h.allTo(S, 'm.private').length, 0, 'no private player view'); + const starts = h.allTo(S, 'b.start'); + assert.ok(starts.length > 0); + for (const st of starts) { + assert.equal(st.watch, true, `${st.fieldId}: watched`); + assert.equal(st.authoritative, false, `${st.fieldId}: never the authority`); + assert.ok(!st.spec.players.some((p) => p.playerId === S), 'never a player of a field'); + } + const kinds = new Set(starts.map((x) => x.kind)); + for (const k of ['normal', 'unite', 'boss']) assert.ok(kinds.has(k), `a ${k} field was shown (${[...kinds]})`); + // what it was shown in 各自行动 is what an eliminated player is shown: the first field of the phase + for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_0'); + // the Final Assault's end reaches the boss field it watched, like every human shown that field + const ends = h.allTo(S, 'b.end'); + assert.ok(ends.some((e) => e.fieldId === starts.filter((x) => x.kind === 'boss').pop().fieldId), 'b.end of the watched boss field'); + // the settlement: every player's public row, none of its own + const res = h.lastTo(S, 'm.result'); + assert.ok(res, 'the spectator gets the result'); + assert.equal(res.playerId, S); + assert.deepEqual(res.players.map((p) => p.playerId), ['p_0', 'p_1', 'p_2']); + assert.deepEqual({ ...res, playerId: null }, { ...h.lastTo('p_1', 'm.result'), playerId: null }, 'the same rows a player gets'); + // nothing shown to it — unicast or broadcast — carries a private key + for (const x of [...got, ...h.bc]) assert.deepEqual(privateKeys(x), [], `${x.t}: private keys`); + assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2))); + m.dispose(); +}); + +test('during a battle a spectator may watch any field at once; one added mid-battle gets the state; a removed one gets nothing', () => { + const h = makeMatch({ + mode: 'coop', humans: 2, seed: 9302, fake: true, clientCombat: true, instant: false, pace: 'paced', spectators: [S], + script: () => ({ duration: 30 }), + }).start(); + const m = h.m; + h.toPrep(1); + for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true }); + h.run(() => m.phase === PHASE.COMBAT); + h.sched.advance(500); + const first = h.lastTo(S, 'b.start'); + assert.deepEqual([first.fieldId, first.watch, first.authoritative], ['n:p_0', true, false], 'auto-observes the first field'); + // a fighting player may not look elsewhere — a spectator (like an eliminated player) may + assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }).error, ERR.WRONG_PHASE); + assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); + const st = h.lastTo(S, 'b.start'); + assert.deepEqual([st.fieldId, st.watch, st.authoritative], ['n:p_1', true, false]); + // the field's spec as every watcher gets it — minus the player's funds at the battle start (no battle effect reads it) + const spec = m.fields.find((f) => f.fieldId === 'n:p_1').spec; + assert.ok(Object.hasOwn(spec.players[0].contentInfo, 'funds'), 'a player / teammate replica gets contentInfo.funds'); + const { funds, ...counters } = spec.players[0].contentInfo; + assert.ok(Number.isFinite(funds)); + assert.deepEqual(st.spec, { ...spec, players: [{ ...spec.players[0], contentInfo: counters }] }, 'the same spec without funds'); + assert.deepEqual(privateKeys(st), []); + assert.equal(m.watchers.get(S), 'n:p_1'); + for (const f of m.fields) assert.notEqual(f.authority, S); + // a spectator seat taken mid-battle: m.public and the first field at once + const LATE = 's_late'; + m.addSpectator(LATE); + assert.ok(h.lastTo(LATE, 'm.public')); + assert.equal(h.lastTo(LATE, 'b.start').fieldId, 'n:p_0'); + assert.equal(h.allTo(LATE, 'm.private').length, 0); + m.addSpectator(LATE); // a resync: idempotent registration, the state again + assert.equal(h.allTo(LATE, 'm.public').length, 2); + assert.equal(m.spectators.size, 2); + m.addSpectator('p_0'); // a player's id is never a spectator + assert.ok(!m.spectators.has('p_0')); + assert.equal(h.allTo('p_0', 'm.private').length > 0, true); + // removed: nothing reaches it any more, its watch is gone + m.removeSpectator(LATE); + const before = frames(h, LATE).length; + assert.equal(m.watchers.has(LATE), false); + assert.equal(m.handle(LATE, { t: 'g.watch', fieldId: 'n:p_0' }).error, ERR.NOT_IN_ROOM); + h.run(() => m.phase === PHASE.PREP && m.round === 2, { maxSteps: 2e6 }); + assert.equal(frames(h, LATE).length, before, 'a removed spectator is sent nothing'); + assert.equal(h.allTo(S, 'm.private').length, 0); + m.dispose(); +}); + +test('server-run combat (SP_COMBAT=server): a spectator is streamed the first field like an eliminated player', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 9303, fake: true, spectators: [S] }).start(); + const m = h.m; + h.toPrep(1); + for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true }); + h.run(() => m.phase === PHASE.SETTLE); + const meta = h.allTo(S, 'm.field'); + assert.ok(meta.length > 0 && meta[0].fieldId === 'n:p_0', 'm.field of the first field'); + assert.ok(h.allTo(S, 'b.snap').length > 0, 'its snapshots'); + assert.equal(h.allTo(S, 'm.private').length, 0); + m.dispose(); +});