d40bc09867
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.
118 lines
3.4 KiB
JavaScript
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();
|
|
}
|
|
}
|