8df4859696
Adds public/boardRender.js: a layout algorithm that derives every space's position purely from shared/board.js (column = longest-path distance from Start, lane = branch offset that fans out at a choice space and re-centers wherever branches rejoin), so it keeps working unmodified as the board data grows — no hand-placed coordinates to maintain. Renders spaces as styled SVG nodes (color/shape by type), connects them with curved path lines, and animates player tokens between positions. Pending choices glow and are clickable directly on the board, in addition to the existing text buttons. Game view widens the card on desktop for the board and scrolls horizontally on narrow viewports. Also repicks the player color palette (server/index.js) to avoid the board's own semantic colors (green/gold/red), after a token nearly disappeared into the same-colored Start space during visual testing. Verified with a headless-browser run (Playwright, off-screen — not a desktop screenshot): two players through lobby -> live join update -> board -> a fork choice, no console errors, reducer tests still green.
229 lines
7.0 KiB
JavaScript
229 lines
7.0 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 };
|
|
// 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}`);
|
|
});
|