@its-not-rocket-science/ananke 0.1.16 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,151 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.22] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 77 · Dynasty & Succession** (`src/succession.ts`)
14
+ - `SuccessionRuleType`: `"primogeniture" | "renown_based" | "election"`.
15
+ - `SuccessionCandidate { entityId, kinshipDegree, renown_Q, inheritedRenown_Q, claimStrength_Q }`.
16
+ - `SuccessionResult { heirId, candidates, rule, stabilityImpact_Q }` — signed Q stability delta.
17
+ - `findSuccessionCandidates(lineage, deceasedId, renownRegistry, maxDegree?)` — BFS over family graph (Phase 76), computes `renown_Q` and `inheritedRenown_Q` per candidate.
18
+ - `resolveSuccession(lineage, deceasedId, renownRegistry, rule, worldSeed, tick)` → `SuccessionResult`:
19
+ - **primogeniture**: first-born child (lowest entityId) gets SCALE.Q claim; others by distance.
20
+ - **renown_based**: claim = 70% own renown + 30% inherited renown.
21
+ - **election**: renown-weighted deterministic lottery via `eventSeed`.
22
+ - Stability: `+STABILITY_CLEAN_SUCCESSION_Q` for uncontested direct heir; `−STABILITY_DISTANT_HEIR_Q` per extra degree; `−STABILITY_CONTESTED_Q` when top-two gap < q(0.10); `−STABILITY_NO_HEIR_Q` if no candidates.
23
+ - `applySuccessionToPolity(polity, result)` — applies `stabilityImpact_Q` to `polity.stabilityQ` (clamped).
24
+ - Added `./succession` subpath export to `package.json`.
25
+ - 21 new tests; 4,142 total. Coverage maintained above all thresholds.
26
+
27
+ ---
28
+
29
+ ## [0.1.21] — 2026-03-26
30
+
31
+ ### Added
32
+
33
+ - **Phase 76 · Kinship & Lineage** (`src/kinship.ts`)
34
+ - `LineageNode { entityId, parentIds, childIds, partnerIds }` — family links per entity.
35
+ - `LineageRegistry { nodes: Map<number, LineageNode> }` — flat registry, no Entity field changes.
36
+ - `createLineageRegistry()` / `getLineageNode(registry, entityId)` — factory and lazy-init accessor.
37
+ - `recordBirth(registry, childId, parentAId, parentBId?)` — links child to 1–2 parents; idempotent.
38
+ - `recordPartnership(registry, entityAId, entityBId)` — mutual partner link; idempotent.
39
+ - `getParents / getChildren / getSiblings` — direct family queries; siblings deduplicated.
40
+ - `findAncestors(registry, entityId, maxDepth?)` — BFS upward through parent links (default depth 4).
41
+ - `computeKinshipDegree(registry, entityA, entityB)` — BFS on undirected family graph (parents + children + partners); returns 0–4 or `null` beyond `MAX_KINSHIP_DEPTH = 4`.
42
+ - `isKin(registry, entityA, entityB, maxDegree?)` — convenience boolean.
43
+ - `getKinshipLabel(degree)` → `"self" | "immediate" | "close" | "extended" | "distant" | "unrelated"`.
44
+ - `computeInheritedRenown(lineage, entityId, renownRegistry, maxDepth?)` — sums ancestor `renown_Q` with geometric decay (`RENOWN_DEPTH_DECAY_Q = q(0.50)` per generation); clamped to SCALE.Q.
45
+ - Added `./kinship` subpath export to `package.json`.
46
+ - 42 new tests; 4,121 total. Coverage maintained above all thresholds.
47
+
48
+ ---
49
+
50
+ ## [0.1.20] — 2026-03-26
51
+
52
+ ### Added
53
+
54
+ - **Phase 75 · Entity Renown & Legend Registry** (`src/renown.ts`)
55
+ - `RenownRecord { entityId, renown_Q, infamy_Q, entries: LegendEntry[] }` — per-entity reputation on two orthogonal axes.
56
+ - `LegendEntry { entryId, tick, eventType, significance }` — lightweight reference to a significant `ChronicleEntry`.
57
+ - `RenownRegistry { records: Map<number, RenownRecord> }` — flat registry, one record per entity.
58
+ - `createRenownRegistry()` / `getRenownRecord(registry, entityId)` — factory and lazy-init accessor.
59
+ - `updateRenownFromChronicle(registry, chronicle, entityId, minSignificance?)` — idempotent scan; renown events (legendary_deed, quest_completed, combat_victory, masterwork_crafted, rank_promotion, settlement_founded, first_contact) add to `renown_Q`; infamy events (relationship_betrayal, settlement_raided, settlement_destroyed, quest_failed) add to `infamy_Q`; both capped at SCALE.Q.
60
+ - `getRenownLabel(renown_Q)` → `"unknown" | "noted" | "known" | "renowned" | "legendary" | "mythic"` (6 tiers at q(0.10) boundaries).
61
+ - `getInfamyLabel(infamy_Q)` → `"innocent" | "suspect" | "notorious" | "infamous" | "reviled" | "condemned"`.
62
+ - `deriveFactionStandingAdjustment(renown_Q, infamy_Q, allianceBias)` — signed Q adjustment; heroic factions (bias=1.0) reward renown and punish infamy; criminal factions (bias=0.0) the reverse; clamped to [-SCALE.Q, SCALE.Q].
63
+ - `getTopLegendEntries(record, n)` — top N entries by significance (tick-descending tie-break).
64
+ - `renderLegendWithTone(record, entryMap, ctx, maxEntries?)` — renders top entries as prose via Phase 74's `renderEntryWithTone`.
65
+ - Added `./narrative-prose` and `./renown` subpath exports to `package.json`.
66
+ - 42 new tests; 4,079 total. Coverage maintained above all thresholds.
67
+
68
+ ---
69
+
70
+ ## [0.1.19] — 2026-03-26
71
+
72
+ ### Added
73
+
74
+ - **Phase 74 · Simulation Trace → Narrative Prose** (`src/narrative-prose.ts`)
75
+ - 6 prose tones: `neutral | heroic | tragic | martial | spiritual | mercantile`
76
+ - Tone-varied templates for all 19 `ChronicleEventType` values.
77
+ - `deriveNarrativeTone(culture)` — maps dominant `CultureProfile` value → `ProseTone`
78
+ via `VALUE_TONE_MAP` (martial_virtue→martial, spiritual_devotion→spiritual,
79
+ commerce→mercantile, honour→heroic, fatalism→tragic; others fall back to neutral).
80
+ - `mythArchetypeFrame(archetype)` — returns a culturally-flavoured closing phrase for
81
+ each `MythArchetype` (hero, monster, trickster, great_plague, divine_wrath, golden_age).
82
+ - `createNarrativeContext(entityNames, culture?, myth?)` — bundles tone + name map + myth frame.
83
+ - `renderEntryWithTone(entry, ctx)` — picks the tone variant for each event, substitutes
84
+ `{name}`, `{target}`, computed helper strings (`{cause_str}`, `{location_str}`, etc.),
85
+ raw `entry.variables`, and appends the myth frame (replacing terminal period).
86
+ - `renderChronicleWithTone(chronicle, ctx, minSignificance?)` — filters by significance,
87
+ sorts chronologically, maps via `renderEntryWithTone`.
88
+ - **Success criterion met:** martial, spiritual, and mercantile tones produce clearly
89
+ distinguishable prose from the same chronicle events.
90
+ - 39 new tests; 4,037 total. Coverage: statements 96.81%, branches 86.87%, functions 94.80%.
91
+
92
+ ---
93
+
94
+ ## [0.1.18] — 2026-03-26
95
+
96
+ ### Added
97
+
98
+ - **CE-18 · External Agent Interface** (`tools/agent-server.ts`)
99
+ - WebSocket server (default port 3001) implementing an agent observation/action loop
100
+ over the existing `stepWorld` kernel — no src/ changes, no new npm exports.
101
+ - **Protocol:**
102
+ - Client → `{ type: "step", commands?: AgentCommand[] }` or `{ type: "reset" }`
103
+ - Server → `{ type: "obs", tick, entities: ObservationSlice[], done, winner? }`
104
+ - On connect → `{ type: "init", config, obs }`
105
+ - **`ObservationSlice`** — safe subset: position, velocity, fatigue, shock/consciousness/dead,
106
+ detected nearby enemies (filtered via Phase 52 `canDetect`). No raw internals exposed.
107
+ - **`AgentCommand`** — validated high-level actions: `attack | move | dodge | flee | idle`.
108
+ Invalid team targeting silently dropped; `decideCommandsForEntity` fills in missing commands.
109
+ - Configurable scenario: `TEAM1_SIZE` / `TEAM2_SIZE` (1–4 each), `SEED`, `MAX_TICKS` via env vars.
110
+ Default: 1v1, Knight (longsword + mail) vs Brawler (club).
111
+ - Agent-driven stepping: server advances only when client sends `step` — agent controls tick rate.
112
+ - Determinism preserved: external commands injected via existing `CommandMap` before `stepWorld`.
113
+ - HTTP endpoints: `GET /config`, `GET /status`, `POST /reset`.
114
+ - Run: `npm run agent-server`
115
+ - **Success criterion met:** An external Python script using only `websockets` can drive a single
116
+ entity through a 1v1 fight, receiving `ObservationSlice` observations each tick and submitting
117
+ `attack` / `move` commands, without importing any Ananke TypeScript.
118
+
119
+ ---
120
+
121
+ ## [0.1.17] — 2026-03-26
122
+
123
+ ### Added
124
+
125
+ - **Phase 73 · Enhanced Epidemiological Models** (`src/sim/disease.ts` extended in-place)
126
+ - `VaccinationRecord { diseaseId, efficacy_Q, doseCount }` — partial-efficacy vaccination
127
+ stored on `entity.vaccinations?`; `vaccinate(entity, diseaseId, efficacy_Q)` helper.
128
+ - `ageSusceptibility_Q(ageYears)` — U-shaped multiplier: infants ×1.30, children ×0.80,
129
+ adults ×1.00, early elderly ×1.20, late elderly ×1.50. Auto-applied in
130
+ `computeTransmissionRisk` when `entity.age` is set.
131
+ - `NPIType`, `NPIRecord`, `NPIRegistry` — non-pharmaceutical intervention registry;
132
+ `applyNPI / removeNPI / hasNPI` helpers. `mask_mandate` reduces airborne transmission
133
+ by `NPI_MASK_REDUCTION_Q = q(0.60)` (60 %). `quarantine` recorded for host-side pair
134
+ filtering.
135
+ - `computeTransmissionRisk` extended with optional 5th `options?` parameter — backward-
136
+ compatible; applies vaccination, age susceptibility, and NPI effects when present.
137
+ - `computeR0(profile, entityMap)` — basic reproductive number estimate
138
+ (β × infectious-days × min(15, population−1)); used for validation.
139
+ - `stepSEIR(entity, delta_s, profile, worldSeed, tick)` — SEIR-aware entity step that
140
+ isolates a single disease profile; delegates to Phase 56 `stepDiseaseForEntity` for
141
+ full backward compatibility.
142
+ - `registerDiseaseProfile(profile)` — registers custom/SEIR profiles into the lookup map
143
+ without modifying the canonical `DISEASE_PROFILES` array.
144
+ - `MEASLES` profile (`useSeir: true`): R0 ≈ 15.1 in population ≥ 16, 14-day incubation,
145
+ 14-day infectious period, 0.2 % IFR, permanent immunity. Validates epidemic curve
146
+ peaking days 10–20 and burning out by day 60 (matches standard SIR output ±15 %).
147
+ - `entity.vaccinations?: VaccinationRecord[]` added to `Entity`.
148
+ - `DiseaseProfile.useSeir?: boolean` opt-in field (no effect on existing callers).
149
+ - 37 new tests in `test/disease-seir.test.ts`. All 37 Phase 56 tests pass unmodified.
150
+ - **3 998 tests total.**
151
+
152
+ ---
153
+
9
154
  ## [0.1.16] — 2026-03-25
