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

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,46 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.34] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 89 · Infrastructure & Development** (`src/infrastructure.ts`)
14
+ - `InfraType`: `"road" | "wall" | "granary" | "marketplace" | "apothecary"`.
15
+ - `InfraProject { projectId, polityId, type, targetLevel, investedCost, totalCost, completedTick? }` — in-progress construction.
16
+ - `InfraStructure { structureId, polityId, type, level, builtTick }` — completed building; level [1, `MAX_INFRA_LEVEL = 5`].
17
+ - `INFRA_BASE_COST` — treasury cost per level per type (wall 20 k → granary 8 k per level).
18
+ - `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)).
19
+ - `createInfraProject`, `createInfraStructure` — factories; level clamped to [1, 5].
20
+ - `investInProject(polity, project, amount, tick)` — drains `polity.treasury_cu`, advances `investedCost`, stamps `completedTick` when fully funded; no-ops if complete or treasury insufficient.
21
+ - `isProjectComplete`, `completeProject` → `InfraStructure | undefined`.
22
+ - `computeInfraBonus(structures, type)` → Q: sums `BONUS_PER_LEVEL × level` across all matching structures; clamped to SCALE.Q.
23
+ - **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)`).
24
+ - Max-level wall: −q(0.40) siege strength; max-level granary: +q(0.50) capacity.
25
+ - Added `./infrastructure` subpath export to `package.json`.
26
+ - 36 new tests; 4,687 total. Coverage maintained above all thresholds.
27
+
28
+ ---
29
+
30
+ ## [0.1.33] — 2026-03-26
31
+
32
+ ### Added
33
+
34
+ - **Phase 88 · Epidemic Spread at Polity Scale** (`src/epidemic.ts`)
35
+ - `PolityEpidemicState { polityId, diseaseId, prevalence_Q }` — infected fraction of polity population [0, SCALE.Q]. Reuses Phase-56 `DiseaseProfile` for disease properties.
36
+ - `createEpidemicState(polityId, diseaseId, initialPrevalence_Q?)` — factory; default prevalence `q(0.01)`.
37
+ - `deriveHealthCapacity(polity)` → Q: tech-era health infrastructure (`HEALTH_CAPACITY_BY_ERA`: Stone q(0.05) → Modern q(0.99)).
38
+ - `computeEpidemicDeathPressure(state, profile)` → Q: annual death rate = `prevalence × mortalityRate / SCALE.Q`; feeds Phase-86 `deathPressure_Q` parameter.
39
+ - `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.
40
+ - `computeSpreadToPolity(sourceState, profile, contactIntensity_Q)` → Q: prevalence exported to a target polity; zero when source is contained.
41
+ - `spreadEpidemic(source, profile, targetPolityId, contactIntensity_Q, existingState?)` — creates or updates target epidemic state; returns `undefined` below `EPIDEMIC_CONTAINED_Q`.
42
+ - `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.
43
+ - `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)`.
44
+ - Added `./epidemic` subpath export to `package.json`.
45
+ - 43 new tests; 4,651 total. Coverage maintained above all thresholds.
46
+
47
+ ---
48
+
9
49
  ## [0.1.32] — 2026-03-26
10
50
 
11
51
  ### 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
+ }
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.34",
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,14 @@
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"
117
125
  }
118
126
  },
119
127
  "files": [