/** * 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 }; // Deliberately avoids the board's own semantic colors (green=start, // marigold=finish, red=choice) so a player's token never blends into a space. const PLAYER_COLORS = ['#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c']; 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}`); });