feat(backend): add WS connection lifecycle and player identity registry
This commit is contained in:
parent
0047da5aab
commit
66db5b1931
2 changed files with 163 additions and 5 deletions
|
|
@ -1,8 +1,106 @@
|
|||
// Placeholder connection handler. Matchmaking, room assignment, and
|
||||
// game-state sync are not implemented yet — this only proves the
|
||||
// WebSocket transport itself works end to end.
|
||||
// WebSocket connection lifecycle: parses each incoming text frame as a
|
||||
// flat `{ type, ...payload }` envelope (see PROTOCOL.md "Transport") and
|
||||
// dispatches it to whichever handler has been registered for that
|
||||
// `type` via registerHandler(). Matchmaking, rooms, and the session
|
||||
// engine own their own message types and register into this dispatcher
|
||||
// from their own files (backend/src/matchmaking/, backend/src/rooms/)
|
||||
// instead of this file growing a god-switch.
|
||||
//
|
||||
// The registry (registry.js) is the source of truth for which socket
|
||||
// belongs to which playerId. This file owns wiring a socket into that
|
||||
// registry on `identify` and tearing it down on close; it does not know
|
||||
// anything about matches, rooms, or queues beyond that.
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import { registerSocket, unregisterSocket, getPlayerId } from './registry.js';
|
||||
|
||||
const handlers = new Map();
|
||||
const disconnectSubscribers = new Set();
|
||||
|
||||
/**
|
||||
* Registers a handler function for a given message `type`. Called by
|
||||
* this file (for `identify`) and by other modules that want to handle
|
||||
* their own message types without editing this file's dispatch loop.
|
||||
* `fn(socket, message)` is invoked with the parsed message envelope.
|
||||
*/
|
||||
export function registerHandler(type, fn) {
|
||||
handlers.set(type, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to socket disconnects. `cb(playerId)` fires once a socket
|
||||
* that had identified as `playerId` closes, after it has already been
|
||||
* removed from the registry. Lets matchmaking/rooms react (ban table,
|
||||
* `room:playerLeft`, ...) without this file knowing about matches or
|
||||
* rooms. Returns an unsubscribe function.
|
||||
*/
|
||||
export function onDisconnect(cb) {
|
||||
disconnectSubscribers.add(cb);
|
||||
return () => disconnectSubscribers.delete(cb);
|
||||
}
|
||||
|
||||
function sendError(socket, message) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'error', message }));
|
||||
}
|
||||
}
|
||||
|
||||
registerHandler('identify', (socket, message) => {
|
||||
const { playerId, name } = message;
|
||||
if (typeof playerId !== 'string' || playerId.length === 0) {
|
||||
sendError(socket, 'identify requires a string playerId');
|
||||
return;
|
||||
}
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
sendError(socket, 'identify requires a string name');
|
||||
return;
|
||||
}
|
||||
registerSocket(playerId, socket);
|
||||
socket.send(JSON.stringify({ type: 'identify:ack' }));
|
||||
});
|
||||
|
||||
function handleMessage(socket, raw) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
sendError(socket, 'Malformed JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object' || Array.isArray(message) || typeof message.type !== 'string') {
|
||||
sendError(socket, 'Message must be an object with a string "type" field');
|
||||
return;
|
||||
}
|
||||
|
||||
// Per PROTOCOL.md "Identity": no message is valid before `identify`.
|
||||
if (getPlayerId(socket) === undefined && message.type !== 'identify') {
|
||||
socket.close(1008, 'identify required');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers.get(message.type);
|
||||
if (!handler) {
|
||||
sendError(socket, `Unknown message type: ${message.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
handler(socket, message);
|
||||
} catch (err) {
|
||||
console.error(`handler for "${message.type}" threw:`, err);
|
||||
sendError(socket, `Internal error handling ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleConnection(socket) {
|
||||
console.log('client connected');
|
||||
socket.on('close', () => console.log('client disconnected'));
|
||||
socket.on('message', (raw) => handleMessage(socket, raw));
|
||||
|
||||
socket.on('close', () => {
|
||||
const playerId = unregisterSocket(socket);
|
||||
if (playerId === undefined) return;
|
||||
for (const cb of disconnectSubscribers) {
|
||||
cb(playerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
60
backend/src/ws/registry.js
Normal file
60
backend/src/ws/registry.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// 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
|
||||
// socket" on disconnect for cleanup.
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const socketsByPlayerId = new Map();
|
||||
const playerIdsBySocket = 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.
|
||||
*/
|
||||
export function registerSocket(playerId, socket) {
|
||||
const existingSocket = socketsByPlayerId.get(playerId);
|
||||
if (existingSocket && existingSocket !== socket) {
|
||||
playerIdsBySocket.delete(existingSocket);
|
||||
}
|
||||
socketsByPlayerId.set(playerId, socket);
|
||||
playerIdsBySocket.set(socket, playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a socket's registration. Returns the playerId that was bound
|
||||
* to it, or undefined if the socket was never registered (e.g. it
|
||||
* disconnected before sending `identify`).
|
||||
*/
|
||||
export function unregisterSocket(socket) {
|
||||
const playerId = playerIdsBySocket.get(socket);
|
||||
if (playerId === undefined) return undefined;
|
||||
playerIdsBySocket.delete(socket);
|
||||
if (socketsByPlayerId.get(playerId) === socket) {
|
||||
socketsByPlayerId.delete(playerId);
|
||||
}
|
||||
return playerId;
|
||||
}
|
||||
|
||||
export function getSocket(playerId) {
|
||||
return socketsByPlayerId.get(playerId);
|
||||
}
|
||||
|
||||
export function getPlayerId(socket) {
|
||||
return playerIdsBySocket.get(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a typed JSON message to the socket registered for playerId.
|
||||
* Returns true if the message was sent, false if there was no open
|
||||
* socket for that player (already disconnected, or never identified).
|
||||
*/
|
||||
export function sendTo(playerId, message) {
|
||||
const socket = socketsByPlayerId.get(playerId);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
Loading…
Reference in a new issue