From 832957c635e1955952ae67e1d50335919e3ff99a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 09:50:57 +0000 Subject: [PATCH] feat(#52): wire /api/scenarios routes + dynamic-game jump preset - GET /api/scenarios/templates: the 3 era templates (templateStore from presets/templates/). - POST /api/scenarios/draft: generateDraft (template + description + year) -> { world, checkpoints } (502 on transport failure / unusable world). - POST /api/scenarios/commit: finalizeWorld (constrained to approved checkpoints, optionally based on the re-sent draft) -> saved GameState, returns { id } (a normal game at #/game/). - scenarioLlm: the deterministic scenario stub under LLM_STUB=1 (distinct from the untouched turn stub), the live client otherwise. - jump route: synthesize a minimal Preset for dynamic games (presetId 'dynamic', no static file) so their turns run; genuinely-missing static presets still 404. Fixes #52 --- server/index.ts | 134 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 5 deletions(-) diff --git a/server/index.ts b/server/index.ts index bda31c9..c755721 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,13 +17,17 @@ import type { RejectNationOpRequest, RemoveQueueItemRequest, } from '../shared/protocol'; -import type { GameState } from '../shared/types'; +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, validKeys } from './regions'; +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)); @@ -52,6 +56,8 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data'); // 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); @@ -59,6 +65,10 @@ 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()); @@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void { // --- 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 }); }); @@ -278,8 +316,14 @@ 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' }); + 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, @@ -347,6 +391,86 @@ app.post('/api/games/:id/advisor', async (req, res) => { } }); +// --- 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; @@ -384,4 +508,4 @@ app.listen(PORT, () => { }); // Exported for tests (createApp is implicit via app; here we just expose handles). -export { app, db, llm }; +export { app, db, llm, scenarioLlm, templateStore };