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

908 lines
41 KiB
JavaScript

// 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.kick, 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).
// * room.kick {seat} (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). 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, 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), '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<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 {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;
}
/** 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<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;
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.kick': return this.kick(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;
}
/** Host removes another human before the match (header: room.kick). */
kick(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.left) return fail(ERR.BAD_TARGET, 'seat holds no player');
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);
}
/**
* 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);
}
}