// Private rooms: numeric-code matches for two players who agree on a // code out-of-band (see PROTOCOL.md "Private rooms"). A room replays // the same round-session engine (game/session.js) up to 5 times in a // row, picking a random theme per match (excluding the previous // match's theme) unless the creator asked for `themeMode: 'chosen'`, // in which case a 10-second theme vote (rooms/themeVote.js, see // PROTOCOL.md "Theme voting") settles match 1's theme once; every // later match in the series reuses that same voted theme (see // resolveChosenTheme below). // // Rooms never import or touch matchmaking's ban store: quitting a // private room carries no penalty, unlike quitting a matchmaking match. import { registerHandler, onDisconnect } from '../ws/connectionHandler.js'; import { getPlayerId, getPlayerName, sendTo } from '../ws/registry.js'; import { createSession } from '../game/session.js'; import { pickRandomThemeId } from '../themeIds.js'; import { startVote } from './themeVote.js'; const MAX_MATCHES = 5; const CODE_MIN = 100000; const CODE_MAX = 999999; // code (number) -> room. A room's code is freed (removed from this map) // as soon as the room closes, so a future room:create can reuse it. const rooms = new Map(); // playerId -> code, so onDisconnect (which only knows a playerId) can // find the room a player belongs to without scanning every room. const roomCodeByPlayerId = new Map(); function generateCode() { let code; do { code = CODE_MIN + Math.floor(Math.random() * (CODE_MAX - CODE_MIN + 1)); } while (rooms.has(code)); return code; } function otherPlayerId(room, playerId) { return room.players.find((id) => id !== playerId); } function teardownRoom(room) { rooms.delete(room.code); for (const id of room.players) { roomCodeByPlayerId.delete(id); } } /** * Closes a room whose match just ended via an explicit quit or a * mid-match disconnect. game/session.js's own quitSession() already * sent `room:playerLeft` + `room:closed` (reason: "quit") to the * remaining player before calling onEnd — this just forgets the room. */ function closeAfterSessionQuit(room) { teardownRoom(room); } /** * Closes a room the session engine never got a chance to react to: * either the 5-match series cap was reached (`matchLimit`), or a * player quit/disconnected while no match was in flight (e.g. the * creator alone, still waiting for an opponent). Notifies whoever is * still connected, then forgets the room. */ function closeRoom(room, reason, quitterId) { if (reason === 'quit') { const remainingId = quitterId === undefined ? undefined : otherPlayerId(room, quitterId); if (remainingId) { sendTo(remainingId, { type: 'room:playerLeft' }); sendTo(remainingId, { type: 'room:closed', reason: 'quit' }); } } else { for (const id of room.players) { sendTo(id, { type: 'room:closed', reason }); } } teardownRoom(room); } /** * `themeMode: 'chosen'` theme resolution: runs the real theme vote * (rooms/themeVote.js) for match 1 only. PROTOCOL.md "Theme voting" * says the voted theme is reused for the whole 5-match series, so once * `room.votedThemeId` is set every later startMatch() call short- * circuits straight to it instead of voting again. */ function resolveChosenTheme(room, cb) { if (room.votedThemeId !== null) { cb(room.votedThemeId); return; } startVote(room, (themeId) => { // The room may have been torn down (e.g. a player disconnected) // while the 10s vote was in flight; don't resurrect it. if (!rooms.has(room.code)) return; room.votedThemeId = themeId; cb(themeId); }); } function resolveTheme(room, cb) { if (room.themeMode === 'chosen') { resolveChosenTheme(room, cb); return; } // Random mode: uniform pick, excluding the previous match's theme so // the same theme never plays twice in a row. Match 1 has no previous // theme yet, so nothing is excluded. cb(pickRandomThemeId(room.previousThemeId ?? undefined)); } function startMatch(room) { resolveTheme(room, (themeId) => { room.currentThemeId = themeId; const [playerA, playerB] = room.players; room.session = createSession({ playerA, playerB, timeMode: room.timeMode, themeId, onEnd: (reason, quitterId) => handleSessionEnd(room, reason, quitterId), }); }); } function handleSessionEnd(room, reason, quitterId) { if (!rooms.has(room.code)) return; // already torn down if (reason === 'quit') { closeAfterSessionQuit(room); return; } // Normal completion (round:reveal delivered, no quit). room.matchesPlayed += 1; room.previousThemeId = room.currentThemeId; if (room.matchesPlayed >= MAX_MATCHES) { closeRoom(room, 'matchLimit'); return; } const nextMatch = room.matchesPlayed + 1; for (const id of room.players) { sendTo(id, { type: 'round:nextMatch', match: nextMatch }); } startMatch(room); } /** * Registers this module's message handlers and disconnect hook. Called * once from index.js at boot so private rooms are actually reachable. */ export function registerRoomHandlers() { registerHandler('room:create', (socket, message) => { const ownerId = getPlayerId(socket); if (!ownerId) return; const { timeMode, themeMode } = message; if (timeMode !== 1 && timeMode !== 2) return; if (themeMode !== 'random' && themeMode !== 'chosen') return; const code = generateCode(); const room = { code, ownerId, timeMode, themeMode, players: [ownerId], matchesPlayed: 0, previousThemeId: null, currentThemeId: null, votedThemeId: null, session: null, }; rooms.set(code, room); roomCodeByPlayerId.set(ownerId, code); sendTo(ownerId, { type: 'room:created', code }); }); registerHandler('room:join', (socket, message) => { const joinerId = getPlayerId(socket); if (!joinerId) return; const { code } = message; const room = typeof code === 'number' ? rooms.get(code) : undefined; if (!room) { sendTo(joinerId, { type: 'room:error', reason: 'not_found' }); return; } if (room.players.length >= 2 || room.players.includes(joinerId)) { sendTo(joinerId, { type: 'room:error', reason: 'full' }); return; } room.players.push(joinerId); roomCodeByPlayerId.set(joinerId, room.code); const ownerName = getPlayerName(room.ownerId) ?? ''; const joinerName = getPlayerName(joinerId) ?? ''; sendTo(room.ownerId, { type: 'room:joined', code: room.code, opponentName: joinerName, timeMode: room.timeMode, themeMode: room.themeMode, }); sendTo(joinerId, { type: 'room:joined', code: room.code, opponentName: ownerName, timeMode: room.timeMode, themeMode: room.themeMode, }); startMatch(room); }); // Handles a dropped socket identically to an explicit player:quit for // room-closing purposes. game/session.js's own onDisconnect hook // already runs first (room.js imports session.js, so session.js's // top-level onDisconnect() subscription registers before this one) // and, for a mid-match disconnect, already tore this room down via // handleSessionEnd -> closeAfterSessionQuit, clearing // roomCodeByPlayerId for both players. So by the time this fires, a // mid-match disconnect is a no-op here; only a disconnect with no // active session (e.g. the creator alone, still waiting for an // opponent) is still live in `rooms`/`roomCodeByPlayerId` and needs // handling here. onDisconnect((playerId) => { const code = roomCodeByPlayerId.get(playerId); if (code === undefined) return; const room = rooms.get(code); if (!room) return; closeRoom(room, 'quit', playerId); }); }