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:
parent
ea003453af
commit
a7cc3e7d8c
3 changed files with 235 additions and 3 deletions
|
|
@ -2,6 +2,7 @@ import http from 'node:http';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { handleConnection } from './ws/connectionHandler.js';
|
import { handleConnection } from './ws/connectionHandler.js';
|
||||||
import { registerMatchmakingHandlers } from './matchmaking/queue.js';
|
import { registerMatchmakingHandlers } from './matchmaking/queue.js';
|
||||||
|
import { registerRoomHandlers } from './rooms/room.js';
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8080;
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|
||||||
|
|
@ -22,6 +23,8 @@ const server = http.createServer((req, res) => {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
wss.on('connection', handleConnection);
|
wss.on('connection', handleConnection);
|
||||||
|
|
||||||
|
registerRoomHandlers();
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Matchmaking server listening on :${PORT}`);
|
console.log(`Matchmaking server listening on :${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# rooms/
|
# rooms/
|
||||||
|
|
||||||
Reserved for game-session/room state: tracking which sockets belong to a
|
Numeric-code private rooms: `room.js` tracks live rooms (code -> room
|
||||||
match, broadcasting round state, and cleaning up on disconnect. Not
|
state), handles `room:create`/`room:join`, and replays
|
||||||
implemented yet.
|
`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
228
backend/src/rooms/room.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue