// Dynamic scenario generation — the shared (dependency-free) contract. // // This is the additive "Create scenario" path: instead of picking one of the // static presets, the user picks a light **era template**, writes a free-text // description, and lets the LLM generate a fresh world (a {@link WorldDraft}) // plus an advisor **checkpoint** list. The player reviews the checkpoints, // approves/removes some, and picks a nation to lead; a second LLM call then // finalizes the draft into a playable {@link GameState} (a normal game). // // Everything here is pure and has no runtime dependencies beyond the other // shared modules: it may import types from `./types` and helpers from // `./protocol`, but it never imports the engine, the LLM client, or any // external package. The two LLM calls (`generateDraft` / `finalizeWorld`) // live in `server/scenario.ts`; only the *shapes* and the pure transforms // live here. import type { EventEntry, EventOutcome, EventType, GameState, Nation, NationStats, NationType, RelationStatus, } from './types'; import { clampStat, makeEvent, seedRegionStats, type TurnEventSpec } from './protocol'; // --- Era templates ----------------------------------------------------------- /** * A suggested starting power for an era template: a display `name`, an ISO_A3 * core `region` (the country the power is rooted in), and a short `note` the * LLM is shown when it designs the world. */ export interface SuggestedPower { name: string; /** ISO_A3 country code the power's core territory sits in. */ region: string; note: string; } /** * A light **era template** the user can start from on the "Create scenario" * path (one JSON file per template in `presets/templates/`, plus an * `index.json` manifest). It is deliberately small: a name, a default `year`, * a short `eraDescription`, a few `suggestedPowers`, and a few `tensions` — * a seed the LLM generator expands into a full {@link WorldDraft}. */ export interface EraTemplate { id: string; name: string; /** Default starting year (the user may override it in the form). */ year: number; /** A short description of the era, shown on the card and fed to the LLM. */ eraDescription: string; /** A few suggested starting powers (name + ISO_A3 core region + a note). */ suggestedPowers: SuggestedPower[]; /** A few suggested tensions / conflicts to seed the world. */ tensions: string[]; } /** The lightweight manifest entry for an era template (the picker card). */ export interface TemplateMeta { id: string; name: string; year: number; eraDescription: string; } // --- World draft ------------------------------------------------------------- /** * A single nation in a generated world **draft** (before it is committed to a * playable {@link GameState}). Its `territory` holds **ISO_A3 country codes** * (not region keys) — the engine expands them to sub-region keys at commit * time via the injected `expand` resolver. `relations` are the LLM's * numeric sentiment toward other nations (−100..100); the engine maps them to * a {@link RelationStatus} on commit. */ export interface WorldNation { id: string; name: string; type?: NationType; stats: NationStats; /** ISO_A3 country codes the nation controls. */ territory: string[]; /** Bilateral sentiment: other nation id -> −100..100. */ relations: Record; } /** An optional narrative backstory event carried by a world draft (turn 0). */ export interface ScenarioEvent { nation: string; type: EventType; title: string; text: string; outcome: EventOutcome; } /** * A draft world the LLM generator returns before the player confirms it. * `nations` is required and non-empty; `events` is optional backstory that * seeds the new game's event feed on commit. */ export interface WorldDraft { nations: WorldNation[]; events?: ScenarioEvent[]; } /** * A single **advisor checkpoint** — a short string the generator proposes for * the player to approve (or remove) before the world is finalized. The * approved checkpoints are handed to the finalize call as constraints. */ export type Checkpoint = string; // --- Client ↔ server message shapes (the two LLM steps) ----------------------- /** The body of `POST /api/scenarios/draft` (step 1: design the world). */ export interface ScenarioDraftRequest { templateId: string; description: string; /** The chosen start year (the server falls back to the template's year). */ year?: number; } /** The response of `POST /api/scenarios/draft` — the draft world + checkpoints. */ export interface ScenarioDraftResponse { world: WorldDraft; checkpoints: string[]; } /** The body of `POST /api/scenarios/commit` (step 2: finalize + save the game). */ export interface ScenarioCommitRequest { templateId: string; description: string; /** The chosen start year (the server falls back to the template's year). */ year?: number; /** The step-1 draft, re-sent so the final world stays close to it. */ draft?: WorldDraft; /** The checkpoints the player approved (the finalize step's constraints). */ approvedCheckpoints: string[]; /** The nation id the player will lead (kept in the final world when present). */ playerNationId: string; } /** The response of `POST /api/scenarios/commit` — the saved game's id. */ export interface ScenarioCommitResponse { id: string; } // --- Validation + transforms (pure) ------------------------------------------ /** The 8 known event types (a local copy so this module needs no runtime import). */ const EVENT_TYPES: readonly string[] = [ 'military', 'diplomatic', 'economic', 'political', 'social', 'disaster', 'scientific', 'cultural', ]; /** The 5 known nation types. */ const NATION_TYPES: readonly string[] = ['major', 'minor', 'power', 'alliance', 'faction']; /** True when `t` is one of the eight known event types. */ function isEventType(t: unknown): t is EventType { return typeof t === 'string' && (EVENT_TYPES as string[]).includes(t); } /** True when `t` is one of the five known event outcomes. */ function isEventOutcome(o: unknown): o is EventOutcome { return o === 'success' || o === 'failure' || o === 'partial' || o === 'neutral'; } /** True when `t` is one of the five known nation types. */ function isNationType(t: unknown): t is NationType { return typeof t === 'string' && (NATION_TYPES as string[]).includes(t); } /** Clamp a number to [lo, hi] and round (non-finite → lo). */ function clampRange(v: number, lo: number, hi: number): number { if (!Number.isFinite(v)) return lo; return Math.max(lo, Math.min(hi, Math.round(v))); } /** Parse a raw (unknown) stats object into a clamped NationStats (missing → 50). */ function parseStats(raw: unknown): NationStats { const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; const get = (k: keyof NationStats): number => { const v = o[k]; return typeof v === 'number' && Number.isFinite(v) ? clampStat(v) : 50; }; return { military: get('military'), economy: get('economy'), stability: get('stability'), population: get('population'), }; } /** * A short, stable, lowercase slug for a nation name (used as a fallback id): * "United Kingdom" → "united-kingdom". Diacritics stripped, runs of * non-alphanumerics collapsed to a single dash, length capped at 20. */ function slugFromName(name: string): string { const s = name .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); if (!s) return ''; return s.length > 20 ? s.slice(0, 20).replace(/-+$/, '') : s; } /** * Map a numeric sentiment score (−100..100) to a {@link RelationStatus}. * Five bands: ally (≥60), neutral (0..59), rival (−40..−1), hostile (−80..−41), * war (≤−81). Pure. */ export function relationFromScore(v: number): RelationStatus { const c = clampRange(v, -100, 100); if (c >= 60) return 'ally'; if (c >= 0) return 'neutral'; if (c >= -40) return 'rival'; if (c >= -80) return 'hostile'; return 'war'; } /** * Validate + normalize a raw LLM world-draft payload into a clean * {@link WorldDraft} (or `null` when it is unusable). Never throws: * - the top level must be an object with a non-empty `nations` array; * - each nation needs a non-empty `name`; its `id` is kept when a valid * non-empty string, else derived from a name slug (else `n`); * duplicate ids are suffixed until unique; * - each nation's `type`/stats are clamped (missing stats → 50); * - each nation's `territory` is upper-cased, deduped, and (when * `validCountryCodes` is given) filtered to only known ISO_A3 codes; * - a nation's `relations` are clamped to −100..100 and re-keyed to the * assigned ids (a key that is a known id, or a case-insensitive name * match, is kept; unknown / self references are dropped); * - optional `events` are kept when they name a known nation (by id or * name) and have a non-empty title; * - the result is `null` when there are no nations, or no nation ends up * owning a valid territory (nothing to render on the map). */ export function validateWorldDraft( raw: unknown, opts: { validCountryCodes?: ReadonlySet } = {}, ): WorldDraft | null { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const obj = raw as Record; const rawNations = Array.isArray(obj.nations) ? obj.nations : null; if (!rawNations || rawNations.length === 0) return null; const validCodes = opts.validCountryCodes; const usedIds = new Set(); const nameToId = new Map(); interface Tmp { id: string; name: string; type: NationType; stats: NationStats; territory: string[]; rawRelations: Record; } const tmp: Tmp[] = []; for (const rn of rawNations) { if (!rn || typeof rn !== 'object') continue; const o = rn as Record; const name = typeof o.name === 'string' ? o.name.trim() : ''; if (name === '') continue; // a nation needs a name // id: keep a valid non-empty string, else derive from the name slug, else n. let id = typeof o.id === 'string' && o.id.trim() !== '' ? o.id.trim() : ''; if (id === '') id = slugFromName(name) || `n${tmp.length + 1}`; let candidate = id; let k = 2; while (usedIds.has(candidate)) { candidate = `${id}-${k}`; k += 1; } id = candidate; usedIds.add(id); const lowerName = name.toLowerCase(); if (!nameToId.has(lowerName)) nameToId.set(lowerName, id); const type = isNationType(o.type) ? o.type : 'major'; const stats = parseStats(o.stats); // territory: upper-case, de-duped, and (optionally) filtered to known codes. const territory: string[] = []; if (Array.isArray(o.territory)) { for (const t of o.territory) { if (typeof t !== 'string') continue; const code = t.trim().toUpperCase(); if (code === '') continue; if (validCodes && !validCodes.has(code)) continue; // drop unknown country codes if (!territory.includes(code)) territory.push(code); } } // relations: keep only finite numeric values (clamped later on re-key). const rawRelations: Record = {}; if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) { for (const [key, v] of Object.entries(o.relations as Record)) { if (typeof v === 'number' && Number.isFinite(v)) rawRelations[key] = v; } } tmp.push({ id, name, type, stats, territory, rawRelations }); } if (tmp.length === 0) return null; // Unusable when no nation owns a valid territory (there is nothing to render). if (!tmp.some((t) => t.territory.length > 0)) return null; const idSet = new Set(tmp.map((t) => t.id)); const nations: WorldNation[] = tmp.map((t) => { const relations: Record = {}; for (const [key, val] of Object.entries(t.rawRelations)) { // Resolve the key: a known id, else a case-insensitive name match. let target = key; if (!idSet.has(target)) { const mapped = nameToId.get(key.toLowerCase()); if (mapped) target = mapped; } if (target === t.id) continue; // drop self-references if (!idSet.has(target)) continue; // drop references to unknown nations relations[target] = clampRange(val, -100, 100); } return { id: t.id, name: t.name, type: t.type, stats: t.stats, territory: t.territory, relations }; }); // Optional backstory events (kept when they name a known nation). let events: ScenarioEvent[] | undefined; if (Array.isArray(obj.events)) { const evs: ScenarioEvent[] = []; for (const re of obj.events) { if (!re || typeof re !== 'object') continue; const e = re as Record; const title = typeof e.title === 'string' ? e.title.trim() : ''; if (title === '') continue; const text = typeof e.text === 'string' ? e.text : ''; const type = isEventType(e.type) ? e.type : 'political'; const outcome = isEventOutcome(e.outcome) ? e.outcome : 'neutral'; let nation = typeof e.nation === 'string' ? e.nation : ''; if (nation !== '' && !idSet.has(nation)) { nation = nameToId.get(nation.toLowerCase()) ?? ''; } if (nation === '' || !idSet.has(nation)) continue; // drop events with no known owner evs.push({ nation, type, title, text, outcome }); } events = evs; } return { nations, events }; } /** Options for {@link worldDraftToGameState} (all optional, sensible defaults). */ export interface WorldDraftToGameOptions { /** The new game's id (the server sets `randomUUID()`; default `''`). */ id?: string; /** The scenario title (becomes `GameState.presetTitle`; default 'Custom World'). */ title?: string; /** The game's start date (`YYYY-MM-DD`, from the chosen year; default '1970-01-01'). */ startDate?: string; } /** * Convert a validated {@link WorldDraft} into a fresh, playable {@link * GameState} (a normal game at `#/game/`). Mirrors `POST /api/games` * exactly, except the draft's nations carry **ISO_A3** territory which is * expanded to sub-region keys by the injected `expand` resolver, and * `regionStats` is seeded from the expanded territories via * {@link seedRegionStats} (so every owned region starts with its owner's * stats and the derived aggregate equals them on turn 1). * * The result is a clean turn-1 state: `version: 1`, `turn: 1`, `unowned: []`, * `markers: []`, `pendingNationOps: []`, `pending: []`, the given * `playerNationId`, and a `startDate` from the chosen year. Backstory events * (if any) seed the event feed at turn 1. Pure — no side effects, no I/O. */ export function worldDraftToGameState( draft: WorldDraft, expand: (countries: string[]) => string[], playerNationId: string, opts: WorldDraftToGameOptions = {}, ): GameState { const id = opts.id ?? ''; const title = opts.title ?? 'Custom World'; const startDate = opts.startDate ?? '1970-01-01'; const nations: Record = {}; for (const wn of draft.nations) { const relations: Record = {}; for (const [otherId, score] of Object.entries(wn.relations)) { if (otherId === wn.id) continue; // no self-relations relations[otherId] = relationFromScore(score); } nations[wn.id] = { id: wn.id, name: wn.name, type: wn.type ?? 'major', stats: wn.stats, territory: expand(wn.territory), relations, }; } // Guard: a finalize step (or a bad model answer) might drop the nation the // player chose to lead. Rather than produce a game with a dangling // playerNationId, fall back to the first nation (deterministic insertion // order) so the resulting game is always playable. const effectivePlayer = nations[playerNationId] !== undefined ? playerNationId : draft.nations.length > 0 ? draft.nations[0].id : ''; // Seed the event feed from the draft's optional backstory (turn 1, startDate). const events: EventEntry[] = (draft.events ?? []).map((e) => makeEvent(e as unknown as TurnEventSpec, 1, startDate), ); const state: GameState = { id, presetId: 'dynamic', presetTitle: title, playerNationId: effectivePlayer, turn: 1, date: startDate, nations, events, pending: [], version: 1, unowned: [], pendingNationOps: [], markers: [], // Seed AFTER the territories hold real region keys: each owned region gets // a copy of its owner's stats, so the derived aggregate equals them. regionStats: seedRegionStats(nations), // (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war` // relation stays a bare/implicit one, not an entity (see syncWarRelations). wars: [], // (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game. trades: [], embargoes: [], }; return state; }