@its-not-rocket-science/ananke 0.1.32 → 0.1.35

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,62 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.35] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 90 · Civil Unrest & Rebellion** (`src/unrest.ts`)
14
+ - `UnrestFactors { faminePressure_Q?, epidemicPressure_Q?, heresyRisk_Q?, weakestBond_Q? }` — optional pressure inputs from Phases 85/87/88/79.
15
+ - `computeUnrestLevel(polity, factors?)` → Q: weighted composite of morale deficit (×q(0.30)), stability deficit (×q(0.25)), famine (×q(0.20)), epidemic (×q(0.10)), heresy (×q(0.10)), feudal bond deficit (×q(0.05)).
16
+ - `UNREST_ACTION_THRESHOLD_Q = q(0.30)` — excess above this drains morale/stability.
17
+ - `REBELLION_THRESHOLD_Q = q(0.65)` — above this `rebellionRisk` flag is set.
18
+ - `stepUnrest(polity, unrestLevel_Q, elapsedDays)` → `UnrestStepResult`: drains morale at `excess × UNREST_MORALE_DRAIN_Q = q(0.005)` per day, stability at `q(0.003)` per day; mutates polity in place; floor at 0.
19
+ - `resolveRebellion(polity, worldSeed, tick)` → `RebellionResult`: deterministic via `eventSeed`; outcomes `"quelled" | "uprising" | "civil_war"` weighted by polity `militaryStrength_Q` vs. unrest roll; each outcome applies morale/stability penalties and treasury raid (`REBELLION_TREASURY_RAID_Q = q(0.15)`; civil war = 2×).
20
+ - Added `./unrest` subpath export to `package.json`.
21
+ - 35 new tests; 4,722 total. Coverage maintained above all thresholds.
22
+
23
+ ---
24
+
25
+ ## [0.1.34] — 2026-03-26
26
+
27
+ ### Added
28
+
29
+ - **Phase 89 · Infrastructure & Development** (`src/infrastructure.ts`)
30
+ - `InfraType`: `"road" | "wall" | "granary" | "marketplace" | "apothecary"`.
31
+ - `InfraProject { projectId, polityId, type, targetLevel, investedCost, totalCost, completedTick? }` — in-progress construction.
32
+ - `InfraStructure { structureId, polityId, type, level, builtTick }` — completed building; level [1, `MAX_INFRA_LEVEL = 5`].
33
+ - `INFRA_BASE_COST` — treasury cost per level per type (wall 20 k → granary 8 k per level).
34
+ - `INFRA_BONUS_PER_LEVEL_Q` — Q bonus per level (road q(0.05), wall q(0.08), granary q(0.10), marketplace q(0.02), apothecary q(0.06)).
35
+ - `createInfraProject`, `createInfraStructure` — factories; level clamped to [1, 5].
36
+ - `investInProject(polity, project, amount, tick)` — drains `polity.treasury_cu`, advances `investedCost`, stamps `completedTick` when fully funded; no-ops if complete or treasury insufficient.
37
+ - `isProjectComplete`, `completeProject` → `InfraStructure | undefined`.
38
+ - `computeInfraBonus(structures, type)` → Q: sums `BONUS_PER_LEVEL × level` across all matching structures; clamped to SCALE.Q.
39
+ - **Typed bonus helpers**: `computeRoadTradeBonus` (Phase-83 efficiency boost), `computeWallSiegeBonus` (Phase-84 attacker strength reduction), `computeGranaryCapacityBonus` (Phase-87 capacity multiplier), `computeApothecaryHealthBonus` (Phase-88 health capacity), `computeMarketplaceIncome` (daily treasury income = `floor(treasury × bonus / SCALE.Q)`).
40
+ - Max-level wall: −q(0.40) siege strength; max-level granary: +q(0.50) capacity.
41
+ - Added `./infrastructure` subpath export to `package.json`.
42
+ - 36 new tests; 4,687 total. Coverage maintained above all thresholds.
43
+
44
+ ---
45
+
46
+ ## [0.1.33] — 2026-03-26
47
+
48
+ ### Added
49
+
50
+ - **Phase 88 · Epidemic Spread at Polity Scale** (`src/epidemic.ts`)
51
+ - `PolityEpidemicState { polityId, diseaseId, prevalence_Q }` — infected fraction of polity population [0, SCALE.Q]. Reuses Phase-56 `DiseaseProfile` for disease properties.
52
+ - `createEpidemicState(polityId, diseaseId, initialPrevalence_Q?)` — factory; default prevalence `q(0.01)`.
53
+ - `deriveHealthCapacity(polity)` → Q: tech-era health infrastructure (`HEALTH_CAPACITY_BY_ERA`: Stone q(0.05) → Modern q(0.99)).
54
+ - `computeEpidemicDeathPressure(state, profile)` → Q: annual death rate = `prevalence × mortalityRate / SCALE.Q`; feeds Phase-86 `deathPressure_Q` parameter.
55
+ - `stepEpidemic(state, profile, elapsedDays, healthCapacity_Q?)` — **discrete logistic model**: growth proportional to `prevalence × (SCALE.Q − prevalence) × GROWTH_RATE × transmissionRate`; recovery proportional to `prevalence × (RECOVERY_RATE + healthBonus)`; higher `healthCapacity_Q` accelerates recovery.
56
+ - `computeSpreadToPolity(sourceState, profile, contactIntensity_Q)` → Q: prevalence exported to a target polity; zero when source is contained.
57
+ - `spreadEpidemic(source, profile, targetPolityId, contactIntensity_Q, existingState?)` — creates or updates target epidemic state; returns `undefined` below `EPIDEMIC_CONTAINED_Q`.
58
+ - `computeEpidemicMigrationPush(state, profile)` → Q [0, `EPIDEMIC_MIGRATION_PUSH_MAX_Q = q(0.20)`]: flight pressure proportional to prevalence × severity; zero when `symptomSeverity_Q < EPIDEMIC_SEVERITY_THRESHOLD_Q = q(0.30)`. Integrates with Phase-81 push pressure.
59
+ - `EPIDEMIC_CONTAINED_Q = q(0.01)`, `EPIDEMIC_BASE_GROWTH_RATE_Q = q(0.05)`, `EPIDEMIC_BASE_RECOVERY_RATE_Q = q(0.02)`, `EPIDEMIC_HEALTH_RECOVERY_BONUS_Q = q(0.04)`.
60
+ - Added `./epidemic` subpath export to `package.json`.
61
+ - 43 new tests; 4,651 total. Coverage maintained above all thresholds.
62
+
63
+ ---
64
+
9
65
  ## [0.1.32] — 2026-03-26
10
66
 
11
67
  ### Added
