@its-not-rocket-science/ananke 0.1.15 → 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 +51 -2
- package/dist/src/battle-bridge.d.ts +94 -0
- package/dist/src/battle-bridge.js +126 -0
- package/dist/src/economy-gen.d.ts +133 -0
- package/dist/src/economy-gen.js +261 -0
- package/dist/src/sim/disease.d.ts +144 -1
- package/dist/src/sim/disease.js +212 -6
- package/dist/src/sim/entity.d.ts +6 -1
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,57 @@ 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
|
+
|
|
42
|
+
## [0.1.16] — 2026-03-25
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **CE-5 · Persistent World Server** — campaign ↔ combat battle bridge:
|
|
47
|
+
- src/battle-bridge.ts: pure functions translating polity state to
|
|
48
|
+
BattleConfig and BattleOutcome back to PolityImpact[]. Covers
|
|
49
|
+
tech-era→loadout mapping, military-strength→team-size scaling,
|
|
50
|
+
deterministic battle seed, morale/stability/population impact.
|
|
51
|
+
27 tests in test/battle-bridge.test.ts.
|
|
52
|
+
- tools/persistent-world.ts: integrated server running polity tick +
|
|
53
|
+
synchronous tactical battles every 7 days per active war. Battle
|
|
54
|
+
outcomes mutate polity morale, stability, and population. Full
|
|
55
|
+
checkpoint/resume, WebSocket push, HTTP war/peace/save/reset/battles
|
|
56
|
+
endpoints. Run with: npm run persistent-world
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
9
60
|
## [0.1.15] — 2026-03-25
|
|
10
61
|
|
|
11
62
|
### Added
|
|
@@ -33,8 +84,6 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
33
84
|
accumulation parity with the TypeScript reference implementation.
|
|
34
85
|
- Build scripts: `npm run build:wasm:all`, `npm run test:wasm`.
|
|
35
86
|
|
|
36
|
-
## [Unreleased]
|
|
37
|
-
|
|
38
87
|
### Added
|
|
39
88
|
|
|
40
89
|
- **Phase 71 · Cultural Generation & Evolution Framework** (`src/culture.ts`)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { TechEra } from "./sim/tech.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
/** Equipment loadout inferred from a polity's tech era. */
|
|
4
|
+
export interface EraLoadout {
|
|
5
|
+
archetype: string;
|
|
6
|
+
weaponId: string;
|
|
7
|
+
armourId: string;
|
|
8
|
+
}
|
|
9
|
+
/** Configuration for a single tactical battle between two polities. */
|
|
10
|
+
export interface BattleConfig {
|
|
11
|
+
/** Deterministic seed: combines world seed + day + polity ids. */
|
|
12
|
+
seed: number;
|
|
13
|
+
polityAId: string;
|
|
14
|
+
polityBId: string;
|
|
15
|
+
teamASize: number;
|
|
16
|
+
teamBSize: number;
|
|
17
|
+
loadoutA: EraLoadout;
|
|
18
|
+
loadoutB: EraLoadout;
|
|
19
|
+
/** Tick limit — battle is a draw if neither side wins by this tick. */
|
|
20
|
+
maxTicks: number;
|
|
21
|
+
}
|
|
22
|
+
/** Result reported by the caller after the battle completes. */
|
|
23
|
+
export interface BattleOutcome {
|
|
24
|
+
/** 1 = team A won, 2 = team B won, 0 = draw (timeout or mutual annihilation). */
|
|
25
|
+
winner: 0 | 1 | 2;
|
|
26
|
+
ticksElapsed: number;
|
|
27
|
+
teamACasualties: number;
|
|
28
|
+
teamBCasualties: number;
|
|
29
|
+
}
|
|
30
|
+
/** Per-polity state changes to apply after a battle. */
|
|
31
|
+
export interface PolityImpact {
|
|
32
|
+
polityId: string;
|
|
33
|
+
moraleDelta_Q: number;
|
|
34
|
+
stabilityDelta_Q: number;
|
|
35
|
+
populationLost: number;
|
|
36
|
+
}
|
|
37
|
+
/** Summary record written to the battle log. */
|
|
38
|
+
export interface BattleRecord {
|
|
39
|
+
day: number;
|
|
40
|
+
polityAId: string;
|
|
41
|
+
polityBId: string;
|
|
42
|
+
winner: 0 | 1 | 2;
|
|
43
|
+
teamACasualties: number;
|
|
44
|
+
teamBCasualties: number;
|
|
45
|
+
ticksElapsed: number;
|
|
46
|
+
}
|
|
47
|
+
/** Minimum and maximum combatants per side. */
|
|
48
|
+
export declare const MIN_TEAM_SIZE = 2;
|
|
49
|
+
export declare const MAX_TEAM_SIZE = 16;
|
|
50
|
+
/** Battle ends after this many ticks regardless of outcome (prevents infinite loops). */
|
|
51
|
+
export declare const DEFAULT_MAX_TICKS = 6000;
|
|
52
|
+
/** Morale bonus for winning a battle (Q units). */
|
|
53
|
+
export declare const WIN_MORALE_BONUS: number;
|
|
54
|
+
/** Morale penalty for losing a battle (Q units). */
|
|
55
|
+
export declare const LOSS_MORALE_PENALTY: number;
|
|
56
|
+
/** Stability penalty per 10% casualties above 20% casualty rate. */
|
|
57
|
+
export declare const CASUALTY_STABILITY_RATE: number;
|
|
58
|
+
/** Population lost per combatant casualty (polity headcount, not Q). */
|
|
59
|
+
export declare const POP_PER_CASUALTY = 50;
|
|
60
|
+
/**
|
|
61
|
+
* Returns the best available weapon and armour for a given tech era.
|
|
62
|
+
* Prehistoric → club + none; Ancient → knife + leather; Medieval+ → longsword + mail/plate.
|
|
63
|
+
*/
|
|
64
|
+
export declare function techEraToLoadout(era: TechEra): EraLoadout;
|
|
65
|
+
/**
|
|
66
|
+
* Converts a polity's military strength (Q) to a team size.
|
|
67
|
+
* q(0) → MIN_TEAM_SIZE; q(1.0) → MAX_TEAM_SIZE. Linear interpolation.
|
|
68
|
+
*/
|
|
69
|
+
export declare function militaryStrengthToTeamSize(militaryStrength_Q: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* Produces a deterministic battle seed from the world seed, day, and polity ids.
|
|
72
|
+
* Ensures each polity pair on each day gets a unique, reproducible seed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function battleSeed(worldSeed: number, day: number, polityAId: string, polityBId: string): number;
|
|
75
|
+
/**
|
|
76
|
+
* Builds a BattleConfig for a war between two polities.
|
|
77
|
+
* Team sizes scale with militaryStrength_Q; loadouts reflect each side's tech era.
|
|
78
|
+
*/
|
|
79
|
+
export declare function battleConfigFromPolities(polityA: Polity, polityB: Polity, worldSeed: number, day: number, maxTicks?: number): BattleConfig;
|
|
80
|
+
/**
|
|
81
|
+
* Derives the per-polity state changes to apply after a battle.
|
|
82
|
+
*
|
|
83
|
+
* Win: +WIN_MORALE_BONUS morale.
|
|
84
|
+
* Loss: −LOSS_MORALE_PENALTY morale.
|
|
85
|
+
* Draw: no morale change.
|
|
86
|
+
* Both sides: stability penalty proportional to casualties above 20%.
|
|
87
|
+
* Population: POP_PER_CASUALTY headcount lost per casualty.
|
|
88
|
+
*/
|
|
89
|
+
export declare function polityImpactFromBattle(outcome: BattleOutcome, config: BattleConfig): PolityImpact[];
|
|
90
|
+
/**
|
|
91
|
+
* Apply a PolityImpact to a polity in-place.
|
|
92
|
+
* Clamps morale and stability to [0, SCALE.Q].
|
|
93
|
+
*/
|
|
94
|
+
export declare function applyPolityImpact(polity: Polity, impact: PolityImpact): void;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/battle-bridge.ts — Campaign ↔ Combat bridge for the Persistent World Server.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions that translate between polity-scale state and tactical combat
|
|
4
|
+
// configuration, and back. No I/O, no timers, no side effects.
|
|
5
|
+
//
|
|
6
|
+
// Flow:
|
|
7
|
+
// 1. Polity layer detects a war (activeWars contains pair key).
|
|
8
|
+
// 2. Caller invokes battleConfigFromPolities() to get a BattleConfig.
|
|
9
|
+
// 3. Caller runs a tactical combat instance until one team is wiped out.
|
|
10
|
+
// 4. Caller invokes polityImpactFromBattle() to get PolityImpact[] to apply.
|
|
11
|
+
import { SCALE, q, clampQ } from "./units.js";
|
|
12
|
+
import { TechEra } from "./sim/tech.js";
|
|
13
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
14
|
+
/** Minimum and maximum combatants per side. */
|
|
15
|
+
export const MIN_TEAM_SIZE = 2;
|
|
16
|
+
export const MAX_TEAM_SIZE = 16;
|
|
17
|
+
/** Battle ends after this many ticks regardless of outcome (prevents infinite loops). */
|
|
18
|
+
export const DEFAULT_MAX_TICKS = 6000; // 5 min at 20 Hz
|
|
19
|
+
/** Morale bonus for winning a battle (Q units). */
|
|
20
|
+
export const WIN_MORALE_BONUS = q(0.08);
|
|
21
|
+
/** Morale penalty for losing a battle (Q units). */
|
|
22
|
+
export const LOSS_MORALE_PENALTY = q(0.12);
|
|
23
|
+
/** Stability penalty per 10% casualties above 20% casualty rate. */
|
|
24
|
+
export const CASUALTY_STABILITY_RATE = q(0.02);
|
|
25
|
+
/** Population lost per combatant casualty (polity headcount, not Q). */
|
|
26
|
+
export const POP_PER_CASUALTY = 50;
|
|
27
|
+
// ── Era → Loadout mapping ──────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Returns the best available weapon and armour for a given tech era.
|
|
30
|
+
* Prehistoric → club + none; Ancient → knife + leather; Medieval+ → longsword + mail/plate.
|
|
31
|
+
*/
|
|
32
|
+
export function techEraToLoadout(era) {
|
|
33
|
+
switch (era) {
|
|
34
|
+
case TechEra.Prehistoric:
|
|
35
|
+
return { archetype: "HUMAN_BASE", weaponId: "wpn_club", armourId: "arm_leather" };
|
|
36
|
+
case TechEra.Ancient:
|
|
37
|
+
return { archetype: "HUMAN_BASE", weaponId: "wpn_knife", armourId: "arm_leather" };
|
|
38
|
+
case TechEra.Medieval:
|
|
39
|
+
return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_mail" };
|
|
40
|
+
case TechEra.EarlyModern:
|
|
41
|
+
return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_plate" };
|
|
42
|
+
default:
|
|
43
|
+
return { archetype: "HUMAN_BASE", weaponId: "wpn_longsword", armourId: "arm_plate" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ── Military strength → team size ─────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Converts a polity's military strength (Q) to a team size.
|
|
49
|
+
* q(0) → MIN_TEAM_SIZE; q(1.0) → MAX_TEAM_SIZE. Linear interpolation.
|
|
50
|
+
*/
|
|
51
|
+
export function militaryStrengthToTeamSize(militaryStrength_Q) {
|
|
52
|
+
const frac = Math.max(0, Math.min(SCALE.Q, militaryStrength_Q)) / SCALE.Q;
|
|
53
|
+
const size = Math.round(MIN_TEAM_SIZE + frac * (MAX_TEAM_SIZE - MIN_TEAM_SIZE));
|
|
54
|
+
return Math.max(MIN_TEAM_SIZE, Math.min(MAX_TEAM_SIZE, size));
|
|
55
|
+
}
|
|
56
|
+
// ── Deterministic seed ─────────────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Produces a deterministic battle seed from the world seed, day, and polity ids.
|
|
59
|
+
* Ensures each polity pair on each day gets a unique, reproducible seed.
|
|
60
|
+
*/
|
|
61
|
+
export function battleSeed(worldSeed, day, polityAId, polityBId) {
|
|
62
|
+
const idSalt = [...(polityAId + polityBId)].reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
63
|
+
return ((worldSeed * 1000003 + day * 6151 + idSalt) >>> 0);
|
|
64
|
+
}
|
|
65
|
+
// ── BattleConfig factory ───────────────────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Builds a BattleConfig for a war between two polities.
|
|
68
|
+
* Team sizes scale with militaryStrength_Q; loadouts reflect each side's tech era.
|
|
69
|
+
*/
|
|
70
|
+
export function battleConfigFromPolities(polityA, polityB, worldSeed, day, maxTicks = DEFAULT_MAX_TICKS) {
|
|
71
|
+
return {
|
|
72
|
+
seed: battleSeed(worldSeed, day, polityA.id, polityB.id),
|
|
73
|
+
polityAId: polityA.id,
|
|
74
|
+
polityBId: polityB.id,
|
|
75
|
+
teamASize: militaryStrengthToTeamSize(polityA.militaryStrength_Q),
|
|
76
|
+
teamBSize: militaryStrengthToTeamSize(polityB.militaryStrength_Q),
|
|
77
|
+
loadoutA: techEraToLoadout(polityA.techEra),
|
|
78
|
+
loadoutB: techEraToLoadout(polityB.techEra),
|
|
79
|
+
maxTicks,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── PolityImpact derivation ────────────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Derives the per-polity state changes to apply after a battle.
|
|
85
|
+
*
|
|
86
|
+
* Win: +WIN_MORALE_BONUS morale.
|
|
87
|
+
* Loss: −LOSS_MORALE_PENALTY morale.
|
|
88
|
+
* Draw: no morale change.
|
|
89
|
+
* Both sides: stability penalty proportional to casualties above 20%.
|
|
90
|
+
* Population: POP_PER_CASUALTY headcount lost per casualty.
|
|
91
|
+
*/
|
|
92
|
+
export function polityImpactFromBattle(outcome, config) {
|
|
93
|
+
const results = [];
|
|
94
|
+
const sides = [
|
|
95
|
+
{ id: config.polityAId, casualties: outcome.teamACasualties, teamSize: config.teamASize, isWinner: outcome.winner === 1 },
|
|
96
|
+
{ id: config.polityBId, casualties: outcome.teamBCasualties, teamSize: config.teamBSize, isWinner: outcome.winner === 2 },
|
|
97
|
+
];
|
|
98
|
+
for (const side of sides) {
|
|
99
|
+
const isLoser = outcome.winner !== 0 && !side.isWinner;
|
|
100
|
+
const moraleDelta_Q = side.isWinner
|
|
101
|
+
? WIN_MORALE_BONUS
|
|
102
|
+
: isLoser
|
|
103
|
+
? -LOSS_MORALE_PENALTY
|
|
104
|
+
: 0;
|
|
105
|
+
// Stability penalty for casualty rates above 20%
|
|
106
|
+
const casualtyRate = side.teamSize > 0 ? side.casualties / side.teamSize : 0;
|
|
107
|
+
const excessCasualties = Math.max(0, casualtyRate - 0.20);
|
|
108
|
+
const stabilityDelta_Q = -Math.round(excessCasualties * 10 * CASUALTY_STABILITY_RATE);
|
|
109
|
+
results.push({
|
|
110
|
+
polityId: side.id,
|
|
111
|
+
moraleDelta_Q,
|
|
112
|
+
stabilityDelta_Q,
|
|
113
|
+
populationLost: side.casualties * POP_PER_CASUALTY,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Apply a PolityImpact to a polity in-place.
|
|
120
|
+
* Clamps morale and stability to [0, SCALE.Q].
|
|
121
|
+
*/
|
|
122
|
+
export function applyPolityImpact(polity, impact) {
|
|
123
|
+
polity.moraleQ = clampQ(polity.moraleQ + impact.moraleDelta_Q, 0, SCALE.Q);
|
|
124
|
+
polity.stabilityQ = clampQ(polity.stabilityQ + impact.stabilityDelta_Q, 0, SCALE.Q);
|
|
125
|
+
polity.population = Math.max(0, polity.population - impact.populationLost);
|
|
126
|
+
}
|
|
@@ -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;
|
package/dist/src/sim/disease.js
CHANGED
|
@@ -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
|
-
|
|
346
|
+
risk = Math.round(disease.baseTransmissionRate_Q * proximity_Q / SCALE.Q);
|
|
293
347
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
package/dist/src/sim/entity.d.ts
CHANGED
|
@@ -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.
|
|
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,8 +116,10 @@
|
|
|
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",
|
|
122
|
+
"persistent-world": "node dist/tools/persistent-world.js",
|
|
121
123
|
"replication-server": "node dist/tools/replication-server.js",
|
|
122
124
|
"benchmark-check": "node dist/tools/benchmark-check.js",
|
|
123
125
|
"benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
|