Files
ark-stronghold-protocol/server/lobby.js
T

977 lines
45 KiB
JavaScript
Raw 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.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).
// * 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, 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';
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<string, { skill: number, module: string|null }> | 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 {{ 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,
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.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);
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);
if (target && target.roomCode === room.code) {
if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' });
else target.notice = 'kicked';
}
this.removeMember(room, playerId);
return OK;
}
ready(session, { ready }) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
if (room.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
const seat = room.seatOf(session.playerId);
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 || !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);
} 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,
// 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,
};
}
/**
* 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);
}
}