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

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,39 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.17] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 73 · Enhanced Epidemiological Models** (`src/sim/disease.ts` extended in-place)
14
+ - `VaccinationRecord { diseaseId, efficacy_Q, doseCount }` — partial-efficacy vaccination
15
+ stored on `entity.vaccinations?`; `vaccinate(entity, diseaseId, efficacy_Q)` helper.
16
+ - `ageSusceptibility_Q(ageYears)` — U-shaped multiplier: infants ×1.30, children ×0.80,
17
+ adults ×1.00, early elderly ×1.20, late elderly ×1.50. Auto-applied in
18
+ `computeTransmissionRisk` when `entity.age` is set.
19
+ - `NPIType`, `NPIRecord`, `NPIRegistry` — non-pharmaceutical intervention registry;
20
+ `applyNPI / removeNPI / hasNPI` helpers. `mask_mandate` reduces airborne transmission
21
+ by `NPI_MASK_REDUCTION_Q = q(0.60)` (60 %). `quarantine` recorded for host-side pair
22
+ filtering.
23
+ - `computeTransmissionRisk` extended with optional 5th `options?` parameter — backward-
24
+ compatible; applies vaccination, age susceptibility, and NPI effects when present.
25
+ - `computeR0(profile, entityMap)` — basic reproductive number estimate
26
+ (β × infectious-days × min(15, population−1)); used for validation.
27
+ - `stepSEIR(entity, delta_s, profile, worldSeed, tick)` — SEIR-aware entity step that
28
+ isolates a single disease profile; delegates to Phase 56 `stepDiseaseForEntity` for
29
+ full backward compatibility.
30
+ - `registerDiseaseProfile(profile)` — registers custom/SEIR profiles into the lookup map
31
+ without modifying the canonical `DISEASE_PROFILES` array.
32
+ - `MEASLES` profile (`useSeir: true`): R0 ≈ 15.1 in population ≥ 16, 14-day incubation,
33
+ 14-day infectious period, 0.2 % IFR, permanent immunity. Validates epidemic curve
34
+ peaking days 10–20 and burning out by day 60 (matches standard SIR output ±15 %).
35
+ - `entity.vaccinations?: VaccinationRecord[]` added to `Entity`.
36
+ - `DiseaseProfile.useSeir?: boolean` opt-in field (no effect on existing callers).
37
+ - 37 new tests in `test/disease-seir.test.ts`. All 37 Phase 56 tests pass unmodified.
38
+ - **3 998 tests total.**
39
+
40
+ ---
41
+
9
42
  ## [0.1.16] — 2026-03-25
10
43
 
11
44
  ### 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
+ }
@@ -27,6 +27,42 @@ export interface DiseaseProfile {
27
27
  * -1 = permanent; 0 = no immunity (can be reinfected immediately).
28
28
  */
29
29
  immunityDuration_s: number;
30
+ /**
31
+ * Phase 73: opt-in to SEIR compartment tracking via `stepSEIR`.
32
+ * No effect on `stepDiseaseForEntity` — backward-compatible.
33
+ */
34
+ useSeir?: boolean;
35
+ }
36
+ /**
37
+ * Vaccination record granting partial-efficacy protection.
38
+ * Stored on `entity.vaccinations?`.
39
+ */
40
+ export interface VaccinationRecord {
41
+ diseaseId: string;
42
+ /** Fraction of transmission risk blocked [Q]. q(0.95) = 95 % efficacy. */
43
+ efficacy_Q: Q;
44
+ /** Number of doses received. Informational; efficacy reflects total dose schedule. */
45
+ doseCount: number;
46
+ }
47
+ /** Non-pharmaceutical intervention type. */
48
+ export type NPIType = "quarantine" | "mask_mandate";
49
+ /** An active NPI for a polity. */
50
+ export interface NPIRecord {
51
+ polityId: string;
52
+ npiType: NPIType;
53
+ }
54
+ /**
55
+ * Registry of active NPIs per polity.
56
+ * Key format: `"${polityId}:${npiType}"`.
57
+ */
58
+ export type NPIRegistry = Map<string, NPIRecord>;
59
+ /** Options for the extended `computeTransmissionRisk`. */
60
+ export interface TransmissionOptions {
61
+ /**
62
+ * Mask mandate NPI active for this pair's polity.
63
+ * Reduces airborne transmission by `NPI_MASK_REDUCTION_Q` (60 %).
64
+ */
65
+ maskMandate?: boolean;
30
66
  }
