// 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.addBot, room.removeBot, room.start. ▸ Changing the difficulty // 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). // * 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, 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), '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). import { randomBytes, randomInt } from 'node:crypto'; import { ERR, MAX_SEATS, 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'; import { Match as DefaultMatch } from './match/Match.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·可露希尔']); 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 | null }} Seat */ /** 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); } /** 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; /** @type {string | null} */ this.hostId = null; /** @type {(Seat | null)[]} */ this.seats = new Array(MAX_SEATS).fill(null); /** @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, pending: Set } | 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; } /** 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, 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)), }; } } /** 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, * }} 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} */ this.rooms = new Map(); /** @type {Map} lobby grace timers by playerId */ this.graceTimers = new Map(); /** @type {Map} 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; 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++); } return { rooms: this.rooms.size, matches, humans, bots }; } // --------------------------------------------------------------------------------------------------- // 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; const seat = room.seatOf(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.addBot': return this.addBot(session); case 'room.removeBot': return this.removeBot(session, msg); case 'room.start': return this.start(session); case 'room.loadout': return this.loadout(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 seat = room.seatOf(session.playerId); seat.connected = false; if (room.match) 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. const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 ? 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); if (cur === room) { 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; } ready(session, { ready }) { const room = this.roomOf(session); if (!room) return fail(ERR.NOT_IN_ROOM); 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; } 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; } 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); } /** * room.loadout (DESIGN §16): check the operator loadout against the game data, store it on the session and the seat, * and — while a match runs — hand it to the match (accepted only during INFO_CHECK, see the header). */ loadout(session, { entries }) { 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 loadout = freezeLoadout(res.loadout); session.loadout = loadout; const room = this.roomOf(session); if (!room) return OK; const seat = room.seatOf(session.playerId); if (seat) seat.loadout = loadout; if (!room.match) return OK; 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); } 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; } // --------------------------------------------------------------------------------------------------- // 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 (bots fight with the defaults) loadout: s.isBot ? null : s.loadout || 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), seats, 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); } 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) { 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, '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; } 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 || 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, }; } /** * 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); 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); } /** 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); 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; } 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. */ *memberSessions(room) { for (const s of room.seats) { 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); 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); } }