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:
— " 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 };