Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90e9ae753 | ||
|
|
df52261835 | ||
|
|
a46efcc1cb | ||
|
|
4f8e3534ff | ||
|
|
15a1642eec | ||
|
|
2d17c743f6 | ||
|
|
5cfbce861b | ||
|
|
fc908aaea9 | ||
|
|
fff4163f2c | ||
|
|
7866a1987d | ||
|
|
9d201135b3 | ||
|
|
8730a56170 | ||
|
|
b6dcd3f979 | ||
|
|
31182c8730 | ||
|
|
0fd8981452 | ||
|
|
b237fe8dd4 | ||
|
|
dd9fd6a09d | ||
|
|
b5ea8ba5a4 | ||
|
|
ed91735c8a | ||
|
|
8eeb6df284 | ||
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b | ||
|
|
073e916767 | ||
|
|
396702b9da | ||
|
|
832957c635 | ||
|
|
68402694c5 | ||
|
|
2191514db1 | ||
|
|
5d194636d0 |
@@ -56,7 +56,7 @@ history/
|
||||
├── eslint.config.js # flat config (ESLint 9)
|
||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 407 tests)
|
||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
@@ -67,11 +67,13 @@ history/
|
||||
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
|
||||
│ ├── region-resources.json # region key -> synthesized {oil, iron, food, industry} (each 0–1, 2dp), 1,800 entries (the resource profiles that scale industry boosts + feed the prompt aggregate, #59)
|
||||
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
|
||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
||||
│ │ # → build-region-resources
|
||||
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
|
||||
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
|
||||
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
|
||||
@@ -94,6 +96,15 @@ history/
|
||||
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||
│ ├── presets.ts # load preset JSON files from disk
|
||||
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
|
||||
│ ├── abilities/ # the modular LLM-ability system (v0.40.0 #67) — one file per world lever
|
||||
│ │ ├── index.ts # the ABILITIES registry (list order = apply order) + the re-export surface
|
||||
│ │ ├── types.ts # the Ability contract + the AbilityField union
|
||||
│ │ ├── shared.ts # shared helpers (stat dips / re-derive / ref resolution, …)
|
||||
│ │ ├── apply-helpers.ts # shared apply-path helpers
|
||||
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
|
||||
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63)
|
||||
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 9-ability contract)
|
||||
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
|
||||
│ └── *.test.ts # unit tests (vitest)
|
||||
└── src/
|
||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||||
@@ -131,6 +142,7 @@ history/
|
||||
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
|
||||
| `npm run build:region-resources` | (re)generate `public/region-resources.json` (region key → synthesized `{ oil, iron, food, industry }` 0–1 profile) from `region-keys.json` + `region-geo.json` (area factor) — deterministic + idempotent (byte-identical re-run; #59) |
|
||||
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
|
||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||
|
||||
@@ -327,6 +339,176 @@ history/
|
||||
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
||||
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||
when PR 2, the per-region LLM lever, ships).
|
||||
- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).**
|
||||
Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in
|
||||
`GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||
`[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list
|
||||
(≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`,
|
||||
`declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an
|
||||
optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }`
|
||||
(both required, default `[]`): a **declare** creates a new active `War` (sides resolved via
|
||||
`resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved
|
||||
distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets
|
||||
its `status` to the outcome (`armistice` or `capitulation`). After declare/end,
|
||||
`syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite
|
||||
sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you
|
||||
cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not
|
||||
covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching
|
||||
active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense
|
||||
drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the
|
||||
war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted
|
||||
id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations
|
||||
per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars
|
||||
(as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or
|
||||
ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the
|
||||
path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).**
|
||||
`TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert /
|
||||
hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp =
|
||||
{ type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate |
|
||||
cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM
|
||||
declares its *intended* outcome; the engine applies the deterministic effect).
|
||||
`from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then
|
||||
case-insensitive unique name); a `target` that is not a resolvable nation is treated
|
||||
as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the
|
||||
acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy`
|
||||
(−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips
|
||||
`stability` (−5); `information` nudges the from↔target relation one step apart (capped
|
||||
at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only.
|
||||
A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops
|
||||
never move territory — `regionChanges` is the only lever that does); a nation-target
|
||||
dip lands on every region the nation owns; afterwards the target's derived `stats` is
|
||||
re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership
|
||||
invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended
|
||||
effect is NOT applied; instead the from↔target relation moves one step *toward*
|
||||
`hostile` (capped; `war` is never created or escalated by an exposed op) and a
|
||||
diplomatic/failed event is auto-appended to the turn's `events`.
|
||||
**Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are
|
||||
consumed inside `applyTurnResult` after territory + wars and before markers).
|
||||
`isMeaningful` counts hybrid ops; the system prompt documents the field + per-type
|
||||
effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the
|
||||
user payload carries the player's `relations` map so the model can reason about the
|
||||
relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is
|
||||
unaffected and the path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Trade & embargo economy layer — `GameState.trades` / `GameState.embargoes`, `TurnResult.economy` (v0.35, #58).**
|
||||
`TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required;
|
||||
default `{ trade: [], embargo: [] }`) is the model's lever for the economic layer: a
|
||||
bilateral **trade** (`{ a, b, lift? }` — symmetric, stored **sorted** `a < b`) and a
|
||||
unilateral **embargo** (`{ from, target, lift? }` — ordered). No `lift` declares/imposes,
|
||||
`lift: true` ends/lifts (a no-op when absent). Refs are resolved at apply time by the
|
||||
shared `resolveNationRef` (id first, then case-insensitive unique name); an unknown /
|
||||
ambiguous / self-pairing op is dropped (never thrown). The lists live in `GameState`
|
||||
(`trades`/`embargoes`, required, `[]` at creation; a legacy SQLite row without them loads
|
||||
as `[]` — additive, never wiped, like `wars`/`markers`). **Deterministic, no hidden
|
||||
randomness.** Every committed turn, in `applyTurnResult` (after hybrid, before markers):
|
||||
(1) `applyEconomyOps` applies this turn's declares/lifts to the active lists (idempotent
|
||||
declares — an already-active pair/ordered pair is not duplicated); (2)
|
||||
`suspendEconomyOnWar` **auto-suspends** any trade or embargo whose two nations are on
|
||||
opposite sides of an **active** `War` (#56) — a war severs the economic link, so the
|
||||
suspended pair stops producing its effect (this also drops a trade declared this turn
|
||||
between two nations that just went to war); (3) `applyEconomyEffects` applies the per-turn
|
||||
effect to the **full** active lists: each active trade gives **both** parties a small,
|
||||
deterministic `economy` gain (`TRADE_ECONOMY_BONUS`), each active embargo dips the
|
||||
**target's** `economy` only (`EMBARGO_ECONOMY_PENALTY`) — clamped to 0–100, mirrored to
|
||||
every region the nation owns (`regionStats`), and each affected nation's derived `stats`
|
||||
re-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership
|
||||
is untouched (the layer moves stats, never territory). `isMeaningful` counts non-empty
|
||||
`economy.trade` / `economy.embargo`; the system prompt documents the field + constraints
|
||||
(at most 1–2 ops per turn, narrated in `events`; warring pairs auto-suspended), and the
|
||||
user payload carries the active `trades`/`embargoes` so the model can `lift` them. The
|
||||
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
|
||||
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||
(`server/engine.test.ts`).
|
||||
- **Resources & Industry — per-region resource profiles + the `industry` lever (v0.36, #59).**
|
||||
Since v0.36 each of the 1,800 sub-regions carries a **synthesized 4-value resource profile**
|
||||
(`{ oil, iron, food, industry }`, each 0–1, 2dp) from the committed `public/region-resources.json`
|
||||
— built by the deterministic, idempotent `scripts/build-region-resources.mjs` (an FNV-1a hash of
|
||||
`` `${key}\u0000${resource}` `` scaled by an area factor from `region-geo.json`, clamped to 1,
|
||||
byte-identical re-run) and loaded by `server/regions.ts` via the same optional-manifest pattern as
|
||||
the other manifests (`hasResources()` / `resourceOf()`, probe `public/` then `dist/`; a missing or
|
||||
malformed file degrades — boosts use the unscaled base value and the aggregate is omitted from the
|
||||
prompt, never a crash). `TurnResult.industry = { projects: IndustryProject[] }` (required, default
|
||||
`{ projects: [] }`; `IndustryProject = { regionKey, type }`, `type ∈ INDUSTRY_TYPES =
|
||||
oil | iron | food | industry`) declares a project on a region. `applyTurnResult` maintains
|
||||
`GameState.industry` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||
`[]` — `db.ts`, additive, never wiped) as a **per-region SET**: a region key not in the valid-keys
|
||||
manifest is dropped; a region that already hosts a project is untouched (the first declared type
|
||||
wins — re-declaring is a no-op, the type is never replaced). Every committed turn,
|
||||
`applyIndustryEffects` (placed after the economy block, before markers; the owner is resolved from
|
||||
the FINAL post-claim ownership) grants each active project's region's **SOLE owner** a small,
|
||||
deterministic economy boost — `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's
|
||||
`industry` resource value is ≥ 0.5 (without the manifest, just the base) — clamped to 0–100,
|
||||
mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats`
|
||||
re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). A project on a
|
||||
region with 0 or 2+ owners yields no boost but is **kept** in the list (never removed, never
|
||||
thrown); sole ownership is untouched (the layer moves stats, never territory). `isMeaningful`
|
||||
counts non-empty `industry.projects`; the system prompt documents the field + constraints (a region
|
||||
holds at most one project — never re-declare on a region that already has one; at most 1–2 per
|
||||
turn, each narrated in `events`), and the user payload carries the active projects (`industry`)
|
||||
plus each nation's aggregate resource profile (`resources` — `deriveNationResources` in
|
||||
`shared/protocol.ts`, the area×population-weighted **average** of the owned regions' profiles;
|
||||
omitted entirely when the manifest is not loaded). The **stub never** declares a project
|
||||
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
|
||||
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
|
||||
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
|
||||
- **Modular LLM abilities — how to add a new one (v0.40.0, #67).**
|
||||
Since v0.40.0 the per-feature LLM levers are a modular registry in `server/abilities/`: one
|
||||
file per ability plus `types.ts` (the `Ability<K>` interface, the closed `AbilityField` union,
|
||||
and the `AbilityCtx` / `UserPromptCtx` shapes), `shared.ts` (the dependency-free `str` /
|
||||
`resolveNationRef` / `resolveRegionKey` / fresh-id primitives the abilities share), and
|
||||
`index.ts` (the ordered `ABILITIES` array — its position is the canonical apply / prompt /
|
||||
payload order). Each ability is a plain object implementing `Ability<K>`: `field` (its
|
||||
`TurnResult` field name), `default()` (a FRESH no-op value), `validate(raw)` (defensive parse
|
||||
into the field's exact shape — never throws), `apply(state, data, ctx)` (mutate the world in
|
||||
place — never throws), `schemaLine()` (its system-prompt JSON line), `constraint()` (its HARD
|
||||
CONSTRAINTS bullet), `isMeaningful(data)` (does a non-default value make the turn meaningful?),
|
||||
`stateFields?` (the `GameState` fields it owns, for the `db.ts` legacy-row migration —
|
||||
`undefined` for turn-transient abilities), `stub()` (the deterministic stub's value — every
|
||||
ability returns `default()`, so e2e is never affected), and `payload(ctx)` (the user-payload
|
||||
keys it contributes — `{}` for turn-transient ones). The engine consumes the registry
|
||||
generically and is never edited to add an ability: `buildSystemPrompt` emits each
|
||||
`schemaLine()` / `constraint()` in `ABILITIES` order (between the `nationOps` block/line and the
|
||||
`markers` line / "- Prefer 1-4 events" bullet), `buildUserPrompt` merges each `payload()` at its
|
||||
historical position, `validateTurnResult` fills each field via `a.validate()`, `isMeaningful`
|
||||
ORs the per-ability predicates, `applyTurnResult` runs `a.apply` in `ABILITIES` order (after the
|
||||
core effects, before markers), the stub fills each field via `a.stub()`, and the "unclear"
|
||||
fallback uses `a.default()`. To add a new ability: (1) add the field's type to `TurnResult` in
|
||||
`shared/protocol.ts` (a required field with a default) and the field name to the `AbilityField`
|
||||
union in `types.ts`; (2) create `server/abilities/<name>.ts` exporting an `Ability<'<name'>`
|
||||
const; (3) import it in `index.ts` and APPEND it to `ABILITIES` (append last — never reorder
|
||||
existing entries; their order is pinned by the goldens and the stub's byte-identical output);
|
||||
(4) re-export any new public names from `index.ts` and from `engine.ts`; (5) if it owns a durable
|
||||
`GameState` field, add it to `shared/types.ts` + the `db.ts` legacy-row migration (additive,
|
||||
never wiped) and declare it in `stateFields`; if it contributes to the user payload, return its
|
||||
keys from `payload()` and extend `UserPromptCtx`; (6) write `server/abilities/<name>.test.ts`
|
||||
(the stub never exercises a new ability, so its apply path is unit-tested only) and — if it
|
||||
changes the system prompt — regenerate the goldens
|
||||
(`REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts`); `user.txt` must stay
|
||||
byte-identical unless the new ability contributes user-payload keys.
|
||||
- **Disasters & pandemics — natural / man-made catastrophes with a deterministic dip (v0.41.0, #63).**
|
||||
`TurnResult.disasters = DisastersOps = { events: DisasterEvent[] }` (required, default
|
||||
`{ events: [] }`); `DisasterEvent = { type, target, severity, detail? }` with `type ∈
|
||||
DISASTER_TYPES = earthquake | flood | volcanic | wildfire | famine | plague | epidemic` and
|
||||
`severity ∈ DISASTER_SEVERITIES = minor | moderate | severe`. Like the hybrid layer this is
|
||||
**turn-transient**: it owns NO `GameState` field (no durable list, no `db.ts` change) — a
|
||||
plague or epidemic that persists is simply re-declared each turn it continues. The DETERMINISTIC
|
||||
`DISASTER_EFFECTS` table (`server/abilities/disasters.ts`) maps each `(type, severity)` to a
|
||||
small set of signed stat deltas (a severe plague is the hardest hit; none of them touch
|
||||
`population`, and none move borders — sole ownership is untouched). The `target` resolves like a
|
||||
hybrid op — a nation ref via the shared `resolveNationRef` (id first, then a case-insensitive
|
||||
unique name), else a region key that hits that region's SOLE owner (a key with 0 or 2+ owners, or
|
||||
one absent from the valid-keys manifest, is dropped); there is no `from`/actor field — a
|
||||
catastrophe is not an attack — so (unlike hybrid) a self-target drop does NOT apply.
|
||||
`applyDisasterEvent` (run in the `ABILITIES` loop after territory / wars / economy, before
|
||||
markers) dips the target nation's `stats` (clamped 0–100), mirrors the dip to its regions (the
|
||||
single named region for a region target, else every region it owns — entries with no
|
||||
`regionStats` are skipped), and re-derives the nation's aggregate stats (the same 0-region /
|
||||
no-entry guards, so a 0-region nation keeps its directly-mutated value and the dip persists).
|
||||
`payload()` is `{}` (turn-transient), so the user-prompt golden (`user.txt`) is unchanged; the
|
||||
system prompt gains the field's schema line + constraint bullet (the system goldens were
|
||||
re-baselined in v0.41.0). `isMeaningful` is `events.length > 0`. The stub never proposes a
|
||||
disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
|
||||
only (`server/abilities/disasters.test.ts`).
|
||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||
|
||||
+190
@@ -2,6 +2,196 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.41.0 — 2026-10-16 09:07
|
||||
- **#63: Disasters & pandemics — a `TurnResult.disasters` lever letting the LLM declare natural/man-made disasters (earthquake / flood / volcanic / wildfire / famine / plague / epidemic) on a nation or a region, with a deterministic stat dip (stability / economy / military).** It is a **turn-transient** feature, exactly like the v0.34 `hybrid` layer: **no new `GameState` field, no `db.ts` change, no durable list** — the ops are consumed inside `applyTurnResult` and leave nothing behind. It lands as the **9th ability** of the v0.40.0 modular system, in a new `server/abilities/disasters.ts` module, **registered last** in the `ABILITIES` registry (after `ideology`, before the loop appends `markers` / `nationOps`).
|
||||
- **Types (`shared/protocol.ts`).** New `DisasterType` (`'earthquake' | 'flood' | 'volcanic' | 'wildfire' | 'famine' | 'plague' | 'epidemic'`), `DisasterSeverity` (`'minor' | 'moderate' | 'severe'`), `DisasterEvent` (`{ type; target; severity; detail? }` — `target` is a nation ref **or** a region key), and `DisastersOps` (`{ events: DisasterEvent[] }`); `TurnResult.disasters` is **required**, placed between `ideology` and `markers`, default `{ events: [] }`.
|
||||
- **Ability module (`server/abilities/disasters.ts`).** `field: 'disasters'`; `default()` / `stub()` → a **fresh** `{ events: [] }` each call; `validate` wraps the exported `validateDisasterEvents` (defensive, never throws — accepts the `{ events: [...] }` object or a bare array; each event needs a valid `type`, a valid `severity`, and a non-empty `target` (trimmed, ≤120) — anything else is dropped, with a per-turn cap); `apply` runs `applyDisasterEvent` per event; `schemaLine()` / `constraint()` supply the prompt text; `payload()` → `{}` (no user-payload keys — targets resolve from the existing ref table); `isMeaningful` = `events.length > 0`; **no** `stateFields` (turn-transient, no durable `GameState` field).
|
||||
- **Deterministic effects (`DISASTER_EFFECTS`).** A pure `(type, severity) → stat-dip` table — **no hidden randomness**. `population` is never touched. Earthquake and famine dip **both** `stability` and `economy` (−2 / −4 / −7 for minor / moderate / severe); flood dips `economy` (−2 / −3 / −6) and `stability` (−1 / −2 / −4); volcanic dips `economy` (−2 / −4 / −7) and `stability` (−2 / −3 / −6); wildfire dips **only** `economy` (−2 / −4 / −7); plague dips `stability` (−3 / −5 / −9) and `military` (−2 / −3 / −6); epidemic dips `stability` (−2 / −4 / −7) and `military` (−1 / −2 / −4).
|
||||
- **Apply semantics (`applyDisasterEvent`).** The `target` is resolved exactly like a hybrid op (`resolveHybridTarget`): a resolvable nation ref hits every region the nation owns; a region key hits its **sole** owner; a 0-owner or 2+-owner region key is **dropped** (never thrown). The one deliberate difference from hybrid: a disaster has **no `from` field**, so there is **no self-target drop** — a nation may suffer a disaster it itself declared. The dip is clamped to 0–100, **mirrored to every region the target owns** (`regionStats`), and the target's derived `stats` is **re-derived** afterwards (the same 0-region / no-entry guards as hybrid / economy / industry — a 0-region nation keeps its directly-mutated value). It never moves territory (sole ownership is untouched). The **stub never** declares a disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||
- **Registry & prompt.** `disasters` is added last in `ABILITIES`; the system prompt gains the `disasters` schema line and its constraint bullet ("at most 1–2 per turn, each narrated in `events`"); the user prompt is unchanged (`payload()` is `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `disasters` schema line + constraint bullet); the **`user.txt` golden stays byte-identical** (no new user-payload keys — verified by checksum).
|
||||
- **Tests.** New `server/abilities/disasters.test.ts` (**33 tests**): the type/severity predicate sets; `validateDisasterEvents` (shape / leniency / drops / cap); `applyDisasterEvent` exact dips (nation target, single-region target, region gated by `validKeys`, clamping at 0, 0-owner / 2+-owner region drops, unknown-key drop, unknown / ambiguous ref drops, case-insensitive name resolve, 0-region direct-dip persistence, missing-`regionStats`-entry skip); and the ability const contract (field, no `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload() {}`, `validate` wrapping, `apply` ctx wiring). `registry.test.ts` is updated for the **9-ability** contract (order, fields, `fullDefaultTurn()`, `meaningfulFor`, re-export pin); the goldens are regenerated (verified idempotent).
|
||||
- **Secondary cleanups from the v0.40.0 refactor (folded into this release):**
|
||||
- **AGENTS.md** — `server/abilities/` is added to the Layout tree (the per-ability files, tests, and goldens); Conventions gains a "Modular LLM abilities — how to add a new one (v0.40.0, #67)" bullet and a "Disasters & pandemics (v0.41.0, #63)" bullet.
|
||||
- The **orphaned `validateTakeovers` JSDoc** (left over after the v0.40 refactor removed the function body) is deleted.
|
||||
- Both **inline `as unknown` double-casts** are folded into the single top-level `./abilities` import (`AbilityCtx` + `UserPromptCtx` types).
|
||||
- **`str()` is de-duplicated** — imported from `./abilities/shared` instead of the local copy.
|
||||
- **Guarantees.** No client (`src/`) changes, no new runtime deps, `shared/` keeps zero runtime deps, and no `@turf`/`@types/geojson` in any `.ts`. All unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is disasters-inert).
|
||||
- **Version** bumped `0.40.0 → 0.41.0`.
|
||||
|
||||
## v0.40.0 — 2026-10-16 07:14
|
||||
- **#67: Modular LLM-ability system — the 8 per-feature LLM abilities are extracted from `server/engine.ts` into an ordered `ABILITIES` registry (`server/abilities/`).** A pure, **behavior-preserving** refactor: each per-ability concern (validate / schema-line / constraint / payload / meaningfulness / apply / stub-default / durable-state-field) moves into one file per ability, assembled into a single ordered registry that the engine iterates uniformly. **Nothing observable changes** — the system prompt, user prompt, `applyTurnResult` world-state mutations, and the deterministic stub are all byte-for-byte identical to v0.39.0 (pinned by new golden fixtures + registry tests), and `server/engine.ts` re-exports every name its existing callers import, so `server/index.ts` and all tests work with zero edits.
|
||||
- **New `server/abilities/` package.**
|
||||
- `types.ts` — the `Ability<K extends AbilityField>` interface (a self-contained per-field module: `field`, `default()`, `validate()`, `apply()`, `schemaLine()`, `constraint()`, `isMeaningful()`, `stub()`, `payload()`, optional `stateFields`) plus the `AbilityField` union (the closed set of the 8 fields), `AbilityCtx`, and `UserPromptCtx` type surfaces.
|
||||
- `shared.ts` — the cross-ability pure helpers (`resolveNationRef`, `resolveRegionKey`, `freshNationId`, `freshWarId`, `NEW_NATION_STATS`).
|
||||
- `apply-helpers.ts` — the war-relation sync helpers (`activeWarPairs`, `atWar`, `syncWarRelations`).
|
||||
- One file per ability — `takeovers.ts`, `wars.ts`, `hybrid.ts`, `economy.ts`, `industry.ts`, `nuclear.ts`, `leaders.ts`, `ideology.ts` — each owning its `Ability<K>` const (validator, applier, schema line, constraint bullet, meaningfulness predicate, inert stub default, user-payload keys, and — for wars / economy / industry — the `GameState` fields it owns).
|
||||
- `index.ts` — assembles the ordered `ABILITIES` registry (`takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology`) and re-exports every public name the engine and its tests need.
|
||||
- **Engine (`server/engine.ts`).** Every per-ability code path now iterates `ABILITIES` instead of hand-enumerating the eight fields: `buildSystemPrompt` (schema lines + constraint bullets), `buildUserPrompt` (payload keys at their exact historical positions), `validateTurnResult` (per-field validate), `applyTurnResult` (the ordered apply block), `isMeaningful`, and the `runTurn` "unclear" fallback. The ~1,249 lines of moved ability bodies are deleted; the 21 names its callers import are re-exported from `./abilities` (5 shared primitives + 16 ability-module exports).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` fills the 8 ability fields from the registry (each contributes its inert default) between the core fields and `markers` — the JSON output stays byte-identical to v0.39.0 (verified against `origin/master`).
|
||||
- **Persistence (`server/db.ts`).** The legacy-row migration for ability-owned `GameState` fields (wars / trades / embargoes / industry) iterates the registry's `stateFields` (only-if-undefined) instead of a hardcoded field list.
|
||||
- **Tests.** New `server/abilities/registry.test.ts` (11 tests): the committed **golden prompts** (`server/abilities/goldens/` — system no-tool / system tool / user) are asserted **byte-identical** to the pre-refactor engine's output (a `REGEN_GOLDENS=1` mode regenerates them after an intentional prompt change — verified a no-op); the registry is the canonical 8-ability order with 8 distinct `TurnResult` fields; each `default()` equals the canonical `TurnResult` default and is a **fresh** value (never a shared reference); the default is never "meaningful" while a populated value is; the registry declares exactly the durable `GameState` fields; the registry composes (a hypothetical 9th ability appends cleanly to every uniform loop); and the engine still re-exports every name its callers import.
|
||||
- **Guarantees.** No client (`src/`) changes, no new runtime deps, no `@turf`/`@types/geojson` in any `.ts`, and `shared/` keeps zero runtime deps. All 522 unit tests green; e2e (CI, `LLM_STUB=1`) unaffected.
|
||||
- **Version** bumped `0.39.0 → 0.40.0`.
|
||||
|
||||
## v0.39.0 — 2026-10-15 20:51
|
||||
- **#62: Ideology / form of government — a per-nation `ideology` + a `TurnResult.ideology` lever (coups / revolutions / reforms / constitutional changes) with a deterministic −5 stability dip and political narration.** Each nation may carry a free-form `ideology` label — its form of government ("democracy", "fascism", "military junta", …). The model changes it each turn via `ideology.changes`; each change names a target nation (id or name) and the new government type. The engine sets the label and applies a **deterministic** stability dip (a regime shift always unsettles a nation) — **no hidden randomness**, **additive** (no `db.ts` change — `ideology` is an optional `Nation` field; a pre-v0.39 row loads with it absent, i.e. "no recorded government"). It is a third, independent axis next to `leader` (who is in charge) and the v0.32.0 takeover (which renames the nation): the id, name, territory, relations, and the leader all stay valid — only the form of government and stability move.
|
||||
- **Types (`shared/types.ts`).** `Nation.ideology?: string` (optional; `undefined`/absent = no recorded government).
|
||||
- **Protocol (`shared/protocol.ts`).** New `IdeologyChange` (`{ target; to }` — `target` is a nation ref, `to` is the new form of government), `IdeologyOps` (`{ changes: IdeologyChange[] }`); `TurnResult.ideology` is **required**, placed between `leaders` and `markers`, default `{ changes: [] }`. New exported **`IDEOLOGY_STABILITY_EFFECT = −5`** (the deterministic stability dip of a regime shift). `nationForPrompt` now includes the nation's `ideology` (omitted when absent via `JSON.stringify`), so `buildUserPrompt` carries each nation's current government with no separate wiring.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateIdeologyOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (trimmed, ≤120) and a non-empty, non-whitespace `to` (trimmed, ≤120) — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear / leader ops).
|
||||
- `applyIdeologyChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyLeaderChange` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). On a valid change it sets `n.ideology = to` and applies `IDEOLOGY_STABILITY_EFFECT` (−5) to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry / leaders, so a 0-region nation keeps its directly-mutated value). Re-setting the **same** label is idempotent for the `ideology` field but still narrated and still dips (the shift just happened). Finally it appends a **`political`** narration event (`"<nation>'s government shifted to <to>"`). The change never moves territory (sole ownership is untouched).
|
||||
- `applyTurnResult` — the ideology block runs **after `leaders` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → leaders → **ideology** → markers → nationOps).
|
||||
- `isMeaningful` — a non-empty `ideology.changes` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `ideology` (default `{ changes: [] }`).
|
||||
- `buildSystemPrompt` — documents the `ideology` field (free-form label, the −5 dip, the "at most 1–2 per turn, narrated" rule) and contrasts it with a takeover (which RENAMES the nation) and a leader change (who is in charge); similar forms of government are a **soft, prompt-level compatibility nudge only** (alike ideologies tend to ally/trade, opposed ones tend to clash) — there is no engine gate behind it.
|
||||
- `runTurn` — the "unclear" fallback carries `ideology: { changes: [] }`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `ideology: { changes: [] }` and the `StubPayload` nations type gains an optional `ideology` — the stub **never** changes an ideology, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||
- **Tests.** New unit tests in `engine.test.ts`: `validateIdeologyOps` (`{changes}` object, bare array, empty/whitespace `to` drop, missing/whitespace target drop, malformed top-level, mixed batch keeps only valid, trim + 120-cap); `isMeaningful` (an ideology change is meaningful, empty ideology is not); `applyTurnResult` (a valid change → field set + 50→45 + `political` event + region mirror, other nation untouched; re-setting the same label → one dip, one event; a multi-region nation → every region dipped and stats re-derived; a target resolved by name as well as id; a 0-region nation keeps its directly-dipped stat; a dip clamped at 0; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing); `buildUserPrompt` (a set ideology is stamped in the prompt nations, an absent one is omitted). `protocol.test.ts`: the `IDEOLOGY_STABILITY_EFFECT` constant is −5 and `nationForPrompt` includes/omits the `ideology` key in the JSON. Two `db.test.ts` round-trips: a legacy row with no ideology loads with it absent, and a set ideology round-trips through save/load.
|
||||
- **Version** bumped `0.38.0 → 0.39.0`.
|
||||
|
||||
## v0.38.0 — 2026-10-15 20:06
|
||||
- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader").
|
||||
- **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader).
|
||||
- **Protocol (`shared/protocol.ts`).** New `LeaderCause` (`'assassination' | 'death' | 'appointed'`), `LeaderChange` (`{ target; name; cause }` — `target` is a nation ref, `name` is the new leader's name), and `LeaderOps` (`{ changes: LeaderChange[] }`); `TurnResult.leaders` is **required**, placed between `nuclear` and `markers`, default `{ changes: [] }`. New exported **`LEADER_STABILITY_EFFECT = { assassination: −15, death: −3, appointed: 0 }`** (the deterministic stability dip by cause). `nationForPrompt` now includes the nation's `leader` (omitted when absent), so `buildUserPrompt` carries each nation's current leader with no separate wiring.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateLeaderOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (≤120), a non-empty `name` (≤120), and a `cause` that is one of the three literals — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear ops).
|
||||
- `applyLeaderChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyHybridOp` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). **GATE**: an `assassination`/`death` requires the target to already have a leader (a leaderless target is dropped); `appointed` is always valid (it creates the first leader). On a valid change it sets `n.leader = { name }` and applies `LEADER_STABILITY_EFFECT[cause]` to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry, so a 0-region nation keeps its directly-mutated value). Finally it appends a narration event: a dramatic **`military`** event for an assassination, a **`political`** succession event for a death or an appointment. The replacement never moves territory (sole ownership is untouched).
|
||||
- `applyTurnResult` — the leader block runs **after `nuclear` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → **leaders** → markers → nationOps).
|
||||
- `isMeaningful` — a non-empty `leaders.changes` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `leaders` (default `{ changes: [] }`).
|
||||
- `buildSystemPrompt` — documents the `leaders` field (the three causes, the stability effects, the leaderless-gate, the "at most 1–2 per turn, narrated" rule) and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement) in both tool and plain modes.
|
||||
- `runTurn` — the "unclear" fallback carries `leaders: { changes: [] }`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader` — the stub **never** changes a leader, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||
- **Tests.** New unit tests in `engine.test.ts`: `validateLeaderOps` (`{changes}` object, bare array, unknown-cause drop, missing target/name drop, malformed top-level, mixed batch keeps only valid); `isMeaningful` (a leader change is meaningful, empty leaders is not); `applyTurnResult` (assassination with a leader → replaced + −15 + `military` event; assassination / death on a leaderless nation → dropped, no stat change, no event; death → replaced + −3 + `political` succession event; appointment on a leader-having and on a leaderless nation → sets the leader, neutral (no dip); a multi-region nation → every region dipped and stats re-derived; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing). Two `db.test.ts` round-trips: a legacy row with no leader loads with it absent, and a set leader round-trips through save/load.
|
||||
- **Version** bumped `0.37.0 → 0.38.0`.
|
||||
|
||||
## v0.37.0 — 2026-10-15 19:15
|
||||
- **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent).
|
||||
- **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn.
|
||||
- **Types (`shared/types.ts`).** `Nation.nuclear?: boolean` (optional; `undefined`/absent = not armed; default `false` behavior).
|
||||
- **Protocol (`shared/protocol.ts`).** New `NuclearStrike` (`{ from; target }` — `from` is the striking nation, `target` is a nation ref or a region key) and `NuclearOps` (`{ arm: string[]; strikes: NuclearStrike[] }`); `TurnResult.nuclear` is **required**, placed between `industry` and `markers`, default `{ arm: [], strikes: [] }`. New exported `NuclearStatKey` (`'economy' | 'military' | 'stability'`) and the two deterministic collapse tables: **`NUCLEAR_COLLAPSE = { economy: −40, military: −50, stability: −40 }**` (a full strike's effect on the struck nation / region owner) and **`NUCLEAR_RETALIATION = { economy: −20, military: −25, stability: −20 }`**` (the half-strength MAD backfire).
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateNuclearOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ arm: [], strikes: [] }`; `arm` entries are non-empty strings (≤120 chars) deduped; a strike needs a non-empty `from` **and** `target` (≤120) and is **dropped when it targets itself** (same ref, case-insensitive) — a self-strike is a no-op, never thrown.
|
||||
- `applyNuclearOps(next, ops, state, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — new exported, the load-bearing nuclear block. **`arm` first** (idempotent; a resolve miss or a self-arm is a no-op), then **`strikes`** in order: resolve `from` (an unresolvable or non-capable striker is **dropped** — capability checked against the *pre-turn* marker set plus any flag granted this turn); a `target` that is a **nation** collapses it (the `NUCLEAR_COLLAPSE` deltas applied to its `stats` and every region it owns, each affected nation's derived stats re-derived, 0-region / no-entry guards as elsewhere), ends **every active war it fights** by **capitulation** (`endWarsBetween` captures the active pairs *before* ending so a war declared+ended the same turn is handled), and downgrades the now-inactive `war` relations to `hostile`; a `target` that is a **region key** releases it to no man's land (a key with 0 or 2+ owners, or one the striker itself owns, is **dropped**), deletes its `regionStats` entry, and collapses its **sole owner** the same way. **MAD**: if the struck nation (or the struck region's sole owner) is itself nuclear-capable, it retaliates against the striker for the half-strength `NUCLEAR_RETALIATION` (clamped, mirrored, re-derived) and the two nations' relations are forced to `hostile` (`forceHostile` — both directions, unless already `war`). Each strike and each retaliation appends a dramatic **`military`** event (`war` is not a valid `EventType`). Sole ownership is never broken by a strike (a region is released, not re-claimed) and the block never throws.
|
||||
- `applyTurnResult` — the nuclear block runs **after `industry` and before `markers`** (order: events → relations → territory → stateChanges → recompute → wars → hybrid → economy → industry → **nuclear** → markers → nationOps); it passes the *original* (pre-turn) `state` for the capability marker set and the current `regionNameOf` for event text.
|
||||
- `isMeaningful` — a non-empty `nuclear.arm` or `nuclear.strikes` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `nuclear` (default `{ arm: [], strikes: [] }`).
|
||||
- `buildSystemPrompt` — documents the `nuclear` field and a constraint bullet (the capability rule; `arm` is idempotent; a strike hits a nation or a region; the collapse + capitulation + MAD outcomes; at most one strike per turn, always narrated) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `nuclear` param (`Record<string, boolean>`, the per-nation capability map); when provided, each `state.nations` entry in the payload is stamped with its `nuclear` capability; when undefined the key is omitted entirely (stub / no-capability prompt stays byte-identical). `runTurn` always supplies the map (computed from `isNuclearCapable` over `state.markers`).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `nuclear: { arm: [], strikes: [] }` — the stub **never** arms a nation or fires a strike, so the e2e (CI, `LLM_STUB=1`) is unaffected and the nuclear path is covered by unit tests only.
|
||||
- **Tests.** 29 new unit tests: `isNuclearCapable` (flag / own rocket / non-rocket / other's rocket / null owner); `validateNuclearOps` (well-formed, `arm` dedupe, missing `from`/`target`, self-target drop, malformed top-level); `applyTurnResult` nuclear (arm idempotency across turns, arm+strike same turn, region strike → release + stats deleted + owner collapse + no MAD + the single `military` event, nation strike + pre-existing war → capitulation + hostile, nation strike + same-turn declare → capitulation + hostile, MAD on a capable nation target, MAD on a capable region owner, rocket-marker-only capability, non-capable striker dropped, 2+-owner region dropped, 0-owner/unowned region dropped, self-target dropped, malformed strikes never throw); `isMeaningful` (arm / strike); `buildUserPrompt` (per-nation capability stamped when the map is given, omitted when undefined); and two `db.test.ts` round-trips (a legacy row with no flag loads with it absent; a set flag round-trips through save/load).
|
||||
- **Version** bumped `0.36.0 → 0.37.0`.
|
||||
|
||||
## v0.36.0 — 2026-10-15 18:17
|
||||
- **#59: Resources & Industry — per-region synthesized resource profiles + a per-region industry-project lever with a deterministic per-turn economy boost.** Every prior lever moved borders, dipped stats one-shot, or proposed a player-confirmed change; this version adds a **resource abundance layer** (every sub-region gets a synthesized 4-value profile) and a **development lever**: the model can build industry projects (oil facilities, iron mines, food operations, factories) in individual regions, and every committed turn each active project pays its region's sole owner a small, deterministic economy gain scaled by the region's `industry` resource value. The engine is **fully deterministic (no hidden randomness)**: the resource profiles are a pure function of the region key (FNV-1a) scaled by an area factor, and the per-turn effect is a fixed, clamped, mirrored, re-derived stat adjustment.
|
||||
- **Manifest (`public/region-resources.json`, new).** 1,800 entries — region key → `{ oil, iron, food, industry }` (each 0–1, 2dp) — built by the new turf-free, deterministic, **idempotent** `scripts/build-region-resources.mjs` (`npm run build:region-resources`): per resource, `(fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000` → [0,1), times an area factor `1 + 0.2·min(1, log10(1+area)/8)` (max ≈1.2) from `region-geo.json` (missing geo / zero area → pure hash), clamped to 1, keys sorted, byte-identical re-run. Pinned by `scripts/build-region-resources.test.ts` (key-set equality vs `region-keys.json`, sorted order, per-entry shape, spot-checked values, determinism via a re-run).
|
||||
- **Types (`shared/types.ts`).** New `RegionResources` (`{ oil; iron; food; industry }`), `IndustryType` (`'oil' | 'iron' | 'food' | 'industry'`) + `INDUSTRY_TYPES` const, `IndustryProject` (`{ regionKey; type }`), and the required `GameState.industry: IndustryProject[]` field (placed after `embargoes`). `[]` on a fresh game; rows saved before v0.36 load with it defaulted to `[]` (additive, never wiped).
|
||||
- **Protocol (`shared/protocol.ts`).** New `IndustryOps` (`{ projects: IndustryProject[] }`); `TurnResult.industry` is required, placed between `economy` and `markers`. New exported `deriveNationResources(regionKeys, resourceOf?, geoOf?)` — a nation's aggregate profile as the **area×population-weighted AVERAGE** (an average, not a median, is the meaningful aggregation for an abundance profile) of the resource profiles of its owned regions, same weight formula as `deriveNationStats`, 2dp-rounded, zero profile when no owned region has a profile.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `isIndustryType(t)` — exported guard over the four types (mirrors `isHybridOpType`).
|
||||
- `validateIndustryOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ projects: [] }`; each op needs a non-empty `regionKey` (≤120 chars) and a known `type`.
|
||||
- `industryBoost(regionKey, resourceOf?)` — internal; `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's `industry` value is ≥ 0.5 (without the manifest, just the base).
|
||||
- `applyIndustryOps(next, ops, validKeys?)` — internal; applies the turn's projects to `GameState.industry` as a **per-region SET** — a key not in the valid-keys manifest is dropped; a region that already hosts a project is untouched (first type wins; re-declare is a no-op, the type is never replaced).
|
||||
- `applyIndustryEffects(next, resourceOf?, geoOf?)` — new exported; the per-turn effect, applied **every committed turn** to the **full** active list: each project grants its region's **SOLE owner** (owner resolved from the FINAL post-claim ownership; 0 or 2+ owners → no boost, project **kept**, never thrown) a deterministic economy boost, clamped to 0–100, mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). Sole ownership is untouched (the layer moves stats, never territory). No-op when there are no active projects; never throws.
|
||||
- `applyTurnResult` — new `resourceOf` param (last) and **industry block** (after the economy block, before markers): `applyIndustryOps` → `applyIndustryEffects` (ops first, so a project declared this turn is paid this turn).
|
||||
- `isMeaningful` — non-empty `industry.projects` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `industry` (default `{ projects: [] }`).
|
||||
- `buildSystemPrompt` — documents the `industry` field and a constraint bullet (a per-resource development lever; one project per region — never re-declare on a region that already has one; at most 1–2 per turn, each narrated in `events`) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `nationResources` (each nation's aggregate profile) and `industryProjects` (the active projects) params — emitted as `resources` / `industry` in the payload, only when provided (so the stub / no-manifest prompt stays byte-identical).
|
||||
- `runTurn` — new optional `resourceOf` (last); computes `nationResources` via `deriveNationResources` **only when** the resource manifest is loaded (otherwise omitted from the prompt); passes both new payload fields; the "unclear" fallback carries `industry: { projects: [] }`; `applyTurnResult` receives `resourceOf`.
|
||||
- **Regions loader (`server/regions.ts`).** The optional-manifest pattern extended: `region-resources.json` is probed after `public/` then `dist/`; malformed entries (any of the four values not a finite number in [0,1]) are filtered out, a failure logs `[regions] failed to load region-resources.json, continuing without it` and the server keeps working without it; new `hasResources()` / `resourceOf(key)` accessors (mirror `hasGeo()` / `geoOf()`).
|
||||
- **Server wiring (`server/index.ts`).** The jump route passes `resourceOf: hasResources() ? (key) => resourceOf(key) : undefined` into `runTurn` (like `geoOf`); fresh preset games are created with `industry: []` (dynamic games already via `shared/scenario.ts`).
|
||||
- **Persistence (`server/db.ts`).** `load()` defaults `industry` to `[]` on rows saved before v0.36 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`/`trades`/`embargoes`) — legacy games are never wiped and never gain phantom projects.
|
||||
- **Stub (`server/stub.ts`).** The deterministic stub returns `industry: { projects: [] }` — it **never** declares an industry project, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only. `StubPayload` gains optional `resources` / `industry` fields (ignored).
|
||||
- **Unit tests.** New: `scripts/build-region-resources.test.ts` (5 tests); `server/regions.test.ts` (resources load/spot-check/unknown-key, degradation, prod `dist/` layout, incl. a new "with the manifest" case); `server/protocol.test.ts` — `deriveNationResources` (7 tests: area×population weighting, unweighted degradation, profile-skipping, zero profile, single region, weight fallback, 2dp rounding); `server/db.test.ts` — legacy-row default; `server/engine.test.ts` — `validateIndustryOps` (shape/drops), `applyTurnResult` industry ops (declare, per-region SET / re-declare no-op, invalid-key drop, persistence across turns), `applyIndustryEffects` (sole-owner boost, ≥0.5 scaling, multi-project accumulation, ownerless/2+ owner kept-but-unpaid, no-op empty), `isMeaningful`, the system-prompt contract (both modes), the user-prompt payload (carried / omitted), and the `runTurn` defensive init; all pre-existing fixtures gained the new required fields (mechanical, deliberate updates).
|
||||
- **Client.** No change — the industry layer is an engine/LLM concern; its effects surface through the existing stats and event feed.
|
||||
- **AGENTS.md.** New convention entry documenting the resource manifest, the per-region SET semantics, the sole-owner per-turn boost, and the stub/test coverage; the `public/` layout, the scripts pipeline line, and the Commands table all gained `region-resources` / `build:region-resources`.
|
||||
|
||||
## v0.35.0 — 2026-10-15 17:22
|
||||
- **#58: Trade & Embargoes — a bilateral trade + embargo economy layer with a deterministic per-turn effect and war auto-suspension.** Every prior turn lever moved borders, dipped stats one-shot, or proposed a player-confirmed change. This version adds `TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required, default `{ trade: [], embargo: [] }`) so the model can declare/lift **bilateral trade agreements** and **unilateral embargoes**, which persist in `GameState` and apply a small, deterministic economic effect **every committed turn**. The engine is **fully deterministic (no hidden randomness)** and **additive** — declares add to the lists, lifts remove, and nothing else (not even a war, beyond the suspension below) mutates them.
|
||||
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
|
||||
- **Protocol (`shared/protocol.ts`).** New `EconomyTradeOp` (`{ a; b; lift? }`), `EconomyEmbargoOp` (`{ from; target; lift? }`), and `EconomyOps` (`{ trade; embargo }`) types. `TurnResult.economy` is required, placed between `hybrid` and `markers`; a no-`lift` op declares/imposes, `lift: true` ends/lifts.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateEconomyOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ trade: [], embargo: [] }`; each op needs non-empty `a`/`b` (or `from`/`target`) refs (trimmed, ≤200 chars) and a boolean `lift` (default false). Raw refs are kept — resolution happens at apply time against the real state (like wars/hybrid ops).
|
||||
- `applyEconomyOps(next, ops)` — internal; resolves each op's refs via `resolveNationRef` (id first, then unique case-insensitive name). An unresolvable / ambiguous / self-pairing op is dropped (never thrown). A no-`lift` trade **declares** the agreement idempotently (stored sorted, so `{a,b}` and `{b,a}` are the same pair); `lift: true` ends it (a no-op when absent). A no-`lift` embargo **imposes** the ordered from→target pair idempotently; `lift: true` lifts it.
|
||||
- `suspendEconomyOnWar(next)` — internal; any active trade or embargo whose two nations are on **opposite sides of an active war** (v0.33, #56) is removed — a war severs the economic link, so the suspended agreement stops producing its per-turn effect from now on. Runs **after** the turn's war declare/end ops (so it sees the final `next.wars`, including a war declared this turn) and **before** the per-turn effect.
|
||||
- `applyEconomyEffects(next, geoOf?)` — internal; the per-turn effect, applied **every committed turn** to the **full** active lists (not just this turn's declares): each active trade grants **both** parties a small, deterministic economy gain (`TRADE_ECONOMY_BONUS`); each active embargo applies a small, deterministic economy dip to the **target only** (`EMBARGO_ECONOMY_PENALTY`). Clamped to 0–100, mirrored to every region the nation owns (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region/no-entry guards as the hybrid dip). No-op when there are no active trades or embargoes; never throws.
|
||||
- `applyTurnResult` — new **economy block** (after the hybrid block, before markers): `applyEconomyOps` → `suspendEconomyOnWar` → `applyEconomyEffects`, in that order (ops first, so a same-turn trade declared between two nations that just went to war is suspended; effects then run on the surviving full lists). Sole ownership is untouched (the economy layer moves stats, never territory).
|
||||
- `isMeaningful` — a non-empty `economy.trade` or `economy.embargo` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `economy` (default `{ trade: [], embargo: [] }`).
|
||||
- `buildSystemPrompt` — documents the `economy` field and a constraint bullet (bilateral trade gains both parties; a unilateral embargo hits the target only; omit `lift` to declare/impose, `lift: true` to end/lift; at most 1–2 ops per turn, narrated in `events`; trades/embargoes between warring nations are auto-suspended) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `trades` and `embargoes` params (the currently active agreements/embargoes) so the model knows what is active and can `lift` it; emitted only when provided.
|
||||
- `runTurn` — passes `state.trades` and `state.embargoes` to `buildUserPrompt`; the "unclear" fallback carries `economy: { trade: [], embargo: [] }`.
|
||||
- **Stub (`server/stub.ts`).** The deterministic stub returns `economy: { trade: [], embargo: [] }` — it **never** declares or lifts a trade or embargo, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only (`server/engine.test.ts`).
|
||||
- **Persistence (`server/db.ts`).** `load()` defaults `trades`/`embargoes` to `[]` on rows saved before v0.35 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`) — legacy games are never wiped and never gain phantom trades/embargoes.
|
||||
- **Game creation (`server/index.ts`, `shared/scenario.ts`).** Fresh games (preset and dynamic) are created with `trades: []` and `embargoes: []`.
|
||||
|
||||
## v0.34.0 — 2026-10-15 16:35
|
||||
- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted).
|
||||
- **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `isHybridOpType(t)` — exported guard over the seven types.
|
||||
- `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300.
|
||||
- `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect.
|
||||
- `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire.
|
||||
- `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path.
|
||||
- `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only.
|
||||
- `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership).
|
||||
- `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`).
|
||||
- `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided.
|
||||
- `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`.
|
||||
- **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid.
|
||||
- **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed.
|
||||
- **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage.
|
||||
|
||||
## v0.33.0 — 2026-10-15 15:45
|
||||
- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally.
|
||||
- **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||
- **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400.
|
||||
- `activeWarPairs(wars): Set<string>` — NUL-joined sorted pair keys for active-only wars.
|
||||
- `atWar(wars, a, b)` — convenience check.
|
||||
- `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created).
|
||||
- `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`).
|
||||
- `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`.
|
||||
- `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful.
|
||||
- `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs).
|
||||
- `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id.
|
||||
- `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`.
|
||||
- **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars.
|
||||
- **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync).
|
||||
- **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage.
|
||||
|
||||
## v0.32.0 — 2026-10-15 10:56
|
||||
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.31.0 — 2026-10-15 10:22
|
||||
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||
- **Shared contract (`shared/scenario.ts`, dependency-free).** `EraTemplate` / `TemplateMeta` / `SuggestedPower`; `WorldDraft = { nations: WorldNation[]; events? }` where `WorldNation.territory` holds **ISO_A3 country codes** (not region keys) and `relations` are numeric sentiments (−100..100); `Checkpoint = string`; the four message shapes (`ScenarioDraftRequest/Response`, `ScenarioCommitRequest/Response`). Pure transforms: `validateWorldDraft(raw, { validCountryCodes })` — defensive parse/clamp/normalize that never throws (name-required, id kept-or-slug fallback, duplicate ids suffixed, territory upper-cased/deduped and filtered to known ISO_A3 codes when a valid set is given, relations re-keyed to assigned ids via id or case-insensitive name match with self/unknown refs dropped, optional backstory events kept only when they name a known nation; returns `null` when the result is unusable — no nations, or no valid territory to render); `relationFromScore` (≥60 ally · 0..59 neutral · −40..−1 rival · −80..−41 hostile · ≤−81 war); `worldDraftToGameState(draft, expand, playerNationId, opts)` — expands ISO_A3 territory to region keys via the injected `expand` (`expandCountriesToKeys` in production), seeds `regionStats` (so each owned region starts with its owner's stats and the derived aggregate equals them on turn 1), a clean turn-1 state (`version:1`, `turn:1`, empty `unowned`/`markers`/`pendingNationOps`/`pending`), `presetId: 'dynamic'`, and a guard that re-points a dangling `playerNationId` to the first nation so the game is always playable.
|
||||
- **Scenario LLM steps (`server/scenario.ts`).** Two plain `chat()` calls (no tools, no separate advisor endpoint — the advisor is embodied in step 1's checkpoint list): `generateDraft({ template, description, year })` → `{ world, checkpoints }` (the prompt carries the era description, suggested powers, tensions, the user's description, the year, and the valid ISO_A3 reference), and `finalizeWorld({ …, approvedCheckpoints, draft, playerNationId })` → final `GameState` (re-asked to produce the final world constrained to the approved checkpoints, based on the step-1 draft the client re-sends so the chosen nation survives; falls back to the draft itself when the model returns nothing usable). Same defensive parsing as the engine (strip fences → first balanced object → `JSON.parse` → `validateWorldDraft`), one nudge-retry when the answer is unusable; a transport failure (model down) throws → HTTP 502, a bad *answer* degrades to the fallback and never crashes.
|
||||
- **Scenario stub (`server/stubScenario.ts`).** Deterministic transport for the two scenario calls only (discriminated by `SCENARIO_GENERATE_MARKER` / `SCENARIO_REGENERATE_MARKER` in the system message, mirroring how the turn stub keys off `ADVISOR_MARKER`): a fixed 3-nation world (Alba/FRA 70·60·65·55, Boreas/RUS 55·70·50·60, Gamma/ESP 30·45·70·40) + one backstory event ("The new order takes shape") + a fixed 3-checkpoint list; the finalize step refines the world deterministically from the approved checkpoints (each naming a nation by its 4-letter name prefix raises that nation's stability +5, clamped) — so "finalize respects the approved checkpoints" is observable end-to-end. The turn stub stays 100% unchanged.
|
||||
- **Routes (`server/index.ts`).** `GET /api/scenarios/templates` (the 3 manifest entries), `POST /api/scenarios/draft` (`{ templateId, description, year? }` → `{ world, checkpoints }`), `POST /api/scenarios/commit` (`{ templateId, description, year?, draft?, approvedCheckpoints, playerNationId }` → `{ id }` — the finalized world is saved to SQLite with `startDate = <year>-01-01` and title = the template name). A separate `scenarioLlm` client (the scenario stub under `LLM_STUB=1`, the same live `qwen3.8-27b` client otherwise) — the turn `llm` is untouched. `POST /api/games/:id/jump` synthesizes a minimal `Preset` (`dynamicPresetFromGame`) for a saved dynamic game (`presetId: 'dynamic'` has no static file on disk — `runTurn` only consumes title/startDate/worldBackground/rules) so a created scenario plays with the normal turn engine; a genuinely-missing static preset still 404s.
|
||||
- **Client.** New `ScenarioForm` (3 template cards, a pre-filled editable year, a description textarea, Generate → `/draft`; a 502 toasts and stays on the form) and `CheckpointReview` (the draft world as nation cards with stats, the checkpoint checklist with per-item approve/remove, a pick-a-nation-to-lead step, Finalize → `/commit` → navigates to `#/game/<id>`; a 502 toasts and stays on review). `PresetPicker` gains a "Create scenario" card, and `App.tsx` wires the `home → form → review → navigate` flow (a distinct `scenarioBusy` flag that never gates "Jump forward" or the turn flow).
|
||||
- **Tests.** Unit: the template store (manifest + all 3 files' shape); `validateWorldDraft` / `relationFromScore` / `worldDraftToGameState` (valid → clean, stat/score clamping, bad country codes dropped, garbage → null without throwing, id dedup, territory expansion, `regionStats` seeding, player fallback, turn-1 invariants); `generateDraft` / `finalizeWorld` against the scenario stub (deterministic output, finalize respecting the approved checkpoints, a saved playable game). E2E: a new `e2e/scenario.spec.ts` (2 tests — the templates manifest over the API, and the full deterministic flow under `LLM_STUB=1`: near-future (2035) + description → draft → remove one checkpoint → lead Boreas → finalize (checkpoints shift stats) → a playable game at the `#/game/<id>` deep link with turn 1, correct date, dossier stats, map legend, and the backstory event; plus an empty jump proving the dynamic game runs the normal turn loop). The existing `e2e/sim.spec.ts` is unchanged. Totals: **307 unit tests (14 files)**, **16 e2e tests (2 scenario + 14 sim)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.30.0 — 2026-10-15 08:22
|
||||
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
|
||||
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic
|
||||
* under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so
|
||||
* the two generation calls ride the deterministic transport in
|
||||
* server/stubScenario.ts — the turn stub in server/stub.ts is untouched).
|
||||
*
|
||||
* The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for
|
||||
* the generate step, and — on the finalize step — refines that world in a
|
||||
* deterministic way that depends on which checkpoints the player approved
|
||||
* (each approved checkpoint naming one of the fixed nations, by its first 4
|
||||
* letters, raises that nation's stability by 5). So the whole two-step
|
||||
* "Create scenario" path plus the resulting game is reproducible:
|
||||
*
|
||||
* home → "Create scenario" → pick the Near Future era (2035) + a description
|
||||
* → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the
|
||||
* 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved,
|
||||
* playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump
|
||||
* (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no
|
||||
* territory change — Russia shares no land border with France or Spain).
|
||||
*
|
||||
* The e2e webServer shares one SQLite (data/e2e) across tests, so the test
|
||||
* captures the *specific* id of the game it creates (from the POST
|
||||
* /api/scenarios/commit response) rather than assuming "the only game".
|
||||
*/
|
||||
|
||||
/** The saved-game shape the assertions read back from GET /api/games/<id>. */
|
||||
interface SavedGame {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
version: number;
|
||||
pending: unknown[];
|
||||
unowned: string[];
|
||||
pendingNationOps: unknown[];
|
||||
markers: unknown[];
|
||||
events: Array<{ type: string; outcome: string; title: string }>;
|
||||
nations: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
stats: { military: number; economy: number; stability: number; population: number };
|
||||
territory: string[];
|
||||
relations: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
regionStats: Record<
|
||||
string,
|
||||
{ military: number; economy: number; stability: number; population: number }
|
||||
>;
|
||||
}
|
||||
|
||||
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
|
||||
async function waitHash(page: Page, expected: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(exp) => window.location.hash === exp,
|
||||
expected,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the "Create scenario" form from the home screen. */
|
||||
async function openScenarioForm(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
// Home: the create-scenario card is additive (first in the grid); the five
|
||||
// static presets are still all present alongside it.
|
||||
await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('preset-card')).toHaveCount(5);
|
||||
await page.getByTestId('create-scenario').click();
|
||||
await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => {
|
||||
test('the era template manifest renders three light templates', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await openScenarioForm(page);
|
||||
|
||||
// Exactly three era-template cards, each carrying its id and default year.
|
||||
const cards = page.getByTestId('template-card');
|
||||
await expect(cards).toHaveCount(3, { timeout: 15000 });
|
||||
await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939');
|
||||
await expect(page.locator('[data-template-id="present"]')).toContainText('2026');
|
||||
await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035');
|
||||
});
|
||||
|
||||
test('full create → review → finalize → play flow is deterministic', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await openScenarioForm(page);
|
||||
|
||||
// --- 1 · Pick the Near Future era (its default year pre-fills the field).
|
||||
const nf = page.locator('[data-template-id="near-future"]');
|
||||
await nf.click();
|
||||
await expect(nf).toHaveAttribute('data-selected', 'true');
|
||||
// The year field is pre-filled from the era (editable, a real value).
|
||||
await expect(page.getByTestId('scenario-year')).toHaveValue('2035');
|
||||
|
||||
// --- 2 · Describe the world.
|
||||
const description =
|
||||
'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.';
|
||||
await page.getByTestId('scenario-description').fill(description);
|
||||
|
||||
// --- Step 1 · Generate the draft world + the advisor's checkpoint list.
|
||||
const generate = page.getByTestId('generate-scenario');
|
||||
await expect(generate).toBeEnabled();
|
||||
const [draftResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft',
|
||||
),
|
||||
generate.click(),
|
||||
]);
|
||||
expect(draftResp.status()).toBe(200);
|
||||
const draft = (await draftResp.json()) as {
|
||||
world: {
|
||||
nations: Array<{ id: string; name: string; territory: string[] }>;
|
||||
};
|
||||
checkpoints: string[];
|
||||
};
|
||||
// The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints.
|
||||
expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']);
|
||||
expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']);
|
||||
expect(draft.checkpoints).toHaveLength(3);
|
||||
|
||||
// --- Review screen: the generated world + the checkpoint checklist.
|
||||
await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 });
|
||||
// Header shows the era's name + year and the description.
|
||||
await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible();
|
||||
await expect(page.getByTestId('scenario-review')).toContainText('2035');
|
||||
await expect(page.getByTestId('scenario-review')).toContainText(description);
|
||||
// The three draft nations (with their ISO_A3 territory) and the backstory.
|
||||
// Scope to the draft-nation cards (the nation-pick cards share the
|
||||
// data-nation-id attribute but carry a different testid).
|
||||
await expect(page.getByTestId('draft-nation')).toHaveCount(3);
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA');
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS');
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP');
|
||||
await expect(page.getByTestId('draft-event')).toHaveCount(1);
|
||||
// Three checkpoints, all approved by default, in the stub's fixed order.
|
||||
const cps = page.getByTestId('checkpoint-item');
|
||||
await expect(cps).toHaveCount(3);
|
||||
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(0)).toContainText('Alba dominates');
|
||||
await expect(cps.nth(1)).toContainText('Boreas fortifies');
|
||||
await expect(cps.nth(2)).toContainText('Gamma brokers');
|
||||
// Three nation-pick cards; finalize is disabled until a nation is chosen.
|
||||
await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3);
|
||||
await expect(page.getByTestId('finalize-scenario')).toBeDisabled();
|
||||
|
||||
// --- Review: remove checkpoint 3, keep 1 + 2.
|
||||
await cps.nth(2).getByTestId('checkpoint-toggle').click();
|
||||
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false');
|
||||
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved');
|
||||
|
||||
// --- Review: choose Boreas (Russia) to lead.
|
||||
const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]');
|
||||
await boreasCard.getByTestId('scenario-lead-nation').click();
|
||||
await expect(boreasCard).toHaveAttribute('data-selected', 'true');
|
||||
await expect(page.getByTestId('finalize-scenario')).toBeEnabled();
|
||||
|
||||
// --- Step 2 · Finalize: commit the approved draft into a saved game.
|
||||
const [commitResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit',
|
||||
),
|
||||
page.getByTestId('finalize-scenario').click(),
|
||||
]);
|
||||
expect(commitResp.status()).toBe(200);
|
||||
const { id } = (await commitResp.json()) as { id: string };
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
// --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('2035-01-01');
|
||||
await expect(page.getByTestId('player-nation')).toHaveText('Boreas');
|
||||
// The scenario title (the era name) is shown in the top bar.
|
||||
await expect(page.getByTestId('top-bar')).toContainText('Near Future');
|
||||
// Boreas's stats: the finalized world (its approved checkpoint raised
|
||||
// stability 50→55); per-region stats are seeded to equal the derived.
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('55');
|
||||
await expect(page.getByTestId('stat-economy')).toHaveText('70');
|
||||
await expect(page.getByTestId('stat-stability')).toHaveText('55');
|
||||
await expect(page.getByTestId('stat-population')).toHaveText('60');
|
||||
// One backstory event; three nations on the legend.
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible();
|
||||
// The deep link lands on this exact game.
|
||||
await waitHash(page, `#/game/${id}`);
|
||||
|
||||
// --- One empty jump: the world advances on its own (the turn stub).
|
||||
// The button sits at the bottom of a scrollable window body and may be
|
||||
// outside the viewport; a native DOM click bypasses the viewport check.
|
||||
await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click());
|
||||
|
||||
// Turn 2, date +2 days, Boreas military 55→60.
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 });
|
||||
await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 });
|
||||
// Two events now (the empty-round "world moves on" on top, then the
|
||||
// backstory); both political/neutral.
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
||||
await expect(page.getByTestId('event-item').first()).toContainText('The world moves on');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
||||
await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape');
|
||||
// No territory change: Russia shares no land border with France or Spain,
|
||||
// so the frontier is empty and no conquest is emitted.
|
||||
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||
|
||||
// --- The saved game is a coherent dynamic GameState on the server.
|
||||
const saved = await page.evaluate(async (gid) => {
|
||||
const r = await fetch(`/api/games/${gid}`);
|
||||
return { status: r.status, body: (await r.json()) as { game: SavedGame } };
|
||||
}, id);
|
||||
expect(saved.status).toBe(200);
|
||||
const g = saved.body.game;
|
||||
expect(g.presetId).toBe('dynamic');
|
||||
expect(g.presetTitle).toBe('Near Future');
|
||||
expect(g.playerNationId).toBe('boreas');
|
||||
expect(g.turn).toBe(2);
|
||||
expect(g.date).toBe('2035-01-03');
|
||||
expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump
|
||||
expect(g.pending).toEqual([]);
|
||||
expect(g.unowned).toEqual([]);
|
||||
expect(g.pendingNationOps).toEqual([]);
|
||||
expect(g.markers).toEqual([]);
|
||||
expect(g.events).toHaveLength(2);
|
||||
// Boreas after the jump: military bumped to 60, the rest untouched.
|
||||
expect(g.nations.boreas.stats).toEqual({
|
||||
military: 60,
|
||||
economy: 70,
|
||||
stability: 55,
|
||||
population: 60,
|
||||
});
|
||||
// The stub allies the player with the first other nation (Alba).
|
||||
expect(g.nations.boreas.relations.alba).toBe('ally');
|
||||
// Every owned region key carries a seeded per-region stats entry.
|
||||
const allKeys = new Set<string>();
|
||||
for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k);
|
||||
expect(allKeys.size).toBeGreaterThan(0);
|
||||
for (const k of allKeys) {
|
||||
expect(g.regionStats[k]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.30.0",
|
||||
"version": "0.41.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -20,6 +20,7 @@
|
||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||
"build:region-geo": "node scripts/build-region-geo.mjs",
|
||||
"build:region-resources": "node scripts/build-region-resources.mjs",
|
||||
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "ww2",
|
||||
"name": "World War II",
|
||||
"year": 1939,
|
||||
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength."
|
||||
},
|
||||
{
|
||||
"id": "present",
|
||||
"name": "Present Day",
|
||||
"year": 2026,
|
||||
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision."
|
||||
},
|
||||
{
|
||||
"id": "near-future",
|
||||
"name": "Near Future",
|
||||
"year": 2035,
|
||||
"eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "near-future",
|
||||
"name": "Near Future",
|
||||
"year": 2035,
|
||||
"eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." },
|
||||
{ "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." },
|
||||
{ "name": "European Confederation", "region": "FRA", "note": "The united European bloc." },
|
||||
{ "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." }
|
||||
],
|
||||
"tensions": [
|
||||
"Autonomous weapons and the new arms race",
|
||||
"Climate migration and resource wars",
|
||||
"The contest for space and orbital dominance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "present",
|
||||
"name": "Present Day",
|
||||
"year": 2026,
|
||||
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." },
|
||||
{ "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." },
|
||||
{ "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." },
|
||||
{ "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." }
|
||||
],
|
||||
"tensions": [
|
||||
"US–China tech and trade rivalry",
|
||||
"The war in Europe and the eastern flank",
|
||||
"AI and cyber dominance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "ww2",
|
||||
"name": "World War II",
|
||||
"year": 1939,
|
||||
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." },
|
||||
{ "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." },
|
||||
{ "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." },
|
||||
{ "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." }
|
||||
],
|
||||
"tensions": [
|
||||
"The Axis versus the Allies",
|
||||
"The race for continental resources and sea lanes",
|
||||
"The uneasy balance before the bomb"
|
||||
]
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Shared, cross-ability `apply` primitives (v0.40.0, #67): the war-derived
|
||||
// relation machinery that several abilities share. Two abilities (wars,
|
||||
// nuclear) mutate `GameState.wars` and both need the same "which pairs are at
|
||||
// war" primitive (`activeWarPairs` / `atWar`); the wars ability applies the
|
||||
// turn's declare/end ops and then runs the war → relation sync
|
||||
// (`syncWarRelations`), and the nuclear ability's `endWarsBetween` (which
|
||||
// lives in ./nuclear.ts) reuses `activeWarPairs` to downgrade the relations a
|
||||
// just-ended war no longer covers.
|
||||
//
|
||||
// The bodies here are moved VERBATIM from the pre-refactor engine.ts (v0.39.0)
|
||||
// — `engine.ts` re-exports all three so existing callers (server/index.ts, the
|
||||
// unit tests) keep importing them from the engine unchanged. All functions are
|
||||
// pure or mutate their `GameState` argument in place and never throw.
|
||||
|
||||
import type { GameState, War } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||
* {b,c} (any two distinct belligerents of the same active war are at war —
|
||||
* see the War type's semantics). Pure; exported for the unit tests.
|
||||
*/
|
||||
export function activeWarPairs(wars: War[]): Set<string> {
|
||||
const pairs = new Set<string>();
|
||||
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||
for (const w of wars) {
|
||||
if (w.status !== 'active') continue;
|
||||
for (let i = 0; i < w.sides.length; i++) {
|
||||
for (let j = i + 1; j < w.sides.length; j++) {
|
||||
pairs.add(key(w.sides[i], w.sides[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
|
||||
export function atWar(wars: War[], a: string, b: string): boolean {
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
return activeWarPairs(wars).has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
|
||||
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
|
||||
* applyTurnResult does) and never touching `prev`. For every unordered pair
|
||||
* of distinct existing nations:
|
||||
*
|
||||
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
|
||||
* of the pair's relation are set to `war` (the engine owns this relation
|
||||
* while the war is active — a manual `relations` entry to a lesser status
|
||||
* on a warring pair is overridden, so the two can never "make peace"
|
||||
* without the war being ended);
|
||||
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
|
||||
* wars that tied them ended this turn) AND the current relation is
|
||||
* `war` → both directions become `hostile` (a war is the strongest
|
||||
* hostility — when it stops, the nations stay hostile, not suddenly
|
||||
* neutral);
|
||||
* - otherwise (not at war now, and the relation is not `war`, or the pair
|
||||
* was never tied by an active war) → untouched: the LLM owns
|
||||
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
|
||||
* no active war ever covered (a preset leftover) stays as-is — an
|
||||
* implicit, unnamed war, never force-created into a War entity and never
|
||||
* clobbered by the sync.
|
||||
*
|
||||
* Never throws.
|
||||
*/
|
||||
export function syncWarRelations(prev: GameState, next: GameState): void {
|
||||
const prevAtWar = activeWarPairs(prev.wars ?? []);
|
||||
const nextAtWar = activeWarPairs(next.wars ?? []);
|
||||
const ids = Object.keys(next.nations);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const a = ids[i];
|
||||
const b = ids[j];
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
const na = next.nations[a];
|
||||
const nb = next.nations[b];
|
||||
if (nextAtWar.has(key)) {
|
||||
na.relations[b] = 'war';
|
||||
nb.relations[a] = 'war';
|
||||
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
|
||||
na.relations[b] = 'hostile';
|
||||
nb.relations[a] = 'hostile';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Unit tests for the DISASTERS & PANDEMICS ability (v0.41.0, #63) — the
|
||||
// modular, turn-transient LLM-ability that applies a deterministic
|
||||
// (type, severity) stat dip to a nation or a single region. This is the
|
||||
// ability's own test file (the registry's cross-cutting guarantees — order,
|
||||
// distinct fields, the system/user goldens — are covered by
|
||||
// ./registry.test.ts). The stub never proposes a disaster, so the apply path
|
||||
// is exercised here, not by the e2e suite.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AbilityCtx } from './types';
|
||||
import {
|
||||
DISASTER_EFFECTS,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_TYPES,
|
||||
applyDisasterEvent,
|
||||
disasters,
|
||||
isDisasterSeverity,
|
||||
isDisasterType,
|
||||
validateDisasterEvents,
|
||||
} from './disasters';
|
||||
|
||||
import type { DisasterEvent, DisastersOps } from '../../shared/protocol';
|
||||
import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a NationStats from the four canonical indices (military, economy, stability, population). */
|
||||
function NS(military: number, economy: number, stability: number, population: number): NationStats {
|
||||
return { military, economy, stability, population };
|
||||
}
|
||||
|
||||
/** Build a RegionStats from the four canonical indices. */
|
||||
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
|
||||
return { military, economy, stability, population };
|
||||
}
|
||||
|
||||
/** A minimal nation: the given id/name/stats/region-key territory, neutral relations. */
|
||||
function mkNation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
|
||||
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal GameState carrying the given nations (keyed by id) and per-region
|
||||
* stats. Every other required field is at its fresh-game baseline, so a test
|
||||
* can focus purely on the disaster dip.
|
||||
*/
|
||||
function mkState(
|
||||
nations: Record<string, Nation>,
|
||||
regionStats: Record<string, RegionStats>,
|
||||
): GameState {
|
||||
return {
|
||||
id: 'g1',
|
||||
presetId: 'test',
|
||||
presetTitle: 'Test',
|
||||
playerNationId: Object.keys(nations)[0] ?? 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations,
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats,
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal AbilityCtx for the ability's `apply` (no manifests loaded). */
|
||||
function mkCtx(state: GameState, validRegionKeys?: ReadonlySet<string>): AbilityCtx {
|
||||
return {
|
||||
previous: state,
|
||||
actingTurn: state.turn,
|
||||
...(validRegionKeys ? { validRegionKeys } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type / severity constants + predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DISASTER_TYPES / DISASTER_SEVERITIES', () => {
|
||||
it('exposes exactly the seven types and three severities', () => {
|
||||
expect([...DISASTER_TYPES]).toEqual([
|
||||
'earthquake',
|
||||
'flood',
|
||||
'volcanic',
|
||||
'wildfire',
|
||||
'famine',
|
||||
'plague',
|
||||
'epidemic',
|
||||
]);
|
||||
expect([...DISASTER_SEVERITIES]).toEqual(['minor', 'moderate', 'severe']);
|
||||
});
|
||||
|
||||
it('every (type, severity) pair dips at least one stat, never population', () => {
|
||||
for (const t of DISASTER_TYPES) {
|
||||
for (const s of DISASTER_SEVERITIES) {
|
||||
const dips = DISASTER_EFFECTS[t][s];
|
||||
expect(dips).toBeDefined();
|
||||
const keys = Object.keys(dips);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
expect(keys).not.toContain('population');
|
||||
for (const k of keys) expect(dips[k as keyof typeof dips]).toBeLessThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDisasterType / isDisasterSeverity', () => {
|
||||
it('accepts each known type and rejects unknown / non-strings', () => {
|
||||
for (const t of DISASTER_TYPES) expect(isDisasterType(t)).toBe(true);
|
||||
expect(isDisasterType('tsunami')).toBe(false);
|
||||
expect(isDisasterType('Earthquake')).toBe(false); // case-sensitive
|
||||
expect(isDisasterType('')).toBe(false);
|
||||
expect(isDisasterType(42)).toBe(false);
|
||||
expect(isDisasterType(null)).toBe(false);
|
||||
expect(isDisasterType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts each known severity and rejects unknown / non-strings', () => {
|
||||
for (const s of DISASTER_SEVERITIES) expect(isDisasterSeverity(s)).toBe(true);
|
||||
expect(isDisasterSeverity('extreme')).toBe(false);
|
||||
expect(isDisasterSeverity('Minor')).toBe(false); // case-sensitive
|
||||
expect(isDisasterSeverity('')).toBe(false);
|
||||
expect(isDisasterSeverity(3)).toBe(false);
|
||||
expect(isDisasterSeverity(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateDisasterEvents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateDisasterEvents', () => {
|
||||
const valid: DisasterEvent = { type: 'earthquake', target: 'uk', severity: 'severe' };
|
||||
|
||||
it('accepts the { events: [...] } shape', () => {
|
||||
expect(validateDisasterEvents({ events: [valid] })).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('accepts a bare array (lenient about the wrapper)', () => {
|
||||
expect(validateDisasterEvents([valid])).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('drops an event with an unknown type, keeping the valid one', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [
|
||||
{ type: 'tsunami', target: 'uk', severity: 'minor' },
|
||||
valid,
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('drops an event with a missing type', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [{ target: 'uk', severity: 'minor' }, valid],
|
||||
});
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('drops an event with an unknown severity, keeping the valid one', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [
|
||||
{ type: 'plague', target: 'uk', severity: 'extreme' },
|
||||
valid,
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('drops an event with a missing severity', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [{ type: 'plague', target: 'uk' }, valid],
|
||||
});
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('drops an event with an empty / whitespace target', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [
|
||||
{ type: 'plague', target: ' ', severity: 'minor' },
|
||||
{ type: 'plague', target: '', severity: 'minor' },
|
||||
valid,
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('caps a non-empty detail at 300 chars and keeps a short one', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
const out = validateDisasterEvents({
|
||||
events: [
|
||||
{ type: 'plague', target: 'uk', severity: 'minor', detail: long },
|
||||
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{ type: 'plague', target: 'uk', severity: 'minor', detail: 'x'.repeat(300) },
|
||||
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the detail key entirely when detail is empty / missing', () => {
|
||||
const out = validateDisasterEvents({
|
||||
events: [
|
||||
{ type: 'flood', target: 'uk', severity: 'minor', detail: '' },
|
||||
{ type: 'flood', target: 'de', severity: 'minor' },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{ type: 'flood', target: 'uk', severity: 'minor' },
|
||||
{ type: 'flood', target: 'de', severity: 'minor' },
|
||||
]);
|
||||
expect(out[0]).not.toHaveProperty('detail');
|
||||
});
|
||||
|
||||
it('drops non-object event entries', () => {
|
||||
const out = validateDisasterEvents({ events: ['not-an-object', 7, null, valid] });
|
||||
expect(out).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('yields { events: [] } (here, []) for a missing / malformed top-level value', () => {
|
||||
expect(validateDisasterEvents(undefined)).toEqual([]);
|
||||
expect(validateDisasterEvents(null)).toEqual([]);
|
||||
expect(validateDisasterEvents('nope')).toEqual([]);
|
||||
expect(validateDisasterEvents(5)).toEqual([]);
|
||||
expect(validateDisasterEvents({})).toEqual([]);
|
||||
expect(validateDisasterEvents({ events: 'not-an-array' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyDisasterEvent — the deterministic dip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyDisasterEvent', () => {
|
||||
it('a nation target: dips the nation AND every region it owns, then re-derives (identical values pass through)', () => {
|
||||
// uk owns three identical regions; all stats identical → the re-derived
|
||||
// aggregate equals the dipped values exactly.
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2', 'g3']) },
|
||||
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), g3: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
|
||||
// earthquake moderate = stability -4, economy -4 (military / population untouched)
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
|
||||
for (const k of ['g1', 'g2', 'g3']) expect(state.regionStats[k]).toEqual(RS(50, 46, 46, 50));
|
||||
});
|
||||
|
||||
it('a region target: dips ONLY the named region (the sibling region is untouched)', () => {
|
||||
// uk owns g1 and g2 (identical). Target the region key g1 → only g1 dips.
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
|
||||
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'wildfire', target: 'g1', severity: 'severe' });
|
||||
// wildfire severe = economy -7 only
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 43, 50, 50));
|
||||
expect(state.regionStats.g2).toEqual(RS(50, 50, 50, 50)); // untouched
|
||||
// re-derive (equal weights, min-biased median) → the nation takes the dipped value
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 43, 50, 50));
|
||||
});
|
||||
|
||||
it('a region target resolved by a validKeys-gated key hits its sole owner', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(
|
||||
state,
|
||||
{ type: 'famine', target: 'g1', severity: 'moderate' },
|
||||
undefined,
|
||||
new Set(['g1']),
|
||||
);
|
||||
// famine moderate = economy -4, stability -4
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
|
||||
});
|
||||
|
||||
it('clamps every dip at 0 (never negative)', () => {
|
||||
// low stability + military; a severe plague (stability -9, military -6) floors both.
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(3, 50, 3, 50), ['g1']) },
|
||||
{ g1: RS(3, 50, 3, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'uk', severity: 'severe' });
|
||||
expect(state.nations.uk.stats).toEqual(NS(0, 50, 0, 50));
|
||||
expect(state.regionStats.g1).toEqual(RS(0, 50, 0, 50));
|
||||
});
|
||||
|
||||
it('a 0-owner region is untargetable (the event is dropped, nothing dips)', () => {
|
||||
// 'x1' is owned by nobody; no validKeys supplied, so the owners check drops it.
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'x1', severity: 'severe' });
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('a region with 2+ owners is untargetable (the event is dropped, neither dips)', () => {
|
||||
// corrupt double-ownership of 'y1'
|
||||
const state = mkState(
|
||||
{
|
||||
uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['y1']),
|
||||
de: mkNation('de', 'DE', NS(60, 60, 60, 60), ['y1']),
|
||||
},
|
||||
{ y1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'y1', severity: 'severe' });
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
|
||||
expect(state.nations.de.stats).toEqual(NS(60, 60, 60, 60)); // untouched
|
||||
});
|
||||
|
||||
it('an unknown region key (gated by validKeys) is dropped', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
// 'zzz' is not in the manifest → dropped before the owners check.
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'zzz', severity: 'severe' }, undefined, new Set(['g1']));
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('an unknown nation reference is dropped (no throw, no dip)', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'ghost', severity: 'severe' });
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('an ambiguous nation reference (two share the name) is dropped', () => {
|
||||
const state = mkState(
|
||||
{
|
||||
a: mkNation('a', 'Same', NS(50, 50, 50, 50), ['g1']),
|
||||
b: mkNation('b', 'Same', NS(70, 70, 70, 70), ['d1']),
|
||||
},
|
||||
{ g1: RS(50, 50, 50, 50), d1: RS(70, 70, 70, 70) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'plague', target: 'Same', severity: 'severe' });
|
||||
expect(state.nations.a.stats).toEqual(NS(50, 50, 50, 50)); // untouched
|
||||
expect(state.nations.b.stats).toEqual(NS(70, 70, 70, 70)); // untouched
|
||||
});
|
||||
|
||||
it('resolves a nation by (case-insensitive) name, not just id', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'United Kingdom', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'epidemic', target: 'united kingdom', severity: 'moderate' });
|
||||
// epidemic moderate = stability -4, military -2
|
||||
expect(state.nations.uk.stats).toEqual(NS(48, 50, 46, 50));
|
||||
expect(state.regionStats.g1).toEqual(RS(48, 50, 46, 50));
|
||||
});
|
||||
|
||||
it('a 0-region nation keeps its directly-dipped stats (no re-derive to zero)', () => {
|
||||
const iso = mkNation('iso', 'Iso', NS(50, 50, 50, 50), []);
|
||||
const state = mkState({ iso }, {});
|
||||
applyDisasterEvent(state, { type: 'epidemic', target: 'iso', severity: 'moderate' });
|
||||
// epidemic moderate = stability -4, military -2 — the direct dip persists
|
||||
expect(state.nations.iso.stats).toEqual(NS(48, 50, 46, 50));
|
||||
expect(state.regionStats).toEqual({});
|
||||
});
|
||||
|
||||
it('a region with no regionStats entry is skipped (the nation still dips + re-derives from its other regions)', () => {
|
||||
// uk owns g1 (has stats) and g2 (no entry). Target the nation → the dip
|
||||
// mirrors to g1 only (g2 has no entry to dip); re-derive uses g1 alone.
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
|
||||
expect(state.regionStats).not.toHaveProperty('g2'); // never created
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Ability object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('disasters (Ability)', () => {
|
||||
it('owns the "disasters" field and declares NO durable state fields (turn-transient)', () => {
|
||||
expect(disasters.field).toBe('disasters');
|
||||
expect(disasters.stateFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it('default() and stub() are fresh, empty { events: [] } values', () => {
|
||||
expect(disasters.default()).toEqual({ events: [] });
|
||||
expect(disasters.stub()).toEqual({ events: [] });
|
||||
// fresh on every call (never a shared mutable reference)
|
||||
expect(disasters.default()).not.toBe(disasters.default());
|
||||
expect(disasters.default().events).not.toBe(disasters.default().events);
|
||||
expect(disasters.stub()).not.toBe(disasters.stub());
|
||||
});
|
||||
|
||||
it('isMeaningful is true iff there is at least one event', () => {
|
||||
expect(disasters.isMeaningful({ events: [] })).toBe(false);
|
||||
expect(disasters.isMeaningful({ events: [{ type: 'plague', target: 'uk', severity: 'minor' }] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('payload() is always empty (the layer is turn-transient)', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
expect(disasters.payload({ state })).toEqual({});
|
||||
});
|
||||
|
||||
it('validate() wraps the low-level validator into the { events } field shape', () => {
|
||||
const valid = { type: 'flood', target: 'uk', severity: 'minor' } as DisasterEvent;
|
||||
expect(disasters.validate({ events: [valid] })).toEqual({ events: [valid] });
|
||||
expect(disasters.validate(undefined)).toEqual({ events: [] });
|
||||
expect(disasters.validate([valid])).toEqual({ events: [valid] }); // bare array leniency
|
||||
});
|
||||
|
||||
it('apply() wires ctx.geoOf + ctx.validRegionKeys through to the dip', () => {
|
||||
const state = mkState(
|
||||
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
|
||||
{ g1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const ctx = mkCtx(state, new Set(['g1']));
|
||||
const data: DisastersOps = { events: [{ type: 'volcanic', target: 'g1', severity: 'moderate' }] };
|
||||
disasters.apply(state, data, ctx);
|
||||
// volcanic moderate = economy -4, stability -3
|
||||
expect(state.regionStats.g1).toEqual(RS(50, 46, 47, 50));
|
||||
expect(state.nations.uk.stats).toEqual(NS(50, 46, 47, 50));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
// The DISASTERS & PANDEMICS ability (v0.41.0, #63) as a modular LLM-ability
|
||||
// (v0.40.0, #67). Natural and man-made catastrophes — earthquakes, floods,
|
||||
// volcanic eruptions, wildfires, famines, plagues, and epidemics — hit a
|
||||
// nation or a single region each turn. Unlike most other abilities this one
|
||||
// is **turn-transient**: it owns NO `GameState` field (no durable list, no
|
||||
// `db.ts` change) — exactly like the hybrid layer. Each event carries a
|
||||
// `type` and a `severity`, and the engine applies a DETERMINISTIC stat dip
|
||||
// chosen from the `(type, severity)` lookup table below (no hidden
|
||||
// randomness — the LLM chooses the type and severity, the table decides the
|
||||
// magnitude). The dip lands on the target nation's `stats` (clamped 0–100)
|
||||
// AND on the target's regions (`regionStats` — the single named region when
|
||||
// the target was a region key, otherwise every region the target owns), and
|
||||
// the target's derived `stats` are re-derived afterwards (the same 0-region /
|
||||
// no-entry guards as hybrid / economy / industry, so a 0-region nation keeps
|
||||
// its directly-mutated value and the dip persists to the next turn). The
|
||||
// target is a nation (resolved by the shared `resolveNationRef`) or a region
|
||||
// key (which hits its SOLE owner); a key with 0 or 2+ owners is untargetable.
|
||||
// There is no acting-nation (`from`) field on a disaster — a catastrophe is
|
||||
// not an attack, so the hybrid's self-target drop does not apply. Malformed
|
||||
// events and unresolvable targets are dropped, never thrown.
|
||||
|
||||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||||
import type {
|
||||
DisasterEvent,
|
||||
DisasterSeverity,
|
||||
DisasterType,
|
||||
} from '../../shared/protocol';
|
||||
import type { GameState, Nation, NationStats, StatKey } from '../../shared/types';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/** The seven disaster types a turn can declare (v0.41.0, #63). */
|
||||
export const DISASTER_TYPES: readonly DisasterType[] = [
|
||||
'earthquake',
|
||||
'flood',
|
||||
'volcanic',
|
||||
'wildfire',
|
||||
'famine',
|
||||
'plague',
|
||||
'epidemic',
|
||||
];
|
||||
|
||||
/** The three severity levels a disaster event can carry (v0.41.0, #63). */
|
||||
export const DISASTER_SEVERITIES: readonly DisasterSeverity[] = ['minor', 'moderate', 'severe'];
|
||||
|
||||
const VALID_DISASTER_TYPES = new Set<string>(DISASTER_TYPES);
|
||||
const VALID_SEVERITIES = new Set<string>(DISASTER_SEVERITIES);
|
||||
|
||||
/** True when `t` is one of the seven known disaster types (v0.41.0, #63). */
|
||||
export function isDisasterType(t: unknown): t is DisasterType {
|
||||
return typeof t === 'string' && VALID_DISASTER_TYPES.has(t);
|
||||
}
|
||||
|
||||
/** True when `s` is one of the three known disaster severities (v0.41.0, #63). */
|
||||
export function isDisasterSeverity(s: unknown): s is DisasterSeverity {
|
||||
return typeof s === 'string' && VALID_SEVERITIES.has(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) The DETERMINISTIC per-`(type, severity)` stat dip of a
|
||||
* disaster event. Each pair maps to a small set of signed (negative) stat
|
||||
* deltas: no hidden randomness — the LLM picks the type and severity, the
|
||||
* table decides the magnitude. Population is never touched (a disaster hurts
|
||||
* military / economy / stability, never the head-count). Every (type,
|
||||
* severity) combination dips at least one stat (none is purely
|
||||
* informational).
|
||||
*/
|
||||
export const DISASTER_EFFECTS: Record<DisasterType, Record<DisasterSeverity, Partial<Record<StatKey, number>>>> = {
|
||||
earthquake: {
|
||||
minor: { stability: -2, economy: -2 },
|
||||
moderate: { stability: -4, economy: -4 },
|
||||
severe: { stability: -7, economy: -7 },
|
||||
},
|
||||
flood: {
|
||||
minor: { economy: -2, stability: -1 },
|
||||
moderate: { economy: -3, stability: -2 },
|
||||
severe: { economy: -6, stability: -4 },
|
||||
},
|
||||
volcanic: {
|
||||
minor: { economy: -2, stability: -2 },
|
||||
moderate: { economy: -4, stability: -3 },
|
||||
severe: { economy: -7, stability: -6 },
|
||||
},
|
||||
wildfire: {
|
||||
minor: { economy: -2 },
|
||||
moderate: { economy: -4 },
|
||||
severe: { economy: -7 },
|
||||
},
|
||||
famine: {
|
||||
minor: { economy: -2, stability: -2 },
|
||||
moderate: { economy: -4, stability: -4 },
|
||||
severe: { economy: -7, stability: -7 },
|
||||
},
|
||||
plague: {
|
||||
minor: { stability: -3, military: -2 },
|
||||
moderate: { stability: -5, military: -3 },
|
||||
severe: { stability: -9, military: -6 },
|
||||
},
|
||||
epidemic: {
|
||||
minor: { stability: -2, military: -1 },
|
||||
moderate: { stability: -4, military: -2 },
|
||||
severe: { stability: -7, military: -4 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Validate the raw `disasters` field of a turn into a safe
|
||||
* list of {@link DisasterEvent}s. Defensive like the rest of the parser — never throws.
|
||||
* The raw value may be the object `{ events: [...] }` (the TurnResult shape)
|
||||
* or a bare array (be lenient about either). Each event needs a known `type`
|
||||
* (one of {@link DISASTER_TYPES}), a known `severity` (one of
|
||||
* {@link DISASTER_SEVERITIES}), and a non-empty `target` (a nation id/name or
|
||||
* a region key, capped at 200 chars); a non-empty `detail` is capped at 300
|
||||
* chars. An event with an unknown / missing `type`, an unknown / missing
|
||||
* `severity`, or an empty `target` is dropped. Resolution of `target` to a
|
||||
* concrete nation (or a region's sole owner) happens at apply time
|
||||
* (`applyDisasterEvent`), not here. A missing / malformed top-level value
|
||||
* yields `{ events: [] }`.
|
||||
*/
|
||||
export function validateDisasterEvents(raw: unknown): DisasterEvent[] {
|
||||
let list: unknown[] | null = null;
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw;
|
||||
} else if (raw && typeof raw === 'object') {
|
||||
const inner = (raw as Record<string, unknown>).events;
|
||||
if (Array.isArray(inner)) list = inner;
|
||||
}
|
||||
if (!list) return [];
|
||||
const out: DisasterEvent[] = [];
|
||||
for (const e of list) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (!isDisasterType(o.type)) continue; // unknown / missing type: drop
|
||||
if (!isDisasterSeverity(o.severity)) continue; // unknown / missing severity: drop
|
||||
const target = str(o.target, 200);
|
||||
if (!target) continue; // empty / missing target: drop
|
||||
const detail = str(o.detail, 300);
|
||||
out.push({
|
||||
type: o.type,
|
||||
target,
|
||||
severity: o.severity,
|
||||
...(detail ? { detail } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Resolve a disaster event's `target` (a nation id/name OR a
|
||||
* region key) to the concrete nation it affects. A nation ref resolves via
|
||||
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
|
||||
* with zero or 2+ owners can't be a valid target). Returns the affected
|
||||
* nation plus the specific region key (when the target was a region) so the
|
||||
* dip can land on just that region's entry. `validKeys` (when supplied)
|
||||
* gates the region-key path; a ref that is neither a known nation nor a
|
||||
* valid region key returns `null`. Pure; never throws.
|
||||
*
|
||||
* NOTE: there is no acting-nation (`from`) field on a disaster — a
|
||||
* catastrophe is not an attack, so (unlike the hybrid layer) a self-target
|
||||
* drop does NOT apply: an earthquake can hit the nation it is declared
|
||||
* against.
|
||||
*/
|
||||
function resolveDisasterTarget(
|
||||
state: GameState,
|
||||
target: string,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): { nation: Nation; regionKey?: string } | null {
|
||||
const id = resolveNationRef(state, target);
|
||||
if (id !== null) {
|
||||
const n = state.nations[id];
|
||||
return n ? { nation: n } : null;
|
||||
}
|
||||
const key = (target ?? '').trim();
|
||||
if (!key) return null;
|
||||
if (validKeys && !validKeys.has(key)) return null; // unknown region key
|
||||
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
|
||||
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
|
||||
return { nation: owners[0], regionKey: key };
|
||||
}
|
||||
|
||||
/** Apply each signed dip in `dips` to a stats record in place (clamped). */
|
||||
function applyDips(stats: NationStats, dips: Partial<Record<StatKey, number>>): void {
|
||||
(Object.keys(dips) as StatKey[]).forEach((k) => {
|
||||
const d = dips[k];
|
||||
if (typeof d === 'number') stats[k] = clampStat(stats[k] + d);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Apply ONE disaster event to the (cloned) `next` state,
|
||||
* mutating it in place and never throwing. Resolution order:
|
||||
* 1. `target` must resolve to a concrete nation (a nation ref, or a region
|
||||
* key's sole owner); an unresolvable target (unknown / ambiguous ref, a
|
||||
* region with 0 or 2+ owners, or an unknown region key) drops the event.
|
||||
* Then the deterministic `(type, severity)` dip from {@link DISASTER_EFFECTS}
|
||||
* is applied:
|
||||
* - the target nation's `stats` is dipped on each affected stat (clamped
|
||||
* 0–100);
|
||||
* - the dip is mirrored to the target's regions — the single named region
|
||||
* when the target was a region key, otherwise every region the target
|
||||
* owns (regions with no `regionStats` entry are skipped);
|
||||
* - the target nation's derived `stats` are re-derived afterwards (the
|
||||
* same 0-region / no-entry guards as the main recompute, so a 0-region
|
||||
* nation keeps its directly-mutated value and a dip persists).
|
||||
* A disaster never moves territory (sole ownership is untouched) and never
|
||||
* touches `population`.
|
||||
*/
|
||||
export function applyDisasterEvent(
|
||||
next: GameState,
|
||||
ev: DisasterEvent,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): void {
|
||||
const target = resolveDisasterTarget(next, ev.target, validKeys);
|
||||
if (target === null) return; // unresolvable target: drop
|
||||
const dips = DISASTER_EFFECTS[ev.type][ev.severity];
|
||||
if (!dips) return; // defensive: unknown (type, severity) pair
|
||||
applyDips(target.nation.stats, dips);
|
||||
if (target.regionKey !== undefined) {
|
||||
const rs = next.regionStats[target.regionKey];
|
||||
if (rs) applyDips(rs, dips);
|
||||
} else {
|
||||
for (const key of target.nation.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue; // a key with no entry is skipped
|
||||
applyDips(rs, dips);
|
||||
}
|
||||
}
|
||||
// Re-derive the target nation's aggregate stats so the dip persists (the
|
||||
// main recompute ran earlier this turn; without this the dip would be
|
||||
// washed out next turn). Same 0-region / no-entry guards as hybrid /
|
||||
// economy: a nation with no region stats entry keeps its directly-mutated
|
||||
// `stats`.
|
||||
if (target.nation.territory.length > 0 && target.nation.territory.some((k) => next.regionStats[k])) {
|
||||
target.nation.stats = deriveNationStats(target.nation.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
|
||||
export const disasters: Ability<'disasters'> = {
|
||||
field: 'disasters',
|
||||
default: () => ({ events: [] }),
|
||||
// The low-level validator returns a bare list; the field's shape is the
|
||||
// `{ events }` object (mirrors the historical TurnResult shape).
|
||||
validate: (raw) => ({ events: validateDisasterEvents(raw) }),
|
||||
apply: (state, data, ctx) => {
|
||||
for (const ev of data.events) {
|
||||
applyDisasterEvent(state, ev, ctx.geoOf, ctx.validRegionKeys);
|
||||
}
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},',
|
||||
constraint: () =>
|
||||
'- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region\'s SOLE owner — a region with 0 or 2+ owners can\'t be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).',
|
||||
isMeaningful: (data) => data.events.length > 0,
|
||||
stub: () => ({ events: [] }),
|
||||
// The layer is turn-transient — it contributes nothing to the user payload
|
||||
// (no durable state to show the model), so the payload is always empty.
|
||||
payload: () => ({}),
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
// The ECONOMY (trade & embargo) ability (v0.35.0, #58) as a modular
|
||||
// LLM-ability (v0.40.0, #67). A bilateral trade gives BOTH parties a small,
|
||||
// deterministic economy gain every committed turn; a unilateral embargo
|
||||
// lowers only the TARGET's economy every committed turn. Ops are idempotent
|
||||
// (a no-`lift` op declares / imposes; `lift: true` ends / lifts; refs
|
||||
// resolved against the real state at apply time — unknown / ambiguous /
|
||||
// self-pairing ops are dropped, never thrown). Every committed turn the FULL
|
||||
// active lists apply their effect (clamped, mirrored to each affected
|
||||
// nation's regions, re-derived), and any trade/embargo between nations on
|
||||
// opposite sides of an active war is auto-suspended (a war severs the
|
||||
// economic link). All bodies are moved VERBATIM from the pre-refactor
|
||||
// engine.ts.
|
||||
|
||||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { EconomyEmbargoOp, EconomyOps, EconomyTradeOp } from '../../shared/protocol';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import { activeWarPairs } from './apply-helpers';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe
|
||||
* {@link EconomyOps}. Defensive like the rest of the parser — never throws.
|
||||
* `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique
|
||||
* name, capped at 200 chars) and a boolean `lift` (default false = declare);
|
||||
* ops with a missing ref are dropped — ref resolution and the
|
||||
* a≠b / from≠target checks happen at apply time against the real state
|
||||
* (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op
|
||||
* needs non-empty `from` and `target` refs and a boolean `lift` (default
|
||||
* false = impose). A missing / malformed top-level object yields
|
||||
* `{ trade: [], embargo: [] }`.
|
||||
*/
|
||||
export function validateEconomyOps(raw: unknown): EconomyOps {
|
||||
const out: EconomyOps = { trade: [], embargo: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(o.trade)) {
|
||||
for (const e of o.trade) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const t = e as Record<string, unknown>;
|
||||
const a = str(t.a, 200);
|
||||
const b = str(t.b, 200);
|
||||
if (!a || !b) continue;
|
||||
const op: EconomyTradeOp = { a, b };
|
||||
if (t.lift === true) op.lift = true;
|
||||
out.trade.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.embargo)) {
|
||||
for (const e of o.embargo) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const t = e as Record<string, unknown>;
|
||||
const from = str(t.from, 200);
|
||||
const target = str(t.target, 200);
|
||||
if (!from || !target) continue;
|
||||
const op: EconomyEmbargoOp = { from, target };
|
||||
if (t.lift === true) op.lift = true;
|
||||
out.embargo.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) The per-turn economic effect of the economy layer, applied
|
||||
* EVERY committed turn (not just to this turn's declares): each active
|
||||
* bilateral trade grants both parties a small, deterministic economy gain;
|
||||
* each active embargo applies a small, deterministic economy dip to the
|
||||
* target only. Clamped to 0–100. The effect lands on `stats.economy` AND on
|
||||
* every region the nation owns (`regionStats`), mirroring the hybrid-op
|
||||
* effect, and each affected nation's derived stats are re-derived afterwards
|
||||
* (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there
|
||||
* are no active trades or embargoes. Never throws.
|
||||
*/
|
||||
const TRADE_ECONOMY_BONUS = 1;
|
||||
const EMBARGO_ECONOMY_PENALTY = 1;
|
||||
|
||||
export function applyEconomyEffects(
|
||||
next: GameState,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): void {
|
||||
if (next.trades.length === 0 && next.embargoes.length === 0) return;
|
||||
|
||||
// nation id -> signed economy delta (trades add for both parties, embargoes
|
||||
// subtract for the target; deltas accumulate for nations hit by several).
|
||||
const delta = new Map<string, number>();
|
||||
for (const t of next.trades) {
|
||||
for (const id of [t.a, t.b]) {
|
||||
delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS);
|
||||
}
|
||||
}
|
||||
for (const e of next.embargoes) {
|
||||
delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY);
|
||||
}
|
||||
|
||||
const affected = new Set<string>();
|
||||
for (const [id, d] of delta) {
|
||||
const n = next.nations[id];
|
||||
if (!n) continue; // a nation removed this turn (a merge): the delta is dropped
|
||||
const cur = clampStat(n.stats.economy + d);
|
||||
if (cur === n.stats.economy) continue; // no change (already at the clamp)
|
||||
n.stats.economy = cur;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Apply the turn's economy ops to the active lists on
|
||||
* `next`, RESOLVING the (possibly name-based) refs against the current
|
||||
* state: a no-`lift` trade op declares the bilateral agreement (idempotent —
|
||||
* an already-active pair is not duplicated; the pair is stored sorted, since
|
||||
* a trade is symmetric); `lift: true` ends it (a no-op when absent). A no-
|
||||
* `lift` embargo op imposes the unilateral embargo (idempotent — the ordered
|
||||
* from→target pair is kept as-is); `lift: true` lifts it. An op whose refs
|
||||
* don't resolve to distinct EXISTING nations is dropped (defensive — never
|
||||
* throws). Mutates `next` in place.
|
||||
*/
|
||||
function applyEconomyOps(next: GameState, ops: EconomyOps): void {
|
||||
const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||
const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`;
|
||||
|
||||
for (const op of ops.trade) {
|
||||
const a = resolveNationRef(next, op.a);
|
||||
const b = resolveNationRef(next, op.b);
|
||||
if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop
|
||||
const key = tradeKey(a, b);
|
||||
if (op.lift === true) {
|
||||
next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key);
|
||||
} else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) {
|
||||
// stored sorted (a < b): a trade is symmetric, so the agreement has a
|
||||
// canonical shape no matter which way the LLM named the pair.
|
||||
next.trades.push(a < b ? { a, b } : { a: b, b: a });
|
||||
}
|
||||
}
|
||||
|
||||
for (const op of ops.embargo) {
|
||||
const from = resolveNationRef(next, op.from);
|
||||
const target = resolveNationRef(next, op.target);
|
||||
if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop
|
||||
const key = embargoKey(from, target);
|
||||
if (op.lift === true) {
|
||||
next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key);
|
||||
} else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) {
|
||||
next.embargoes.push({ from, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any
|
||||
* active trade or embargo whose two nations are on OPPOSITE sides of an
|
||||
* active war (v0.33, #56) is removed — a war severs the economic link, so
|
||||
* the suspended agreement stops producing its per-turn effect from now on.
|
||||
* A trade/embargo between two non-belligerents, or between two nations on
|
||||
* the SAME side, is untouched. Mutates `next` in place; idempotent. Must be
|
||||
* called AFTER the war declare/end ops of the turn (so it sees the final
|
||||
* `next.wars`). Never throws.
|
||||
*/
|
||||
function suspendEconomyOnWar(next: GameState): void {
|
||||
if (next.trades.length === 0 && next.embargoes.length === 0) return;
|
||||
const warring = activeWarPairs(next.wars);
|
||||
const atWarNow = (x: string, y: string): boolean => {
|
||||
if (x === y) return false;
|
||||
const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`;
|
||||
return warring.has(key);
|
||||
};
|
||||
next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b));
|
||||
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
|
||||
}
|
||||
|
||||
export const economy: Ability<'economy'> = {
|
||||
field: 'economy',
|
||||
default: () => ({ trade: [], embargo: [] }),
|
||||
validate: (raw) => validateEconomyOps(raw),
|
||||
apply: (state, data, ctx) => {
|
||||
// Placed after the wars block (so the auto-suspension sees the FINAL
|
||||
// next.wars, including wars declared this turn) and before markers /
|
||||
// nationOps (which never touch the economy lists). Order:
|
||||
// (1) APPLY OPS: the turn's declare/lift ops mutate the active
|
||||
// GameState.trades / GameState.embargoes lists (a declare is
|
||||
// idempotent; refs resolved against the current state, unknown /
|
||||
// ambiguous / self-pairing ops dropped).
|
||||
// (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two
|
||||
// nations are on opposite sides of an active war is removed —
|
||||
// a war severs the economic link, so the suspended agreement
|
||||
// stops producing its per-turn effect from now on (this also
|
||||
// drops a trade declared this turn between two nations that just
|
||||
// went to war).
|
||||
// (3) PER-TURN EFFECT: the FULL active lists (not just this turn's
|
||||
// declares) each apply their deterministic effect this turn —
|
||||
// trade: both parties' economy up a little; embargo: the
|
||||
// target's economy down a little — clamped, mirrored to the
|
||||
// owning nations' regions, and re-derived like the hybrid dip.
|
||||
// Additive only: declares add, lifts remove, and nothing else (not even
|
||||
// a war) mutates the lists besides the suspension above.
|
||||
applyEconomyOps(state, data);
|
||||
suspendEconomyOnWar(state);
|
||||
applyEconomyEffects(state, ctx.geoOf);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||||
constraint: () =>
|
||||
'- 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.',
|
||||
isMeaningful: (data) => data.trade.length > 0 || data.embargo.length > 0,
|
||||
stateFields: ['trades', 'embargoes'],
|
||||
stub: () => ({ trade: [], embargo: [] }),
|
||||
payload: (ctx) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (ctx.trades !== undefined) out.trades = ctx.trades;
|
||||
if (ctx.embargoes !== undefined) out.embargoes = ctx.embargoes;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
WORLD: World War II — 1939-09-01
|
||||
It is early September 1939. Germany has just invaded Poland, and Britain and France have declared war. Europe stands on the edge of the largest conflict the world has ever seen. The Axis powers — Germany, Italy, and Japan — are pressing their advantage across Europe and the Pacific, while the Allies — Britain, France, the Soviet Union, and a still-neutral but industrially immense United States — are marshalling their strength.
|
||||
|
||||
The balance of power is delicate. Germany's army is the most battle-hardened force on the continent, but its resources are finite and its flanks exposed. Britain commands the seas but is an island nation. The Soviet Union has a vast army and deep territory but a fragile industrial base. The United States has unmatched economic power but is geographically isolated and politically divided over entry into the war.
|
||||
|
||||
You are the head of one of these nations. Every decision you make — military, economic, or diplomatic — ripples across the world and reshapes the map, the alliances, and the fate of hundreds of millions.
|
||||
|
||||
SIMULATION RULES
|
||||
- Serious, historically-plausible tone; write like a war correspondent and a policy analyst combined.
|
||||
- Prefer gradual change: 1-4 meaningful events per turn, never a single decisive world-ending event.
|
||||
- Respect existing borders unless a war is declared or a decisive military shift occurs.
|
||||
- No single nation may instantly dominate the world; power shifts are incremental.
|
||||
- Respect the player's chosen nation's resources, manpower, and geography.
|
||||
- Diplomacy can shift relations over time but rarely reverses a declared war in one turn.
|
||||
- Keep stat values (military, economy, stability, population) between 0 and 100.
|
||||
|
||||
You are the world simulator. You advance the shared world state for ALL nations given the current state and the player's move.
|
||||
Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:
|
||||
{
|
||||
"date": "YYYY-MM-DD (a few days after the current date)",
|
||||
"events": [
|
||||
{"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}
|
||||
],
|
||||
"stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
|
||||
"regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
|
||||
"relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],
|
||||
"territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],
|
||||
"unowned": ["<region key>"],
|
||||
"nationOps": [
|
||||
{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},
|
||||
{"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}
|
||||
{"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}
|
||||
],
|
||||
"takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],
|
||||
"wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},
|
||||
"hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},
|
||||
"economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},
|
||||
"industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},
|
||||
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
|
||||
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
|
||||
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
|
||||
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
||||
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
||||
}
|
||||
|
||||
HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)
|
||||
- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender's military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man's land) region is never blocked this way.
|
||||
- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.
|
||||
- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation's other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.
|
||||
|
||||
HARD CONSTRAINTS
|
||||
- Use only nation ids and region keys that exist in the provided state.
|
||||
- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.
|
||||
- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".
|
||||
- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation's main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message's "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).
|
||||
- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".
|
||||
- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation's name.
|
||||
- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation's region keys to at most one part — any key you leave unassigned goes to the first part.
|
||||
- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.
|
||||
- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.
|
||||
- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair's relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message's "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".
|
||||
- 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.
|
||||
- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region's owner a small, deterministic economy gain, the bigger the region's industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message's "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".
|
||||
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
|
||||
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
|
||||
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
|
||||
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
||||
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
||||
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
||||
- Keep every stat value between 0 and 100.
|
||||
- Per-region stat changes ("regionChanges") target ONE region's stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation's regions; a "regionChanges" entry overrides it for that one region. The user message's "playerRegionStats" lists the player's own regions' current stats; use region keys from the "regions" table.
|
||||
- Only change borders when war or a decisive political shift justifies it.
|
||||
- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man's land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man's land.
|
||||
- If a field is not relevant this turn, set it to an empty array.
|
||||
@@ -0,0 +1,76 @@
|
||||
WORLD: World War II — 1939-09-01
|
||||
It is early September 1939. Germany has just invaded Poland, and Britain and France have declared war. Europe stands on the edge of the largest conflict the world has ever seen. The Axis powers — Germany, Italy, and Japan — are pressing their advantage across Europe and the Pacific, while the Allies — Britain, France, the Soviet Union, and a still-neutral but industrially immense United States — are marshalling their strength.
|
||||
|
||||
The balance of power is delicate. Germany's army is the most battle-hardened force on the continent, but its resources are finite and its flanks exposed. Britain commands the seas but is an island nation. The Soviet Union has a vast army and deep territory but a fragile industrial base. The United States has unmatched economic power but is geographically isolated and politically divided over entry into the war.
|
||||
|
||||
You are the head of one of these nations. Every decision you make — military, economic, or diplomatic — ripples across the world and reshapes the map, the alliances, and the fate of hundreds of millions.
|
||||
|
||||
SIMULATION RULES
|
||||
- Serious, historically-plausible tone; write like a war correspondent and a policy analyst combined.
|
||||
- Prefer gradual change: 1-4 meaningful events per turn, never a single decisive world-ending event.
|
||||
- Respect existing borders unless a war is declared or a decisive military shift occurs.
|
||||
- No single nation may instantly dominate the world; power shifts are incremental.
|
||||
- Respect the player's chosen nation's resources, manpower, and geography.
|
||||
- Diplomacy can shift relations over time but rarely reverses a declared war in one turn.
|
||||
- Keep stat values (military, economy, stability, population) between 0 and 100.
|
||||
|
||||
You are the world simulator. You advance the shared world state for ALL nations given the current state and the player's move.
|
||||
You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:
|
||||
{
|
||||
"date": "YYYY-MM-DD (a few days after the current date)",
|
||||
"events": [
|
||||
{"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}
|
||||
],
|
||||
"stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
|
||||
"regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
|
||||
"relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],
|
||||
"territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],
|
||||
"unowned": ["<region key>"],
|
||||
"nationOps": [
|
||||
{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},
|
||||
{"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}
|
||||
{"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}
|
||||
],
|
||||
"takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],
|
||||
"wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},
|
||||
"hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},
|
||||
"economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},
|
||||
"industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},
|
||||
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
|
||||
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
|
||||
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
|
||||
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
||||
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
||||
}
|
||||
|
||||
HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)
|
||||
- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender's military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man's land) region is never blocked this way.
|
||||
- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.
|
||||
- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation's other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.
|
||||
|
||||
HARD CONSTRAINTS
|
||||
- Use only nation ids and region keys that exist in the provided state.
|
||||
- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.
|
||||
- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".
|
||||
- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation's main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message's "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).
|
||||
- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".
|
||||
- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation's name.
|
||||
- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation's region keys to at most one part — any key you leave unassigned goes to the first part.
|
||||
- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.
|
||||
- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.
|
||||
- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair's relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message's "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".
|
||||
- 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.
|
||||
- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region's owner a small, deterministic economy gain, the bigger the region's industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message's "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".
|
||||
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
|
||||
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
|
||||
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
|
||||
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
||||
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
||||
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
||||
- Keep every stat value between 0 and 100.
|
||||
- Per-region stat changes ("regionChanges") target ONE region's stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation's regions; a "regionChanges" entry overrides it for that one region. The user message's "playerRegionStats" lists the player's own regions' current stats; use region keys from the "regions" table.
|
||||
- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.
|
||||
- The "frontier" field of the user message lists region keys owned by other nations that border the player's territory — the natural targets of a player-led conquest this turn.
|
||||
- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man's land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man's land.
|
||||
- If a field is not relevant this turn, set it to an empty array.
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"state": {
|
||||
"turn": 4,
|
||||
"date": "1940-02-14",
|
||||
"playerNationId": "a1",
|
||||
"nations": {
|
||||
"a1": {
|
||||
"id": "a1",
|
||||
"name": "Alpha",
|
||||
"type": "major",
|
||||
"stats": {
|
||||
"military": 60,
|
||||
"economy": 55,
|
||||
"stability": 70,
|
||||
"population": 80
|
||||
},
|
||||
"territory": [
|
||||
"a-r1",
|
||||
"a-r2"
|
||||
],
|
||||
"relations": {
|
||||
"b2": "ally",
|
||||
"c3": "rival"
|
||||
},
|
||||
"leader": {
|
||||
"name": "Alpha Leader"
|
||||
},
|
||||
"ideology": "democracy",
|
||||
"nuclear": true
|
||||
},
|
||||
"b2": {
|
||||
"id": "b2",
|
||||
"name": "Bravo",
|
||||
"type": "major",
|
||||
"stats": {
|
||||
"military": 75,
|
||||
"economy": 40,
|
||||
"stability": 20,
|
||||
"population": 30
|
||||
},
|
||||
"territory": [
|
||||
"b-r1",
|
||||
"b-r2"
|
||||
],
|
||||
"relations": {
|
||||
"a1": "ally"
|
||||
},
|
||||
"ideology": "fascism",
|
||||
"nuclear": false
|
||||
},
|
||||
"c3": {
|
||||
"id": "c3",
|
||||
"name": "Charlie",
|
||||
"type": "major",
|
||||
"stats": {
|
||||
"military": 30,
|
||||
"economy": 35,
|
||||
"stability": 80,
|
||||
"population": 20
|
||||
},
|
||||
"territory": [
|
||||
"c-r1"
|
||||
],
|
||||
"relations": {
|
||||
"a1": "rival"
|
||||
},
|
||||
"nuclear": false
|
||||
}
|
||||
},
|
||||
"recentEvents": []
|
||||
},
|
||||
"playerActions": [
|
||||
"Rearm the northern border",
|
||||
{
|
||||
"to": "b2",
|
||||
"message": "We can still talk terms, no?"
|
||||
}
|
||||
],
|
||||
"pending": [
|
||||
{
|
||||
"kind": "action",
|
||||
"text": "Rearm the northern border"
|
||||
},
|
||||
{
|
||||
"kind": "diplomacy",
|
||||
"to": "b2",
|
||||
"text": "We can still talk terms, no?"
|
||||
}
|
||||
],
|
||||
"roundInstruction": "This period the player (Alpha) takes the following actions and sends the following diplomatic messages: [\"Rearm the northern border\",{\"to\":\"b2\",\"message\":\"We can still talk terms, no?\"}]. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.",
|
||||
"playerRegionStats": {
|
||||
"a-r1": {
|
||||
"military": 60,
|
||||
"economy": 55,
|
||||
"stability": 70,
|
||||
"population": 80
|
||||
},
|
||||
"a-r2": {
|
||||
"military": 58,
|
||||
"economy": 53,
|
||||
"stability": 72,
|
||||
"population": 78
|
||||
}
|
||||
},
|
||||
"regions": {
|
||||
"a-r1": "Alpha North (US)",
|
||||
"a-r2": "Alpha South (US)",
|
||||
"b-r1": "Bravo Plain (RU)",
|
||||
"b-r2": "Bravo Coast (RU)",
|
||||
"c-r1": "Charlie Isle (FR)",
|
||||
"x-unowned": "X Released (UN)"
|
||||
},
|
||||
"frontier": [
|
||||
"b-r1"
|
||||
],
|
||||
"frontierMilitary": [
|
||||
"[Your military: 60] vs [Bravo: 75] — a direct invasion could succeed"
|
||||
],
|
||||
"takeoverRisks": [
|
||||
"Bravo (stability 20, a gathering is in progress)"
|
||||
],
|
||||
"unowned": [
|
||||
"x-unowned"
|
||||
],
|
||||
"markers": [
|
||||
{
|
||||
"id": "mk0001",
|
||||
"type": "gathering",
|
||||
"name": "Bravo Rally",
|
||||
"regionKey": "b-r1",
|
||||
"owner": "b2"
|
||||
},
|
||||
{
|
||||
"id": "mk0002",
|
||||
"type": "rocket",
|
||||
"name": "Charlie Silo",
|
||||
"regionKey": "c-r1",
|
||||
"owner": "c3"
|
||||
},
|
||||
{
|
||||
"id": "mk0003",
|
||||
"type": "capital",
|
||||
"name": "Alpha Cap",
|
||||
"regionKey": "a-r1",
|
||||
"owner": "a1"
|
||||
}
|
||||
],
|
||||
"wars": [
|
||||
{
|
||||
"id": "w1",
|
||||
"sides": [
|
||||
"a1",
|
||||
"b2"
|
||||
],
|
||||
"aims": [
|
||||
"control the border"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "w2",
|
||||
"sides": [
|
||||
"b2",
|
||||
"c3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relations": {
|
||||
"b2": "ally",
|
||||
"c3": "rival"
|
||||
},
|
||||
"trades": [
|
||||
{
|
||||
"a": "a1",
|
||||
"b": "c3"
|
||||
}
|
||||
],
|
||||
"embargoes": [
|
||||
{
|
||||
"from": "a1",
|
||||
"target": "b2"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"a1": {
|
||||
"oil": 0.5,
|
||||
"iron": 0.2,
|
||||
"food": 0.3,
|
||||
"industry": 0.8
|
||||
},
|
||||
"b2": {
|
||||
"oil": 0.1,
|
||||
"iron": 0.9,
|
||||
"food": 0.1,
|
||||
"industry": 0.2
|
||||
},
|
||||
"c3": {
|
||||
"oil": 0.2,
|
||||
"iron": 0.3,
|
||||
"food": 0.7,
|
||||
"industry": 0.4
|
||||
}
|
||||
},
|
||||
"industry": [
|
||||
{
|
||||
"regionKey": "a-r1",
|
||||
"type": "industry"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// The HYBRID-WAR ability (v0.34.0, #57) as a modular LLM-ability (v0.40.0, #67).
|
||||
// Covert, non-kinetic operations: the seven op types each have a DETERMINISTIC
|
||||
// engine effect (no hidden randomness — the LLM declares the intended `outcome`
|
||||
// and the engine acts only on that):
|
||||
//
|
||||
// spy informational only — no stat, no relation (pure intel)
|
||||
// sabotage a small ECONOMY dip on the target
|
||||
// assassinate a small STABILITY dip on the target (absent the named-leaders
|
||||
// feature, #61, a leader's removal sours the polity)
|
||||
// cyber a small MILITARY dip on the target
|
||||
// economic a small ECONOMY dip on the target
|
||||
// information a relation nudge toward hostile (cap hostile) — disinformation
|
||||
// proxy a light relation nudge toward hostile (cap rival) — proxy war
|
||||
//
|
||||
// A successful stat op dips the target's `regionStats` (the single named region
|
||||
// when the target was a region key, otherwise every region the target owns) and
|
||||
// re-derives the target nation's aggregate stats so the effect persists. A
|
||||
// successful information/proxy and any exposed op sour the actor's relation
|
||||
// with the target by one step on the hostile ladder. All bodies are moved
|
||||
// VERBATIM from the pre-refactor engine.ts.
|
||||
|
||||
import { clampStat, deriveNationStats, HYBRID_OP_TYPES, makeEvent } from '../../shared/protocol';
|
||||
import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types';
|
||||
import type { HybridOp, HybridOpType } from '../../shared/protocol';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
const VALID_HYBRID_TYPES = new Set<string>(HYBRID_OP_TYPES);
|
||||
|
||||
/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */
|
||||
export function isHybridOpType(t: unknown): t is HybridOpType {
|
||||
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe
|
||||
* `{ ops }` shape. Defensive like the rest of the parser — never throws. The
|
||||
* raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare
|
||||
* array (be lenient about either). Each op needs a known `type` (one of
|
||||
* {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at
|
||||
* 200 chars) and a non-empty `target` (a nation id/name or a region key, capped
|
||||
* at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the
|
||||
* default, `success`, is stored as absent to keep the shape minimal — the
|
||||
* engine treats a missing outcome as success); a non-empty `detail` is capped
|
||||
* at 300 chars. Resolution of `from` / `target` to concrete nation ids or
|
||||
* region owners happens at apply time (`applyHybridOp`), not here.
|
||||
*/
|
||||
export function validateHybridOps(raw: unknown): HybridOp[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
// Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []).
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const inner = (raw as Record<string, unknown>).ops;
|
||||
if (!Array.isArray(inner)) return [];
|
||||
return validateHybridOpsList(inner);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return validateHybridOpsList(raw);
|
||||
}
|
||||
|
||||
function validateHybridOpsList(raw: unknown[]): HybridOp[] {
|
||||
const out: HybridOp[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (!isHybridOpType(o.type)) continue;
|
||||
const from = str(o.from, 200);
|
||||
const target = str(o.target, 200);
|
||||
if (!from || !target) continue;
|
||||
const detail = str(o.detail, 300);
|
||||
const exposed = o.outcome === 'exposed'; // 'success' / missing → success
|
||||
out.push({
|
||||
type: o.type,
|
||||
from,
|
||||
target,
|
||||
...(detail ? { detail } : {}),
|
||||
...(exposed ? { outcome: 'exposed' as const } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */
|
||||
export const HYBRID_STAT_EFFECT: Record<HybridOpType, { stat: StatKey; delta: number } | null> = {
|
||||
spy: null, // informational only
|
||||
sabotage: { stat: 'economy', delta: -3 },
|
||||
assassinate: { stat: 'stability', delta: -5 },
|
||||
cyber: { stat: 'military', delta: -3 },
|
||||
economic: { stat: 'economy', delta: -5 },
|
||||
information: null, // relation nudge only
|
||||
proxy: null, // relation nudge only
|
||||
};
|
||||
|
||||
/**
|
||||
* The hostile-relations ladder, weakest to strongest (the `cap` in
|
||||
* {@link shiftRelationTowardHostile} is a member of this array). A missing
|
||||
* relation is treated as `neutral`; `war` is never touched (a war is its own
|
||||
* entity — a covert op neither fabricates nor ends one).
|
||||
*/
|
||||
const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b`
|
||||
* ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied
|
||||
* symmetrically to both directions, but never past the given `cap`. A missing
|
||||
* relation starts at `neutral`; a `war` relation is never touched (a covert op
|
||||
* neither fabricates nor downgrades a war). Used for the `information` /
|
||||
* `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the
|
||||
* caller passes a clone — applyTurnResult does). Never throws.
|
||||
*/
|
||||
export function shiftRelationTowardHostile(
|
||||
state: GameState,
|
||||
a: string,
|
||||
b: string,
|
||||
cap: RelationStatus,
|
||||
): void {
|
||||
const na = state.nations[a];
|
||||
const nb = state.nations[b];
|
||||
if (!na || !nb) return;
|
||||
const capIdx = HOSTILE_ORDER.indexOf(cap);
|
||||
if (capIdx === -1) return; // unknown cap: no-op (defensive)
|
||||
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
|
||||
if (cur === 'war') return null; // never touch a war relation
|
||||
const s = cur ?? 'neutral'; // a missing relation is neutral
|
||||
const i = HOSTILE_ORDER.indexOf(s);
|
||||
if (i === -1) return 'neutral'; // defensive: unknown status
|
||||
if (i >= capIdx) return s; // at or past the cap: no change
|
||||
return HOSTILE_ORDER[i + 1];
|
||||
};
|
||||
const stepA = step(na.relations[b]);
|
||||
if (stepA !== null) na.relations[b] = stepA;
|
||||
const stepB = step(nb.relations[a]);
|
||||
if (stepB !== null) nb.relations[a] = stepB;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) The display name of a hybrid op's target for the auto
|
||||
* backfire event: a nation target shows the nation's name; a region target
|
||||
* shows the region's display name (or its raw key when no resolver is given).
|
||||
*/
|
||||
function hybridTargetDisplayName(
|
||||
target: { nation: Nation; regionKey?: string },
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): string {
|
||||
if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey;
|
||||
return target.nation.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region
|
||||
* key) to the concrete nation it affects. A nation ref resolves via
|
||||
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
|
||||
* with zero or 2+ owners can't be a valid target). Returns the affected nation
|
||||
* plus the specific region key (when the target was a region) so a stat op can
|
||||
* dip just that region. `validKeys` (when supplied) gates the region-key path;
|
||||
* a ref that is neither a known nation nor a valid region key returns `null`.
|
||||
* Pure; never throws.
|
||||
*/
|
||||
function resolveHybridTarget(
|
||||
state: GameState,
|
||||
target: string,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): { nation: Nation; regionKey?: string } | null {
|
||||
const id = resolveNationRef(state, target);
|
||||
if (id !== null) {
|
||||
const n = state.nations[id];
|
||||
return n ? { nation: n } : null;
|
||||
}
|
||||
const key = (target ?? '').trim();
|
||||
if (!key) return null;
|
||||
if (validKeys && !validKeys.has(key)) return null; // unknown region key
|
||||
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
|
||||
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
|
||||
return { nation: owners[0], regionKey: key };
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it
|
||||
* in place and never throwing. Resolution order:
|
||||
* 1. `from` must resolve to a nation (id first, then a unique name); an
|
||||
* unknown / ambiguous actor drops the op.
|
||||
* 2. `target` must resolve to a concrete nation (a nation ref, or a region
|
||||
* key's sole owner); an unresolvable target drops the op.
|
||||
* 3. A self-target (`from` and the target nation are the same) is dropped.
|
||||
* Then, per type:
|
||||
* - an `exposed` op BACKFIRES: no intended effect, the actor's relation with
|
||||
* the target sours by one step (cap hostile), and an auto diplomatic/
|
||||
* failure event is emitted (the engine's own narrative, mirroring the
|
||||
* "repelled" auto-event).
|
||||
* - a successful stat op (sabotage / assassinate / cyber / economic) dips the
|
||||
* target's `regionStats` by its per-type delta — the single named region
|
||||
* when the target was a region key, otherwise every region the target
|
||||
* owns — then re-derives the target nation's aggregate stats (the same
|
||||
* 0-region / no-entry guards as the main recompute), so the effect
|
||||
* persists to the next turn.
|
||||
* - a successful `information` / `proxy` sours the actor's relation with the
|
||||
* target by one step (information caps at hostile, proxy at rival).
|
||||
* - `spy` is informational only (no stat, no relation) — a no-op except for
|
||||
* whatever the LLM narrates in `events`.
|
||||
*/
|
||||
export function applyHybridOp(
|
||||
next: GameState,
|
||||
op: HybridOp,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const from = resolveNationRef(next, op.from);
|
||||
if (from === null) return; // unknown / ambiguous actor: drop
|
||||
const target = resolveHybridTarget(next, op.target, validKeys);
|
||||
if (target === null) return; // unresolvable target: drop
|
||||
if (from === target.nation.id) return; // self-target: drop
|
||||
|
||||
if (op.outcome === 'exposed') {
|
||||
// Backfire: no intended effect; the relation sours by one step (cap
|
||||
// hostile) and an auto diplomatic/failure event is emitted.
|
||||
shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
const actor = next.nations[from];
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: from,
|
||||
type: 'diplomatic',
|
||||
title: 'Covert operation exposed',
|
||||
text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName(
|
||||
target,
|
||||
regionNameOf,
|
||||
)} was exposed; relations sour in response.`,
|
||||
outcome: 'failure',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = HYBRID_STAT_EFFECT[op.type];
|
||||
if (eff) {
|
||||
// A successful stat op: dip the target's region(s), then re-derive.
|
||||
if (target.regionKey !== undefined) {
|
||||
const rs = next.regionStats[target.regionKey];
|
||||
if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
} else {
|
||||
for (const key of target.nation.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
}
|
||||
}
|
||||
// Re-derive the target nation's aggregate stats. The main recompute ran
|
||||
// earlier this turn, so without this the dip would be washed out next
|
||||
// turn. The same 0-region / no-entry guards as the main recompute apply.
|
||||
if (target.nation.territory.length > 0) {
|
||||
if (target.nation.territory.some((k) => next.regionStats[k])) {
|
||||
target.nation.stats = deriveNationStats(
|
||||
target.nation.territory,
|
||||
next.regionStats,
|
||||
geoOf,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// information / proxy: a relation nudge (spy is a pure no-op here).
|
||||
if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
|
||||
}
|
||||
|
||||
export const hybrid: Ability<'hybrid'> = {
|
||||
field: 'hybrid',
|
||||
default: () => ({ ops: [] }),
|
||||
// The low-level validator returns a bare list; the field's shape is the
|
||||
// `{ ops }` object (mirrors the historical `result.hybrid = { ops: ... }`).
|
||||
validate: (raw) => ({ ops: validateHybridOps(raw) }),
|
||||
apply: (state, data, ctx) => {
|
||||
for (const op of data.ops) {
|
||||
applyHybridOp(state, op, ctx.geoOf, ctx.validRegionKeys, ctx.regionNameOf, ctx.actingTurn);
|
||||
}
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "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>"}]},',
|
||||
constraint: () =>
|
||||
'- 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".',
|
||||
isMeaningful: (data) => data.ops.length > 0,
|
||||
stub: () => ({ ops: [] }),
|
||||
payload: (ctx) => (ctx.relations !== undefined ? { relations: ctx.relations } : {}),
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
// The IDEOLOGY ability (v0.39, #62) as a modular LLM-ability (v0.40.0, #67).
|
||||
// The model may change a nation's form of government (its `ideology` field) —
|
||||
// a free-form label such as "democracy", "fascism", or "military junta" — via
|
||||
// a coup, revolution, reform, or constitutional change. On a valid change the
|
||||
// target's `ideology` is set, a DETERMINISTIC stability dip
|
||||
// (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability`
|
||||
// (clamped) and mirrored to every region the nation owns (`regionStats`), the
|
||||
// nation's derived stats are re-derived afterwards (the same 0-region /
|
||||
// no-entry guards), and a narration event is appended. The change never moves
|
||||
// territory (sole ownership is untouched). All bodies are moved VERBATIM from
|
||||
// the pre-refactor engine.ts.
|
||||
|
||||
import { deriveNationStats, makeEvent } from '../../shared/protocol';
|
||||
import { clampStat, IDEOLOGY_STABILITY_EFFECT } from '../../shared/protocol';
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { IdeologyChange } from '../../shared/protocol';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.39, #62) Validate the raw `ideology` object of a turn into a safe
|
||||
* `IdeologyChange[]`. Defensive like the rest of the parser — never throws.
|
||||
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each
|
||||
* change needs a non-empty `target` (a nation ref, capped at 120 chars) and
|
||||
* a non-empty, non-whitespace `to` (the new form of government, capped at
|
||||
* 120 chars). Refs stay raw here (resolved against the real state at apply
|
||||
* time, like the wars / hybrid / nuclear / leader ops); an unresolvable
|
||||
* target is dropped at apply time, never thrown.
|
||||
*/
|
||||
export function validateIdeologyOps(raw: unknown): IdeologyChange[] {
|
||||
const out: IdeologyChange[] = [];
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, unknown>).changes
|
||||
: undefined;
|
||||
if (!Array.isArray(list)) return out;
|
||||
for (const e of list) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const s = e as Record<string, unknown>;
|
||||
const target = str(s.target, 120);
|
||||
const to = str(s.to, 120);
|
||||
if (!target || !to) continue;
|
||||
out.push({ target, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.39, #62) Apply a single ideology change to `next` (in place). The
|
||||
* target nation is resolved by the shared resolver (id first, then a
|
||||
* case-insensitive unique name; an unknown or ambiguous ref is dropped,
|
||||
* never thrown). On a valid change the target's `ideology` is set to the new
|
||||
* government type (re-setting the same value is idempotent for the field but
|
||||
* still narrated) and a DETERMINISTIC stability dip
|
||||
* (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability`
|
||||
* (clamped 0–100) and mirrored to every region the nation owns
|
||||
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
|
||||
* same 0-region / no-entry guards as the hybrid/economy/industry/leader
|
||||
* effects, so a 0-region nation keeps its directly-mutated value). Finally a
|
||||
* narration event is appended: a `political` event for the government shift.
|
||||
* The change never moves territory (sole ownership is untouched).
|
||||
*/
|
||||
function applyIdeologyChange(
|
||||
next: GameState,
|
||||
op: IdeologyChange,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const id = resolveNationRef(next, op.target);
|
||||
if (id === null) return; // unknown / ambiguous target: drop, never throw
|
||||
const n = next.nations[id];
|
||||
|
||||
n.ideology = op.to;
|
||||
|
||||
const delta = IDEOLOGY_STABILITY_EFFECT;
|
||||
if (delta !== 0) {
|
||||
n.stats.stability = clampStat(n.stats.stability + delta);
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs.stability = clampStat(rs.stability + delta);
|
||||
}
|
||||
// Re-derive the nation's aggregate stats (the main recompute ran earlier
|
||||
// this turn, so without this the dip would be washed out next turn).
|
||||
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: n.id,
|
||||
type: 'political',
|
||||
title: `${n.name}'s government shifted to ${op.to}`,
|
||||
text: `${n.name} has changed its form of government to ${op.to}.`,
|
||||
outcome: 'success',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const ideology: Ability<'ideology'> = {
|
||||
field: 'ideology',
|
||||
default: () => ({ changes: [] }),
|
||||
// The low-level validator returns a bare list; the field's shape is the
|
||||
// `{ changes }` object (mirrors the historical `result.ideology = { changes: ... }`).
|
||||
validate: (raw) => ({ changes: validateIdeologyOps(raw) }),
|
||||
apply: (state, data, ctx) => {
|
||||
for (const ch of data.changes) applyIdeologyChange(state, ch, ctx.geoOf, ctx.actingTurn);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},',
|
||||
constraint: () =>
|
||||
'- Ideology ("ideology") is each nation\'s form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation\'s ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message\'s "nations" field lists each nation\'s current "ideology" (omitted when a nation has none).',
|
||||
isMeaningful: (data) => data.changes.length > 0,
|
||||
stub: () => ({ changes: [] }),
|
||||
payload: () => ({}),
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
// The ordered LLM-ability registry.
|
||||
//
|
||||
// `ABILITIES` is the single source of truth for the 9 per-feature LLM abilities.
|
||||
// The engine iterates it in this order:
|
||||
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters
|
||||
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
|
||||
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
|
||||
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
|
||||
// (f) the runTurn unclear-fallback defaults, (g) the stub's deterministic fields, and
|
||||
// (h) the db.ts state-field migrations.
|
||||
//
|
||||
// Each ability file defines its own `Ability<K>` const (the schema line, constraint
|
||||
// bullet, validator, applier, meaningfulness predicate, stub default, and user-payload
|
||||
// field). This module assembles them into the ordered `ABILITIES` array and re-exports
|
||||
// every public name the engine (and its tests) need.
|
||||
|
||||
import { takeovers } from './takeovers';
|
||||
import { wars } from './wars';
|
||||
import { hybrid } from './hybrid';
|
||||
import { economy } from './economy';
|
||||
import { industry } from './industry';
|
||||
import { nuclear } from './nuclear';
|
||||
import { leaders } from './leaders';
|
||||
import { ideology } from './ideology';
|
||||
import { disasters } from './disasters';
|
||||
import type { Ability, AbilityField } from './types';
|
||||
|
||||
/**
|
||||
* The 9 per-feature LLM abilities in their canonical apply order.
|
||||
* The order matters: it determines the order of the schema lines in the
|
||||
* system prompt, the order of the validate calls, the order of the apply
|
||||
* steps, and the order of the payload fields in the user prompt.
|
||||
*/
|
||||
export const ABILITIES: readonly Ability<AbilityField>[] = [
|
||||
takeovers,
|
||||
wars,
|
||||
hybrid,
|
||||
economy,
|
||||
industry,
|
||||
nuclear,
|
||||
leaders,
|
||||
ideology,
|
||||
disasters,
|
||||
];
|
||||
|
||||
// ── type re-exports ────────────────────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
Ability,
|
||||
AbilityCtx,
|
||||
UserPromptCtx,
|
||||
AbilityField,
|
||||
} from './types';
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
NEW_NATION_STATS,
|
||||
} from './shared';
|
||||
|
||||
// ── war helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
activeWarPairs,
|
||||
atWar,
|
||||
syncWarRelations,
|
||||
} from './apply-helpers';
|
||||
|
||||
// ── takeovers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateTakeovers,
|
||||
applyTakeover,
|
||||
} from './takeovers';
|
||||
|
||||
// ── wars ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateWarOps,
|
||||
applyWars,
|
||||
} from './wars';
|
||||
|
||||
// ── hybrid ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateHybridOps,
|
||||
isHybridOpType,
|
||||
HYBRID_STAT_EFFECT,
|
||||
shiftRelationTowardHostile,
|
||||
applyHybridOp,
|
||||
} from './hybrid';
|
||||
|
||||
// ── economy ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateEconomyOps,
|
||||
applyEconomyEffects,
|
||||
} from './economy';
|
||||
|
||||
// ── industry ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateIndustryOps,
|
||||
isIndustryType,
|
||||
applyIndustryEffects,
|
||||
} from './industry';
|
||||
|
||||
// ── nuclear ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateNuclearOps,
|
||||
applyNuclearOps,
|
||||
} from './nuclear';
|
||||
|
||||
// ── leaders ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateLeaderOps,
|
||||
} from './leaders';
|
||||
|
||||
// ── ideology ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateIdeologyOps,
|
||||
} from './ideology';
|
||||
|
||||
// ── disasters ────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
validateDisasterEvents,
|
||||
applyDisasterEvent,
|
||||
isDisasterType,
|
||||
isDisasterSeverity,
|
||||
DISASTER_TYPES,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_EFFECTS,
|
||||
} from './disasters';
|
||||
@@ -0,0 +1,212 @@
|
||||
// The INDUSTRY ability (v0.36, #59) as a modular LLM-ability (v0.40.0, #67).
|
||||
// The per-region economy-boost lever: each project is a building in one
|
||||
// region (`{ regionKey, type }`, type one of oil / iron / food / industry).
|
||||
// `GameState.industry` is a PER-REGION SET — a region key that is not a valid
|
||||
// manifest key is dropped; a region that already hosts a project is untouched
|
||||
// (the first declared type wins — re-declaring is a no-op). Every committed
|
||||
// turn each active project grants its region's SOLE owner a small,
|
||||
// deterministic economy boost (`INDUSTRY_ECONOMY_BONUS`, plus one extra point
|
||||
// when the region's `industry` resource value is ≥ 0.5 — from the synthesized
|
||||
// resource manifest; without it just the base) — clamped to 0–100, mirrored
|
||||
// to every region the owner holds, and re-derived like the economy/hybrid
|
||||
// effects. A project on a region with 0 or 2+ owners yields no boost but is
|
||||
// kept. Sole ownership is untouched (the layer moves stats, never territory).
|
||||
// All bodies are moved VERBATIM from the pre-refactor engine.ts.
|
||||
|
||||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||||
import type { GameState, IndustryType, RegionResources } from '../../shared/types';
|
||||
import { INDUSTRY_TYPES } from '../../shared/types';
|
||||
import type { IndustryOps } from '../../shared/protocol';
|
||||
import { str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* (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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const industry: Ability<'industry'> = {
|
||||
field: 'industry',
|
||||
default: () => ({ projects: [] }),
|
||||
validate: (raw) => validateIndustryOps(raw),
|
||||
apply: (state, data, ctx) => {
|
||||
// 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(state, data, ctx.validRegionKeys);
|
||||
applyIndustryEffects(state, ctx.resourceOf, ctx.geoOf);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||||
constraint: () =>
|
||||
'- 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".',
|
||||
isMeaningful: (data) => data.projects.length > 0,
|
||||
stateFields: ['industry'],
|
||||
stub: () => ({ projects: [] }),
|
||||
payload: (ctx) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (ctx.nationResources !== undefined) out.resources = ctx.nationResources;
|
||||
if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// The NAMED-LEADERS ability (v0.38, #61) as a modular LLM-ability (v0.40.0,
|
||||
// #67). The model may change a nation's head of state: an `assassination`
|
||||
// (a dramatic, destabilizing blow — a −15 stability dip, a "military" event,
|
||||
// only valid when the target already has a leader), a `death` (a natural-
|
||||
// causes succession — a quieter −3 dip, a "political" event, also only valid
|
||||
// for a nation that has a leader), or an `appointed` change (sets or creates
|
||||
// a leader and is always valid — a neutral 0 effect, a "political" event).
|
||||
// On a valid change the target's `leader` is set, the DETERMINISTIC stability
|
||||
// dip (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
|
||||
// (clamped) and mirrored to every region the nation owns (`regionStats`), the
|
||||
// nation's derived stats are re-derived afterwards (the same 0-region /
|
||||
// no-entry guards), and a narration event is appended. The replacement never
|
||||
// moves territory (sole ownership is untouched). All bodies are moved
|
||||
// VERBATIM from the pre-refactor engine.ts.
|
||||
|
||||
import { deriveNationStats, makeEvent } from '../../shared/protocol';
|
||||
import { clampStat, LEADER_STABILITY_EFFECT } from '../../shared/protocol';
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { LeaderCause, LeaderChange } from '../../shared/protocol';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.38, #61) Validate the raw `leaders` object of a turn into a safe
|
||||
* `LeaderChange[]`. Defensive like the rest of the parser — never throws.
|
||||
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change
|
||||
* needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty
|
||||
* `name` (the new leader's name, capped at 120 chars), and a `cause` that is
|
||||
* one of the three known literals (assassination / death / appointed). Refs
|
||||
* stay raw here (resolved against the real state at apply time, like the wars
|
||||
* / hybrid / nuclear ops); a leaderless assassination/death is dropped at
|
||||
* apply time, never thrown.
|
||||
*/
|
||||
export function validateLeaderOps(raw: unknown): LeaderChange[] {
|
||||
const out: LeaderChange[] = [];
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, unknown>).changes
|
||||
: undefined;
|
||||
if (!Array.isArray(list)) return out;
|
||||
for (const e of list) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const s = e as Record<string, unknown>;
|
||||
const target = str(s.target, 120);
|
||||
const name = str(s.name, 120);
|
||||
if (!target || !name) continue;
|
||||
const cause = typeof s.cause === 'string' ? s.cause : '';
|
||||
if (cause === 'assassination' || cause === 'death' || cause === 'appointed') {
|
||||
out.push({ target, name, cause: cause as LeaderCause });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.38, #61) Apply a single leader change to `next` (in place). The target
|
||||
* nation is resolved by the shared resolver (id first, then a case-insensitive
|
||||
* unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE
|
||||
* then applies: an `assassination` or `death` requires the target to already
|
||||
* have a leader (a leaderless target is dropped); an `appointed` change is
|
||||
* always valid (it creates the first leader). On a valid change the target's
|
||||
* `leader` is set to the new name and a DETERMINISTIC stability dip
|
||||
* (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
|
||||
* (clamped 0–100) and mirrored to every region the nation owns
|
||||
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
|
||||
* same 0-region / no-entry guards as the hybrid/economy/industry effects, so a
|
||||
* 0-region nation keeps its directly-mutated value). Finally a narration event
|
||||
* is appended: a dramatic `military` event for an assassination, a `political`
|
||||
* succession event for a death or an appointment. The replacement never moves
|
||||
* territory (sole ownership is untouched).
|
||||
*/
|
||||
function applyLeaderChange(
|
||||
next: GameState,
|
||||
op: LeaderChange,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const id = resolveNationRef(next, op.target);
|
||||
if (id === null) return; // unknown / ambiguous target: drop, never throw
|
||||
const n = next.nations[id];
|
||||
|
||||
// GATE: assassination / death require an existing leader; an appointment is
|
||||
// always valid (it creates the first leader).
|
||||
if (op.cause !== 'appointed' && !n.leader) return;
|
||||
|
||||
const prevName = n.leader?.name;
|
||||
n.leader = { name: op.name };
|
||||
|
||||
const delta = LEADER_STABILITY_EFFECT[op.cause];
|
||||
if (delta !== 0) {
|
||||
n.stats.stability = clampStat(n.stats.stability + delta);
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs.stability = clampStat(rs.stability + delta);
|
||||
}
|
||||
// Re-derive the nation's aggregate stats (the main recompute ran earlier
|
||||
// this turn, so without this the dip would be washed out next turn).
|
||||
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
|
||||
const title =
|
||||
op.cause === 'assassination'
|
||||
? `${prevName ?? 'The leader'} of ${n.name} is assassinated`
|
||||
: op.cause === 'death'
|
||||
? `${prevName ?? 'The leader'} of ${n.name} dies`
|
||||
: `${n.name} appoints ${op.name}`;
|
||||
const text =
|
||||
op.cause === 'assassination'
|
||||
? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.`
|
||||
: op.cause === 'death'
|
||||
? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.`
|
||||
: `${n.name} has appointed ${op.name} as its new leader.`;
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: n.id,
|
||||
type: op.cause === 'assassination' ? 'military' : 'political',
|
||||
title,
|
||||
text,
|
||||
outcome: 'success',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const leaders: Ability<'leaders'> = {
|
||||
field: 'leaders',
|
||||
default: () => ({ changes: [] }),
|
||||
// The low-level validator returns a bare list; the field's shape is the
|
||||
// `{ changes }` object (mirrors the historical `result.leaders = { changes: ... }`).
|
||||
validate: (raw) => ({ changes: validateLeaderOps(raw) }),
|
||||
apply: (state, data, ctx) => {
|
||||
// Placed after the nuclear block (both are the "heavier" levers) and
|
||||
// before the markers block (markers recompute their owner from the FINAL
|
||||
// post-claim ownership; leaders only touch stats, so ordering w.r.t.
|
||||
// markers is immaterial, but keeping the levers grouped is clearer). Each
|
||||
// change sets the target's leader, applies a deterministic stability dip
|
||||
// by cause, and appends a narration event. A leaderless assassination/
|
||||
// death is dropped.
|
||||
for (const op of data.changes) applyLeaderChange(state, op, ctx.geoOf, ctx.actingTurn);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
|
||||
constraint: () =>
|
||||
'- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).',
|
||||
isMeaningful: (data) => data.changes.length > 0,
|
||||
stub: () => ({ changes: [] }),
|
||||
payload: () => ({}),
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
// The NUCLEAR / WMD ability (v0.37, #60) as a modular LLM-ability (v0.40.0, #67).
|
||||
// The model may name nations to ARM with the nuclear flag (`nuclear.arm`) and
|
||||
// fire nuclear strikes (`nuclear.strikes`, from a capable nation at a nation or
|
||||
// a region). The deterministic effects (devastating the target, MAD retaliation,
|
||||
// ending the target's wars, forcing relations hostile, releasing struck regions
|
||||
// to no man's land) are all applied here. The apply bodies are moved VERBATIM
|
||||
// from the pre-refactor engine.ts; `NUCLEAR_COLLAPSE` / `NUCLEAR_RETALIATION` /
|
||||
// `isNuclearCapable` / `makeEvent` / `deriveNationStats` come from shared/protocol.
|
||||
|
||||
import {
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
isNuclearCapable,
|
||||
makeEvent,
|
||||
NUCLEAR_COLLAPSE,
|
||||
NUCLEAR_RETALIATION,
|
||||
type NuclearOps,
|
||||
type NuclearStatKey,
|
||||
} from '../../shared/protocol';
|
||||
import type { GameState, Nation } from '../../shared/types';
|
||||
import { activeWarPairs } from './apply-helpers';
|
||||
import { resolveNationRef, resolveRegionKey, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
|
||||
|
||||
/**
|
||||
* (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devastate a nation's stats by a delta profile (collapse / retaliation): each
|
||||
* of the three nuclear stat keys is shifted by the given delta (clamped) on
|
||||
* BOTH the nation and every region it owns. 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
|
||||
* already owns one this turn is capable now).
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export const nuclear: Ability<'nuclear'> = {
|
||||
field: 'nuclear',
|
||||
default: () => ({ arm: [], strikes: [] }),
|
||||
validate: (raw) => validateNuclearOps(raw),
|
||||
apply: (state, data, ctx) => {
|
||||
applyNuclearOps(
|
||||
state,
|
||||
data,
|
||||
ctx.previous,
|
||||
ctx.geoOf,
|
||||
ctx.validRegionKeys,
|
||||
ctx.regionNameOf,
|
||||
ctx.actingTurn,
|
||||
);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
|
||||
constraint: () =>
|
||||
'- 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".',
|
||||
isMeaningful: (data) => data.arm.length > 0 || data.strikes.length > 0,
|
||||
stub: () => ({ arm: [], strikes: [] }),
|
||||
payload: () => ({}),
|
||||
};
|
||||
@@ -0,0 +1,396 @@
|
||||
// The ordered LLM-ability registry: contract + golden-prompt tests (v0.40.0, #67).
|
||||
//
|
||||
// These tests pin the refactor's two load-bearing guarantees:
|
||||
//
|
||||
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
|
||||
// prompt the engine builds are BYTE-IDENTICAL to the committed golden
|
||||
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
|
||||
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
|
||||
// system goldens were regenerated in v0.41.0 when the disasters ability
|
||||
// was added). Any transcription drift in an ability's schemaLine /
|
||||
// constraint / payload is caught here. The USER prompt golden is unchanged
|
||||
// by the disasters feature (its payload is always empty).
|
||||
//
|
||||
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
|
||||
// list of the 9 abilities; each ability's default() equals the canonical
|
||||
// TurnResult default for its field, is a FRESH value (never a shared
|
||||
// reference), its default is not "meaningful" while a populated value is,
|
||||
// and the registry composes (a hypothetical extra ability can be appended
|
||||
// and every uniform loop still runs).
|
||||
//
|
||||
// Regenerate the goldens after an INTENTIONAL prompt change with:
|
||||
// REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts
|
||||
// (the goldens are then rewritten from the current engine output; review the
|
||||
// diff and commit). In a normal run (no env) the tests assert byte-equality.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
activeWarPairs,
|
||||
applyDisasterEvent,
|
||||
applyIndustryEffects,
|
||||
atWar,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
DISASTER_EFFECTS,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_TYPES,
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
HYBRID_STAT_EFFECT,
|
||||
isDisasterSeverity,
|
||||
isDisasterType,
|
||||
isHybridOpType,
|
||||
isIndustryType,
|
||||
NEW_NATION_STATS,
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
shiftRelationTowardHostile,
|
||||
syncWarRelations,
|
||||
validateDisasterEvents,
|
||||
validateEconomyOps,
|
||||
validateHybridOps,
|
||||
validateIdeologyOps,
|
||||
validateIndustryOps,
|
||||
validateLeaderOps,
|
||||
validateNuclearOps,
|
||||
validateTakeovers,
|
||||
validateWarOps,
|
||||
} from '../engine';
|
||||
import { createPresetStore } from '../presets';
|
||||
import { ABILITIES } from './index';
|
||||
import type { Ability, AbilityField } from './types';
|
||||
import type {
|
||||
Embargo,
|
||||
GameState,
|
||||
IndustryProject,
|
||||
Nation,
|
||||
NationStats,
|
||||
RegionResources,
|
||||
RelationStatus,
|
||||
RoundItem,
|
||||
TradeAgreement,
|
||||
} from '../../shared/types';
|
||||
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const goldensDir = path.join(here, 'goldens');
|
||||
const REGEN = process.env.REGEN_GOLDENS === '1';
|
||||
|
||||
// --- The preset the system-prompt goldens were captured with. ---
|
||||
const root = path.resolve(here, '..', '..');
|
||||
const store = createPresetStore(path.join(root, 'presets'));
|
||||
const preset = store.get('wwii-1939');
|
||||
if (!preset) throw new Error('wwii-1939 preset not found');
|
||||
|
||||
// --- The small, fully-controlled deterministic state that exercises EVERY
|
||||
// --- optional branch of buildUserPrompt (re-embedded from the P1
|
||||
// --- golden-capture so the committed goldens can be regenerated). ---
|
||||
function nation(
|
||||
id: string,
|
||||
name: string,
|
||||
stats: NationStats,
|
||||
territory: string[],
|
||||
extra: Partial<Nation> = {},
|
||||
): Nation {
|
||||
return { id, name, type: 'major', stats, territory, relations: {}, ...extra };
|
||||
}
|
||||
|
||||
const s = (m: number, e: number, st: number, p: number): NationStats => ({
|
||||
military: m,
|
||||
economy: e,
|
||||
stability: st,
|
||||
population: p,
|
||||
});
|
||||
|
||||
const a1 = nation('a1', 'Alpha', s(60, 55, 70, 80), ['a-r1', 'a-r2'], {
|
||||
nuclear: true,
|
||||
leader: { name: 'Alpha Leader' },
|
||||
ideology: 'democracy',
|
||||
relations: { b2: 'ally', c3: 'rival' },
|
||||
});
|
||||
const b2 = nation('b2', 'Bravo', s(75, 40, 20, 30), ['b-r1', 'b-r2'], {
|
||||
ideology: 'fascism',
|
||||
relations: { a1: 'ally' },
|
||||
});
|
||||
const c3 = nation('c3', 'Charlie', s(30, 35, 80, 20), ['c-r1'], {
|
||||
relations: { a1: 'rival' },
|
||||
});
|
||||
|
||||
const state: GameState = {
|
||||
id: 'golden-0000-0000-0000-0000-000000000000',
|
||||
presetId: 'wwii-1939',
|
||||
presetTitle: preset.title,
|
||||
playerNationId: 'a1',
|
||||
turn: 4,
|
||||
date: '1940-02-14',
|
||||
nations: { a1, b2, c3 },
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 9,
|
||||
unowned: ['x-unowned'],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats: {
|
||||
'a-r1': s(60, 55, 70, 80),
|
||||
'a-r2': s(58, 53, 72, 78),
|
||||
'b-r1': s(75, 40, 20, 30),
|
||||
'b-r2': s(70, 42, 22, 28),
|
||||
'c-r1': s(30, 35, 80, 20),
|
||||
},
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
};
|
||||
|
||||
// --- buildUserPrompt arguments: every optional branch exercised. ---
|
||||
const items: RoundItem[] = [
|
||||
{ kind: 'action', text: 'Rearm the northern border' },
|
||||
{ kind: 'diplomacy', to: 'b2', text: 'We can still talk terms, no?' },
|
||||
];
|
||||
const regionReference: Record<string, string> = {
|
||||
'a-r1': 'Alpha North (US)',
|
||||
'a-r2': 'Alpha South (US)',
|
||||
'b-r1': 'Bravo Plain (RU)',
|
||||
'b-r2': 'Bravo Coast (RU)',
|
||||
'c-r1': 'Charlie Isle (FR)',
|
||||
'x-unowned': 'X Released (UN)',
|
||||
};
|
||||
const frontier: string[] = ['b-r1']; // owned by b2 (a non-player) → frontierMilitary
|
||||
const noMansLand: string[] = ['x-unowned'];
|
||||
const markers: MarkerInfo[] = [
|
||||
{ id: 'mk0001', type: 'gathering', name: 'Bravo Rally', regionKey: 'b-r1', owner: 'b2' },
|
||||
{ id: 'mk0002', type: 'rocket', name: 'Charlie Silo', regionKey: 'c-r1', owner: 'c3' },
|
||||
{ id: 'mk0003', type: 'capital', name: 'Alpha Cap', regionKey: 'a-r1', owner: 'a1' },
|
||||
];
|
||||
const wars: WarInfo[] = [
|
||||
{ id: 'w1', sides: ['a1', 'b2'], aims: ['control the border'] },
|
||||
{ id: 'w2', sides: ['b2', 'c3'] },
|
||||
];
|
||||
const relations: Record<string, RelationStatus> = { b2: 'ally', c3: 'rival' };
|
||||
const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }];
|
||||
const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }];
|
||||
const nationResources: Record<string, RegionResources> = {
|
||||
a1: { oil: 0.5, iron: 0.2, food: 0.3, industry: 0.8 },
|
||||
b2: { oil: 0.1, iron: 0.9, food: 0.1, industry: 0.2 },
|
||||
c3: { oil: 0.2, iron: 0.3, food: 0.7, industry: 0.4 },
|
||||
};
|
||||
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
|
||||
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
|
||||
|
||||
/**
|
||||
* Assert the current prompt output is byte-identical to the committed golden —
|
||||
* or, under REGEN_GOLDENS=1, rewrite the golden from the current output (a
|
||||
* manual regeneration step; the test then passes without asserting).
|
||||
*/
|
||||
function checkGolden(name: string, current: string): void {
|
||||
const file = path.join(goldensDir, name);
|
||||
if (REGEN) {
|
||||
mkdirSync(goldensDir, { recursive: true });
|
||||
writeFileSync(file, current, 'utf8');
|
||||
return;
|
||||
}
|
||||
const expected = readFileSync(file, 'utf8');
|
||||
expect(current, `golden ${name} mismatch`).toBe(expected);
|
||||
}
|
||||
|
||||
/** The canonical (default / no-op) value of EVERY TurnResult field. */
|
||||
function fullDefaultTurn(): TurnResult {
|
||||
return {
|
||||
date: '1939-09-01',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] },
|
||||
leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
disasters: { events: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** A "meaningful" (non-default) value for each ability field. */
|
||||
function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
|
||||
switch (field) {
|
||||
case 'takeovers':
|
||||
return [{ nationRef: 'a1', newRegimeName: 'Alpha Regime' }];
|
||||
case 'wars':
|
||||
return { declare: [{ sides: ['a1', 'b2'] }], end: [] };
|
||||
case 'hybrid':
|
||||
return { ops: [{ type: 'spy', from: 'a1', target: 'b2' }] };
|
||||
case 'economy':
|
||||
return { trade: [{ a: 'a1', b: 'c3' }], embargo: [] };
|
||||
case 'industry':
|
||||
return { projects: [{ regionKey: 'a-r1', type: 'oil' }] };
|
||||
case 'nuclear':
|
||||
return { arm: ['a1'], strikes: [] };
|
||||
case 'leaders':
|
||||
return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] };
|
||||
case 'ideology':
|
||||
return { changes: [{ target: 'a1', to: 'democracy' }] };
|
||||
case 'disasters':
|
||||
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
|
||||
}
|
||||
}
|
||||
|
||||
describe('ABILITIES registry (v0.40.0, #67)', () => {
|
||||
it('is the canonical 9-ability order (takeovers → disasters)', () => {
|
||||
expect(ABILITIES.map((a) => a.field)).toEqual([
|
||||
'takeovers',
|
||||
'wars',
|
||||
'hybrid',
|
||||
'economy',
|
||||
'industry',
|
||||
'nuclear',
|
||||
'leaders',
|
||||
'ideology',
|
||||
'disasters',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists 9 distinct fields, each a key of TurnResult', () => {
|
||||
const fields = ABILITIES.map((a) => a.field);
|
||||
expect(fields).toHaveLength(9);
|
||||
expect(new Set(fields).size).toBe(9);
|
||||
const all = fullDefaultTurn();
|
||||
for (const f of fields) expect(f in all).toBe(true);
|
||||
});
|
||||
|
||||
it('system prompt (no tool) is byte-identical to the committed golden', () => {
|
||||
checkGolden('system-no-tool.txt', buildSystemPrompt(preset, false));
|
||||
});
|
||||
|
||||
it('system prompt (tool) is byte-identical to the committed golden', () => {
|
||||
checkGolden('system-tool.txt', buildSystemPrompt(preset, true));
|
||||
});
|
||||
|
||||
it('user prompt is byte-identical to the committed golden', () => {
|
||||
checkGolden(
|
||||
'user.txt',
|
||||
buildUserPrompt(
|
||||
state,
|
||||
items,
|
||||
regionReference,
|
||||
frontier,
|
||||
noMansLand,
|
||||
markers,
|
||||
wars,
|
||||
relations,
|
||||
trades,
|
||||
embargoes,
|
||||
nationResources,
|
||||
industryProjects,
|
||||
nuclear,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("each ability's default() equals the canonical TurnResult default for its field", () => {
|
||||
const all = fullDefaultTurn();
|
||||
for (const a of ABILITIES) {
|
||||
expect(a.default(), `default() for ${a.field}`).toEqual(all[a.field]);
|
||||
}
|
||||
});
|
||||
|
||||
it('default() is a FRESH value on every call (never a shared reference)', () => {
|
||||
for (const a of ABILITIES) {
|
||||
expect(a.default(), `${a.field} default should be a fresh object`).not.toBe(a.default());
|
||||
}
|
||||
});
|
||||
|
||||
it('default() is never meaningful; a populated value always is', () => {
|
||||
for (const a of ABILITIES) {
|
||||
expect(a.isMeaningful(a.default()), `${a.field} default should not be meaningful`).toBe(false);
|
||||
expect(a.isMeaningful(meaningfulFor(a.field)), `${a.field} populated should be meaningful`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares exactly the durable GameState fields the db migration must default', () => {
|
||||
const owned: string[] = [];
|
||||
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
|
||||
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'trades', 'wars']);
|
||||
});
|
||||
|
||||
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
|
||||
// The registry is a plain ordered array the engine iterates uniformly. A
|
||||
// structural (field-name-agnostic) loop over the abilities — schema line,
|
||||
// constraint bullet — must keep working when a 10th ability is appended.
|
||||
// This documents the extension point without widening the closed
|
||||
// AbilityField union.
|
||||
interface Loopable {
|
||||
field: string;
|
||||
schemaLine(): string;
|
||||
constraint(): string;
|
||||
}
|
||||
const dummy: Loopable = {
|
||||
field: 'custom',
|
||||
schemaLine: () => ' "custom": [],',
|
||||
constraint: () => '- A custom ability.',
|
||||
};
|
||||
const extended: readonly (Ability<AbilityField> | Loopable)[] = [...ABILITIES, dummy];
|
||||
expect(extended.map((a) => a.field)).toEqual([
|
||||
...ABILITIES.map((a) => a.field),
|
||||
'custom',
|
||||
]);
|
||||
// every uniform loop runs over all N abilities, the appended one included:
|
||||
expect(extended.map((a) => a.schemaLine())).toHaveLength(10);
|
||||
expect(extended.map((a) => a.constraint())).toHaveLength(10);
|
||||
expect(extended[9].schemaLine()).toBe(' "custom": [],');
|
||||
});
|
||||
|
||||
it('the engine still re-exports every name its callers import from it', () => {
|
||||
// Backward compatibility: server/index.ts and engine.test.ts import these
|
||||
// from the engine; the re-export block must keep them all exported (a
|
||||
// missing re-export would also fail typecheck — this pins it at runtime).
|
||||
const reexported: Record<string, unknown> = {
|
||||
// shared primitives (./abilities/shared)
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
NEW_NATION_STATS,
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
// ability modules (./abilities)
|
||||
isHybridOpType,
|
||||
isIndustryType,
|
||||
activeWarPairs,
|
||||
atWar,
|
||||
syncWarRelations,
|
||||
HYBRID_STAT_EFFECT,
|
||||
shiftRelationTowardHostile,
|
||||
validateEconomyOps,
|
||||
validateHybridOps,
|
||||
validateIdeologyOps,
|
||||
validateIndustryOps,
|
||||
validateLeaderOps,
|
||||
validateNuclearOps,
|
||||
validateTakeovers,
|
||||
validateWarOps,
|
||||
applyIndustryEffects,
|
||||
// disasters (v0.41.0, #63)
|
||||
validateDisasterEvents,
|
||||
applyDisasterEvent,
|
||||
isDisasterType,
|
||||
isDisasterSeverity,
|
||||
DISASTER_TYPES,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_EFFECTS,
|
||||
};
|
||||
for (const [name, value] of Object.entries(reexported)) {
|
||||
expect(value, `engine should re-export ${name}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Shared reference-resolution / id-generation primitives used across the
|
||||
// modular LLM abilities (and re-exported by the core engine). Living here —
|
||||
// and in `./types.ts` rather than in `engine.ts` — breaks the engine <->
|
||||
// ability import cycle: the ability modules import from here, `engine.ts`
|
||||
// imports + re-exports them for the existing callers (`server/index.ts`, the
|
||||
// unit tests), and no ability imports the engine. Pure and dependency-free.
|
||||
|
||||
import type { GameState, NationStats } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Resolve a nation reference (the `nationRef` of a NationOp, or the `target` /
|
||||
* `from` / `sides` of an ability op — the LLM may write either the nation's
|
||||
* stable `id` or its human-readable `name`) to a concrete nation id: an exact
|
||||
* id match wins, then a unique case-insensitive name match. Returns `null` when
|
||||
* the reference is empty, unknown, or ambiguous (two or more nations share that
|
||||
* name) — every caller treats `null` as "no-op" and nothing here ever throws.
|
||||
*/
|
||||
export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (state.nations[r]) return r; // exact id first
|
||||
const matches = Object.values(state.nations).filter(
|
||||
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||
);
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Resolve a **region** reference (a region op's `region` /
|
||||
* `regionKey` — the LLM may write either the region's stable **key** or its
|
||||
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||||
* then a unique case-insensitive name match (via `nameOf`). Returns `null` when
|
||||
* the reference is empty, unknown, or ambiguous (two or more regions share that
|
||||
* name) — every caller treats `null` as "drop this change" and nothing here
|
||||
* ever throws. Mirrors {@link resolveNationRef}. `validKeys` is the set of all
|
||||
* region keys and `nameOf` maps a key to its human name (both supplied by
|
||||
* server/regions.ts).
|
||||
*/
|
||||
export function resolveRegionKey(
|
||||
ref: string,
|
||||
validKeys: ReadonlySet<string>,
|
||||
nameOf: (key: string) => string | undefined,
|
||||
): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (validKeys.has(r)) return r; // exact key first
|
||||
const lower = r.toLowerCase();
|
||||
let match: string | null = null;
|
||||
for (const k of validKeys) {
|
||||
const name = nameOf(k);
|
||||
if (name && name.trim().toLowerCase() === lower) {
|
||||
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||||
match = k;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused nation id for a newly created nation (split parts,
|
||||
* v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds each id it
|
||||
* creates to `taken` as it goes, so ids stay unique within an op and are never
|
||||
* reused from a removed nation.
|
||||
*/
|
||||
export function freshNationId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `n${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused war id for a newly declared {@link War}
|
||||
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id it
|
||||
* creates to `taken` as it goes, so ids stay unique within a turn and are never
|
||||
* reused from an ended war.
|
||||
*/
|
||||
export function freshWarId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `w${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline stats a freshly created nation starts with (split parts,
|
||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT a
|
||||
* copy of the parent's stats (a small rump state is not the parent's military
|
||||
* power).
|
||||
*/
|
||||
export const NEW_NATION_STATS: NationStats = {
|
||||
military: 50,
|
||||
economy: 50,
|
||||
stability: 50,
|
||||
population: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Coerce an unknown LLM JSON value to a trimmed, non-empty string, or `null`
|
||||
* when it is not a non-empty string. An optional `max` caps the length (used
|
||||
* for the 200-char caps on nationOp fields). Shared by every defensive
|
||||
* validator (engine + the ability modules). Never throws.
|
||||
*/
|
||||
export function str(s: unknown, max = 0): string | null {
|
||||
if (typeof s !== 'string') return null;
|
||||
const t = s.trim();
|
||||
if (!t) return null;
|
||||
return max ? t.slice(0, max) : t;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// The TAKEOVER ability (v0.32.0, #54) as a modular LLM-ability (v0.40.0, #67).
|
||||
// The model may overthrow a low-stability nation hosting a "gathering" marker
|
||||
// (a coup / revolution) — an AUTOMATIC, non-military regime change that RENAMES
|
||||
// the nation to its new regime (the stable id, territory, relations, stats and
|
||||
// playerNationId all survive). The validator and the apply body are moved
|
||||
// VERBATIM from the pre-refactor engine.ts; the user-payload `takeoverRisks`
|
||||
// hint (v0.30, #50 — the bounded soft-lever nudge listing the nations ripe for
|
||||
// a takeover) moves here too, since it is this ability's own prompt data.
|
||||
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { Takeover } from '../../shared/protocol';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||
* revolution that renames a nation to its new regime name). Defensive like
|
||||
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||
* here. Entries with either field missing or whitespace-only are dropped.
|
||||
*/
|
||||
export function validateTakeovers(raw: unknown): Takeover[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: Takeover[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
const nationRef = str(o.nationRef, 200);
|
||||
const newRegimeName = str(o.newRegimeName, 200);
|
||||
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) Apply a takeover to `next` (in place). For each takeover:
|
||||
* 1. Resolve the target nation via `resolveNationRef` (id first, then
|
||||
* case-insensitive unique name). Skip (no-op) if `null` — an
|
||||
* unknown or ambiguous ref is silently dropped, never thrown.
|
||||
* 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
|
||||
* the display name changes — the stable id, territory, relations
|
||||
* (keyed by id), stats, and `playerNationId` (which references the
|
||||
* id, not the name) are all preserved. The player keeps leading an
|
||||
* overthrown nation under its new regime.
|
||||
*/
|
||||
export function applyTakeover(next: GameState, t: Takeover): void {
|
||||
const id = resolveNationRef(next, t.nationRef);
|
||||
if (id === null) return; // unknown / ambiguous ref: no-op
|
||||
const n = next.nations[id];
|
||||
if (!n) return; // defensive: id resolved but nation missing (corrupt state)
|
||||
n.name = t.newRegimeName;
|
||||
}
|
||||
|
||||
export const takeovers: Ability<'takeovers'> = {
|
||||
field: 'takeovers',
|
||||
default: () => [],
|
||||
validate: (raw) => validateTakeovers(raw),
|
||||
apply: (state, data) => {
|
||||
// Applied after the per-region recompute (the rename doesn't depend on
|
||||
// territory or stats, so it is order-independent with respect to those
|
||||
// steps) and before the markers block (a marker's owner is a nation id,
|
||||
// not a name, so the rename does not affect the owner recompute).
|
||||
for (const t of data) applyTakeover(state, t);
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||
constraint: () =>
|
||||
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||
isMeaningful: (data) => data.length > 0,
|
||||
stub: () => [],
|
||||
payload: (ctx) => {
|
||||
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||||
// AND that currently have a "gathering" marker on one of their regions are
|
||||
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||||
// seizure) — the model can act on them this turn. One line per matching
|
||||
// nation, emitted only when there is at least one; the rest of the payload
|
||||
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||
let takeoverRisks: string[] | undefined;
|
||||
if (ctx.markers !== undefined && ctx.markers.length > 0) {
|
||||
const risks = new Set<string>();
|
||||
for (const m of ctx.markers) {
|
||||
if (m.type !== 'gathering') continue;
|
||||
const ownerId = m.owner;
|
||||
if (!ownerId) continue;
|
||||
const n = ctx.state.nations[ownerId];
|
||||
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||
}
|
||||
if (risks.size > 0) {
|
||||
takeoverRisks = [...risks].map((id) => {
|
||||
const n = ctx.state.nations[id];
|
||||
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
return takeoverRisks !== undefined ? { takeoverRisks } : {};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// The shared type surface for the modular LLM-ability system (v0.40.0, #67).
|
||||
// Every ability is a plain object conforming to `Ability`, listed (in its
|
||||
// canonical apply / prompt / payload order) by `ABILITIES` in ./index.ts. This
|
||||
// file holds only the *types* the ability modules program against — the
|
||||
// concrete implementations live in the sibling one-file-per-ability modules.
|
||||
// No runtime deps here (type-only imports), so it stays pure like the rest of
|
||||
// the `server/abilities` package.
|
||||
|
||||
import type {
|
||||
Embargo,
|
||||
GameState,
|
||||
IndustryProject,
|
||||
RegionResources,
|
||||
RelationStatus,
|
||||
TradeAgreement,
|
||||
} from '../../shared/types';
|
||||
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
||||
|
||||
/**
|
||||
* The 9 per-feature LLM abilities a turn can exercise, by the `TurnResult`
|
||||
* field each one owns. This is the closed universe the ordered `ABILITIES`
|
||||
* registry (./index.ts) is built from: a turn's world changes are exactly the
|
||||
* core engine effects (events / stats / territory / no-man's-land / nation-ops
|
||||
* / markers) plus, in order, these nine abilities.
|
||||
*/
|
||||
export type AbilityField =
|
||||
| 'takeovers'
|
||||
| 'wars'
|
||||
| 'hybrid'
|
||||
| 'economy'
|
||||
| 'industry'
|
||||
| 'nuclear'
|
||||
| 'leaders'
|
||||
| 'ideology'
|
||||
| 'disasters';
|
||||
|
||||
/**
|
||||
* Everything a single ability's `apply` may need from the surrounding turn.
|
||||
*
|
||||
* `previous` is the world state BEFORE this turn: reference resolution and
|
||||
* sole-ownership checks must see the state before this turn's own mutations, so
|
||||
* abilities read `previous` (not the state they are mutating) to resolve refs.
|
||||
* `actingTurn` is the turn number stamped onto anything the ability creates or
|
||||
* ends. The region-manifest resolvers are each optional and are absent when
|
||||
* their manifest is not loaded — an ability must degrade gracefully when one is
|
||||
* missing (never throw).
|
||||
*/
|
||||
export interface AbilityCtx {
|
||||
/** The world state before this turn (a read-only snapshot). */
|
||||
readonly previous: GameState;
|
||||
/** The turn number stamped onto anything this ability creates / ends. */
|
||||
actingTurn: number;
|
||||
/** All valid region keys (public/region-keys.json); undefined if not loaded. */
|
||||
readonly validRegionKeys?: ReadonlySet<string>;
|
||||
/** Region key -> human name; undefined if the key manifest is not loaded. */
|
||||
readonly regionNameOf?: (key: string) => string | undefined;
|
||||
/** Region key -> {area (km²), population}; undefined if not loaded (v0.28 #46). */
|
||||
readonly geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||||
/** Region key -> synthesized resource profile; undefined if not loaded (v0.36 #59). */
|
||||
readonly resourceOf?: (key: string) => RegionResources | undefined;
|
||||
/**
|
||||
* Region key -> centroid anchor {lat, lon, spread}; undefined if not loaded
|
||||
* (v0.27 #44).
|
||||
*/
|
||||
readonly centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
||||
/**
|
||||
* The `getNeighbours` tool executor (verify land borders). Available to an
|
||||
* ability that needs it; none of the current nine use it (it drives the
|
||||
* core territory/conquest logic), but the type is here so a future one can.
|
||||
*/
|
||||
readonly getNeighbours?: (regionKeys: string[]) => string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs the user-prompt builder hands to an ability's `payload` method —
|
||||
* the optional buildUserPrompt parameters (the core-only ones — `items`,
|
||||
* `regionReference`, `frontier`, `noMansLand`, `nuclear` — are consumed by the
|
||||
* engine, not by an ability). Each ability reports only the payload keys it
|
||||
* owns here; the engine merges them into the payload in the historical
|
||||
* buildUserPrompt order (pinned by the user-prompt golden fixture).
|
||||
*/
|
||||
export interface UserPromptCtx {
|
||||
readonly state: GameState;
|
||||
readonly markers?: MarkerInfo[];
|
||||
readonly wars?: WarInfo[];
|
||||
readonly relations?: Record<string, RelationStatus>;
|
||||
readonly trades?: TradeAgreement[];
|
||||
readonly embargoes?: Embargo[];
|
||||
readonly nationResources?: Record<string, RegionResources>;
|
||||
readonly industryProjects?: IndustryProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single LLM ability — a feature the model can exercise via one named
|
||||
* `TurnResult` field. Each ability is self-contained: it validates the raw
|
||||
* JSON for its field, documents itself in the system prompt (a JSON schema line
|
||||
* and a HARD CONSTRAINTS bullet), contributes its keys to the user payload,
|
||||
* reports whether a non-default value makes the turn meaningful, declares the
|
||||
* `GameState` fields it owns (for db migrations), and applies its field to the
|
||||
* world. `K` pins the field so every value-bearing method operates on the
|
||||
* field's exact `TurnResult[K]` type (no `any`).
|
||||
*/
|
||||
export interface Ability<K extends AbilityField = AbilityField> {
|
||||
/** The `TurnResult` field this ability owns (its stable, unique name). */
|
||||
readonly field: K;
|
||||
|
||||
/**
|
||||
* The default (empty / no-op) value of this field — a FRESH value on every
|
||||
* call (never a shared mutable reference). Used by the "unclear" fallback and
|
||||
* the deterministic stub.
|
||||
*/
|
||||
default(): TurnResult[K];
|
||||
|
||||
/**
|
||||
* Parse and defensively validate the raw (unknown) JSON for this field into a
|
||||
* clean `TurnResult[K]`. Never throws — a missing / malformed value yields
|
||||
* `default()`. NOTE: the hybrid / leaders / ideology low-level validators
|
||||
* return a bare inner list, so those abilities' `validate` wraps that list
|
||||
* into the field's object shape (`{ ops }` / `{ changes }`).
|
||||
*/
|
||||
validate(raw: unknown): TurnResult[K];
|
||||
|
||||
/**
|
||||
* Apply this field's (validated) value to the world, MUTATING `state` in
|
||||
* place. Never throws — any unresolvable reference or missing manifest
|
||||
* degrades to a no-op. Abilities run in the fixed order of `ABILITIES`.
|
||||
*/
|
||||
apply(state: GameState, data: TurnResult[K], ctx: AbilityCtx): void;
|
||||
|
||||
/**
|
||||
* The JSON schema line for this field in the system prompt's JSON block
|
||||
* (e.g. `' "wars": {"declare":[…],"end":[…]},'`). Inserted by the engine,
|
||||
* in `ABILITIES` order, between the `nationOps` block and the `markers` line.
|
||||
*/
|
||||
schemaLine(): string;
|
||||
|
||||
/**
|
||||
* The HARD CONSTRAINTS bullet documenting this ability (e.g.
|
||||
* `'- Wars ("wars") are named conflicts, …'`). Inserted by the engine, in
|
||||
* `ABILITIES` order, between the `nationOps` bullets and the
|
||||
* "- Prefer 1-4 events" bullet.
|
||||
*/
|
||||
constraint(): string;
|
||||
|
||||
/**
|
||||
* Whether a non-default value of this field makes the turn "meaningful"
|
||||
* (not an empty round). Mirrors the historical per-field predicate exactly.
|
||||
*/
|
||||
isMeaningful(data: TurnResult[K]): boolean;
|
||||
|
||||
/**
|
||||
* The `GameState` fields this ability owns and that must be defaulted on a
|
||||
* legacy SQLite row (db.ts migration, only-if-undefined). `undefined` for
|
||||
* abilities that add no durable state (takeovers, hybrid, nuclear, leaders,
|
||||
* ideology, disasters).
|
||||
*/
|
||||
readonly stateFields?: readonly string[];
|
||||
|
||||
/**
|
||||
* The deterministic stub's value for this field. Every ability returns
|
||||
* `default()` (the stub never exercises an ability, so e2e is unaffected) —
|
||||
* kept as a zero-arg method so the stub stays a uniform registry loop (the
|
||||
* stub has no real `GameState` / `AbilityCtx` to hand one; it needs none).
|
||||
*/
|
||||
stub(): TurnResult[K];
|
||||
|
||||
/**
|
||||
* The keys this ability contributes to the user prompt payload, each included
|
||||
* only when it should be emitted (matching buildUserPrompt's "omit when
|
||||
* undefined" behavior). The engine merges the returned entries into the
|
||||
* payload at each key's exact historical position (pinned by the user-prompt
|
||||
* golden).
|
||||
*/
|
||||
payload(ctx: UserPromptCtx): Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// The WARS ability (v0.33.0, #56) as a modular LLM-ability (v0.40.0, #67).
|
||||
// Wars are named first-class conflicts (`GameState.wars`), not just a `war`
|
||||
// relation: the model declares a war (≥2 belligerents, optional aims) or ends
|
||||
// one by id (armistice / capitulation). Declaring drags in each belligerent's
|
||||
// mutual-defense allies; the derived `war` relation is synced from the ACTIVE
|
||||
// wars by `syncWarRelations` (a war-ended pair falls back to `hostile`). The
|
||||
// validator and the apply body are moved VERBATIM from the pre-refactor
|
||||
// engine.ts.
|
||||
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { TurnResult, WarDeclareOp, WarEndOp } from '../../shared/protocol';
|
||||
import { freshWarId, resolveNationRef } from './shared';
|
||||
import { syncWarRelations } from './apply-helpers';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
|
||||
* `{ declare, end }` shape. Defensive like the rest of the parser — never
|
||||
* throws. `declare`: each entry must be an object whose `sides` is an array
|
||||
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
|
||||
* yields at least TWO distinct sides — anything else (a one-sided or
|
||||
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
|
||||
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
|
||||
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
|
||||
* of exactly `armistice` or `capitulation` (anything else is dropped);
|
||||
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
|
||||
* input yields `{ declare: [], end: [] }`.
|
||||
*/
|
||||
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
|
||||
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.declare)) {
|
||||
for (const e of o.declare) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const d = e as Record<string, unknown>;
|
||||
const sides = (Array.isArray(d.sides) ? d.sides : [])
|
||||
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
|
||||
.filter((s) => s !== '')
|
||||
// dedupe case-insensitively? No — sides are nation ids: keep exact
|
||||
// strings, drop exact duplicates only (resolution is later).
|
||||
.filter((s, i, arr) => arr.indexOf(s) === i);
|
||||
if (sides.length < 2) continue; // a war needs at least two belligerents
|
||||
const aims = Array.isArray(d.aims)
|
||||
? (d.aims as unknown[])
|
||||
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
|
||||
.filter((a) => a !== '')
|
||||
.slice(0, 5)
|
||||
: undefined;
|
||||
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(o.end)) {
|
||||
for (const e of o.end) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const w = e as Record<string, unknown>;
|
||||
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
|
||||
if (!id) continue;
|
||||
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
|
||||
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
|
||||
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Apply the turn's wars ops to the (cloned) `next` state,
|
||||
* mutating it in place and never throwing. Two kinds of change, applied in
|
||||
* this order against `next`:
|
||||
*
|
||||
* (1) DECLARE: each validated declare is resolved to concrete nation ids
|
||||
* (`resolveNationRef`, so the LLM may use an id OR a unique name) and
|
||||
* becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
|
||||
* with the acting turn and the turn's new date. Mutually-defensive
|
||||
* allies of the belligerents are DRAGGED IN: any nation that is an
|
||||
* `ally` of a belligerent (per the current, post-relations relations)
|
||||
* and is not already a side joins the war's `sides`. A declare with
|
||||
* fewer than two DISTINCT resolved sides is dropped; a declare whose
|
||||
* sides are all already contained in an existing ACTIVE war is a
|
||||
* duplicate and is skipped (the existing war stands).
|
||||
* (2) END: each validated end targets a war BY ID and sets its status to
|
||||
* the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
|
||||
* affected — an unknown id or an already-ended war is a no-op.
|
||||
* (A war may be declared and ended in the SAME turn: declare runs
|
||||
* first, so the just-declared id is a valid end target.)
|
||||
* (3) RELATION SYNC: `syncWarRelations(prev, next)` derives the `war`
|
||||
* relation from the ACTIVE wars — the load-bearing step that keeps
|
||||
* the relation table honest with the war entities. `prev` is the
|
||||
* state this turn started from (ctx.previous).
|
||||
*
|
||||
* Nothing here throws: every unresolvable / invalid op is dropped.
|
||||
*/
|
||||
export function applyWars(
|
||||
next: GameState,
|
||||
data: TurnResult['wars'],
|
||||
prev: GameState,
|
||||
actingTurn: number,
|
||||
): void {
|
||||
const takenWarIds = new Set(next.wars.map((w) => w.id));
|
||||
// (1) DECLARE
|
||||
for (const d of data.declare) {
|
||||
const resolved: string[] = [];
|
||||
for (const ref of d.sides) {
|
||||
const id = resolveNationRef(next, ref);
|
||||
if (id !== null && !resolved.includes(id)) resolved.push(id);
|
||||
}
|
||||
if (resolved.length < 2) continue; // not enough distinct belligerents
|
||||
// Duplicate: an active war already contains all of these sides.
|
||||
const dup = next.wars.some(
|
||||
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
|
||||
);
|
||||
if (dup) continue;
|
||||
const sides = [...resolved];
|
||||
// Mutual-defense drag-in: each belligerent's allies join the war.
|
||||
for (const s of resolved) {
|
||||
const rels = next.nations[s]?.relations ?? {};
|
||||
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
|
||||
if (!sides.includes(ally)) sides.push(ally);
|
||||
}
|
||||
}
|
||||
const id = freshWarId(takenWarIds);
|
||||
takenWarIds.add(id);
|
||||
next.wars.push({
|
||||
id,
|
||||
sides,
|
||||
declaredTurn: actingTurn,
|
||||
declaredDate: next.date,
|
||||
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
// (2) END (by id; active only)
|
||||
for (const e of data.end) {
|
||||
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
|
||||
if (!w) continue; // unknown id / already ended: no-op
|
||||
w.status = e.outcome;
|
||||
}
|
||||
// (3) RELATION SYNC (prev = the state this turn started from)
|
||||
syncWarRelations(prev, next);
|
||||
}
|
||||
|
||||
export const wars: Ability<'wars'> = {
|
||||
field: 'wars',
|
||||
default: () => ({ declare: [], end: [] }),
|
||||
validate: (raw) => validateWarOps(raw),
|
||||
apply: (state, data, ctx) => applyWars(state, data, ctx.previous, ctx.actingTurn),
|
||||
schemaLine: () =>
|
||||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||
constraint: () =>
|
||||
'- 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".',
|
||||
isMeaningful: (data) => data.declare.length > 0 || data.end.length > 0,
|
||||
stateFields: ['wars'],
|
||||
stub: () => ({ declare: [], end: [] }),
|
||||
payload: (ctx) => (ctx.wars !== undefined ? { wars: ctx.wars } : {}),
|
||||
};
|
||||
@@ -31,6 +31,13 @@ function makeState(id: string): GameState {
|
||||
regionStats: {
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
},
|
||||
// (v0.33.0, #56) no wars.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) no active trades or embargoes.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36.0, #59) no industry projects.
|
||||
industry: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +154,96 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
|
||||
delete (legacy as { embargoes?: unknown[] }).embargoes;
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.trades).toEqual([]);
|
||||
expect(loaded!.embargoes).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.36.0 #59) a legacy row saved before the industry field existed loads with it defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { industry?: unknown[] }).industry; // a pre-v0.36 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.industry).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.37.0 #60) a legacy row saved before the nation nuclear flag existed loads with it absent (not capable)', () => {
|
||||
const db = openDb(':memory:');
|
||||
// makeState has no nation.nuclear field — a pre-v0.37 save. The flag is
|
||||
// optional and not re-stamped by load, so an absent flag stays absent.
|
||||
db.save(makeState('legacy'));
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.nuclear).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.37.0 #60) a set nation nuclear flag round-trips through save/load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('nuc');
|
||||
s.nations.uk.nuclear = true; // armed (e.g. by a turn's nuclear.arm op)
|
||||
db.save(s);
|
||||
const loaded = db.load('nuc');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.nuclear).toBe(true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.38.0 #61) a legacy row saved before the nation leader field existed loads with it absent (no leader)', () => {
|
||||
const db = openDb(':memory:');
|
||||
// makeState has no nation.leader field — a pre-v0.38 save. The field is
|
||||
// optional and not re-stamped by load, so an absent leader stays absent.
|
||||
db.save(makeState('legacy'));
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.leader).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.38.0 #61) a set nation leader round-trips through save/load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('ldr');
|
||||
s.nations.uk.leader = { name: 'Admiral Old' };
|
||||
db.save(s);
|
||||
const loaded = db.load('ldr');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.leader).toEqual({ name: 'Admiral Old' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.39.0 #62) a legacy row saved before the nation ideology field existed loads with it absent (no recorded government)', () => {
|
||||
const db = openDb(':memory:');
|
||||
// makeState has no nation.ideology field — a pre-v0.39 save. The field is
|
||||
// optional and not re-stamped by load, so an absent ideology stays absent.
|
||||
db.save(makeState('legacy'));
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.ideology).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.39.0 #62) a set nation ideology round-trips through save/load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('ideo');
|
||||
s.nations.uk.ideology = 'fascism';
|
||||
db.save(s);
|
||||
const loaded = db.load('ideo');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.nations.uk.ideology).toBe('fascism');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { migrateRegionStats } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { ABILITIES } from './abilities';
|
||||
|
||||
export interface Db {
|
||||
save(state: GameState): void;
|
||||
@@ -49,6 +50,20 @@ export function openDb(filePath: string): Db {
|
||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||
if (state.markers === undefined) state.markers = [];
|
||||
// (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every
|
||||
// ability-owned GameState field (wars / trades / embargoes / industry):
|
||||
// a legacy game has none of them. The ABILITIES registry is the single
|
||||
// source of truth for which fields the LLM abilities own — each is a
|
||||
// list, so the default is `[]`, applied only-if-undefined so a saved
|
||||
// value is never wiped. (Any pre-v0.33 `war` relation stays a
|
||||
// bare/implicit relation — see syncWarRelations, which never
|
||||
// force-creates a War entity.)
|
||||
for (const a of ABILITIES) {
|
||||
const fields = a.stateFields;
|
||||
if (!fields) continue;
|
||||
const row = state as unknown as Record<string, unknown>;
|
||||
for (const f of fields) if (row[f] === undefined) row[f] = [];
|
||||
}
|
||||
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||
// ONCE at load time (and never re-migrated afterwards — the field is
|
||||
// then present): each owned region is seeded with a copy of its owner's
|
||||
|
||||
+2373
-26
File diff suppressed because it is too large
Load Diff
+258
-118
@@ -4,27 +4,32 @@
|
||||
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
|
||||
// llm.ts (injectable) and is invoked by runTurn.
|
||||
import type {
|
||||
Embargo,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
IndustryProject,
|
||||
MapMarker,
|
||||
Nation,
|
||||
NationStats,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RegionResources,
|
||||
RegionStats,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
TradeAgreement,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
canConquer,
|
||||
clampStat,
|
||||
deriveNationResources,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
isNuclearCapable,
|
||||
makeEvent,
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
type RegionStatChange,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
type WarInfo,
|
||||
} from '../shared/protocol';
|
||||
import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
@@ -45,11 +51,50 @@ import {
|
||||
type WireMessage,
|
||||
} from './llm';
|
||||
import { ADVISOR_MARKER } from './stub';
|
||||
import {
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
NEW_NATION_STATS,
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
str,
|
||||
} from './abilities/shared';
|
||||
import { ABILITIES, type AbilityCtx, type UserPromptCtx } from './abilities';
|
||||
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
|
||||
// importing these shared primitives from the engine unchanged.
|
||||
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
|
||||
|
||||
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||||
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
||||
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
|
||||
// (v0.40.0, #67) Re-exported from the ability modules so the existing callers
|
||||
// (server/index.ts, the unit tests) keep importing these names from the engine.
|
||||
export {
|
||||
isHybridOpType,
|
||||
isIndustryType,
|
||||
activeWarPairs,
|
||||
atWar,
|
||||
syncWarRelations,
|
||||
HYBRID_STAT_EFFECT,
|
||||
shiftRelationTowardHostile,
|
||||
validateEconomyOps,
|
||||
validateHybridOps,
|
||||
validateIdeologyOps,
|
||||
validateIndustryOps,
|
||||
validateLeaderOps,
|
||||
validateNuclearOps,
|
||||
validateTakeovers,
|
||||
validateWarOps,
|
||||
applyIndustryEffects,
|
||||
validateDisasterEvents,
|
||||
applyDisasterEvent,
|
||||
isDisasterType,
|
||||
isDisasterSeverity,
|
||||
DISASTER_TYPES,
|
||||
DISASTER_SEVERITIES,
|
||||
DISASTER_EFFECTS,
|
||||
} from './abilities';
|
||||
|
||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||
|
||||
@@ -149,7 +194,9 @@ export function playerFrontier(
|
||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||
* may verify borders with the tool before answering. The contract also
|
||||
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b), and the
|
||||
* trade & embargo economy layer (v0.35.0, #58): bilateral trades and
|
||||
* unilateral embargoes, each ended with a `lift: true` op.
|
||||
*/
|
||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||
const rules = preset.simulationRules
|
||||
@@ -199,6 +246,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
...ABILITIES.map((a) => a.schemaLine()),
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -216,7 +264,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
...ABILITIES.map((a) => a.constraint()),
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
'- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.',
|
||||
@@ -233,6 +282,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
* other consumer) can read the exact items. The framing instruction tells the
|
||||
* model to simulate the combined consequences of the whole batch (or to let the
|
||||
* world advance on its own when the batch is empty) and return one TurnResult.
|
||||
* (v0.35.0, #58) It also carries the currently active `trades` / `embargoes`
|
||||
* (omitted entirely when undefined, like the other optional fields) so the
|
||||
* model can reference them — and `lift` them — in `TurnResult.economy`.
|
||||
*/
|
||||
export function buildUserPrompt(
|
||||
state: GameState,
|
||||
@@ -260,6 +312,60 @@ export function buildUserPrompt(
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
markers?: MarkerInfo[],
|
||||
/**
|
||||
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
|
||||
* stable id, sides and aims. Emitted verbatim (even when empty) so the
|
||||
* model can reference a war's id in `TurnResult.wars.end` to end it.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
wars?: WarInfo[],
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the
|
||||
* model sees the relation ladder it can nudge with `information` / `proxy`
|
||||
* hybrid ops and knows the current state before an `exposed` backfire.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>,
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active bilateral trade agreements (each a
|
||||
* pair of nation ids). Emitted verbatim (even when empty) so the model
|
||||
* knows which trades exist and can end one with `economy.trade` +
|
||||
* `lift: true`. Omitted entirely when undefined.
|
||||
*/
|
||||
trades?: TradeAgreement[],
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active unilateral embargoes (each an
|
||||
* `from` → `target` pair of nation ids). Emitted verbatim (even when
|
||||
* empty) so the model knows which embargoes are in force and can lift one
|
||||
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
|
||||
*/
|
||||
embargoes?: Embargo[],
|
||||
/**
|
||||
* (v0.36, #59) Each nation's AGGREGATE resource profile (nation id -> the
|
||||
* area×population-weighted mean of the resource profiles of the regions it
|
||||
* owns, via `deriveNationResources`). Emitted verbatim (even when empty) so
|
||||
* the model can reason about which nations are rich in which resource and
|
||||
* where to build industry projects. Omitted entirely when undefined (no
|
||||
* resource manifest loaded).
|
||||
*/
|
||||
nationResources?: Record<string, RegionResources>,
|
||||
/**
|
||||
* (v0.36, #59) The game's currently ACTIVE industry projects (each a region
|
||||
* key + project type). Emitted verbatim (even when empty) so the model knows
|
||||
* which regions already host a project (and should not be re-declared) and
|
||||
* can reference them. Omitted entirely when undefined.
|
||||
*/
|
||||
industryProjects?: IndustryProject[],
|
||||
/**
|
||||
* (v0.37.0, #60) Each nation's nuclear capability (nation id -> boolean),
|
||||
* computed by the caller via `isNuclearCapable` (the nation's `nuclear` flag
|
||||
* OR a current `rocket` marker). When defined, each nation in the `state.
|
||||
* nations` map is stamped with `nuclear: true|false` so the model knows who
|
||||
* can arm/strike and who can MAD-retaliate. Omitted entirely (no `nuclear`
|
||||
* key on any nation) when undefined.
|
||||
*/
|
||||
nuclear?: Record<string, boolean>,
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -325,31 +431,19 @@ export function buildUserPrompt(
|
||||
// seizure) — the model can act on them this turn. One line per matching
|
||||
// nation, emitted only when there is at least one; the rest of the payload
|
||||
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||
let takeoverRisks: string[] | undefined;
|
||||
if (markers !== undefined && markers.length > 0) {
|
||||
const risks = new Set<string>();
|
||||
for (const m of markers) {
|
||||
if (m.type !== 'gathering') continue;
|
||||
const ownerId = m.owner;
|
||||
if (!ownerId) continue;
|
||||
const n = state.nations[ownerId];
|
||||
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||
}
|
||||
if (risks.size > 0) {
|
||||
takeoverRisks = [...risks].map((id) => {
|
||||
const n = state.nations[id];
|
||||
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.40.0, #67) The ability-specific payload fields (takeoverRisks, wars,
|
||||
// relations, trades, embargoes, resources, industry) are now computed by
|
||||
// each Ability's payload() method and merged at their exact positions below.
|
||||
const payload: Record<string, unknown> = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
date: state.date,
|
||||
playerNationId: state.playerNationId,
|
||||
nations: Object.fromEntries(
|
||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
||||
Object.entries(state.nations).map(([id, n]) => [
|
||||
id,
|
||||
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
|
||||
]),
|
||||
),
|
||||
recentEvents: state.events.slice(-5).map((e) => ({
|
||||
turn: e.turn,
|
||||
@@ -369,9 +463,26 @@ export function buildUserPrompt(
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
// (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's
|
||||
// payload() at its exact position (the key order is load-bearing for the
|
||||
// golden test).
|
||||
const upCtx: UserPromptCtx = {
|
||||
state,
|
||||
markers,
|
||||
wars,
|
||||
relations,
|
||||
trades,
|
||||
embargoes,
|
||||
nationResources,
|
||||
industryProjects,
|
||||
};
|
||||
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
Object.assign(payload, ABILITIES[1].payload(upCtx)); // wars → wars
|
||||
Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations
|
||||
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
|
||||
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -430,13 +541,6 @@ export function parseTurnResult(
|
||||
return validateTurnResult(obj, regionKeyResolver);
|
||||
}
|
||||
|
||||
function str(s: unknown, max = 0): string | null {
|
||||
if (typeof s !== 'string') return null;
|
||||
const t = s.trim();
|
||||
if (!t) return null;
|
||||
return max ? t.slice(0, max) : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
|
||||
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
|
||||
@@ -509,6 +613,15 @@ export function validateTurnResult(
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] },
|
||||
leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
disasters: { events: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -599,6 +712,13 @@ export function validateTurnResult(
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
// The 9 per-feature LLM abilities (v0.40.0, #67): each one's validate call
|
||||
// is owned by its Ability module in the ABILITIES registry. The field's raw
|
||||
// value is passed through a.validate() which returns the validated field shape.
|
||||
for (const a of ABILITIES) {
|
||||
(result as unknown as Record<string, unknown>)[a.field] = a.validate((o as Record<string, unknown>)[a.field]);
|
||||
}
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -705,14 +825,6 @@ export function validateRegionChanges(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
||||
* a proposal is a real pending-world change the player will resolve, so it
|
||||
* must survive the retry/nudge path instead of being replaced by the generic
|
||||
* "unclear" result).
|
||||
*/
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
result.events.length > 0 ||
|
||||
@@ -721,6 +833,10 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.40.0, #67) any of the 9 per-feature abilities having a non-empty
|
||||
// payload is a real world change (each Ability's isMeaningful predicate
|
||||
// is the per-ability half of the old 18-sub-field check).
|
||||
ABILITIES.some((a) => a.isMeaningful(result[a.field])) ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -765,6 +881,13 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||||
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||||
* the raw key. Pure either way.
|
||||
*
|
||||
* `resourceOf` (v0.36, #59) resolves a region key to its synthesized resource
|
||||
* profile (`{ oil, iron, food, industry }`, from public/region-resources.json
|
||||
* via server/regions.ts). It is used to scale each industry project's per-turn
|
||||
* economy boost by the region's `industry` value (a region with a strong
|
||||
* industry profile yields a bigger return). Without it the boost is the
|
||||
* unscaled base value — the engine stays pure and deterministic either way.
|
||||
*/
|
||||
export function applyTurnResult(
|
||||
state: GameState,
|
||||
@@ -773,12 +896,16 @@ export function applyTurnResult(
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
resourceOf?: (key: string) => RegionResources | undefined,
|
||||
): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||||
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||||
next.regionStats = next.regionStats ?? {};
|
||||
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
|
||||
// field starts empty (the turn then only ever adds to it).
|
||||
next.industry = next.industry ?? [];
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
@@ -973,6 +1100,24 @@ export function applyTurnResult(
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// (v0.40.0, #67) The 9 per-feature LLM abilities, applied in ABILITIES
|
||||
// order (takeovers → wars → hybrid → economy → industry → nuclear →
|
||||
// leaders → ideology → disasters). Each Ability's apply method handles its
|
||||
// own field's validated data against the post-territory/post-recompute
|
||||
// `next` state. The AbilityCtx carries the shared resolvers.
|
||||
const abilityCtx: AbilityCtx = {
|
||||
previous: state,
|
||||
actingTurn,
|
||||
validRegionKeys: validKeys,
|
||||
regionNameOf,
|
||||
geoOf,
|
||||
resourceOf,
|
||||
centroidOf,
|
||||
};
|
||||
for (const a of ABILITIES) {
|
||||
a.apply(next, result[a.field], abilityCtx);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1096,81 +1241,6 @@ function validPendingOpIndex(state: GameState, index: number): boolean {
|
||||
return Number.isInteger(index) && index >= 0 && index < len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
|
||||
* write either the nation's stable `id` or its human-readable `name`) to a
|
||||
* concrete nation id: an exact id match wins, then a unique case-insensitive
|
||||
* name match. Returns `null` when the reference is empty, unknown, or
|
||||
* ambiguous (two or more nations share that name) — every caller treats
|
||||
* `null` as "no-op" and nothing here ever throws.
|
||||
*/
|
||||
export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (state.nations[r]) return r; // exact id first
|
||||
const matches = Object.values(state.nations).filter(
|
||||
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||
);
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
|
||||
* `region` — the LLM may write either the region's stable **key** or its
|
||||
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||||
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
|
||||
* when the reference is empty, unknown, or ambiguous (two or more regions
|
||||
* share that name) — every caller treats `null` as "drop this change" and
|
||||
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
|
||||
* the set of all region keys and `nameOf` maps a key to its human name (both
|
||||
* supplied by server/regions.ts).
|
||||
*/
|
||||
export function resolveRegionKey(
|
||||
ref: string,
|
||||
validKeys: ReadonlySet<string>,
|
||||
nameOf: (key: string) => string | undefined,
|
||||
): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (validKeys.has(r)) return r; // exact key first
|
||||
const lower = r.toLowerCase();
|
||||
let match: string | null = null;
|
||||
for (const k of validKeys) {
|
||||
const name = nameOf(k);
|
||||
if (name && name.trim().toLowerCase() === lower) {
|
||||
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||||
match = k;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused nation id for a newly created nation (split
|
||||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||
* each id it creates to `taken` as it goes, so ids stay unique within an
|
||||
* op and are never reused from a removed nation.
|
||||
*/
|
||||
export function freshNationId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `n${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline stats a freshly created nation starts with (split parts,
|
||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||
* a copy of the parent's stats (a small rump state is not the parent's
|
||||
* military power).
|
||||
*/
|
||||
export const NEW_NATION_STATS: NationStats = {
|
||||
military: 50,
|
||||
economy: 50,
|
||||
stability: 50,
|
||||
population: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
|
||||
* the removed nations' ids from every remaining nation's `relations` map so
|
||||
@@ -1431,8 +1501,17 @@ export async function runTurn(opts: {
|
||||
* it falls back to the raw region key. Pure either way.
|
||||
*/
|
||||
regionNameOf?: (key: string) => string | undefined;
|
||||
/**
|
||||
* (v0.36, #59) Region-key -> synthesized resource profile resolver (from
|
||||
* public/region-resources.json via server/regions.ts). Present, each
|
||||
* industry project's per-turn economy boost is scaled by its region's
|
||||
* `industry` value, and each nation's aggregate resource profile is carried
|
||||
* in the user prompt; absent, industry boosts use the unscaled base value
|
||||
* and the profile is omitted.
|
||||
*/
|
||||
resourceOf?: (key: string) => RegionResources | undefined;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf, resourceOf } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
@@ -1448,7 +1527,63 @@ export async function runTurn(opts: {
|
||||
regionKey: m.regionKey,
|
||||
owner: m.ownerNationId,
|
||||
}));
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
|
||||
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
|
||||
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
|
||||
// ended wars stay in GameState.wars as history but are not surfaced.
|
||||
const wars = (state.wars ?? [])
|
||||
.filter((w) => w.status === 'active')
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
sides: w.sides,
|
||||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||
}));
|
||||
// (v0.34.0, #57) the player's current relations to every other nation, so the
|
||||
// model sees the ladder its `information` / `proxy` hybrid ops nudge (and the
|
||||
// starting point before an `exposed` backfire). Always emitted ({} when the
|
||||
// player has no relations yet).
|
||||
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
||||
// (v0.36, #59) each nation's AGGREGATE resource profile (the area×population-
|
||||
// weighted mean of the resource profiles of the regions it owns). Carried in
|
||||
// the user prompt ONLY when the resource manifest is loaded (resourceOf
|
||||
// present); otherwise omitted (undefined) so the prompt — and the stub —
|
||||
// stay identical to the no-manifest case.
|
||||
const nationResources =
|
||||
resourceOf === undefined
|
||||
? undefined
|
||||
: Object.fromEntries(
|
||||
Object.values(state.nations).map((n) => [
|
||||
n.id,
|
||||
deriveNationResources(n.territory, resourceOf, geoOf),
|
||||
]),
|
||||
);
|
||||
// (v0.37.0, #60) each nation's nuclear capability (its `nuclear` flag OR a
|
||||
// current `rocket` marker), so the model knows who can arm/strike and who
|
||||
// can MAD-retaliate. Always emitted (computed from the PRE-turn markers).
|
||||
const nuclearCapability = Object.fromEntries(
|
||||
Object.values(state.nations).map((n) => [n.id, isNuclearCapable(n, state.markers ?? [])]),
|
||||
);
|
||||
const user = buildUserPrompt(
|
||||
state,
|
||||
items,
|
||||
regionReference,
|
||||
frontier,
|
||||
state.unowned ?? [],
|
||||
markers,
|
||||
wars,
|
||||
relations,
|
||||
// (v0.35.0, #58) the active trade / embargo lists, so the model can
|
||||
// reference — and `lift` — what is currently in force.
|
||||
state.trades,
|
||||
state.embargoes,
|
||||
// (v0.36, #59) the aggregate resource profiles (undefined without the
|
||||
// manifest) + the active industry projects, so the model knows which
|
||||
// regions already host a project and which nations are rich in which
|
||||
// resource.
|
||||
nationResources,
|
||||
state.industry ?? [],
|
||||
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
|
||||
nuclearCapability,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
@@ -1491,7 +1626,7 @@ export async function runTurn(opts: {
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = {
|
||||
const fallback = {
|
||||
date: addDays(state.date, 2),
|
||||
events: [genericUnclearEvent(state)],
|
||||
stateChanges: [],
|
||||
@@ -1501,11 +1636,16 @@ export async function runTurn(opts: {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
} as unknown as Record<string, unknown>;
|
||||
// (v0.40.0, #67) Each ability's default() provides its field's empty value.
|
||||
for (const a of ABILITIES) {
|
||||
fallback[a.field] = a.default();
|
||||
}
|
||||
result = fallback as unknown as TurnResult;
|
||||
}
|
||||
|
||||
return {
|
||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf, resourceOf),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
+147
-5
@@ -17,13 +17,17 @@ import type {
|
||||
RejectNationOpRequest,
|
||||
RemoveQueueItemRequest,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { WorldDraft } from '../shared/scenario';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { makeScenarioStubLlmClient } from './stubScenario';
|
||||
import { finalizeWorld, generateDraft } from './scenario';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, hasResources, loadRegions, neighboursOf, referenceForKeys, regionName, resourceOf, validCountryCodes, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { createTemplateStore } from './templates';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -52,6 +56,8 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||
const presetStore = createPresetStore(presetsDir);
|
||||
// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/).
|
||||
const templateStore = createTemplateStore(path.join(root, 'presets', 'templates'));
|
||||
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||
loadRegions(root);
|
||||
@@ -59,6 +65,10 @@ const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||
const useStub = process.env.LLM_STUB === '1';
|
||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||
// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario
|
||||
// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays
|
||||
// untouched), and the live client otherwise.
|
||||
const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void {
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
/**
|
||||
* (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a
|
||||
* dynamically generated game (`presetId: 'dynamic'` — there is no static preset
|
||||
* file on disk). `runTurn` only consumes `title` / `startDate` /
|
||||
* `worldBackground` / `simulationRules` (never `preset.nations`), so a generic
|
||||
* alternate-history background plus the standard rules is sufficient; `nations`
|
||||
* is carried from the saved world so the type is complete. `startDate` uses the
|
||||
* game's current date (GameState stores no separate start date) — it only feeds
|
||||
* the static "WORLD: <title> — <date>" header of the system prompt.
|
||||
*/
|
||||
function dynamicPresetFromGame(game: GameState): Preset {
|
||||
return {
|
||||
id: game.presetId,
|
||||
title: game.presetTitle,
|
||||
startDate: game.date,
|
||||
summary: game.presetTitle,
|
||||
worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`,
|
||||
simulationRules: [
|
||||
"Advance the shared world for ALL nations, not only the player's.",
|
||||
'Keep every stat value between 0 and 100.',
|
||||
'Only change borders when war or a decisive political shift justifies it.',
|
||||
'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.',
|
||||
'Use only nation ids and region keys that exist in the provided state.',
|
||||
],
|
||||
nations: Object.values(game.nations),
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, stub: useStub, port: PORT });
|
||||
});
|
||||
@@ -140,6 +178,18 @@ app.post('/api/games', (req: Request, res) => {
|
||||
// real region keys — each region starts as a copy of its owner's initial
|
||||
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time
|
||||
// (any preset-level `war` relation stays a bare/implicit one, not an
|
||||
// entity — see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
|
||||
// declares (and lifts) them over time via TurnResult.economy.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36.0, #59) No active industry projects on a fresh game; the LLM
|
||||
// declares them over time via TurnResult.industry (one per region, a
|
||||
// per-region SET).
|
||||
industry: [],
|
||||
};
|
||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||
@@ -278,8 +328,14 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
||||
let preset = presetStore.get(game.presetId);
|
||||
if (!preset) {
|
||||
// (v0.31, #52) A dynamically generated game has no static preset file:
|
||||
// synthesize one from the saved world so the turn engine can run. A
|
||||
// genuinely-missing static preset still 404s.
|
||||
if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' });
|
||||
preset = dynamicPresetFromGame(game);
|
||||
}
|
||||
try {
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
@@ -318,6 +374,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
// event (geo properties.name, else the raw key). Always on — the
|
||||
// resolver is pure and degrades to the key when the manifest is absent.
|
||||
regionNameOf: (k) => regionName(k),
|
||||
// (v0.36.0, #59) when the region-resources manifest (the synthesized
|
||||
// per-region resource profiles) loaded, hand the engine the resolver —
|
||||
// the industry boost is then scaled by each region's `industry` value
|
||||
// (without it boosts use the unscaled base value) and each nation's
|
||||
// aggregate resource profile is carried in the user prompt.
|
||||
resourceOf: hasResources() ? (key) => resourceOf(key) : undefined,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
@@ -347,6 +409,86 @@ app.post('/api/games/:id/advisor', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Dynamic scenario generation (v0.31, #52) ---
|
||||
//
|
||||
// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft
|
||||
// world + an advisor checkpoint list; the client shows both, the player
|
||||
// approves/removes checkpoints and picks a nation; step 2 (POST /commit)
|
||||
// finalizes the world (constrained to the approved checkpoints) into a saved,
|
||||
// playable game. Both steps ride the scenario LLM client (the deterministic
|
||||
// scenario stub under LLM_STUB=1, the live model otherwise). All existing
|
||||
// routes are untouched.
|
||||
|
||||
app.get('/api/scenarios/templates', (_req, res) => {
|
||||
res.json({ templates: templateStore.listMeta() });
|
||||
});
|
||||
|
||||
app.post('/api/scenarios/draft', async (req, res) => {
|
||||
const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number };
|
||||
const template = templateStore.get(body.templateId ?? '');
|
||||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||
const codes = validCountryCodes();
|
||||
try {
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: scenarioLlm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
// No region data → no code filtering (the draft keeps whatever codes the
|
||||
// model emitted; expansion degrades to the raw keys, like presets do).
|
||||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||
});
|
||||
if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' });
|
||||
res.json({ world, checkpoints });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: `generate failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/scenarios/commit', async (req, res) => {
|
||||
const body = (req.body ?? {}) as {
|
||||
templateId?: string;
|
||||
description?: string;
|
||||
year?: number;
|
||||
draft?: WorldDraft;
|
||||
approvedCheckpoints?: string[];
|
||||
playerNationId?: string;
|
||||
};
|
||||
const template = templateStore.get(body.templateId ?? '');
|
||||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||
const approvedCheckpoints = Array.isArray(body.approvedCheckpoints)
|
||||
? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
|
||||
: [];
|
||||
const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : '';
|
||||
const codes = validCountryCodes();
|
||||
try {
|
||||
const state = await finalizeWorld({
|
||||
llm: scenarioLlm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
draft: body.draft,
|
||||
playerNationId,
|
||||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||
expand: (cs) => expandCountriesToKeys(cs),
|
||||
id: randomUUID(),
|
||||
title: template.name,
|
||||
startDate: `${year}-01-01`,
|
||||
});
|
||||
db.save(state);
|
||||
res.json({ id: state.id });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: `commit failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSE stream ---
|
||||
app.get('/sse/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
@@ -384,4 +526,4 @@ app.listen(PORT, () => {
|
||||
});
|
||||
|
||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||
export { app, db, llm };
|
||||
export { app, db, llm, scenarioLlm, templateStore };
|
||||
|
||||
@@ -52,6 +52,13 @@ const state: GameState = {
|
||||
markers: [],
|
||||
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36.0, #59) the map is ownership-only; industry is unused here.
|
||||
industry: [],
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationResources,
|
||||
deriveNationStats,
|
||||
IDEOLOGY_STABILITY_EFFECT,
|
||||
migrateRegionStats,
|
||||
nationForPrompt,
|
||||
seedRegionStats,
|
||||
weightedMedian,
|
||||
} from '../shared/protocol';
|
||||
@@ -41,6 +44,10 @@ function mkState(
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats: {} as GameState['regionStats'],
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
};
|
||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||
@@ -136,6 +143,81 @@ describe('deriveNationStats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- deriveNationResources ---------------------------------------------------
|
||||
|
||||
describe('deriveNationResources (v0.36.0 #59)', () => {
|
||||
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
|
||||
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
|
||||
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
|
||||
};
|
||||
const geo: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 100, population: 1000 },
|
||||
b: { area: 1000, population: 1000 },
|
||||
};
|
||||
|
||||
it('weights by area×population: the larger region dominates the average', () => {
|
||||
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
|
||||
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
|
||||
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
|
||||
// equal weights 1,1 → the true mean 0.5 on every axis
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
|
||||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||
});
|
||||
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
|
||||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a region key that has no resource-profile entry', () => {
|
||||
// b has no profile → only a is counted → a's values
|
||||
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
|
||||
expect(out).toEqual(prof.a);
|
||||
});
|
||||
|
||||
it('returns the zero profile when none of the owned regions has a profile', () => {
|
||||
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
|
||||
oil: 0, iron: 0, food: 0, industry: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a single region yields exactly that region\'s profile', () => {
|
||||
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
|
||||
});
|
||||
|
||||
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
|
||||
const g: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 0, population: 1000 },
|
||||
b: { area: 1000, population: 1000 },
|
||||
};
|
||||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
|
||||
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds each axis to 2 decimal places', () => {
|
||||
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
|
||||
const g: Record<string, { area: number; population: number }> = {
|
||||
a: { area: 1, population: 1 },
|
||||
b: { area: 2, population: 1 },
|
||||
};
|
||||
const out = deriveNationResources(
|
||||
['a', 'b'],
|
||||
(k) => k === 'a'
|
||||
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
|
||||
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
|
||||
(k) => g[k],
|
||||
);
|
||||
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- seedRegionStats ---------------------------------------------------------
|
||||
|
||||
describe('seedRegionStats', () => {
|
||||
@@ -257,3 +339,30 @@ describe('canConquer', () => {
|
||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||
});
|
||||
});
|
||||
|
||||
// --- IDEOLOGY_STABILITY_EFFECT (v0.39, #62) ----------------------------------
|
||||
|
||||
describe('IDEOLOGY_STABILITY_EFFECT (v0.39.0 #62)', () => {
|
||||
it('a regime shift is a deterministic −5 stability dip', () => {
|
||||
expect(IDEOLOGY_STABILITY_EFFECT).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
// --- nationForPrompt — ideology (v0.39, #62) ---------------------------------
|
||||
|
||||
describe('nationForPrompt — ideology (v0.39.0 #62)', () => {
|
||||
it('stamps the ideology when a nation has one', () => {
|
||||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||||
n.ideology = 'fascism';
|
||||
expect(nationForPrompt(n).ideology).toBe('fascism');
|
||||
// and it survives JSON serialisation (the user prompt path)
|
||||
expect(JSON.stringify(nationForPrompt(n))).toContain('"ideology":"fascism"');
|
||||
});
|
||||
|
||||
it('omits the ideology from the JSON when a nation has none (undefined key dropped)', () => {
|
||||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||||
expect(n.ideology).toBeUndefined();
|
||||
// JSON.stringify drops undefined-valued keys → no "ideology" in the prompt
|
||||
expect(JSON.stringify(nationForPrompt(n))).not.toContain('ideology');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
hasCentroids,
|
||||
hasGeo,
|
||||
hasRegionData,
|
||||
hasResources,
|
||||
loadRegions,
|
||||
neighboursOf,
|
||||
referenceForKeys,
|
||||
regionName,
|
||||
resetRegions,
|
||||
resourceOf,
|
||||
validKeys,
|
||||
} from './regions';
|
||||
import type { GameState } from '../shared/types';
|
||||
@@ -110,6 +112,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
markers: [],
|
||||
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||
regionStats: {},
|
||||
wars: [],
|
||||
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36.0, #59) unused here (allTerritoryKeys is ownership-only).
|
||||
industry: [],
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
@@ -168,6 +176,16 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.36 #59) hasResources() is true and resourceOf resolves a real key to a 4-value profile', () => {
|
||||
expect(hasResources()).toBe(true);
|
||||
// exact spot-check values pinned from the committed manifest (Ain, FRA)
|
||||
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
|
||||
});
|
||||
|
||||
it('(v0.36 #59) resourceOf returns undefined for an unknown key', () => {
|
||||
expect(resourceOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('graceful degradation with no region data', () => {
|
||||
beforeEach(() => resetRegions());
|
||||
|
||||
@@ -187,6 +205,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
// and no region-geo: the per-region stats weighting degrades to uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-resources: industry boosts use the unscaled base value
|
||||
expect(hasResources()).toBe(false);
|
||||
expect(resourceOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +265,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
||||
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-resources (not copied into this dist/) -> boosts unscaled
|
||||
expect(hasResources()).toBe(false);
|
||||
expect(resourceOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.36 #59) a dist/ WITH the region-resources manifest loads it (prod layout)', () => {
|
||||
copyFileSync(
|
||||
path.join(realRoot, 'public', 'region-resources.json'),
|
||||
path.join(prodRoot, 'dist', 'region-resources.json'),
|
||||
);
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasResources()).toBe(true);
|
||||
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
|
||||
});
|
||||
|
||||
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||
|
||||
+76
-2
@@ -19,6 +19,9 @@
|
||||
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||||
// — the weighting source for the per-region stats model (v0.28, #46)
|
||||
// - resourceOf(): a region key's synthesized resource profile (from
|
||||
// region-resources.json) — the data behind the LLM's industry lever
|
||||
// (v0.36, #59)
|
||||
//
|
||||
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||
@@ -26,7 +29,7 @@
|
||||
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GameState } from '../shared/types';
|
||||
import type { GameState, RegionResources } from '../shared/types';
|
||||
|
||||
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||
interface RegionInfo {
|
||||
@@ -49,6 +52,8 @@ interface RegionData {
|
||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||||
geo: Record<string, { area: number; population: number }>;
|
||||
/** region key -> resource profile (from public/region-resources.json, v0.36 #59). */
|
||||
resources: Record<string, RegionResources>;
|
||||
}
|
||||
|
||||
let data: RegionData | null = null;
|
||||
@@ -77,6 +82,7 @@ export function loadRegions(root: string): boolean {
|
||||
const adjacencyFile = 'region-adjacency.json';
|
||||
const centroidsFile = 'region-centroids.json';
|
||||
const regionGeoFile = 'region-geo.json';
|
||||
const regionResourcesFile = 'region-resources.json';
|
||||
const base = [
|
||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||
@@ -185,7 +191,40 @@ export function loadRegions(root: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
|
||||
// The region-resources manifest (v0.36, #59): a synthesized four-value
|
||||
// resource profile per region key — the data behind the LLM's industry
|
||||
// lever. OPTIONAL like the others: a missing/broken file means "no
|
||||
// resource data" — industry boosts degrade to the unscaled base value and
|
||||
// the prompt omits the aggregate profile — while everything else keeps
|
||||
// working.
|
||||
let resources: Record<string, RegionResources> = {};
|
||||
const resourcesPath = path.join(base, regionResourcesFile);
|
||||
if (existsSync(resourcesPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(resourcesPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ oil?: unknown; iron?: unknown; food?: unknown; industry?: unknown }
|
||||
>;
|
||||
// Keep only well-formed entries (all four values finite numbers in
|
||||
// [0, 1]); a corrupt file degrades to "none".
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (
|
||||
v &&
|
||||
typeof v.oil === 'number' && Number.isFinite(v.oil) && v.oil >= 0 && v.oil <= 1 &&
|
||||
typeof v.iron === 'number' && Number.isFinite(v.iron) && v.iron >= 0 && v.iron <= 1 &&
|
||||
typeof v.food === 'number' && Number.isFinite(v.food) && v.food >= 0 && v.food <= 1 &&
|
||||
typeof v.industry === 'number' && Number.isFinite(v.industry) && v.industry >= 0 && v.industry <= 1
|
||||
) {
|
||||
resources[k] = { oil: v.oil, iron: v.iron, food: v.food, industry: v.industry };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region-resources.json, continuing without it', err);
|
||||
resources = {};
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo, resources };
|
||||
return validKeys.size > 0;
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region data', err);
|
||||
@@ -259,6 +298,18 @@ export function validKeys(): ReadonlySet<string> {
|
||||
return data ? data.validKeys : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of ISO_A3 country codes that own at least one sub-region in the
|
||||
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
|
||||
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
|
||||
* values in regions.geo.json. Empty when no region data is loaded; callers
|
||||
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
|
||||
* draft's territory is checked against real countries.
|
||||
*/
|
||||
export function validCountryCodes(): ReadonlySet<string> {
|
||||
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-adjacency manifest loaded with at least one entry.
|
||||
* Callers (the server's jump route) use this to decide whether to hand the
|
||||
@@ -310,6 +361,29 @@ export function geoOf(key: string): { area: number; population: number } | undef
|
||||
return data?.geo[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-resources manifest loaded with at least one entry
|
||||
* (v0.36, #59). The server's jump route uses this to decide whether to hand
|
||||
* the engine a resource resolver (which lets a turn scale industry boosts by a
|
||||
* region's `industry` value and carry each nation's aggregate resource profile
|
||||
* in the LLM prompt) or skip both (industry boosts fall back to the unscaled
|
||||
* base value).
|
||||
*/
|
||||
export function hasResources(): boolean {
|
||||
return data !== null && Object.keys(data.resources).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthesized resource profile for a region key (v0.36, #59): the
|
||||
* four-value `{ oil, iron, food, industry }` from the committed
|
||||
* `public/region-resources.json`. `undefined` for an unknown key or when no
|
||||
* resource data is loaded (industry boosts then use the unscaled base value,
|
||||
* and the aggregate profile is simply omitted from the prompt).
|
||||
*/
|
||||
export function resourceOf(key: string): RegionResources | undefined {
|
||||
return data?.resources[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* The union of the region keys that share a land border with any of the given
|
||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
relationFromScore,
|
||||
validateWorldDraft,
|
||||
worldDraftToGameState,
|
||||
type WorldDraft,
|
||||
} from '../shared/scenario';
|
||||
import type { NationStats } from '../shared/types';
|
||||
|
||||
// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per
|
||||
// country), standing in for server/regions.expandCountriesToKeys in these
|
||||
// pure-function tests.
|
||||
const fakeExpand = (countries: string[]): string[] =>
|
||||
countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||
|
||||
const S = (m: number, e: number, s: number, p: number): NationStats => ({
|
||||
military: m,
|
||||
economy: e,
|
||||
stability: s,
|
||||
population: p,
|
||||
});
|
||||
|
||||
describe('relationFromScore (v0.31.0, #52)', () => {
|
||||
it('maps the five sentiment bands to RelationStatus', () => {
|
||||
expect(relationFromScore(100)).toBe('ally');
|
||||
expect(relationFromScore(60)).toBe('ally');
|
||||
expect(relationFromScore(0)).toBe('neutral');
|
||||
expect(relationFromScore(30)).toBe('neutral');
|
||||
expect(relationFromScore(-1)).toBe('rival');
|
||||
expect(relationFromScore(-40)).toBe('rival');
|
||||
expect(relationFromScore(-41)).toBe('hostile');
|
||||
expect(relationFromScore(-80)).toBe('hostile');
|
||||
expect(relationFromScore(-81)).toBe('war');
|
||||
expect(relationFromScore(-100)).toBe('war');
|
||||
});
|
||||
|
||||
it('clamps out-of-range scores before banding', () => {
|
||||
expect(relationFromScore(250)).toBe('ally');
|
||||
expect(relationFromScore(-250)).toBe('war');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWorldDraft (v0.31.0, #52)', () => {
|
||||
it('returns a clean draft from a valid payload', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } },
|
||||
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw);
|
||||
expect(d).not.toBeNull();
|
||||
const draft = d as WorldDraft;
|
||||
expect(draft.nations).toHaveLength(2);
|
||||
expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' });
|
||||
expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90));
|
||||
expect(draft.nations[0].territory).toEqual(['DEU']);
|
||||
expect(draft.nations[0].relations).toEqual({ uk: 80 });
|
||||
expect(draft.events).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50
|
||||
});
|
||||
|
||||
it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => {
|
||||
const codes = new Set(['DEU', 'GBR', 'FRA']);
|
||||
const raw = {
|
||||
nations: [
|
||||
{
|
||||
name: 'Mixed',
|
||||
// lower-case, duplicated, and one unknown code (USA) to be dropped.
|
||||
territory: ['deu', 'DEU', 'usa', 'gbr'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft;
|
||||
expect(d.nations[0].territory).toEqual(['DEU', 'GBR']);
|
||||
});
|
||||
|
||||
it('keeps unknown codes when no validCountryCodes set is supplied', () => {
|
||||
const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] };
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].territory).toEqual(['AAA', 'BBB']);
|
||||
});
|
||||
|
||||
it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } },
|
||||
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
// de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped.
|
||||
expect(d.nations[0].relations).toEqual({ uk: 55 });
|
||||
// uk: the id-keyed ref to de is kept, clamped (already in range).
|
||||
expect(d.nations[1].relations).toEqual({ de: -70 });
|
||||
});
|
||||
|
||||
it('clamps relation scores to -100..100', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } },
|
||||
{ id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].relations).toEqual({ b: 100 });
|
||||
expect(d.nations[1].relations).toEqual({ a: -100 });
|
||||
});
|
||||
|
||||
it('suffices duplicate ids until unique and derives ids from names', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'x', name: 'First', territory: ['AAA'] },
|
||||
{ id: 'x', name: 'Second', territory: ['BBB'] },
|
||||
{ name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
const ids = d.nations.map((n) => n.id);
|
||||
expect(ids).toEqual(['x', 'x-2', 'united-kingdom']);
|
||||
});
|
||||
|
||||
it('keeps backstory events that name a known nation and drops the rest', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', territory: ['DEU'] },
|
||||
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'] },
|
||||
],
|
||||
events: [
|
||||
{ nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' },
|
||||
{ nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' },
|
||||
{ nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' },
|
||||
{ nation: 'de', type: 'political' }, // no title -> dropped
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.events).toHaveLength(2);
|
||||
expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' });
|
||||
expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' });
|
||||
});
|
||||
|
||||
it('returns null (never throws) for unusable / garbage payloads', () => {
|
||||
expect(validateWorldDraft(null)).toBeNull();
|
||||
expect(validateWorldDraft('nope')).toBeNull();
|
||||
expect(validateWorldDraft([])).toBeNull();
|
||||
expect(validateWorldDraft({})).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [] })).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name
|
||||
expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object
|
||||
// no nation owns a *valid* territory -> unusable.
|
||||
expect(
|
||||
validateWorldDraft(
|
||||
{ nations: [{ name: 'X', territory: ['ZZZ'] }] },
|
||||
{ validCountryCodes: new Set(['DEU']) },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('worldDraftToGameState (v0.31.0, #52)', () => {
|
||||
function draft(): WorldDraft {
|
||||
return {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } },
|
||||
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } },
|
||||
],
|
||||
events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }],
|
||||
};
|
||||
}
|
||||
|
||||
it('expands ISO_A3 territory to region keys and seeds per-region stats', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'uk', {
|
||||
id: 'g1',
|
||||
title: 'Custom World',
|
||||
startDate: '1939-09-01',
|
||||
});
|
||||
expect(g.id).toBe('g1');
|
||||
expect(g.presetTitle).toBe('Custom World');
|
||||
expect(g.date).toBe('1939-09-01');
|
||||
expect(g.turn).toBe(1);
|
||||
expect(g.version).toBe(1);
|
||||
expect(g.playerNationId).toBe('uk');
|
||||
expect(g.unowned).toEqual([]);
|
||||
expect(g.markers).toEqual([]);
|
||||
expect(g.pendingNationOps).toEqual([]);
|
||||
expect(g.pending).toEqual([]);
|
||||
|
||||
// de owns DEU -> two region keys; uk owns GBR+IRL -> four.
|
||||
expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']);
|
||||
expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']);
|
||||
|
||||
// Every owned region is seeded with a copy of its owner's stats.
|
||||
expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats);
|
||||
expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats);
|
||||
// No entry for unowned regions.
|
||||
expect(g.regionStats['ZZZ-a']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps numeric relations to RelationStatus', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||
expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally
|
||||
expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war
|
||||
});
|
||||
|
||||
it('seeds the event feed from backstory events at turn 1 / startDate', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' });
|
||||
expect(g.events).toHaveLength(1);
|
||||
expect(g.events[0]).toMatchObject({
|
||||
turn: 1,
|
||||
date: '2035-01-01',
|
||||
nation: 'de',
|
||||
type: 'political',
|
||||
title: 'The new order',
|
||||
outcome: 'neutral',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||
expect(g.id).toBe('');
|
||||
expect(g.presetTitle).toBe('Custom World');
|
||||
expect(g.date).toBe('1970-01-01');
|
||||
expect(g.presetId).toBe('dynamic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52):
|
||||
// the prompt builders, the defensive parsers, generateDraft / finalizeWorld
|
||||
// over an injected LlmClient, and the deterministic scenario stub.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildGeneratePrompt,
|
||||
buildRegeneratePrompt,
|
||||
finalizeWorld,
|
||||
generateDraft,
|
||||
parseGenerate,
|
||||
parseRegenerate,
|
||||
SCENARIO_GENERATE_MARKER,
|
||||
SCENARIO_REGENERATE_MARKER,
|
||||
} from './scenario';
|
||||
import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario';
|
||||
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||
import type { ChatMessage, LlmClient } from './llm';
|
||||
|
||||
const TEMPLATE: EraTemplate = {
|
||||
id: 'near-future',
|
||||
name: 'Near Future',
|
||||
year: 2035,
|
||||
eraDescription: 'A fractured, multipolar 2035.',
|
||||
suggestedPowers: [
|
||||
{ name: 'United States', region: 'USA', note: 'declining hegemon' },
|
||||
{ name: 'China', region: 'CHN', note: 'rising industrial power' },
|
||||
],
|
||||
tensions: ['Indo-Pacific standoff', 'Climate migration'],
|
||||
};
|
||||
|
||||
const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']);
|
||||
|
||||
/** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */
|
||||
function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient {
|
||||
return {
|
||||
async chat(messages) {
|
||||
if (typeof canned === 'function') return canned(messages);
|
||||
return canned ?? '{"world":{"nations":[]},"checkpoints":[]}';
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const GOOD_GENERATE = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -60 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -60 },
|
||||
},
|
||||
],
|
||||
},
|
||||
checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'],
|
||||
});
|
||||
|
||||
const GOOD_REGENERATE = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 70, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -70 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -70 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('buildGeneratePrompt', () => {
|
||||
it('embeds the era, description, year and the valid country codes', () => {
|
||||
const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES);
|
||||
expect(system).toContain(SCENARIO_GENERATE_MARKER);
|
||||
expect(system).toContain('"checkpoints"');
|
||||
expect(user).toContain('near-future');
|
||||
expect(user).toContain('a cold war in space');
|
||||
expect(user).toContain('2041');
|
||||
expect(user).toContain('CHN');
|
||||
expect(user).toContain('validCountryCodes');
|
||||
});
|
||||
|
||||
it('omits the code list when no valid codes are supplied', () => {
|
||||
const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined);
|
||||
expect(system).toContain('standard ISO 3166-1 alpha-3');
|
||||
expect(user).not.toContain('validCountryCodes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRegeneratePrompt', () => {
|
||||
it('embeds the approved checkpoints and the draft when present', () => {
|
||||
const draft: WorldDraft = {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const { system, user } = buildRegeneratePrompt(
|
||||
TEMPLATE,
|
||||
'desc',
|
||||
2035,
|
||||
['Alba presses its advantage.'],
|
||||
VALID_CODES,
|
||||
draft,
|
||||
);
|
||||
expect(system).toContain(SCENARIO_REGENERATE_MARKER);
|
||||
expect(user).toContain('approvedCheckpoints');
|
||||
expect(user).toContain('Alba presses its advantage.');
|
||||
expect(user).toContain('draft');
|
||||
expect(user).toContain('alba');
|
||||
});
|
||||
|
||||
it('omits the draft field when none is supplied', () => {
|
||||
const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined);
|
||||
expect(user).not.toContain('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGenerate / parseRegenerate (defensive)', () => {
|
||||
it('parses a valid generate answer into a world + checkpoints', () => {
|
||||
const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES);
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations).toHaveLength(2);
|
||||
expect(world!.nations[0].id).toBe('alba');
|
||||
expect(world!.nations[0].territory).toEqual(['FRA']);
|
||||
expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']);
|
||||
});
|
||||
|
||||
it('strips code fences and drops unknown country codes', () => {
|
||||
const raw = '```json\n' + GOOD_GENERATE + '\n```';
|
||||
const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null world + empty checkpoints for garbage', () => {
|
||||
const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES);
|
||||
expect(world).toBeNull();
|
||||
expect(checkpoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a valid regenerate answer (no checkpoints echoed)', () => {
|
||||
const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES);
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations[0].stats.stability).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDraft (injected client)', () => {
|
||||
it('returns a validated world + checkpoints from the model', async () => {
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: fakeClient(GOOD_GENERATE),
|
||||
template: TEMPLATE,
|
||||
description: 'a cold war in space',
|
||||
year: 2041,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations).toHaveLength(2);
|
||||
expect(checkpoints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('nudges once when the first answer is unusable', async () => {
|
||||
let call = 0;
|
||||
const client: LlmClient = {
|
||||
async chat() {
|
||||
call += 1;
|
||||
return call === 1 ? 'total garbage' : GOOD_GENERATE;
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: client,
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(call).toBe(2);
|
||||
expect(world).not.toBeNull();
|
||||
expect(checkpoints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns null world when even the nudge fails', async () => {
|
||||
const { world } = await generateDraft({
|
||||
llm: fakeClient('still no json'),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(world).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeWorld (injected client)', () => {
|
||||
const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||
|
||||
it('converts the finalized world to a playable GameState', async () => {
|
||||
const state = await finalizeWorld({
|
||||
llm: fakeClient(GOOD_REGENERATE),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
approvedCheckpoints: ['Alba presses its advantage.'],
|
||||
playerNationId: 'alba',
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand,
|
||||
id: 'g-1',
|
||||
title: 'Custom World',
|
||||
startDate: '2035-01-01',
|
||||
});
|
||||
expect(state.id).toBe('g-1');
|
||||
expect(state.presetId).toBe('dynamic');
|
||||
expect(state.playerNationId).toBe('alba');
|
||||
expect(state.turn).toBe(1);
|
||||
expect(state.date).toBe('2035-01-01');
|
||||
// FRA expands to two region keys; the per-region stats are seeded.
|
||||
expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']);
|
||||
expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats);
|
||||
});
|
||||
|
||||
it('falls back to the first nation when the model drops the player nation', async () => {
|
||||
// The regenerated world has no 'alba' — only 'boreas'.
|
||||
const dropped = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const state = await finalizeWorld({
|
||||
llm: fakeClient(dropped),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
approvedCheckpoints: [],
|
||||
playerNationId: 'alba', // not in the world
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand,
|
||||
});
|
||||
expect(state.playerNationId).toBe('boreas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scenario stub (deterministic, LLM_STUB=1)', () => {
|
||||
it('is deterministic: same input → same generate output', async () => {
|
||||
const llm = makeScenarioStubLlmClient();
|
||||
const a = await generateDraft({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'd',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
const b = await generateDraft({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'completely different description',
|
||||
year: 1999,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(a.world).toEqual(b.world);
|
||||
expect(a.checkpoints).toEqual(b.checkpoints);
|
||||
expect(a.world!.nations).toHaveLength(3);
|
||||
expect(a.checkpoints).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('finalize respects the approved checkpoints (deterministically)', async () => {
|
||||
const baseline = stubWorldFor([]);
|
||||
const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']);
|
||||
const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||
const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||
const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||
const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||
// Approving a checkpoint that names Alba raises Alba's stability only.
|
||||
expect(albaWith).toBe(albaBase + 5);
|
||||
expect(boreasWith).toBe(boreasBase);
|
||||
});
|
||||
|
||||
it('finalizeWorld over the stub returns a playable game honoring approvals', async () => {
|
||||
const llm = makeScenarioStubLlmClient();
|
||||
const state = await finalizeWorld({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'd',
|
||||
year: 2035,
|
||||
approvedCheckpoints: ['Boreas fortifies its northern frontier.'],
|
||||
playerNationId: 'boreas',
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand: (cs) => cs.flatMap((c) => [`${c}-a`]),
|
||||
id: 'stub-1',
|
||||
});
|
||||
expect(state.id).toBe('stub-1');
|
||||
expect(state.playerNationId).toBe('boreas');
|
||||
expect(state.nations.boreas.territory).toEqual(['RUS-a']);
|
||||
// The approved Boreas checkpoint raised its stability by 5 (50 → 55).
|
||||
expect(state.nations.boreas.stats.stability).toBe(55);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// Dynamic scenario generation — the two LLM steps (v0.31, #52).
|
||||
//
|
||||
// This is the additive "Create scenario" path. Two plain `chat()` calls (no
|
||||
// tools, no separate advisor endpoint — the advisor is embodied in step 1's
|
||||
// checkpoint list):
|
||||
//
|
||||
// 1. `generateDraft` — from an era template + a free-text description + a
|
||||
// year, the model designs a draft {@link WorldDraft} (nations with ISO_A3
|
||||
// territory, stats, relations, optional backstory) AND an opening
|
||||
// checkpoint list (the advisor's briefing of the world's live tensions).
|
||||
//
|
||||
// 2. `finalizeWorld` — on commit, the model is re-asked to produce the
|
||||
// final world, constrained to the player's APPROVED checkpoints (and,
|
||||
// when the client re-sends the step-1 draft, based on it so the player's
|
||||
// chosen nation survives). The result is converted to a playable
|
||||
// {@link GameState} (region-key territory via the injected `expand`
|
||||
// resolver, per-region stats seeded) and returned for the caller to
|
||||
// persist — a normal game at `#/game/<id>`.
|
||||
//
|
||||
// Both steps are defensive like the rest of the engine: raw model output is
|
||||
// fence-stripped, the first balanced object is extracted, JSON.parsed, and
|
||||
// validated via the pure `validateWorldDraft` (which never throws and returns
|
||||
// `null` for unusable input). A transport-level failure (the model being down)
|
||||
// throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to
|
||||
// a null world, never a crash. The `LlmClient` is injected so unit tests and
|
||||
// the deterministic scenario stub (server/stubScenario.ts) can replace the
|
||||
// network call exactly like the turn path does.
|
||||
import type { LlmClient } from './llm';
|
||||
import { DEFAULT_MAX_TOKENS } from './llm';
|
||||
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||
import { validateWorldDraft } from '../shared/scenario';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { worldDraftToGameState } from '../shared/scenario';
|
||||
|
||||
// --- Defensive parsing (shared with the engine's approach) --------------------
|
||||
|
||||
/** The system-prompt markers the deterministic scenario stub keys off. */
|
||||
export const SCENARIO_GENERATE_MARKER = '<<SCENARIO_GENERATE>>';
|
||||
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
|
||||
|
||||
/** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */
|
||||
function stripFence(text: string): string {
|
||||
let t = text.trim();
|
||||
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||||
if (lead) t = t.slice(lead[0].length);
|
||||
const tail = t.match(/\n?\s*```\s*$/);
|
||||
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||||
function extractBalancedObject(text: string): string | null {
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a raw top-level JSON object, or `null` when none is recoverable. */
|
||||
function parseObject(raw: string): Record<string, unknown> | null {
|
||||
const jsonText = extractBalancedObject(stripFence(raw));
|
||||
if (!jsonText) return null;
|
||||
try {
|
||||
const obj: unknown = JSON.parse(jsonText);
|
||||
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
||||
? (obj as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt building ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shared nation JSON contract text, embedded in both system prompts so the
|
||||
* model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a
|
||||
* valid JSON object" style.
|
||||
*/
|
||||
const WORLD_SHAPE = [
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' },',
|
||||
' "checkpoints": ["short advisor checkpoint", "..."]',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The system prompt for step 1 (generate). Tells the model to design a fresh
|
||||
* world for the era AND to emit an opening advisor checkpoint list.
|
||||
*/
|
||||
export function buildGeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`,
|
||||
'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
WORLD_SHAPE,
|
||||
'',
|
||||
'RULES',
|
||||
'- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'. A nation may control several codes; together the nations should cover a plausible portion of the world.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.',
|
||||
'- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.',
|
||||
'- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.',
|
||||
'- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
suggestedPowers: template.suggestedPowers,
|
||||
tensions: template.tensions,
|
||||
description,
|
||||
year,
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* The system prompt for step 2 (regenerate/finalize). Same world contract, but
|
||||
* the model must now respect the player's APPROVED checkpoints (and, when a
|
||||
* draft is supplied, stay close to it so the player's chosen nation survives).
|
||||
*/
|
||||
export function buildRegeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
approvedCheckpoints: string[],
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
draft?: WorldDraft,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`,
|
||||
'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
[
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
'',
|
||||
'RULES',
|
||||
'- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.',
|
||||
'- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
...(draft ? { draft } : {}),
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// --- Parsing the model's answers ----------------------------------------------
|
||||
|
||||
/** Parse the step-1 (generate) answer into a validated world + checkpoint list. */
|
||||
export function parseGenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): { world: WorldDraft | null; checkpoints: string[] } {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return { world: null, checkpoints: [] };
|
||||
const world = validateWorldDraft(obj.world, { validCountryCodes });
|
||||
const checkpoints: string[] = [];
|
||||
if (Array.isArray(obj.checkpoints)) {
|
||||
const seen = new Set<string>();
|
||||
for (const c of obj.checkpoints) {
|
||||
if (typeof c !== 'string') continue;
|
||||
const t = c.trim();
|
||||
if (t.length > 2 && !seen.has(t)) {
|
||||
seen.add(t);
|
||||
checkpoints.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { world, checkpoints: checkpoints.slice(0, 12) };
|
||||
}
|
||||
|
||||
/** Parse the step-2 (finalize) answer into a validated world (or null). */
|
||||
export function parseRegenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): WorldDraft | null {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return null;
|
||||
return validateWorldDraft(obj.world, { validCountryCodes });
|
||||
}
|
||||
|
||||
// --- Orchestration -------------------------------------------------------------
|
||||
|
||||
export interface GenerateDraftOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: ask the model to design a draft world + an advisor checkpoint list.
|
||||
* Throws only on a transport-level failure (the model is down) — a bad answer
|
||||
* yields `{ world: null, checkpoints: [] }` and is the caller's to surface as
|
||||
* an error, never a crash.
|
||||
*/
|
||||
export async function generateDraft(
|
||||
opts: GenerateDraftOptions,
|
||||
): Promise<{ world: WorldDraft | null; checkpoints: string[] }> {
|
||||
const { llm, template, description, year, validCountryCodes } = opts;
|
||||
const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
const parsed = parseGenerate(out, validCountryCodes);
|
||||
if (parsed.world) return parsed;
|
||||
// One nudge re-send with a fresh history, mirroring the engine's retry.
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
return parseGenerate(retry, validCountryCodes);
|
||||
}
|
||||
|
||||
export interface FinalizeWorldOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
approvedCheckpoints: string[];
|
||||
/** The step-1 draft (re-sent by the client) — the base the final world refines. */
|
||||
draft?: WorldDraft;
|
||||
/** The nation id the player will lead (kept in the final world when present). */
|
||||
playerNationId: string;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
/** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */
|
||||
expand: (countries: string[]) => string[];
|
||||
/** The new game's id (the route sets randomUUID()). */
|
||||
id?: string;
|
||||
/** The scenario title (becomes presetTitle; default 'Custom World'). */
|
||||
title?: string;
|
||||
/** The game's start date (YYYY-MM-DD, from the chosen year). */
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: ask the model to produce the final world constrained to the approved
|
||||
* checkpoints, convert it to a playable {@link GameState}, and return it for the
|
||||
* caller to persist. Throws only on a transport-level failure; a bad answer
|
||||
* that still yields a usable world is committed, and if the final world drops
|
||||
* the player's chosen nation the conversion falls back to the first nation so
|
||||
* the game is always playable.
|
||||
*/
|
||||
export async function finalizeWorld(opts: FinalizeWorldOptions): Promise<GameState> {
|
||||
const {
|
||||
llm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
draft,
|
||||
playerNationId,
|
||||
validCountryCodes,
|
||||
expand,
|
||||
id,
|
||||
title,
|
||||
startDate,
|
||||
} = opts;
|
||||
const { system, user } = buildRegeneratePrompt(
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
validCountryCodes,
|
||||
draft,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
let world = parseRegenerate(out, validCountryCodes);
|
||||
if (!world) {
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
world = parseRegenerate(retry, validCountryCodes);
|
||||
}
|
||||
if (!world) {
|
||||
// The model could not produce a usable final world. Rather than fail the
|
||||
// whole commit, fall back to the draft the player already approved (if any)
|
||||
// so the commit still yields a playable game.
|
||||
if (draft) world = draft;
|
||||
else throw new Error('finalize: the model returned no usable world');
|
||||
}
|
||||
return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate });
|
||||
}
|
||||
+126
-3
@@ -3,7 +3,8 @@
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
import type { RelationStatus, RoundItem } from '../shared/types';
|
||||
import { ABILITIES, type AbilityField } from './abilities';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
@@ -13,7 +14,25 @@ export interface StubPayload {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
||||
// `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
|
||||
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
|
||||
// it — it never arms or strikes (see deterministicTurn).
|
||||
// `leader` (v0.38.0, #61) is the per-nation named leader stamped by the
|
||||
// engine; the stub ignores it — it never changes a leader (see
|
||||
// deterministicTurn).
|
||||
// `ideology` (v0.39.0, #62) is the per-nation form of government stamped
|
||||
// by the engine; the stub ignores it — it never changes an ideology (see
|
||||
// deterministicTurn).
|
||||
nations: Record<
|
||||
string,
|
||||
{
|
||||
stats: Record<string, number>;
|
||||
territory?: string[];
|
||||
nuclear?: boolean;
|
||||
leader?: { name: string };
|
||||
ideology?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||
pending?: RoundItem[];
|
||||
@@ -38,6 +57,49 @@ export interface StubPayload {
|
||||
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||
/**
|
||||
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
|
||||
* the engine's user prompt. The stub ignores this field — it never declares
|
||||
* or ends a war (see deterministicTurn), so the war path is covered by unit
|
||||
* tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never runs a hybrid op (see
|
||||
* deterministicTurn), so the hybrid path is covered by unit tests only.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active bilateral trade agreements
|
||||
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never declares, lifts or suspends a trade
|
||||
* (see deterministicTurn), so the trade path is covered by unit tests only.
|
||||
*/
|
||||
trades?: Array<{ a: string; b: string }>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active unilateral embargoes
|
||||
* (`{ from, target }` nation-id pairs), emitted by the engine's user
|
||||
* prompt. The stub ignores this field — it never imposes, lifts or
|
||||
* suspends an embargo (see deterministicTurn), so the embargo path is
|
||||
* covered by unit tests only.
|
||||
*/
|
||||
embargoes?: Array<{ from: string; target: string }>;
|
||||
/**
|
||||
* (v0.36.0, #59) Each nation's aggregate resource profile
|
||||
* (`{ oil, iron, food, industry }`), emitted by the engine's user prompt.
|
||||
* The stub ignores this field — it never declares an industry project (see
|
||||
* deterministicTurn), so the industry path is covered by unit tests only.
|
||||
*/
|
||||
resources?: Record<string, { oil: number; iron: number; food: number; industry: number }>;
|
||||
/**
|
||||
* (v0.36.0, #59) The currently active industry projects
|
||||
* (`{ regionKey, type }`), emitted by the engine's user prompt. The stub
|
||||
* ignores this field — it never declares an industry project (see
|
||||
* deterministicTurn), so the industry path is covered by unit tests only.
|
||||
*/
|
||||
industry?: Array<{ regionKey: string; type: string }>;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -77,6 +139,40 @@ function truncate(s: string, n: number): string {
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*
|
||||
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||
* unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
|
||||
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
|
||||
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
|
||||
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
||||
* the trade & embargo economy path is covered by unit tests only, keeping
|
||||
* the e2e assertions untouched.
|
||||
*
|
||||
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
|
||||
* always returns `industry: { projects: [] }`) — the per-region industry
|
||||
* path is covered by unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.37.0, #60) The stub likewise NEVER arms a nation or fires a nuclear
|
||||
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
|
||||
* nuclear / WMD path is covered by unit tests only, keeping the e2e
|
||||
* assertions untouched.
|
||||
*
|
||||
* (v0.38.0, #61) The stub likewise NEVER changes a leader (it always returns
|
||||
* `leaders: { changes: [] }`) — the leader path is covered by unit tests
|
||||
* only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it
|
||||
* always returns `ideology: { changes: [] }`) — the ideology path is covered
|
||||
* by unit tests only, keeping the e2e assertions untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -134,7 +230,18 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
// Core (non-ability) turn fields, in their historical position order.
|
||||
const pre: Pick<
|
||||
TurnResult,
|
||||
| 'date'
|
||||
| 'events'
|
||||
| 'stateChanges'
|
||||
| 'regionChanges'
|
||||
| 'relations'
|
||||
| 'territoryChanges'
|
||||
| 'unowned'
|
||||
| 'nationOps'
|
||||
> = {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
@@ -143,8 +250,24 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
};
|
||||
|
||||
// The 9 ability fields, filled by the registry in its canonical order. The
|
||||
// stub is ability-inert: every ability contributes its own no-op default
|
||||
// (each ability's `stub()` — see the per-ability doc comments above), so the
|
||||
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
|
||||
// historical key order pinned by the stub's deterministic output
|
||||
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
|
||||
// ideology → disasters), which must stay byte-identical.
|
||||
const abilities = {} as Record<AbilityField, unknown>;
|
||||
for (const a of ABILITIES) abilities[a.field] = a.stub();
|
||||
|
||||
// The single field positioned after the abilities (markers last, as before).
|
||||
const post: Pick<TurnResult, 'markers'> = {
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
|
||||
return { ...pre, ...abilities, ...post } as TurnResult;
|
||||
}
|
||||
|
||||
const CANNED_ADVISOR: string[] = [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Deterministic SCENARIO-generation stub (v0.31, #52).
|
||||
//
|
||||
// Distinct from the turn stub (server/stub.ts, which stays untouched): this one
|
||||
// answers the two scenario-generation LLM calls (see server/scenario.ts) so the
|
||||
// whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and
|
||||
// the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it
|
||||
// plugs in as a Transport, and it discriminates the two calls by the markers
|
||||
// scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the
|
||||
// draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how
|
||||
// the turn stub discriminates the advisor call via ADVISOR_MARKER.
|
||||
//
|
||||
// The stub returns a fixed 3-nation world and a fixed checkpoint list for the
|
||||
// generate step, and — for the finalize step — the same world refined in a
|
||||
// simple, deterministic way that depends on the approved checkpoints, so the
|
||||
// "finalize respects approved checkpoints" behavior is observable end to end.
|
||||
import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm';
|
||||
import { SCENARIO_REGENERATE_MARKER } from './scenario';
|
||||
import type { WorldDraft } from '../shared/scenario';
|
||||
|
||||
/** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */
|
||||
const WORLD: WorldDraft = {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -60, gamma: 20 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -60, gamma: 10 },
|
||||
},
|
||||
{
|
||||
id: 'gamma',
|
||||
name: 'Gamma',
|
||||
type: 'minor',
|
||||
stats: { military: 30, economy: 45, stability: 70, population: 40 },
|
||||
territory: ['ESP'],
|
||||
relations: { alba: 20, boreas: 10 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
nation: 'alba',
|
||||
type: 'political',
|
||||
title: 'The new order takes shape',
|
||||
text: 'The three powers settle their borders after the great realignment.',
|
||||
outcome: 'neutral',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The advisor's opening checkpoint list (fixed, in order). */
|
||||
const CHECKPOINTS: string[] = [
|
||||
'Alba dominates the western sea lanes and presses its advantage.',
|
||||
'Boreas fortifies its northern frontier against a long winter.',
|
||||
'Gamma brokers a neutral corridor between the two rivals.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministically refine the fixed world to "respect" the approved
|
||||
* checkpoints: each approved checkpoint that names one of the fixed nations (by
|
||||
* the first 4 letters of the nation's name, case-insensitive) raises that
|
||||
* nation's stability by 5 (clamped at 100). Same input → same output, and the
|
||||
* result visibly depends on which checkpoints were approved.
|
||||
*/
|
||||
export function stubWorldFor(approved: string[]): WorldDraft {
|
||||
const nations = WORLD.nations.map((n) => ({
|
||||
...n,
|
||||
stats: { ...n.stats },
|
||||
relations: { ...n.relations },
|
||||
}));
|
||||
const lower = approved.map((c) => c.toLowerCase());
|
||||
for (const n of nations) {
|
||||
const key = n.name.toLowerCase().slice(0, 4);
|
||||
if (lower.some((c) => c.includes(key))) {
|
||||
n.stats.stability = Math.min(100, n.stats.stability + 5);
|
||||
}
|
||||
}
|
||||
return { nations, events: WORLD.events };
|
||||
}
|
||||
|
||||
export function makeScenarioStubTransport(): Transport {
|
||||
return async (_url, init) => {
|
||||
let content: string;
|
||||
try {
|
||||
const req = JSON.parse(String(init?.body ?? '{}')) as { messages?: ChatMessage[] };
|
||||
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
|
||||
const user = req.messages?.find((m) => m.role === 'user');
|
||||
if (sys.includes(SCENARIO_REGENERATE_MARKER)) {
|
||||
// Finalize step: read the approved checkpoints out of the user payload
|
||||
// and refine the fixed world accordingly (no checkpoints field echoed).
|
||||
const payload = JSON.parse(user?.content ?? '{}') as {
|
||||
approvedCheckpoints?: string[];
|
||||
};
|
||||
content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) });
|
||||
} else {
|
||||
// Generate step (and any unrecognized prompt): the fixed world + list.
|
||||
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||
}
|
||||
} catch {
|
||||
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content } }] }),
|
||||
text: async () => content,
|
||||
} as unknown as Response;
|
||||
};
|
||||
}
|
||||
|
||||
/** A ready-made LlmClient backed by the deterministic scenario transport. */
|
||||
export function makeScenarioStubLlmClient(): LlmClient {
|
||||
return createLlmClient({ transport: makeScenarioStubTransport() });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createTemplateStore } from './templates';
|
||||
|
||||
// The project root is the parent of server/ (mirrors how server/index.ts and
|
||||
// server/regions.test.ts locate it via import.meta.url).
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const templatesDir = path.join(root, 'presets', 'templates');
|
||||
|
||||
const store = createTemplateStore(templatesDir);
|
||||
|
||||
/** A well-formed ISO_A3 country code: exactly three uppercase letters. */
|
||||
function isIsoA3(code: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(code);
|
||||
}
|
||||
|
||||
describe('era templates store (v0.31.0, #52)', () => {
|
||||
it('the manifest lists exactly the three era templates', () => {
|
||||
const meta = store.listMeta();
|
||||
expect(meta).toHaveLength(3);
|
||||
const ids = meta.map((m) => m.id);
|
||||
expect(ids).toEqual(['ww2', 'present', 'near-future']);
|
||||
for (const m of meta) {
|
||||
expect(m.id).toBeTruthy();
|
||||
expect(m.name).toBeTruthy();
|
||||
expect(typeof m.year).toBe('number');
|
||||
expect(m.eraDescription).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('each template file loads and is well-formed', () => {
|
||||
for (const m of store.listMeta()) {
|
||||
const t = store.get(m.id);
|
||||
expect(t, `template ${m.id} should load`).not.toBeNull();
|
||||
const tpl = t as NonNullable<typeof t>;
|
||||
expect(tpl.id).toBe(m.id);
|
||||
expect(tpl.name).toBe(m.name);
|
||||
expect(tpl.year).toBe(m.year);
|
||||
expect(tpl.eraDescription).toBeTruthy();
|
||||
// suggested powers: non-empty, each a named power with an ISO_A3 core region + note.
|
||||
expect(tpl.suggestedPowers.length).toBeGreaterThan(0);
|
||||
for (const p of tpl.suggestedPowers) {
|
||||
expect(p.name).toBeTruthy();
|
||||
expect(isIsoA3(p.region)).toBe(true);
|
||||
expect(p.note).toBeTruthy();
|
||||
}
|
||||
// tensions: a non-empty list of non-empty strings.
|
||||
expect(tpl.tensions.length).toBeGreaterThan(0);
|
||||
for (const s of tpl.tensions) expect(typeof s).toBe('string');
|
||||
for (const s of tpl.tensions) expect(s.trim()).not.toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('get returns null for an unknown id', () => {
|
||||
expect(store.get('does-not-exist')).toBeNull();
|
||||
expect(store.get('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// Load era templates for the "Create scenario" path from disk (v0.31.0, #52).
|
||||
// Mirrors the preset store (server/presets.ts): each template is a JSON file
|
||||
// (presets/templates/<id>.json) and index.json is the lightweight manifest used
|
||||
// to render the picker cards. The store is created with the resolved templates
|
||||
// directory so it works from repo root (dev) and /app (Docker) alike.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { EraTemplate, TemplateMeta } from '../shared/scenario';
|
||||
|
||||
export interface TemplateStore {
|
||||
listMeta(): TemplateMeta[];
|
||||
get(id: string): EraTemplate | null;
|
||||
}
|
||||
|
||||
export function createTemplateStore(dir: string): TemplateStore {
|
||||
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[];
|
||||
return {
|
||||
listMeta() {
|
||||
return manifest;
|
||||
},
|
||||
get(id: string) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
IndustryProject,
|
||||
MapMarker,
|
||||
MarkerType,
|
||||
Nation,
|
||||
NationStats,
|
||||
RelationStatus,
|
||||
RegionResources,
|
||||
RegionStats,
|
||||
StatKey,
|
||||
} from './types';
|
||||
@@ -51,6 +54,50 @@ export interface TerritoryChange {
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
// --- Wars (v0.33.0, #56): first-class war entities --------------------------
|
||||
//
|
||||
// A war is an explicit world entity (see types.War), not just a boolean
|
||||
// relation: the LLM DECLARES one (it becomes an active War with an engine-
|
||||
// assigned stable id) and later ENDS one by id. The engine derives the
|
||||
// symmetric `war` relation from the set of ACTIVE wars on every turn (two
|
||||
// nations are in a `war` relation iff they are both belligerents of at least
|
||||
// one active war) and downgrades it to `hostile` when the war ends — so the
|
||||
// relation can never drift apart from the war state.
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Declare a new war this turn. `sides` names every
|
||||
* belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`,
|
||||
* so a unique name also works); any two distinct sides end the turn in a
|
||||
* `war` relation. `aims` are optional short war aims for the narrative. The
|
||||
* engine assigns the stable id, the declared turn/date, drags in the
|
||||
* mutual-defense allies of each belligerent, and drops the op when fewer than
|
||||
* two sides resolve (never throws).
|
||||
*/
|
||||
export interface WarDeclareOp {
|
||||
/** The belligerent nations (≥2 distinct ids or unique names). */
|
||||
sides: string[];
|
||||
/** Optional short war aims (narrative only). */
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id
|
||||
* (the model sees the current active wars in the user payload's `wars`
|
||||
* field). `outcome` is `armistice` (a negotiated stop) or `capitulation`
|
||||
* (one side surrenders); the war keeps its id/sides and stays in
|
||||
* `GameState.wars` as history with that final status. `terms` are optional
|
||||
* short peace terms (narrative only — the model narrates the details in
|
||||
* `events`). An unknown id, or a war that is already ended, is a no-op
|
||||
* (never throws).
|
||||
*/
|
||||
export interface WarEndOp {
|
||||
/** The stable id of the active war to end (from the user payload). */
|
||||
id: string;
|
||||
outcome: 'armistice' | 'capitulation';
|
||||
/** Optional short peace terms (narrative only). */
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||
// proposes, player confirms" model) ------------------------------------------
|
||||
//
|
||||
@@ -68,6 +115,25 @@ export interface TerritoryChange {
|
||||
* engine's `resolveNationRef`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
|
||||
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
|
||||
* (a player-confirmed diplomatic/political name change), a takeover is
|
||||
* **automatic and in-world**: `applyTurnResult` applies it directly (no
|
||||
* pending queue, no player confirm). Only `Nation.name` changes — the stable
|
||||
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
|
||||
* (which references the id) all stay intact, so the player keeps leading the
|
||||
* nation under its new regime. `nationRef` is resolved exactly like the
|
||||
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
|
||||
* match — see `resolveNationRef`).
|
||||
*/
|
||||
export interface Takeover {
|
||||
/** The target nation's id or current name (resolved by `resolveNationRef`). */
|
||||
nationRef: string;
|
||||
/** The name of the new regime to set as the nation's display name. */
|
||||
newRegimeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||
@@ -170,6 +236,19 @@ export interface MarkerInfo {
|
||||
owner: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model
|
||||
* (the `wars` field): its stable `id` (the model echoes it in
|
||||
* `TurnResult.wars.end` to end it), its belligerent `sides`, and optional
|
||||
* short `aims`. Ended wars are NOT included — they stay in `GameState.wars`
|
||||
* as history, but the model only needs to address the active ones.
|
||||
*/
|
||||
export interface WarInfo {
|
||||
id: string;
|
||||
sides: string[];
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-type presentation of a marker: its display label (tooltip /
|
||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||
@@ -229,6 +308,348 @@ export function isMarkerType(t: unknown): t is MarkerType {
|
||||
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): non-kinetic operations ---------------------
|
||||
//
|
||||
// The LLM can run covert, non-kinetic operations each turn: spies, sabotage,
|
||||
// assassination attempts, cyberattacks, economic pressure, disinformation, and
|
||||
// proxy support. Unlike kinetic conquest (territory), these never move borders
|
||||
// — they dip a target's stats or sour a relation. The LLM declares an intended
|
||||
// outcome (`success` or `exposed`); the engine is fully deterministic (no
|
||||
// hidden randomness) and acts only on that declaration: a successful op applies
|
||||
// its small, per-type effect, and an "exposed" op backfires (no effect, the
|
||||
// actor's relation with the target sours by one step, and an auto
|
||||
// diplomatic/failure event is emitted).
|
||||
|
||||
/** The seven covert operation types a hybrid op can be (v0.34.0, #57). */
|
||||
export type HybridOpType =
|
||||
| 'spy'
|
||||
| 'sabotage'
|
||||
| 'assassinate'
|
||||
| 'cyber'
|
||||
| 'economic'
|
||||
| 'information'
|
||||
| 'proxy';
|
||||
|
||||
/** All hybrid op types, in canonical order (the LLM contract + validator). */
|
||||
export const HYBRID_OP_TYPES: HybridOpType[] = [
|
||||
'spy',
|
||||
'sabotage',
|
||||
'assassinate',
|
||||
'cyber',
|
||||
'economic',
|
||||
'information',
|
||||
'proxy',
|
||||
];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn
|
||||
* (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique
|
||||
* name — resolved by the engine's `resolveNationRef`). `target` is EITHER a
|
||||
* nation (id or unique name) or a specific region key; a region target hits
|
||||
* that region's sole owner (a region with zero or 2+ owners can't be
|
||||
* targeted). `detail` is a short narrative (the LLM may also narrate the op in
|
||||
* `events`). `outcome` is the LLM's *intended* result: `success` (the default)
|
||||
* applies the op's deterministic effect, while `exposed` backfires it — the
|
||||
* effect is NOT applied, the actor's relation with the target sours by one
|
||||
* step, and an auto diplomatic/failure event is emitted.
|
||||
*/
|
||||
export interface HybridOp {
|
||||
/** The operation type (see {@link HybridOpType}). */
|
||||
type: HybridOpType;
|
||||
/** The acting nation's id or current name (resolved by `resolveNationRef`). */
|
||||
from: string;
|
||||
/** The target: a nation's id/name, or a region key (hits that region's owner). */
|
||||
target: string;
|
||||
/** Short narrative detail (optional). */
|
||||
detail?: string;
|
||||
/** The LLM's intended result; `exposed` backfires the op. Defaults to success. */
|
||||
outcome?: 'success' | 'exposed';
|
||||
}
|
||||
|
||||
// --- Trade & embargo ops (v0.35.0, #58) --------------------------------------
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A bilateral TRADE op in `TurnResult.economy.trade`: `a` and
|
||||
* `b` are the two parties (each a nation's id or unique name — resolved by
|
||||
* the engine's `resolveNationRef`; a pair that resolves to the same nation,
|
||||
* or contains an unknown/ambiguous ref, is dropped defensively). Without
|
||||
* `lift` the op DECLARES the agreement (idempotent — an already-active
|
||||
* agreement is not duplicated); `lift: true` ENDS it.
|
||||
*/
|
||||
export interface EconomyTradeOp {
|
||||
/** First party's id or unique name. */
|
||||
a: string;
|
||||
/** Second party's id or unique name (distinct from `a` after resolution). */
|
||||
b: string;
|
||||
/** `true` → end this bilateral trade; default `false` = declare it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A unilateral EMBARGO op in `TurnResult.economy.embargo`:
|
||||
* `from` blocks trade with `target` (each a nation's id or unique name —
|
||||
* resolved by `resolveNationRef`; an unknown/ambiguous ref or
|
||||
* `from === target` is dropped defensively). Without `lift` the op imposes
|
||||
* the embargo (idempotent — an already-active one is not duplicated);
|
||||
* `lift: true` LIFTS it.
|
||||
*/
|
||||
export interface EconomyEmbargoOp {
|
||||
/** The imposing nation's id or unique name. */
|
||||
from: string;
|
||||
/** The embargoed nation's id or unique name (distinct from `from`). */
|
||||
target: string;
|
||||
/** `true` → lift this embargo; default `false` = impose it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) The turn's trade-and-embargo operations, nested under
|
||||
* `TurnResult.economy`. Both arrays are required and default to `[]`. The
|
||||
* engine applies the ops to `GameState.trades` / `GameState.embargoes`
|
||||
* (add = declare/impose, remove = lift) and, on every committed turn, applies
|
||||
* the per-turn economic effect of the FULL active lists (trade: both
|
||||
* parties' economy up a little; embargo: the target's economy down a
|
||||
* little). Active trades/embargoes between nations on opposite sides of an
|
||||
* active war (v0.33, #56) are suspended (removed) automatically.
|
||||
*/
|
||||
export interface EconomyOps {
|
||||
/** Bilateral trades (declare or lift). */
|
||||
trade: EconomyTradeOp[];
|
||||
/** Unilateral embargoes (impose or lift). */
|
||||
embargo: EconomyEmbargoOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.36, #59) The turn's INDUSTRY operations, nested under
|
||||
* `TurnResult.industry`. `projects` lists the industry projects the LLM builds
|
||||
* this turn: each names a **region key** (the region must be valid, and — for
|
||||
* the boost to take effect — will need a sole owner at apply time) and one of
|
||||
* the four project {@link IndustryType}s. The engine applies each project to
|
||||
* `GameState.industry` (a per-region SET — a region already hosting a project
|
||||
* keeps the first one declared; a region key not in the valid manifest is
|
||||
* dropped, never thrown). Required; default `{ projects: [] }`.
|
||||
*/
|
||||
export interface IndustryOps {
|
||||
/** The industry projects built this turn (region key + type). */
|
||||
projects: IndustryProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.37, #60) A single nuclear strike. `from` names the striking nation
|
||||
* (id or name — it must be **nuclear-capable**, see `isNuclearCapable`);
|
||||
* `target` names what is struck — either a **region key** (the region is
|
||||
* devastated to no man's land and its former owner devastated) or a
|
||||
* **nation** (id or name — the target's economy / military / stability are
|
||||
* devastated; if the striker and the target are on opposite sides of an
|
||||
* active war, that war ends in `capitulation`).
|
||||
*/
|
||||
export interface NuclearStrike {
|
||||
/** The striking nation (id or name). Must be nuclear-capable. */
|
||||
from: string;
|
||||
/** The struck target: a region key, or a nation (id or name). */
|
||||
target: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.37, #60) The turn's NUCLEAR operations, nested under
|
||||
* `TurnResult.nuclear`. `arm` names the nations (id or name) that declare a
|
||||
* nuclear arsenal this turn (idempotent — arming an already-armed nation is a
|
||||
* no-op; an unresolvable ref is dropped); `strikes` are the nuclear strikes
|
||||
* fired this turn (at most one per turn in the prompt contract; each is
|
||||
* validated independently — an invalid `from` or `target` is dropped, never
|
||||
* thrown). A strike on a nuclear-capable target (or the owner of a struck
|
||||
* region) triggers a smaller "MAD" retaliation on the striker. Required;
|
||||
* default `{ arm: [], strikes: [] }`.
|
||||
*/
|
||||
export interface NuclearOps {
|
||||
/** Nation refs (id or name) arming a nuclear arsenal this turn. */
|
||||
arm: string[];
|
||||
/** The nuclear strikes fired this turn. */
|
||||
strikes: NuclearStrike[];
|
||||
}
|
||||
|
||||
// --- Named leaders (v0.38, #61) -----------------------------------------------
|
||||
//
|
||||
// Each nation may have a single named leader (`Nation.leader`). The LLM changes
|
||||
// it each turn via `TurnResult.leaders`. A change names a target nation (id or
|
||||
// name) and the outcome: an `assassination` (a leader killed — a dramatic,
|
||||
// destabilizing blow), a `death` (a leader dies of natural causes — a quieter
|
||||
// succession), or an `appointed` leader (a neutral succession / new head of
|
||||
// state). The engine sets the nation's leader to the new name and applies a
|
||||
// DETERMINISTIC stability dip (see `LEADER_STABILITY_EFFECT` below) — no hidden
|
||||
// randomness. A change whose target has no current leader may only *appoint*
|
||||
// one (an assassination/death of a leaderless nation is dropped, not thrown).
|
||||
|
||||
/** How a nation's leader changed this turn (v0.38, #61). */
|
||||
export type LeaderCause = 'assassination' | 'death' | 'appointed';
|
||||
|
||||
/** A single leader change: the target nation, the new leader's name, the cause. */
|
||||
export interface LeaderChange {
|
||||
/** The target nation, by id or name (resolved by the shared resolver). */
|
||||
target: string;
|
||||
/** The new leader's name (the incoming / successor head of state). */
|
||||
name: string;
|
||||
/** What happened to the leader this turn. */
|
||||
cause: LeaderCause;
|
||||
}
|
||||
|
||||
/** The leader-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
|
||||
export interface LeaderOps {
|
||||
changes: LeaderChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic stability effect of each leader-cause (v0.38, #61).
|
||||
* Assassination is the most destabilizing, a natural death a minor wobble, an
|
||||
* appointment is neutral. Applied to the target's `stability` (clamped 0–100),
|
||||
* mirrored to its `regionStats`, and its derived stats re-derived afterwards.
|
||||
*/
|
||||
export const LEADER_STABILITY_EFFECT: Record<LeaderCause, number> = {
|
||||
assassination: -15,
|
||||
death: -3,
|
||||
appointed: 0,
|
||||
};
|
||||
|
||||
// --- Ideology / form of government (v0.39, #62) ------------------------------
|
||||
//
|
||||
// Each nation may carry a free-form `ideology` label — its form of
|
||||
// government ("democracy", "fascism", "military junta", …). The LLM changes it
|
||||
// each turn via `TurnResult.ideology`: a change names a target nation (id or
|
||||
// name) and the new government type. The engine sets the nation's `ideology`
|
||||
// and applies a DETERMINISTIC stability dip (see `IDEOLOGY_STABILITY_EFFECT`
|
||||
// below) — a regime shift always unsettles a nation — no hidden randomness.
|
||||
// It is a third, independent axis next to `leader` (who is in charge) and the
|
||||
// v0.32.0 takeover (which renames the nation): the id, name, territory,
|
||||
// relations, and the leader all stay valid, only the form of government and
|
||||
// stability move.
|
||||
|
||||
/** A single ideology change: the target nation and its new form of government. */
|
||||
export interface IdeologyChange {
|
||||
/** The target nation, by id or name (resolved by the shared resolver). */
|
||||
target: string;
|
||||
/** The new form of government (a free-form label, e.g. "democracy"). */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** The ideology-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
|
||||
export interface IdeologyOps {
|
||||
changes: IdeologyChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic stability effect of an ideology change (v0.39, #62). A
|
||||
* regime shift (a coup, a revolution, a reform, a constitutional change)
|
||||
* unsettles the nation: a small −5 dip applied to the target's `stability`
|
||||
* (clamped 0–100), mirrored to its `regionStats`, and its derived stats
|
||||
* re-derived afterwards. Applied the same way a leader-cause effect is —
|
||||
* re-setting the SAME ideology still dips (the shift just happened, even if
|
||||
* the label does not change) but is idempotent for the `ideology` field
|
||||
* itself.
|
||||
*/
|
||||
export const IDEOLOGY_STABILITY_EFFECT: number = -5;
|
||||
|
||||
// --- Nuclear / WMD effect math (v0.37, #60) ----------------------------------
|
||||
//
|
||||
// A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no
|
||||
// hidden randomness (the LLM decides WHO strikes and WHAT is targeted; the
|
||||
// engine decides the size of the effect). The two magnitude tables below are
|
||||
// the single source of truth for the collapse: a full strike devastates a
|
||||
// nation (or the former owner of a devastated region) by `NUCLEAR_COLLAPSE`,
|
||||
// while a "MAD" retaliation (fired when a nuclear-capable nation is struck)
|
||||
// devastates the STRIKER by half that, `NUCLEAR_RETALIATION`. Both dip the
|
||||
// three war-relevant stats only (population is untouched); each value is the
|
||||
// signed per-turn delta the engine clamps against 0–100.
|
||||
|
||||
/** The stats a nuclear strike collapses (population is never touched). */
|
||||
export type NuclearStatKey = 'economy' | 'military' | 'stability';
|
||||
|
||||
/**
|
||||
* (v0.37, #60) The deterministic stat collapse a FULL nuclear strike inflicts
|
||||
* on its target (a struck nation, or the former owner of a devastated region):
|
||||
* a heavy dip in economy / military / stability. Shared so the engine and the
|
||||
* unit tests agree on the exact magnitudes.
|
||||
*/
|
||||
export const NUCLEAR_COLLAPSE: Record<NuclearStatKey, number> = {
|
||||
economy: -40,
|
||||
military: -50,
|
||||
stability: -40,
|
||||
};
|
||||
|
||||
/**
|
||||
* (v0.37, #60) The deterministic stat collapse of a "MAD" RETALIATION — fired
|
||||
* automatically when a nuclear-capable nation (or the owner of a devastated
|
||||
* region) is struck — which hits the STRIKER at half the full collapse.
|
||||
*/
|
||||
export const NUCLEAR_RETALIATION: Record<NuclearStatKey, number> = {
|
||||
economy: NUCLEAR_COLLAPSE.economy / 2,
|
||||
military: NUCLEAR_COLLAPSE.military / 2,
|
||||
stability: NUCLEAR_COLLAPSE.stability / 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* (v0.37, #60) Whether a nation is **nuclear-capable** — i.e. able to fire a
|
||||
* nuclear strike and subject to MAD retaliation: its `nuclear` flag is set, OR
|
||||
* it owns a `rocket` marker (a launch site is an arsenal). A legacy nation
|
||||
* without the flag is not nuclear unless it has a rocket marker. Pure +
|
||||
* shared (used by the engine and the unit tests).
|
||||
*/
|
||||
export function isNuclearCapable(nation: Nation, markers: readonly MapMarker[]): boolean {
|
||||
if (nation.nuclear === true) return true;
|
||||
return markers.some((m) => m.ownerNationId === nation.id && m.type === 'rocket');
|
||||
}
|
||||
|
||||
// --- Disasters & pandemics (v0.41.0, #63) ------------------------------------
|
||||
//
|
||||
// Natural and man-made catastrophes — earthquakes, floods, volcanic eruptions,
|
||||
// wildfires, famines, plagues, and epidemics — hit a nation or a single region
|
||||
// each turn. Each event carries a `type` and a `severity`; the engine applies
|
||||
// a DETERMINISTIC stat dip chosen from the `(type, severity)` lookup table
|
||||
// `DISASTER_EFFECTS` (defined in `server/abilities/disasters.ts`) — no hidden
|
||||
// randomness: the LLM chooses the type and severity, the table decides the
|
||||
// magnitude. The dip is clamped to 0–100, mirrored to the target's regions
|
||||
// (`regionStats`), and the target's derived `stats` re-derived afterwards
|
||||
// (the same 0-region / no-entry guards as the hybrid / economy layers). It is
|
||||
// **turn-transient** like the hybrid layer: there is NO `GameState` field, no
|
||||
// durable list, and no `db.ts` change — an ongoing plague / epidemic is simply
|
||||
// re-declared each turn it persists. Disasters never move territory (sole
|
||||
// ownership is untouched) and never touch `population`.
|
||||
|
||||
/** The kind of catastrophe a disaster event describes (v0.41.0, #63). */
|
||||
export type DisasterType =
|
||||
| 'earthquake'
|
||||
| 'flood'
|
||||
| 'volcanic'
|
||||
| 'wildfire'
|
||||
| 'famine'
|
||||
| 'plague'
|
||||
| 'epidemic';
|
||||
|
||||
/** The severity a disaster event carries (v0.41.0, #63). */
|
||||
export type DisasterSeverity = 'minor' | 'moderate' | 'severe';
|
||||
|
||||
/**
|
||||
* A single disaster event (v0.41.0, #63): the catastrophe `type`, its
|
||||
* `target` (a nation id / name, or a region key that hits that region's sole
|
||||
* owner — resolved at apply time), the `severity`, and an optional short
|
||||
* `detail`. The deterministic stat dip is looked up from
|
||||
* `DISASTER_EFFECTS[type][severity]`.
|
||||
*/
|
||||
export interface DisasterEvent {
|
||||
/** The kind of catastrophe (one of {@link DisasterType}). */
|
||||
type: DisasterType;
|
||||
/** The affected nation (by id or name) or a region key (hits its sole owner). */
|
||||
target: string;
|
||||
/** How bad it was (one of {@link DisasterSeverity}). */
|
||||
severity: DisasterSeverity;
|
||||
/** Optional short annotation (≤300 chars). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** The disasters block of a `TurnResult`. Required; default `{ events: [] }`. */
|
||||
export interface DisastersOps {
|
||||
events: DisasterEvent[];
|
||||
}
|
||||
|
||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||
export interface TurnResult {
|
||||
date: string;
|
||||
@@ -267,6 +688,137 @@ export interface TurnResult {
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
/**
|
||||
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
|
||||
* effect **this turn** — each renames the target nation to `newRegimeName`.
|
||||
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
|
||||
* and in-world**: it is applied directly by `applyTurnResult` (like territory
|
||||
* / state changes), not queued to `pendingNationOps`. Only the nation's
|
||||
* `name` changes; the id, territory, relations, stats, and `playerNationId`
|
||||
* are all preserved. Empty on turns where no takeover occurs. Required
|
||||
* (default `[]`).
|
||||
*/
|
||||
takeovers: Takeover[];
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars
|
||||
* (≥2 distinct belligerents; the engine assigns the stable id / declared
|
||||
* turn / date, drags in the mutual-defense allies of each belligerent, and
|
||||
* derives the `war` relation between every pair of sides); `end` closes an
|
||||
* existing ACTIVE war by id (`armistice` = a negotiated stop,
|
||||
* `capitulation` = one side surrenders), which downgrades the relation the
|
||||
* war had derived back to `hostile`. Both sub-fields are required and
|
||||
* default `[]`; a turn with war activity is meaningful (it survives the
|
||||
* retry/nudge path). The model sees the current active wars (id + sides +
|
||||
* aims) in the user payload's `wars` field, so it can `end` by id.
|
||||
*/
|
||||
wars: { declare: WarDeclareOp[]; end: WarEndOp[] };
|
||||
/**
|
||||
* (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies,
|
||||
* sabotage, assassination attempts, cyberattacks, economic pressure,
|
||||
* disinformation, and proxy support. Unlike `nationOps` (player-confirmed
|
||||
* proposals) these are applied directly by the turn, like territory / state
|
||||
* changes: each op's deterministic per-type effect is applied by
|
||||
* `applyTurnResult` (a small stat dip on the target's regions, or a
|
||||
* relation nudge toward hostile for `information` / `proxy`); an `exposed`
|
||||
* op backfires (no effect, the actor's relation with the target sours by
|
||||
* one step, and an auto diplomatic/failure event is emitted). They never
|
||||
* move borders — sole ownership is untouched. Required (default
|
||||
* `{ ops: [] }`).
|
||||
*/
|
||||
hybrid: { ops: HybridOp[] };
|
||||
/**
|
||||
* (v0.35.0, #58) Trade and embargo ops — the economy layer. `trade`
|
||||
* declares (or, with `lift: true`, ends) bilateral trade agreements
|
||||
* between two nations; `embargo` imposes (or, with `lift: true`, lifts)
|
||||
* a unilateral embargo on a target. Both arrays are required and default
|
||||
* to `[]`. Applied directly by `applyTurnResult` (add to / remove from
|
||||
* `GameState.trades` / `GameState.embargoes`), and the per-turn economic
|
||||
* effect of the FULL active lists is applied on every committed turn (a
|
||||
* trade lifts both parties' economy a little; an embargo lowers the
|
||||
* target's economy a little — clamped, deterministic). Active
|
||||
* trades/embargoes between nations on opposite sides of an active war are
|
||||
* suspended automatically.
|
||||
*/
|
||||
economy: EconomyOps;
|
||||
/**
|
||||
* (v0.36, #59) Industry projects built this turn — the LLM's "industry"
|
||||
* lever. `projects` lists `{ regionKey, type }` pairs: the model names a
|
||||
* region it (believes it) controls and one of the four project types
|
||||
* (`oil` / `iron` / `food` / `industry`). Applied directly by
|
||||
* `applyTurnResult`: each project is added to `GameState.industry` (a
|
||||
* per-region SET — re-declaring on a region that already has a project is a
|
||||
* no-op), and on every committed turn each active project gives its region's
|
||||
* SOLE owner a small, deterministic economy boost scaled by that region's
|
||||
* `industry` resource value (a region with no owner, or 2+ owners, yields no
|
||||
* boost but keeps its project). They never move borders — sole ownership is
|
||||
* untouched. Required; default `{ projects: [] }`.
|
||||
*/
|
||||
industry: IndustryOps;
|
||||
/**
|
||||
* (v0.37, #60) Nuclear / WMD ops — the LLM's nuclear lever. `arm` names
|
||||
* the nations that declare a nuclear arsenal this turn (idempotent);
|
||||
* `strikes` are the nuclear strikes fired this turn (at most one per turn
|
||||
* in the prompt contract). A strike may target a **region** (devastated to
|
||||
* no man's land, its former owner devastated) or a **nation** (its economy
|
||||
* / military / stability devastated; a strike that lands on a nation
|
||||
* warring with the striker ends that war in capitulation). A strike on a
|
||||
* nuclear-capable target (or the owner of a struck region) triggers a
|
||||
* smaller, automatic "MAD" retaliation on the striker. Only
|
||||
* nuclear-capable nations (the `Nation.nuclear` flag set, or owning a
|
||||
* `rocket` marker) may strike. Applied directly by `applyTurnResult`,
|
||||
* never queued. Required; default `{ arm: [], strikes: [] }`.
|
||||
*/
|
||||
nuclear: NuclearOps;
|
||||
/**
|
||||
* (v0.38, #61) Leader changes this turn. Each change names a target nation
|
||||
* (id or name) and sets its leader to the new name, applying a deterministic
|
||||
* stability effect by cause: `assassination` (leader killed — a dramatic
|
||||
* −15 to the target's stability, a `military` event), `death` (natural
|
||||
* causes — a quieter −3 succession, a `political` event), or `appointed`
|
||||
* (a neutral 0 succession, a `political` event). An assassination/death
|
||||
* requires the target to already have a leader (a leaderless target is
|
||||
* dropped, never thrown); an appointment is always valid (it creates the
|
||||
* first leader). At most 1–2 per turn, each narrated in `events`. This is
|
||||
* the *leader* lever — heavier and more destabilizing than the lighter
|
||||
* `hybrid.assassinate` op (a −5 stability dip on a nation or region, with
|
||||
* no leader replacement). Required; default `{ changes: [] }`.
|
||||
*/
|
||||
leaders: LeaderOps;
|
||||
/**
|
||||
* (v0.39, #62) Ideology (form of government) changes this turn. Each
|
||||
* change names a target nation (id or name) and its new form of
|
||||
* government — a free-form label such as "democracy", "fascism", or
|
||||
* "military junta". Applied directly by `applyTurnResult` (like leaders /
|
||||
* hybrid / economy): the target's `ideology` is set to the new label, a
|
||||
* small, deterministic stability dip (`IDEOLOGY_STABILITY_EFFECT`, −5) is
|
||||
* applied (clamped, mirrored to the nation's regions, re-derived), and a
|
||||
* `political` narration event is appended. Re-setting the same label is
|
||||
* idempotent for the field (still narrated, still dips). Unknown /
|
||||
* ambiguous targets and empty labels are dropped, never thrown; the
|
||||
* change never moves territory (sole ownership is untouched). Distinct
|
||||
* from a takeover (which renames the nation) and from a leader change
|
||||
* (who is in charge). At most 1–2 per turn, each narrated in `events`.
|
||||
* Required; default `{ changes: [] }`.
|
||||
*/
|
||||
ideology: IdeologyOps;
|
||||
/**
|
||||
* (v0.41.0, #63) Disasters & pandemics that struck this turn — earthquakes,
|
||||
* floods, volcanic eruptions, wildfires, famines, plagues, and epidemics,
|
||||
* each hitting a **nation** (by id or name) or a single **region** (by key,
|
||||
* which hits that region's SOLE owner — a key with 0 or 2+ owners can't be
|
||||
* hit). Each event names a `type` (one of the seven `DisasterType`s) and a
|
||||
* `severity` (minor / moderate / severe); the engine applies a small,
|
||||
* DETERMINISTIC stat dip from the `(type, severity)` table
|
||||
* (`DISASTER_EFFECTS`, in `server/abilities/disasters.ts`) — clamped to
|
||||
* 0–100, mirrored to the target's regions, and the target's derived `stats`
|
||||
* re-derived afterwards (no hidden randomness). They NEVER move borders
|
||||
* (sole ownership is untouched) and never touch `population`, and the layer
|
||||
* is turn-transient (no durable state): an ongoing plague / epidemic is
|
||||
* simply re-declared each turn it persists. At most 1–2 events per turn,
|
||||
* each narrated in `events` (a `disaster` event). Required; default
|
||||
* `{ events: [] }`.
|
||||
*/
|
||||
disasters: DisastersOps;
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
@@ -579,6 +1131,8 @@ export function nationForPrompt(n: Nation) {
|
||||
stats: n.stats,
|
||||
territory: n.territory,
|
||||
relations: n.relations,
|
||||
leader: n.leader,
|
||||
ideology: n.ideology,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -715,3 +1269,49 @@ export function migrateRegionStats(
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.36, #59) Derive a nation's aggregate {@link RegionResources} from the
|
||||
* resource profiles of the regions it owns: each of the four resource values is
|
||||
* the **area×population-weighted AVERAGE** (an average — not a median — is the
|
||||
* meaningful aggregation for an abundance profile) over the nation's owned
|
||||
* region keys. A region's weight is the same
|
||||
* `(area > 0 ? area : 1) × (population > 0 ? population : 1)` as
|
||||
* {@link deriveNationStats} — both factors fall back to 1 when the region has
|
||||
* no geo entry (`geoOf` returns `undefined` for it), so every region carries
|
||||
* weight ≥ 1 and a missing geo degrades gracefully to a uniform (unweighted)
|
||||
* average. A region key with no resource-profile entry (`resourceOf` returns
|
||||
* `undefined` for it — the manifest is not loaded, or the key is absent) is
|
||||
* skipped. Returns the zero profile when the nation owns no region with a
|
||||
* profile. Pure + shared.
|
||||
*/
|
||||
export function deriveNationResources(
|
||||
regionKeys: string[],
|
||||
resourceOf?: (key: string) => RegionResources | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): RegionResources {
|
||||
const zero: RegionResources = { oil: 0, iron: 0, food: 0, industry: 0 };
|
||||
const sums: Record<keyof RegionResources, number> = { oil: 0, iron: 0, food: 0, industry: 0 };
|
||||
let totalW = 0;
|
||||
for (const key of regionKeys) {
|
||||
const r = resourceOf?.(key);
|
||||
if (!r) continue; // a key with no resource profile is skipped
|
||||
const g = geoOf?.(key);
|
||||
const a = g && g.area > 0 ? g.area : 1;
|
||||
const p = g && g.population > 0 ? g.population : 1;
|
||||
const w = a * p;
|
||||
totalW += w;
|
||||
sums.oil += r.oil * w;
|
||||
sums.iron += r.iron * w;
|
||||
sums.food += r.food * w;
|
||||
sums.industry += r.industry * w;
|
||||
}
|
||||
if (totalW <= 0) return zero;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
return {
|
||||
oil: round2(sums.oil / totalW),
|
||||
iron: round2(sums.iron / totalW),
|
||||
food: round2(sums.food / totalW),
|
||||
industry: round2(sums.industry / totalW),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
// Dynamic scenario generation — the shared (dependency-free) contract.
|
||||
//
|
||||
// This is the additive "Create scenario" path: instead of picking one of the
|
||||
// static presets, the user picks a light **era template**, writes a free-text
|
||||
// description, and lets the LLM generate a fresh world (a {@link WorldDraft})
|
||||
// plus an advisor **checkpoint** list. The player reviews the checkpoints,
|
||||
// approves/removes some, and picks a nation to lead; a second LLM call then
|
||||
// finalizes the draft into a playable {@link GameState} (a normal game).
|
||||
//
|
||||
// Everything here is pure and has no runtime dependencies beyond the other
|
||||
// shared modules: it may import types from `./types` and helpers from
|
||||
// `./protocol`, but it never imports the engine, the LLM client, or any
|
||||
// external package. The two LLM calls (`generateDraft` / `finalizeWorld`)
|
||||
// live in `server/scenario.ts`; only the *shapes* and the pure transforms
|
||||
// live here.
|
||||
|
||||
import type {
|
||||
EventEntry,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
NationStats,
|
||||
NationType,
|
||||
RelationStatus,
|
||||
} from './types';
|
||||
import { clampStat, makeEvent, seedRegionStats, type TurnEventSpec } from './protocol';
|
||||
|
||||
// --- Era templates -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A suggested starting power for an era template: a display `name`, an ISO_A3
|
||||
* core `region` (the country the power is rooted in), and a short `note` the
|
||||
* LLM is shown when it designs the world.
|
||||
*/
|
||||
export interface SuggestedPower {
|
||||
name: string;
|
||||
/** ISO_A3 country code the power's core territory sits in. */
|
||||
region: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A light **era template** the user can start from on the "Create scenario"
|
||||
* path (one JSON file per template in `presets/templates/`, plus an
|
||||
* `index.json` manifest). It is deliberately small: a name, a default `year`,
|
||||
* a short `eraDescription`, a few `suggestedPowers`, and a few `tensions` —
|
||||
* a seed the LLM generator expands into a full {@link WorldDraft}.
|
||||
*/
|
||||
export interface EraTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Default starting year (the user may override it in the form). */
|
||||
year: number;
|
||||
/** A short description of the era, shown on the card and fed to the LLM. */
|
||||
eraDescription: string;
|
||||
/** A few suggested starting powers (name + ISO_A3 core region + a note). */
|
||||
suggestedPowers: SuggestedPower[];
|
||||
/** A few suggested tensions / conflicts to seed the world. */
|
||||
tensions: string[];
|
||||
}
|
||||
|
||||
/** The lightweight manifest entry for an era template (the picker card). */
|
||||
export interface TemplateMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
eraDescription: string;
|
||||
}
|
||||
|
||||
// --- World draft -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single nation in a generated world **draft** (before it is committed to a
|
||||
* playable {@link GameState}). Its `territory` holds **ISO_A3 country codes**
|
||||
* (not region keys) — the engine expands them to sub-region keys at commit
|
||||
* time via the injected `expand` resolver. `relations` are the LLM's
|
||||
* numeric sentiment toward other nations (−100..100); the engine maps them to
|
||||
* a {@link RelationStatus} on commit.
|
||||
*/
|
||||
export interface WorldNation {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: NationType;
|
||||
stats: NationStats;
|
||||
/** ISO_A3 country codes the nation controls. */
|
||||
territory: string[];
|
||||
/** Bilateral sentiment: other nation id -> −100..100. */
|
||||
relations: Record<string, number>;
|
||||
}
|
||||
|
||||
/** An optional narrative backstory event carried by a world draft (turn 0). */
|
||||
export interface ScenarioEvent {
|
||||
nation: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft world the LLM generator returns before the player confirms it.
|
||||
* `nations` is required and non-empty; `events` is optional backstory that
|
||||
* seeds the new game's event feed on commit.
|
||||
*/
|
||||
export interface WorldDraft {
|
||||
nations: WorldNation[];
|
||||
events?: ScenarioEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single **advisor checkpoint** — a short string the generator proposes for
|
||||
* the player to approve (or remove) before the world is finalized. The
|
||||
* approved checkpoints are handed to the finalize call as constraints.
|
||||
*/
|
||||
export type Checkpoint = string;
|
||||
|
||||
// --- Client ↔ server message shapes (the two LLM steps) -----------------------
|
||||
|
||||
/** The body of `POST /api/scenarios/draft` (step 1: design the world). */
|
||||
export interface ScenarioDraftRequest {
|
||||
templateId: string;
|
||||
description: string;
|
||||
/** The chosen start year (the server falls back to the template's year). */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
/** The response of `POST /api/scenarios/draft` — the draft world + checkpoints. */
|
||||
export interface ScenarioDraftResponse {
|
||||
world: WorldDraft;
|
||||
checkpoints: string[];
|
||||
}
|
||||
|
||||
/** The body of `POST /api/scenarios/commit` (step 2: finalize + save the game). */
|
||||
export interface ScenarioCommitRequest {
|
||||
templateId: string;
|
||||
description: string;
|
||||
/** The chosen start year (the server falls back to the template's year). */
|
||||
year?: number;
|
||||
/** The step-1 draft, re-sent so the final world stays close to it. */
|
||||
draft?: WorldDraft;
|
||||
/** The checkpoints the player approved (the finalize step's constraints). */
|
||||
approvedCheckpoints: string[];
|
||||
/** The nation id the player will lead (kept in the final world when present). */
|
||||
playerNationId: string;
|
||||
}
|
||||
|
||||
/** The response of `POST /api/scenarios/commit` — the saved game's id. */
|
||||
export interface ScenarioCommitResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// --- Validation + transforms (pure) ------------------------------------------
|
||||
|
||||
/** The 8 known event types (a local copy so this module needs no runtime import). */
|
||||
const EVENT_TYPES: readonly string[] = [
|
||||
'military',
|
||||
'diplomatic',
|
||||
'economic',
|
||||
'political',
|
||||
'social',
|
||||
'disaster',
|
||||
'scientific',
|
||||
'cultural',
|
||||
];
|
||||
|
||||
/** The 5 known nation types. */
|
||||
const NATION_TYPES: readonly string[] = ['major', 'minor', 'power', 'alliance', 'faction'];
|
||||
|
||||
/** True when `t` is one of the eight known event types. */
|
||||
function isEventType(t: unknown): t is EventType {
|
||||
return typeof t === 'string' && (EVENT_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
/** True when `t` is one of the five known event outcomes. */
|
||||
function isEventOutcome(o: unknown): o is EventOutcome {
|
||||
return o === 'success' || o === 'failure' || o === 'partial' || o === 'neutral';
|
||||
}
|
||||
|
||||
/** True when `t` is one of the five known nation types. */
|
||||
function isNationType(t: unknown): t is NationType {
|
||||
return typeof t === 'string' && (NATION_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
/** Clamp a number to [lo, hi] and round (non-finite → lo). */
|
||||
function clampRange(v: number, lo: number, hi: number): number {
|
||||
if (!Number.isFinite(v)) return lo;
|
||||
return Math.max(lo, Math.min(hi, Math.round(v)));
|
||||
}
|
||||
|
||||
/** Parse a raw (unknown) stats object into a clamped NationStats (missing → 50). */
|
||||
function parseStats(raw: unknown): NationStats {
|
||||
const o =
|
||||
raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const get = (k: keyof NationStats): number => {
|
||||
const v = o[k];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? clampStat(v) : 50;
|
||||
};
|
||||
return {
|
||||
military: get('military'),
|
||||
economy: get('economy'),
|
||||
stability: get('stability'),
|
||||
population: get('population'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, stable, lowercase slug for a nation name (used as a fallback id):
|
||||
* "United Kingdom" → "united-kingdom". Diacritics stripped, runs of
|
||||
* non-alphanumerics collapsed to a single dash, length capped at 20.
|
||||
*/
|
||||
function slugFromName(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!s) return '';
|
||||
return s.length > 20 ? s.slice(0, 20).replace(/-+$/, '') : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric sentiment score (−100..100) to a {@link RelationStatus}.
|
||||
* Five bands: ally (≥60), neutral (0..59), rival (−40..−1), hostile (−80..−41),
|
||||
* war (≤−81). Pure.
|
||||
*/
|
||||
export function relationFromScore(v: number): RelationStatus {
|
||||
const c = clampRange(v, -100, 100);
|
||||
if (c >= 60) return 'ally';
|
||||
if (c >= 0) return 'neutral';
|
||||
if (c >= -40) return 'rival';
|
||||
if (c >= -80) return 'hostile';
|
||||
return 'war';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a raw LLM world-draft payload into a clean
|
||||
* {@link WorldDraft} (or `null` when it is unusable). Never throws:
|
||||
* - the top level must be an object with a non-empty `nations` array;
|
||||
* - each nation needs a non-empty `name`; its `id` is kept when a valid
|
||||
* non-empty string, else derived from a name slug (else `n<index>`);
|
||||
* duplicate ids are suffixed until unique;
|
||||
* - each nation's `type`/stats are clamped (missing stats → 50);
|
||||
* - each nation's `territory` is upper-cased, deduped, and (when
|
||||
* `validCountryCodes` is given) filtered to only known ISO_A3 codes;
|
||||
* - a nation's `relations` are clamped to −100..100 and re-keyed to the
|
||||
* assigned ids (a key that is a known id, or a case-insensitive name
|
||||
* match, is kept; unknown / self references are dropped);
|
||||
* - optional `events` are kept when they name a known nation (by id or
|
||||
* name) and have a non-empty title;
|
||||
* - the result is `null` when there are no nations, or no nation ends up
|
||||
* owning a valid territory (nothing to render on the map).
|
||||
*/
|
||||
export function validateWorldDraft(
|
||||
raw: unknown,
|
||||
opts: { validCountryCodes?: ReadonlySet<string> } = {},
|
||||
): WorldDraft | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const rawNations = Array.isArray(obj.nations) ? obj.nations : null;
|
||||
if (!rawNations || rawNations.length === 0) return null;
|
||||
|
||||
const validCodes = opts.validCountryCodes;
|
||||
const usedIds = new Set<string>();
|
||||
const nameToId = new Map<string, string>();
|
||||
|
||||
interface Tmp {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NationType;
|
||||
stats: NationStats;
|
||||
territory: string[];
|
||||
rawRelations: Record<string, number>;
|
||||
}
|
||||
const tmp: Tmp[] = [];
|
||||
|
||||
for (const rn of rawNations) {
|
||||
if (!rn || typeof rn !== 'object') continue;
|
||||
const o = rn as Record<string, unknown>;
|
||||
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
||||
if (name === '') continue; // a nation needs a name
|
||||
|
||||
// id: keep a valid non-empty string, else derive from the name slug, else n<index>.
|
||||
let id = typeof o.id === 'string' && o.id.trim() !== '' ? o.id.trim() : '';
|
||||
if (id === '') id = slugFromName(name) || `n${tmp.length + 1}`;
|
||||
let candidate = id;
|
||||
let k = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${id}-${k}`;
|
||||
k += 1;
|
||||
}
|
||||
id = candidate;
|
||||
usedIds.add(id);
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!nameToId.has(lowerName)) nameToId.set(lowerName, id);
|
||||
|
||||
const type = isNationType(o.type) ? o.type : 'major';
|
||||
const stats = parseStats(o.stats);
|
||||
|
||||
// territory: upper-case, de-duped, and (optionally) filtered to known codes.
|
||||
const territory: string[] = [];
|
||||
if (Array.isArray(o.territory)) {
|
||||
for (const t of o.territory) {
|
||||
if (typeof t !== 'string') continue;
|
||||
const code = t.trim().toUpperCase();
|
||||
if (code === '') continue;
|
||||
if (validCodes && !validCodes.has(code)) continue; // drop unknown country codes
|
||||
if (!territory.includes(code)) territory.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
// relations: keep only finite numeric values (clamped later on re-key).
|
||||
const rawRelations: Record<string, number> = {};
|
||||
if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) {
|
||||
for (const [key, v] of Object.entries(o.relations as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) rawRelations[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
tmp.push({ id, name, type, stats, territory, rawRelations });
|
||||
}
|
||||
|
||||
if (tmp.length === 0) return null;
|
||||
// Unusable when no nation owns a valid territory (there is nothing to render).
|
||||
if (!tmp.some((t) => t.territory.length > 0)) return null;
|
||||
|
||||
const idSet = new Set(tmp.map((t) => t.id));
|
||||
const nations: WorldNation[] = tmp.map((t) => {
|
||||
const relations: Record<string, number> = {};
|
||||
for (const [key, val] of Object.entries(t.rawRelations)) {
|
||||
// Resolve the key: a known id, else a case-insensitive name match.
|
||||
let target = key;
|
||||
if (!idSet.has(target)) {
|
||||
const mapped = nameToId.get(key.toLowerCase());
|
||||
if (mapped) target = mapped;
|
||||
}
|
||||
if (target === t.id) continue; // drop self-references
|
||||
if (!idSet.has(target)) continue; // drop references to unknown nations
|
||||
relations[target] = clampRange(val, -100, 100);
|
||||
}
|
||||
return { id: t.id, name: t.name, type: t.type, stats: t.stats, territory: t.territory, relations };
|
||||
});
|
||||
|
||||
// Optional backstory events (kept when they name a known nation).
|
||||
let events: ScenarioEvent[] | undefined;
|
||||
if (Array.isArray(obj.events)) {
|
||||
const evs: ScenarioEvent[] = [];
|
||||
for (const re of obj.events) {
|
||||
if (!re || typeof re !== 'object') continue;
|
||||
const e = re as Record<string, unknown>;
|
||||
const title = typeof e.title === 'string' ? e.title.trim() : '';
|
||||
if (title === '') continue;
|
||||
const text = typeof e.text === 'string' ? e.text : '';
|
||||
const type = isEventType(e.type) ? e.type : 'political';
|
||||
const outcome = isEventOutcome(e.outcome) ? e.outcome : 'neutral';
|
||||
let nation = typeof e.nation === 'string' ? e.nation : '';
|
||||
if (nation !== '' && !idSet.has(nation)) {
|
||||
nation = nameToId.get(nation.toLowerCase()) ?? '';
|
||||
}
|
||||
if (nation === '' || !idSet.has(nation)) continue; // drop events with no known owner
|
||||
evs.push({ nation, type, title, text, outcome });
|
||||
}
|
||||
events = evs;
|
||||
}
|
||||
|
||||
return { nations, events };
|
||||
}
|
||||
|
||||
/** Options for {@link worldDraftToGameState} (all optional, sensible defaults). */
|
||||
export interface WorldDraftToGameOptions {
|
||||
/** The new game's id (the server sets `randomUUID()`; default `''`). */
|
||||
id?: string;
|
||||
/** The scenario title (becomes `GameState.presetTitle`; default 'Custom World'). */
|
||||
title?: string;
|
||||
/** The game's start date (`YYYY-MM-DD`, from the chosen year; default '1970-01-01'). */
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a validated {@link WorldDraft} into a fresh, playable {@link
|
||||
* GameState} (a normal game at `#/game/<id>`). Mirrors `POST /api/games`
|
||||
* exactly, except the draft's nations carry **ISO_A3** territory which is
|
||||
* expanded to sub-region keys by the injected `expand` resolver, and
|
||||
* `regionStats` is seeded from the expanded territories via
|
||||
* {@link seedRegionStats} (so every owned region starts with its owner's
|
||||
* stats and the derived aggregate equals them on turn 1).
|
||||
*
|
||||
* The result is a clean turn-1 state: `version: 1`, `turn: 1`, `unowned: []`,
|
||||
* `markers: []`, `pendingNationOps: []`, `pending: []`, the given
|
||||
* `playerNationId`, and a `startDate` from the chosen year. Backstory events
|
||||
* (if any) seed the event feed at turn 1. Pure — no side effects, no I/O.
|
||||
*/
|
||||
export function worldDraftToGameState(
|
||||
draft: WorldDraft,
|
||||
expand: (countries: string[]) => string[],
|
||||
playerNationId: string,
|
||||
opts: WorldDraftToGameOptions = {},
|
||||
): GameState {
|
||||
const id = opts.id ?? '';
|
||||
const title = opts.title ?? 'Custom World';
|
||||
const startDate = opts.startDate ?? '1970-01-01';
|
||||
|
||||
const nations: Record<string, Nation> = {};
|
||||
for (const wn of draft.nations) {
|
||||
const relations: Record<string, RelationStatus> = {};
|
||||
for (const [otherId, score] of Object.entries(wn.relations)) {
|
||||
if (otherId === wn.id) continue; // no self-relations
|
||||
relations[otherId] = relationFromScore(score);
|
||||
}
|
||||
nations[wn.id] = {
|
||||
id: wn.id,
|
||||
name: wn.name,
|
||||
type: wn.type ?? 'major',
|
||||
stats: wn.stats,
|
||||
territory: expand(wn.territory),
|
||||
relations,
|
||||
};
|
||||
}
|
||||
|
||||
// Guard: a finalize step (or a bad model answer) might drop the nation the
|
||||
// player chose to lead. Rather than produce a game with a dangling
|
||||
// playerNationId, fall back to the first nation (deterministic insertion
|
||||
// order) so the resulting game is always playable.
|
||||
const effectivePlayer =
|
||||
nations[playerNationId] !== undefined
|
||||
? playerNationId
|
||||
: draft.nations.length > 0
|
||||
? draft.nations[0].id
|
||||
: '';
|
||||
|
||||
// Seed the event feed from the draft's optional backstory (turn 1, startDate).
|
||||
const events: EventEntry[] = (draft.events ?? []).map((e) =>
|
||||
makeEvent(e as unknown as TurnEventSpec, 1, startDate),
|
||||
);
|
||||
|
||||
const state: GameState = {
|
||||
id,
|
||||
presetId: 'dynamic',
|
||||
presetTitle: title,
|
||||
playerNationId: effectivePlayer,
|
||||
turn: 1,
|
||||
date: startDate,
|
||||
nations,
|
||||
events,
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// Seed AFTER the territories hold real region keys: each owned region gets
|
||||
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||
regionStats: seedRegionStats(nations),
|
||||
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
|
||||
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
|
||||
industry: [],
|
||||
};
|
||||
return state;
|
||||
}
|
||||
+192
@@ -26,6 +26,23 @@ export interface RegionStats {
|
||||
population: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.36, #59) A region's synthesized four-value resource profile — each value
|
||||
* a finite fraction in [0, 1]. Built once per region key by
|
||||
* `scripts/build-region-resources.mjs` into the committed
|
||||
* `public/region-resources.json` (the engine reads it via `regions.resourceOf`).
|
||||
* The engine scales a region's per-turn industry boost by its `industry` value,
|
||||
* and a nation's aggregate profile (the area×population-weighted mean of its
|
||||
* owned regions' profiles, see `protocol.deriveNationResources`) is what the
|
||||
* LLM prompt carries so the model can reason about where to build.
|
||||
*/
|
||||
export interface RegionResources {
|
||||
oil: number;
|
||||
iron: number;
|
||||
food: number;
|
||||
industry: number;
|
||||
}
|
||||
|
||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||
export type StatKey = (typeof STAT_KEYS)[number];
|
||||
|
||||
@@ -55,6 +72,34 @@ export interface Nation {
|
||||
stats: NationStats;
|
||||
territory: string[];
|
||||
relations: Record<string, RelationStatus>;
|
||||
/**
|
||||
* Whether the nation controls nuclear weapons (v0.37, #60). Optional — a
|
||||
* legacy game loaded from SQLite has no flag, which means "not nuclear".
|
||||
* A nation is *nuclear-capable* (for the `nuclear` lever and MAD
|
||||
* retaliation) when this is `true` OR it owns a `rocket` marker (see
|
||||
* `isNuclearCapable` in `protocol.ts`).
|
||||
*/
|
||||
nuclear?: boolean;
|
||||
/**
|
||||
* The nation's current named leader (v0.38, #61). Optional — a legacy game
|
||||
* (or a nation the LLM has never given a leader to) has none, which is the
|
||||
* "no leader" baseline. The LLM changes it via `TurnResult.leaders`
|
||||
* (assassination / death / appointment, see `LeaderChange` in `protocol.ts`);
|
||||
* each change carries a deterministic stability effect + a narration event.
|
||||
*/
|
||||
leader?: { name: string };
|
||||
/**
|
||||
* The nation's current form of government (v0.39, #62) — a free-form label
|
||||
* such as `"democracy"`, `"fascism"` or `"military junta"`. Optional — a
|
||||
* legacy game (or a nation the LLM has never given an ideology to) has none,
|
||||
* which is the "no recorded government" baseline. The LLM changes it via
|
||||
* `TurnResult.ideology` (coups / revolutions / reforms / constitutional
|
||||
* changes, see `IdeologyChange` in `protocol.ts`); each change carries a
|
||||
* deterministic stability dip + a political narration event. Distinct from
|
||||
* the v0.32.0 takeover (which renames the nation) and from `leader` (who is
|
||||
* in charge): only the form of government and stability move.
|
||||
*/
|
||||
ideology?: string;
|
||||
}
|
||||
|
||||
/** Categories a narrative event can take. */
|
||||
@@ -143,6 +188,105 @@ export interface MapMarker {
|
||||
placedTurn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
|
||||
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
|
||||
* op) and ended by a `lift: true` op; while active it yields a small,
|
||||
* deterministic economy gain to BOTH parties every committed turn. The
|
||||
* engine suspends (removes) the trade automatically when the two parties end
|
||||
* up on opposite sides of an active war (v0.33, #56).
|
||||
*/
|
||||
export interface TradeAgreement {
|
||||
/** First party's nation id. */
|
||||
a: string;
|
||||
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
|
||||
b: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
|
||||
* `target` (which must differ from `from`). Declared by the LLM via
|
||||
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
|
||||
* op; while active it applies a small, deterministic economy dip to the
|
||||
* TARGET only, every committed turn. The engine suspends (removes) the
|
||||
* embargo automatically when the two nations end up on opposite sides of an
|
||||
* active war (v0.33, #56).
|
||||
*/
|
||||
export interface Embargo {
|
||||
/** The imposing nation's id. */
|
||||
from: string;
|
||||
/** The embargoed nation's id (distinct from `from`). */
|
||||
target: string;
|
||||
}
|
||||
|
||||
// --- Resources & industry (v0.36, #59) ---------------------------------------
|
||||
//
|
||||
// The LLM can build per-region INDUSTRY PROJECTS (an oil facility, an iron
|
||||
// mine, a food operation, or a factory) on regions it controls; each active
|
||||
// project gives that region's sole owner a small, deterministic economy boost
|
||||
// every committed turn, scaled by the region's `industry` resource value from
|
||||
// the committed resource manifest. The active projects live in
|
||||
// `GameState.industry` — the LLM's "industry" lever.
|
||||
|
||||
/** The four industry project types a region can host (v0.36, #59). */
|
||||
export type IndustryType = 'oil' | 'iron' | 'food' | 'industry';
|
||||
|
||||
/** All industry project types, in canonical order (the LLM contract + validator). */
|
||||
export const INDUSTRY_TYPES: IndustryType[] = ['oil', 'iron', 'food', 'industry'];
|
||||
|
||||
/**
|
||||
* (v0.36, #59) One active industry project: a building of a given
|
||||
* {@link IndustryType} in a specific region. The engine keys the per-turn
|
||||
* economy boost by the region's SOLE owner (a region with no owner or 2+
|
||||
* owners yields no boost) and scales it by the region's `industry` resource
|
||||
* value. A region hosts at most ONE project — re-declaring on a region that
|
||||
* already has one is a no-op (the first declared type wins).
|
||||
*/
|
||||
export interface IndustryProject {
|
||||
/** The region key the project sits in (see `public/region-keys.json`). */
|
||||
regionKey: string;
|
||||
/** The kind of project built there (see {@link IndustryType}). */
|
||||
type: IndustryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
|
||||
* between two or more nations. Unlike the boolean `war` relation (which a
|
||||
* preset or a bare `relations` entry can carry without any structure behind
|
||||
* it), a `War` is an explicit world entity the engine tracks: the LLM
|
||||
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
|
||||
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
|
||||
* `GameState.wars` for the rest of the game as history after it ends.
|
||||
*
|
||||
* **Semantics of `sides`:** the flat list of every belligerent in the war
|
||||
* (≥2 distinct nation ids). Any two distinct members of an active war's
|
||||
* sides are at war with each other — the engine derives the symmetric `war`
|
||||
* relation for every such pair on every turn (see the engine's
|
||||
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
|
||||
* ends. A mutual-defense ally of a belligerent is dragged into the same list
|
||||
* on declaration (the engine does this automatically).
|
||||
*/
|
||||
export interface War {
|
||||
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
|
||||
id: string;
|
||||
/** The belligerent nations (≥2 distinct ids; see the note above). */
|
||||
sides: string[];
|
||||
/** The turn number the war was declared on. */
|
||||
declaredTurn: number;
|
||||
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
|
||||
declaredDate?: string;
|
||||
/** Optional short war aims the LLM names at declaration. */
|
||||
aims?: string[];
|
||||
/**
|
||||
* Lifecycle: `active` until a turn ends the war, then the end outcome —
|
||||
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
|
||||
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
|
||||
*/
|
||||
status: 'active' | 'armistice' | 'capitulation' | 'ended';
|
||||
/** Optional region keys the war's front line runs through (reserved). */
|
||||
front?: string[];
|
||||
}
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
@@ -251,6 +395,54 @@ export interface GameState {
|
||||
* — additive, no data wipe.
|
||||
*/
|
||||
regionStats: Record<string, RegionStats>;
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
|
||||
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
|
||||
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
|
||||
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
|
||||
* wars on every turn (two nations are in a `war` relation iff they are both
|
||||
* belligerents of ≥1 active war), so the boolean relation and the war
|
||||
* entities can never drift apart. A bare preset `war` relation with no
|
||||
* active war behind it stays as-is (an implicit, unnamed war — never
|
||||
* force-created into an entity). Always `[]` on a fresh game; rows saved
|
||||
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
wars: War[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
|
||||
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
|
||||
* `lift: true` ends). Every committed turn the engine applies a small,
|
||||
* deterministic economy gain to BOTH parties of each active agreement, and
|
||||
* suspends (removes) any agreement whose two parties are on opposite sides
|
||||
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
|
||||
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
trades: TradeAgreement[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
|
||||
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
|
||||
* `lift: true` lifts). Every committed turn the engine applies a small,
|
||||
* deterministic economy dip to each embargo's TARGET only, and suspends
|
||||
* (removes) any embargo whose two nations are on opposite sides of an
|
||||
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
|
||||
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
|
||||
* no data wipe.
|
||||
*/
|
||||
embargoes: Embargo[];
|
||||
/**
|
||||
* (v0.36, #59) Active industry projects (see {@link IndustryProject}): built
|
||||
* by a turn's `industry.projects` ops (the LLM names a region key + a
|
||||
* project type). Each region hosts at most one project — re-declaring on a
|
||||
* region that already has one is a no-op. Every committed turn the engine
|
||||
* gives each project's region's SOLE owner a small, deterministic economy
|
||||
* boost scaled by that region's `industry` resource value. A project on a
|
||||
* region that ends a turn with no owner (or 2+ owners) yields no boost but
|
||||
* is NOT removed. Always `[]` on a fresh game; rows saved before v0.36 load
|
||||
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
||||
*/
|
||||
industry: IndustryProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+128
-2
@@ -16,6 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import ScenarioForm from './components/ScenarioForm';
|
||||
import CheckpointReview from './components/CheckpointReview';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
@@ -26,9 +28,27 @@ import { api } from './lib/api';
|
||||
import { useSse } from './lib/sse';
|
||||
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
import type { TemplateMeta, WorldDraft } from '@shared/scenario';
|
||||
|
||||
/** Which top-level screen the app is showing. */
|
||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
||||
/**
|
||||
* Which top-level screen the app is showing. The dynamic path (v0.31, #52) adds
|
||||
* two: 'scenario-form' (era + description + year) and 'scenario-review' (the
|
||||
* generated world + checkpoint checklist + nation pick), the latter carrying
|
||||
* the step-1 draft + chosen template so the finalize step can re-send them.
|
||||
*/
|
||||
type Screen =
|
||||
| { name: 'home' }
|
||||
| { name: 'pick'; preset: Preset }
|
||||
| { name: 'game' }
|
||||
| { name: 'scenario-form' }
|
||||
| {
|
||||
name: 'scenario-review';
|
||||
template: TemplateMeta;
|
||||
description: string;
|
||||
year: number;
|
||||
world: WorldDraft;
|
||||
checkpoints: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
|
||||
@@ -181,6 +201,10 @@ export default function App() {
|
||||
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||
const [opBusy, setOpBusy] = useState(false);
|
||||
// (v0.31, #52) A scenario-generation LLM call (generate or finalize) is
|
||||
// in flight. Distinct from `busy`/`advisorBusy` — it only gates the
|
||||
// scenario-form/review screens (those screens never coexist with a game).
|
||||
const [scenarioBusy, setScenarioBusy] = useState(false);
|
||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||
// list of saved games (newest first) shown on the home screen.
|
||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||
@@ -359,6 +383,77 @@ export default function App() {
|
||||
[screen],
|
||||
);
|
||||
|
||||
// --- (v0.31, #52) Dynamic scenario generation: form → generate → review →
|
||||
// finalize → game. Both LLM steps run on the server (the scenario stub under
|
||||
// LLM_STUB=1, the live model otherwise); on failure we toast and stay on the
|
||||
// current screen (the form's / review's local selections are preserved).
|
||||
|
||||
// Home screen's "Create scenario" card → the scenario form.
|
||||
const openScenarioForm = useCallback(() => {
|
||||
setScreen({ name: 'scenario-form' });
|
||||
}, []);
|
||||
|
||||
// Step 1 (generate): design the draft world + checkpoint list. On success
|
||||
// the app moves to the review screen (carrying the draft, template, year,
|
||||
// and description so the finalize step can re-send them).
|
||||
const generateScenarioDraft = useCallback(
|
||||
async (template: TemplateMeta, description: string, year: number) => {
|
||||
setScenarioBusy(true);
|
||||
try {
|
||||
const { world, checkpoints } = await api.draftScenario({
|
||||
templateId: template.id,
|
||||
description,
|
||||
year,
|
||||
});
|
||||
setScreen({ name: 'scenario-review', template, description, year, world, checkpoints });
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setScenarioBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Step 2 (finalize): commit the approved draft into a saved, playable game
|
||||
// and enter it. The step-1 draft is re-sent so the final world stays close
|
||||
// to it (and the chosen nation survives); the new game's id is then fetched
|
||||
// and adopted exactly like a normal created game (active-game persistence,
|
||||
// applyState, screen → game — the URL-sync effect writes the deep link).
|
||||
const finalizeScenario = useCallback(
|
||||
async (
|
||||
template: TemplateMeta,
|
||||
description: string,
|
||||
year: number,
|
||||
world: WorldDraft,
|
||||
approvedCheckpoints: string[],
|
||||
playerNationId: string,
|
||||
) => {
|
||||
setScenarioBusy(true);
|
||||
try {
|
||||
const { id } = await api.commitScenario({
|
||||
templateId: template.id,
|
||||
description,
|
||||
year,
|
||||
draft: world,
|
||||
approvedCheckpoints,
|
||||
playerNationId,
|
||||
});
|
||||
const { game } = await api.getGame(id);
|
||||
persistActiveGameId(id);
|
||||
setActiveGameId(id);
|
||||
applyState(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setScenarioBusy(false);
|
||||
}
|
||||
},
|
||||
[applyState],
|
||||
);
|
||||
|
||||
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
||||
// list updates from the response and the SSE `state` event. Both are
|
||||
// version-gated via applyState, so the item can never be clobbered by a
|
||||
@@ -573,6 +668,36 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'scenario-form') {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<ScenarioForm busy={scenarioBusy} onBack={goHome} onGenerate={generateScenarioDraft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'scenario-review') {
|
||||
const { template, description, year, world, checkpoints } = screen;
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<CheckpointReview
|
||||
template={template}
|
||||
description={description}
|
||||
year={year}
|
||||
world={world}
|
||||
checkpoints={checkpoints}
|
||||
busy={scenarioBusy}
|
||||
onBack={openScenarioForm}
|
||||
onFinalize={(approved, nationId) =>
|
||||
void finalizeScenario(template, description, year, world, approved, nationId)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
@@ -584,6 +709,7 @@ export default function App() {
|
||||
activeGameId={activeGameId}
|
||||
onResume={resumeGame}
|
||||
onDelete={runDeleteGame}
|
||||
onCreateScenario={openScenarioForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, ListChecks, Loader2, Sparkles } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EVENT_COLORS, RELATION_COLORS } from '@/lib/colors';
|
||||
import { relationFromScore, type TemplateMeta, type WorldDraft, type WorldNation } from '@shared/scenario';
|
||||
|
||||
interface Props {
|
||||
/** The era template the draft was generated from (name/year for the header). */
|
||||
template: TemplateMeta;
|
||||
/** The free-text description the user wrote (shown as the world's context). */
|
||||
description: string;
|
||||
/** The effective start year (template default or the user's override). */
|
||||
year: number;
|
||||
/** The step-1 draft world to review and finalize. */
|
||||
world: WorldDraft;
|
||||
/** The advisor's proposed checkpoints (all approved by default). */
|
||||
checkpoints: string[];
|
||||
/** A finalize (LLM) call is in flight — disables the controls. */
|
||||
busy?: boolean;
|
||||
/** Back to the scenario form (a fresh attempt — the form's state resets). */
|
||||
onBack: () => void;
|
||||
/** Step 2: finalize with the approved checkpoints + the chosen nation to lead. */
|
||||
onFinalize: (approvedCheckpoints: string[], playerNationId: string) => void;
|
||||
}
|
||||
|
||||
const STAT_BARS: Array<{ key: keyof WorldNation['stats']; label: string }> = [
|
||||
{ key: 'military', label: 'Military' },
|
||||
{ key: 'economy', label: 'Economy' },
|
||||
{ key: 'stability', label: 'Stability' },
|
||||
{ key: 'population', label: 'Population' },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Create scenario" — step 2 of the dynamic path (v0.31, #52). Shows the
|
||||
* generated world (each nation's stats, territory country codes, and
|
||||
* relations; plus any backstory events) and the advisor's proposed
|
||||
* checkpoints as an approve/remove checklist (approved by default — the
|
||||
* approved ones bind the finalize step). The player then picks which nation
|
||||
* to lead (the nation cards mirror NationSelect's pick screen; the draft's
|
||||
* nations are not a static Preset, so they are rendered here directly) and
|
||||
* finalizes. On success the app navigates into the new game; on failure it
|
||||
* toasts and stays here with the current selections intact.
|
||||
*/
|
||||
export default function CheckpointReview({
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
world,
|
||||
checkpoints,
|
||||
busy = false,
|
||||
onBack,
|
||||
onFinalize,
|
||||
}: Props) {
|
||||
// Approve/remove per checkpoint (approved by default — the advisor's list is
|
||||
// a starting point the player prunes, not a form they fill in).
|
||||
const [approved, setApproved] = useState<boolean[]>(() => checkpoints.map(() => true));
|
||||
// The nation the player will lead (none until explicitly picked).
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const nameOf = (id: string) => world.nations.find((n) => n.id === id)?.name ?? id;
|
||||
const approvedCount = approved.filter(Boolean).length;
|
||||
const selected = selectedId ? world.nations.find((n) => n.id === selectedId) : undefined;
|
||||
|
||||
const toggle = (i: number) =>
|
||||
setApproved((prev) => prev.map((v, j) => (j === i ? !v : v)));
|
||||
|
||||
const finalize = () => {
|
||||
if (!selectedId) return;
|
||||
onFinalize(
|
||||
checkpoints.filter((_, i) => approved[i]),
|
||||
selectedId,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-review">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to form
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{template.name}</h1>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{year}
|
||||
</Badge>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* --- The generated world --- */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">The generated world</h2>
|
||||
<div className="space-y-3">
|
||||
{world.nations.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="rounded-lg border border-border/60 bg-card/40 p-3"
|
||||
data-testid="draft-nation"
|
||||
data-nation-id={n.id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{n.name}</span>
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{n.type ?? 'major'}
|
||||
</Badge>
|
||||
{n.territory.length > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{n.territory.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||
{STAT_BARS.map(({ key, label }) => (
|
||||
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||
))}
|
||||
</div>
|
||||
{Object.keys(n.relations).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Object.entries(n.relations).map(([otherId, score]) => {
|
||||
const status = relationFromScore(score);
|
||||
return (
|
||||
<span
|
||||
key={otherId}
|
||||
className="rounded-full border px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
color: RELATION_COLORS[status],
|
||||
borderColor: `${RELATION_COLORS[status]}55`,
|
||||
}}
|
||||
>
|
||||
{nameOf(otherId)} {score > 0 ? `+${score}` : score}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(world.events ?? []).length > 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Backstory
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{(world.events ?? []).map((e, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm" data-testid="draft-event">
|
||||
<span
|
||||
className="mt-1.5 inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ background: EVENT_COLORS[e.type] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{nameOf(e.nation)}:</span>{' '}
|
||||
{e.title} — {e.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Advisor checkpoints (approve / remove) --- */}
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<ListChecks className="size-4 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Advisor checkpoints</h2>
|
||||
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
|
||||
{approvedCount} of {checkpoints.length} approved
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
The advisor’s opening read of this world’s live strategic threads. Keep the ones the final
|
||||
world should respect — removed checkpoints are dropped.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{checkpoints.map((c, i) => (
|
||||
<li
|
||||
key={`${i}-${c}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border p-2.5 text-sm',
|
||||
approved[i]
|
||||
? 'border-amber-500/30 bg-amber-500/10'
|
||||
: 'border-border/60 bg-card/30 text-muted-foreground',
|
||||
)}
|
||||
data-testid="checkpoint-item"
|
||||
data-approved={approved[i] ? 'true' : 'false'}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
approved[i] ? 'border-amber-500/50 bg-amber-500/20' : 'border-border',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{approved[i] && <Check className="size-3 text-amber-400" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug">{c}</span>
|
||||
<Button
|
||||
variant={approved[i] ? 'ghost' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => toggle(i)}
|
||||
disabled={busy}
|
||||
data-testid="checkpoint-toggle"
|
||||
>
|
||||
{approved[i] ? 'Remove' : 'Keep'}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">Choose your nation</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{world.nations.map((n) => {
|
||||
const isSel = n.id === selectedId;
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'flex flex-col transition',
|
||||
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||
)}
|
||||
data-testid="scenario-nation-card"
|
||||
data-nation-id={n.id}
|
||||
data-selected={isSel ? 'true' : 'false'}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{n.name}
|
||||
{isSel && <Check className="size-4 shrink-0 text-primary" />}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{n.type ?? 'major'} · {n.territory.length} territory unit
|
||||
{n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-1.5">
|
||||
{STAT_BARS.map(({ key, label }) => (
|
||||
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||
))}
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
variant={isSel ? 'default' : 'secondary'}
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
disabled={busy}
|
||||
data-testid="scenario-lead-nation"
|
||||
>
|
||||
{isSel ? 'Leading' : `Play as ${n.name}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* --- Finalize --- */}
|
||||
<div
|
||||
className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3"
|
||||
data-testid="finalize-bar"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||
{selected
|
||||
? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.`
|
||||
: 'Pick the nation you will lead, then finalize the scenario.'}
|
||||
</span>
|
||||
<Button className="shrink-0" onClick={finalize} disabled={busy || !selected} data-testid="finalize-scenario">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{busy ? 'Finalizing…' : 'Finalize scenario'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, Trash2 } from 'lucide-react';
|
||||
import { Play, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
@@ -26,6 +26,12 @@ interface Props {
|
||||
onResume?: (id: string) => void;
|
||||
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||
onDelete?: (id: string) => void;
|
||||
/**
|
||||
* (v0.31, #52) Open the "Create scenario" path (era template + description →
|
||||
* AI-generated world → checkpoint review → a normal game). Omitted = the
|
||||
* create-scenario card is hidden.
|
||||
*/
|
||||
onCreateScenario?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +47,7 @@ export default function PresetPicker({
|
||||
activeGameId,
|
||||
onResume,
|
||||
onDelete,
|
||||
onCreateScenario,
|
||||
}: Props) {
|
||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||
// The game awaiting a delete confirmation (null = dialog closed).
|
||||
@@ -83,6 +90,51 @@ export default function PresetPicker({
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(
|
||||
onCreateScenario
|
||||
? [
|
||||
{
|
||||
// (v0.31, #52) The dynamic path, first in the grid: it is the
|
||||
// "any world you can describe" card, distinct from the static presets.
|
||||
id: 'create',
|
||||
title: 'Create scenario',
|
||||
summary:
|
||||
'Design your own world — pick an era, describe it, and the AI drafts the world and the advisor’s opening checkpoints for your review before you pick a nation to lead.',
|
||||
badge: 'custom world',
|
||||
},
|
||||
]
|
||||
: []
|
||||
).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
className="flex flex-col border-primary/40 bg-primary/5 transition hover:ring-1 hover:ring-primary/50"
|
||||
data-testid="create-scenario-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="size-4 shrink-0 text-primary" />
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{c.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">{c.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => onCreateScenario?.()}
|
||||
disabled={loading}
|
||||
data-testid="create-scenario"
|
||||
>
|
||||
Create scenario
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{presets.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TemplateMeta } from '@shared/scenario';
|
||||
|
||||
interface Props {
|
||||
/** A generate (LLM) call is in flight — disables the form's controls. */
|
||||
busy?: boolean;
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Step 1: design the draft world + checkpoint list. The form hands over the
|
||||
* selected template's full metadata (the review screen shows its name/year)
|
||||
* plus the description and the effective start year.
|
||||
*/
|
||||
onGenerate: (template: TemplateMeta, description: string, year: number) => void;
|
||||
}
|
||||
|
||||
/** The year input is an empty string ("use the template's default") or a number. */
|
||||
const YEAR_MIN = 1000;
|
||||
const YEAR_MAX = 3000;
|
||||
|
||||
/**
|
||||
* "Create scenario" — step 1 of the dynamic path (v0.31, #52). The user picks
|
||||
* one of the light era templates (a card each: name, default year, blurb),
|
||||
* writes a free-text description of the world they want, and may override the
|
||||
* start year (the field is pre-filled from the selected template; leaving it
|
||||
* empty keeps the template's default). "Generate world" posts the draft
|
||||
* request; on success the app moves to the checkpoint review screen, on
|
||||
* failure it toasts and stays here (the form's local state is preserved).
|
||||
*/
|
||||
export default function ScenarioForm({ busy = false, onBack, onGenerate }: Props) {
|
||||
const [templates, setTemplates] = useState<TemplateMeta[]>([]);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
// Pre-filled from the selected template's default year (the user can edit).
|
||||
const [yearText, setYearText] = useState('');
|
||||
|
||||
// Load the era-template manifest on mount (the form is the only consumer).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.listScenarioTemplates()
|
||||
.then((r) => alive && setTemplates(r.templates))
|
||||
.catch((e: unknown) => alive && setTemplatesError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? null;
|
||||
const effectiveYear =
|
||||
yearText.trim() === '' ? NaN : Number.parseInt(yearText.trim(), 10);
|
||||
const yearValid =
|
||||
Number.isInteger(effectiveYear) && effectiveYear >= YEAR_MIN && effectiveYear <= YEAR_MAX;
|
||||
|
||||
const canGenerate = !busy && selected !== null && description.trim() !== '' && yearValid;
|
||||
|
||||
const selectTemplate = (t: TemplateMeta) => {
|
||||
setSelectedId(t.id);
|
||||
// Selecting an era pre-fills the year with that era's default (the user
|
||||
// can still edit it afterwards).
|
||||
setYearText(String(t.year));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
if (!canGenerate || !selected) return;
|
||||
onGenerate(selected, description.trim(), effectiveYear);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-form">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to scenarios
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create a scenario</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
Design your own world: pick an era, describe what should be true, and the AI will draft the
|
||||
world and an opening set of advisor checkpoints for your review. Nothing is saved until you
|
||||
finalize.
|
||||
</p>
|
||||
|
||||
{/* 1 — the era template (a card each). */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||
1 · Pick an era <span className="font-normal text-muted-foreground">(required)</span>
|
||||
</h2>
|
||||
{templatesError ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Could not load the era templates: {templatesError}
|
||||
</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="rounded-lg border border-border/60 bg-card/40 p-3 text-sm text-muted-foreground">
|
||||
Loading era templates…
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{templates.map((t) => {
|
||||
const isSel = t.id === selectedId;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col transition',
|
||||
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||
)}
|
||||
data-testid="template-card"
|
||||
data-template-id={t.id}
|
||||
data-selected={isSel ? 'true' : 'false'}
|
||||
onClick={() => selectTemplate(t)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{t.name}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{t.year}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
{t.eraDescription}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
isSel ? 'font-medium text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
{isSel ? 'Selected' : 'Start from this era'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2 — the description (+ optional year override). */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||
2 · Describe your world <span className="font-normal text-muted-foreground">(required)</span>
|
||||
</h2>
|
||||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. “A cold-war world where the space race decided the Cold War — two blocs orbit, and the arctic shipping lanes are the new flashpoint.”"
|
||||
rows={4}
|
||||
disabled={busy}
|
||||
data-testid="scenario-description"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="scenario-year-input" className="shrink-0 text-sm">
|
||||
Start year
|
||||
</Label>
|
||||
<Input
|
||||
id="scenario-year-input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={yearText}
|
||||
onChange={(e) => setYearText(e.target.value)}
|
||||
placeholder={selected ? `default ${selected.year}` : 'pick an era first'}
|
||||
min={YEAR_MIN}
|
||||
max={YEAR_MAX}
|
||||
disabled={busy || !selected}
|
||||
className="w-32"
|
||||
data-testid="scenario-year"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{yearValid ? `The game begins on ${effectiveYear}-01-01.` : 'Leave empty for the era default, or use a year between 1000 and 3000.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate. */}
|
||||
<div className="mt-8">
|
||||
<Button className="h-11 w-full" onClick={generate} disabled={!canGenerate} data-testid="generate-scenario">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{busy ? 'Generating world…' : 'Generate world'}
|
||||
</Button>
|
||||
{busy && (
|
||||
<p className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||
The world-builder is thinking — this can take a few seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,13 @@ import type {
|
||||
RemoveQueueItemResponse,
|
||||
TurnResponse,
|
||||
} from '@shared/protocol';
|
||||
import type {
|
||||
ScenarioCommitRequest,
|
||||
ScenarioCommitResponse,
|
||||
ScenarioDraftRequest,
|
||||
ScenarioDraftResponse,
|
||||
TemplateMeta,
|
||||
} from '@shared/scenario';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
@@ -95,4 +102,24 @@ export const api = {
|
||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||
`/api/games/${id}/map`,
|
||||
),
|
||||
// --- Dynamic scenario generation (v0.31, #52) ---------------------------
|
||||
// The additive "Create scenario" path: a two-step LLM flow (design a draft
|
||||
// world + checkpoint list, then finalize it into a saved, playable game).
|
||||
// The era-template manifest for the form's cards (id / name / year / blurb).
|
||||
listScenarioTemplates: () => req<{ templates: TemplateMeta[] }>('/api/scenarios/templates'),
|
||||
// Step 1: design a draft world + an advisor checkpoint list from an era
|
||||
// template + a free-text description. An LLM call — can be slow; a 502
|
||||
// means the model is down or returned nothing usable (the caller toasts).
|
||||
draftScenario: (body: ScenarioDraftRequest) =>
|
||||
req<ScenarioDraftResponse>('/api/scenarios/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// Step 2: finalize the approved draft (constrained to the approved
|
||||
// checkpoints) into a new, playable game and save it. Returns the id.
|
||||
commitScenario: (body: ScenarioCommitRequest) =>
|
||||
req<ScenarioCommitResponse>('/api/scenarios/commit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user