// The HYBRID-WAR ability (v0.34.0, #57) as a modular LLM-ability (v0.40.0, #67). // Covert, non-kinetic operations: the seven op types each have a DETERMINISTIC // engine effect (no hidden randomness — the LLM declares the intended `outcome` // and the engine acts only on that): // // 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. All bodies are moved // VERBATIM from the pre-refactor engine.ts. import { clampStat, deriveNationStats, HYBRID_OP_TYPES, makeEvent } from '../../shared/protocol'; import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types'; import type { HybridOp, HybridOpType } from '../../shared/protocol'; import { resolveNationRef, str } from './shared'; import type { Ability } from './types'; 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); } /** * (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; } /** 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`. */ export 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'); } export const hybrid: Ability<'hybrid'> = { field: 'hybrid', default: () => ({ ops: [] }), // The low-level validator returns a bare list; the field's shape is the // `{ ops }` object (mirrors the historical `result.hybrid = { ops: ... }`). validate: (raw) => ({ ops: validateHybridOps(raw) }), apply: (state, data, ctx) => { for (const op of data.ops) { applyHybridOp(state, op, ctx.geoOf, ctx.validRegionKeys, ctx.regionNameOf, ctx.actingTurn); } }, schemaLine: () => ' "hybrid": {"ops":[{"type":"","from":"","target":"","detail":"","outcome":""}]},', constraint: () => '- 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".', isMeaningful: (data) => data.ops.length > 0, stub: () => ({ ops: [] }), payload: (ctx) => (ctx.relations !== undefined ? { relations: ctx.relations } : {}), };