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,117 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user