Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ea8ba5a4 | ||
|
|
ed91735c8a | ||
|
|
8eeb6df284 | ||
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b | ||
|
|
073e916767 | ||
|
|
396702b9da | ||
|
|
832957c635 | ||
|
|
68402694c5 | ||
|
|
2191514db1 | ||
|
|
5d194636d0 | ||
|
|
a93ece9474 | ||
|
|
44e54470e2 | ||
|
|
cdabf16052 | ||
|
|
7fbc00ebbe | ||
|
|
22805a3be6 | ||
|
|
327fffb789 |
@@ -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; 189 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)
|
||||
@@ -65,28 +65,35 @@ history/
|
||||
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
||||
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||
│ └── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
|
||||
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
|
||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||
│ │ # → build-region-adjacency → build-region-centroids
|
||||
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
||||
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
|
||||
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
|
||||
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
|
||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
|
||||
│ # seedRegionStats / migrateRegionStats)
|
||||
├── server/
|
||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||
│ │ # PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3; per-region stats maintenance + derived recompute, v0.28 #46) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
||||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency/centroids/geo-manifest (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency, centroidOf/hasCentroids, geoOf/hasGeo (#46)
|
||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||
│ ├── presets.ts # load preset JSON files from disk
|
||||
│ ├── db.ts # SQLite open/save/load
|
||||
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
|
||||
│ └── *.test.ts # unit tests (vitest)
|
||||
└── src/
|
||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||||
@@ -116,13 +123,15 @@ history/
|
||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||
| `npm test` | vitest unit tests (`server/` + `scripts/`; 189 tests) |
|
||||
| `npm test` | vitest unit tests (`server/` + `scripts/`; 225 tests) |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||
| `npm run lint` | ESLint flat config |
|
||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
|
||||
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
|
||||
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
|
||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||
|
||||
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
||||
@@ -286,6 +295,118 @@ history/
|
||||
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
||||
unaffected and the marker path is unit-tested only (`server/engine.test.ts`,
|
||||
`scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`).
|
||||
- **Per-region stats — `GameState.regionStats`, derived `Nation.stats` (v0.28, #46; PR 1 of 2 — the LLM lever is PR 2).**
|
||||
Since v0.28 the four 0–100 stats live at **region-key** granularity: `GameState.regionStats`
|
||||
(required; `{}` at game creation) maps each owned region key to its own `RegionStats`, and
|
||||
`Nation.stats` is **derived** — the **area×population-weighted median** of the stats of the
|
||||
regions the nation owns (`deriveNationStats`, pure in `shared/protocol.ts`). A region's weight
|
||||
is `(area>0?area:1)×(population>0?population:1)` from the committed `public/region-geo.json`
|
||||
manifest (1,800 entries; area in km² at 2dp, integer population) — which is built by
|
||||
`scripts/build-region-geo.mjs` from `regions.geo.json` + `region-keys.json` + the committed
|
||||
`public/ne-admin0-pop.json` (Natural Earth admin-0 `POP_EST`, 249 codes, one-time distilled by
|
||||
`scripts/extract-admin0-pop.mjs`; each country's `POP_EST` is spread over its sub-regions
|
||||
proportional to area by largest-remainder, so per-country totals are preserved exactly). A
|
||||
released / 0-owner / 2+-owner key has **no** entry. A fresh game is seeded by
|
||||
`seedRegionStats` (every owned region gets a **copy** of its owner's stats — identical values,
|
||||
so the derived median equals the original and fresh-game numbers are unchanged). Legacy SQLite
|
||||
rows are migrated once on load by `migrateRegionStats` (`db.load`, called without `geoOf` —
|
||||
weight-invariant for the identical-value seed): a row that already has `regionStats` (v0.28+)
|
||||
is returned **unchanged** (never recomputed on load → no drift), a legacy row is seeded +
|
||||
recomputed (0-region guard keeps its stats). `applyTurnResult` takes an optional `geoOf`
|
||||
(passed through `runTurn`; the jump route wires it via `hasGeo() ? (key) => geoOf(key) :
|
||||
undefined`) and maintains the table inside the **load-bearing order events → relations →
|
||||
territory → stateChanges → recompute → markers → nationOps**: a release deletes the key's
|
||||
entry; a (re-)claim re-inits an absent entry as a copy of the gainer's current `stats`;
|
||||
nation-level `stateChanges` (the only LLM lever for now — the prompt is **unchanged** in this
|
||||
PR) write to `n.stats` **and** to every region the nation owns; then every nation with ≥1
|
||||
entered region gets `stats` recomputed as its regions' weighted median (a 0-region — or no-
|
||||
entered-region — nation keeps its directly-maintained `stats`, which is why the existing unit
|
||||
and e2e assertions pass unchanged; without the geo manifest the recompute degrades to an
|
||||
unweighted median, never throwing). The stub never touches per-region stats (`LLM_STUB=1`
|
||||
e2e unaffected), and the path is unit-tested (`server/engine.test.ts`,
|
||||
`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
|
||||
|
||||
+139
@@ -2,6 +2,145 @@
|
||||
|
||||
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).
|
||||
- **Types (`shared/types.ts`).** `EventOutcome` gains `'repelled'` (engine-only; the LLM never produces it).
|
||||
- **Colors (`src/lib/colors.ts`).** `OUTCOME_COLORS.repelled = '#9f1239'` — a muted rose-800, distinct from `failure` (`#ef4444`) and `neutral` (`#94a3b8`).
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `applyTurnResult` gains an optional `regionNameOf` (5th optional param, after `geoOf`) and the **military hard gate** inside the snapshot-first claims loop: for each contested key (a gainer's `gain` whose current sole owner is a *different* nation), it compares the gainer's start-of-turn military to the owner's via `canConquer`; on a fail it keeps the key with the owner, leaves both nations' military untouched, and pushes one `repelled` military event (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
|
||||
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
|
||||
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
|
||||
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
|
||||
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
|
||||
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
|
||||
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
|
||||
|
||||
## v0.29.0 — 2026-10-15 01:05
|
||||
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
|
||||
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `resolveRegionKey(ref, validKeys, nameOf)` — pure resolver: exact key first; then a case-insensitive unique-name match; ambiguous / unknown / empty → `null`.
|
||||
- `validateRegionChanges(raw, regionKeyResolver?)` — validates and resolves each entry; drops unknown / ambiguous refs (never throws); clamps `set` values at validation; `add` values are clamped at apply time.
|
||||
- `parseTurnResult` / `validateTurnResult` / `runTurn` all accept an **optional** `regionKeyResolver` (2nd arg / opts) threaded to `validateRegionChanges`; the generic "unclear" fallback carries `regionChanges: []`.
|
||||
- `applyTurnResult` applies `regionChanges` after `stateChanges` and before the weighted-median recompute (the load-bearing order is: events → relations → territory → stateChanges → **regionChanges** → recompute → markers → nationOps).
|
||||
- `isMeaningful` — a turn with only `regionChanges` is meaningful (survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — documents the `regionChanges` JSON field and a constraint bullet (target one region, use keys from the "regions" table, "playerRegionStats" lists the player's current per-region stats).
|
||||
- `buildUserPrompt` — computes and emits `playerRegionStats` (the player's owned regions' current stats; bounded to the player's territory to keep the prompt small; `{}` when none).
|
||||
- **Server route (`server/index.ts`).** The `/jump` route wires the `regionKeyResolver` (always on; degrades to key-only matching when the adjacency manifest is absent).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `regionChanges: []` — the per-region lever is covered by unit tests only, keeping the e2e assertions untouched.
|
||||
- **Client (`src/components/WorldMap.tsx`).** The hover tooltip gains a `regionStats` prop and shows the region's own four stats (`Mil / Eco / Stab / Pop`); a region with no entry shows a `–` placeholder. The name and owner chip are kept; marker rendering and the click→dossier path are unchanged. `src/App.tsx` passes `game.regionStats ?? {}`.
|
||||
- **Unit tests (`server/engine.test.ts`).** New suites: `resolveRegionKey` (key match, case-insensitive name match, unknown / ambiguous → null, key-over-name priority); `validateRegionChanges` (key / name resolution, unknown / invalid stat / non-numeric / invalid op dropped, `set` clamped, `add` not clamped at validation, non-array → [], no-resolver path); `applyTurnResult` with `regionChanges` (set changes only that region, add clamps 0/100, unowned no-op, applied after stateChanges, non-mutation); prompt contract (system prompt documents the field + constraint, user prompt emits bounded `playerRegionStats`); `isMeaningful` with `regionChanges`; stub returns `regionChanges: []`.
|
||||
|
||||
## v0.28.0 — 2026-10-15 00:12
|
||||
- **#46 (PR 1 of 2): per-region stats — a nation's four 0–100 stats become a *derived* aggregate.** Instead of storing `Nation.stats` as four independent per-nation numbers, they are now **derived** as the **area×population-weighted median** of the stats of the regions the nation owns. Each sub-region carries its own `RegionStats` in a new `GameState.regionStats: Record<regionKey, RegionStats>` table; a nation's displayed stats are the weighted median over its owned regions. This is the data foundation for the per-region LLM levers that land in **PR 2** — in this PR the LLM still only emits nation-level `stateChanges`, which are re-interpreted as applying to *all* of a nation's owned regions. A new `public/region-geo.json` manifest (area km² + population per region key) supplies the weighting. **Not deployed in this PR** — the production site updates when PR 2 ships (the change is additive and backward-compatible, so deferring the deploy is safe).
|
||||
- **Data build (`scripts/`, turf-`.mjs` only; never imported by `.ts`).**
|
||||
- `scripts/extract-admin0-pop.mjs` (`npm run extract:admin0-pop`) — **one-time** distillation of the 13 MB Natural Earth 1:10m admin-0 GeoJSON (a *source* file we do **not** commit) into the small committed `public/ne-admin0-pop.json` = `{ ADM0_A3 → max POP_EST }` (249 country codes, total 7,677,059,722). The 1:10m **admin-1** file the region generator uses carries no population, so the source is the **admin-0** `POP_EST` (a deliberate deviation, recorded here).
|
||||
- `scripts/build-region-geo.mjs` (`npm run build:region-geo`) — builds `public/region-geo.json` = `{ regionKey → { area, population } }`: `area` = `@turf/area(finalFeature)/1e6` km² (2 dp); `population` = the parent country's `POP_EST` spread across its sub-regions **proportional to area via largest-remainder**, so every country's sub-regions sum **exactly** to its total (no rounding drift). Reads the committed `ne-admin0-pop.json` → fully reproducible in CI. **Deterministic + idempotent** (sorted keys; byte-identical re-run, pinned by the test). 1,800 entries; **total population 7,654,092,021** (the sum over the 177 repo countries).
|
||||
- **Types (`shared/types.ts`, no runtime deps).** New `RegionStats = { military, economy, stability, population }` (0–100). `GameState.regionStats: Record<string, RegionStats>` is **required** (`{}` at game creation; a released / 0-owner / 2+owner region has **no** entry; a legacy SQLite row without the field is migrated on load, never wiped).
|
||||
- **Pure helpers (`shared/protocol.ts`, no deps).**
|
||||
- `weightedMedian(values, weights)` — sorts by value and returns the value at the first point the cumulative weight reaches half the total; non-finite/non-positive weights are excluded; empty / zero-total → 0. **Translation-invariant** (the median of identical values is that value — the invariant seeding and migration rely on).
|
||||
- `deriveNationStats(regionKeys, regionStats, geoOf?)` — each stat is the weighted median over the nation's owned region keys; a region's weight is `(area>0?area:1)×(population>0?population:1)` (both fall back to 1 when the key has no geo entry, so missing geo degrades to a uniform median); a key with no `regionStats` entry is skipped; returns zero stats when no owned region has an entry (the caller's 0-region guard keeps `n.stats` in that case).
|
||||
- `seedRegionStats(nations)` — seeds the table from nations: every owned key gets a **copy** of its owner's stats (first owner wins on a corrupt double-ownership), so a fresh nation's regions start identical (derived = seeded).
|
||||
- `migrateRegionStats(state, geoOf?)` — **one-time** legacy migration: a row that already has `regionStats` is returned **unchanged** (no recompute on load → no drift); a legacy row (field absent) is seeded per region and each nation's stats recomputed (0-region guard). Never throws.
|
||||
- **Server (`server/regions.ts`, turf-free).** Loads `region-geo.json` (probes `public/` then `dist/`) **optionally** — a missing/broken file degrades weighting to uniform (every region weight 1) while everything else keeps working. New `hasGeo()` and `geoOf(key)` accessors mirror the existing `hasCentroids`/`centroidOf`.
|
||||
- **Server (`server/engine.ts`).** `applyTurnResult` gains an optional `geoOf` param and is reordered to: events → relations → **territory** → **stateChanges** → **recompute** → markers → nationOps.
|
||||
- **Territory:** a **transferred** region **keeps its own** per-region stats (the conquest is what makes a nation's regions diverge); a **released** region's entry is **deleted** (no man's land carries none); a **re-claimed** region with no entry is **re-inited** to the gainer's current stats.
|
||||
- **StateChanges (re-interpreted):** a nation-level `set`/`add` applies to `n.stats` **and** to every region the nation owns (clamped 0–100).
|
||||
- **Recompute:** every nation's `stats` is re-derived via `deriveNationStats` (weighted by area×population when `geoOf` is present). Two guards: a **0-region** nation keeps its directly-maintained stats, and a nation **none of whose regions has an entry** keeps its stats too (avoids clobbering with zeros).
|
||||
- `runTurn` threads the optional `geoOf` through to `applyTurnResult`.
|
||||
- **Server (`server/index.ts`).** Game creation seeds `regionStats` via `seedRegionStats` **after** `expandCountriesToKeys` (so regions hold real keys) — a new game's derived stats equal its nation-level stats on turn 1. The `jump` route passes `geoOf: hasGeo() ? geoOf : undefined`.
|
||||
- **Server (`server/db.ts`).** `load()` migrates a legacy row (regionStats absent) via `migrateRegionStats(state)` **without** `geoOf` (uniform weights — weight-invariant for the equal-value seeding, and avoids a `db`→`regions` import). v0.28+ rows load unchanged.
|
||||
- **Prompt / client / stub unchanged.** `nationForPrompt`/`buildUserPrompt` still surface the (now derived) four stats; there is no per-region LLM lever yet (PR 2). The deterministic stub is unaffected (nation-level `stateChanges` flow through the re-interpretation; the median of identical values is that value, so e2e stat assertions hold).
|
||||
- **Tests (189 → 225).** New `server/protocol.test.ts` (weightedMedian incl. translation-invariance / identical / zero / empty; deriveNationStats weighted vs uniform / skip-missing / zero / single / fallback-weight; seedRegionStats; migrateRegionStats v0.28+ no-op vs legacy / 0-region); `server/engine.test.ts` (transfer keeps region stats + aggregate blends, release deletes entry, re-claim re-inits, 0-region keeps stats, geoOf-weighted vs uniform recompute, nation-level change re-applies to regions, original not mutated); `server/db.test.ts` (legacy row → seeded + preserved stats; v0.28+ row loads unchanged); `server/regions.test.ts` (`hasGeo`/`geoOf` in dev + prod-`dist/` layouts, graceful degradation when absent); new `scripts/region-geo.test.ts` (1,800-entry key-set match, sorted, finite / ≤2dp area / integer population, total 7,654,092,021, per-country totals preserved vs admin-0, spot-checked values, byte-identical re-run).
|
||||
- **Verification.** Typecheck, lint, **225 unit tests**, and the production build all pass (the build copies `region-geo.json` + `ne-admin0-pop.json` into `dist/` like the other manifests, so the prod container layout is covered). e2e is verified by CI on the PR (no local run).
|
||||
- **Deploy:** **none** in this PR — the production site (`https://history.dev.kkhost.pl`) updates when **PR 2** ships. The change is additive + backward-compatible, so existing games migrate on load and no data wipe is needed.
|
||||
|
||||
## v0.27.0 — 2026-10-14 22:34
|
||||
- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer.
|
||||
- **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.27.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -19,6 +19,8 @@
|
||||
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||
"build:region-geo": "node scripts/build-region-geo.mjs",
|
||||
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"ABW": 106314,
|
||||
"AFG": 38041754,
|
||||
"AGO": 31825295,
|
||||
"AIA": 14731,
|
||||
"ALB": 2854191,
|
||||
"ALD": 29884,
|
||||
"AND": 77142,
|
||||
"ARE": 9770529,
|
||||
"ARG": 44938712,
|
||||
"ARM": 2957731,
|
||||
"ASM": 55312,
|
||||
"ATA": 4490,
|
||||
"ATF": 140,
|
||||
"ATG": 97118,
|
||||
"AUS": 25364307,
|
||||
"AUT": 8877067,
|
||||
"AZE": 10023318,
|
||||
"BDI": 11530580,
|
||||
"BEL": 11484055,
|
||||
"BEN": 11801151,
|
||||
"BFA": 20321378,
|
||||
"BGD": 163046161,
|
||||
"BGR": 6975761,
|
||||
"BHR": 1641172,
|
||||
"BHS": 389482,
|
||||
"BIH": 3301000,
|
||||
"BLM": 9961,
|
||||
"BLR": 9466856,
|
||||
"BLZ": 390353,
|
||||
"BMU": 63918,
|
||||
"BOL": 11513100,
|
||||
"BRA": 211049527,
|
||||
"BRB": 287025,
|
||||
"BRN": 433285,
|
||||
"BTN": 763092,
|
||||
"BWA": 2303697,
|
||||
"CAF": 4745185,
|
||||
"CAN": 37589262,
|
||||
"CHE": 8574832,
|
||||
"CHL": 18952038,
|
||||
"CHN": 1397715000,
|
||||
"CIV": 25716544,
|
||||
"CMR": 25876380,
|
||||
"CNM": 10000,
|
||||
"COD": 86790567,
|
||||
"COG": 5380508,
|
||||
"COK": 17459,
|
||||
"COL": 50339443,
|
||||
"COM": 850886,
|
||||
"CPV": 549935,
|
||||
"CRI": 5047561,
|
||||
"CSI": 4,
|
||||
"CUB": 11333483,
|
||||
"CUW": 157538,
|
||||
"CYM": 64948,
|
||||
"CYN": 326000,
|
||||
"CYP": 1198575,
|
||||
"CZE": 10669709,
|
||||
"DEU": 83132799,
|
||||
"DJI": 973560,
|
||||
"DMA": 71808,
|
||||
"DNK": 5818553,
|
||||
"DOM": 10738958,
|
||||
"DZA": 43053054,
|
||||
"ECU": 17373662,
|
||||
"EGY": 100388073,
|
||||
"ERI": 6081196,
|
||||
"ESB": 7850,
|
||||
"ESP": 47076781,
|
||||
"EST": 1326590,
|
||||
"ETH": 112078730,
|
||||
"FIN": 5520314,
|
||||
"FJI": 889953,
|
||||
"FLK": 3398,
|
||||
"FRA": 67059887,
|
||||
"FRO": 48678,
|
||||
"FSM": 113815,
|
||||
"GAB": 2172579,
|
||||
"GBR": 66834405,
|
||||
"GEO": 3720382,
|
||||
"GGY": 62792,
|
||||
"GHA": 30417856,
|
||||
"GIB": 33701,
|
||||
"GIN": 12771246,
|
||||
"GMB": 2347706,
|
||||
"GNB": 1920922,
|
||||
"GNQ": 1355986,
|
||||
"GRC": 10716322,
|
||||
"GRD": 112003,
|
||||
"GRL": 56225,
|
||||
"GTM": 16604026,
|
||||
"GUM": 167294,
|
||||
"GUY": 782766,
|
||||
"HKG": 7507400,
|
||||
"HND": 9746117,
|
||||
"HRV": 4067500,
|
||||
"HTI": 11263077,
|
||||
"HUN": 9769949,
|
||||
"IDN": 270625568,
|
||||
"IMN": 84584,
|
||||
"IND": 1366417754,
|
||||
"IOA": 2387,
|
||||
"IOT": 3000,
|
||||
"IRL": 4941444,
|
||||
"IRN": 82913906,
|
||||
"IRQ": 39309783,
|
||||
"ISL": 361313,
|
||||
"ISR": 9053300,
|
||||
"ITA": 60297396,
|
||||
"JAM": 2948279,
|
||||
"JEY": 107800,
|
||||
"JOR": 10101694,
|
||||
"JPN": 126264931,
|
||||
"KAB": 36175,
|
||||
"KAS": 6000,
|
||||
"KAZ": 18513930,
|
||||
"KEN": 52573973,
|
||||
"KGZ": 6456900,
|
||||
"KHM": 16486542,
|
||||
"KIR": 117606,
|
||||
"KNA": 52834,
|
||||
"KOR": 51709098,
|
||||
"KOS": 1794248,
|
||||
"KWT": 4207083,
|
||||
"LAO": 7169455,
|
||||
"LBN": 6855713,
|
||||
"LBR": 4937374,
|
||||
"LBY": 6777452,
|
||||
"LCA": 182790,
|
||||
"LIE": 38019,
|
||||
"LKA": 21803000,
|
||||
"LSO": 2125268,
|
||||
"LTU": 2786844,
|
||||
"LUX": 619896,
|
||||
"LVA": 1912789,
|
||||
"MAC": 640445,
|
||||
"MAF": 38002,
|
||||
"MAR": 36471769,
|
||||
"MCO": 38964,
|
||||
"MDA": 2657637,
|
||||
"MDG": 26969307,
|
||||
"MDV": 530953,
|
||||
"MEX": 127575529,
|
||||
"MHL": 58791,
|
||||
"MKD": 2083459,
|
||||
"MLI": 19658031,
|
||||
"MLT": 502653,
|
||||
"MMR": 54045420,
|
||||
"MNE": 622137,
|
||||
"MNG": 3225167,
|
||||
"MNP": 57216,
|
||||
"MOZ": 30366036,
|
||||
"MRT": 4525696,
|
||||
"MSR": 4649,
|
||||
"MUS": 1265711,
|
||||
"MWI": 18628747,
|
||||
"MYS": 31949777,
|
||||
"NAM": 2494530,
|
||||
"NCL": 287800,
|
||||
"NER": 23310715,
|
||||
"NFK": 2169,
|
||||
"NGA": 200963599,
|
||||
"NIC": 6545502,
|
||||
"NIU": 1620,
|
||||
"NLD": 17332850,
|
||||
"NOR": 5347896,
|
||||
"NPL": 28608710,
|
||||
"NRU": 12581,
|
||||
"NZL": 4917000,
|
||||
"OMN": 4974986,
|
||||
"PAK": 216565318,
|
||||
"PAN": 4246439,
|
||||
"PCN": 54,
|
||||
"PER": 32510453,
|
||||
"PGA": 100,
|
||||
"PHL": 108116615,
|
||||
"PLW": 18008,
|
||||
"PNG": 8776109,
|
||||
"POL": 37970874,
|
||||
"PRI": 3193694,
|
||||
"PRK": 25666161,
|
||||
"PRT": 10269417,
|
||||
"PRY": 7044636,
|
||||
"PSX": 4685306,
|
||||
"PYF": 279287,
|
||||
"QAT": 2832067,
|
||||
"ROU": 19356544,
|
||||
"RUS": 144373535,
|
||||
"RWA": 12626950,
|
||||
"SAH": 603253,
|
||||
"SAU": 34268528,
|
||||
"SDN": 42813238,
|
||||
"SDS": 11062113,
|
||||
"SEN": 16296364,
|
||||
"SGP": 5703569,
|
||||
"SGS": 30,
|
||||
"SHN": 4534,
|
||||
"SLB": 669823,
|
||||
"SLE": 7813215,
|
||||
"SLV": 6453553,
|
||||
"SMR": 33860,
|
||||
"SOL": 5096159,
|
||||
"SOM": 10192317,
|
||||
"SPM": 5997,
|
||||
"SRB": 6944975,
|
||||
"STP": 215056,
|
||||
"SUR": 581363,
|
||||
"SVK": 5454073,
|
||||
"SVN": 2087946,
|
||||
"SWE": 10285453,
|
||||
"SWZ": 1148130,
|
||||
"SXM": 40733,
|
||||
"SYC": 97625,
|
||||
"SYR": 17070135,
|
||||
"TCA": 38191,
|
||||
"TCD": 15946876,
|
||||
"TGO": 8082366,
|
||||
"THA": 69625582,
|
||||
"TJK": 9321018,
|
||||
"TKM": 5942089,
|
||||
"TLS": 1293119,
|
||||
"TON": 104494,
|
||||
"TTO": 1394973,
|
||||
"TUN": 11694719,
|
||||
"TUR": 83429615,
|
||||
"TUV": 11646,
|
||||
"TWN": 23568378,
|
||||
"TZA": 58005463,
|
||||
"UGA": 44269594,
|
||||
"UKR": 44385155,
|
||||
"UMI": 300,
|
||||
"URY": 3461734,
|
||||
"USA": 328239523,
|
||||
"USG": 6000,
|
||||
"UZB": 33580650,
|
||||
"VAT": 825,
|
||||
"VCT": 110589,
|
||||
"VEN": 28515829,
|
||||
"VGB": 30030,
|
||||
"VIR": 106631,
|
||||
"VNM": 96462106,
|
||||
"VUT": 299882,
|
||||
"WLF": 11558,
|
||||
"WSB": 7850,
|
||||
"WSM": 197097,
|
||||
"YEM": 29161922,
|
||||
"ZAF": 58558270,
|
||||
"ZMB": 17861030,
|
||||
"ZWE": 14645468
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* scripts/build-region-geo.mjs — build the region **geo** manifest:
|
||||
* `public/region-geo.json` = `{ "<regionKey>": { "area": <number>, "population": <number> }, ... }`.
|
||||
*
|
||||
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||
* two facts the v0.28 per-region stats model (and its area×population weighting)
|
||||
* needs:
|
||||
* - `area` — the region's **total** surface area in km², from
|
||||
* `@turf/area` on the FINAL generated feature
|
||||
* (`public/regions.geo.json`) — turf reports square metres,
|
||||
* so the raw value is divided by 1e6 — rounded to 2 decimals.
|
||||
* - `population` — the region's share of its **parent country's** real
|
||||
* population (Natural Earth 1:10m admin-0 `POP_EST`, from the
|
||||
* committed `public/ne-admin0-pop.json`), distributed across
|
||||
* the country's sub-regions **proportional to each region's
|
||||
* area** (a first-order "uniform population density within a
|
||||
* country" approximation — Natural Earth admin-1, which the
|
||||
* region generator uses, carries no per-unit population).
|
||||
*
|
||||
* Population distribution uses **largest remainder** per country, so the sum of
|
||||
* a country's sub-region populations is EXACTLY the country's POP_EST, and the
|
||||
* sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries'
|
||||
* POP_EST (7,654,092,021) — no rounding drift.
|
||||
*
|
||||
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||
* v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json`
|
||||
* (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from
|
||||
* the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build
|
||||
* is fully reproducible in CI.
|
||||
*
|
||||
* Output contract (asserted by scripts/region-geo.test.ts):
|
||||
* - one entry for **every** region key, keyed identically to
|
||||
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||
* - `area` is a finite number >= 0 (km², <= 2 decimals);
|
||||
* - `population` is a non-negative integer;
|
||||
* - the total population across all entries is a non-negative integer.
|
||||
*
|
||||
* Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and
|
||||
* the committed population table), pure turf, largest-remainder with a
|
||||
* (remainder desc, key asc) tie-break, and stable sorted key order → a re-run
|
||||
* rewrites a byte-identical file (the test re-runs this script and compares
|
||||
* bytes).
|
||||
*
|
||||
* Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import area from '@turf/area';
|
||||
import { feature } from '@turf/helpers';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
const round2 = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
// --- load the inputs (plain parse) ------------------------------------------
|
||||
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
||||
const keysPath = path.join(root, 'public', 'region-keys.json');
|
||||
const popPath = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||
|
||||
let geo, idToKey, popByCountry;
|
||||
try {
|
||||
geo = JSON.parse(readFileSync(geoPath, 'utf8'));
|
||||
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
|
||||
popByCountry = JSON.parse(readFileSync(popPath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` +
|
||||
`Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over the geo features: for each region key record its (unrounded)
|
||||
* turf area and its parent ISO_A3, then group the keys by parent so the
|
||||
* population can be distributed per country.
|
||||
*/
|
||||
const regionArea = new Map(); // key -> unrounded area (km²)
|
||||
const regionParent = new Map(); // key -> parent ISO_A3
|
||||
for (const f of geo.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
// @turf/area reports square METRES; the manifest stores km².
|
||||
const a = area(feature(f.geometry)) / 1e6;
|
||||
regionArea.set(key, a);
|
||||
regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : '');
|
||||
}
|
||||
|
||||
// Group region keys by parent country (file order is stable, but we sort for
|
||||
// deterministic population distribution).
|
||||
const keysByCountry = new Map(); // parent -> string[] of keys
|
||||
for (const [key, parent] of regionParent) {
|
||||
if (!parent) continue; // a region with no parent has no population source
|
||||
const list = keysByCountry.get(parent);
|
||||
if (list) list.push(key);
|
||||
else keysByCountry.set(parent, [key]);
|
||||
}
|
||||
|
||||
const population = new Map(); // key -> integer population
|
||||
for (const [parent, keys] of keysByCountry) {
|
||||
keys.sort(); // deterministic ordering for the tie-break
|
||||
const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0;
|
||||
const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0);
|
||||
if (P <= 0 || totalArea <= 0) {
|
||||
for (const k of keys) population.set(k, 0);
|
||||
continue;
|
||||
}
|
||||
// Largest remainder: floor each share, then hand out the leftover 1s to the
|
||||
// regions with the largest fractional remainders (ties broken by key asc).
|
||||
const shares = keys.map((k) => {
|
||||
const a = regionArea.get(k) || 0;
|
||||
const ideal = (P * a) / totalArea;
|
||||
return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) };
|
||||
});
|
||||
const distributed = shares.reduce((s, x) => s + x.floor, 0);
|
||||
let deficit = P - distributed; // 0 <= deficit < keys.length
|
||||
const order = shares
|
||||
.map((x, i) => ({ i, rem: x.rem, k: x.k }))
|
||||
.sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
|
||||
for (let i = 0; i < order.length && deficit > 0; i++) {
|
||||
shares[order[i].i].floor += 1;
|
||||
deficit -= 1;
|
||||
}
|
||||
for (const x of shares) population.set(x.k, x.floor);
|
||||
}
|
||||
|
||||
// --- assemble the manifest (sorted keys) -------------------------------------
|
||||
const expectedKeys = Object.values(idToKey);
|
||||
const manifest = {};
|
||||
for (const k of [...expectedKeys].sort()) {
|
||||
const a = regionArea.get(k);
|
||||
const p = population.get(k);
|
||||
if (a === undefined || p === undefined) {
|
||||
throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`);
|
||||
}
|
||||
manifest[k] = { area: round2(a), population: p };
|
||||
}
|
||||
|
||||
// --- hard assertions (the test re-checks these against the committed file) ----
|
||||
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||
throw new Error(
|
||||
`expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||
);
|
||||
}
|
||||
let totalPop = 0;
|
||||
let zeros = 0;
|
||||
for (const [k, v] of Object.entries(manifest)) {
|
||||
if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`);
|
||||
if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`);
|
||||
if (v.area === 0) zeros += 1;
|
||||
totalPop += v.population;
|
||||
}
|
||||
|
||||
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
|
||||
writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
// --- summary ------------------------------------------------------------------
|
||||
console.log(
|
||||
`[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` +
|
||||
`${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`,
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* scripts/extract-admin0-pop.mjs — one-time extraction of the population
|
||||
* SOURCE for the region-geo manifest: `public/ne-admin0-pop.json`.
|
||||
*
|
||||
* Output = `{ "<ADM0_A3>": <POP_EST>, ... }` — the estimated population
|
||||
* (POP_EST) of every Natural Earth 1:10m **admin-0 country** code, keeping
|
||||
* the **maximum** POP_EST seen for each code (Natural Earth ships a few
|
||||
* duplicated / broken-out features per code, e.g. the `TLC` stubs, whose
|
||||
* POP_EST is 0; the real country always wins by having the larger value).
|
||||
* Only codes with a finite, positive POP_EST are recorded (a 0/null POP_EST
|
||||
* has nothing to distribute). Keys are sorted; the file is written with
|
||||
* 2-space indent + trailing newline (deterministic, committed).
|
||||
*
|
||||
* Why a separate committed file: `public/region-geo.json` (built by
|
||||
* `build-region-geo.mjs`) needs a per-country population, but the raw
|
||||
* Natural Earth GeoJSON (13 MB) is a *source* file we deliberately do NOT
|
||||
* commit (see the AGENTS.md data-pipeline note). This script distils it
|
||||
* into a tiny (a few KB) derived table that IS committed, so the region-geo
|
||||
* builder stays fully reproducible in CI without the source present.
|
||||
* Re-run only when the underlying Natural Earth data changes.
|
||||
*
|
||||
* The 1:10m admin-1 file (the one the region generator uses) carries NO
|
||||
* population; the 1:10m admin-0 file does (uppercase `POP_EST` / `ADM0_A3`).
|
||||
*
|
||||
* Usage:
|
||||
* NE_ADMIN0=/path/to/ne_10m_admin_0_countries.geojson node scripts/extract-admin0-pop.mjs
|
||||
* (default source: /tmp/ne10m_admin0.geojson)
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
const src = process.env.NE_ADMIN0 || '/tmp/ne10m_admin0.geojson';
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(src, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[extract-admin0-pop] could not read the Natural Earth admin-0 source at ${src}. ` +
|
||||
`Download it (ne_10m_admin_0_countries.geojson) and re-run with NE_ADMIN0 pointing at it.`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const geo = JSON.parse(raw);
|
||||
|
||||
/** ADM0_A3 -> max finite, positive POP_EST. */
|
||||
const byCode = new Map();
|
||||
for (const f of geo.features ?? []) {
|
||||
const p = f.properties ?? {};
|
||||
const code = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : '';
|
||||
const pop = typeof p.POP_EST === 'number' ? p.POP_EST : NaN;
|
||||
if (!code || !Number.isFinite(pop) || pop <= 0) continue;
|
||||
const prev = byCode.get(code);
|
||||
if (prev === undefined || pop > prev) byCode.set(code, Math.round(pop));
|
||||
}
|
||||
|
||||
if (byCode.size === 0) {
|
||||
throw new Error('[extract-admin0-pop] no finite positive POP_EST found in the source — wrong file?');
|
||||
}
|
||||
|
||||
// Deterministic: sorted keys, 2-space indent, trailing newline.
|
||||
const keys = [...byCode.keys()].sort();
|
||||
const manifest = {};
|
||||
for (const k of keys) manifest[k] = byCode.get(k);
|
||||
|
||||
const out = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
let sum = 0;
|
||||
for (const v of byCode.values()) sum += v;
|
||||
console.log(
|
||||
`[extract-admin0-pop] wrote public/ne-admin0-pop.json — ${byCode.size} country codes, ` +
|
||||
`total population ${sum.toLocaleString('en-US')} (Natural Earth 1:10m admin-0, max POP_EST per code)`,
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* scripts/region-geo.test.ts — validation for the committed
|
||||
* `public/region-geo.json` (built by `scripts/build-region-geo.mjs`).
|
||||
*
|
||||
* The manifest maps each region key to its { area (km²), population } — the
|
||||
* weighting source for the per-region stats model (v0.28, #46): a nation's
|
||||
* four 0–100 stats are derived as the area×population-weighted median of the
|
||||
* stats of the regions it owns. It is a load-bearing data artifact — this
|
||||
* test pins its contract.
|
||||
*
|
||||
* The `area` is the turf area of the region's final geometry (km², 2 dp); the
|
||||
* `population` is the country's Natural-Earth admin-0 `POP_EST` (from the
|
||||
* committed `public/ne-admin0-pop.json`) distributed across its sub-regions
|
||||
* proportional to area via largest-remainder, so every country's sub-regions
|
||||
* sum EXACTLY to the country's total (and the global total is the sum over the
|
||||
* 177 repo countries). This is a dependency-free test: it reads the JSON with
|
||||
* plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull
|
||||
* `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the
|
||||
* v0.17 note). The determinism test re-runs the (turf `.mjs`) builder and
|
||||
* compares bytes, matching that the builder is idempotent.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
function readJson(rel: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||
}
|
||||
|
||||
interface GeoEntry {
|
||||
area: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
// The 177 repo countries' admin-0 population totals (the committed derived
|
||||
// source). Only the countries actually present in the region dataset appear.
|
||||
const admin0 = readJson('public/ne-admin0-pop.json') as Record<string, number>;
|
||||
// feature id -> region key
|
||||
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||
// feature id -> { name, parent (ISO_A3) }
|
||||
const regGeo = readJson('public/regions.geo.json') as {
|
||||
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||
};
|
||||
|
||||
// region key -> parent ISO_A3 (via the key manifest + the geo feature parent).
|
||||
const keyToParent: Record<string, string> = {};
|
||||
for (const f of regGeo.features ?? []) {
|
||||
const key = manifest[String(f.id)];
|
||||
const parent = f.properties?.parent;
|
||||
if (key && parent) keyToParent[key] = parent;
|
||||
}
|
||||
|
||||
const geo = readJson('public/region-geo.json') as Record<string, GeoEntry>;
|
||||
const keys = Object.values(manifest);
|
||||
|
||||
describe('region-geo.json', () => {
|
||||
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||
expect(new Set(Object.keys(geo))).toEqual(new Set(keys));
|
||||
expect(Object.keys(geo)).toHaveLength(1800);
|
||||
});
|
||||
|
||||
it('is keyed in sorted (deterministic) order', () => {
|
||||
const ks = Object.keys(geo);
|
||||
expect(ks).toEqual([...ks].sort());
|
||||
});
|
||||
|
||||
it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => {
|
||||
for (const [k, v] of Object.entries(geo)) {
|
||||
expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true);
|
||||
expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true);
|
||||
expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true);
|
||||
// area is recorded to at most 2 decimal places (km²)
|
||||
expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true);
|
||||
expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true);
|
||||
expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => {
|
||||
// The distribution preserved each country's total exactly, so the grand
|
||||
// total over all sub-regions is the sum over the repo countries only.
|
||||
const total = Object.values(geo).reduce((s, v) => s + v.population, 0);
|
||||
expect(total).toBe(7654092021);
|
||||
});
|
||||
|
||||
it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => {
|
||||
// For every parent country present in the region dataset, the sum of its
|
||||
// sub-regions' populations must equal that country's admin-0 POP_EST
|
||||
// EXACTLY — this is the invariant the largest-remainder distribution
|
||||
// guarantees (no population is created or lost by the split).
|
||||
const byParent: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(geo)) {
|
||||
const p = keyToParent[k];
|
||||
if (!p) throw new Error(`no parent for region key ${k}`);
|
||||
byParent[p] = (byParent[p] ?? 0) + v.population;
|
||||
}
|
||||
expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries
|
||||
for (const [p, sum] of Object.entries(byParent)) {
|
||||
expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined();
|
||||
expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]);
|
||||
}
|
||||
});
|
||||
|
||||
it('spot-checks well-known regions for exact area + population', () => {
|
||||
const is = (k: string, area: number, population: number) => {
|
||||
const v = geo[k];
|
||||
expect(v, `missing geo for ${k}`).toBeDefined();
|
||||
expect(v!.area, `area for ${k}`).toBe(area);
|
||||
expect(v!.population, `population for ${k}`).toBe(population);
|
||||
};
|
||||
is('california-kikh', 413329.21, 14184195); // US-CA
|
||||
is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core
|
||||
is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41)
|
||||
is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41)
|
||||
is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31)
|
||||
is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31)
|
||||
is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain)
|
||||
});
|
||||
|
||||
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||
const file = path.join(root, 'public', 'region-geo.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
// The builder is cwd-independent (it resolves the repo root from its own
|
||||
// location) and idempotent: same input files (regions.geo.json +
|
||||
// region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding
|
||||
// + stable (sorted) key order → identical bytes.
|
||||
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const after = readFileSync(file, 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,16 @@ function makeState(id: string): GameState {
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// (v0.28, #46) a v0.28+ save carries per-region stats; the fake 'GBR'
|
||||
// region here holds a copy of the uk's (single-region) stats.
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,6 +119,52 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a legacy row saved before the regionStats field existed loads with it seeded per region and stats recomputed', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { regionStats?: Record<string, unknown> }).regionStats; // a pre-v0.28 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
// each owned region is seeded with a copy of its owner's stats…
|
||||
expect(loaded!.regionStats).toEqual({
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
});
|
||||
// …and a single-region nation's derived stats equal the seeded ones
|
||||
// (the weighted median of identical values is that value).
|
||||
expect(loaded!.nations.uk.stats).toEqual({ military: 80, economy: 70, stability: 60, population: 50 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a v0.28+ row (regionStats present) loads UNCHANGED — no recompute on load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('current');
|
||||
// Diverge one region's stats from the nation level to prove load does not
|
||||
// recompute (a v0.28+ save keeps its stored derived stats exactly).
|
||||
s.regionStats = { GBR: { military: 99, economy: 70, stability: 60, population: 50 } };
|
||||
s.nations = { uk: { ...s.nations.uk, stats: { military: 42, economy: 70, stability: 60, population: 50 } } };
|
||||
db.save(s);
|
||||
const loaded = db.load('current');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.regionStats).toEqual({ GBR: { military: 99, economy: 70, stability: 60, population: 50 } });
|
||||
// the nation's stats are NOT recomputed from the region on load (they stay 42)
|
||||
expect(loaded!.nations.uk.stats.military).toBe(42);
|
||||
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'));
|
||||
|
||||
+17
-1
@@ -2,6 +2,7 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { migrateRegionStats } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export interface Db {
|
||||
@@ -39,7 +40,7 @@ export function openDb(filePath: string): Db {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const state = JSON.parse(row.data) as GameState;
|
||||
let state = JSON.parse(row.data) as GameState;
|
||||
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||
if (state.unowned === undefined) state.unowned = [];
|
||||
@@ -48,6 +49,21 @@ 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
|
||||
// nation-level stats and the nations' stats are recomputed as the
|
||||
// (uniform-weight) area×population-weighted median, so no data is lost
|
||||
// and a single-region nation keeps exactly its old stats.
|
||||
state = migrateRegionStats(state);
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+2256
-34
File diff suppressed because it is too large
Load Diff
+1201
-24
File diff suppressed because it is too large
Load Diff
+162
-7
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||||
import type {
|
||||
AddActionRequest,
|
||||
AddDiplomacyRequest,
|
||||
@@ -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 { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||
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, hasAdjacency, hasCentroids, loadRegions, neighboursOf, referenceForKeys, 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 });
|
||||
});
|
||||
@@ -136,11 +174,26 @@ app.post('/api/games', (req: Request, res) => {
|
||||
pendingNationOps: [],
|
||||
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||
markers: [],
|
||||
// (v0.28, #46) Per-region stats are seeded below, once territories hold
|
||||
// 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
|
||||
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
|
||||
// region key gets its owner's initial stats (see seedRegionStats).
|
||||
state.regionStats = seedRegionStats(state.nations);
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
});
|
||||
@@ -271,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,
|
||||
@@ -295,6 +354,22 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
// centroid (without it, marker adds are dropped; existing markers are
|
||||
// still maintained).
|
||||
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
||||
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
|
||||
// hand the engine the resolver so the end-of-turn recompute weights each
|
||||
// owned region by area×population (without it the aggregate degrades to
|
||||
// an unweighted median — still region-consistent, just unweighted).
|
||||
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
|
||||
// (v0.29, #46 PR 2) region ref resolver: lets the model name a
|
||||
// regionChanges target by its human-readable name (resolved
|
||||
// case-insensitively against the region-key manifest) or its key;
|
||||
// unknown / ambiguous refs are dropped. Always on — the resolver is
|
||||
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||
// the manifest is absent.
|
||||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||||
// (v0.30, #50) region display name for the engine's "invasion repelled"
|
||||
// event (geo properties.name, else the raw key). Always on — the
|
||||
// resolver is pure and degrades to the key when the manifest is absent.
|
||||
regionNameOf: (k) => regionName(k),
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
@@ -324,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;
|
||||
@@ -361,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 };
|
||||
|
||||
@@ -50,6 +50,13 @@ const state: GameState = {
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationStats,
|
||||
migrateRegionStats,
|
||||
seedRegionStats,
|
||||
weightedMedian,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState, Nation, NationStats } from '../shared/types';
|
||||
|
||||
// --- Test fixtures -----------------------------------------------------------
|
||||
|
||||
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
|
||||
military,
|
||||
economy,
|
||||
stability,
|
||||
population,
|
||||
});
|
||||
|
||||
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
|
||||
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||
}
|
||||
|
||||
function mkState(
|
||||
nations: Record<string, Nation>,
|
||||
regionStats?: Record<string, unknown>,
|
||||
): GameState {
|
||||
const base: GameState = {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: Object.keys(nations)[0] ?? '',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations,
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
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
|
||||
return base;
|
||||
}
|
||||
|
||||
// --- weightedMedian ----------------------------------------------------------
|
||||
|
||||
describe('weightedMedian', () => {
|
||||
it('returns 0 for an empty input or zero total weight', () => {
|
||||
expect(weightedMedian([], [])).toBe(0);
|
||||
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
|
||||
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
|
||||
});
|
||||
|
||||
it('returns the single value when it is the only weighted input', () => {
|
||||
expect(weightedMedian([42], [7])).toBe(42);
|
||||
});
|
||||
|
||||
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
|
||||
// The invariant seeding + legacy migration rely on: equal values → that value,
|
||||
// regardless of the (arbitrary) weights.
|
||||
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
|
||||
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
|
||||
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
|
||||
// and a constant shift shifts the result by the same constant
|
||||
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
|
||||
});
|
||||
|
||||
it('picks the value at the first point the cumulative weight reaches half the total', () => {
|
||||
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
|
||||
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
|
||||
// an odd middle: the true middle value
|
||||
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
|
||||
// the heavier value dominates
|
||||
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
|
||||
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
|
||||
});
|
||||
|
||||
it('treats non-finite or non-positive weights as excluded (0)', () => {
|
||||
// NaN weight on 10, positive on 20 → only 20 counts
|
||||
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
|
||||
// negative weight on 20, positive on 10 → only 10 counts
|
||||
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// --- deriveNationStats -------------------------------------------------------
|
||||
|
||||
describe('deriveNationStats', () => {
|
||||
const regionStats = {
|
||||
a: S(50, 50, 50, 50),
|
||||
b: S(90, 90, 90, 90),
|
||||
};
|
||||
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||
|
||||
it('weights by area×population: the larger region dominates the aggregate', () => {
|
||||
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
|
||||
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
|
||||
expect(out).toEqual(S(90, 90, 90, 90));
|
||||
});
|
||||
|
||||
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
|
||||
// equal weights 1,1 → the lower (a=50) is reached at half-total first
|
||||
const out = deriveNationStats(['a', 'b'], regionStats);
|
||||
expect(out).toEqual(S(50, 50, 50, 50));
|
||||
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
|
||||
expect(out2).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('skips a region key that has no stats entry', () => {
|
||||
// b has no entry → only a is counted → a's (50) values
|
||||
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
|
||||
expect(out).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('returns zero stats when none of the owned regions has a stats entry', () => {
|
||||
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
|
||||
expect(out).toEqual(S(0, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
|
||||
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
|
||||
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
|
||||
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
|
||||
expect(out).toEqual(S(90, 90, 90, 90));
|
||||
});
|
||||
});
|
||||
|
||||
// --- seedRegionStats ---------------------------------------------------------
|
||||
|
||||
describe('seedRegionStats', () => {
|
||||
it('copies each owner\'s stats to every region it owns', () => {
|
||||
const nations = {
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
|
||||
};
|
||||
expect(seedRegionStats(nations)).toEqual({
|
||||
k1: S(80, 70, 60, 50),
|
||||
k2: S(80, 70, 60, 50),
|
||||
k3: S(95, 70, 75, 90),
|
||||
});
|
||||
});
|
||||
|
||||
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
|
||||
const nations = {
|
||||
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
|
||||
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
|
||||
};
|
||||
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
|
||||
});
|
||||
|
||||
it('returns an empty table for nations with no territory', () => {
|
||||
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
|
||||
expect(seedRegionStats(nations)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// --- migrateRegionStats ------------------------------------------------------
|
||||
|
||||
describe('migrateRegionStats', () => {
|
||||
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
|
||||
const state = mkState(
|
||||
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
|
||||
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
|
||||
);
|
||||
const out = migrateRegionStats(state);
|
||||
expect(out).toBe(state); // same reference: untouched
|
||||
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
|
||||
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
|
||||
});
|
||||
|
||||
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
|
||||
const state = mkState({
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
|
||||
});
|
||||
// sanity: we built a legacy row
|
||||
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||
|
||||
const out = migrateRegionStats(state, (k) =>
|
||||
k === 'k1' ? { area: 100, population: 1000 }
|
||||
: k === 'k2' ? { area: 1, population: 1 }
|
||||
: k === 'k3' ? { area: 9, population: 9 }
|
||||
: { area: 50, population: 500 });
|
||||
// each owned region is seeded with a copy of its owner's stats…
|
||||
expect(out.regionStats).toEqual({
|
||||
k1: S(80, 70, 60, 50),
|
||||
k2: S(80, 70, 60, 50),
|
||||
k3: S(80, 70, 60, 50),
|
||||
k4: S(95, 70, 75, 90),
|
||||
});
|
||||
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
|
||||
// identical values is that value, regardless of the (uneven) weights.
|
||||
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
|
||||
// the original row is NOT mutated
|
||||
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||
});
|
||||
|
||||
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
|
||||
const state = mkState({
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
|
||||
});
|
||||
const out = migrateRegionStats(state);
|
||||
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
|
||||
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
|
||||
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
|
||||
expect('uk' in (out.regionStats ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
|
||||
|
||||
describe('canConquer', () => {
|
||||
it('the default margin constant is 15', () => {
|
||||
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||||
});
|
||||
|
||||
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
|
||||
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
|
||||
expect(canConquer(10, 25)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
|
||||
// attacker 9, defender 25 → gap of 16 > margin → repel
|
||||
expect(canConquer(9, 25)).toBe(false);
|
||||
});
|
||||
|
||||
it('an equal attacker proceeds', () => {
|
||||
expect(canConquer(50, 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker within the margin (gap < margin) proceeds', () => {
|
||||
expect(canConquer(30, 40)).toBe(true); // gap 10
|
||||
expect(canConquer(14, 25)).toBe(true); // gap 11
|
||||
});
|
||||
|
||||
it('an attacker ahead of the defender always proceeds', () => {
|
||||
expect(canConquer(90, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
|
||||
expect(canConquer(50, 50, 0)).toBe(true); // equal
|
||||
expect(canConquer(51, 50, 0)).toBe(true); // ahead
|
||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
allTerritoryKeys,
|
||||
centroidOf,
|
||||
expandCountriesToKeys,
|
||||
geoOf,
|
||||
hasAdjacency,
|
||||
hasCentroids,
|
||||
hasGeo,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
neighboursOf,
|
||||
@@ -106,6 +108,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
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);
|
||||
@@ -149,6 +157,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => {
|
||||
expect(hasGeo()).toBe(true);
|
||||
const ain = geoOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.area).toBeGreaterThanOrEqual(0); // km²
|
||||
expect(ain!.population).toBeGreaterThanOrEqual(0);
|
||||
// exact spot-check values pinned from the committed manifest (Ain, FRA)
|
||||
expect(ain!.area).toBeCloseTo(22557.77, 1); // km²
|
||||
expect(ain!.population).toBe(2304126);
|
||||
});
|
||||
|
||||
it('(v0.28 #46) geoOf returns undefined for an unknown key', () => {
|
||||
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('graceful degradation with no region data', () => {
|
||||
beforeEach(() => resetRegions());
|
||||
|
||||
@@ -165,6 +188,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
// and no centroids: the marker-placement path degrades to a no-op
|
||||
expect(hasCentroids()).toBe(false);
|
||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-geo: the per-region stats weighting degrades to uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +245,9 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
||||
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||
expect(hasCentroids()).toBe(false);
|
||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||
@@ -233,6 +262,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
||||
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a dist/ WITH the region-geo manifest loads it (prod layout)', () => {
|
||||
copyFileSync(
|
||||
path.join(realRoot, 'public', 'region-geo.json'),
|
||||
path.join(prodRoot, 'dist', 'region-geo.json'),
|
||||
);
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasGeo()).toBe(true);
|
||||
const ain = geoOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.area).toBeCloseTo(22557.77, 1);
|
||||
expect(ain!.population).toBe(2304126);
|
||||
});
|
||||
|
||||
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||
const emptyRoot = path.join(base, 'empty');
|
||||
mkdirSync(emptyRoot, { recursive: true });
|
||||
|
||||
+73
-3
@@ -17,6 +17,8 @@
|
||||
// field (v0.21, #16 P2)
|
||||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||||
// — the weighting source for the per-region stats model (v0.28, #46)
|
||||
//
|
||||
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||
@@ -45,6 +47,8 @@ interface RegionData {
|
||||
adjacency: Record<string, string[]>;
|
||||
/** region key -> anchor point (from public/region-centroids.json). */
|
||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||||
geo: Record<string, { area: number; population: number }>;
|
||||
}
|
||||
|
||||
let data: RegionData | null = null;
|
||||
@@ -72,6 +76,7 @@ export function loadRegions(root: string): boolean {
|
||||
const geoFile = 'regions.geo.json';
|
||||
const adjacencyFile = 'region-adjacency.json';
|
||||
const centroidsFile = 'region-centroids.json';
|
||||
const regionGeoFile = 'region-geo.json';
|
||||
const base = [
|
||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||
@@ -85,13 +90,13 @@ export function loadRegions(root: string): boolean {
|
||||
const geoPath = path.join(base, geoFile);
|
||||
try {
|
||||
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||
const geoJson = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||
};
|
||||
const keyToInfo: Record<string, RegionInfo> = {};
|
||||
const keysByCountry: Record<string, string[]> = {};
|
||||
const validKeys = new Set<string>();
|
||||
for (const f of geo.features ?? []) {
|
||||
for (const f of geoJson.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
@@ -147,7 +152,40 @@ export function loadRegions(root: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids };
|
||||
// The region-geo manifest (v0.28, #46): area (km²) + population per region
|
||||
// key — the weighting source for the per-region stats model. OPTIONAL like
|
||||
// the others: a missing/broken file means "no geo data", and weighting
|
||||
// degrades to uniform (every region weight 1) while everything else keeps
|
||||
// working.
|
||||
let geo: Record<string, { area: number; population: number }> = {};
|
||||
const geoDataPath = path.join(base, regionGeoFile);
|
||||
if (existsSync(geoDataPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ area?: unknown; population?: unknown }
|
||||
>;
|
||||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (
|
||||
v &&
|
||||
typeof v.area === 'number' &&
|
||||
Number.isFinite(v.area) &&
|
||||
v.area >= 0 &&
|
||||
typeof v.population === 'number' &&
|
||||
Number.isFinite(v.population) &&
|
||||
v.population >= 0
|
||||
) {
|
||||
geo[k] = { area: v.area, population: v.population };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region-geo.json, continuing without it', err);
|
||||
geo = {};
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
|
||||
return validKeys.size > 0;
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region data', err);
|
||||
@@ -221,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
|
||||
@@ -241,6 +291,16 @@ export function hasCentroids(): boolean {
|
||||
return data !== null && Object.keys(data.centroids).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-geo manifest loaded with at least one entry (v0.28,
|
||||
* #46). The server's jump route uses this to decide whether to hand the
|
||||
* engine a geo resolver (which weights the per-region stats derivation by
|
||||
* area×population) or fall back to a uniform (unweighted) median.
|
||||
*/
|
||||
export function hasGeo(): boolean {
|
||||
return data !== null && Object.keys(data.geo).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed anchor point for a region key: the centroid of its
|
||||
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||||
@@ -252,6 +312,16 @@ export function centroidOf(key: string): { lat: number; lon: number; spread: num
|
||||
return data?.centroids[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
|
||||
* weighting source for the per-region stats model. `undefined` for an unknown
|
||||
* key or when no geo data is loaded (the derivation then falls back to a
|
||||
* uniform weight of 1 for that region).
|
||||
*/
|
||||
export function geoOf(key: string): { area: number; population: number } | undefined {
|
||||
return data?.geo[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* The union of the region keys that share a land border with any of the given
|
||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
+57
-1
@@ -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 {
|
||||
@@ -72,6 +101,28 @@ function truncate(s: string, n: number): string {
|
||||
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.29, #46 PR 2) The stub likewise NEVER changes per-region stats (it
|
||||
* 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;
|
||||
@@ -133,10 +184,15 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
regionChanges: [], // the stub never changes per-region stats (see doc comment)
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+440
-1
@@ -8,16 +8,35 @@ import type {
|
||||
GameState,
|
||||
MarkerType,
|
||||
Nation,
|
||||
NationStats,
|
||||
RelationStatus,
|
||||
RegionStats,
|
||||
StatKey,
|
||||
} from './types';
|
||||
import { MARKER_TYPES } from './types';
|
||||
import { MARKER_TYPES, STAT_KEYS } from './types';
|
||||
|
||||
/** A numeric change to one of a nation's 0–100 stats. */
|
||||
export type StateChange =
|
||||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||||
| { nation: string; stat: StatKey; op: 'add'; value: number };
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) A numeric change to one of **one region's** 0–100 stats —
|
||||
* the fine-grained, per-region lever that complements the nation-wide
|
||||
* {@link StateChange}. `region` names the target by its **region key**
|
||||
* (preferred) or its human-readable **name** (the engine resolves it
|
||||
* case-insensitively, mirroring `resolveNationRef`); after validation it holds
|
||||
* the resolved **region key**. `op` is `set` (replace the value) or `add`
|
||||
* (delta, clamped to 0–100 by the engine).
|
||||
*/
|
||||
export type RegionStatChange = {
|
||||
/** The target region's key (preferred) or name (resolved by the engine). */
|
||||
region: string;
|
||||
stat: StatKey;
|
||||
op: 'set' | 'add';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||||
export interface RelationChange {
|
||||
a: string;
|
||||
@@ -32,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) ------------------------------------------
|
||||
//
|
||||
@@ -49,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
|
||||
@@ -151,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 —
|
||||
@@ -210,11 +305,133 @@ 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;
|
||||
events: TurnEventSpec[];
|
||||
stateChanges: StateChange[];
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Fine-grained, **per-region** stat changes: each targets a
|
||||
* single region's 0–100 stats (by region key or name) and complements the
|
||||
* nation-wide `stateChanges` (which apply to *all* of a nation's regions).
|
||||
* The engine resolves each `region` to a key (key first, then a
|
||||
* case-insensitive unique name match) and applies it to
|
||||
* `GameState.regionStats` only for regions that have an entry (owned); an
|
||||
* unowned / no-man's-land region is a no-op. Empty on turns where the model
|
||||
* changes no individual region. Required (default `[]`).
|
||||
*/
|
||||
regionChanges: RegionStatChange[];
|
||||
relations: RelationChange[];
|
||||
territoryChanges: TerritoryChange[];
|
||||
/**
|
||||
@@ -237,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
|
||||
@@ -422,6 +691,42 @@ export function nationColor(id: string): string {
|
||||
return `hsl(${hue} 58% 46%)`;
|
||||
}
|
||||
|
||||
// --- Combat: the military hard gate (v0.30, #50) ------------------------------
|
||||
//
|
||||
// The one rule the engine enforces on the player's behalf instead of trusting
|
||||
// the LLM: a contested conquest only succeeds when the attacker is not MORE
|
||||
// than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes
|
||||
// the transfer; the engine decides whether it holds (see `applyTurnResult`).
|
||||
// Re-claims of unowned / no-man's-land regions are never gated.
|
||||
|
||||
/**
|
||||
* (v0.30, #50) The margin of military superiority an attacker must NOT be more
|
||||
* than below the defender for a contested conquest to succeed. A gap of exactly
|
||||
* this many points is still enough to win; a gap one larger is repelled.
|
||||
*/
|
||||
export const CONQUEST_MILITARY_MARGIN = 15;
|
||||
|
||||
/**
|
||||
* (v0.30, #50) Hard military gate for a **contested** conquest (a region
|
||||
* currently owned by a different nation). Returns `true` — the invasion
|
||||
* proceeds — unless the attacker's military is MORE than `margin` below the
|
||||
* defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}),
|
||||
* is enough to win, while a gap larger than `margin` is repelled.
|
||||
*
|
||||
* The comparison is intentionally one-sided (it only protects the *defender*
|
||||
* from an overwhelming odds gap, not the attacker from a small disadvantage):
|
||||
* `attacker < defender - margin` → repel; everything else (equal, within the
|
||||
* margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the
|
||||
* prompt's frontier-balance hint all agree on the outcome.
|
||||
*/
|
||||
export function canConquer(
|
||||
attackerMil: number,
|
||||
defenderMil: number,
|
||||
margin: number = CONQUEST_MILITARY_MARGIN,
|
||||
): boolean {
|
||||
return !(attackerMil < defenderMil - margin);
|
||||
}
|
||||
|
||||
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
|
||||
//
|
||||
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||
@@ -515,3 +820,137 @@ export function nationForPrompt(n: Nation) {
|
||||
relations: n.relations,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Per-region stats (v0.28, #46) ------------------------------------------
|
||||
//
|
||||
// A nation's 0–100 `NationStats` are no longer stored as independent per-nation
|
||||
// values: they are DERIVED as the area×population-weighted median of the stats
|
||||
// of the regions the nation owns (`GameState.regionStats`, keyed by region key).
|
||||
// These pure helpers implement that derivation, the fresh-game / legacy seeding,
|
||||
// and the one-time migration of a pre-v0.28 row. They have no runtime deps.
|
||||
|
||||
/**
|
||||
* The weighted median of `values` with per-value `weights` (paired by index).
|
||||
* The pairs are sorted by value ascending and the result is the value at the
|
||||
* first point where the cumulative weight reaches at least half the total
|
||||
* weight. An empty input, or a total weight of zero, yields 0. Non-finite or
|
||||
* non-positive weights are treated as 0 (excluded from the median).
|
||||
* Translation-invariant: adding a constant to every value shifts the result by
|
||||
* that constant — so the median of a set of **identical** values is that value
|
||||
* (the invariant the equal-value seeding and legacy migration rely on).
|
||||
* Pure + shared.
|
||||
*/
|
||||
export function weightedMedian(values: number[], weights: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const pairs = values
|
||||
.map((v, i) => {
|
||||
const w =
|
||||
typeof weights[i] === 'number' && Number.isFinite(weights[i]) && weights[i] > 0
|
||||
? weights[i]
|
||||
: 0;
|
||||
return { v: Number.isFinite(v) ? v : 0, w };
|
||||
})
|
||||
.filter((p) => p.w > 0);
|
||||
if (pairs.length === 0) return 0;
|
||||
const total = pairs.reduce((s, p) => s + p.w, 0);
|
||||
if (total <= 0) return 0;
|
||||
pairs.sort((a, b) => a.v - b.v);
|
||||
let cum = 0;
|
||||
for (const p of pairs) {
|
||||
cum += p.w;
|
||||
if (cum >= total / 2) return p.v;
|
||||
}
|
||||
return pairs[pairs.length - 1]!.v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a nation's aggregate {@link NationStats} from the stats of the regions
|
||||
* it owns: each of the four stats is the **area×population-weighted median**
|
||||
* (see {@link weightedMedian}) over the nation's owned region keys. A region's
|
||||
* weight is `(area > 0 ? area : 1) × (population > 0 ? population : 1)` — both
|
||||
* factors fall back to 1 when the region has no geo entry (`geoOf` returns
|
||||
* `undefined` for it), so every region carries weight ≥ 1 and a missing geo
|
||||
* degrades gracefully to a uniform (unweighted) median. A region key with no
|
||||
* `regionStats` entry is skipped. Returns the zero stats when the nation owns
|
||||
* no region with a stats entry (the caller's 0-region guard keeps the nation's
|
||||
* directly-maintained `n.stats` in that case). Pure + shared.
|
||||
*/
|
||||
export function deriveNationStats(
|
||||
regionKeys: string[],
|
||||
regionStats: Record<string, RegionStats>,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): NationStats {
|
||||
const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
|
||||
const byStat: Record<StatKey, number[]> = {
|
||||
military: [],
|
||||
economy: [],
|
||||
stability: [],
|
||||
population: [],
|
||||
};
|
||||
const weights: number[] = [];
|
||||
for (const key of regionKeys) {
|
||||
const rs = regionStats[key];
|
||||
if (!rs) continue; // a key with no per-region stats is skipped
|
||||
const g = geoOf?.(key);
|
||||
const a = g && g.area > 0 ? g.area : 1;
|
||||
const p = g && g.population > 0 ? g.population : 1;
|
||||
weights.push(a * p);
|
||||
for (const k of STAT_KEYS) byStat[k].push(rs[k]);
|
||||
}
|
||||
if (weights.length === 0) return zero;
|
||||
const out: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
|
||||
for (const k of STAT_KEYS) out[k] = clampStat(weightedMedian(byStat[k], weights));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a per-region stats table from a set of nations (used both at game
|
||||
* creation and by the legacy migration): every owned region key gets a **copy**
|
||||
* of its owner's current `NationStats`, so a fresh nation's regions all start
|
||||
* identical (the weighted median of identical values is that value, so the
|
||||
* derived `n.stats` equals the seeded one). A region with two or more owners
|
||||
* (corrupt) is seeded by the first owner encountered; a key owned by nobody gets
|
||||
* no entry. Pure + shared.
|
||||
*/
|
||||
export function seedRegionStats(nations: Record<string, Nation>): Record<string, RegionStats> {
|
||||
const out: Record<string, RegionStats> = {};
|
||||
for (const n of Object.values(nations)) {
|
||||
for (const key of n.territory) {
|
||||
if (!(key in out)) out[key] = { ...n.stats }; // first owner wins (sole ownership holds normally)
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.28, #46) One-time migration of a **legacy** (pre-v0.28) `GameState` row
|
||||
* into the per-region-stats model, returning a new state (the input is never
|
||||
* mutated):
|
||||
* - a row that already has `regionStats` (a v0.28+ save) is returned
|
||||
* **UNCHANGED** — its `Nation.stats` are already the correct derived medians
|
||||
* and must not be recomputed on load (that would drift them);
|
||||
* - a legacy row (`regionStats` absent at runtime) seeds every owned region key
|
||||
* with a copy of its owner's current stats (so each nation's regions start
|
||||
* identical), then recomputes each nation's stats as the weighted median of
|
||||
* its regions (a 0-region nation keeps its stats). Without `geoOf` the
|
||||
* weighting is uniform, which is weight-invariant for the identical-value
|
||||
* case, so the recomputed stats equal the originals — the migration is a
|
||||
* no-op on the visible numbers and only materializes the (previously
|
||||
* implicit) per-region values. Never throws.
|
||||
*/
|
||||
export function migrateRegionStats(
|
||||
state: GameState,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): GameState {
|
||||
// A v0.28+ row already carries per-region stats: leave it exactly as saved.
|
||||
if (state.regionStats !== undefined) return state;
|
||||
// Legacy row: materialize per-region stats from each nation's (uniform) stats.
|
||||
const next: GameState = { ...state, regionStats: seedRegionStats(state.nations) };
|
||||
for (const n of Object.values(next.nations)) {
|
||||
if (n.territory.length === 0) continue; // 0-region guard: keep its stats
|
||||
if (n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+140
-1
@@ -13,6 +13,19 @@ export interface NationStats {
|
||||
population: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats
|
||||
* are the unit of record: a nation's aggregate {@link NationStats} is derived as
|
||||
* the area×population-weighted median of the stats of the regions it owns (see
|
||||
* `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine.
|
||||
*/
|
||||
export interface RegionStats {
|
||||
military: number;
|
||||
economy: number;
|
||||
stability: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||
export type StatKey = (typeof STAT_KEYS)[number];
|
||||
|
||||
@@ -66,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
|
||||
'cultural',
|
||||
];
|
||||
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||
/**
|
||||
* The outcome of a narrative event. `success` / `failure` / `partial` /
|
||||
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
|
||||
* reserved for the engine: it is emitted automatically when the military hard
|
||||
* gate rejects a contested conquest (an invasion whose attacker was more than
|
||||
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
|
||||
* and the client renders it in a distinct muted-red failure tone.
|
||||
*/
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
|
||||
|
||||
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||
//
|
||||
@@ -122,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;
|
||||
@@ -217,6 +307,55 @@ export interface GameState {
|
||||
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||
*/
|
||||
markers: MapMarker[];
|
||||
/**
|
||||
* (v0.28, #46) Per-region stats, keyed by **region key** (see
|
||||
* `public/region-keys.json`): each owned region carries its own four 0–100
|
||||
* stats, and a nation's aggregate `Nation.stats` is the area×population-
|
||||
* weighted median of the regions it owns (see `protocol.deriveNationStats`).
|
||||
* A region that ends a turn **unowned** (released to no man's land) has NO
|
||||
* entry, and a preset-unassigned region (never owned) has none until first
|
||||
* claimed (re-claiming re-inits it to the gainer's stats). Always present on a
|
||||
* fresh game (every owned region seeded with its owner's initial stats); a
|
||||
* legacy SQLite row without the field is migrated at load (see server/db.ts)
|
||||
* — 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+129
-2
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -707,6 +833,7 @@ function GameScreen({
|
||||
presetId={game.presetId}
|
||||
unowned={game.unowned ?? []}
|
||||
markers={game.markers ?? []}
|
||||
regionStats={game.regionStats ?? {}}
|
||||
onFocusNation={focusNation}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type RegionMap,
|
||||
type RegionOwner,
|
||||
} from '@shared/protocol';
|
||||
import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types';
|
||||
import { MARKER_TYPES, type MapMarker, type Nation, type RegionStats } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { idToKey } from '@/lib/regions';
|
||||
|
||||
@@ -410,11 +410,29 @@ function hoverHtml(
|
||||
region?: RegionOwner,
|
||||
nation?: Nation,
|
||||
noMansLand = false,
|
||||
regionKey?: string,
|
||||
regionStats?: Record<string, RegionStats>,
|
||||
): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
||||
}
|
||||
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
|
||||
// derived aggregate), looked up by region key. When the region has no stats
|
||||
// entry (defensive — an owned region should always have one) every stat
|
||||
// shows a placeholder dash. The dossier (NationPanel) still shows the
|
||||
// nation's derived stats, so this tooltip is the one place a single region's
|
||||
// numbers are visible on the map.
|
||||
const rs = regionKey ? regionStats?.[regionKey] : undefined;
|
||||
const row = (label: string, v?: number): string =>
|
||||
rs == null
|
||||
? `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">–</span></div>`
|
||||
: statRow(label, v);
|
||||
const stats = [
|
||||
row('Mil', rs?.military),
|
||||
row('Eco', rs?.economy),
|
||||
row('Stab', rs?.stability),
|
||||
row('Pop', rs?.population),
|
||||
].join('');
|
||||
return `<div class="hm-popup">
|
||||
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
|
||||
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||
@@ -453,6 +471,14 @@ interface Props {
|
||||
* a top-most symbol layer (per-type glyph + name, per-type color).
|
||||
*/
|
||||
markers?: MapMarker[];
|
||||
/**
|
||||
* (v0.29, #46 PR 2) The game's per-region stats (`GameState.regionStats`,
|
||||
* keyed by region key). Used only by the hover tooltip to show a single
|
||||
* region's own four 0–100 stats (a region with no entry shows placeholders).
|
||||
* The dossier (NationPanel) is unaffected — it keeps the nation's derived
|
||||
* stats.
|
||||
*/
|
||||
regionStats?: Record<string, RegionStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,6 +535,7 @@ export default function WorldMap({
|
||||
unowned,
|
||||
onFocusNation,
|
||||
markers,
|
||||
regionStats,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
@@ -524,6 +551,8 @@ export default function WorldMap({
|
||||
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||
const onFocusNationRef = useRef(onFocusNation);
|
||||
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||||
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
|
||||
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
|
||||
regionsRef.current = regions;
|
||||
nationsRef.current = nations;
|
||||
playerRef.current = playerNationId;
|
||||
@@ -531,6 +560,7 @@ export default function WorldMap({
|
||||
unownedRef.current = new Set(unowned ?? []);
|
||||
onFocusNationRef.current = onFocusNation;
|
||||
markersRef.current = markers ?? [];
|
||||
regionStatsRef.current = regionStats ?? {};
|
||||
|
||||
// Diffing state for the transient territory-change flash (owned by this component
|
||||
// so a resync with identical data never re-flashes).
|
||||
@@ -689,11 +719,11 @@ export default function WorldMap({
|
||||
// (v0.22, #16 P3) whether this region is no man's land (released
|
||||
// mid-game), so the ownerless tooltip can say so.
|
||||
const pKey = f.properties?.regionKey;
|
||||
const noMansLand =
|
||||
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
|
||||
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
||||
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
||||
failure: '#ef4444',
|
||||
partial: '#f59e0b',
|
||||
neutral: '#94a3b8',
|
||||
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
|
||||
// reads as a defensive failure distinct from a bright outright `failure`.
|
||||
repelled: '#9f1239',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user