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
|
// WebSocket connection lifecycle: parses each incoming text frame as a
|
||||||
// game-state sync are not implemented yet — this only proves the
|
// flat `{ type, ...payload }` envelope (see PROTOCOL.md "Transport") and
|
||||||
// WebSocket transport itself works end to end.
|
// 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) {
|
export function handleConnection(socket) {
|
||||||
console.log('client connected');
|
socket.on('message', (raw) => handleMessage(socket, raw));
|
||||||
socket.on('close', () => console.log('client disconnected'));
|
|
||||||
|
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