CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.
- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
= -5, TurnResult.ideology (required, between leaders and markers),
nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
re-derived -> political event), ideology block in applyTurnResult after
leaders / before markers, isMeaningful + validateTurnResult + system prompt
+ runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
3322 lines
154 KiB
TypeScript
3322 lines
154 KiB
TypeScript
// The world-sim engine: pure, testable, no I/O. It (1) builds the LLM prompt from a
|
||
// preset + state, (2) parses and strictly validates the LLM's TurnResult JSON
|
||
// (defensively — never throws), (3) applies the delta atomically to a GameState,
|
||
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
|
||
// llm.ts (injectable) and is invoked by runTurn.
|
||
import type {
|
||
Embargo,
|
||
EventOutcome,
|
||
EventType,
|
||
GameState,
|
||
IndustryProject,
|
||
IndustryType,
|
||
MapMarker,
|
||
Nation,
|
||
NationStats,
|
||
Preset,
|
||
RelationStatus,
|
||
RegionResources,
|
||
RegionStats,
|
||
RoundItem,
|
||
StatKey,
|
||
TradeAgreement,
|
||
War,
|
||
} from '../shared/types';
|
||
import { EVENT_TYPES, INDUSTRY_TYPES, RELATION_STATUSES } from '../shared/types';
|
||
import {
|
||
addDays,
|
||
canConquer,
|
||
clampStat,
|
||
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 {
|
||
DEFAULT_MAX_TOKENS,
|
||
type ChatMessage,
|
||
type LlmClient,
|
||
type ToolDefinition,
|
||
type ToolExecutor,
|
||
type WireMessage,
|
||
} from './llm';
|
||
import { ADVISOR_MARKER } from './stub';
|
||
|
||
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
||
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
|
||
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);
|
||
}
|
||
|
||
const VALID_INDUSTRY_TYPES = new Set<string>(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);
|
||
}
|
||
|
||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||
|
||
/**
|
||
* The single tool the turn loop exposes to the model: the precomputed region
|
||
* adjacency manifest (public/region-adjacency.json, via the resolver injected
|
||
* into runTurn). Given a set of region keys it returns the union of their
|
||
* bordering keys (the queried keys themselves excluded, sorted for
|
||
* determinism) — so the model can verify that a territory transfer is
|
||
* *contiguous* (a nation gains a region it already abuts) before committing
|
||
* it, instead of moving whole countries at a whim.
|
||
*/
|
||
export const GET_NEIGHBOURS_TOOL: ToolDefinition = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'getNeighbours',
|
||
description:
|
||
'List the region keys that share a land border with any of the given region keys. ' +
|
||
'Pass a nation\'s territory keys (or a single region key) to see which regions are ' +
|
||
'contiguous to it. Use it before transferring territory: a nation can realistically ' +
|
||
'only gain regions that border its existing territory.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
regionKeys: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: 'One or more region keys to query (e.g. a gaining nation\'s territory).',
|
||
},
|
||
},
|
||
required: ['regionKeys'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Build the tool executor for the turn loop. Never throws: unknown tool
|
||
* names, malformed arguments, and unknown region keys all degrade to a safe
|
||
* response (an error object or an empty list) that the model can recover from.
|
||
*/
|
||
export function makeNeighboursExecutor(
|
||
getNeighbours: (keys: string[]) => string[],
|
||
validRegionKeys?: ReadonlySet<string>,
|
||
): ToolExecutor {
|
||
return (name, args) => {
|
||
if (name !== GET_NEIGHBOURS_TOOL.function.name) {
|
||
return JSON.stringify({ error: `unknown tool: ${name}` });
|
||
}
|
||
const raw = Array.isArray(args.regionKeys) ? args.regionKeys : [];
|
||
const keys = raw.filter((k): k is string => typeof k === 'string' && k.length > 0);
|
||
const valid = validRegionKeys ? keys.filter((k) => validRegionKeys.has(k)) : keys;
|
||
if (valid.length === 0) return JSON.stringify({ neighbours: [] });
|
||
return JSON.stringify({ neighbours: getNeighbours(valid) });
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The player's frontier: region keys owned by *other* nations that share a
|
||
* land border with the player's territory (deduped, sorted — stable order for
|
||
* the deterministic stub). Computed server-side from the adjacency manifest so
|
||
* both the LLM (the `frontier` field of the user prompt) and the stub (which
|
||
* cannot call tools) see the same candidates. Empty when there is no
|
||
* adjacency data or no controllable border (e.g. the UK in 1939).
|
||
*/
|
||
export function playerFrontier(
|
||
state: GameState,
|
||
getNeighbours: (keys: string[]) => string[],
|
||
): string[] {
|
||
const player = state.nations[state.playerNationId];
|
||
if (!player || player.territory.length === 0) return [];
|
||
const playerKeys = new Set(player.territory);
|
||
const candidates = new Set<string>();
|
||
for (const key of getNeighbours(player.territory)) {
|
||
if (!playerKeys.has(key)) candidates.add(key);
|
||
}
|
||
if (candidates.size === 0) return [];
|
||
const frontier: string[] = [];
|
||
for (const n of Object.values(state.nations)) {
|
||
if (n.id === state.playerNationId) continue;
|
||
for (const key of n.territory) {
|
||
if (candidates.has(key)) {
|
||
candidates.delete(key);
|
||
frontier.push(key);
|
||
}
|
||
}
|
||
}
|
||
return frontier.sort();
|
||
}
|
||
|
||
// --- Prompt building ----------------------------------------------------------
|
||
|
||
/**
|
||
* The LLM system prompt: world context + rules + a strict JSON contract.
|
||
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
||
* available) the contract gains the contiguity rules for territory transfers
|
||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||
* may verify borders with the tool before answering. The contract also
|
||
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||
* split/merge shapes applied on player confirm (v0.24, #16 P4b), and the
|
||
* trade & embargo economy layer (v0.35.0, #58): bilateral trades and
|
||
* unilateral embargoes, each ended with a `lift: true` op.
|
||
*/
|
||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||
const rules = preset.simulationRules
|
||
.map((r) => `- ${r}`)
|
||
.join('\n');
|
||
const respondLine = withNeighboursTool
|
||
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
|
||
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
|
||
// (v0.22, #16 P3) No man's land: a region can be left unowned after a war or
|
||
// cataclysm (the model narrates the cause in "events" and lists the key in
|
||
// "unowned"); unowned regions can later be re-claimed with an ordinary gain.
|
||
// The model sees the current no-man's-land keys in the user payload's
|
||
// "unowned" field and in the "regions" reference table.
|
||
const noMansLandRule =
|
||
'- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.';
|
||
const territoryRules = withNeighboursTool
|
||
? [
|
||
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.',
|
||
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
|
||
noMansLandRule,
|
||
]
|
||
: [
|
||
'- Only change borders when war or a decisive political shift justifies it.',
|
||
noMansLandRule,
|
||
];
|
||
return [
|
||
`WORLD: ${preset.title} — ${preset.startDate}`,
|
||
preset.worldBackground,
|
||
'',
|
||
'SIMULATION RULES',
|
||
rules,
|
||
'',
|
||
'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.',
|
||
respondLine,
|
||
'{',
|
||
' "date": "YYYY-MM-DD (a few days after the current date)",',
|
||
' "events": [',
|
||
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
||
' ],',
|
||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||
' "regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
||
' "unowned": ["<region key>"],',
|
||
' "nationOps": [',
|
||
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
|
||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||
' ],',
|
||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||
' "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>"}]},',
|
||
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
|
||
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
|
||
' "ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},',
|
||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||
'}',
|
||
'',
|
||
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
|
||
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
|
||
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
|
||
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
|
||
'',
|
||
'HARD CONSTRAINTS',
|
||
'- Use only nation ids and region keys that exist in the provided state.',
|
||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
||
'- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).',
|
||
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
|
||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} 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":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} 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":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} 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":["<id>","<id>"],"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":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — 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":"<region key>","type":"<oil|iron|food|industry>"} (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":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "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":"<nation id or name>","to":"<new form of government>"}: 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).',
|
||
'- 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.',
|
||
'- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.',
|
||
...territoryRules,
|
||
'- If a field is not relevant this turn, set it to an empty array.',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* The user message: a compact JSON snapshot of the world plus the player's
|
||
* queued round (`items`) — the free-text actions and diplomatic messages to be
|
||
* simulated this period. `playerActions` is an LLM-friendly rendering of each
|
||
* item; the raw `pending` list is also emitted so the deterministic stub (and any
|
||
* other consumer) can read the exact items. The framing instruction tells the
|
||
* model to simulate the combined consequences of the whole batch (or to let the
|
||
* world advance on its own when the batch is empty) and return one TurnResult.
|
||
* (v0.35.0, #58) It also carries the currently active `trades` / `embargoes`
|
||
* (omitted entirely when undefined, like the other optional fields) so the
|
||
* model can reference them — and `lift` them — in `TurnResult.economy`.
|
||
*/
|
||
export function buildUserPrompt(
|
||
state: GameState,
|
||
items: RoundItem[],
|
||
regionReference?: Record<string, string>,
|
||
/**
|
||
* The player's frontier: region keys owned by other nations that border the
|
||
* player's territory (v0.21, #16 P2). Emitted verbatim (even when empty) so
|
||
* the model — and the deterministic stub, which reads it — see the same
|
||
* contiguous targets. Omitted entirely when undefined (no adjacency data).
|
||
*/
|
||
frontier?: string[],
|
||
/**
|
||
* (v0.22, #16 P3) Region keys that are currently "no man's land" (released
|
||
* mid-game — `state.unowned`). Emitted verbatim (even when empty) so the
|
||
* model knows which unowned regions exist and can be re-claimed this turn.
|
||
* Omitted entirely when undefined.
|
||
*/
|
||
noMansLand?: string[],
|
||
/**
|
||
* (v0.27, #44) The game's current map markers, as the model sees them:
|
||
* stable id, type, name, region key and current owner. Emitted verbatim
|
||
* (even when empty) so the model can reference a marker's id in
|
||
* `TurnResult.markers.remove` or leave an existing marker untouched.
|
||
* Omitted entirely when undefined.
|
||
*/
|
||
markers?: MarkerInfo[],
|
||
/**
|
||
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
|
||
* stable id, sides and aims. Emitted verbatim (even when empty) so the
|
||
* model can reference a war's id in `TurnResult.wars.end` to end it.
|
||
* Omitted entirely when undefined.
|
||
*/
|
||
wars?: WarInfo[],
|
||
/**
|
||
* (v0.34.0, #57) The player's current relations to every other nation
|
||
* (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the
|
||
* model sees the relation ladder it can nudge with `information` / `proxy`
|
||
* hybrid ops and knows the current state before an `exposed` backfire.
|
||
* Omitted entirely when undefined.
|
||
*/
|
||
relations?: Record<string, RelationStatus>,
|
||
/**
|
||
* (v0.35.0, #58) The currently active bilateral trade agreements (each a
|
||
* pair of nation ids). Emitted verbatim (even when empty) so the model
|
||
* knows which trades exist and can end one with `economy.trade` +
|
||
* `lift: true`. Omitted entirely when undefined.
|
||
*/
|
||
trades?: TradeAgreement[],
|
||
/**
|
||
* (v0.35.0, #58) The currently active unilateral embargoes (each an
|
||
* `from` → `target` pair of nation ids). Emitted verbatim (even when
|
||
* empty) so the model knows which embargoes are in force and can lift one
|
||
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
|
||
*/
|
||
embargoes?: Embargo[],
|
||
/**
|
||
* (v0.36, #59) Each nation's AGGREGATE resource profile (nation id -> the
|
||
* area×population-weighted mean of the resource profiles of the regions it
|
||
* owns, via `deriveNationResources`). Emitted verbatim (even when empty) so
|
||
* the model can reason about which nations are rich in which resource and
|
||
* where to build industry projects. Omitted entirely when undefined (no
|
||
* resource manifest loaded).
|
||
*/
|
||
nationResources?: Record<string, RegionResources>,
|
||
/**
|
||
* (v0.36, #59) The game's currently ACTIVE industry projects (each a region
|
||
* key + project type). Emitted verbatim (even when empty) so the model knows
|
||
* which regions already host a project (and should not be re-declared) and
|
||
* can reference them. Omitted entirely when undefined.
|
||
*/
|
||
industryProjects?: IndustryProject[],
|
||
/**
|
||
* (v0.37.0, #60) Each nation's nuclear capability (nation id -> boolean),
|
||
* computed by the caller via `isNuclearCapable` (the nation's `nuclear` flag
|
||
* OR a current `rocket` marker). When defined, each nation in the `state.
|
||
* nations` map is stamped with `nuclear: true|false` so the model knows who
|
||
* can arm/strike and who can MAD-retaliate. Omitted entirely (no `nuclear`
|
||
* key on any nation) when undefined.
|
||
*/
|
||
nuclear?: Record<string, boolean>,
|
||
): string {
|
||
const player = state.nations[state.playerNationId];
|
||
const playerName = player?.name ?? state.playerNationId;
|
||
const playerActions = items.map((i) =>
|
||
i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text },
|
||
);
|
||
const roundInstruction =
|
||
items.length > 0
|
||
? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify(
|
||
playerActions,
|
||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||
|
||
// (v0.29, #46 PR 2) The player's own regions' current per-region stats, so the
|
||
// model can reason about — and target via `regionChanges` — the player's
|
||
// individual regions. Bounded to the player's territory (not the whole
|
||
// world's 1,800 regions) to keep the prompt small. Always emitted: {} when
|
||
// the player has no region with a stats entry yet.
|
||
const playerRegionStats: Record<string, RegionStats> = {};
|
||
if (state.regionStats) {
|
||
for (const key of player?.territory ?? []) {
|
||
const rs = state.regionStats[key];
|
||
if (rs) playerRegionStats[key] = rs;
|
||
}
|
||
}
|
||
|
||
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
|
||
// so the model — and, via the same data, the player — sees the hard gate
|
||
// before it fires: for each frontier neighbor, compare the player's derived
|
||
// military to the neighbor's and state whether a direct invasion would be
|
||
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
|
||
// emitted only when a non-empty frontier is present, and the rest of the
|
||
// payload is left untouched.
|
||
let frontierMilitary: string[] | undefined;
|
||
if (frontier !== undefined && frontier.length > 0) {
|
||
const playerMil = player?.stats.military ?? 0;
|
||
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
|
||
for (const n of Object.values(state.nations)) {
|
||
for (const k of n.territory) ownerByKey.set(k, n.id);
|
||
}
|
||
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
|
||
for (const k of frontier) {
|
||
const ownerId = ownerByKey.get(k);
|
||
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
|
||
const owner = state.nations[ownerId];
|
||
if (!owner) continue;
|
||
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
|
||
}
|
||
if (byNeighbor.size > 0) {
|
||
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
|
||
const o = state.nations[ownerId];
|
||
const wins = canConquer(playerMil, mil);
|
||
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
|
||
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
|
||
}`;
|
||
});
|
||
}
|
||
}
|
||
|
||
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||
// AND that currently have a "gathering" marker on one of their regions are
|
||
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||
// 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<string>();
|
||
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)`;
|
||
});
|
||
}
|
||
}
|
||
|
||
const payload: Record<string, unknown> = {
|
||
state: {
|
||
turn: state.turn,
|
||
date: state.date,
|
||
playerNationId: state.playerNationId,
|
||
nations: Object.fromEntries(
|
||
Object.entries(state.nations).map(([id, n]) => [
|
||
id,
|
||
nuclear ? { ...nationForPrompt(n), nuclear: nuclear[id] ?? false } : nationForPrompt(n),
|
||
]),
|
||
),
|
||
recentEvents: state.events.slice(-5).map((e) => ({
|
||
turn: e.turn,
|
||
date: e.date,
|
||
nation: e.nation,
|
||
type: e.type,
|
||
title: e.title,
|
||
text: e.text,
|
||
outcome: e.outcome,
|
||
})),
|
||
},
|
||
playerActions,
|
||
pending: items,
|
||
roundInstruction,
|
||
playerRegionStats,
|
||
};
|
||
if (regionReference) payload.regions = regionReference;
|
||
if (frontier !== undefined) payload.frontier = frontier;
|
||
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||
if (takeoverRisks !== undefined) payload.takeoverRisks = 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;
|
||
return JSON.stringify(payload, null, 2);
|
||
}
|
||
|
||
// --- Defensive parsing --------------------------------------------------------
|
||
|
||
function stripFence(text: string): string {
|
||
let t = text.trim();
|
||
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||
if (lead) t = t.slice(lead[0].length);
|
||
const tail = t.match(/\n?\s*```\s*$/);
|
||
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||
return t.trim();
|
||
}
|
||
|
||
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||
function extractBalancedObject(text: string): string | null {
|
||
const start = text.indexOf('{');
|
||
if (start === -1) return null;
|
||
let depth = 0;
|
||
let inStr = false;
|
||
let esc = false;
|
||
for (let i = start; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (inStr) {
|
||
if (esc) esc = false;
|
||
else if (c === '\\') esc = true;
|
||
else if (c === '"') inStr = false;
|
||
continue;
|
||
}
|
||
if (c === '"') inStr = true;
|
||
else if (c === '{') depth++;
|
||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
||
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` is threaded to
|
||
* `validateTurnResult` to resolve `regionChanges` refs to region keys.
|
||
*/
|
||
export function parseTurnResult(
|
||
raw: string,
|
||
regionKeyResolver?: (ref: string) => string | null,
|
||
): TurnResult | null {
|
||
const cleaned = stripFence(raw);
|
||
const jsonText = extractBalancedObject(cleaned);
|
||
if (!jsonText) return null;
|
||
let obj: unknown;
|
||
try {
|
||
obj = JSON.parse(jsonText);
|
||
} catch {
|
||
return null;
|
||
}
|
||
return validateTurnResult(obj, regionKeyResolver);
|
||
}
|
||
|
||
function str(s: unknown, max = 0): string | null {
|
||
if (typeof s !== 'string') return null;
|
||
const t = s.trim();
|
||
if (!t) return null;
|
||
return max ? t.slice(0, max) : t;
|
||
}
|
||
|
||
/**
|
||
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
|
||
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
|
||
* unknown `kind` or missing/empty required fields are dropped, never throw.
|
||
* Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution
|
||
* of `nationRef` to a concrete nation happens later, at confirm time). Split:
|
||
* non-empty `nationRef` + at least one part with a name. Merge: non-empty
|
||
* `nationRefs` (capped at 20) + `survivingRef` + `into`.
|
||
*/
|
||
export function validateNationOps(raw: unknown): NationOp[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
const out: NationOp[] = [];
|
||
for (const e of raw) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const o = e as Record<string, unknown>;
|
||
if (o.kind === 'rename') {
|
||
const nationRef = str(o.nationRef, 200);
|
||
const to = str(o.to, 200);
|
||
if (nationRef && to) out.push({ kind: 'rename', nationRef, to });
|
||
} else if (o.kind === 'split') {
|
||
const nationRef = str(o.nationRef, 200);
|
||
const parts = (Array.isArray(o.parts) ? o.parts : [])
|
||
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
||
.map((p) => {
|
||
const name = str(p.name, 200);
|
||
const territory = Array.isArray(p.territory)
|
||
? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
|
||
: [];
|
||
return { name, territory };
|
||
})
|
||
.filter((p): p is { name: string; territory: string[] } => p.name !== null);
|
||
if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts });
|
||
} else if (o.kind === 'merge') {
|
||
const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : [])
|
||
.map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : ''))
|
||
.filter((x) => x !== '')
|
||
.slice(0, 20);
|
||
const survivingRef = str(o.survivingRef, 200);
|
||
const into = str(o.into, 200);
|
||
if (nationRefs.length > 0 && survivingRef && into) {
|
||
out.push({ kind: 'merge', nationRefs, survivingRef, into });
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||
* only when the input is not even a plain object.
|
||
*
|
||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` resolves each
|
||
* `regionChanges` ref (key or name) to a concrete region key, dropping
|
||
* unknown/ambiguous refs; without it the refs are kept as-is (shape-only), so
|
||
* callers that build a TurnResult by hand (tests) need not supply it.
|
||
*/
|
||
export function validateTurnResult(
|
||
input: unknown,
|
||
regionKeyResolver?: (ref: string) => string | null,
|
||
): TurnResult | null {
|
||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
||
const o = input as Record<string, unknown>;
|
||
const result: TurnResult = {
|
||
date: typeof o.date === 'string' ? o.date : '',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] },
|
||
industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] },
|
||
leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
|
||
if (Array.isArray(o.events)) {
|
||
for (const e of o.events) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const ev = e as Record<string, unknown>;
|
||
const nation = str(ev.nation);
|
||
const title = str(ev.title, 200);
|
||
const text = str(ev.text, 1200);
|
||
if (!nation || !title || !text) continue;
|
||
const type = VALID_EVENT_TYPES.has(String(ev.type))
|
||
? (ev.type as EventType)
|
||
: 'political';
|
||
const outcome = VALID_OUTCOMES.has(String(ev.outcome))
|
||
? (ev.outcome as EventOutcome)
|
||
: 'neutral';
|
||
result.events.push({ nation, type, title, text, outcome });
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(o.stateChanges)) {
|
||
for (const c of o.stateChanges) {
|
||
if (!c || typeof c !== 'object') continue;
|
||
const cc = c as Record<string, unknown>;
|
||
const nation = str(cc.nation);
|
||
const stat = str(cc.stat);
|
||
if (!nation || !stat || !VALID_STATS.has(stat)) continue;
|
||
if (typeof cc.value !== 'number' || !Number.isFinite(cc.value)) continue;
|
||
if (cc.op === 'add') {
|
||
result.stateChanges.push({
|
||
nation,
|
||
stat: stat as StatKey,
|
||
op: 'add',
|
||
value: cc.value,
|
||
});
|
||
} else if (cc.op === 'set') {
|
||
result.stateChanges.push({
|
||
nation,
|
||
stat: stat as StatKey,
|
||
op: 'set',
|
||
value: clampStat(cc.value),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(o.relations)) {
|
||
for (const r of o.relations) {
|
||
if (!r || typeof r !== 'object') continue;
|
||
const rr = r as Record<string, unknown>;
|
||
const a = str(rr.a);
|
||
const b = str(rr.b);
|
||
if (!a || !b || a === b) continue;
|
||
if (!VALID_RELATIONS.has(String(rr.set))) continue;
|
||
result.relations.push({ a, b, set: rr.set as RelationStatus });
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(o.territoryChanges)) {
|
||
for (const t of o.territoryChanges) {
|
||
if (!t || typeof t !== 'object') continue;
|
||
const tt = t as Record<string, unknown>;
|
||
const nation = str(tt.nation);
|
||
if (!nation) continue;
|
||
const gain = Array.isArray(tt.gain)
|
||
? tt.gain.filter((x): x is string => typeof x === 'string')
|
||
: [];
|
||
const lose = Array.isArray(tt.lose)
|
||
? tt.lose.filter((x): x is string => typeof x === 'string')
|
||
: [];
|
||
if (gain.length === 0 && lose.length === 0) continue;
|
||
result.territoryChanges.push({ nation, gain, lose });
|
||
}
|
||
}
|
||
|
||
// (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty
|
||
// strings only (applied later by applyTurnResult against validKeys).
|
||
if (Array.isArray(o.unowned)) {
|
||
for (const k of o.unowned) {
|
||
const s = typeof k === 'string' ? k.trim() : '';
|
||
if (s && !result.unowned.includes(s)) result.unowned.push(s);
|
||
}
|
||
}
|
||
|
||
// (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
||
// are NOT applied by the turn — applyTurnResult appends them to the pending
|
||
// 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) };
|
||
|
||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||
// removals by stable id. Applied by applyTurnResult after the territory
|
||
// moves (a new marker's owner is the region's post-turn owner).
|
||
result.markers = validateMarkerOps(o.markers);
|
||
|
||
// (v0.29, #46 PR 2) Per-region stat changes: each targets a single region's
|
||
// stats (by key or name). Resolved to a key here when a resolver is
|
||
// supplied; applied by applyTurnResult after the nation-wide stateChanges
|
||
// and before the derived-stat recompute.
|
||
result.regionChanges = validateRegionChanges(o.regionChanges, regionKeyResolver);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* (v0.27, #44) Validate the raw `markers` object of a turn into a safe
|
||
* `{ add, remove }` shape. Defensive like the rest of the parser — never
|
||
* throws. `add`: each spec needs a known marker type, a non-empty `name`
|
||
* (capped at 120 chars) and a non-empty `regionKey` (capped at 120 chars);
|
||
* specs whose derived id collides with an earlier one in the same batch
|
||
* collapse to the first (re-adding is idempotent). `remove`: deduped,
|
||
* non-empty marker ids (capped at 120 chars).
|
||
*/
|
||
export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: string[] } {
|
||
const out: { add: MarkerSpec[]; remove: string[] } = { add: [], remove: [] };
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||
const o = raw as Record<string, unknown>;
|
||
if (Array.isArray(o.add)) {
|
||
const seen = new Set<string>();
|
||
for (const e of o.add) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const s = e as Record<string, unknown>;
|
||
if (!isMarkerType(s.type)) continue;
|
||
const name = str(s.name, 120);
|
||
const regionKey = str(s.regionKey, 120);
|
||
if (!name || !regionKey) continue;
|
||
const id = markerId(s.type, name, regionKey);
|
||
if (seen.has(id)) continue;
|
||
seen.add(id);
|
||
out.add.push({ type: s.type, name, regionKey });
|
||
}
|
||
}
|
||
if (Array.isArray(o.remove)) {
|
||
for (const k of o.remove) {
|
||
const s = typeof k === 'string' ? k.trim() : '';
|
||
if (s && !out.remove.includes(s)) out.remove.push(s.slice(0, 120));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* (v0.29, #46 PR 2) Validate the raw `regionChanges` array of a turn into a
|
||
* safe {@link RegionStatChange} list — the fine-grained, per-region stat lever
|
||
* that complements the nation-wide `stateChanges`. Defensive like the rest of
|
||
* the parser — never throws. Each entry needs a non-empty `region` (the
|
||
* region's stable key, preferred, or its human-readable name — capped at 120
|
||
* chars), a known stat, a finite numeric `value`, and an op of `set` or `add`.
|
||
*
|
||
* When `regionKeyResolver` is provided, each `region` ref is resolved to a
|
||
* concrete region key (key first, then a unique case-insensitive name match —
|
||
* see {@link resolveRegionKey}) and the resolved key is stored; a ref that
|
||
* resolves to `null` (unknown or ambiguous) is dropped, exactly like an
|
||
* unresolvable nation ref. When the resolver is absent (pure unit tests), the
|
||
* ref is kept as-is (shape-only validation) — a name that is not a key simply
|
||
* no-ops later in `applyTurnResult` (its `regionStats` lookup misses). `set`
|
||
* values are clamped to 0–100 here; `add` deltas are clamped against the
|
||
* current value at apply time (they may legitimately exceed 100 in the raw
|
||
* input, since they are added to an existing 0–100 value).
|
||
*/
|
||
export function validateRegionChanges(
|
||
raw: unknown,
|
||
regionKeyResolver?: (ref: string) => string | null,
|
||
): RegionStatChange[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
const out: RegionStatChange[] = [];
|
||
for (const e of raw) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const c = e as Record<string, unknown>;
|
||
const region = str(c.region, 120);
|
||
const stat = str(c.stat);
|
||
if (!region || !stat || !VALID_STATS.has(stat)) continue;
|
||
if (typeof c.value !== 'number' || !Number.isFinite(c.value)) continue;
|
||
if (c.op !== 'set' && c.op !== 'add') continue;
|
||
// Resolve the ref to a key when a resolver is available; drop it when the
|
||
// resolver says the ref is unknown or ambiguous (never throw). Without a
|
||
// resolver the ref is kept as-is (a name that is not a key no-ops later in
|
||
// applyTurnResult, whose regionStats lookup simply misses it).
|
||
let finalRegion: string;
|
||
if (regionKeyResolver) {
|
||
const resolved = regionKeyResolver(region);
|
||
if (resolved === null) continue; // unknown or ambiguous → drop
|
||
finalRegion = resolved;
|
||
} else {
|
||
finalRegion = region;
|
||
}
|
||
out.push({
|
||
region: finalRegion,
|
||
stat: stat as StatKey,
|
||
op: c.op,
|
||
value: c.op === 'set' ? clampStat(c.value) : c.value,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||
* revolution that renames a nation to its new regime name). Defensive like
|
||
* the rest of the parser — never throws. Each entry needs a non-empty
|
||
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||
* 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<string, unknown>;
|
||
const nationRef = str(o.nationRef, 200);
|
||
const newRegimeName = str(o.newRegimeName, 200);
|
||
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* (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<string, unknown>;
|
||
if (Array.isArray(o.declare)) {
|
||
for (const e of o.declare) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const d = e as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
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<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;
|
||
}
|
||
|
||
/**
|
||
* (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<string, unknown>;
|
||
|
||
if (Array.isArray(o.trade)) {
|
||
for (const e of o.trade) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const t = e as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
if (Array.isArray(o.projects)) {
|
||
for (const e of o.projects) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const p = e as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>).changes
|
||
: undefined;
|
||
if (!Array.isArray(list)) return out;
|
||
for (const e of list) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const s = e as Record<string, unknown>;
|
||
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<string, unknown>).changes
|
||
: undefined;
|
||
if (!Array.isArray(list)) return out;
|
||
for (const e of list) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const s = e as Record<string, unknown>;
|
||
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<string> {
|
||
const pairs = new Set<string>();
|
||
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<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`.
|
||
*/
|
||
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');
|
||
}
|
||
|
||
// --- 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<string, number>();
|
||
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<string>();
|
||
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<string>,
|
||
): 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<string, string>();
|
||
{
|
||
const owners = new Map<string, string[]>();
|
||
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<string, number>();
|
||
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<string>();
|
||
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<NuclearStatKey, number>,
|
||
): 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<string>,
|
||
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<string>();
|
||
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 ||
|
||
result.stateChanges.length > 0 ||
|
||
// (v0.29, #46 PR 2) a per-region stat change is a real world change.
|
||
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.27, #44) a marker placed or removed this turn is a real world change.
|
||
result.markers.add.length > 0 ||
|
||
result.markers.remove.length > 0
|
||
);
|
||
}
|
||
|
||
// --- Applying the delta -------------------------------------------------------
|
||
|
||
function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||
return {
|
||
nation: state.playerNationId,
|
||
type: 'political',
|
||
title: 'The turn was unclear',
|
||
text: 'The simulation could not resolve a clear outcome this turn; the world drifts forward on its own momentum.',
|
||
outcome: 'neutral',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||
* events, apply stat changes (clamped), symmetric relations, territory moves
|
||
* (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27
|
||
* #44). It also appends any proposed nation-ops to the pending list (v0.23
|
||
* #16 P4a) without applying them — see the doc comment on `pendingNationOps`
|
||
* for why that list deliberately survives across turns.
|
||
*
|
||
* `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid
|
||
* (`{ lat, lon, spread }`, from public/region-centroids.json via
|
||
* server/regions.ts). New markers are positioned at that centroid plus a small
|
||
* deterministic offset; a spec whose key has no known centroid is dropped.
|
||
* Without it no marker can be placed (all adds are dropped), which keeps the
|
||
* engine pure and lets tests inject their own data.
|
||
*
|
||
* `geoOf` (v0.28, #46) resolves a region key to its area + population
|
||
* (from public/region-geo.json via server/regions.ts), used to weight the
|
||
* end-of-turn recompute of each nation's derived stats as the
|
||
* area×population-weighted median of its regions' stats. Without it the
|
||
* recompute degrades to an unweighted median (every region weight 1) — still
|
||
* region-consistent, just unweighted. The engine stays pure either way.
|
||
*
|
||
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
|
||
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||
* the raw key. Pure either way.
|
||
*
|
||
* `resourceOf` (v0.36, #59) resolves a region key to its synthesized resource
|
||
* profile (`{ oil, iron, food, industry }`, from public/region-resources.json
|
||
* via server/regions.ts). It is used to scale each industry project's per-turn
|
||
* economy boost by the region's `industry` value (a region with a strong
|
||
* industry profile yields a bigger return). Without it the boost is the
|
||
* unscaled base value — the engine stays pure and deterministic either way.
|
||
*/
|
||
export function applyTurnResult(
|
||
state: GameState,
|
||
result: TurnResult,
|
||
validKeys?: ReadonlySet<string>,
|
||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||
regionNameOf?: (key: string) => string | undefined,
|
||
resourceOf?: (key: string) => RegionResources | undefined,
|
||
): GameState {
|
||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||
next.regionStats = next.regionStats ?? {};
|
||
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
|
||
// field starts empty (the turn then only ever adds to it).
|
||
next.industry = next.industry ?? [];
|
||
const actingTurn = state.turn; // this action resolves the current turn
|
||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||
// Committing a turn consumes the whole queued round — reset it for the next.
|
||
next.pending = [];
|
||
for (const ev of result.events) {
|
||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||
}
|
||
for (const r of result.relations) {
|
||
const a = next.nations[r.a];
|
||
const b = next.nations[r.b];
|
||
if (!a || !b) continue;
|
||
a.relations[r.b] = r.set;
|
||
b.relations[r.a] = r.set;
|
||
}
|
||
// --- Territory (v0.21, #16 P2: at most one owner per key;
|
||
// v0.22, #16 P3: a key may also be released to "no man's land") ---
|
||
//
|
||
// Two kinds of change, applied in this order:
|
||
//
|
||
// (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation
|
||
// CURRENTLY owns (exactly one owner) is removed from that owner, left
|
||
// ownerless, and added to `GameState.unowned` — the client renders it
|
||
// as the distinct no-man's-land grey. A key with zero owners (a preset
|
||
// never assigned it) is never released: doing so would fabricate a
|
||
// visible no-man's-land out of an ordinary unowned region. A key with
|
||
// two or more owners (pre-existing corruption) is never touched, and
|
||
// a key already in `unowned` is a no-op (it is already unowned).
|
||
//
|
||
// (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the
|
||
// releases (snapshot-first):
|
||
// - a key with exactly one owner that is not the gainer → a TRANSFER:
|
||
// removed from the old owner, granted to the gainer (v0.21 rules);
|
||
// - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and
|
||
// removed from `GameState.unowned` (it leaves no man's land);
|
||
// - a double claim (two or more distinct gainers) is rejected — the
|
||
// key stays as it is; a self-gain is a no-op; a pre-existing double
|
||
// ownership is never touched.
|
||
//
|
||
// Invariants after the turn: no key sits in two or more territories; a
|
||
// released key sits in none; `GameState.unowned` holds exactly the
|
||
// zero-owner keys that were released mid-game (not the preset-unassigned
|
||
// ones). "lose" entries remain deliberate no-ops: releasing is the
|
||
// `unowned` list's job, and a lone loss without a release would just
|
||
// orphan a key silently.
|
||
const valid = (k: string) => !validKeys || validKeys.has(k);
|
||
const unowned = new Set(state.unowned ?? []);
|
||
|
||
// (1) Releases: establish the new no-man's-land set before any claim.
|
||
for (const key of new Set(result.unowned)) {
|
||
if (!valid(key) || unowned.has(key)) continue;
|
||
const owners = Object.values(next.nations)
|
||
.filter((n) => n.territory.includes(key))
|
||
.map((n) => n.id);
|
||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||
unowned.add(key);
|
||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
||
// (v0.28, #46) a released region leaves no-man's land ownerless — its
|
||
// per-region stats leave the table with it (no man's land carries none;
|
||
// a later re-claim re-inits the entry from the gainer, see below).
|
||
delete next.regionStats[key];
|
||
}
|
||
|
||
// (2) Claims against the post-release ownership snapshot.
|
||
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (2+ owners; 0 = absent)
|
||
for (const n of Object.values(next.nations)) {
|
||
for (const key of n.territory) {
|
||
if (!valid(key)) continue;
|
||
const cur = ownerOf.get(key);
|
||
if (cur === undefined) ownerOf.set(key, n.id);
|
||
else if (cur !== '') ownerOf.set(key, '');
|
||
}
|
||
}
|
||
// Claim pairs, deduped: one (key, gainer) per pair even if the model emits
|
||
// duplicate entries; two DIFFERENT gainers for one key is a double claim.
|
||
const claimPairs = new Set<string>();
|
||
for (const t of result.territoryChanges) {
|
||
if (!next.nations[t.nation]) continue;
|
||
for (const key of new Set(t.gain)) {
|
||
if (!valid(key)) continue;
|
||
claimPairs.add(`${key}\u0000${t.nation}`);
|
||
}
|
||
}
|
||
const claimants = new Map<string, Set<string>>(); // key -> distinct gainers
|
||
for (const pair of claimPairs) {
|
||
const idx = pair.indexOf('\u0000');
|
||
const key = pair.slice(0, idx);
|
||
const gainer = pair.slice(idx + 1);
|
||
let set = claimants.get(key);
|
||
if (set === undefined) {
|
||
set = new Set();
|
||
claimants.set(key, set);
|
||
}
|
||
set.add(gainer);
|
||
}
|
||
for (const [key, claiming] of claimants) {
|
||
if (claiming.size !== 1) continue; // double claim: the key stays as it was
|
||
const gainer = [...claiming][0];
|
||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||
if (owner === '') continue; // pre-existing double ownership: never touched
|
||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||
// different sole owner) only proceeds when the attacker's derived military
|
||
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||
// re-claim of an unowned key (owner === undefined) is never gated. On
|
||
// repel, the key stays with its owner, a "repelled" event is emitted (the
|
||
// engine's own narrative — the model never sees it as an input), and the
|
||
// gainer is untouched; the engine never throws.
|
||
if (
|
||
owner !== undefined &&
|
||
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
|
||
) {
|
||
next.events.push(
|
||
makeEvent(
|
||
{
|
||
nation: gainer,
|
||
type: 'military',
|
||
title: 'Invasion repelled',
|
||
text: `${next.nations[gainer].name}'s invasion of ${
|
||
regionNameOf?.(key) ?? key
|
||
} was repelled by ${next.nations[owner].name}.`,
|
||
outcome: 'repelled',
|
||
},
|
||
actingTurn,
|
||
next.date,
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
for (const n of Object.values(next.nations)) {
|
||
const i = n.territory.indexOf(key);
|
||
if (i !== -1) n.territory.splice(i, 1);
|
||
}
|
||
next.nations[gainer].territory.push(key);
|
||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
||
// (v0.28, #46) a transferred region keeps its own per-region stats (the
|
||
// conquest is what makes a nation's regions diverge); only a re-claimed
|
||
// region that has no entry left (it was released earlier this turn) is
|
||
// re-inited as a copy of the gainer's current stats, so the recompute
|
||
// below never sees a hole in the table.
|
||
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
|
||
}
|
||
|
||
next.unowned = [...unowned];
|
||
|
||
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
|
||
// the regions, then every nation's stats recomputed as the derived
|
||
// area×population-weighted median of its regions ---
|
||
//
|
||
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
|
||
// applying to EVERY region the nation owns: each owned region's copy of
|
||
// that stat gets the same `set`/`add` (clamped to 0–100), and the
|
||
// nation-level stat is updated too (it is kept as-is for nations the
|
||
// recompute below does not touch — see (b) — and overwritten for the
|
||
// rest, where the regions are the source of truth).
|
||
//
|
||
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
|
||
// median of its owned regions' stats (via deriveNationStats, weighted
|
||
// by area×population when `geoOf` is available, unweighted otherwise).
|
||
// Two guards keep degenerate states sane: a nation with NO regions
|
||
// keeps its stats untouched (it has no regions to aggregate), and a
|
||
// nation none of whose regions has a stats entry keeps its stats
|
||
// too (deriveNationStats would return zeros, which would be wrong).
|
||
// This recompute is what makes a conquered region's foreign stats
|
||
// blend into the conqueror's aggregate and keeps the derived stats in
|
||
// lockstep with the per-region table across the turn's changes.
|
||
for (const c of result.stateChanges) {
|
||
const n = next.nations[c.nation];
|
||
if (!n) continue;
|
||
for (const key of n.territory) {
|
||
const rs = next.regionStats[key];
|
||
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
|
||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||
}
|
||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||
}
|
||
// (v0.29, #46 PR 2) Fine-grained, per-region stat changes: each targets ONE
|
||
// region's own stats entry (by its resolved region key). Applied AFTER the
|
||
// nation-wide stateChanges (so a region-level change overrides the
|
||
// nation-level one for that single region) and BEFORE the weighted-median
|
||
// recompute below, which propagates every region's final value up into its
|
||
// owning nation's derived `stats`. A region with no stats entry (unowned /
|
||
// no man's land) is a no-op — there is nothing to change.
|
||
for (const c of result.regionChanges) {
|
||
const rs = next.regionStats[c.region];
|
||
if (!rs) continue; // no entry (unowned) → no-op
|
||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||
}
|
||
for (const n of Object.values(next.nations)) {
|
||
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);
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
// --- 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
|
||
// post-claim ownership, in this order:
|
||
// (1) REMOVE: a marker whose id is in result.markers.remove is dropped
|
||
// (a listed-but-absent id is a no-op).
|
||
// (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to
|
||
// its region's current owner — a captured region follows the new
|
||
// owner, a region released to no man's land (or a key with 0 or 2+
|
||
// owners) becomes `null`. A marker always follows its region.
|
||
// (3) ADD: each new spec (in order) is placed at its region's centroid
|
||
// (injected via centroidOf) plus a small deterministic per-axis
|
||
// offset, owned by the region's post-turn owner, and stamped with
|
||
// the acting turn. Dropped (never throws): a region key that is not
|
||
// in validKeys or has no known centroid, an id that already exists
|
||
// (a re-add is idempotent — the existing marker stays), and any adds
|
||
// beyond MAX_MARKERS (the later ones).
|
||
{
|
||
const finalOwner = new Map<string, string | null>();
|
||
{
|
||
const owners = new Map<string, string[]>();
|
||
for (const n of Object.values(next.nations)) {
|
||
for (const key of n.territory) {
|
||
if (!valid(key)) continue;
|
||
const list = owners.get(key);
|
||
if (list) list.push(n.id);
|
||
else owners.set(key, [n.id]);
|
||
}
|
||
}
|
||
for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null);
|
||
}
|
||
const removeIds = new Set(result.markers.remove);
|
||
const markers: MapMarker[] = [];
|
||
for (const m of next.markers ?? []) {
|
||
if (removeIds.has(m.id)) continue;
|
||
markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null });
|
||
}
|
||
// Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit
|
||
// fraction of the marker id (stable across runs and platforms), so
|
||
// markers of one region scatter naturally instead of stacking.
|
||
const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => {
|
||
const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff;
|
||
return (frac * 2 - 1) * (spread / 2);
|
||
};
|
||
const round6 = (v: number): number => Math.round(v * 1e6) / 1e6;
|
||
for (const spec of result.markers.add) {
|
||
const id = markerId(spec.type, spec.name, spec.regionKey);
|
||
if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing
|
||
if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess
|
||
if (!valid(spec.regionKey)) continue; // unknown region key
|
||
const c = centroidOf?.(spec.regionKey);
|
||
if (!c) continue; // no centroid data for this key
|
||
markers.push({
|
||
id,
|
||
type: spec.type,
|
||
name: spec.name,
|
||
regionKey: spec.regionKey,
|
||
lat: round6(c.lat + jitter(id, 'lat', c.spread)),
|
||
lon: round6(c.lon + jitter(id, 'lon', c.spread)),
|
||
ownerNationId: finalOwner.get(spec.regionKey) ?? null,
|
||
placedTurn: actingTurn,
|
||
});
|
||
}
|
||
next.markers = markers;
|
||
}
|
||
|
||
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
|
||
// are appended to the pending list, which (unlike `pending`) SURVIVES
|
||
// across turns until the player confirms or rejects each one. `next` is a
|
||
// deep clone of `state`, so `next.pendingNationOps` is a copy of whatever
|
||
// the state already held (or undefined for a legacy row) — start from that
|
||
// and append the new proposals, defaulting a missing field to `[]`.
|
||
next.pendingNationOps = [...(next.pendingNationOps ?? []), ...result.nationOps];
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Immutably queue one item onto a round: append it to `pending` and return a new
|
||
* state. This never advances the turn or the date — that only happens when a
|
||
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
|
||
* snapshots can never clobber it client-side). Exported so the append-without-
|
||
* advance behavior is unit-testable, and reused by the /action and /diplomacy
|
||
* routes.
|
||
*/
|
||
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
|
||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||
next.version = (state.version ?? 0) + 1;
|
||
next.pending.push(item);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Immutably remove one item from the pending round by index and return a new
|
||
* state (bumping `version`). Returns `null` when the index is missing or not a
|
||
* valid, in-bounds integer position, so the route can answer 400 without
|
||
* mutating anything. Reused by the server's /queue/remove route.
|
||
*/
|
||
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
|
||
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
|
||
return null;
|
||
}
|
||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||
next.version = (state.version ?? 0) + 1;
|
||
next.pending.splice(index, 1);
|
||
return next;
|
||
}
|
||
|
||
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes,
|
||
// player confirms" model) ----------------------------------------------------
|
||
//
|
||
// A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see
|
||
// applyTurnResult) and waits there across turns. These functions resolve the
|
||
// player's decision on one pending proposal, by index. Like removeRoundItem
|
||
// they are pure: clone the state, mutate the clone, bump `version` once,
|
||
// return the new state — and return `null` for a bad index so the route can
|
||
// answer 400 without mutating anything.
|
||
|
||
/** True when `index` is a valid, in-bounds integer position in the pending list. */
|
||
function validPendingOpIndex(state: GameState, index: number): boolean {
|
||
const len = state.pendingNationOps?.length ?? 0;
|
||
return Number.isInteger(index) && index >= 0 && index < len;
|
||
}
|
||
|
||
/**
|
||
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
|
||
* write either the nation's stable `id` or its human-readable `name`) to a
|
||
* concrete nation id: an exact id match wins, then a unique case-insensitive
|
||
* name match. Returns `null` when the reference is empty, unknown, or
|
||
* ambiguous (two or more nations share that name) — every caller treats
|
||
* `null` as "no-op" and nothing here ever throws.
|
||
*/
|
||
export function resolveNationRef(state: GameState, ref: string): string | null {
|
||
const r = (ref ?? '').trim();
|
||
if (!r) return null;
|
||
if (state.nations[r]) return r; // exact id first
|
||
const matches = Object.values(state.nations).filter(
|
||
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||
);
|
||
return matches.length === 1 ? matches[0].id : null;
|
||
}
|
||
|
||
/**
|
||
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
|
||
* `region` — the LLM may write either the region's stable **key** or its
|
||
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
|
||
* when the reference is empty, unknown, or ambiguous (two or more regions
|
||
* share that name) — every caller treats `null` as "drop this change" and
|
||
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
|
||
* the set of all region keys and `nameOf` maps a key to its human name (both
|
||
* supplied by server/regions.ts).
|
||
*/
|
||
export function resolveRegionKey(
|
||
ref: string,
|
||
validKeys: ReadonlySet<string>,
|
||
nameOf: (key: string) => string | undefined,
|
||
): string | null {
|
||
const r = (ref ?? '').trim();
|
||
if (!r) return null;
|
||
if (validKeys.has(r)) return r; // exact key first
|
||
const lower = r.toLowerCase();
|
||
let match: string | null = null;
|
||
for (const k of validKeys) {
|
||
const name = nameOf(k);
|
||
if (name && name.trim().toLowerCase() === lower) {
|
||
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||
match = k;
|
||
}
|
||
}
|
||
return match;
|
||
}
|
||
|
||
/**
|
||
* Generate a fresh, unused nation id for a newly created nation (split
|
||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||
* each id it creates to `taken` as it goes, so ids stay unique within an
|
||
* op and are never reused from a removed nation.
|
||
*/
|
||
export function freshNationId(taken: ReadonlySet<string>): string {
|
||
for (let i = 1; ; i++) {
|
||
const id = `n${i}`;
|
||
if (!taken.has(id)) return id;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate a fresh, unused war id for a newly declared {@link War}
|
||
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
|
||
* it creates to `taken` as it goes, so ids stay unique within a turn and are
|
||
* never reused from an ended war.
|
||
*/
|
||
export function freshWarId(taken: ReadonlySet<string>): string {
|
||
for (let i = 1; ; i++) {
|
||
const id = `w${i}`;
|
||
if (!taken.has(id)) return id;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The baseline stats a freshly created nation starts with (split parts,
|
||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||
* a copy of the parent's stats (a small rump state is not the parent's
|
||
* military power).
|
||
*/
|
||
export const NEW_NATION_STATS: NationStats = {
|
||
military: 50,
|
||
economy: 50,
|
||
stability: 50,
|
||
population: 50,
|
||
};
|
||
|
||
/**
|
||
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
|
||
* the removed nations' ids from every remaining nation's `relations` map so
|
||
* no key dangles. (The client already degrades gracefully on a missing
|
||
* target, but consistent data is cleaner.)
|
||
*/
|
||
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
|
||
for (const n of Object.values(state.nations)) {
|
||
for (const id of removedIds) {
|
||
if (id in n.relations) delete n.relations[id];
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
||
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||
* (the route-level functions do that once per decision). It never throws:
|
||
* any unresolvable reference or malformed op is a silent no-op (the op is
|
||
* still removed by confirmNationOp — the world simply doesn't change).
|
||
*
|
||
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
|
||
* territory, its relations (keyed by id), and the player pointer all stay
|
||
* valid, so nothing downstream breaks; a player-supplied `edits.name`
|
||
* overrides the proposed `to` (an empty edit falls back to the proposal's
|
||
* own name).
|
||
*
|
||
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
|
||
* be at least two named parts, else no-op. Parts are processed in order;
|
||
* each receives the region keys of its `territory` that (a) the parent
|
||
* owns and (b) no earlier part claimed — the first part to name a key
|
||
* wins. Keys of the parent's territory that no part claimed go to part 0
|
||
* (in the parent's `territory` order), so the parent's territory is fully
|
||
* partitioned — no key is lost or ever held twice. Each part gets a
|
||
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
|
||
* copy of the parent's), plus a copy of the parent's relations; the
|
||
* parent is then removed. If the parent was the player's nation,
|
||
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
|
||
* into range, default 0); `edits.name` renames that same chosen part.
|
||
*
|
||
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
|
||
* resolve, at least two inputs must resolve to DISTINCT nations, and the
|
||
* survivor must be one of them — else no-op. The survivor keeps its id,
|
||
* keeps its own stats, gains the union of all inputs' territories
|
||
* (survivor's keys first, then each other input's unclaimed keys in its
|
||
* own order), and gains a union of the inputs' relations (on a third
|
||
* party the survivor's own view wins, else the first input's). The other
|
||
* inputs are removed (their ids dropped from everyone else's relations
|
||
* maps), and if the player led a removed input, `playerNationId` moves to
|
||
* the survivor. The merged name is `edits.name` (if non-empty), else
|
||
* `op.into`, else the survivor's current name.
|
||
*/
|
||
export function applyNationOp(
|
||
state: GameState,
|
||
op: NationOp,
|
||
edits?: { name?: string; playerPart?: number },
|
||
): void {
|
||
if (op.kind === 'rename') {
|
||
const id = resolveNationRef(state, op.nationRef);
|
||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||
const n = state.nations[id];
|
||
if (!n) return;
|
||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||
if (name) n.name = name;
|
||
return;
|
||
}
|
||
|
||
if (op.kind === 'split') {
|
||
const id = resolveNationRef(state, op.nationRef);
|
||
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
|
||
const parent = state.nations[id];
|
||
if (!parent) return;
|
||
const taken = new Set(Object.keys(state.nations));
|
||
const created: string[] = [];
|
||
const claimed = new Set<string>();
|
||
for (const part of op.parts) {
|
||
const newId = freshNationId(taken);
|
||
taken.add(newId);
|
||
const keys = (part.territory ?? [])
|
||
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
|
||
for (const k of keys) claimed.add(k);
|
||
const partNation: Nation = {
|
||
id: newId,
|
||
type: parent.type, // a part keeps the parent's form of state
|
||
name: part.name,
|
||
stats: { ...NEW_NATION_STATS },
|
||
territory: keys,
|
||
relations: { ...parent.relations },
|
||
};
|
||
state.nations[newId] = partNation;
|
||
created.push(newId);
|
||
}
|
||
// Keys the parent owned but no part claimed go to part 0 (in the
|
||
// parent's territory order) — nothing is lost or duplicated.
|
||
for (const k of parent.territory) {
|
||
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
|
||
}
|
||
// The player chooses which part is its successor (edits.playerPart,
|
||
// clamped into range; default 0) — only relevant when the parent was
|
||
// the player's nation; the same part also gets a player-edited name.
|
||
const partIdx =
|
||
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
|
||
if (state.playerNationId === id) state.playerNationId = created[partIdx];
|
||
const editedName = (edits?.name ?? '').trim();
|
||
if (editedName) state.nations[created[partIdx]].name = editedName;
|
||
delete state.nations[id];
|
||
dropRelationsTo(state, new Set([id]));
|
||
return;
|
||
}
|
||
|
||
// merge
|
||
const resolved: string[] = [];
|
||
for (const ref of op.nationRefs) {
|
||
const rid = resolveNationRef(state, ref);
|
||
if (rid === null) return; // any unresolvable input: no-op
|
||
resolved.push(rid);
|
||
}
|
||
const inputIds = new Set(resolved);
|
||
const survivorId = resolveNationRef(state, op.survivingRef);
|
||
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
|
||
const survivor = state.nations[survivorId];
|
||
if (!survivor) return;
|
||
// Territory: the union of all inputs — the survivor's keys first (stable
|
||
// order), then each other input's unclaimed keys in its own order.
|
||
const territory = [...survivor.territory];
|
||
const seen = new Set(territory);
|
||
for (const rid of resolved) {
|
||
for (const k of state.nations[rid].territory) {
|
||
if (!seen.has(k)) {
|
||
seen.add(k);
|
||
territory.push(k);
|
||
}
|
||
}
|
||
}
|
||
// Relations: the union of all inputs' relations; the first input to name a
|
||
// third party sets it, then the survivor's own view overwrites per key.
|
||
const relations: Record<string, RelationStatus> = {};
|
||
for (const rid of resolved) {
|
||
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
|
||
if (other === survivorId) continue; // never relate to oneself
|
||
if (!(other in relations)) relations[other] = status;
|
||
}
|
||
}
|
||
for (const [other, status] of Object.entries(survivor.relations)) {
|
||
if (other !== survivorId) relations[other] = status;
|
||
}
|
||
const removed = new Set<string>();
|
||
for (const rid of inputIds) {
|
||
if (rid === survivorId) continue;
|
||
delete state.nations[rid];
|
||
removed.add(rid);
|
||
}
|
||
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
|
||
state.playerNationId = survivorId;
|
||
}
|
||
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
|
||
if (name) survivor.name = name;
|
||
survivor.territory = territory;
|
||
survivor.relations = relations;
|
||
dropRelationsTo(state, removed);
|
||
}
|
||
|
||
/**
|
||
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
||
* effect, optionally with a player-edited name; a split partitions the
|
||
* nation's territory into the new parts, the player picking which part is
|
||
* its successor via `edits.playerPart`; a merge folds the inputs into the
|
||
* survivor, optionally renamed via `edits.name`) and remove it from
|
||
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
|
||
* index, so the route answers 400 without mutating. Reused by the
|
||
* /nation-op/confirm route.
|
||
*/
|
||
export function confirmNationOp(
|
||
state: GameState,
|
||
index: number,
|
||
edits?: { name?: string; playerPart?: number },
|
||
): GameState | null {
|
||
if (!validPendingOpIndex(state, index)) return null;
|
||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||
next.version = (state.version ?? 0) + 1;
|
||
const op = next.pendingNationOps[index];
|
||
applyNationOp(next, op, edits);
|
||
next.pendingNationOps.splice(index, 1);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Reject (drop) a pending nation-op proposal by index without applying it,
|
||
* bumping `version`. Returns `null` for an invalid index, so the route
|
||
* answers 400 without mutating. Reused by the /nation-op/reject route.
|
||
*/
|
||
export function rejectNationOp(state: GameState, index: number): GameState | null {
|
||
if (!validPendingOpIndex(state, index)) return null;
|
||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||
next.version = (state.version ?? 0) + 1;
|
||
next.pendingNationOps.splice(index, 1);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
|
||
* validate, retry once with a nudge if the result is empty/invalid, and — if
|
||
* it is still invalid — emit a generic "unclear" event and advance. The
|
||
* committed state has `pending` cleared. Never throws on bad model output;
|
||
* only the underlying transport/network can throw.
|
||
*
|
||
* When `getNeighbours` is provided AND the LLM client implements
|
||
* `chatWithTools` (v0.21, #16 P2), the turn runs the bounded function-
|
||
* calling loop with the getNeighbours tool (the model may verify land
|
||
* borders before committing a territory transfer) and the user prompt gains
|
||
* the player's `frontier` field. Otherwise it falls back to a plain chat()
|
||
* call — same as before the tool existed.
|
||
*/
|
||
export async function runTurn(opts: {
|
||
llm: LlmClient;
|
||
preset: Preset;
|
||
state: GameState;
|
||
items: RoundItem[];
|
||
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
|
||
regionReference?: Record<string, string>;
|
||
/** Set of valid region keys; territory changes with other keys are ignored. */
|
||
validRegionKeys?: ReadonlySet<string>;
|
||
/**
|
||
* Region adjacency resolver (keys -> bordering keys, from the precomputed
|
||
* manifest). Its presence (together with the client's chatWithTools)
|
||
* switches the turn onto the getNeighbours tool path.
|
||
*/
|
||
getNeighbours?: (keys: string[]) => string[];
|
||
/**
|
||
* (v0.27, #44) Region-key -> precomputed centroid resolver (from
|
||
* public/region-centroids.json). Present, the turn's new markers can be
|
||
* placed at their region's centroid; absent, marker adds are dropped and
|
||
* the existing markers' owners still get recomputed.
|
||
*/
|
||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
||
/**
|
||
* (v0.28, #46) Region-key -> area/population resolver (from
|
||
* public/region-geo.json). Present, the end-of-turn recompute of each
|
||
* nation's derived stats weights each owned region by area×population;
|
||
* absent, the recompute degrades to an unweighted (uniform) median —
|
||
* still region-consistent, just unweighted.
|
||
*/
|
||
geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||
/**
|
||
* (v0.29, #46 PR 2) Region ref resolver: resolves a `regionChanges` entry's
|
||
* `region` (a region key, preferred, or a human-readable name) to a concrete
|
||
* region key, or `null` when the ref is unknown or ambiguous (two or more
|
||
* regions share the name) — the engine drops such entries. Present, the
|
||
* model may name a region by its name and it is resolved before apply;
|
||
* absent, refs are kept as-is (a name that is not a key simply no-ops in
|
||
* applyTurnResult's regionStats lookup).
|
||
*/
|
||
regionKeyResolver?: (ref: string) => string | null;
|
||
/**
|
||
* (v0.30, #50) Region-key -> human display name resolver (the geo
|
||
* `properties.name`, from server/regions.ts). Present, the engine's
|
||
* "invasion repelled" event names the region by its display name; absent,
|
||
* it falls back to the raw region key. Pure either way.
|
||
*/
|
||
regionNameOf?: (key: string) => string | undefined;
|
||
/**
|
||
* (v0.36, #59) Region-key -> synthesized resource profile resolver (from
|
||
* public/region-resources.json via server/regions.ts). Present, each
|
||
* industry project's per-turn economy boost is scaled by its region's
|
||
* `industry` value, and each nation's aggregate resource profile is carried
|
||
* in the user prompt; absent, industry boosts use the unscaled base value
|
||
* and the profile is omitted.
|
||
*/
|
||
resourceOf?: (key: string) => RegionResources | undefined;
|
||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf, resourceOf } = opts;
|
||
const chatWithTools = llm.chatWithTools;
|
||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||
const system = buildSystemPrompt(preset, useTools);
|
||
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
||
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
|
||
// list on a fresh game) so it knows which unowned regions can be re-claimed.
|
||
// (v0.27, #44) likewise the current map markers, so it can reference their
|
||
// stable ids in `markers.remove` (or leave them untouched).
|
||
const markers = (state.markers ?? []).map((m) => ({
|
||
id: m.id,
|
||
type: m.type,
|
||
name: m.name,
|
||
regionKey: m.regionKey,
|
||
owner: m.ownerNationId,
|
||
}));
|
||
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
|
||
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
|
||
// ended wars stay in GameState.wars as history but are not surfaced.
|
||
const wars = (state.wars ?? [])
|
||
.filter((w) => w.status === 'active')
|
||
.map((w) => ({
|
||
id: w.id,
|
||
sides: w.sides,
|
||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||
}));
|
||
// (v0.34.0, #57) the player's current relations to every other nation, so the
|
||
// model sees the ladder its `information` / `proxy` hybrid ops nudge (and the
|
||
// starting point before an `exposed` backfire). Always emitted ({} when the
|
||
// player has no relations yet).
|
||
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
||
// (v0.36, #59) each nation's AGGREGATE resource profile (the area×population-
|
||
// weighted mean of the resource profiles of the regions it owns). Carried in
|
||
// the user prompt ONLY when the resource manifest is loaded (resourceOf
|
||
// present); otherwise omitted (undefined) so the prompt — and the stub —
|
||
// stay identical to the no-manifest case.
|
||
const nationResources =
|
||
resourceOf === undefined
|
||
? undefined
|
||
: Object.fromEntries(
|
||
Object.values(state.nations).map((n) => [
|
||
n.id,
|
||
deriveNationResources(n.territory, resourceOf, geoOf),
|
||
]),
|
||
);
|
||
// (v0.37.0, #60) each nation's nuclear capability (its `nuclear` flag OR a
|
||
// current `rocket` marker), so the model knows who can arm/strike and who
|
||
// can MAD-retaliate. Always emitted (computed from the PRE-turn markers).
|
||
const nuclearCapability = Object.fromEntries(
|
||
Object.values(state.nations).map((n) => [n.id, isNuclearCapable(n, state.markers ?? [])]),
|
||
);
|
||
const user = buildUserPrompt(
|
||
state,
|
||
items,
|
||
regionReference,
|
||
frontier,
|
||
state.unowned ?? [],
|
||
markers,
|
||
wars,
|
||
relations,
|
||
// (v0.35.0, #58) the active trade / embargo lists, so the model can
|
||
// reference — and `lift` — what is currently in force.
|
||
state.trades,
|
||
state.embargoes,
|
||
// (v0.36, #59) the aggregate resource profiles (undefined without the
|
||
// manifest) + the active industry projects, so the model knows which
|
||
// regions already host a project and which nations are rich in which
|
||
// resource.
|
||
nationResources,
|
||
state.industry ?? [],
|
||
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
|
||
nuclearCapability,
|
||
);
|
||
const nudge =
|
||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||
|
||
/**
|
||
* One model call with the standard turn sampling: the bounded
|
||
* function-calling loop when available, else a plain chat(). The nudge
|
||
* re-send below always starts a FRESH history (no tool context carried
|
||
* over).
|
||
*/
|
||
const call = async (history: WireMessage[]): Promise<string> => {
|
||
if (useTools) {
|
||
return await chatWithTools(
|
||
history,
|
||
[GET_NEIGHBOURS_TOOL],
|
||
makeNeighboursExecutor(getNeighbours, validRegionKeys),
|
||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||
);
|
||
}
|
||
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
||
};
|
||
|
||
let result = parseTurnResult(
|
||
await call([
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
]),
|
||
regionKeyResolver,
|
||
);
|
||
|
||
if (result === null || !isMeaningful(result)) {
|
||
result = parseTurnResult(
|
||
await call([
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
{ role: 'assistant', content: '' },
|
||
{ role: 'user', content: nudge },
|
||
]),
|
||
regionKeyResolver,
|
||
);
|
||
}
|
||
|
||
if (result === null || !isMeaningful(result)) {
|
||
result = {
|
||
date: addDays(state.date, 2),
|
||
events: [genericUnclearEvent(state)],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] },
|
||
industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] },
|
||
leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
}
|
||
|
||
return {
|
||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf, resourceOf),
|
||
result,
|
||
};
|
||
}
|
||
|
||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||
|
||
export function buildAdvisorPrompt(
|
||
state: GameState,
|
||
prompt?: string,
|
||
): { system: string; user: string } {
|
||
const player = state.nations[state.playerNationId];
|
||
const system = [
|
||
`${ADVISOR_MARKER} You are a trusted strategic advisor to ${player?.name ?? 'the player'} (${state.playerNationId}).`,
|
||
'Given the current world state, brainstorm 3-5 concrete, in-character action ideas the player could take this turn.',
|
||
'Respond with ONLY a JSON array of 3-5 short strings. No object, no markdown fences, no prose.',
|
||
].join('\n');
|
||
const user = JSON.stringify({
|
||
player: player ? nationForPrompt(player) : null,
|
||
date: state.date,
|
||
turn: state.turn,
|
||
prompt: prompt ?? null,
|
||
});
|
||
return { system, user };
|
||
}
|
||
|
||
/** Parse advisor output (a JSON array of strings) into a clean suggestion list. */
|
||
export function parseAdvisor(raw: string): string[] {
|
||
const t = stripFence(raw);
|
||
const a = t.indexOf('[');
|
||
const b = t.lastIndexOf(']');
|
||
if (a !== -1 && b > a) {
|
||
try {
|
||
const arr = JSON.parse(t.slice(a, b + 1));
|
||
if (Array.isArray(arr)) {
|
||
const out = arr
|
||
.map((x) =>
|
||
typeof x === 'string'
|
||
? x
|
||
: x && typeof x === 'object'
|
||
? String((x as Record<string, unknown>).title ?? (x as Record<string, unknown>).text ?? '')
|
||
: '',
|
||
)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 2);
|
||
if (out.length) return out.slice(0, 5);
|
||
}
|
||
} catch {
|
||
/* fall through to line-splitting */
|
||
}
|
||
}
|
||
return t
|
||
.split(/\n+/)
|
||
.map((l) => l.replace(/^[-*\d.)\s]+/, '').trim())
|
||
.filter((l) => l.length > 3)
|
||
.slice(0, 5);
|
||
}
|
||
|
||
/** Ask the LLM for 3-5 action ideas; degrades to a canned line if the model fails. */
|
||
export async function askAdvisor(
|
||
llm: LlmClient,
|
||
state: GameState,
|
||
prompt?: string,
|
||
): Promise<string[]> {
|
||
const { system, user } = buildAdvisorPrompt(state, prompt);
|
||
const raw = await llm.chat([
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS });
|
||
const out = parseAdvisor(raw);
|
||
return out.length
|
||
? out
|
||
: ['The council is divided; no clear course of action is advisable this turn.'];
|
||
}
|