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 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' });
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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');
|
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' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue