Files
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

1156 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/lobby.js — rooms, seats, host, AI seats, ready/start, reconnect, and room → Match wiring
// (DESIGN §2, §6.1 LOBBY, §8.1). Implements the handler interface consumed by server/net.js.
//
// Rules (the choices where DESIGN is silent are marked ▸):
// * Rooms are keyed by 4-letter codes from an unambiguous alphabet (no I/O, letters only). Join codes are
// case-insensitive.
// * 'solo' rooms hold exactly one human and never bots. 'coop' rooms have 4 seats (humans + AI bots).
// Humans and bots take the lowest free seat index; seat indexes never compact.
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
// * Host-only: room.setDifficulty, room.setAiPicksLast, room.addBot, room.removeBot, room.kick, room.start.
// ▸ Changing the difficulty or the AI-picks-last option (co-op only) un-readies the other humans.
// ▸ room.start requires every other human to be connected and ready; the host's start counts as the host's
// ready (the host may still toggle room.ready for display).
// * room.kick {seat, playerId} (community report #17, owner approved): before the match only, the host removes another
// human like an AI seat (an AI seat stays room.removeBot's; never the host itself). `playerId` names the player the
// host confirmed: a seat that changed hands meanwhile (left, someone else joined) is refused with BAD_TARGET. The
// seat is freed at once and the player gets `room.closed {reason:'kicked'}` — now, or on the next resume when
// offline (with the result replay, as the grace timeout) —, so the reconnect token no longer leads back to the seat
// (it stays the player's identity: net.js sessions belong to players, not seats). ▸ No ban: the player may join
// again with the code.
// * Host migration: when the host leaves (or is removed), the lowest-seat remaining human (connected
// ones first) becomes host. A room without humans is disposed (bots never keep a room alive).
// * Disconnect in LOBBY: the seat shows connected=false and is freed after `lobbyGraceMs` (60 s); a
// session that comes back after that gets `room.closed {reason:'timeout'}`.
// Disconnect in a match: the seat is kept and match.onDisconnect(playerId) is called.
// * Reconnect: `hello` with a known token (reconnect window, 10 min, see net.js) rebinds the session;
// the lobby then broadcasts room.state and, in a match, calls match.onReconnect(playerId).
// Solo runs (下半: "休整期及机变阶段没有时间限制…24小时内随时返回", research 01 §1 / 06 §17): a session that drops
// while its solo room's match runs stays resumable for the official `config.constants.singleReconnectTime`
// (86400 s; option `soloReconnectWindowMs` overrides it) instead of the 10-minute window — the untimed solo match
// simply waits (net.js session.resumeWindowMs, set at every disconnect). Only after that does expiry turn into
// match.onLeave ('abandoned'). The extension outlives the match, so a run that ended meanwhile (e.g. a server-run
// Final Assault) still shows its result on the player's return.
// A repeated hello on a live connection is a full resync: room.state goes to the requester only
// (broadcast only when the seat visibly changed, e.g. a rename in LOBBY); the heavy part (match.onReconnect,
// or the result replay below) runs at most once per `resyncMinGapMs` per session — extra requests inside
// that window coalesce into one deferred resync, so hello spam cannot amplify into ~15 KB per request.
// * Result replay: the match's final m.public and each human's m.result are kept after the match ends. A
// human who resyncs (resume after a drop, a reloaded tab, a repeated hello) while the room is back in LOBBY
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, the
// AI-picks-last option, AI seats, start), leave it, or a new match starts. A human removed by the lobby grace
// gets them right after `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the
// lobby drops the match reference at onEnd and disposes it on the next macrotask).
// * Per-network limits (internet clients only, see net.js clientAddress): at most `maxRoomsPerAddr` rooms
// created from one network may exist at once and at most `maxMatchesPerAddr` matches started from one
// network may run at once (room.create / room.start → ERR.RATE). Without them a socket loop could fill
// `maxRooms` or keep hundreds of unattended matches simulating for the whole reconnect window.
// * Permanent departure during a match (room.leave, g.leave, reconnect window expired): the seat is
// marked departed (shown as connected=false), match.onLeave(playerId) is called, and the seat is freed
// when the match ends. 'g.leave' is handled here and never reaches match.handle().
// * All other 'g.*' messages go to room.match.handle(playerId, msg); its {ok}/{error} becomes the reply.
// * Match lifecycle: room.start → new Match({...}) → room.state (inMatch=true) → match.start(). The match gets
// `matchNo` = the room's match number (1, 2, …): with the seed it keeps battleIds unique across the room's
// matches, so a late b.progress / b.result of the previous match is ignored by the next one (DESIGN §14).
// onEnd(summary) → room back to LOBBY (departed seats freed, humans un-readied, disconnected humans
// get the lobby grace), dispose() on the next macrotask. Players can start again.
// * room.closed reasons: 'timeout' (removed after lobby grace), 'kicked' (room.kick, room.removeSpectator), 'empty' (a
// spectator whose room lost its last player), 'shutdown' (server stopping).
// * Operator loadout (DESIGN §16): room.loadout { entries } is checked strictly against the game data
// (shared/protocol.js checkLoadout: known visible chess, a skill index legal for the normal AND the elite status, a
// module of the elite or 'none'; any bad entry rejects the whole message, nothing is stored). ▸ It is stored on the
// session (it follows the player into every room they create/join, and survives a resume) and on the seat; the
// match receives seats[].loadout (bots: none — they fight with the defaults). ▸ Accepted any time: in a LOBBY room
// (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).
// ▸ Its `ops` (0.2.2: the per-operator 潜能 / 练度, shared/protocol.js checkLoadoutOps — an operator of the 干员调配
// roster or the 自选 owned pool, strict like the entries) travel with it: session.ops / seat.ops / seats[].ops and
// match.setLoadout(playerId, loadout, ops); a message without `ops` sets none (every operator 潜能 6, 精英2 Lv.60).
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
// session and the seat like the loadout. The match receives seats[].notOwned when it starts (bots: none — they own
// every operator) and keeps it for its whole length: the setting is out of match ("局外设置,下一局生效"), so while
// the room's match runs a new list is only stored for the next match (ROOM_STARTED 'stored for the next match',
// never handed to the match). A spectator's list stays on its session.
// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05): room.diy { picks } — the player's picks for the four DIY
// slots ({ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }) — is checked leniently (shared/protocol.js
// checkDiyPicks against the game data and the kit registry, server/sim/content/kits/index.js KITTED_CHARS: an
// illegal pick — an operator without a kit, another tier's prototype, a prototype off its locked skill, a second slot
// of one owned operator, the same operator twice in a tier, an unknown slot / skill / module — is dropped, never the
// whole roster; only malformed picks are BAD_MSG) and stored on the session and the seat exactly like the
// not-owned list: the match receives seats[].diy when it starts (bots: none — they field no 自选 piece [ASSUMED]),
// and a change while it runs is stored for the next match (ROOM_STARTED 'stored for the next match'). Every
// `welcome` carries `diyKitted` (welcomeInfo): the operators a DIY slot may field, so the client's picker offers
// exactly what the server accepts.
// * 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 / room.ownership /
// room.diy (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, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
import { getData as defaultGetData, lookup } from './data.js';
import { Match as DefaultMatch } from './match/Match.js';
import { KITTED_CHARS } from './sim/content/kits/index.js';
/** Room code alphabet: uppercase letters without I and O (and no digits, so no 0/1). */
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
/** Tunables. */
export const LOBBY_DEFAULTS = Object.freeze({
lobbyGraceMs: 60_000, // disconnected humans keep their lobby seat this long
maxRooms: 1000,
maxRoomsPerAddr: 16, // rooms created from one client network that may exist at once (0 = unlimited)
maxMatchesPerAddr: 8, // matches started from one client network that may run at once (0 = unlimited)
resyncMinGapMs: 1000, // heavy resyncs (match state / result replay) per session at most this often on repeated hellos
soloReconnectWindowMs: null, // a dropped solo run stays resumable this long (null = data singleReconnectTime, 24 h)
});
/** Official `singleReconnectTime` (s) when the data lacks it (constData, research 01 §1). */
export const SOLO_RECONNECT_FALLBACK_SEC = 86_400;
/** Display names for AI teammates (the tutorial NPCs first, then a few familiar faces). */
export const BOT_NAMES = Object.freeze(['AI·华法琳', 'AI·阿米娅', 'AI·惊蛰', 'AI·杜宾', 'AI·凯尔希', 'AI·可露希尔']); // i18n-ignore: player names (docs/I18N.md)
const OK = Object.freeze({ ok: true });
const fail = (code, detail) => (detail ? { error: code, detail } : { error: code });
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
/**
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
* ops?: Readonly<Record<string, { potential: number, cultivate: number }>> | null,
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
*/
/** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */
function freezeLoadout(loadout) {
const out = {};
for (const [id, e] of Object.entries(loadout || {})) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
return Object.freeze(out);
}
/** Deep-frozen copy of checked operator settings (0.2.2 潜能 / 练度; shared by the session, the seat and the match). */
function freezeOps(ops) {
const out = {};
for (const [id, e] of Object.entries(ops || {})) out[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
return Object.freeze(out);
}
/** The charIds a player may set a potential / 练度 for, per data object (shared/protocol.js cultivationCharIds). */
const OPS_IDS = new WeakMap();
function opsCharIds(data) {
if (!data || typeof data !== 'object') return new Set();
let ids = OPS_IDS.get(data);
if (!ids) { ids = cultivationCharIds(data.chess, data.backups); OPS_IDS.set(data, ids); }
return ids;
}
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
function freezeDiy(picks) {
const out = {};
for (const [id, p] of Object.entries(picks || {})) out[id] = Object.freeze({ charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId ?? null });
return Object.freeze(out);
}
/** One room: 4 seat slots, host, difficulty, optional running match. */
export class Room {
/** @param {string} code @param {'solo'|'coop'} mode @param {string} difficulty @param {number} now */
constructor(code, mode, difficulty, now) {
this.code = code;
this.mode = mode;
this.difficulty = difficulty;
/**
* 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338; co-op only, off by default): the match's strategy and 机变 drafts
* put every human seat before every AI seat (Match opts.aiPicksLast). Kept across the room's matches.
*/
this.aiPicksLast = false;
/** @type {string | null} */
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} */
this.matchCtx = null;
this.matchCount = 0;
/** @type {any} summary passed to onEnd by the last match */
this.lastSummary = null;
/**
* Frames of the last match's end, replayed on resync to humans who have not moved on yet.
* @type {{ publicFrame: string | null, frames: Map<string, string>, pending: Set<string> } | null}
*/
this.replay = null;
/** @type {string | null} per-network limit key of the creator (net.js clientAddress) */
this.ownerKey = null;
/** @type {string | null} per-network limit key of whoever started the running match */
this.matchKey = null;
this.createdAt = now;
this.disposed = false;
}
/** @param {string} playerId @returns {Seat | null} */
seatOf(playerId) {
for (const s of this.seats) if (s && s.playerId === playerId) return s;
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); }
/** Humans that have not departed, in seat order. @returns {Seat[]} */
activeHumans() { return this.seats.filter((s) => s && !s.isBot && !s.left); }
/** `room.state` frame (DESIGN §8.1) plus `inMatch`. */
toState() {
return {
t: 'room.state',
code: this.code,
hostId: this.hostId,
mode: this.mode,
difficulty: this.difficulty,
aiPicksLast: this.aiPicksLast,
inMatch: !!this.match,
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 })),
};
}
}
/** Room registry + lobby message handlers. Pass an instance as the `handler` of net.js Network. */
export class Lobby {
/**
* @param {{
* registry: import('./net.js').SessionRegistry,
* log?: { info: Function, warn: Function, error: Function, debug?: Function },
* MatchClass?: new (opts: object) => any,
* getData?: () => object,
* now?: () => number,
* seedFn?: () => number,
* options?: Partial<typeof LOBBY_DEFAULTS>,
* }} opts
*/
constructor({ registry, log = noopLog, MatchClass = DefaultMatch, getData = defaultGetData, now = Date.now, seedFn, options = {} }) {
this.registry = registry;
this.log = log;
this.MatchClass = MatchClass;
this.getData = getData;
this.now = now;
this.seedFn = seedFn || (() => randomInt(2 ** 32));
this.opts = { ...LOBBY_DEFAULTS, ...options };
/** @type {Map<string, Room>} */
this.rooms = new Map();
/** @type {Map<string, NodeJS.Timeout>} lobby grace timers by playerId */
this.graceTimers = new Map();
/** @type {Map<string, NodeJS.Timeout>} deferred (coalesced) resyncs by playerId */
this.resyncTimers = new Map();
/** per-network limit warnings: at most one log line per 10 s (the rest are counted) */
this.limitLog = { at: -Infinity, suppressed: 0 };
}
/** @param {string} code @returns {Room | null} */
getRoom(code) { return this.rooms.get(String(code).toUpperCase()) || null; }
/** Counters for /healthz. */
stats() {
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, spectators };
}
// ---------------------------------------------------------------------------------------------------
// net.js handler interface
// ---------------------------------------------------------------------------------------------------
/**
* After `welcome`: resend room state / match state for resumed (or repeated) hellos.
* @param {import('./net.js').Session} session
* @param {{ resumed: boolean, repeat: boolean }} info
*/
onHello(session, { resumed, repeat }) {
if (!resumed && !repeat) return;
const room = this.roomOf(session);
if (!room) {
if (session.notice) {
sendSession(session, { t: 'room.closed', reason: session.notice });
session.notice = null;
}
if (session.pendingResult) {
for (const frame of session.pendingResult) if (frame) sendRaw(session.ws, frame);
session.pendingResult = null;
}
return;
}
session.notice = null;
session.pendingResult = null;
// 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.
let changed = !seat.connected;
seat.connected = true;
if (!room.match && seat.name !== session.name) { seat.name = session.name; changed = true; }
if (!room.hostId) { this.migrateHost(room); changed = true; }
if (changed) this.broadcastState(room);
else this.sendState(room, session);
this.resync(session, !resumed);
}
/**
* Validated client message from an identified session.
* @param {import('./net.js').Session} session
* @param {any} msg
* @returns {{ ok: true } | { error: string, detail?: string }}
*/
onMessage(session, msg) {
switch (msg.t) {
case 'room.create': return this.create(session, msg);
case 'room.join': return this.join(session, msg);
case 'room.leave': return this.leave(session);
case 'room.ready': return this.ready(session, msg);
case 'room.setDifficulty': return this.setDifficulty(session, msg);
case 'room.setAiPicksLast': return this.setAiPicksLast(session, msg);
case 'room.addBot': return this.addBot(session);
case 'room.removeBot': return this.removeBot(session, msg);
case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session);
case 'room.rerollSetup': return this.rerollSetup(session, msg);
case 'room.cancelReroll': return this.rerollSetup(session, msg, true);
case 'room.loadout': return this.loadout(session, msg);
case 'room.ownership': return this.ownership(session, msg);
case 'room.diy': return this.diy(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)}`);
}
}
/** The session's socket closed. @param {import('./net.js').Session} session */
onDisconnect(session) {
this.clearResync(session.playerId); // the next resume resyncs immediately
const room = this.roomOf(session);
// 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 player = room.seatOf(session.playerId);
const seat = player || room.spectatorOf(session.playerId);
seat.connected = false;
// 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);
}
/** The session's reconnect window elapsed (already removed from the registry). */
onExpire(session) {
session.notice = null;
session.pendingResult = null;
this.clearResync(session.playerId);
const code = session.roomCode;
session.roomCode = null;
const room = code ? this.rooms.get(code) : null;
if (room) this.removeMember(room, session.playerId);
}
/**
* Dispose every room (notifying members with room.closed) — used on server shutdown.
* @param {string} [reason]
*/
shutdown(reason = 'shutdown') {
for (const room of [...this.rooms.values()]) this.disposeRoom(room, reason);
for (const t of this.graceTimers.values()) clearTimeout(t);
this.graceTimers.clear();
for (const t of this.resyncTimers.values()) clearTimeout(t);
this.resyncTimers.clear();
}
// ---------------------------------------------------------------------------------------------------
// room.* handlers
// ---------------------------------------------------------------------------------------------------
create(session, { mode, difficulty }) {
const cur = this.roomOf(session);
if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first');
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 (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');
}
}
const code = this.genCode();
if (!code) return fail(ERR.INTERNAL, 'no room code available');
if (cur) this.removeMember(cur, session.playerId);
const room = new Room(code, mode, difficulty, this.now());
room.ownerKey = key;
room.seats[0] = this.humanSeat(0, session);
room.hostId = session.playerId;
this.rooms.set(code, room);
session.roomCode = code;
session.notice = null;
session.pendingResult = null;
this.log.info(`[lobby] ${code} created (${mode}/${difficulty}) by ${session.name}`);
this.broadcastState(room);
return OK;
}
join(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);
// 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');
const idx = room.freeSeat();
if (idx < 0) return fail(ERR.ROOM_FULL);
if (cur) this.removeMember(cur, session.playerId);
room.seats[idx] = this.humanSeat(idx, session);
session.roomCode = room.code;
session.notice = null;
session.pendingResult = null;
if (!room.hostId) room.hostId = session.playerId;
this.broadcastState(room);
return OK;
}
leave(session) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
this.removeMember(room, session.playerId);
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);
if (seat.ready !== ready) {
seat.ready = ready;
this.broadcastState(room);
}
return OK;
}
setDifficulty(session, { difficulty }) {
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.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
if (room.difficulty !== difficulty) {
room.difficulty = difficulty;
for (const s of room.seats) if (s && !s.isBot && s.playerId !== room.hostId) s.ready = false;
this.broadcastState(room);
}
return OK;
}
/** 「AI 队友最后选择」 (GitHub #338): host-only, before the match, co-op rooms only (a solo room has no AI seat). */
setAiPicksLast(session, { on }) {
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.match) return fail(ERR.ROOM_STARTED);
if (room.mode === 'solo') return fail(ERR.BAD_TARGET, 'solo rooms have no AI teammates');
this.dropReplay(room, session.playerId);
if (room.aiPicksLast !== on) {
room.aiPicksLast = on;
for (const s of room.seats) if (s && !s.isBot && s.playerId !== room.hostId) s.ready = false;
this.broadcastState(room);
}
return OK;
}
addBot(session) {
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.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo rooms cannot have AI teammates');
const idx = room.freeSeat();
if (idx < 0) return fail(ERR.ROOM_FULL);
const used = new Set(room.seats.filter((s) => s && s.isBot).map((s) => s.name));
const name = BOT_NAMES.find((n) => !used.has(n)) || `AI·${idx + 1}`;
let playerId;
do playerId = 'ai_' + randomBytes(4).toString('hex'); while (room.seatOf(playerId));
room.seats[idx] = { seat: idx, playerId, name, isBot: true, ready: true, connected: true, left: false };
this.broadcastState(room);
return OK;
}
removeBot(session, { seat }) {
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.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
const target = room.seats[seat];
if (!target || !target.isBot) return fail(ERR.BAD_TARGET, 'seat does not hold an AI');
room.seats[seat] = null;
this.broadcastState(room);
return OK;
}
/** Host removes another human before the match (header: room.kick). */
kick(session, { seat, 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.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
const target = room.seats[seat];
if (!target || target.left) return fail(ERR.BAD_TARGET, 'seat holds no player');
if (target.playerId !== playerId) return fail(ERR.BAD_TARGET, 'seat changed hands'); // the confirmed player left meanwhile
if (target.isBot) return fail(ERR.BAD_TARGET, 'seat holds an AI (room.removeBot)');
if (target.playerId === session.playerId) return fail(ERR.BAD_TARGET, 'cannot kick yourself');
const kicked = this.registry.byId(target.playerId);
const wasHere = !!kicked && kicked.roomCode === room.code;
const replay = this.replayFor(room, target.playerId);
this.removeMember(room, target.playerId);
if (wasHere) {
if (kicked.connected) sendSession(kicked, { t: 'room.closed', reason: 'kicked' });
else { kicked.notice = 'kicked'; kicked.pendingResult = replay; }
}
this.log.info(`[lobby] ${room.code} ${target.name} removed by the host`);
return OK;
}
start(session) {
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.match) return fail(ERR.ROOM_STARTED);
const humans = room.activeHumans();
for (const s of humans) {
if (s.playerId !== room.hostId && (!s.connected || !s.ready)) return fail(ERR.NOT_READY);
}
const bots = room.seats.filter((s) => s && s.isBot);
if (humans.length < 1 || (room.mode === 'solo' && (humans.length !== 1 || bots.length > 0))) {
return fail(ERR.BAD_MSG, 'invalid seat configuration');
}
const key = session.limitKey || null;
if (key && this.opts.maxMatchesPerAddr > 0 && this.countRooms((r) => !!r.match && r.matchKey === key) >= this.opts.maxMatchesPerAddr) {
this.limitWarn(`match limit (${this.opts.maxMatchesPerAddr}) reached for ${session.addr}`);
return fail(ERR.RATE, 'too many running matches from your network');
}
return this.startMatch(room, key);
}
/** Host authorization stays in the lobby; the match owns the vote and setup. */
rerollSetup(session, msg, cancel = false) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
const method = cancel ? 'cancelSetupReroll' : 'requestSetupReroll';
if (!room.match || typeof room.match[method] !== 'function') return fail(ERR.WRONG_PHASE);
return this.callMatch(room, method, session.playerId, cancel ? msg.voteId : msg.setupRevision) || fail(ERR.INTERNAL);
}
/**
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
* during INFO_CHECK, see the header). Either part refused: nothing is stored.
*/
loadout(session, { entries, ops }) {
const data = this.safeData();
const res = checkLoadout(entries, (id) => lookup('chess', id, data));
if (!res || res.error) return fail(res && isErrCode(res.error) ? res.error : ERR.BAD_MSG, res && res.detail);
const ids = opsCharIds(data);
const resOps = checkLoadoutOps(ops, (id) => ids.has(id));
if (!resOps || resOps.error) return fail(resOps && isErrCode(resOps.error) ? resOps.error : ERR.BAD_MSG, resOps && resOps.detail);
const loadout = freezeLoadout(res.loadout);
const opsSet = freezeOps(resOps.ops);
session.loadout = loadout;
session.ops = opsSet;
const room = this.roomOf(session);
if (!room) return OK;
const seat = room.seatOf(session.playerId);
if (seat) { seat.loadout = loadout; seat.ops = opsSet; }
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 {
r = room.match.setLoadout(session.playerId, loadout, opsSet);
} catch (e) {
this.log.error(`[lobby] ${room.code} match.setLoadout threw`, e);
return fail(ERR.INTERNAL);
}
if (r && typeof r === 'object' && r.error) {
return fail(isErrCode(r.error) ? r.error : ERR.INTERNAL, typeof r.detail === 'string' ? r.detail : undefined);
}
return OK;
}
/**
* room.ownership (0.2.0 补位): keep the droppable chess of the not-owned list, store it on the session and the seat
* (see the header). A running match never takes it: it keeps the list its seat had at its start.
*/
ownership(session, { notOwned }) {
const data = this.safeData();
const res = checkNotOwned(notOwned, (id) => lookup('chess', id, data));
if (!res || res.error) return fail(ERR.BAD_MSG, res && res.detail);
const list = Object.freeze(res.notOwned.slice());
session.notOwned = list;
const room = this.roomOf(session);
if (!room) return OK;
const seat = room.seatOf(session.playerId);
if (seat) seat.notOwned = list;
if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match');
return OK;
}
/**
* room.diy (0.2.0 自选编队): keep the legal picks (checkDiyPicks against the data and KITTED_CHARS), store them on the
* session and the seat (see the header). A running match never takes them: it keeps the picks its seat had at its
* start.
*/
diy(session, { picks }) {
const res = checkDiyPicks(picks, { data: this.safeData(), kitted: KITTED_CHARS });
if (!res || !('ok' in res)) return fail(ERR.BAD_MSG, res && res.detail);
const kept = freezeDiy(res.picks);
session.diy = kept;
const room = this.roomOf(session);
if (!room) return OK;
const seat = room.seatOf(session.playerId);
if (seat) seat.diy = kept;
if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match');
return OK;
}
/** Extra fields of every `welcome` (net.js): the operators a 自选 slot may field (shared/diy.js `kitted`). */
welcomeInfo() {
return { diyKitted: KITTED_CHARS };
}
// ---------------------------------------------------------------------------------------------------
// Match wiring
// ---------------------------------------------------------------------------------------------------
/** @param {Room} room @param {string | null} [key] per-network limit key of the starter */
startMatch(room, key = null) {
const host = room.seatOf(room.hostId);
if (host) host.ready = true;
const seats = room.seats.filter(Boolean).map((s) => ({
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
// DESIGN §16: the human's checked operator loadout and (0.2.2) per-operator 潜能 / 练度 (bots fight with the defaults)
loadout: s.isBot ? null : s.loadout || null,
ops: s.isBot ? null : s.ops || null,
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
notOwned: s.isBot ? null : s.notOwned || null,
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
diy: s.isBot ? null : s.diy || null,
}));
// lastPublic / results: the latest m.public broadcast and the m.result frames (encoded), kept for the replay.
const ctx = { live: true, ended: false, disposed: false, match: null, lastPublic: null, sharedResult: null, results: new Map() };
let seed = 0;
try { seed = this.seedFn() >>> 0; } catch { seed = randomInt(2 ** 32); }
try {
const match = new this.MatchClass({
roomCode: room.code,
mode: room.mode,
difficulty: room.difficulty,
modeId: modeIdFor(room.mode, room.difficulty),
// 「AI 队友最后选择」 (GitHub #338): fixed for the match
aiPicksLast: room.mode !== 'solo' && room.aiPicksLast === true,
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,
data: this.safeData(),
log: this.log,
now: this.now,
send: (playerId, msg) => (ctx.live ? this.matchSend(room, ctx, playerId, msg) : false),
broadcast: (msg) => { if (ctx.live) this.matchBroadcast(room, ctx, msg); },
onEnd: (summary) => this.onMatchEnd(room, ctx, summary),
});
ctx.match = match;
room.match = match;
room.matchCtx = ctx;
room.matchKey = key;
room.replay = null;
room.matchCount++;
this.log.info(`[lobby] ${room.code} match #${room.matchCount} starting (${room.mode}/${room.difficulty}, ${seats.length} seats, seed ${seed})`);
this.broadcastState(room);
match.start();
} catch (e) {
this.log.error(`[lobby] ${room.code} match failed to start`, e);
if (room.matchCtx === ctx) { room.match = null; room.matchCtx = null; room.matchKey = null; }
this.disposeMatchCtx(ctx);
this.broadcastState(room);
return fail(ERR.INTERNAL, 'match failed to start');
}
return OK;
}
/** onEnd callback: return the room to LOBBY and dispose the match on the next macrotask. */
onMatchEnd(room, ctx, summary) {
if (ctx.ended || !ctx.live || room.matchCtx !== ctx || room.disposed) return;
ctx.ended = true;
room.lastSummary = summary ?? null;
room.match = null;
room.matchCtx = null;
room.matchKey = null;
room.replay = this.buildReplay(room, ctx);
setImmediate(() => this.disposeMatchCtx(ctx));
this.log.info(`[lobby] ${room.code} match #${room.matchCount} ended`);
for (let i = 0; i < room.seats.length; i++) {
const s = room.seats[i];
if (!s || s.isBot) continue;
if (s.left) { room.seats[i] = null; continue; }
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');
else this.broadcastState(room);
}
/** Match unicast; m.result frames are also kept for the replay. */
matchSend(room, ctx, playerId, msg) {
if (msg && msg.t === 'm.result') {
const data = encode(msg);
if (data != null) ctx.results.set(playerId, data);
}
return this.sendToPlayer(room, playerId, msg);
}
/** Match broadcast; the latest m.public and a broadcast m.result are also kept for the replay. */
matchBroadcast(room, ctx, msg) {
const data = this.broadcastRoom(room, msg);
if (data == null) return;
if (msg.t === 'm.public') ctx.lastPublic = data;
else if (msg.t === 'm.result') ctx.sharedResult = data;
}
/**
* Replay record for the humans still seated when a match ends (null when the match produced no m.result,
* e.g. it was abandoned: those clients then see "simulation closed").
* @param {Room} room @returns {Room['replay']}
*/
buildReplay(room, ctx) {
const frames = new Map();
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);
}
if (frames.size === 0) return null;
return { publicFrame: ctx.lastPublic, frames, pending: new Set(frames.keys()) };
}
/** The replay frames still owed to a player (null when they moved on). @returns {string[] | null} */
replayFor(room, playerId) {
const r = room.replay;
if (!r || !r.pending.has(playerId)) return null;
return [r.publicFrame, r.frames.get(playerId)].filter(Boolean);
}
/** The player moved on from the result screen (acted in the room, left): stop replaying it. */
dropReplay(room, playerId) {
const r = room.replay;
if (!r || !r.pending.delete(playerId)) return;
r.frames.delete(playerId);
if (r.pending.size === 0) room.replay = null;
}
/**
* The heavy part of a resync — full match state (match.onReconnect) or, back in LOBBY, the result replay.
* Immediate after a (re)connect; for repeated hellos on a live socket at most once per resyncMinGapMs
* (requests inside the window coalesce into one deferred resync).
* @param {import('./net.js').Session} session @param {boolean} coalesce
*/
resync(session, coalesce) {
const pid = session.playerId;
if (coalesce) {
if (this.resyncTimers.has(pid)) return; // the scheduled resync answers this request too
const wait = (Number.isFinite(session.resyncAt) ? session.resyncAt : -Infinity) + this.opts.resyncMinGapMs - this.now();
if (wait > 0) {
const t = setTimeout(() => { this.resyncTimers.delete(pid); this.runResync(session); }, wait);
t.unref?.();
this.resyncTimers.set(pid, t);
return;
}
} else {
this.clearResync(pid);
}
this.runResync(session);
}
/** @param {import('./net.js').Session} session */
runResync(session) {
if (!session.connected || this.registry.byId(session.playerId) !== session) return;
const room = this.roomOf(session);
if (!room) return;
session.resyncAt = this.now();
if (room.match) {
this.callMatch(room, room.spectatorOf(session.playerId) ? 'addSpectator' : 'onReconnect', session.playerId);
return;
}
const frames = this.replayFor(room, session.playerId);
if (frames) for (const frame of frames) sendRaw(session.ws, frame);
}
clearResync(playerId) {
const t = this.resyncTimers.get(playerId);
if (t) { clearTimeout(t); this.resyncTimers.delete(playerId); }
}
/** Log a per-network limit refusal without letting a refusal loop flood the log. */
limitWarn(text) {
const now = this.now();
if (now - this.limitLog.at < 10_000) { this.limitLog.suppressed++; return; }
const more = this.limitLog.suppressed ? ` (+${this.limitLog.suppressed} similar refusals)` : '';
this.limitLog.at = now;
this.limitLog.suppressed = 0;
this.log.warn(`[lobby] ${text}${more}`);
}
/** Number of rooms matching a predicate. */
countRooms(pred) {
let n = 0;
for (const r of this.rooms.values()) if (pred(r)) n++;
return n;
}
/** Route a 'g.*' intent to the running match. */
routeGame(session, msg) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (!room.match) return fail(ERR.WRONG_PHASE, 'no running match');
if (msg.t === 'g.leave') {
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);
} catch (e) {
this.log.error(`[lobby] ${room.code} match.handle(${msg.t}) threw`, e);
return fail(ERR.INTERNAL);
}
if (res && typeof res.then === 'function') {
// Contract violation (handle must be synchronous): never let the rejection go unhandled.
this.log.error(`[lobby] ${room.code} match.handle(${msg.t}) returned a Promise; it must be synchronous`);
Promise.resolve(res).catch((e) => this.log.error(`[lobby] ${room.code} match.handle(${msg.t}) rejected`, e));
return OK;
}
if (res && typeof res === 'object' && res.error) {
return fail(isErrCode(res.error) ? res.error : ERR.INTERNAL, typeof res.detail === 'string' ? res.detail : undefined);
}
return OK;
}
/** Call an optional match hook without letting it throw. onLeave falls back to onDisconnect. */
callMatch(room, method, ...args) {
const m = room.match;
if (!m) return undefined;
let fn = m[method];
if (typeof fn !== 'function' && method === 'onLeave') fn = m.onDisconnect;
if (typeof fn !== 'function') return undefined;
try {
return fn.apply(m, args);
} catch (e) {
this.log.error(`[lobby] ${room.code} match.${method} threw`, e);
return undefined;
}
}
disposeMatchCtx(ctx) {
if (ctx.disposed) return;
ctx.disposed = true;
ctx.live = false;
try { ctx.match?.dispose?.(); } catch (e) { this.log.error('[lobby] match.dispose threw', e); }
}
safeData() {
try { return this.getData(); } catch (e) { this.log.error('[lobby] getData failed', e); return Object.freeze({}); }
}
/** How long a dropped solo run stays resumable (ms): the option, else data singleReconnectTime, else 24 h. */
soloResumeWindowMs() {
const o = this.opts.soloReconnectWindowMs;
if (typeof o === 'number' && Number.isFinite(o) && o > 0) return o;
const sec = this.safeData()?.config?.constants?.singleReconnectTime;
return (typeof sec === 'number' && Number.isFinite(sec) && sec > 0 ? sec : SOLO_RECONNECT_FALLBACK_SEC) * 1000;
}
// ---------------------------------------------------------------------------------------------------
// Membership helpers
// ---------------------------------------------------------------------------------------------------
/** The session's current room (self-heals stale `roomCode`). @returns {Room | null} */
roomOf(session) {
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;
}
/** @returns {Seat} */
humanSeat(idx, session) {
return {
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
loadout: session.loadout || null,
ops: session.ops || null,
notOwned: session.notOwned || null,
diy: session.diy || null,
};
}
/**
* Remove a human from a room permanently (leave, grace timeout, expiry, switching rooms).
* In LOBBY the seat is freed; during a match it is marked departed and match.onLeave is called.
* @param {Room} room @param {string} playerId
*/
removeMember(room, playerId) {
const session = this.registry.byId(playerId);
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) {
seat.left = true;
seat.connected = false;
seat.ready = false;
this.callMatch(room, 'onLeave', playerId);
} else {
room.seats[seat.seat] = null;
}
if (room.disposed) return; // onLeave may have ended the match and emptied the room
if (room.hostId === playerId) this.migrateHost(room);
if (room.activeHumans().length === 0) this.disposeRoom(room, 'empty');
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();
const pick = humans.find((s) => s.connected) || humans[0] || null;
const prev = room.hostId;
room.hostId = pick ? pick.playerId : null;
if (pick && prev !== pick.playerId) this.log.info(`[lobby] ${room.code} host → ${pick.name}`);
}
startGrace(room, seat) {
const playerId = seat.playerId;
this.clearGrace(playerId);
const t = setTimeout(() => {
this.graceTimers.delete(playerId);
if (room.disposed || room.match) return;
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) {
session.notice = 'timeout';
session.pendingResult = this.replayFor(room, playerId); // still shown after room.closed on resume
}
this.removeMember(room, playerId);
}, this.opts.lobbyGraceMs);
t.unref?.();
this.graceTimers.set(playerId, t);
}
clearGrace(playerId) {
const t = this.graceTimers.get(playerId);
if (t) { clearTimeout(t); this.graceTimers.delete(playerId); }
}
/**
* Delete a room, detach its members (room.closed unless the room simply emptied) and dispose its match.
* @param {Room} room @param {string} reason
*/
disposeRoom(room, reason) {
if (room.disposed) return;
room.disposed = true;
if (this.rooms.get(room.code) === room) this.rooms.delete(room.code);
const ctx = room.matchCtx;
room.match = null;
room.matchCtx = null;
room.matchKey = null;
room.replay = null;
for (const s of room.seats) {
if (!s || s.isBot) continue;
this.clearGrace(s.playerId);
const session = this.registry.byId(s.playerId);
if (!session || session.roomCode !== room.code) continue;
session.roomCode = null;
if (s.left || reason === 'empty') continue;
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})`);
}
genCode() {
for (let attempt = 0; attempt < 1000; attempt++) {
let code = '';
for (let i = 0; i < ROOM_CODE_LEN; i++) code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
if (!this.rooms.has(code)) return code;
}
return null;
}
// ---------------------------------------------------------------------------------------------------
// Sending
// ---------------------------------------------------------------------------------------------------
/** Connected, non-departed human sessions of a room — its spectators included (room.state, match broadcasts). */
*memberSessions(room) {
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;
}
}
broadcastState(room) {
if (room.disposed) return;
const data = encode(room.toState());
for (const session of this.memberSessions(room)) sendRaw(session.ws, data);
}
sendState(room, session) {
sendSession(session, room.toState());
}
/** Match broadcast: encode once, send to every connected member. @returns {string | null} the encoded frame */
broadcastRoom(room, msg) {
if (room.disposed) return null;
const data = encode(msg);
if (data == null) { this.log.error(`[lobby] ${room.code} unserializable broadcast ${msg && msg.t}`); return null; }
const droppable = isDroppable(msg);
for (const session of this.memberSessions(room)) sendRaw(session.ws, data, { droppable });
return data;
}
/** Match unicast. @returns {boolean} */
sendToPlayer(room, playerId, msg) {
if (room.disposed) return false;
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;
return sendSession(session, msg);
}
}