Files
History/server/index.ts
T
kamilkrzywda cdabf16052
CI / build + unit + e2e (pull_request) Successful in 2m31s
feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
v0.29.0 — the LLM can now target individual regions' stats via
TurnResult.regionChanges (set/add per stat, clamped 0-100), applied
after nation-wide stateChanges and before the weighted-median
recompute. The map hover tooltip shows each region's own four stats.
The stub stays deterministic (regionChanges: []).

Fixes #46 (PR 2 of 2)
2026-10-15 01:06:53 +00:00

384 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
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, validKeys } from './regions';
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);
// 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();
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: [],
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.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' });
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,
// (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)),
});
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 };