Files
History/server/index.ts
T
kamilkrzywda 2ed1b15318 Fix save/resume after refresh + map basemap
Map (basemap was rendering black):
- Inline MapLibre style (dark-navy background + subtle OpenFreeMap ne2sr
  raster) instead of the remote "dark" style, whose vector tiles are empty at
  world zoom and gated the choropleth on a remote styledata fetch.
- Explicit prod worker URL (maplibre-gl-worker via ?url) so GeoJSON sources
  process in the production bundle.
- Hardened the choropleth against geo load-order races (empty sources up
  front, ownership stamped once data lands) and bumped fill opacity so the
  vivid per-nation colors are the star on the non-black background.

Save/resume (a refresh used to drop users to the home screen):
- Persist the active game id in localStorage (history.activeGameId) on create
  and resume; clear it when explicitly starting a new game.
- Auto-resume the last game on a page refresh (GET /api/games/:id + /map),
  re-attaching the SSE stream; fall back to home gracefully if it's gone.
- Home screen: prominent "Resume last game" button + a "Saved games" section
  (newest first). New GET /api/games list endpoint.

e2e: two new deterministic tests — auto-resume-on-reload and explicit resume
from the home screen (both under LLM_STUB=1).

Quality gates: typecheck, lint, 21 unit tests, 3 e2e tests — all pass.
2026-10-11 07:18:11 +00:00

253 lines
8.3 KiB
TypeScript

// Server entry: one Express process that serves the built client, the /api REST
// routes, and the /sse stream — all on a single internal port (default 4321).
// In dev, Vite (:5173) proxies /api and /sse here; in Docker/Traefik the whole
// thing is one origin.
import express, { type NextFunction, type Request, type Response } from 'express';
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, runTurn } from './engine';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
const here = fileURLToPath(new URL('.', import.meta.url));
/**
* Resolve the project root: dev (tsx) runs from <root>/server, the compiled prod
* bundle runs from <root>/server/dist. Probe a couple of candidates for presets/.
*/
function findRoot(): string {
const candidates = [
path.resolve(here, '..'), // dev: <root> | prod: <root>/server
path.resolve(here, '../..'), // prod: <root>
];
for (const c of candidates) {
if (existsSync(path.join(c, 'presets', 'index.json'))) return c;
}
return candidates[0];
}
const root = findRoot();
const presetsDir = path.join(root, 'presets');
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
// PORT wins; HISTORY_API_PORT is the override used by the dev proxy (vite) and
// the e2e config, so honor it as a fallback to keep all three in agreement.
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
const presetStore = createPresetStore(presetsDir);
const db: Db = openDb(path.join(dataDir, 'history.db'));
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
const useStub = process.env.LLM_STUB === '1';
const llm = useStub ? makeStubLlmClient() : createLlmClient();
const app = express();
app.use(express.json());
// --- SSE client registry (gameId -> set of open responses) ---
const clients = new Map<string, Set<Response>>();
function addClient(res: Response, id: string): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('retry: 2000\n\n');
let set = clients.get(id);
if (!set) {
set = new Set();
clients.set(id, set);
}
set.add(res);
res.on('close', () => {
set.delete(res);
if (set.size === 0) clients.delete(id);
});
}
function broadcast(id: string, event: string, data: unknown): void {
const set = clients.get(id);
if (!set) return;
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const r of set) r.write(chunk);
}
// --- Routes ---
app.get('/api/health', (_req, res) => {
res.json({ ok: true, stub: useStub, port: PORT });
});
app.get('/api/presets', (_req, res) => {
res.json({ presets: presetStore.listMeta() });
});
app.get('/api/presets/:id', (req, res) => {
const preset = presetStore.get(req.params.id);
if (!preset) return res.status(404).json({ error: 'unknown preset' });
res.json({ preset });
});
app.post('/api/games', (req: Request, res) => {
const body = req.body as CreateGameRequest | undefined;
const preset = presetStore.get(body?.presetId ?? '');
if (!preset) return res.status(400).json({ error: 'unknown preset' });
const nation = preset.nations.find((n) => n.id === body?.playerNationId);
if (!nation) return res.status(400).json({ error: 'unknown nation' });
const state: GameState = {
id: randomUUID(),
presetId: preset.id,
presetTitle: preset.title,
playerNationId: nation.id,
turn: 1,
date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
};
db.save(state);
res.json({ game: state });
});
// List every saved game (newest-updated first) as a lightweight summary, so the
// home screen can offer to resume prior games. `db.listIds()` is already ordered
// by updated_at DESC.
app.get('/api/games', (_req, res) => {
const games = db
.listIds()
.map((id) => db.load(id))
.filter((g): g is GameState => g !== null)
.map((g) => ({
id: g.id,
presetId: g.presetId,
presetTitle: g.presetTitle,
playerNationId: g.playerNationId,
turn: g.turn,
date: g.date,
}));
res.json({ games });
});
app.get('/api/games/:id', (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
res.json({ game });
});
app.get('/api/games/:id/map', (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
res.json({ regions: buildRegionMap(game) });
});
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
const game = db.load(id);
if (!game) throw new Error('not_found');
const preset = presetStore.get(game.presetId);
if (!preset) throw new Error('preset_missing');
const { state, result } = await runTurn({
llm,
preset,
state: game,
action: body?.action,
chat: body?.chat,
});
db.save(state);
broadcast(id, 'turn', { state, result });
return { state, result };
}
app.post('/api/games/:id/turn', async (req, res) => {
try {
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `turn failed: ${msg}` });
}
});
app.post('/api/games/:id/jump', async (req, res) => {
try {
// Jump forward = a turn with no player action (auto-simulate the world).
const { state, result } = await handleTurn(req.params.id, {});
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `jump failed: ${msg}` });
}
});
app.post('/api/games/:id/advisor', async (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
const prompt = (req.body as { prompt?: string } | undefined)?.prompt;
try {
const suggestions = await askAdvisor(llm, game, prompt);
res.json({ suggestions });
} catch {
// Live model down → degrade gracefully with a canned idea list.
res.json({
suggestions: [
'Rearm quietly to buy time while the great powers watch.',
'Court a neutral neighbor for a defensive pact.',
'Expand industry to sustain a longer conflict.',
],
});
}
});
// --- SSE stream ---
app.get('/sse/:id', (req, res) => {
const id = req.params.id;
const game = db.load(id);
addClient(res, id);
if (game) {
res.write(`event: state\ndata: ${JSON.stringify({ state: game })}\n\n`);
} else {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'game not found' })}\n\n`);
}
});
// --- Static client + SPA fallback ---
if (existsSync(clientDir)) {
app.use(express.static(clientDir));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/sse/')) {
return res.status(404).send('not found');
}
res.sendFile(path.join(clientDir, 'index.html'));
});
}
// Unhandled async errors shouldn't take down the process.
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
console.error('[history] unhandled error:', err);
if (!res.headersSent) res.status(500).json({ error: 'internal error' });
next();
});
app.listen(PORT, () => {
console.log(
`[history] listening on :${PORT} (llm=${useStub ? 'STUB' : 'live'}) client=${existsSync(clientDir) ? 'found' : 'absent'}`,
);
});
// Exported for tests (createApp is implicit via app; here we just expose handles).
export { app, db, llm };