diff --git a/backend/src/index.js b/backend/src/index.js index ec8a545..886ab2e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -2,6 +2,7 @@ import http from 'node:http'; import { WebSocketServer } from 'ws'; import { handleConnection } from './ws/connectionHandler.js'; import { registerMatchmakingHandlers } from './matchmaking/queue.js'; +import { registerRoomHandlers } from './rooms/room.js'; const PORT = process.env.PORT || 8080; @@ -22,6 +23,8 @@ const server = http.createServer((req, res) => { const wss = new WebSocketServer({ server }); wss.on('connection', handleConnection); +registerRoomHandlers(); + server.listen(PORT, () => { console.log(`Matchmaking server listening on :${PORT}`); }); diff --git a/backend/src/rooms/README.md b/backend/src/rooms/README.md index 7d3e004..9fdd34c 100644 --- a/backend/src/rooms/README.md +++ b/backend/src/rooms/README.md @@ -1,5 +1,6 @@ # rooms/ -Reserved for game-session/room state: tracking which sockets belong to a -match, broadcasting round state, and cleaning up on disconnect. Not -implemented yet. +Numeric-code private rooms: `room.js` tracks live rooms (code -> room +state), handles `room:create`/`room:join`, and replays +`game/session.js` matches up to a 5-match cap unless a player quits. +Implemented; theme voting for `themeMode: 'chosen'` rooms is ticket #7. diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js new file mode 100644 index 0000000..e9b3f9d --- /dev/null +++ b/backend/src/rooms/room.js @@ -0,0 +1,228 @@ +// 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 real theme voting is ticket #7 — this ticket only +// wires a narrow, synchronous stand-in (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'; + +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 is not implemented by this + * ticket (#6) — real theme voting (`vote:start`/`vote:cast`/ + * `vote:settled`) is ticket #7. This is a narrow, synchronous stand-in + * so #7 has an obvious integration point without this ticket blocking + * on it: swap this function's body for the real voting flow. + */ +function resolveChosenTheme(room, cb) { + // TODO(#7): replace with real theme-voting flow (startVote) + cb(pickRandomThemeId()); +} + +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, + 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); + }); +}