feedback3: merge W3E — two spectator seats per co-op room (GitHub-relayed community request #26; a remake feature): watch like an eliminated player, never act, no private data
This commit is contained in:
commit
8707c161b9
28 files changed
+976
-82
No files matched your search
+117
-16
@@ -63,9 +63,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';
|
||||
@@ -117,6 +129,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} */
|
||||
@@ -143,6 +157,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); }
|
||||
|
||||
@@ -161,6 +178,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 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -204,11 +222,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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -236,7 +256,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.
|
||||
@@ -267,6 +288,8 @@ export class Lobby {
|
||||
case 'room.kick': return this.kick(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)}`);
|
||||
@@ -280,10 +303,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);
|
||||
}
|
||||
|
||||
@@ -320,8 +344,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');
|
||||
@@ -348,7 +372,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');
|
||||
@@ -371,9 +396,55 @@ 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);
|
||||
const wasHere = !!target && target.roomCode === room.code;
|
||||
const replay = this.replayFor(room, playerId);
|
||||
this.removeMember(room, playerId);
|
||||
if (wasHere) {
|
||||
// like room.kick: now, or on the next resume (with the result replay, as after the grace timeout)
|
||||
if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' });
|
||||
else { target.notice = 'kicked'; target.pendingResult = replay; }
|
||||
}
|
||||
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);
|
||||
@@ -488,7 +559,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 {
|
||||
@@ -527,6 +598,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,
|
||||
@@ -574,6 +647,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');
|
||||
@@ -604,7 +678,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);
|
||||
@@ -658,7 +732,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);
|
||||
@@ -696,6 +770,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);
|
||||
@@ -758,6 +834,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;
|
||||
}
|
||||
@@ -780,6 +857,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) {
|
||||
@@ -796,6 +874,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();
|
||||
@@ -811,7 +903,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) {
|
||||
@@ -852,6 +944,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})`);
|
||||
}
|
||||
@@ -869,9 +970,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;
|
||||
@@ -901,7 +1002,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;
|
||||
|
||||
+114
-31
@@ -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; }
|
||||
}
|
||||
@@ -1772,8 +1837,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;
|
||||
@@ -2076,10 +2140,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,
|
||||
@@ -2087,17 +2171,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. */
|
||||
@@ -2246,8 +2330,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;
|
||||
@@ -2256,8 +2340,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 });
|
||||
}
|
||||
@@ -2273,9 +2357,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) });
|
||||
}
|
||||
@@ -2490,10 +2573,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;
|
||||
@@ -3065,7 +3147,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;
|
||||
|
||||
+4
-3
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user