From 15a1642eec15d5cf258d8a2885876af0a9bae65d Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 16 Oct 2026 07:23:11 +0000 Subject: [PATCH] refactor(#74) P4: wire engine/stub/db through the ABILITIES registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving rewire: every per-ability code path now iterates the ordered ABILITIES registry instead of hand-enumerating the eight fields. - engine.ts: buildSystemPrompt (schema lines + constraint bullets), buildUserPrompt (payload keys at their historical positions), validateTurnResult (per-field validate), applyTurnResult (ordered apply block), isMeaningful, and the runTurn "unclear" fallback all drive off ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted; the names the engine and its tests import are re-exported from ./abilities. - abilities/types.ts: Ability.stub is a zero-arg method (the stub has no GameState/AbilityCtx to pass; it needs none) — all eight implementations were already arg-less. - stub.ts: deterministicTurn fills the eight ability fields from the registry (each contributes its inert default), between the core fields and markers — JSON output stays byte-identical (verified against origin/master). - db.ts: the legacy-row migration for ability-owned GameState fields (wars/trades/embargoes/industry) iterates the registry's stateFields, only-if-undefined, instead of a hardcoded field list. No client changes, no new deps, no shared/ runtime deps. typecheck + lint + 511 unit tests green; stub JSON byte-identical to origin/master. Fixes #74 --- server/abilities/types.ts | 5 +- server/db.ts | 26 +- server/engine.ts | 1672 ++----------------------------------- server/stub.ts | 38 +- 4 files changed, 129 insertions(+), 1612 deletions(-) diff --git a/server/abilities/types.ts b/server/abilities/types.ts index 667b84d..f3f671c 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -158,9 +158,10 @@ export interface Ability { /** * The deterministic stub's value for this field. Every ability returns * `default()` (the stub never exercises an ability, so e2e is unaffected) — - * kept as a method so the stub stays a uniform registry loop. + * kept as a zero-arg method so the stub stays a uniform registry loop (the + * stub has no real `GameState` / `AbilityCtx` to hand one; it needs none). */ - stub(state: GameState, ctx: AbilityCtx): TurnResult[K]; + stub(): TurnResult[K]; /** * The keys this ability contributes to the user prompt payload, each included diff --git a/server/db.ts b/server/db.ts index 1e1d086..baeef21 100644 --- a/server/db.ts +++ b/server/db.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { migrateRegionStats } from '../shared/protocol'; import type { GameState } from '../shared/types'; +import { ABILITIES } from './abilities'; export interface Db { save(state: GameState): void; @@ -49,17 +50,20 @@ export function openDb(filePath: string): Db { if (state.pendingNationOps === undefined) state.pendingNationOps = []; // (v0.27, #44) Same for map markers: a legacy game has none placed. if (state.markers === undefined) state.markers = []; - // (v0.33.0, #56) Same for named wars: a legacy game has none declared - // (any pre-v0.33 `war` relation stays a bare/implicit relation — see - // syncWarRelations, which never force-creates a War entity). - if (state.wars === undefined) state.wars = []; - // (v0.35.0, #58) Same for the trade & embargo economy layer: a - // legacy game has no active trades or embargoes. - if (state.trades === undefined) state.trades = []; - if (state.embargoes === undefined) state.embargoes = []; - // (v0.36, #59) Same for the industry layer: a legacy game has no - // active industry projects. - if (state.industry === undefined) state.industry = []; + // (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every + // ability-owned GameState field (wars / trades / embargoes / industry): + // a legacy game has none of them. The ABILITIES registry is the single + // source of truth for which fields the LLM abilities own — each is a + // list, so the default is `[]`, applied only-if-undefined so a saved + // value is never wiped. (Any pre-v0.33 `war` relation stays a + // bare/implicit relation — see syncWarRelations, which never + // force-creates a War entity.) + for (const a of ABILITIES) { + const fields = a.stateFields; + if (!fields) continue; + const row = state as unknown as Record; + for (const f of fields) if (row[f] === undefined) row[f] = []; + } // (v0.28, #46) Rows saved before per-region stats existed are migrated // ONCE at load time (and never re-migrated afterwards — the field is // then present): each owned region is seeded with a copy of its owner's diff --git a/server/engine.ts b/server/engine.ts index 9c5251b..88db771 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -9,7 +9,6 @@ import type { EventType, GameState, IndustryProject, - IndustryType, MapMarker, Nation, Preset, @@ -19,9 +18,8 @@ import type { RoundItem, StatKey, TradeAgreement, - War, } from '../shared/types'; -import { EVENT_TYPES, INDUSTRY_TYPES, RELATION_STATUSES } from '../shared/types'; +import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { addDays, canConquer, @@ -29,38 +27,19 @@ import { deriveNationResources, deriveNationStats, fnv1a32, - HYBRID_OP_TYPES, - IDEOLOGY_STABILITY_EFFECT, isDateLike, isMarkerType, isNuclearCapable, - LEADER_STABILITY_EFFECT, makeEvent, markerId, MAX_MARKERS, - NUCLEAR_COLLAPSE, - NUCLEAR_RETALIATION, nationForPrompt, - type EconomyEmbargoOp, - type EconomyOps, - type EconomyTradeOp, - type HybridOp, - type HybridOpType, - type IdeologyChange, - type IndustryOps, - type LeaderCause, - type LeaderChange, type MarkerInfo, type MarkerSpec, type NationOp, - type NuclearOps, - type NuclearStatKey, type RegionStatChange, - type Takeover, type TurnEventSpec, type TurnResult, - type WarDeclareOp, - type WarEndOp, type WarInfo, } from '../shared/protocol'; import { @@ -79,6 +58,7 @@ import { resolveNationRef, resolveRegionKey, } from './abilities/shared'; +import { ABILITIES } from './abilities'; // Re-exported so the existing callers (server/index.ts, the unit tests) keep // importing these shared primitives from the engine unchanged. export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey }; @@ -87,19 +67,26 @@ const VALID_EVENT_TYPES = new Set(EVENT_TYPES); const VALID_RELATIONS = new Set(RELATION_STATUSES); const VALID_OUTCOMES = new Set(['success', 'failure', 'partial', 'neutral']); const VALID_STATS = new Set(['military', 'economy', 'stability', 'population']); -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); -} - -const VALID_INDUSTRY_TYPES = new Set(INDUSTRY_TYPES); - -/** True when `t` is one of the four known industry project types (v0.36, #59). */ -export function isIndustryType(t: unknown): t is IndustryType { - return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t); -} +// (v0.40.0, #67) Re-exported from the ability modules so the existing callers +// (server/index.ts, the unit tests) keep importing these names from the engine. +export { + isHybridOpType, + isIndustryType, + activeWarPairs, + atWar, + syncWarRelations, + HYBRID_STAT_EFFECT, + shiftRelationTowardHostile, + validateEconomyOps, + validateHybridOps, + validateIdeologyOps, + validateIndustryOps, + validateLeaderOps, + validateNuclearOps, + validateTakeovers, + validateWarOps, + applyIndustryEffects, +} from './abilities'; // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- @@ -251,14 +238,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}]}', ' {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""}', ' ],', - ' "takeovers": [{"nationRef":"","newRegimeName":""}],', - ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', - ' "hybrid": {"ops":[{"type":"","from":"","target":"","detail":"","outcome":""}]},', - ' "economy": {"trade":[{"a":"","b":"","lift":}],"embargo":[{"from":"","target":"","lift":}]},', - ' "industry": {"projects":[{"regionKey":"","type":""}]},', - ' "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]},', - ' "leaders": {"changes":[{"target":"","name":"","cause":""}]},', - ' "ideology": {"changes":[{"target":"","to":""}]},', + ...ABILITIES.map((a) => a.schemaLine()), ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -276,14 +256,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Rename: add {"kind":"rename","nationRef":"","to":""} for a revolution, regime change or reunification that changes a nation\'s name.', '- Split: add {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.', '- Merge: add {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.', - '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', - '- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["",""],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"","outcome":""}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".', - '- 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".', - '- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.', - '- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"","type":""} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".', - '- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".', - '- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).', - '- Ideology ("ideology") is each nation\'s form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation\'s ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message\'s "nations" field lists each nation\'s current "ideology" (omitted when a nation has none).', + ...ABILITIES.map((a) => a.constraint()), '- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep every stat value between 0 and 100.', @@ -450,24 +423,9 @@ export function buildUserPrompt( // seizure) — the model can act on them this turn. One line per matching // nation, emitted only when there is at least one; the rest of the payload // is left untouched. A soft nudge only — there is no hard gate behind it. - let takeoverRisks: string[] | undefined; - if (markers !== undefined && markers.length > 0) { - const risks = new Set(); - for (const m of markers) { - if (m.type !== 'gathering') continue; - const ownerId = m.owner; - if (!ownerId) continue; - const n = state.nations[ownerId]; - if (n && n.stats.stability < 30) risks.add(n.id); - } - if (risks.size > 0) { - takeoverRisks = [...risks].map((id) => { - const n = state.nations[id]; - return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`; - }); - } - } - + // (v0.40.0, #67) The ability-specific payload fields (takeoverRisks, wars, + // relations, trades, embargoes, resources, industry) are now computed by + // each Ability's payload() method and merged at their exact positions below. const payload: Record = { state: { turn: state.turn, @@ -497,15 +455,27 @@ export function buildUserPrompt( if (regionReference) payload.regions = regionReference; if (frontier !== undefined) payload.frontier = frontier; if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary; - if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks; + // (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's + // payload() at its exact position (the key order is load-bearing for the + // golden test). + const upCtx = { + state, + markers, + wars, + relations, + trades, + embargoes, + nationResources, + industryProjects, + }; + const upCtxAny = upCtx as unknown as import('./abilities').UserPromptCtx; + Object.assign(payload, ABILITIES[0].payload(upCtxAny)); // takeovers → takeoverRisks if (noMansLand !== undefined) payload.unowned = noMansLand; if (markers !== undefined) payload.markers = markers; - if (wars !== undefined) payload.wars = wars; - if (relations !== undefined) payload.relations = relations; - if (trades !== undefined) payload.trades = trades; - if (embargoes !== undefined) payload.embargoes = embargoes; - if (nationResources !== undefined) payload.resources = nationResources; - if (industryProjects !== undefined) payload.industry = industryProjects; + Object.assign(payload, ABILITIES[1].payload(upCtxAny)); // wars → wars + Object.assign(payload, ABILITIES[2].payload(upCtxAny)); // hybrid → relations + Object.assign(payload, ABILITIES[3].payload(upCtxAny)); // economy → trades, embargoes + Object.assign(payload, ABILITIES[4].payload(upCtxAny)); // industry → resources, industry return JSON.stringify(payload, null, 2); } @@ -741,56 +711,12 @@ export function validateTurnResult( // list for the player to confirm/reject. result.nationOps = validateNationOps(o.nationOps); - // (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly - // by applyTurnResult (automatic, not player-confirmed). - result.takeovers = validateTakeovers(o.takeovers); - - // (v0.33.0, #56) War declare/end ops: creating and ending named Wars. - // Applied directly by applyTurnResult (after takeovers, before markers). - result.wars = validateWarOps(o.wars); - - // (v0.34.0, #57) Covert hybrid ops (spies, sabotage, assassination, cyber, - // economic, disinformation, proxy). Applied directly by applyTurnResult - // (after wars, before markers) — a deterministic per-type effect or an - // exposed backfire. - result.hybrid = { ops: validateHybridOps(o.hybrid) }; - - // (v0.35.0, #58) Trade & embargo ops: a no-`lift` op declares / imposes, - // `lift: true` ends / lifts. Refs stay raw here and are resolved at apply - // time against the real state (like wars / hybrid ops); unknown / - // ambiguous / self-pairing ops are dropped there, never thrown. - result.economy = validateEconomyOps(o.economy); - - // (v0.36, #59) Industry projects: the LLM names a region key + one of the - // four project types. Each project becomes an active entry in - // `GameState.industry` (a per-region SET). The region key is validated as a - // non-empty string here; whether the key is real and owned is checked at - // apply time (a project on a region with no sole owner yields no boost but - // is kept). Refs stay raw and the effect is deterministic at apply time. - result.industry = validateIndustryOps(o.industry); - - // (v0.37, #60) Nuclear / WMD ops: `arm` grants an arsenal to the named - // nations and `strikes` are nuclear strikes fired this turn. Refs stay raw - // here (resolved against the real state at apply time, like wars / hybrid - // ops); an unresolvable ref, a non-capable striker, or an invalid region is - // dropped at apply time, never thrown. Applied directly by applyTurnResult - // (after industry, before markers). - result.nuclear = validateNuclearOps(o.nuclear); - - // (v0.38, #61) Leader changes: assassinations / deaths / appointments of - // named leaders. Refs stay raw here (resolved against the real state at - // apply time, like wars / hybrid / nuclear ops); an unresolvable target or a - // leaderless assassination/death is dropped at apply time, never thrown. - // Applied by applyTurnResult after nuclear, before markers. - result.leaders = { changes: validateLeaderOps(o.leaders) }; - - // (v0.39, #62) Ideology (form of government) changes: each names a target - // nation (id or name) and a new government type. Refs stay raw here - // (resolved against the real state at apply time, like wars / hybrid / - // nuclear / leader ops); an unresolvable target or an empty label is - // dropped at apply time, never thrown. Applied by applyTurnResult after - // leaders, before markers. - result.ideology = { changes: validateIdeologyOps(o.ideology) }; + // The 8 per-feature LLM abilities (v0.40.0, #67): each one's validate call + // is owned by its Ability module in the ABILITIES registry. The field's raw + // value is passed through a.validate() which returns the validated field shape. + for (const a of ABILITIES) { + (result as unknown as Record)[a.field] = a.validate((o as Record)[a.field]); + } // (v0.27, #44) Map-marker ops: new placements (validated here) and // removals by stable id. Applied by applyTurnResult after the territory @@ -908,1255 +834,9 @@ export function validateRegionChanges( * to a concrete nation id happens at apply time (`resolveNationRef`), not * here. Entries with either field missing or whitespace-only are dropped. */ -export function validateTakeovers(raw: unknown): Takeover[] { - if (!Array.isArray(raw)) return []; - const out: Takeover[] = []; - for (const e of raw) { - if (!e || typeof e !== 'object') continue; - const o = e as Record; - const nationRef = str(o.nationRef, 200); - const newRegimeName = str(o.newRegimeName, 200); - if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName }); - } - return out; -} + // (v0.40.0, #67) The 8 per-feature ability validators, appliers, and helpers + // have been moved to server/abilities/ — re-exported below for the tests. -/** - * (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe - * `{ declare, end }` shape. Defensive like the rest of the parser — never - * throws. `declare`: each entry must be an object whose `sides` is an array - * of non-empty strings (trimmed, capped at 200 chars each, deduped) that - * yields at least TWO distinct sides — anything else (a one-sided or - * empty-sided "war") is dropped; `aims`, when an array, is kept only as - * non-empty strings (capped at 200 chars, at most 5). `end`: each entry must - * be an object with a non-empty `id` (capped at 200 chars) and an `outcome` - * of exactly `armistice` or `capitulation` (anything else is dropped); - * `terms`, when a non-empty string, is capped at 400 chars. A non-object - * input yields `{ declare: [], end: [] }`. - */ -export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } { - const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] }; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; - const o = raw as Record; - if (Array.isArray(o.declare)) { - for (const e of o.declare) { - if (!e || typeof e !== 'object') continue; - const d = e as Record; - const sides = (Array.isArray(d.sides) ? d.sides : []) - .map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : '')) - .filter((s) => s !== '') - // dedupe case-insensitively? No — sides are nation ids: keep exact - // strings, drop exact duplicates only (resolution is later). - .filter((s, i, arr) => arr.indexOf(s) === i); - if (sides.length < 2) continue; // a war needs at least two belligerents - const aims = Array.isArray(d.aims) - ? (d.aims as unknown[]) - .map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : '')) - .filter((a) => a !== '') - .slice(0, 5) - : undefined; - out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) }); - } - } - if (Array.isArray(o.end)) { - for (const e of o.end) { - if (!e || typeof e !== 'object') continue; - const w = e as Record; - const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : ''; - if (!id) continue; - if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue; - const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : ''; - out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) }); - } - } - return out; -} - -/** - * (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; -} - -/** - * (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe - * {@link EconomyOps}. Defensive like the rest of the parser — never throws. - * `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique - * name, capped at 200 chars) and a boolean `lift` (default false = declare); - * ops with a missing ref are dropped — ref resolution and the - * a≠b / from≠target checks happen at apply time against the real state - * (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op - * needs non-empty `from` and `target` refs and a boolean `lift` (default - * false = impose). A missing / malformed top-level object yields - * `{ trade: [], embargo: [] }`. - */ -export function validateEconomyOps(raw: unknown): EconomyOps { - const out: EconomyOps = { trade: [], embargo: [] }; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; - const o = raw as Record; - - if (Array.isArray(o.trade)) { - for (const e of o.trade) { - if (!e || typeof e !== 'object') continue; - const t = e as Record; - const a = str(t.a, 200); - const b = str(t.b, 200); - if (!a || !b) continue; - const op: EconomyTradeOp = { a, b }; - if (t.lift === true) op.lift = true; - out.trade.push(op); - } - } - - if (Array.isArray(o.embargo)) { - for (const e of o.embargo) { - if (!e || typeof e !== 'object') continue; - const t = e as Record; - const from = str(t.from, 200); - const target = str(t.target, 200); - if (!from || !target) continue; - const op: EconomyEmbargoOp = { from, target }; - if (t.lift === true) op.lift = true; - out.embargo.push(op); - } - } - - return out; -} - -/** - * (v0.36, #59) Validate the raw `industry` object of a turn into a safe - * {@link IndustryOps}. Defensive like the rest of the parser — never throws. - * `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a - * known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a - * missing/malformed field are dropped. The region key is kept raw here (like - * the hybrid / economy refs) — whether it is a *valid* region key, and whether - * its region ends the turn with a sole owner, is checked at apply time against - * the manifest and the final ownership (a project on a region with no sole - * owner yields no boost but is kept). A missing / malformed top-level object - * yields `{ projects: [] }`. - */ -export function validateIndustryOps(raw: unknown): IndustryOps { - const out: IndustryOps = { projects: [] }; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; - const o = raw as Record; - if (Array.isArray(o.projects)) { - for (const e of o.projects) { - if (!e || typeof e !== 'object') continue; - const p = e as Record; - const regionKey = str(p.regionKey, 120); - if (!regionKey || !isIndustryType(p.type)) continue; - out.projects.push({ regionKey, type: p.type }); - } - } - return out; -} - -/** - * (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe - * {@link NuclearOps}. Defensive like the rest of the parser — never throws. - * `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is - * resolved (id or name) at APPLY time, so an unknown ref is dropped there, not - * here. `strikes`: each needs a non-empty `from` (the striking nation ref) and - * a non-empty `target` (a nation ref OR a region key), each capped at 120 - * chars; a self-target (`from` and `target` the same ref) is dropped here. A - * missing / malformed top-level object yields `{ arm: [], strikes: [] }`. - */ -export function validateNuclearOps(raw: unknown): NuclearOps { - const out: NuclearOps = { arm: [], strikes: [] }; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; - const o = raw as Record; - if (Array.isArray(o.arm)) { - for (const e of o.arm) { - const s = typeof e === 'string' ? e.trim() : ''; - if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120)); - } - } - if (Array.isArray(o.strikes)) { - for (const e of o.strikes) { - if (!e || typeof e !== 'object') continue; - const s = e as Record; - const from = str(s.from, 120); - const target = str(s.target, 120); - if (!from || !target) continue; - if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop - out.strikes.push({ from, target }); - } - } - return out; -} - -/** - * (v0.38, #61) Validate the raw `leaders` object of a turn into a safe - * `LeaderChange[]`. Defensive like the rest of the parser — never throws. - * Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change - * needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty - * `name` (the new leader's name, capped at 120 chars), and a `cause` that is - * one of the three known literals (assassination / death / appointed). Refs - * stay raw here (resolved against the real state at apply time, like the wars - * / hybrid / nuclear ops); a leaderless assassination/death is dropped at - * apply time, never thrown. - */ -export function validateLeaderOps(raw: unknown): LeaderChange[] { - const out: LeaderChange[] = []; - const list = Array.isArray(raw) - ? raw - : raw && typeof raw === 'object' - ? (raw as Record).changes - : undefined; - if (!Array.isArray(list)) return out; - for (const e of list) { - if (!e || typeof e !== 'object') continue; - const s = e as Record; - const target = str(s.target, 120); - const name = str(s.name, 120); - if (!target || !name) continue; - const cause = typeof s.cause === 'string' ? s.cause : ''; - if (cause === 'assassination' || cause === 'death' || cause === 'appointed') { - out.push({ target, name, cause: cause as LeaderCause }); - } - } - return out; -} - -/** - * (v0.39, #62) Validate the raw `ideology` object of a turn into a safe - * `IdeologyChange[]`. Defensive like the rest of the parser — never throws. - * Accepts a `{ changes: [...] }` object or a bare array (lenient). Each - * change needs a non-empty `target` (a nation ref, capped at 120 chars) and - * a non-empty, non-whitespace `to` (the new form of government, capped at - * 120 chars). Refs stay raw here (resolved against the real state at apply - * time, like the wars / hybrid / nuclear / leader ops); an unresolvable - * target is dropped at apply time, never thrown. - */ -export function validateIdeologyOps(raw: unknown): IdeologyChange[] { - const out: IdeologyChange[] = []; - const list = Array.isArray(raw) - ? raw - : raw && typeof raw === 'object' - ? (raw as Record).changes - : undefined; - if (!Array.isArray(list)) return out; - for (const e of list) { - if (!e || typeof e !== 'object') continue; - const s = e as Record; - const target = str(s.target, 120); - const to = str(s.to, 120); - if (!target || !to) continue; - out.push({ target, to }); - } - return out; -} - -/** - * (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE - * wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c}, - * {b,c} (any two distinct belligerents of the same active war are at war — - * see the War type's semantics). Pure; exported for the unit tests. - */ -export function activeWarPairs(wars: War[]): Set { - const pairs = new Set(); - const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); - for (const w of wars) { - if (w.status !== 'active') continue; - for (let i = 0; i < w.sides.length; i++) { - for (let j = i + 1; j < w.sides.length; j++) { - pairs.add(key(w.sides[i], w.sides[j])); - } - } - } - return pairs; -} - -/** True when nations `a` and `b` are both belligerents of ≥1 active war. */ -export function atWar(wars: War[], a: string, b: string): boolean { - const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; - return activeWarPairs(wars).has(key); -} - -/** - * (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars, - * MUTATING the (cloned) `next` state in place (the caller passes a clone — - * applyTurnResult does) and never touching `prev`. For every unordered pair - * of distinct existing nations: - * - * - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions - * of the pair's relation are set to `war` (the engine owns this relation - * while the war is active — a manual `relations` entry to a lesser status - * on a warring pair is overridden, so the two can never "make peace" - * without the war being ended); - * - the pair is NOT at war in `next` but WAS at war in `prev` (one of the - * wars that tied them ended this turn) AND the current relation is - * `war` → both directions become `hostile` (a war is the strongest - * hostility — when it stops, the nations stay hostile, not suddenly - * neutral); - * - otherwise (not at war now, and the relation is not `war`, or the pair - * was never tied by an active war) → untouched: the LLM owns - * ally/neutral/rival/hostile freely, and a **bare** `war` relation that - * no active war ever covered (a preset leftover) stays as-is — an - * implicit, unnamed war, never force-created into a War entity and never - * clobbered by the sync. - * - * Never throws. - */ -export function syncWarRelations(prev: GameState, next: GameState): void { - const prevAtWar = activeWarPairs(prev.wars ?? []); - const nextAtWar = activeWarPairs(next.wars ?? []); - const ids = Object.keys(next.nations); - for (let i = 0; i < ids.length; i++) { - for (let j = i + 1; j < ids.length; j++) { - const a = ids[i]; - const b = ids[j]; - const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; - const na = next.nations[a]; - const nb = next.nations[b]; - if (nextAtWar.has(key)) { - na.relations[b] = 'war'; - nb.relations[a] = 'war'; - } else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) { - na.relations[b] = 'hostile'; - nb.relations[a] = 'hostile'; - } - } - } -} - -// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations ------------- -// -// The engine's DETERMINISTIC effects for the seven hybrid op types. There is no -// hidden randomness: the LLM declares an intended `outcome` (success / exposed) -// and the engine acts only on that. Each effect is small and documented: -// -// 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. - -/** 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`. - */ -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'); -} - -// --- Named leaders (v0.38, #61) ----------------------------------------------- - -/** - * (v0.38, #61) Apply a single leader change to `next` (in place). The target - * nation is resolved by the shared resolver (id first, then a case-insensitive - * unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE - * then applies: an `assassination` or `death` requires the target to already - * have a leader (a leaderless target is dropped); an `appointed` change is - * always valid (it creates the first leader). On a valid change the target's - * `leader` is set to the new name and a DETERMINISTIC stability dip - * (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability` - * (clamped 0–100) and mirrored to every region the nation owns - * (`regionStats`); the nation's derived stats are re-derived afterwards (the - * same 0-region / no-entry guards as the hybrid/economy/industry effects, so a - * 0-region nation keeps its directly-mutated value). Finally a narration event - * is appended: a dramatic `military` event for an assassination, a `political` - * succession event for a death or an appointment. The replacement never moves - * territory (sole ownership is untouched). - */ -function applyLeaderChange( - next: GameState, - op: LeaderChange, - geoOf?: (key: string) => { area: number; population: number } | undefined, - actingTurn?: number, -): void { - const turn = actingTurn ?? next.turn; - const id = resolveNationRef(next, op.target); - if (id === null) return; // unknown / ambiguous target: drop, never throw - const n = next.nations[id]; - - // GATE: assassination / death require an existing leader; an appointment is - // always valid (it creates the first leader). - if (op.cause !== 'appointed' && !n.leader) return; - - const prevName = n.leader?.name; - n.leader = { name: op.name }; - - const delta = LEADER_STABILITY_EFFECT[op.cause]; - if (delta !== 0) { - n.stats.stability = clampStat(n.stats.stability + delta); - for (const key of n.territory) { - const rs = next.regionStats[key]; - if (!rs) continue; - rs.stability = clampStat(rs.stability + delta); - } - // Re-derive the nation's aggregate stats (the main recompute ran earlier - // this turn, so without this the dip would be washed out next turn). - if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { - n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); - } - } - - const title = - op.cause === 'assassination' - ? `${prevName ?? 'The leader'} of ${n.name} is assassinated` - : op.cause === 'death' - ? `${prevName ?? 'The leader'} of ${n.name} dies` - : `${n.name} appoints ${op.name}`; - const text = - op.cause === 'assassination' - ? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.` - : op.cause === 'death' - ? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.` - : `${n.name} has appointed ${op.name} as its new leader.`; - next.events.push( - makeEvent( - { - nation: n.id, - type: op.cause === 'assassination' ? 'military' : 'political', - title, - text, - outcome: 'success', - }, - turn, - next.date, - ), - ); -} - -// --- Ideology / form of government (v0.39, #62) ------------------------------ - -/** - * (v0.39, #62) Apply a single ideology change to `next` (in place). The - * target nation is resolved by the shared resolver (id first, then a - * case-insensitive unique name; an unknown or ambiguous ref is dropped, - * never thrown). On a valid change the target's `ideology` is set to the new - * government type (re-setting the same value is idempotent for the field but - * still narrated) and a DETERMINISTIC stability dip - * (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability` - * (clamped 0–100) and mirrored to every region the nation owns - * (`regionStats`); the nation's derived stats are re-derived afterwards (the - * same 0-region / no-entry guards as the hybrid/economy/industry/leader - * effects, so a 0-region nation keeps its directly-mutated value). Finally a - * narration event is appended: a `political` event for the government shift. - * The change never moves territory (sole ownership is untouched). - */ -function applyIdeologyChange( - next: GameState, - op: IdeologyChange, - geoOf?: (key: string) => { area: number; population: number } | undefined, - actingTurn?: number, -): void { - const turn = actingTurn ?? next.turn; - const id = resolveNationRef(next, op.target); - if (id === null) return; // unknown / ambiguous target: drop, never throw - const n = next.nations[id]; - - n.ideology = op.to; - - const delta = IDEOLOGY_STABILITY_EFFECT; - if (delta !== 0) { - n.stats.stability = clampStat(n.stats.stability + delta); - for (const key of n.territory) { - const rs = next.regionStats[key]; - if (!rs) continue; - rs.stability = clampStat(rs.stability + delta); - } - // Re-derive the nation's aggregate stats (the main recompute ran earlier - // this turn, so without this the dip would be washed out next turn). - if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { - n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); - } - } - - next.events.push( - makeEvent( - { - nation: n.id, - type: 'political', - title: `${n.name}'s government shifted to ${op.to}`, - text: `${n.name} has changed its form of government to ${op.to}.`, - outcome: 'success', - }, - turn, - next.date, - ), - ); -} - -// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------ - -/** - * (v0.35.0, #58) The per-turn economic effect of the economy layer, applied - * EVERY committed turn (not just to this turn's declares): each active - * bilateral trade grants both parties a small, deterministic economy gain; - * each active embargo applies a small, deterministic economy dip to the - * target only. Clamped to 0–100. The effect lands on `stats.economy` AND on - * every region the nation owns (`regionStats`), mirroring the hybrid-op - * effect, and each affected nation's derived stats are re-derived afterwards - * (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there - * are no active trades or embargoes. Never throws. - */ -const TRADE_ECONOMY_BONUS = 1; -const EMBARGO_ECONOMY_PENALTY = 1; - -export function applyEconomyEffects( - next: GameState, - geoOf?: (key: string) => { area: number; population: number } | undefined, -): void { - if (next.trades.length === 0 && next.embargoes.length === 0) return; - - // nation id -> signed economy delta (trades add for both parties, embargoes - // subtract for the target; deltas accumulate for nations hit by several). - const delta = new Map(); - for (const t of next.trades) { - for (const id of [t.a, t.b]) { - delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS); - } - } - for (const e of next.embargoes) { - delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY); - } - - const affected = new Set(); - for (const [id, d] of delta) { - const n = next.nations[id]; - if (!n) continue; // a nation removed this turn (a merge): the delta is dropped - const cur = clampStat(n.stats.economy + d); - if (cur === n.stats.economy) continue; // no change (already at the clamp) - n.stats.economy = cur; - for (const key of n.territory) { - const rs = next.regionStats[key]; - if (rs) rs.economy = clampStat(rs.economy + d); - } - affected.add(id); - } - - if (affected.size > 0) { - for (const id of affected) { - const n = next.nations[id]; - if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { - n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); - } - } - } -} - -/** - * (v0.35.0, #58) Apply the turn's economy ops to the active lists on - * `next`, RESOLVING the (possibly name-based) refs against the current - * state: a no-`lift` trade op declares the bilateral agreement (idempotent — - * an already-active pair is not duplicated; the pair is stored sorted, since - * a trade is symmetric); `lift: true` ends it (a no-op when absent). A no- - * `lift` embargo op imposes the unilateral embargo (idempotent — the ordered - * from→target pair is kept as-is); `lift: true` lifts it. An op whose refs - * don't resolve to distinct EXISTING nations is dropped (defensive — never - * throws). Mutates `next` in place. - */ -function applyEconomyOps(next: GameState, ops: EconomyOps): void { - const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); - const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`; - - for (const op of ops.trade) { - const a = resolveNationRef(next, op.a); - const b = resolveNationRef(next, op.b); - if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop - const key = tradeKey(a, b); - if (op.lift === true) { - next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key); - } else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) { - // stored sorted (a < b): a trade is symmetric, so the agreement has a - // canonical shape no matter which way the LLM named the pair. - next.trades.push(a < b ? { a, b } : { a: b, b: a }); - } - } - - for (const op of ops.embargo) { - const from = resolveNationRef(next, op.from); - const target = resolveNationRef(next, op.target); - if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop - const key = embargoKey(from, target); - if (op.lift === true) { - next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key); - } else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) { - next.embargoes.push({ from, target }); - } - } -} - -/** - * (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any - * active trade or embargo whose two nations are on OPPOSITE sides of an - * active war (v0.33, #56) is removed — a war severs the economic link, so - * the suspended agreement stops producing its per-turn effect from now on. - * A trade/embargo between two non-belligerents, or between two nations on - * the SAME side, is untouched. Mutates `next` in place; idempotent. Must be - * called AFTER the war declare/end ops of the turn (so it sees the final - * `next.wars`). Never throws. - */ -function suspendEconomyOnWar(next: GameState): void { - if (next.trades.length === 0 && next.embargoes.length === 0) return; - const warring = activeWarPairs(next.wars); - const atWarNow = (x: string, y: string): boolean => { - if (x === y) return false; - const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`; - return warring.has(key); - }; - next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b)); - next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target)); -} - -// --- Industry projects: the per-region economy-boost lever (v0.36, #59) ------ - -/** - * (v0.36, #59) The BASE per-turn economy boost one industry project grants its - * region's sole owner. A project's actual boost is this base plus one extra - * point when the region's `industry` resource value is at least 0.5 (a region - * with a strong industry profile yields a bigger return). The base value is - * deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} / - * {@link EMBARGO_ECONOMY_PENALTY}. - */ -const INDUSTRY_ECONOMY_BONUS = 1; - -/** - * (v0.36, #59) The per-turn economy boost for ONE industry project sitting in - * `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the - * region's `industry` resource value (from `resourceOf`) is at least 0.5. - * Without the resource manifest (or for a region with no profile) the boost is - * the base value. Pure + deterministic. - */ -function industryBoost( - regionKey: string, - resourceOf?: (key: string) => RegionResources | undefined, -): number { - const r = resourceOf?.(regionKey); - return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0); -} - -/** - * (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a - * PER-REGION SET: each op names a region key + a project type. A region key - * that is not a valid manifest key (`validKeys`, when supplied) is dropped; a - * region that already hosts a project is untouched (the first declared type - * wins — re-declaring on the same region is a no-op, the type is never - * replaced). Otherwise the project is appended. Mutates `next` in place. - * Nothing here throws. - */ -function applyIndustryOps( - next: GameState, - ops: IndustryOps, - validKeys?: ReadonlySet, -): void { - if (ops.projects.length === 0) return; - const has = (key: string) => next.industry.some((p) => p.regionKey === key); - for (const p of ops.projects) { - if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop - if (has(p.regionKey)) continue; // a region hosts at most one project - next.industry.push({ regionKey: p.regionKey, type: p.type }); - } -} - -/** - * (v0.36, #59) The per-turn economic effect of the industry layer, applied - * EVERY committed turn (not just to this turn's adds): each active project - * grants its region's SOLE owner a small, deterministic economy boost (see - * {@link industryBoost}) — clamped to 0–100, mirrored to every region the - * owner holds (`regionStats`), and each affected nation's derived stats are - * re-derived afterwards (the same 0-region / no-entry guards as - * `applyEconomyEffects`). A project whose region ends the turn with NO owner, - * or 2+ owners (released to no man's land / corrupt), yields no boost but is - * kept in the list. Sole ownership is untouched. No-op when there are no - * active projects. Never throws. - */ -export function applyIndustryEffects( - next: GameState, - resourceOf?: (key: string) => RegionResources | undefined, - geoOf?: (key: string) => { area: number; population: number } | undefined, -): void { - if (next.industry.length === 0) return; - - // Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to - // nothing and yields no boost). - const ownerOf = new Map(); - { - const owners = new Map(); - for (const n of Object.values(next.nations)) { - for (const key of n.territory) { - const list = owners.get(key); - if (list) list.push(n.id); - else owners.set(key, [n.id]); - } - } - for (const [key, list] of owners) { - if (list.length === 1) ownerOf.set(key, list[0]); - } - } - - // nation id -> accumulated economy boost (a nation owning several project - // regions gets each of their boosts). - const delta = new Map(); - for (const p of next.industry) { - const owner = ownerOf.get(p.regionKey); - if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept) - const n = next.nations[owner]; - if (!n) continue; // a nation removed this turn (a merge): the boost is dropped - delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf)); - } - - const affected = new Set(); - for (const [id, d] of delta) { - const n = next.nations[id]; - if (!n) continue; - n.stats.economy = clampStat(n.stats.economy + d); - for (const key of n.territory) { - const rs = next.regionStats[key]; - if (rs) rs.economy = clampStat(rs.economy + d); - } - affected.add(id); - } - - if (affected.size > 0) { - for (const id of affected) { - const n = next.nations[id]; - if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { - n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); - } - } - } -} - -// --- Nuclear / WMD (v0.37, #60): the WMD lever -------------------------------- -// -// A nuclear strike is a DETERMINISTIC devastation (no hidden randomness): the -// LLM decides which nuclear-capable nation strikes and what it targets; the -// engine decides the size of the effect (NUCLEAR_COLLAPSE / NUCLEAR_RETALIATION -// in protocol.ts). This block runs AFTER the industry effects and BEFORE the -// marker owner-recompute, because a region strike RELEASES its region to no -// man's land, which changes the final ownership the markers depend on. -// -// ARM: each `arm` ref (id or name) is resolved and that nation's `nuclear` -// flag is set to true (idempotent; an unresolvable ref is dropped). -// Arming runs first, so a nation that arms AND strikes this turn is -// capable. -// -// STRIKE: each strike is validated independently (never throws): -// (a) `from` must resolve to a nuclear-capable nation (the `nuclear` flag -// or a rocket marker), else dropped. -// (b) The target is a NATION (a ref resolving to a distinct nation) or a -// REGION (a region key / name with a SOLE owner that is not the -// striker): -// - a nation target: the target's economy / military / stability are -// devastated (NUCLEAR_COLLAPSE, mirrored to its regions); if `from` -// and the target are on opposite sides of an active war, that war -// ends in `capitulation` and the warring relation falls back to -// hostile; if the target is nuclear-capable, the striker suffers a -// half-strength MAD retaliation. -// - a region target: the region is released to no man's land (its -// regionStats entry leaves the table), its former owner is -// devastated (NUCLEAR_COLLAPSE), and if that owner is nuclear-capable -// the striker suffers a half-strength MAD retaliation. -// (c) every applied strike appends a dramatic event (the EXISTING `military` -// category — no new category is invented), and a MAD strike appends an -// extra "MAD retaliation" event. -// -// After all strikes, every affected nation's derived stats are re-derived -// (the same 0-region / no-entry guards as the main recompute), so the -// collapse persists and the sole-ownership invariant is untouched. - -/** The three stats a nuclear strike collapses (population is never touched). */ -const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability']; - -/** - * Devastate one nation's stats AND every region it owns (mirroring the - * collapse into `regionStats`, like the economy / hybrid layers), clamped to - * 0–100. Never throws. - */ -function collapseNationStats( - next: GameState, - nationId: string, - collapse: Record, -): void { - const n = next.nations[nationId]; - if (!n) return; - for (const k of NUCLEAR_STATS) { - const d = collapse[k]; - n.stats[k] = clampStat(n.stats[k] + d); - for (const key of n.territory) { - const rs = next.regionStats[key]; - if (rs) rs[k] = clampStat(rs[k] + d); - } - } -} - -/** - * Release a region key to no man's land, reusing the turn's release semantics: - * the key leaves its SOLE owner's territory, is marked in `GameState.unowned`, - * and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an - * already-unowned key) is left untouched. Never throws. - */ -function releaseRegionToNoMansLand(next: GameState, key: string): void { - if (next.unowned.includes(key)) return; // already no man's land - const owners = Object.values(next.nations).filter((n) => n.territory.includes(key)); - if (owners.length !== 1) return; // 0 or 2+ owners: not releasable - next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter( - (k) => k !== key, - ); - next.unowned.push(key); - delete next.regionStats[key]; -} - -/** - * End every ACTIVE war whose sides include both `a` and `b` (a nuclear - * capitulation), then downgrade the `war` relation of every pair that is no - * longer covered by an active war back to `hostile` (the war → relation sync). - * Capturing the active pairs immediately BEFORE the end keeps this correct for - * a war declared and ended in the SAME turn (where the start-of-turn `prev` - * that `syncWarRelations` uses would not contain it). Never throws. - */ -function endWarsBetween(next: GameState, a: string, b: string): void { - const before = activeWarPairs(next.wars); - let ended = false; - for (const w of next.wars) { - if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) { - w.status = 'capitulation'; - ended = true; - } - } - if (!ended) return; - const after = activeWarPairs(next.wars); - for (const key of before) { - if (after.has(key)) continue; // still covered by another active war - const [x, y] = key.split('\u0000'); - const nx = next.nations[x]; - const ny = next.nations[y]; - if (!nx || !ny) continue; - if (nx.relations[y] === 'war' || ny.relations[x] === 'war') { - nx.relations[y] = 'hostile'; - ny.relations[x] = 'hostile'; - } - } -} - -/** - * Force the relation between two nations to `hostile` in BOTH directions — - * the aftermath of a MAD exchange. A `war` relation is never downgraded (the - * war entity owns it while active — e.g. a region strike that devastates one - * warring side without ending the war). Never throws. - */ -function forceHostile(next: GameState, a: string, b: string): void { - const na = next.nations[a]; - const nb = next.nations[b]; - if (!na || !nb) return; - if (na.relations[b] !== 'war') na.relations[b] = 'hostile'; - if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile'; -} - -/** - * Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in - * place and never throwing. See the section comment above for the full - * contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the - * "owns a rocket" half of nuclear capability (a rocket marker placed this very - * turn makes its owner capable only from the next turn, while a nation that - * ARMS this turn is capable immediately, since arming runs first). - */ -export function applyNuclearOps( - next: GameState, - ops: NuclearOps, - state: GameState, - geoOf?: (key: string) => { area: number; population: number } | undefined, - validKeys?: ReadonlySet, - regionNameOf?: (key: string) => string | undefined, - actingTurn?: number, -): void { - if (ops.arm.length === 0 && ops.strikes.length === 0) return; - const turn = actingTurn ?? next.turn; - const markers = state.markers ?? []; - const capable = (id: string): boolean => { - const n = next.nations[id]; - return n ? isNuclearCapable(n, markers) : false; - }; - - // (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped. - for (const ref of ops.arm) { - const id = resolveNationRef(next, ref); - if (id === null) continue; - const n = next.nations[id]; - if (!n) continue; - n.nuclear = true; - } - - const affected = new Set(); - const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key; - - // (2) STRIKES: each validated independently against the post-arm state. - for (const strike of ops.strikes) { - const fromId = resolveNationRef(next, strike.from); - if (fromId === null) continue; // unknown / ambiguous striker: drop - const from = next.nations[fromId]; - if (!from) continue; - if (!capable(fromId)) continue; // a non-capable nation cannot strike - if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop - - // Resolve the target: a NATION (a ref resolving to a distinct nation) or a - // REGION (a region key / name with a sole owner that is not the striker). - let target: - | { kind: 'nation'; nation: Nation } - | { kind: 'region'; key: string; owner: Nation } - | null = null; - const tid = resolveNationRef(next, strike.target); - if (tid !== null) { - const tn = next.nations[tid]; - if (tn) target = { kind: 'nation', nation: tn }; - } else { - const rk = validKeys - ? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined)) - : (strike.target.trim() || null); - if (rk !== null) { - const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk)); - if (owners.length === 1 && owners[0]!.id !== fromId) { - target = { kind: 'region', key: rk, owner: owners[0]! }; - } - // a 0/2+-owner region, or a region the striker owns (self), is dropped - } - } - if (target === null) continue; // unresolvable target: drop - - if (target.kind === 'nation') { - // Devastate the target nation. - collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE); - affected.add(target.nation.id); - // A strike on a nation at war with the striker ends that war in - // capitulation (the relation then falls back to hostile). - endWarsBetween(next, fromId, target.nation.id); - // MAD: a nuclear-capable target strikes back at half strength and the - // two nations end the exchange at least hostile. - if (capable(target.nation.id)) { - collapseNationStats(next, fromId, NUCLEAR_RETALIATION); - affected.add(fromId); - forceHostile(next, fromId, target.nation.id); - next.events.push( - makeEvent( - { - nation: fromId, - type: 'military', - title: 'MAD retaliation', - text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`, - outcome: 'failure', - }, - turn, - next.date, - ), - ); - } - next.events.push( - makeEvent( - { - nation: fromId, - type: 'military', - title: `Nuclear strike on ${target.nation.name}`, - text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`, - outcome: 'success', - }, - turn, - next.date, - ), - ); - } else { - // Devastate the region: release it to no man's land, then devastate its - // former owner. - const owner = target.owner; - releaseRegionToNoMansLand(next, target.key); - collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE); - affected.add(owner.id); - // MAD: a nuclear-capable owner strikes back at half strength and the - // two nations end the exchange at least hostile. - if (capable(owner.id)) { - collapseNationStats(next, fromId, NUCLEAR_RETALIATION); - affected.add(fromId); - forceHostile(next, fromId, owner.id); - next.events.push( - makeEvent( - { - nation: fromId, - type: 'military', - title: 'MAD retaliation', - text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay( - target.key, - )}; mutual assured destruction devastates ${from.name} in turn.`, - outcome: 'failure', - }, - turn, - next.date, - ), - ); - } - next.events.push( - makeEvent( - { - nation: fromId, - type: 'military', - title: `Nuclear strike on ${regionDisplay(target.key)}`, - text: `${from.name} obliterates ${regionDisplay( - target.key, - )} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`, - outcome: 'success', - }, - turn, - next.date, - ), - ); - } - } - - // (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region / - // no-entry guards as the main recompute), so the collapse persists. - for (const id of affected) { - const n = next.nations[id]; - if (!n) continue; - if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate - if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats - n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); - } -} - -/** - * A turn is "meaningful" if it produced at least an event, a stat change, a stat change, a - * no-man's-land release (v0.22 #16 P3: a release is a real world change even - * when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a: - * a proposal is a real pending-world change the player will resolve, so it - * must survive the retry/nudge path instead of being replaced by the generic - * "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended - * (v0.33.0 #56: both are real world changes). - */ export function isMeaningful(result: TurnResult): boolean { return ( result.events.length > 0 || @@ -2165,31 +845,10 @@ export function isMeaningful(result: TurnResult): boolean { result.regionChanges.length > 0 || result.unowned.length > 0 || result.nationOps.length > 0 || - // (v0.32.0, #54) a takeover is a real world change (regime rename). - result.takeovers.length > 0 || - // (v0.33.0, #56) a war declared or ended is a real world change. - result.wars.declare.length > 0 || - result.wars.end.length > 0 || - // (v0.34.0, #57) a hybrid op (a successful one or an exposed backfire) is - // a real world change (a stat dip or a soured relation). - result.hybrid.ops.length > 0 || - // (v0.35.0, #58) a trade declared/ended or an embargo imposed/lifted this - // turn is a real world change (the active economy lists moved). - result.economy.trade.length > 0 || - result.economy.embargo.length > 0 || - // (v0.36, #59) an industry project built this turn is a real world change - // (the active project list moved). - result.industry.projects.length > 0 || - // (v0.37, #60) a nation arming a nuclear arsenal or a nuclear strike fired - // this turn is a real world change. - result.nuclear.arm.length > 0 || - result.nuclear.strikes.length > 0 || - // (v0.38, #61) a leader assassinated / died / appointed this turn is a real - // world change (a nation's leader and stability moved). - result.leaders.changes.length > 0 || - // (v0.39, #62) an ideology (form of government) change this turn is a real - // world change (a nation's government type and stability moved). - result.ideology.changes.length > 0 || + // (v0.40.0, #67) any of the 8 per-feature abilities having a non-empty + // payload is a real world change (each Ability's isMeaningful predicate + // is the per-ability half of the old 18-sub-field check). + ABILITIES.some((a) => a.isMeaningful(result[a.field])) || // (v0.27, #44) a marker placed or removed this turn is a real world change. result.markers.add.length > 0 || result.markers.remove.length > 0 @@ -2453,189 +1112,25 @@ export function applyTurnResult( n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); } - // --- Takeovers (v0.32.0, #54): non-military regime changes (coups / - // revolutions) that rename a nation to its new regime. Applied AFTER the - // per-region recompute (the rename doesn't depend on territory or stats, - // so it is order-independent with respect to those steps) and BEFORE the - // markers block (a marker's owner is a nation id, not a name, so the - // rename does not affect the owner recompute). - // - // For each takeover: - // 1. Resolve the target nation via `resolveNationRef` (id first, then - // case-insensitive unique name). Skip (no-op) if `null` — an - // unknown or ambiguous ref is silently dropped, never thrown. - // 2. Rename the resolved nation: set `n.name = newRegimeName`. Only - // the display name changes — the stable id, territory, relations - // (keyed by id), stats, and `playerNationId` (which references the - // id, not the name) are all preserved. The player keeps leading an - // overthrown nation under its new regime. - for (const t of result.takeovers) { - const id = resolveNationRef(next, t.nationRef); - if (id === null) continue; // unknown / ambiguous ref: no-op - const n = next.nations[id]; - if (!n) continue; // defensive: id resolved but nation missing (corrupt state) - n.name = t.newRegimeName; + // (v0.40.0, #67) The 8 per-feature LLM abilities, applied in ABILITIES + // order (takeovers → wars → hybrid → economy → industry → nuclear → + // leaders → ideology). Each Ability's apply method handles its own + // field's validated data against the post-territory/post-recompute + // `next` state. The AbilityCtx carries the shared resolvers. + const abilityCtx = { + previous: state, + actingTurn, + validRegionKeys: validKeys, + regionNameOf, + geoOf, + resourceOf, + centroidOf, + }; + const abilityCtxAny = abilityCtx as unknown as import('./abilities').AbilityCtx; + for (const a of ABILITIES) { + a.apply(next, result[a.field], abilityCtxAny); } - // --- Wars (v0.33.0, #56: war as a first-class state) --- - // - // Wars are named entities (`GameState.wars`), not just a `war` relation. - // Two kinds of change, applied in this order against the (cloned) `next`: - // - // (1) DECLARE: each validated declare is resolved to concrete nation ids - // (`resolveNationRef`, so the LLM may use an id OR a unique name) and - // becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped - // with the acting turn and the turn's new date. Mutually-defensive - // allies of the belligerents are DRAGGED IN: any nation that is an - // `ally` of a belligerent (per the current, post-relations relations) - // and is not already a side joins the war's `sides`. A declare with - // fewer than two DISTINCT resolved sides is dropped; a declare whose - // sides are all already contained in an existing ACTIVE war is a - // duplicate and is skipped (the existing war stands). - // (2) END: each validated end targets a war BY ID and sets its status to - // the outcome (`armistice` / `capitulation`). Only ACTIVE wars are - // affected — an unknown id or an already-ended war is a no-op. - // (A war may be declared and ended in the SAME turn: declare runs - // first, so the just-declared id is a valid end target.) - // (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from - // the ACTIVE wars (see its doc) — the load-bearing step that keeps - // the relation table honest with the war entities. - // - // Nothing here throws: every unresolvable / invalid op is dropped. - { - const takenWarIds = new Set(next.wars.map((w) => w.id)); - // (1) DECLARE - for (const d of result.wars.declare) { - const resolved: string[] = []; - for (const ref of d.sides) { - const id = resolveNationRef(next, ref); - if (id !== null && !resolved.includes(id)) resolved.push(id); - } - if (resolved.length < 2) continue; // not enough distinct belligerents - // Duplicate: an active war already contains all of these sides. - const dup = next.wars.some( - (w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)), - ); - if (dup) continue; - const sides = [...resolved]; - // Mutual-defense drag-in: each belligerent's allies join the war. - for (const s of resolved) { - const rels = next.nations[s]?.relations ?? {}; - for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) { - if (!sides.includes(ally)) sides.push(ally); - } - } - const id = freshWarId(takenWarIds); - takenWarIds.add(id); - next.wars.push({ - id, - sides, - declaredTurn: actingTurn, - declaredDate: next.date, - ...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}), - status: 'active', - }); - } - // (2) END (by id; active only) - for (const e of result.wars.end) { - const w = next.wars.find((w) => w.id === e.id && w.status === 'active'); - if (!w) continue; // unknown id / already ended: no-op - w.status = e.outcome; - } - // (3) RELATION SYNC (prev = the state this turn started from) - syncWarRelations(state, next); - } - - // --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations --- - // - // Each op's deterministic per-type effect is applied to the post-wars state - // (a stat dip on the target's regions, or a relation nudge toward hostile). - // An `exposed` op backfires instead. They never move borders, so they run - // after the territory changes and before the markers (a marker's owner is - // recomputed from the final ownership, unaffected by a stat/relation dip). - for (const op of result.hybrid.ops) { - applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn); - } - - // --- Trade & embargo economy layer (v0.35.0, #58) --- - // - // Placed AFTER the wars block (so the auto-suspension sees the FINAL - // next.wars, including wars declared this turn) and before markers / - // nationOps (which never touch the economy lists). Order: - // (1) APPLY OPS: the turn's declare/lift ops mutate the active - // GameState.trades / GameState.embargoes lists (a declare is - // idempotent; refs resolved against the current state, unknown / - // ambiguous / self-pairing ops dropped). - // (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two - // nations are on opposite sides of an active war is removed — - // a war severs the economic link, so the suspended agreement - // stops producing its per-turn effect from now on (this also - // drops a trade declared this turn between two nations that just - // went to war). - // (3) PER-TURN EFFECT: the FULL active lists (not just this turn's - // declares) each apply their deterministic effect this turn — - // trade: both parties' economy up a little; embargo: the - // target's economy down a little — clamped, mirrored to the - // owning nations' regions, and re-derived like the hybrid dip. - // Additive only: declares add, lifts remove, and nothing else (not even - // a war) mutates the lists besides the suspension above. - applyEconomyOps(next, result.economy); - suspendEconomyOnWar(next); - applyEconomyEffects(next, geoOf); - - // --- Industry projects (v0.36, #59): per-region economy-boost lever --- - // - // Placed AFTER the economy block (both move stats, and the industry boost - // should compose with the trade/embargo effects of the same turn) and - // before markers / nationOps. Order: - // (1) APPLY OPS: this turn's `industry.projects` are added to - // `GameState.industry` as a per-region SET — a region key that is not - // a valid manifest key is dropped, and a region that already hosts a - // project is untouched (the first declared type wins; re-declaring is - // a no-op). - // (2) PER-TURN EFFECT: the FULL active project list (not just this - // turn's adds) each grants their region's SOLE owner a small, - // deterministic economy boost scaled by the region's `industry` - // resource value — clamped, mirrored to every region the owner holds, - // and re-derived like the economy/hybrid effects. A project whose - // region ends the turn with no owner (or 2+ owners) yields no boost - // but is KEPT (the region may be re-claimed later). Sole ownership is - // untouched (the layer moves stats, never territory). - applyIndustryOps(next, result.industry, validKeys); - applyIndustryEffects(next, resourceOf, geoOf); - - // --- Nuclear / WMD (v0.37, #60): the WMD lever --- - // - // Placed AFTER the industry/economy effects (both move stats) and BEFORE the - // markers block (a region strike releases its region to no man's land, which - // changes the FINAL ownership the marker owner-recompute depends on). See - // applyNuclearOps for the full contract (arm, strike, MAD, re-derive). - // `state` (the original pre-turn state) supplies the markers used for the - // rocket-marker half of nuclear capability. - applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn); - - // --- Named leaders (v0.38, #61) --- - // - // Placed AFTER the nuclear block (both are the "heavier" levers) and BEFORE - // the markers block (markers recompute their owner from the FINAL post-claim - // ownership; leaders only touch stats, so ordering w.r.t. markers is - // immaterial, but keeping the levers grouped is clearer). Each change sets - // the target's leader, applies a deterministic stability dip by cause, and - // appends a narration event. A leaderless assassination/death is dropped. - for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn); - - // --- Ideology / form of government (v0.39, #62) --- - // - // Placed AFTER the leaders block (both are per-nation "who is in charge / - // how the state is run" levers) and BEFORE the markers block (markers - // recompute their owner from the FINAL post-claim ownership; ideology only - // touches stats, so ordering w.r.t. markers is immaterial, but keeping the - // levers grouped is clearer). Each change sets the target's ideology, - // applies a deterministic stability dip (−5), and appends a political - // narration event. An unresolvable (unknown / ambiguous) target is - // dropped, never thrown. - for (const op of result.ideology.changes) applyIdeologyChange(next, op, geoOf, actingTurn); - // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL @@ -3144,7 +1639,7 @@ export async function runTurn(opts: { } if (result === null || !isMeaningful(result)) { - result = { + const fallback = { date: addDays(state.date, 2), events: [genericUnclearEvent(state)], stateChanges: [], @@ -3153,16 +1648,13 @@ export async function runTurn(opts: { territoryChanges: [], unowned: [], nationOps: [], - takeovers: [], - wars: { declare: [], end: [] }, - hybrid: { ops: [] }, - economy: { trade: [], embargo: [] }, - industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, - leaders: { changes: [] }, - ideology: { changes: [] }, markers: { add: [], remove: [] }, - }; + } as unknown as Record; + // (v0.40.0, #67) Each ability's default() provides its field's empty value. + for (const a of ABILITIES) { + fallback[a.field] = a.default(); + } + result = fallback as unknown as TurnResult; } return { diff --git a/server/stub.ts b/server/stub.ts index 6ae0379..c99ca59 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -4,6 +4,7 @@ import { createLlmClient, type ChatMessage, type Transport } from './llm'; import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol'; import type { RelationStatus, RoundItem } from '../shared/types'; +import { ABILITIES, type AbilityField } from './abilities'; export const ADVISOR_MARKER = '<>'; @@ -229,7 +230,18 @@ export function deterministicTurn(p: StubPayload): TurnResult { }, ); - return { + // Core (non-ability) turn fields, in their historical position order. + const pre: Pick< + TurnResult, + | 'date' + | 'events' + | 'stateChanges' + | 'regionChanges' + | 'relations' + | 'territoryChanges' + | 'unowned' + | 'nationOps' + > = { date: addDays(state?.date ?? '1939-09-01', 2), events, stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }], @@ -238,16 +250,24 @@ export function deterministicTurn(p: StubPayload): TurnResult { territoryChanges, unowned: [], // the stub never creates no man's land (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment) - takeovers: [], // the stub never performs a takeover (see doc comment) - wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment) - hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment) - economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment) - industry: { projects: [] }, // the stub never declares industry projects (see doc comment) - nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment) - leaders: { changes: [] }, // the stub never changes leaders (see doc comment) - ideology: { changes: [] }, // the stub never changes ideologies (see doc comment) + }; + + // The 8 ability fields, filled by the registry in its canonical order. The + // stub is ability-inert: every ability contributes its own no-op default + // (each ability's `stub()` — see the per-ability doc comments above), so the + // e2e assertions are unaffected. The insertion order here (ABILITIES) is the + // historical key order pinned by the stub's deterministic output + // (takeovers → wars → hybrid → economy → industry → nuclear → leaders → + // ideology), which must stay byte-identical. + const abilities = {} as Record; + for (const a of ABILITIES) abilities[a.field] = a.stub(); + + // The single field positioned after the abilities (markers last, as before). + const post: Pick = { markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; + + return { ...pre, ...abilities, ...post } as TurnResult; } const CANNED_ADVISOR: string[] = [