/** * In-memory room registry, SQLite as the durable backing store. Every * authoritative state change flows through applyAndBroadcast(), which is the * only place the shared reducer is invoked server-side. */ import crypto from 'node:crypto'; import { reduce, getLegalIntents } from '../shared/game.js'; import { board } from '../shared/board.js'; import { rollsNeededFor, DIE_SIZES } from '../shared/tileEffects.js'; import * as db from './db.js'; export class RoomError extends Error {} const rooms = new Map(); // gameId -> { gameId, state, sockets: Set } export function loadOrCreateRoom(gameId) { const existing = rooms.get(gameId); if (existing) return existing; const row = db.getGameById(gameId); if (!row) throw new RoomError(`Game not found: ${gameId}`); const room = { gameId, state: row.state, sockets: new Set() }; rooms.set(gameId, room); return room; } export function attachSocket(room, ws, playerId) { ws.playerId = playerId; room.sockets.add(ws); send(ws, { type: 'state', gameId: room.gameId, state: room.state, lastAction: null }); ws.on('close', () => room.sockets.delete(ws)); } /** Apply an already-constructed action through the shared reducer, persist, * and broadcast the result to every socket in the room. Throws if the * reducer rejects the action. */ export function applyAndBroadcast(room, action) { const newState = reduce(room.state, action); room.state = newState; db.updateGameState(room.gameId, newState, newState.status); broadcast(room, { type: 'state', gameId: room.gameId, state: newState, lastAction: action }); return newState; } /** Validate + resolve a client intent into a real action, then apply it. * Sends an {type:'error'} back to the originating socket on rejection. */ export function handleIntent(room, ws, playerId, intent) { try { const action = buildAction(room.state, playerId, intent); applyAndBroadcast(room, action); } catch (err) { send(ws, { type: 'error', message: err.message }); } } function buildAction(state, playerId, intent) { const legal = getLegalIntents(state, playerId); if (!legal.includes(intent.type)) { throw new Error(`Not allowed: ${intent.type}`); } switch (intent.type) { case 'REQUEST_START': return { type: 'START_GAME' }; case 'REQUEST_ROLL': { const player = state.players[playerId]; const nextSpaceId = board.spaces[player.position].next; if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board'); return { type: 'ROLL', playerId, rolls: rollForSpace(board.spaces[nextSpaceId]) }; } case 'REQUEST_CHOOSE': { if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required'); return { type: 'CHOOSE', playerId, spaceId: intent.spaceId, rolls: rollForSpace(board.spaces[intent.spaceId]) }; } default: throw new Error(`Unknown intent: ${intent.type}`); } } /** Pre-roll whatever dice `space` needs to resolve its own tile effect — the * only place actual randomness happens. rollsNeededFor() never touches * randomness itself, it just says which die sizes are needed and in what * order; reduce() applies the results deterministically. */ function rollForSpace(space) { return rollsNeededFor(space).map((die) => { const max = DIE_SIZES[die]; if (!max) throw new Error(`Unknown die size: ${die}`); return 1 + crypto.randomInt(max); }); } function broadcast(room, payload) { for (const ws of room.sockets) send(ws, payload); } function send(ws, payload) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload)); }