Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff4163f2c | ||
|
|
7866a1987d | ||
|
|
9d201135b3 | ||
|
|
8730a56170 | ||
|
|
b6dcd3f979 | ||
|
|
31182c8730 |
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.39.0 — 2026-10-15 20:51
|
||||||
|
- **#62: Ideology / form of government — a per-nation `ideology` + a `TurnResult.ideology` lever (coups / revolutions / reforms / constitutional changes) with a deterministic −5 stability dip and political narration.** Each nation may carry a free-form `ideology` label — its form of government ("democracy", "fascism", "military junta", …). The model changes it each turn via `ideology.changes`; each change names a target nation (id or name) and the new government type. The engine sets the label and applies a **deterministic** stability dip (a regime shift always unsettles a nation) — **no hidden randomness**, **additive** (no `db.ts` change — `ideology` is an optional `Nation` field; a pre-v0.39 row loads with it absent, i.e. "no recorded government"). It is a third, independent axis next to `leader` (who is in charge) and the v0.32.0 takeover (which renames the nation): the id, name, territory, relations, and the leader all stay valid — only the form of government and stability move.
|
||||||
|
- **Types (`shared/types.ts`).** `Nation.ideology?: string` (optional; `undefined`/absent = no recorded government).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `IdeologyChange` (`{ target; to }` — `target` is a nation ref, `to` is the new form of government), `IdeologyOps` (`{ changes: IdeologyChange[] }`); `TurnResult.ideology` is **required**, placed between `leaders` and `markers`, default `{ changes: [] }`. New exported **`IDEOLOGY_STABILITY_EFFECT = −5`** (the deterministic stability dip of a regime shift). `nationForPrompt` now includes the nation's `ideology` (omitted when absent via `JSON.stringify`), so `buildUserPrompt` carries each nation's current government with no separate wiring.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateIdeologyOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (trimmed, ≤120) and a non-empty, non-whitespace `to` (trimmed, ≤120) — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear / leader ops).
|
||||||
|
- `applyIdeologyChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyLeaderChange` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). On a valid change it sets `n.ideology = to` and applies `IDEOLOGY_STABILITY_EFFECT` (−5) to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry / leaders, so a 0-region nation keeps its directly-mutated value). Re-setting the **same** label is idempotent for the `ideology` field but still narrated and still dips (the shift just happened). Finally it appends a **`political`** narration event (`"<nation>'s government shifted to <to>"`). The change never moves territory (sole ownership is untouched).
|
||||||
|
- `applyTurnResult` — the ideology block runs **after `leaders` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → leaders → **ideology** → markers → nationOps).
|
||||||
|
- `isMeaningful` — a non-empty `ideology.changes` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `ideology` (default `{ changes: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `ideology` field (free-form label, the −5 dip, the "at most 1–2 per turn, narrated" rule) and contrasts it with a takeover (which RENAMES the nation) and a leader change (who is in charge); similar forms of government are a **soft, prompt-level compatibility nudge only** (alike ideologies tend to ally/trade, opposed ones tend to clash) — there is no engine gate behind it.
|
||||||
|
- `runTurn` — the "unclear" fallback carries `ideology: { changes: [] }`.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `ideology: { changes: [] }` and the `StubPayload` nations type gains an optional `ideology` — the stub **never** changes an ideology, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||||
|
- **Tests.** New unit tests in `engine.test.ts`: `validateIdeologyOps` (`{changes}` object, bare array, empty/whitespace `to` drop, missing/whitespace target drop, malformed top-level, mixed batch keeps only valid, trim + 120-cap); `isMeaningful` (an ideology change is meaningful, empty ideology is not); `applyTurnResult` (a valid change → field set + 50→45 + `political` event + region mirror, other nation untouched; re-setting the same label → one dip, one event; a multi-region nation → every region dipped and stats re-derived; a target resolved by name as well as id; a 0-region nation keeps its directly-dipped stat; a dip clamped at 0; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing); `buildUserPrompt` (a set ideology is stamped in the prompt nations, an absent one is omitted). `protocol.test.ts`: the `IDEOLOGY_STABILITY_EFFECT` constant is −5 and `nationForPrompt` includes/omits the `ideology` key in the JSON. Two `db.test.ts` round-trips: a legacy row with no ideology loads with it absent, and a set ideology round-trips through save/load.
|
||||||
|
- **Version** bumped `0.38.0 → 0.39.0`.
|
||||||
|
|
||||||
|
## v0.38.0 — 2026-10-15 20:06
|
||||||
|
- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader").
|
||||||
|
- **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `LeaderCause` (`'assassination' | 'death' | 'appointed'`), `LeaderChange` (`{ target; name; cause }` — `target` is a nation ref, `name` is the new leader's name), and `LeaderOps` (`{ changes: LeaderChange[] }`); `TurnResult.leaders` is **required**, placed between `nuclear` and `markers`, default `{ changes: [] }`. New exported **`LEADER_STABILITY_EFFECT = { assassination: −15, death: −3, appointed: 0 }`** (the deterministic stability dip by cause). `nationForPrompt` now includes the nation's `leader` (omitted when absent), so `buildUserPrompt` carries each nation's current leader with no separate wiring.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateLeaderOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (≤120), a non-empty `name` (≤120), and a `cause` that is one of the three literals — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear ops).
|
||||||
|
- `applyLeaderChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyHybridOp` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). **GATE**: an `assassination`/`death` requires the target to already have a leader (a leaderless target is dropped); `appointed` is always valid (it creates the first leader). On a valid change it sets `n.leader = { name }` and applies `LEADER_STABILITY_EFFECT[cause]` to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry, so a 0-region nation keeps its directly-mutated value). Finally it appends a narration event: a dramatic **`military`** event for an assassination, a **`political`** succession event for a death or an appointment. The replacement never moves territory (sole ownership is untouched).
|
||||||
|
- `applyTurnResult` — the leader block runs **after `nuclear` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → **leaders** → markers → nationOps).
|
||||||
|
- `isMeaningful` — a non-empty `leaders.changes` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `leaders` (default `{ changes: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `leaders` field (the three causes, the stability effects, the leaderless-gate, the "at most 1–2 per turn, narrated" rule) and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement) in both tool and plain modes.
|
||||||
|
- `runTurn` — the "unclear" fallback carries `leaders: { changes: [] }`.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader` — the stub **never** changes a leader, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||||
|
- **Tests.** New unit tests in `engine.test.ts`: `validateLeaderOps` (`{changes}` object, bare array, unknown-cause drop, missing target/name drop, malformed top-level, mixed batch keeps only valid); `isMeaningful` (a leader change is meaningful, empty leaders is not); `applyTurnResult` (assassination with a leader → replaced + −15 + `military` event; assassination / death on a leaderless nation → dropped, no stat change, no event; death → replaced + −3 + `political` succession event; appointment on a leader-having and on a leaderless nation → sets the leader, neutral (no dip); a multi-region nation → every region dipped and stats re-derived; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing). Two `db.test.ts` round-trips: a legacy row with no leader loads with it absent, and a set leader round-trips through save/load.
|
||||||
|
- **Version** bumped `0.37.0 → 0.38.0`.
|
||||||
|
|
||||||
|
## v0.37.0 — 2026-10-15 19:15
|
||||||
|
- **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent).
|
||||||
|
- **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn.
|
||||||
|
- **Types (`shared/types.ts`).** `Nation.nuclear?: boolean` (optional; `undefined`/absent = not armed; default `false` behavior).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `NuclearStrike` (`{ from; target }` — `from` is the striking nation, `target` is a nation ref or a region key) and `NuclearOps` (`{ arm: string[]; strikes: NuclearStrike[] }`); `TurnResult.nuclear` is **required**, placed between `industry` and `markers`, default `{ arm: [], strikes: [] }`. New exported `NuclearStatKey` (`'economy' | 'military' | 'stability'`) and the two deterministic collapse tables: **`NUCLEAR_COLLAPSE = { economy: −40, military: −50, stability: −40 }**` (a full strike's effect on the struck nation / region owner) and **`NUCLEAR_RETALIATION = { economy: −20, military: −25, stability: −20 }`**` (the half-strength MAD backfire).
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateNuclearOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ arm: [], strikes: [] }`; `arm` entries are non-empty strings (≤120 chars) deduped; a strike needs a non-empty `from` **and** `target` (≤120) and is **dropped when it targets itself** (same ref, case-insensitive) — a self-strike is a no-op, never thrown.
|
||||||
|
- `applyNuclearOps(next, ops, state, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — new exported, the load-bearing nuclear block. **`arm` first** (idempotent; a resolve miss or a self-arm is a no-op), then **`strikes`** in order: resolve `from` (an unresolvable or non-capable striker is **dropped** — capability checked against the *pre-turn* marker set plus any flag granted this turn); a `target` that is a **nation** collapses it (the `NUCLEAR_COLLAPSE` deltas applied to its `stats` and every region it owns, each affected nation's derived stats re-derived, 0-region / no-entry guards as elsewhere), ends **every active war it fights** by **capitulation** (`endWarsBetween` captures the active pairs *before* ending so a war declared+ended the same turn is handled), and downgrades the now-inactive `war` relations to `hostile`; a `target` that is a **region key** releases it to no man's land (a key with 0 or 2+ owners, or one the striker itself owns, is **dropped**), deletes its `regionStats` entry, and collapses its **sole owner** the same way. **MAD**: if the struck nation (or the struck region's sole owner) is itself nuclear-capable, it retaliates against the striker for the half-strength `NUCLEAR_RETALIATION` (clamped, mirrored, re-derived) and the two nations' relations are forced to `hostile` (`forceHostile` — both directions, unless already `war`). Each strike and each retaliation appends a dramatic **`military`** event (`war` is not a valid `EventType`). Sole ownership is never broken by a strike (a region is released, not re-claimed) and the block never throws.
|
||||||
|
- `applyTurnResult` — the nuclear block runs **after `industry` and before `markers`** (order: events → relations → territory → stateChanges → recompute → wars → hybrid → economy → industry → **nuclear** → markers → nationOps); it passes the *original* (pre-turn) `state` for the capability marker set and the current `regionNameOf` for event text.
|
||||||
|
- `isMeaningful` — a non-empty `nuclear.arm` or `nuclear.strikes` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `nuclear` (default `{ arm: [], strikes: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `nuclear` field and a constraint bullet (the capability rule; `arm` is idempotent; a strike hits a nation or a region; the collapse + capitulation + MAD outcomes; at most one strike per turn, always narrated) in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` — new optional `nuclear` param (`Record<string, boolean>`, the per-nation capability map); when provided, each `state.nations` entry in the payload is stamped with its `nuclear` capability; when undefined the key is omitted entirely (stub / no-capability prompt stays byte-identical). `runTurn` always supplies the map (computed from `isNuclearCapable` over `state.markers`).
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `nuclear: { arm: [], strikes: [] }` — the stub **never** arms a nation or fires a strike, so the e2e (CI, `LLM_STUB=1`) is unaffected and the nuclear path is covered by unit tests only.
|
||||||
|
- **Tests.** 29 new unit tests: `isNuclearCapable` (flag / own rocket / non-rocket / other's rocket / null owner); `validateNuclearOps` (well-formed, `arm` dedupe, missing `from`/`target`, self-target drop, malformed top-level); `applyTurnResult` nuclear (arm idempotency across turns, arm+strike same turn, region strike → release + stats deleted + owner collapse + no MAD + the single `military` event, nation strike + pre-existing war → capitulation + hostile, nation strike + same-turn declare → capitulation + hostile, MAD on a capable nation target, MAD on a capable region owner, rocket-marker-only capability, non-capable striker dropped, 2+-owner region dropped, 0-owner/unowned region dropped, self-target dropped, malformed strikes never throw); `isMeaningful` (arm / strike); `buildUserPrompt` (per-nation capability stamped when the map is given, omitted when undefined); and two `db.test.ts` round-trips (a legacy row with no flag loads with it absent; a set flag round-trips through save/load).
|
||||||
|
- **Version** bumped `0.36.0 → 0.37.0`.
|
||||||
|
|
||||||
## v0.36.0 — 2026-10-15 18:17
|
## 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.
|
- **#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).
|
- **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).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.36.0",
|
"version": "0.39.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
@@ -178,6 +178,72 @@ describe('openDb', () => {
|
|||||||
db.close();
|
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', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
+706
-21
@@ -1,15 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
|
import type { GameState, MapMarker, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
CONQUEST_MILITARY_MARGIN,
|
CONQUEST_MILITARY_MARGIN,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
markerId,
|
markerId,
|
||||||
MAX_MARKERS,
|
MAX_MARKERS,
|
||||||
seedRegionStats,
|
seedRegionStats,
|
||||||
|
isNuclearCapable,
|
||||||
|
NUCLEAR_COLLAPSE,
|
||||||
|
NUCLEAR_RETALIATION,
|
||||||
type HybridOp,
|
type HybridOp,
|
||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
|
type NuclearStrike,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
@@ -43,8 +47,11 @@ import {
|
|||||||
syncWarRelations,
|
syncWarRelations,
|
||||||
validateEconomyOps,
|
validateEconomyOps,
|
||||||
validateHybridOps,
|
validateHybridOps,
|
||||||
|
validateIdeologyOps,
|
||||||
validateIndustryOps,
|
validateIndustryOps,
|
||||||
|
validateLeaderOps,
|
||||||
validateMarkerOps,
|
validateMarkerOps,
|
||||||
|
validateNuclearOps,
|
||||||
validateNationOps,
|
validateNationOps,
|
||||||
validateRegionChanges,
|
validateRegionChanges,
|
||||||
validateTakeovers,
|
validateTakeovers,
|
||||||
@@ -563,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withOps).not.toBeNull();
|
expect(withOps).not.toBeNull();
|
||||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
@@ -585,7 +592,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
@@ -606,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([
|
expect(next.pendingNationOps).toEqual([
|
||||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
@@ -625,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1082,7 +1089,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1099,7 +1106,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1122,7 +1129,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -1148,7 +1155,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -1166,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
regionChanges: [],
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -1256,7 +1263,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -1371,7 +1378,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
|
|
||||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1678,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
it('is true with an event, false when everything is empty', () => {
|
||||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
@@ -1693,7 +1700,7 @@ describe('isMeaningful', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1713,6 +1720,8 @@ describe('isMeaningful', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -1733,6 +1742,8 @@ describe('isMeaningful', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -1755,6 +1766,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1787,6 +1800,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1810,6 +1825,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1836,6 +1853,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1861,6 +1880,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(validated!.takeovers).toEqual([]);
|
expect(validated!.takeovers).toEqual([]);
|
||||||
@@ -1886,6 +1907,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1909,6 +1932,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1930,6 +1955,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
@@ -1963,6 +1990,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||||
@@ -1987,13 +2016,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||||
const first = applyTurnResult(state, {
|
const first = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(first.markers).toHaveLength(1);
|
expect(first.markers).toHaveLength(1);
|
||||||
// a second turn proposes the exact same marker again
|
// a second turn proposes the exact same marker again
|
||||||
const second = applyTurnResult(first, {
|
const second = applyTurnResult(first, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(second.markers).toHaveLength(1);
|
expect(second.markers).toHaveLength(1);
|
||||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||||
@@ -2019,6 +2048,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||||
@@ -2065,6 +2096,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||||
@@ -2090,7 +2123,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
];
|
];
|
||||||
const next = applyTurnResult(state, {
|
const next = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(next.markers).toHaveLength(1);
|
expect(next.markers).toHaveLength(1);
|
||||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||||
@@ -2106,14 +2139,14 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
// 1) uk releases the region → the marker becomes ownerless
|
// 1) uk releases the region → the marker becomes ownerless
|
||||||
const released = applyTurnResult(state, {
|
const released = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(released.markers[0].ownerNationId).toBeNull();
|
expect(released.markers[0].ownerNationId).toBeNull();
|
||||||
// 2) de re-claims the region → the marker's owner follows to de
|
// 2) de re-claims the region → the marker's owner follows to de
|
||||||
const reclaimed = applyTurnResult(released, {
|
const reclaimed = applyTurnResult(released, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
||||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
});
|
});
|
||||||
@@ -2182,6 +2215,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
});
|
});
|
||||||
const { state } = await runTurn({
|
const { state } = await runTurn({
|
||||||
@@ -2275,6 +2310,9 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
|||||||
hybrid: partial.hybrid ?? { ops: [] },
|
hybrid: partial.hybrid ?? { ops: [] },
|
||||||
economy: partial.economy ?? { trade: [], embargo: [] },
|
economy: partial.economy ?? { trade: [], embargo: [] },
|
||||||
industry: partial.industry ?? { projects: [] },
|
industry: partial.industry ?? { projects: [] },
|
||||||
|
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
|
||||||
|
leaders: partial.leaders ?? { changes: [] },
|
||||||
|
ideology: partial.ideology ?? { changes: [] },
|
||||||
markers: partial.markers ?? { add: [], remove: [] },
|
markers: partial.markers ?? { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2999,6 +3037,8 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withTakeovers).not.toBeNull();
|
expect(withTakeovers).not.toBeNull();
|
||||||
@@ -3386,6 +3426,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -3406,6 +3448,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -3426,6 +3470,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
wars: { declare: [], end: [] },
|
wars: { declare: [], end: [] },
|
||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -4349,3 +4395,642 @@ describe('runTurn — industry (v0.36.0 #59)', () => {
|
|||||||
expect(state.industry).toEqual([]);
|
expect(state.industry).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37.0, #60) -------------------------------------------
|
||||||
|
|
||||||
|
describe('isNuclearCapable (v0.37.0 #60)', () => {
|
||||||
|
const marker = (id: string, type: MarkerType, ownerNationId: string | null): MapMarker =>
|
||||||
|
({ id, type, name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId, placedTurn: 1 });
|
||||||
|
const n = (id: string, nuclear?: boolean): Nation =>
|
||||||
|
nuclear ? { ...mkNation(id, NS(50, 50, 50, 50), []), nuclear } : mkNation(id, NS(50, 50, 50, 50), []);
|
||||||
|
|
||||||
|
it('is true when the nuclear flag is set', () => {
|
||||||
|
expect(isNuclearCapable(n('uk', true), [])).toBe(true);
|
||||||
|
});
|
||||||
|
it('is true when the nation owns a rocket marker (a launch site is an arsenal)', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'uk')])).toBe(true);
|
||||||
|
});
|
||||||
|
it('is false when the flag is unset and no rocket marker is owned', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'city', 'uk')])).toBe(false);
|
||||||
|
});
|
||||||
|
it('a rocket marker owned by another nation does not confer capability', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'de')])).toBe(false);
|
||||||
|
});
|
||||||
|
it('a rocket marker with a null owner does not confer capability', () => {
|
||||||
|
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', null)])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateNuclearOps (v0.37.0 #60)', () => {
|
||||||
|
it('accepts well-formed arm + strikes', () => {
|
||||||
|
const out = validateNuclearOps({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
|
||||||
|
expect(out).toEqual({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
|
||||||
|
});
|
||||||
|
it('dedupes arm refs and drops empty / non-string entries', () => {
|
||||||
|
const out = validateNuclearOps({ arm: ['uk', 'uk', '', ' ', 'de', null, 5] as unknown[] });
|
||||||
|
expect(out.arm).toEqual(['uk', 'de']);
|
||||||
|
});
|
||||||
|
it('drops a strike missing a from or a target', () => {
|
||||||
|
const out = validateNuclearOps({
|
||||||
|
strikes: [
|
||||||
|
{ target: 'de' } as unknown as NuclearStrike, // no from
|
||||||
|
{ from: 'uk' } as unknown as NuclearStrike, // no target
|
||||||
|
'not-an-object' as unknown as NuclearStrike,
|
||||||
|
null as unknown as NuclearStrike,
|
||||||
|
{ from: 'uk', target: 'g1' }, // valid
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.strikes).toEqual([{ from: 'uk', target: 'g1' }]);
|
||||||
|
});
|
||||||
|
it('drops a self-target (from === target, case-insensitive)', () => {
|
||||||
|
const out = validateNuclearOps({ strikes: [{ from: 'UK', target: 'uk' }, { from: 'uk', target: 'de' }] });
|
||||||
|
expect(out.strikes).toEqual([{ from: 'uk', target: 'de' }]);
|
||||||
|
});
|
||||||
|
it('a missing / malformed top-level object yields { arm: [], strikes: [] }', () => {
|
||||||
|
expect(validateNuclearOps(undefined)).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps(null)).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps('nope')).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps({})).toEqual({ arm: [], strikes: [] });
|
||||||
|
expect(validateNuclearOps({ arm: 'x', strikes: 'y' })).toEqual({ arm: [], strikes: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult — nuclear ops (v0.37.0 #60)', () => {
|
||||||
|
// uk starts nuclear-capable (the flag); de is not. Each owns one region.
|
||||||
|
const base = (): GameState => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true };
|
||||||
|
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
|
||||||
|
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
};
|
||||||
|
|
||||||
|
it('arming a nation sets its nuclear flag (idempotent across turns)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({ nuclear: { arm: ['uk', 'de'], strikes: [] } }));
|
||||||
|
expect(next.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next.nations.de.nuclear).toBe(true);
|
||||||
|
const next2 = applyTurnResult(next, mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }));
|
||||||
|
expect(next2.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next2.nations.de.nuclear).toBe(true); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation that arms this turn is capable and can strike in the same turn', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // start unarmed
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: ['uk'], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(next.unowned).toContain('d1'); // the armed-this-turn strike landed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a region strike releases the region, deletes its stats, and collapses the owner', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.territory).toEqual([]);
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
expect(next.regionStats['d1']).toBeUndefined();
|
||||||
|
expect(next.regionStats['g1']).toBeDefined();
|
||||||
|
expect(next.nations.de.stats.economy).toBe(50 + NUCLEAR_COLLAPSE.economy);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.de.stats.stability).toBe(50 + NUCLEAR_COLLAPSE.stability);
|
||||||
|
expect(next.nations.de.stats.population).toBe(50);
|
||||||
|
// de is not capable → no MAD, the striker is unharmed
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.nations.uk.stats.economy).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0].type).toBe('military');
|
||||||
|
expect(next.events[0].title).toBe('Nuclear strike on d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation strike devastates the target and ends a shared war in capitulation (relation → hostile)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.wars = [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.wars.find((w) => w.id === 'w1')?.status).toBe('capitulation');
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
// de is not capable → no MAD
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation strike ends a war declared in the same turn (the sync-then-end ordering)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
wars: { declare: [{ sides: ['uk', 'de'] }], end: [] },
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
const w = next.wars.find((w) => w.sides.includes('uk') && w.sides.includes('de'));
|
||||||
|
expect(w?.status).toBe('capitulation');
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on a nuclear-capable target triggers a MAD retaliation on the striker', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.nuclear = true; // the target is capable
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
|
||||||
|
expect(next.nations.uk.stats.economy).toBe(50 + NUCLEAR_RETALIATION.economy);
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(50 + NUCLEAR_RETALIATION.stability);
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
expect(next.events).toHaveLength(2);
|
||||||
|
expect(next.events.some((e) => e.title === 'MAD retaliation')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a region strike on a nuclear-capable owner triggers a MAD retaliation', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.nuclear = true; // the owner of d1 is capable
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
|
||||||
|
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||||
|
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||||
|
expect(next.events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation with a rocket marker (not the flag) can strike', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // no flag
|
||||||
|
s.markers = [{ id: 'm1', type: 'rocket', name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId: 'uk', placedTurn: 1 }];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toContain('d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike by a non-nuclear-capable nation is dropped (never thrown)', () => {
|
||||||
|
const s = base();
|
||||||
|
delete (s.nations.uk as { nuclear?: boolean }).nuclear;
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.territory).toEqual(['d1']);
|
||||||
|
expect(next.unowned).toEqual([]);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on a region with two or more owners is dropped (never thrown)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.uk.territory = ['g1', 'd1']; // d1 now has two owners (uk, de)
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toEqual([]);
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike on an unowned (no-man\'s-land) region is dropped (nothing to devastate)', () => {
|
||||||
|
const s = base();
|
||||||
|
s.nations.de.territory = []; // d1 has zero owners
|
||||||
|
s.unowned = ['d1'];
|
||||||
|
delete s.regionStats['d1'];
|
||||||
|
const next = applyTurnResult(s, mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
|
||||||
|
}));
|
||||||
|
expect(next.unowned).toEqual(['d1']);
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strike whose target is the striker itself is dropped (never thrown)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'uk' }] },
|
||||||
|
}));
|
||||||
|
expect(next.nations.uk.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws on malformed strikes (unknown from, unknown target, empty strings)', () => {
|
||||||
|
const next = applyTurnResult(base(), mkTurnResult({
|
||||||
|
nuclear: {
|
||||||
|
arm: [],
|
||||||
|
strikes: [
|
||||||
|
{ from: 'ghost', target: 'de' },
|
||||||
|
{ from: 'uk', target: 'nowhere' },
|
||||||
|
{ from: 'uk', target: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(next.nations.de.stats.military).toBe(50);
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMeaningful — nuclear (v0.37.0 #60)', () => {
|
||||||
|
it('a turn with only an arm op or a strike is meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }))).toBe(true);
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] } }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('a turn with empty nuclear ops and nothing else is not meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] }, leaders: { changes: [] } }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUserPrompt — nuclear capability (v0.37.0 #60)', () => {
|
||||||
|
const state = (): GameState =>
|
||||||
|
mkTurnState(
|
||||||
|
{
|
||||||
|
uk: { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true },
|
||||||
|
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||||||
|
},
|
||||||
|
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('stamps each nation\'s nuclear capability when the map is provided', () => {
|
||||||
|
const out = JSON.parse(
|
||||||
|
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined, {
|
||||||
|
uk: true,
|
||||||
|
de: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(out.state.nations.uk.nuclear).toBe(true);
|
||||||
|
expect(out.state.nations.de.nuclear).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the nuclear capability entirely when the map is undefined', () => {
|
||||||
|
const out = JSON.parse(
|
||||||
|
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined),
|
||||||
|
);
|
||||||
|
expect(out.state.nations.uk).not.toHaveProperty('nuclear');
|
||||||
|
expect(out.state.nations.de).not.toHaveProperty('nuclear');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('named leaders (v0.38.0 #61)', () => {
|
||||||
|
// `uk` starts with a named leader; `de` has none. Each owns one region
|
||||||
|
// seeded to its stats (the fresh-state invariant), so a uniform dip leaves
|
||||||
|
// the derived median equal to the dipped value.
|
||||||
|
const leaderBase = (): GameState => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), leader: { name: 'Admiral Old' } };
|
||||||
|
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
|
||||||
|
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateLeaderOps', () => {
|
||||||
|
it('accepts a {changes} object and a bare array of valid changes', () => {
|
||||||
|
const fromObj = validateLeaderOps({
|
||||||
|
changes: [
|
||||||
|
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
|
||||||
|
{ target: 'de', name: 'Other', cause: 'death' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(fromObj).toEqual([
|
||||||
|
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
|
||||||
|
{ target: 'de', name: 'Other', cause: 'death' },
|
||||||
|
]);
|
||||||
|
expect(validateLeaderOps([{ target: 'uk', name: 'X', cause: 'appointed' }])).toEqual([
|
||||||
|
{ target: 'uk', name: 'X', cause: 'appointed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a change with an unknown cause', () => {
|
||||||
|
expect(validateLeaderOps({ changes: [{ target: 'uk', name: 'X', cause: 'explosion' }] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a change missing a target or a name', () => {
|
||||||
|
expect(
|
||||||
|
validateLeaderOps({
|
||||||
|
changes: [
|
||||||
|
{ target: '', name: 'X', cause: 'appointed' },
|
||||||
|
{ target: 'uk', name: '', cause: 'death' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for undefined / non-object / non-array-changes input', () => {
|
||||||
|
expect(validateLeaderOps(undefined)).toEqual([]);
|
||||||
|
expect(validateLeaderOps('nope')).toEqual([]);
|
||||||
|
expect(validateLeaderOps({ changes: 'nope' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the valid changes of a mixed batch', () => {
|
||||||
|
const out = validateLeaderOps({
|
||||||
|
changes: [
|
||||||
|
{ target: 'uk', name: 'Good', cause: 'appointed' },
|
||||||
|
{ target: 'uk', name: 'Bad', cause: 'nope' },
|
||||||
|
null,
|
||||||
|
{ target: 'uk', name: 'Also Good', cause: 'death' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ target: 'uk', name: 'Good', cause: 'appointed' },
|
||||||
|
{ target: 'uk', name: 'Also Good', cause: 'death' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMeaningful — leaders', () => {
|
||||||
|
it('a turn with only a leader change is meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'X', cause: 'death' }] } }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('a turn with empty leaders and nothing else is not meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ leaders: { changes: [] } }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult — leaders', () => {
|
||||||
|
it('assassinating a nation with a leader replaces it, dips stability, and adds a military event', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New Leader', cause: 'assassination' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.leader).toEqual({ name: 'New Leader' });
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(35); // 50 - 15
|
||||||
|
expect(next.regionStats.g1.stability).toBe(35); // mirrored to the region
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0]!.type).toBe('military');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assassinating a leaderless nation is dropped (no leader, no stat change, no event)', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'New Leader', cause: 'assassination' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.de.leader).toBeUndefined();
|
||||||
|
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a death of a leader replaces it, dips stability a little, and adds a political succession event', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'Successor', cause: 'death' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.leader).toEqual({ name: 'Successor' });
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(47); // 50 - 3
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0]!.type).toBe('political');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a death of a leaderless nation is dropped', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'X', cause: 'death' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.de.leader).toBeUndefined();
|
||||||
|
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an appointment on a nation that has a leader replaces it with no stability dip', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New PM', cause: 'appointed' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.leader).toEqual({ name: 'New PM' });
|
||||||
|
expect(next.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // neutral: no dip
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0]!.type).toBe('political');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an appointment creates the first leader on a leaderless nation', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'First', cause: 'appointed' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.de.leader).toEqual({ name: 'First' });
|
||||||
|
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a leader change on a multi-region nation dips every region and re-derives its stats', () => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), leader: { name: 'Old' } };
|
||||||
|
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New', cause: 'assassination' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.regionStats.g1.stability).toBe(35);
|
||||||
|
expect(next.regionStats.g2.stability).toBe(35);
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(35); // re-derived from the dipped regions
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown target is dropped without throwing (no leader change, no event, no crash)', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
leaderBase(),
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'ghost', name: 'X', cause: 'assassination' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.leader).toEqual({ name: 'Admiral Old' }); // untouched
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
|
||||||
|
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same', leader: { name: 'Old' } };
|
||||||
|
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
|
||||||
|
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ leaders: { changes: [{ target: 'Same', name: 'New', cause: 'assassination' }] } }),
|
||||||
|
);
|
||||||
|
// 'Same' matches two nations -> ambiguous -> dropped; neither leader changes.
|
||||||
|
expect(next.nations.a.leader).toEqual({ name: 'Old' });
|
||||||
|
expect(next.nations.b.leader).toBeUndefined();
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ideology / form of government (v0.39.0 #62)', () => {
|
||||||
|
// `uk` starts with an ideology ('monarchy'); `de` has none. Each owns one
|
||||||
|
// region seeded to its stats (the fresh-state invariant), so a uniform dip
|
||||||
|
// leaves the derived median equal to the dipped value.
|
||||||
|
const ideoBase = (): GameState => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'monarchy' };
|
||||||
|
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
|
||||||
|
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateIdeologyOps', () => {
|
||||||
|
it('accepts a {changes} object and a bare array of valid changes', () => {
|
||||||
|
const fromObj = validateIdeologyOps({
|
||||||
|
changes: [
|
||||||
|
{ target: 'uk', to: 'fascism' },
|
||||||
|
{ target: 'de', to: 'democracy' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(fromObj).toEqual([
|
||||||
|
{ target: 'uk', to: 'fascism' },
|
||||||
|
{ target: 'de', to: 'democracy' },
|
||||||
|
]);
|
||||||
|
expect(validateIdeologyOps([{ target: 'uk', to: 'x' }])).toEqual([{ target: 'uk', to: 'x' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a change with a missing / empty / whitespace `to`', () => {
|
||||||
|
expect(
|
||||||
|
validateIdeologyOps({ changes: [{ target: 'uk', to: '' }, { target: 'uk', to: ' ' }] }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a change with a missing / empty / whitespace target', () => {
|
||||||
|
expect(
|
||||||
|
validateIdeologyOps({ changes: [{ target: '', to: 'fascism' }, { target: ' ', to: 'fascism' }] }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for undefined / non-object / non-array-changes input', () => {
|
||||||
|
expect(validateIdeologyOps(undefined)).toEqual([]);
|
||||||
|
expect(validateIdeologyOps('nope')).toEqual([]);
|
||||||
|
expect(validateIdeologyOps({ changes: 'nope' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the valid changes of a mixed batch', () => {
|
||||||
|
const out = validateIdeologyOps({
|
||||||
|
changes: [
|
||||||
|
{ target: 'uk', to: 'Good' },
|
||||||
|
null,
|
||||||
|
{ target: '', to: 'Bad' },
|
||||||
|
{ target: 'de', to: 'Also Good' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ target: 'uk', to: 'Good' },
|
||||||
|
{ target: 'de', to: 'Also Good' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims both fields and caps each at 120 chars', () => {
|
||||||
|
const long = 'x'.repeat(200);
|
||||||
|
const out = validateIdeologyOps({ changes: [{ target: ' uk ', to: ` ${long} ` }] });
|
||||||
|
expect(out).toEqual([{ target: 'uk', to: 'x'.repeat(120) }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMeaningful — ideology', () => {
|
||||||
|
it('a turn with only an ideology change is meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('a turn with empty ideology and nothing else is not meaningful', () => {
|
||||||
|
expect(isMeaningful(mkTurnResult({ ideology: { changes: [] } }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult — ideology', () => {
|
||||||
|
it('a valid change sets the field, dips stability −5, and adds a political event', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
ideoBase(),
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.ideology).toBe('fascism');
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(45); // 50 - 5
|
||||||
|
expect(next.regionStats.g1.stability).toBe(45); // mirrored to the region
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0]!.type).toBe('political');
|
||||||
|
expect(next.events[0]!.title).toBe('UK\'s government shifted to fascism');
|
||||||
|
// the other nation is untouched
|
||||||
|
expect(next.nations.de.ideology).toBeUndefined();
|
||||||
|
expect(next.nations.de.stats.stability).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-setting the SAME ideology still dips (idempotent for the field, not the stat)', () => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||||||
|
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.ideology).toBe('fascism'); // the same label
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(45); // still a −5 dip
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.events[0]!.type).toBe('political');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a change on a multi-region nation dips every region and re-derives its stats', () => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), ideology: 'monarchy' };
|
||||||
|
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'dictatorship' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.regionStats.g1.stability).toBe(45);
|
||||||
|
expect(next.regionStats.g2.stability).toBe(45);
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(45); // re-derived from the dipped regions
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the target by (case-insensitive) name as well as id', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
ideoBase(),
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'UK', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.ideology).toBe('fascism');
|
||||||
|
expect(next.nations.uk.stats.stability).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a 0-region nation keeps its directly-dipped stat (no re-derive to zero)', () => {
|
||||||
|
const iso: Nation = { ...mkNation('iso', NS(50, 50, 50, 50), []) };
|
||||||
|
const state = mkTurnState({ iso }, {});
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'iso', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.iso.ideology).toBe('fascism');
|
||||||
|
expect(next.nations.iso.stats.stability).toBe(45); // direct dip, not re-derived
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the dip at 0 (a stability of 3 becomes 0, not −2)', () => {
|
||||||
|
const low: Nation = { ...mkNation('low', NS(50, 50, 3, 50), ['g1']), ideology: 'monarchy' };
|
||||||
|
const state = mkTurnState({ low }, { g1: RS(50, 50, 3, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'low', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.low.stats.stability).toBe(0);
|
||||||
|
expect(next.regionStats.g1.stability).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown target is dropped without throwing (no change, no event, no crash)', () => {
|
||||||
|
const next = applyTurnResult(
|
||||||
|
ideoBase(),
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'ghost', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
expect(next.nations.uk.ideology).toBe('monarchy'); // untouched
|
||||||
|
expect(next.nations.de.ideology).toBeUndefined();
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
|
||||||
|
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same' };
|
||||||
|
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
|
||||||
|
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||||||
|
const next = applyTurnResult(
|
||||||
|
state,
|
||||||
|
mkTurnResult({ ideology: { changes: [{ target: 'Same', to: 'fascism' }] } }),
|
||||||
|
);
|
||||||
|
// 'Same' matches two nations -> ambiguous -> dropped; neither ideology is set.
|
||||||
|
expect(next.nations.a.ideology).toBeUndefined();
|
||||||
|
expect(next.nations.b.ideology).toBeUndefined();
|
||||||
|
expect(next.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUserPrompt — ideology', () => {
|
||||||
|
it('stamps a nation\'s ideology in the prompt nations when it has one', () => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||||||
|
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||||
|
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||||
|
state: { nations: Record<string, { ideology?: string }> };
|
||||||
|
};
|
||||||
|
expect(parsed.state.nations.uk.ideology).toBe('fascism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a nation\'s ideology from the prompt when it has none', () => {
|
||||||
|
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']) };
|
||||||
|
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||||
|
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||||
|
state: { nations: Record<string, { ideology?: string }> };
|
||||||
|
};
|
||||||
|
expect(parsed.state.nations.uk.ideology).toBeUndefined();
|
||||||
|
expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+637
-1
@@ -31,21 +31,31 @@ import {
|
|||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
fnv1a32,
|
fnv1a32,
|
||||||
HYBRID_OP_TYPES,
|
HYBRID_OP_TYPES,
|
||||||
|
IDEOLOGY_STABILITY_EFFECT,
|
||||||
isDateLike,
|
isDateLike,
|
||||||
isMarkerType,
|
isMarkerType,
|
||||||
|
isNuclearCapable,
|
||||||
|
LEADER_STABILITY_EFFECT,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
markerId,
|
markerId,
|
||||||
MAX_MARKERS,
|
MAX_MARKERS,
|
||||||
|
NUCLEAR_COLLAPSE,
|
||||||
|
NUCLEAR_RETALIATION,
|
||||||
nationForPrompt,
|
nationForPrompt,
|
||||||
type EconomyEmbargoOp,
|
type EconomyEmbargoOp,
|
||||||
type EconomyOps,
|
type EconomyOps,
|
||||||
type EconomyTradeOp,
|
type EconomyTradeOp,
|
||||||
type HybridOp,
|
type HybridOp,
|
||||||
type HybridOpType,
|
type HybridOpType,
|
||||||
|
type IdeologyChange,
|
||||||
type IndustryOps,
|
type IndustryOps,
|
||||||
|
type LeaderCause,
|
||||||
|
type LeaderChange,
|
||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
|
type NuclearOps,
|
||||||
|
type NuclearStatKey,
|
||||||
type RegionStatChange,
|
type RegionStatChange,
|
||||||
type Takeover,
|
type Takeover,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
@@ -237,6 +247,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
||||||
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||||||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
' "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>"}]},',
|
||||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
@@ -259,6 +272,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
|
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
|
||||||
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
|
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
|
||||||
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
|
'- 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).',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -351,6 +367,15 @@ export function buildUserPrompt(
|
|||||||
* can reference them. Omitted entirely when undefined.
|
* can reference them. Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
industryProjects?: IndustryProject[],
|
industryProjects?: IndustryProject[],
|
||||||
|
/**
|
||||||
|
* (v0.37.0, #60) Each nation's nuclear capability (nation id -> boolean),
|
||||||
|
* computed by the caller via `isNuclearCapable` (the nation's `nuclear` flag
|
||||||
|
* OR a current `rocket` marker). When defined, each nation in the `state.
|
||||||
|
* nations` map is stamped with `nuclear: true|false` so the model knows who
|
||||||
|
* can arm/strike and who can MAD-retaliate. Omitted entirely (no `nuclear`
|
||||||
|
* key on any nation) when undefined.
|
||||||
|
*/
|
||||||
|
nuclear?: Record<string, boolean>,
|
||||||
): string {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -440,7 +465,10 @@ export function buildUserPrompt(
|
|||||||
date: state.date,
|
date: state.date,
|
||||||
playerNationId: state.playerNationId,
|
playerNationId: state.playerNationId,
|
||||||
nations: Object.fromEntries(
|
nations: Object.fromEntries(
|
||||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
Object.entries(state.nations).map(([id, n]) => [
|
||||||
|
id,
|
||||||
|
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
recentEvents: state.events.slice(-5).map((e) => ({
|
recentEvents: state.events.slice(-5).map((e) => ({
|
||||||
turn: e.turn,
|
turn: e.turn,
|
||||||
@@ -611,6 +639,9 @@ export function validateTurnResult(
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] },
|
||||||
industry: { projects: [] },
|
industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
|
leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -729,6 +760,29 @@ export function validateTurnResult(
|
|||||||
// is kept). Refs stay raw and the effect is deterministic at apply time.
|
// is kept). Refs stay raw and the effect is deterministic at apply time.
|
||||||
result.industry = validateIndustryOps(o.industry);
|
result.industry = validateIndustryOps(o.industry);
|
||||||
|
|
||||||
|
// (v0.37, #60) Nuclear / WMD ops: `arm` grants an arsenal to the named
|
||||||
|
// nations and `strikes` are nuclear strikes fired this turn. Refs stay raw
|
||||||
|
// here (resolved against the real state at apply time, like wars / hybrid
|
||||||
|
// ops); an unresolvable ref, a non-capable striker, or an invalid region is
|
||||||
|
// dropped at apply time, never thrown. Applied directly by applyTurnResult
|
||||||
|
// (after industry, before markers).
|
||||||
|
result.nuclear = validateNuclearOps(o.nuclear);
|
||||||
|
|
||||||
|
// (v0.38, #61) Leader changes: assassinations / deaths / appointments of
|
||||||
|
// named leaders. Refs stay raw here (resolved against the real state at
|
||||||
|
// apply time, like wars / hybrid / nuclear ops); an unresolvable target or a
|
||||||
|
// leaderless assassination/death is dropped at apply time, never thrown.
|
||||||
|
// Applied by applyTurnResult after nuclear, before markers.
|
||||||
|
result.leaders = { changes: validateLeaderOps(o.leaders) };
|
||||||
|
|
||||||
|
// (v0.39, #62) Ideology (form of government) changes: each names a target
|
||||||
|
// nation (id or name) and a new government type. Refs stay raw here
|
||||||
|
// (resolved against the real state at apply time, like wars / hybrid /
|
||||||
|
// nuclear / leader ops); an unresolvable target or an empty label is
|
||||||
|
// dropped at apply time, never thrown. Applied by applyTurnResult after
|
||||||
|
// leaders, before markers.
|
||||||
|
result.ideology = { changes: validateIdeologyOps(o.ideology) };
|
||||||
|
|
||||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||||
// removals by stable id. Applied by applyTurnResult after the territory
|
// removals by stable id. Applied by applyTurnResult after the territory
|
||||||
// moves (a new marker's owner is the region's post-turn owner).
|
// moves (a new marker's owner is the region's post-turn owner).
|
||||||
@@ -1031,6 +1085,102 @@ export function validateIndustryOps(raw: unknown): IndustryOps {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe
|
||||||
|
* {@link NuclearOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is
|
||||||
|
* resolved (id or name) at APPLY time, so an unknown ref is dropped there, not
|
||||||
|
* here. `strikes`: each needs a non-empty `from` (the striking nation ref) and
|
||||||
|
* a non-empty `target` (a nation ref OR a region key), each capped at 120
|
||||||
|
* chars; a self-target (`from` and `target` the same ref) is dropped here. A
|
||||||
|
* missing / malformed top-level object yields `{ arm: [], strikes: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateNuclearOps(raw: unknown): NuclearOps {
|
||||||
|
const out: NuclearOps = { arm: [], strikes: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(o.arm)) {
|
||||||
|
for (const e of o.arm) {
|
||||||
|
const s = typeof e === 'string' ? e.trim() : '';
|
||||||
|
if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.strikes)) {
|
||||||
|
for (const e of o.strikes) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const s = e as Record<string, unknown>;
|
||||||
|
const from = str(s.from, 120);
|
||||||
|
const target = str(s.target, 120);
|
||||||
|
if (!from || !target) continue;
|
||||||
|
if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop
|
||||||
|
out.strikes.push({ from, target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.38, #61) Validate the raw `leaders` object of a turn into a safe
|
||||||
|
* `LeaderChange[]`. Defensive like the rest of the parser — never throws.
|
||||||
|
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change
|
||||||
|
* needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty
|
||||||
|
* `name` (the new leader's name, capped at 120 chars), and a `cause` that is
|
||||||
|
* one of the three known literals (assassination / death / appointed). Refs
|
||||||
|
* stay raw here (resolved against the real state at apply time, like the wars
|
||||||
|
* / hybrid / nuclear ops); a leaderless assassination/death is dropped at
|
||||||
|
* apply time, never thrown.
|
||||||
|
*/
|
||||||
|
export function validateLeaderOps(raw: unknown): LeaderChange[] {
|
||||||
|
const out: LeaderChange[] = [];
|
||||||
|
const list = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: raw && typeof raw === 'object'
|
||||||
|
? (raw as Record<string, unknown>).changes
|
||||||
|
: undefined;
|
||||||
|
if (!Array.isArray(list)) return out;
|
||||||
|
for (const e of list) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const s = e as Record<string, unknown>;
|
||||||
|
const target = str(s.target, 120);
|
||||||
|
const name = str(s.name, 120);
|
||||||
|
if (!target || !name) continue;
|
||||||
|
const cause = typeof s.cause === 'string' ? s.cause : '';
|
||||||
|
if (cause === 'assassination' || cause === 'death' || cause === 'appointed') {
|
||||||
|
out.push({ target, name, cause: cause as LeaderCause });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.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.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||||
@@ -1315,6 +1465,145 @@ function applyHybridOp(
|
|||||||
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
|
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Named leaders (v0.38, #61) -----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.38, #61) Apply a single leader change to `next` (in place). The target
|
||||||
|
* nation is resolved by the shared resolver (id first, then a case-insensitive
|
||||||
|
* unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE
|
||||||
|
* then applies: an `assassination` or `death` requires the target to already
|
||||||
|
* have a leader (a leaderless target is dropped); an `appointed` change is
|
||||||
|
* always valid (it creates the first leader). On a valid change the target's
|
||||||
|
* `leader` is set to the new name and a DETERMINISTIC stability dip
|
||||||
|
* (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
|
||||||
|
* (clamped 0–100) and mirrored to every region the nation owns
|
||||||
|
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
|
||||||
|
* same 0-region / no-entry guards as the hybrid/economy/industry effects, so a
|
||||||
|
* 0-region nation keeps its directly-mutated value). Finally a narration event
|
||||||
|
* is appended: a dramatic `military` event for an assassination, a `political`
|
||||||
|
* succession event for a death or an appointment. The replacement never moves
|
||||||
|
* territory (sole ownership is untouched).
|
||||||
|
*/
|
||||||
|
function applyLeaderChange(
|
||||||
|
next: GameState,
|
||||||
|
op: LeaderChange,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
actingTurn?: number,
|
||||||
|
): void {
|
||||||
|
const turn = actingTurn ?? next.turn;
|
||||||
|
const id = resolveNationRef(next, op.target);
|
||||||
|
if (id === null) return; // unknown / ambiguous target: drop, never throw
|
||||||
|
const n = next.nations[id];
|
||||||
|
|
||||||
|
// GATE: assassination / death require an existing leader; an appointment is
|
||||||
|
// always valid (it creates the first leader).
|
||||||
|
if (op.cause !== 'appointed' && !n.leader) return;
|
||||||
|
|
||||||
|
const prevName = n.leader?.name;
|
||||||
|
n.leader = { name: op.name };
|
||||||
|
|
||||||
|
const delta = LEADER_STABILITY_EFFECT[op.cause];
|
||||||
|
if (delta !== 0) {
|
||||||
|
n.stats.stability = clampStat(n.stats.stability + delta);
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (!rs) continue;
|
||||||
|
rs.stability = clampStat(rs.stability + delta);
|
||||||
|
}
|
||||||
|
// Re-derive the nation's aggregate stats (the main recompute ran earlier
|
||||||
|
// this turn, so without this the dip would be washed out next turn).
|
||||||
|
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
op.cause === 'assassination'
|
||||||
|
? `${prevName ?? 'The leader'} of ${n.name} is assassinated`
|
||||||
|
: op.cause === 'death'
|
||||||
|
? `${prevName ?? 'The leader'} of ${n.name} dies`
|
||||||
|
: `${n.name} appoints ${op.name}`;
|
||||||
|
const text =
|
||||||
|
op.cause === 'assassination'
|
||||||
|
? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.`
|
||||||
|
: op.cause === 'death'
|
||||||
|
? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.`
|
||||||
|
: `${n.name} has appointed ${op.name} as its new leader.`;
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: n.id,
|
||||||
|
type: op.cause === 'assassination' ? 'military' : 'political',
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ideology / form of government (v0.39, #62) ------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
|
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1556,6 +1845,300 @@ export function applyIndustryEffects(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37, #60): the WMD lever --------------------------------
|
||||||
|
//
|
||||||
|
// A nuclear strike is a DETERMINISTIC devastation (no hidden randomness): the
|
||||||
|
// LLM decides which nuclear-capable nation strikes and what it targets; the
|
||||||
|
// engine decides the size of the effect (NUCLEAR_COLLAPSE / NUCLEAR_RETALIATION
|
||||||
|
// in protocol.ts). This block runs AFTER the industry effects and BEFORE the
|
||||||
|
// marker owner-recompute, because a region strike RELEASES its region to no
|
||||||
|
// man's land, which changes the final ownership the markers depend on.
|
||||||
|
//
|
||||||
|
// ARM: each `arm` ref (id or name) is resolved and that nation's `nuclear`
|
||||||
|
// flag is set to true (idempotent; an unresolvable ref is dropped).
|
||||||
|
// Arming runs first, so a nation that arms AND strikes this turn is
|
||||||
|
// capable.
|
||||||
|
//
|
||||||
|
// STRIKE: each strike is validated independently (never throws):
|
||||||
|
// (a) `from` must resolve to a nuclear-capable nation (the `nuclear` flag
|
||||||
|
// or a rocket marker), else dropped.
|
||||||
|
// (b) The target is a NATION (a ref resolving to a distinct nation) or a
|
||||||
|
// REGION (a region key / name with a SOLE owner that is not the
|
||||||
|
// striker):
|
||||||
|
// - a nation target: the target's economy / military / stability are
|
||||||
|
// devastated (NUCLEAR_COLLAPSE, mirrored to its regions); if `from`
|
||||||
|
// and the target are on opposite sides of an active war, that war
|
||||||
|
// ends in `capitulation` and the warring relation falls back to
|
||||||
|
// hostile; if the target is nuclear-capable, the striker suffers a
|
||||||
|
// half-strength MAD retaliation.
|
||||||
|
// - a region target: the region is released to no man's land (its
|
||||||
|
// regionStats entry leaves the table), its former owner is
|
||||||
|
// devastated (NUCLEAR_COLLAPSE), and if that owner is nuclear-capable
|
||||||
|
// the striker suffers a half-strength MAD retaliation.
|
||||||
|
// (c) every applied strike appends a dramatic event (the EXISTING `military`
|
||||||
|
// category — no new category is invented), and a MAD strike appends an
|
||||||
|
// extra "MAD retaliation" event.
|
||||||
|
//
|
||||||
|
// After all strikes, every affected nation's derived stats are re-derived
|
||||||
|
// (the same 0-region / no-entry guards as the main recompute), so the
|
||||||
|
// collapse persists and the sole-ownership invariant is untouched.
|
||||||
|
|
||||||
|
/** The three stats a nuclear strike collapses (population is never touched). */
|
||||||
|
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devastate one nation's stats AND every region it owns (mirroring the
|
||||||
|
* collapse into `regionStats`, like the economy / hybrid layers), clamped to
|
||||||
|
* 0–100. Never throws.
|
||||||
|
*/
|
||||||
|
function collapseNationStats(
|
||||||
|
next: GameState,
|
||||||
|
nationId: string,
|
||||||
|
collapse: Record<NuclearStatKey, number>,
|
||||||
|
): void {
|
||||||
|
const n = next.nations[nationId];
|
||||||
|
if (!n) return;
|
||||||
|
for (const k of NUCLEAR_STATS) {
|
||||||
|
const d = collapse[k];
|
||||||
|
n.stats[k] = clampStat(n.stats[k] + d);
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (rs) rs[k] = clampStat(rs[k] + d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a region key to no man's land, reusing the turn's release semantics:
|
||||||
|
* the key leaves its SOLE owner's territory, is marked in `GameState.unowned`,
|
||||||
|
* and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an
|
||||||
|
* already-unowned key) is left untouched. Never throws.
|
||||||
|
*/
|
||||||
|
function releaseRegionToNoMansLand(next: GameState, key: string): void {
|
||||||
|
if (next.unowned.includes(key)) return; // already no man's land
|
||||||
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(key));
|
||||||
|
if (owners.length !== 1) return; // 0 or 2+ owners: not releasable
|
||||||
|
next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter(
|
||||||
|
(k) => k !== key,
|
||||||
|
);
|
||||||
|
next.unowned.push(key);
|
||||||
|
delete next.regionStats[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End every ACTIVE war whose sides include both `a` and `b` (a nuclear
|
||||||
|
* capitulation), then downgrade the `war` relation of every pair that is no
|
||||||
|
* longer covered by an active war back to `hostile` (the war → relation sync).
|
||||||
|
* Capturing the active pairs immediately BEFORE the end keeps this correct for
|
||||||
|
* a war declared and ended in the SAME turn (where the start-of-turn `prev`
|
||||||
|
* that `syncWarRelations` uses would not contain it). Never throws.
|
||||||
|
*/
|
||||||
|
function endWarsBetween(next: GameState, a: string, b: string): void {
|
||||||
|
const before = activeWarPairs(next.wars);
|
||||||
|
let ended = false;
|
||||||
|
for (const w of next.wars) {
|
||||||
|
if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) {
|
||||||
|
w.status = 'capitulation';
|
||||||
|
ended = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ended) return;
|
||||||
|
const after = activeWarPairs(next.wars);
|
||||||
|
for (const key of before) {
|
||||||
|
if (after.has(key)) continue; // still covered by another active war
|
||||||
|
const [x, y] = key.split('\u0000');
|
||||||
|
const nx = next.nations[x];
|
||||||
|
const ny = next.nations[y];
|
||||||
|
if (!nx || !ny) continue;
|
||||||
|
if (nx.relations[y] === 'war' || ny.relations[x] === 'war') {
|
||||||
|
nx.relations[y] = 'hostile';
|
||||||
|
ny.relations[x] = 'hostile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the relation between two nations to `hostile` in BOTH directions —
|
||||||
|
* the aftermath of a MAD exchange. A `war` relation is never downgraded (the
|
||||||
|
* war entity owns it while active — e.g. a region strike that devastates one
|
||||||
|
* warring side without ending the war). Never throws.
|
||||||
|
*/
|
||||||
|
function forceHostile(next: GameState, a: string, b: string): void {
|
||||||
|
const na = next.nations[a];
|
||||||
|
const nb = next.nations[b];
|
||||||
|
if (!na || !nb) return;
|
||||||
|
if (na.relations[b] !== 'war') na.relations[b] = 'hostile';
|
||||||
|
if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in
|
||||||
|
* place and never throwing. See the section comment above for the full
|
||||||
|
* contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the
|
||||||
|
* "owns a rocket" half of nuclear capability (a rocket marker placed this very
|
||||||
|
* turn makes its owner capable only from the next turn, while a nation that
|
||||||
|
* ARMS this turn is capable immediately, since arming runs first).
|
||||||
|
*/
|
||||||
|
export function applyNuclearOps(
|
||||||
|
next: GameState,
|
||||||
|
ops: NuclearOps,
|
||||||
|
state: GameState,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
validKeys?: ReadonlySet<string>,
|
||||||
|
regionNameOf?: (key: string) => string | undefined,
|
||||||
|
actingTurn?: number,
|
||||||
|
): void {
|
||||||
|
if (ops.arm.length === 0 && ops.strikes.length === 0) return;
|
||||||
|
const turn = actingTurn ?? next.turn;
|
||||||
|
const markers = state.markers ?? [];
|
||||||
|
const capable = (id: string): boolean => {
|
||||||
|
const n = next.nations[id];
|
||||||
|
return n ? isNuclearCapable(n, markers) : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped.
|
||||||
|
for (const ref of ops.arm) {
|
||||||
|
const id = resolveNationRef(next, ref);
|
||||||
|
if (id === null) continue;
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue;
|
||||||
|
n.nuclear = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = new Set<string>();
|
||||||
|
const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key;
|
||||||
|
|
||||||
|
// (2) STRIKES: each validated independently against the post-arm state.
|
||||||
|
for (const strike of ops.strikes) {
|
||||||
|
const fromId = resolveNationRef(next, strike.from);
|
||||||
|
if (fromId === null) continue; // unknown / ambiguous striker: drop
|
||||||
|
const from = next.nations[fromId];
|
||||||
|
if (!from) continue;
|
||||||
|
if (!capable(fromId)) continue; // a non-capable nation cannot strike
|
||||||
|
if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop
|
||||||
|
|
||||||
|
// Resolve the target: a NATION (a ref resolving to a distinct nation) or a
|
||||||
|
// REGION (a region key / name with a sole owner that is not the striker).
|
||||||
|
let target:
|
||||||
|
| { kind: 'nation'; nation: Nation }
|
||||||
|
| { kind: 'region'; key: string; owner: Nation }
|
||||||
|
| null = null;
|
||||||
|
const tid = resolveNationRef(next, strike.target);
|
||||||
|
if (tid !== null) {
|
||||||
|
const tn = next.nations[tid];
|
||||||
|
if (tn) target = { kind: 'nation', nation: tn };
|
||||||
|
} else {
|
||||||
|
const rk = validKeys
|
||||||
|
? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined))
|
||||||
|
: (strike.target.trim() || null);
|
||||||
|
if (rk !== null) {
|
||||||
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk));
|
||||||
|
if (owners.length === 1 && owners[0]!.id !== fromId) {
|
||||||
|
target = { kind: 'region', key: rk, owner: owners[0]! };
|
||||||
|
}
|
||||||
|
// a 0/2+-owner region, or a region the striker owns (self), is dropped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === null) continue; // unresolvable target: drop
|
||||||
|
|
||||||
|
if (target.kind === 'nation') {
|
||||||
|
// Devastate the target nation.
|
||||||
|
collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE);
|
||||||
|
affected.add(target.nation.id);
|
||||||
|
// A strike on a nation at war with the striker ends that war in
|
||||||
|
// capitulation (the relation then falls back to hostile).
|
||||||
|
endWarsBetween(next, fromId, target.nation.id);
|
||||||
|
// MAD: a nuclear-capable target strikes back at half strength and the
|
||||||
|
// two nations end the exchange at least hostile.
|
||||||
|
if (capable(target.nation.id)) {
|
||||||
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
||||||
|
affected.add(fromId);
|
||||||
|
forceHostile(next, fromId, target.nation.id);
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: 'MAD retaliation',
|
||||||
|
text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`,
|
||||||
|
outcome: 'failure',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: `Nuclear strike on ${target.nation.name}`,
|
||||||
|
text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`,
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Devastate the region: release it to no man's land, then devastate its
|
||||||
|
// former owner.
|
||||||
|
const owner = target.owner;
|
||||||
|
releaseRegionToNoMansLand(next, target.key);
|
||||||
|
collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE);
|
||||||
|
affected.add(owner.id);
|
||||||
|
// MAD: a nuclear-capable owner strikes back at half strength and the
|
||||||
|
// two nations end the exchange at least hostile.
|
||||||
|
if (capable(owner.id)) {
|
||||||
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
||||||
|
affected.add(fromId);
|
||||||
|
forceHostile(next, fromId, owner.id);
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: 'MAD retaliation',
|
||||||
|
text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay(
|
||||||
|
target.key,
|
||||||
|
)}; mutual assured destruction devastates ${from.name} in turn.`,
|
||||||
|
outcome: 'failure',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next.events.push(
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: fromId,
|
||||||
|
type: 'military',
|
||||||
|
title: `Nuclear strike on ${regionDisplay(target.key)}`,
|
||||||
|
text: `${from.name} obliterates ${regionDisplay(
|
||||||
|
target.key,
|
||||||
|
)} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`,
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
turn,
|
||||||
|
next.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region /
|
||||||
|
// no-entry guards as the main recompute), so the collapse persists.
|
||||||
|
for (const id of affected) {
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue;
|
||||||
|
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||||
|
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a
|
* A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a
|
||||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||||
@@ -1588,6 +2171,16 @@ export function isMeaningful(result: TurnResult): boolean {
|
|||||||
// (v0.36, #59) an industry project built this turn is a real world change
|
// (v0.36, #59) an industry project built this turn is a real world change
|
||||||
// (the active project list moved).
|
// (the active project list moved).
|
||||||
result.industry.projects.length > 0 ||
|
result.industry.projects.length > 0 ||
|
||||||
|
// (v0.37, #60) a nation arming a nuclear arsenal or a nuclear strike fired
|
||||||
|
// this turn is a real world change.
|
||||||
|
result.nuclear.arm.length > 0 ||
|
||||||
|
result.nuclear.strikes.length > 0 ||
|
||||||
|
// (v0.38, #61) a leader assassinated / died / appointed this turn is a real
|
||||||
|
// world change (a nation's leader and stability moved).
|
||||||
|
result.leaders.changes.length > 0 ||
|
||||||
|
// (v0.39, #62) an ideology (form of government) change this turn is a real
|
||||||
|
// world change (a nation's government type and stability moved).
|
||||||
|
result.ideology.changes.length > 0 ||
|
||||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||||
result.markers.add.length > 0 ||
|
result.markers.add.length > 0 ||
|
||||||
result.markers.remove.length > 0
|
result.markers.remove.length > 0
|
||||||
@@ -2002,6 +2595,38 @@ export function applyTurnResult(
|
|||||||
applyIndustryOps(next, result.industry, validKeys);
|
applyIndustryOps(next, result.industry, validKeys);
|
||||||
applyIndustryEffects(next, resourceOf, geoOf);
|
applyIndustryEffects(next, resourceOf, geoOf);
|
||||||
|
|
||||||
|
// --- Nuclear / WMD (v0.37, #60): the WMD lever ---
|
||||||
|
//
|
||||||
|
// Placed AFTER the industry/economy effects (both move stats) and BEFORE the
|
||||||
|
// markers block (a region strike releases its region to no man's land, which
|
||||||
|
// changes the FINAL ownership the marker owner-recompute depends on). See
|
||||||
|
// applyNuclearOps for the full contract (arm, strike, MAD, re-derive).
|
||||||
|
// `state` (the original pre-turn state) supplies the markers used for the
|
||||||
|
// rocket-marker half of nuclear capability.
|
||||||
|
applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn);
|
||||||
|
|
||||||
|
// --- Named leaders (v0.38, #61) ---
|
||||||
|
//
|
||||||
|
// Placed AFTER the nuclear block (both are the "heavier" levers) and BEFORE
|
||||||
|
// the markers block (markers recompute their owner from the FINAL post-claim
|
||||||
|
// ownership; leaders only touch stats, so ordering w.r.t. markers is
|
||||||
|
// immaterial, but keeping the levers grouped is clearer). Each change sets
|
||||||
|
// the target's leader, applies a deterministic stability dip by cause, and
|
||||||
|
// appends a narration event. A leaderless assassination/death is dropped.
|
||||||
|
for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn);
|
||||||
|
|
||||||
|
// --- Ideology / form of government (v0.39, #62) ---
|
||||||
|
//
|
||||||
|
// Placed AFTER the leaders block (both are per-nation "who is in charge /
|
||||||
|
// how the state is run" levers) and BEFORE the markers block (markers
|
||||||
|
// recompute their owner from the FINAL post-claim ownership; ideology only
|
||||||
|
// touches stats, so ordering w.r.t. markers is immaterial, but keeping the
|
||||||
|
// levers grouped is clearer). Each change sets the target's ideology,
|
||||||
|
// applies a deterministic stability dip (−5), and appends a political
|
||||||
|
// narration event. An unresolvable (unknown / ambiguous) target is
|
||||||
|
// dropped, never thrown.
|
||||||
|
for (const op of result.ideology.changes) applyIdeologyChange(next, op, geoOf, actingTurn);
|
||||||
|
|
||||||
// --- Map markers (v0.27, #44) ---
|
// --- Map markers (v0.27, #44) ---
|
||||||
//
|
//
|
||||||
// Markers are anchored to a region key and are processed against the FINAL
|
// Markers are anchored to a region key and are processed against the FINAL
|
||||||
@@ -2528,6 +3153,12 @@ export async function runTurn(opts: {
|
|||||||
deriveNationResources(n.territory, resourceOf, geoOf),
|
deriveNationResources(n.territory, resourceOf, geoOf),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// (v0.37.0, #60) each nation's nuclear capability (its `nuclear` flag OR a
|
||||||
|
// current `rocket` marker), so the model knows who can arm/strike and who
|
||||||
|
// can MAD-retaliate. Always emitted (computed from the PRE-turn markers).
|
||||||
|
const nuclearCapability = Object.fromEntries(
|
||||||
|
Object.values(state.nations).map((n) => [n.id, isNuclearCapable(n, state.markers ?? [])]),
|
||||||
|
);
|
||||||
const user = buildUserPrompt(
|
const user = buildUserPrompt(
|
||||||
state,
|
state,
|
||||||
items,
|
items,
|
||||||
@@ -2547,6 +3178,8 @@ export async function runTurn(opts: {
|
|||||||
// resource.
|
// resource.
|
||||||
nationResources,
|
nationResources,
|
||||||
state.industry ?? [],
|
state.industry ?? [],
|
||||||
|
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
|
||||||
|
nuclearCapability,
|
||||||
);
|
);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
@@ -2604,6 +3237,9 @@ export async function runTurn(opts: {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] },
|
economy: { trade: [], embargo: [] },
|
||||||
industry: { projects: [] },
|
industry: { projects: [] },
|
||||||
|
nuclear: { arm: [], strikes: [] },
|
||||||
|
leaders: { changes: [] },
|
||||||
|
ideology: { changes: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
CONQUEST_MILITARY_MARGIN,
|
CONQUEST_MILITARY_MARGIN,
|
||||||
deriveNationResources,
|
deriveNationResources,
|
||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
|
IDEOLOGY_STABILITY_EFFECT,
|
||||||
migrateRegionStats,
|
migrateRegionStats,
|
||||||
|
nationForPrompt,
|
||||||
seedRegionStats,
|
seedRegionStats,
|
||||||
weightedMedian,
|
weightedMedian,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
@@ -337,3 +339,30 @@ describe('canConquer', () => {
|
|||||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+35
-1
@@ -13,7 +13,25 @@ export interface StubPayload {
|
|||||||
turn: number;
|
turn: number;
|
||||||
date: string;
|
date: string;
|
||||||
playerNationId: string;
|
playerNationId: string;
|
||||||
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
// `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
|
||||||
|
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
|
||||||
|
// it — it never arms or strikes (see deterministicTurn).
|
||||||
|
// `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). */
|
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||||
pending?: RoundItem[];
|
pending?: RoundItem[];
|
||||||
@@ -141,6 +159,19 @@ function truncate(s: string, n: number): string {
|
|||||||
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
|
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
|
||||||
* always returns `industry: { projects: [] }`) — the per-region industry
|
* always returns `industry: { projects: [] }`) — the per-region industry
|
||||||
* path is covered by unit tests only, keeping the e2e assertions untouched.
|
* 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 {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -212,6 +243,9 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
||||||
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
|
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
|
||||||
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
|
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
|
||||||
|
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
|
||||||
|
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
|
||||||
|
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment)
|
||||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
IndustryProject,
|
IndustryProject,
|
||||||
|
MapMarker,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
NationStats,
|
NationStats,
|
||||||
@@ -433,6 +434,170 @@ export interface IndustryOps {
|
|||||||
projects: IndustryProject[];
|
projects: IndustryProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) A single nuclear strike. `from` names the striking nation
|
||||||
|
* (id or name — it must be **nuclear-capable**, see `isNuclearCapable`);
|
||||||
|
* `target` names what is struck — either a **region key** (the region is
|
||||||
|
* devastated to no man's land and its former owner devastated) or a
|
||||||
|
* **nation** (id or name — the target's economy / military / stability are
|
||||||
|
* devastated; if the striker and the target are on opposite sides of an
|
||||||
|
* active war, that war ends in `capitulation`).
|
||||||
|
*/
|
||||||
|
export interface NuclearStrike {
|
||||||
|
/** The striking nation (id or name). Must be nuclear-capable. */
|
||||||
|
from: string;
|
||||||
|
/** The struck target: a region key, or a nation (id or name). */
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) The turn's NUCLEAR operations, nested under
|
||||||
|
* `TurnResult.nuclear`. `arm` names the nations (id or name) that declare a
|
||||||
|
* nuclear arsenal this turn (idempotent — arming an already-armed nation is a
|
||||||
|
* no-op; an unresolvable ref is dropped); `strikes` are the nuclear strikes
|
||||||
|
* fired this turn (at most one per turn in the prompt contract; each is
|
||||||
|
* validated independently — an invalid `from` or `target` is dropped, never
|
||||||
|
* thrown). A strike on a nuclear-capable target (or the owner of a struck
|
||||||
|
* region) triggers a smaller "MAD" retaliation on the striker. Required;
|
||||||
|
* default `{ arm: [], strikes: [] }`.
|
||||||
|
*/
|
||||||
|
export interface NuclearOps {
|
||||||
|
/** Nation refs (id or name) arming a nuclear arsenal this turn. */
|
||||||
|
arm: string[];
|
||||||
|
/** The nuclear strikes fired this turn. */
|
||||||
|
strikes: NuclearStrike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Named leaders (v0.38, #61) -----------------------------------------------
|
||||||
|
//
|
||||||
|
// Each nation may have a single named leader (`Nation.leader`). The LLM changes
|
||||||
|
// it each turn via `TurnResult.leaders`. A change names a target nation (id or
|
||||||
|
// name) and the outcome: an `assassination` (a leader killed — a dramatic,
|
||||||
|
// destabilizing blow), a `death` (a leader dies of natural causes — a quieter
|
||||||
|
// succession), or an `appointed` leader (a neutral succession / new head of
|
||||||
|
// state). The engine sets the nation's leader to the new name and applies a
|
||||||
|
// DETERMINISTIC stability dip (see `LEADER_STABILITY_EFFECT` below) — no hidden
|
||||||
|
// randomness. A change whose target has no current leader may only *appoint*
|
||||||
|
// one (an assassination/death of a leaderless nation is dropped, not thrown).
|
||||||
|
|
||||||
|
/** How a nation's leader changed this turn (v0.38, #61). */
|
||||||
|
export type LeaderCause = 'assassination' | 'death' | 'appointed';
|
||||||
|
|
||||||
|
/** A single leader change: the target nation, the new leader's name, the cause. */
|
||||||
|
export interface LeaderChange {
|
||||||
|
/** The target nation, by id or name (resolved by the shared resolver). */
|
||||||
|
target: string;
|
||||||
|
/** The new leader's name (the incoming / successor head of state). */
|
||||||
|
name: string;
|
||||||
|
/** What happened to the leader this turn. */
|
||||||
|
cause: LeaderCause;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The leader-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
|
||||||
|
export interface LeaderOps {
|
||||||
|
changes: LeaderChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic stability effect of each leader-cause (v0.38, #61).
|
||||||
|
* Assassination is the most destabilizing, a natural death a minor wobble, an
|
||||||
|
* appointment is neutral. Applied to the target's `stability` (clamped 0–100),
|
||||||
|
* mirrored to its `regionStats`, and its derived stats re-derived afterwards.
|
||||||
|
*/
|
||||||
|
export const LEADER_STABILITY_EFFECT: Record<LeaderCause, number> = {
|
||||||
|
assassination: -15,
|
||||||
|
death: -3,
|
||||||
|
appointed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Ideology / form of government (v0.39, #62) ------------------------------
|
||||||
|
//
|
||||||
|
// Each nation may carry a free-form `ideology` label — its form of
|
||||||
|
// government ("democracy", "fascism", "military junta", …). The LLM changes it
|
||||||
|
// each turn via `TurnResult.ideology`: a change names a target nation (id or
|
||||||
|
// name) and the new government type. The engine sets the nation's `ideology`
|
||||||
|
// and applies a DETERMINISTIC stability dip (see `IDEOLOGY_STABILITY_EFFECT`
|
||||||
|
// below) — a regime shift always unsettles a nation — no hidden randomness.
|
||||||
|
// It is a third, independent axis next to `leader` (who is in charge) and the
|
||||||
|
// v0.32.0 takeover (which renames the nation): the id, name, territory,
|
||||||
|
// relations, and the leader all stay valid, only the form of government and
|
||||||
|
// stability move.
|
||||||
|
|
||||||
|
/** A single ideology change: the target nation and its new form of government. */
|
||||||
|
export interface IdeologyChange {
|
||||||
|
/** The target nation, by id or name (resolved by the shared resolver). */
|
||||||
|
target: string;
|
||||||
|
/** The new form of government (a free-form label, e.g. "democracy"). */
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ideology-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
|
||||||
|
export interface IdeologyOps {
|
||||||
|
changes: IdeologyChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic stability effect of an ideology change (v0.39, #62). A
|
||||||
|
* regime shift (a coup, a revolution, a reform, a constitutional change)
|
||||||
|
* unsettles the nation: a small −5 dip applied to the target's `stability`
|
||||||
|
* (clamped 0–100), mirrored to its `regionStats`, and its derived stats
|
||||||
|
* re-derived afterwards. Applied the same way a leader-cause effect is —
|
||||||
|
* re-setting the SAME ideology still dips (the shift just happened, even if
|
||||||
|
* the label does not change) but is idempotent for the `ideology` field
|
||||||
|
* itself.
|
||||||
|
*/
|
||||||
|
export const IDEOLOGY_STABILITY_EFFECT: number = -5;
|
||||||
|
|
||||||
|
// --- Nuclear / WMD effect math (v0.37, #60) ----------------------------------
|
||||||
|
//
|
||||||
|
// A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no
|
||||||
|
// hidden randomness (the LLM decides WHO strikes and WHAT is targeted; the
|
||||||
|
// engine decides the size of the effect). The two magnitude tables below are
|
||||||
|
// the single source of truth for the collapse: a full strike devastates a
|
||||||
|
// nation (or the former owner of a devastated region) by `NUCLEAR_COLLAPSE`,
|
||||||
|
// while a "MAD" retaliation (fired when a nuclear-capable nation is struck)
|
||||||
|
// devastates the STRIKER by half that, `NUCLEAR_RETALIATION`. Both dip the
|
||||||
|
// three war-relevant stats only (population is untouched); each value is the
|
||||||
|
// signed per-turn delta the engine clamps against 0–100.
|
||||||
|
|
||||||
|
/** The stats a nuclear strike collapses (population is never touched). */
|
||||||
|
export type NuclearStatKey = 'economy' | 'military' | 'stability';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) The deterministic stat collapse a FULL nuclear strike inflicts
|
||||||
|
* on its target (a struck nation, or the former owner of a devastated region):
|
||||||
|
* a heavy dip in economy / military / stability. Shared so the engine and the
|
||||||
|
* unit tests agree on the exact magnitudes.
|
||||||
|
*/
|
||||||
|
export const NUCLEAR_COLLAPSE: Record<NuclearStatKey, number> = {
|
||||||
|
economy: -40,
|
||||||
|
military: -50,
|
||||||
|
stability: -40,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) The deterministic stat collapse of a "MAD" RETALIATION — fired
|
||||||
|
* automatically when a nuclear-capable nation (or the owner of a devastated
|
||||||
|
* region) is struck — which hits the STRIKER at half the full collapse.
|
||||||
|
*/
|
||||||
|
export const NUCLEAR_RETALIATION: Record<NuclearStatKey, number> = {
|
||||||
|
economy: NUCLEAR_COLLAPSE.economy / 2,
|
||||||
|
military: NUCLEAR_COLLAPSE.military / 2,
|
||||||
|
stability: NUCLEAR_COLLAPSE.stability / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) Whether a nation is **nuclear-capable** — i.e. able to fire a
|
||||||
|
* nuclear strike and subject to MAD retaliation: its `nuclear` flag is set, OR
|
||||||
|
* it owns a `rocket` marker (a launch site is an arsenal). A legacy nation
|
||||||
|
* without the flag is not nuclear unless it has a rocket marker. Pure +
|
||||||
|
* shared (used by the engine and the unit tests).
|
||||||
|
*/
|
||||||
|
export function isNuclearCapable(nation: Nation, markers: readonly MapMarker[]): boolean {
|
||||||
|
if (nation.nuclear === true) return true;
|
||||||
|
return markers.some((m) => m.ownerNationId === nation.id && m.type === 'rocket');
|
||||||
|
}
|
||||||
|
|
||||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -537,6 +702,53 @@ export interface TurnResult {
|
|||||||
* untouched. Required; default `{ projects: [] }`.
|
* untouched. Required; default `{ projects: [] }`.
|
||||||
*/
|
*/
|
||||||
industry: IndustryOps;
|
industry: IndustryOps;
|
||||||
|
/**
|
||||||
|
* (v0.37, #60) Nuclear / WMD ops — the LLM's nuclear lever. `arm` names
|
||||||
|
* the nations that declare a nuclear arsenal this turn (idempotent);
|
||||||
|
* `strikes` are the nuclear strikes fired this turn (at most one per turn
|
||||||
|
* in the prompt contract). A strike may target a **region** (devastated to
|
||||||
|
* no man's land, its former owner devastated) or a **nation** (its economy
|
||||||
|
* / military / stability devastated; a strike that lands on a nation
|
||||||
|
* warring with the striker ends that war in capitulation). A strike on a
|
||||||
|
* nuclear-capable target (or the owner of a struck region) triggers a
|
||||||
|
* smaller, automatic "MAD" retaliation on the striker. Only
|
||||||
|
* nuclear-capable nations (the `Nation.nuclear` flag set, or owning a
|
||||||
|
* `rocket` marker) may strike. Applied directly by `applyTurnResult`,
|
||||||
|
* never queued. Required; default `{ arm: [], strikes: [] }`.
|
||||||
|
*/
|
||||||
|
nuclear: NuclearOps;
|
||||||
|
/**
|
||||||
|
* (v0.38, #61) Leader changes this turn. Each change names a target nation
|
||||||
|
* (id or name) and sets its leader to the new name, applying a deterministic
|
||||||
|
* stability effect by cause: `assassination` (leader killed — a dramatic
|
||||||
|
* −15 to the target's stability, a `military` event), `death` (natural
|
||||||
|
* causes — a quieter −3 succession, a `political` event), or `appointed`
|
||||||
|
* (a neutral 0 succession, a `political` event). An assassination/death
|
||||||
|
* requires the target to already have a leader (a leaderless target is
|
||||||
|
* dropped, never thrown); an appointment is always valid (it creates the
|
||||||
|
* first leader). At most 1–2 per turn, each narrated in `events`. This is
|
||||||
|
* the *leader* lever — heavier and more destabilizing than the lighter
|
||||||
|
* `hybrid.assassinate` op (a −5 stability dip on a nation or region, with
|
||||||
|
* no leader replacement). Required; default `{ changes: [] }`.
|
||||||
|
*/
|
||||||
|
leaders: LeaderOps;
|
||||||
|
/**
|
||||||
|
* (v0.39, #62) Ideology (form of government) changes this turn. Each
|
||||||
|
* change names a target nation (id or name) and its new form of
|
||||||
|
* government — a free-form label such as "democracy", "fascism", or
|
||||||
|
* "military junta". Applied directly by `applyTurnResult` (like leaders /
|
||||||
|
* hybrid / economy): the target's `ideology` is set to the new label, a
|
||||||
|
* small, deterministic stability dip (`IDEOLOGY_STABILITY_EFFECT`, −5) is
|
||||||
|
* applied (clamped, mirrored to the nation's regions, re-derived), and a
|
||||||
|
* `political` narration event is appended. Re-setting the same label is
|
||||||
|
* idempotent for the field (still narrated, still dips). Unknown /
|
||||||
|
* ambiguous targets and empty labels are dropped, never thrown; the
|
||||||
|
* change never moves territory (sole ownership is untouched). Distinct
|
||||||
|
* from a takeover (which renames the nation) and from a leader change
|
||||||
|
* (who is in charge). At most 1–2 per turn, each narrated in `events`.
|
||||||
|
* Required; default `{ changes: [] }`.
|
||||||
|
*/
|
||||||
|
ideology: IdeologyOps;
|
||||||
/**
|
/**
|
||||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
* (the engine positions each at its region's centroid + a small
|
* (the engine positions each at its region's centroid + a small
|
||||||
@@ -849,6 +1061,8 @@ export function nationForPrompt(n: Nation) {
|
|||||||
stats: n.stats,
|
stats: n.stats,
|
||||||
territory: n.territory,
|
territory: n.territory,
|
||||||
relations: n.relations,
|
relations: n.relations,
|
||||||
|
leader: n.leader,
|
||||||
|
ideology: n.ideology,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,34 @@ export interface Nation {
|
|||||||
stats: NationStats;
|
stats: NationStats;
|
||||||
territory: string[];
|
territory: string[];
|
||||||
relations: Record<string, RelationStatus>;
|
relations: Record<string, RelationStatus>;
|
||||||
|
/**
|
||||||
|
* Whether the nation controls nuclear weapons (v0.37, #60). Optional — a
|
||||||
|
* legacy game loaded from SQLite has no flag, which means "not nuclear".
|
||||||
|
* A nation is *nuclear-capable* (for the `nuclear` lever and MAD
|
||||||
|
* retaliation) when this is `true` OR it owns a `rocket` marker (see
|
||||||
|
* `isNuclearCapable` in `protocol.ts`).
|
||||||
|
*/
|
||||||
|
nuclear?: boolean;
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Categories a narrative event can take. */
|
||||||
|
|||||||
Reference in New Issue
Block a user