31
67
  /** One active disease infection on an entity. */
32
68
  export interface DiseaseState {
@@ -67,10 +103,38 @@ export interface SpreadResult {
67
103
  }
68
104
  /** Maximum distance for contact/vector/waterborne transmission [SCALE.m]. */
69
105
  export declare const CONTACT_RANGE_Sm = 20000;
106
+ /**
107
+ * Airborne transmission reduction from mask mandate NPI [Q].
108
+ * Risk is multiplied by (SCALE.Q − NPI_MASK_REDUCTION_Q) / SCALE.Q → ×0.40 remaining.
109
+ */
110
+ export declare const NPI_MASK_REDUCTION_Q: number;
111
+ /**
112
+ * Daily contacts-per-entity estimate for `computeR0`.
113
+ * Community-setting assumption; capped by actual population size.
114
+ */
115
+ export declare const DAILY_CONTACTS_ESTIMATE = 15;
70
116
  /** All disease profiles indexed by id. */
71
117
  export declare const DISEASE_PROFILES: DiseaseProfile[];
72
118
  /** Look up a disease profile by id. Returns undefined for unknown ids. */
73
119
  export declare function getDiseaseProfile(id: string): DiseaseProfile | undefined;
120
+ /**
121
+ * Register a custom disease profile so it can be used with
122
+ * `exposeToDisease`, `spreadDisease`, and `stepDiseaseForEntity`.
123
+ *
124
+ * Does not modify `DISEASE_PROFILES`. Use this to add `MEASLES` or other
125
+ * Phase 73 / host-defined profiles to the lookup map.
126
+ */
127
+ export declare function registerDiseaseProfile(profile: DiseaseProfile): void;
128
+ /**
129
+ * Measles — highly contagious SEIR airborne disease.
130
+ *
131
+ * R0 ≈ 12–18 in populations of 15+ (DAILY_CONTACTS_ESTIMATE × 14 days × baseRate).
132
+ * Use with `registerDiseaseProfile(MEASLES)` before calling `exposeToDisease`.
133
+ *
134
+ * Validation target: epidemic curve peaks days 10–20, burns out by day 60,
135
+ * matching standard SIR model output within ±15 % for 95 % susceptible population.
136
+ */
137
+ export declare const MEASLES: DiseaseProfile;
74
138
  /**
75
139
  * Attempt to expose an entity to a disease.
76
140
  *
@@ -116,12 +180,18 @@ export declare function stepDiseaseForEntity(entity: Entity, delta_s: number, wo
116
180
  * Returns q(0) if the carrier has no symptomatic instance of this disease,
117
181
  * or if target already has immunity / active infection for this disease.
118
182
  *
183
+ * **Phase 73 extensions (backward-compatible):**
184
+ * - If `target.age` is set, applies age-stratified susceptibility multiplier.
185
+ * - If `target.vaccinations` contains a record for this disease, reduces risk by efficacy.
186
+ * - If `options.maskMandate` is true and disease is airborne, reduces risk by `NPI_MASK_REDUCTION_Q`.
187
+ *
119
188
  * @param carrier The potentially infectious entity.
120
189
  * @param target The potentially susceptible entity.
121
190
  * @param dist_Sm Distance between them [SCALE.m].
122
191
  * @param disease The disease profile to evaluate.
192
+ * @param options Phase 73 optional NPI modifiers.
123
193
  */
124
- export declare function computeTransmissionRisk(carrier: Entity, target: Entity, dist_Sm: number, disease: DiseaseProfile): Q;
194
+ export declare function computeTransmissionRisk(carrier: Entity, target: Entity, dist_Sm: number, disease: DiseaseProfile, options?: TransmissionOptions): Q;
125
195
  /**
126
196
  * Attempt to spread disease across a set of nearby entity pairs.
127
197
  *
@@ -139,3 +209,76 @@ export declare function computeTransmissionRisk(carrier: Entity, target: Entity,
139
209
  * @returns Number of new exposures created.
140
210
  */
141
211
  export declare function spreadDisease(entityMap: Map<number, Entity>, pairs: NearbyPair[], worldSeed: number, tick: number): SpreadResult;
212
+ /**
213
+ * Age-stratified susceptibility multiplier [Q].
214
+ *
215
+ * Returns a value that may exceed SCALE.Q (increased susceptibility) or fall
216
+ * below it (relative protection). Applied in `computeTransmissionRisk` when
217
+ * `target.age` is set.
218
+ *
219
+ * | Age range | Multiplier | Notes |
220
+ * |-----------|-----------|-------------------------------|
221
+ * | 0–4 yrs | ×1.30 | High infant susceptibility |
222
+ * | 5–14 yrs | ×0.80 | Children — lower risk |
223
+ * | 15–59 yrs | ×1.00 | Adult baseline |
224
+ * | 60–74 yrs | ×1.20 | Early elderly |
225
+ * | 75 + yrs | ×1.50 | Late elderly / ancient |
226
+ */
227
+ export declare function ageSusceptibility_Q(ageYears: number): Q;
228
+ /**
229
+ * Add or update a vaccination record on an entity.
230
+ *
231
+ * If the entity already has a record for this disease, updates `efficacy_Q`
232
+ * and increments `doseCount` (booster model). Otherwise creates a new record.
233
+ *
234
+ * @param entity Target entity to vaccinate.
235
+ * @param diseaseId Disease being vaccinated against.
236
+ * @param efficacy_Q Protection level [Q]; q(0.95) = 95 % efficacy.
237
+ */
238
+ export declare function vaccinate(entity: Entity, diseaseId: string, efficacy_Q: Q): void;
239
+ /**
240
+ * Activate an NPI for a polity.
241
+ *
242
+ * `"mask_mandate"` — reduces airborne transmission in `computeTransmissionRisk`
243
+ * by `NPI_MASK_REDUCTION_Q` when the caller passes `options.maskMandate = true`.
244
+ *
245
+ * `"quarantine"` — recorded in the registry; the host is responsible for halving
246
+ * the contact-range pairs passed to `spreadDisease` (spatial filtering).
247
+ */
248
+ export declare function applyNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): void;
249
+ /** Remove an NPI from a polity's registry entry. */
250
+ export declare function removeNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): void;
251
+ /** Returns true if the specified NPI is currently active for the polity. */
252
+ export declare function hasNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): boolean;
253
+ /**
254
+ * Estimate the basic reproductive number R0 for a disease profile.
255
+ *
256
+ * Formula: R0 = beta × D × c
257
+ * - beta = baseTransmissionRate_Q / SCALE.Q (per-contact daily probability)
258
+ * - D = symptomaticDuration_s / 86400 (infectious period in days)
259
+ * - c = min(DAILY_CONTACTS_ESTIMATE, entityMap.size − 1) (daily contacts)
260
+ *
261
+ * Used for validation — not a simulation path value.
262
+ *
263
+ * @param profile Disease profile to evaluate.
264
+ * @param entityMap Population map (size determines contact estimate).
265
+ * @returns Estimated R0 (float; not fixed-point).
266
+ */
267
+ export declare function computeR0(profile: DiseaseProfile, entityMap: Map<number, Entity>): number;
268
+ /**
269
+ * Advance a single SEIR-enabled disease on an entity by `delta_s` seconds.
270
+ *
271
+ * Functionally equivalent to `stepDiseaseForEntity` for this profile only —
272
+ * isolates the target disease so other active diseases are not advanced.
273
+ * Backward-compatible: calls through to the Phase 56 step function.
274
+ *
275
+ * Intended for use with `profile.useSeir === true` diseases, but works with
276
+ * any profile registered via `registerDiseaseProfile`.
277
+ *
278
+ * @param entity Entity to advance.
279
+ * @param delta_s Elapsed seconds.
280
+ * @param profile Disease profile to process.
281
+ * @param worldSeed World seed for deterministic mortality roll.
282
+ * @param tick Current tick for deterministic mortality roll.
283
+ */
284
+ export declare function stepSEIR(entity: Entity, delta_s: number, profile: DiseaseProfile, worldSeed: number, tick: number): EntityDiseaseResult;
@@ -22,6 +22,19 @@ export const CONTACT_RANGE_Sm = 20_000; // 2 m
22
22
  const FEVER_AIRBORNE_Sm = 100_000; // 10 m
