Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7866a1987d | ||
|
|
9d201135b3 | ||
|
|
8730a56170 | ||
|
|
b6dcd3f979 | ||
|
|
31182c8730 | ||
|
|
0fd8981452 | ||
|
|
b237fe8dd4 | ||
|
|
dd9fd6a09d | ||
|
|
b5ea8ba5a4 | ||
|
|
ed91735c8a | ||
|
|
8eeb6df284 | ||
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b |
@@ -56,7 +56,7 @@ history/
|
||||
├── eslint.config.js # flat config (ESLint 9)
|
||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 407 tests)
|
||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
@@ -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` |
|
||||
|
||||
@@ -327,6 +330,118 @@ history/
|
||||
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
||||
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||
when PR 2, the per-region LLM lever, ships).
|
||||
- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).**
|
||||
Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in
|
||||
`GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||
`[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list
|
||||
(≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`,
|
||||
`declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an
|
||||
optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }`
|
||||
(both required, default `[]`): a **declare** creates a new active `War` (sides resolved via
|
||||
`resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved
|
||||
distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets
|
||||
its `status` to the outcome (`armistice` or `capitulation`). After declare/end,
|
||||
`syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite
|
||||
sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you
|
||||
cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not
|
||||
covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching
|
||||
active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense
|
||||
drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the
|
||||
war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted
|
||||
id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations
|
||||
per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars
|
||||
(as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or
|
||||
ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the
|
||||
path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).**
|
||||
`TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert /
|
||||
hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp =
|
||||
{ type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate |
|
||||
cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM
|
||||
declares its *intended* outcome; the engine applies the deterministic effect).
|
||||
`from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then
|
||||
case-insensitive unique name); a `target` that is not a resolvable nation is treated
|
||||
as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the
|
||||
acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy`
|
||||
(−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips
|
||||
`stability` (−5); `information` nudges the from↔target relation one step apart (capped
|
||||
at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only.
|
||||
A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops
|
||||
never move territory — `regionChanges` is the only lever that does); a nation-target
|
||||
dip lands on every region the nation owns; afterwards the target's derived `stats` is
|
||||
re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership
|
||||
invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended
|
||||
effect is NOT applied; instead the from↔target relation moves one step *toward*
|
||||
`hostile` (capped; `war` is never created or escalated by an exposed op) and a
|
||||
diplomatic/failed event is auto-appended to the turn's `events`.
|
||||
**Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are
|
||||
consumed inside `applyTurnResult` after territory + wars and before markers).
|
||||
`isMeaningful` counts hybrid ops; the system prompt documents the field + per-type
|
||||
effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the
|
||||
user payload carries the player's `relations` map so the model can reason about the
|
||||
relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is
|
||||
unaffected and the path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Trade & embargo economy layer — `GameState.trades` / `GameState.embargoes`, `TurnResult.economy` (v0.35, #58).**
|
||||
`TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required;
|
||||
default `{ trade: [], embargo: [] }`) is the model's lever for the economic layer: a
|
||||
bilateral **trade** (`{ a, b, lift? }` — symmetric, stored **sorted** `a < b`) and a
|
||||
unilateral **embargo** (`{ from, target, lift? }` — ordered). No `lift` declares/imposes,
|
||||
`lift: true` ends/lifts (a no-op when absent). Refs are resolved at apply time by the
|
||||
shared `resolveNationRef` (id first, then case-insensitive unique name); an unknown /
|
||||
ambiguous / self-pairing op is dropped (never thrown). The lists live in `GameState`
|
||||
(`trades`/`embargoes`, required, `[]` at creation; a legacy SQLite row without them loads
|
||||
as `[]` — additive, never wiped, like `wars`/`markers`). **Deterministic, no hidden
|
||||
randomness.** Every committed turn, in `applyTurnResult` (after hybrid, before markers):
|
||||
(1) `applyEconomyOps` applies this turn's declares/lifts to the active lists (idempotent
|
||||
declares — an already-active pair/ordered pair is not duplicated); (2)
|
||||
`suspendEconomyOnWar` **auto-suspends** any trade or embargo whose two nations are on
|
||||
opposite sides of an **active** `War` (#56) — a war severs the economic link, so the
|
||||
suspended pair stops producing its effect (this also drops a trade declared this turn
|
||||
between two nations that just went to war); (3) `applyEconomyEffects` applies the per-turn
|
||||
effect to the **full** active lists: each active trade gives **both** parties a small,
|
||||
deterministic `economy` gain (`TRADE_ECONOMY_BONUS`), each active embargo dips the
|
||||
**target's** `economy` only (`EMBARGO_ECONOMY_PENALTY`) — clamped to 0–100, mirrored to
|
||||
every region the nation owns (`regionStats`), and each affected nation's derived `stats`
|
||||
re-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership
|
||||
is untouched (the layer moves stats, never territory). `isMeaningful` counts non-empty
|
||||
`economy.trade` / `economy.embargo`; the system prompt documents the field + constraints
|
||||
(at most 1–2 ops per turn, narrated in `events`; warring pairs auto-suspended), and the
|
||||
user payload carries the active `trades`/`embargoes` so the model can `lift` them. The
|
||||
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
|
||||
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||
(`server/engine.test.ts`).
|
||||
- **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
|
||||
|
||||
+149
@@ -2,6 +2,155 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.39.0 — 2026-10-15 20:51
|
||||
- **#62: Ideology / form of government — a per-nation `ideology` + a `TurnResult.ideology` lever (coups / revolutions / reforms / constitutional changes) with a deterministic −5 stability dip and political narration.** Each nation may carry a free-form `ideology` label — its form of government ("democracy", "fascism", "military junta", …). The model changes it each turn via `ideology.changes`; each change names a target nation (id or name) and the new government type. The engine sets the label and applies a **deterministic** stability dip (a regime shift always unsettles a nation) — **no hidden randomness**, **additive** (no `db.ts` change — `ideology` is an optional `Nation` field; a pre-v0.39 row loads with it absent, i.e. "no recorded government"). It is a third, independent axis next to `leader` (who is in charge) and the v0.32.0 takeover (which renames the nation): the id, name, territory, relations, and the leader all stay valid — only the form of government and stability move.
|
||||
- **Types (`shared/types.ts`).** `Nation.ideology?: string` (optional; `undefined`/absent = no recorded government).
|
||||
- **Protocol (`shared/protocol.ts`).** New `IdeologyChange` (`{ target; to }` — `target` is a nation ref, `to` is the new form of government), `IdeologyOps` (`{ changes: IdeologyChange[] }`); `TurnResult.ideology` is **required**, placed between `leaders` and `markers`, default `{ changes: [] }`. New exported **`IDEOLOGY_STABILITY_EFFECT = −5`** (the deterministic stability dip of a regime shift). `nationForPrompt` now includes the nation's `ideology` (omitted when absent via `JSON.stringify`), so `buildUserPrompt` carries each nation's current government with no separate wiring.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateIdeologyOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (trimmed, ≤120) and a non-empty, non-whitespace `to` (trimmed, ≤120) — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear / leader ops).
|
||||
- `applyIdeologyChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyLeaderChange` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). On a valid change it sets `n.ideology = to` and applies `IDEOLOGY_STABILITY_EFFECT` (−5) 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 / leaders, so a 0-region nation keeps its directly-mutated value). Re-setting the **same** label is idempotent for the `ideology` field but still narrated and still dips (the shift just happened). Finally it appends a **`political`** narration event (`"<nation>'s government shifted to <to>"`). The change never moves territory (sole ownership is untouched).
|
||||
- `applyTurnResult` — the ideology block runs **after `leaders` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → leaders → **ideology** → markers → nationOps).
|
||||
- `isMeaningful` — a non-empty `ideology.changes` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `ideology` (default `{ changes: [] }`).
|
||||
- `buildSystemPrompt` — documents the `ideology` field (free-form label, the −5 dip, the "at most 1–2 per turn, narrated" rule) and contrasts it with a takeover (which RENAMES the nation) and a leader change (who is in charge); similar forms of government are a **soft, prompt-level compatibility nudge only** (alike ideologies tend to ally/trade, opposed ones tend to clash) — there is no engine gate behind it.
|
||||
- `runTurn` — the "unclear" fallback carries `ideology: { changes: [] }`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `ideology: { changes: [] }` and the `StubPayload` nations type gains an optional `ideology` — the stub **never** changes an ideology, 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`: `validateIdeologyOps` (`{changes}` object, bare array, empty/whitespace `to` drop, missing/whitespace target drop, malformed top-level, mixed batch keeps only valid, trim + 120-cap); `isMeaningful` (an ideology change is meaningful, empty ideology is not); `applyTurnResult` (a valid change → field set + 50→45 + `political` event + region mirror, other nation untouched; re-setting the same label → one dip, one event; a multi-region nation → every region dipped and stats re-derived; a target resolved by name as well as id; a 0-region nation keeps its directly-dipped stat; a dip clamped at 0; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing); `buildUserPrompt` (a set ideology is stamped in the prompt nations, an absent one is omitted). `protocol.test.ts`: the `IDEOLOGY_STABILITY_EFFECT` constant is −5 and `nationForPrompt` includes/omits the `ideology` key in the JSON. Two `db.test.ts` round-trips: a legacy row with no ideology loads with it absent, and a set ideology round-trips through save/load.
|
||||
- **Version** bumped `0.38.0 → 0.39.0`.
|
||||
|
||||
## 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).
|
||||
- **Protocol (`shared/protocol.ts`).** New `EconomyTradeOp` (`{ a; b; lift? }`), `EconomyEmbargoOp` (`{ from; target; lift? }`), and `EconomyOps` (`{ trade; embargo }`) types. `TurnResult.economy` is required, placed between `hybrid` and `markers`; a no-`lift` op declares/imposes, `lift: true` ends/lifts.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateEconomyOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ trade: [], embargo: [] }`; each op needs non-empty `a`/`b` (or `from`/`target`) refs (trimmed, ≤200 chars) and a boolean `lift` (default false). Raw refs are kept — resolution happens at apply time against the real state (like wars/hybrid ops).
|
||||
- `applyEconomyOps(next, ops)` — internal; resolves each op's refs via `resolveNationRef` (id first, then unique case-insensitive name). An unresolvable / ambiguous / self-pairing op is dropped (never thrown). A no-`lift` trade **declares** the agreement idempotently (stored sorted, so `{a,b}` and `{b,a}` are the same pair); `lift: true` ends it (a no-op when absent). A no-`lift` embargo **imposes** the ordered from→target pair idempotently; `lift: true` lifts it.
|
||||
- `suspendEconomyOnWar(next)` — internal; any active trade or embargo whose two nations are on **opposite sides of an active war** (v0.33, #56) is removed — a war severs the economic link, so the suspended agreement stops producing its per-turn effect from now on. Runs **after** the turn's war declare/end ops (so it sees the final `next.wars`, including a war declared this turn) and **before** the per-turn effect.
|
||||
- `applyEconomyEffects(next, geoOf?)` — internal; the per-turn effect, applied **every committed turn** to the **full** active lists (not just this turn's declares): each active trade grants **both** parties a small, deterministic economy gain (`TRADE_ECONOMY_BONUS`); each active embargo applies a small, deterministic economy dip to the **target only** (`EMBARGO_ECONOMY_PENALTY`). Clamped to 0–100, mirrored to every region the nation owns (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region/no-entry guards as the hybrid dip). No-op when there are no active trades or embargoes; never throws.
|
||||
- `applyTurnResult` — new **economy block** (after the hybrid block, before markers): `applyEconomyOps` → `suspendEconomyOnWar` → `applyEconomyEffects`, in that order (ops first, so a same-turn trade declared between two nations that just went to war is suspended; effects then run on the surviving full lists). Sole ownership is untouched (the economy layer moves stats, never territory).
|
||||
- `isMeaningful` — a non-empty `economy.trade` or `economy.embargo` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `economy` (default `{ trade: [], embargo: [] }`).
|
||||
- `buildSystemPrompt` — documents the `economy` field and a constraint bullet (bilateral trade gains both parties; a unilateral embargo hits the target only; omit `lift` to declare/impose, `lift: true` to end/lift; at most 1–2 ops per turn, narrated in `events`; trades/embargoes between warring nations are auto-suspended) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `trades` and `embargoes` params (the currently active agreements/embargoes) so the model knows what is active and can `lift` it; emitted only when provided.
|
||||
- `runTurn` — passes `state.trades` and `state.embargoes` to `buildUserPrompt`; the "unclear" fallback carries `economy: { trade: [], embargo: [] }`.
|
||||
- **Stub (`server/stub.ts`).** The deterministic stub returns `economy: { trade: [], embargo: [] }` — it **never** declares or lifts a trade or embargo, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only (`server/engine.test.ts`).
|
||||
- **Persistence (`server/db.ts`).** `load()` defaults `trades`/`embargoes` to `[]` on rows saved before v0.35 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`) — legacy games are never wiped and never gain phantom trades/embargoes.
|
||||
- **Game creation (`server/index.ts`, `shared/scenario.ts`).** Fresh games (preset and dynamic) are created with `trades: []` and `embargoes: []`.
|
||||
|
||||
## v0.34.0 — 2026-10-15 16:35
|
||||
- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted).
|
||||
- **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `isHybridOpType(t)` — exported guard over the seven types.
|
||||
- `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300.
|
||||
- `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect.
|
||||
- `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire.
|
||||
- `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path.
|
||||
- `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only.
|
||||
- `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership).
|
||||
- `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`).
|
||||
- `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided.
|
||||
- `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`.
|
||||
- **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid.
|
||||
- **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed.
|
||||
- **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage.
|
||||
|
||||
## v0.33.0 — 2026-10-15 15:45
|
||||
- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally.
|
||||
- **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||
- **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400.
|
||||
- `activeWarPairs(wars): Set<string>` — NUL-joined sorted pair keys for active-only wars.
|
||||
- `atWar(wars, a, b)` — convenience check.
|
||||
- `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created).
|
||||
- `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`).
|
||||
- `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`.
|
||||
- `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful.
|
||||
- `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs).
|
||||
- `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id.
|
||||
- `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`.
|
||||
- **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars.
|
||||
- **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync).
|
||||
- **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage.
|
||||
|
||||
## v0.32.0 — 2026-10-15 10:56
|
||||
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.31.0 — 2026-10-15 10:22
|
||||
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.31.0",
|
||||
"version": "0.39.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
@@ -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)`,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,13 @@ function makeState(id: string): GameState {
|
||||
regionStats: {
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
},
|
||||
// (v0.33.0, #56) no wars.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) no active trades or embargoes.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36.0, #59) no industry projects.
|
||||
industry: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +154,96 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
|
||||
delete (legacy as { embargoes?: unknown[] }).embargoes;
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.trades).toEqual([]);
|
||||
expect(loaded!.embargoes).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(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('(v0.39.0 #62) a legacy row saved before the nation ideology field existed loads with it absent (no recorded government)', () => {
|
||||
const db = openDb(':memory:');
|
||||
// makeState has no nation.ideology field — a pre-v0.39 save. The field is
|
||||
// optional and not re-stamped by load, so an absent ideology stays absent.
|
||||
db.save(makeState('legacy'));
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.ideology).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.39.0 #62) a set nation ideology round-trips through save/load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('ideo');
|
||||
s.nations.uk.ideology = 'fascism';
|
||||
db.save(s);
|
||||
const loaded = db.load('ideo');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.ideology).toBe('fascism');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
@@ -49,6 +49,17 @@ export function openDb(filePath: string): Db {
|
||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||
if (state.markers === undefined) state.markers = [];
|
||||
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
|
||||
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
|
||||
// syncWarRelations, which never force-creates a War entity).
|
||||
if (state.wars === undefined) state.wars = [];
|
||||
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
|
||||
// legacy game has no active trades or embargoes.
|
||||
if (state.trades === undefined) state.trades = [];
|
||||
if (state.embargoes === undefined) state.embargoes = [];
|
||||
// (v0.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
|
||||
|
||||
+2385
-21
File diff suppressed because it is too large
Load Diff
+1750
-10
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -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';
|
||||
@@ -178,6 +178,18 @@ app.post('/api/games', (req: Request, res) => {
|
||||
// real region keys — each region starts as a copy of its owner's initial
|
||||
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time
|
||||
// (any preset-level `war` relation stays a bare/implicit one, not an
|
||||
// entity — see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
|
||||
// declares (and lifts) them over time via TurnResult.economy.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.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
|
||||
@@ -362,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 });
|
||||
|
||||
@@ -52,6 +52,13 @@ const state: GameState = {
|
||||
markers: [],
|
||||
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (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.
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationResources,
|
||||
deriveNationStats,
|
||||
IDEOLOGY_STABILITY_EFFECT,
|
||||
migrateRegionStats,
|
||||
nationForPrompt,
|
||||
seedRegionStats,
|
||||
weightedMedian,
|
||||
} from '../shared/protocol';
|
||||
@@ -41,6 +44,10 @@ function mkState(
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats: {} as GameState['regionStats'],
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
};
|
||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||
@@ -136,6 +143,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', () => {
|
||||
@@ -257,3 +339,30 @@ describe('canConquer', () => {
|
||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||
});
|
||||
});
|
||||
|
||||
// --- IDEOLOGY_STABILITY_EFFECT (v0.39, #62) ----------------------------------
|
||||
|
||||
describe('IDEOLOGY_STABILITY_EFFECT (v0.39.0 #62)', () => {
|
||||
it('a regime shift is a deterministic −5 stability dip', () => {
|
||||
expect(IDEOLOGY_STABILITY_EFFECT).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
// --- nationForPrompt — ideology (v0.39, #62) ---------------------------------
|
||||
|
||||
describe('nationForPrompt — ideology (v0.39.0 #62)', () => {
|
||||
it('stamps the ideology when a nation has one', () => {
|
||||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||||
n.ideology = 'fascism';
|
||||
expect(nationForPrompt(n).ideology).toBe('fascism');
|
||||
// and it survives JSON serialisation (the user prompt path)
|
||||
expect(JSON.stringify(nationForPrompt(n))).toContain('"ideology":"fascism"');
|
||||
});
|
||||
|
||||
it('omits the ideology from the JSON when a nation has none (undefined key dropped)', () => {
|
||||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||||
expect(n.ideology).toBeUndefined();
|
||||
// JSON.stringify drops undefined-valued keys → no "ideology" in the prompt
|
||||
expect(JSON.stringify(nationForPrompt(n))).not.toContain('ideology');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
hasCentroids,
|
||||
hasGeo,
|
||||
hasRegionData,
|
||||
hasResources,
|
||||
loadRegions,
|
||||
neighboursOf,
|
||||
referenceForKeys,
|
||||
regionName,
|
||||
resetRegions,
|
||||
resourceOf,
|
||||
validKeys,
|
||||
} from './regions';
|
||||
import type { GameState } from '../shared/types';
|
||||
@@ -110,6 +112,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
markers: [],
|
||||
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||
regionStats: {},
|
||||
wars: [],
|
||||
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (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);
|
||||
@@ -168,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());
|
||||
|
||||
@@ -187,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,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
@@ -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
|
||||
|
||||
+105
-2
@@ -3,7 +3,7 @@
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
import type { RelationStatus, RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
@@ -13,7 +13,25 @@ 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).
|
||||
// `ideology` (v0.39.0, #62) is the per-nation form of government stamped
|
||||
// by the engine; the stub ignores it — it never changes an ideology (see
|
||||
// deterministicTurn).
|
||||
nations: Record<
|
||||
string,
|
||||
{
|
||||
stats: Record<string, number>;
|
||||
territory?: string[];
|
||||
nuclear?: boolean;
|
||||
leader?: { name: string };
|
||||
ideology?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||
pending?: RoundItem[];
|
||||
@@ -38,6 +56,49 @@ export interface StubPayload {
|
||||
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||
/**
|
||||
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
|
||||
* the engine's user prompt. The stub ignores this field — it never declares
|
||||
* or ends a war (see deterministicTurn), so the war path is covered by unit
|
||||
* tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never runs a hybrid op (see
|
||||
* deterministicTurn), so the hybrid path is covered by unit tests only.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active bilateral trade agreements
|
||||
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never declares, lifts or suspends a trade
|
||||
* (see deterministicTurn), so the trade path is covered by unit tests only.
|
||||
*/
|
||||
trades?: Array<{ a: string; b: string }>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active unilateral embargoes
|
||||
* (`{ from, target }` nation-id pairs), emitted by the engine's user
|
||||
* prompt. The stub ignores this field — it never imposes, lifts or
|
||||
* suspends an embargo (see deterministicTurn), so the embargo path is
|
||||
* covered by unit tests only.
|
||||
*/
|
||||
embargoes?: Array<{ from: string; target: string }>;
|
||||
/**
|
||||
* (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 {
|
||||
@@ -77,6 +138,40 @@ function truncate(s: string, n: number): string {
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*
|
||||
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||
* unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
|
||||
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
|
||||
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
|
||||
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
||||
* the trade & embargo economy path is covered by unit tests only, keeping
|
||||
* the e2e assertions untouched.
|
||||
*
|
||||
* (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.
|
||||
*
|
||||
* (v0.38.0, #61) The stub likewise NEVER changes a leader (it always returns
|
||||
* `leaders: { changes: [] }`) — the leader path is covered by unit tests
|
||||
* only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it
|
||||
* always returns `ideology: { changes: [] }`) — the ideology path is covered
|
||||
* by unit tests only, keeping the e2e assertions untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -143,6 +238,14 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
takeovers: [], // the stub never performs a takeover (see doc comment)
|
||||
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
|
||||
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
||||
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
|
||||
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)
|
||||
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
IndustryProject,
|
||||
MapMarker,
|
||||
MarkerType,
|
||||
Nation,
|
||||
NationStats,
|
||||
RelationStatus,
|
||||
RegionResources,
|
||||
RegionStats,
|
||||
StatKey,
|
||||
} from './types';
|
||||
@@ -51,6 +54,50 @@ export interface TerritoryChange {
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
// --- Wars (v0.33.0, #56): first-class war entities --------------------------
|
||||
//
|
||||
// A war is an explicit world entity (see types.War), not just a boolean
|
||||
// relation: the LLM DECLARES one (it becomes an active War with an engine-
|
||||
// assigned stable id) and later ENDS one by id. The engine derives the
|
||||
// symmetric `war` relation from the set of ACTIVE wars on every turn (two
|
||||
// nations are in a `war` relation iff they are both belligerents of at least
|
||||
// one active war) and downgrades it to `hostile` when the war ends — so the
|
||||
// relation can never drift apart from the war state.
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Declare a new war this turn. `sides` names every
|
||||
* belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`,
|
||||
* so a unique name also works); any two distinct sides end the turn in a
|
||||
* `war` relation. `aims` are optional short war aims for the narrative. The
|
||||
* engine assigns the stable id, the declared turn/date, drags in the
|
||||
* mutual-defense allies of each belligerent, and drops the op when fewer than
|
||||
* two sides resolve (never throws).
|
||||
*/
|
||||
export interface WarDeclareOp {
|
||||
/** The belligerent nations (≥2 distinct ids or unique names). */
|
||||
sides: string[];
|
||||
/** Optional short war aims (narrative only). */
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id
|
||||
* (the model sees the current active wars in the user payload's `wars`
|
||||
* field). `outcome` is `armistice` (a negotiated stop) or `capitulation`
|
||||
* (one side surrenders); the war keeps its id/sides and stays in
|
||||
* `GameState.wars` as history with that final status. `terms` are optional
|
||||
* short peace terms (narrative only — the model narrates the details in
|
||||
* `events`). An unknown id, or a war that is already ended, is a no-op
|
||||
* (never throws).
|
||||
*/
|
||||
export interface WarEndOp {
|
||||
/** The stable id of the active war to end (from the user payload). */
|
||||
id: string;
|
||||
outcome: 'armistice' | 'capitulation';
|
||||
/** Optional short peace terms (narrative only). */
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||
// proposes, player confirms" model) ------------------------------------------
|
||||
//
|
||||
@@ -68,6 +115,25 @@ export interface TerritoryChange {
|
||||
* engine's `resolveNationRef`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
|
||||
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
|
||||
* (a player-confirmed diplomatic/political name change), a takeover is
|
||||
* **automatic and in-world**: `applyTurnResult` applies it directly (no
|
||||
* pending queue, no player confirm). Only `Nation.name` changes — the stable
|
||||
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
|
||||
* (which references the id) all stay intact, so the player keeps leading the
|
||||
* nation under its new regime. `nationRef` is resolved exactly like the
|
||||
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
|
||||
* match — see `resolveNationRef`).
|
||||
*/
|
||||
export interface Takeover {
|
||||
/** The target nation's id or current name (resolved by `resolveNationRef`). */
|
||||
nationRef: string;
|
||||
/** The name of the new regime to set as the nation's display name. */
|
||||
newRegimeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||
@@ -170,6 +236,19 @@ export interface MarkerInfo {
|
||||
owner: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model
|
||||
* (the `wars` field): its stable `id` (the model echoes it in
|
||||
* `TurnResult.wars.end` to end it), its belligerent `sides`, and optional
|
||||
* short `aims`. Ended wars are NOT included — they stay in `GameState.wars`
|
||||
* as history, but the model only needs to address the active ones.
|
||||
*/
|
||||
export interface WarInfo {
|
||||
id: string;
|
||||
sides: string[];
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-type presentation of a marker: its display label (tooltip /
|
||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||
@@ -229,6 +308,296 @@ export function isMarkerType(t: unknown): t is MarkerType {
|
||||
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): non-kinetic operations ---------------------
|
||||
//
|
||||
// The LLM can run covert, non-kinetic operations each turn: spies, sabotage,
|
||||
// assassination attempts, cyberattacks, economic pressure, disinformation, and
|
||||
// proxy support. Unlike kinetic conquest (territory), these never move borders
|
||||
// — they dip a target's stats or sour a relation. The LLM declares an intended
|
||||
// outcome (`success` or `exposed`); the engine is fully deterministic (no
|
||||
// hidden randomness) and acts only on that declaration: a successful op applies
|
||||
// its small, per-type effect, and an "exposed" op backfires (no effect, the
|
||||
// actor's relation with the target sours by one step, and an auto
|
||||
// diplomatic/failure event is emitted).
|
||||
|
||||
/** The seven covert operation types a hybrid op can be (v0.34.0, #57). */
|
||||
export type HybridOpType =
|
||||
| 'spy'
|
||||
| 'sabotage'
|
||||
| 'assassinate'
|
||||
| 'cyber'
|
||||
| 'economic'
|
||||
| 'information'
|
||||
| 'proxy';
|
||||
|
||||
/** All hybrid op types, in canonical order (the LLM contract + validator). */
|
||||
export const HYBRID_OP_TYPES: HybridOpType[] = [
|
||||
'spy',
|
||||
'sabotage',
|
||||
'assassinate',
|
||||
'cyber',
|
||||
'economic',
|
||||
'information',
|
||||
'proxy',
|
||||
];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn
|
||||
* (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique
|
||||
* name — resolved by the engine's `resolveNationRef`). `target` is EITHER a
|
||||
* nation (id or unique name) or a specific region key; a region target hits
|
||||
* that region's sole owner (a region with zero or 2+ owners can't be
|
||||
* targeted). `detail` is a short narrative (the LLM may also narrate the op in
|
||||
* `events`). `outcome` is the LLM's *intended* result: `success` (the default)
|
||||
* applies the op's deterministic effect, while `exposed` backfires it — the
|
||||
* effect is NOT applied, the actor's relation with the target sours by one
|
||||
* step, and an auto diplomatic/failure event is emitted.
|
||||
*/
|
||||
export interface HybridOp {
|
||||
/** The operation type (see {@link HybridOpType}). */
|
||||
type: HybridOpType;
|
||||
/** The acting nation's id or current name (resolved by `resolveNationRef`). */
|
||||
from: string;
|
||||
/** The target: a nation's id/name, or a region key (hits that region's owner). */
|
||||
target: string;
|
||||
/** Short narrative detail (optional). */
|
||||
detail?: string;
|
||||
/** The LLM's intended result; `exposed` backfires the op. Defaults to success. */
|
||||
outcome?: 'success' | 'exposed';
|
||||
}
|
||||
|
||||
// --- Trade & embargo ops (v0.35.0, #58) --------------------------------------
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A bilateral TRADE op in `TurnResult.economy.trade`: `a` and
|
||||
* `b` are the two parties (each a nation's id or unique name — resolved by
|
||||
* the engine's `resolveNationRef`; a pair that resolves to the same nation,
|
||||
* or contains an unknown/ambiguous ref, is dropped defensively). Without
|
||||
* `lift` the op DECLARES the agreement (idempotent — an already-active
|
||||
* agreement is not duplicated); `lift: true` ENDS it.
|
||||
*/
|
||||
export interface EconomyTradeOp {
|
||||
/** First party's id or unique name. */
|
||||
a: string;
|
||||
/** Second party's id or unique name (distinct from `a` after resolution). */
|
||||
b: string;
|
||||
/** `true` → end this bilateral trade; default `false` = declare it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A unilateral EMBARGO op in `TurnResult.economy.embargo`:
|
||||
* `from` blocks trade with `target` (each a nation's id or unique name —
|
||||
* resolved by `resolveNationRef`; an unknown/ambiguous ref or
|
||||
* `from === target` is dropped defensively). Without `lift` the op imposes
|
||||
* the embargo (idempotent — an already-active one is not duplicated);
|
||||
* `lift: true` LIFTS it.
|
||||
*/
|
||||
export interface EconomyEmbargoOp {
|
||||
/** The imposing nation's id or unique name. */
|
||||
from: string;
|
||||
/** The embargoed nation's id or unique name (distinct from `from`). */
|
||||
target: string;
|
||||
/** `true` → lift this embargo; default `false` = impose it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) The turn's trade-and-embargo operations, nested under
|
||||
* `TurnResult.economy`. Both arrays are required and default to `[]`. The
|
||||
* engine applies the ops to `GameState.trades` / `GameState.embargoes`
|
||||
* (add = declare/impose, remove = lift) and, on every committed turn, applies
|
||||
* the per-turn economic effect of the FULL active lists (trade: both
|
||||
* parties' economy up a little; embargo: the target's economy down a
|
||||
* little). Active trades/embargoes between nations on opposite sides of an
|
||||
* active war (v0.33, #56) are suspended (removed) automatically.
|
||||
*/
|
||||
export interface EconomyOps {
|
||||
/** Bilateral trades (declare or lift). */
|
||||
trade: EconomyTradeOp[];
|
||||
/** Unilateral embargoes (impose or lift). */
|
||||
embargo: EconomyEmbargoOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (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,
|
||||
};
|
||||
|
||||
// --- Ideology / form of government (v0.39, #62) ------------------------------
|
||||
//
|
||||
// Each nation may carry a free-form `ideology` label — its form of
|
||||
// government ("democracy", "fascism", "military junta", …). The LLM changes it
|
||||
// each turn via `TurnResult.ideology`: a change names a target nation (id or
|
||||
// name) and the new government type. The engine sets the nation's `ideology`
|
||||
// and applies a DETERMINISTIC stability dip (see `IDEOLOGY_STABILITY_EFFECT`
|
||||
// below) — a regime shift always unsettles a nation — no hidden randomness.
|
||||
// It is a third, independent axis next to `leader` (who is in charge) and the
|
||||
// v0.32.0 takeover (which renames the nation): the id, name, territory,
|
||||
// relations, and the leader all stay valid, only the form of government and
|
||||
// stability move.
|
||||
|
||||
/** A single ideology change: the target nation and its new form of government. */
|
||||
export interface IdeologyChange {
|
||||
/** The target nation, by id or name (resolved by the shared resolver). */
|
||||
target: string;
|
||||
/** The new form of government (a free-form label, e.g. "democracy"). */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** The ideology-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
|
||||
export interface IdeologyOps {
|
||||
changes: IdeologyChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic stability effect of an ideology change (v0.39, #62). A
|
||||
* regime shift (a coup, a revolution, a reform, a constitutional change)
|
||||
* unsettles the nation: a small −5 dip applied to the target's `stability`
|
||||
* (clamped 0–100), mirrored to its `regionStats`, and its derived stats
|
||||
* re-derived afterwards. Applied the same way a leader-cause effect is —
|
||||
* re-setting the SAME ideology still dips (the shift just happened, even if
|
||||
* the label does not change) but is idempotent for the `ideology` field
|
||||
* itself.
|
||||
*/
|
||||
export const IDEOLOGY_STABILITY_EFFECT: number = -5;
|
||||
|
||||
// --- 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;
|
||||
@@ -267,6 +636,119 @@ export interface TurnResult {
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
/**
|
||||
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
|
||||
* effect **this turn** — each renames the target nation to `newRegimeName`.
|
||||
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
|
||||
* and in-world**: it is applied directly by `applyTurnResult` (like territory
|
||||
* / state changes), not queued to `pendingNationOps`. Only the nation's
|
||||
* `name` changes; the id, territory, relations, stats, and `playerNationId`
|
||||
* are all preserved. Empty on turns where no takeover occurs. Required
|
||||
* (default `[]`).
|
||||
*/
|
||||
takeovers: Takeover[];
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars
|
||||
* (≥2 distinct belligerents; the engine assigns the stable id / declared
|
||||
* turn / date, drags in the mutual-defense allies of each belligerent, and
|
||||
* derives the `war` relation between every pair of sides); `end` closes an
|
||||
* existing ACTIVE war by id (`armistice` = a negotiated stop,
|
||||
* `capitulation` = one side surrenders), which downgrades the relation the
|
||||
* war had derived back to `hostile`. Both sub-fields are required and
|
||||
* default `[]`; a turn with war activity is meaningful (it survives the
|
||||
* retry/nudge path). The model sees the current active wars (id + sides +
|
||||
* aims) in the user payload's `wars` field, so it can `end` by id.
|
||||
*/
|
||||
wars: { declare: WarDeclareOp[]; end: WarEndOp[] };
|
||||
/**
|
||||
* (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies,
|
||||
* sabotage, assassination attempts, cyberattacks, economic pressure,
|
||||
* disinformation, and proxy support. Unlike `nationOps` (player-confirmed
|
||||
* proposals) these are applied directly by the turn, like territory / state
|
||||
* changes: each op's deterministic per-type effect is applied by
|
||||
* `applyTurnResult` (a small stat dip on the target's regions, or a
|
||||
* relation nudge toward hostile for `information` / `proxy`); an `exposed`
|
||||
* op backfires (no effect, the actor's relation with the target sours by
|
||||
* one step, and an auto diplomatic/failure event is emitted). They never
|
||||
* move borders — sole ownership is untouched. Required (default
|
||||
* `{ ops: [] }`).
|
||||
*/
|
||||
hybrid: { ops: HybridOp[] };
|
||||
/**
|
||||
* (v0.35.0, #58) Trade and embargo ops — the economy layer. `trade`
|
||||
* declares (or, with `lift: true`, ends) bilateral trade agreements
|
||||
* between two nations; `embargo` imposes (or, with `lift: true`, lifts)
|
||||
* a unilateral embargo on a target. Both arrays are required and default
|
||||
* to `[]`. Applied directly by `applyTurnResult` (add to / remove from
|
||||
* `GameState.trades` / `GameState.embargoes`), and the per-turn economic
|
||||
* effect of the FULL active lists is applied on every committed turn (a
|
||||
* trade lifts both parties' economy a little; an embargo lowers the
|
||||
* target's economy a little — clamped, deterministic). Active
|
||||
* trades/embargoes between nations on opposite sides of an active war are
|
||||
* suspended automatically.
|
||||
*/
|
||||
economy: EconomyOps;
|
||||
/**
|
||||
* (v0.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.39, #62) Ideology (form of government) changes this turn. Each
|
||||
* change names a target nation (id or name) and its new form of
|
||||
* government — a free-form label such as "democracy", "fascism", or
|
||||
* "military junta". Applied directly by `applyTurnResult` (like leaders /
|
||||
* hybrid / economy): the target's `ideology` is set to the new label, a
|
||||
* small, deterministic stability dip (`IDEOLOGY_STABILITY_EFFECT`, −5) is
|
||||
* applied (clamped, mirrored to the nation's regions, re-derived), and a
|
||||
* `political` narration event is appended. Re-setting the same label is
|
||||
* idempotent for the field (still narrated, still dips). Unknown /
|
||||
* ambiguous targets and empty labels are dropped, never thrown; the
|
||||
* change never moves territory (sole ownership is untouched). Distinct
|
||||
* from a takeover (which renames the nation) and from a leader change
|
||||
* (who is in charge). At most 1–2 per turn, each narrated in `events`.
|
||||
* Required; default `{ changes: [] }`.
|
||||
*/
|
||||
ideology: IdeologyOps;
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
@@ -579,6 +1061,8 @@ export function nationForPrompt(n: Nation) {
|
||||
stats: n.stats,
|
||||
territory: n.territory,
|
||||
relations: n.relations,
|
||||
leader: n.leader,
|
||||
ideology: n.ideology,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -715,3 +1199,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +453,14 @@ export function worldDraftToGameState(
|
||||
// Seed AFTER the territories hold real region keys: each owned region gets
|
||||
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||
regionStats: seedRegionStats(nations),
|
||||
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
|
||||
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
|
||||
industry: [],
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
+192
@@ -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,34 @@ 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 };
|
||||
/**
|
||||
* The nation's current form of government (v0.39, #62) — a free-form label
|
||||
* such as `"democracy"`, `"fascism"` or `"military junta"`. Optional — a
|
||||
* legacy game (or a nation the LLM has never given an ideology to) has none,
|
||||
* which is the "no recorded government" baseline. The LLM changes it via
|
||||
* `TurnResult.ideology` (coups / revolutions / reforms / constitutional
|
||||
* changes, see `IdeologyChange` in `protocol.ts`); each change carries a
|
||||
* deterministic stability dip + a political narration event. Distinct from
|
||||
* the v0.32.0 takeover (which renames the nation) and from `leader` (who is
|
||||
* in charge): only the form of government and stability move.
|
||||
*/
|
||||
ideology?: string;
|
||||
}
|
||||
|
||||
/** Categories a narrative event can take. */
|
||||
@@ -143,6 +188,105 @@ export interface MapMarker {
|
||||
placedTurn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
|
||||
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
|
||||
* op) and ended by a `lift: true` op; while active it yields a small,
|
||||
* deterministic economy gain to BOTH parties every committed turn. The
|
||||
* engine suspends (removes) the trade automatically when the two parties end
|
||||
* up on opposite sides of an active war (v0.33, #56).
|
||||
*/
|
||||
export interface TradeAgreement {
|
||||
/** First party's nation id. */
|
||||
a: string;
|
||||
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
|
||||
b: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
|
||||
* `target` (which must differ from `from`). Declared by the LLM via
|
||||
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
|
||||
* op; while active it applies a small, deterministic economy dip to the
|
||||
* TARGET only, every committed turn. The engine suspends (removes) the
|
||||
* embargo automatically when the two nations end up on opposite sides of an
|
||||
* active war (v0.33, #56).
|
||||
*/
|
||||
export interface Embargo {
|
||||
/** The imposing nation's id. */
|
||||
from: string;
|
||||
/** The embargoed nation's id (distinct from `from`). */
|
||||
target: string;
|
||||
}
|
||||
|
||||
// --- 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
|
||||
* preset or a bare `relations` entry can carry without any structure behind
|
||||
* it), a `War` is an explicit world entity the engine tracks: the LLM
|
||||
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
|
||||
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
|
||||
* `GameState.wars` for the rest of the game as history after it ends.
|
||||
*
|
||||
* **Semantics of `sides`:** the flat list of every belligerent in the war
|
||||
* (≥2 distinct nation ids). Any two distinct members of an active war's
|
||||
* sides are at war with each other — the engine derives the symmetric `war`
|
||||
* relation for every such pair on every turn (see the engine's
|
||||
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
|
||||
* ends. A mutual-defense ally of a belligerent is dragged into the same list
|
||||
* on declaration (the engine does this automatically).
|
||||
*/
|
||||
export interface War {
|
||||
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
|
||||
id: string;
|
||||
/** The belligerent nations (≥2 distinct ids; see the note above). */
|
||||
sides: string[];
|
||||
/** The turn number the war was declared on. */
|
||||
declaredTurn: number;
|
||||
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
|
||||
declaredDate?: string;
|
||||
/** Optional short war aims the LLM names at declaration. */
|
||||
aims?: string[];
|
||||
/**
|
||||
* Lifecycle: `active` until a turn ends the war, then the end outcome —
|
||||
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
|
||||
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
|
||||
*/
|
||||
status: 'active' | 'armistice' | 'capitulation' | 'ended';
|
||||
/** Optional region keys the war's front line runs through (reserved). */
|
||||
front?: string[];
|
||||
}
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
@@ -251,6 +395,54 @@ export interface GameState {
|
||||
* — additive, no data wipe.
|
||||
*/
|
||||
regionStats: Record<string, RegionStats>;
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
|
||||
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
|
||||
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
|
||||
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
|
||||
* wars on every turn (two nations are in a `war` relation iff they are both
|
||||
* belligerents of ≥1 active war), so the boolean relation and the war
|
||||
* entities can never drift apart. A bare preset `war` relation with no
|
||||
* active war behind it stays as-is (an implicit, unnamed war — never
|
||||
* force-created into an entity). Always `[]` on a fresh game; rows saved
|
||||
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
wars: War[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
|
||||
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
|
||||
* `lift: true` ends). Every committed turn the engine applies a small,
|
||||
* deterministic economy gain to BOTH parties of each active agreement, and
|
||||
* suspends (removes) any agreement whose two parties are on opposite sides
|
||||
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
|
||||
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
trades: TradeAgreement[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
|
||||
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
|
||||
* `lift: true` lifts). Every committed turn the engine applies a small,
|
||||
* deterministic economy dip to each embargo's TARGET only, and suspends
|
||||
* (removes) any embargo whose two nations are on opposite sides of an
|
||||
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
|
||||
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
|
||||
* no data wipe.
|
||||
*/
|
||||
embargoes: Embargo[];
|
||||
/**
|
||||
* (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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user