feat(backend): add private rooms with create/join and match repeat

Implements PROTOCOL.md's private-room flow: room:create/room:join
with a collision-checked numeric code, up to a 5-match repeating
series per room via game/session.js, random-theme selection that
excludes the previous match's theme, and room:closed for matchLimit
or quit (rooms never touch matchmaking's ban store). themeMode:
'chosen' gets a narrow resolveChosenTheme() stand-in pending #7's
real theme-voting flow.

Also adds a minimal opponentName lookup to registry.js
(registerSocket's new optional name arg + getPlayerName) since
room:joined needs it, mirroring the same addition landing on #5's
branch.
This commit is contained in:
Gabriel Franco 2026-09-10 12:16:13 -03:00
parent ea003453af
commit a7cc3e7d8c
3 changed files with 235 additions and 3 deletions

View file

@ -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}`);
});

View file

@ -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.

228
backend/src/rooms/room.js Normal file
View file

@ -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);
});
}