Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31182c8730 | ||
|
|
0fd8981452 | ||
|
|
b237fe8dd4 | ||
|
|
dd9fd6a09d |
@@ -67,11 +67,13 @@ history/
|
|||||||
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||||
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||||
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
|
│ ├── 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)
|
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
|
||||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
||||||
|
│ │ # → build-region-resources
|
||||||
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
|
│ │ # (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
|
│ │ # 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)
|
│ │ # 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 split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||||
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||||
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
|
| `npm run 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) |
|
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
|
||||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||||
|
|
||||||
@@ -407,6 +410,38 @@ history/
|
|||||||
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
|
**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
|
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||||
(`server/engine.test.ts`).
|
(`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).**
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- **#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).
|
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.35.0",
|
"version": "0.37.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||||
"build:region-geo": "node scripts/build-region-geo.mjs",
|
"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",
|
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,8 @@ function makeState(id: string): GameState {
|
|||||||
// (v0.35.0, #58) no active trades or embargoes.
|
// (v0.35.0, #58) no active trades or embargoes.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) no industry projects.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,39 @@ describe('openDb', () => {
|
|||||||
db.close();
|
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('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export function openDb(filePath: string): Db {
|
|||||||
// legacy game has no active trades or embargoes.
|
// legacy game has no active trades or embargoes.
|
||||||
if (state.trades === undefined) state.trades = [];
|
if (state.trades === undefined) state.trades = [];
|
||||||
if (state.embargoes === undefined) state.embargoes = [];
|
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
|
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||||
// ONCE at load time (and never re-migrated afterwards — the field is
|
// 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
|
// then present): each owned region is seeded with a copy of its owner's
|
||||||
|
|||||||
+553
-39
@@ -1,21 +1,26 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
|
import type { GameState, MapMarker, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
CONQUEST_MILITARY_MARGIN,
|
CONQUEST_MILITARY_MARGIN,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
markerId,
|
markerId,
|
||||||
MAX_MARKERS,
|
MAX_MARKERS,
|
||||||
seedRegionStats,
|
seedRegionStats,
|
||||||
|
isNuclearCapable,
|
||||||
|
NUCLEAR_COLLAPSE,
|
||||||
|
NUCLEAR_RETALIATION,
|
||||||
type HybridOp,
|
type HybridOp,
|
||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
|
type NuclearStrike,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
activeWarPairs,
|
activeWarPairs,
|
||||||
|
applyIndustryEffects,
|
||||||
applyNationOp,
|
applyNationOp,
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
atWar,
|
atWar,
|
||||||
@@ -42,7 +47,9 @@ import {
|
|||||||
syncWarRelations,
|
syncWarRelations,
|
||||||
validateEconomyOps,
|
validateEconomyOps,
|
||||||
validateHybridOps,
|
validateHybridOps,
|
||||||
|
validateIndustryOps,
|
||||||
validateMarkerOps,
|
validateMarkerOps,
|
||||||
|
validateNuclearOps,
|
||||||
validateNationOps,
|
validateNationOps,
|
||||||
validateRegionChanges,
|
validateRegionChanges,
|
||||||
validateTakeovers,
|
validateTakeovers,
|
||||||
@@ -122,6 +129,8 @@ function baseState(): GameState {
|
|||||||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) no industry projects on a fresh state.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +568,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withOps).not.toBeNull();
|
expect(withOps).not.toBeNull();
|
||||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
@@ -581,7 +590,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
@@ -602,7 +611,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([
|
expect(next.pendingNationOps).toEqual([
|
||||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
@@ -621,7 +630,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1078,7 +1087,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1095,7 +1104,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1118,7 +1127,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -1144,7 +1153,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -1162,7 +1171,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
regionChanges: [],
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -1252,7 +1261,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -1367,7 +1376,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
|
|
||||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1674,9 +1683,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
it('is true with an event, false when everything is empty', () => {
|
||||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
@@ -1689,7 +1698,7 @@ describe('isMeaningful', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1708,7 +1717,8 @@ describe('isMeaningful', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -1728,7 +1738,8 @@ describe('isMeaningful', () => {
|
|||||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -1750,7 +1761,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1782,7 +1794,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1805,7 +1818,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1831,7 +1845,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1856,7 +1871,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(validated!.takeovers).toEqual([]);
|
expect(validated!.takeovers).toEqual([]);
|
||||||
@@ -1881,7 +1897,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
],
|
],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1904,7 +1921,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1925,7 +1943,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1958,7 +1977,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||||
@@ -1983,13 +2003,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||||
const first = applyTurnResult(state, {
|
const first = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(first.markers).toHaveLength(1);
|
expect(first.markers).toHaveLength(1);
|
||||||
// a second turn proposes the exact same marker again
|
// a second turn proposes the exact same marker again
|
||||||
const second = applyTurnResult(first, {
|
const second = applyTurnResult(first, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(second.markers).toHaveLength(1);
|
expect(second.markers).toHaveLength(1);
|
||||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||||
@@ -2014,7 +2034,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||||
@@ -2060,7 +2081,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||||
@@ -2086,7 +2108,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
];
|
];
|
||||||
const next = applyTurnResult(state, {
|
const next = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(next.markers).toHaveLength(1);
|
expect(next.markers).toHaveLength(1);
|
||||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||||
@@ -2102,14 +2124,14 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
// 1) uk releases the region → the marker becomes ownerless
|
// 1) uk releases the region → the marker becomes ownerless
|
||||||
const released = applyTurnResult(state, {
|
const released = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(released.markers[0].ownerNationId).toBeNull();
|
expect(released.markers[0].ownerNationId).toBeNull();
|
||||||
// 2) de re-claims the region → the marker's owner follows to de
|
// 2) de re-claims the region → the marker's owner follows to de
|
||||||
const reclaimed = applyTurnResult(released, {
|
const reclaimed = applyTurnResult(released, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
||||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
});
|
});
|
||||||
@@ -2177,7 +2199,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
nationOps: [],
|
nationOps: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
});
|
});
|
||||||
const { state } = await runTurn({
|
const { state } = await runTurn({
|
||||||
@@ -2251,6 +2274,8 @@ function mkTurnState(
|
|||||||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) no industry projects on a fresh state.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2268,6 +2293,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
|||||||
wars: partial.wars ?? { declare: [], end: [] },
|
wars: partial.wars ?? { declare: [], end: [] },
|
||||||
hybrid: partial.hybrid ?? { ops: [] },
|
hybrid: partial.hybrid ?? { ops: [] },
|
||||||
economy: partial.economy ?? { trade: [], embargo: [] },
|
economy: partial.economy ?? { trade: [], embargo: [] },
|
||||||
|
industry: partial.industry ?? { projects: [] },
|
||||||
|
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
|
||||||
markers: partial.markers ?? { add: [], remove: [] },
|
markers: partial.markers ?? { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2991,7 +3018,8 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
|||||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withTakeovers).not.toBeNull();
|
expect(withTakeovers).not.toBeNull();
|
||||||
@@ -3378,7 +3406,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -3398,7 +3427,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -3418,7 +3448,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
takeovers: [],
|
takeovers: [],
|
||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -4128,5 +4159,488 @@ describe('stub — economy (v0.35.0 #58)', () => {
|
|||||||
};
|
};
|
||||||
const result = deterministicTurn(payload);
|
const result = deterministicTurn(payload);
|
||||||
expect(result.economy).toEqual({ trade: [], embargo: [] });
|
expect(result.economy).toEqual({ trade: [], embargo: [] });
|
||||||
|
expect(result.industry).toEqual({ projects: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Industry projects (v0.36.0, #59) ----------------------------------------
|
||||||
|
|
||||||
|
describe('validateIndustryOps (v0.36.0 #59)', () => {
|
||||||
|
it('accepts well-formed project ops', () => {
|
||||||
|
const out = validateIndustryOps({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
|
||||||
|
expect(out).toEqual({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops ops with a missing regionKey, an unknown type, or a non-object entry', () => {
|
||||||
|
const out = validateIndustryOps({
|
||||||
|
projects: [
|
||||||
|
{ type: 'oil' }, // no regionKey
|
||||||
|
{ regionKey: 'k1', type: 'coal' }, // unknown type
|
||||||
|
{ regionKey: '', type: 'iron' }, // empty key
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
{ regionKey: 'k2', type: 'food' }, // valid
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out).toEqual({ projects: [{ regionKey: 'k2', type: 'food' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a missing / malformed top-level object yields { projects: [] }', () => {
|
||||||
|
expect(validateIndustryOps(undefined)).toEqual({ projects: [] });
|
||||||
|
expect(validateIndustryOps(null)).toEqual({ projects: [] });
|
||||||
|
expect(validateIndustryOps('nope')).toEqual({ projects: [] });
|
||||||
|
expect(validateIndustryOps({})).toEqual({ projects: [] });
|
||||||
|
expect(validateIndustryOps({ projects: 'x' })).toEqual({ projects: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult — industry ops (v0.36.0 #59)', () => {
|
||||||
|
const state = () =>
|
||||||
|
mkTurnState(
|
||||||
|
{
|
||||||
|
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']),
|
||||||
|
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||||||
|
},
|
||||||
|
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('declares a project on each valid region (per-region set)', () => {
|
||||||
|
const next = applyTurnResult(state(), mkTurnResult({
|
||||||
|
industry: { projects: [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'industry' }] },
|
||||||
|
}));
|
||||||
|
expect(next.industry).toEqual([
|
||||||
|
{ regionKey: 'g1', type: 'oil' },
|
||||||
|
{ regionKey: 'd1', type: 'industry' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-declaring on a region that already hosts a project is a no-op (first type wins)', () => {
|
||||||
|
const s = state();
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
industry: { projects: [{ regionKey: 'g1', type: 'iron' }, { regionKey: 'g2', type: 'food' }] },
|
||||||
|
}));
|
||||||
|
expect(next.industry).toEqual([
|
||||||
|
{ regionKey: 'g1', type: 'oil' }, // the existing type is never replaced
|
||||||
|
{ regionKey: 'g2', type: 'food' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a project whose region key is not in the valid-keys manifest', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state(),
|
||||||
|
mkTurnResult({ industry: { projects: [{ regionKey: 'not-a-real-key-xyz', type: 'oil' }] } }),
|
||||||
|
new Set(['g1', 'g2', 'd1']),
|
||||||
|
);
|
||||||
|
expect(next.industry).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active projects persist across turns (the set is never cleared on commit)', () => {
|
||||||
|
const s = state();
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({}));
|
||||||
|
expect(next.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyIndustryEffects (v0.36.0 #59)', () => {
|
||||||
|
const mk = () =>
|
||||||
|
mkTurnState(
|
||||||
|
{
|
||||||
|
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1']),
|
||||||
|
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||||||
|
},
|
||||||
|
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('boosts the region\'s SOLE owner by the base value (no resource manifest)', () => {
|
||||||
|
const s = mk();
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||||||
|
applyIndustryEffects(s);
|
||||||
|
expect(s.nations.uk.stats.economy).toBe(51); // 50 + 1
|
||||||
|
expect(s.nations.de.stats.economy).toBe(50); // d1 hosts no project
|
||||||
|
// the boost is mirrored to every region the owner holds
|
||||||
|
expect(s.regionStats.g1.economy).toBe(51);
|
||||||
|
expect(s.regionStats.d1.economy).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds one extra point when the region\'s industry value is >= 0.5 (and not below)', () => {
|
||||||
|
const s = mk();
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
|
||||||
|
applyIndustryEffects(
|
||||||
|
s,
|
||||||
|
(k) => (k === 'g1'
|
||||||
|
? { oil: 0, iron: 0, food: 0, industry: 0.5 } // 0.5 >= 0.5 → boosted
|
||||||
|
: { oil: 0, iron: 0, food: 0, industry: 0.4 }), // 0.4 < 0.5 → base
|
||||||
|
);
|
||||||
|
expect(s.nations.uk.stats.economy).toBe(52); // 1 + 1
|
||||||
|
expect(s.nations.de.stats.economy).toBe(51); // 1 + 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation owning several project regions gets each of their boosts', () => {
|
||||||
|
const s = mk();
|
||||||
|
s.nations.uk.territory = ['g1', 'd1'];
|
||||||
|
s.nations.de.territory = [];
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
|
||||||
|
applyIndustryEffects(s);
|
||||||
|
expect(s.nations.uk.stats.economy).toBe(52); // 2 projects × base 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a project on an ownerless (released) region yields no boost but is kept', () => {
|
||||||
|
const s = mk();
|
||||||
|
s.nations.uk.territory = []; // g1 is no man's land
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||||||
|
applyIndustryEffects(s);
|
||||||
|
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept, not removed
|
||||||
|
expect(s.nations.de.stats.economy).toBe(50); // nobody was boosted
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a project on a region with 2+ owners (corrupt) yields no boost but is kept', () => {
|
||||||
|
const s = mk();
|
||||||
|
s.nations.de.territory = ['d1', 'g1']; // corrupt: g1 has two owners
|
||||||
|
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||||||
|
applyIndustryEffects(s);
|
||||||
|
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept
|
||||||
|
expect(s.nations.uk.stats.economy).toBe(50);
|
||||||
|
expect(s.nations.de.stats.economy).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when there are no active projects', () => {
|
||||||
|
const s = mk();
|
||||||
|
applyIndustryEffects(s);
|
||||||
|
expect(s.nations.uk.stats.economy).toBe(50);
|
||||||
|
expect(s.nations.de.stats.economy).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMeaningful — industry (v0.36.0 #59)', () => {
|
||||||
|
it('a project-only turn is meaningful; an empty industry is not', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ industry: { projects: [{ regionKey: 'k', type: 'oil' }] } }))).toBe(true);
|
||||||
|
expect(isMeaningful(mkTurnResult({}))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt — industry contract (v0.36.0 #59)', () => {
|
||||||
|
it('documents the industry field and its constraints in both modes', () => {
|
||||||
|
for (const withTools of [false, true]) {
|
||||||
|
const p = buildSystemPrompt(preset, withTools);
|
||||||
|
expect(p).toContain('"industry": {"projects":');
|
||||||
|
expect(p).toContain('Industry ("industry") is the per-resource development lever');
|
||||||
|
expect(p).toContain('do not re-declare one on a region that already has one');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUserPrompt — industry (v0.36.0 #59)', () => {
|
||||||
|
const state = mkTurnState(
|
||||||
|
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||||||
|
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('carries the resource aggregate and active projects when provided', () => {
|
||||||
|
const out = JSON.parse(
|
||||||
|
buildUserPrompt(
|
||||||
|
state,
|
||||||
|
[],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } },
|
||||||
|
[{ regionKey: 'ra1', type: 'oil' }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out.resources).toEqual({ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } });
|
||||||
|
expect(out.industry).toEqual([{ regionKey: 'ra1', type: 'oil' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the resources/industry fields entirely when undefined', () => {
|
||||||
|
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
|
||||||
|
expect(out).not.toHaveProperty('resources');
|
||||||
|
expect(out).not.toHaveProperty('industry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTurn — industry (v0.36.0 #59)', () => {
|
||||||
|
it('a result without an industry field leaves state.industry = [] (defensive init)', async () => {
|
||||||
|
const s = baseState();
|
||||||
|
delete (s as { industry?: unknown[] }).industry; // simulate a pre-v0.36 row
|
||||||
|
const { state } = await runTurn({ llm: fakeLlm([validResult]), preset, state: s, items: [] });
|
||||||
|
expect(state.industry).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37.0, #60) -------------------------------------------
|
||||||
|
|
||||||
|
describe('isNuclearCapable (v0.37.0 #60)', () => {
|
||||||
|
const marker = (id: string, type: MarkerType, ownerNationId: string | null): MapMarker =>
|
||||||
|
({ id, type, name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId, placedTurn: 1 });
|
||||||
|
const n = (id: string, nuclear?: boolean): Nation =>
|
||||||
|
nuclear ? { ...mkNation(id, NS(50, 50, 50, 50), []), nuclear } : mkNation(id, NS(50, 50, 50, 50), []);
|
||||||
|
|
||||||
|
it('is true when the nuclear flag is set', () => {
|
||||||
|
expect(isNuclearCapable(n('uk', true), [])).toBe(true);
|
||||||
|
});
|
||||||
|
it('is true when the nation owns a rocket marker (a launch site is an arsenal)', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'uk')])).toBe(true);
|
||||||
|
});
|
||||||
|
it('is false when the flag is unset and no rocket marker is owned', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'city', 'uk')])).toBe(false);
|
||||||
|
});
|
||||||
|
it('a rocket marker owned by another nation does not confer capability', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'de')])).toBe(false);
|
||||||
|
});
|
||||||
|
it('a rocket marker with a null owner does not confer capability', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', null)])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateNuclearOps (v0.37.0 #60)', () => {
|
||||||
|
it('accepts well-formed arm + strikes', () => {
|
||||||
|
const out = validateNuclearOps({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
|
||||||
|
expect(out).toEqual({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
|
||||||
|
});
|
||||||
|
it('dedupes arm refs and drops empty / non-string entries', () => {
|
||||||
|
const out = validateNuclearOps({ arm: ['uk', 'uk', '', ' ', 'de', null, 5] as unknown[] });
|
||||||
|
expect(out.arm).toEqual(['uk', 'de']);
|
||||||
|
});
|
||||||
|
it('drops a strike missing a from or a target', () => {
|
||||||
|
const out = validateNuclearOps({
|
||||||
|
strikes: [
|
||||||
|
{ target: 'de' } as unknown as NuclearStrike, // no from
|
||||||
|
{ from: 'uk' } as unknown as NuclearStrike, // no target
|
||||||
|
'not-an-object' as unknown as NuclearStrike,
|
||||||
|
null as unknown as NuclearStrike,
|
||||||
|
{ from: 'uk', target: 'g1' }, // valid
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.strikes).toEqual([{ from: 'uk', target: 'g1' }]);
|
||||||
|
});
|
||||||
|
it('drops a self-target (from === target, case-insensitive)', () => {
|
||||||
|
const out = validateNuclearOps({ strikes: [{ from: 'UK', target: 'uk' }, { from: 'uk', target: 'de' }] });
|
||||||
|
expect(out.strikes).toEqual([{ from: 'uk', target: 'de' }]);
|
||||||
|
});
|
||||||
|
it('a missing / malformed top-level object yields { arm: [], strikes: [] }', () => {
|
||||||
|
expect(validateNuclearOps(undefined)).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps(null)).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps('nope')).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps({})).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps({ arm: 'x', strikes: 'y' })).toEqual({ arm: [], strikes: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult — nuclear ops (v0.37.0 #60)', () => {
|
||||||
|
// uk starts nuclear-capable (the flag); de is not. Each owns one region.
|
||||||
|
const base = (): GameState => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true };
|
||||||
|
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
|
||||||
|
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
};
|
||||||
|
|
||||||
|
it('arming a nation sets its nuclear flag (idempotent across turns)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({ nuclear: { arm: ['uk', 'de'], strikes: [] } }));
|
||||||
|
expect(next.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next.nations.de.nuclear).toBe(true);
|
||||||
|
const next2 = applyTurnResult(next, mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }));
|
||||||
|
expect(next2.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next2.nations.de.nuclear).toBe(true); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation that arms this turn is capable and can strike in the same turn', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // start unarmed
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: ['uk'], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next.unowned).toContain('d1'); // the armed-this-turn strike landed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a region strike releases the region, deletes its stats, and collapses the owner', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.territory).toEqual([]);
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
expect(next.regionStats['d1']).toBeUndefined();
|
||||||
|
expect(next.regionStats['g1']).toBeDefined();
|
||||||
|
expect(next.nations.de.stats.economy).toBe(50 + NUCLEAR_COLLAPSE.economy);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.de.stats.stability).toBe(50 + NUCLEAR_COLLAPSE.stability);
|
||||||
|
expect(next.nations.de.stats.population).toBe(50);
|
||||||
|
// de is not capable → no MAD, the striker is unharmed
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.nations.uk.stats.economy).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0].type).toBe('military');
|
||||||
|
expect(next.events[0].title).toBe('Nuclear strike on d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation strike devastates the target and ends a shared war in capitulation (relation → hostile)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.wars = [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.wars.find((w) => w.id === 'w1')?.status).toBe('capitulation');
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
// de is not capable → no MAD
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation strike ends a war declared in the same turn (the sync-then-end ordering)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
wars: { declare: [{ sides: ['uk', 'de'] }], end: [] },
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
const w = next.wars.find((w) => w.sides.includes('uk') && w.sides.includes('de'));
|
||||||
|
expect(w?.status).toBe('capitulation');
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on a nuclear-capable target triggers a MAD retaliation on the striker', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.nuclear = true; // the target is capable
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
|
||||||
|
expect(next.nations.uk.stats.economy).toBe(50 + NUCLEAR_RETALIATION.economy);
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(50 + NUCLEAR_RETALIATION.stability);
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
expect(next.events).toHaveLength(2);
|
||||||
|
expect(next.events.some((e) => e.title === 'MAD retaliation')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a region strike on a nuclear-capable owner triggers a MAD retaliation', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.nuclear = true; // the owner of d1 is capable
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
expect(next.events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation with a rocket marker (not the flag) can strike', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // no flag
|
||||||
|
s.markers = [{ id: 'm1', type: 'rocket', name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId: 'uk', placedTurn: 1 }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike by a non-nuclear-capable nation is dropped (never thrown)', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear;
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.territory).toEqual(['d1']);
|
||||||
|
expect(next.unowned).toEqual([]);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on a region with two or more owners is dropped (never thrown)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.uk.territory = ['g1', 'd1']; // d1 now has two owners (uk, de)
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toEqual([]);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on an unowned (no-man\'s-land) region is dropped (nothing to devastate)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.territory = []; // d1 has zero owners
|
||||||
|
s.unowned = ['d1'];
|
||||||
|
delete s.regionStats['d1'];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toEqual(['d1']);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike whose target is the striker itself is dropped (never thrown)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'uk' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws on malformed strikes (unknown from, unknown target, empty strings)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: {
|
||||||
|
arm: [],
|
||||||
|
strikes: [
|
||||||
|
{ from: 'ghost', target: 'de' },
|
||||||
|
{ from: 'uk', target: 'nowhere' },
|
||||||
|
{ from: 'uk', target: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMeaningful — nuclear (v0.37.0 #60)', () => {
|
||||||
|
it('a turn with only an arm op or a strike is meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }))).toBe(true);
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] } }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('a turn with empty nuclear ops and nothing else is not meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] } }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUserPrompt — nuclear capability (v0.37.0 #60)', () => {
|
||||||
|
const state = (): GameState =>
|
||||||
|
mkTurnState(
|
||||||
|
{
|
||||||
|
uk: { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true },
|
||||||
|
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||||||
|
},
|
||||||
|
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('stamps each nation\'s nuclear capability when the map is provided', () => {
|
||||||
|
const out = JSON.parse(
|
||||||
|
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined, {
|
||||||
|
uk: true,
|
||||||
|
de: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(out.state.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(out.state.nations.de.nuclear).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the nuclear capability entirely when the map is undefined', () => {
|
||||||
|
const out = JSON.parse(
|
||||||
|
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined),
|
||||||
|
);
|
||||||
|
expect(out.state.nations.uk).not.toHaveProperty('nuclear');
|
||||||
|
expect(out.state.nations.de).not.toHaveProperty('nuclear');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+637
-5
@@ -8,39 +8,49 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
IndustryProject,
|
||||||
|
IndustryType,
|
||||||
MapMarker,
|
MapMarker,
|
||||||
Nation,
|
Nation,
|
||||||
NationStats,
|
NationStats,
|
||||||
Preset,
|
Preset,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RegionResources,
|
||||||
RegionStats,
|
RegionStats,
|
||||||
RoundItem,
|
RoundItem,
|
||||||
StatKey,
|
StatKey,
|
||||||
TradeAgreement,
|
TradeAgreement,
|
||||||
War,
|
War,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
import { EVENT_TYPES, INDUSTRY_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
canConquer,
|
canConquer,
|
||||||
clampStat,
|
clampStat,
|
||||||
|
deriveNationResources,
|
||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
fnv1a32,
|
fnv1a32,
|
||||||
HYBRID_OP_TYPES,
|
HYBRID_OP_TYPES,
|
||||||
isDateLike,
|
isDateLike,
|
||||||
isMarkerType,
|
isMarkerType,
|
||||||
|
isNuclearCapable,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
markerId,
|
markerId,
|
||||||
MAX_MARKERS,
|
MAX_MARKERS,
|
||||||
|
NUCLEAR_COLLAPSE,
|
||||||
|
NUCLEAR_RETALIATION,
|
||||||
nationForPrompt,
|
nationForPrompt,
|
||||||
type EconomyEmbargoOp,
|
type EconomyEmbargoOp,
|
||||||
type EconomyOps,
|
type EconomyOps,
|
||||||
type EconomyTradeOp,
|
type EconomyTradeOp,
|
||||||
type HybridOp,
|
type HybridOp,
|
||||||
type HybridOpType,
|
type HybridOpType,
|
||||||
|
type IndustryOps,
|
||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
|
type NuclearOps,
|
||||||
|
type NuclearStatKey,
|
||||||
type RegionStatChange,
|
type RegionStatChange,
|
||||||
type Takeover,
|
type Takeover,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
@@ -70,6 +80,13 @@ export function isHybridOpType(t: unknown): t is HybridOpType {
|
|||||||
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
|
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_INDUSTRY_TYPES = new Set<string>(INDUSTRY_TYPES);
|
||||||
|
|
||||||
|
/** True when `t` is one of the four known industry project types (v0.36, #59). */
|
||||||
|
export function isIndustryType(t: unknown): t is IndustryType {
|
||||||
|
return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t);
|
||||||
|
}
|
||||||
|
|
||||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +241,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||||
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
||||||
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||||||
|
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||||||
|
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
|
||||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
@@ -245,6 +264,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
||||||
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
|
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
|
||||||
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
|
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
|
||||||
|
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
|
||||||
|
'- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -321,6 +342,31 @@ export function buildUserPrompt(
|
|||||||
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
|
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
embargoes?: Embargo[],
|
embargoes?: Embargo[],
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) Each nation's AGGREGATE resource profile (nation id -> the
|
||||||
|
* area×population-weighted mean of the resource profiles of the regions it
|
||||||
|
* owns, via `deriveNationResources`). Emitted verbatim (even when empty) so
|
||||||
|
* the model can reason about which nations are rich in which resource and
|
||||||
|
* where to build industry projects. Omitted entirely when undefined (no
|
||||||
|
* resource manifest loaded).
|
||||||
|
*/
|
||||||
|
nationResources?: Record<string, RegionResources>,
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) The game's currently ACTIVE industry projects (each a region
|
||||||
|
* key + project type). Emitted verbatim (even when empty) so the model knows
|
||||||
|
* which regions already host a project (and should not be re-declared) and
|
||||||
|
* can reference them. Omitted entirely when undefined.
|
||||||
|
*/
|
||||||
|
industryProjects?: IndustryProject[],
|
||||||
|
/**
|
||||||
|
* (v0.37.0, #60) Each nation's nuclear capability (nation id -> boolean),
|
||||||
|
* computed by the caller via `isNuclearCapable` (the nation's `nuclear` flag
|
||||||
|
* OR a current `rocket` marker). When defined, each nation in the `state.
|
||||||
|
* nations` map is stamped with `nuclear: true|false` so the model knows who
|
||||||
|
* can arm/strike and who can MAD-retaliate. Omitted entirely (no `nuclear`
|
||||||
|
* key on any nation) when undefined.
|
||||||
|
*/
|
||||||
|
nuclear?: Record<string, boolean>,
|
||||||
): string {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -410,7 +456,10 @@ export function buildUserPrompt(
|
|||||||
date: state.date,
|
date: state.date,
|
||||||
playerNationId: state.playerNationId,
|
playerNationId: state.playerNationId,
|
||||||
nations: Object.fromEntries(
|
nations: Object.fromEntries(
|
||||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
Object.entries(state.nations).map(([id, n]) => [
|
||||||
|
id,
|
||||||
|
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
recentEvents: state.events.slice(-5).map((e) => ({
|
recentEvents: state.events.slice(-5).map((e) => ({
|
||||||
turn: e.turn,
|
turn: e.turn,
|
||||||
@@ -437,6 +486,8 @@ export function buildUserPrompt(
|
|||||||
if (relations !== undefined) payload.relations = relations;
|
if (relations !== undefined) payload.relations = relations;
|
||||||
if (trades !== undefined) payload.trades = trades;
|
if (trades !== undefined) payload.trades = trades;
|
||||||
if (embargoes !== undefined) payload.embargoes = embargoes;
|
if (embargoes !== undefined) payload.embargoes = embargoes;
|
||||||
|
if (nationResources !== undefined) payload.resources = nationResources;
|
||||||
|
if (industryProjects !== undefined) payload.industry = industryProjects;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +629,8 @@ export function validateTurnResult(
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] },
|
||||||
|
industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -688,6 +741,22 @@ export function validateTurnResult(
|
|||||||
// ambiguous / self-pairing ops are dropped there, never thrown.
|
// ambiguous / self-pairing ops are dropped there, never thrown.
|
||||||
result.economy = validateEconomyOps(o.economy);
|
result.economy = validateEconomyOps(o.economy);
|
||||||
|
|
||||||
|
// (v0.36, #59) Industry projects: the LLM names a region key + one of the
|
||||||
|
// four project types. Each project becomes an active entry in
|
||||||
|
// `GameState.industry` (a per-region SET). The region key is validated as a
|
||||||
|
// non-empty string here; whether the key is real and owned is checked at
|
||||||
|
// apply time (a project on a region with no sole owner yields no boost but
|
||||||
|
// is kept). Refs stay raw and the effect is deterministic at apply time.
|
||||||
|
result.industry = validateIndustryOps(o.industry);
|
||||||
|
|
||||||
|
// (v0.37, #60) Nuclear / WMD ops: `arm` grants an arsenal to the named
|
||||||
|
// nations and `strikes` are nuclear strikes fired this turn. Refs stay raw
|
||||||
|
// here (resolved against the real state at apply time, like wars / hybrid
|
||||||
|
// ops); an unresolvable ref, a non-capable striker, or an invalid region is
|
||||||
|
// dropped at apply time, never thrown. Applied directly by applyTurnResult
|
||||||
|
// (after industry, before markers).
|
||||||
|
result.nuclear = validateNuclearOps(o.nuclear);
|
||||||
|
|
||||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||||
// removals by stable id. Applied by applyTurnResult after the territory
|
// removals by stable id. Applied by applyTurnResult after the territory
|
||||||
// moves (a new marker's owner is the region's post-turn owner).
|
// moves (a new marker's owner is the region's post-turn owner).
|
||||||
@@ -962,6 +1031,68 @@ export function validateEconomyOps(raw: unknown): EconomyOps {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) Validate the raw `industry` object of a turn into a safe
|
||||||
|
* {@link IndustryOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a
|
||||||
|
* known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a
|
||||||
|
* missing/malformed field are dropped. The region key is kept raw here (like
|
||||||
|
* the hybrid / economy refs) — whether it is a *valid* region key, and whether
|
||||||
|
* its region ends the turn with a sole owner, is checked at apply time against
|
||||||
|
* the manifest and the final ownership (a project on a region with no sole
|
||||||
|
* owner yields no boost but is kept). A missing / malformed top-level object
|
||||||
|
* yields `{ projects: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateIndustryOps(raw: unknown): IndustryOps {
|
||||||
|
const out: IndustryOps = { projects: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(o.projects)) {
|
||||||
|
for (const e of o.projects) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const p = e as Record<string, unknown>;
|
||||||
|
const regionKey = str(p.regionKey, 120);
|
||||||
|
if (!regionKey || !isIndustryType(p.type)) continue;
|
||||||
|
out.projects.push({ regionKey, type: p.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe
|
||||||
|
* {@link NuclearOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is
|
||||||
|
* resolved (id or name) at APPLY time, so an unknown ref is dropped there, not
|
||||||
|
* here. `strikes`: each needs a non-empty `from` (the striking nation ref) and
|
||||||
|
* a non-empty `target` (a nation ref OR a region key), each capped at 120
|
||||||
|
* chars; a self-target (`from` and `target` the same ref) is dropped here. A
|
||||||
|
* missing / malformed top-level object yields `{ arm: [], strikes: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateNuclearOps(raw: unknown): NuclearOps {
|
||||||
|
const out: NuclearOps = { arm: [], strikes: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(o.arm)) {
|
||||||
|
for (const e of o.arm) {
|
||||||
|
const s = typeof e === 'string' ? e.trim() : '';
|
||||||
|
if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.strikes)) {
|
||||||
|
for (const e of o.strikes) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const s = e as Record<string, unknown>;
|
||||||
|
const from = str(s.from, 120);
|
||||||
|
const target = str(s.target, 120);
|
||||||
|
if (!from || !target) continue;
|
||||||
|
if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop
|
||||||
|
out.strikes.push({ from, target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||||
@@ -1368,8 +1499,421 @@ function suspendEconomyOnWar(next: GameState): void {
|
|||||||
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
|
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Industry projects: the per-region economy-boost lever (v0.36, #59) ------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
* (v0.36, #59) The BASE per-turn economy boost one industry project grants its
|
||||||
|
* region's sole owner. A project's actual boost is this base plus one extra
|
||||||
|
* point when the region's `industry` resource value is at least 0.5 (a region
|
||||||
|
* with a strong industry profile yields a bigger return). The base value is
|
||||||
|
* deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} /
|
||||||
|
* {@link EMBARGO_ECONOMY_PENALTY}.
|
||||||
|
*/
|
||||||
|
const INDUSTRY_ECONOMY_BONUS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) The per-turn economy boost for ONE industry project sitting in
|
||||||
|
* `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the
|
||||||
|
* region's `industry` resource value (from `resourceOf`) is at least 0.5.
|
||||||
|
* Without the resource manifest (or for a region with no profile) the boost is
|
||||||
|
* the base value. Pure + deterministic.
|
||||||
|
*/
|
||||||
|
function industryBoost(
|
||||||
|
regionKey: string,
|
||||||
|
resourceOf?: (key: string) => RegionResources | undefined,
|
||||||
|
): number {
|
||||||
|
const r = resourceOf?.(regionKey);
|
||||||
|
return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a
|
||||||
|
* PER-REGION SET: each op names a region key + a project type. A region key
|
||||||
|
* that is not a valid manifest key (`validKeys`, when supplied) is dropped; a
|
||||||
|
* region that already hosts a project is untouched (the first declared type
|
||||||
|
* wins — re-declaring on the same region is a no-op, the type is never
|
||||||
|
* replaced). Otherwise the project is appended. Mutates `next` in place.
|
||||||
|
* Nothing here throws.
|
||||||
|
*/
|
||||||
|
function applyIndustryOps(
|
||||||
|
next: GameState,
|
||||||
|
ops: IndustryOps,
|
||||||
|
validKeys?: ReadonlySet<string>,
|
||||||
|
): void {
|
||||||
|
if (ops.projects.length === 0) return;
|
||||||
|
const has = (key: string) => next.industry.some((p) => p.regionKey === key);
|
||||||
|
for (const p of ops.projects) {
|
||||||
|
if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop
|
||||||
|
if (has(p.regionKey)) continue; // a region hosts at most one project
|
||||||
|
next.industry.push({ regionKey: p.regionKey, type: p.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) The per-turn economic effect of the industry layer, applied
|
||||||
|
* EVERY committed turn (not just to this turn's adds): each active project
|
||||||
|
* grants its region's SOLE owner a small, deterministic economy boost (see
|
||||||
|
* {@link industryBoost}) — clamped to 0–100, mirrored to every region the
|
||||||
|
* owner holds (`regionStats`), and each affected nation's derived stats are
|
||||||
|
* re-derived afterwards (the same 0-region / no-entry guards as
|
||||||
|
* `applyEconomyEffects`). A project whose region ends the turn with NO owner,
|
||||||
|
* or 2+ owners (released to no man's land / corrupt), yields no boost but is
|
||||||
|
* kept in the list. Sole ownership is untouched. No-op when there are no
|
||||||
|
* active projects. Never throws.
|
||||||
|
*/
|
||||||
|
export function applyIndustryEffects(
|
||||||
|
next: GameState,
|
||||||
|
resourceOf?: (key: string) => RegionResources | undefined,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
): void {
|
||||||
|
if (next.industry.length === 0) return;
|
||||||
|
|
||||||
|
// Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to
|
||||||
|
// nothing and yields no boost).
|
||||||
|
const ownerOf = new Map<string, string>();
|
||||||
|
{
|
||||||
|
const owners = new Map<string, string[]>();
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const list = owners.get(key);
|
||||||
|
if (list) list.push(n.id);
|
||||||
|
else owners.set(key, [n.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, list] of owners) {
|
||||||
|
if (list.length === 1) ownerOf.set(key, list[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nation id -> accumulated economy boost (a nation owning several project
|
||||||
|
// regions gets each of their boosts).
|
||||||
|
const delta = new Map<string, number>();
|
||||||
|
for (const p of next.industry) {
|
||||||
|
const owner = ownerOf.get(p.regionKey);
|
||||||
|
if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept)
|
||||||
|
const n = next.nations[owner];
|
||||||
|
if (!n) continue; // a nation removed this turn (a merge): the boost is dropped
|
||||||
|
delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf));
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = new Set<string>();
|
||||||
|
for (const [id, d] of delta) {
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue;
|
||||||
|
n.stats.economy = clampStat(n.stats.economy + d);
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (rs) rs.economy = clampStat(rs.economy + d);
|
||||||
|
}
|
||||||
|
affected.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affected.size > 0) {
|
||||||
|
for (const id of affected) {
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37, #60): the WMD lever --------------------------------
|
||||||
|
//
|
||||||
|
// A nuclear strike is a DETERMINISTIC devastation (no hidden randomness): the
|
||||||
|
// LLM decides which nuclear-capable nation strikes and what it targets; the
|
||||||
|
// engine decides the size of the effect (NUCLEAR_COLLAPSE / NUCLEAR_RETALIATION
|
||||||
|
// in protocol.ts). This block runs AFTER the industry effects and BEFORE the
|
||||||
|
// marker owner-recompute, because a region strike RELEASES its region to no
|
||||||
|
// man's land, which changes the final ownership the markers depend on.
|
||||||
|
//
|
||||||
|
// ARM: each `arm` ref (id or name) is resolved and that nation's `nuclear`
|
||||||
|
// flag is set to true (idempotent; an unresolvable ref is dropped).
|
||||||
|
// Arming runs first, so a nation that arms AND strikes this turn is
|
||||||
|
// capable.
|
||||||
|
//
|
||||||
|
// STRIKE: each strike is validated independently (never throws):
|
||||||
|
// (a) `from` must resolve to a nuclear-capable nation (the `nuclear` flag
|
||||||
|
// or a rocket marker), else dropped.
|
||||||
|
// (b) The target is a NATION (a ref resolving to a distinct nation) or a
|
||||||
|
// REGION (a region key / name with a SOLE owner that is not the
|
||||||
|
// striker):
|
||||||
|
// - a nation target: the target's economy / military / stability are
|
||||||
|
// devastated (NUCLEAR_COLLAPSE, mirrored to its regions); if `from`
|
||||||
|
// and the target are on opposite sides of an active war, that war
|
||||||
|
// ends in `capitulation` and the warring relation falls back to
|
||||||
|
// hostile; if the target is nuclear-capable, the striker suffers a
|
||||||
|
// half-strength MAD retaliation.
|
||||||
|
// - a region target: the region is released to no man's land (its
|
||||||
|
// regionStats entry leaves the table), its former owner is
|
||||||
|
// devastated (NUCLEAR_COLLAPSE), and if that owner is nuclear-capable
|
||||||
|
// the striker suffers a half-strength MAD retaliation.
|
||||||
|
// (c) every applied strike appends a dramatic event (the EXISTING `military`
|
||||||
|
// category — no new category is invented), and a MAD strike appends an
|
||||||
|
// extra "MAD retaliation" event.
|
||||||
|
//
|
||||||
|
// After all strikes, every affected nation's derived stats are re-derived
|
||||||
|
// (the same 0-region / no-entry guards as the main recompute), so the
|
||||||
|
// collapse persists and the sole-ownership invariant is untouched.
|
||||||
|
|
||||||
|
/** The three stats a nuclear strike collapses (population is never touched). */
|
||||||
|
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devastate one nation's stats AND every region it owns (mirroring the
|
||||||
|
* collapse into `regionStats`, like the economy / hybrid layers), clamped to
|
||||||
|
* 0–100. Never throws.
|
||||||
|
*/
|
||||||
|
function collapseNationStats(
|
||||||
|
next: GameState,
|
||||||
|
nationId: string,
|
||||||
|
collapse: Record<NuclearStatKey, number>,
|
||||||
|
): void {
|
||||||
|
const n = next.nations[nationId];
|
||||||
|
if (!n) return;
|
||||||
|
for (const k of NUCLEAR_STATS) {
|
||||||
|
const d = collapse[k];
|
||||||
|
n.stats[k] = clampStat(n.stats[k] + d);
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (rs) rs[k] = clampStat(rs[k] + d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a region key to no man's land, reusing the turn's release semantics:
|
||||||
|
* the key leaves its SOLE owner's territory, is marked in `GameState.unowned`,
|
||||||
|
* and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an
|
||||||
|
* already-unowned key) is left untouched. Never throws.
|
||||||
|
*/
|
||||||
|
function releaseRegionToNoMansLand(next: GameState, key: string): void {
|
||||||
|
if (next.unowned.includes(key)) return; // already no man's land
|
||||||
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(key));
|
||||||
|
if (owners.length !== 1) return; // 0 or 2+ owners: not releasable
|
||||||
|
next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter(
|
||||||
|
(k) => k !== key,
|
||||||
|
);
|
||||||
|
next.unowned.push(key);
|
||||||
|
delete next.regionStats[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End every ACTIVE war whose sides include both `a` and `b` (a nuclear
|
||||||
|
* capitulation), then downgrade the `war` relation of every pair that is no
|
||||||
|
* longer covered by an active war back to `hostile` (the war → relation sync).
|
||||||
|
* Capturing the active pairs immediately BEFORE the end keeps this correct for
|
||||||
|
* a war declared and ended in the SAME turn (where the start-of-turn `prev`
|
||||||
|
* that `syncWarRelations` uses would not contain it). Never throws.
|
||||||
|
*/
|
||||||
|
function endWarsBetween(next: GameState, a: string, b: string): void {
|
||||||
|
const before = activeWarPairs(next.wars);
|
||||||
|
let ended = false;
|
||||||
|
for (const w of next.wars) {
|
||||||
|
if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) {
|
||||||
|
w.status = 'capitulation';
|
||||||
|
ended = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ended) return;
|
||||||
|
const after = activeWarPairs(next.wars);
|
||||||
|
for (const key of before) {
|
||||||
|
if (after.has(key)) continue; // still covered by another active war
|
||||||
|
const [x, y] = key.split('\u0000');
|
||||||
|
const nx = next.nations[x];
|
||||||
|
const ny = next.nations[y];
|
||||||
|
if (!nx || !ny) continue;
|
||||||
|
if (nx.relations[y] === 'war' || ny.relations[x] === 'war') {
|
||||||
|
nx.relations[y] = 'hostile';
|
||||||
|
ny.relations[x] = 'hostile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the relation between two nations to `hostile` in BOTH directions —
|
||||||
|
* the aftermath of a MAD exchange. A `war` relation is never downgraded (the
|
||||||
|
* war entity owns it while active — e.g. a region strike that devastates one
|
||||||
|
* warring side without ending the war). Never throws.
|
||||||
|
*/
|
||||||
|
function forceHostile(next: GameState, a: string, b: string): void {
|
||||||
|
const na = next.nations[a];
|
||||||
|
const nb = next.nations[b];
|
||||||
|
if (!na || !nb) return;
|
||||||
|
if (na.relations[b] !== 'war') na.relations[b] = 'hostile';
|
||||||
|
if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in
|
||||||
|
* place and never throwing. See the section comment above for the full
|
||||||
|
* contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the
|
||||||
|
* "owns a rocket" half of nuclear capability (a rocket marker placed this very
|
||||||
|
* turn makes its owner capable only from the next turn, while a nation that
|
||||||
|
* ARMS this turn is capable immediately, since arming runs first).
|
||||||
|
*/
|
||||||
|
export function applyNuclearOps(
|
||||||
|
next: GameState,
|
||||||
|
ops: NuclearOps,
|
||||||
|
state: GameState,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
validKeys?: ReadonlySet<string>,
|
||||||
|
regionNameOf?: (key: string) => string | undefined,
|
||||||
|
actingTurn?: number,
|
||||||
|
): void {
|
||||||
|
if (ops.arm.length === 0 && ops.strikes.length === 0) return;
|
||||||
|
const turn = actingTurn ?? next.turn;
|
||||||
|
const markers = state.markers ?? [];
|
||||||
|
const capable = (id: string): boolean => {
|
||||||
|
const n = next.nations[id];
|
||||||
|
return n ? isNuclearCapable(n, markers) : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped.
|
||||||
|
for (const ref of ops.arm) {
|
||||||
|
const id = resolveNationRef(next, ref);
|
||||||
|
if (id === null) continue;
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue;
|
||||||
|
n.nuclear = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = new Set<string>();
|
||||||
|
const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key;
|
||||||
|
|
||||||
|
// (2) STRIKES: each validated independently against the post-arm state.
|
||||||
|
for (const strike of ops.strikes) {
|
||||||
|
const fromId = resolveNationRef(next, strike.from);
|
||||||
|
if (fromId === null) continue; // unknown / ambiguous striker: drop
|
||||||
|
const from = next.nations[fromId];
|
||||||
|
if (!from) continue;
|
||||||
|
if (!capable(fromId)) continue; // a non-capable nation cannot strike
|
||||||
|
if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop
|
||||||
|
|
||||||
|
// Resolve the target: a NATION (a ref resolving to a distinct nation) or a
|
||||||
|
// REGION (a region key / name with a sole owner that is not the striker).
|
||||||
|
let target:
|
||||||
|
| { kind: 'nation'; nation: Nation }
|
||||||
|
| { kind: 'region'; key: string; owner: Nation }
|
||||||
|
| null = null;
|
||||||
|
const tid = resolveNationRef(next, strike.target);
|
||||||
|
if (tid !== null) {
|
||||||
|
const tn = next.nations[tid];
|
||||||
|
if (tn) target = { kind: 'nation', nation: tn };
|
||||||
|
} else {
|
||||||
|
const rk = validKeys
|
||||||
|
? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined))
|
||||||
|
: (strike.target.trim() || null);
|
||||||
|
if (rk !== null) {
|
||||||
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk));
|
||||||
|
if (owners.length === 1 && owners[0]!.id !== fromId) {
|
||||||
|
target = { kind: 'region', key: rk, owner: owners[0]! };
|
||||||
|
}
|
||||||
|
// a 0/2+-owner region, or a region the striker owns (self), is dropped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === null) continue; // unresolvable target: drop
|
||||||
|
|
||||||
|
if (target.kind === 'nation') {
|
||||||
|
// Devastate the target nation.
|
||||||
|
collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE);
|
||||||
|
affected.add(target.nation.id);
|
||||||
|
// A strike on a nation at war with the striker ends that war in
|
||||||
|
// capitulation (the relation then falls back to hostile).
|
||||||
|
endWarsBetween(next, fromId, target.nation.id);
|
||||||
|
// MAD: a nuclear-capable target strikes back at half strength and the
|
||||||
|
// two nations end the exchange at least hostile.
|
||||||
|
if (capable(target.nation.id)) {
|
||||||
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
||||||
|
affected.add(fromId);
|
||||||
|
forceHostile(next, fromId, target.nation.id);
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: 'MAD retaliation',
|
||||||
|
text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`,
|
||||||
|
outcome: 'failure',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: `Nuclear strike on ${target.nation.name}`,
|
||||||
|
text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`,
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Devastate the region: release it to no man's land, then devastate its
|
||||||
|
// former owner.
|
||||||
|
const owner = target.owner;
|
||||||
|
releaseRegionToNoMansLand(next, target.key);
|
||||||
|
collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE);
|
||||||
|
affected.add(owner.id);
|
||||||
|
// MAD: a nuclear-capable owner strikes back at half strength and the
|
||||||
|
// two nations end the exchange at least hostile.
|
||||||
|
if (capable(owner.id)) {
|
||||||
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
||||||
|
affected.add(fromId);
|
||||||
|
forceHostile(next, fromId, owner.id);
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: 'MAD retaliation',
|
||||||
|
text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay(
|
||||||
|
target.key,
|
||||||
|
)}; mutual assured destruction devastates ${from.name} in turn.`,
|
||||||
|
outcome: 'failure',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: `Nuclear strike on ${regionDisplay(target.key)}`,
|
||||||
|
text: `${from.name} obliterates ${regionDisplay(
|
||||||
|
target.key,
|
||||||
|
)} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`,
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region /
|
||||||
|
// no-entry guards as the main recompute), so the collapse persists.
|
||||||
|
for (const id of affected) {
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue;
|
||||||
|
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||||
|
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a
|
||||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||||
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
|
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
|
||||||
* a proposal is a real pending-world change the player will resolve, so it
|
* a proposal is a real pending-world change the player will resolve, so it
|
||||||
@@ -1397,6 +1941,13 @@ export function isMeaningful(result: TurnResult): boolean {
|
|||||||
// turn is a real world change (the active economy lists moved).
|
// turn is a real world change (the active economy lists moved).
|
||||||
result.economy.trade.length > 0 ||
|
result.economy.trade.length > 0 ||
|
||||||
result.economy.embargo.length > 0 ||
|
result.economy.embargo.length > 0 ||
|
||||||
|
// (v0.36, #59) an industry project built this turn is a real world change
|
||||||
|
// (the active project list moved).
|
||||||
|
result.industry.projects.length > 0 ||
|
||||||
|
// (v0.37, #60) a nation arming a nuclear arsenal or a nuclear strike fired
|
||||||
|
// this turn is a real world change.
|
||||||
|
result.nuclear.arm.length > 0 ||
|
||||||
|
result.nuclear.strikes.length > 0 ||
|
||||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||||
result.markers.add.length > 0 ||
|
result.markers.add.length > 0 ||
|
||||||
result.markers.remove.length > 0
|
result.markers.remove.length > 0
|
||||||
@@ -1441,6 +1992,13 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||||||
* engine-emitted "invasion repelled" event; without it the event falls back to
|
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||||||
* the raw key. Pure either way.
|
* the raw key. Pure either way.
|
||||||
|
*
|
||||||
|
* `resourceOf` (v0.36, #59) resolves a region key to its synthesized resource
|
||||||
|
* profile (`{ oil, iron, food, industry }`, from public/region-resources.json
|
||||||
|
* via server/regions.ts). It is used to scale each industry project's per-turn
|
||||||
|
* economy boost by the region's `industry` value (a region with a strong
|
||||||
|
* industry profile yields a bigger return). Without it the boost is the
|
||||||
|
* unscaled base value — the engine stays pure and deterministic either way.
|
||||||
*/
|
*/
|
||||||
export function applyTurnResult(
|
export function applyTurnResult(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
@@ -1449,12 +2007,16 @@ export function applyTurnResult(
|
|||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
regionNameOf?: (key: string) => string | undefined,
|
regionNameOf?: (key: string) => string | undefined,
|
||||||
|
resourceOf?: (key: string) => RegionResources | undefined,
|
||||||
): GameState {
|
): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||||||
// field starts empty (the turn then re-inits claimed regions as it goes).
|
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||||||
next.regionStats = next.regionStats ?? {};
|
next.regionStats = next.regionStats ?? {};
|
||||||
|
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
|
||||||
|
// field starts empty (the turn then only ever adds to it).
|
||||||
|
next.industry = next.industry ?? [];
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||||
@@ -1779,6 +2341,37 @@ export function applyTurnResult(
|
|||||||
suspendEconomyOnWar(next);
|
suspendEconomyOnWar(next);
|
||||||
applyEconomyEffects(next, geoOf);
|
applyEconomyEffects(next, geoOf);
|
||||||
|
|
||||||
|
// --- Industry projects (v0.36, #59): per-region economy-boost lever ---
|
||||||
|
//
|
||||||
|
// Placed AFTER the economy block (both move stats, and the industry boost
|
||||||
|
// should compose with the trade/embargo effects of the same turn) and
|
||||||
|
// before markers / nationOps. Order:
|
||||||
|
// (1) APPLY OPS: this turn's `industry.projects` are added to
|
||||||
|
// `GameState.industry` as a per-region SET — a region key that is not
|
||||||
|
// a valid manifest key is dropped, and a region that already hosts a
|
||||||
|
// project is untouched (the first declared type wins; re-declaring is
|
||||||
|
// a no-op).
|
||||||
|
// (2) PER-TURN EFFECT: the FULL active project list (not just this
|
||||||
|
// turn's adds) each grants their region's SOLE owner a small,
|
||||||
|
// deterministic economy boost scaled by the region's `industry`
|
||||||
|
// resource value — clamped, mirrored to every region the owner holds,
|
||||||
|
// and re-derived like the economy/hybrid effects. A project whose
|
||||||
|
// region ends the turn with no owner (or 2+ owners) yields no boost
|
||||||
|
// but is KEPT (the region may be re-claimed later). Sole ownership is
|
||||||
|
// untouched (the layer moves stats, never territory).
|
||||||
|
applyIndustryOps(next, result.industry, validKeys);
|
||||||
|
applyIndustryEffects(next, resourceOf, geoOf);
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37, #60): the WMD lever ---
|
||||||
|
//
|
||||||
|
// Placed AFTER the industry/economy effects (both move stats) and BEFORE the
|
||||||
|
// markers block (a region strike releases its region to no man's land, which
|
||||||
|
// changes the FINAL ownership the marker owner-recompute depends on). See
|
||||||
|
// applyNuclearOps for the full contract (arm, strike, MAD, re-derive).
|
||||||
|
// `state` (the original pre-turn state) supplies the markers used for the
|
||||||
|
// rocket-marker half of nuclear capability.
|
||||||
|
applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn);
|
||||||
|
|
||||||
// --- Map markers (v0.27, #44) ---
|
// --- Map markers (v0.27, #44) ---
|
||||||
//
|
//
|
||||||
// Markers are anchored to a region key and are processed against the FINAL
|
// Markers are anchored to a region key and are processed against the FINAL
|
||||||
@@ -2250,8 +2843,17 @@ export async function runTurn(opts: {
|
|||||||
* it falls back to the raw region key. Pure either way.
|
* it falls back to the raw region key. Pure either way.
|
||||||
*/
|
*/
|
||||||
regionNameOf?: (key: string) => string | undefined;
|
regionNameOf?: (key: string) => string | undefined;
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) Region-key -> synthesized resource profile resolver (from
|
||||||
|
* public/region-resources.json via server/regions.ts). Present, each
|
||||||
|
* industry project's per-turn economy boost is scaled by its region's
|
||||||
|
* `industry` value, and each nation's aggregate resource profile is carried
|
||||||
|
* in the user prompt; absent, industry boosts use the unscaled base value
|
||||||
|
* and the profile is omitted.
|
||||||
|
*/
|
||||||
|
resourceOf?: (key: string) => RegionResources | undefined;
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf, resourceOf } = opts;
|
||||||
const chatWithTools = llm.chatWithTools;
|
const chatWithTools = llm.chatWithTools;
|
||||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||||
const system = buildSystemPrompt(preset, useTools);
|
const system = buildSystemPrompt(preset, useTools);
|
||||||
@@ -2282,6 +2884,26 @@ export async function runTurn(opts: {
|
|||||||
// starting point before an `exposed` backfire). Always emitted ({} when the
|
// starting point before an `exposed` backfire). Always emitted ({} when the
|
||||||
// player has no relations yet).
|
// player has no relations yet).
|
||||||
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
||||||
|
// (v0.36, #59) each nation's AGGREGATE resource profile (the area×population-
|
||||||
|
// weighted mean of the resource profiles of the regions it owns). Carried in
|
||||||
|
// the user prompt ONLY when the resource manifest is loaded (resourceOf
|
||||||
|
// present); otherwise omitted (undefined) so the prompt — and the stub —
|
||||||
|
// stay identical to the no-manifest case.
|
||||||
|
const nationResources =
|
||||||
|
resourceOf === undefined
|
||||||
|
? undefined
|
||||||
|
: Object.fromEntries(
|
||||||
|
Object.values(state.nations).map((n) => [
|
||||||
|
n.id,
|
||||||
|
deriveNationResources(n.territory, resourceOf, geoOf),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// (v0.37.0, #60) each nation's nuclear capability (its `nuclear` flag OR a
|
||||||
|
// current `rocket` marker), so the model knows who can arm/strike and who
|
||||||
|
// can MAD-retaliate. Always emitted (computed from the PRE-turn markers).
|
||||||
|
const nuclearCapability = Object.fromEntries(
|
||||||
|
Object.values(state.nations).map((n) => [n.id, isNuclearCapable(n, state.markers ?? [])]),
|
||||||
|
);
|
||||||
const user = buildUserPrompt(
|
const user = buildUserPrompt(
|
||||||
state,
|
state,
|
||||||
items,
|
items,
|
||||||
@@ -2295,6 +2917,14 @@ export async function runTurn(opts: {
|
|||||||
// reference — and `lift` — what is currently in force.
|
// reference — and `lift` — what is currently in force.
|
||||||
state.trades,
|
state.trades,
|
||||||
state.embargoes,
|
state.embargoes,
|
||||||
|
// (v0.36, #59) the aggregate resource profiles (undefined without the
|
||||||
|
// manifest) + the active industry projects, so the model knows which
|
||||||
|
// regions already host a project and which nations are rich in which
|
||||||
|
// resource.
|
||||||
|
nationResources,
|
||||||
|
state.industry ?? [],
|
||||||
|
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
|
||||||
|
nuclearCapability,
|
||||||
);
|
);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
@@ -2351,12 +2981,14 @@ export async function runTurn(opts: {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] },
|
||||||
|
industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf, resourceOf),
|
||||||
result,
|
result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -25,7 +25,7 @@ import { makeScenarioStubLlmClient } from './stubScenario';
|
|||||||
import { finalizeWorld, generateDraft } from './scenario';
|
import { finalizeWorld, generateDraft } from './scenario';
|
||||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
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 { createPresetStore } from './presets';
|
||||||
import { createTemplateStore } from './templates';
|
import { createTemplateStore } from './templates';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
@@ -186,6 +186,10 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
// declares (and lifts) them over time via TurnResult.economy.
|
// declares (and lifts) them over time via TurnResult.economy.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
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
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
@@ -370,6 +374,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
// event (geo properties.name, else the raw key). Always on — the
|
// event (geo properties.name, else the raw key). Always on — the
|
||||||
// resolver is pure and degrades to the key when the manifest is absent.
|
// resolver is pure and degrades to the key when the manifest is absent.
|
||||||
regionNameOf: (k) => regionName(k),
|
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);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const state: GameState = {
|
|||||||
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
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.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canConquer,
|
canConquer,
|
||||||
CONQUEST_MILITARY_MARGIN,
|
CONQUEST_MILITARY_MARGIN,
|
||||||
|
deriveNationResources,
|
||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
migrateRegionStats,
|
migrateRegionStats,
|
||||||
seedRegionStats,
|
seedRegionStats,
|
||||||
@@ -44,6 +45,7 @@ function mkState(
|
|||||||
wars: [],
|
wars: [],
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
embargoes: [],
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||||
@@ -139,6 +141,81 @@ describe('deriveNationStats', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- deriveNationResources ---------------------------------------------------
|
||||||
|
|
||||||
|
describe('deriveNationResources (v0.36.0 #59)', () => {
|
||||||
|
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
|
||||||
|
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
|
||||||
|
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
|
||||||
|
};
|
||||||
|
const geo: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 100, population: 1000 },
|
||||||
|
b: { area: 1000, population: 1000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('weights by area×population: the larger region dominates the average', () => {
|
||||||
|
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
|
||||||
|
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
|
||||||
|
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
|
||||||
|
// equal weights 1,1 → the true mean 0.5 on every axis
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
|
||||||
|
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||||
|
});
|
||||||
|
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
|
||||||
|
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a region key that has no resource-profile entry', () => {
|
||||||
|
// b has no profile → only a is counted → a's values
|
||||||
|
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
|
||||||
|
expect(out).toEqual(prof.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the zero profile when none of the owned regions has a profile', () => {
|
||||||
|
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
|
||||||
|
oil: 0, iron: 0, food: 0, industry: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single region yields exactly that region\'s profile', () => {
|
||||||
|
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||||
|
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
|
||||||
|
const g: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 0, population: 1000 },
|
||||||
|
b: { area: 1000, population: 1000 },
|
||||||
|
};
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
|
||||||
|
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds each axis to 2 decimal places', () => {
|
||||||
|
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
|
||||||
|
const g: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 1, population: 1 },
|
||||||
|
b: { area: 2, population: 1 },
|
||||||
|
};
|
||||||
|
const out = deriveNationResources(
|
||||||
|
['a', 'b'],
|
||||||
|
(k) => k === 'a'
|
||||||
|
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
|
||||||
|
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
|
||||||
|
(k) => g[k],
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- seedRegionStats ---------------------------------------------------------
|
// --- seedRegionStats ---------------------------------------------------------
|
||||||
|
|
||||||
describe('seedRegionStats', () => {
|
describe('seedRegionStats', () => {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
hasCentroids,
|
hasCentroids,
|
||||||
hasGeo,
|
hasGeo,
|
||||||
hasRegionData,
|
hasRegionData,
|
||||||
|
hasResources,
|
||||||
loadRegions,
|
loadRegions,
|
||||||
neighboursOf,
|
neighboursOf,
|
||||||
referenceForKeys,
|
referenceForKeys,
|
||||||
regionName,
|
regionName,
|
||||||
resetRegions,
|
resetRegions,
|
||||||
|
resourceOf,
|
||||||
validKeys,
|
validKeys,
|
||||||
} from './regions';
|
} from './regions';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
@@ -114,6 +116,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
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.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
@@ -172,6 +176,16 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
|
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', () => {
|
describe('graceful degradation with no region data', () => {
|
||||||
beforeEach(() => resetRegions());
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
@@ -191,6 +205,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
// and no region-geo: the per-region stats weighting degrades to uniform
|
// and no region-geo: the per-region stats weighting degrades to uniform
|
||||||
expect(hasGeo()).toBe(false);
|
expect(hasGeo()).toBe(false);
|
||||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||||
|
// and no region-resources: industry boosts use the unscaled base value
|
||||||
|
expect(hasResources()).toBe(false);
|
||||||
|
expect(resourceOf('ain-lqtt')).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,6 +265,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
||||||
expect(hasGeo()).toBe(false);
|
expect(hasGeo()).toBe(false);
|
||||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
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)', () => {
|
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)
|
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||||||
// — the weighting source for the per-region stats model (v0.28, #46)
|
// — 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
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
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. */
|
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||||
interface RegionInfo {
|
interface RegionInfo {
|
||||||
@@ -49,6 +52,8 @@ interface RegionData {
|
|||||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||||||
geo: Record<string, { area: number; population: number }>;
|
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;
|
let data: RegionData | null = null;
|
||||||
@@ -77,6 +82,7 @@ export function loadRegions(root: string): boolean {
|
|||||||
const adjacencyFile = 'region-adjacency.json';
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
const centroidsFile = 'region-centroids.json';
|
const centroidsFile = 'region-centroids.json';
|
||||||
const regionGeoFile = 'region-geo.json';
|
const regionGeoFile = 'region-geo.json';
|
||||||
|
const regionResourcesFile = 'region-resources.json';
|
||||||
const base = [
|
const base = [
|
||||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
@@ -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;
|
return validKeys.size > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[regions] failed to load region data', err);
|
console.error('[regions] failed to load region data', err);
|
||||||
@@ -322,6 +361,29 @@ export function geoOf(key: string): { area: number; population: number } | undef
|
|||||||
return data?.geo[key];
|
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
|
* The union of the region keys that share a land border with any of the given
|
||||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||||
|
|||||||
+32
-1
@@ -13,7 +13,13 @@ export interface StubPayload {
|
|||||||
turn: number;
|
turn: number;
|
||||||
date: string;
|
date: string;
|
||||||
playerNationId: 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).
|
||||||
|
nations: Record<
|
||||||
|
string,
|
||||||
|
{ stats: Record<string, number>; territory?: string[]; nuclear?: boolean }
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||||
pending?: RoundItem[];
|
pending?: RoundItem[];
|
||||||
@@ -67,6 +73,20 @@ export interface StubPayload {
|
|||||||
* covered by unit tests only.
|
* covered by unit tests only.
|
||||||
*/
|
*/
|
||||||
embargoes?: Array<{ from: string; target: string }>;
|
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 {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -123,6 +143,15 @@ function truncate(s: string, n: number): string {
|
|||||||
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
||||||
* the trade & embargo economy path is covered by unit tests only, keeping
|
* the trade & embargo economy path is covered by unit tests only, keeping
|
||||||
* the e2e assertions untouched.
|
* the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
|
||||||
|
* always returns `industry: { projects: [] }`) — the per-region industry
|
||||||
|
* path is covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.37.0, #60) The stub likewise NEVER arms a nation or fires a nuclear
|
||||||
|
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
|
||||||
|
* nuclear / WMD path is covered by unit tests only, keeping the e2e
|
||||||
|
* assertions untouched.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -193,6 +222,8 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
wars: { declare: [], end: [] }, // the stub never declares/ends wars (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)
|
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
||||||
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (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)
|
||||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
IndustryProject,
|
||||||
|
MapMarker,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
NationStats,
|
NationStats,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RegionResources,
|
||||||
RegionStats,
|
RegionStats,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -416,6 +419,105 @@ export interface EconomyOps {
|
|||||||
embargo: EconomyEmbargoOp[];
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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. */
|
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -506,6 +608,35 @@ export interface TurnResult {
|
|||||||
* suspended automatically.
|
* suspended automatically.
|
||||||
*/
|
*/
|
||||||
economy: EconomyOps;
|
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.27, #44) Map-marker ops this turn. `add` places new named markers
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
* (the engine positions each at its region's centroid + a small
|
* (the engine positions each at its region's centroid + a small
|
||||||
@@ -954,3 +1085,49 @@ export function migrateRegionStats(
|
|||||||
}
|
}
|
||||||
return next;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -459,6 +459,8 @@ export function worldDraftToGameState(
|
|||||||
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
||||||
trades: [],
|
trades: [],
|
||||||
embargoes: [],
|
embargoes: [],
|
||||||
|
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ export interface RegionStats {
|
|||||||
population: number;
|
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 const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||||
export type StatKey = (typeof STAT_KEYS)[number];
|
export type StatKey = (typeof STAT_KEYS)[number];
|
||||||
|
|
||||||
@@ -55,6 +72,14 @@ export interface Nation {
|
|||||||
stats: NationStats;
|
stats: NationStats;
|
||||||
territory: string[];
|
territory: string[];
|
||||||
relations: Record<string, RelationStatus>;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Categories a narrative event can take. */
|
/** Categories a narrative event can take. */
|
||||||
@@ -174,6 +199,36 @@ export interface Embargo {
|
|||||||
target: string;
|
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
|
* (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
|
* between two or more nations. Unlike the boolean `war` relation (which a
|
||||||
@@ -356,6 +411,18 @@ export interface GameState {
|
|||||||
* no data wipe.
|
* no data wipe.
|
||||||
*/
|
*/
|
||||||
embargoes: Embargo[];
|
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