4d78937dca
shared/board.js: rebuilt from shared/tileInventory.js instead of the
sketch-inspired placeholder — one chain() per segment (same helper as
before), TEMP_SEGMENT_ORDER concatenating all 12 segments in the design
doc's own order since board-graph.json (the real segment connections/STOP
forks) doesn't exist yet — loudly commented as a placeholder to replace, not
a guess at real topology. A synthetic `finish` space is appended since no
tile in the source data has that type (win condition is itself an open
question). Movement is now one tile per turn (board.spaces[pos].next) —
no tile in the data implies a movement die, every `die` belongs to that
tile's own effect resolution, so walkForward() is gone.
shared/tileEffects.js (new): pure resolveTileEffect(space, rolls) covering
all 9 real tile types (payday, action_space, dice_space, roll_table_ref,
inline_table, cash_bonus, event, choice, stop) plus rollsNeededFor(space) so
the server knows what to pre-roll before constructing an action. Real
roll_table_ref/action_space/dice_space tiles resolve against the actual 48
placeholder tables; the two real content gaps (inline_table's 85 tiles,
payday/cash_bonus amounts) resolve against a synthesized generic banded
table per die size, every result clearly flagged `todo: true` so it's
visible in the game log, not just in docs.
shared/game.js: player state is now flat {cash, love, education, wealth,
age} fields (mirrors a roll-table effect object's shape directly). Landing
logic calls resolveTileEffect() instead of applying a single cash delta, and
only pauses for a decision when a space's `choices` array is actually
populated (true of nothing yet) — keeps the existing pending-choice
machinery intact for once board-graph.json exists, instead of needing
choice/stop tiles to block on options nobody has defined.
server/rooms.js: REQUEST_ROLL now looks up the player's next tile,
determines what it needs via rollsNeededFor(), and pre-rolls each die via
crypto.randomInt before constructing the ROLL action — same
randomness-happens-once-at-the-server-boundary pattern as before, just
covering N table rolls instead of one movement die. server/index.js:
default config now carries startingStats instead of startingCash, and
maxPlayers is 8.
235 lines
7.1 KiB
JavaScript
235 lines
7.1 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: 8,
|
|
startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 },
|
|
};
|
|
// 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', '#4a4a9f', '#8a8a3f',
|
|
];
|
|
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}`);
|
|
});
|