@@ -0,0 +1,126 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ import type { DiseaseProfile } from "./sim/disease.js";
4
+ /**
5
+ * Epidemic state for one disease in one polity.
6
+ * Attach one record per active disease; store externally (e.g. `Map<string, PolityEpidemicState[]>`).
7
+ */
8
+ export interface PolityEpidemicState {
9
+ polityId: string;
10
+ diseaseId: string;
11
+ /** Infected fraction of population [0, SCALE.Q]. */
12
+ prevalence_Q: Q;
13
+ }
14
+ /** Outcome of a single `stepEpidemic` call. */
15
+ export interface EpidemicStepResult {
16
+ /** New prevalence after the step. */
17
+ newPrevalence_Q: Q;
18
+ /** Signed change in prevalence. */
19
+ delta_Q: number;
20
+ /** Whether the epidemic is now contained (prevalence ≤ EPIDEMIC_CONTAINED_Q). */
21
+ contained: boolean;
22
+ }
23
+ /**
24
+ * Prevalence at or below this value is considered "contained" — epidemic
25
+ * no longer produces meaningful mortality or migration pressure.
26
+ */
27
+ export declare const EPIDEMIC_CONTAINED_Q: Q;
28
+ /**
29
+ * Base daily growth rate of prevalence per susceptible unit.
30
+ *
31
+ * Logistic growth: `growthDelta = prevalence × (SCALE.Q − prevalence) × GROWTH_RATE / SCALE.Q²`
32
+ * The actual rate is further scaled by `profile.baseTransmissionRate_Q`.
33
+ */
34
+ export declare const EPIDEMIC_BASE_GROWTH_RATE_Q: Q;
35
+ /**
36
+ * Base daily recovery rate (natural immunity + mortality removes infecteds).
37
+ * Scaled by `healthCapacity_Q`: better medicine → faster clearance.
38
+ */
39
+ export declare const EPIDEMIC_BASE_RECOVERY_RATE_Q: Q;
40
+ /**
41
+ * Maximum additional daily recovery from maximum `healthCapacity_Q`.
42
+ * At healthCapacity = SCALE.Q: recovery rate += this value.
43
+ */
44
+ export declare const EPIDEMIC_HEALTH_RECOVERY_BONUS_Q: Q;
45
+ /**
46
+ * Peak migration push pressure from a severe epidemic (at full prevalence).
47
+ * Integrates with Phase-81 `computePushPressure` as additive bonus.
48
+ */
49
+ export declare const EPIDEMIC_MIGRATION_PUSH_MAX_Q: Q;
50
+ /**
51
+ * Minimum symptom severity that generates significant migration push.
52
+ * Below this threshold `computeEpidemicMigrationPush` returns reduced pressure.
53
+ */
54
+ export declare const EPIDEMIC_SEVERITY_THRESHOLD_Q: Q;
55
+ /** Health-care capacity by tech era [0, SCALE.Q]. */
56
+ export declare const HEALTH_CAPACITY_BY_ERA: Record<string, Q>;
57
+ /** Create a new epidemic state for a polity. */
58
+ export declare function createEpidemicState(polityId: string, diseaseId: string, initialPrevalence_Q?: Q): PolityEpidemicState;
59
+ /**
60
+ * Derive health-care capacity [0, SCALE.Q] for a polity from its tech era.
61
+ *
62
+ * Hosts may blend this with morale or stability for a richer model.
63
+ */
64
+ export declare function deriveHealthCapacity(polity: Polity): Q;
65
+ /**
66
+ * Compute annual death pressure [Q = fraction/year] from an active epidemic.
67
+ *
68
+ * Formula: `prevalence_Q × mortalityRate_Q / SCALE.Q`
69
+ *
70
+ * Pass the result as `deathPressure_Q` to Phase-86 `stepPolityPopulation`.
71
+ */
72
+ export declare function computeEpidemicDeathPressure(state: PolityEpidemicState, profile: DiseaseProfile): Q;
73
+ /**
74
+ * Advance epidemic prevalence for `elapsedDays` days.
75
+ *
76
+ * **Logistic growth model** (daily, applied `elapsedDays` times via single formula):
77
+ *
78
+ * ```
79
+ * susceptible_Q = SCALE.Q − prevalence_Q
80
+ * growthDelta_Q = prevalence_Q × susceptible_Q × GROWTH_RATE × transmissionRate
81
+ * / SCALE.Q³
82
+ * recoveryDelta_Q = prevalence_Q × (RECOVERY_RATE + healthBonus) / SCALE.Q
83
+ * netDelta_Q = (growthDelta − recoveryDelta) × elapsedDays
84
+ * ```
85
+ *
86
+ * Prevalence is clamped to [0, SCALE.Q].
87
+ *
88
+ * @param healthCapacity_Q [0, SCALE.Q] tech-era / infrastructure health bonus.
89
+ * Derive via `deriveHealthCapacity(polity)`.
90
+ */
91
+ export declare function stepEpidemic(state: PolityEpidemicState, profile: DiseaseProfile, elapsedDays: number, healthCapacity_Q?: Q): EpidemicStepResult;
92
+ /**
93
+ * Compute the prevalence increase introduced into a target polity from a source.
94
+ *
95
+ * The `contactIntensity_Q` captures how connected the polities are:
96
+ * - Trade route efficiency or volume → high contact
97
+ * - Migration flow fraction → moderate contact
98
+ * - No trade/migration → zero
99
+ *
100
+ * Formula: `sourcePrevalence × contactIntensity × transmissionRate / SCALE.Q²`
101
+ *
102
+ * Returns 0 if the source epidemic is contained.
103
+ */
104
+ export declare function computeSpreadToPolity(source: PolityEpidemicState, profile: DiseaseProfile, contactIntensity_Q: Q): Q;
105
+ /**
106
+ * Introduce disease from a source polity into a target polity.
107
+ *
108
+ * Creates a new `PolityEpidemicState` for the target if the computed spread
109
+ * exceeds `EPIDEMIC_CONTAINED_Q`. If the disease is already present in the
110
+ * target the existing state's prevalence is increased.
111
+ *
112
+ * Returns the state that was created or modified, or `undefined` if the
113
+ * spread was below the contained threshold.
114
+ */
115
+ export declare function spreadEpidemic(sourceState: PolityEpidemicState, profile: DiseaseProfile, targetPolityId: string, contactIntensity_Q: Q, existingState?: PolityEpidemicState): PolityEpidemicState | undefined;
116
+ /**
117
+ * Compute epidemic-driven migration push pressure [0, SCALE.Q].
118
+ *
119
+ * Pressure scales with both prevalence and symptom severity.
120
+ * Only fires when `profile.symptomSeverity_Q >= EPIDEMIC_SEVERITY_THRESHOLD_Q`.
121
+ *
122
+ * Formula: `prevalence × severity × MIGRATION_PUSH_MAX / SCALE.Q²`
123
+ *
124
+ * Add the result to Phase-81 `computePushPressure` output.
125
+ */
126
+ export declare function computeEpidemicMigrationPush(state: PolityEpidemicState, profile: DiseaseProfile): Q;
@@ -0,0 +1,193 @@
1
+ // src/epidemic.ts — Phase 88: Epidemic Spread at Polity Scale
2
+ //
3
+ // Models disease prevalence in polity populations as a Q fraction of population
4
+ // [0, SCALE.Q]. Uses Phase-56 DiseaseProfile for disease properties.
5
+ // A discrete logistic model governs growth and recovery each step.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `PolityEpidemicState` tracks prevalence per (polity, disease) pair.
10
+ // - `computeEpidemicDeathPressure` produces the `deathPressure_Q` annual rate
11
+ // consumed by Phase-86 `stepPolityPopulation`.
12
+ // - `spreadEpidemic` models contact-driven inter-polity transmission via
13
+ // Phase-83 trade route volume or Phase-81 migration flow intensity.
14
+ // - `computeEpidemicMigrationPush` adds flight pressure to Phase-81.
15
+ //
16
+ // Integration:
17
+ // Phase 56 (Disease): reuses `DiseaseProfile` (transmissionRoute, mortalityRate_Q, etc.).
18
+ // Phase 61 (Polity): techEra drives `deriveHealthCapacity`.
19
+ // Phase 81 (Migration): `computeEpidemicMigrationPush` as additive push bonus.
20
+ // Phase 83 (Trade Routes): trade contact intensity drives inter-polity spread.
21
+ // Phase 86 (Demography): `computeEpidemicDeathPressure` → `deathPressure_Q` param.
22
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
23
+ // ── Constants ─────────────────────────────────────────────────────────────────
24
+ /**
25
+ * Prevalence at or below this value is considered "contained" — epidemic
26
+ * no longer produces meaningful mortality or migration pressure.
27
+ */
28
+ export const EPIDEMIC_CONTAINED_Q = q(0.01);
29
+ /**
30
+ * Base daily growth rate of prevalence per susceptible unit.
31
+ *
32
+ * Logistic growth: `growthDelta = prevalence × (SCALE.Q − prevalence) × GROWTH_RATE / SCALE.Q²`
33
+ * The actual rate is further scaled by `profile.baseTransmissionRate_Q`.
34
+ */
35
+ export const EPIDEMIC_BASE_GROWTH_RATE_Q = q(0.05);
36
+ /**
37
+ * Base daily recovery rate (natural immunity + mortality removes infecteds).
38
+ * Scaled by `healthCapacity_Q`: better medicine → faster clearance.
39
+ */
40
+ export const EPIDEMIC_BASE_RECOVERY_RATE_Q = q(0.02);
41
+ /**
42
+ * Maximum additional daily recovery from maximum `healthCapacity_Q`.
43
+ * At healthCapacity = SCALE.Q: recovery rate += this value.
44
+ */
45
+ export const EPIDEMIC_HEALTH_RECOVERY_BONUS_Q = q(0.04);
46
+ /**
47
+ * Peak migration push pressure from a severe epidemic (at full prevalence).
48
+ * Integrates with Phase-81 `computePushPressure` as additive bonus.
49
+ */
50
+ export const EPIDEMIC_MIGRATION_PUSH_MAX_Q = q(0.20);
51
+ /**
52
+ * Minimum symptom severity that generates significant migration push.
53
+ * Below this threshold `computeEpidemicMigrationPush` returns reduced pressure.
54
+ */
55
+ export const EPIDEMIC_SEVERITY_THRESHOLD_Q = q(0.30);
56
+ /** Health-care capacity by tech era [0, SCALE.Q]. */
57
+ export const HEALTH_CAPACITY_BY_ERA = {
58
+ "Stone": q(0.05),
59
+ "Bronze": q(0.15),
60
+ "Iron": q(0.25),
61
+ "Medieval": q(0.40),
62
+ "Renaissance": q(0.60),
63
+ "Industrial": q(0.80),
64
+ "Modern": q(0.99),
65
+ };
66
+ // ── Factory ───────────────────────────────────────────────────────────────────
67
+ /** Create a new epidemic state for a polity. */
68
+ export function createEpidemicState(polityId, diseaseId, initialPrevalence_Q = q(0.01)) {
69
+ return {
70
+ polityId,
71
+ diseaseId,
72
+ prevalence_Q: clampQ(initialPrevalence_Q, 0, SCALE.Q),
73
+ };
74
+ }
75
+ // ── Health capacity ───────────────────────────────────────────────────────────
76
+ /**
77
+ * Derive health-care capacity [0, SCALE.Q] for a polity from its tech era.
78
+ *
79
+ * Hosts may blend this with morale or stability for a richer model.
80
+ */
81
+ export function deriveHealthCapacity(polity) {
82
+ return (HEALTH_CAPACITY_BY_ERA[polity.techEra] ?? q(0.05));
83
+ }
84
+ // ── Death pressure ────────────────────────────────────────────────────────────
85
+ /**
86
+ * Compute annual death pressure [Q = fraction/year] from an active epidemic.
87
+ *
88
+ * Formula: `prevalence_Q × mortalityRate_Q / SCALE.Q`
89
+ *
90
+ * Pass the result as `deathPressure_Q` to Phase-86 `stepPolityPopulation`.
91
+ */
92
+ export function computeEpidemicDeathPressure(state, profile) {
93
+ return clampQ(mulDiv(state.prevalence_Q, profile.mortalityRate_Q, SCALE.Q), 0, SCALE.Q);
94
+ }
95
+ // ── Epidemic step ─────────────────────────────────────────────────────────────
96
+ /**
97
+ * Advance epidemic prevalence for `elapsedDays` days.
98
+ *
99
+ * **Logistic growth model** (daily, applied `elapsedDays` times via single formula):
100
+ *
101
+ * ```
102
+ * susceptible_Q = SCALE.Q − prevalence_Q
103
+ * growthDelta_Q = prevalence_Q × susceptible_Q × GROWTH_RATE × transmissionRate
104
+ * / SCALE.Q³
105
+ * recoveryDelta_Q = prevalence_Q × (RECOVERY_RATE + healthBonus) / SCALE.Q
106
+ * netDelta_Q = (growthDelta − recoveryDelta) × elapsedDays
107
+ * ```
108
+ *
109
+ * Prevalence is clamped to [0, SCALE.Q].
110
+ *
111
+ * @param healthCapacity_Q [0, SCALE.Q] tech-era / infrastructure health bonus.
112
+ * Derive via `deriveHealthCapacity(polity)`.
113
+ */
114
+ export function stepEpidemic(state, profile, elapsedDays, healthCapacity_Q) {
115
+ const prev = state.prevalence_Q;
116
+ const susceptible = clampQ(SCALE.Q - prev, 0, SCALE.Q);
117
+ const healthBonus = healthCapacity_Q != null
118
+ ? mulDiv(EPIDEMIC_HEALTH_RECOVERY_BONUS_Q, healthCapacity_Q, SCALE.Q)
119
+ : 0;
120
+ const recoveryRate = EPIDEMIC_BASE_RECOVERY_RATE_Q + healthBonus;
121
+ // Growth: logistic — fast when few infected; slows as susceptibles run out
122
+ // growthDelta = prev × susceptible × BASE_GROWTH × transmissionRate / SCALE.Q³
123
+ const step1 = mulDiv(prev, susceptible, SCALE.Q); // prev × susc / SCALE.Q
124
+ const step2 = mulDiv(step1, EPIDEMIC_BASE_GROWTH_RATE_Q, SCALE.Q); // × GROWTH / SCALE.Q
125
+ const growthDaily = mulDiv(step2, profile.baseTransmissionRate_Q, SCALE.Q); // × transmRate / SCALE.Q
126
+ // Recovery: linear proportion of current prevalence
127
+ const recoveryDaily = mulDiv(prev, recoveryRate, SCALE.Q);
128
+ const netDaily = growthDaily - recoveryDaily;
129
+ const delta = Math.round(netDaily * elapsedDays);
130
+ const newPrev = clampQ(prev + delta, 0, SCALE.Q);
131
+ state.prevalence_Q = newPrev;
132
+ return {
133
+ newPrevalence_Q: newPrev,
134
+ delta_Q: delta,
135
+ contained: newPrev <= EPIDEMIC_CONTAINED_Q,
136
+ };
137
+ }
138
+ // ── Inter-polity spread ───────────────────────────────────────────────────────
139
+ /**
140
+ * Compute the prevalence increase introduced into a target polity from a source.
141
+ *
142
+ * The `contactIntensity_Q` captures how connected the polities are:
143
+ * - Trade route efficiency or volume → high contact
144
+ * - Migration flow fraction → moderate contact
145
+ * - No trade/migration → zero
146
+ *
147
+ * Formula: `sourcePrevalence × contactIntensity × transmissionRate / SCALE.Q²`
148
+ *
149
+ * Returns 0 if the source epidemic is contained.
150
+ */
151
+ export function computeSpreadToPolity(source, profile, contactIntensity_Q) {
152
+ if (source.prevalence_Q <= EPIDEMIC_CONTAINED_Q)
153
+ return 0;
154
+ const step1 = mulDiv(source.prevalence_Q, contactIntensity_Q, SCALE.Q);
155
+ return clampQ(mulDiv(step1, profile.baseTransmissionRate_Q, SCALE.Q), 0, SCALE.Q);
156
+ }
157
+ /**
158
+ * Introduce disease from a source polity into a target polity.
159
+ *
160
+ * Creates a new `PolityEpidemicState` for the target if the computed spread
161
+ * exceeds `EPIDEMIC_CONTAINED_Q`. If the disease is already present in the
162
+ * target the existing state's prevalence is increased.
163
+ *
164
+ * Returns the state that was created or modified, or `undefined` if the
165
+ * spread was below the contained threshold.
166
+ */
167
+ export function spreadEpidemic(sourceState, profile, targetPolityId, contactIntensity_Q, existingState) {
168
+ const added = computeSpreadToPolity(sourceState, profile, contactIntensity_Q);
169
+ if (added <= EPIDEMIC_CONTAINED_Q)
170
+ return undefined;
171
+ if (existingState) {
172
+ existingState.prevalence_Q = clampQ(existingState.prevalence_Q + added, 0, SCALE.Q);
173
+ return existingState;
174
+ }
175
+ return createEpidemicState(targetPolityId, profile.id, added);
176
+ }
177
+ // ── Migration push ────────────────────────────────────────────────────────────
178
+ /**
179
+ * Compute epidemic-driven migration push pressure [0, SCALE.Q].
180
+ *
181
+ * Pressure scales with both prevalence and symptom severity.
182
+ * Only fires when `profile.symptomSeverity_Q >= EPIDEMIC_SEVERITY_THRESHOLD_Q`.
183
+ *
184
+ * Formula: `prevalence × severity × MIGRATION_PUSH_MAX / SCALE.Q²`
185
+ *
186
+ * Add the result to Phase-81 `computePushPressure` output.
187
+ */
188
+ export function computeEpidemicMigrationPush(state, profile) {
189
+ if (profile.symptomSeverity_Q < EPIDEMIC_SEVERITY_THRESHOLD_Q)
190
+ return 0;
191
+ const step1 = mulDiv(state.prevalence_Q, profile.symptomSeverity_Q, SCALE.Q);
192
+ return clampQ(mulDiv(step1, EPIDEMIC_MIGRATION_PUSH_MAX_Q, SCALE.Q), 0, SCALE.Q);
193
+ }
@@ -0,0 +1,90 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /** Available infrastructure types. */
4
+ export type InfraType = "road" | "wall" | "granary" | "marketplace" | "apothecary";
5
+ /** A completed infrastructure structure. */
6
+ export interface InfraStructure {
7
+ structureId: string;
8
+ polityId: string;
9
+ type: InfraType;
10
+ /** Current upgrade level [1, MAX_INFRA_LEVEL]. */
11
+ level: number;
12
+ builtTick: number;
13
+ }
14
+ /** An in-progress construction project. */
15
+ export interface InfraProject {
16
+ projectId: string;
17
+ polityId: string;
18
+ type: InfraType;
19
+ /** Target level upon completion. */
20
+ targetLevel: number;
21
+ /** Treasury already invested [cost units]. */
22
+ investedCost: number;
23
+ /** Total treasury cost required [cost units]. */
24
+ totalCost: number;
25
+ /** Tick on which construction completed, or `undefined` if still in progress. */
26
+ completedTick?: number;
27
+ }
28
+ /** Maximum upgrade level for any structure. */
29
+ export declare const MAX_INFRA_LEVEL = 5;
30
+ /**
31
+ * Base treasury cost per level for each structure type [cost units].
32
+ * Each level costs `BASE_COST × level` (level 1 = cheapest, level 5 = 5×).
33
+ */
34
+ export declare const INFRA_BASE_COST: Record<InfraType, number>;
35
+ /**
36
+ * Bonus Q per level for each infrastructure type.
37
+ * Total bonus = `BONUS_PER_LEVEL × level` (clamped by the calling function).
38
+ */
39
+ export declare const INFRA_BONUS_PER_LEVEL_Q: Record<InfraType, Q>;
40
+ /** Start a new construction project. Returns the project record (not yet complete). */
41
+ export declare function createInfraProject(projectId: string, polityId: string, type: InfraType, targetLevel: number): InfraProject;
42
+ /** Create a completed structure directly (e.g., at world initialisation). */
43
+ export declare function createInfraStructure(structureId: string, polityId: string, type: InfraType, level: number, builtTick: number): InfraStructure;
44
+ /**
45
+ * Invest treasury into a project.
46
+ *
47
+ * Drains `Math.min(investAmount, remainingCost)` from `polity.treasury_cu`.
48
+ * Sets `project.completedTick` when fully funded.
49
+ *
50
+ * Returns the amount actually invested this call.
51
+ */
52
+ export declare function investInProject(polity: Polity, project: InfraProject, investAmount: number, currentTick: number): number;
53
+ /** Return `true` if the project is fully funded and complete. */
54
+ export declare function isProjectComplete(project: InfraProject): boolean;
55
+ /**
56
+ * Convert a completed project into a permanent structure.
57
+ * Returns `undefined` if the project is not yet complete.
58
+ */
59
+ export declare function completeProject(project: InfraProject, structureId: string): InfraStructure | undefined;
60
+ /**
61
+ * Compute the total Q bonus from all structures of a given type at a polity.
62
+ * Sums `BONUS_PER_LEVEL × level` across all matching structures.
63
+ * Clamped to [0, SCALE.Q].
64
+ */
65
+ export declare function computeInfraBonus(structures: InfraStructure[], type: InfraType): Q;
66
+ /**
67
+ * Trade route efficiency bonus from roads [0, SCALE.Q].
68
+ * Add to route `efficiency_Q` when calling Phase-83 `computeDailyTradeIncome`.
69
+ */
70
+ export declare function computeRoadTradeBonus(structures: InfraStructure[]): Q;
71
+ /**
72
+ * Siege defence bonus from walls [0, SCALE.Q].
73
+ * Subtract from attacker's effective `siegeStrength_Q` in Phase-84.
74
+ */
75
+ export declare function computeWallSiegeBonus(structures: InfraStructure[]): Q;
76
+ /**
77
+ * Granary capacity multiplier bonus [0, SCALE.Q].
78
+ * Effective capacity = `baseCapacity × (SCALE.Q + bonus) / SCALE.Q`.
79
+ */
80
+ export declare function computeGranaryCapacityBonus(structures: InfraStructure[]): Q;
81
+ /**
82
+ * Daily treasury income from marketplaces [cost units].
83
+ * `income = treasury_cu × MARKETPLACE_BONUS / SCALE.Q`
84
+ */
85
+ export declare function computeMarketplaceIncome(polity: Polity, structures: InfraStructure[]): number;
86
+ /**
87
+ * Health capacity bonus from apothecaries [0, SCALE.Q].
88
+ * Add to `deriveHealthCapacity(polity)` result in Phase-88.
89
+ */
90
+ export declare function computeApothecaryHealthBonus(structures: InfraStructure[]): Q;
@@ -0,0 +1,147 @@
1
+ // src/infrastructure.ts — Phase 89: Infrastructure & Development
2
+ //
3
+ // Models polity investment in permanent physical structures. Each structure type
4
+ // grants passive bonuses to existing systems (trade, siege, granary, treasury).
5
+ // Construction consumes treasury and progresses over multiple ticks.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `InfraProject` tracks in-progress construction; `InfraStructure` records
10
+ // completed buildings with an integer level (1–MAX_INFRA_LEVEL).
11
+ // - Bonus functions return Q modifiers; the host adds them to the relevant
12
+ // system calls (e.g., route efficiency, siege strength multiplier).
13
+ //
14
+ // Integration:
15
+ // Phase 61 (Polity): treasury_cu is drained by construction costs.
16
+ // Phase 83 (Trade Routes): `computeRoadTradeBonus` → route efficiency boost.
17
+ // Phase 84 (Siege): `computeWallSiegeBonus` → siege strength reduction for attacker.
18
+ // Phase 87 (Granary): `computeGranaryCapacityBonus` → capacity multiplier.
19
+ // Phase 88 (Epidemic): `computeApothecaryHealthBonus` → health capacity boost.
20
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
21
+ // ── Constants ─────────────────────────────────────────────────────────────────
22
+ /** Maximum upgrade level for any structure. */
23
+ export const MAX_INFRA_LEVEL = 5;
24
+ /**
25
+ * Base treasury cost per level for each structure type [cost units].
26
+ * Each level costs `BASE_COST × level` (level 1 = cheapest, level 5 = 5×).
27
+ */
28
+ export const INFRA_BASE_COST = {
29
+ road: 10_000,
30
+ wall: 20_000,
31
+ granary: 8_000,
32
+ marketplace: 15_000,
33
+ apothecary: 12_000,
34
+ };
35
+ /**
36
+ * Bonus Q per level for each infrastructure type.
37
+ * Total bonus = `BONUS_PER_LEVEL × level` (clamped by the calling function).
38
+ */
39
+ export const INFRA_BONUS_PER_LEVEL_Q = {
40
+ road: q(0.05), // +5% trade route efficiency per level → +25% at max
41
+ wall: q(0.08), // +8% siege strength reduction per level → −40% at max
42
+ granary: q(0.10), // +10% granary capacity per level → +50% at max
43
+ marketplace: q(0.02), // +2% daily treasury income per level → +10% at max
44
+ apothecary: q(0.06), // +6% health capacity per level → +30% at max
45
+ };
46
+ // ── Factory ───────────────────────────────────────────────────────────────────
47
+ /** Start a new construction project. Returns the project record (not yet complete). */
48
+ export function createInfraProject(projectId, polityId, type, targetLevel) {
49
+ const level = Math.max(1, Math.min(targetLevel, MAX_INFRA_LEVEL));
50
+ const totalCost = INFRA_BASE_COST[type] * level;
51
+ return { projectId, polityId, type, targetLevel: level, investedCost: 0, totalCost };
52
+ }
53
+ /** Create a completed structure directly (e.g., at world initialisation). */
54
+ export function createInfraStructure(structureId, polityId, type, level, builtTick) {
55
+ return {
56
+ structureId,
57
+ polityId,
58
+ type,
59
+ level: Math.max(1, Math.min(level, MAX_INFRA_LEVEL)),
60
+ builtTick,
61
+ };
62
+ }
63
+ // ── Construction ──────────────────────────────────────────────────────────────
64
+ /**
65
+ * Invest treasury into a project.
66
+ *
67
+ * Drains `Math.min(investAmount, remainingCost)` from `polity.treasury_cu`.
68
+ * Sets `project.completedTick` when fully funded.
69
+ *
70
+ * Returns the amount actually invested this call.
71
+ */
72
+ export function investInProject(polity, project, investAmount, currentTick) {
73
+ if (project.completedTick != null)
74
+ return 0; // already complete
75
+ const remaining = project.totalCost - project.investedCost;
76
+ const actual = Math.min(investAmount, remaining, polity.treasury_cu);
77
+ project.investedCost += actual;
78
+ polity.treasury_cu -= actual;
79
+ if (project.investedCost >= project.totalCost) {
80
+ project.completedTick = currentTick;
81
+ }
82
+ return actual;
83
+ }
84
+ /** Return `true` if the project is fully funded and complete. */
85
+ export function isProjectComplete(project) {
86
+ return project.completedTick != null;
87
+ }
88
+ /**
89
+ * Convert a completed project into a permanent structure.
90
+ * Returns `undefined` if the project is not yet complete.
91
+ */
92
+ export function completeProject(project, structureId) {
93
+ if (project.completedTick == null)
94
+ return undefined;
95
+ return createInfraStructure(structureId, project.polityId, project.type, project.targetLevel, project.completedTick);
96
+ }
97
+ // ── Bonus computations ────────────────────────────────────────────────────────
98
+ /**
99
+ * Compute the total Q bonus from all structures of a given type at a polity.
100
+ * Sums `BONUS_PER_LEVEL × level` across all matching structures.
101
+ * Clamped to [0, SCALE.Q].
102
+ */
103
+ export function computeInfraBonus(structures, type) {
104
+ let total = 0;
105
+ for (const s of structures) {
106
+ if (s.type === type) {
107
+ total += INFRA_BONUS_PER_LEVEL_Q[type] * s.level;
108
+ }
109
+ }
110
+ return clampQ(total, 0, SCALE.Q);
111
+ }
112
+ /**
113
+ * Trade route efficiency bonus from roads [0, SCALE.Q].
114
+ * Add to route `efficiency_Q` when calling Phase-83 `computeDailyTradeIncome`.
115
+ */
116
+ export function computeRoadTradeBonus(structures) {
117
+ return computeInfraBonus(structures, "road");
118
+ }
119
+ /**
120
+ * Siege defence bonus from walls [0, SCALE.Q].
121
+ * Subtract from attacker's effective `siegeStrength_Q` in Phase-84.
122
+ */
123
+ export function computeWallSiegeBonus(structures) {
124
+ return computeInfraBonus(structures, "wall");
125
+ }
126
+ /**
127
+ * Granary capacity multiplier bonus [0, SCALE.Q].
128
+ * Effective capacity = `baseCapacity × (SCALE.Q + bonus) / SCALE.Q`.
129
+ */
130
+ export function computeGranaryCapacityBonus(structures) {
131
+ return computeInfraBonus(structures, "granary");
132
+ }
133
+ /**
134
+ * Daily treasury income from marketplaces [cost units].
135
+ * `income = treasury_cu × MARKETPLACE_BONUS / SCALE.Q`
136
+ */
137
+ export function computeMarketplaceIncome(polity, structures) {
138
+ const bonus = computeInfraBonus(structures, "marketplace");
139
+ return Math.floor(mulDiv(polity.treasury_cu, bonus, SCALE.Q));
140
+ }
141
+ /**
142
+ * Health capacity bonus from apothecaries [0, SCALE.Q].
143
+ * Add to `deriveHealthCapacity(polity)` result in Phase-88.
144
+ */
145
+ export function computeApothecaryHealthBonus(structures) {
146
+ return computeInfraBonus(structures, "apothecary");
147
+ }
@@ -0,0 +1,99 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /**
4
+ * Pressure signals fed into `computeUnrestLevel`.
5
+ * All fields are Q fractions [0, SCALE.Q]; omit any that are not applicable.
6
+ */
7
+ export interface UnrestFactors {
8
+ /** Phase-87 famine push pressure. */
9
+ faminePressure_Q?: Q;
10
+ /** Phase-88 epidemic flight pressure. */
11
+ epidemicPressure_Q?: Q;
12
+ /** Phase-85 heresy risk. */
13
+ heresyRisk_Q?: Q;
14
+ /**
15
+ * Weakest feudal bond strength [0, SCALE.Q] from Phase-79.
16
+ * Low value → high feudal unrest contribution.
17
+ */
18
+ weakestBond_Q?: Q;
19
+ }
20
+ /** Possible outcomes of a rebellion resolution. */
21
+ export type RebellionOutcome = "quelled" | "uprising" | "civil_war";
22
+ /** Result returned by `resolveRebellion`. */
23
+ export interface RebellionResult {
24
+ outcome: RebellionOutcome;
25
+ /** Morale penalty applied to the polity (always ≤ 0). */
26
+ moraleHit_Q: number;
27
+ /** Stability penalty applied to the polity (always ≤ 0). */
28
+ stabilityHit_Q: number;
29
+ /** Treasury plundered by rebels [cost units]. */
30
+ treasuryLoss: number;
31
+ }
32
+ /** Outcome of `stepUnrest` — the changes applied this step. */
33
+ export interface UnrestStepResult {
34
+ unrestLevel_Q: Q;
35
+ moraleDecay_Q: number;
36
+ stabilityDecay_Q: number;
37
+ /** Whether rebellion threshold was crossed (host should call resolveRebellion). */
38
+ rebellionRisk: boolean;
39
+ }
40
+ /** Weights applied to each pressure source in `computeUnrestLevel`. */
41
+ export declare const UNREST_MORALE_WEIGHT_Q: Q;
42
+ export declare const UNREST_STABILITY_WEIGHT_Q: Q;
43
+ export declare const UNREST_FAMINE_WEIGHT_Q: Q;
44
+ export declare const UNREST_EPIDEMIC_WEIGHT_Q: Q;
45
+ export declare const UNREST_HERESY_WEIGHT_Q: Q;
46
+ export declare const UNREST_FEUDAL_WEIGHT_Q: Q;
47
+ /** Unrest above this threshold → morale and stability begin draining. */
48
+ export declare const UNREST_ACTION_THRESHOLD_Q: Q;
49
+ /** Unrest above this threshold → rebellion risk flag raised. */
50
+ export declare const REBELLION_THRESHOLD_Q: Q;
51
+ /** Maximum daily morale drain from sustained unrest [Q/day]. */
52
+ export declare const UNREST_MORALE_DRAIN_Q: Q;
53
+ /** Maximum daily stability drain from sustained unrest [Q/day]. */
54
+ export declare const UNREST_STABILITY_DRAIN_Q: Q;
55
+ /** Fraction of treasury rebels plunder during an uprising or civil war. */
56
+ export declare const REBELLION_TREASURY_RAID_Q: Q;
57
+ /**
58
+ * Compute the composite unrest level [0, SCALE.Q] for a polity.
59
+ *
60
+ * Unrest is the weighted sum of:
61
+ * - Low morale (`(SCALE.Q - moraleQ) × MORALE_WEIGHT`)
62
+ * - Low stability (`(SCALE.Q - stabilityQ) × STABILITY_WEIGHT`)
63
+ * - Famine pressure × FAMINE_WEIGHT
64
+ * - Epidemic pressure × EPIDEMIC_WEIGHT
65
+ * - Heresy risk × HERESY_WEIGHT
66
+ * - Feudal deficit × FEUDAL_WEIGHT (`SCALE.Q − weakestBond_Q`)
67
+ *
68
+ * All inputs are optional; omitted factors contribute zero.
69
+ */
70
+ export declare function computeUnrestLevel(polity: Polity, factors?: UnrestFactors): Q;
71
+ /**
72
+ * Apply unrest consequences to a polity for `elapsedDays` days.
73
+ *
74
+ * When `unrestLevel_Q > UNREST_ACTION_THRESHOLD_Q`:
75
+ * - Drains morale at rate `(unrest − threshold) × MORALE_DRAIN_Q / SCALE.Q` per day.
76
+ * - Drains stability at a lower rate.
77
+ *
78
+ * Mutates `polity.moraleQ` and `polity.stabilityQ` in place.
79
+ * Returns the step result for host inspection.
80
+ */
81
+ export declare function stepUnrest(polity: Polity, unrestLevel_Q: Q, elapsedDays: number): UnrestStepResult;
82
+ /**
83
+ * Resolve a rebellion event deterministically.
84
+ *
85
+ * Outcomes:
86
+ * - `"quelled"`: rebels dispersed — morale/treasury hit only.
87
+ * - `"uprising"`: significant unrest — larger morale/stability hit + treasury raid.
88
+ * - `"civil_war"`: polity fractures — severe penalties across all stats.
89
+ *
90
+ * Outcome probability is weighted by unrest level vs. military strength:
91
+ * - High military strength + moderate unrest → likely `"quelled"`
92
+ * - Low military + high unrest → risk of `"civil_war"`
93
+ *
94
+ * Mutates polity morale, stability, and treasury.
95
+ *
96
+ * @param worldSeed World seed for deterministic resolution.
97
+ * @param tick Current simulation tick.
98
+ */
99
+ export declare function resolveRebellion(polity: Polity, worldSeed: number, tick: number): RebellionResult;
@@ -0,0 +1,147 @@
1
+ // src/unrest.ts — Phase 90: Civil Unrest & Rebellion
2
+ //
3
+ // Aggregates pressure signals from existing systems into a composite unrest
4
+ // level, drains polity morale and stability under sustained pressure, and
5
+ // resolves rebellion events deterministically.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `computeUnrestLevel` is a pure aggregator: callers pass pre-computed
10
+ // pressure values from Phase-85 (heresy), Phase-87 (famine), Phase-88
11
+ // (epidemic), Phase-79 (weakest feudal bond), etc.
12
+ // - `stepUnrest` mutates polity.moraleQ and polity.stabilityQ when unrest
13
+ // exceeds thresholds.
14
+ // - `resolveRebellion` uses eventSeed for full determinism and replay safety.
15
+ //
16
+ // Integration:
17
+ // Phase 61 (Polity): mutates moraleQ / stabilityQ; reads militaryStrength_Q.
18
+ // Phase 79 (Feudal): weakestBond_Q input.
19
+ // Phase 85 (Faith): heresyRisk_Q input.
20
+ // Phase 87 (Granary): faminePressure_Q input from computeFamineMigrationPush.
21
+ // Phase 88 (Epidemic): epidemicPressure_Q input from computeEpidemicMigrationPush.
22
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
23
+ import { eventSeed, hashString } from "./sim/seeds.js";
24
+ // ── Constants ─────────────────────────────────────────────────────────────────
25
+ /** Weights applied to each pressure source in `computeUnrestLevel`. */
26
+ export const UNREST_MORALE_WEIGHT_Q = q(0.30);
27
+ export const UNREST_STABILITY_WEIGHT_Q = q(0.25);
28
+ export const UNREST_FAMINE_WEIGHT_Q = q(0.20);
29
+ export const UNREST_EPIDEMIC_WEIGHT_Q = q(0.10);
30
+ export const UNREST_HERESY_WEIGHT_Q = q(0.10);
31
+ export const UNREST_FEUDAL_WEIGHT_Q = q(0.05);
32
+ /** Unrest above this threshold → morale and stability begin draining. */
33
+ export const UNREST_ACTION_THRESHOLD_Q = q(0.30);
34
+ /** Unrest above this threshold → rebellion risk flag raised. */
35
+ export const REBELLION_THRESHOLD_Q = q(0.65);
36
+ /** Maximum daily morale drain from sustained unrest [Q/day]. */
37
+ export const UNREST_MORALE_DRAIN_Q = q(0.005);
38
+ /** Maximum daily stability drain from sustained unrest [Q/day]. */
39
+ export const UNREST_STABILITY_DRAIN_Q = q(0.003);
40
+ /** Fraction of treasury rebels plunder during an uprising or civil war. */
41
+ export const REBELLION_TREASURY_RAID_Q = q(0.15);
42
+ // ── Unrest computation ────────────────────────────────────────────────────────
43
+ /**
44
+ * Compute the composite unrest level [0, SCALE.Q] for a polity.
45
+ *
46
+ * Unrest is the weighted sum of:
47
+ * - Low morale (`(SCALE.Q - moraleQ) × MORALE_WEIGHT`)
48
+ * - Low stability (`(SCALE.Q - stabilityQ) × STABILITY_WEIGHT`)
49
+ * - Famine pressure × FAMINE_WEIGHT
50
+ * - Epidemic pressure × EPIDEMIC_WEIGHT
51
+ * - Heresy risk × HERESY_WEIGHT
52
+ * - Feudal deficit × FEUDAL_WEIGHT (`SCALE.Q − weakestBond_Q`)
53
+ *
54
+ * All inputs are optional; omitted factors contribute zero.
55
+ */
56
+ export function computeUnrestLevel(polity, factors = {}) {
57
+ const moraleContrib = mulDiv(SCALE.Q - polity.moraleQ, UNREST_MORALE_WEIGHT_Q, SCALE.Q);
58
+ const stabilityContrib = mulDiv(SCALE.Q - polity.stabilityQ, UNREST_STABILITY_WEIGHT_Q, SCALE.Q);
59
+ const famineContrib = mulDiv(factors.faminePressure_Q ?? 0, UNREST_FAMINE_WEIGHT_Q, SCALE.Q);
60
+ const epidemicContrib = mulDiv(factors.epidemicPressure_Q ?? 0, UNREST_EPIDEMIC_WEIGHT_Q, SCALE.Q);
61
+ const heresyContrib = mulDiv(factors.heresyRisk_Q ?? 0, UNREST_HERESY_WEIGHT_Q, SCALE.Q);
62
+ const feudalDeficit = factors.weakestBond_Q != null
63
+ ? clampQ(SCALE.Q - factors.weakestBond_Q, 0, SCALE.Q)
64
+ : 0;
65
+ const feudalContrib = mulDiv(feudalDeficit, UNREST_FEUDAL_WEIGHT_Q, SCALE.Q);
66
+ const total = moraleContrib + stabilityContrib + famineContrib +
67
+ epidemicContrib + heresyContrib + feudalContrib;
68
+ return clampQ(total, 0, SCALE.Q);
69
+ }
70
+ // ── Unrest step ───────────────────────────────────────────────────────────────
71
+ /**
72
+ * Apply unrest consequences to a polity for `elapsedDays` days.
73
+ *
74
+ * When `unrestLevel_Q > UNREST_ACTION_THRESHOLD_Q`:
75
+ * - Drains morale at rate `(unrest − threshold) × MORALE_DRAIN_Q / SCALE.Q` per day.
76
+ * - Drains stability at a lower rate.
77
+ *
78
+ * Mutates `polity.moraleQ` and `polity.stabilityQ` in place.
79
+ * Returns the step result for host inspection.
80
+ */
81
+ export function stepUnrest(polity, unrestLevel_Q, elapsedDays) {
82
+ const excess = clampQ(unrestLevel_Q - UNREST_ACTION_THRESHOLD_Q, 0, SCALE.Q);
83
+ const moraleDecayPerDay = mulDiv(excess, UNREST_MORALE_DRAIN_Q, SCALE.Q);
84
+ const stabilityDecayPerDay = mulDiv(excess, UNREST_STABILITY_DRAIN_Q, SCALE.Q);
85
+ const totalMoraleDecay = Math.round(moraleDecayPerDay * elapsedDays);
86
+ const totalStabilityDecay = Math.round(stabilityDecayPerDay * elapsedDays);
87
+ polity.moraleQ = clampQ(polity.moraleQ - totalMoraleDecay, 0, SCALE.Q);
88
+ polity.stabilityQ = clampQ(polity.stabilityQ - totalStabilityDecay, 0, SCALE.Q);
89
+ return {
90
+ unrestLevel_Q,
91
+ moraleDecay_Q: totalMoraleDecay,
92
+ stabilityDecay_Q: totalStabilityDecay,
93
+ rebellionRisk: unrestLevel_Q > REBELLION_THRESHOLD_Q,
94
+ };
95
+ }
96
+ // ── Rebellion resolution ──────────────────────────────────────────────────────
97
+ /**
98
+ * Resolve a rebellion event deterministically.
99
+ *
100
+ * Outcomes:
101
+ * - `"quelled"`: rebels dispersed — morale/treasury hit only.
102
+ * - `"uprising"`: significant unrest — larger morale/stability hit + treasury raid.
103
+ * - `"civil_war"`: polity fractures — severe penalties across all stats.
104
+ *
105
+ * Outcome probability is weighted by unrest level vs. military strength:
106
+ * - High military strength + moderate unrest → likely `"quelled"`
107
+ * - Low military + high unrest → risk of `"civil_war"`
108
+ *
109
+ * Mutates polity morale, stability, and treasury.
110
+ *
111
+ * @param worldSeed World seed for deterministic resolution.
112
+ * @param tick Current simulation tick.
113
+ */
114
+ export function resolveRebellion(polity, worldSeed, tick) {
115
+ const polityHash = hashString(polity.id);
116
+ const seed = eventSeed(worldSeed, tick, polityHash, 0, 9001); // salt: rebellion
117
+ const roll = seed % SCALE.Q; // [0, SCALE.Q)
118
+ // Suppression capacity = military strength
119
+ const suppressCap = polity.militaryStrength_Q;
120
+ // Civil war threshold = low suppression + any roll in top quarter
121
+ const civilWarThresh = clampQ(SCALE.Q - suppressCap, 0, SCALE.Q);
122
+ const uprisingThresh = Math.round(civilWarThresh * 0.6);
123
+ let outcome;
124
+ if (roll >= civilWarThresh) {
125
+ outcome = "quelled";
126
+ }
127
+ else if (roll >= uprisingThresh) {
128
+ outcome = "uprising";
129
+ }
130
+ else {
131
+ outcome = "civil_war";
132
+ }
133
+ const moraleHit = outcome === "quelled" ? -q(0.05)
134
+ : outcome === "uprising" ? -q(0.15)
135
+ : -q(0.30);
136
+ const stabilityHit = outcome === "quelled" ? -q(0.03)
137
+ : outcome === "uprising" ? -q(0.10)
138
+ : -q(0.25);
139
+ const treasuryRaid = outcome === "quelled"
140
+ ? 0
141
+ : Math.floor(mulDiv(polity.treasury_cu, REBELLION_TREASURY_RAID_Q, SCALE.Q)
142
+ * (outcome === "civil_war" ? 2 : 1));
143
+ polity.moraleQ = clampQ(polity.moraleQ + moraleHit, 0, SCALE.Q);
144
+ polity.stabilityQ = clampQ(polity.stabilityQ + stabilityHit, 0, SCALE.Q);
145
+ polity.treasury_cu = Math.max(0, polity.treasury_cu - treasuryRaid);
146
+ return { outcome, moraleHit_Q: moraleHit, stabilityHit_Q: stabilityHit, treasuryLoss: treasuryRaid };
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.32",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -114,6 +114,18 @@
114
114
  "./granary": {
115
115
  "import": "./dist/src/granary.js",
116
116
  "types": "./dist/src/granary.d.ts"
117
+ },
118
+ "./epidemic": {
119
+ "import": "./dist/src/epidemic.js",
120
+ "types": "./dist/src/epidemic.d.ts"
121
+ },
122
+ "./infrastructure": {
123
+ "import": "./dist/src/infrastructure.js",
124
+ "types": "./dist/src/infrastructure.d.ts"
125
+ },
126
+ "./unrest": {
127
+ "import": "./dist/src/unrest.js",
128
+ "types": "./dist/src/unrest.d.ts"
117
129
  }
118
130
  },
119
131
  "files": [