From ea003453af8b28c3f27e65a6c15382b741fc6fc4 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 12:15:52 -0300 Subject: [PATCH] feat(backend): add matchmaking queue with random mode and quit ban Registers queue:join/queue:leave handlers that FIFO-pair waiting players, randomly assign timeMode (1 or 2, uniform) and theme via themeIds.js, and hand off to game/session.js's createSession. Tracks a 2-minute matchmaking-only quit ban (in-memory, resets on restart) via onEnd's 'quit' reason, scoped to sessions this module produced. Exports isBanned() and registerMatchmakingHandlers(), wired into index.js at boot. Also extends ws/registry.js with a playerId -> display name store (registerSocket's new optional third arg, getPlayerName()) so match:found.opponentName (and future room:joined.opponentName) can be populated; connectionHandler.js's identify handler now passes name through. --- backend/src/index.js | 3 + backend/src/matchmaking/README.md | 7 +- backend/src/matchmaking/queue.js | 126 ++++++++++++++++++++++++++++ backend/src/ws/connectionHandler.js | 2 +- backend/src/ws/registry.js | 36 +++++--- 5 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 backend/src/matchmaking/queue.js diff --git a/backend/src/index.js b/backend/src/index.js index 108320e..ec8a545 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,9 +1,12 @@ import http from 'node:http'; import { WebSocketServer } from 'ws'; import { handleConnection } from './ws/connectionHandler.js'; +import { registerMatchmakingHandlers } from './matchmaking/queue.js'; const PORT = process.env.PORT || 8080; +registerMatchmakingHandlers(); + const server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/backend/src/matchmaking/README.md b/backend/src/matchmaking/README.md index 50d9575..b7ad313 100644 --- a/backend/src/matchmaking/README.md +++ b/backend/src/matchmaking/README.md @@ -1,4 +1,7 @@ # matchmaking/ -Reserved for the matchmaking queue: pairing waiting players, party/lobby -handling, and handing off matched players to a room. Not implemented yet. +Pairs waiting players FIFO-style, randomly assigns per-match timeMode +(1 or 2 minutes, never player-chosen) and theme, and enforces the +2-minute matchmaking quit ban — see `queue.js` and +`../PROTOCOL.md`'s "Matchmaking" and "Quit" sections. Party/lobby +handling beyond a single 1v1 queue is not implemented. diff --git a/backend/src/matchmaking/queue.js b/backend/src/matchmaking/queue.js new file mode 100644 index 0000000..8f74a73 --- /dev/null +++ b/backend/src/matchmaking/queue.js @@ -0,0 +1,126 @@ +// FIFO-ish matchmaking queue: pairs the two longest-waiting players, +// randomly assigns the round's timeMode and theme (never chosen by +// players, see PROTOCOL.md "Matchmaking"), and hands off to +// game/session.js to run the actual round. Also owns the matchmaking +// quit-ban table (PROTOCOL.md "Quit" > "Matchmaking match"). +// +// Registration is exported as registerMatchmakingHandlers() rather than +// running as an import-time side effect, so backend/src/index.js has an +// explicit, greppable call site instead of relying on "importing this +// file happens to register handlers". + +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'; + +// PROTOCOL.md "Quit": a matchmaking quitter's playerId is banned from +// queue:join for 2 minutes from the moment they quit. +const BAN_DURATION_MS = 2 * 60 * 1000; + +// playerId -> { joinedAt } for players waiting to be matched. Map +// preserves insertion order, so its key iteration order is FIFO. +const waiting = new Map(); + +// playerId -> banUntil (epoch ms). Module-level in-memory state only — +// bans reset whenever the server process restarts. +const bansByPlayerId = new Map(); + +// playerIds currently in a session THIS module produced, so this +// module's onEnd callback only ever bans for a matchmaking quit, never +// for a private-room quit (session.js is a shared engine with no notion +// of who created a given session). +const activeMatchmakingPlayerIds = new Set(); + +/** + * Returns the current matchmaking-ban state for playerId. A ban that has + * already expired is treated (and cleaned up) as not banned. + */ +export function isBanned(playerId) { + const banUntil = bansByPlayerId.get(playerId); + if (banUntil === undefined) return { banned: false }; + if (Date.now() >= banUntil) { + bansByPlayerId.delete(playerId); + return { banned: false }; + } + return { banned: true, until: banUntil }; +} + +function popOldestWaiting() { + const playerId = waiting.keys().next().value; + if (playerId !== undefined) waiting.delete(playerId); + return playerId; +} + +// Pairs off waiting players two at a time for as long as at least two +// are waiting (handles the rare case of several queue:join calls landing +// before this runs, e.g. two players already waiting when a third +// leaves and a fourth joins). +function tryMatch() { + while (waiting.size >= 2) { + const playerAId = popOldestWaiting(); + const playerBId = popOldestWaiting(); + + const timeMode = Math.random() < 0.5 ? 1 : 2; + const themeId = pickRandomThemeId(); + + activeMatchmakingPlayerIds.add(playerAId); + activeMatchmakingPlayerIds.add(playerBId); + + createSession({ + playerA: playerAId, + playerB: playerBId, + timeMode, + themeId, + onEnd: (reason, quitterId) => { + activeMatchmakingPlayerIds.delete(playerAId); + activeMatchmakingPlayerIds.delete(playerBId); + // Only a quit bans; normal completion (reason "reveal") just + // closes the match with no re-match loop (that's rooms-only). + if (reason === 'quit' && quitterId) { + bansByPlayerId.set(quitterId, Date.now() + BAN_DURATION_MS); + } + }, + }); + + sendTo(playerAId, { type: 'match:found', opponentName: getPlayerName(playerBId) ?? '', timeMode }); + sendTo(playerBId, { type: 'match:found', opponentName: getPlayerName(playerAId) ?? '', timeMode }); + } +} + +/** + * Registers this module's message handlers (queue:join, queue:leave) + * and disconnect cleanup. Called once from backend/src/index.js at + * startup. + */ +export function registerMatchmakingHandlers() { + registerHandler('queue:join', (socket) => { + const playerId = getPlayerId(socket); + if (!playerId) return; + + const ban = isBanned(playerId); + if (ban.banned) { + sendTo(playerId, { type: 'queue:banned', bannedUntil: ban.until }); + return; + } + + // Already waiting (duplicate queue:join before being matched): no-op. + if (waiting.has(playerId)) return; + + waiting.set(playerId, { joinedAt: Date.now() }); + tryMatch(); + }); + + registerHandler('queue:leave', (socket) => { + const playerId = getPlayerId(socket); + if (!playerId) return; + waiting.delete(playerId); + }); + + // A dropped socket while still waiting (never matched) just leaves the + // queue; a drop mid-match is handled by session.js's own onDisconnect + // subscriber, which drives quitSession -> our onEnd callback above. + onDisconnect((playerId) => { + waiting.delete(playerId); + }); +} diff --git a/backend/src/ws/connectionHandler.js b/backend/src/ws/connectionHandler.js index f0768f4..eeada7e 100644 --- a/backend/src/ws/connectionHandler.js +++ b/backend/src/ws/connectionHandler.js @@ -55,7 +55,7 @@ registerHandler('identify', (socket, message) => { sendError(socket, 'identify requires a string name'); return; } - registerSocket(playerId, socket); + registerSocket(playerId, socket, name); socket.send(JSON.stringify({ type: 'identify:ack' })); }); diff --git a/backend/src/ws/registry.js b/backend/src/ws/registry.js index 0bd0671..97795a5 100644 --- a/backend/src/ws/registry.js +++ b/backend/src/ws/registry.js @@ -1,27 +1,35 @@ // In-memory bidirectional registry mapping the client-generated // `playerId` (see PROTOCOL.md "Identity") to the live WebSocket for that -// player, and back. Other backend modules (matchmaking, rooms, ...) use -// this to look up "the socket for player X" to send targeted messages, -// and connectionHandler.js uses it to look up "the playerId for this +// player, and back, plus the player's last-known display `name` (also +// sent on `identify`). Other backend modules (matchmaking, rooms, ...) +// use this to look up "the socket for player X" (or "the name for +// player X", e.g. for `match:found.opponentName` / +// `room:joined.opponentName`) to send targeted messages, and +// connectionHandler.js uses it to look up "the playerId for this // socket" on disconnect for cleanup. - -import { WebSocket } from 'ws'; - const socketsByPlayerId = new Map(); const playerIdsBySocket = new Map(); +// Names persist across reconnects (keyed by the client-generated +// playerId, which is stable per PROTOCOL.md "Identity"), so a socket +// dropping does not erase who a still-waiting/queued opponent is. +const namesByPlayerId = new Map(); /** - * Registers a socket under a playerId. If that playerId was already - * bound to a different (stale) socket, the stale binding is dropped - * first so the registry never points a playerId at a dead socket. + * Registers a socket under a playerId, optionally recording/updating its + * display name (from `identify`). If that playerId was already bound to + * a different (stale) socket, the stale binding is dropped first so the + * registry never points a playerId at a dead socket. */ -export function registerSocket(playerId, socket) { +export function registerSocket(playerId, socket, name) { const existingSocket = socketsByPlayerId.get(playerId); if (existingSocket && existingSocket !== socket) { playerIdsBySocket.delete(existingSocket); } socketsByPlayerId.set(playerId, socket); playerIdsBySocket.set(socket, playerId); + if (typeof name === 'string' && name.length > 0) { + namesByPlayerId.set(playerId, name); + } } /** @@ -47,6 +55,14 @@ export function getPlayerId(socket) { return playerIdsBySocket.get(socket); } +/** + * Returns the last-known display name for playerId (set via `identify`), + * or undefined if that playerId has never identified with a name. + */ +export function getPlayerName(playerId) { + return namesByPlayerId.get(playerId); +} + /** * Sends a typed JSON message to the socket registered for playerId. * Returns true if the message was sent, false if there was no open