Compare commits

..
15 Commits
Author SHA1 Message Date
kamilkrzywda dd9fd6a09d Merge pull request 'feat(#58): trade & embargoes — economy layer (v0.35.0)' (#69) from feat/58-trade-embargo into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-15 19:30:59 +02:00
kamilkrzywda b5ea8ba5a4 feat(#58): trade & embargoes — TurnResult.economy ops (trade/embargo, declare/lift) with a deterministic per-turn effect and war auto-suspension
CI / build + unit + e2e (pull_request) Successful in 2m3s
2026-10-15 17:26:24 +00:00
kamilkrzywda ed91735c8a Merge pull request 'feat(#57): hybrid war — TurnResult.hybrid ops with exposure backfire (v0.34.0)' (#68) from feat/57-hybrid-war into master
CI / build + unit + e2e (push) Successful in 2m9s
2026-10-15 18:45:56 +02:00
kamilkrzywda 8eeb6df284 feat(#57): hybrid war — TurnResult.hybrid ops (spy/sabotage/assassinate/cyber/economic/information/proxy) with exposure backfire
CI / build + unit + e2e (pull_request) Successful in 2m7s
2026-10-15 16:42:27 +00:00
kamilkrzywda f45c01b8d6 Merge pull request 'feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync' (#67) from feat/56-war-as-state into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-15 17:50:26 +02:00
kamilkrzywda b5e2229ee2 feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
CI / build + unit + e2e (pull_request) Successful in 2m32s
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests

v0.33.0
2026-10-15 15:47:06 +00:00
kamilkrzywda 38d3202472 Merge pull request 'feat(#54): non-military takeovers — automatic regime rename (v0.32.0)' (#55) from feat/54-takeover-consequence into master
CI / build + unit + e2e (push) Successful in 1m55s
2026-10-15 13:01:31 +02:00
kamilkrzywda 58c56024bc chore(#54): v0.32.0 — version bump + changelog
CI / build + unit + e2e (pull_request) Successful in 2m40s
2026-10-15 10:58:23 +00:00
kamilkrzywda 3c187c6901 feat(#54): non-military takeovers — automatic regime rename (TurnResult.takeovers) 2026-10-15 10:58:23 +00:00
kamilkrzywda 8eea17148b Merge pull request 'feat(#52): dynamic scenario generation (v0.31.0)' (#53) from feat/52-dynamic-scenarios into master
CI / build + unit + e2e (push) Successful in 2m4s
2026-10-15 12:29:48 +02:00
kamilkrzywda 073e916767 chore(#52): v0.31.0 — version bump + changelog
CI / build + unit + e2e (pull_request) Successful in 1m52s
2026-10-15 10:27:41 +00:00
kamilkrzywda 396702b9da feat(#52): dynamic scenario client (form → review → finalize) + e2e
Additive 'Create scenario' path (v0.31): the home screen's create-scenario
card opens a ScenarioForm (pick one of 3 light era templates + a free-text
description + an editable, era-prefilled start year) whose 'Generate world'
posts the draft request; the CheckpointReview screen shows the generated
world (per-nation stats/territory/relations + backstory) and the advisor's
checkpoint list as an approve/remove checklist, then the player picks a
nation to lead and finalizes into a saved, playable game. Both LLM steps ride
the scenario client (deterministic stub under LLM_STUB=1); a failure toasts
and stays on the current screen. The turn stub and static presets are
untouched. New e2e covers the full deterministic flow (near-future 2035,
remove checkpoint 3, lead Boreas, finalize, one empty jump) and the 3-template
manifest.
2026-10-15 10:21:53 +00:00
kamilkrzywda 832957c635 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/<id>).
- 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
2026-10-15 09:50:57 +00:00
kamilkrzywda 68402694c5 feat(#52): scenario generator LLM steps + deterministic stub
- server/scenario.ts: buildGeneratePrompt / buildRegeneratePrompt, defensive
  parseGenerate / parseRegenerate (fence-strip + balanced-object + validate),
  generateDraft (draft world + advisor checkpoints) and finalizeWorld (final
  world constrained to approved checkpoints -> playable GameState), all over
  the injectable LlmClient with a nudge re-send. SCENARIO_* markers for the
  stub.
- server/stubScenario.ts: deterministic scenario stub transport (fixed 3-nation
  world + checkpoint list; finalize refines stability per approved checkpoint)
  + makeScenarioStubLlmClient. Distinct from and never touching turn stub.ts.
- server/regions.ts: validCountryCodes() accessor (ISO_A3 parents) for draft
  territory validation.
- shared/scenario.ts: worldDraftToGameState now guards a dangling playerNationId
  (falls back to the first nation) so a finalize that drops it stays playable.
- server/scenario.test.ts: 16 tests (prompt builders, defensive parsing,
  generateDraft/finalizeWorld over a fake client + the stub).

Fixes #52
2026-10-15 09:46:34 +00:00
kamilkrzywda 2191514db1 feat(#52): era templates + shared scenario types/transforms
- presets/templates/: 3 light era templates (ww2/present/near-future) + index.json
  manifest (id, name, year, eraDescription, suggestedPowers, tensions).
- server/templates.ts: createTemplateStore (mirror of createPresetStore).
- shared/scenario.ts: dependency-free contract for the dynamic scenario path —
  SuggestedPower / EraTemplate / TemplateMeta / WorldNation / ScenarioEvent /
  WorldDraft / Checkpoint types, relationFromScore, validateWorldDraft (pure,
  defensive, never throws), worldDraftToGameState (region-key territory via the
  injected expand resolver, regionStats seeded, clean turn-1 state).
- tests: server/templates.test.ts (manifest order + file shape),
  server/scenario-shared.test.ts (15 cases: validation/clamp/dedup/null +
  transform).

Fixes #52
2026-10-15 09:37:31 +00:00
32 changed files with 5725 additions and 36 deletions
+81 -1
View File
@@ -56,7 +56,7 @@ history/
├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 407 tests)
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
@@ -327,6 +327,86 @@ history/
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
when PR 2, the per-region LLM lever, ships).
- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).**
Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in
`GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as
`[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list
(≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`,
`declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an
optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }`
(both required, default `[]`): a **declare** creates a new active `War` (sides resolved via
`resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved
distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets
its `status` to the outcome (`armistice` or `capitulation`). After declare/end,
`syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite
sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you
cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not
covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching
active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense
drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the
war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted
id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations
per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars
(as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or
ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the
path is unit-tested only (`server/engine.test.ts`).
- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).**
`TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert /
hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp =
{ type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate |
cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM
declares its *intended* outcome; the engine applies the deterministic effect).
`from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then
case-insensitive unique name); a `target` that is not a resolvable nation is treated
as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the
acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy`
(−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips
`stability` (−5); `information` nudges the from↔target relation one step apart (capped
at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only.
A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops
never move territory — `regionChanges` is the only lever that does); a nation-target
dip lands on every region the nation owns; afterwards the target's derived `stats` is
re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership
invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended
effect is NOT applied; instead the from↔target relation moves one step *toward*
`hostile` (capped; `war` is never created or escalated by an exposed op) and a
diplomatic/failed event is auto-appended to the turn's `events`.
**Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are
consumed inside `applyTurnResult` after territory + wars and before markers).
`isMeaningful` counts hybrid ops; the system prompt documents the field + per-type
effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the
user payload carries the player's `relations` map so the model can reason about the
relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is
unaffected and the path is unit-tested only (`server/engine.test.ts`).
- **Trade & embargo economy layer — `GameState.trades` / `GameState.embargoes`, `TurnResult.economy` (v0.35, #58).**
`TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required;
default `{ trade: [], embargo: [] }`) is the model's lever for the economic layer: a
bilateral **trade** (`{ a, b, lift? }` — symmetric, stored **sorted** `a < b`) and a
unilateral **embargo** (`{ from, target, lift? }` — ordered). No `lift` declares/imposes,
`lift: true` ends/lifts (a no-op when absent). Refs are resolved at apply time by the
shared `resolveNationRef` (id first, then case-insensitive unique name); an unknown /
ambiguous / self-pairing op is dropped (never thrown). The lists live in `GameState`
(`trades`/`embargoes`, required, `[]` at creation; a legacy SQLite row without them loads
as `[]` — additive, never wiped, like `wars`/`markers`). **Deterministic, no hidden
randomness.** Every committed turn, in `applyTurnResult` (after hybrid, before markers):
(1) `applyEconomyOps` applies this turn's declares/lifts to the active lists (idempotent
declares — an already-active pair/ordered pair is not duplicated); (2)
`suspendEconomyOnWar` **auto-suspends** any trade or embargo whose two nations are on
opposite sides of an **active** `War` (#56) — a war severs the economic link, so the
suspended pair stops producing its effect (this also drops a trade declared this turn
between two nations that just went to war); (3) `applyEconomyEffects` applies the per-turn
effect to the **full** active lists: each active trade gives **both** parties a small,
deterministic `economy` gain (`TRADE_ECONOMY_BONUS`), each active embargo dips the
**target's** `economy` only (`EMBARGO_ECONOMY_PENALTY`) — clamped to 0–100, mirrored to
every region the nation owns (`regionStats`), and each affected nation's derived `stats`
re-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership
is untouched (the layer moves stats, never territory). `isMeaningful` counts non-empty
`economy.trade` / `economy.embargo`; the system prompt documents the field + constraints
(at most 1–2 ops per turn, narrated in `events`; warring pairs auto-suspended), and the
user payload carries the active `trades`/`embargoes` so the model can `lift` them. The
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
(`server/engine.test.ts`).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
+85
View File
@@ -2,6 +2,91 @@
All notable changes to **History** are listed here, newest first.
## v0.35.0 — 2026-10-15 17:22
- **#58: Trade & Embargoes — a bilateral trade + embargo economy layer with a deterministic per-turn effect and war auto-suspension.** Every prior turn lever moved borders, dipped stats one-shot, or proposed a player-confirmed change. This version adds `TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required, default `{ trade: [], embargo: [] }`) so the model can declare/lift **bilateral trade agreements** and **unilateral embargoes**, which persist in `GameState` and apply a small, deterministic economic effect **every committed turn**. The engine is **fully deterministic (no hidden randomness)** and **additive** — declares add to the lists, lifts remove, and nothing else (not even a war, beyond the suspension below) mutates them.
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
- **Protocol (`shared/protocol.ts`).** New `EconomyTradeOp` (`{ a; b; lift? }`), `EconomyEmbargoOp` (`{ from; target; lift? }`), and `EconomyOps` (`{ trade; embargo }`) types. `TurnResult.economy` is required, placed between `hybrid` and `markers`; a no-`lift` op declares/imposes, `lift: true` ends/lifts.
- **Engine (`server/engine.ts`).**
- `validateEconomyOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ trade: [], embargo: [] }`; each op needs non-empty `a`/`b` (or `from`/`target`) refs (trimmed, ≤200 chars) and a boolean `lift` (default false). Raw refs are kept — resolution happens at apply time against the real state (like wars/hybrid ops).
- `applyEconomyOps(next, ops)` — internal; resolves each op's refs via `resolveNationRef` (id first, then unique case-insensitive name). An unresolvable / ambiguous / self-pairing op is dropped (never thrown). A no-`lift` trade **declares** the agreement idempotently (stored sorted, so `{a,b}` and `{b,a}` are the same pair); `lift: true` ends it (a no-op when absent). A no-`lift` embargo **imposes** the ordered from→target pair idempotently; `lift: true` lifts it.
- `suspendEconomyOnWar(next)` — internal; any active trade or embargo whose two nations are on **opposite sides of an active war** (v0.33, #56) is removed — a war severs the economic link, so the suspended agreement stops producing its per-turn effect from now on. Runs **after** the turn's war declare/end ops (so it sees the final `next.wars`, including a war declared this turn) and **before** the per-turn effect.
- `applyEconomyEffects(next, geoOf?)` — internal; the per-turn effect, applied **every committed turn** to the **full** active lists (not just this turn's declares): each active trade grants **both** parties a small, deterministic economy gain (`TRADE_ECONOMY_BONUS`); each active embargo applies a small, deterministic economy dip to the **target only** (`EMBARGO_ECONOMY_PENALTY`). Clamped to 0–100, mirrored to every region the nation owns (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region/no-entry guards as the hybrid dip). No-op when there are no active trades or embargoes; never throws.
- `applyTurnResult` — new **economy block** (after the hybrid block, before markers): `applyEconomyOps` → `suspendEconomyOnWar` → `applyEconomyEffects`, in that order (ops first, so a same-turn trade declared between two nations that just went to war is suspended; effects then run on the surviving full lists). Sole ownership is untouched (the economy layer moves stats, never territory).
- `isMeaningful` — a non-empty `economy.trade` or `economy.embargo` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `economy` (default `{ trade: [], embargo: [] }`).
- `buildSystemPrompt` — documents the `economy` field and a constraint bullet (bilateral trade gains both parties; a unilateral embargo hits the target only; omit `lift` to declare/impose, `lift: true` to end/lift; at most 1–2 ops per turn, narrated in `events`; trades/embargoes between warring nations are auto-suspended) in both tool and plain modes.
- `buildUserPrompt` — new optional `trades` and `embargoes` params (the currently active agreements/embargoes) so the model knows what is active and can `lift` it; emitted only when provided.
- `runTurn` — passes `state.trades` and `state.embargoes` to `buildUserPrompt`; the "unclear" fallback carries `economy: { trade: [], embargo: [] }`.
- **Stub (`server/stub.ts`).** The deterministic stub returns `economy: { trade: [], embargo: [] }` — it **never** declares or lifts a trade or embargo, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only (`server/engine.test.ts`).
- **Persistence (`server/db.ts`).** `load()` defaults `trades`/`embargoes` to `[]` on rows saved before v0.35 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`) — legacy games are never wiped and never gain phantom trades/embargoes.
- **Game creation (`server/index.ts`, `shared/scenario.ts`).** Fresh games (preset and dynamic) are created with `trades: []` and `embargoes: []`.
## v0.34.0 — 2026-10-15 16:35
- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted).
- **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`.
- **Engine (`server/engine.ts`).**
- `isHybridOpType(t)` — exported guard over the seven types.
- `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300.
- `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect.
- `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire.
- `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path.
- `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only.
- `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership).
- `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`).
- `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes.
- `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided.
- `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`.
- **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`.
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only.
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid.
- **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed.
- **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage.
## v0.33.0 — 2026-10-15 15:45
- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally.
- **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
- **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload.
- **Engine (`server/engine.ts`).**
- `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400.
- `activeWarPairs(wars): Set<string>` — NUL-joined sorted pair keys for active-only wars.
- `atWar(wars, a, b)` — convenience check.
- `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created).
- `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`).
- `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`.
- `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful.
- `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs).
- `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id.
- `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`.
- **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`).
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only.
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars.
- **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync).
- **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage.
## v0.32.0 — 2026-10-15 10:56
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
- **Engine (`server/engine.ts`).**
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
## v0.31.0 — 2026-10-15 10:22
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
- **Shared contract (`shared/scenario.ts`, dependency-free).** `EraTemplate` / `TemplateMeta` / `SuggestedPower`; `WorldDraft = { nations: WorldNation[]; events? }` where `WorldNation.territory` holds **ISO_A3 country codes** (not region keys) and `relations` are numeric sentiments (−100..100); `Checkpoint = string`; the four message shapes (`ScenarioDraftRequest/Response`, `ScenarioCommitRequest/Response`). Pure transforms: `validateWorldDraft(raw, { validCountryCodes })` — defensive parse/clamp/normalize that never throws (name-required, id kept-or-slug fallback, duplicate ids suffixed, territory upper-cased/deduped and filtered to known ISO_A3 codes when a valid set is given, relations re-keyed to assigned ids via id or case-insensitive name match with self/unknown refs dropped, optional backstory events kept only when they name a known nation; returns `null` when the result is unusable — no nations, or no valid territory to render); `relationFromScore` (≥60 ally · 0..59 neutral · −40..−1 rival · −80..−41 hostile · ≤−81 war); `worldDraftToGameState(draft, expand, playerNationId, opts)` — expands ISO_A3 territory to region keys via the injected `expand` (`expandCountriesToKeys` in production), seeds `regionStats` (so each owned region starts with its owner's stats and the derived aggregate equals them on turn 1), a clean turn-1 state (`version:1`, `turn:1`, empty `unowned`/`markers`/`pendingNationOps`/`pending`), `presetId: 'dynamic'`, and a guard that re-points a dangling `playerNationId` to the first nation so the game is always playable.
- **Scenario LLM steps (`server/scenario.ts`).** Two plain `chat()` calls (no tools, no separate advisor endpoint — the advisor is embodied in step 1's checkpoint list): `generateDraft({ template, description, year })` → `{ world, checkpoints }` (the prompt carries the era description, suggested powers, tensions, the user's description, the year, and the valid ISO_A3 reference), and `finalizeWorld({ …, approvedCheckpoints, draft, playerNationId })` → final `GameState` (re-asked to produce the final world constrained to the approved checkpoints, based on the step-1 draft the client re-sends so the chosen nation survives; falls back to the draft itself when the model returns nothing usable). Same defensive parsing as the engine (strip fences → first balanced object → `JSON.parse` → `validateWorldDraft`), one nudge-retry when the answer is unusable; a transport failure (model down) throws → HTTP 502, a bad *answer* degrades to the fallback and never crashes.
- **Scenario stub (`server/stubScenario.ts`).** Deterministic transport for the two scenario calls only (discriminated by `SCENARIO_GENERATE_MARKER` / `SCENARIO_REGENERATE_MARKER` in the system message, mirroring how the turn stub keys off `ADVISOR_MARKER`): a fixed 3-nation world (Alba/FRA 70·60·65·55, Boreas/RUS 55·70·50·60, Gamma/ESP 30·45·70·40) + one backstory event ("The new order takes shape") + a fixed 3-checkpoint list; the finalize step refines the world deterministically from the approved checkpoints (each naming a nation by its 4-letter name prefix raises that nation's stability +5, clamped) — so "finalize respects the approved checkpoints" is observable end-to-end. The turn stub stays 100% unchanged.
- **Routes (`server/index.ts`).** `GET /api/scenarios/templates` (the 3 manifest entries), `POST /api/scenarios/draft` (`{ templateId, description, year? }` → `{ world, checkpoints }`), `POST /api/scenarios/commit` (`{ templateId, description, year?, draft?, approvedCheckpoints, playerNationId }` → `{ id }` — the finalized world is saved to SQLite with `startDate = <year>-01-01` and title = the template name). A separate `scenarioLlm` client (the scenario stub under `LLM_STUB=1`, the same live `qwen3.8-27b` client otherwise) — the turn `llm` is untouched. `POST /api/games/:id/jump` synthesizes a minimal `Preset` (`dynamicPresetFromGame`) for a saved dynamic game (`presetId: 'dynamic'` has no static file on disk — `runTurn` only consumes title/startDate/worldBackground/rules) so a created scenario plays with the normal turn engine; a genuinely-missing static preset still 404s.
- **Client.** New `ScenarioForm` (3 template cards, a pre-filled editable year, a description textarea, Generate → `/draft`; a 502 toasts and stays on the form) and `CheckpointReview` (the draft world as nation cards with stats, the checkpoint checklist with per-item approve/remove, a pick-a-nation-to-lead step, Finalize → `/commit` → navigates to `#/game/<id>`; a 502 toasts and stays on review). `PresetPicker` gains a "Create scenario" card, and `App.tsx` wires the `home → form → review → navigate` flow (a distinct `scenarioBusy` flag that never gates "Jump forward" or the turn flow).
- **Tests.** Unit: the template store (manifest + all 3 files' shape); `validateWorldDraft` / `relationFromScore` / `worldDraftToGameState` (valid → clean, stat/score clamping, bad country codes dropped, garbage → null without throwing, id dedup, territory expansion, `regionStats` seeding, player fallback, turn-1 invariants); `generateDraft` / `finalizeWorld` against the scenario stub (deterministic output, finalize respecting the approved checkpoints, a saved playable game). E2E: a new `e2e/scenario.spec.ts` (2 tests — the templates manifest over the API, and the full deterministic flow under `LLM_STUB=1`: near-future (2035) + description → draft → remove one checkpoint → lead Boreas → finalize (checkpoints shift stats) → a playable game at the `#/game/<id>` deep link with turn 1, correct date, dossier stats, map legend, and the backstory event; plus an empty jump proving the dynamic game runs the normal turn loop). The existing `e2e/sim.spec.ts` is unchanged. Totals: **307 unit tests (14 files)**, **16 e2e tests (2 scenario + 14 sim)**; typecheck, lint, and build green.
## v0.30.0 — 2026-10-15 08:22
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
+253
View File
@@ -0,0 +1,253 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic
* under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so
* the two generation calls ride the deterministic transport in
* server/stubScenario.ts — the turn stub in server/stub.ts is untouched).
*
* The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for
* the generate step, and — on the finalize step — refines that world in a
* deterministic way that depends on which checkpoints the player approved
* (each approved checkpoint naming one of the fixed nations, by its first 4
* letters, raises that nation's stability by 5). So the whole two-step
* "Create scenario" path plus the resulting game is reproducible:
*
* home → "Create scenario" → pick the Near Future era (2035) + a description
* → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the
* 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved,
* playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump
* (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no
* territory change — Russia shares no land border with France or Spain).
*
* The e2e webServer shares one SQLite (data/e2e) across tests, so the test
* captures the *specific* id of the game it creates (from the POST
* /api/scenarios/commit response) rather than assuming "the only game".
*/
/** The saved-game shape the assertions read back from GET /api/games/<id>. */
interface SavedGame {
id: string;
presetId: string;
presetTitle: string;
playerNationId: string;
turn: number;
date: string;
version: number;
pending: unknown[];
unowned: string[];
pendingNationOps: unknown[];
markers: unknown[];
events: Array<{ type: string; outcome: string; title: string }>;
nations: Record<
string,
{
name: string;
stats: { military: number; economy: number; stability: number; population: number };
territory: string[];
relations: Record<string, string>;
}
>;
regionStats: Record<
string,
{ military: number; economy: number; stability: number; population: number }
>;
}
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
async function waitHash(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(exp) => window.location.hash === exp,
expected,
{ timeout: 15000 },
);
}
/** Open the "Create scenario" form from the home screen. */
async function openScenarioForm(page: Page): Promise<void> {
await page.goto('/');
// Home: the create-scenario card is additive (first in the grid); the five
// static presets are still all present alongside it.
await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('preset-card')).toHaveCount(5);
await page.getByTestId('create-scenario').click();
await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 });
}
test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => {
test('the era template manifest renders three light templates', async ({ page }) => {
test.setTimeout(60000);
await openScenarioForm(page);
// Exactly three era-template cards, each carrying its id and default year.
const cards = page.getByTestId('template-card');
await expect(cards).toHaveCount(3, { timeout: 15000 });
await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939');
await expect(page.locator('[data-template-id="present"]')).toContainText('2026');
await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035');
});
test('full create → review → finalize → play flow is deterministic', async ({ page }) => {
test.setTimeout(120000);
await openScenarioForm(page);
// --- 1 · Pick the Near Future era (its default year pre-fills the field).
const nf = page.locator('[data-template-id="near-future"]');
await nf.click();
await expect(nf).toHaveAttribute('data-selected', 'true');
// The year field is pre-filled from the era (editable, a real value).
await expect(page.getByTestId('scenario-year')).toHaveValue('2035');
// --- 2 · Describe the world.
const description =
'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.';
await page.getByTestId('scenario-description').fill(description);
// --- Step 1 · Generate the draft world + the advisor's checkpoint list.
const generate = page.getByTestId('generate-scenario');
await expect(generate).toBeEnabled();
const [draftResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft',
),
generate.click(),
]);
expect(draftResp.status()).toBe(200);
const draft = (await draftResp.json()) as {
world: {
nations: Array<{ id: string; name: string; territory: string[] }>;
};
checkpoints: string[];
};
// The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints.
expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']);
expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']);
expect(draft.checkpoints).toHaveLength(3);
// --- Review screen: the generated world + the checkpoint checklist.
await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 });
// Header shows the era's name + year and the description.
await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible();
await expect(page.getByTestId('scenario-review')).toContainText('2035');
await expect(page.getByTestId('scenario-review')).toContainText(description);
// The three draft nations (with their ISO_A3 territory) and the backstory.
// Scope to the draft-nation cards (the nation-pick cards share the
// data-nation-id attribute but carry a different testid).
await expect(page.getByTestId('draft-nation')).toHaveCount(3);
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA');
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS');
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP');
await expect(page.getByTestId('draft-event')).toHaveCount(1);
// Three checkpoints, all approved by default, in the stub's fixed order.
const cps = page.getByTestId('checkpoint-item');
await expect(cps).toHaveCount(3);
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true');
await expect(cps.nth(0)).toContainText('Alba dominates');
await expect(cps.nth(1)).toContainText('Boreas fortifies');
await expect(cps.nth(2)).toContainText('Gamma brokers');
// Three nation-pick cards; finalize is disabled until a nation is chosen.
await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3);
await expect(page.getByTestId('finalize-scenario')).toBeDisabled();
// --- Review: remove checkpoint 3, keep 1 + 2.
await cps.nth(2).getByTestId('checkpoint-toggle').click();
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false');
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved');
// --- Review: choose Boreas (Russia) to lead.
const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]');
await boreasCard.getByTestId('scenario-lead-nation').click();
await expect(boreasCard).toHaveAttribute('data-selected', 'true');
await expect(page.getByTestId('finalize-scenario')).toBeEnabled();
// --- Step 2 · Finalize: commit the approved draft into a saved game.
const [commitResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit',
),
page.getByTestId('finalize-scenario').click(),
]);
expect(commitResp.status()).toBe(200);
const { id } = (await commitResp.json()) as { id: string };
expect(id).toBeTruthy();
// --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01.
await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('2035-01-01');
await expect(page.getByTestId('player-nation')).toHaveText('Boreas');
// The scenario title (the era name) is shown in the top bar.
await expect(page.getByTestId('top-bar')).toContainText('Near Future');
// Boreas's stats: the finalized world (its approved checkpoint raised
// stability 50→55); per-region stats are seeded to equal the derived.
await expect(page.getByTestId('stat-military')).toHaveText('55');
await expect(page.getByTestId('stat-economy')).toHaveText('70');
await expect(page.getByTestId('stat-stability')).toHaveText('55');
await expect(page.getByTestId('stat-population')).toHaveText('60');
// One backstory event; three nations on the legend.
await expect(page.getByTestId('event-item')).toHaveCount(1);
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible();
// The deep link lands on this exact game.
await waitHash(page, `#/game/${id}`);
// --- One empty jump: the world advances on its own (the turn stub).
// The button sits at the bottom of a scrollable window body and may be
// outside the viewport; a native DOM click bypasses the viewport check.
await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click());
// Turn 2, date +2 days, Boreas military 55→60.
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 });
await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 });
// Two events now (the empty-round "world moves on" on top, then the
// backstory); both political/neutral.
await expect(page.getByTestId('event-item')).toHaveCount(2);
await expect(page.getByTestId('event-item').first()).toContainText('The world moves on');
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape');
// No territory change: Russia shares no land border with France or Spain,
// so the frontier is empty and no conquest is emitted.
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
// --- The saved game is a coherent dynamic GameState on the server.
const saved = await page.evaluate(async (gid) => {
const r = await fetch(`/api/games/${gid}`);
return { status: r.status, body: (await r.json()) as { game: SavedGame } };
}, id);
expect(saved.status).toBe(200);
const g = saved.body.game;
expect(g.presetId).toBe('dynamic');
expect(g.presetTitle).toBe('Near Future');
expect(g.playerNationId).toBe('boreas');
expect(g.turn).toBe(2);
expect(g.date).toBe('2035-01-03');
expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump
expect(g.pending).toEqual([]);
expect(g.unowned).toEqual([]);
expect(g.pendingNationOps).toEqual([]);
expect(g.markers).toEqual([]);
expect(g.events).toHaveLength(2);
// Boreas after the jump: military bumped to 60, the rest untouched.
expect(g.nations.boreas.stats).toEqual({
military: 60,
economy: 70,
stability: 55,
population: 60,
});
// The stub allies the player with the first other nation (Alba).
expect(g.nations.boreas.relations.alba).toBe('ally');
// Every owned region key carries a seeded per-region stats entry.
const allKeys = new Set<string>();
for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k);
expect(allKeys.size).toBeGreaterThan(0);
for (const k of allKeys) {
expect(g.regionStats[k]).toBeDefined();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.30.0",
"version": "0.35.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": "ww2",
"name": "World War II",
"year": 1939,
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength."
},
{
"id": "present",
"name": "Present Day",
"year": 2026,
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision."
},
{
"id": "near-future",
"name": "Near Future",
"year": 2035,
"eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances."
}
]
+17
View File
@@ -0,0 +1,17 @@
{
"id": "near-future",
"name": "Near Future",
"year": 2035,
"eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.",
"suggestedPowers": [
{ "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." },
{ "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." },
{ "name": "European Confederation", "region": "FRA", "note": "The united European bloc." },
{ "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." }
],
"tensions": [
"Autonomous weapons and the new arms race",
"Climate migration and resource wars",
"The contest for space and orbital dominance"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"id": "present",
"name": "Present Day",
"year": 2026,
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.",
"suggestedPowers": [
{ "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." },
{ "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." },
{ "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." },
{ "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." }
],
"tensions": [
"US–China tech and trade rivalry",
"The war in Europe and the eastern flank",
"AI and cyber dominance"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"id": "ww2",
"name": "World War II",
"year": 1939,
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.",
"suggestedPowers": [
{ "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." },
{ "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." },
{ "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." },
{ "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." }
],
"tensions": [
"The Axis versus the Allies",
"The race for continental resources and sea lanes",
"The uneasy balance before the bomb"
]
}
+18
View File
@@ -31,6 +31,11 @@ function makeState(id: string): GameState {
regionStats: {
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
},
// (v0.33.0, #56) no wars.
wars: [],
// (v0.35.0, #58) no active trades or embargoes.
trades: [],
embargoes: [],
};
}
@@ -147,6 +152,19 @@ describe('openDb', () => {
db.close();
});
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
delete (legacy as { embargoes?: unknown[] }).embargoes;
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.trades).toEqual([]);
expect(loaded!.embargoes).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+8
View File
@@ -49,6 +49,14 @@ export function openDb(filePath: string): Db {
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
// syncWarRelations, which never force-creates a War entity).
if (state.wars === undefined) state.wars = [];
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
// legacy game has no active trades or embargoes.
if (state.trades === undefined) state.trades = [];
if (state.embargoes === undefined) state.embargoes = [];
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
+1481 -21
View File
File diff suppressed because it is too large Load Diff
+855 -4
View File
@@ -4,6 +4,7 @@
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
// llm.ts (injectable) and is invoked by runTurn.
import type {
Embargo,
EventOutcome,
EventType,
GameState,
@@ -15,6 +16,8 @@ import type {
RegionStats,
RoundItem,
StatKey,
TradeAgreement,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
@@ -23,18 +26,28 @@ import {
clampStat,
deriveNationStats,
fnv1a32,
HYBRID_OP_TYPES,
isDateLike,
isMarkerType,
makeEvent,
markerId,
MAX_MARKERS,
nationForPrompt,
type EconomyEmbargoOp,
type EconomyOps,
type EconomyTradeOp,
type HybridOp,
type HybridOpType,
type MarkerInfo,
type MarkerSpec,
type NationOp,
type RegionStatChange,
type Takeover,
type TurnEventSpec,
type TurnResult,
type WarDeclareOp,
type WarEndOp,
type WarInfo,
} from '../shared/protocol';
import {
DEFAULT_MAX_TOKENS,
@@ -50,6 +63,12 @@ const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
const VALID_HYBRID_TYPES = new Set<string>(HYBRID_OP_TYPES);
/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */
export function isHybridOpType(t: unknown): t is HybridOpType {
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
}
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -149,7 +168,9 @@ export function playerFrontier(
* (v0.21, #16 P2): the model may only move regions across a shared border, and
* may verify borders with the tool before answering. The contract also
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
* split/merge shapes applied on player confirm (v0.24, #16 P4b), and the
* trade & embargo economy layer (v0.35.0, #58): bilateral trades and
* unilateral embargoes, each ended with a `lift: true` op.
*/
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules
@@ -199,6 +220,10 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -216,6 +241,10 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -233,6 +262,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
* other consumer) can read the exact items. The framing instruction tells the
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
* (v0.35.0, #58) It also carries the currently active `trades` / `embargoes`
* (omitted entirely when undefined, like the other optional fields) so the
* model can reference them — and `lift` them — in `TurnResult.economy`.
*/
export function buildUserPrompt(
state: GameState,
@@ -260,6 +292,35 @@ export function buildUserPrompt(
* Omitted entirely when undefined.
*/
markers?: MarkerInfo[],
/**
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
* stable id, sides and aims. Emitted verbatim (even when empty) so the
* model can reference a war's id in `TurnResult.wars.end` to end it.
* Omitted entirely when undefined.
*/
wars?: WarInfo[],
/**
* (v0.34.0, #57) The player's current relations to every other nation
* (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the
* model sees the relation ladder it can nudge with `information` / `proxy`
* hybrid ops and knows the current state before an `exposed` backfire.
* Omitted entirely when undefined.
*/
relations?: Record<string, RelationStatus>,
/**
* (v0.35.0, #58) The currently active bilateral trade agreements (each a
* pair of nation ids). Emitted verbatim (even when empty) so the model
* knows which trades exist and can end one with `economy.trade` +
* `lift: true`. Omitted entirely when undefined.
*/
trades?: TradeAgreement[],
/**
* (v0.35.0, #58) The currently active unilateral embargoes (each an
* `from` → `target` pair of nation ids). Emitted verbatim (even when
* empty) so the model knows which embargoes are in force and can lift one
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
*/
embargoes?: Embargo[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -372,6 +433,10 @@ export function buildUserPrompt(
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
if (wars !== undefined) payload.wars = wars;
if (relations !== undefined) payload.relations = relations;
if (trades !== undefined) payload.trades = trades;
if (embargoes !== undefined) payload.embargoes = embargoes;
return JSON.stringify(payload, null, 2);
}
@@ -509,6 +574,10 @@ export function validateTurnResult(
territoryChanges: [],
unowned: [],
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
@@ -599,6 +668,26 @@ export function validateTurnResult(
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly
// by applyTurnResult (automatic, not player-confirmed).
result.takeovers = validateTakeovers(o.takeovers);
// (v0.33.0, #56) War declare/end ops: creating and ending named Wars.
// Applied directly by applyTurnResult (after takeovers, before markers).
result.wars = validateWarOps(o.wars);
// (v0.34.0, #57) Covert hybrid ops (spies, sabotage, assassination, cyber,
// economic, disinformation, proxy). Applied directly by applyTurnResult
// (after wars, before markers) — a deterministic per-type effect or an
// exposed backfire.
result.hybrid = { ops: validateHybridOps(o.hybrid) };
// (v0.35.0, #58) Trade & embargo ops: a no-`lift` op declares / imposes,
// `lift: true` ends / lifts. Refs stay raw here and are resolved at apply
// time against the real state (like wars / hybrid ops); unknown /
// ambiguous / self-pairing ops are dropped there, never thrown.
result.economy = validateEconomyOps(o.economy);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -705,13 +794,588 @@ export function validateRegionChanges(
return out;
}
/**
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
* {@link Takeover} list — the non-military regime-change lever (a coup or
* revolution that renames a nation to its new regime name). Defensive like
* the rest of the parser — never throws. Each entry needs a non-empty
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
* to a concrete nation id happens at apply time (`resolveNationRef`), not
* here. Entries with either field missing or whitespace-only are dropped.
*/
export function validateTakeovers(raw: unknown): Takeover[] {
if (!Array.isArray(raw)) return [];
const out: Takeover[] = [];
for (const e of raw) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
const nationRef = str(o.nationRef, 200);
const newRegimeName = str(o.newRegimeName, 200);
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
}
return out;
}
/**
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
* `{ declare, end }` shape. Defensive like the rest of the parser — never
* throws. `declare`: each entry must be an object whose `sides` is an array
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
* yields at least TWO distinct sides — anything else (a one-sided or
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
* of exactly `armistice` or `capitulation` (anything else is dropped);
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
* input yields `{ declare: [], end: [] }`.
*/
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.declare)) {
for (const e of o.declare) {
if (!e || typeof e !== 'object') continue;
const d = e as Record<string, unknown>;
const sides = (Array.isArray(d.sides) ? d.sides : [])
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
.filter((s) => s !== '')
// dedupe case-insensitively? No — sides are nation ids: keep exact
// strings, drop exact duplicates only (resolution is later).
.filter((s, i, arr) => arr.indexOf(s) === i);
if (sides.length < 2) continue; // a war needs at least two belligerents
const aims = Array.isArray(d.aims)
? (d.aims as unknown[])
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
.filter((a) => a !== '')
.slice(0, 5)
: undefined;
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
}
}
if (Array.isArray(o.end)) {
for (const e of o.end) {
if (!e || typeof e !== 'object') continue;
const w = e as Record<string, unknown>;
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
if (!id) continue;
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
}
}
return out;
}
/**
* (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe
* `{ ops }` shape. Defensive like the rest of the parser — never throws. The
* raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare
* array (be lenient about either). Each op needs a known `type` (one of
* {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at
* 200 chars) and a non-empty `target` (a nation id/name or a region key, capped
* at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the
* default, `success`, is stored as absent to keep the shape minimal — the
* engine treats a missing outcome as success); a non-empty `detail` is capped
* at 300 chars. Resolution of `from` / `target` to concrete nation ids or
* region owners happens at apply time (`applyHybridOp`), not here.
*/
export function validateHybridOps(raw: unknown): HybridOp[] {
if (!Array.isArray(raw)) {
// Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []).
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const inner = (raw as Record<string, unknown>).ops;
if (!Array.isArray(inner)) return [];
return validateHybridOpsList(inner);
}
return [];
}
return validateHybridOpsList(raw);
}
function validateHybridOpsList(raw: unknown[]): HybridOp[] {
const out: HybridOp[] = [];
for (const e of raw) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isHybridOpType(o.type)) continue;
const from = str(o.from, 200);
const target = str(o.target, 200);
if (!from || !target) continue;
const detail = str(o.detail, 300);
const exposed = o.outcome === 'exposed'; // 'success' / missing → success
out.push({
type: o.type,
from,
target,
...(detail ? { detail } : {}),
...(exposed ? { outcome: 'exposed' as const } : {}),
});
}
return out;
}
/**
* (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe
* {@link EconomyOps}. Defensive like the rest of the parser — never throws.
* `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique
* name, capped at 200 chars) and a boolean `lift` (default false = declare);
* ops with a missing ref are dropped — ref resolution and the
* a≠b / from≠target checks happen at apply time against the real state
* (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op
* needs non-empty `from` and `target` refs and a boolean `lift` (default
* false = impose). A missing / malformed top-level object yields
* `{ trade: [], embargo: [] }`.
*/
export function validateEconomyOps(raw: unknown): EconomyOps {
const out: EconomyOps = { trade: [], embargo: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.trade)) {
for (const e of o.trade) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const a = str(t.a, 200);
const b = str(t.b, 200);
if (!a || !b) continue;
const op: EconomyTradeOp = { a, b };
if (t.lift === true) op.lift = true;
out.trade.push(op);
}
}
if (Array.isArray(o.embargo)) {
for (const e of o.embargo) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const from = str(t.from, 200);
const target = str(t.target, 200);
if (!from || !target) continue;
const op: EconomyEmbargoOp = { from, target };
if (t.lift === true) op.lift = true;
out.embargo.push(op);
}
}
return out;
}
/**
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
* {b,c} (any two distinct belligerents of the same active war are at war —
* see the War type's semantics). Pure; exported for the unit tests.
*/
export function activeWarPairs(wars: War[]): Set<string> {
const pairs = new Set<string>();
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
for (const w of wars) {
if (w.status !== 'active') continue;
for (let i = 0; i < w.sides.length; i++) {
for (let j = i + 1; j < w.sides.length; j++) {
pairs.add(key(w.sides[i], w.sides[j]));
}
}
}
return pairs;
}
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
export function atWar(wars: War[], a: string, b: string): boolean {
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
return activeWarPairs(wars).has(key);
}
/**
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
* applyTurnResult does) and never touching `prev`. For every unordered pair
* of distinct existing nations:
*
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
* of the pair's relation are set to `war` (the engine owns this relation
* while the war is active — a manual `relations` entry to a lesser status
* on a warring pair is overridden, so the two can never "make peace"
* without the war being ended);
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
* wars that tied them ended this turn) AND the current relation is
* `war` → both directions become `hostile` (a war is the strongest
* hostility — when it stops, the nations stay hostile, not suddenly
* neutral);
* - otherwise (not at war now, and the relation is not `war`, or the pair
* was never tied by an active war) → untouched: the LLM owns
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
* no active war ever covered (a preset leftover) stays as-is — an
* implicit, unnamed war, never force-created into a War entity and never
* clobbered by the sync.
*
* Never throws.
*/
export function syncWarRelations(prev: GameState, next: GameState): void {
const prevAtWar = activeWarPairs(prev.wars ?? []);
const nextAtWar = activeWarPairs(next.wars ?? []);
const ids = Object.keys(next.nations);
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = ids[i];
const b = ids[j];
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
const na = next.nations[a];
const nb = next.nations[b];
if (nextAtWar.has(key)) {
na.relations[b] = 'war';
nb.relations[a] = 'war';
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
na.relations[b] = 'hostile';
nb.relations[a] = 'hostile';
}
}
}
}
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations -------------
//
// The engine's DETERMINISTIC effects for the seven hybrid op types. There is no
// hidden randomness: the LLM declares an intended `outcome` (success / exposed)
// and the engine acts only on that. Each effect is small and documented:
//
// spy informational only — no stat, no relation (pure intel)
// sabotage a small ECONOMY dip on the target
// assassinate a small STABILITY dip on the target (absent the named-leaders
// feature, #61, a leader's removal sours the polity)
// cyber a small MILITARY dip on the target
// economic a small ECONOMY dip on the target
// information a relation nudge toward hostile (cap hostile) — disinformation
// proxy a light relation nudge toward hostile (cap rival) — proxy war
//
// A successful stat op dips the target's `regionStats` (the single named region
// when the target was a region key, otherwise every region the target owns) and
// re-derives the target nation's aggregate stats so the effect persists. A
// successful information/proxy and any exposed op sour the actor's relation
// with the target by one step on the hostile ladder.
/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */
export const HYBRID_STAT_EFFECT: Record<HybridOpType, { stat: StatKey; delta: number } | null> = {
spy: null, // informational only
sabotage: { stat: 'economy', delta: -3 },
assassinate: { stat: 'stability', delta: -5 },
cyber: { stat: 'military', delta: -3 },
economic: { stat: 'economy', delta: -5 },
information: null, // relation nudge only
proxy: null, // relation nudge only
};
/**
* The hostile-relations ladder, weakest to strongest (the `cap` in
* {@link shiftRelationTowardHostile} is a member of this array). A missing
* relation is treated as `neutral`; `war` is never touched (a war is its own
* entity — a covert op neither fabricates nor ends one).
*/
const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
/**
* (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b`
* ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied
* symmetrically to both directions, but never past the given `cap`. A missing
* relation starts at `neutral`; a `war` relation is never touched (a covert op
* neither fabricates nor downgrades a war). Used for the `information` /
* `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the
* caller passes a clone — applyTurnResult does). Never throws.
*/
export function shiftRelationTowardHostile(
state: GameState,
a: string,
b: string,
cap: RelationStatus,
): void {
const na = state.nations[a];
const nb = state.nations[b];
if (!na || !nb) return;
const capIdx = HOSTILE_ORDER.indexOf(cap);
if (capIdx === -1) return; // unknown cap: no-op (defensive)
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
if (cur === 'war') return null; // never touch a war relation
const s = cur ?? 'neutral'; // a missing relation is neutral
const i = HOSTILE_ORDER.indexOf(s);
if (i === -1) return 'neutral'; // defensive: unknown status
if (i >= capIdx) return s; // at or past the cap: no change
return HOSTILE_ORDER[i + 1];
};
const stepA = step(na.relations[b]);
if (stepA !== null) na.relations[b] = stepA;
const stepB = step(nb.relations[a]);
if (stepB !== null) nb.relations[a] = stepB;
}
/**
* (v0.34.0, #57) The display name of a hybrid op's target for the auto
* backfire event: a nation target shows the nation's name; a region target
* shows the region's display name (or its raw key when no resolver is given).
*/
function hybridTargetDisplayName(
target: { nation: Nation; regionKey?: string },
regionNameOf?: (key: string) => string | undefined,
): string {
if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey;
return target.nation.name;
}
/**
* (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region
* key) to the concrete nation it affects. A nation ref resolves via
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
* with zero or 2+ owners can't be a valid target). Returns the affected nation
* plus the specific region key (when the target was a region) so a stat op can
* dip just that region. `validKeys` (when supplied) gates the region-key path;
* a ref that is neither a known nation nor a valid region key returns `null`.
* Pure; never throws.
*/
function resolveHybridTarget(
state: GameState,
target: string,
validKeys?: ReadonlySet<string>,
): { nation: Nation; regionKey?: string } | null {
const id = resolveNationRef(state, target);
if (id !== null) {
const n = state.nations[id];
return n ? { nation: n } : null;
}
const key = (target ?? '').trim();
if (!key) return null;
if (validKeys && !validKeys.has(key)) return null; // unknown region key
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
return { nation: owners[0], regionKey: key };
}
/**
* (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it
* in place and never throwing. Resolution order:
* 1. `from` must resolve to a nation (id first, then a unique name); an
* unknown / ambiguous actor drops the op.
* 2. `target` must resolve to a concrete nation (a nation ref, or a region
* key's sole owner); an unresolvable target drops the op.
* 3. A self-target (`from` and the target nation are the same) is dropped.
* Then, per type:
* - an `exposed` op BACKFIRES: no intended effect, the actor's relation with
* the target sours by one step (cap hostile), and an auto diplomatic/
* failure event is emitted (the engine's own narrative, mirroring the
* "repelled" auto-event).
* - a successful stat op (sabotage / assassinate / cyber / economic) dips the
* target's `regionStats` by its per-type delta — the single named region
* when the target was a region key, otherwise every region the target
* owns — then re-derives the target nation's aggregate stats (the same
* 0-region / no-entry guards as the main recompute), so the effect
* persists to the next turn.
* - a successful `information` / `proxy` sours the actor's relation with the
* target by one step (information caps at hostile, proxy at rival).
* - `spy` is informational only (no stat, no relation) — a no-op except for
* whatever the LLM narrates in `events`.
*/
function applyHybridOp(
next: GameState,
op: HybridOp,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
regionNameOf?: (key: string) => string | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const from = resolveNationRef(next, op.from);
if (from === null) return; // unknown / ambiguous actor: drop
const target = resolveHybridTarget(next, op.target, validKeys);
if (target === null) return; // unresolvable target: drop
if (from === target.nation.id) return; // self-target: drop
if (op.outcome === 'exposed') {
// Backfire: no intended effect; the relation sours by one step (cap
// hostile) and an auto diplomatic/failure event is emitted.
shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
const actor = next.nations[from];
next.events.push(
makeEvent(
{
nation: from,
type: 'diplomatic',
title: 'Covert operation exposed',
text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName(
target,
regionNameOf,
)} was exposed; relations sour in response.`,
outcome: 'failure',
},
turn,
next.date,
),
);
return;
}
const eff = HYBRID_STAT_EFFECT[op.type];
if (eff) {
// A successful stat op: dip the target's region(s), then re-derive.
if (target.regionKey !== undefined) {
const rs = next.regionStats[target.regionKey];
if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
} else {
for (const key of target.nation.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
}
}
// Re-derive the target nation's aggregate stats. The main recompute ran
// earlier this turn, so without this the dip would be washed out next
// turn. The same 0-region / no-entry guards as the main recompute apply.
if (target.nation.territory.length > 0) {
if (target.nation.territory.some((k) => next.regionStats[k])) {
target.nation.stats = deriveNationStats(
target.nation.territory,
next.regionStats,
geoOf,
);
}
}
return;
}
// information / proxy: a relation nudge (spy is a pure no-op here).
if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
}
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
/**
* (v0.35.0, #58) The per-turn economic effect of the economy layer, applied
* EVERY committed turn (not just to this turn's declares): each active
* bilateral trade grants both parties a small, deterministic economy gain;
* each active embargo applies a small, deterministic economy dip to the
* target only. Clamped to 0–100. The effect lands on `stats.economy` AND on
* every region the nation owns (`regionStats`), mirroring the hybrid-op
* effect, and each affected nation's derived stats are re-derived afterwards
* (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there
* are no active trades or embargoes. Never throws.
*/
const TRADE_ECONOMY_BONUS = 1;
const EMBARGO_ECONOMY_PENALTY = 1;
export function applyEconomyEffects(
next: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
// nation id -> signed economy delta (trades add for both parties, embargoes
// subtract for the target; deltas accumulate for nations hit by several).
const delta = new Map<string, number>();
for (const t of next.trades) {
for (const id of [t.a, t.b]) {
delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS);
}
}
for (const e of next.embargoes) {
delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY);
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue; // a nation removed this turn (a merge): the delta is dropped
const cur = clampStat(n.stats.economy + d);
if (cur === n.stats.economy) continue; // no change (already at the clamp)
n.stats.economy = cur;
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
/**
* (v0.35.0, #58) Apply the turn's economy ops to the active lists on
* `next`, RESOLVING the (possibly name-based) refs against the current
* state: a no-`lift` trade op declares the bilateral agreement (idempotent —
* an already-active pair is not duplicated; the pair is stored sorted, since
* a trade is symmetric); `lift: true` ends it (a no-op when absent). A no-
* `lift` embargo op imposes the unilateral embargo (idempotent — the ordered
* from→target pair is kept as-is); `lift: true` lifts it. An op whose refs
* don't resolve to distinct EXISTING nations is dropped (defensive — never
* throws). Mutates `next` in place.
*/
function applyEconomyOps(next: GameState, ops: EconomyOps): void {
const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`;
for (const op of ops.trade) {
const a = resolveNationRef(next, op.a);
const b = resolveNationRef(next, op.b);
if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop
const key = tradeKey(a, b);
if (op.lift === true) {
next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key);
} else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) {
// stored sorted (a < b): a trade is symmetric, so the agreement has a
// canonical shape no matter which way the LLM named the pair.
next.trades.push(a < b ? { a, b } : { a: b, b: a });
}
}
for (const op of ops.embargo) {
const from = resolveNationRef(next, op.from);
const target = resolveNationRef(next, op.target);
if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop
const key = embargoKey(from, target);
if (op.lift === true) {
next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key);
} else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) {
next.embargoes.push({ from, target });
}
}
}
/**
* (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any
* active trade or embargo whose two nations are on OPPOSITE sides of an
* active war (v0.33, #56) is removed — a war severs the economic link, so
* the suspended agreement stops producing its per-turn effect from now on.
* A trade/embargo between two non-belligerents, or between two nations on
* the SAME side, is untouched. Mutates `next` in place; idempotent. Must be
* called AFTER the war declare/end ops of the turn (so it sees the final
* `next.wars`). Never throws.
*/
function suspendEconomyOnWar(next: GameState): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
const warring = activeWarPairs(next.wars);
const atWarNow = (x: string, y: string): boolean => {
if (x === y) return false;
const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`;
return warring.has(key);
};
next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b));
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
* a proposal is a real pending-world change the player will resolve, so it
* must survive the retry/nudge path instead of being replaced by the generic
* "unclear" result).
* "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended
* (v0.33.0 #56: both are real world changes).
*/
export function isMeaningful(result: TurnResult): boolean {
return (
@@ -721,6 +1385,18 @@ export function isMeaningful(result: TurnResult): boolean {
result.regionChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0 ||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
result.takeovers.length > 0 ||
// (v0.33.0, #56) a war declared or ended is a real world change.
result.wars.declare.length > 0 ||
result.wars.end.length > 0 ||
// (v0.34.0, #57) a hybrid op (a successful one or an exposed backfire) is
// a real world change (a stat dip or a soured relation).
result.hybrid.ops.length > 0 ||
// (v0.35.0, #58) a trade declared/ended or an embargo imposed/lifted this
// turn is a real world change (the active economy lists moved).
result.economy.trade.length > 0 ||
result.economy.embargo.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -973,6 +1649,136 @@ export function applyTurnResult(
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// --- Takeovers (v0.32.0, #54): non-military regime changes (coups /
// revolutions) that rename a nation to its new regime. Applied AFTER the
// per-region recompute (the rename doesn't depend on territory or stats,
// so it is order-independent with respect to those steps) and BEFORE the
// markers block (a marker's owner is a nation id, not a name, so the
// rename does not affect the owner recompute).
//
// For each takeover:
// 1. Resolve the target nation via `resolveNationRef` (id first, then
// case-insensitive unique name). Skip (no-op) if `null` — an
// unknown or ambiguous ref is silently dropped, never thrown.
// 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
// the display name changes — the stable id, territory, relations
// (keyed by id), stats, and `playerNationId` (which references the
// id, not the name) are all preserved. The player keeps leading an
// overthrown nation under its new regime.
for (const t of result.takeovers) {
const id = resolveNationRef(next, t.nationRef);
if (id === null) continue; // unknown / ambiguous ref: no-op
const n = next.nations[id];
if (!n) continue; // defensive: id resolved but nation missing (corrupt state)
n.name = t.newRegimeName;
}
// --- Wars (v0.33.0, #56: war as a first-class state) ---
//
// Wars are named entities (`GameState.wars`), not just a `war` relation.
// Two kinds of change, applied in this order against the (cloned) `next`:
//
// (1) DECLARE: each validated declare is resolved to concrete nation ids
// (`resolveNationRef`, so the LLM may use an id OR a unique name) and
// becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
// with the acting turn and the turn's new date. Mutually-defensive
// allies of the belligerents are DRAGGED IN: any nation that is an
// `ally` of a belligerent (per the current, post-relations relations)
// and is not already a side joins the war's `sides`. A declare with
// fewer than two DISTINCT resolved sides is dropped; a declare whose
// sides are all already contained in an existing ACTIVE war is a
// duplicate and is skipped (the existing war stands).
// (2) END: each validated end targets a war BY ID and sets its status to
// the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
// affected — an unknown id or an already-ended war is a no-op.
// (A war may be declared and ended in the SAME turn: declare runs
// first, so the just-declared id is a valid end target.)
// (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from
// the ACTIVE wars (see its doc) — the load-bearing step that keeps
// the relation table honest with the war entities.
//
// Nothing here throws: every unresolvable / invalid op is dropped.
{
const takenWarIds = new Set(next.wars.map((w) => w.id));
// (1) DECLARE
for (const d of result.wars.declare) {
const resolved: string[] = [];
for (const ref of d.sides) {
const id = resolveNationRef(next, ref);
if (id !== null && !resolved.includes(id)) resolved.push(id);
}
if (resolved.length < 2) continue; // not enough distinct belligerents
// Duplicate: an active war already contains all of these sides.
const dup = next.wars.some(
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
);
if (dup) continue;
const sides = [...resolved];
// Mutual-defense drag-in: each belligerent's allies join the war.
for (const s of resolved) {
const rels = next.nations[s]?.relations ?? {};
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
if (!sides.includes(ally)) sides.push(ally);
}
}
const id = freshWarId(takenWarIds);
takenWarIds.add(id);
next.wars.push({
id,
sides,
declaredTurn: actingTurn,
declaredDate: next.date,
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
status: 'active',
});
}
// (2) END (by id; active only)
for (const e of result.wars.end) {
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
if (!w) continue; // unknown id / already ended: no-op
w.status = e.outcome;
}
// (3) RELATION SYNC (prev = the state this turn started from)
syncWarRelations(state, next);
}
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations ---
//
// Each op's deterministic per-type effect is applied to the post-wars state
// (a stat dip on the target's regions, or a relation nudge toward hostile).
// An `exposed` op backfires instead. They never move borders, so they run
// after the territory changes and before the markers (a marker's owner is
// recomputed from the final ownership, unaffected by a stat/relation dip).
for (const op of result.hybrid.ops) {
applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn);
}
// --- Trade & embargo economy layer (v0.35.0, #58) ---
//
// Placed AFTER the wars block (so the auto-suspension sees the FINAL
// next.wars, including wars declared this turn) and before markers /
// nationOps (which never touch the economy lists). Order:
// (1) APPLY OPS: the turn's declare/lift ops mutate the active
// GameState.trades / GameState.embargoes lists (a declare is
// idempotent; refs resolved against the current state, unknown /
// ambiguous / self-pairing ops dropped).
// (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two
// nations are on opposite sides of an active war is removed —
// a war severs the economic link, so the suspended agreement
// stops producing its per-turn effect from now on (this also
// drops a trade declared this turn between two nations that just
// went to war).
// (3) PER-TURN EFFECT: the FULL active lists (not just this turn's
// declares) each apply their deterministic effect this turn —
// trade: both parties' economy up a little; embargo: the
// target's economy down a little — clamped, mirrored to the
// owning nations' regions, and re-derived like the hybrid dip.
// Additive only: declares add, lifts remove, and nothing else (not even
// a war) mutates the lists besides the suspension above.
applyEconomyOps(next, result.economy);
suspendEconomyOnWar(next);
applyEconomyEffects(next, geoOf);
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -1158,6 +1964,19 @@ export function freshNationId(taken: ReadonlySet<string>): string {
}
}
/**
* Generate a fresh, unused war id for a newly declared {@link War}
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
* it creates to `taken` as it goes, so ids stay unique within a turn and are
* never reused from an ended war.
*/
export function freshWarId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `w${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
@@ -1448,7 +2267,35 @@ export async function runTurn(opts: {
regionKey: m.regionKey,
owner: m.ownerNationId,
}));
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
// ended wars stay in GameState.wars as history but are not surfaced.
const wars = (state.wars ?? [])
.filter((w) => w.status === 'active')
.map((w) => ({
id: w.id,
sides: w.sides,
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
}));
// (v0.34.0, #57) the player's current relations to every other nation, so the
// model sees the ladder its `information` / `proxy` hybrid ops nudge (and the
// starting point before an `exposed` backfire). Always emitted ({} when the
// player has no relations yet).
const relations = state.nations[state.playerNationId]?.relations ?? {};
const user = buildUserPrompt(
state,
items,
regionReference,
frontier,
state.unowned ?? [],
markers,
wars,
relations,
// (v0.35.0, #58) the active trade / embargo lists, so the model can
// reference — and `lift` — what is currently in force.
state.trades,
state.embargoes,
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -1500,6 +2347,10 @@ export async function runTurn(opts: {
territoryChanges: [],
unowned: [],
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
}
+137 -5
View File
@@ -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: <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) => {
res.json({ ok: true, stub: useStub, port: PORT });
});
@@ -140,6 +178,14 @@ app.post('/api/games', (req: Request, res) => {
// real region keys — each region starts as a copy of its owner's initial
// stats, so the derived aggregate equals the nation's stats on turn 1.
regionStats: {},
// (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time
// (any preset-level `war` relation stays a bare/implicit one, not an
// entity — see syncWarRelations).
wars: [],
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
// declares (and lifts) them over time via TurnResult.economy.
trades: [],
embargoes: [],
};
// 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
@@ -278,8 +324,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 +399,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 +516,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 };
+5
View File
@@ -52,6 +52,11 @@ const state: GameState = {
markers: [],
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
regionStats: {},
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
wars: [],
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
trades: [],
embargoes: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+3
View File
@@ -41,6 +41,9 @@ function mkState(
pendingNationOps: [],
markers: [],
regionStats: {} as GameState['regionStats'],
wars: [],
trades: [],
embargoes: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
+4
View File
@@ -110,6 +110,10 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
markers: [],
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
regionStats: {},
wars: [],
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
trades: [],
embargoes: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
+12
View File
@@ -259,6 +259,18 @@ export function validKeys(): ReadonlySet<string> {
return data ? data.validKeys : new Set<string>();
}
/**
* The set of ISO_A3 country codes that own at least one sub-region in the
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
* values in regions.geo.json. Empty when no region data is loaded; callers
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
* draft's territory is checked against real countries.
*/
export function validCountryCodes(): ReadonlySet<string> {
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
}
/**
* True when the region-adjacency manifest loaded with at least one entry.
* Callers (the server's jump route) use this to decide whether to hand the
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest';
import {
relationFromScore,
validateWorldDraft,
worldDraftToGameState,
type WorldDraft,
} from '../shared/scenario';
import type { NationStats } from '../shared/types';
// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per
// country), standing in for server/regions.expandCountriesToKeys in these
// pure-function tests.
const fakeExpand = (countries: string[]): string[] =>
countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
const S = (m: number, e: number, s: number, p: number): NationStats => ({
military: m,
economy: e,
stability: s,
population: p,
});
describe('relationFromScore (v0.31.0, #52)', () => {
it('maps the five sentiment bands to RelationStatus', () => {
expect(relationFromScore(100)).toBe('ally');
expect(relationFromScore(60)).toBe('ally');
expect(relationFromScore(0)).toBe('neutral');
expect(relationFromScore(30)).toBe('neutral');
expect(relationFromScore(-1)).toBe('rival');
expect(relationFromScore(-40)).toBe('rival');
expect(relationFromScore(-41)).toBe('hostile');
expect(relationFromScore(-80)).toBe('hostile');
expect(relationFromScore(-81)).toBe('war');
expect(relationFromScore(-100)).toBe('war');
});
it('clamps out-of-range scores before banding', () => {
expect(relationFromScore(250)).toBe('ally');
expect(relationFromScore(-250)).toBe('war');
});
});
describe('validateWorldDraft (v0.31.0, #52)', () => {
it('returns a clean draft from a valid payload', () => {
const raw = {
nations: [
{ id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } },
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } },
],
};
const d = validateWorldDraft(raw);
expect(d).not.toBeNull();
const draft = d as WorldDraft;
expect(draft.nations).toHaveLength(2);
expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' });
expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90));
expect(draft.nations[0].territory).toEqual(['DEU']);
expect(draft.nations[0].relations).toEqual({ uk: 80 });
expect(draft.events).toBeUndefined();
});
it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => {
const raw = {
nations: [
{ name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] },
],
};
const d = validateWorldDraft(raw) as WorldDraft;
expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50
});
it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => {
const codes = new Set(['DEU', 'GBR', 'FRA']);
const raw = {
nations: [
{
name: 'Mixed',
// lower-case, duplicated, and one unknown code (USA) to be dropped.
territory: ['deu', 'DEU', 'usa', 'gbr'],
},
],
};
const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft;
expect(d.nations[0].territory).toEqual(['DEU', 'GBR']);
});
it('keeps unknown codes when no validCountryCodes set is supplied', () => {
const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] };
const d = validateWorldDraft(raw) as WorldDraft;
expect(d.nations[0].territory).toEqual(['AAA', 'BBB']);
});
it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => {
const raw = {
nations: [
{ id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } },
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } },
],
};
const d = validateWorldDraft(raw) as WorldDraft;
// de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped.
expect(d.nations[0].relations).toEqual({ uk: 55 });
// uk: the id-keyed ref to de is kept, clamped (already in range).
expect(d.nations[1].relations).toEqual({ de: -70 });
});
it('clamps relation scores to -100..100', () => {
const raw = {
nations: [
{ id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } },
{ id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } },
],
};
const d = validateWorldDraft(raw) as WorldDraft;
expect(d.nations[0].relations).toEqual({ b: 100 });
expect(d.nations[1].relations).toEqual({ a: -100 });
});
it('suffices duplicate ids until unique and derives ids from names', () => {
const raw = {
nations: [
{ id: 'x', name: 'First', territory: ['AAA'] },
{ id: 'x', name: 'Second', territory: ['BBB'] },
{ name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug
],
};
const d = validateWorldDraft(raw) as WorldDraft;
const ids = d.nations.map((n) => n.id);
expect(ids).toEqual(['x', 'x-2', 'united-kingdom']);
});
it('keeps backstory events that name a known nation and drops the rest', () => {
const raw = {
nations: [
{ id: 'de', name: 'Germany', territory: ['DEU'] },
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'] },
],
events: [
{ nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' },
{ nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' },
{ nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' },
{ nation: 'de', type: 'political' }, // no title -> dropped
],
};
const d = validateWorldDraft(raw) as WorldDraft;
expect(d.events).toHaveLength(2);
expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' });
expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' });
});
it('returns null (never throws) for unusable / garbage payloads', () => {
expect(validateWorldDraft(null)).toBeNull();
expect(validateWorldDraft('nope')).toBeNull();
expect(validateWorldDraft([])).toBeNull();
expect(validateWorldDraft({})).toBeNull();
expect(validateWorldDraft({ nations: [] })).toBeNull();
expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull();
expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name
expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object
// no nation owns a *valid* territory -> unusable.
expect(
validateWorldDraft(
{ nations: [{ name: 'X', territory: ['ZZZ'] }] },
{ validCountryCodes: new Set(['DEU']) },
),
).toBeNull();
});
});
describe('worldDraftToGameState (v0.31.0, #52)', () => {
function draft(): WorldDraft {
return {
nations: [
{ id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } },
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } },
],
events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }],
};
}
it('expands ISO_A3 territory to region keys and seeds per-region stats', () => {
const g = worldDraftToGameState(draft(), fakeExpand, 'uk', {
id: 'g1',
title: 'Custom World',
startDate: '1939-09-01',
});
expect(g.id).toBe('g1');
expect(g.presetTitle).toBe('Custom World');
expect(g.date).toBe('1939-09-01');
expect(g.turn).toBe(1);
expect(g.version).toBe(1);
expect(g.playerNationId).toBe('uk');
expect(g.unowned).toEqual([]);
expect(g.markers).toEqual([]);
expect(g.pendingNationOps).toEqual([]);
expect(g.pending).toEqual([]);
// de owns DEU -> two region keys; uk owns GBR+IRL -> four.
expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']);
expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']);
// Every owned region is seeded with a copy of its owner's stats.
expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats);
expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats);
// No entry for unowned regions.
expect(g.regionStats['ZZZ-a']).toBeUndefined();
});
it('maps numeric relations to RelationStatus', () => {
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally
expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war
});
it('seeds the event feed from backstory events at turn 1 / startDate', () => {
const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' });
expect(g.events).toHaveLength(1);
expect(g.events[0]).toMatchObject({
turn: 1,
date: '2035-01-01',
nation: 'de',
type: 'political',
title: 'The new order',
outcome: 'neutral',
});
});
it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => {
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
expect(g.id).toBe('');
expect(g.presetTitle).toBe('Custom World');
expect(g.date).toBe('1970-01-01');
expect(g.presetId).toBe('dynamic');
});
});
+335
View File
@@ -0,0 +1,335 @@
// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52):
// the prompt builders, the defensive parsers, generateDraft / finalizeWorld
// over an injected LlmClient, and the deterministic scenario stub.
import { describe, expect, it } from 'vitest';
import {
buildGeneratePrompt,
buildRegeneratePrompt,
finalizeWorld,
generateDraft,
parseGenerate,
parseRegenerate,
SCENARIO_GENERATE_MARKER,
SCENARIO_REGENERATE_MARKER,
} from './scenario';
import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario';
import type { EraTemplate, WorldDraft } from '../shared/scenario';
import type { ChatMessage, LlmClient } from './llm';
const TEMPLATE: EraTemplate = {
id: 'near-future',
name: 'Near Future',
year: 2035,
eraDescription: 'A fractured, multipolar 2035.',
suggestedPowers: [
{ name: 'United States', region: 'USA', note: 'declining hegemon' },
{ name: 'China', region: 'CHN', note: 'rising industrial power' },
],
tensions: ['Indo-Pacific standoff', 'Climate migration'],
};
const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']);
/** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */
function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient {
return {
async chat(messages) {
if (typeof canned === 'function') return canned(messages);
return canned ?? '{"world":{"nations":[]},"checkpoints":[]}';
},
async listModels() {
return [];
},
};
}
const GOOD_GENERATE = JSON.stringify({
world: {
nations: [
{
id: 'alba',
name: 'Alba',
type: 'major',
stats: { military: 70, economy: 60, stability: 65, population: 55 },
territory: ['FRA'],
relations: { boreas: -60 },
},
{
id: 'boreas',
name: 'Boreas',
type: 'major',
stats: { military: 55, economy: 70, stability: 50, population: 60 },
territory: ['RUS'],
relations: { alba: -60 },
},
],
},
checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'],
});
const GOOD_REGENERATE = JSON.stringify({
world: {
nations: [
{
id: 'alba',
name: 'Alba',
type: 'major',
stats: { military: 70, economy: 60, stability: 70, population: 55 },
territory: ['FRA'],
relations: { boreas: -70 },
},
{
id: 'boreas',
name: 'Boreas',
type: 'major',
stats: { military: 55, economy: 70, stability: 55, population: 60 },
territory: ['RUS'],
relations: { alba: -70 },
},
],
},
});
describe('buildGeneratePrompt', () => {
it('embeds the era, description, year and the valid country codes', () => {
const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES);
expect(system).toContain(SCENARIO_GENERATE_MARKER);
expect(system).toContain('"checkpoints"');
expect(user).toContain('near-future');
expect(user).toContain('a cold war in space');
expect(user).toContain('2041');
expect(user).toContain('CHN');
expect(user).toContain('validCountryCodes');
});
it('omits the code list when no valid codes are supplied', () => {
const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined);
expect(system).toContain('standard ISO 3166-1 alpha-3');
expect(user).not.toContain('validCountryCodes');
});
});
describe('buildRegeneratePrompt', () => {
it('embeds the approved checkpoints and the draft when present', () => {
const draft: WorldDraft = {
nations: [
{
id: 'alba',
name: 'Alba',
stats: { military: 70, economy: 60, stability: 65, population: 55 },
territory: ['FRA'],
relations: {},
},
],
};
const { system, user } = buildRegeneratePrompt(
TEMPLATE,
'desc',
2035,
['Alba presses its advantage.'],
VALID_CODES,
draft,
);
expect(system).toContain(SCENARIO_REGENERATE_MARKER);
expect(user).toContain('approvedCheckpoints');
expect(user).toContain('Alba presses its advantage.');
expect(user).toContain('draft');
expect(user).toContain('alba');
});
it('omits the draft field when none is supplied', () => {
const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined);
expect(user).not.toContain('draft');
});
});
describe('parseGenerate / parseRegenerate (defensive)', () => {
it('parses a valid generate answer into a world + checkpoints', () => {
const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES);
expect(world).not.toBeNull();
expect(world!.nations).toHaveLength(2);
expect(world!.nations[0].id).toBe('alba');
expect(world!.nations[0].territory).toEqual(['FRA']);
expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']);
});
it('strips code fences and drops unknown country codes', () => {
const raw = '```json\n' + GOOD_GENERATE + '\n```';
const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set
expect(world).not.toBeNull();
expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]);
});
it('returns null world + empty checkpoints for garbage', () => {
const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES);
expect(world).toBeNull();
expect(checkpoints).toEqual([]);
});
it('parses a valid regenerate answer (no checkpoints echoed)', () => {
const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES);
expect(world).not.toBeNull();
expect(world!.nations[0].stats.stability).toBe(70);
});
});
describe('generateDraft (injected client)', () => {
it('returns a validated world + checkpoints from the model', async () => {
const { world, checkpoints } = await generateDraft({
llm: fakeClient(GOOD_GENERATE),
template: TEMPLATE,
description: 'a cold war in space',
year: 2041,
validCountryCodes: VALID_CODES,
});
expect(world).not.toBeNull();
expect(world!.nations).toHaveLength(2);
expect(checkpoints).toHaveLength(2);
});
it('nudges once when the first answer is unusable', async () => {
let call = 0;
const client: LlmClient = {
async chat() {
call += 1;
return call === 1 ? 'total garbage' : GOOD_GENERATE;
},
async listModels() {
return [];
},
};
const { world, checkpoints } = await generateDraft({
llm: client,
template: TEMPLATE,
description: 'x',
year: 2035,
validCountryCodes: VALID_CODES,
});
expect(call).toBe(2);
expect(world).not.toBeNull();
expect(checkpoints).toHaveLength(2);
});
it('returns null world when even the nudge fails', async () => {
const { world } = await generateDraft({
llm: fakeClient('still no json'),
template: TEMPLATE,
description: 'x',
year: 2035,
validCountryCodes: VALID_CODES,
});
expect(world).toBeNull();
});
});
describe('finalizeWorld (injected client)', () => {
const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
it('converts the finalized world to a playable GameState', async () => {
const state = await finalizeWorld({
llm: fakeClient(GOOD_REGENERATE),
template: TEMPLATE,
description: 'x',
year: 2035,
approvedCheckpoints: ['Alba presses its advantage.'],
playerNationId: 'alba',
validCountryCodes: VALID_CODES,
expand,
id: 'g-1',
title: 'Custom World',
startDate: '2035-01-01',
});
expect(state.id).toBe('g-1');
expect(state.presetId).toBe('dynamic');
expect(state.playerNationId).toBe('alba');
expect(state.turn).toBe(1);
expect(state.date).toBe('2035-01-01');
// FRA expands to two region keys; the per-region stats are seeded.
expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']);
expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats);
});
it('falls back to the first nation when the model drops the player nation', async () => {
// The regenerated world has no 'alba' — only 'boreas'.
const dropped = JSON.stringify({
world: {
nations: [
{
id: 'boreas',
name: 'Boreas',
stats: { military: 55, economy: 70, stability: 55, population: 60 },
territory: ['RUS'],
relations: {},
},
],
},
});
const state = await finalizeWorld({
llm: fakeClient(dropped),
template: TEMPLATE,
description: 'x',
year: 2035,
approvedCheckpoints: [],
playerNationId: 'alba', // not in the world
validCountryCodes: VALID_CODES,
expand,
});
expect(state.playerNationId).toBe('boreas');
});
});
describe('scenario stub (deterministic, LLM_STUB=1)', () => {
it('is deterministic: same input → same generate output', async () => {
const llm = makeScenarioStubLlmClient();
const a = await generateDraft({
llm,
template: TEMPLATE,
description: 'd',
year: 2035,
validCountryCodes: VALID_CODES,
});
const b = await generateDraft({
llm,
template: TEMPLATE,
description: 'completely different description',
year: 1999,
validCountryCodes: VALID_CODES,
});
expect(a.world).toEqual(b.world);
expect(a.checkpoints).toEqual(b.checkpoints);
expect(a.world!.nations).toHaveLength(3);
expect(a.checkpoints).toHaveLength(3);
});
it('finalize respects the approved checkpoints (deterministically)', async () => {
const baseline = stubWorldFor([]);
const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']);
const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
// Approving a checkpoint that names Alba raises Alba's stability only.
expect(albaWith).toBe(albaBase + 5);
expect(boreasWith).toBe(boreasBase);
});
it('finalizeWorld over the stub returns a playable game honoring approvals', async () => {
const llm = makeScenarioStubLlmClient();
const state = await finalizeWorld({
llm,
template: TEMPLATE,
description: 'd',
year: 2035,
approvedCheckpoints: ['Boreas fortifies its northern frontier.'],
playerNationId: 'boreas',
validCountryCodes: VALID_CODES,
expand: (cs) => cs.flatMap((c) => [`${c}-a`]),
id: 'stub-1',
});
expect(state.id).toBe('stub-1');
expect(state.playerNationId).toBe('boreas');
expect(state.nations.boreas.territory).toEqual(['RUS-a']);
// The approved Boreas checkpoint raised its stability by 5 (50 → 55).
expect(state.nations.boreas.stats.stability).toBe(55);
});
});
+390
View File
@@ -0,0 +1,390 @@
// Dynamic scenario generation — the two LLM steps (v0.31, #52).
//
// This is the additive "Create scenario" path. Two plain `chat()` calls (no
// tools, no separate advisor endpoint — the advisor is embodied in step 1's
// checkpoint list):
//
// 1. `generateDraft` — from an era template + a free-text description + a
// year, the model designs a draft {@link WorldDraft} (nations with ISO_A3
// territory, stats, relations, optional backstory) AND an opening
// checkpoint list (the advisor's briefing of the world's live tensions).
//
// 2. `finalizeWorld` — on commit, the model is re-asked to produce the
// final world, constrained to the player's APPROVED checkpoints (and,
// when the client re-sends the step-1 draft, based on it so the player's
// chosen nation survives). The result is converted to a playable
// {@link GameState} (region-key territory via the injected `expand`
// resolver, per-region stats seeded) and returned for the caller to
// persist — a normal game at `#/game/<id>`.
//
// Both steps are defensive like the rest of the engine: raw model output is
// fence-stripped, the first balanced object is extracted, JSON.parsed, and
// validated via the pure `validateWorldDraft` (which never throws and returns
// `null` for unusable input). A transport-level failure (the model being down)
// throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to
// a null world, never a crash. The `LlmClient` is injected so unit tests and
// the deterministic scenario stub (server/stubScenario.ts) can replace the
// network call exactly like the turn path does.
import type { LlmClient } from './llm';
import { DEFAULT_MAX_TOKENS } from './llm';
import type { EraTemplate, WorldDraft } from '../shared/scenario';
import { validateWorldDraft } from '../shared/scenario';
import type { GameState } from '../shared/types';
import { worldDraftToGameState } from '../shared/scenario';
// --- Defensive parsing (shared with the engine's approach) --------------------
/** The system-prompt markers the deterministic scenario stub keys off. */
export const SCENARIO_GENERATE_MARKER = '<<SCENARIO_GENERATE>>';
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
/** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */
function stripFence(text: string): string {
let t = text.trim();
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
if (lead) t = t.slice(lead[0].length);
const tail = t.match(/\n?\s*```\s*$/);
if (tail) t = t.slice(0, t.length - tail[0].length);
return t.trim();
}
/** Extract the first balanced `{ ... }` substring (string-aware). */
function extractBalancedObject(text: string): string | null {
const start = text.indexOf('{');
if (start === -1) return null;
let depth = 0;
let inStr = false;
let esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
}
return null;
}
/** Parse a raw top-level JSON object, or `null` when none is recoverable. */
function parseObject(raw: string): Record<string, unknown> | null {
const jsonText = extractBalancedObject(stripFence(raw));
if (!jsonText) return null;
try {
const obj: unknown = JSON.parse(jsonText);
return obj && typeof obj === 'object' && !Array.isArray(obj)
? (obj as Record<string, unknown>)
: null;
} catch {
return null;
}
}
// --- Prompt building ------------------------------------------------------------
/**
* The shared nation JSON contract text, embedded in both system prompts so the
* model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a
* valid JSON object" style.
*/
const WORLD_SHAPE = [
'{',
' "world": {',
' "nations": [',
' {',
' "id": "short-stable-id",',
' "name": "Human readable name",',
' "type": "major|minor|power|alliance|faction",',
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
' "territory": ["ISO_A3","ISO_A3"],',
' "relations": {"<other nation id or name>": -100..100}',
' }',
' ],',
' "events": [',
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
' ]',
' },',
' "checkpoints": ["short advisor checkpoint", "..."]',
'}',
].join('\n');
/**
* The system prompt for step 1 (generate). Tells the model to design a fresh
* world for the era AND to emit an opening advisor checkpoint list.
*/
export function buildGeneratePrompt(
template: EraTemplate,
description: string,
year: number,
validCountryCodes: ReadonlySet<string> | undefined,
): { system: string; user: string } {
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
const system = [
`${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`,
'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.',
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
WORLD_SHAPE,
'',
'RULES',
'- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).',
codes
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
codes.join(', ') +
'. A nation may control several codes; together the nations should cover a plausible portion of the world.'
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
'- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.',
'- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.',
'- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.',
'- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.',
].join('\n');
const user = JSON.stringify(
{
era: {
id: template.id,
name: template.name,
year,
description: template.eraDescription,
},
suggestedPowers: template.suggestedPowers,
tensions: template.tensions,
description,
year,
...(codes ? { validCountryCodes: codes } : {}),
},
null,
2,
);
return { system, user };
}
/**
* The system prompt for step 2 (regenerate/finalize). Same world contract, but
* the model must now respect the player's APPROVED checkpoints (and, when a
* draft is supplied, stay close to it so the player's chosen nation survives).
*/
export function buildRegeneratePrompt(
template: EraTemplate,
description: string,
year: number,
approvedCheckpoints: string[],
validCountryCodes: ReadonlySet<string> | undefined,
draft?: WorldDraft,
): { system: string; user: string } {
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
const system = [
`${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`,
'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.',
'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:',
[
'{',
' "world": {',
' "nations": [',
' {',
' "id": "short-stable-id",',
' "name": "Human readable name",',
' "type": "major|minor|power|alliance|faction",',
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
' "territory": ["ISO_A3","ISO_A3"],',
' "relations": {"<other nation id or name>": -100..100}',
' }',
' ],',
' "events": [',
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
' ]',
' }',
'}',
].join('\n'),
'',
'RULES',
'- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.',
'- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.',
codes
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
codes.join(', ') +
'.'
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
'- Keep every stat value between 0 and 100.',
].join('\n');
const user = JSON.stringify(
{
era: {
id: template.id,
name: template.name,
year,
description: template.eraDescription,
},
description,
year,
approvedCheckpoints,
...(draft ? { draft } : {}),
...(codes ? { validCountryCodes: codes } : {}),
},
null,
2,
);
return { system, user };
}
// --- Parsing the model's answers ----------------------------------------------
/** Parse the step-1 (generate) answer into a validated world + checkpoint list. */
export function parseGenerate(
raw: string,
validCountryCodes?: ReadonlySet<string>,
): { world: WorldDraft | null; checkpoints: string[] } {
const obj = parseObject(raw);
if (!obj) return { world: null, checkpoints: [] };
const world = validateWorldDraft(obj.world, { validCountryCodes });
const checkpoints: string[] = [];
if (Array.isArray(obj.checkpoints)) {
const seen = new Set<string>();
for (const c of obj.checkpoints) {
if (typeof c !== 'string') continue;
const t = c.trim();
if (t.length > 2 && !seen.has(t)) {
seen.add(t);
checkpoints.push(t);
}
}
}
return { world, checkpoints: checkpoints.slice(0, 12) };
}
/** Parse the step-2 (finalize) answer into a validated world (or null). */
export function parseRegenerate(
raw: string,
validCountryCodes?: ReadonlySet<string>,
): WorldDraft | null {
const obj = parseObject(raw);
if (!obj) return null;
return validateWorldDraft(obj.world, { validCountryCodes });
}
// --- Orchestration -------------------------------------------------------------
export interface GenerateDraftOptions {
llm: LlmClient;
template: EraTemplate;
description: string;
year: number;
/** ISO_A3 codes to accept (undefined = no filtering). */
validCountryCodes?: ReadonlySet<string>;
}
/**
* Step 1: ask the model to design a draft world + an advisor checkpoint list.
* Throws only on a transport-level failure (the model is down) — a bad answer
* yields `{ world: null, checkpoints: [] }` and is the caller's to surface as
* an error, never a crash.
*/
export async function generateDraft(
opts: GenerateDraftOptions,
): Promise<{ world: WorldDraft | null; checkpoints: string[] }> {
const { llm, template, description, year, validCountryCodes } = opts;
const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
const out = await llm.chat(
[{ role: 'system', content: system }, { role: 'user', content: user }],
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
);
const parsed = parseGenerate(out, validCountryCodes);
if (parsed.world) return parsed;
// One nudge re-send with a fresh history, mirroring the engine's retry.
const retry = await llm.chat(
[
{ role: 'system', content: system },
{ role: 'user', content: user },
{ role: 'assistant', content: '' },
{ role: 'user', content: nudge },
],
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
);
return parseGenerate(retry, validCountryCodes);
}
export interface FinalizeWorldOptions {
llm: LlmClient;
template: EraTemplate;
description: string;
year: number;
approvedCheckpoints: string[];
/** The step-1 draft (re-sent by the client) — the base the final world refines. */
draft?: WorldDraft;
/** The nation id the player will lead (kept in the final world when present). */
playerNationId: string;
/** ISO_A3 codes to accept (undefined = no filtering). */
validCountryCodes?: ReadonlySet<string>;
/** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */
expand: (countries: string[]) => string[];
/** The new game's id (the route sets randomUUID()). */
id?: string;
/** The scenario title (becomes presetTitle; default 'Custom World'). */
title?: string;
/** The game's start date (YYYY-MM-DD, from the chosen year). */
startDate?: string;
}
/**
* Step 2: ask the model to produce the final world constrained to the approved
* checkpoints, convert it to a playable {@link GameState}, and return it for the
* caller to persist. Throws only on a transport-level failure; a bad answer
* that still yields a usable world is committed, and if the final world drops
* the player's chosen nation the conversion falls back to the first nation so
* the game is always playable.
*/
export async function finalizeWorld(opts: FinalizeWorldOptions): Promise<GameState> {
const {
llm,
template,
description,
year,
approvedCheckpoints,
draft,
playerNationId,
validCountryCodes,
expand,
id,
title,
startDate,
} = opts;
const { system, user } = buildRegeneratePrompt(
template,
description,
year,
approvedCheckpoints,
validCountryCodes,
draft,
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
const out = await llm.chat(
[{ role: 'system', content: system }, { role: 'user', content: user }],
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
);
let world = parseRegenerate(out, validCountryCodes);
if (!world) {
const retry = await llm.chat(
[
{ role: 'system', content: system },
{ role: 'user', content: user },
{ role: 'assistant', content: '' },
{ role: 'user', content: nudge },
],
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
);
world = parseRegenerate(retry, validCountryCodes);
}
if (!world) {
// The model could not produce a usable final world. Rather than fail the
// whole commit, fall back to the draft the player already approved (if any)
// so the commit still yields a playable game.
if (draft) world = draft;
else throw new Error('finalize: the model returned no usable world');
}
return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate });
}
+51 -1
View File
@@ -3,7 +3,7 @@
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RoundItem } from '../shared/types';
import type { RelationStatus, RoundItem } from '../shared/types';
export const ADVISOR_MARKER = '<<ADVISOR>>';
@@ -38,6 +38,35 @@ export interface StubPayload {
* path is covered by unit tests against applyTurnResult, not by the stub.
*/
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
/**
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
* the engine's user prompt. The stub ignores this field — it never declares
* or ends a war (see deterministicTurn), so the war path is covered by unit
* tests against applyTurnResult, not by the stub.
*/
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
/**
* (v0.34.0, #57) The player's current relations to every other nation
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
* stub ignores this field — it never runs a hybrid op (see
* deterministicTurn), so the hybrid path is covered by unit tests only.
*/
relations?: Record<string, RelationStatus>;
/**
* (v0.35.0, #58) The currently active bilateral trade agreements
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
* stub ignores this field — it never declares, lifts or suspends a trade
* (see deterministicTurn), so the trade path is covered by unit tests only.
*/
trades?: Array<{ a: string; b: string }>;
/**
* (v0.35.0, #58) The currently active unilateral embargoes
* (`{ from, target }` nation-id pairs), emitted by the engine's user
* prompt. The stub ignores this field — it never imposes, lifts or
* suspends an embargo (see deterministicTurn), so the embargo path is
* covered by unit tests only.
*/
embargoes?: Array<{ from: string; target: string }>;
}
function truncate(s: string, n: number): string {
@@ -77,6 +106,23 @@ function truncate(s: string, n: number): string {
* always returns `regionChanges: []`) — the per-region lever is covered by
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
* untouched.
*
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
* (it always returns `takeovers: []`) — the regime-rename path is covered by
* unit tests only, keeping the e2e assertions untouched.
*
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
* tests only, keeping the e2e assertions untouched.
*
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
* tests only, keeping the e2e assertions untouched.
*
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
* the trade & embargo economy path is covered by unit tests only, keeping
* the e2e assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -143,6 +189,10 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
takeovers: [], // the stub never performs a takeover (see doc comment)
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
+122
View File
@@ -0,0 +1,122 @@
// Deterministic SCENARIO-generation stub (v0.31, #52).
//
// Distinct from the turn stub (server/stub.ts, which stays untouched): this one
// answers the two scenario-generation LLM calls (see server/scenario.ts) so the
// whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and
// the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it
// plugs in as a Transport, and it discriminates the two calls by the markers
// scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the
// draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how
// the turn stub discriminates the advisor call via ADVISOR_MARKER.
//
// The stub returns a fixed 3-nation world and a fixed checkpoint list for the
// generate step, and — for the finalize step — the same world refined in a
// simple, deterministic way that depends on the approved checkpoints, so the
// "finalize respects approved checkpoints" behavior is observable end to end.
import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm';
import { SCENARIO_REGENERATE_MARKER } from './scenario';
import type { WorldDraft } from '../shared/scenario';
/** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */
const WORLD: WorldDraft = {
nations: [
{
id: 'alba',
name: 'Alba',
type: 'major',
stats: { military: 70, economy: 60, stability: 65, population: 55 },
territory: ['FRA'],
relations: { boreas: -60, gamma: 20 },
},
{
id: 'boreas',
name: 'Boreas',
type: 'major',
stats: { military: 55, economy: 70, stability: 50, population: 60 },
territory: ['RUS'],
relations: { alba: -60, gamma: 10 },
},
{
id: 'gamma',
name: 'Gamma',
type: 'minor',
stats: { military: 30, economy: 45, stability: 70, population: 40 },
territory: ['ESP'],
relations: { alba: 20, boreas: 10 },
},
],
events: [
{
nation: 'alba',
type: 'political',
title: 'The new order takes shape',
text: 'The three powers settle their borders after the great realignment.',
outcome: 'neutral',
},
],
};
/** The advisor's opening checkpoint list (fixed, in order). */
const CHECKPOINTS: string[] = [
'Alba dominates the western sea lanes and presses its advantage.',
'Boreas fortifies its northern frontier against a long winter.',
'Gamma brokers a neutral corridor between the two rivals.',
];
/**
* Deterministically refine the fixed world to "respect" the approved
* checkpoints: each approved checkpoint that names one of the fixed nations (by
* the first 4 letters of the nation's name, case-insensitive) raises that
* nation's stability by 5 (clamped at 100). Same input → same output, and the
* result visibly depends on which checkpoints were approved.
*/
export function stubWorldFor(approved: string[]): WorldDraft {
const nations = WORLD.nations.map((n) => ({
...n,
stats: { ...n.stats },
relations: { ...n.relations },
}));
const lower = approved.map((c) => c.toLowerCase());
for (const n of nations) {
const key = n.name.toLowerCase().slice(0, 4);
if (lower.some((c) => c.includes(key))) {
n.stats.stability = Math.min(100, n.stats.stability + 5);
}
}
return { nations, events: WORLD.events };
}
export function makeScenarioStubTransport(): Transport {
return async (_url, init) => {
let content: string;
try {
const req = JSON.parse(String(init?.body ?? '{}')) as { messages?: ChatMessage[] };
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
const user = req.messages?.find((m) => m.role === 'user');
if (sys.includes(SCENARIO_REGENERATE_MARKER)) {
// Finalize step: read the approved checkpoints out of the user payload
// and refine the fixed world accordingly (no checkpoints field echoed).
const payload = JSON.parse(user?.content ?? '{}') as {
approvedCheckpoints?: string[];
};
content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) });
} else {
// Generate step (and any unrecognized prompt): the fixed world + list.
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
}
} catch {
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
}
return {
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content } }] }),
text: async () => content,
} as unknown as Response;
};
}
/** A ready-made LlmClient backed by the deterministic scenario transport. */
export function makeScenarioStubLlmClient(): LlmClient {
return createLlmClient({ transport: makeScenarioStubTransport() });
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createTemplateStore } from './templates';
// The project root is the parent of server/ (mirrors how server/index.ts and
// server/regions.test.ts locate it via import.meta.url).
const root = fileURLToPath(new URL('..', import.meta.url));
const templatesDir = path.join(root, 'presets', 'templates');
const store = createTemplateStore(templatesDir);
/** A well-formed ISO_A3 country code: exactly three uppercase letters. */
function isIsoA3(code: string): boolean {
return /^[A-Z]{3}$/.test(code);
}
describe('era templates store (v0.31.0, #52)', () => {
it('the manifest lists exactly the three era templates', () => {
const meta = store.listMeta();
expect(meta).toHaveLength(3);
const ids = meta.map((m) => m.id);
expect(ids).toEqual(['ww2', 'present', 'near-future']);
for (const m of meta) {
expect(m.id).toBeTruthy();
expect(m.name).toBeTruthy();
expect(typeof m.year).toBe('number');
expect(m.eraDescription).toBeTruthy();
}
});
it('each template file loads and is well-formed', () => {
for (const m of store.listMeta()) {
const t = store.get(m.id);
expect(t, `template ${m.id} should load`).not.toBeNull();
const tpl = t as NonNullable<typeof t>;
expect(tpl.id).toBe(m.id);
expect(tpl.name).toBe(m.name);
expect(tpl.year).toBe(m.year);
expect(tpl.eraDescription).toBeTruthy();
// suggested powers: non-empty, each a named power with an ISO_A3 core region + note.
expect(tpl.suggestedPowers.length).toBeGreaterThan(0);
for (const p of tpl.suggestedPowers) {
expect(p.name).toBeTruthy();
expect(isIsoA3(p.region)).toBe(true);
expect(p.note).toBeTruthy();
}
// tensions: a non-empty list of non-empty strings.
expect(tpl.tensions.length).toBeGreaterThan(0);
for (const s of tpl.tensions) expect(typeof s).toBe('string');
for (const s of tpl.tensions) expect(s.trim()).not.toBe('');
}
});
it('get returns null for an unknown id', () => {
expect(store.get('does-not-exist')).toBeNull();
expect(store.get('')).toBeNull();
});
});
+28
View File
@@ -0,0 +1,28 @@
// Load era templates for the "Create scenario" path from disk (v0.31.0, #52).
// Mirrors the preset store (server/presets.ts): each template is a JSON file
// (presets/templates/<id>.json) and index.json is the lightweight manifest used
// to render the picker cards. The store is created with the resolved templates
// directory so it works from repo root (dev) and /app (Docker) alike.
import { readFileSync } from 'node:fs';
import type { EraTemplate, TemplateMeta } from '../shared/scenario';
export interface TemplateStore {
listMeta(): TemplateMeta[];
get(id: string): EraTemplate | null;
}
export function createTemplateStore(dir: string): TemplateStore {
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[];
return {
listMeta() {
return manifest;
},
get(id: string) {
try {
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate;
} catch {
return null;
}
},
};
}
+239
View File
@@ -51,6 +51,50 @@ export interface TerritoryChange {
lose: string[];
}
// --- Wars (v0.33.0, #56): first-class war entities --------------------------
//
// A war is an explicit world entity (see types.War), not just a boolean
// relation: the LLM DECLARES one (it becomes an active War with an engine-
// assigned stable id) and later ENDS one by id. The engine derives the
// symmetric `war` relation from the set of ACTIVE wars on every turn (two
// nations are in a `war` relation iff they are both belligerents of at least
// one active war) and downgrades it to `hostile` when the war ends — so the
// relation can never drift apart from the war state.
/**
* (v0.33.0, #56) Declare a new war this turn. `sides` names every
* belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`,
* so a unique name also works); any two distinct sides end the turn in a
* `war` relation. `aims` are optional short war aims for the narrative. The
* engine assigns the stable id, the declared turn/date, drags in the
* mutual-defense allies of each belligerent, and drops the op when fewer than
* two sides resolve (never throws).
*/
export interface WarDeclareOp {
/** The belligerent nations (≥2 distinct ids or unique names). */
sides: string[];
/** Optional short war aims (narrative only). */
aims?: string[];
}
/**
* (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id
* (the model sees the current active wars in the user payload's `wars`
* field). `outcome` is `armistice` (a negotiated stop) or `capitulation`
* (one side surrenders); the war keeps its id/sides and stays in
* `GameState.wars` as history with that final status. `terms` are optional
* short peace terms (narrative only — the model narrates the details in
* `events`). An unknown id, or a war that is already ended, is a no-op
* (never throws).
*/
export interface WarEndOp {
/** The stable id of the active war to end (from the user payload). */
id: string;
outcome: 'armistice' | 'capitulation';
/** Optional short peace terms (narrative only). */
terms?: string;
}
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
// proposes, player confirms" model) ------------------------------------------
//
@@ -68,6 +112,25 @@ export interface TerritoryChange {
* engine's `resolveNationRef`).
*/
/**
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
* (a player-confirmed diplomatic/political name change), a takeover is
* **automatic and in-world**: `applyTurnResult` applies it directly (no
* pending queue, no player confirm). Only `Nation.name` changes — the stable
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
* (which references the id) all stay intact, so the player keeps leading the
* nation under its new regime. `nationRef` is resolved exactly like the
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
* match — see `resolveNationRef`).
*/
export interface Takeover {
/** The target nation's id or current name (resolved by `resolveNationRef`). */
nationRef: string;
/** The name of the new regime to set as the nation's display name. */
newRegimeName: string;
}
/**
* A **rename** proposal: change a nation's human-readable `name`. Only the
* `name` changes — the stable `id`, its territory keys, its relations (keyed
@@ -170,6 +233,19 @@ export interface MarkerInfo {
owner: string | null;
}
/**
* (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model
* (the `wars` field): its stable `id` (the model echoes it in
* `TurnResult.wars.end` to end it), its belligerent `sides`, and optional
* short `aims`. Ended wars are NOT included — they stay in `GameState.wars`
* as history, but the model only needs to address the active ones.
*/
export interface WarInfo {
id: string;
sides: string[];
aims?: string[];
}
/**
* The per-type presentation of a marker: its display label (tooltip /
* dossier), its color, and its glyph character (a symbol-layer text label —
@@ -229,6 +305,117 @@ export function isMarkerType(t: unknown): t is MarkerType {
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
}
// --- Hybrid war (v0.34.0, #57): non-kinetic operations ---------------------
//
// The LLM can run covert, non-kinetic operations each turn: spies, sabotage,
// assassination attempts, cyberattacks, economic pressure, disinformation, and
// proxy support. Unlike kinetic conquest (territory), these never move borders
// — they dip a target's stats or sour a relation. The LLM declares an intended
// outcome (`success` or `exposed`); the engine is fully deterministic (no
// hidden randomness) and acts only on that declaration: a successful op applies
// its small, per-type effect, and an "exposed" op backfires (no effect, the
// actor's relation with the target sours by one step, and an auto
// diplomatic/failure event is emitted).
/** The seven covert operation types a hybrid op can be (v0.34.0, #57). */
export type HybridOpType =
| 'spy'
| 'sabotage'
| 'assassinate'
| 'cyber'
| 'economic'
| 'information'
| 'proxy';
/** All hybrid op types, in canonical order (the LLM contract + validator). */
export const HYBRID_OP_TYPES: HybridOpType[] = [
'spy',
'sabotage',
'assassinate',
'cyber',
'economic',
'information',
'proxy',
];
/**
* (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn
* (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique
* name — resolved by the engine's `resolveNationRef`). `target` is EITHER a
* nation (id or unique name) or a specific region key; a region target hits
* that region's sole owner (a region with zero or 2+ owners can't be
* targeted). `detail` is a short narrative (the LLM may also narrate the op in
* `events`). `outcome` is the LLM's *intended* result: `success` (the default)
* applies the op's deterministic effect, while `exposed` backfires it — the
* effect is NOT applied, the actor's relation with the target sours by one
* step, and an auto diplomatic/failure event is emitted.
*/
export interface HybridOp {
/** The operation type (see {@link HybridOpType}). */
type: HybridOpType;
/** The acting nation's id or current name (resolved by `resolveNationRef`). */
from: string;
/** The target: a nation's id/name, or a region key (hits that region's owner). */
target: string;
/** Short narrative detail (optional). */
detail?: string;
/** The LLM's intended result; `exposed` backfires the op. Defaults to success. */
outcome?: 'success' | 'exposed';
}
// --- Trade & embargo ops (v0.35.0, #58) --------------------------------------
/**
* (v0.35.0, #58) A bilateral TRADE op in `TurnResult.economy.trade`: `a` and
* `b` are the two parties (each a nation's id or unique name — resolved by
* the engine's `resolveNationRef`; a pair that resolves to the same nation,
* or contains an unknown/ambiguous ref, is dropped defensively). Without
* `lift` the op DECLARES the agreement (idempotent — an already-active
* agreement is not duplicated); `lift: true` ENDS it.
*/
export interface EconomyTradeOp {
/** First party's id or unique name. */
a: string;
/** Second party's id or unique name (distinct from `a` after resolution). */
b: string;
/** `true` → end this bilateral trade; default `false` = declare it. */
lift?: boolean;
}
/**
* (v0.35.0, #58) A unilateral EMBARGO op in `TurnResult.economy.embargo`:
* `from` blocks trade with `target` (each a nation's id or unique name —
* resolved by `resolveNationRef`; an unknown/ambiguous ref or
* `from === target` is dropped defensively). Without `lift` the op imposes
* the embargo (idempotent — an already-active one is not duplicated);
* `lift: true` LIFTS it.
*/
export interface EconomyEmbargoOp {
/** The imposing nation's id or unique name. */
from: string;
/** The embargoed nation's id or unique name (distinct from `from`). */
target: string;
/** `true` → lift this embargo; default `false` = impose it. */
lift?: boolean;
}
/**
* (v0.35.0, #58) The turn's trade-and-embargo operations, nested under
* `TurnResult.economy`. Both arrays are required and default to `[]`. The
* engine applies the ops to `GameState.trades` / `GameState.embargoes`
* (add = declare/impose, remove = lift) and, on every committed turn, applies
* the per-turn economic effect of the FULL active lists (trade: both
* parties' economy up a little; embargo: the target's economy down a
* little). Active trades/embargoes between nations on opposite sides of an
* active war (v0.33, #56) are suspended (removed) automatically.
*/
export interface EconomyOps {
/** Bilateral trades (declare or lift). */
trade: EconomyTradeOp[];
/** Unilateral embargoes (impose or lift). */
embargo: EconomyEmbargoOp[];
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -267,6 +454,58 @@ export interface TurnResult {
* survivor. Empty on turns where the model proposes none.
*/
nationOps: NationOp[];
/**
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
* effect **this turn** — each renames the target nation to `newRegimeName`.
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
* and in-world**: it is applied directly by `applyTurnResult` (like territory
* / state changes), not queued to `pendingNationOps`. Only the nation's
* `name` changes; the id, territory, relations, stats, and `playerNationId`
* are all preserved. Empty on turns where no takeover occurs. Required
* (default `[]`).
*/
takeovers: Takeover[];
/**
* (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars
* (≥2 distinct belligerents; the engine assigns the stable id / declared
* turn / date, drags in the mutual-defense allies of each belligerent, and
* derives the `war` relation between every pair of sides); `end` closes an
* existing ACTIVE war by id (`armistice` = a negotiated stop,
* `capitulation` = one side surrenders), which downgrades the relation the
* war had derived back to `hostile`. Both sub-fields are required and
* default `[]`; a turn with war activity is meaningful (it survives the
* retry/nudge path). The model sees the current active wars (id + sides +
* aims) in the user payload's `wars` field, so it can `end` by id.
*/
wars: { declare: WarDeclareOp[]; end: WarEndOp[] };
/**
* (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies,
* sabotage, assassination attempts, cyberattacks, economic pressure,
* disinformation, and proxy support. Unlike `nationOps` (player-confirmed
* proposals) these are applied directly by the turn, like territory / state
* changes: each op's deterministic per-type effect is applied by
* `applyTurnResult` (a small stat dip on the target's regions, or a
* relation nudge toward hostile for `information` / `proxy`); an `exposed`
* op backfires (no effect, the actor's relation with the target sours by
* one step, and an auto diplomatic/failure event is emitted). They never
* move borders — sole ownership is untouched. Required (default
* `{ ops: [] }`).
*/
hybrid: { ops: HybridOp[] };
/**
* (v0.35.0, #58) Trade and embargo ops — the economy layer. `trade`
* declares (or, with `lift: true`, ends) bilateral trade agreements
* between two nations; `embargo` imposes (or, with `lift: true`, lifts)
* a unilateral embargo on a target. Both arrays are required and default
* to `[]`. Applied directly by `applyTurnResult` (add to / remove from
* `GameState.trades` / `GameState.embargoes`), and the per-turn economic
* effect of the FULL active lists is applied on every committed turn (a
* trade lifts both parties' economy a little; an embargo lowers the
* target's economy a little — clamped, deterministic). Active
* trades/embargoes between nations on opposite sides of an active war are
* suspended automatically.
*/
economy: EconomyOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
+464
View File
@@ -0,0 +1,464 @@
// 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<string, number>;
}
/** 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<string, unknown>)
: {};
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<index>`);
* 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<string> } = {},
): WorldDraft | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const rawNations = Array.isArray(obj.nations) ? obj.nations : null;
if (!rawNations || rawNations.length === 0) return null;
const validCodes = opts.validCountryCodes;
const usedIds = new Set<string>();
const nameToId = new Map<string, string>();
interface Tmp {
id: string;
name: string;
type: NationType;
stats: NationStats;
territory: string[];
rawRelations: Record<string, number>;
}
const tmp: Tmp[] = [];
for (const rn of rawNations) {
if (!rn || typeof rn !== 'object') continue;
const o = rn as Record<string, unknown>;
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<index>.
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<string, number> = {};
if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) {
for (const [key, v] of Object.entries(o.relations as Record<string, unknown>)) {
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<string, number> = {};
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<string, unknown>;
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/<id>`). 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<string, Nation> = {};
for (const wn of draft.nations) {
const relations: Record<string, RelationStatus> = {};
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;
}
+105
View File
@@ -143,6 +143,75 @@ export interface MapMarker {
placedTurn: number;
}
/**
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
* op) and ended by a `lift: true` op; while active it yields a small,
* deterministic economy gain to BOTH parties every committed turn. The
* engine suspends (removes) the trade automatically when the two parties end
* up on opposite sides of an active war (v0.33, #56).
*/
export interface TradeAgreement {
/** First party's nation id. */
a: string;
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
b: string;
}
/**
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
* `target` (which must differ from `from`). Declared by the LLM via
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
* op; while active it applies a small, deterministic economy dip to the
* TARGET only, every committed turn. The engine suspends (removes) the
* embargo automatically when the two nations end up on opposite sides of an
* active war (v0.33, #56).
*/
export interface Embargo {
/** The imposing nation's id. */
from: string;
/** The embargoed nation's id (distinct from `from`). */
target: string;
}
/**
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
* between two or more nations. Unlike the boolean `war` relation (which a
* preset or a bare `relations` entry can carry without any structure behind
* it), a `War` is an explicit world entity the engine tracks: the LLM
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
* `GameState.wars` for the rest of the game as history after it ends.
*
* **Semantics of `sides`:** the flat list of every belligerent in the war
* (≥2 distinct nation ids). Any two distinct members of an active war's
* sides are at war with each other — the engine derives the symmetric `war`
* relation for every such pair on every turn (see the engine's
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
* ends. A mutual-defense ally of a belligerent is dragged into the same list
* on declaration (the engine does this automatically).
*/
export interface War {
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
id: string;
/** The belligerent nations (≥2 distinct ids; see the note above). */
sides: string[];
/** The turn number the war was declared on. */
declaredTurn: number;
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
declaredDate?: string;
/** Optional short war aims the LLM names at declaration. */
aims?: string[];
/**
* Lifecycle: `active` until a turn ends the war, then the end outcome —
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
*/
status: 'active' | 'armistice' | 'capitulation' | 'ended';
/** Optional region keys the war's front line runs through (reserved). */
front?: string[];
}
/** A narrative event the simulation emitted on some turn. */
export interface EventEntry {
id: string;
@@ -251,6 +320,42 @@ export interface GameState {
* — additive, no data wipe.
*/
regionStats: Record<string, RegionStats>;
/**
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
* wars on every turn (two nations are in a `war` relation iff they are both
* belligerents of ≥1 active war), so the boolean relation and the war
* entities can never drift apart. A bare preset `war` relation with no
* active war behind it stays as-is (an implicit, unnamed war — never
* force-created into an entity). Always `[]` on a fresh game; rows saved
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
* additive, no data wipe.
*/
wars: War[];
/**
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
* `lift: true` ends). Every committed turn the engine applies a small,
* deterministic economy gain to BOTH parties of each active agreement, and
* suspends (removes) any agreement whose two parties are on opposite sides
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
* additive, no data wipe.
*/
trades: TradeAgreement[];
/**
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
* `lift: true` lifts). Every committed turn the engine applies a small,
* deterministic economy dip to each embargo's TARGET only, and suspends
* (removes) any embargo whose two nations are on opposite sides of an
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
* no data wipe.
*/
embargoes: Embargo[];
}
/**
+128 -2
View File
@@ -16,6 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect';
import ScenarioForm from './components/ScenarioForm';
import CheckpointReview from './components/CheckpointReview';
import WorldMap from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend';
@@ -26,9 +28,27 @@ import { api } from './lib/api';
import { useSse } from './lib/sse';
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
import type { GameState, Preset, PresetMeta } from '@shared/types';
import type { TemplateMeta, WorldDraft } from '@shared/scenario';
/** Which top-level screen the app is showing. */
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
/**
* Which top-level screen the app is showing. The dynamic path (v0.31, #52) adds
* two: 'scenario-form' (era + description + year) and 'scenario-review' (the
* generated world + checkpoint checklist + nation pick), the latter carrying
* the step-1 draft + chosen template so the finalize step can re-send them.
*/
type Screen =
| { name: 'home' }
| { name: 'pick'; preset: Preset }
| { name: 'game' }
| { name: 'scenario-form' }
| {
name: 'scenario-review';
template: TemplateMeta;
description: string;
year: number;
world: WorldDraft;
checkpoints: string[];
};
/**
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
@@ -181,6 +201,10 @@ export default function App() {
// non-LLM) request. It gates only the nation-op dialog controls, never the
// turn flow — "Jump forward" stays usable while it is in flight.
const [opBusy, setOpBusy] = useState(false);
// (v0.31, #52) A scenario-generation LLM call (generate or finalize) is
// in flight. Distinct from `busy`/`advisorBusy` — it only gates the
// scenario-form/review screens (those screens never coexist with a game).
const [scenarioBusy, setScenarioBusy] = useState(false);
// The id of the game the user is/was last in (mirrors localStorage) and the
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
@@ -359,6 +383,77 @@ export default function App() {
[screen],
);
// --- (v0.31, #52) Dynamic scenario generation: form → generate → review →
// finalize → game. Both LLM steps run on the server (the scenario stub under
// LLM_STUB=1, the live model otherwise); on failure we toast and stay on the
// current screen (the form's / review's local selections are preserved).
// Home screen's "Create scenario" card → the scenario form.
const openScenarioForm = useCallback(() => {
setScreen({ name: 'scenario-form' });
}, []);
// Step 1 (generate): design the draft world + checkpoint list. On success
// the app moves to the review screen (carrying the draft, template, year,
// and description so the finalize step can re-send them).
const generateScenarioDraft = useCallback(
async (template: TemplateMeta, description: string, year: number) => {
setScenarioBusy(true);
try {
const { world, checkpoints } = await api.draftScenario({
templateId: template.id,
description,
year,
});
setScreen({ name: 'scenario-review', template, description, year, world, checkpoints });
} catch (e) {
toastError(e);
} finally {
setScenarioBusy(false);
}
},
[],
);
// Step 2 (finalize): commit the approved draft into a saved, playable game
// and enter it. The step-1 draft is re-sent so the final world stays close
// to it (and the chosen nation survives); the new game's id is then fetched
// and adopted exactly like a normal created game (active-game persistence,
// applyState, screen → game — the URL-sync effect writes the deep link).
const finalizeScenario = useCallback(
async (
template: TemplateMeta,
description: string,
year: number,
world: WorldDraft,
approvedCheckpoints: string[],
playerNationId: string,
) => {
setScenarioBusy(true);
try {
const { id } = await api.commitScenario({
templateId: template.id,
description,
year,
draft: world,
approvedCheckpoints,
playerNationId,
});
const { game } = await api.getGame(id);
persistActiveGameId(id);
setActiveGameId(id);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
toastError(e);
} finally {
setScenarioBusy(false);
}
},
[applyState],
);
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
// list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
@@ -573,6 +668,36 @@ export default function App() {
);
}
if (screen.name === 'scenario-form') {
return (
<>
<Toaster richColors position="top-right" />
<ScenarioForm busy={scenarioBusy} onBack={goHome} onGenerate={generateScenarioDraft} />
</>
);
}
if (screen.name === 'scenario-review') {
const { template, description, year, world, checkpoints } = screen;
return (
<>
<Toaster richColors position="top-right" />
<CheckpointReview
template={template}
description={description}
year={year}
world={world}
checkpoints={checkpoints}
busy={scenarioBusy}
onBack={openScenarioForm}
onFinalize={(approved, nationId) =>
void finalizeScenario(template, description, year, world, approved, nationId)
}
/>
</>
);
}
return (
<>
<Toaster richColors position="top-right" />
@@ -584,6 +709,7 @@ export default function App() {
activeGameId={activeGameId}
onResume={resumeGame}
onDelete={runDeleteGame}
onCreateScenario={openScenarioForm}
/>
</>
);
+278
View File
@@ -0,0 +1,278 @@
import { useState } from 'react';
import { Check, ListChecks, Loader2, Sparkles } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import StatBar from './StatBar';
import { cn } from '@/lib/utils';
import { EVENT_COLORS, RELATION_COLORS } from '@/lib/colors';
import { relationFromScore, type TemplateMeta, type WorldDraft, type WorldNation } from '@shared/scenario';
interface Props {
/** The era template the draft was generated from (name/year for the header). */
template: TemplateMeta;
/** The free-text description the user wrote (shown as the world's context). */
description: string;
/** The effective start year (template default or the user's override). */
year: number;
/** The step-1 draft world to review and finalize. */
world: WorldDraft;
/** The advisor's proposed checkpoints (all approved by default). */
checkpoints: string[];
/** A finalize (LLM) call is in flight — disables the controls. */
busy?: boolean;
/** Back to the scenario form (a fresh attempt — the form's state resets). */
onBack: () => void;
/** Step 2: finalize with the approved checkpoints + the chosen nation to lead. */
onFinalize: (approvedCheckpoints: string[], playerNationId: string) => void;
}
const STAT_BARS: Array<{ key: keyof WorldNation['stats']; label: string }> = [
{ key: 'military', label: 'Military' },
{ key: 'economy', label: 'Economy' },
{ key: 'stability', label: 'Stability' },
{ key: 'population', label: 'Population' },
];
/**
* "Create scenario" — step 2 of the dynamic path (v0.31, #52). Shows the
* generated world (each nation's stats, territory country codes, and
* relations; plus any backstory events) and the advisor's proposed
* checkpoints as an approve/remove checklist (approved by default — the
* approved ones bind the finalize step). The player then picks which nation
* to lead (the nation cards mirror NationSelect's pick screen; the draft's
* nations are not a static Preset, so they are rendered here directly) and
* finalizes. On success the app navigates into the new game; on failure it
* toasts and stays here with the current selections intact.
*/
export default function CheckpointReview({
template,
description,
year,
world,
checkpoints,
busy = false,
onBack,
onFinalize,
}: Props) {
// Approve/remove per checkpoint (approved by default — the advisor's list is
// a starting point the player prunes, not a form they fill in).
const [approved, setApproved] = useState<boolean[]>(() => checkpoints.map(() => true));
// The nation the player will lead (none until explicitly picked).
const [selectedId, setSelectedId] = useState<string | null>(null);
const nameOf = (id: string) => world.nations.find((n) => n.id === id)?.name ?? id;
const approvedCount = approved.filter(Boolean).length;
const selected = selectedId ? world.nations.find((n) => n.id === selectedId) : undefined;
const toggle = (i: number) =>
setApproved((prev) => prev.map((v, j) => (j === i ? !v : v)));
const finalize = () => {
if (!selectedId) return;
onFinalize(
checkpoints.filter((_, i) => approved[i]),
selectedId,
);
};
return (
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-review">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
← Back to form
</Button>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{template.name}</h1>
<Badge variant="secondary" className="text-sm">
{year}
</Badge>
</div>
{description && (
<p className="mt-2 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground">
{description}
</p>
)}
{/* --- The generated world --- */}
<h2 className="mb-3 mt-8 text-lg font-semibold">The generated world</h2>
<div className="space-y-3">
{world.nations.map((n) => (
<div
key={n.id}
className="rounded-lg border border-border/60 bg-card/40 p-3"
data-testid="draft-nation"
data-nation-id={n.id}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{n.name}</span>
<Badge variant="secondary" className="text-xs capitalize">
{n.type ?? 'major'}
</Badge>
{n.territory.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{n.territory.join(' · ')}
</span>
)}
</div>
<div className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2">
{STAT_BARS.map(({ key, label }) => (
<StatBar key={key} label={label} value={n.stats[key]} />
))}
</div>
{Object.keys(n.relations).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{Object.entries(n.relations).map(([otherId, score]) => {
const status = relationFromScore(score);
return (
<span
key={otherId}
className="rounded-full border px-2 py-0.5 text-[11px] font-medium"
style={{
color: RELATION_COLORS[status],
borderColor: `${RELATION_COLORS[status]}55`,
}}
>
{nameOf(otherId)} {score > 0 ? `+${score}` : score}
</span>
);
})}
</div>
)}
</div>
))}
{(world.events ?? []).length > 0 && (
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Backstory
</div>
<ul className="space-y-2">
{(world.events ?? []).map((e, i) => (
<li key={i} className="flex items-start gap-2 text-sm" data-testid="draft-event">
<span
className="mt-1.5 inline-block size-2 shrink-0 rounded-full"
style={{ background: EVENT_COLORS[e.type] }}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="font-medium">{nameOf(e.nation)}:</span>{' '}
{e.title} — {e.text}
</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* --- Advisor checkpoints (approve / remove) --- */}
<div className="mt-8 flex items-center gap-2">
<ListChecks className="size-4 text-primary" />
<h2 className="text-lg font-semibold">Advisor checkpoints</h2>
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
{approvedCount} of {checkpoints.length} approved
</Badge>
</div>
<p className="mb-3 mt-1 text-sm leading-relaxed text-muted-foreground">
The advisor’s opening read of this world’s live strategic threads. Keep the ones the final
world should respect — removed checkpoints are dropped.
</p>
<ul className="space-y-1.5">
{checkpoints.map((c, i) => (
<li
key={`${i}-${c}`}
className={cn(
'flex items-center gap-2 rounded-lg border p-2.5 text-sm',
approved[i]
? 'border-amber-500/30 bg-amber-500/10'
: 'border-border/60 bg-card/30 text-muted-foreground',
)}
data-testid="checkpoint-item"
data-approved={approved[i] ? 'true' : 'false'}
>
<span
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-full border',
approved[i] ? 'border-amber-500/50 bg-amber-500/20' : 'border-border',
)}
aria-hidden
>
{approved[i] && <Check className="size-3 text-amber-400" />}
</span>
<span className="min-w-0 flex-1 leading-snug">{c}</span>
<Button
variant={approved[i] ? 'ghost' : 'secondary'}
size="sm"
onClick={() => toggle(i)}
disabled={busy}
data-testid="checkpoint-toggle"
>
{approved[i] ? 'Remove' : 'Keep'}
</Button>
</li>
))}
</ul>
{/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */}
<h2 className="mb-3 mt-8 text-lg font-semibold">Choose your nation</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{world.nations.map((n) => {
const isSel = n.id === selectedId;
return (
<Card
key={n.id}
className={cn(
'flex flex-col transition',
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
)}
data-testid="scenario-nation-card"
data-nation-id={n.id}
data-selected={isSel ? 'true' : 'false'}
>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{n.name}
{isSel && <Check className="size-4 shrink-0 text-primary" />}
</CardTitle>
<CardDescription>
{n.type ?? 'major'} · {n.territory.length} territory unit
{n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')})
</CardDescription>
</CardHeader>
<CardContent className="mt-auto space-y-1.5">
{STAT_BARS.map(({ key, label }) => (
<StatBar key={key} label={label} value={n.stats[key]} />
))}
<Button
className="mt-3 w-full"
variant={isSel ? 'default' : 'secondary'}
onClick={() => setSelectedId(n.id)}
disabled={busy}
data-testid="scenario-lead-nation"
>
{isSel ? 'Leading' : `Play as ${n.name}`}
</Button>
</CardContent>
</Card>
);
})}
</div>
{/* --- Finalize --- */}
<div
className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3"
data-testid="finalize-bar"
>
<span className="min-w-0 flex-1 text-sm text-muted-foreground">
{selected
? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.`
: 'Pick the nation you will lead, then finalize the scenario.'}
</span>
<Button className="shrink-0" onClick={finalize} disabled={busy || !selected} data-testid="finalize-scenario">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{busy ? 'Finalizing…' : 'Finalize scenario'}
</Button>
</div>
</div>
);
}
+53 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Play, Trash2 } from 'lucide-react';
import { Play, Sparkles, Trash2 } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
@@ -26,6 +26,12 @@ interface Props {
onResume?: (id: string) => void;
/** Delete a saved game by id (confirmed by a dialog in this component). */
onDelete?: (id: string) => void;
/**
* (v0.31, #52) Open the "Create scenario" path (era template + description →
* AI-generated world → checkpoint review → a normal game). Omitted = the
* create-scenario card is hidden.
*/
onCreateScenario?: () => void;
}
/**
@@ -41,6 +47,7 @@ export default function PresetPicker({
activeGameId,
onResume,
onDelete,
onCreateScenario,
}: Props) {
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
// The game awaiting a delete confirmation (null = dialog closed).
@@ -83,6 +90,51 @@ export default function PresetPicker({
)}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{(
onCreateScenario
? [
{
// (v0.31, #52) The dynamic path, first in the grid: it is the
// "any world you can describe" card, distinct from the static presets.
id: 'create',
title: 'Create scenario',
summary:
'Design your own world — pick an era, describe it, and the AI drafts the world and the advisor’s opening checkpoints for your review before you pick a nation to lead.',
badge: 'custom world',
},
]
: []
).map((c) => (
<Card
key={c.id}
className="flex flex-col border-primary/40 bg-primary/5 transition hover:ring-1 hover:ring-primary/50"
data-testid="create-scenario-card"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="size-4 shrink-0 text-primary" />
{c.title}
</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{c.badge}
</Badge>
</div>
<CardDescription className="text-sm leading-relaxed">{c.summary}</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<Button
variant="secondary"
className="w-full"
onClick={() => onCreateScenario?.()}
disabled={loading}
data-testid="create-scenario"
>
Create scenario
</Button>
</CardContent>
</Card>
))}
{presets.map((p) => (
<Card
key={p.id}
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { Loader2, Sparkles } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import type { TemplateMeta } from '@shared/scenario';
interface Props {
/** A generate (LLM) call is in flight — disables the form's controls. */
busy?: boolean;
onBack: () => void;
/**
* Step 1: design the draft world + checkpoint list. The form hands over the
* selected template's full metadata (the review screen shows its name/year)
* plus the description and the effective start year.
*/
onGenerate: (template: TemplateMeta, description: string, year: number) => void;
}
/** The year input is an empty string ("use the template's default") or a number. */
const YEAR_MIN = 1000;
const YEAR_MAX = 3000;
/**
* "Create scenario" — step 1 of the dynamic path (v0.31, #52). The user picks
* one of the light era templates (a card each: name, default year, blurb),
* writes a free-text description of the world they want, and may override the
* start year (the field is pre-filled from the selected template; leaving it
* empty keeps the template's default). "Generate world" posts the draft
* request; on success the app moves to the checkpoint review screen, on
* failure it toasts and stays here (the form's local state is preserved).
*/
export default function ScenarioForm({ busy = false, onBack, onGenerate }: Props) {
const [templates, setTemplates] = useState<TemplateMeta[]>([]);
const [templatesError, setTemplatesError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [description, setDescription] = useState('');
// Pre-filled from the selected template's default year (the user can edit).
const [yearText, setYearText] = useState('');
// Load the era-template manifest on mount (the form is the only consumer).
useEffect(() => {
let alive = true;
api
.listScenarioTemplates()
.then((r) => alive && setTemplates(r.templates))
.catch((e: unknown) => alive && setTemplatesError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, []);
const selected = templates.find((t) => t.id === selectedId) ?? null;
const effectiveYear =
yearText.trim() === '' ? NaN : Number.parseInt(yearText.trim(), 10);
const yearValid =
Number.isInteger(effectiveYear) && effectiveYear >= YEAR_MIN && effectiveYear <= YEAR_MAX;
const canGenerate = !busy && selected !== null && description.trim() !== '' && yearValid;
const selectTemplate = (t: TemplateMeta) => {
setSelectedId(t.id);
// Selecting an era pre-fills the year with that era's default (the user
// can still edit it afterwards).
setYearText(String(t.year));
};
const generate = () => {
if (!canGenerate || !selected) return;
onGenerate(selected, description.trim(), effectiveYear);
};
return (
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-form">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
← Back to scenarios
</Button>
<h1 className="text-3xl font-bold tracking-tight">Create a scenario</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
Design your own world: pick an era, describe what should be true, and the AI will draft the
world and an opening set of advisor checkpoints for your review. Nothing is saved until you
finalize.
</p>
{/* 1 — the era template (a card each). */}
<h2 className="mb-3 mt-8 text-lg font-semibold">
1 · Pick an era <span className="font-normal text-muted-foreground">(required)</span>
</h2>
{templatesError ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
Could not load the era templates: {templatesError}
</p>
) : templates.length === 0 ? (
<p className="rounded-lg border border-border/60 bg-card/40 p-3 text-sm text-muted-foreground">
Loading era templates…
</p>
) : (
<div className="grid gap-4 sm:grid-cols-3">
{templates.map((t) => {
const isSel = t.id === selectedId;
return (
<Card
key={t.id}
className={cn(
'flex cursor-pointer flex-col transition',
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
)}
data-testid="template-card"
data-template-id={t.id}
data-selected={isSel ? 'true' : 'false'}
onClick={() => selectTemplate(t)}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{t.name}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{t.year}
</Badge>
</div>
<CardDescription className="text-sm leading-relaxed">
{t.eraDescription}
</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<div
className={cn(
'flex items-center gap-2 text-xs',
isSel ? 'font-medium text-primary' : 'text-muted-foreground',
)}
>
<Sparkles className="size-3.5" />
{isSel ? 'Selected' : 'Start from this era'}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* 2 — the description (+ optional year override). */}
<h2 className="mb-3 mt-8 text-lg font-semibold">
2 · Describe your world <span className="font-normal text-muted-foreground">(required)</span>
</h2>
<div className="space-y-4 rounded-lg border border-border/60 bg-card/40 p-4">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. “A cold-war world where the space race decided the Cold War — two blocs orbit, and the arctic shipping lanes are the new flashpoint.”"
rows={4}
disabled={busy}
data-testid="scenario-description"
/>
<div className="flex items-center gap-3">
<Label htmlFor="scenario-year-input" className="shrink-0 text-sm">
Start year
</Label>
<Input
id="scenario-year-input"
type="number"
inputMode="numeric"
value={yearText}
onChange={(e) => setYearText(e.target.value)}
placeholder={selected ? `default ${selected.year}` : 'pick an era first'}
min={YEAR_MIN}
max={YEAR_MAX}
disabled={busy || !selected}
className="w-32"
data-testid="scenario-year"
/>
<span className="text-xs text-muted-foreground">
{yearValid ? `The game begins on ${effectiveYear}-01-01.` : 'Leave empty for the era default, or use a year between 1000 and 3000.'}
</span>
</div>
</div>
{/* Generate. */}
<div className="mt-8">
<Button className="h-11 w-full" onClick={generate} disabled={!canGenerate} data-testid="generate-scenario">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{busy ? 'Generating world…' : 'Generate world'}
</Button>
{busy && (
<p className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
The world-builder is thinking — this can take a few seconds.
</p>
)}
</div>
</div>
);
}
+27
View File
@@ -14,6 +14,13 @@ import type {
RemoveQueueItemResponse,
TurnResponse,
} from '@shared/protocol';
import type {
ScenarioCommitRequest,
ScenarioCommitResponse,
ScenarioDraftRequest,
ScenarioDraftResponse,
TemplateMeta,
} from '@shared/scenario';
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
@@ -95,4 +102,24 @@ export const api = {
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
`/api/games/${id}/map`,
),
// --- Dynamic scenario generation (v0.31, #52) ---------------------------
// The additive "Create scenario" path: a two-step LLM flow (design a draft
// world + checkpoint list, then finalize it into a saved, playable game).
// The era-template manifest for the form's cards (id / name / year / blurb).
listScenarioTemplates: () => req<{ templates: TemplateMeta[] }>('/api/scenarios/templates'),
// Step 1: design a draft world + an advisor checkpoint list from an era
// template + a free-text description. An LLM call — can be slow; a 502
// means the model is down or returned nothing usable (the caller toasts).
draftScenario: (body: ScenarioDraftRequest) =>
req<ScenarioDraftResponse>('/api/scenarios/draft', {
method: 'POST',
body: JSON.stringify(body),
}),
// Step 2: finalize the approved draft (constrained to the approved
// checkpoints) into a new, playable game and save it. Returns the id.
commitScenario: (body: ScenarioCommitRequest) =>
req<ScenarioCommitResponse>('/api/scenarios/commit', {
method: 'POST',
body: JSON.stringify(body),
}),
};