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:
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SQLite persistence. games/players hold identity + metadata; tokens covers
|
||||
* both unclaimed room invite links (kind='invite', player_id NULL) and
|
||||
* per-player reconnect secrets (kind='session'). config/state are stored as
|
||||
* JSON text — Phase 1 never needs to query into them.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
board_id TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','active','finished')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY,
|
||||
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
last_seen_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_players_game_id ON players(game_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_game_seat ON players(game_id, seat);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
player_id TEXT REFERENCES players(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('invite','session')),
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_game_id ON tokens(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_player_id ON tokens(player_id);
|
||||
`;
|
||||
|
||||
let db;
|
||||
|
||||
export function initDb(dbPath) {
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function deserializeGame(row) {
|
||||
if (!row) return null;
|
||||
return { ...row, config: JSON.parse(row.config), state: JSON.parse(row.state) };
|
||||
}
|
||||
|
||||
export function createGame({ id, code, boardId, config, state }) {
|
||||
db.prepare(
|
||||
`INSERT INTO games (id, code, board_id, config, state) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, code, boardId, JSON.stringify(config), JSON.stringify(state));
|
||||
return getGameById(id);
|
||||
}
|
||||
|
||||
export function getGameByCode(code) {
|
||||
return deserializeGame(db.prepare(`SELECT * FROM games WHERE code = ?`).get(code));
|
||||
}
|
||||
|
||||
export function getGameById(id) {
|
||||
return deserializeGame(db.prepare(`SELECT * FROM games WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
export function updateGameState(id, state, status) {
|
||||
db.prepare(`UPDATE games SET state = ?, status = ?, updated_at = ? WHERE id = ?`).run(
|
||||
JSON.stringify(state),
|
||||
status,
|
||||
now(),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlayer({ id, gameId, name, seat, color }) {
|
||||
db.prepare(
|
||||
`INSERT INTO players (id, game_id, name, seat, color) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, gameId, name, seat, color);
|
||||
}
|
||||
|
||||
export function getPlayersByGame(gameId) {
|
||||
return db.prepare(`SELECT * FROM players WHERE game_id = ? ORDER BY seat`).all(gameId);
|
||||
}
|
||||
|
||||
export function countPlayers(gameId) {
|
||||
return db.prepare(`SELECT COUNT(*) AS n FROM players WHERE game_id = ?`).get(gameId).n;
|
||||
}
|
||||
|
||||
export function touchPlayerLastSeen(playerId) {
|
||||
db.prepare(`UPDATE players SET last_seen_at = ? WHERE id = ?`).run(now(), playerId);
|
||||
}
|
||||
|
||||
export function createToken({ token, gameId, playerId = null, kind, expiresAt = null }) {
|
||||
db.prepare(
|
||||
`INSERT INTO tokens (token, game_id, player_id, kind, expires_at) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(token, gameId, playerId, kind, expiresAt);
|
||||
}
|
||||
|
||||
export function getToken(token) {
|
||||
return db.prepare(`SELECT * FROM tokens WHERE token = ?`).get(token) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Unambiguous alphabet for human-shareable join codes: no 0/O or 1/I.
|
||||
const CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
|
||||
export function randomId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function randomToken() {
|
||||
return crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
export function randomJoinCode(length = 6) {
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
+187
-24
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* Life Journey — Phase 0 deployment shell.
|
||||
* Life Journey — Phase 1 server.
|
||||
*
|
||||
* This does the minimum needed to prove the plumbing works end to end:
|
||||
* - serves the static frontend
|
||||
* - answers GET /api/health (is the server reachable?)
|
||||
* - accepts a WebSocket on /ws (does WS survive the reverse proxy?)
|
||||
*
|
||||
* There is deliberately NO game logic here yet. Phase 1 adds the shared
|
||||
* game.js reducer, a SQLite-backed data model, rooms, and real actions.
|
||||
* The data directory is created now so the Docker volume mount is validated.
|
||||
* The server is the sole authority over game state: it validates client
|
||||
* intents, generates the only source of randomness (the dice roll), builds
|
||||
* the real action, and applies it through the exact same shared reducer the
|
||||
* browser imports. Rooms/tokens/state are persisted to SQLite so a restart
|
||||
* (or a closed tab reconnecting later) resumes rather than resets.
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
@@ -18,46 +15,212 @@ import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { board } from '../shared/board.js';
|
||||
import { createInitialState } from '../shared/game.js';
|
||||
import * as db from './db.js';
|
||||
import * as rooms from './rooms.js';
|
||||
import { randomId, randomToken, randomJoinCode } from './ids.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
|
||||
// Ensure the persisted data directory exists (future: SQLite db + token uploads).
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
|
||||
|
||||
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
||||
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f'];
|
||||
const AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((err, _req, res, next) => {
|
||||
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
|
||||
next(err);
|
||||
});
|
||||
|
||||
// --- Health check: lets the frontend (and you) confirm the server is up ---
|
||||
// --- Health check ---
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'life-journey',
|
||||
phase: 0,
|
||||
version: '0.0.1',
|
||||
time: new Date().toISOString(),
|
||||
res.json({ ok: true, service: 'life-journey', phase: 1, version: '0.1.0', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
function cleanName(raw, label) {
|
||||
const name = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!name) throw Object.assign(new Error(`${label} is required`), { status: 400 });
|
||||
if (name.length > 40) throw Object.assign(new Error(`${label} is too long`), { status: 400 });
|
||||
return name;
|
||||
}
|
||||
|
||||
function uniqueJoinCode() {
|
||||
let code;
|
||||
do {
|
||||
code = randomJoinCode();
|
||||
} while (db.getGameByCode(code));
|
||||
return code;
|
||||
}
|
||||
|
||||
function buildInviteUrl(req, code) {
|
||||
const base = (process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
||||
return `${base}/join/${code}`;
|
||||
}
|
||||
|
||||
function colorForSeat(seat) {
|
||||
return PLAYER_COLORS[(seat - 1) % PLAYER_COLORS.length];
|
||||
}
|
||||
|
||||
// --- REST: games/rooms ---
|
||||
app.post('/api/games', (req, res) => {
|
||||
let hostName;
|
||||
try {
|
||||
hostName = cleanName(req.body?.hostName, 'hostName');
|
||||
} catch (err) {
|
||||
return res.status(err.status ?? 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const gameId = randomId();
|
||||
const code = uniqueJoinCode();
|
||||
const initialState = createInitialState(DEFAULT_CONFIG);
|
||||
db.createGame({ id: gameId, code, boardId: board.id, config: DEFAULT_CONFIG, state: initialState });
|
||||
|
||||
const room = rooms.loadOrCreateRoom(gameId);
|
||||
const playerId = randomId();
|
||||
const color = colorForSeat(1);
|
||||
db.createPlayer({ id: playerId, gameId, name: hostName, seat: 1, color });
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name: hostName, seat: 1, color });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
const sessionToken = randomToken();
|
||||
db.createToken({ token: sessionToken, gameId, playerId, kind: 'session' });
|
||||
|
||||
res.status(201).json({
|
||||
gameId,
|
||||
code,
|
||||
inviteUrl: buildInviteUrl(req, code),
|
||||
playerId,
|
||||
sessionToken,
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/games/:code', (req, res) => {
|
||||
const game = db.getGameByCode(req.params.code);
|
||||
if (!game) return res.status(404).json({ error: 'Game not found' });
|
||||
res.json({
|
||||
gameId: game.id,
|
||||
code: game.code,
|
||||
status: game.status,
|
||||
config: game.config,
|
||||
players: db.getPlayersByGame(game.id),
|
||||
state: game.state,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/games/:code/join', (req, res) => {
|
||||
const game = db.getGameByCode(req.params.code);
|
||||
if (!game) return res.status(404).json({ error: 'Game not found' });
|
||||
if (game.status !== 'lobby') return res.status(409).json({ error: 'Game already started' });
|
||||
|
||||
let name;
|
||||
try {
|
||||
name = cleanName(req.body?.name, 'name');
|
||||
} catch (err) {
|
||||
return res.status(err.status ?? 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const seat = db.countPlayers(game.id) + 1;
|
||||
if (seat > game.config.maxPlayers) return res.status(409).json({ error: 'Game is full' });
|
||||
|
||||
const room = rooms.loadOrCreateRoom(game.id);
|
||||
const playerId = randomId();
|
||||
const color = colorForSeat(seat);
|
||||
db.createPlayer({ id: playerId, gameId: game.id, name, seat, color });
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name, seat, color });
|
||||
} catch (err) {
|
||||
return res.status(409).json({ error: err.message });
|
||||
}
|
||||
|
||||
const sessionToken = randomToken();
|
||||
db.createToken({ token: sessionToken, gameId: game.id, playerId, kind: 'session' });
|
||||
|
||||
res.status(201).json({ gameId: game.id, playerId, sessionToken, state });
|
||||
});
|
||||
|
||||
// --- Static frontend ---
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(publicDir));
|
||||
app.use('/shared', express.static(path.join(__dirname, '..', 'shared')));
|
||||
|
||||
// Deep link for invite URLs — express.static won't match this path.
|
||||
app.get('/join/:code', (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// --- WebSocket smoke test on /ws ---
|
||||
// If this connects through your domain, Nginx Proxy Manager is passing the
|
||||
// Upgrade/Connection headers correctly ("Websockets Support" is ON). Phase 1
|
||||
// reuses this exact endpoint to push game state to each device.
|
||||
// --- WebSocket: room-aware, authenticated via a first {type:'AUTH'} message ---
|
||||
// (not a query param, so the session token never lands in access/proxy logs)
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.send(JSON.stringify({ type: 'welcome', msg: 'WebSocket connected' }));
|
||||
let authenticated = false;
|
||||
let room = null;
|
||||
let playerId = null;
|
||||
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!authenticated) ws.close(4001, 'auth timeout');
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
ws.send(JSON.stringify({ type: 'echo', received: raw.toString() }));
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
if (msg?.type !== 'AUTH' || typeof msg.token !== 'string') {
|
||||
ws.close(4001, 'expected AUTH');
|
||||
return;
|
||||
}
|
||||
const tokenRow = db.getToken(msg.token);
|
||||
if (!tokenRow || tokenRow.kind !== 'session') {
|
||||
ws.close(4001, 'invalid token');
|
||||
return;
|
||||
}
|
||||
let targetRoom;
|
||||
try {
|
||||
targetRoom = rooms.loadOrCreateRoom(tokenRow.game_id);
|
||||
} catch {
|
||||
ws.close(4004, 'game not found');
|
||||
return;
|
||||
}
|
||||
clearTimeout(authTimeout);
|
||||
authenticated = true;
|
||||
room = targetRoom;
|
||||
playerId = tokenRow.player_id;
|
||||
rooms.attachSocket(room, ws, playerId);
|
||||
db.touchPlayerLastSeen(playerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof msg?.type !== 'string') return;
|
||||
rooms.handleIntent(room, ws, playerId, msg);
|
||||
});
|
||||
|
||||
ws.on('close', () => clearTimeout(authTimeout));
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Life Journey (Phase 0) listening on :${PORT}`);
|
||||
console.log(`Life Journey (Phase 1) listening on :${PORT}`);
|
||||
console.log(`Data directory: ${DATA_DIR}`);
|
||||
});
|
||||
|
||||
@@ -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