23
23
  /** Plague airborne range [SCALE.m]. */
24
24
  const PLAGUE_AIRBORNE_Sm = 50_000; // 5 m
25
+ // Phase 73 constants ───────────────────────────────────────────────────────────
26
+ /**
27
+ * Airborne transmission reduction from mask mandate NPI [Q].
28
+ * Risk is multiplied by (SCALE.Q − NPI_MASK_REDUCTION_Q) / SCALE.Q → ×0.40 remaining.
29
+ */
30
+ export const NPI_MASK_REDUCTION_Q = q(0.60);
31
+ /**
32
+ * Daily contacts-per-entity estimate for `computeR0`.
33
+ * Community-setting assumption; capped by actual population size.
34
+ */
35
+ export const DAILY_CONTACTS_ESTIMATE = 15;
36
+ /** Seconds per year — mirrored from aging.ts to avoid circular import. */
37
+ const _SECS_PER_YEAR = 365 * 86_400;
25
38
  // ── Disease Catalogue ─────────────────────────────────────────────────────────
26
39
  /**
27
40
  * Common fever — mild respiratory infection.
@@ -133,6 +146,39 @@ const _PROFILE_MAP = new Map(DISEASE_PROFILES.map(p => [p.id, p]));
133
146
  export function getDiseaseProfile(id) {
134
147
  return _PROFILE_MAP.get(id);
135
148
  }
149
+ /**
150
+ * Register a custom disease profile so it can be used with
151
+ * `exposeToDisease`, `spreadDisease`, and `stepDiseaseForEntity`.
152
+ *
153
+ * Does not modify `DISEASE_PROFILES`. Use this to add `MEASLES` or other
154
+ * Phase 73 / host-defined profiles to the lookup map.
155
+ */
156
+ export function registerDiseaseProfile(profile) {
157
+ _PROFILE_MAP.set(profile.id, profile);
158
+ }
159
+ // ── Phase 73: MEASLES profile ─────────────────────────────────────────────────
160
+ /**
161
+ * Measles — highly contagious SEIR airborne disease.
162
+ *
163
+ * R0 ≈ 12–18 in populations of 15+ (DAILY_CONTACTS_ESTIMATE × 14 days × baseRate).
164
+ * Use with `registerDiseaseProfile(MEASLES)` before calling `exposeToDisease`.
165
+ *
166
+ * Validation target: epidemic curve peaks days 10–20, burns out by day 60,
167
+ * matching standard SIR model output within ±15 % for 95 % susceptible population.
168
+ */
169
+ export const MEASLES = {
170
+ id: "measles",
171
+ name: "Measles",
172
+ transmissionRoute: "airborne",
173
+ baseTransmissionRate_Q: q(0.072), // R0 ≈ 15.1 with 15 daily contacts, 14-day duration
174
+ incubationPeriod_s: 14 * 86_400, // 14-day latent period
175
+ symptomaticDuration_s: 14 * 86_400, // 14-day infectious period
176
+ mortalityRate_Q: q(0.002), // 0.2 % IFR (developed world)
177
+ symptomSeverity_Q: q(0.15),
178
+ airborneRange_Sm: 100_000, // 10 m
179
+ immunityDuration_s: -1, // permanent lifelong immunity
180
+ useSeir: true,
181
+ };
136
182
  // ── Entity-level API ──────────────────────────────────────────────────────────
