// Client <-> server protocol types + a few shared pure helpers (single source of // truth). The LLM is asked to return exactly the `TurnResult` shape; the engine // validates/clamps it before applying it to a GameState. import type { EventEntry, EventOutcome, EventType, GameState, MarkerType, Nation, NationStats, RelationStatus, RegionStats, StatKey, } from './types'; import { MARKER_TYPES, STAT_KEYS } from './types'; /** A numeric change to one of a nation's 0–100 stats. */ export type StateChange = | { nation: string; stat: StatKey; op: 'set'; value: number } | { nation: string; stat: StatKey; op: 'add'; value: number }; /** * (v0.29, #46 PR 2) A numeric change to one of **one region's** 0–100 stats — * the fine-grained, per-region lever that complements the nation-wide * {@link StateChange}. `region` names the target by its **region key** * (preferred) or its human-readable **name** (the engine resolves it * case-insensitively, mirroring `resolveNationRef`); after validation it holds * the resolved **region key**. `op` is `set` (replace the value) or `add` * (delta, clamped to 0–100 by the engine). */ export type RegionStatChange = { /** The target region's key (preferred) or name (resolved by the engine). */ region: string; stat: StatKey; op: 'set' | 'add'; value: number; }; /** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */ export interface RelationChange { a: string; b: string; set: RelationStatus; } /** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */ export interface TerritoryChange { nation: string; gain: string[]; lose: string[]; } // --- Wars (v0.33.0, #56): first-class war entities -------------------------- // // A war is an explicit world entity (see types.War), not just a boolean // relation: the LLM DECLARES one (it becomes an active War with an engine- // assigned stable id) and later ENDS one by id. The engine derives the // symmetric `war` relation from the set of ACTIVE wars on every turn (two // nations are in a `war` relation iff they are both belligerents of at least // one active war) and downgrades it to `hostile` when the war ends — so the // relation can never drift apart from the war state. /** * (v0.33.0, #56) Declare a new war this turn. `sides` names every * belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`, * so a unique name also works); any two distinct sides end the turn in a * `war` relation. `aims` are optional short war aims for the narrative. The * engine assigns the stable id, the declared turn/date, drags in the * mutual-defense allies of each belligerent, and drops the op when fewer than * two sides resolve (never throws). */ export interface WarDeclareOp { /** The belligerent nations (≥2 distinct ids or unique names). */ sides: string[]; /** Optional short war aims (narrative only). */ aims?: string[]; } /** * (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id * (the model sees the current active wars in the user payload's `wars` * field). `outcome` is `armistice` (a negotiated stop) or `capitulation` * (one side surrenders); the war keeps its id/sides and stays in * `GameState.wars` as history with that final status. `terms` are optional * short peace terms (narrative only — the model narrates the details in * `events`). An unknown id, or a war that is already ended, is a no-op * (never throws). */ export interface WarEndOp { /** The stable id of the active war to end (from the user payload). */ id: string; outcome: 'armistice' | 'capitulation'; /** Optional short peace terms (narrative only). */ terms?: string; } // --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM // proposes, player confirms" model) ------------------------------------------ // // Unlike stat / relation / territory deltas, a NationOp proposed in a turn is // NOT applied by that turn. It is stored as a *pending proposal* in // `GameState.pendingNationOps` and only takes effect when the player confirms // it (optionally editing it) or is dropped when the player rejects it. All // three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b). /** * The identity reference a nation is addressed by inside a {@link NationOp}. * The LLM may use either the nation's stable `id` (the `nations` record key, * e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves * it to a concrete `nationId` (id first, then a unique name match — see the * engine's `resolveNationRef`). */ /** * (v0.32.0, #54) A **non-military regime change** — a coup or revolution that * renames a nation to the name of the new regime. Unlike a {@link RenameOp} * (a player-confirmed diplomatic/political name change), a takeover is * **automatic and in-world**: `applyTurnResult` applies it directly (no * pending queue, no player confirm). Only `Nation.name` changes — the stable * `id`, territory, relations (keyed by id), stats, and `playerNationId` * (which references the id) all stay intact, so the player keeps leading the * nation under its new regime. `nationRef` is resolved exactly like the * `nationRef` of a NationOp (id first, then a case-insensitive unique name * match — see `resolveNationRef`). */ export interface Takeover { /** The target nation's id or current name (resolved by `resolveNationRef`). */ nationRef: string; /** The name of the new regime to set as the nation's display name. */ newRegimeName: string; } /** * A **rename** proposal: change a nation's human-readable `name`. Only the * `name` changes — the stable `id`, its territory keys, its relations (keyed * by id), and the player pointer are all left untouched, so nothing downstream * breaks. `to` is the new display name. */ export interface RenameOp { kind: 'rename'; /** The target nation's id or current name (see the note above). */ nationRef: string; /** The new display name to apply when the player confirms. */ to: string; } /** * A **split** proposal (v0.24, #16 P4b). Describes the new nations a source * nation is divided into. On confirm the source is dissolved: each part gets a * fresh id, the region keys it was given (restricted to keys the source * actually owns — first claim wins), fresh baseline stats, and a copy of the * source's relations; any of the source's unclaimed keys go to `parts[0]`. * When the source is the player's own nation the player chooses which part * they lead (the confirm request's `edits.playerPart`, default `parts[0]`). */ export interface SplitOp { kind: 'split'; /** The id/name of the nation being split. */ nationRef: string; /** The new nations created from the split. */ parts: Array<{ name: string; territory?: string[] }>; } /** * A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single * surviving nation. On confirm the survivor keeps its id (renamed to `into`, * or to the confirm request's `edits.name`), its territory becomes the union * of all inputs' territories, it keeps its own stats, and it inherits the * union of all inputs' relations (its own winning per third party, else the * first input's); every non-survivor input is removed. If the player's nation * was a non-survivor input the player pointer is re-pointed to the survivor. */ export interface MergeOp { kind: 'merge'; /** The id/name of every nation folded into the result. */ nationRefs: string[]; /** The id/name of the nation that survives and keeps its identity. */ survivingRef: string; /** The name to give the merged nation. */ into: string; } /** * A structural operation on the set of nations that the LLM may propose in a * turn (`TurnResult.nationOps`). See the section comment above for the hybrid * propose→confirm semantics. All three kinds are implemented: rename (P4a), * and split / merge (P4b, v0.24). */ export type NationOp = RenameOp | SplitOp | MergeOp; /** One narrative event the LLM proposes for this turn. */ export interface TurnEventSpec { nation: string; type: EventType; title: string; text: string; outcome: EventOutcome; } // --- Map markers (v0.27, #44) ------------------------------------------------- // // The LLM places named point markers (cities, factories, armies, ports, // capitals, fleets, rocket launch sites, gatherings) anchored to a region // key. It never sees or sets coordinates: the engine derives the position // from the region's precomputed centroid (public/region-centroids.json) plus a // small deterministic offset, and it recomputes each marker's owner from the // region's current owner on every turn (a released region → null). // // The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit → // base 26, the same scheme as the region keys' 4-char suffix) derived from // (type, name, regionKey) — so re-adding an identical marker is idempotent // (same id, skipped) and removing by id is unambiguous. /** The spec the LLM emits to place a marker: type, name and the region it sits in. */ export interface MarkerSpec { type: MarkerType; name: string; regionKey: string; } /** * One existing marker as the user prompt presents it to the model (v0.27, * #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`), * type, name, region key, and the id of the nation currently owning its * region (`null` when the region is unowned / no man's land). */ export interface MarkerInfo { id: string; type: MarkerType; name: string; regionKey: string; owner: string | null; } /** * (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model * (the `wars` field): its stable `id` (the model echoes it in * `TurnResult.wars.end` to end it), its belligerent `sides`, and optional * short `aims`. Ended wars are NOT included — they stay in `GameState.wars` * as history, but the model only needs to address the active ones. */ export interface WarInfo { id: string; sides: string[]; aims?: string[]; } /** * The per-type presentation of a marker: its display label (tooltip / * dossier), its color, and its glyph character (a symbol-layer text label — * a small, font-safe character in front of the name). Pure + shared so the * client's marker layer and any server-side consumers agree on the look. */ export const MARKER_TYPE_INFO: Record = { city: { label: 'City', color: '#cbd5e1', glyph: '●' }, factory: { label: 'Factory', color: '#fb923c', glyph: '■' }, army: { label: 'Army', color: '#ef4444', glyph: '▲' }, port: { label: 'Port', color: '#38bdf8', glyph: '◆' }, capital: { label: 'Capital', color: '#fbbf24', glyph: '★' }, fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' }, rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' }, gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' }, }; /** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */ export const MAX_MARKERS = 400; /** * FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use * (see scripts/build-region-keys.mjs). Exported so the engine can derive * deterministic per-axis jitter fractions from it (marker placement, v0.27 #44). */ export function fnv1a32(str: string): number { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h >>> 0; } /** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */ function code4(h: number): string { let out = ''; for (let i = 0; i < 4; i++) { out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26)); } return out; } /** * The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash * of `(type, name, regionKey)` (NUL-separated so no field boundary is * ambiguous). Deterministic across runs and platforms (pure string math), so * the same (type, name, regionKey) always maps to the same id — re-adding is * idempotent, removing by id is unambiguous. */ export function markerId(type: MarkerType, name: string, regionKey: string): string { return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`)); } /** True when `t` is one of the eight known marker types (v0.27, #44). */ export function isMarkerType(t: unknown): t is MarkerType { return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t); } // --- Hybrid war (v0.34.0, #57): non-kinetic operations --------------------- // // The LLM can run covert, non-kinetic operations each turn: spies, sabotage, // assassination attempts, cyberattacks, economic pressure, disinformation, and // proxy support. Unlike kinetic conquest (territory), these never move borders // — they dip a target's stats or sour a relation. The LLM declares an intended // outcome (`success` or `exposed`); the engine is fully deterministic (no // hidden randomness) and acts only on that declaration: a successful op applies // its small, per-type effect, and an "exposed" op backfires (no effect, the // actor's relation with the target sours by one step, and an auto // diplomatic/failure event is emitted). /** The seven covert operation types a hybrid op can be (v0.34.0, #57). */ export type HybridOpType = | 'spy' | 'sabotage' | 'assassinate' | 'cyber' | 'economic' | 'information' | 'proxy'; /** All hybrid op types, in canonical order (the LLM contract + validator). */ export const HYBRID_OP_TYPES: HybridOpType[] = [ 'spy', 'sabotage', 'assassinate', 'cyber', 'economic', 'information', 'proxy', ]; /** * (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn * (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique * name — resolved by the engine's `resolveNationRef`). `target` is EITHER a * nation (id or unique name) or a specific region key; a region target hits * that region's sole owner (a region with zero or 2+ owners can't be * targeted). `detail` is a short narrative (the LLM may also narrate the op in * `events`). `outcome` is the LLM's *intended* result: `success` (the default) * applies the op's deterministic effect, while `exposed` backfires it — the * effect is NOT applied, the actor's relation with the target sours by one * step, and an auto diplomatic/failure event is emitted. */ export interface HybridOp { /** The operation type (see {@link HybridOpType}). */ type: HybridOpType; /** The acting nation's id or current name (resolved by `resolveNationRef`). */ from: string; /** The target: a nation's id/name, or a region key (hits that region's owner). */ target: string; /** Short narrative detail (optional). */ detail?: string; /** The LLM's intended result; `exposed` backfires the op. Defaults to success. */ outcome?: 'success' | 'exposed'; } /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; events: TurnEventSpec[]; stateChanges: StateChange[]; /** * (v0.29, #46 PR 2) Fine-grained, **per-region** stat changes: each targets a * single region's 0–100 stats (by region key or name) and complements the * nation-wide `stateChanges` (which apply to *all* of a nation's regions). * The engine resolves each `region` to a key (key first, then a * case-insensitive unique name match) and applies it to * `GameState.regionStats` only for regions that have an entry (owned); an * unowned / no-man's-land region is a no-op. Empty on turns where the model * changes no individual region. Required (default `[]`). */ regionChanges: RegionStatChange[]; relations: RelationChange[]; territoryChanges: TerritoryChange[]; /** * (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" — * this turn. The engine removes each key from its current (sole) owner so it * ends the turn ownerless and marks it in `GameState.unowned`; the LLM * narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is * NOT expressed here: a nation re-claims an unowned region with an ordinary * `gain` on that key. Only keys a nation currently owns can be released; * empty on normal turns. */ unowned: string[]; /** * (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this * turn. These are NOT applied by the turn: each is appended to * `GameState.pendingNationOps` as a proposal awaiting the player's * confirm/reject. All three kinds (rename, split, merge) are implemented — * a confirmed rename changes `Nation.name`; a confirmed split dissolves the * source into its parts; a confirmed merge folds its inputs into the * survivor. Empty on turns where the model proposes none. */ nationOps: NationOp[]; /** * (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take * effect **this turn** — each renames the target nation to `newRegimeName`. * Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic * and in-world**: it is applied directly by `applyTurnResult` (like territory * / state changes), not queued to `pendingNationOps`. Only the nation's * `name` changes; the id, territory, relations, stats, and `playerNationId` * are all preserved. Empty on turns where no takeover occurs. Required * (default `[]`). */ takeovers: Takeover[]; /** * (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars * (≥2 distinct belligerents; the engine assigns the stable id / declared * turn / date, drags in the mutual-defense allies of each belligerent, and * derives the `war` relation between every pair of sides); `end` closes an * existing ACTIVE war by id (`armistice` = a negotiated stop, * `capitulation` = one side surrenders), which downgrades the relation the * war had derived back to `hostile`. Both sub-fields are required and * default `[]`; a turn with war activity is meaningful (it survives the * retry/nudge path). The model sees the current active wars (id + sides + * aims) in the user payload's `wars` field, so it can `end` by id. */ wars: { declare: WarDeclareOp[]; end: WarEndOp[] }; /** * (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies, * sabotage, assassination attempts, cyberattacks, economic pressure, * disinformation, and proxy support. Unlike `nationOps` (player-confirmed * proposals) these are applied directly by the turn, like territory / state * changes: each op's deterministic per-type effect is applied by * `applyTurnResult` (a small stat dip on the target's regions, or a * relation nudge toward hostile for `information` / `proxy`); an `exposed` * op backfires (no effect, the actor's relation with the target sours by * one step, and an auto diplomatic/failure event is emitted). They never * move borders — sole ownership is untouched. Required (default * `{ ops: [] }`). */ hybrid: { ops: HybridOp[] }; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small * deterministic offset, sets the owner to the region's post-turn owner, * derives the stable id, and drops unknown types/region keys, duplicates, * and adds beyond MAX_MARKERS); `remove` drops markers by their stable id * (a no-op for an id that is not present). Both required, `[]` on turns * with no marker activity. */ markers: { add: MarkerSpec[]; remove: string[] }; } /** Per-region map payload: region key -> owning nation + its stable color. */ export interface RegionOwner { color: string; owner: string; ownerId: string; } /** * The authoritative ownership map: a **region key** (see `public/region-keys.json`) * -> the owning nation + its stable color. Owned at sub-region granularity (v0.19, * #16 P1); a key absent from this map is unowned (rendered grey). */ export type RegionMap = Record; /** * Look up the owner record from the authoritative RegionMap by a RegionMap key. * The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one * per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as * `moselle-rygy`, never a raw feature id such as `FR-57` and never a * parent-country ISO_A3. Returns `undefined` for an empty/missing key or an * unowned region. * * Pure + shared: the primitive "key → owner" lookup. The *map feature → owner* * resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below, * which reads the feature's stamped `properties.regionKey` — never the repacked * top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) * and never the sub-region id. */ export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined { if (!key) return undefined; return regions[key]; } /** * Resolve the owning-nation record for a **rendered map feature** from the * authoritative RegionMap. The choropleth renders sub-national regions, and the * RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1), * so a feature's owner is found via its `properties.regionKey` — the region key * the client stamps from `public/region-keys.json` by the feature's stable id * (see WorldMap) — and then `regionOwner`. This is the single shared source of * truth for that resolution, so the "use `regionKey`, never the sub-region `id` * / repacked `feature.id`" rule lives in one tested place rather than in the * React component. * * Returns `undefined` for a missing feature/regionKey or an unowned region. */ export function mapFeatureOwner( regions: RegionMap, feature: { properties?: { regionKey?: unknown } } | null | undefined, ): RegionOwner | undefined { const regionKey = feature?.properties?.regionKey; return regionOwner(regions, typeof regionKey === 'string' ? regionKey : ''); } /** Server -> client Server-Sent Events payload (see server/index.ts /sse). */ export type SseMessage = | { type: 'state'; state: GameState } | { type: 'turn'; state: GameState; result: TurnResult } | { type: 'error'; message: string }; // --- API request / response shapes (REST) --- export interface CreateGameRequest { presetId: string; playerNationId: string; } export interface CreateGameResponse { game: GameState; } /** Queue a free-text player action onto the pending round (no turn advance). */ export interface AddActionRequest { text: string; } export interface AddActionResponse { game: GameState; } /** Queue a diplomatic message onto the pending round (no turn advance). */ export interface AddDiplomacyRequest { to: string; message: string; } export interface AddDiplomacyResponse { game: GameState; } /** * "Jump forward" — commit the whole pending round to the model in a single * turn. The body is empty; everything being committed lives in `game.pending`. */ export type JumpRequest = Record; export interface TurnResponse { state: GameState; result: TurnResult; } /** Remove one queued item from the pending round, by its index in `game.pending`. */ export interface RemoveQueueItemRequest { index: number; } export interface RemoveQueueItemResponse { game: GameState; } /** * (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its * index in `game.pendingNationOps`. A rename takes effect (the player may * override the proposed name via `edits.name`); a split and a merge are fully * applied (v0.24 P4b). No LLM call, no turn advance. */ export interface ConfirmNationOpRequest { index: number; /** * Optional player edits applied on confirm. * - `name`: the final display name — overrides a rename's `to`, a merge's * `into`, or (for a split) the name of the part the player leads. * - `playerPart`: for a split of the player's own nation, which part (index * into the op's `parts`) the player will lead (default `0` when missing or * out of range, so the player is never orphaned). */ edits?: { name?: string; playerPart?: number }; } export interface ConfirmNationOpResponse { game: GameState; } /** * (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index * in `game.pendingNationOps` without applying it. No LLM call, no turn advance. */ export interface RejectNationOpRequest { index: number; } export interface RejectNationOpResponse { game: GameState; } export interface AdvisorRequest { /** Optional prompt to steer the brainstorm. */ prompt?: string; } export interface AdvisorResponse { suggestions: string[]; } export interface GameSummary { id: string; presetId: string; presetTitle: string; playerNationId: string; turn: number; date: string; } /** Response for deleting a saved game (see `DELETE /api/games/:id`). */ export interface DeleteGameResponse { deleted: boolean; } /** * Deterministic, stable per-nation color for the choropleth. Hashes the nation id * to a hue; sat/light tuned for the dark command-center theme. Pure + shared so * the server's RegionMap and any client-side coloring always agree. */ export function nationColor(id: string): string { let h = 2166136261; for (let i = 0; i < id.length; i++) { h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0; } const hue = h % 360; return `hsl(${hue} 58% 46%)`; } // --- Combat: the military hard gate (v0.30, #50) ------------------------------ // // The one rule the engine enforces on the player's behalf instead of trusting // the LLM: a contested conquest only succeeds when the attacker is not MORE // than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes // the transfer; the engine decides whether it holds (see `applyTurnResult`). // Re-claims of unowned / no-man's-land regions are never gated. /** * (v0.30, #50) The margin of military superiority an attacker must NOT be more * than below the defender for a contested conquest to succeed. A gap of exactly * this many points is still enough to win; a gap one larger is repelled. */ export const CONQUEST_MILITARY_MARGIN = 15; /** * (v0.30, #50) Hard military gate for a **contested** conquest (a region * currently owned by a different nation). Returns `true` — the invasion * proceeds — unless the attacker's military is MORE than `margin` below the * defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}), * is enough to win, while a gap larger than `margin` is repelled. * * The comparison is intentionally one-sided (it only protects the *defender* * from an overwhelming odds gap, not the attacker from a small disadvantage): * `attacker < defender - margin` → repel; everything else (equal, within the * margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the * prompt's frontier-balance hint all agree on the outcome. */ export function canConquer( attackerMil: number, defenderMil: number, margin: number = CONQUEST_MILITARY_MARGIN, ): boolean { return !(attackerMil < defenderMil - margin); } // --- No man's land (v0.22, #16 P3) — the map presentation contract --- // // An unowned region has two distinct looks, and the difference is NOT in the // RegionMap (a key absent there is simply unowned): it is decided by the // client from `GameState.unowned` (the keys released mid-game). The constants // and the one decision function live here — shared and pure — so the // client's choropleth is the only consumer and the color contract is testable // from the server-side tests (shared/*.test.ts is not in the vitest glob). /** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */ export const NO_MANS_LAND_FILL = '#3f3f46'; /** The border of a no-man's-land region (brighter than a plain unowned one). */ export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)'; /** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */ export const UNOWNED_FILL = 'rgba(148,163,184,0.05)'; /** The border of a plain unowned region. */ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)'; /** The border of an owned region (brighter than unowned). */ export const OWNED_STROKE = 'rgba(255,255,255,0.30)'; /** The hover-tooltip sub-line for a region with no owning nation. */ export function unownedTooltipLabel(isNoMansLand: boolean): string { return isNoMansLand ? "No man's land" : 'No owner'; } /** * The fill + border for one sub-region, given its RegionMap entry * (`undefined` = unowned) and whether its key is no man's land (released * mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section * comment above for why the distinction is not encoded in the RegionMap. */ export function regionFillStyle( owner: RegionOwner | undefined, isNoMansLand: boolean, ): { fill: string; stroke: string } { if (owner) return { fill: owner.color, stroke: OWNED_STROKE }; return isNoMansLand ? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE } : { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE }; } /** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */ export function makeEvent( spec: TurnEventSpec, turn: number, date: string, ): EventEntry { return { id: `${turn}-${spec.nation}-${spec.type}-${Math.random().toString(36).slice(2, 8)}`, turn, date, nation: spec.nation, type: spec.type, title: spec.title, text: spec.text, outcome: spec.outcome, }; } /** Defensive helpers used by both the engine (server) and tests. */ export function clampStat(v: number): number { if (!Number.isFinite(v)) return 0; return Math.max(0, Math.min(100, Math.round(v))); } /** True if the string looks like a `YYYY-MM-DD` date. */ export function isDateLike(s: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s)); } /** Advance a `YYYY-MM-DD` string by N days (UTC). Invalid input returns as-is. */ export function addDays(date: string, days: number): string { if (!isDateLike(date)) return date; const d = new Date(`${date}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } export function deepClone(v: T): T { return JSON.parse(JSON.stringify(v)) as T; } /** The subset of a Nation the prompt needs (keeps the LLM context small). */ export function nationForPrompt(n: Nation) { return { id: n.id, name: n.name, type: n.type, stats: n.stats, territory: n.territory, relations: n.relations, }; } // --- Per-region stats (v0.28, #46) ------------------------------------------ // // A nation's 0–100 `NationStats` are no longer stored as independent per-nation // values: they are DERIVED as the area×population-weighted median of the stats // of the regions the nation owns (`GameState.regionStats`, keyed by region key). // These pure helpers implement that derivation, the fresh-game / legacy seeding, // and the one-time migration of a pre-v0.28 row. They have no runtime deps. /** * The weighted median of `values` with per-value `weights` (paired by index). * The pairs are sorted by value ascending and the result is the value at the * first point where the cumulative weight reaches at least half the total * weight. An empty input, or a total weight of zero, yields 0. Non-finite or * non-positive weights are treated as 0 (excluded from the median). * Translation-invariant: adding a constant to every value shifts the result by * that constant — so the median of a set of **identical** values is that value * (the invariant the equal-value seeding and legacy migration rely on). * Pure + shared. */ export function weightedMedian(values: number[], weights: number[]): number { if (values.length === 0) return 0; const pairs = values .map((v, i) => { const w = typeof weights[i] === 'number' && Number.isFinite(weights[i]) && weights[i] > 0 ? weights[i] : 0; return { v: Number.isFinite(v) ? v : 0, w }; }) .filter((p) => p.w > 0); if (pairs.length === 0) return 0; const total = pairs.reduce((s, p) => s + p.w, 0); if (total <= 0) return 0; pairs.sort((a, b) => a.v - b.v); let cum = 0; for (const p of pairs) { cum += p.w; if (cum >= total / 2) return p.v; } return pairs[pairs.length - 1]!.v; } /** * Derive a nation's aggregate {@link NationStats} from the stats of the regions * it owns: each of the four stats is the **area×population-weighted median** * (see {@link weightedMedian}) over the nation's owned region keys. A region's * weight is `(area > 0 ? area : 1) × (population > 0 ? population : 1)` — both * factors fall back to 1 when the region has no geo entry (`geoOf` returns * `undefined` for it), so every region carries weight ≥ 1 and a missing geo * degrades gracefully to a uniform (unweighted) median. A region key with no * `regionStats` entry is skipped. Returns the zero stats when the nation owns * no region with a stats entry (the caller's 0-region guard keeps the nation's * directly-maintained `n.stats` in that case). Pure + shared. */ export function deriveNationStats( regionKeys: string[], regionStats: Record, geoOf?: (key: string) => { area: number; population: number } | undefined, ): NationStats { const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 }; const byStat: Record = { military: [], economy: [], stability: [], population: [], }; const weights: number[] = []; for (const key of regionKeys) { const rs = regionStats[key]; if (!rs) continue; // a key with no per-region stats is skipped const g = geoOf?.(key); const a = g && g.area > 0 ? g.area : 1; const p = g && g.population > 0 ? g.population : 1; weights.push(a * p); for (const k of STAT_KEYS) byStat[k].push(rs[k]); } if (weights.length === 0) return zero; const out: NationStats = { military: 0, economy: 0, stability: 0, population: 0 }; for (const k of STAT_KEYS) out[k] = clampStat(weightedMedian(byStat[k], weights)); return out; } /** * Seed a per-region stats table from a set of nations (used both at game * creation and by the legacy migration): every owned region key gets a **copy** * of its owner's current `NationStats`, so a fresh nation's regions all start * identical (the weighted median of identical values is that value, so the * derived `n.stats` equals the seeded one). A region with two or more owners * (corrupt) is seeded by the first owner encountered; a key owned by nobody gets * no entry. Pure + shared. */ export function seedRegionStats(nations: Record): Record { const out: Record = {}; for (const n of Object.values(nations)) { for (const key of n.territory) { if (!(key in out)) out[key] = { ...n.stats }; // first owner wins (sole ownership holds normally) } } return out; } /** * (v0.28, #46) One-time migration of a **legacy** (pre-v0.28) `GameState` row * into the per-region-stats model, returning a new state (the input is never * mutated): * - a row that already has `regionStats` (a v0.28+ save) is returned * **UNCHANGED** — its `Nation.stats` are already the correct derived medians * and must not be recomputed on load (that would drift them); * - a legacy row (`regionStats` absent at runtime) seeds every owned region key * with a copy of its owner's current stats (so each nation's regions start * identical), then recomputes each nation's stats as the weighted median of * its regions (a 0-region nation keeps its stats). Without `geoOf` the * weighting is uniform, which is weight-invariant for the identical-value * case, so the recomputed stats equal the originals — the migration is a * no-op on the visible numbers and only materializes the (previously * implicit) per-region values. Never throws. */ export function migrateRegionStats( state: GameState, geoOf?: (key: string) => { area: number; population: number } | undefined, ): GameState { // A v0.28+ row already carries per-region stats: leave it exactly as saved. if (state.regionStats !== undefined) return state; // Legacy row: materialize per-region stats from each nation's (uniform) stats. const next: GameState = { ...state, regionStats: seedRegionStats(state.nations) }; for (const n of Object.values(next.nations)) { if (n.territory.length === 0) continue; // 0-region guard: keep its stats if (n.territory.some((k) => next.regionStats[k])) { n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); } } return next; }