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
+28
View File
@@ -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