// 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, seedRegionStats } from '../shared/protocol'; import type { AddActionRequest, AddDiplomacyRequest, ConfirmNationOpRequest, CreateGameRequest, RejectNationOpRequest, RemoveQueueItemRequest, } from '../shared/protocol'; import type { GameState, Preset } from '../shared/types'; import type { WorldDraft } from '../shared/scenario'; import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; import { makeScenarioStubLlmClient } from './stubScenario'; import { finalizeWorld, generateDraft } from './scenario'; import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine'; import { buildRegionMap } from './map'; import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validCountryCodes, validKeys } from './regions'; import { createPresetStore } from './presets'; import { createTemplateStore } from './templates'; import { openDb, type Db } from './db'; const here = fileURLToPath(new URL('.', import.meta.url)); /** * Resolve the project root: dev (tsx) runs from /server, the compiled prod * bundle runs from /server/dist. Probe a couple of candidates for presets/. */ function findRoot(): string { const candidates = [ path.resolve(here, '..'), // dev: | prod: /server path.resolve(here, '../..'), // prod: ]; 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); // (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/). const templateStore = createTemplateStore(path.join(root, 'presets', 'templates')); // Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file // just means region expansion/reference degrade to the raw keys (server still boots). loadRegions(root); 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(); // (v0.31, #52) The scenario-generation LLM client: the deterministic scenario // stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays // untouched), and the live client otherwise. const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm; const app = express(); app.use(express.json()); // --- SSE client registry (gameId -> set of open responses) --- const clients = new Map>(); 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 --- /** * (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a * dynamically generated game (`presetId: 'dynamic'` — there is no static preset * file on disk). `runTurn` only consumes `title` / `startDate` / * `worldBackground` / `simulationRules` (never `preset.nations`), so a generic * alternate-history background plus the standard rules is sufficient; `nations` * is carried from the saved world so the type is complete. `startDate` uses the * game's current date (GameState stores no separate start date) — it only feeds * the static "WORLD: — <date>" header of the system prompt. */ function dynamicPresetFromGame(game: GameState): Preset { return { id: game.presetId, title: game.presetTitle, startDate: game.date, summary: game.presetTitle, worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`, simulationRules: [ "Advance the shared world for ALL nations, not only the player's.", 'Keep every stat value between 0 and 100.', 'Only change borders when war or a decisive political shift justifies it.', 'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.', 'Use only nation ids and region keys that exist in the provided state.', ], nations: Object.values(game.nations), }; } 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: [], pending: [], version: 1, // the first saved revision; every later mutation bumps it // (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game // starts with nothing released; this list grows as the LLM releases // regions and shrinks as they get re-claimed. unowned: [], // (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the // list grows as the LLM proposes renames (and, later, splits/merges) and // shrinks as the player confirms or rejects them. pendingNationOps: [], // (v0.27, #44) No markers on a fresh game; the LLM places them over time. markers: [], // (v0.28, #46) Per-region stats are seeded below, once territories hold // real region keys — each region starts as a copy of its owner's initial // stats, so the derived aggregate equals the nation's stats on turn 1. regionStats: {}, // (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time // (any preset-level `war` relation stays a bare/implicit one, not an // entity — see syncWarRelations). wars: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region // granularity from the very first turn. Preset JSONs stay country-level. for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory); // (v0.28, #46): seed per-region stats AFTER the expansion so each owned // region key gets its owner's initial stats (see seedRegionStats). state.regionStats = seedRegionStats(state.nations); 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) }); }); // Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on // success broadcast an SSE `error` to any other open tab watching it so it // returns home (see the `event: error` handling in src/lib/sse.ts). app.delete('/api/games/:id', (req, res) => { const id = req.params.id; const removed = db.delete(id); if (!removed) return res.status(404).json({ error: 'game not found' }); broadcast(id, 'error', { message: 'game deleted' }); res.json({ deleted: true }); }); // Queue a free-text player action onto the pending round. No LLM call and no // turn advance — the round is only committed (in one turn) by "Jump forward". app.post('/api/games/:id/action', (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); const text = (req.body as AddActionRequest | undefined)?.text?.trim(); if (!text) return res.status(400).json({ error: 'empty action' }); const next = queueRoundItem(game, { kind: 'action', text }); db.save(next); broadcast(id, 'state', { state: next }); res.json({ game: next }); }); // Queue a diplomatic message to a target nation onto the pending round. No LLM // call and no advance. app.post('/api/games/:id/diplomacy', (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); const body = (req.body ?? {}) as AddDiplomacyRequest; const message = body.message?.trim(); if (!body.to || !message) return res.status(400).json({ error: 'empty message' }); const next = queueRoundItem(game, { kind: 'diplomacy', to: body.to, text: message }); db.save(next); broadcast(id, 'state', { state: next }); res.json({ game: next }); }); // Remove one item from the pending round, by its index in `game.pending`. No // LLM call, no turn advance — same fast save → broadcast → respond order as the // add routes above. The mutated state bumps `version`, so clients (which apply // states "never go backwards") always reconcile to it. app.post('/api/games/:id/queue/remove', (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); const index = (req.body as RemoveQueueItemRequest | undefined)?.index; const next = removeRoundItem(game, index); if (!next) return res.status(400).json({ error: 'invalid queue index' }); db.save(next); broadcast(id, 'state', { state: next }); res.json({ game: next }); }); // (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal // waiting in game.pendingNationOps: a rename takes effect (body.edits.name // may override the proposed name), a split partitions the nation's territory // into the proposed parts (body.edits.playerPart picks which part the player // leads, default 0), and a merge folds its inputs into the survivor // (body.edits.name may override the merged name). No LLM call, no turn // advance — same fast save → broadcast → respond order as /queue/remove. // The version bump lets "never go backwards" clients reconcile. app.post('/api/games/:id/nation-op/confirm', (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); const body = (req.body ?? {}) as ConfirmNationOpRequest; const next = confirmNationOp(game, body.index, body.edits); if (!next) return res.status(400).json({ error: 'invalid proposal index' }); db.save(next); broadcast(id, 'state', { state: next }); res.json({ game: next }); }); app.post('/api/games/:id/nation-op/reject', (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); const body = (req.body ?? {}) as RejectNationOpRequest; const next = rejectNationOp(game, body.index); if (!next) return res.status(400).json({ error: 'invalid proposal index' }); db.save(next); broadcast(id, 'state', { state: next }); res.json({ game: next }); }); // Jump forward: commit the whole pending round (or an empty one) to the model // in a single turn. applyTurnResult clears the pending queue on the returned // state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors. app.post('/api/games/:id/jump', async (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); let preset = presetStore.get(game.presetId); if (!preset) { // (v0.31, #52) A dynamically generated game has no static preset file: // synthesize one from the saved world so the turn engine can run. A // genuinely-missing static preset still 404s. if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' }); preset = dynamicPresetFromGame(game); } try { const { state, result } = await runTurn({ llm, preset, state: game, items: game.pending, // (v0.22, #16 P3) the region reference table must also cover released // (no-man's-land) keys — they belong to no nation's territory, but the // model needs their names to re-claim them. regionReference: referenceForKeys( new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]), ), validRegionKeys: validKeys(), // v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the // engine the resolver — the turn then runs with the LLM's getNeighbours // tool and the player-frontier prompt field (both switch off without it). getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined, // (v0.27, #44) when the centroid manifest loaded, hand the engine the // resolver so a turn can place new map markers at their region's // centroid (without it, marker adds are dropped; existing markers are // still maintained). centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined, // (v0.28, #46) when the region-geo manifest (area + population) loaded, // hand the engine the resolver so the end-of-turn recompute weights each // owned region by area×population (without it the aggregate degrades to // an unweighted median — still region-consistent, just unweighted). geoOf: hasGeo() ? (key) => geoOf(key) : undefined, // (v0.29, #46 PR 2) region ref resolver: lets the model name a // regionChanges target by its human-readable name (resolved // case-insensitively against the region-key manifest) or its key; // unknown / ambiguous refs are dropped. Always on — the resolver is // pure (validKeys + regionName) and degrades to a key-only match when // the manifest is absent. regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)), // (v0.30, #50) region display name for the engine's "invasion repelled" // event (geo properties.name, else the raw key). Always on — the // resolver is pure and degrades to the key when the manifest is absent. regionNameOf: (k) => regionName(k), }); db.save(state); broadcast(id, 'turn', { state, result }); res.json({ state, result }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); 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.', ], }); } }); // --- Dynamic scenario generation (v0.31, #52) --- // // The additive "Create scenario" path. Step 1 (POST /draft) designs a draft // world + an advisor checkpoint list; the client shows both, the player // approves/removes checkpoints and picks a nation; step 2 (POST /commit) // finalizes the world (constrained to the approved checkpoints) into a saved, // playable game. Both steps ride the scenario LLM client (the deterministic // scenario stub under LLM_STUB=1, the live model otherwise). All existing // routes are untouched. app.get('/api/scenarios/templates', (_req, res) => { res.json({ templates: templateStore.listMeta() }); }); app.post('/api/scenarios/draft', async (req, res) => { const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number }; const template = templateStore.get(body.templateId ?? ''); if (!template) return res.status(400).json({ error: 'unknown template' }); const description = typeof body.description === 'string' ? body.description.trim() : ''; const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year; const codes = validCountryCodes(); try { const { world, checkpoints } = await generateDraft({ llm: scenarioLlm, template, description, year, // No region data → no code filtering (the draft keeps whatever codes the // model emitted; expansion degrades to the raw keys, like presets do). validCountryCodes: codes.size > 0 ? codes : undefined, }); if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' }); res.json({ world, checkpoints }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); res.status(502).json({ error: `generate failed: ${msg}` }); } }); app.post('/api/scenarios/commit', async (req, res) => { const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number; draft?: WorldDraft; approvedCheckpoints?: string[]; playerNationId?: string; }; const template = templateStore.get(body.templateId ?? ''); if (!template) return res.status(400).json({ error: 'unknown template' }); const description = typeof body.description === 'string' ? body.description.trim() : ''; const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year; const approvedCheckpoints = Array.isArray(body.approvedCheckpoints) ? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '') : []; const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : ''; const codes = validCountryCodes(); try { const state = await finalizeWorld({ llm: scenarioLlm, template, description, year, approvedCheckpoints, draft: body.draft, playerNationId, validCountryCodes: codes.size > 0 ? codes : undefined, expand: (cs) => expandCountriesToKeys(cs), id: randomUUID(), title: template.name, startDate: `${year}-01-01`, }); db.save(state); res.json({ id: state.id }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); res.status(502).json({ error: `commit failed: ${msg}` }); } }); // --- 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, scenarioLlm, templateStore };