137
183
  /**
138
184
  * Attempt to expose an entity to a disease.
@@ -268,12 +314,18 @@ export function stepDiseaseForEntity(entity, delta_s, worldSeed, tick) {
268
314
  * Returns q(0) if the carrier has no symptomatic instance of this disease,
269
315
  * or if target already has immunity / active infection for this disease.
270
316
  *
317
+ * **Phase 73 extensions (backward-compatible):**
318
+ * - If `target.age` is set, applies age-stratified susceptibility multiplier.
319
+ * - If `target.vaccinations` contains a record for this disease, reduces risk by efficacy.
320
+ * - If `options.maskMandate` is true and disease is airborne, reduces risk by `NPI_MASK_REDUCTION_Q`.
321
+ *
271
322
  * @param carrier The potentially infectious entity.
272
323
  * @param target The potentially susceptible entity.
273
324
  * @param dist_Sm Distance between them [SCALE.m].
274
325
  * @param disease The disease profile to evaluate.
326
+ * @param options Phase 73 optional NPI modifiers.
275
327
  */
276
- export function computeTransmissionRisk(carrier, target, dist_Sm, disease) {
328
+ export function computeTransmissionRisk(carrier, target, dist_Sm, disease, options) {
277
329
  // Carrier must be symptomatic with this disease
278
330
  const carrierState = carrier.activeDiseases?.find(d => d.diseaseId === disease.id && d.phase === "symptomatic");
279
331
  if (!carrierState)
@@ -285,16 +337,37 @@ export function computeTransmissionRisk(carrier, target, dist_Sm, disease) {
285
337
  const immune = target.immunity?.some(r => r.diseaseId === disease.id && (r.remainingSeconds === -1 || r.remainingSeconds > 0));
286
338
  if (immune)
287
339
  return q(0);
340
+ // ── Compute distance-based base risk ───────────────────────────────────────
341
+ let risk;
288
342
  if (disease.transmissionRoute === "airborne") {
289
343
  if (disease.airborneRange_Sm <= 0 || dist_Sm >= disease.airborneRange_Sm)
290
344
  return q(0);
291
345
  const proximity_Q = Math.round((disease.airborneRange_Sm - dist_Sm) * SCALE.Q / disease.airborneRange_Sm);
292
- return Math.round(disease.baseTransmissionRate_Q * proximity_Q / SCALE.Q);
346
+ risk = Math.round(disease.baseTransmissionRate_Q * proximity_Q / SCALE.Q);
293
347
  }
294
- // contact / vector / waterborne: flat risk within CONTACT_RANGE
295
- if (dist_Sm > CONTACT_RANGE_Sm)
296
- return q(0);
297
- return disease.baseTransmissionRate_Q;
348
+ else {
349
+ // contact / vector / waterborne: flat risk within CONTACT_RANGE
350
+ if (dist_Sm > CONTACT_RANGE_Sm)
351
+ return q(0);
352
+ risk = disease.baseTransmissionRate_Q;
353
+ }
354
+ // ── Phase 73: age-stratified susceptibility ────────────────────────────────
355
+ if (target.age) {
356
+ const ageYears = target.age.ageSeconds / _SECS_PER_YEAR;
357
+ const ageMultiplier = ageSusceptibility_Q(ageYears);
358
+ risk = clampQ(Math.round(risk * ageMultiplier / SCALE.Q), 0, SCALE.Q);
359
+ }
360
+ // ── Phase 73: vaccination efficacy reduction ───────────────────────────────
361
+ const vacc = target.vaccinations?.find(v => v.diseaseId === disease.id);
362
+ if (vacc && vacc.efficacy_Q > 0) {
363
+ const blocked = Math.round(risk * vacc.efficacy_Q / SCALE.Q);
364
+ risk = Math.max(0, risk - blocked);
365
+ }
366
+ // ── Phase 73: NPI mask mandate (airborne only) ─────────────────────────────
367
+ if (options?.maskMandate && disease.transmissionRoute === "airborne") {
368
+ risk = Math.round(risk * (SCALE.Q - NPI_MASK_REDUCTION_Q) / SCALE.Q);
369
+ }
370
+ return risk;
298
371
  }
299
372
  /**
300
373
  * Attempt to spread disease across a set of nearby entity pairs.
@@ -351,3 +424,136 @@ function diseaseIdSalt(id) {
351
424
  h = (h + id.charCodeAt(i)) & 0xFFFFFF;
352
425
  return h;
353
426
  }
427
+ // ── Phase 73: Enhanced Epidemiology Functions ─────────────────────────────────
428
+ /**
429
+ * Age-stratified susceptibility multiplier [Q].
430
+ *
431
+ * Returns a value that may exceed SCALE.Q (increased susceptibility) or fall
432
+ * below it (relative protection). Applied in `computeTransmissionRisk` when
433
+ * `target.age` is set.
434
+ *
435
+ * | Age range | Multiplier | Notes |
436
+ * |-----------|-----------|-------------------------------|
437
+ * | 0–4 yrs | ×1.30 | High infant susceptibility |
438
+ * | 5–14 yrs | ×0.80 | Children — lower risk |
439
+ * | 15–59 yrs | ×1.00 | Adult baseline |
440
+ * | 60–74 yrs | ×1.20 | Early elderly |
441
+ * | 75 + yrs | ×1.50 | Late elderly / ancient |
442
+ */
443
+ export function ageSusceptibility_Q(ageYears) {
444
+ if (ageYears < 5)
445
+ return 13_000; // ×1.30
446
+ if (ageYears < 15)
447
+ return 8_000; // ×0.80
448
+ if (ageYears < 60)
449
+ return 10_000; // ×1.00 baseline
450
+ if (ageYears < 75)
451
+ return 12_000; // ×1.20
452
+ return 15_000; // ×1.50
453
+ }
454
+ /**
455
+ * Add or update a vaccination record on an entity.
456
+ *
457
+ * If the entity already has a record for this disease, updates `efficacy_Q`
458
+ * and increments `doseCount` (booster model). Otherwise creates a new record.
459
+ *
460
+ * @param entity Target entity to vaccinate.
461
+ * @param diseaseId Disease being vaccinated against.
462
+ * @param efficacy_Q Protection level [Q]; q(0.95) = 95 % efficacy.
463
+ */
464
+ export function vaccinate(entity, diseaseId, efficacy_Q) {
465
+ if (!entity.vaccinations)
466
+ entity.vaccinations = [];
467
+ const existing = entity.vaccinations.find(v => v.diseaseId === diseaseId);
468
+ if (existing) {
469
+ existing.efficacy_Q = efficacy_Q;
470
+ existing.doseCount++;
471
+ }
472
+ else {
473
+ entity.vaccinations.push({ diseaseId, efficacy_Q, doseCount: 1 });
474
+ }
475
+ }
476
+ // ── NPI registry helpers ───────────────────────────────────────────────────────
477
+ function _npiKey(polityId, npiType) {
478
+ return `${polityId}:${npiType}`;
479
+ }
480
+ /**
481
+ * Activate an NPI for a polity.
482
+ *
483
+ * `"mask_mandate"` — reduces airborne transmission in `computeTransmissionRisk`
484
+ * by `NPI_MASK_REDUCTION_Q` when the caller passes `options.maskMandate = true`.
485
+ *
486
+ * `"quarantine"` — recorded in the registry; the host is responsible for halving
487
+ * the contact-range pairs passed to `spreadDisease` (spatial filtering).
488
+ */
489
+ export function applyNPI(npiRegistry, npiType, polityId) {
490
+ npiRegistry.set(_npiKey(polityId, npiType), { polityId, npiType });
491
+ }
492
+ /** Remove an NPI from a polity's registry entry. */
493
+ export function removeNPI(npiRegistry, npiType, polityId) {
494
+ npiRegistry.delete(_npiKey(polityId, npiType));
495
+ }
496
+ /** Returns true if the specified NPI is currently active for the polity. */
497
+ export function hasNPI(npiRegistry, npiType, polityId) {
498
+ return npiRegistry.has(_npiKey(polityId, npiType));
499
+ }
500
+ /**
501
+ * Estimate the basic reproductive number R0 for a disease profile.
502
+ *
503
+ * Formula: R0 = beta × D × c
504
+ * - beta = baseTransmissionRate_Q / SCALE.Q (per-contact daily probability)
505
+ * - D = symptomaticDuration_s / 86400 (infectious period in days)
506
+ * - c = min(DAILY_CONTACTS_ESTIMATE, entityMap.size − 1) (daily contacts)
507
+ *
508
+ * Used for validation — not a simulation path value.
509
+ *
510
+ * @param profile Disease profile to evaluate.
511
+ * @param entityMap Population map (size determines contact estimate).
512
+ * @returns Estimated R0 (float; not fixed-point).
513
+ */
514
+ export function computeR0(profile, entityMap) {
515
+ const infectiousDays = profile.symptomaticDuration_s / 86_400;
516
+ const beta = profile.baseTransmissionRate_Q / SCALE.Q;
517
+ const contacts = Math.min(DAILY_CONTACTS_ESTIMATE, Math.max(1, entityMap.size - 1));
518
+ return beta * infectiousDays * contacts;
519
+ }
520
+ /**
521
+ * Advance a single SEIR-enabled disease on an entity by `delta_s` seconds.
522
+ *
523
+ * Functionally equivalent to `stepDiseaseForEntity` for this profile only —
524
+ * isolates the target disease so other active diseases are not advanced.
525
+ * Backward-compatible: calls through to the Phase 56 step function.
526
+ *
527
+ * Intended for use with `profile.useSeir === true` diseases, but works with
528
+ * any profile registered via `registerDiseaseProfile`.
529
+ *
530
+ * @param entity Entity to advance.
531
+ * @param delta_s Elapsed seconds.
532
+ * @param profile Disease profile to process.
533
+ * @param worldSeed World seed for deterministic mortality roll.
534
+ * @param tick Current tick for deterministic mortality roll.
535
+ */
536
+ export function stepSEIR(entity, delta_s, profile, worldSeed, tick) {
537
+ const empty = {
538
+ advancedToSymptomatic: [],
539
+ recovered: [],
540
+ died: false,
541
+ fatigueApplied: 0,
542
+ };
543
+ if (entity.injury.dead)
544
+ return empty;
545
+ const diseaseState = entity.activeDiseases?.find(d => d.diseaseId === profile.id);
546
+ if (!diseaseState)
547
+ return empty;
548
+ // Isolate this disease so stepDiseaseForEntity only processes it
549
+ const others = entity.activeDiseases.filter(d => d.diseaseId !== profile.id);
550
+ entity.activeDiseases = [diseaseState];
551
+ const result = stepDiseaseForEntity(entity, delta_s, worldSeed, tick);
552
+ // Reattach other diseases (preserving any mutations stepDiseaseForEntity made)
553
+ const remaining = entity.activeDiseases ?? [];
554
+ entity.activeDiseases = [...others, ...remaining];
555
+ if (entity.activeDiseases.length === 0) {
556
+ delete entity.activeDiseases;
557
+ }
558
+ return result;
559
+ }
@@ -17,7 +17,7 @@ import type { LimbState } from "./limb.js";
17
17
  import type { ExtendedSenses } from "./sensory-extended.js";
18
18
  import type { ActiveIngestedToxin, CumulativeExposureRecord, WithdrawalState } from "./systemic-toxicology.js";
19
19
  import type { TraumaState } from "./wound-aging.js";
20
- import type { DiseaseState, ImmunityRecord } from "./disease.js";
20
+ import type { DiseaseState, ImmunityRecord, VaccinationRecord } from "./disease.js";
21
21
  import type { AgeState } from "./aging.js";
22
22
  import type { SleepState } from "./sleep.js";
23
23
  import type { MountState } from "./mount.js";
@@ -219,6 +219,11 @@ export interface Entity {
219
219
  * Consumed by `src/sim/disease.ts`.
220
220
  */
221
221
  immunity?: ImmunityRecord[];
222
+ /**
223
+ * @subsystem(disease/seir) Phase 73: vaccination records granting partial-efficacy protection.
224
+ * Consumed by `computeTransmissionRisk` in `src/sim/disease.ts`.
225
+ */
226
+ vaccinations?: VaccinationRecord[];
222
227
  /**
223
228
  * @subsystem(aging) Elapsed life-seconds for aging calculations.
224
229
  * Consumed by `src/sim/aging.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -116,6 +116,7 @@
116
116
  "example:species": "node dist/examples/quickstart-species.js",
117
117
  "generate-fixtures": "node dist/tools/generate-fixtures.js",
118
118
  "generate-zoo": "node dist/tools/generate-zoo.js",
119
+ "generate-playground": "node dist/tools/generate-playground.js",
119
120
  "generate-map": "node dist/tools/generate-map.js",
120
121
  "world-server": "node dist/tools/world-server.js",
121
122
  "persistent-world": "node dist/tools/persistent-world.js",