Phase 1: reducer, SQLite, rooms & invite links
Turns the Phase 0 deployment shell into a playable async multiplayer game:
- shared/game.js + shared/board.js: pure reducer (reduce(state, action) ->
newState) over a small branching board subset mirroring the sketched
design (Career/Education fork, Relationship/Investment fork, High-Risk/Safe
fork, race to Finish). Dice randomness is generated server-side and shipped
inside the ROLL action payload, so the reducer itself stays fully pure and
is identically importable by both server and browser.
- server/db.js: SQLite (better-sqlite3) schema for games/players/tokens,
config and state stored as JSON. tokens covers both room invite links and
per-player reconnect secrets.
- server/rooms.js: in-memory room registry that is the only place the shared
reducer is invoked server-side — validates intents, applies actions,
persists, and broadcasts to every socket in the room.
- server/index.js: REST endpoints to create/join/inspect a game, and a
room-aware /ws that authenticates via a first {type:'AUTH'} message rather
than a URL query param (keeps session tokens out of access/proxy logs).
- public/client.js + public/index.html: NetworkTransport wrapping the
WebSocket, localStorage-backed session persistence so a reload resumes as
the same player, and a lobby/waiting-room/game-view UI.
- Dockerfile: adds python3/make/g++ so better-sqlite3's node-gyp fallback
builds on Alpine when a prebuilt binary isn't available for the exact
Node/musl combo.
Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 * as db from './db.js';
|
||||
|
||||
export class RoomError extends Error {}
|
||||
|
||||
const rooms = new Map(); // gameId -> { gameId, state, sockets: Set<WebSocket> }
|
||||
|
||||
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':
|
||||
return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) };
|
||||
case 'REQUEST_CHOOSE':
|
||||
if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required');
|
||||
return { type: 'CHOOSE', playerId, spaceId: intent.spaceId };
|
||||
default:
|
||||
throw new Error(`Unknown intent: ${intent.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user