// 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 { AddActionRequest, AddDiplomacyRequest, CreateGameRequest, RemoveQueueItemRequest, } from '../shared/protocol'; import type { GameState } from '../shared/types'; import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine'; import { expandTerritory, loadRegions } from './regions'; 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 /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(); // Load the sub-region geo once at boot (a graceful no-op, with a warning, when // the file is missing — the territory model then degrades to whole-country // ISO_A3 codes). Must run before the first game creation/load. loadRegions(root); 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>(); 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, // v0.19: territory is at sub-region granularity — expand each preset // nation's ISO_A3 country codes into all their sub-region ids (a no-op that // keeps the country codes when the region geo is not loaded). nations: Object.fromEntries( preset.nations.map((n) => { const nation = deepClone(n); nation.territory = expandTerritory(nation.territory); return [n.id, nation]; }), ), events: [], pending: [], version: 1, // the first saved revision; every later mutation bumps it }; 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 }); }); // 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' }); const preset = presetStore.get(game.presetId); if (!preset) return res.status(404).json({ error: 'preset_missing' }); try { const { state, result } = await runTurn({ llm, preset, state: game, items: game.pending, }); 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.', ], }); } }); // --- 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 };