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:
parent
611b588db6
commit
ea003453af
5 changed files with 161 additions and 13 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
126
backend/src/matchmaking/queue.js
Normal file
126
backend/src/matchmaking/queue.js
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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' }));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue