feat(#57): hybrid war — TurnResult.hybrid ops with exposure backfire (v0.34.0) #68

Merged
kamilkrzywda merged 1 commits from feat/57-hybrid-war into master 2026-10-15 18:45:57 +02:00
Owner

Summary

Implements issue #57 — Espionage & Hybrid War: a new TurnResult.hybrid = { ops: HybridOp[] } lever (required, default { ops: [] }) that adds a deterministic, non-kinetic covert-operations spectrum alongside the kinetic regionChanges lever.

What changed

  • shared/protocol.ts — HybridOpType (7 types), HYBRID_OP_TYPES, HybridOp = { type, from, target, detail?, outcome? }, and the required TurnResult.hybrid field (between wars and markers).
  • server/engine.ts
    • validateHybridOps (lenient: accepts {ops:[...]} or a bare array; keeps outcome: 'exposed' only; field-length caps) wired into validateTurnResult.
    • Deterministic per-type effects (no randomness): sabotage economy −3, economic economy −5, cyber military −3, assassinate stability −5, information/proxy one-step relation nudge (capped at hostile / rival), spy informational only.
    • from/target resolve via the shared resolveNationRef (id first, then case-insensitive unique name); a non-nation target is treated as a region key and hits that region's sole owner (0 or 2+ owners, or a self-target, is dropped — never thrown).
    • A region-targeted dip lands on the single regionStats[regionKey] entry; a nation-targeted 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. Hybrid ops never move territory — the sole-ownership invariant is untouched.
    • Exposed backfire: an op with outcome: 'exposed' does NOT apply its intended effect; instead the from↔target relation moves one step toward hostile (capped; war is never created or escalated) and a diplomatic/failed event is auto-appended to the turn's events.
    • The apply block runs inside applyTurnResult after territory + wars, before markers; isMeaningful counts hybrid ops; the system prompt documents the field (tool and plain modes); buildUserPrompt gained an 8th, backward-compatible relations param (the user payload now carries the player's relations map); the "unclear" fallback returns hybrid: { ops: [] }.
  • server/stub.ts — the stub always returns hybrid: { ops: [] } (deterministic; e2e unaffected).
  • Additive only — no GameState field, no db.ts change.
  • Tests — ~30 new unit tests in server/engine.test.ts (all 48 raw TurnResult literals updated with the required field).

Local verification: typecheck ✅ · lint ✅ · test (386) ✅ · build ✅ — e2e runs in CI (LLM_STUB=1, the stub is hybrid-free).

Fixes #57

## Summary Implements issue #57 — Espionage & Hybrid War: a new `TurnResult.hybrid = { ops: HybridOp[] }` lever (required, default `{ ops: [] }`) that adds a deterministic, non-kinetic covert-operations spectrum alongside the kinetic `regionChanges` lever. ## What changed - **`shared/protocol.ts`** — `HybridOpType` (7 types), `HYBRID_OP_TYPES`, `HybridOp = { type, from, target, detail?, outcome? }`, and the required `TurnResult.hybrid` field (between `wars` and `markers`). - **`server/engine.ts`** - `validateHybridOps` (lenient: accepts `{ops:[...]}` or a bare array; keeps `outcome: 'exposed'` only; field-length caps) wired into `validateTurnResult`. - Deterministic per-type effects (no randomness): `sabotage` economy −3, `economic` economy −5, `cyber` military −3, `assassinate` stability −5, `information`/`proxy` one-step relation nudge (capped at `hostile` / `rival`), `spy` informational only. - `from`/`target` resolve via the shared `resolveNationRef` (id first, then case-insensitive unique name); a non-nation `target` is treated as a region key and hits that region's **sole** owner (0 or 2+ owners, or a self-target, is dropped — never thrown). - A region-targeted dip lands on the single `regionStats[regionKey]` entry; a nation-targeted 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. Hybrid ops **never move territory** — the sole-ownership invariant is untouched. - **Exposed backfire**: an op with `outcome: 'exposed'` does NOT apply its intended effect; instead the from↔target relation moves one step toward `hostile` (capped; `war` is never created or escalated) and a diplomatic/failed event is auto-appended to the turn's `events`. - The apply block runs inside `applyTurnResult` after territory + wars, before markers; `isMeaningful` counts hybrid ops; the system prompt documents the field (tool and plain modes); `buildUserPrompt` gained an 8th, backward-compatible `relations` param (the user payload now carries the player's relations map); the "unclear" fallback returns `hybrid: { ops: [] }`. - **`server/stub.ts`** — the stub always returns `hybrid: { ops: [] }` (deterministic; e2e unaffected). - **Additive only** — no `GameState` field, no `db.ts` change. - **Tests** — ~30 new unit tests in `server/engine.test.ts` (all 48 raw `TurnResult` literals updated with the required field). Local verification: typecheck ✅ · lint ✅ · test (386) ✅ · build ✅ — e2e runs in CI (`LLM_STUB=1`, the stub is hybrid-free). Fixes #57
kamilkrzywda added 1 commit 2026-10-15 18:43:27 +02:00
kamilkrzywda scheduled this pull request to auto merge when all checks succeed 2026-10-15 18:45:54 +02:00
kamilkrzywda merged commit ed91735c8a into master 2026-10-15 18:45:57 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#68