CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
(area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
(per-region SET, first-declared-type wins), applyIndustryEffects
(INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
0/2+ owner regions kept but no boost; re-derive after), system+user prompt
contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0
Fixes #59
1034 lines
43 KiB
TypeScript
1034 lines
43 KiB
TypeScript
// 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,
|
||
IndustryProject,
|
||
MarkerType,
|
||
Nation,
|
||
NationStats,
|
||
RelationStatus,
|
||
RegionResources,
|
||
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<MarkerType, { label: string; color: string; glyph: string }> = {
|
||
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';
|
||
}
|
||
|
||
// --- Trade & embargo ops (v0.35.0, #58) --------------------------------------
|
||
|
||
/**
|
||
* (v0.35.0, #58) A bilateral TRADE op in `TurnResult.economy.trade`: `a` and
|
||
* `b` are the two parties (each a nation's id or unique name — resolved by
|
||
* the engine's `resolveNationRef`; a pair that resolves to the same nation,
|
||
* or contains an unknown/ambiguous ref, is dropped defensively). Without
|
||
* `lift` the op DECLARES the agreement (idempotent — an already-active
|
||
* agreement is not duplicated); `lift: true` ENDS it.
|
||
*/
|
||
export interface EconomyTradeOp {
|
||
/** First party's id or unique name. */
|
||
a: string;
|
||
/** Second party's id or unique name (distinct from `a` after resolution). */
|
||
b: string;
|
||
/** `true` → end this bilateral trade; default `false` = declare it. */
|
||
lift?: boolean;
|
||
}
|
||
|
||
/**
|
||
* (v0.35.0, #58) A unilateral EMBARGO op in `TurnResult.economy.embargo`:
|
||
* `from` blocks trade with `target` (each a nation's id or unique name —
|
||
* resolved by `resolveNationRef`; an unknown/ambiguous ref or
|
||
* `from === target` is dropped defensively). Without `lift` the op imposes
|
||
* the embargo (idempotent — an already-active one is not duplicated);
|
||
* `lift: true` LIFTS it.
|
||
*/
|
||
export interface EconomyEmbargoOp {
|
||
/** The imposing nation's id or unique name. */
|
||
from: string;
|
||
/** The embargoed nation's id or unique name (distinct from `from`). */
|
||
target: string;
|
||
/** `true` → lift this embargo; default `false` = impose it. */
|
||
lift?: boolean;
|
||
}
|
||
|
||
/**
|
||
* (v0.35.0, #58) The turn's trade-and-embargo operations, nested under
|
||
* `TurnResult.economy`. Both arrays are required and default to `[]`. The
|
||
* engine applies the ops to `GameState.trades` / `GameState.embargoes`
|
||
* (add = declare/impose, remove = lift) and, on every committed turn, applies
|
||
* the per-turn economic effect of the FULL active lists (trade: both
|
||
* parties' economy up a little; embargo: the target's economy down a
|
||
* little). Active trades/embargoes between nations on opposite sides of an
|
||
* active war (v0.33, #56) are suspended (removed) automatically.
|
||
*/
|
||
export interface EconomyOps {
|
||
/** Bilateral trades (declare or lift). */
|
||
trade: EconomyTradeOp[];
|
||
/** Unilateral embargoes (impose or lift). */
|
||
embargo: EconomyEmbargoOp[];
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) The turn's INDUSTRY operations, nested under
|
||
* `TurnResult.industry`. `projects` lists the industry projects the LLM builds
|
||
* this turn: each names a **region key** (the region must be valid, and — for
|
||
* the boost to take effect — will need a sole owner at apply time) and one of
|
||
* the four project {@link IndustryType}s. The engine applies each project to
|
||
* `GameState.industry` (a per-region SET — a region already hosting a project
|
||
* keeps the first one declared; a region key not in the valid manifest is
|
||
* dropped, never thrown). Required; default `{ projects: [] }`.
|
||
*/
|
||
export interface IndustryOps {
|
||
/** The industry projects built this turn (region key + type). */
|
||
projects: IndustryProject[];
|
||
}
|
||
|
||
/** 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.35.0, #58) Trade and embargo ops — the economy layer. `trade`
|
||
* declares (or, with `lift: true`, ends) bilateral trade agreements
|
||
* between two nations; `embargo` imposes (or, with `lift: true`, lifts)
|
||
* a unilateral embargo on a target. Both arrays are required and default
|
||
* to `[]`. Applied directly by `applyTurnResult` (add to / remove from
|
||
* `GameState.trades` / `GameState.embargoes`), and the per-turn economic
|
||
* effect of the FULL active lists is applied on every committed turn (a
|
||
* trade lifts both parties' economy a little; an embargo lowers the
|
||
* target's economy a little — clamped, deterministic). Active
|
||
* trades/embargoes between nations on opposite sides of an active war are
|
||
* suspended automatically.
|
||
*/
|
||
economy: EconomyOps;
|
||
/**
|
||
* (v0.36, #59) Industry projects built this turn — the LLM's "industry"
|
||
* lever. `projects` lists `{ regionKey, type }` pairs: the model names a
|
||
* region it (believes it) controls and one of the four project types
|
||
* (`oil` / `iron` / `food` / `industry`). Applied directly by
|
||
* `applyTurnResult`: each project is added to `GameState.industry` (a
|
||
* per-region SET — re-declaring on a region that already has a project is a
|
||
* no-op), and on every committed turn each active project gives its region's
|
||
* SOLE owner a small, deterministic economy boost scaled by that region's
|
||
* `industry` resource value (a region with no owner, or 2+ owners, yields no
|
||
* boost but keeps its project). They never move borders — sole ownership is
|
||
* untouched. Required; default `{ projects: [] }`.
|
||
*/
|
||
industry: IndustryOps;
|
||
/**
|
||
* (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<string, RegionOwner>;
|
||
|
||
/**
|
||
* 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<string, never>;
|
||
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<T>(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<string, RegionStats>,
|
||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||
): NationStats {
|
||
const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
|
||
const byStat: Record<StatKey, number[]> = {
|
||
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<string, Nation>): Record<string, RegionStats> {
|
||
const out: Record<string, RegionStats> = {};
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) Derive a nation's aggregate {@link RegionResources} from the
|
||
* resource profiles of the regions it owns: each of the four resource values is
|
||
* the **area×population-weighted AVERAGE** (an average — not a median — is the
|
||
* meaningful aggregation for an abundance profile) over the nation's owned
|
||
* region keys. A region's weight is the same
|
||
* `(area > 0 ? area : 1) × (population > 0 ? population : 1)` as
|
||
* {@link deriveNationStats} — 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)
|
||
* average. A region key with no resource-profile entry (`resourceOf` returns
|
||
* `undefined` for it — the manifest is not loaded, or the key is absent) is
|
||
* skipped. Returns the zero profile when the nation owns no region with a
|
||
* profile. Pure + shared.
|
||
*/
|
||
export function deriveNationResources(
|
||
regionKeys: string[],
|
||
resourceOf?: (key: string) => RegionResources | undefined,
|
||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||
): RegionResources {
|
||
const zero: RegionResources = { oil: 0, iron: 0, food: 0, industry: 0 };
|
||
const sums: Record<keyof RegionResources, number> = { oil: 0, iron: 0, food: 0, industry: 0 };
|
||
let totalW = 0;
|
||
for (const key of regionKeys) {
|
||
const r = resourceOf?.(key);
|
||
if (!r) continue; // a key with no resource profile is skipped
|
||
const g = geoOf?.(key);
|
||
const a = g && g.area > 0 ? g.area : 1;
|
||
const p = g && g.population > 0 ? g.population : 1;
|
||
const w = a * p;
|
||
totalW += w;
|
||
sums.oil += r.oil * w;
|
||
sums.iron += r.iron * w;
|
||
sums.food += r.food * w;
|
||
sums.industry += r.industry * w;
|
||
}
|
||
if (totalW <= 0) return zero;
|
||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||
return {
|
||
oil: round2(sums.oil / totalW),
|
||
iron: round2(sums.iron / totalW),
|
||
food: round2(sums.food / totalW),
|
||
industry: round2(sums.industry / totalW),
|
||
};
|
||
}
|