Files
lifegame/server/index.js
T
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

227 lines
6.9 KiB
JavaScript

/**
* Life Journey — Phase 1 server.
*
* 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';
import path from 'node:path';
import fs from 'node:fs';
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');
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 ---
app.get('/api/health', (_req, res) => {
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: 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) => {
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) => {
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 1) listening on :${PORT}`);
console.log(`Data directory: ${DATA_DIR}`);
});