Files
Kevin d40bc09867 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.
2026-07-21 17:10:56 -07:00

118 lines
3.4 KiB
JavaScript

/**
* Browser-side networking: thin REST wrappers, per-game session storage, and
* a NetworkTransport wrapping the authenticated WebSocket. UI code (index.html)
* never touches fetch()/WebSocket directly — it goes through this module.
*/
// Re-exported purely so the UI can decide what to show (enable the Roll
// button, render choice options, ...). Authoritative state always comes from
// the server's `state` broadcast — the UI never re-derives it locally.
export { getLegalIntents, isPlayersTurn } from '/shared/game.js';
export { board } from '/shared/board.js';
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
export function createGame(hostName) {
return postJson('/api/games', { hostName });
}
export function joinGame(code, name) {
return postJson(`/api/games/${encodeURIComponent(code)}/join`, { name });
}
export async function fetchGame(code) {
const res = await fetch(`/api/games/${encodeURIComponent(code)}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
// --- Per-game session persistence (localStorage), so a reload/reconnect
// resumes as the same player instead of joining again. ---
const STORAGE_PREFIX = 'lifegame:session:';
export function saveSession(code, session) {
localStorage.setItem(STORAGE_PREFIX + code, JSON.stringify(session));
}
export function loadSession(code) {
const raw = localStorage.getItem(STORAGE_PREFIX + code);
return raw ? JSON.parse(raw) : null;
}
// --- WebSocket transport ---
export class NetworkTransport {
constructor() {
this.ws = null;
this._stateHandlers = [];
this._errorHandlers = [];
}
/** Opens the socket, authenticates with the session token, and resolves
* once the first authoritative state has been received. */
connect(sessionToken) {
return new Promise((resolve, reject) => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
this.ws = ws;
let settled = false;
ws.onopen = () => ws.send(JSON.stringify({ type: 'AUTH', token: sessionToken }));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'state') {
if (!settled) {
settled = true;
resolve();
}
this._stateHandlers.forEach((cb) => cb(msg));
} else if (msg.type === 'error') {
this._errorHandlers.forEach((cb) => cb(msg));
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
reject(new Error('WebSocket connection failed'));
}
};
ws.onclose = (event) => {
if (!settled) {
settled = true;
reject(new Error(`Connection closed (${event.code})`));
}
};
});
}
sendIntent(intent) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(intent));
}
}
onState(cb) {
this._stateHandlers.push(cb);
}
onError(cb) {
this._errorHandlers.push(cb);
}
close() {
this.ws?.close();
}
}