feat(#52): dynamic scenario generation (v0.31.0) #53
+129
-5
@@ -17,13 +17,17 @@ import type {
|
|||||||
RejectNationOpRequest,
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} 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 { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
|
import { makeScenarioStubLlmClient } from './stubScenario';
|
||||||
|
import { finalizeWorld, generateDraft } from './scenario';
|
||||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
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 { createPresetStore } from './presets';
|
||||||
|
import { createTemplateStore } from './templates';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
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.
|
// 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 PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
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
|
// 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).
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
loadRegions(root);
|
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).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void {
|
|||||||
|
|
||||||
// --- Routes ---
|
// --- 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: <title> — <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) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true, stub: useStub, port: PORT });
|
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 id = req.params.id;
|
||||||
const game = db.load(id);
|
const game = db.load(id);
|
||||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
const preset = presetStore.get(game.presetId);
|
let preset = presetStore.get(game.presetId);
|
||||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
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 {
|
try {
|
||||||
const { state, result } = await runTurn({
|
const { state, result } = await runTurn({
|
||||||
llm,
|
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 ---
|
// --- SSE stream ---
|
||||||
app.get('/sse/:id', (req, res) => {
|
app.get('/sse/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
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).
|
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||||
export { app, db, llm };
|
export { app, db, llm, scenarioLlm, templateStore };
|
||||||
|
|||||||
Reference in New Issue
Block a user