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.
This commit is contained in:
Gabriel Franco 2026-09-10 12:15:52 -03:00
parent 611b588db6
commit ea003453af
5 changed files with 161 additions and 13 deletions

View file

@ -1,9 +1,12 @@
import http from 'node:http'; 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';
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8080;
registerMatchmakingHandlers();
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.url === '/health') { if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });

View file

@ -1,4 +1,7 @@
# matchmaking/ # matchmaking/
Reserved for the matchmaking queue: pairing waiting players, party/lobby Pairs waiting players FIFO-style, randomly assigns per-match timeMode
handling, and handing off matched players to a room. Not implemented yet. (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.

View file

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

View file

@ -55,7 +55,7 @@ registerHandler('identify', (socket, message) => {
sendError(socket, 'identify requires a string name'); sendError(socket, 'identify requires a string name');
return; return;
} }
registerSocket(playerId, socket); registerSocket(playerId, socket, name);
socket.send(JSON.stringify({ type: 'identify:ack' })); socket.send(JSON.stringify({ type: 'identify:ack' }));
}); });

View file

@ -1,27 +1,35 @@
// In-memory bidirectional registry mapping the client-generated // In-memory bidirectional registry mapping the client-generated
// `playerId` (see PROTOCOL.md "Identity") to the live WebSocket for that // `playerId` (see PROTOCOL.md "Identity") to the live WebSocket for that
// player, and back. Other backend modules (matchmaking, rooms, ...) use // player, and back, plus the player's last-known display `name` (also
// this to look up "the socket for player X" to send targeted messages, // sent on `identify`). Other backend modules (matchmaking, rooms, ...)
// and connectionHandler.js uses it to look up "the playerId for this // 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. // socket" on disconnect for cleanup.
import { WebSocket } from 'ws';
const socketsByPlayerId = new Map(); const socketsByPlayerId = new Map();
const playerIdsBySocket = 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 * Registers a socket under a playerId, optionally recording/updating its
* bound to a different (stale) socket, the stale binding is dropped * display name (from `identify`). If that playerId was already bound to
* first so the registry never points a playerId at a dead socket. * 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); const existingSocket = socketsByPlayerId.get(playerId);
if (existingSocket && existingSocket !== socket) { if (existingSocket && existingSocket !== socket) {
playerIdsBySocket.delete(existingSocket); playerIdsBySocket.delete(existingSocket);
} }
socketsByPlayerId.set(playerId, socket); socketsByPlayerId.set(playerId, socket);
playerIdsBySocket.set(socket, playerId); 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); 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. * Sends a typed JSON message to the socket registered for playerId.
* Returns true if the message was sent, false if there was no open * Returns true if the message was sent, false if there was no open