Compare commits

...
6 Commits
Author SHA1 Message Date
kamilkrzywda 9d201135b3 Merge pull request 'feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)' (#72) from feat/61-named-leaders into master
CI / build + unit + e2e (push) Successful in 2m10s
2026-10-15 22:14:24 +02:00
kamilkrzywda 8730a56170 feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Per-nation named leaders (Nation.leader) and a required TurnResult.leaders
lever ({ changes: [{ target, name, cause }] }). Each change sets the target
nation's leader and applies a deterministic stability dip by cause
(assassination -15, death -3, appointed 0) mirrored to its regions and
re-derived into its stats, plus a narration event (military for an
assassination, political for a death/appointment). An assassination/death
requires the target to already have a leader (a leaderless target is
dropped); an appointment always creates/sets one. Unknown/ambiguous targets
are dropped, never thrown.

- shared/types.ts: optional Nation.leader
- shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT,
  TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt
- server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult
  leader block (after nuclear, before markers), isMeaningful, system-prompt
  contract + constraint, validateTurnResult + runTurn fallback
- server/stub.ts: leaders always { changes: [] } (e2e unaffected)
- tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips
- version 0.37.0 -> 0.38.0, CHANGELOG entry
2026-10-15 20:09:19 +00:00
kamilkrzywda b6dcd3f979 Merge pull request 'feat(#60): nuclear/WMD — capability flag + strikes (region → no-man's-land, nation collapse + war capitulation, MAD retaliation) (v0.37.0)' (#71) from feat/60-nuclear-wmd into master
CI / build + unit + e2e (push) Successful in 2m3s
2026-10-15 21:20:30 +02:00
kamilkrzywda 31182c8730 feat(#60): nuclear/WMD — capability flag + strikes (region → no-man's-land, nation collapse + war capitulation, MAD retaliation) (v0.37.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
2026-10-15 19:17:26 +00:00
kamilkrzywda 0fd8981452 Merge pull request 'feat: Resources & Industry — per-region resource profiles + industry LLM lever (v0.36.0, #59)' (#70) from feat/59-resources-industry into master
CI / build + unit + e2e (push) Successful in 2m15s
2026-10-15 20:27:17 +02:00
kamilkrzywda b237fe8dd4 feat: Resources & Industry — per-region resource profiles + industry LLM lever (v0.36.0, #59)
CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
  public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
  GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
  (area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
  (per-region SET, first-declared-type wins), applyIndustryEffects
  (INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
  0/2+ owner regions kept but no boost; re-derive after), system+user prompt
  contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
  in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
  protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0

Fixes #59
2026-10-15 18:23:05 +00:00
19 changed files with 13255 additions and 50 deletions
+35
View File
@@ -67,11 +67,13 @@ history/
│ ├── 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-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
│ ├── region-resources.json # region key -> synthesized {oil, iron, food, industry} (each 0–1, 2dp), 1,800 entries (the resource profiles that scale industry boosts + feed the prompt aggregate, #59)
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
│ │ # → build-region-resources
│ │ # (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)
@@ -131,6 +133,7 @@ history/
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
| `npm run build:region-resources` | (re)generate `public/region-resources.json` (region key → synthesized `{ oil, iron, food, industry }` 0–1 profile) from `region-keys.json` + `region-geo.json` (area factor) — deterministic + idempotent (byte-identical re-run; #59) |
| `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` |
@@ -407,6 +410,38 @@ history/
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
(`server/engine.test.ts`).
- **Resources & Industry — per-region resource profiles + the `industry` lever (v0.36, #59).**
Since v0.36 each of the 1,800 sub-regions carries a **synthesized 4-value resource profile**
(`{ oil, iron, food, industry }`, each 0–1, 2dp) from the committed `public/region-resources.json`
— built by the deterministic, idempotent `scripts/build-region-resources.mjs` (an FNV-1a hash of
`` `${key}\u0000${resource}` `` scaled by an area factor from `region-geo.json`, clamped to 1,
byte-identical re-run) and loaded by `server/regions.ts` via the same optional-manifest pattern as
the other manifests (`hasResources()` / `resourceOf()`, probe `public/` then `dist/`; a missing or
malformed file degrades — boosts use the unscaled base value and the aggregate is omitted from the
prompt, never a crash). `TurnResult.industry = { projects: IndustryProject[] }` (required, default
`{ projects: [] }`; `IndustryProject = { regionKey, type }`, `type ∈ INDUSTRY_TYPES =
oil | iron | food | industry`) declares a project on a region. `applyTurnResult` maintains
`GameState.industry` (required, `[]` at creation; a legacy SQLite row without the field loads as
`[]` — `db.ts`, additive, never wiped) as a **per-region SET**: a region key not in the valid-keys
manifest is dropped; a region that already hosts a project is untouched (the first declared type
wins — re-declaring is a no-op, the type is never replaced). Every committed turn,
`applyIndustryEffects` (placed after the economy block, before markers; the owner is resolved from
the FINAL post-claim ownership) grants each active project's region's **SOLE owner** a small,
deterministic economy boost — `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's
`industry` resource value is ≥ 0.5 (without the manifest, just the base) — clamped to 0–100,
mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats`
re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). A project on a
region with 0 or 2+ owners yields no boost but is **kept** in the list (never removed, never
thrown); sole ownership is untouched (the layer moves stats, never territory). `isMeaningful`
counts non-empty `industry.projects`; the system prompt documents the field + constraints (a region
holds at most one project — never re-declare on a region that already has one; at most 1–2 per
turn, each narrated in `events`), and the user payload carries the active projects (`industry`)
plus each nation's aggregate resource profile (`resources` — `deriveNationResources` in
`shared/protocol.ts`, the area×population-weighted **average** of the owned regions' profiles;
omitted entirely when the manifest is not loaded). The **stub never** declares a project
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
+58
View File
@@ -2,6 +2,64 @@
All notable changes to **History** are listed here, newest first.
## v0.38.0 — 2026-10-15 20:06
- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader").
- **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader).
- **Protocol (`shared/protocol.ts`).** New `LeaderCause` (`'assassination' | 'death' | 'appointed'`), `LeaderChange` (`{ target; name; cause }` — `target` is a nation ref, `name` is the new leader's name), and `LeaderOps` (`{ changes: LeaderChange[] }`); `TurnResult.leaders` is **required**, placed between `nuclear` and `markers`, default `{ changes: [] }`. New exported **`LEADER_STABILITY_EFFECT = { assassination: −15, death: −3, appointed: 0 }`** (the deterministic stability dip by cause). `nationForPrompt` now includes the nation's `leader` (omitted when absent), so `buildUserPrompt` carries each nation's current leader with no separate wiring.
- **Engine (`server/engine.ts`).**
- `validateLeaderOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (≤120), a non-empty `name` (≤120), and a `cause` that is one of the three literals — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear ops).
- `applyLeaderChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyHybridOp` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). **GATE**: an `assassination`/`death` requires the target to already have a leader (a leaderless target is dropped); `appointed` is always valid (it creates the first leader). On a valid change it sets `n.leader = { name }` and applies `LEADER_STABILITY_EFFECT[cause]` to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry, so a 0-region nation keeps its directly-mutated value). Finally it appends a narration event: a dramatic **`military`** event for an assassination, a **`political`** succession event for a death or an appointment. The replacement never moves territory (sole ownership is untouched).
- `applyTurnResult` — the leader block runs **after `nuclear` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → **leaders** → markers → nationOps).
- `isMeaningful` — a non-empty `leaders.changes` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `leaders` (default `{ changes: [] }`).
- `buildSystemPrompt` — documents the `leaders` field (the three causes, the stability effects, the leaderless-gate, the "at most 1–2 per turn, narrated" rule) and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement) in both tool and plain modes.
- `runTurn` — the "unclear" fallback carries `leaders: { changes: [] }`.
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader` — the stub **never** changes a leader, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
- **Tests.** New unit tests in `engine.test.ts`: `validateLeaderOps` (`{changes}` object, bare array, unknown-cause drop, missing target/name drop, malformed top-level, mixed batch keeps only valid); `isMeaningful` (a leader change is meaningful, empty leaders is not); `applyTurnResult` (assassination with a leader → replaced + −15 + `military` event; assassination / death on a leaderless nation → dropped, no stat change, no event; death → replaced + −3 + `political` succession event; appointment on a leader-having and on a leaderless nation → sets the leader, neutral (no dip); a multi-region nation → every region dipped and stats re-derived; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing). Two `db.test.ts` round-trips: a legacy row with no leader loads with it absent, and a set leader round-trips through save/load.
- **Version** bumped `0.37.0 → 0.38.0`.
## v0.37.0 — 2026-10-15 19:15
- **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent).
- **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn.
- **Types (`shared/types.ts`).** `Nation.nuclear?: boolean` (optional; `undefined`/absent = not armed; default `false` behavior).
- **Protocol (`shared/protocol.ts`).** New `NuclearStrike` (`{ from; target }` — `from` is the striking nation, `target` is a nation ref or a region key) and `NuclearOps` (`{ arm: string[]; strikes: NuclearStrike[] }`); `TurnResult.nuclear` is **required**, placed between `industry` and `markers`, default `{ arm: [], strikes: [] }`. New exported `NuclearStatKey` (`'economy' | 'military' | 'stability'`) and the two deterministic collapse tables: **`NUCLEAR_COLLAPSE = { economy: −40, military: −50, stability: −40 }**` (a full strike's effect on the struck nation / region owner) and **`NUCLEAR_RETALIATION = { economy: −20, military: −25, stability: −20 }`**` (the half-strength MAD backfire).
- **Engine (`server/engine.ts`).**
- `validateNuclearOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ arm: [], strikes: [] }`; `arm` entries are non-empty strings (≤120 chars) deduped; a strike needs a non-empty `from` **and** `target` (≤120) and is **dropped when it targets itself** (same ref, case-insensitive) — a self-strike is a no-op, never thrown.
- `applyNuclearOps(next, ops, state, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — new exported, the load-bearing nuclear block. **`arm` first** (idempotent; a resolve miss or a self-arm is a no-op), then **`strikes`** in order: resolve `from` (an unresolvable or non-capable striker is **dropped** — capability checked against the *pre-turn* marker set plus any flag granted this turn); a `target` that is a **nation** collapses it (the `NUCLEAR_COLLAPSE` deltas applied to its `stats` and every region it owns, each affected nation's derived stats re-derived, 0-region / no-entry guards as elsewhere), ends **every active war it fights** by **capitulation** (`endWarsBetween` captures the active pairs *before* ending so a war declared+ended the same turn is handled), and downgrades the now-inactive `war` relations to `hostile`; a `target` that is a **region key** releases it to no man's land (a key with 0 or 2+ owners, or one the striker itself owns, is **dropped**), deletes its `regionStats` entry, and collapses its **sole owner** the same way. **MAD**: if the struck nation (or the struck region's sole owner) is itself nuclear-capable, it retaliates against the striker for the half-strength `NUCLEAR_RETALIATION` (clamped, mirrored, re-derived) and the two nations' relations are forced to `hostile` (`forceHostile` — both directions, unless already `war`). Each strike and each retaliation appends a dramatic **`military`** event (`war` is not a valid `EventType`). Sole ownership is never broken by a strike (a region is released, not re-claimed) and the block never throws.
- `applyTurnResult` — the nuclear block runs **after `industry` and before `markers`** (order: events → relations → territory → stateChanges → recompute → wars → hybrid → economy → industry → **nuclear** → markers → nationOps); it passes the *original* (pre-turn) `state` for the capability marker set and the current `regionNameOf` for event text.
- `isMeaningful` — a non-empty `nuclear.arm` or `nuclear.strikes` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `nuclear` (default `{ arm: [], strikes: [] }`).
- `buildSystemPrompt` — documents the `nuclear` field and a constraint bullet (the capability rule; `arm` is idempotent; a strike hits a nation or a region; the collapse + capitulation + MAD outcomes; at most one strike per turn, always narrated) in both tool and plain modes.
- `buildUserPrompt` — new optional `nuclear` param (`Record<string, boolean>`, the per-nation capability map); when provided, each `state.nations` entry in the payload is stamped with its `nuclear` capability; when undefined the key is omitted entirely (stub / no-capability prompt stays byte-identical). `runTurn` always supplies the map (computed from `isNuclearCapable` over `state.markers`).
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `nuclear: { arm: [], strikes: [] }` — the stub **never** arms a nation or fires a strike, so the e2e (CI, `LLM_STUB=1`) is unaffected and the nuclear path is covered by unit tests only.
- **Tests.** 29 new unit tests: `isNuclearCapable` (flag / own rocket / non-rocket / other's rocket / null owner); `validateNuclearOps` (well-formed, `arm` dedupe, missing `from`/`target`, self-target drop, malformed top-level); `applyTurnResult` nuclear (arm idempotency across turns, arm+strike same turn, region strike → release + stats deleted + owner collapse + no MAD + the single `military` event, nation strike + pre-existing war → capitulation + hostile, nation strike + same-turn declare → capitulation + hostile, MAD on a capable nation target, MAD on a capable region owner, rocket-marker-only capability, non-capable striker dropped, 2+-owner region dropped, 0-owner/unowned region dropped, self-target dropped, malformed strikes never throw); `isMeaningful` (arm / strike); `buildUserPrompt` (per-nation capability stamped when the map is given, omitted when undefined); and two `db.test.ts` round-trips (a legacy row with no flag loads with it absent; a set flag round-trips through save/load).
- **Version** bumped `0.36.0 → 0.37.0`.
## v0.36.0 — 2026-10-15 18:17
- **#59: Resources & Industry — per-region synthesized resource profiles + a per-region industry-project lever with a deterministic per-turn economy boost.** Every prior lever moved borders, dipped stats one-shot, or proposed a player-confirmed change; this version adds a **resource abundance layer** (every sub-region gets a synthesized 4-value profile) and a **development lever**: the model can build industry projects (oil facilities, iron mines, food operations, factories) in individual regions, and every committed turn each active project pays its region's sole owner a small, deterministic economy gain scaled by the region's `industry` resource value. The engine is **fully deterministic (no hidden randomness)**: the resource profiles are a pure function of the region key (FNV-1a) scaled by an area factor, and the per-turn effect is a fixed, clamped, mirrored, re-derived stat adjustment.
- **Manifest (`public/region-resources.json`, new).** 1,800 entries — region key → `{ oil, iron, food, industry }` (each 0–1, 2dp) — built by the new turf-free, deterministic, **idempotent** `scripts/build-region-resources.mjs` (`npm run build:region-resources`): per resource, `(fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000` → [0,1), times an area factor `1 + 0.2·min(1, log10(1+area)/8)` (max ≈1.2) from `region-geo.json` (missing geo / zero area → pure hash), clamped to 1, keys sorted, byte-identical re-run. Pinned by `scripts/build-region-resources.test.ts` (key-set equality vs `region-keys.json`, sorted order, per-entry shape, spot-checked values, determinism via a re-run).
- **Types (`shared/types.ts`).** New `RegionResources` (`{ oil; iron; food; industry }`), `IndustryType` (`'oil' | 'iron' | 'food' | 'industry'`) + `INDUSTRY_TYPES` const, `IndustryProject` (`{ regionKey; type }`), and the required `GameState.industry: IndustryProject[]` field (placed after `embargoes`). `[]` on a fresh game; rows saved before v0.36 load with it defaulted to `[]` (additive, never wiped).
- **Protocol (`shared/protocol.ts`).** New `IndustryOps` (`{ projects: IndustryProject[] }`); `TurnResult.industry` is required, placed between `economy` and `markers`. New exported `deriveNationResources(regionKeys, resourceOf?, geoOf?)` — a nation's aggregate profile as the **area×population-weighted AVERAGE** (an average, not a median, is the meaningful aggregation for an abundance profile) of the resource profiles of its owned regions, same weight formula as `deriveNationStats`, 2dp-rounded, zero profile when no owned region has a profile.
- **Engine (`server/engine.ts`).**
- `isIndustryType(t)` — exported guard over the four types (mirrors `isHybridOpType`).
- `validateIndustryOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ projects: [] }`; each op needs a non-empty `regionKey` (≤120 chars) and a known `type`.
- `industryBoost(regionKey, resourceOf?)` — internal; `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's `industry` value is ≥ 0.5 (without the manifest, just the base).
- `applyIndustryOps(next, ops, validKeys?)` — internal; applies the turn's projects to `GameState.industry` as a **per-region SET** — a key not in the valid-keys manifest is dropped; a region that already hosts a project is untouched (first type wins; re-declare is a no-op, the type is never replaced).
- `applyIndustryEffects(next, resourceOf?, geoOf?)` — new exported; the per-turn effect, applied **every committed turn** to the **full** active list: each project grants its region's **SOLE owner** (owner resolved from the FINAL post-claim ownership; 0 or 2+ owners → no boost, project **kept**, never thrown) a deterministic economy boost, clamped to 0–100, mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). Sole ownership is untouched (the layer moves stats, never territory). No-op when there are no active projects; never throws.
- `applyTurnResult` — new `resourceOf` param (last) and **industry block** (after the economy block, before markers): `applyIndustryOps` → `applyIndustryEffects` (ops first, so a project declared this turn is paid this turn).
- `isMeaningful` — non-empty `industry.projects` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `industry` (default `{ projects: [] }`).
- `buildSystemPrompt` — documents the `industry` field and a constraint bullet (a per-resource development lever; one project per region — never re-declare on a region that already has one; at most 1–2 per turn, each narrated in `events`) in both tool and plain modes.
- `buildUserPrompt` — new optional `nationResources` (each nation's aggregate profile) and `industryProjects` (the active projects) params — emitted as `resources` / `industry` in the payload, only when provided (so the stub / no-manifest prompt stays byte-identical).
- `runTurn` — new optional `resourceOf` (last); computes `nationResources` via `deriveNationResources` **only when** the resource manifest is loaded (otherwise omitted from the prompt); passes both new payload fields; the "unclear" fallback carries `industry: { projects: [] }`; `applyTurnResult` receives `resourceOf`.
- **Regions loader (`server/regions.ts`).** The optional-manifest pattern extended: `region-resources.json` is probed after `public/` then `dist/`; malformed entries (any of the four values not a finite number in [0,1]) are filtered out, a failure logs `[regions] failed to load region-resources.json, continuing without it` and the server keeps working without it; new `hasResources()` / `resourceOf(key)` accessors (mirror `hasGeo()` / `geoOf()`).
- **Server wiring (`server/index.ts`).** The jump route passes `resourceOf: hasResources() ? (key) => resourceOf(key) : undefined` into `runTurn` (like `geoOf`); fresh preset games are created with `industry: []` (dynamic games already via `shared/scenario.ts`).
- **Persistence (`server/db.ts`).** `load()` defaults `industry` to `[]` on rows saved before v0.36 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`/`trades`/`embargoes`) — legacy games are never wiped and never gain phantom projects.
- **Stub (`server/stub.ts`).** The deterministic stub returns `industry: { projects: [] }` — it **never** declares an industry project, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only. `StubPayload` gains optional `resources` / `industry` fields (ignored).
- **Unit tests.** New: `scripts/build-region-resources.test.ts` (5 tests); `server/regions.test.ts` (resources load/spot-check/unknown-key, degradation, prod `dist/` layout, incl. a new "with the manifest" case); `server/protocol.test.ts` — `deriveNationResources` (7 tests: area×population weighting, unweighted degradation, profile-skipping, zero profile, single region, weight fallback, 2dp rounding); `server/db.test.ts` — legacy-row default; `server/engine.test.ts` — `validateIndustryOps` (shape/drops), `applyTurnResult` industry ops (declare, per-region SET / re-declare no-op, invalid-key drop, persistence across turns), `applyIndustryEffects` (sole-owner boost, ≥0.5 scaling, multi-project accumulation, ownerless/2+ owner kept-but-unpaid, no-op empty), `isMeaningful`, the system-prompt contract (both modes), the user-prompt payload (carried / omitted), and the `runTurn` defensive init; all pre-existing fixtures gained the new required fields (mechanical, deliberate updates).
- **Client.** No change — the industry layer is an engine/LLM concern; its effects surface through the existing stats and event feed.
- **AGENTS.md.** New convention entry documenting the resource manifest, the per-region SET semantics, the sole-owner per-turn boost, and the stub/test coverage; the `public/` layout, the scripts pipeline line, and the Commands table all gained `region-resources` / `build:region-resources`.
## v0.35.0 — 2026-10-15 17:22
- **#58: Trade & Embargoes — a bilateral trade + embargo economy layer with a deterministic per-turn effect and war auto-suspension.** Every prior turn lever moved borders, dipped stats one-shot, or proposed a player-confirmed change. This version adds `TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required, default `{ trade: [], embargo: [] }`) so the model can declare/lift **bilateral trade agreements** and **unilateral embargoes**, which persist in `GameState` and apply a small, deterministic economic effect **every committed turn**. The engine is **fully deterministic (no hidden randomness)** and **additive** — declares add to the lists, lifts remove, and nothing else (not even a war, beyond the suspension below) mutates them.
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.35.0",
"version": "0.38.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -20,6 +20,7 @@
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
"build:region-centroids": "node scripts/build-region-centroids.mjs",
"build:region-geo": "node scripts/build-region-geo.mjs",
"build:region-resources": "node scripts/build-region-resources.mjs",
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
/**
* scripts/build-region-resources.mjs — build the region **resources** manifest:
* `public/region-resources.json` = `{ "<regionKey>": { "oil": <0-1>, "iron": <0-1>, "food": <0-1>, "industry": <0-1> }, ... }`.
*
* Every sub-region (region key, see `public/region-keys.json`) gets a
* synthesized four-value resource profile. For each resource dimension a
* value in [0, 1] (2 decimal places) is derived from the FNV-1a 32-bit hash
* of the region key (salted per resource — the same tiny, stable, pure
* string hash the region keys themselves use), then scaled MODESTLY by the
* region's area (from the committed `public/region-geo.json`, when present):
* larger regions carry a slightly higher value (a log-scaled factor of at
* most ~1.2), so a big region reads as holding more of each resource. The
* geo file is OPTIONAL — without it (or for a zero-area region) the factor
* is exactly 1 and the profile is pure hash.
*
* This is the v0.36 (#59) "Resources & Industry" data source: the engine
* scales each region's per-turn industry boost by its `industry` value, and
* the LLM prompt carries each nation's aggregate profile (the
* area×population-weighted mean of the owned regions' profiles, see
* `deriveNationResources` in shared/protocol.ts). The profiles are
* synthesized (there is no real per-sub-region resource census) — they exist
* to give the model a stable, deterministic world fact to reason about.
*
* Output contract (asserted by scripts/build-region-resources.test.ts):
* - one entry for **every** region key, keyed identically to
* `public/region-keys.json` (same key set, 1,800 entries);
* - each entry is `{ oil, iron, food, industry }`, every value a finite
* number in [0, 1] with at most 2 decimal places;
* - keys are in sorted (deterministic) order.
*
* Deterministic + idempotent: fixed inputs (the key manifest, and the
* committed geo file when present), pure string hashing, fixed 2-dp
* rounding, 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-resources.mjs` (or `npm run build:region-resources`).
*/
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 round2 = (v) => Math.round(v * 100) / 100;
/** FNV-1a 32-bit — the same tiny, stable, pure hash the region keys use. */
function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
const RESOURCES = ['oil', 'iron', 'food', 'industry'];
// --- load the inputs (plain parse) ------------------------------------------
const keysPath = path.join(root, 'public', 'region-keys.json');
const geoPath = path.join(root, 'public', 'region-geo.json');
let idToKey;
try {
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
} catch (err) {
console.error(`[build-region-resources] could not load ${keysPath}.`);
throw err;
}
// region-geo is OPTIONAL: a missing/broken file means "no area scaling"
// (factor 1 for every region) — the profile is then pure hash.
let areaByKey = {};
try {
const parsed = JSON.parse(readFileSync(geoPath, 'utf8'));
for (const [k, v] of Object.entries(parsed)) {
if (v && typeof v.area === 'number' && Number.isFinite(v.area) && v.area >= 0) {
areaByKey[k] = v.area;
}
}
} catch {
areaByKey = {};
}
/**
* The base value in [0, 1) for one (region key, resource): the low 14 bits of
* the salted FNV-1a hash, normalized. Stable across runs and platforms; a
* uniform-enough spread for a synthesized profile.
*/
const base = (key, resource) => (fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000;
/**
* The area scaling factor: 1.0 for a region with no geo entry (or zero area),
* rising on a log scale (clamped) to at most ~1.2 for the largest regions —
* bigger regions hold proportionally more of each resource.
*/
const areaScale = (key) => {
const a = areaByKey[key];
if (!a || a <= 0) return 1;
// log10(1 + area) runs ~0.004 (tiny slivers) to ~5.5 (a 300k km² region);
// /8 + clamp keeps the factor in [1, 1.2].
const t = Math.min(1, Math.log10(1 + a) / 8);
return 1 + 0.2 * t;
};
// --- assemble the manifest (sorted keys) -------------------------------------
const expectedKeys = Object.values(idToKey);
const manifest = {};
for (const k of [...expectedKeys].sort()) {
const scale = areaScale(k);
manifest[k] = {
oil: round2(Math.min(1, base(k, 'oil') * scale)),
iron: round2(Math.min(1, base(k, 'iron') * scale)),
food: round2(Math.min(1, base(k, 'food') * scale)),
industry: round2(Math.min(1, base(k, 'industry') * scale)),
};
}
// --- 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-resources entries (one per region key), got ${Object.keys(manifest).length}`,
);
}
let scaled = 0;
for (const [k, v] of Object.entries(manifest)) {
if (areaByKey[k]) scaled += 1;
for (const r of RESOURCES) {
const val = v[r];
if (typeof val !== 'number' || !Number.isFinite(val) || val < 0 || val > 1) {
throw new Error(`bad ${r} value for ${k}: ${JSON.stringify(val)}`);
}
if (Math.round(val * 100) / 100 !== val) {
throw new Error(`${r} over 2dp for ${k}: ${val}`);
}
}
}
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
writeFileSync(
path.join(root, 'public', 'region-resources.json'),
JSON.stringify(manifest, null, 2) + '\n',
);
// --- summary ------------------------------------------------------------------
console.log(
`[build-region-resources] wrote public/region-resources.json — ${Object.keys(manifest).length} region keys` +
` (${scaled} area-scaled, ${Object.keys(manifest).length - scaled} pure-hash)`,
);
+106
View File
@@ -0,0 +1,106 @@
/**
* scripts/build-region-resources.test.ts — validation for the committed
* `public/region-resources.json` (built by `scripts/build-region-resources.mjs`).
*
* The manifest maps each region key to a synthesized four-value resource
* profile `{ oil, iron, food, industry }` — each a finite number in [0, 1]
* (2 decimal places). It is the v0.36 (#59) "Resources & Industry" data
* source: the engine scales each region's per-turn industry boost by its
* `industry` value, and the LLM prompt carries each nation's aggregate
* profile (the area×population-weighted mean of the owned regions' profiles,
* `deriveNationResources` in shared/protocol.ts).
*
* Like the region-geo manifest, this is a load-bearing data artifact — the
* test pins its contract. It is dependency-free: 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-free) `.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 ResourceEntry {
oil: number;
iron: number;
food: number;
industry: number;
}
const RESOURCES = ['oil', 'iron', 'food', 'industry'] as const;
// feature id -> region key
const manifest = readJson('public/region-keys.json') as Record<string, string>;
const resources = readJson('public/region-resources.json') as Record<string, ResourceEntry>;
const keys = Object.values(manifest);
describe('region-resources.json', () => {
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
expect(new Set(Object.keys(resources))).toEqual(new Set(keys));
expect(Object.keys(resources)).toHaveLength(1800);
});
it('is keyed in sorted (deterministic) order', () => {
const ks = Object.keys(resources);
expect(ks).toEqual([...ks].sort());
});
it('every entry is a finite { oil, iron, food, industry } in [0,1] with ≤ 2 dp', () => {
for (const [k, v] of Object.entries(resources)) {
for (const r of RESOURCES) {
const val = v[r];
expect(typeof val, `non-number ${r} for ${k}`).toBe('number');
expect(Number.isFinite(val), `non-finite ${r} for ${k}`).toBe(true);
expect(val >= 0, `negative ${r} for ${k}: ${val}`).toBe(true);
expect(val <= 1, `${r} above 1 for ${k}: ${val}`).toBe(true);
expect(
Math.round(val * 100) / 100 === val,
`${r} over 2dp for ${k}: ${val}`,
).toBe(true);
}
}
});
it('spot-checks well-known regions for exact profiles', () => {
const is = (k: string, oil: number, iron: number, food: number, industry: number) => {
const v = resources[k];
expect(v, `missing resources for ${k}`).toBeDefined();
expect(v!.oil, `oil for ${k}`).toBe(oil);
expect(v!.iron, `iron for ${k}`).toBe(iron);
expect(v!.food, `food for ${k}`).toBe(food);
expect(v!.industry, `industry for ${k}`).toBe(industry);
};
is('california-kikh', 0.31, 0.1, 0.82, 0.97); // US-CA
is('arkhangel-sk-lhbk', 0.49, 0.52, 0.98, 0.82); // RU-ARK mainland core
is('pechora-qwhs', 0.11, 0.16, 1, 0.04); // RU-PEO (Arctic-coast band, #41)
is('yamal-gosw', 0.64, 1, 1, 0.49); // RU-YAM (Arctic-coast band, #41)
is('new-earth-eikt', 0.6, 0.56, 0.91, 0.66); // RU-ARK-NE (island group, #31)
is('severnaya-zemlya-awqp', 0.1, 0.48, 1, 0.3); // RU-ARK-SZ (island group, #31)
is('ain-lqtt', 0.94, 0.92, 0.51, 0.4); // FR-01 (Ain)
});
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
const file = path.join(root, 'public', 'region-resources.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 (region-keys.json + the
// committed region-geo.json when present) + pure string hashing + fixed
// 2-dp rounding + stable (sorted) key order → identical bytes.
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-resources.mjs')], {
cwd: root,
stdio: 'pipe',
});
const after = readFileSync(file, 'utf8');
expect(after).toBe(before);
});
});
+57
View File
@@ -36,6 +36,8 @@ function makeState(id: string): GameState {
// (v0.35.0, #58) no active trades or embargoes.
trades: [],
embargoes: [],
// (v0.36.0, #59) no industry projects.
industry: [],
};
}
@@ -165,6 +167,61 @@ describe('openDb', () => {
db.close();
});
it('(v0.36.0 #59) a legacy row saved before the industry field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { industry?: unknown[] }).industry; // a pre-v0.36 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.industry).toEqual([]);
db.close();
});
it('(v0.37.0 #60) a legacy row saved before the nation nuclear flag existed loads with it absent (not capable)', () => {
const db = openDb(':memory:');
// makeState has no nation.nuclear field — a pre-v0.37 save. The flag is
// optional and not re-stamped by load, so an absent flag stays absent.
db.save(makeState('legacy'));
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.nuclear).toBeUndefined();
db.close();
});
it('(v0.37.0 #60) a set nation nuclear flag round-trips through save/load', () => {
const db = openDb(':memory:');
const s = makeState('nuc');
s.nations.uk.nuclear = true; // armed (e.g. by a turn's nuclear.arm op)
db.save(s);
const loaded = db.load('nuc');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.nuclear).toBe(true);
db.close();
});
it('(v0.38.0 #61) a legacy row saved before the nation leader field existed loads with it absent (no leader)', () => {
const db = openDb(':memory:');
// makeState has no nation.leader field — a pre-v0.38 save. The field is
// optional and not re-stamped by load, so an absent leader stays absent.
db.save(makeState('legacy'));
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.leader).toBeUndefined();
db.close();
});
it('(v0.38.0 #61) a set nation leader round-trips through save/load', () => {
const db = openDb(':memory:');
const s = makeState('ldr');
s.nations.uk.leader = { name: 'Admiral Old' };
db.save(s);
const loaded = db.load('ldr');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.leader).toEqual({ name: 'Admiral Old' });
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+3
View File
@@ -57,6 +57,9 @@ export function openDb(filePath: string): Db {
// legacy game has no active trades or embargoes.
if (state.trades === undefined) state.trades = [];
if (state.embargoes === undefined) state.embargoes = [];
// (v0.36, #59) Same for the industry layer: a legacy game has no
// active industry projects.
if (state.industry === undefined) state.industry = [];
// (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
+729 -39
View File
File diff suppressed because it is too large Load Diff
+777 -6
View File
@@ -8,39 +8,52 @@ import type {
EventOutcome,
EventType,
GameState,
IndustryProject,
IndustryType,
MapMarker,
Nation,
NationStats,
Preset,
RelationStatus,
RegionResources,
RegionStats,
RoundItem,
StatKey,
TradeAgreement,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import { EVENT_TYPES, INDUSTRY_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
canConquer,
clampStat,
deriveNationResources,
deriveNationStats,
fnv1a32,
HYBRID_OP_TYPES,
isDateLike,
isMarkerType,
isNuclearCapable,
LEADER_STABILITY_EFFECT,
makeEvent,
markerId,
MAX_MARKERS,
NUCLEAR_COLLAPSE,
NUCLEAR_RETALIATION,
nationForPrompt,
type EconomyEmbargoOp,
type EconomyOps,
type EconomyTradeOp,
type HybridOp,
type HybridOpType,
type IndustryOps,
type LeaderCause,
type LeaderChange,
type MarkerInfo,
type MarkerSpec,
type NationOp,
type NuclearOps,
type NuclearStatKey,
type RegionStatChange,
type Takeover,
type TurnEventSpec,
@@ -70,6 +83,13 @@ export function isHybridOpType(t: unknown): t is HybridOpType {
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
}
const VALID_INDUSTRY_TYPES = new Set<string>(INDUSTRY_TYPES);
/** True when `t` is one of the four known industry project types (v0.36, #59). */
export function isIndustryType(t: unknown): t is IndustryType {
return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t);
}
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
/**
@@ -224,6 +244,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -245,7 +268,10 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
'- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".',
'- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
'- 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.',
@@ -321,6 +347,31 @@ export function buildUserPrompt(
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
*/
embargoes?: Embargo[],
/**
* (v0.36, #59) Each nation's AGGREGATE resource profile (nation id -> the
* area×population-weighted mean of the resource profiles of the regions it
* owns, via `deriveNationResources`). Emitted verbatim (even when empty) so
* the model can reason about which nations are rich in which resource and
* where to build industry projects. Omitted entirely when undefined (no
* resource manifest loaded).
*/
nationResources?: Record<string, RegionResources>,
/**
* (v0.36, #59) The game's currently ACTIVE industry projects (each a region
* key + project type). Emitted verbatim (even when empty) so the model knows
* which regions already host a project (and should not be re-declared) and
* can reference them. Omitted entirely when undefined.
*/
industryProjects?: IndustryProject[],
/**
* (v0.37.0, #60) Each nation's nuclear capability (nation id -> boolean),
* computed by the caller via `isNuclearCapable` (the nation's `nuclear` flag
* OR a current `rocket` marker). When defined, each nation in the `state.
* nations` map is stamped with `nuclear: true|false` so the model knows who
* can arm/strike and who can MAD-retaliate. Omitted entirely (no `nuclear`
* key on any nation) when undefined.
*/
nuclear?: Record<string, boolean>,
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -410,7 +461,10 @@ export function buildUserPrompt(
date: state.date,
playerNationId: state.playerNationId,
nations: Object.fromEntries(
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
Object.entries(state.nations).map(([id, n]) => [
id,
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
]),
),
recentEvents: state.events.slice(-5).map((e) => ({
turn: e.turn,
@@ -437,6 +491,8 @@ export function buildUserPrompt(
if (relations !== undefined) payload.relations = relations;
if (trades !== undefined) payload.trades = trades;
if (embargoes !== undefined) payload.embargoes = embargoes;
if (nationResources !== undefined) payload.resources = nationResources;
if (industryProjects !== undefined) payload.industry = industryProjects;
return JSON.stringify(payload, null, 2);
}
@@ -578,6 +634,9 @@ export function validateTurnResult(
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
markers: { add: [], remove: [] },
};
@@ -688,6 +747,29 @@ export function validateTurnResult(
// ambiguous / self-pairing ops are dropped there, never thrown.
result.economy = validateEconomyOps(o.economy);
// (v0.36, #59) Industry projects: the LLM names a region key + one of the
// four project types. Each project becomes an active entry in
// `GameState.industry` (a per-region SET). The region key is validated as a
// non-empty string here; whether the key is real and owned is checked at
// apply time (a project on a region with no sole owner yields no boost but
// is kept). Refs stay raw and the effect is deterministic at apply time.
result.industry = validateIndustryOps(o.industry);
// (v0.37, #60) Nuclear / WMD ops: `arm` grants an arsenal to the named
// nations and `strikes` are nuclear strikes fired this turn. Refs stay raw
// here (resolved against the real state at apply time, like wars / hybrid
// ops); an unresolvable ref, a non-capable striker, or an invalid region is
// dropped at apply time, never thrown. Applied directly by applyTurnResult
// (after industry, before markers).
result.nuclear = validateNuclearOps(o.nuclear);
// (v0.38, #61) Leader changes: assassinations / deaths / appointments of
// named leaders. Refs stay raw here (resolved against the real state at
// apply time, like wars / hybrid / nuclear ops); an unresolvable target or a
// leaderless assassination/death is dropped at apply time, never thrown.
// Applied by applyTurnResult after nuclear, before markers.
result.leaders = { changes: validateLeaderOps(o.leaders) };
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -962,6 +1044,101 @@ export function validateEconomyOps(raw: unknown): EconomyOps {
return out;
}
/**
* (v0.36, #59) Validate the raw `industry` object of a turn into a safe
* {@link IndustryOps}. Defensive like the rest of the parser — never throws.
* `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a
* known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a
* missing/malformed field are dropped. The region key is kept raw here (like
* the hybrid / economy refs) — whether it is a *valid* region key, and whether
* its region ends the turn with a sole owner, is checked at apply time against
* the manifest and the final ownership (a project on a region with no sole
* owner yields no boost but is kept). A missing / malformed top-level object
* yields `{ projects: [] }`.
*/
export function validateIndustryOps(raw: unknown): IndustryOps {
const out: IndustryOps = { projects: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.projects)) {
for (const e of o.projects) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const regionKey = str(p.regionKey, 120);
if (!regionKey || !isIndustryType(p.type)) continue;
out.projects.push({ regionKey, type: p.type });
}
}
return out;
}
/**
* (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe
* {@link NuclearOps}. Defensive like the rest of the parser — never throws.
* `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is
* resolved (id or name) at APPLY time, so an unknown ref is dropped there, not
* here. `strikes`: each needs a non-empty `from` (the striking nation ref) and
* a non-empty `target` (a nation ref OR a region key), each capped at 120
* chars; a self-target (`from` and `target` the same ref) is dropped here. A
* missing / malformed top-level object yields `{ arm: [], strikes: [] }`.
*/
export function validateNuclearOps(raw: unknown): NuclearOps {
const out: NuclearOps = { arm: [], strikes: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.arm)) {
for (const e of o.arm) {
const s = typeof e === 'string' ? e.trim() : '';
if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120));
}
}
if (Array.isArray(o.strikes)) {
for (const e of o.strikes) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const from = str(s.from, 120);
const target = str(s.target, 120);
if (!from || !target) continue;
if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop
out.strikes.push({ from, target });
}
}
return out;
}
/**
* (v0.38, #61) Validate the raw `leaders` object of a turn into a safe
* `LeaderChange[]`. Defensive like the rest of the parser — never throws.
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change
* needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty
* `name` (the new leader's name, capped at 120 chars), and a `cause` that is
* one of the three known literals (assassination / death / appointed). Refs
* stay raw here (resolved against the real state at apply time, like the wars
* / hybrid / nuclear ops); a leaderless assassination/death is dropped at
* apply time, never thrown.
*/
export function validateLeaderOps(raw: unknown): LeaderChange[] {
const out: LeaderChange[] = [];
const list = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? (raw as Record<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const target = str(s.target, 120);
const name = str(s.name, 120);
if (!target || !name) continue;
const cause = typeof s.cause === 'string' ? s.cause : '';
if (cause === 'assassination' || cause === 'death' || cause === 'appointed') {
out.push({ target, name, cause: cause as LeaderCause });
}
}
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},
@@ -1246,6 +1423,85 @@ function applyHybridOp(
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
}
// --- Named leaders (v0.38, #61) -----------------------------------------------
/**
* (v0.38, #61) Apply a single leader change to `next` (in place). The target
* nation is resolved by the shared resolver (id first, then a case-insensitive
* unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE
* then applies: an `assassination` or `death` requires the target to already
* have a leader (a leaderless target is dropped); an `appointed` change is
* always valid (it creates the first leader). On a valid change the target's
* `leader` is set to the new name and a DETERMINISTIC stability dip
* (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
* (clamped 0–100) and mirrored to every region the nation owns
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
* same 0-region / no-entry guards as the hybrid/economy/industry effects, so a
* 0-region nation keeps its directly-mutated value). Finally a narration event
* is appended: a dramatic `military` event for an assassination, a `political`
* succession event for a death or an appointment. The replacement never moves
* territory (sole ownership is untouched).
*/
function applyLeaderChange(
next: GameState,
op: LeaderChange,
geoOf?: (key: string) => { area: number; population: number } | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const id = resolveNationRef(next, op.target);
if (id === null) return; // unknown / ambiguous target: drop, never throw
const n = next.nations[id];
// GATE: assassination / death require an existing leader; an appointment is
// always valid (it creates the first leader).
if (op.cause !== 'appointed' && !n.leader) return;
const prevName = n.leader?.name;
n.leader = { name: op.name };
const delta = LEADER_STABILITY_EFFECT[op.cause];
if (delta !== 0) {
n.stats.stability = clampStat(n.stats.stability + delta);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs.stability = clampStat(rs.stability + delta);
}
// Re-derive the nation's aggregate stats (the main recompute ran earlier
// this turn, so without this the dip would be washed out next turn).
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
const title =
op.cause === 'assassination'
? `${prevName ?? 'The leader'} of ${n.name} is assassinated`
: op.cause === 'death'
? `${prevName ?? 'The leader'} of ${n.name} dies`
: `${n.name} appoints ${op.name}`;
const text =
op.cause === 'assassination'
? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.`
: op.cause === 'death'
? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.`
: `${n.name} has appointed ${op.name} as its new leader.`;
next.events.push(
makeEvent(
{
nation: n.id,
type: op.cause === 'assassination' ? 'military' : 'political',
title,
text,
outcome: 'success',
},
turn,
next.date,
),
);
}
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
/**
@@ -1368,8 +1624,421 @@ function suspendEconomyOnWar(next: GameState): void {
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
}
// --- Industry projects: the per-region economy-boost lever (v0.36, #59) ------
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* (v0.36, #59) The BASE per-turn economy boost one industry project grants its
* region's sole owner. A project's actual boost is this base plus one extra
* point when the region's `industry` resource value is at least 0.5 (a region
* with a strong industry profile yields a bigger return). The base value is
* deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} /
* {@link EMBARGO_ECONOMY_PENALTY}.
*/
const INDUSTRY_ECONOMY_BONUS = 1;
/**
* (v0.36, #59) The per-turn economy boost for ONE industry project sitting in
* `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the
* region's `industry` resource value (from `resourceOf`) is at least 0.5.
* Without the resource manifest (or for a region with no profile) the boost is
* the base value. Pure + deterministic.
*/
function industryBoost(
regionKey: string,
resourceOf?: (key: string) => RegionResources | undefined,
): number {
const r = resourceOf?.(regionKey);
return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0);
}
/**
* (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a
* PER-REGION SET: each op names a region key + a project type. A region key
* that is not a valid manifest key (`validKeys`, when supplied) is dropped; a
* region that already hosts a project is untouched (the first declared type
* wins — re-declaring on the same region is a no-op, the type is never
* replaced). Otherwise the project is appended. Mutates `next` in place.
* Nothing here throws.
*/
function applyIndustryOps(
next: GameState,
ops: IndustryOps,
validKeys?: ReadonlySet<string>,
): void {
if (ops.projects.length === 0) return;
const has = (key: string) => next.industry.some((p) => p.regionKey === key);
for (const p of ops.projects) {
if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop
if (has(p.regionKey)) continue; // a region hosts at most one project
next.industry.push({ regionKey: p.regionKey, type: p.type });
}
}
/**
* (v0.36, #59) The per-turn economic effect of the industry layer, applied
* EVERY committed turn (not just to this turn's adds): each active project
* grants its region's SOLE owner a small, deterministic economy boost (see
* {@link industryBoost}) — clamped to 0–100, mirrored to every region the
* owner holds (`regionStats`), and each affected nation's derived stats are
* re-derived afterwards (the same 0-region / no-entry guards as
* `applyEconomyEffects`). A project whose region ends the turn with NO owner,
* or 2+ owners (released to no man's land / corrupt), yields no boost but is
* kept in the list. Sole ownership is untouched. No-op when there are no
* active projects. Never throws.
*/
export function applyIndustryEffects(
next: GameState,
resourceOf?: (key: string) => RegionResources | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.industry.length === 0) return;
// Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to
// nothing and yields no boost).
const ownerOf = new Map<string, string>();
{
const owners = new Map<string, string[]>();
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
const list = owners.get(key);
if (list) list.push(n.id);
else owners.set(key, [n.id]);
}
}
for (const [key, list] of owners) {
if (list.length === 1) ownerOf.set(key, list[0]);
}
}
// nation id -> accumulated economy boost (a nation owning several project
// regions gets each of their boosts).
const delta = new Map<string, number>();
for (const p of next.industry) {
const owner = ownerOf.get(p.regionKey);
if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept)
const n = next.nations[owner];
if (!n) continue; // a nation removed this turn (a merge): the boost is dropped
delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf));
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue;
n.stats.economy = clampStat(n.stats.economy + d);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
// --- Nuclear / WMD (v0.37, #60): the WMD lever --------------------------------
//
// A nuclear strike is a DETERMINISTIC devastation (no hidden randomness): the
// LLM decides which nuclear-capable nation strikes and what it targets; the
// engine decides the size of the effect (NUCLEAR_COLLAPSE / NUCLEAR_RETALIATION
// in protocol.ts). This block runs AFTER the industry effects and BEFORE the
// marker owner-recompute, because a region strike RELEASES its region to no
// man's land, which changes the final ownership the markers depend on.
//
// ARM: each `arm` ref (id or name) is resolved and that nation's `nuclear`
// flag is set to true (idempotent; an unresolvable ref is dropped).
// Arming runs first, so a nation that arms AND strikes this turn is
// capable.
//
// STRIKE: each strike is validated independently (never throws):
// (a) `from` must resolve to a nuclear-capable nation (the `nuclear` flag
// or a rocket marker), else dropped.
// (b) The target is a NATION (a ref resolving to a distinct nation) or a
// REGION (a region key / name with a SOLE owner that is not the
// striker):
// - a nation target: the target's economy / military / stability are
// devastated (NUCLEAR_COLLAPSE, mirrored to its regions); if `from`
// and the target are on opposite sides of an active war, that war
// ends in `capitulation` and the warring relation falls back to
// hostile; if the target is nuclear-capable, the striker suffers a
// half-strength MAD retaliation.
// - a region target: the region is released to no man's land (its
// regionStats entry leaves the table), its former owner is
// devastated (NUCLEAR_COLLAPSE), and if that owner is nuclear-capable
// the striker suffers a half-strength MAD retaliation.
// (c) every applied strike appends a dramatic event (the EXISTING `military`
// category — no new category is invented), and a MAD strike appends an
// extra "MAD retaliation" event.
//
// After all strikes, every affected nation's derived stats are re-derived
// (the same 0-region / no-entry guards as the main recompute), so the
// collapse persists and the sole-ownership invariant is untouched.
/** The three stats a nuclear strike collapses (population is never touched). */
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
/**
* Devastate one nation's stats AND every region it owns (mirroring the
* collapse into `regionStats`, like the economy / hybrid layers), clamped to
* 0–100. Never throws.
*/
function collapseNationStats(
next: GameState,
nationId: string,
collapse: Record<NuclearStatKey, number>,
): void {
const n = next.nations[nationId];
if (!n) return;
for (const k of NUCLEAR_STATS) {
const d = collapse[k];
n.stats[k] = clampStat(n.stats[k] + d);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs[k] = clampStat(rs[k] + d);
}
}
}
/**
* Release a region key to no man's land, reusing the turn's release semantics:
* the key leaves its SOLE owner's territory, is marked in `GameState.unowned`,
* and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an
* already-unowned key) is left untouched. Never throws.
*/
function releaseRegionToNoMansLand(next: GameState, key: string): void {
if (next.unowned.includes(key)) return; // already no man's land
const owners = Object.values(next.nations).filter((n) => n.territory.includes(key));
if (owners.length !== 1) return; // 0 or 2+ owners: not releasable
next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter(
(k) => k !== key,
);
next.unowned.push(key);
delete next.regionStats[key];
}
/**
* End every ACTIVE war whose sides include both `a` and `b` (a nuclear
* capitulation), then downgrade the `war` relation of every pair that is no
* longer covered by an active war back to `hostile` (the war → relation sync).
* Capturing the active pairs immediately BEFORE the end keeps this correct for
* a war declared and ended in the SAME turn (where the start-of-turn `prev`
* that `syncWarRelations` uses would not contain it). Never throws.
*/
function endWarsBetween(next: GameState, a: string, b: string): void {
const before = activeWarPairs(next.wars);
let ended = false;
for (const w of next.wars) {
if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) {
w.status = 'capitulation';
ended = true;
}
}
if (!ended) return;
const after = activeWarPairs(next.wars);
for (const key of before) {
if (after.has(key)) continue; // still covered by another active war
const [x, y] = key.split('\u0000');
const nx = next.nations[x];
const ny = next.nations[y];
if (!nx || !ny) continue;
if (nx.relations[y] === 'war' || ny.relations[x] === 'war') {
nx.relations[y] = 'hostile';
ny.relations[x] = 'hostile';
}
}
}
/**
* Force the relation between two nations to `hostile` in BOTH directions —
* the aftermath of a MAD exchange. A `war` relation is never downgraded (the
* war entity owns it while active — e.g. a region strike that devastates one
* warring side without ending the war). Never throws.
*/
function forceHostile(next: GameState, a: string, b: string): void {
const na = next.nations[a];
const nb = next.nations[b];
if (!na || !nb) return;
if (na.relations[b] !== 'war') na.relations[b] = 'hostile';
if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile';
}
/**
* Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in
* place and never throwing. See the section comment above for the full
* contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the
* "owns a rocket" half of nuclear capability (a rocket marker placed this very
* turn makes its owner capable only from the next turn, while a nation that
* ARMS this turn is capable immediately, since arming runs first).
*/
export function applyNuclearOps(
next: GameState,
ops: NuclearOps,
state: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
regionNameOf?: (key: string) => string | undefined,
actingTurn?: number,
): void {
if (ops.arm.length === 0 && ops.strikes.length === 0) return;
const turn = actingTurn ?? next.turn;
const markers = state.markers ?? [];
const capable = (id: string): boolean => {
const n = next.nations[id];
return n ? isNuclearCapable(n, markers) : false;
};
// (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped.
for (const ref of ops.arm) {
const id = resolveNationRef(next, ref);
if (id === null) continue;
const n = next.nations[id];
if (!n) continue;
n.nuclear = true;
}
const affected = new Set<string>();
const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key;
// (2) STRIKES: each validated independently against the post-arm state.
for (const strike of ops.strikes) {
const fromId = resolveNationRef(next, strike.from);
if (fromId === null) continue; // unknown / ambiguous striker: drop
const from = next.nations[fromId];
if (!from) continue;
if (!capable(fromId)) continue; // a non-capable nation cannot strike
if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop
// Resolve the target: a NATION (a ref resolving to a distinct nation) or a
// REGION (a region key / name with a sole owner that is not the striker).
let target:
| { kind: 'nation'; nation: Nation }
| { kind: 'region'; key: string; owner: Nation }
| null = null;
const tid = resolveNationRef(next, strike.target);
if (tid !== null) {
const tn = next.nations[tid];
if (tn) target = { kind: 'nation', nation: tn };
} else {
const rk = validKeys
? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined))
: (strike.target.trim() || null);
if (rk !== null) {
const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk));
if (owners.length === 1 && owners[0]!.id !== fromId) {
target = { kind: 'region', key: rk, owner: owners[0]! };
}
// a 0/2+-owner region, or a region the striker owns (self), is dropped
}
}
if (target === null) continue; // unresolvable target: drop
if (target.kind === 'nation') {
// Devastate the target nation.
collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE);
affected.add(target.nation.id);
// A strike on a nation at war with the striker ends that war in
// capitulation (the relation then falls back to hostile).
endWarsBetween(next, fromId, target.nation.id);
// MAD: a nuclear-capable target strikes back at half strength and the
// two nations end the exchange at least hostile.
if (capable(target.nation.id)) {
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
affected.add(fromId);
forceHostile(next, fromId, target.nation.id);
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: 'MAD retaliation',
text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`,
outcome: 'failure',
},
turn,
next.date,
),
);
}
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: `Nuclear strike on ${target.nation.name}`,
text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`,
outcome: 'success',
},
turn,
next.date,
),
);
} else {
// Devastate the region: release it to no man's land, then devastate its
// former owner.
const owner = target.owner;
releaseRegionToNoMansLand(next, target.key);
collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE);
affected.add(owner.id);
// MAD: a nuclear-capable owner strikes back at half strength and the
// two nations end the exchange at least hostile.
if (capable(owner.id)) {
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
affected.add(fromId);
forceHostile(next, fromId, owner.id);
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: 'MAD retaliation',
text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay(
target.key,
)}; mutual assured destruction devastates ${from.name} in turn.`,
outcome: 'failure',
},
turn,
next.date,
),
);
}
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: `Nuclear strike on ${regionDisplay(target.key)}`,
text: `${from.name} obliterates ${regionDisplay(
target.key,
)} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`,
outcome: 'success',
},
turn,
next.date,
),
);
}
}
// (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region /
// no-entry guards as the main recompute), so the collapse persists.
for (const id of affected) {
const n = next.nations[id];
if (!n) continue;
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);
}
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
* a proposal is a real pending-world change the player will resolve, so it
@@ -1397,6 +2066,16 @@ export function isMeaningful(result: TurnResult): boolean {
// turn is a real world change (the active economy lists moved).
result.economy.trade.length > 0 ||
result.economy.embargo.length > 0 ||
// (v0.36, #59) an industry project built this turn is a real world change
// (the active project list moved).
result.industry.projects.length > 0 ||
// (v0.37, #60) a nation arming a nuclear arsenal or a nuclear strike fired
// this turn is a real world change.
result.nuclear.arm.length > 0 ||
result.nuclear.strikes.length > 0 ||
// (v0.38, #61) a leader assassinated / died / appointed this turn is a real
// world change (a nation's leader and stability moved).
result.leaders.changes.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -1441,6 +2120,13 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* (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.
*
* `resourceOf` (v0.36, #59) resolves a region key to its synthesized resource
* profile (`{ oil, iron, food, industry }`, from public/region-resources.json
* via server/regions.ts). It is used to scale each industry project's per-turn
* economy boost by the region's `industry` value (a region with a strong
* industry profile yields a bigger return). Without it the boost is the
* unscaled base value — the engine stays pure and deterministic either way.
*/
export function applyTurnResult(
state: GameState,
@@ -1449,12 +2135,16 @@ export function applyTurnResult(
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
regionNameOf?: (key: string) => string | undefined,
resourceOf?: (key: string) => RegionResources | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
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 ?? {};
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
// field starts empty (the turn then only ever adds to it).
next.industry = next.industry ?? [];
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.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -1779,6 +2469,47 @@ export function applyTurnResult(
suspendEconomyOnWar(next);
applyEconomyEffects(next, geoOf);
// --- Industry projects (v0.36, #59): per-region economy-boost lever ---
//
// Placed AFTER the economy block (both move stats, and the industry boost
// should compose with the trade/embargo effects of the same turn) and
// before markers / nationOps. Order:
// (1) APPLY OPS: this turn's `industry.projects` are added to
// `GameState.industry` as a per-region SET — a region key that is not
// a valid manifest key is dropped, and a region that already hosts a
// project is untouched (the first declared type wins; re-declaring is
// a no-op).
// (2) PER-TURN EFFECT: the FULL active project list (not just this
// turn's adds) each grants their region's SOLE owner a small,
// deterministic economy boost scaled by the region's `industry`
// resource value — clamped, mirrored to every region the owner holds,
// and re-derived like the economy/hybrid effects. A project whose
// region ends the turn with no owner (or 2+ owners) yields no boost
// but is KEPT (the region may be re-claimed later). Sole ownership is
// untouched (the layer moves stats, never territory).
applyIndustryOps(next, result.industry, validKeys);
applyIndustryEffects(next, resourceOf, geoOf);
// --- Nuclear / WMD (v0.37, #60): the WMD lever ---
//
// Placed AFTER the industry/economy effects (both move stats) and BEFORE the
// markers block (a region strike releases its region to no man's land, which
// changes the FINAL ownership the marker owner-recompute depends on). See
// applyNuclearOps for the full contract (arm, strike, MAD, re-derive).
// `state` (the original pre-turn state) supplies the markers used for the
// rocket-marker half of nuclear capability.
applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn);
// --- Named leaders (v0.38, #61) ---
//
// Placed AFTER the nuclear block (both are the "heavier" levers) and BEFORE
// the markers block (markers recompute their owner from the FINAL post-claim
// ownership; leaders only touch stats, so ordering w.r.t. markers is
// immaterial, but keeping the levers grouped is clearer). Each change sets
// the target's leader, applies a deterministic stability dip by cause, and
// appends a narration event. A leaderless assassination/death is dropped.
for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn);
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -2250,8 +2981,17 @@ export async function runTurn(opts: {
* it falls back to the raw region key. Pure either way.
*/
regionNameOf?: (key: string) => string | undefined;
/**
* (v0.36, #59) Region-key -> synthesized resource profile resolver (from
* public/region-resources.json via server/regions.ts). Present, each
* industry project's per-turn economy boost is scaled by its region's
* `industry` value, and each nation's aggregate resource profile is carried
* in the user prompt; absent, industry boosts use the unscaled base value
* and the profile is omitted.
*/
resourceOf?: (key: string) => RegionResources | undefined;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf, resourceOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
@@ -2282,6 +3022,26 @@ export async function runTurn(opts: {
// starting point before an `exposed` backfire). Always emitted ({} when the
// player has no relations yet).
const relations = state.nations[state.playerNationId]?.relations ?? {};
// (v0.36, #59) each nation's AGGREGATE resource profile (the area×population-
// weighted mean of the resource profiles of the regions it owns). Carried in
// the user prompt ONLY when the resource manifest is loaded (resourceOf
// present); otherwise omitted (undefined) so the prompt — and the stub —
// stay identical to the no-manifest case.
const nationResources =
resourceOf === undefined
? undefined
: Object.fromEntries(
Object.values(state.nations).map((n) => [
n.id,
deriveNationResources(n.territory, resourceOf, geoOf),
]),
);
// (v0.37.0, #60) each nation's nuclear capability (its `nuclear` flag OR a
// current `rocket` marker), so the model knows who can arm/strike and who
// can MAD-retaliate. Always emitted (computed from the PRE-turn markers).
const nuclearCapability = Object.fromEntries(
Object.values(state.nations).map((n) => [n.id, isNuclearCapable(n, state.markers ?? [])]),
);
const user = buildUserPrompt(
state,
items,
@@ -2295,6 +3055,14 @@ export async function runTurn(opts: {
// reference — and `lift` — what is currently in force.
state.trades,
state.embargoes,
// (v0.36, #59) the aggregate resource profiles (undefined without the
// manifest) + the active industry projects, so the model knows which
// regions already host a project and which nations are rich in which
// resource.
nationResources,
state.industry ?? [],
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
nuclearCapability,
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -2351,12 +3119,15 @@ export async function runTurn(opts: {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
markers: { add: [], remove: [] },
};
}
return {
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf, resourceOf),
result,
};
}
+11 -1
View File
@@ -25,7 +25,7 @@ import { makeScenarioStubLlmClient } from './stubScenario';
import { finalizeWorld, generateDraft } from './scenario';
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validCountryCodes, validKeys } from './regions';
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, hasResources, loadRegions, neighboursOf, referenceForKeys, regionName, resourceOf, validCountryCodes, validKeys } from './regions';
import { createPresetStore } from './presets';
import { createTemplateStore } from './templates';
import { openDb, type Db } from './db';
@@ -186,6 +186,10 @@ app.post('/api/games', (req: Request, res) => {
// declares (and lifts) them over time via TurnResult.economy.
trades: [],
embargoes: [],
// (v0.36.0, #59) No active industry projects on a fresh game; the LLM
// declares them over time via TurnResult.industry (one per region, a
// per-region SET).
industry: [],
};
// 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
@@ -370,6 +374,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
// 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),
// (v0.36.0, #59) when the region-resources manifest (the synthesized
// per-region resource profiles) loaded, hand the engine the resolver —
// the industry boost is then scaled by each region's `industry` value
// (without it boosts use the unscaled base value) and each nation's
// aggregate resource profile is carried in the user prompt.
resourceOf: hasResources() ? (key) => resourceOf(key) : undefined,
});
db.save(state);
broadcast(id, 'turn', { state, result });
+2
View File
@@ -57,6 +57,8 @@ const state: GameState = {
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
trades: [],
embargoes: [],
// (v0.36.0, #59) the map is ownership-only; industry is unused here.
industry: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+77
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
canConquer,
CONQUEST_MILITARY_MARGIN,
deriveNationResources,
deriveNationStats,
migrateRegionStats,
seedRegionStats,
@@ -44,6 +45,7 @@ function mkState(
wars: [],
trades: [],
embargoes: [],
industry: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
@@ -139,6 +141,81 @@ describe('deriveNationStats', () => {
});
});
// --- deriveNationResources ---------------------------------------------------
describe('deriveNationResources (v0.36.0 #59)', () => {
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
};
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 average', () => {
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
});
});
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
// equal weights 1,1 → the true mean 0.5 on every axis
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
});
// same when geoOf is provided but returns undefined for the keys (missing geo)
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
});
});
it('skips a region key that has no resource-profile entry', () => {
// b has no profile → only a is counted → a's values
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
expect(out).toEqual(prof.a);
});
it('returns the zero profile when none of the owned regions has a profile', () => {
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
oil: 0, iron: 0, food: 0, industry: 0,
});
});
it('a single region yields exactly that region\'s profile', () => {
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
});
it('treats zero/negative area or population as a fallback weight of 1', () => {
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
const g: Record<string, { area: number; population: number }> = {
a: { area: 0, population: 1000 },
b: { area: 1000, population: 1000 },
};
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
});
});
it('rounds each axis to 2 decimal places', () => {
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
const g: Record<string, { area: number; population: number }> = {
a: { area: 1, population: 1 },
b: { area: 2, population: 1 },
};
const out = deriveNationResources(
['a', 'b'],
(k) => k === 'a'
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
(k) => g[k],
);
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
});
});
// --- seedRegionStats ---------------------------------------------------------
describe('seedRegionStats', () => {
+30
View File
@@ -12,11 +12,13 @@ import {
hasCentroids,
hasGeo,
hasRegionData,
hasResources,
loadRegions,
neighboursOf,
referenceForKeys,
regionName,
resetRegions,
resourceOf,
validKeys,
} from './regions';
import type { GameState } from '../shared/types';
@@ -114,6 +116,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
trades: [],
embargoes: [],
// (v0.36.0, #59) unused here (allTerritoryKeys is ownership-only).
industry: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -172,6 +176,16 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
});
it('(v0.36 #59) hasResources() is true and resourceOf resolves a real key to a 4-value profile', () => {
expect(hasResources()).toBe(true);
// exact spot-check values pinned from the committed manifest (Ain, FRA)
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
});
it('(v0.36 #59) resourceOf returns undefined for an unknown key', () => {
expect(resourceOf('not-a-real-key-xyz')).toBeUndefined();
});
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
@@ -191,6 +205,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// and no region-geo: the per-region stats weighting degrades to uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
// and no region-resources: industry boosts use the unscaled base value
expect(hasResources()).toBe(false);
expect(resourceOf('ain-lqtt')).toBeUndefined();
});
});
@@ -248,6 +265,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
// and no region-resources (not copied into this dist/) -> boosts unscaled
expect(hasResources()).toBe(false);
expect(resourceOf('ain-lqtt')).toBeUndefined();
});
it('(v0.36 #59) a dist/ WITH the region-resources manifest loads it (prod layout)', () => {
copyFileSync(
path.join(realRoot, 'public', 'region-resources.json'),
path.join(prodRoot, 'dist', 'region-resources.json'),
);
expect(loadRegions(prodRoot)).toBe(true);
expect(hasResources()).toBe(true);
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
});
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
+64 -2
View File
@@ -19,6 +19,9 @@
// — 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)
// - resourceOf(): a region key's synthesized resource profile (from
// region-resources.json) — the data behind the LLM's industry lever
// (v0.36, #59)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
@@ -26,7 +29,7 @@
// Loaded once at boot; the accessors below are pure data reads thereafter.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { GameState } from '../shared/types';
import type { GameState, RegionResources } from '../shared/types';
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
interface RegionInfo {
@@ -49,6 +52,8 @@ interface RegionData {
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 }>;
/** region key -> resource profile (from public/region-resources.json, v0.36 #59). */
resources: Record<string, RegionResources>;
}
let data: RegionData | null = null;
@@ -77,6 +82,7 @@ export function loadRegions(root: string): boolean {
const adjacencyFile = 'region-adjacency.json';
const centroidsFile = 'region-centroids.json';
const regionGeoFile = 'region-geo.json';
const regionResourcesFile = 'region-resources.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
@@ -185,7 +191,40 @@ export function loadRegions(root: string): boolean {
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
// The region-resources manifest (v0.36, #59): a synthesized four-value
// resource profile per region key — the data behind the LLM's industry
// lever. OPTIONAL like the others: a missing/broken file means "no
// resource data" — industry boosts degrade to the unscaled base value and
// the prompt omits the aggregate profile — while everything else keeps
// working.
let resources: Record<string, RegionResources> = {};
const resourcesPath = path.join(base, regionResourcesFile);
if (existsSync(resourcesPath)) {
try {
const raw = JSON.parse(readFileSync(resourcesPath, 'utf8')) as Record<
string,
{ oil?: unknown; iron?: unknown; food?: unknown; industry?: unknown }
>;
// Keep only well-formed entries (all four values finite numbers in
// [0, 1]); a corrupt file degrades to "none".
for (const [k, v] of Object.entries(raw)) {
if (
v &&
typeof v.oil === 'number' && Number.isFinite(v.oil) && v.oil >= 0 && v.oil <= 1 &&
typeof v.iron === 'number' && Number.isFinite(v.iron) && v.iron >= 0 && v.iron <= 1 &&
typeof v.food === 'number' && Number.isFinite(v.food) && v.food >= 0 && v.food <= 1 &&
typeof v.industry === 'number' && Number.isFinite(v.industry) && v.industry >= 0 && v.industry <= 1
) {
resources[k] = { oil: v.oil, iron: v.iron, food: v.food, industry: v.industry };
}
}
} catch (err) {
console.error('[regions] failed to load region-resources.json, continuing without it', err);
resources = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo, resources };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
@@ -322,6 +361,29 @@ export function geoOf(key: string): { area: number; population: number } | undef
return data?.geo[key];
}
/**
* True when the region-resources manifest loaded with at least one entry
* (v0.36, #59). The server's jump route uses this to decide whether to hand
* the engine a resource resolver (which lets a turn scale industry boosts by a
* region's `industry` value and carry each nation's aggregate resource profile
* in the LLM prompt) or skip both (industry boosts fall back to the unscaled
* base value).
*/
export function hasResources(): boolean {
return data !== null && Object.keys(data.resources).length > 0;
}
/**
* The synthesized resource profile for a region key (v0.36, #59): the
* four-value `{ oil, iron, food, industry }` from the committed
* `public/region-resources.json`. `undefined` for an unknown key or when no
* resource data is loaded (industry boosts then use the unscaled base value,
* and the aggregate profile is simply omitted from the prompt).
*/
export function resourceOf(key: string): RegionResources | undefined {
return data?.resources[key];
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for
+41 -1
View File
@@ -13,7 +13,21 @@ export interface StubPayload {
turn: number;
date: string;
playerNationId: string;
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
// `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
// it — it never arms or strikes (see deterministicTurn).
// `leader` (v0.38.0, #61) is the per-nation named leader stamped by the
// engine; the stub ignores it — it never changes a leader (see
// deterministicTurn).
nations: Record<
string,
{
stats: Record<string, number>;
territory?: string[];
nuclear?: boolean;
leader?: { name: string };
}
>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
@@ -67,6 +81,20 @@ export interface StubPayload {
* covered by unit tests only.
*/
embargoes?: Array<{ from: string; target: string }>;
/**
* (v0.36.0, #59) Each nation's aggregate resource profile
* (`{ oil, iron, food, industry }`), emitted by the engine's user prompt.
* The stub ignores this field — it never declares an industry project (see
* deterministicTurn), so the industry path is covered by unit tests only.
*/
resources?: Record<string, { oil: number; iron: number; food: number; industry: number }>;
/**
* (v0.36.0, #59) The currently active industry projects
* (`{ regionKey, type }`), emitted by the engine's user prompt. The stub
* ignores this field — it never declares an industry project (see
* deterministicTurn), so the industry path is covered by unit tests only.
*/
industry?: Array<{ regionKey: string; type: string }>;
}
function truncate(s: string, n: number): string {
@@ -123,6 +151,15 @@ function truncate(s: string, n: number): string {
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
* the trade & embargo economy path is covered by unit tests only, keeping
* the e2e assertions untouched.
*
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
* always returns `industry: { projects: [] }`) — the per-region industry
* path is covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.37.0, #60) The stub likewise NEVER arms a nation or fires a nuclear
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
* nuclear / WMD path is covered by unit tests only, keeping the e2e
* assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -193,6 +230,9 @@ export function deterministicTurn(p: StubPayload): TurnResult {
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
+235
View File
@@ -6,10 +6,13 @@ import type {
EventOutcome,
EventType,
GameState,
IndustryProject,
MapMarker,
MarkerType,
Nation,
NationStats,
RelationStatus,
RegionResources,
RegionStats,
StatKey,
} from './types';
@@ -416,6 +419,147 @@ export interface EconomyOps {
embargo: EconomyEmbargoOp[];
}
/**
* (v0.36, #59) The turn's INDUSTRY operations, nested under
* `TurnResult.industry`. `projects` lists the industry projects the LLM builds
* this turn: each names a **region key** (the region must be valid, and — for
* the boost to take effect — will need a sole owner at apply time) and one of
* the four project {@link IndustryType}s. The engine applies each project to
* `GameState.industry` (a per-region SET — a region already hosting a project
* keeps the first one declared; a region key not in the valid manifest is
* dropped, never thrown). Required; default `{ projects: [] }`.
*/
export interface IndustryOps {
/** The industry projects built this turn (region key + type). */
projects: IndustryProject[];
}
/**
* (v0.37, #60) A single nuclear strike. `from` names the striking nation
* (id or name — it must be **nuclear-capable**, see `isNuclearCapable`);
* `target` names what is struck — either a **region key** (the region is
* devastated to no man's land and its former owner devastated) or a
* **nation** (id or name — the target's economy / military / stability are
* devastated; if the striker and the target are on opposite sides of an
* active war, that war ends in `capitulation`).
*/
export interface NuclearStrike {
/** The striking nation (id or name). Must be nuclear-capable. */
from: string;
/** The struck target: a region key, or a nation (id or name). */
target: string;
}
/**
* (v0.37, #60) The turn's NUCLEAR operations, nested under
* `TurnResult.nuclear`. `arm` names the nations (id or name) that declare a
* nuclear arsenal this turn (idempotent — arming an already-armed nation is a
* no-op; an unresolvable ref is dropped); `strikes` are the nuclear strikes
* fired this turn (at most one per turn in the prompt contract; each is
* validated independently — an invalid `from` or `target` is dropped, never
* thrown). A strike on a nuclear-capable target (or the owner of a struck
* region) triggers a smaller "MAD" retaliation on the striker. Required;
* default `{ arm: [], strikes: [] }`.
*/
export interface NuclearOps {
/** Nation refs (id or name) arming a nuclear arsenal this turn. */
arm: string[];
/** The nuclear strikes fired this turn. */
strikes: NuclearStrike[];
}
// --- Named leaders (v0.38, #61) -----------------------------------------------
//
// Each nation may have a single named leader (`Nation.leader`). The LLM changes
// it each turn via `TurnResult.leaders`. A change names a target nation (id or
// name) and the outcome: an `assassination` (a leader killed — a dramatic,
// destabilizing blow), a `death` (a leader dies of natural causes — a quieter
// succession), or an `appointed` leader (a neutral succession / new head of
// state). The engine sets the nation's leader to the new name and applies a
// DETERMINISTIC stability dip (see `LEADER_STABILITY_EFFECT` below) — no hidden
// randomness. A change whose target has no current leader may only *appoint*
// one (an assassination/death of a leaderless nation is dropped, not thrown).
/** How a nation's leader changed this turn (v0.38, #61). */
export type LeaderCause = 'assassination' | 'death' | 'appointed';
/** A single leader change: the target nation, the new leader's name, the cause. */
export interface LeaderChange {
/** The target nation, by id or name (resolved by the shared resolver). */
target: string;
/** The new leader's name (the incoming / successor head of state). */
name: string;
/** What happened to the leader this turn. */
cause: LeaderCause;
}
/** The leader-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
export interface LeaderOps {
changes: LeaderChange[];
}
/**
* The deterministic stability effect of each leader-cause (v0.38, #61).
* Assassination is the most destabilizing, a natural death a minor wobble, an
* appointment is neutral. Applied to the target's `stability` (clamped 0–100),
* mirrored to its `regionStats`, and its derived stats re-derived afterwards.
*/
export const LEADER_STABILITY_EFFECT: Record<LeaderCause, number> = {
assassination: -15,
death: -3,
appointed: 0,
};
// --- Nuclear / WMD effect math (v0.37, #60) ----------------------------------
//
// A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no
// hidden randomness (the LLM decides WHO strikes and WHAT is targeted; the
// engine decides the size of the effect). The two magnitude tables below are
// the single source of truth for the collapse: a full strike devastates a
// nation (or the former owner of a devastated region) by `NUCLEAR_COLLAPSE`,
// while a "MAD" retaliation (fired when a nuclear-capable nation is struck)
// devastates the STRIKER by half that, `NUCLEAR_RETALIATION`. Both dip the
// three war-relevant stats only (population is untouched); each value is the
// signed per-turn delta the engine clamps against 0–100.
/** The stats a nuclear strike collapses (population is never touched). */
export type NuclearStatKey = 'economy' | 'military' | 'stability';
/**
* (v0.37, #60) The deterministic stat collapse a FULL nuclear strike inflicts
* on its target (a struck nation, or the former owner of a devastated region):
* a heavy dip in economy / military / stability. Shared so the engine and the
* unit tests agree on the exact magnitudes.
*/
export const NUCLEAR_COLLAPSE: Record<NuclearStatKey, number> = {
economy: -40,
military: -50,
stability: -40,
};
/**
* (v0.37, #60) The deterministic stat collapse of a "MAD" RETALIATION — fired
* automatically when a nuclear-capable nation (or the owner of a devastated
* region) is struck — which hits the STRIKER at half the full collapse.
*/
export const NUCLEAR_RETALIATION: Record<NuclearStatKey, number> = {
economy: NUCLEAR_COLLAPSE.economy / 2,
military: NUCLEAR_COLLAPSE.military / 2,
stability: NUCLEAR_COLLAPSE.stability / 2,
};
/**
* (v0.37, #60) Whether a nation is **nuclear-capable** — i.e. able to fire a
* nuclear strike and subject to MAD retaliation: its `nuclear` flag is set, OR
* it owns a `rocket` marker (a launch site is an arsenal). A legacy nation
* without the flag is not nuclear unless it has a rocket marker. Pure +
* shared (used by the engine and the unit tests).
*/
export function isNuclearCapable(nation: Nation, markers: readonly MapMarker[]): boolean {
if (nation.nuclear === true) return true;
return markers.some((m) => m.ownerNationId === nation.id && m.type === 'rocket');
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -506,6 +650,50 @@ export interface TurnResult {
* suspended automatically.
*/
economy: EconomyOps;
/**
* (v0.36, #59) Industry projects built this turn — the LLM's "industry"
* lever. `projects` lists `{ regionKey, type }` pairs: the model names a
* region it (believes it) controls and one of the four project types
* (`oil` / `iron` / `food` / `industry`). Applied directly by
* `applyTurnResult`: each project is added to `GameState.industry` (a
* per-region SET — re-declaring on a region that already has a project is a
* no-op), and on every committed turn each active project gives its region's
* SOLE owner a small, deterministic economy boost scaled by that region's
* `industry` resource value (a region with no owner, or 2+ owners, yields no
* boost but keeps its project). They never move borders — sole ownership is
* untouched. Required; default `{ projects: [] }`.
*/
industry: IndustryOps;
/**
* (v0.37, #60) Nuclear / WMD ops — the LLM's nuclear lever. `arm` names
* the nations that declare a nuclear arsenal this turn (idempotent);
* `strikes` are the nuclear strikes fired this turn (at most one per turn
* in the prompt contract). A strike may target a **region** (devastated to
* no man's land, its former owner devastated) or a **nation** (its economy
* / military / stability devastated; a strike that lands on a nation
* warring with the striker ends that war in capitulation). A strike on a
* nuclear-capable target (or the owner of a struck region) triggers a
* smaller, automatic "MAD" retaliation on the striker. Only
* nuclear-capable nations (the `Nation.nuclear` flag set, or owning a
* `rocket` marker) may strike. Applied directly by `applyTurnResult`,
* never queued. Required; default `{ arm: [], strikes: [] }`.
*/
nuclear: NuclearOps;
/**
* (v0.38, #61) Leader changes this turn. Each change names a target nation
* (id or name) and sets its leader to the new name, applying a deterministic
* stability effect by cause: `assassination` (leader killed — a dramatic
* −15 to the target's stability, a `military` event), `death` (natural
* causes — a quieter −3 succession, a `political` event), or `appointed`
* (a neutral 0 succession, a `political` event). An assassination/death
* requires the target to already have a leader (a leaderless target is
* dropped, never thrown); an appointment is always valid (it creates the
* first leader). At most 1–2 per turn, each narrated in `events`. This is
* the *leader* lever — heavier and more destabilizing than the lighter
* `hybrid.assassinate` op (a −5 stability dip on a nation or region, with
* no leader replacement). Required; default `{ changes: [] }`.
*/
leaders: LeaderOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
@@ -818,6 +1006,7 @@ export function nationForPrompt(n: Nation) {
stats: n.stats,
territory: n.territory,
relations: n.relations,
leader: n.leader,
};
}
@@ -954,3 +1143,49 @@ export function migrateRegionStats(
}
return next;
}
/**
* (v0.36, #59) Derive a nation's aggregate {@link RegionResources} from the
* resource profiles of the regions it owns: each of the four resource values is
* the **area×population-weighted AVERAGE** (an average — not a median — is the
* meaningful aggregation for an abundance profile) over the nation's owned
* region keys. A region's weight is the same
* `(area > 0 ? area : 1) × (population > 0 ? population : 1)` as
* {@link deriveNationStats} — 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)
* average. A region key with no resource-profile entry (`resourceOf` returns
* `undefined` for it — the manifest is not loaded, or the key is absent) is
* skipped. Returns the zero profile when the nation owns no region with a
* profile. Pure + shared.
*/
export function deriveNationResources(
regionKeys: string[],
resourceOf?: (key: string) => RegionResources | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): RegionResources {
const zero: RegionResources = { oil: 0, iron: 0, food: 0, industry: 0 };
const sums: Record<keyof RegionResources, number> = { oil: 0, iron: 0, food: 0, industry: 0 };
let totalW = 0;
for (const key of regionKeys) {
const r = resourceOf?.(key);
if (!r) continue; // a key with no resource profile is skipped
const g = geoOf?.(key);
const a = g && g.area > 0 ? g.area : 1;
const p = g && g.population > 0 ? g.population : 1;
const w = a * p;
totalW += w;
sums.oil += r.oil * w;
sums.iron += r.iron * w;
sums.food += r.food * w;
sums.industry += r.industry * w;
}
if (totalW <= 0) return zero;
const round2 = (v: number) => Math.round(v * 100) / 100;
return {
oil: round2(sums.oil / totalW),
iron: round2(sums.iron / totalW),
food: round2(sums.food / totalW),
industry: round2(sums.industry / totalW),
};
}
+2
View File
@@ -459,6 +459,8 @@ export function worldDraftToGameState(
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
trades: [],
embargoes: [],
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
industry: [],
};
return state;
}
+75
View File
@@ -26,6 +26,23 @@ export interface RegionStats {
population: number;
}
/**
* (v0.36, #59) A region's synthesized four-value resource profile — each value
* a finite fraction in [0, 1]. Built once per region key by
* `scripts/build-region-resources.mjs` into the committed
* `public/region-resources.json` (the engine reads it via `regions.resourceOf`).
* The engine scales a region's per-turn industry boost by its `industry` value,
* and a nation's aggregate profile (the area×population-weighted mean of its
* owned regions' profiles, see `protocol.deriveNationResources`) is what the
* LLM prompt carries so the model can reason about where to build.
*/
export interface RegionResources {
oil: number;
iron: number;
food: number;
industry: number;
}
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
export type StatKey = (typeof STAT_KEYS)[number];
@@ -55,6 +72,22 @@ export interface Nation {
stats: NationStats;
territory: string[];
relations: Record<string, RelationStatus>;
/**
* Whether the nation controls nuclear weapons (v0.37, #60). Optional — a
* legacy game loaded from SQLite has no flag, which means "not nuclear".
* A nation is *nuclear-capable* (for the `nuclear` lever and MAD
* retaliation) when this is `true` OR it owns a `rocket` marker (see
* `isNuclearCapable` in `protocol.ts`).
*/
nuclear?: boolean;
/**
* The nation's current named leader (v0.38, #61). Optional — a legacy game
* (or a nation the LLM has never given a leader to) has none, which is the
* "no leader" baseline. The LLM changes it via `TurnResult.leaders`
* (assassination / death / appointment, see `LeaderChange` in `protocol.ts`);
* each change carries a deterministic stability effect + a narration event.
*/
leader?: { name: string };
}
/** Categories a narrative event can take. */
@@ -174,6 +207,36 @@ export interface Embargo {
target: string;
}
// --- Resources & industry (v0.36, #59) ---------------------------------------
//
// The LLM can build per-region INDUSTRY PROJECTS (an oil facility, an iron
// mine, a food operation, or a factory) on regions it controls; each active
// project gives that region's sole owner a small, deterministic economy boost
// every committed turn, scaled by the region's `industry` resource value from
// the committed resource manifest. The active projects live in
// `GameState.industry` — the LLM's "industry" lever.
/** The four industry project types a region can host (v0.36, #59). */
export type IndustryType = 'oil' | 'iron' | 'food' | 'industry';
/** All industry project types, in canonical order (the LLM contract + validator). */
export const INDUSTRY_TYPES: IndustryType[] = ['oil', 'iron', 'food', 'industry'];
/**
* (v0.36, #59) One active industry project: a building of a given
* {@link IndustryType} in a specific region. The engine keys the per-turn
* economy boost by the region's SOLE owner (a region with no owner or 2+
* owners yields no boost) and scales it by the region's `industry` resource
* value. A region hosts at most ONE project — re-declaring on a region that
* already has one is a no-op (the first declared type wins).
*/
export interface IndustryProject {
/** The region key the project sits in (see `public/region-keys.json`). */
regionKey: string;
/** The kind of project built there (see {@link IndustryType}). */
type: IndustryType;
}
/**
* (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
@@ -356,6 +419,18 @@ export interface GameState {
* no data wipe.
*/
embargoes: Embargo[];
/**
* (v0.36, #59) Active industry projects (see {@link IndustryProject}): built
* by a turn's `industry.projects` ops (the LLM names a region key + a
* project type). Each region hosts at most one project — re-declaring on a
* region that already has one is a no-op. Every committed turn the engine
* gives each project's region's SOLE owner a small, deterministic economy
* boost scaled by that region's `industry` resource value. A project on a
* region that ends a turn with no owner (or 2+ owners) yields no boost but
* is NOT removed. Always `[]` on a fresh game; rows saved before v0.36 load
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
*/
industry: IndustryProject[];
}
/**