feat(#57): hybrid war — TurnResult.hybrid ops (spy/sabotage/assassinate/cyber/economic/information/proxy) with exposure backfire
CI / build + unit + e2e (pull_request) Successful in 2m7s

This commit is contained in:
2026-10-15 16:42:27 +00:00
parent f45c01b8d6
commit 8eeb6df284
7 changed files with 868 additions and 22 deletions
+22
View File
@@ -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).