From 8eeb6df2849dfc7fb30b96e67f652c39883e429b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 16:42:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(#57):=20hybrid=20war=20=E2=80=94=20TurnRes?= =?UTF-8?q?ult.hybrid=20ops=20(spy/sabotage/assassinate/cyber/economic/inf?= =?UTF-8?q?ormation/proxy)=20with=20exposure=20backfire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 28 +++ CHANGELOG.md | 22 +++ package.json | 2 +- server/engine.test.ts | 445 ++++++++++++++++++++++++++++++++++++++++-- server/engine.ts | 307 +++++++++++++++++++++++++++++ server/stub.ts | 14 +- shared/protocol.ts | 72 +++++++ 7 files changed, 868 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a8ca2f..6aa1f1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,6 +350,34 @@ history/ (as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only (`server/engine.test.ts`). +- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).** + `TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert / + hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp = + { type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate | + cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM + declares its *intended* outcome; the engine applies the deterministic effect). + `from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then + case-insensitive unique name); a `target` that is not a resolvable nation is treated + as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the + acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy` + (−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips + `stability` (−5); `information` nudges the from↔target relation one step apart (capped + at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only. + A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops + never move territory — `regionChanges` is the only lever that does); a nation-target + dip lands on every region the nation owns; afterwards the target's derived `stats` is + re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership + invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended + effect is NOT applied; instead the from↔target relation moves one step *toward* + `hostile` (capped; `war` is never created or escalated by an exposed op) and a + diplomatic/failed event is auto-appended to the turn's `events`. + **Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are + consumed inside `applyTurnResult` after territory + wars and before markers). + `isMeaningful` counts hybrid ops; the system prompt documents the field + per-type + effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the + user payload carries the player's `relations` map so the model can reason about the + relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is + unaffected and the path is unit-tested only (`server/engine.test.ts`). - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was diff --git a/CHANGELOG.md b/CHANGELOG.md index 14dde72..0757662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to **History** are listed here, newest first. +## v0.34.0 — 2026-10-15 16:35 +- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted). + - **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`. + - **Engine (`server/engine.ts`).** + - `isHybridOpType(t)` — exported guard over the seven types. + - `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300. + - `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect. + - `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire. + - `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path. + - `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only. + - `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership). + - `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful. + - `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`). + - `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes. + - `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided. + - `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`. + - **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`. + - **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only. + - **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid. + - **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed. + - **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage. + ## v0.33.0 — 2026-10-15 15:45 - **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally. - **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes). diff --git a/package.json b/package.json index df91e4c..74f1260 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.33.0", + "version": "0.34.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/engine.test.ts b/server/engine.test.ts index 13d44cd..01cc610 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -6,6 +6,7 @@ import { markerId, MAX_MARKERS, seedRegionStats, + type HybridOp, type MarkerInfo, type MarkerSpec, type NationOp, @@ -24,6 +25,8 @@ import { freshNationId, freshWarId, GET_NEIGHBOURS_TOOL, + HYBRID_STAT_EFFECT, + isHybridOpType, isMeaningful, makeNeighboursExecutor, NEW_NATION_STATS, @@ -35,7 +38,9 @@ import { resolveNationRef, resolveRegionKey, runTurn, + shiftRelationTowardHostile, syncWarRelations, + validateHybridOps, validateMarkerOps, validateNationOps, validateRegionChanges, @@ -550,7 +555,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -572,7 +577,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -593,7 +598,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -612,7 +617,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1069,7 +1074,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1086,7 +1091,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1109,7 +1114,7 @@ describe('applyTurnResult', () => { // 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: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; 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 @@ -1135,7 +1140,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1153,7 +1158,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1243,7 +1248,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1358,7 +1363,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1665,9 +1670,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { 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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(false); + 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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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)', () => { @@ -1680,7 +1685,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1698,6 +1703,7 @@ describe('isMeaningful', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); @@ -1716,6 +1722,7 @@ describe('isMeaningful', () => { nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -1736,6 +1743,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1766,6 +1774,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1787,6 +1796,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1811,6 +1821,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1834,6 +1845,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: ' ' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); @@ -1857,6 +1869,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { { nationRef: 'de', newRegimeName: 'The Reich' }, ], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1878,6 +1891,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1897,6 +1911,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1928,6 +1943,7 @@ describe('markers (point placement, v0.27 #44)', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -1952,13 +1968,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -1982,6 +1998,7 @@ describe('markers (point placement, v0.27 #44)', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -2026,6 +2043,7 @@ describe('markers (point placement, v0.27 #44)', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -2051,7 +2069,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2067,14 +2085,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2141,6 +2159,7 @@ describe('markers (point placement, v0.27 #44)', () => { unowned: [], nationOps: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ @@ -2226,6 +2245,7 @@ function mkTurnResult(partial: Partial): TurnResult { nationOps: partial.nationOps ?? [], takeovers: partial.takeovers ?? [], wars: partial.wars ?? { declare: [], end: [] }, + hybrid: partial.hybrid ?? { ops: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -2948,6 +2968,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); @@ -3333,6 +3354,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { nationOps: [], takeovers: [], wars: { declare: [{ sides: ['a', 'b'] }], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3351,6 +3373,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3369,6 +3392,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); @@ -3434,3 +3458,384 @@ describe('stub — wars (v0.33.0 #56)', () => { expect(result.wars).toEqual({ declare: [], end: [] }); }); }); + +// --- Hybrid war (v0.34.0, #57) --------------------------------------------- + +/** + * Build a two-nation state for the hybrid tests: `a` (the actor) owns region + * `ra1`, `b` (the target) owns region `rb1`, both with uniform 50 stats and the + * given relations. Returns the state plus the two region keys. + */ +function hybState( + rels: { a?: Record; b?: Record }, +): GameState { + const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']); + const b = mkNation('b', NS(50, 50, 50, 50), ['rb1']); + a.relations = rels.a ?? {}; + b.relations = rels.b ?? {}; + return mkTurnState({ a, b }, { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) }, []); +} + +describe('isHybridOpType (v0.34.0 #57)', () => { + it('accepts all seven types and rejects anything else', () => { + for (const t of ['spy', 'sabotage', 'assassinate', 'cyber', 'economic', 'information', 'proxy']) { + expect(isHybridOpType(t)).toBe(true); + } + for (const t of ['bogus', 'SPY', '', null, 5, undefined]) { + expect(isHybridOpType(t)).toBe(false); + } + }); +}); + +describe('validateHybridOps (v0.34.0 #57)', () => { + it('non-object / null / array-of-wrongs input yields []', () => { + expect(validateHybridOps(undefined)).toEqual([]); + expect(validateHybridOps(null)).toEqual([]); + expect(validateHybridOps('x')).toEqual([]); + expect(validateHybridOps(5)).toEqual([]); + expect(validateHybridOps([])).toEqual([]); // a bare [] of "ops" — empty + }); + + it('accepts BOTH the { ops: [...] } object and a bare array', () => { + const op = { type: 'spy', from: 'a', target: 'b' }; + expect(validateHybridOps({ ops: [op] })).toEqual([op]); + expect(validateHybridOps([op])).toEqual([op]); + }); + + it('keeps a valid op, trims/caps from & target, and drops outcome:success', () => { + const out = validateHybridOps([ + { type: 'sabotage', from: ' a ', target: ' b ', detail: ' blew up a plant ' }, + ]); + expect(out).toEqual([{ type: 'sabotage', from: 'a', target: 'b', detail: 'blew up a plant' }]); + }); + + it('stores outcome:exposed but omits the default success', () => { + const out = validateHybridOps([ + { type: 'spy', from: 'a', target: 'b', outcome: 'exposed' }, + { type: 'spy', from: 'a', target: 'b', outcome: 'success' }, + { type: 'spy', from: 'a', target: 'b' }, + ]); + expect(out).toEqual([ + { type: 'spy', from: 'a', target: 'b', outcome: 'exposed' }, + { type: 'spy', from: 'a', target: 'b' }, + { type: 'spy', from: 'a', target: 'b' }, + ]); + }); + + it('drops an unknown type, and empty from or target', () => { + const out = validateHybridOps([ + { type: 'bogus', from: 'a', target: 'b' }, + { type: 'spy', from: ' ', target: 'b' }, + { type: 'spy', from: 'a', target: '' }, + { type: 'spy', from: 'a' }, // missing target + ]); + expect(out).toEqual([]); + }); + + it('caps from/target at 200 chars and detail at 300', () => { + const out = validateHybridOps([ + { type: 'spy', from: 'a'.repeat(250), target: 'b'.repeat(250), detail: 'd'.repeat(350) }, + ]); + expect(out).toHaveLength(1); + expect(out[0]!.from).toHaveLength(200); + expect(out[0]!.target).toHaveLength(200); + expect(out[0]!.detail).toHaveLength(300); + }); +}); + +describe('validateTurnResult — hybrid (v0.34.0 #57)', () => { + it('defaults hybrid to { ops: [] } when the field is absent', () => { + const r = validateTurnResult({ date: 'x' }); + expect(r?.hybrid).toEqual({ ops: [] }); + }); + + it('parses a hybrid field into validated ops', () => { + const r = validateTurnResult({ + date: 'x', + hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] }, + }); + expect(r?.hybrid).toEqual({ ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] }); + }); +}); + +describe('isMeaningful — hybrid (v0.34.0 #57)', () => { + it('a hybrid op makes an otherwise-empty turn meaningful', () => { + expect( + isMeaningful(mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } })), + ).toBe(true); + }); + it('an empty hybrid (and nothing else) is NOT meaningful', () => { + expect(isMeaningful(mkTurnResult({ hybrid: { ops: [] } }))).toBe(false); + }); +}); + +describe('shiftRelationTowardHostile (v0.34.0 #57)', () => { + it('steps one rung toward hostile (cap hostile), symmetrically', () => { + const mk = (ab: RelationStatus, ba: RelationStatus) => { + const a = mkNation('a', NS(50, 50, 50, 50), []); + const b = mkNation('b', NS(50, 50, 50, 50), []); + a.relations = { b: ab }; + b.relations = { a: ba }; + return mkTurnState({ a, b }, {}); + }; + let s = mk('neutral', 'neutral'); + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('rival'); + expect(s.nations.b.relations.a).toBe('rival'); + s = mk('rival', 'rival'); + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('hostile'); + s = mk('ally', 'neutral'); // asymmetric: each side steps from its own value + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('neutral'); // ally -> neutral + expect(s.nations.b.relations.a).toBe('rival'); // neutral -> rival + }); + + it('never moves past the cap (hostile cap, rival cap)', () => { + const mk = (ab: RelationStatus, ba: RelationStatus) => { + const a = mkNation('a', NS(50, 50, 50, 50), []); + const b = mkNation('b', NS(50, 50, 50, 50), []); + a.relations = { b: ab }; + b.relations = { a: ba }; + return mkTurnState({ a, b }, {}); + }; + // hostile cap: hostile stays, rival -> hostile + let s = mk('hostile', 'hostile'); + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('hostile'); + // rival cap: hostile stays (past cap), rival stays (at cap), neutral -> rival + s = mk('hostile', 'hostile'); + shiftRelationTowardHostile(s, 'a', 'b', 'rival'); + expect(s.nations.a.relations.b).toBe('hostile'); + s = mk('rival', 'rival'); + shiftRelationTowardHostile(s, 'a', 'b', 'rival'); + expect(s.nations.a.relations.b).toBe('rival'); + s = mk('neutral', 'neutral'); + shiftRelationTowardHostile(s, 'a', 'b', 'rival'); + expect(s.nations.a.relations.b).toBe('rival'); + }); + + it('NEVER touches a war relation (neither direction)', () => { + const a = mkNation('a', NS(50, 50, 50, 50), []); + const b = mkNation('b', NS(50, 50, 50, 50), []); + a.relations = { b: 'war' }; + b.relations = { a: 'war' }; + const s = mkTurnState({ a, b }, {}); + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('war'); + expect(s.nations.b.relations.a).toBe('war'); + }); + + it('treats a missing relation as neutral and is a no-op for unknown nations', () => { + const a = mkNation('a', NS(50, 50, 50, 50), []); + const b = mkNation('b', NS(50, 50, 50, 50), []); + const s = mkTurnState({ a, b }, {}); // no relations set + shiftRelationTowardHostile(s, 'a', 'b', 'hostile'); + expect(s.nations.a.relations.b).toBe('rival'); // neutral -> rival + shiftRelationTowardHostile(s, 'a', 'ghost', 'hostile'); // unknown nation: no-op + expect(s.nations.a.relations.ghost).toBeUndefined(); + }); +}); + +describe('applyTurnResult — hybrid (v0.34.0 #57)', () => { + it('sabotage (nation target): dips ALL of the target\'s economy, re-derives, no relation/event', () => { + // b owns TWO regions — a nation-target op must dip both. + const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']); + const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']); + const state = mkTurnState( + { a, b }, + { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) }, + ); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b' }] } })); + expect(next.regionStats.rb1.economy).toBe(47); + expect(next.regionStats.rb2.economy).toBe(47); + expect(next.nations.b.stats.economy).toBe(47); + expect(next.nations.a.stats.economy).toBe(50); // actor untouched + expect(next.nations.a.relations.b).toBeUndefined(); // no relation nudge for a stat op + expect(next.nations.b.relations.a).toBeUndefined(); + expect(next.events).toHaveLength(0); // the engine emits no event for a successful stat op + }); + + it('a region target dips ONLY that region (not the rest of the nation)', () => { + const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']); + const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']); + const state = mkTurnState( + { a, b }, + { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) }, + ); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } })); + expect(next.regionStats.rb1.military).toBe(47); // the named region + expect(next.regionStats.rb2.military).toBe(50); // the sibling region untouched + }); + + it('each stat type applies its documented dip', () => { + const run = (type: HybridOp['type']) => { + const state = hybState({}); + return applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type, from: 'a', target: 'b' }] } })); + }; + expect(run('sabotage').nations.b.stats.economy).toBe(47); // -3 + expect(run('assassinate').nations.b.stats.stability).toBe(45); // -5 + expect(run('cyber').nations.b.stats.military).toBe(47); // -3 + expect(run('economic').nations.b.stats.economy).toBe(45); // -5 + }); + + it('spy is informational only — no stat, no relation change', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } })); + expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50)); + expect(next.nations.a.relations.b).toBe('neutral'); + expect(next.nations.b.relations.a).toBe('neutral'); + expect(next.events).toHaveLength(0); + }); + + it('information nudges the relation one step toward hostile (cap hostile)', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'information', from: 'a', target: 'b' }] } })); + expect(next.nations.a.relations.b).toBe('rival'); + expect(next.nations.b.relations.a).toBe('rival'); + expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50)); // no stat change + }); + + it('proxy nudges the relation but caps at rival (hostile is not reached)', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } })); + expect(next.nations.a.relations.b).toBe('rival'); + expect(next.nations.b.relations.a).toBe('rival'); + // a hostile pair is left alone by a proxy op (capped at rival) + const state2 = hybState({ a: { b: 'hostile' }, b: { a: 'hostile' } }); + const next2 = applyTurnResult(state2, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } })); + expect(next2.nations.a.relations.b).toBe('hostile'); + }); + + it('an EXPOSED op backfires: no stat effect, relation sours one step, auto diplomatic/failure event', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult( + state, + mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b', outcome: 'exposed' }] } }), + ); + expect(next.nations.b.stats.economy).toBe(50); // the intended dip did NOT happen + expect(next.regionStats.rb1.economy).toBe(50); + expect(next.nations.a.relations.b).toBe('rival'); // neutral -> rival (cap hostile) + expect(next.nations.b.relations.a).toBe('rival'); + expect(next.events).toHaveLength(1); + expect(next.events[0]).toMatchObject({ nation: 'a', type: 'diplomatic', outcome: 'failure', title: "Covert operation exposed" }); + }); + + it('an EXPOSED op never touches a war relation', () => { + const state = hybState({ a: { b: 'war' }, b: { a: 'war' } }); + const next = applyTurnResult( + state, + mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] } }), + ); + expect(next.nations.a.relations.b).toBe('war'); + expect(next.nations.b.relations.a).toBe('war'); + expect(next.events).toHaveLength(1); // the backfire event is still emitted + }); + + it('drops an unknown actor, a self-target, an unknown region, and a double-owned region', () => { + // unknown actor + let state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + let next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'zzz', target: 'b' }] } })); + expect(next.nations.b.stats.economy).toBe(50); + expect(next.events).toHaveLength(0); + // self-target + state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'a' }] } })); + expect(next.nations.a.stats.economy).toBe(50); + expect(next.events).toHaveLength(0); + // unknown region key + state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'not-a-region' }] } })); + expect(next.nations.b.stats.military).toBe(50); + expect(next.events).toHaveLength(0); + // double-owned region (a and b both own 'rx') + const a = mkNation('a', NS(50, 50, 50, 50), ['ra1', 'rx']); + const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rx']); + const st = mkTurnState( + { a, b }, + { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rx: RS(50, 50, 50, 50) }, + ); + const nx = applyTurnResult(st, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rx' }] } })); + expect(nx.regionStats.rx.military).toBe(50); // not a valid sole-owner target: dropped + expect(nx.events).toHaveLength(0); + }); + + it('a region target outside the valid-key set is dropped (validKeys gate)', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult( + state, + mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }), + new Set(['ra1']), // rb1 is not a valid key + ); + expect(next.regionStats.rb1.military).toBe(50); + expect(next.nations.b.stats.military).toBe(50); + }); + + it('NEVER moves borders: a hybrid op leaves every nation\'s territory untouched', () => { + const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } }); + const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'economic', from: 'a', target: 'b' }] } })); + expect(next.nations.a.territory).toEqual(['ra1']); + expect(next.nations.b.territory).toEqual(['rb1']); + expect(next.nations.b.stats.economy).toBe(45); // the op still took effect on stats + }); + + it('HYBRID_STAT_EFFECT encodes the per-type magnitudes', () => { + expect(HYBRID_STAT_EFFECT.sabotage).toEqual({ stat: 'economy', delta: -3 }); + expect(HYBRID_STAT_EFFECT.assassinate).toEqual({ stat: 'stability', delta: -5 }); + expect(HYBRID_STAT_EFFECT.cyber).toEqual({ stat: 'military', delta: -3 }); + expect(HYBRID_STAT_EFFECT.economic).toEqual({ stat: 'economy', delta: -5 }); + expect(HYBRID_STAT_EFFECT.spy).toBeNull(); + expect(HYBRID_STAT_EFFECT.information).toBeNull(); + expect(HYBRID_STAT_EFFECT.proxy).toBeNull(); + }); +}); + +describe('buildSystemPrompt — hybrid contract (v0.34.0 #57)', () => { + it('documents the hybrid field and its constraints in both modes', () => { + for (const withTools of [false, true]) { + const p = buildSystemPrompt(preset, withTools); + expect(p).toContain('"hybrid": {"ops":'); + expect(p).toContain('spy|sabotage|assassinate|cyber|economic|information|proxy'); + expect(p).toContain('Covert / hybrid ops ("hybrid.ops") are non-kinetic'); + expect(p).toContain('NEVER move borders'); + expect(p).toContain("sours by one step toward hostile"); + expect(p).toContain("a region key hits that region's sole owner"); + } + }); +}); + +describe('buildUserPrompt — hybrid relations (v0.34.0 #57)', () => { + const state = mkTurnState( + { a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) }, + { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) }, + ); + + it('carries the player\'s relations when provided', () => { + const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, { b: 'rival' })); + expect(out.relations).toEqual({ b: 'rival' }); + }); + + it('omits the relations field entirely when the param is undefined', () => { + const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined)); + expect(out).not.toHaveProperty('relations'); + }); +}); + +describe('stub — hybrid (v0.34.0 #57)', () => { + it('the deterministic stub returns empty hybrid ops (it never runs a covert op)', () => { + const payload = { + state: { + turn: 1, + date: '1939-09-01', + playerNationId: 'uk', + nations: { + uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] }, + de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] }, + }, + }, + pending: [{ kind: 'action' as const, text: 'test' }], + }; + const result = deterministicTurn(payload); + expect(result.hybrid).toEqual({ ops: [] }); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index 6b8d41f..ccbccef 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -24,12 +24,15 @@ import { clampStat, deriveNationStats, fnv1a32, + HYBRID_OP_TYPES, isDateLike, isMarkerType, makeEvent, markerId, MAX_MARKERS, nationForPrompt, + type HybridOp, + type HybridOpType, type MarkerInfo, type MarkerSpec, type NationOp, @@ -55,6 +58,12 @@ const VALID_EVENT_TYPES = new Set(EVENT_TYPES); const VALID_RELATIONS = new Set(RELATION_STATUSES); const VALID_OUTCOMES = new Set(['success', 'failure', 'partial', 'neutral']); const VALID_STATS = new Set(['military', 'economy', 'stability', 'population']); +const VALID_HYBRID_TYPES = new Set(HYBRID_OP_TYPES); + +/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */ +export function isHybridOpType(t: unknown): t is HybridOpType { + return typeof t === 'string' && VALID_HYBRID_TYPES.has(t); +} // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- @@ -206,6 +215,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' ],', ' "takeovers": [{"nationRef":"","newRegimeName":""}],', ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', + ' "hybrid": {"ops":[{"type":"","from":"","target":"","detail":"","outcome":""}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -225,6 +235,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Merge: add {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.', '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', '- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["",""],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"","outcome":""}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".', + '- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep every stat value between 0 and 100.', @@ -276,6 +287,14 @@ export function buildUserPrompt( * Omitted entirely when undefined. */ wars?: WarInfo[], + /** + * (v0.34.0, #57) The player's current relations to every other nation + * (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the + * model sees the relation ladder it can nudge with `information` / `proxy` + * hybrid ops and knows the current state before an `exposed` backfire. + * Omitted entirely when undefined. + */ + relations?: Record, ): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; @@ -389,6 +408,7 @@ export function buildUserPrompt( if (noMansLand !== undefined) payload.unowned = noMansLand; if (markers !== undefined) payload.markers = markers; if (wars !== undefined) payload.wars = wars; + if (relations !== undefined) payload.relations = relations; return JSON.stringify(payload, null, 2); } @@ -528,6 +548,7 @@ export function validateTurnResult( nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; @@ -626,6 +647,12 @@ export function validateTurnResult( // Applied directly by applyTurnResult (after takeovers, before markers). result.wars = validateWarOps(o.wars); + // (v0.34.0, #57) Covert hybrid ops (spies, sabotage, assassination, cyber, + // economic, disinformation, proxy). Applied directly by applyTurnResult + // (after wars, before markers) — a deterministic per-type effect or an + // exposed backfire. + result.hybrid = { ops: validateHybridOps(o.hybrid) }; + // (v0.27, #44) Map-marker ops: new placements (validated here) and // removals by stable id. Applied by applyTurnResult after the territory // moves (a new marker's owner is the region's post-turn owner). @@ -806,6 +833,54 @@ export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: Wa return out; } +/** + * (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe + * `{ ops }` shape. Defensive like the rest of the parser — never throws. The + * raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare + * array (be lenient about either). Each op needs a known `type` (one of + * {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at + * 200 chars) and a non-empty `target` (a nation id/name or a region key, capped + * at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the + * default, `success`, is stored as absent to keep the shape minimal — the + * engine treats a missing outcome as success); a non-empty `detail` is capped + * at 300 chars. Resolution of `from` / `target` to concrete nation ids or + * region owners happens at apply time (`applyHybridOp`), not here. + */ +export function validateHybridOps(raw: unknown): HybridOp[] { + if (!Array.isArray(raw)) { + // Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []). + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const inner = (raw as Record).ops; + if (!Array.isArray(inner)) return []; + return validateHybridOpsList(inner); + } + return []; + } + return validateHybridOpsList(raw); +} + +function validateHybridOpsList(raw: unknown[]): HybridOp[] { + const out: HybridOp[] = []; + for (const e of raw) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + if (!isHybridOpType(o.type)) continue; + const from = str(o.from, 200); + const target = str(o.target, 200); + if (!from || !target) continue; + const detail = str(o.detail, 300); + const exposed = o.outcome === 'exposed'; // 'success' / missing → success + out.push({ + type: o.type, + from, + target, + ...(detail ? { detail } : {}), + ...(exposed ? { outcome: 'exposed' as const } : {}), + }); + } + return out; +} + /** * (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}, @@ -879,6 +954,217 @@ export function syncWarRelations(prev: GameState, next: GameState): void { } } +// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations ------------- +// +// The engine's DETERMINISTIC effects for the seven hybrid op types. There is no +// hidden randomness: the LLM declares an intended `outcome` (success / exposed) +// and the engine acts only on that. Each effect is small and documented: +// +// spy informational only — no stat, no relation (pure intel) +// sabotage a small ECONOMY dip on the target +// assassinate a small STABILITY dip on the target (absent the named-leaders +// feature, #61, a leader's removal sours the polity) +// cyber a small MILITARY dip on the target +// economic a small ECONOMY dip on the target +// information a relation nudge toward hostile (cap hostile) — disinformation +// proxy a light relation nudge toward hostile (cap rival) — proxy war +// +// A successful stat op dips the target's `regionStats` (the single named region +// when the target was a region key, otherwise every region the target owns) and +// re-derives the target nation's aggregate stats so the effect persists. A +// successful information/proxy and any exposed op sour the actor's relation +// with the target by one step on the hostile ladder. + +/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */ +export const HYBRID_STAT_EFFECT: Record = { + spy: null, // informational only + sabotage: { stat: 'economy', delta: -3 }, + assassinate: { stat: 'stability', delta: -5 }, + cyber: { stat: 'military', delta: -3 }, + economic: { stat: 'economy', delta: -5 }, + information: null, // relation nudge only + proxy: null, // relation nudge only +}; + +/** + * The hostile-relations ladder, weakest to strongest (the `cap` in + * {@link shiftRelationTowardHostile} is a member of this array). A missing + * relation is treated as `neutral`; `war` is never touched (a war is its own + * entity — a covert op neither fabricates nor ends one). + */ +const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile']; + +/** + * (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b` + * ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied + * symmetrically to both directions, but never past the given `cap`. A missing + * relation starts at `neutral`; a `war` relation is never touched (a covert op + * neither fabricates nor downgrades a war). Used for the `information` / + * `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the + * caller passes a clone — applyTurnResult does). Never throws. + */ +export function shiftRelationTowardHostile( + state: GameState, + a: string, + b: string, + cap: RelationStatus, +): void { + const na = state.nations[a]; + const nb = state.nations[b]; + if (!na || !nb) return; + const capIdx = HOSTILE_ORDER.indexOf(cap); + if (capIdx === -1) return; // unknown cap: no-op (defensive) + const step = (cur: RelationStatus | undefined): RelationStatus | null => { + if (cur === 'war') return null; // never touch a war relation + const s = cur ?? 'neutral'; // a missing relation is neutral + const i = HOSTILE_ORDER.indexOf(s); + if (i === -1) return 'neutral'; // defensive: unknown status + if (i >= capIdx) return s; // at or past the cap: no change + return HOSTILE_ORDER[i + 1]; + }; + const stepA = step(na.relations[b]); + if (stepA !== null) na.relations[b] = stepA; + const stepB = step(nb.relations[a]); + if (stepB !== null) nb.relations[a] = stepB; +} + +/** + * (v0.34.0, #57) The display name of a hybrid op's target for the auto + * backfire event: a nation target shows the nation's name; a region target + * shows the region's display name (or its raw key when no resolver is given). + */ +function hybridTargetDisplayName( + target: { nation: Nation; regionKey?: string }, + regionNameOf?: (key: string) => string | undefined, +): string { + if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey; + return target.nation.name; +} + +/** + * (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region + * key) to the concrete nation it affects. A nation ref resolves via + * {@link resolveNationRef}; a region key resolves to its SOLE owner (a region + * with zero or 2+ owners can't be a valid target). Returns the affected nation + * plus the specific region key (when the target was a region) so a stat op can + * dip just that region. `validKeys` (when supplied) gates the region-key path; + * a ref that is neither a known nation nor a valid region key returns `null`. + * Pure; never throws. + */ +function resolveHybridTarget( + state: GameState, + target: string, + validKeys?: ReadonlySet, +): { nation: Nation; regionKey?: string } | null { + const id = resolveNationRef(state, target); + if (id !== null) { + const n = state.nations[id]; + return n ? { nation: n } : null; + } + const key = (target ?? '').trim(); + if (!key) return null; + if (validKeys && !validKeys.has(key)) return null; // unknown region key + const owners = Object.values(state.nations).filter((n) => n.territory.includes(key)); + if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region + return { nation: owners[0], regionKey: key }; +} + +/** + * (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it + * in place and never throwing. Resolution order: + * 1. `from` must resolve to a nation (id first, then a unique name); an + * unknown / ambiguous actor drops the op. + * 2. `target` must resolve to a concrete nation (a nation ref, or a region + * key's sole owner); an unresolvable target drops the op. + * 3. A self-target (`from` and the target nation are the same) is dropped. + * Then, per type: + * - an `exposed` op BACKFIRES: no intended effect, the actor's relation with + * the target sours by one step (cap hostile), and an auto diplomatic/ + * failure event is emitted (the engine's own narrative, mirroring the + * "repelled" auto-event). + * - a successful stat op (sabotage / assassinate / cyber / economic) dips the + * target's `regionStats` by its per-type delta — the single named region + * when the target was a region key, otherwise every region the target + * owns — then re-derives the target nation's aggregate stats (the same + * 0-region / no-entry guards as the main recompute), so the effect + * persists to the next turn. + * - a successful `information` / `proxy` sours the actor's relation with the + * target by one step (information caps at hostile, proxy at rival). + * - `spy` is informational only (no stat, no relation) — a no-op except for + * whatever the LLM narrates in `events`. + */ +function applyHybridOp( + next: GameState, + op: HybridOp, + geoOf?: (key: string) => { area: number; population: number } | undefined, + validKeys?: ReadonlySet, + regionNameOf?: (key: string) => string | undefined, + actingTurn?: number, +): void { + const turn = actingTurn ?? next.turn; + const from = resolveNationRef(next, op.from); + if (from === null) return; // unknown / ambiguous actor: drop + const target = resolveHybridTarget(next, op.target, validKeys); + if (target === null) return; // unresolvable target: drop + if (from === target.nation.id) return; // self-target: drop + + if (op.outcome === 'exposed') { + // Backfire: no intended effect; the relation sours by one step (cap + // hostile) and an auto diplomatic/failure event is emitted. + shiftRelationTowardHostile(next, from, target.nation.id, 'hostile'); + const actor = next.nations[from]; + next.events.push( + makeEvent( + { + nation: from, + type: 'diplomatic', + title: 'Covert operation exposed', + text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName( + target, + regionNameOf, + )} was exposed; relations sour in response.`, + outcome: 'failure', + }, + turn, + next.date, + ), + ); + return; + } + + const eff = HYBRID_STAT_EFFECT[op.type]; + if (eff) { + // A successful stat op: dip the target's region(s), then re-derive. + if (target.regionKey !== undefined) { + const rs = next.regionStats[target.regionKey]; + if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta); + } else { + for (const key of target.nation.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta); + } + } + // Re-derive the target nation's aggregate stats. The main recompute ran + // earlier this turn, so without this the dip would be washed out next + // turn. The same 0-region / no-entry guards as the main recompute apply. + if (target.nation.territory.length > 0) { + if (target.nation.territory.some((k) => next.regionStats[k])) { + target.nation.stats = deriveNationStats( + target.nation.territory, + next.regionStats, + geoOf, + ); + } + } + return; + } + + // information / proxy: a relation nudge (spy is a pure no-op here). + if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile'); + else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival'); +} + /** * A turn is "meaningful" if it produced at least an event, a stat change, a * no-man's-land release (v0.22 #16 P3: a release is a real world change even @@ -901,6 +1187,9 @@ export function isMeaningful(result: TurnResult): boolean { // (v0.33.0, #56) a war declared or ended is a real world change. result.wars.declare.length > 0 || result.wars.end.length > 0 || + // (v0.34.0, #57) a hybrid op (a successful one or an exposed backfire) is + // a real world change (a stat dip or a soured relation). + result.hybrid.ops.length > 0 || // (v0.27, #44) a marker placed or removed this turn is a real world change. result.markers.add.length > 0 || result.markers.remove.length > 0 @@ -1246,6 +1535,17 @@ export function applyTurnResult( syncWarRelations(state, next); } + // --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations --- + // + // Each op's deterministic per-type effect is applied to the post-wars state + // (a stat dip on the target's regions, or a relation nudge toward hostile). + // An `exposed` op backfires instead. They never move borders, so they run + // after the territory changes and before the markers (a marker's owner is + // recomputed from the final ownership, unaffected by a stat/relation dip). + for (const op of result.hybrid.ops) { + applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn); + } + // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL @@ -1744,6 +2044,11 @@ export async function runTurn(opts: { sides: w.sides, ...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}), })); + // (v0.34.0, #57) the player's current relations to every other nation, so the + // model sees the ladder its `information` / `proxy` hybrid ops nudge (and the + // starting point before an `exposed` backfire). Always emitted ({} when the + // player has no relations yet). + const relations = state.nations[state.playerNationId]?.relations ?? {}; const user = buildUserPrompt( state, items, @@ -1752,6 +2057,7 @@ export async function runTurn(opts: { state.unowned ?? [], markers, wars, + relations, ); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; @@ -1806,6 +2112,7 @@ export async function runTurn(opts: { nationOps: [], takeovers: [], wars: { declare: [], end: [] }, + hybrid: { ops: [] }, markers: { add: [], remove: [] }, }; } diff --git a/server/stub.ts b/server/stub.ts index 7926e6a..2799e4c 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -3,7 +3,7 @@ // speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport. import { createLlmClient, type ChatMessage, type Transport } from './llm'; import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol'; -import type { RoundItem } from '../shared/types'; +import type { RelationStatus, RoundItem } from '../shared/types'; export const ADVISOR_MARKER = '<>'; @@ -45,6 +45,13 @@ export interface StubPayload { * tests against applyTurnResult, not by the stub. */ wars?: Array<{ id: string; sides: string[]; aims?: string[] }>; + /** + * (v0.34.0, #57) The player's current relations to every other nation + * (`nationId -> RelationStatus`), emitted by the engine's user prompt. The + * stub ignores this field — it never runs a hybrid op (see + * deterministicTurn), so the hybrid path is covered by unit tests only. + */ + relations?: Record; } function truncate(s: string, n: number): string { @@ -92,6 +99,10 @@ function truncate(s: string, n: number): string { * (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always * returns `wars: { declare: [], end: [] }`) — the war path is covered by unit * tests only, keeping the e2e assertions untouched. + * + * (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always + * returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit + * tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -160,6 +171,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { nationOps: [], // the stub never proposes nation ops (see doc comment) takeovers: [], // the stub never performs a takeover (see doc comment) wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment) + hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index d4ea295..d7d0c7f 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -305,6 +305,64 @@ export function isMarkerType(t: unknown): t is MarkerType { return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t); } +// --- Hybrid war (v0.34.0, #57): non-kinetic operations --------------------- +// +// The LLM can run covert, non-kinetic operations each turn: spies, sabotage, +// assassination attempts, cyberattacks, economic pressure, disinformation, and +// proxy support. Unlike kinetic conquest (territory), these never move borders +// — they dip a target's stats or sour a relation. The LLM declares an intended +// outcome (`success` or `exposed`); the engine is fully deterministic (no +// hidden randomness) and acts only on that declaration: a successful op applies +// its small, per-type effect, and an "exposed" op backfires (no effect, the +// actor's relation with the target sours by one step, and an auto +// diplomatic/failure event is emitted). + +/** The seven covert operation types a hybrid op can be (v0.34.0, #57). */ +export type HybridOpType = + | 'spy' + | 'sabotage' + | 'assassinate' + | 'cyber' + | 'economic' + | 'information' + | 'proxy'; + +/** All hybrid op types, in canonical order (the LLM contract + validator). */ +export const HYBRID_OP_TYPES: HybridOpType[] = [ + 'spy', + 'sabotage', + 'assassinate', + 'cyber', + 'economic', + 'information', + 'proxy', +]; + +/** + * (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn + * (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique + * name — resolved by the engine's `resolveNationRef`). `target` is EITHER a + * nation (id or unique name) or a specific region key; a region target hits + * that region's sole owner (a region with zero or 2+ owners can't be + * targeted). `detail` is a short narrative (the LLM may also narrate the op in + * `events`). `outcome` is the LLM's *intended* result: `success` (the default) + * applies the op's deterministic effect, while `exposed` backfires it — the + * effect is NOT applied, the actor's relation with the target sours by one + * step, and an auto diplomatic/failure event is emitted. + */ +export interface HybridOp { + /** The operation type (see {@link HybridOpType}). */ + type: HybridOpType; + /** The acting nation's id or current name (resolved by `resolveNationRef`). */ + from: string; + /** The target: a nation's id/name, or a region key (hits that region's owner). */ + target: string; + /** Short narrative detail (optional). */ + detail?: string; + /** The LLM's intended result; `exposed` backfires the op. Defaults to success. */ + outcome?: 'success' | 'exposed'; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -367,6 +425,20 @@ export interface TurnResult { * aims) in the user payload's `wars` field, so it can `end` by id. */ wars: { declare: WarDeclareOp[]; end: WarEndOp[] }; + /** + * (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies, + * sabotage, assassination attempts, cyberattacks, economic pressure, + * disinformation, and proxy support. Unlike `nationOps` (player-confirmed + * proposals) these are applied directly by the turn, like territory / state + * changes: each op's deterministic per-type effect is applied by + * `applyTurnResult` (a small stat dip on the target's regions, or a + * relation nudge toward hostile for `information` / `proxy`); an `exposed` + * op backfires (no effect, the actor's relation with the target sours by + * one step, and an auto diplomatic/failure event is emitted). They never + * move borders — sole ownership is untouched. Required (default + * `{ ops: [] }`). + */ + hybrid: { ops: HybridOp[] }; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small