Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b | ||
|
|
073e916767 | ||
|
|
396702b9da | ||
|
|
832957c635 | ||
|
|
68402694c5 | ||
|
|
2191514db1 | ||
|
|
5d194636d0 | ||
|
|
a93ece9474 | ||
|
|
44e54470e2 | ||
|
|
cdabf16052 | ||
|
|
7fbc00ebbe | ||
|
|
22805a3be6 |
@@ -56,7 +56,7 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── 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; 225 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── 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)
|
│ ├── 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-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-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;
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
│ │ # 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
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||||
│ # Jump + NationOp confirm/reject request types + pure helpers
|
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||||
|
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
|
||||||
|
│ # seedRegionStats / migrateRegionStats)
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||||
│ │ # PORT/LLM_*/DATA_DIR
|
│ │ # 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
|
│ ├── 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
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||||
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||||
│ ├── presets.ts # load preset JSON files from disk
|
│ ├── 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)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
└── src/
|
└── src/
|
||||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
├── 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 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 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 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 typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `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 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` | (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 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-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` |
|
| `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.
|
**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,61 @@ history/
|
|||||||
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
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`,
|
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`).
|
`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`).
|
||||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
- **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
|
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
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
|||||||
@@ -2,6 +2,104 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- **#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 }`.
|
- **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",
|
"name": "history",
|
||||||
"version": "0.27.0",
|
"version": "0.33.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"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-keys": "node scripts/build-region-keys.mjs",
|
||||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
"build:region-centroids": "node scripts/build-region-centroids.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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"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,13 @@ function makeState(id: string): GameState {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +116,39 @@ describe('openDb', () => {
|
|||||||
db.close();
|
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('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
+13
-1
@@ -2,6 +2,7 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import { migrateRegionStats } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
@@ -39,7 +40,7 @@ export function openDb(filePath: string): Db {
|
|||||||
const row = byId.get(id) as { data: string } | undefined;
|
const row = byId.get(id) as { data: string } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
try {
|
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
|
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||||
if (state.unowned === undefined) state.unowned = [];
|
if (state.unowned === undefined) state.unowned = [];
|
||||||
@@ -48,6 +49,17 @@ export function openDb(filePath: string): Db {
|
|||||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||||
if (state.markers === undefined) state.markers = [];
|
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.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;
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+1560
-34
File diff suppressed because it is too large
Load Diff
+655
-23
@@ -12,13 +12,17 @@ import type {
|
|||||||
NationStats,
|
NationStats,
|
||||||
Preset,
|
Preset,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RegionStats,
|
||||||
RoundItem,
|
RoundItem,
|
||||||
StatKey,
|
StatKey,
|
||||||
|
War,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
canConquer,
|
||||||
clampStat,
|
clampStat,
|
||||||
|
deriveNationStats,
|
||||||
fnv1a32,
|
fnv1a32,
|
||||||
isDateLike,
|
isDateLike,
|
||||||
isMarkerType,
|
isMarkerType,
|
||||||
@@ -29,8 +33,13 @@ import {
|
|||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
|
type RegionStatChange,
|
||||||
|
type Takeover,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
|
type WarDeclareOp,
|
||||||
|
type WarEndOp,
|
||||||
|
type WarInfo,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
@@ -186,6 +195,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
||||||
' ],',
|
' ],',
|
||||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||||
|
' "regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
||||||
' "unowned": ["<region key>"],',
|
' "unowned": ["<region key>"],',
|
||||||
@@ -194,9 +204,16 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||||
' ],',
|
' ],',
|
||||||
|
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||||
|
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
|
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
|
||||||
|
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
|
||||||
|
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
|
||||||
|
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
|
||||||
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and region keys that exist in the provided state.',
|
'- Use only nation ids and region keys that exist in the provided state.',
|
||||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||||
@@ -206,9 +223,12 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||||
|
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||||
|
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
|
'- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.',
|
||||||
...territoryRules,
|
...territoryRules,
|
||||||
'- If a field is not relevant this turn, set it to an empty array.',
|
'- If a field is not relevant this turn, set it to an empty array.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -249,6 +269,13 @@ export function buildUserPrompt(
|
|||||||
* Omitted entirely when undefined.
|
* Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
markers?: MarkerInfo[],
|
markers?: MarkerInfo[],
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
|
||||||
|
* stable id, sides and aims. Emitted verbatim (even when empty) so the
|
||||||
|
* model can reference a war's id in `TurnResult.wars.end` to end it.
|
||||||
|
* Omitted entirely when undefined.
|
||||||
|
*/
|
||||||
|
wars?: WarInfo[],
|
||||||
): string {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -262,6 +289,76 @@ export function buildUserPrompt(
|
|||||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||||
|
|
||||||
|
// (v0.29, #46 PR 2) The player's own regions' current per-region stats, so the
|
||||||
|
// model can reason about — and target via `regionChanges` — the player's
|
||||||
|
// individual regions. Bounded to the player's territory (not the whole
|
||||||
|
// world's 1,800 regions) to keep the prompt small. Always emitted: {} when
|
||||||
|
// the player has no region with a stats entry yet.
|
||||||
|
const playerRegionStats: Record<string, RegionStats> = {};
|
||||||
|
if (state.regionStats) {
|
||||||
|
for (const key of player?.territory ?? []) {
|
||||||
|
const rs = state.regionStats[key];
|
||||||
|
if (rs) playerRegionStats[key] = rs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
|
||||||
|
// so the model — and, via the same data, the player — sees the hard gate
|
||||||
|
// before it fires: for each frontier neighbor, compare the player's derived
|
||||||
|
// military to the neighbor's and state whether a direct invasion would be
|
||||||
|
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
|
||||||
|
// emitted only when a non-empty frontier is present, and the rest of the
|
||||||
|
// payload is left untouched.
|
||||||
|
let frontierMilitary: string[] | undefined;
|
||||||
|
if (frontier !== undefined && frontier.length > 0) {
|
||||||
|
const playerMil = player?.stats.military ?? 0;
|
||||||
|
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const k of n.territory) ownerByKey.set(k, n.id);
|
||||||
|
}
|
||||||
|
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
|
||||||
|
for (const k of frontier) {
|
||||||
|
const ownerId = ownerByKey.get(k);
|
||||||
|
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
|
||||||
|
const owner = state.nations[ownerId];
|
||||||
|
if (!owner) continue;
|
||||||
|
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
|
||||||
|
}
|
||||||
|
if (byNeighbor.size > 0) {
|
||||||
|
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
|
||||||
|
const o = state.nations[ownerId];
|
||||||
|
const wins = canConquer(playerMil, mil);
|
||||||
|
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
|
||||||
|
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||||||
|
// AND that currently have a "gathering" marker on one of their regions are
|
||||||
|
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||||||
|
// seizure) — the model can act on them this turn. One line per matching
|
||||||
|
// nation, emitted only when there is at least one; the rest of the payload
|
||||||
|
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||||
|
let takeoverRisks: string[] | undefined;
|
||||||
|
if (markers !== undefined && markers.length > 0) {
|
||||||
|
const risks = new Set<string>();
|
||||||
|
for (const m of markers) {
|
||||||
|
if (m.type !== 'gathering') continue;
|
||||||
|
const ownerId = m.owner;
|
||||||
|
if (!ownerId) continue;
|
||||||
|
const n = state.nations[ownerId];
|
||||||
|
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||||
|
}
|
||||||
|
if (risks.size > 0) {
|
||||||
|
takeoverRisks = [...risks].map((id) => {
|
||||||
|
const n = state.nations[id];
|
||||||
|
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
state: {
|
state: {
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
@@ -283,11 +380,15 @@ export function buildUserPrompt(
|
|||||||
playerActions,
|
playerActions,
|
||||||
pending: items,
|
pending: items,
|
||||||
roundInstruction,
|
roundInstruction,
|
||||||
|
playerRegionStats,
|
||||||
};
|
};
|
||||||
if (regionReference) payload.regions = regionReference;
|
if (regionReference) payload.regions = regionReference;
|
||||||
if (frontier !== undefined) payload.frontier = frontier;
|
if (frontier !== undefined) payload.frontier = frontier;
|
||||||
|
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||||||
|
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||||
if (markers !== undefined) payload.markers = markers;
|
if (markers !== undefined) payload.markers = markers;
|
||||||
|
if (wars !== undefined) payload.wars = wars;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +428,13 @@ function extractBalancedObject(text: string): string | null {
|
|||||||
/**
|
/**
|
||||||
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
||||||
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
||||||
|
* (v0.29, #46 PR 2) The optional `regionKeyResolver` is threaded to
|
||||||
|
* `validateTurnResult` to resolve `regionChanges` refs to region keys.
|
||||||
*/
|
*/
|
||||||
export function parseTurnResult(raw: string): TurnResult | null {
|
export function parseTurnResult(
|
||||||
|
raw: string,
|
||||||
|
regionKeyResolver?: (ref: string) => string | null,
|
||||||
|
): TurnResult | null {
|
||||||
const cleaned = stripFence(raw);
|
const cleaned = stripFence(raw);
|
||||||
const jsonText = extractBalancedObject(cleaned);
|
const jsonText = extractBalancedObject(cleaned);
|
||||||
if (!jsonText) return null;
|
if (!jsonText) return null;
|
||||||
@@ -338,7 +444,7 @@ export function parseTurnResult(raw: string): TurnResult | null {
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return validateTurnResult(obj);
|
return validateTurnResult(obj, regionKeyResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
function str(s: unknown, max = 0): string | null {
|
function str(s: unknown, max = 0): string | null {
|
||||||
@@ -399,18 +505,29 @@ export function validateNationOps(raw: unknown): NationOp[] {
|
|||||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||||
* only when the input is not even a plain object.
|
* only when the input is not even a plain object.
|
||||||
|
*
|
||||||
|
* (v0.29, #46 PR 2) The optional `regionKeyResolver` resolves each
|
||||||
|
* `regionChanges` ref (key or name) to a concrete region key, dropping
|
||||||
|
* unknown/ambiguous refs; without it the refs are kept as-is (shape-only), so
|
||||||
|
* callers that build a TurnResult by hand (tests) need not supply it.
|
||||||
*/
|
*/
|
||||||
export function validateTurnResult(input: unknown): TurnResult | null {
|
export function validateTurnResult(
|
||||||
|
input: unknown,
|
||||||
|
regionKeyResolver?: (ref: string) => string | null,
|
||||||
|
): TurnResult | null {
|
||||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
||||||
const o = input as Record<string, unknown>;
|
const o = input as Record<string, unknown>;
|
||||||
const result: TurnResult = {
|
const result: TurnResult = {
|
||||||
date: typeof o.date === 'string' ? o.date : '',
|
date: typeof o.date === 'string' ? o.date : '',
|
||||||
events: [],
|
events: [],
|
||||||
stateChanges: [],
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -501,11 +618,25 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
|||||||
// list for the player to confirm/reject.
|
// list for the player to confirm/reject.
|
||||||
result.nationOps = validateNationOps(o.nationOps);
|
result.nationOps = validateNationOps(o.nationOps);
|
||||||
|
|
||||||
|
// (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly
|
||||||
|
// by applyTurnResult (automatic, not player-confirmed).
|
||||||
|
result.takeovers = validateTakeovers(o.takeovers);
|
||||||
|
|
||||||
|
// (v0.33.0, #56) War declare/end ops: creating and ending named Wars.
|
||||||
|
// Applied directly by applyTurnResult (after takeovers, before markers).
|
||||||
|
result.wars = validateWarOps(o.wars);
|
||||||
|
|
||||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||||
// removals by stable id. Applied by applyTurnResult after the territory
|
// removals by stable id. Applied by applyTurnResult after the territory
|
||||||
// moves (a new marker's owner is the region's post-turn owner).
|
// moves (a new marker's owner is the region's post-turn owner).
|
||||||
result.markers = validateMarkerOps(o.markers);
|
result.markers = validateMarkerOps(o.markers);
|
||||||
|
|
||||||
|
// (v0.29, #46 PR 2) Per-region stat changes: each targets a single region's
|
||||||
|
// stats (by key or name). Resolved to a key here when a resolver is
|
||||||
|
// supplied; applied by applyTurnResult after the nation-wide stateChanges
|
||||||
|
// and before the derived-stat recompute.
|
||||||
|
result.regionChanges = validateRegionChanges(o.regionChanges, regionKeyResolver);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,20 +677,230 @@ export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: st
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.29, #46 PR 2) Validate the raw `regionChanges` array of a turn into a
|
||||||
|
* safe {@link RegionStatChange} list — the fine-grained, per-region stat lever
|
||||||
|
* that complements the nation-wide `stateChanges`. Defensive like the rest of
|
||||||
|
* the parser — never throws. Each entry needs a non-empty `region` (the
|
||||||
|
* region's stable key, preferred, or its human-readable name — capped at 120
|
||||||
|
* chars), a known stat, a finite numeric `value`, and an op of `set` or `add`.
|
||||||
|
*
|
||||||
|
* When `regionKeyResolver` is provided, each `region` ref is resolved to a
|
||||||
|
* concrete region key (key first, then a unique case-insensitive name match —
|
||||||
|
* see {@link resolveRegionKey}) and the resolved key is stored; a ref that
|
||||||
|
* resolves to `null` (unknown or ambiguous) is dropped, exactly like an
|
||||||
|
* unresolvable nation ref. When the resolver is absent (pure unit tests), the
|
||||||
|
* ref is kept as-is (shape-only validation) — a name that is not a key simply
|
||||||
|
* no-ops later in `applyTurnResult` (its `regionStats` lookup misses). `set`
|
||||||
|
* values are clamped to 0–100 here; `add` deltas are clamped against the
|
||||||
|
* current value at apply time (they may legitimately exceed 100 in the raw
|
||||||
|
* input, since they are added to an existing 0–100 value).
|
||||||
|
*/
|
||||||
|
export function validateRegionChanges(
|
||||||
|
raw: unknown,
|
||||||
|
regionKeyResolver?: (ref: string) => string | null,
|
||||||
|
): RegionStatChange[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: RegionStatChange[] = [];
|
||||||
|
for (const e of raw) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const c = e as Record<string, unknown>;
|
||||||
|
const region = str(c.region, 120);
|
||||||
|
const stat = str(c.stat);
|
||||||
|
if (!region || !stat || !VALID_STATS.has(stat)) continue;
|
||||||
|
if (typeof c.value !== 'number' || !Number.isFinite(c.value)) continue;
|
||||||
|
if (c.op !== 'set' && c.op !== 'add') continue;
|
||||||
|
// Resolve the ref to a key when a resolver is available; drop it when the
|
||||||
|
// resolver says the ref is unknown or ambiguous (never throw). Without a
|
||||||
|
// resolver the ref is kept as-is (a name that is not a key no-ops later in
|
||||||
|
// applyTurnResult, whose regionStats lookup simply misses it).
|
||||||
|
let finalRegion: string;
|
||||||
|
if (regionKeyResolver) {
|
||||||
|
const resolved = regionKeyResolver(region);
|
||||||
|
if (resolved === null) continue; // unknown or ambiguous → drop
|
||||||
|
finalRegion = resolved;
|
||||||
|
} else {
|
||||||
|
finalRegion = region;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
region: finalRegion,
|
||||||
|
stat: stat as StatKey,
|
||||||
|
op: c.op,
|
||||||
|
value: c.op === 'set' ? clampStat(c.value) : c.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||||
|
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||||
|
* revolution that renames a nation to its new regime name). Defensive like
|
||||||
|
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||||
|
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||||
|
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||||
|
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||||
|
* here. Entries with either field missing or whitespace-only are dropped.
|
||||||
|
*/
|
||||||
|
export function validateTakeovers(raw: unknown): Takeover[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: Takeover[] = [];
|
||||||
|
for (const e of raw) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
const nationRef = str(o.nationRef, 200);
|
||||||
|
const newRegimeName = str(o.newRegimeName, 200);
|
||||||
|
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
|
||||||
|
* `{ declare, end }` shape. Defensive like the rest of the parser — never
|
||||||
|
* throws. `declare`: each entry must be an object whose `sides` is an array
|
||||||
|
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
|
||||||
|
* yields at least TWO distinct sides — anything else (a one-sided or
|
||||||
|
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
|
||||||
|
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
|
||||||
|
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
|
||||||
|
* of exactly `armistice` or `capitulation` (anything else is dropped);
|
||||||
|
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
|
||||||
|
* input yields `{ declare: [], end: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
|
||||||
|
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(o.declare)) {
|
||||||
|
for (const e of o.declare) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const d = e as Record<string, unknown>;
|
||||||
|
const sides = (Array.isArray(d.sides) ? d.sides : [])
|
||||||
|
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
|
||||||
|
.filter((s) => s !== '')
|
||||||
|
// dedupe case-insensitively? No — sides are nation ids: keep exact
|
||||||
|
// strings, drop exact duplicates only (resolution is later).
|
||||||
|
.filter((s, i, arr) => arr.indexOf(s) === i);
|
||||||
|
if (sides.length < 2) continue; // a war needs at least two belligerents
|
||||||
|
const aims = Array.isArray(d.aims)
|
||||||
|
? (d.aims as unknown[])
|
||||||
|
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
|
||||||
|
.filter((a) => a !== '')
|
||||||
|
.slice(0, 5)
|
||||||
|
: undefined;
|
||||||
|
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.end)) {
|
||||||
|
for (const e of o.end) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const w = e as Record<string, unknown>;
|
||||||
|
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
|
||||||
|
if (!id) continue;
|
||||||
|
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
|
||||||
|
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
|
||||||
|
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||||
|
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||||
|
* {b,c} (any two distinct belligerents of the same active war are at war —
|
||||||
|
* see the War type's semantics). Pure; exported for the unit tests.
|
||||||
|
*/
|
||||||
|
export function activeWarPairs(wars: War[]): Set<string> {
|
||||||
|
const pairs = new Set<string>();
|
||||||
|
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||||
|
for (const w of wars) {
|
||||||
|
if (w.status !== 'active') continue;
|
||||||
|
for (let i = 0; i < w.sides.length; i++) {
|
||||||
|
for (let j = i + 1; j < w.sides.length; j++) {
|
||||||
|
pairs.add(key(w.sides[i], w.sides[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
|
||||||
|
export function atWar(wars: War[], a: string, b: string): boolean {
|
||||||
|
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||||
|
return activeWarPairs(wars).has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
|
||||||
|
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
|
||||||
|
* applyTurnResult does) and never touching `prev`. For every unordered pair
|
||||||
|
* of distinct existing nations:
|
||||||
|
*
|
||||||
|
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
|
||||||
|
* of the pair's relation are set to `war` (the engine owns this relation
|
||||||
|
* while the war is active — a manual `relations` entry to a lesser status
|
||||||
|
* on a warring pair is overridden, so the two can never "make peace"
|
||||||
|
* without the war being ended);
|
||||||
|
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
|
||||||
|
* wars that tied them ended this turn) AND the current relation is
|
||||||
|
* `war` → both directions become `hostile` (a war is the strongest
|
||||||
|
* hostility — when it stops, the nations stay hostile, not suddenly
|
||||||
|
* neutral);
|
||||||
|
* - otherwise (not at war now, and the relation is not `war`, or the pair
|
||||||
|
* was never tied by an active war) → untouched: the LLM owns
|
||||||
|
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
|
||||||
|
* no active war ever covered (a preset leftover) stays as-is — an
|
||||||
|
* implicit, unnamed war, never force-created into a War entity and never
|
||||||
|
* clobbered by the sync.
|
||||||
|
*
|
||||||
|
* Never throws.
|
||||||
|
*/
|
||||||
|
export function syncWarRelations(prev: GameState, next: GameState): void {
|
||||||
|
const prevAtWar = activeWarPairs(prev.wars ?? []);
|
||||||
|
const nextAtWar = activeWarPairs(next.wars ?? []);
|
||||||
|
const ids = Object.keys(next.nations);
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
for (let j = i + 1; j < ids.length; j++) {
|
||||||
|
const a = ids[i];
|
||||||
|
const b = ids[j];
|
||||||
|
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||||
|
const na = next.nations[a];
|
||||||
|
const nb = next.nations[b];
|
||||||
|
if (nextAtWar.has(key)) {
|
||||||
|
na.relations[b] = 'war';
|
||||||
|
nb.relations[a] = 'war';
|
||||||
|
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
|
||||||
|
na.relations[b] = 'hostile';
|
||||||
|
nb.relations[a] = 'hostile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||||
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
|
||||||
* a proposal is a real pending-world change the player will resolve, so it
|
* a proposal is a real pending-world change the player will resolve, so it
|
||||||
* must survive the retry/nudge path instead of being replaced by the generic
|
* must survive the retry/nudge path instead of being replaced by the generic
|
||||||
* "unclear" result).
|
* "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended
|
||||||
|
* (v0.33.0 #56: both are real world changes).
|
||||||
*/
|
*/
|
||||||
export function isMeaningful(result: TurnResult): boolean {
|
export function isMeaningful(result: TurnResult): boolean {
|
||||||
return (
|
return (
|
||||||
result.events.length > 0 ||
|
result.events.length > 0 ||
|
||||||
result.stateChanges.length > 0 ||
|
result.stateChanges.length > 0 ||
|
||||||
|
// (v0.29, #46 PR 2) a per-region stat change is a real world change.
|
||||||
|
result.regionChanges.length > 0 ||
|
||||||
result.unowned.length > 0 ||
|
result.unowned.length > 0 ||
|
||||||
result.nationOps.length > 0 ||
|
result.nationOps.length > 0 ||
|
||||||
|
// (v0.32.0, #54) a takeover is a real world change (regime rename).
|
||||||
|
result.takeovers.length > 0 ||
|
||||||
|
// (v0.33.0, #56) a war declared or ended is a real world change.
|
||||||
|
result.wars.declare.length > 0 ||
|
||||||
|
result.wars.end.length > 0 ||
|
||||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||||
result.markers.add.length > 0 ||
|
result.markers.add.length > 0 ||
|
||||||
result.markers.remove.length > 0
|
result.markers.remove.length > 0
|
||||||
@@ -592,15 +933,32 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* deterministic offset; a spec whose key has no known centroid is dropped.
|
* deterministic offset; a spec whose key has no known centroid is dropped.
|
||||||
* Without it no marker can be placed (all adds are dropped), which keeps the
|
* Without it no marker can be placed (all adds are dropped), which keeps the
|
||||||
* engine pure and lets tests inject their own data.
|
* engine pure and lets tests inject their own data.
|
||||||
|
*
|
||||||
|
* `geoOf` (v0.28, #46) resolves a region key to its area + population
|
||||||
|
* (from public/region-geo.json via server/regions.ts), used to weight the
|
||||||
|
* end-of-turn recompute of each nation's derived stats as the
|
||||||
|
* area×population-weighted median of its regions' stats. Without it the
|
||||||
|
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||||
|
* region-consistent, just unweighted. The engine stays pure either way.
|
||||||
|
*
|
||||||
|
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
|
||||||
|
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||||||
|
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||||||
|
* the raw key. Pure either way.
|
||||||
*/
|
*/
|
||||||
export function applyTurnResult(
|
export function applyTurnResult(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
result: TurnResult,
|
result: TurnResult,
|
||||||
validKeys?: ReadonlySet<string>,
|
validKeys?: ReadonlySet<string>,
|
||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
regionNameOf?: (key: string) => string | undefined,
|
||||||
): GameState {
|
): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
|
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||||||
|
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||||||
|
next.regionStats = next.regionStats ?? {};
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||||
@@ -609,11 +967,6 @@ export function applyTurnResult(
|
|||||||
for (const ev of result.events) {
|
for (const ev of result.events) {
|
||||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||||
}
|
}
|
||||||
for (const c of result.stateChanges) {
|
|
||||||
const n = next.nations[c.nation];
|
|
||||||
if (!n) continue;
|
|
||||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
|
||||||
}
|
|
||||||
for (const r of result.relations) {
|
for (const r of result.relations) {
|
||||||
const a = next.nations[r.a];
|
const a = next.nations[r.a];
|
||||||
const b = next.nations[r.b];
|
const b = next.nations[r.b];
|
||||||
@@ -663,6 +1016,10 @@ export function applyTurnResult(
|
|||||||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||||||
unowned.add(key);
|
unowned.add(key);
|
||||||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
||||||
|
// (v0.28, #46) a released region leaves no-man's land ownerless — its
|
||||||
|
// per-region stats leave the table with it (no man's land carries none;
|
||||||
|
// a later re-claim re-inits the entry from the gainer, see below).
|
||||||
|
delete next.regionStats[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// (2) Claims against the post-release ownership snapshot.
|
// (2) Claims against the post-release ownership snapshot.
|
||||||
@@ -703,16 +1060,192 @@ export function applyTurnResult(
|
|||||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||||
|
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||||||
|
// different sole owner) only proceeds when the attacker's derived military
|
||||||
|
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||||||
|
// re-claim of an unowned key (owner === undefined) is never gated. On
|
||||||
|
// repel, the key stays with its owner, a "repelled" event is emitted (the
|
||||||
|
// engine's own narrative — the model never sees it as an input), and the
|
||||||
|
// gainer is untouched; the engine never throws.
|
||||||
|
if (
|
||||||
|
owner !== undefined &&
|
||||||
|
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
|
||||||
|
) {
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: gainer,
|
||||||
|
type: 'military',
|
||||||
|
title: 'Invasion repelled',
|
||||||
|
text: `${next.nations[gainer].name}'s invasion of ${
|
||||||
|
regionNameOf?.(key) ?? key
|
||||||
|
} was repelled by ${next.nations[owner].name}.`,
|
||||||
|
outcome: 'repelled',
|
||||||
|
},
|
||||||
|
actingTurn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const n of Object.values(next.nations)) {
|
for (const n of Object.values(next.nations)) {
|
||||||
const i = n.territory.indexOf(key);
|
const i = n.territory.indexOf(key);
|
||||||
if (i !== -1) n.territory.splice(i, 1);
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
}
|
}
|
||||||
next.nations[gainer].territory.push(key);
|
next.nations[gainer].territory.push(key);
|
||||||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
unowned.delete(key); // re-claimed: the key leaves no man's land
|
||||||
|
// (v0.28, #46) a transferred region keeps its own per-region stats (the
|
||||||
|
// conquest is what makes a nation's regions diverge); only a re-claimed
|
||||||
|
// region that has no entry left (it was released earlier this turn) is
|
||||||
|
// re-inited as a copy of the gainer's current stats, so the recompute
|
||||||
|
// below never sees a hole in the table.
|
||||||
|
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
next.unowned = [...unowned];
|
next.unowned = [...unowned];
|
||||||
|
|
||||||
|
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
|
||||||
|
// the regions, then every nation's stats recomputed as the derived
|
||||||
|
// area×population-weighted median of its regions ---
|
||||||
|
//
|
||||||
|
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
|
||||||
|
// applying to EVERY region the nation owns: each owned region's copy of
|
||||||
|
// that stat gets the same `set`/`add` (clamped to 0–100), and the
|
||||||
|
// nation-level stat is updated too (it is kept as-is for nations the
|
||||||
|
// recompute below does not touch — see (b) — and overwritten for the
|
||||||
|
// rest, where the regions are the source of truth).
|
||||||
|
//
|
||||||
|
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
|
||||||
|
// median of its owned regions' stats (via deriveNationStats, weighted
|
||||||
|
// by area×population when `geoOf` is available, unweighted otherwise).
|
||||||
|
// Two guards keep degenerate states sane: a nation with NO regions
|
||||||
|
// keeps its stats untouched (it has no regions to aggregate), and a
|
||||||
|
// nation none of whose regions has a stats entry keeps its stats
|
||||||
|
// too (deriveNationStats would return zeros, which would be wrong).
|
||||||
|
// This recompute is what makes a conquered region's foreign stats
|
||||||
|
// blend into the conqueror's aggregate and keeps the derived stats in
|
||||||
|
// lockstep with the per-region table across the turn's changes.
|
||||||
|
for (const c of result.stateChanges) {
|
||||||
|
const n = next.nations[c.nation];
|
||||||
|
if (!n) continue;
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
|
||||||
|
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||||
|
}
|
||||||
|
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||||
|
}
|
||||||
|
// (v0.29, #46 PR 2) Fine-grained, per-region stat changes: each targets ONE
|
||||||
|
// region's own stats entry (by its resolved region key). Applied AFTER the
|
||||||
|
// nation-wide stateChanges (so a region-level change overrides the
|
||||||
|
// nation-level one for that single region) and BEFORE the weighted-median
|
||||||
|
// recompute below, which propagates every region's final value up into its
|
||||||
|
// owning nation's derived `stats`. A region with no stats entry (unowned /
|
||||||
|
// no man's land) is a no-op — there is nothing to change.
|
||||||
|
for (const c of result.regionChanges) {
|
||||||
|
const rs = next.regionStats[c.region];
|
||||||
|
if (!rs) continue; // no entry (unowned) → no-op
|
||||||
|
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||||
|
}
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||||
|
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Takeovers (v0.32.0, #54): non-military regime changes (coups /
|
||||||
|
// revolutions) that rename a nation to its new regime. Applied AFTER the
|
||||||
|
// per-region recompute (the rename doesn't depend on territory or stats,
|
||||||
|
// so it is order-independent with respect to those steps) and BEFORE the
|
||||||
|
// markers block (a marker's owner is a nation id, not a name, so the
|
||||||
|
// rename does not affect the owner recompute).
|
||||||
|
//
|
||||||
|
// For each takeover:
|
||||||
|
// 1. Resolve the target nation via `resolveNationRef` (id first, then
|
||||||
|
// case-insensitive unique name). Skip (no-op) if `null` — an
|
||||||
|
// unknown or ambiguous ref is silently dropped, never thrown.
|
||||||
|
// 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
|
||||||
|
// the display name changes — the stable id, territory, relations
|
||||||
|
// (keyed by id), stats, and `playerNationId` (which references the
|
||||||
|
// id, not the name) are all preserved. The player keeps leading an
|
||||||
|
// overthrown nation under its new regime.
|
||||||
|
for (const t of result.takeovers) {
|
||||||
|
const id = resolveNationRef(next, t.nationRef);
|
||||||
|
if (id === null) continue; // unknown / ambiguous ref: no-op
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue; // defensive: id resolved but nation missing (corrupt state)
|
||||||
|
n.name = t.newRegimeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wars (v0.33.0, #56: war as a first-class state) ---
|
||||||
|
//
|
||||||
|
// Wars are named entities (`GameState.wars`), not just a `war` relation.
|
||||||
|
// Two kinds of change, applied in this order against the (cloned) `next`:
|
||||||
|
//
|
||||||
|
// (1) DECLARE: each validated declare is resolved to concrete nation ids
|
||||||
|
// (`resolveNationRef`, so the LLM may use an id OR a unique name) and
|
||||||
|
// becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
|
||||||
|
// with the acting turn and the turn's new date. Mutually-defensive
|
||||||
|
// allies of the belligerents are DRAGGED IN: any nation that is an
|
||||||
|
// `ally` of a belligerent (per the current, post-relations relations)
|
||||||
|
// and is not already a side joins the war's `sides`. A declare with
|
||||||
|
// fewer than two DISTINCT resolved sides is dropped; a declare whose
|
||||||
|
// sides are all already contained in an existing ACTIVE war is a
|
||||||
|
// duplicate and is skipped (the existing war stands).
|
||||||
|
// (2) END: each validated end targets a war BY ID and sets its status to
|
||||||
|
// the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
|
||||||
|
// affected — an unknown id or an already-ended war is a no-op.
|
||||||
|
// (A war may be declared and ended in the SAME turn: declare runs
|
||||||
|
// first, so the just-declared id is a valid end target.)
|
||||||
|
// (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from
|
||||||
|
// the ACTIVE wars (see its doc) — the load-bearing step that keeps
|
||||||
|
// the relation table honest with the war entities.
|
||||||
|
//
|
||||||
|
// Nothing here throws: every unresolvable / invalid op is dropped.
|
||||||
|
{
|
||||||
|
const takenWarIds = new Set(next.wars.map((w) => w.id));
|
||||||
|
// (1) DECLARE
|
||||||
|
for (const d of result.wars.declare) {
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const ref of d.sides) {
|
||||||
|
const id = resolveNationRef(next, ref);
|
||||||
|
if (id !== null && !resolved.includes(id)) resolved.push(id);
|
||||||
|
}
|
||||||
|
if (resolved.length < 2) continue; // not enough distinct belligerents
|
||||||
|
// Duplicate: an active war already contains all of these sides.
|
||||||
|
const dup = next.wars.some(
|
||||||
|
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
|
||||||
|
);
|
||||||
|
if (dup) continue;
|
||||||
|
const sides = [...resolved];
|
||||||
|
// Mutual-defense drag-in: each belligerent's allies join the war.
|
||||||
|
for (const s of resolved) {
|
||||||
|
const rels = next.nations[s]?.relations ?? {};
|
||||||
|
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
|
||||||
|
if (!sides.includes(ally)) sides.push(ally);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = freshWarId(takenWarIds);
|
||||||
|
takenWarIds.add(id);
|
||||||
|
next.wars.push({
|
||||||
|
id,
|
||||||
|
sides,
|
||||||
|
declaredTurn: actingTurn,
|
||||||
|
declaredDate: next.date,
|
||||||
|
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// (2) END (by id; active only)
|
||||||
|
for (const e of result.wars.end) {
|
||||||
|
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
|
||||||
|
if (!w) continue; // unknown id / already ended: no-op
|
||||||
|
w.status = e.outcome;
|
||||||
|
}
|
||||||
|
// (3) RELATION SYNC (prev = the state this turn started from)
|
||||||
|
syncWarRelations(state, next);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Map markers (v0.27, #44) ---
|
// --- Map markers (v0.27, #44) ---
|
||||||
//
|
//
|
||||||
// Markers are anchored to a region key and are processed against the FINAL
|
// Markers are anchored to a region key and are processed against the FINAL
|
||||||
@@ -854,6 +1387,37 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
|
|||||||
return matches.length === 1 ? matches[0].id : null;
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
|
||||||
|
* `region` — the LLM may write either the region's stable **key** or its
|
||||||
|
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||||||
|
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
|
||||||
|
* when the reference is empty, unknown, or ambiguous (two or more regions
|
||||||
|
* share that name) — every caller treats `null` as "drop this change" and
|
||||||
|
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
|
||||||
|
* the set of all region keys and `nameOf` maps a key to its human name (both
|
||||||
|
* supplied by server/regions.ts).
|
||||||
|
*/
|
||||||
|
export function resolveRegionKey(
|
||||||
|
ref: string,
|
||||||
|
validKeys: ReadonlySet<string>,
|
||||||
|
nameOf: (key: string) => string | undefined,
|
||||||
|
): string | null {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (validKeys.has(r)) return r; // exact key first
|
||||||
|
const lower = r.toLowerCase();
|
||||||
|
let match: string | null = null;
|
||||||
|
for (const k of validKeys) {
|
||||||
|
const name = nameOf(k);
|
||||||
|
if (name && name.trim().toLowerCase() === lower) {
|
||||||
|
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||||||
|
match = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a fresh, unused nation id for a newly created nation (split
|
* Generate a fresh, unused nation id for a newly created nation (split
|
||||||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||||
@@ -867,6 +1431,19 @@ export function freshNationId(taken: ReadonlySet<string>): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh, unused war id for a newly declared {@link War}
|
||||||
|
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
|
||||||
|
* it creates to `taken` as it goes, so ids stay unique within a turn and are
|
||||||
|
* never reused from an ended war.
|
||||||
|
*/
|
||||||
|
export function freshWarId(taken: ReadonlySet<string>): string {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const id = `w${i}`;
|
||||||
|
if (!taken.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The baseline stats a freshly created nation starts with (split parts,
|
* The baseline stats a freshly created nation starts with (split parts,
|
||||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||||
@@ -1115,8 +1692,33 @@ export async function runTurn(opts: {
|
|||||||
* the existing markers' owners still get recomputed.
|
* the existing markers' owners still get recomputed.
|
||||||
*/
|
*/
|
||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
||||||
|
/**
|
||||||
|
* (v0.28, #46) Region-key -> area/population resolver (from
|
||||||
|
* public/region-geo.json). Present, the end-of-turn recompute of each
|
||||||
|
* nation's derived stats weights each owned region by area×population;
|
||||||
|
* absent, the recompute degrades to an unweighted (uniform) median —
|
||||||
|
* still region-consistent, just unweighted.
|
||||||
|
*/
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||||||
|
/**
|
||||||
|
* (v0.29, #46 PR 2) Region ref resolver: resolves a `regionChanges` entry's
|
||||||
|
* `region` (a region key, preferred, or a human-readable name) to a concrete
|
||||||
|
* region key, or `null` when the ref is unknown or ambiguous (two or more
|
||||||
|
* regions share the name) — the engine drops such entries. Present, the
|
||||||
|
* model may name a region by its name and it is resolved before apply;
|
||||||
|
* absent, refs are kept as-is (a name that is not a key simply no-ops in
|
||||||
|
* applyTurnResult's regionStats lookup).
|
||||||
|
*/
|
||||||
|
regionKeyResolver?: (ref: string) => string | null;
|
||||||
|
/**
|
||||||
|
* (v0.30, #50) Region-key -> human display name resolver (the geo
|
||||||
|
* `properties.name`, from server/regions.ts). Present, the engine's
|
||||||
|
* "invasion repelled" event names the region by its display name; absent,
|
||||||
|
* it falls back to the raw region key. Pure either way.
|
||||||
|
*/
|
||||||
|
regionNameOf?: (key: string) => string | undefined;
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||||
const chatWithTools = llm.chatWithTools;
|
const chatWithTools = llm.chatWithTools;
|
||||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||||
const system = buildSystemPrompt(preset, useTools);
|
const system = buildSystemPrompt(preset, useTools);
|
||||||
@@ -1132,7 +1734,25 @@ export async function runTurn(opts: {
|
|||||||
regionKey: m.regionKey,
|
regionKey: m.regionKey,
|
||||||
owner: m.ownerNationId,
|
owner: m.ownerNationId,
|
||||||
}));
|
}));
|
||||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
|
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
|
||||||
|
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
|
||||||
|
// ended wars stay in GameState.wars as history but are not surfaced.
|
||||||
|
const wars = (state.wars ?? [])
|
||||||
|
.filter((w) => w.status === 'active')
|
||||||
|
.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
sides: w.sides,
|
||||||
|
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||||
|
}));
|
||||||
|
const user = buildUserPrompt(
|
||||||
|
state,
|
||||||
|
items,
|
||||||
|
regionReference,
|
||||||
|
frontier,
|
||||||
|
state.unowned ?? [],
|
||||||
|
markers,
|
||||||
|
wars,
|
||||||
|
);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|
||||||
@@ -1154,18 +1774,24 @@ export async function runTurn(opts: {
|
|||||||
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = parseTurnResult(await call([
|
let result = parseTurnResult(
|
||||||
{ role: 'system', content: system },
|
await call([
|
||||||
{ role: 'user', content: user },
|
|
||||||
]));
|
|
||||||
|
|
||||||
if (result === null || !isMeaningful(result)) {
|
|
||||||
result = parseTurnResult(await call([
|
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
{ role: 'user', content: user },
|
{ role: 'user', content: user },
|
||||||
{ role: 'assistant', content: '' },
|
]),
|
||||||
{ role: 'user', content: nudge },
|
regionKeyResolver,
|
||||||
]));
|
);
|
||||||
|
|
||||||
|
if (result === null || !isMeaningful(result)) {
|
||||||
|
result = parseTurnResult(
|
||||||
|
await call([
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
{ role: 'assistant', content: '' },
|
||||||
|
{ role: 'user', content: nudge },
|
||||||
|
]),
|
||||||
|
regionKeyResolver,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null || !isMeaningful(result)) {
|
if (result === null || !isMeaningful(result)) {
|
||||||
@@ -1173,15 +1799,21 @@ export async function runTurn(opts: {
|
|||||||
date: addDays(state.date, 2),
|
date: addDays(state.date, 2),
|
||||||
events: [genericUnclearEvent(state)],
|
events: [genericUnclearEvent(state)],
|
||||||
stateChanges: [],
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result };
|
return {
|
||||||
|
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
||||||
|
result,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
+158
-7
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { deepClone } from '../shared/protocol';
|
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
@@ -17,13 +17,17 @@ import type {
|
|||||||
RejectNationOpRequest,
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState, Preset } from '../shared/types';
|
||||||
|
import type { WorldDraft } from '../shared/scenario';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { 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 { 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 { createPresetStore } from './presets';
|
||||||
|
import { createTemplateStore } from './templates';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||||
@@ -52,6 +56,8 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
|||||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
|
// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/).
|
||||||
|
const templateStore = createTemplateStore(path.join(root, 'presets', 'templates'));
|
||||||
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||||
// just means region expansion/reference degrade to the raw keys (server still boots).
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
loadRegions(root);
|
loadRegions(root);
|
||||||
@@ -59,6 +65,10 @@ const db: Db = openDb(path.join(dataDir, 'history.db'));
|
|||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||||
|
// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario
|
||||||
|
// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays
|
||||||
|
// untouched), and the live client otherwise.
|
||||||
|
const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void {
|
|||||||
|
|
||||||
// --- Routes ---
|
// --- Routes ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a
|
||||||
|
* dynamically generated game (`presetId: 'dynamic'` — there is no static preset
|
||||||
|
* file on disk). `runTurn` only consumes `title` / `startDate` /
|
||||||
|
* `worldBackground` / `simulationRules` (never `preset.nations`), so a generic
|
||||||
|
* alternate-history background plus the standard rules is sufficient; `nations`
|
||||||
|
* is carried from the saved world so the type is complete. `startDate` uses the
|
||||||
|
* game's current date (GameState stores no separate start date) — it only feeds
|
||||||
|
* the static "WORLD: <title> — <date>" header of the system prompt.
|
||||||
|
*/
|
||||||
|
function dynamicPresetFromGame(game: GameState): Preset {
|
||||||
|
return {
|
||||||
|
id: game.presetId,
|
||||||
|
title: game.presetTitle,
|
||||||
|
startDate: game.date,
|
||||||
|
summary: game.presetTitle,
|
||||||
|
worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`,
|
||||||
|
simulationRules: [
|
||||||
|
"Advance the shared world for ALL nations, not only the player's.",
|
||||||
|
'Keep every stat value between 0 and 100.',
|
||||||
|
'Only change borders when war or a decisive political shift justifies it.',
|
||||||
|
'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.',
|
||||||
|
'Use only nation ids and region keys that exist in the provided state.',
|
||||||
|
],
|
||||||
|
nations: Object.values(game.nations),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true, stub: useStub, port: PORT });
|
res.json({ ok: true, stub: useStub, port: PORT });
|
||||||
});
|
});
|
||||||
@@ -136,11 +174,22 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||||
markers: [],
|
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.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
// 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
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
// granularity from the very first turn. Preset JSONs stay country-level.
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
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);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -271,8 +320,14 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const game = db.load(id);
|
const game = db.load(id);
|
||||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
const preset = presetStore.get(game.presetId);
|
let preset = presetStore.get(game.presetId);
|
||||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
if (!preset) {
|
||||||
|
// (v0.31, #52) A dynamically generated game has no static preset file:
|
||||||
|
// synthesize one from the saved world so the turn engine can run. A
|
||||||
|
// genuinely-missing static preset still 404s.
|
||||||
|
if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' });
|
||||||
|
preset = dynamicPresetFromGame(game);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { state, result } = await runTurn({
|
const { state, result } = await runTurn({
|
||||||
llm,
|
llm,
|
||||||
@@ -295,6 +350,22 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
// centroid (without it, marker adds are dropped; existing markers are
|
// centroid (without it, marker adds are dropped; existing markers are
|
||||||
// still maintained).
|
// still maintained).
|
||||||
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
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);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
@@ -324,6 +395,86 @@ app.post('/api/games/:id/advisor', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Dynamic scenario generation (v0.31, #52) ---
|
||||||
|
//
|
||||||
|
// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft
|
||||||
|
// world + an advisor checkpoint list; the client shows both, the player
|
||||||
|
// approves/removes checkpoints and picks a nation; step 2 (POST /commit)
|
||||||
|
// finalizes the world (constrained to the approved checkpoints) into a saved,
|
||||||
|
// playable game. Both steps ride the scenario LLM client (the deterministic
|
||||||
|
// scenario stub under LLM_STUB=1, the live model otherwise). All existing
|
||||||
|
// routes are untouched.
|
||||||
|
|
||||||
|
app.get('/api/scenarios/templates', (_req, res) => {
|
||||||
|
res.json({ templates: templateStore.listMeta() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scenarios/draft', async (req, res) => {
|
||||||
|
const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number };
|
||||||
|
const template = templateStore.get(body.templateId ?? '');
|
||||||
|
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||||
|
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||||
|
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||||
|
const codes = validCountryCodes();
|
||||||
|
try {
|
||||||
|
const { world, checkpoints } = await generateDraft({
|
||||||
|
llm: scenarioLlm,
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
// No region data → no code filtering (the draft keeps whatever codes the
|
||||||
|
// model emitted; expansion degrades to the raw keys, like presets do).
|
||||||
|
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||||
|
});
|
||||||
|
if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' });
|
||||||
|
res.json({ world, checkpoints });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
res.status(502).json({ error: `generate failed: ${msg}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scenarios/commit', async (req, res) => {
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
templateId?: string;
|
||||||
|
description?: string;
|
||||||
|
year?: number;
|
||||||
|
draft?: WorldDraft;
|
||||||
|
approvedCheckpoints?: string[];
|
||||||
|
playerNationId?: string;
|
||||||
|
};
|
||||||
|
const template = templateStore.get(body.templateId ?? '');
|
||||||
|
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||||
|
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||||
|
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||||
|
const approvedCheckpoints = Array.isArray(body.approvedCheckpoints)
|
||||||
|
? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
|
||||||
|
: [];
|
||||||
|
const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : '';
|
||||||
|
const codes = validCountryCodes();
|
||||||
|
try {
|
||||||
|
const state = await finalizeWorld({
|
||||||
|
llm: scenarioLlm,
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
approvedCheckpoints,
|
||||||
|
draft: body.draft,
|
||||||
|
playerNationId,
|
||||||
|
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||||
|
expand: (cs) => expandCountriesToKeys(cs),
|
||||||
|
id: randomUUID(),
|
||||||
|
title: template.name,
|
||||||
|
startDate: `${year}-01-01`,
|
||||||
|
});
|
||||||
|
db.save(state);
|
||||||
|
res.json({ id: state.id });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
res.status(502).json({ error: `commit failed: ${msg}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- SSE stream ---
|
// --- SSE stream ---
|
||||||
app.get('/sse/:id', (req, res) => {
|
app.get('/sse/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
@@ -361,4 +512,4 @@ app.listen(PORT, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||||
export { app, db, llm };
|
export { app, db, llm, scenarioLlm, templateStore };
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ const state: GameState = {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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: [],
|
||||||
|
};
|
||||||
|
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,
|
allTerritoryKeys,
|
||||||
centroidOf,
|
centroidOf,
|
||||||
expandCountriesToKeys,
|
expandCountriesToKeys,
|
||||||
|
geoOf,
|
||||||
hasAdjacency,
|
hasAdjacency,
|
||||||
hasCentroids,
|
hasCentroids,
|
||||||
|
hasGeo,
|
||||||
hasRegionData,
|
hasRegionData,
|
||||||
loadRegions,
|
loadRegions,
|
||||||
neighboursOf,
|
neighboursOf,
|
||||||
@@ -106,6 +108,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
markers: [],
|
||||||
|
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||||
|
regionStats: {},
|
||||||
|
wars: [],
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
@@ -149,6 +154,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
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', () => {
|
describe('graceful degradation with no region data', () => {
|
||||||
beforeEach(() => resetRegions());
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
@@ -165,6 +185,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
// and no centroids: the marker-placement path degrades to a no-op
|
// and no centroids: the marker-placement path degrades to a no-op
|
||||||
expect(hasCentroids()).toBe(false);
|
expect(hasCentroids()).toBe(false);
|
||||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
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 +242,9 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
// and no centroids (not copied into this dist/) -> marker placement degrades
|
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||||
expect(hasCentroids()).toBe(false);
|
expect(hasCentroids()).toBe(false);
|
||||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
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)', () => {
|
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||||
@@ -233,6 +259,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
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', () => {
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
const emptyRoot = path.join(base, 'empty');
|
const emptyRoot = path.join(base, 'empty');
|
||||||
mkdirSync(emptyRoot, { recursive: true });
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
|||||||
+73
-3
@@ -17,6 +17,8 @@
|
|||||||
// field (v0.21, #16 P2)
|
// field (v0.21, #16 P2)
|
||||||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||||
// — the data behind LLM-placed map markers (v0.27, #44)
|
// — 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
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
@@ -45,6 +47,8 @@ interface RegionData {
|
|||||||
adjacency: Record<string, string[]>;
|
adjacency: Record<string, string[]>;
|
||||||
/** region key -> anchor point (from public/region-centroids.json). */
|
/** region key -> anchor point (from public/region-centroids.json). */
|
||||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
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;
|
let data: RegionData | null = null;
|
||||||
@@ -72,6 +76,7 @@ export function loadRegions(root: string): boolean {
|
|||||||
const geoFile = 'regions.geo.json';
|
const geoFile = 'regions.geo.json';
|
||||||
const adjacencyFile = 'region-adjacency.json';
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
const centroidsFile = 'region-centroids.json';
|
const centroidsFile = 'region-centroids.json';
|
||||||
|
const regionGeoFile = 'region-geo.json';
|
||||||
const base = [
|
const base = [
|
||||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
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);
|
const geoPath = path.join(base, geoFile);
|
||||||
try {
|
try {
|
||||||
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
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 } }>;
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
};
|
};
|
||||||
const keyToInfo: Record<string, RegionInfo> = {};
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
const keysByCountry: Record<string, string[]> = {};
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
const validKeys = new Set<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 id = f.id == null ? '' : String(f.id);
|
||||||
const key = idToKey[id];
|
const key = idToKey[id];
|
||||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
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;
|
return validKeys.size > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[regions] failed to load region data', 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>();
|
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.
|
* 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
|
* 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;
|
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
|
* 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
|
* 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];
|
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
|
* 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
|
* 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 });
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ export interface StubPayload {
|
|||||||
* path is covered by unit tests against applyTurnResult, not by the stub.
|
* 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 }>;
|
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[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -72,6 +79,19 @@ function truncate(s: string, n: number): string {
|
|||||||
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||||
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -133,10 +153,13 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||||
events,
|
events,
|
||||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
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' }] : [],
|
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||||
territoryChanges,
|
territoryChanges,
|
||||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
nationOps: [], // the stub never proposes nation ops (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)
|
||||||
markers: { add: [], remove: [] }, // the stub never touches markers (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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+301
-1
@@ -8,16 +8,35 @@ import type {
|
|||||||
GameState,
|
GameState,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
|
NationStats,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RegionStats,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from './types';
|
} 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. */
|
/** A numeric change to one of a nation's 0–100 stats. */
|
||||||
export type StateChange =
|
export type StateChange =
|
||||||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||||||
| { nation: string; stat: StatKey; op: 'add'; 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). */
|
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||||||
export interface RelationChange {
|
export interface RelationChange {
|
||||||
a: string;
|
a: string;
|
||||||
@@ -32,6 +51,50 @@ export interface TerritoryChange {
|
|||||||
lose: string[];
|
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
|
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||||
// proposes, player confirms" model) ------------------------------------------
|
// proposes, player confirms" model) ------------------------------------------
|
||||||
//
|
//
|
||||||
@@ -49,6 +112,25 @@ export interface TerritoryChange {
|
|||||||
* engine's `resolveNationRef`).
|
* 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
|
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||||
@@ -151,6 +233,19 @@ export interface MarkerInfo {
|
|||||||
owner: string | null;
|
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 /
|
* The per-type presentation of a marker: its display label (tooltip /
|
||||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||||
@@ -215,6 +310,17 @@ export interface TurnResult {
|
|||||||
date: string;
|
date: string;
|
||||||
events: TurnEventSpec[];
|
events: TurnEventSpec[];
|
||||||
stateChanges: StateChange[];
|
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[];
|
relations: RelationChange[];
|
||||||
territoryChanges: TerritoryChange[];
|
territoryChanges: TerritoryChange[];
|
||||||
/**
|
/**
|
||||||
@@ -237,6 +343,30 @@ export interface TurnResult {
|
|||||||
* survivor. Empty on turns where the model proposes none.
|
* survivor. Empty on turns where the model proposes none.
|
||||||
*/
|
*/
|
||||||
nationOps: NationOp[];
|
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.27, #44) Map-marker ops this turn. `add` places new named markers
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
* (the engine positions each at its region's centroid + a small
|
* (the engine positions each at its region's centroid + a small
|
||||||
@@ -422,6 +552,42 @@ export function nationColor(id: string): string {
|
|||||||
return `hsl(${hue} 58% 46%)`;
|
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 ---
|
// --- 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
|
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||||
@@ -515,3 +681,137 @@ export function nationForPrompt(n: Nation) {
|
|||||||
relations: n.relations,
|
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,461 @@
|
|||||||
|
// 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: [],
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+87
-1
@@ -13,6 +13,19 @@ export interface NationStats {
|
|||||||
population: number;
|
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 const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||||
export type StatKey = (typeof STAT_KEYS)[number];
|
export type StatKey = (typeof STAT_KEYS)[number];
|
||||||
|
|
||||||
@@ -66,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
|
|||||||
'cultural',
|
'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) -------------------------------------------------
|
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||||
//
|
//
|
||||||
@@ -122,6 +143,44 @@ export interface MapMarker {
|
|||||||
placedTurn: number;
|
placedTurn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (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. */
|
/** A narrative event the simulation emitted on some turn. */
|
||||||
export interface EventEntry {
|
export interface EventEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -217,6 +276,33 @@ export interface GameState {
|
|||||||
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||||
*/
|
*/
|
||||||
markers: MapMarker[];
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+129
-2
@@ -16,6 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
|||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
import PresetPicker from './components/PresetPicker';
|
import PresetPicker from './components/PresetPicker';
|
||||||
import NationSelect from './components/NationSelect';
|
import NationSelect from './components/NationSelect';
|
||||||
|
import ScenarioForm from './components/ScenarioForm';
|
||||||
|
import CheckpointReview from './components/CheckpointReview';
|
||||||
import WorldMap from './components/WorldMap';
|
import WorldMap from './components/WorldMap';
|
||||||
import FloatingWindow from './components/FloatingWindow';
|
import FloatingWindow from './components/FloatingWindow';
|
||||||
import MapLegend from './components/MapLegend';
|
import MapLegend from './components/MapLegend';
|
||||||
@@ -26,9 +28,27 @@ import { api } from './lib/api';
|
|||||||
import { useSse } from './lib/sse';
|
import { useSse } from './lib/sse';
|
||||||
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
||||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
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,
|
* 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
|
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||||
// turn flow — "Jump forward" stays usable while it is in flight.
|
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||||
const [opBusy, setOpBusy] = useState(false);
|
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
|
// 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.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -359,6 +383,77 @@ export default function App() {
|
|||||||
[screen],
|
[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
|
// 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
|
// 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
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
@@ -584,6 +709,7 @@ export default function App() {
|
|||||||
activeGameId={activeGameId}
|
activeGameId={activeGameId}
|
||||||
onResume={resumeGame}
|
onResume={resumeGame}
|
||||||
onDelete={runDeleteGame}
|
onDelete={runDeleteGame}
|
||||||
|
onCreateScenario={openScenarioForm}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -707,6 +833,7 @@ function GameScreen({
|
|||||||
presetId={game.presetId}
|
presetId={game.presetId}
|
||||||
unowned={game.unowned ?? []}
|
unowned={game.unowned ?? []}
|
||||||
markers={game.markers ?? []}
|
markers={game.markers ?? []}
|
||||||
|
regionStats={game.regionStats ?? {}}
|
||||||
onFocusNation={focusNation}
|
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 { useState } from 'react';
|
||||||
import { Play, Trash2 } from 'lucide-react';
|
import { Play, Sparkles, Trash2 } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
@@ -26,6 +26,12 @@ interface Props {
|
|||||||
onResume?: (id: string) => void;
|
onResume?: (id: string) => void;
|
||||||
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||||
onDelete?: (id: string) => void;
|
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,
|
activeGameId,
|
||||||
onResume,
|
onResume,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCreateScenario,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||||
// The game awaiting a delete confirmation (null = dialog closed).
|
// 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">
|
<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) => (
|
{presets.map((p) => (
|
||||||
<Card
|
<Card
|
||||||
key={p.id}
|
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 RegionMap,
|
||||||
type RegionOwner,
|
type RegionOwner,
|
||||||
} from '@shared/protocol';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { idToKey } from '@/lib/regions';
|
import { idToKey } from '@/lib/regions';
|
||||||
|
|
||||||
@@ -410,11 +410,29 @@ function hoverHtml(
|
|||||||
region?: RegionOwner,
|
region?: RegionOwner,
|
||||||
nation?: Nation,
|
nation?: Nation,
|
||||||
noMansLand = false,
|
noMansLand = false,
|
||||||
|
regionKey?: string,
|
||||||
|
regionStats?: Record<string, RegionStats>,
|
||||||
): string {
|
): string {
|
||||||
if (!region || !nation) {
|
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>`;
|
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">
|
return `<div class="hm-popup">
|
||||||
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
|
<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>
|
<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).
|
* a top-most symbol layer (per-type glyph + name, per-type color).
|
||||||
*/
|
*/
|
||||||
markers?: MapMarker[];
|
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,
|
unowned,
|
||||||
onFocusNation,
|
onFocusNation,
|
||||||
markers,
|
markers,
|
||||||
|
regionStats,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(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 unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||||
const onFocusNationRef = useRef(onFocusNation);
|
const onFocusNationRef = useRef(onFocusNation);
|
||||||
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
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;
|
regionsRef.current = regions;
|
||||||
nationsRef.current = nations;
|
nationsRef.current = nations;
|
||||||
playerRef.current = playerNationId;
|
playerRef.current = playerNationId;
|
||||||
@@ -531,6 +560,7 @@ export default function WorldMap({
|
|||||||
unownedRef.current = new Set(unowned ?? []);
|
unownedRef.current = new Set(unowned ?? []);
|
||||||
onFocusNationRef.current = onFocusNation;
|
onFocusNationRef.current = onFocusNation;
|
||||||
markersRef.current = markers ?? [];
|
markersRef.current = markers ?? [];
|
||||||
|
regionStatsRef.current = regionStats ?? {};
|
||||||
|
|
||||||
// Diffing state for the transient territory-change flash (owned by this component
|
// Diffing state for the transient territory-change flash (owned by this component
|
||||||
// so a resync with identical data never re-flashes).
|
// 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
|
// (v0.22, #16 P3) whether this region is no man's land (released
|
||||||
// mid-game), so the ownerless tooltip can say so.
|
// mid-game), so the ownerless tooltip can say so.
|
||||||
const pKey = f.properties?.regionKey;
|
const pKey = f.properties?.regionKey;
|
||||||
const noMansLand =
|
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
||||||
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
|
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
|
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import type {
|
|||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
|
import type {
|
||||||
|
ScenarioCommitRequest,
|
||||||
|
ScenarioCommitResponse,
|
||||||
|
ScenarioDraftRequest,
|
||||||
|
ScenarioDraftResponse,
|
||||||
|
TemplateMeta,
|
||||||
|
} from '@shared/scenario';
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -95,4 +102,24 @@ export const api = {
|
|||||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||||
`/api/games/${id}/map`,
|
`/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',
|
failure: '#ef4444',
|
||||||
partial: '#f59e0b',
|
partial: '#f59e0b',
|
||||||
neutral: '#94a3b8',
|
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