Compare commits

..
2 Commits
8 changed files with 858 additions and 25 deletions
+17
View File
@@ -2,6 +2,23 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.37.0 — 2026-10-15 19:15
- **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent).
- **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn.
- **Types (`shared/types.ts`).** `Nation.nuclear?: boolean` (optional; `undefined`/absent = not armed; default `false` behavior).
- **Protocol (`shared/protocol.ts`).** New `NuclearStrike` (`{ from; target }` — `from` is the striking nation, `target` is a nation ref or a region key) and `NuclearOps` (`{ arm: string[]; strikes: NuclearStrike[] }`); `TurnResult.nuclear` is **required**, placed between `industry` and `markers`, default `{ arm: [], strikes: [] }`. New exported `NuclearStatKey` (`'economy' | 'military' | 'stability'`) and the two deterministic collapse tables: **`NUCLEAR_COLLAPSE = { economy: −40, military: −50, stability: −40 }**` (a full strike's effect on the struck nation / region owner) and **`NUCLEAR_RETALIATION = { economy: −20, military: −25, stability: −20 }`**` (the half-strength MAD backfire).
- **Engine (`server/engine.ts`).**
- `validateNuclearOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ arm: [], strikes: [] }`; `arm` entries are non-empty strings (≤120 chars) deduped; a strike needs a non-empty `from` **and** `target` (≤120) and is **dropped when it targets itself** (same ref, case-insensitive) — a self-strike is a no-op, never thrown.
- `applyNuclearOps(next, ops, state, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — new exported, the load-bearing nuclear block. **`arm` first** (idempotent; a resolve miss or a self-arm is a no-op), then **`strikes`** in order: resolve `from` (an unresolvable or non-capable striker is **dropped** — capability checked against the *pre-turn* marker set plus any flag granted this turn); a `target` that is a **nation** collapses it (the `NUCLEAR_COLLAPSE` deltas applied to its `stats` and every region it owns, each affected nation's derived stats re-derived, 0-region / no-entry guards as elsewhere), ends **every active war it fights** by **capitulation** (`endWarsBetween` captures the active pairs *before* ending so a war declared+ended the same turn is handled), and downgrades the now-inactive `war` relations to `hostile`; a `target` that is a **region key** releases it to no man's land (a key with 0 or 2+ owners, or one the striker itself owns, is **dropped**), deletes its `regionStats` entry, and collapses its **sole owner** the same way. **MAD**: if the struck nation (or the struck region's sole owner) is itself nuclear-capable, it retaliates against the striker for the half-strength `NUCLEAR_RETALIATION` (clamped, mirrored, re-derived) and the two nations' relations are forced to `hostile` (`forceHostile` — both directions, unless already `war`). Each strike and each retaliation appends a dramatic **`military`** event (`war` is not a valid `EventType`). Sole ownership is never broken by a strike (a region is released, not re-claimed) and the block never throws.
- `applyTurnResult` — the nuclear block runs **after `industry` and before `markers`** (order: events → relations → territory → stateChanges → recompute → wars → hybrid → economy → industry → **nuclear** → markers → nationOps); it passes the *original* (pre-turn) `state` for the capability marker set and the current `regionNameOf` for event text.
- `isMeaningful` — a non-empty `nuclear.arm` or `nuclear.strikes` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `nuclear` (default `{ arm: [], strikes: [] }`).
- `buildSystemPrompt` — documents the `nuclear` field and a constraint bullet (the capability rule; `arm` is idempotent; a strike hits a nation or a region; the collapse + capitulation + MAD outcomes; at most one strike per turn, always narrated) in both tool and plain modes.
- `buildUserPrompt` — new optional `nuclear` param (`Record<string, boolean>`, the per-nation capability map); when provided, each `state.nations` entry in the payload is stamped with its `nuclear` capability; when undefined the key is omitted entirely (stub / no-capability prompt stays byte-identical). `runTurn` always supplies the map (computed from `isNuclearCapable` over `state.markers`).
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `nuclear: { arm: [], strikes: [] }` — the stub **never** arms a nation or fires a strike, so the e2e (CI, `LLM_STUB=1`) is unaffected and the nuclear path is covered by unit tests only.
- **Tests.** 29 new unit tests: `isNuclearCapable` (flag / own rocket / non-rocket / other's rocket / null owner); `validateNuclearOps` (well-formed, `arm` dedupe, missing `from`/`target`, self-target drop, malformed top-level); `applyTurnResult` nuclear (arm idempotency across turns, arm+strike same turn, region strike → release + stats deleted + owner collapse + no MAD + the single `military` event, nation strike + pre-existing war → capitulation + hostile, nation strike + same-turn declare → capitulation + hostile, MAD on a capable nation target, MAD on a capable region owner, rocket-marker-only capability, non-capable striker dropped, 2+-owner region dropped, 0-owner/unowned region dropped, self-target dropped, malformed strikes never throw); `isMeaningful` (arm / strike); `buildUserPrompt` (per-nation capability stamped when the map is given, omitted when undefined); and two `db.test.ts` round-trips (a legacy row with no flag loads with it absent; a set flag round-trips through save/load).
- **Version** bumped `0.36.0 → 0.37.0`.
## v0.36.0 — 2026-10-15 18:17 ## 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
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.36.0", "version": "0.37.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+22
View File
@@ -178,6 +178,28 @@ 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('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'));
+316 -21
View File
@@ -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';
@@ -45,6 +49,7 @@ import {
validateHybridOps, validateHybridOps,
validateIndustryOps, validateIndustryOps,
validateMarkerOps, validateMarkerOps,
validateNuclearOps,
validateNationOps, validateNationOps,
validateRegionChanges, validateRegionChanges,
validateTakeovers, validateTakeovers,
@@ -563,7 +568,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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 +590,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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 +611,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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 +630,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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 +1087,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: [] }, markers: { add: [], remove: [] },
}; };
applyTurnResult(state, result); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
@@ -1099,7 +1104,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: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); expect(next.pending).toEqual([]);
@@ -1122,7 +1127,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE. // is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored // the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1148,7 +1153,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned. // released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1166,7 +1171,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [], regionChanges: [],
relations: [], relations: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}; };
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1256,7 +1261,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}; };
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1371,7 +1376,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect( expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, markers: { add: [], remove: [] } }),
).toBe(true); ).toBe(true);
}); });
@@ -1678,9 +1683,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => { describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => { it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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: [] }, 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: [] }, 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 +1698,7 @@ describe('isMeaningful', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -1713,6 +1718,7 @@ 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: [] },
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 +1739,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -1755,6 +1762,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1787,6 +1795,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1810,6 +1819,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1836,6 +1846,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1861,6 +1872,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}); });
expect(validated!.takeovers).toEqual([]); expect(validated!.takeovers).toEqual([]);
@@ -1886,6 +1898,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1909,6 +1922,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1930,6 +1944,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1963,6 +1978,7 @@ 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: [] },
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 +2003,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, { const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] }, unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(first.markers).toHaveLength(1); expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again // a second turn proposes the exact same marker again
const second = applyTurnResult(first, { const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] }, unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(second.markers).toHaveLength(1); expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2019,6 +2035,7 @@ 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: [] },
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 +2082,7 @@ 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: [] },
markers: { markers: {
add: [ add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2090,7 +2108,7 @@ describe('markers (point placement, v0.27 #44)', () => {
]; ];
const next = applyTurnResult(state, { const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, 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 +2124,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless // 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, { const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull(); expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de // 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, { const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de'); expect(reclaimed.markers[0].ownerNationId).toBe('de');
}); });
@@ -2182,6 +2200,7 @@ 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: [] },
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 +2294,7 @@ 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: [] },
markers: partial.markers ?? { add: [], remove: [] }, markers: partial.markers ?? { add: [], remove: [] },
}; };
} }
@@ -2999,6 +3019,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}); });
expect(withTakeovers).not.toBeNull(); expect(withTakeovers).not.toBeNull();
@@ -3386,6 +3407,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -3406,6 +3428,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -3426,6 +3449,7 @@ 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: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(false); ).toBe(false);
@@ -4349,3 +4373,274 @@ 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: [] } }))).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');
});
});
+380 -1
View File
@@ -33,9 +33,12 @@ import {
HYBRID_OP_TYPES, HYBRID_OP_TYPES,
isDateLike, isDateLike,
isMarkerType, isMarkerType,
isNuclearCapable,
makeEvent, makeEvent,
markerId, markerId,
MAX_MARKERS, MAX_MARKERS,
NUCLEAR_COLLAPSE,
NUCLEAR_RETALIATION,
nationForPrompt, nationForPrompt,
type EconomyEmbargoOp, type EconomyEmbargoOp,
type EconomyOps, type EconomyOps,
@@ -46,6 +49,8 @@ import {
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 +242,7 @@ 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>"}]},',
' "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 +265,7 @@ 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".',
'- 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 +358,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 +456,10 @@ export function buildUserPrompt(
date: state.date, date: state.date,
playerNationId: state.playerNationId, playerNationId: state.playerNationId,
nations: Object.fromEntries( nations: Object.fromEntries(
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]), Object.entries(state.nations).map(([id, n]) => [
id,
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
]),
), ),
recentEvents: state.events.slice(-5).map((e) => ({ recentEvents: state.events.slice(-5).map((e) => ({
turn: e.turn, turn: e.turn,
@@ -611,6 +630,7 @@ export function validateTurnResult(
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, economy: { trade: [], embargo: [] },
industry: { projects: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
@@ -729,6 +749,14 @@ 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.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 +1059,40 @@ 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.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},
@@ -1556,6 +1618,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 +1944,10 @@ 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.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 +2362,16 @@ 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);
// --- 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 +2898,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 +2923,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 +2982,7 @@ export async function runTurn(opts: {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, economy: { trade: [], embargo: [] },
industry: { projects: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
} }
+13 -1
View File
@@ -13,7 +13,13 @@ export interface StubPayload {
turn: number; turn: number;
date: string; date: string;
playerNationId: string; playerNationId: string;
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>; // `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
// it — it never arms or strikes (see deterministicTurn).
nations: Record<
string,
{ stats: Record<string, number>; territory?: string[]; nuclear?: boolean }
>;
}; };
/** The raw pending round the engine emitted (one entry per queued item). */ /** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[]; pending?: RoundItem[];
@@ -141,6 +147,11 @@ 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.
*/ */
export function deterministicTurn(p: StubPayload): TurnResult { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -212,6 +223,7 @@ 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)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
}; };
} }
+100
View File
@@ -7,6 +7,7 @@ import type {
EventType, EventType,
GameState, GameState,
IndustryProject, IndustryProject,
MapMarker,
MarkerType, MarkerType,
Nation, Nation,
NationStats, NationStats,
@@ -433,6 +434,90 @@ 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[];
}
// --- 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 +622,21 @@ 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.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
+8
View File
@@ -72,6 +72,14 @@ export interface Nation {
stats: NationStats; stats: NationStats;
territory: string[]; territory: string[];
relations: Record<string, RelationStatus>; relations: Record<string, RelationStatus>;
/**
* Whether the nation controls nuclear weapons (v0.37, #60). Optional — a
* legacy game loaded from SQLite has no flag, which means "not nuclear".
* A nation is *nuclear-capable* (for the `nuclear` lever and MAD
* retaliation) when this is `true` OR it owns a `rocket` marker (see
* `isNuclearCapable` in `protocol.ts`).
*/
nuclear?: boolean;
} }
/** Categories a narrative event can take. */ /** Categories a narrative event can take. */