Each per-feature LLM ability is now a self-contained module in server/abilities/ with its own Ability<K> const (schema line, constraint bullet, validator, applier, isMeaningful, stub default, user-payload field). The ordered ABILITIES registry (index.ts) assembles them in the canonical order: takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology. All function bodies are moved VERBATIM from engine.ts. The engine is untouched in this commit — P4 will rewire it to use the registry.
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
// 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<string>(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<string, unknown>).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<string, unknown>;
|
|
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<HybridOpType, { stat: StatKey; delta: number } | null> = {
|
|
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<string>,
|
|
): { 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<string>,
|
|
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":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
|
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 } : {}),
|
|
};
|