10
155
 
11
156
  ### Added
@@ -0,0 +1,133 @@
1
+ import { type Q } from "./units.js";
2
+ import type { Polity, PolityRegistry, PolityPair } from "./polity.js";
3
+ import { TechEra } from "./sim/tech.js";
4
+ /** Per-step fraction pulled back toward base price (8%). */
5
+ export declare const MEAN_REVERSION_Q: number;
6
+ /** Debt/treasury ratio above which a polity is in crisis (30%). */
7
+ export declare const DEBT_CRISIS_RATIO_Q: number;
8
+ /** Supply fraction added to market in an economic warfare dump (40%). */
9
+ export declare const SUPPLY_DUMP_Q: number;
10
+ /** Base trade income per shared location per commodity per step (cost-units). */
11
+ export declare const TRADE_BASE_CU = 50;
12
+ /** Fraction of treasury wagered per speculate() call (10%). */
13
+ export declare const SPECULATE_WAGER_Q: number;
14
+ /**
15
+ * Profit multiplier on a winning speculation — applied to the wager amount.
16
+ * q(1.0) means winner gains exactly the wager (net +wager).
17
+ * Combined with WIN_PROBABILITY_NUM/DEN = 45/100, EV = −10% per call.
18
+ */
19
+ export declare const SPECULATE_WIN_MUL_Q: number;
20
+ /** Numerator of win probability fraction (45%). */
21
+ export declare const WIN_PROBABILITY_NUM = 45;
22
+ export interface CommodityProfile {
23
+ readonly id: string;
24
+ readonly name: string;
25
+ /** Base price [0, SCALE.Q]. Long-run equilibrium the market reverts toward. */
26
+ readonly basePrice_Q: Q;
27
+ /** Maximum random price swing per step (±half this per step). */
28
+ readonly volatility_Q: Q;
29
+ /** Minimum tech era required for a polity to produce this commodity. */
30
+ readonly techMinEra: TechEra;
31
+ }
32
+ export interface PriceRecord {
33
+ /** Current market price [q(0.05), q(2.0)]. */
34
+ price_Q: Q;
35
+ /** Available supply [0, SCALE.Q]. */
36
+ supply_Q: Q;
37
+ /** Active demand [0, SCALE.Q]. */
38
+ demand_Q: Q;
39
+ }
40
+ export interface MarketState {
41
+ /** Current prices for each commodity. Keyed by CommodityProfile.id. */
42
+ prices: Map<string, PriceRecord>;
43
+ /** Outstanding debt per polity: polityId → cost-units owed. */
44
+ debt: Map<string, number>;
45
+ }
46
+ export interface MarketStepResult {
47
+ /** Trade income credited this step: polityId → cost-units. */
48
+ tradeIncome: Map<string, number>;
49
+ /** IDs of polities that are in (or entered) a debt crisis this step. */
50
+ crisisPolities: string[];
51
+ }
52
+ export interface EconomicWarfareResult {
53
+ /** Cost-units spent by the aggressor on the dump operation. */
54
+ aggressorCost_cu: number;
55
+ /** Price decrease applied to the targeted commodity [0, SCALE.Q]. */
56
+ priceDrop_Q: Q;
57
+ }
58
+ export declare const COMMODITIES: readonly CommodityProfile[];
59
+ /** Returns commodity IDs a polity can produce given its tech era. */
60
+ export declare function availableCommodities(techEra: TechEra): string[];
61
+ /** Create a fresh MarketState with all commodities at base price, balanced supply/demand. */
62
+ export declare function createMarket(): MarketState;
63
+ /**
64
+ * Advance all commodity prices by one step.
65
+ *
66
+ * Each commodity's price changes by:
67
+ * reversion = MEAN_REVERSION_Q × (basePrice − current) / SCALE.Q
68
+ * noise = ±(volatility_Q / 2) via deterministic eventSeed
69
+ * imbalance = (demand_Q − supply_Q) × 5% / SCALE.Q (supply/demand pressure)
70
+ *
71
+ * Result is clamped to [q(0.05), q(2.0)] to prevent collapse or runaway inflation.
72
+ */
73
+ export declare function stepPrices(market: MarketState, worldSeed: number, tick: number): void;
74
+ /**
75
+ * Resolve trade between all non-war polity pairs and credit treasury.
76
+ *
77
+ * For each non-war pair: the traded commodity set is the intersection of each
78
+ * polity's tech-era-gated catalogue. Income per commodity =
79
+ * `price_Q × TRADE_BASE_CU × sharedLocations × routeQuality_Q / SCALE.Q²`
80
+ *
81
+ * Both polities in a pair receive equal income. Mutates `polity.treasury_cu`.
82
+ * Returns a map of total income credited this step per polity id.
83
+ */
84
+ export declare function stepTrade(registry: PolityRegistry, pairs: PolityPair[], market: MarketState): Map<string, number>;
85
+ /**
86
+ * Polity bets a fraction of treasury on a commodity price movement.
87
+ *
88
+ * Wager = `SPECULATE_WAGER_Q` (10%) of current treasury.
89
+ * Win probability = `WIN_PROBABILITY_NUM` / 100 (45%). On win: treasury gains
90
+ * `wager × SPECULATE_WIN_MUL_Q / SCALE.Q`. On loss: treasury loses the wager;
91
+ * if treasury is insufficient, the shortfall is added to `market.debt`.
92
+ *
93
+ * Expected value ≈ −10% per call (house-edge model for opaque markets).
94
+ * Returns the net treasury change (positive = profit, negative = loss or debt).
95
+ */
96
+ export declare function speculate(polity: Polity, commodityId: string, worldSeed: number, tick: number): number;
97
+ /**
98
+ * Returns true when the polity's debt exceeds the crisis threshold.
99
+ *
100
+ * Crisis triggers when:
101
+ * - `debt > treasury × DEBT_CRISIS_RATIO_Q / SCALE.Q` (debt ratio exceeded), or
102
+ * - `treasury ≤ 0 AND debt > 0` (insolvency)
103
+ */
104
+ export declare function checkDebtCrisis(polity: Polity, market: MarketState): boolean;
105
+ /**
106
+ * Aggressor dumps supply of a commodity onto the market to depress its price.
107
+ *
108
+ * Supply increases by `SUPPLY_DUMP_Q`, capped at SCALE.Q. Price drops
109
+ * proportionally to the supply increase × commodity volatility.
110
+ * Aggressor pays `TRADE_BASE_CU × SUPPLY_DUMP_Q / SCALE.Q × sharedLocations`
111
+ * cost-units (stockpile overhead).
112
+ *
113
+ * No-op if the commodity requires a higher tech era than the aggressor possesses,
114
+ * or if either polity is not in the registry.
115
+ */
116
+ export declare function economicWarfare(aggressorId: string, targetId: string, commodityId: string, registry: PolityRegistry, market: MarketState, pairs: PolityPair[]): EconomicWarfareResult;
117
+ /**
118
+ * Derive aggregate economic stress for a polity [0, SCALE.Q].
119
+ *
120
+ * Three components, equal weighting:
121
+ * 1. Debt ratio — `clamp(debt / max(treasury, 1), 0, SCALE.Q)`
122
+ * 2. Price stress — fraction of the polity's available commodities below 60% of base
123
+ * 3. War penalty — q(0.20) per active war involving this polity, capped at q(0.60)
124
+ */
125
+ export declare function deriveEconomicPressure(polity: Polity, market: MarketState, registry: PolityRegistry): Q;
126
+ /**
127
+ * Full market step: advance prices, resolve trade, return income and crisis list.
128
+ *
129
+ * Call once per campaign day. Mutates `market.prices`, polity `treasury_cu`
130
+ * values inside `registry`, and `market.debt` (via `checkDebtCrisis` reads only —
131
+ * debt is written by `speculate`, not by `stepMarket` directly).
132
+ */
133
+ export declare function stepMarket(registry: PolityRegistry, pairs: PolityPair[], market: MarketState, worldSeed: number, tick: number): MarketStepResult;
@@ -0,0 +1,261 @@
1
+ // src/economy-gen.ts — Phase 72: Generative Economics
2
+ //
3
+ // Agent-based commodity markets for polity-scale simulation.
4
+ // Extends Phase 61 (Polity) with price dynamics, debt cycles, and economic warfare.
5
+ //
6
+ // All values are fixed-point; no floating-point in the simulation path.
7
+ // No Math.random() — determinism via eventSeed().
8
+ import { q, SCALE, clampQ } from "./units.js";
9
+ import { eventSeed, hashString } from "./sim/seeds.js";
10
+ import { TechEra } from "./sim/tech.js";
11
+ // ── Constants ──────────────────────────────────────────────────────────────────
12
+ /** Per-step fraction pulled back toward base price (8%). */
13
+ export const MEAN_REVERSION_Q = q(0.08);
14
+ /** Debt/treasury ratio above which a polity is in crisis (30%). */
15
+ export const DEBT_CRISIS_RATIO_Q = q(0.30);
16
+ /** Supply fraction added to market in an economic warfare dump (40%). */
17
+ export const SUPPLY_DUMP_Q = q(0.40);
18
+ /** Base trade income per shared location per commodity per step (cost-units). */
19
+ export const TRADE_BASE_CU = 50;
20
+ /** Fraction of treasury wagered per speculate() call (10%). */
21
+ export const SPECULATE_WAGER_Q = q(0.10);
22
+ /**
23
+ * Profit multiplier on a winning speculation — applied to the wager amount.
24
+ * q(1.0) means winner gains exactly the wager (net +wager).
25
+ * Combined with WIN_PROBABILITY_NUM/DEN = 45/100, EV = −10% per call.
26
+ */
27
+ export const SPECULATE_WIN_MUL_Q = q(1.0);
28
+ /** Numerator of win probability fraction (45%). */
29
+ export const WIN_PROBABILITY_NUM = 45;
30
+ // ── Commodity catalogue ────────────────────────────────────────────────────────
31
+ export const COMMODITIES = [
32
+ { id: "grain", name: "Grain", basePrice_Q: q(0.20), volatility_Q: q(0.12), techMinEra: TechEra.Prehistoric },
33
+ { id: "timber", name: "Timber", basePrice_Q: q(0.25), volatility_Q: q(0.08), techMinEra: TechEra.Prehistoric },
34
+ { id: "iron", name: "Iron", basePrice_Q: q(0.40), volatility_Q: q(0.15), techMinEra: TechEra.Ancient },
35
+ { id: "textile", name: "Textile", basePrice_Q: q(0.35), volatility_Q: q(0.10), techMinEra: TechEra.Ancient },
36
+ { id: "spice", name: "Spice", basePrice_Q: q(0.60), volatility_Q: q(0.25), techMinEra: TechEra.Ancient },
37
+ { id: "labour", name: "Labour", basePrice_Q: q(0.15), volatility_Q: q(0.05), techMinEra: TechEra.Prehistoric },
38
+ { id: "arcane", name: "Arcane Goods", basePrice_Q: q(0.80), volatility_Q: q(0.30), techMinEra: TechEra.Medieval },
39
+ { id: "manufactured", name: "Manufactured Goods", basePrice_Q: q(0.50), volatility_Q: q(0.18), techMinEra: TechEra.EarlyModern },
40
+ ];
41
+ // ── Helpers ────────────────────────────────────────────────────────────────────
42
+ /** Returns commodity IDs a polity can produce given its tech era. */
43
+ export function availableCommodities(techEra) {
44
+ return COMMODITIES.filter(c => c.techMinEra <= techEra).map(c => c.id);
45
+ }
46
+ // ── Market creation ────────────────────────────────────────────────────────────
47
+ /** Create a fresh MarketState with all commodities at base price, balanced supply/demand. */
48
+ export function createMarket() {
49
+ const prices = new Map();
50
+ for (const c of COMMODITIES) {
51
+ prices.set(c.id, { price_Q: c.basePrice_Q, supply_Q: q(0.50), demand_Q: q(0.50) });
52
+ }
53
+ return { prices, debt: new Map() };
54
+ }
55
+ // ── Price dynamics ─────────────────────────────────────────────────────────────
56
+ /**
57
+ * Advance all commodity prices by one step.
58
+ *
59
+ * Each commodity's price changes by:
60
+ * reversion = MEAN_REVERSION_Q × (basePrice − current) / SCALE.Q
61
+ * noise = ±(volatility_Q / 2) via deterministic eventSeed
62
+ * imbalance = (demand_Q − supply_Q) × 5% / SCALE.Q (supply/demand pressure)
63
+ *
64
+ * Result is clamped to [q(0.05), q(2.0)] to prevent collapse or runaway inflation.
65
+ */
66
+ export function stepPrices(market, worldSeed, tick) {
67
+ for (const c of COMMODITIES) {
68
+ const rec = market.prices.get(c.id);
69
+ // Mean reversion toward base price
70
+ const gap = c.basePrice_Q - rec.price_Q;
71
+ const reversion = Math.trunc(gap * MEAN_REVERSION_Q / SCALE.Q);
72
+ // Deterministic noise in [-volatility/2, +volatility/2)
73
+ const salt = hashString(c.id);
74
+ const raw = eventSeed(worldSeed, tick, 0, 0, salt);
75
+ const half = Math.trunc(c.volatility_Q / 2);
76
+ const noise = Math.trunc(raw % c.volatility_Q) - half;
77
+ // Supply/demand imbalance: excess demand = higher price, excess supply = lower
78
+ const imbalance = Math.trunc((rec.demand_Q - rec.supply_Q) * 5 / 100);
79
+ rec.price_Q = clampQ(rec.price_Q + reversion + noise + imbalance, q(0.05), q(2.0));
80
+ }
81
+ }
82
+ // ── Trade income ───────────────────────────────────────────────────────────────
83
+ /**
84
+ * Resolve trade between all non-war polity pairs and credit treasury.
85
+ *
86
+ * For each non-war pair: the traded commodity set is the intersection of each
87
+ * polity's tech-era-gated catalogue. Income per commodity =
88
+ * `price_Q × TRADE_BASE_CU × sharedLocations × routeQuality_Q / SCALE.Q²`
89
+ *
90
+ * Both polities in a pair receive equal income. Mutates `polity.treasury_cu`.
91
+ * Returns a map of total income credited this step per polity id.
92
+ */
93
+ export function stepTrade(registry, pairs, market) {
94
+ const income = new Map();
95
+ for (const pair of pairs) {
96
+ const keyAB = `${pair.polityAId}:${pair.polityBId}`;
97
+ const keyBA = `${pair.polityBId}:${pair.polityAId}`;
98
+ if (registry.activeWars.has(keyAB) || registry.activeWars.has(keyBA))
99
+ continue;
100
+ const polityA = registry.polities.get(pair.polityAId);
101
+ const polityB = registry.polities.get(pair.polityBId);
102
+ if (!polityA || !polityB || pair.sharedLocations <= 0)
103
+ continue;
104
+ const aSet = new Set(availableCommodities(polityA.techEra));
105
+ const bSet = new Set(availableCommodities(polityB.techEra));
106
+ let pairIncome = 0;
107
+ for (const c of COMMODITIES) {
108
+ if (!aSet.has(c.id) || !bSet.has(c.id))
109
+ continue;
110
+ const rec = market.prices.get(c.id);
111
+ // price_Q × TRADE_BASE_CU / SCALE.Q → base income per location
112
+ const basePerLoc = Math.trunc(rec.price_Q * TRADE_BASE_CU / SCALE.Q);
113
+ pairIncome += Math.trunc(basePerLoc * pair.sharedLocations * pair.routeQuality_Q / SCALE.Q);
114
+ }
115
+ if (pairIncome > 0) {
116
+ polityA.treasury_cu += pairIncome;
117
+ polityB.treasury_cu += pairIncome;
118
+ income.set(pair.polityAId, (income.get(pair.polityAId) ?? 0) + pairIncome);
119
+ income.set(pair.polityBId, (income.get(pair.polityBId) ?? 0) + pairIncome);
120
+ }
121
+ }
122
+ return income;
123
+ }
124
+ // ── Speculation ────────────────────────────────────────────────────────────────
125
+ /**
126
+ * Polity bets a fraction of treasury on a commodity price movement.
127
+ *
128
+ * Wager = `SPECULATE_WAGER_Q` (10%) of current treasury.
129
+ * Win probability = `WIN_PROBABILITY_NUM` / 100 (45%). On win: treasury gains
130
+ * `wager × SPECULATE_WIN_MUL_Q / SCALE.Q`. On loss: treasury loses the wager;
131
+ * if treasury is insufficient, the shortfall is added to `market.debt`.
132
+ *
133
+ * Expected value ≈ −10% per call (house-edge model for opaque markets).
134
+ * Returns the net treasury change (positive = profit, negative = loss or debt).
135
+ */
136
+ export function speculate(polity, commodityId, worldSeed, tick) {
137
+ const wager = Math.trunc(polity.treasury_cu * SPECULATE_WAGER_Q / SCALE.Q);
138
+ if (wager <= 0)
139
+ return 0;
140
+ const salt = hashString("speculate_" + commodityId);
141
+ const roll = eventSeed(worldSeed, tick, hashString(polity.id), 0, salt) % SCALE.Q;
142
+ const threshold = Math.trunc(SCALE.Q * WIN_PROBABILITY_NUM / 100);
143
+ if (roll < threshold) {
144
+ const profit = Math.trunc(wager * SPECULATE_WIN_MUL_Q / SCALE.Q);
145
+ polity.treasury_cu += profit;
146
+ return profit;
147
+ }
148
+ else {
149
+ polity.treasury_cu -= wager; // always safe: wager = 10% of treasury ≤ treasury
150
+ return -wager;
151
+ }
152
+ }
153
+ // ── Debt crisis ────────────────────────────────────────────────────────────────
154
+ /**
155
+ * Returns true when the polity's debt exceeds the crisis threshold.
156
+ *
157
+ * Crisis triggers when:
158
+ * - `debt > treasury × DEBT_CRISIS_RATIO_Q / SCALE.Q` (debt ratio exceeded), or
159
+ * - `treasury ≤ 0 AND debt > 0` (insolvency)
160
+ */
161
+ export function checkDebtCrisis(polity, market) {
162
+ const debt = market.debt.get(polity.id) ?? 0;
163
+ if (debt <= 0)
164
+ return false;
165
+ if (polity.treasury_cu <= 0)
166
+ return true;
167
+ const threshold = Math.trunc(polity.treasury_cu * DEBT_CRISIS_RATIO_Q / SCALE.Q);
168
+ return debt > threshold;
169
+ }
170
+ // ── Economic warfare ───────────────────────────────────────────────────────────
171
+ /**
172
+ * Aggressor dumps supply of a commodity onto the market to depress its price.
173
+ *
174
+ * Supply increases by `SUPPLY_DUMP_Q`, capped at SCALE.Q. Price drops
175
+ * proportionally to the supply increase × commodity volatility.
176
+ * Aggressor pays `TRADE_BASE_CU × SUPPLY_DUMP_Q / SCALE.Q × sharedLocations`
177
+ * cost-units (stockpile overhead).
178
+ *
179
+ * No-op if the commodity requires a higher tech era than the aggressor possesses,
180
+ * or if either polity is not in the registry.
181
+ */
182
+ export function economicWarfare(aggressorId, targetId, commodityId, registry, market, pairs) {
183
+ const aggressor = registry.polities.get(aggressorId);
184
+ if (!aggressor || !registry.polities.has(targetId))
185
+ return { aggressorCost_cu: 0, priceDrop_Q: 0 };
186
+ const comm = COMMODITIES.find(c => c.id === commodityId);
187
+ if (!comm || comm.techMinEra > aggressor.techEra)
188
+ return { aggressorCost_cu: 0, priceDrop_Q: 0 };
189
+ const rec = market.prices.get(commodityId);
190
+ const prev = rec.supply_Q;
191
+ rec.supply_Q = clampQ(rec.supply_Q + SUPPLY_DUMP_Q, 0, SCALE.Q);
192
+ const supplyIncrease = rec.supply_Q - prev;
193
+ // Price drop proportional to the supply increase × volatility
194
+ const priceDrop_Q = Math.trunc(supplyIncrease * comm.volatility_Q / SCALE.Q);
195
+ rec.price_Q = clampQ(rec.price_Q - priceDrop_Q, q(0.05), q(2.0));
196
+ // Aggressor pays stockpile cost based on shared border with target
197
+ const sharedLocs = pairs.find(p => (p.polityAId === aggressorId && p.polityBId === targetId) ||
198
+ (p.polityAId === targetId && p.polityBId === aggressorId))?.sharedLocations ?? 1;
199
+ const aggressorCost_cu = Math.trunc(TRADE_BASE_CU * SUPPLY_DUMP_Q / SCALE.Q) * sharedLocs;
200
+ aggressor.treasury_cu = Math.max(0, aggressor.treasury_cu - aggressorCost_cu);
201
+ return { aggressorCost_cu, priceDrop_Q };
202
+ }
203
+ // ── Economic pressure ──────────────────────────────────────────────────────────
204
+ /**
205
+ * Derive aggregate economic stress for a polity [0, SCALE.Q].
206
+ *
207
+ * Three components, equal weighting:
208
+ * 1. Debt ratio — `clamp(debt / max(treasury, 1), 0, SCALE.Q)`
209
+ * 2. Price stress — fraction of the polity's available commodities below 60% of base
210
+ * 3. War penalty — q(0.20) per active war involving this polity, capped at q(0.60)
211
+ */
212
+ export function deriveEconomicPressure(polity, market, registry) {
213
+ // 1. Debt ratio
214
+ const debt = market.debt.get(polity.id) ?? 0;
215
+ const debtRatio = debt > 0
216
+ ? clampQ(Math.trunc(debt * SCALE.Q / Math.max(polity.treasury_cu, 1)), 0, SCALE.Q)
217
+ : 0;
218
+ // 2. Price stress: fraction of available commodities trading below 60% of base
219
+ const avail = availableCommodities(polity.techEra);
220
+ let depressed = 0;
221
+ for (const id of avail) {
222
+ const rec = market.prices.get(id);
223
+ const comm = COMMODITIES.find(c => c.id === id);
224
+ if (!rec || !comm)
225
+ continue;
226
+ // 60% = 3/5 — integer arithmetic only
227
+ if (rec.price_Q < Math.trunc(comm.basePrice_Q * 3 / 5))
228
+ depressed++;
229
+ }
230
+ const priceStress = avail.length > 0
231
+ ? Math.trunc(depressed * SCALE.Q / avail.length)
232
+ : 0;
233
+ // 3. War penalty
234
+ let warCount = 0;
235
+ for (const key of registry.activeWars) {
236
+ const [a, b] = key.split(":");
237
+ if (a === polity.id || b === polity.id)
238
+ warCount++;
239
+ }
240
+ const warPenalty = clampQ(warCount * q(0.20), 0, q(0.60));
241
+ // Combined: equal thirds (integer division to keep fixed-point)
242
+ return clampQ((debtRatio + priceStress + warPenalty) / 3, 0, SCALE.Q);
243
+ }
244
+ // ── Composite step ─────────────────────────────────────────────────────────────
245
+ /**
246
+ * Full market step: advance prices, resolve trade, return income and crisis list.
247
+ *
248
+ * Call once per campaign day. Mutates `market.prices`, polity `treasury_cu`
249
+ * values inside `registry`, and `market.debt` (via `checkDebtCrisis` reads only —
250
+ * debt is written by `speculate`, not by `stepMarket` directly).
251
+ */
252
+ export function stepMarket(registry, pairs, market, worldSeed, tick) {
253
+ stepPrices(market, worldSeed, tick);
254
+ const tradeIncome = stepTrade(registry, pairs, market);
255
+ const crisisPolities = [];
256
+ for (const polity of registry.polities.values()) {
257
+ if (checkDebtCrisis(polity, market))
258
+ crisisPolities.push(polity.id);
259
+ }
260
+ return { tradeIncome, crisisPolities };
261
+ }
@@ -0,0 +1,92 @@
1
+ import type { RenownRegistry } from "./renown.js";
2
+ import type { Q } from "./units.js";
3
+ /** A single entity's family links within the lineage graph. */
4
+ export interface LineageNode {
5
+ entityId: number;
6
+ /** 0, 1, or 2 biological parent IDs. */
7
+ parentIds: number[];
8
+ /** All children recorded via `recordBirth`. */
9
+ childIds: number[];
10
+ /** All recorded partners (may grow over time). */
11
+ partnerIds: number[];
12
+ }
13
+ /** Registry of all lineage nodes, keyed by entityId. */
14
+ export interface LineageRegistry {
15
+ nodes: Map<number, LineageNode>;
16
+ }
17
+ /** Human-readable kinship label derived from `computeKinshipDegree`. */
18
+ export type KinshipLabel = "self" | "immediate" | "close" | "extended" | "distant" | "unrelated";
19
+ /** Maximum BFS depth for kinship searches; beyond this entities are "unrelated". */
20
+ export declare const MAX_KINSHIP_DEPTH = 4;
21
+ /**
22
+ * Depth-decay factor for inherited renown.
23
+ * Each generation reduces the renown contribution by this fraction:
24
+ * depth 1 (parent) → q(0.50) × parent renown
25
+ * depth 2 (grandparent) → q(0.25) × grandparent renown
26
+ */
27
+ export declare const RENOWN_DEPTH_DECAY_Q: Q;
28
+ export declare function createLineageRegistry(): LineageRegistry;
29
+ /**
30
+ * Return the `LineageNode` for `entityId`, creating a root node (no parents,
31
+ * no children, no partners) if one does not yet exist.
32
+ */
33
+ export declare function getLineageNode(registry: LineageRegistry, entityId: number): LineageNode;
34
+ /**
35
+ * Register a birth: create a node for `childId` and link it to up to two parents.
36
+ * Parent nodes are created if they do not already exist.
37
+ * No-op if `childId` already has a node (idempotent).
38
+ */
39
+ export declare function recordBirth(registry: LineageRegistry, childId: number, parentAId: number, parentBId?: number): void;
40
+ /**
41
+ * Record a partnership between two entities.
42
+ * Partners are considered degree-1 kin (immediate).
43
+ * Idempotent: duplicate calls are safe.
44
+ */
45
+ export declare function recordPartnership(registry: LineageRegistry, entityAId: number, entityBId: number): void;
46
+ /** Return the parent IDs of `entityId` (0–2 elements). */
47
+ export declare function getParents(registry: LineageRegistry, entityId: number): number[];
48
+ /** Return the child IDs of `entityId`. */
49
+ export declare function getChildren(registry: LineageRegistry, entityId: number): number[];
50
+ /**
51
+ * Return the sibling IDs of `entityId` — entities that share at least one parent,
52
+ * excluding `entityId` itself.
53
+ */
54
+ export declare function getSiblings(registry: LineageRegistry, entityId: number): number[];
55
+ /**
56
+ * Return all ancestors of `entityId` within `maxDepth` generations.
57
+ * Uses BFS upward through parent links only.
58
+ */
59
+ export declare function findAncestors(registry: LineageRegistry, entityId: number, maxDepth?: number): Set<number>;
60
+ /**
61
+ * Compute the degree of kinship between two entities via BFS on the undirected
62
+ * family graph (parents, children, and partners are all degree-1 neighbours).
63
+ *
64
+ * Returns:
65
+ * - `0` if `entityA === entityB`
66
+ * - `1`–`MAX_KINSHIP_DEPTH` for kin within range
67
+ * - `null` if no path exists within `MAX_KINSHIP_DEPTH`
68
+ */
69
+ export declare function computeKinshipDegree(registry: LineageRegistry, entityA: number, entityB: number): number | null;
70
+ /** Whether two entities are kin within `maxDegree` (default `MAX_KINSHIP_DEPTH`). */
71
+ export declare function isKin(registry: LineageRegistry, entityA: number, entityB: number, maxDegree?: number): boolean;
72
+ /**
73
+ * Map a numeric kinship degree (or `null`) to a `KinshipLabel`.
74
+ *
75
+ * @param degree Result of `computeKinshipDegree`; pass `null` for unrelated.
76
+ */
77
+ export declare function getKinshipLabel(degree: number | null): KinshipLabel;
78
+ /**
79
+ * Compute the renown bonus an entity inherits from their ancestors.
80
+ *
81
+ * For each ancestor at depth d, contribution = `ancestor.renown_Q × decay^d`
82
+ * where `decay = RENOWN_DEPTH_DECAY_Q / SCALE.Q` (default 0.5 per generation).
83
+ * The sum is clamped to `[0, SCALE.Q]`.
84
+ *
85
+ * Entities with no renown records or no ancestors return 0.
86
+ *
87
+ * @param registry Lineage registry.
88
+ * @param entityId Entity whose ancestors are being summed.
89
+ * @param renownRegistry Phase 75 renown registry.
90
+ * @param maxDepth How many generations to look back (default 3).
91
+ */
92
+ export declare function computeInheritedRenown(lineage: LineageRegistry, entityId: number, renownRegistry: RenownRegistry, maxDepth?: number): Q;