- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop (default 6 tool rounds, then one forced no-tools request); shares the retry/backoff/reasoning-effort machinery with chat(); tools are only added to the request body when non-empty, so plain chat() bodies and their tests are unchanged; a tool-call response is a success (never a truncation); malformed tool arguments become an error tool-message. - engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades unknown tools/args/keys without throwing); playerFrontier (sorted, deduped, other-nation-owned only); buildSystemPrompt gains a tool variant (contiguity rules, frontier hint) used only when the tool is actually sent; buildUserPrompt emits a frontier field whenever a resolver is given; runTurn takes getNeighbours and uses chatWithTools when both the resolver and the client's capability are present, falling back to plain chat() otherwise. - applyTurnResult: snapshot-first sole-ownership algorithm — a region key transfers only with exactly one claimant and exactly one previous owner (different from the gainer); double claims and pre-existing double ownership are left untouched; lone losses are no-ops; every key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated. - stub.ts: deterministic one-region contiguous conquest per turn from the prompt's frontier field (bypasses the tool loop; empty frontier -> no transfer, all existing assertions stay stable). - regions.ts: optional region-adjacency.json load; hasAdjacency() and neighboursOf(keys) (union, deduped, queried excluded, sorted). - index.ts: jump route passes the adjacency resolver when loaded. - tests: chatWithTools protocol (echo, executor args, bounded rounds, malformed args, retry), tool-path runTurn wiring + fallback, playerFrontier, executor, applyTurnResult invariants, neighboursOf/ hasAdjacency (105 unit tests, 14 e2e — all green).
311 lines
12 KiB
TypeScript
311 lines
12 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 {
|
|
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 { buildRegionMap } from './map';
|
|
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, 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.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);
|
|
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,
|
|
regionReference: referenceForKeys(allTerritoryKeys(game)),
|
|
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,
|
|
});
|
|
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 };
|