@its-not-rocket-science/ananke 0.1.31 → 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,64 @@ 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
+
49
+ ## [0.1.32] — 2026-03-26
50
+
51
+ ### Added
52
+
53
+ - **Phase 87 · Granary & Food Supply** (`src/granary.ts`)
54
+ - `GranaryState { polityId, grain_su }` — grain reserves in supply units (1 su = food for 1 person for 1 day); capacity derived dynamically from `polity.population × GRANARY_CAPACITY_DAYS = 730`.
55
+ - `createGranary(polity)` — initialises with one year of consumption.
56
+ - `computeCapacity(polity)` → integer; `computeFoodSupply_Q(polity, granary)` → Q [0, SCALE.Q] — feeds directly into Phase-86 `stepPolityPopulation(foodSupply_Q)`.
57
+ - **Harvest yield**: `HARVEST_BASE_SU_PER_CAPITA = 250` su/person/harvest; `HARVEST_YIELD_BASE_Q = q(0.70)` floor; `HARVEST_STABILITY_BONUS_Q = q(0.30)` max bonus from stability. `deriveHarvestYieldFactor(polity, season_Q?)` integrates Phase-78 seasonal multiplier.
58
+ - `computeHarvestYield(polity, yieldFactor_Q?)` → su; `triggerHarvest(polity, granary, yieldFactor_Q?)` → added su (clamped to capacity).
59
+ - `stepGranaryConsumption(polity, granary, elapsedDays)` → consumed su; drains `population × elapsedDays` su per step; floors at 0.
60
+ - `tradeFoodSupply(fromGranary, toGranary, toPolity, amount_su)` → transferred su; limited by source grain, destination capacity. Integrates with Phase-83 trade routes.
61
+ - `raidGranary(granary, raidFraction_Q?)` → plundered su; defaults to `RAID_FRACTION_Q = q(0.40)`. Integrates with Phase-84 siege attacker victory.
62
+ - Added `./granary` subpath export to `package.json`.
63
+ - 47 new tests; 4,608 total. Coverage maintained above all thresholds.
64
+
65
+ ---
66
+
9
67
  ## [0.1.31] — 2026-03-26
10
68
 
11
69
  ### 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,118 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /**
4
+ * Grain reserves for one polity.
5
+ *
6
+ * Capacity is derived (not stored): `population × GRANARY_CAPACITY_DAYS`.
7
+ * Attach one `GranaryState` per polity; store externally (e.g., `Map<string, GranaryState>`).
8
+ */
9
+ export interface GranaryState {
10
+ polityId: string;
11
+ /** Current grain reserves in supply units (1 su = food for 1 person for 1 day). */
12
+ grain_su: number;
13
+ }
14
+ /**
15
+ * Granary holds this many person-days of food at full capacity.
16
+ * Default: 730 (≈ 2 years of food per capita).
17
+ */
18
+ export declare const GRANARY_CAPACITY_DAYS = 730;
19
+ /**
20
+ * Each harvest at full yield contributes this many person-days per capita.
21
+ * With two harvests/year: 500 annual supply vs. 365 consumption → ~37% surplus headroom.
22
+ */
23
+ export declare const HARVEST_BASE_SU_PER_CAPITA = 250;
24
+ /**
25
+ * Minimum harvest yield at zero stability [0, SCALE.Q].
26
+ * Stability linearly scales yield from this floor to `SCALE.Q` (full yield).
27
+ */
28
+ export declare const HARVEST_YIELD_BASE_Q: Q;
29
+ /**
30
+ * Maximum additional yield from full stability [0, SCALE.Q].
31
+ * yieldFactor = HARVEST_YIELD_BASE_Q + mulDiv(HARVEST_STABILITY_BONUS_Q, stabilityQ, SCALE.Q).
32
+ */
33
+ export declare const HARVEST_STABILITY_BONUS_Q: Q;
34
+ /**
35
+ * Fraction of the granary that a successful siege raid removes.
36
+ * Callers may pass a different fraction to `raidGranary`.
37
+ */
38
+ export declare const RAID_FRACTION_Q: Q;
39
+ /**
40
+ * Create a new `GranaryState` for a polity.
41
+ * Initial reserves default to one year of consumption (stable starting point).
42
+ */
43
+ export declare function createGranary(polity: Polity): GranaryState;
44
+ /**
45
+ * Maximum grain the polity can store [supply units].
46
+ * Scales with current population — a growing polity can store more.
47
+ */
48
+ export declare function computeCapacity(polity: Polity): number;
49
+ /**
50
+ * Convert grain reserves to a [0, SCALE.Q] food supply fraction.
51
+ *
52
+ * This is the `foodSupply_Q` input for Phase-86 `stepPolityPopulation`:
53
+ * - q(1.0) = full granary (no famine)
54
+ * - below Phase-86 `FAMINE_THRESHOLD_Q = q(0.20)` → famine active
55
+ *
56
+ * Returns 0 when population is zero (prevents division by zero).
57
+ */
58
+ export declare function computeFoodSupply_Q(polity: Polity, granary: GranaryState): Q;
59
+ /**
60
+ * Derive the harvest yield factor [0, SCALE.Q] for a polity.
61
+ *
62
+ * Formula: `HARVEST_YIELD_BASE_Q + mulDiv(HARVEST_STABILITY_BONUS_Q, stabilityQ, SCALE.Q)`
63
+ * then optionally multiplied by a Phase-78 seasonal factor.
64
+ *
65
+ * @param season_Q Seasonal multiplier [0, SCALE.Q] from Phase-78 Calendar.
66
+ * `q(1.0)` = summer peak; `q(0.50)` = winter harvest.
67
+ * Omit for an unseasoned annual harvest.
68
+ */
69
+ export declare function deriveHarvestYieldFactor(polity: Polity, season_Q?: Q): Q;
70
+ /**
71
+ * Compute the grain added by one harvest [supply units].
72
+ *
73
+ * `yield_su = round(population × HARVEST_BASE_SU_PER_CAPITA × yieldFactor_Q / SCALE.Q)`
74
+ *
75
+ * @param yieldFactor_Q Override factor; defaults to `deriveHarvestYieldFactor(polity)`.
76
+ */
77
+ export declare function computeHarvestYield(polity: Polity, yieldFactor_Q?: Q): number;
78
+ /**
79
+ * Add one harvest to the granary.
80
+ *
81
+ * Grain is clamped to `computeCapacity(polity)` — surplus is lost (no overflow).
82
+ * Returns the amount actually added (may be less than yield if near capacity).
83
+ *
84
+ * Call at the end of each harvest season (biannual: spring + autumn).
85
+ */
86
+ export declare function triggerHarvest(polity: Polity, granary: GranaryState, yieldFactor_Q?: Q): number;
87
+ /**
88
+ * Drain daily grain consumption for `elapsedDays` days.
89
+ *
90
+ * Consumption = `polity.population × elapsedDays` supply units.
91
+ * Grain is clamped to 0 (no negative reserves).
92
+ *
93
+ * Returns the actual amount consumed (may be less than demand if reserves run low).
94
+ */
95
+ export declare function stepGranaryConsumption(polity: Polity, granary: GranaryState, elapsedDays: number): number;
96
+ /**
97
+ * Transfer grain from one polity's granary to another.
98
+ *
99
+ * Actual transfer is limited by:
100
+ * - Grain available in the source granary.
101
+ * - Remaining capacity in the destination granary.
102
+ *
103
+ * Returns the amount actually transferred.
104
+ * Integrate with Phase-83 trade routes: host calls this when resolving a food route.
105
+ */
106
+ export declare function tradeFoodSupply(fromGranary: GranaryState, toGranary: GranaryState, toPolity: Polity, amount_su: number): number;
107
+ /**
108
+ * Plunder a granary after a successful siege.
109
+ *
110
+ * Removes `raidFraction_Q` of current grain reserves.
111
+ * Returns the amount plundered.
112
+ *
113
+ * Integrates with Phase-84 siege: call on `outcome === "attacker_victory"`.
114
+ *
115
+ * @param raidFraction_Q Fraction of reserves plundered [0, SCALE.Q].
116
+ * Defaults to `RAID_FRACTION_Q = q(0.40)`.
117
+ */
118
+ export declare function raidGranary(granary: GranaryState, raidFraction_Q?: Q): number;
@@ -0,0 +1,180 @@
1
+ // src/granary.ts — Phase 87: Granary & Food Supply
2
+ //
3
+ // Tracks grain reserves per polity. Grain is measured in "supply units" (su)
4
+ // where 1 su feeds one person for one day. The granary fills at each harvest
5
+ // and drains with daily consumption; when reserves fall below a fraction of
6
+ // capacity, Phase-86 famine mechanics activate.
7
+ //
8
+ // Design:
9
+ // - Pure data layer — no Entity fields, no kernel changes.
10
+ // - `GranaryState` stores only grain_su; capacity is derived from polity.population.
11
+ // - `computeFoodSupply_Q` produces the [0, SCALE.Q] value consumed by Phase-86
12
+ // `stepPolityPopulation(deathPressure_Q, foodSupply_Q)`.
13
+ // - Harvest yield is modulated by stability and an optional Phase-78 season multiplier.
14
+ // - `tradeFoodSupply` integrates with Phase-83 trade routes (caller-driven).
15
+ // - `raidGranary` integrates with Phase-84 siege warfare (plunder).
16
+ //
17
+ // Integration:
18
+ // Phase 61 (Polity): population drives capacity and harvest yield.
19
+ // Phase 78 (Calendar): season_Q passed to deriveHarvestYieldFactor.
20
+ // Phase 83 (Trade): tradeFoodSupply called when host resolves a food route.
21
+ // Phase 84 (Siege): raidGranary called on attacker victory.
22
+ // Phase 86 (Demography): computeFoodSupply_Q → foodSupply_Q parameter.
23
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
24
+ // ── Constants ─────────────────────────────────────────────────────────────────
25
+ /**
26
+ * Granary holds this many person-days of food at full capacity.
27
+ * Default: 730 (≈ 2 years of food per capita).
28
+ */
29
+ export const GRANARY_CAPACITY_DAYS = 730;
30
+ /**
31
+ * Each harvest at full yield contributes this many person-days per capita.
32
+ * With two harvests/year: 500 annual supply vs. 365 consumption → ~37% surplus headroom.
33
+ */
34
+ export const HARVEST_BASE_SU_PER_CAPITA = 250;
35
+ /**
36
+ * Minimum harvest yield at zero stability [0, SCALE.Q].
37
+ * Stability linearly scales yield from this floor to `SCALE.Q` (full yield).
38
+ */
39
+ export const HARVEST_YIELD_BASE_Q = q(0.70);
40
+ /**
41
+ * Maximum additional yield from full stability [0, SCALE.Q].
42
+ * yieldFactor = HARVEST_YIELD_BASE_Q + mulDiv(HARVEST_STABILITY_BONUS_Q, stabilityQ, SCALE.Q).
43
+ */
44
+ export const HARVEST_STABILITY_BONUS_Q = q(0.30);
45
+ /**
46
+ * Fraction of the granary that a successful siege raid removes.
47
+ * Callers may pass a different fraction to `raidGranary`.
48
+ */
49
+ export const RAID_FRACTION_Q = q(0.40);
50
+ // ── Factory ───────────────────────────────────────────────────────────────────
51
+ /**
52
+ * Create a new `GranaryState` for a polity.
53
+ * Initial reserves default to one year of consumption (stable starting point).
54
+ */
55
+ export function createGranary(polity) {
56
+ return {
57
+ polityId: polity.id,
58
+ grain_su: polity.population * 365,
59
+ };
60
+ }
61
+ // ── Capacity & food supply ────────────────────────────────────────────────────
62
+ /**
63
+ * Maximum grain the polity can store [supply units].
64
+ * Scales with current population — a growing polity can store more.
65
+ */
66
+ export function computeCapacity(polity) {
67
+ return polity.population * GRANARY_CAPACITY_DAYS;
68
+ }
69
+ /**
70
+ * Convert grain reserves to a [0, SCALE.Q] food supply fraction.
71
+ *
72
+ * This is the `foodSupply_Q` input for Phase-86 `stepPolityPopulation`:
73
+ * - q(1.0) = full granary (no famine)
74
+ * - below Phase-86 `FAMINE_THRESHOLD_Q = q(0.20)` → famine active
75
+ *
76
+ * Returns 0 when population is zero (prevents division by zero).
77
+ */
78
+ export function computeFoodSupply_Q(polity, granary) {
79
+ const cap = computeCapacity(polity);
80
+ if (cap <= 0)
81
+ return 0;
82
+ return clampQ(Math.round(granary.grain_su * SCALE.Q / cap), 0, SCALE.Q);
83
+ }
84
+ // ── Harvest ───────────────────────────────────────────────────────────────────
85
+ /**
86
+ * Derive the harvest yield factor [0, SCALE.Q] for a polity.
87
+ *
88
+ * Formula: `HARVEST_YIELD_BASE_Q + mulDiv(HARVEST_STABILITY_BONUS_Q, stabilityQ, SCALE.Q)`
89
+ * then optionally multiplied by a Phase-78 seasonal factor.
90
+ *
91
+ * @param season_Q Seasonal multiplier [0, SCALE.Q] from Phase-78 Calendar.
92
+ * `q(1.0)` = summer peak; `q(0.50)` = winter harvest.
93
+ * Omit for an unseasoned annual harvest.
94
+ */
95
+ export function deriveHarvestYieldFactor(polity, season_Q) {
96
+ const stabilityBonus = mulDiv(HARVEST_STABILITY_BONUS_Q, polity.stabilityQ, SCALE.Q);
97
+ const baseFactor = clampQ(HARVEST_YIELD_BASE_Q + stabilityBonus, 0, SCALE.Q);
98
+ if (season_Q == null)
99
+ return baseFactor;
100
+ return clampQ(mulDiv(baseFactor, season_Q, SCALE.Q), 0, SCALE.Q);
101
+ }
102
+ /**
103
+ * Compute the grain added by one harvest [supply units].
104
+ *
105
+ * `yield_su = round(population × HARVEST_BASE_SU_PER_CAPITA × yieldFactor_Q / SCALE.Q)`
106
+ *
107
+ * @param yieldFactor_Q Override factor; defaults to `deriveHarvestYieldFactor(polity)`.
108
+ */
109
+ export function computeHarvestYield(polity, yieldFactor_Q) {
110
+ const factor = yieldFactor_Q ?? deriveHarvestYieldFactor(polity);
111
+ return Math.round(polity.population * HARVEST_BASE_SU_PER_CAPITA * factor / SCALE.Q);
112
+ }
113
+ /**
114
+ * Add one harvest to the granary.
115
+ *
116
+ * Grain is clamped to `computeCapacity(polity)` — surplus is lost (no overflow).
117
+ * Returns the amount actually added (may be less than yield if near capacity).
118
+ *
119
+ * Call at the end of each harvest season (biannual: spring + autumn).
120
+ */
121
+ export function triggerHarvest(polity, granary, yieldFactor_Q) {
122
+ const cap = computeCapacity(polity);
123
+ const yield_ = computeHarvestYield(polity, yieldFactor_Q);
124
+ const added = Math.min(yield_, Math.max(0, cap - granary.grain_su));
125
+ granary.grain_su = Math.min(cap, granary.grain_su + yield_);
126
+ return added;
127
+ }
128
+ // ── Consumption ───────────────────────────────────────────────────────────────
129
+ /**
130
+ * Drain daily grain consumption for `elapsedDays` days.
131
+ *
132
+ * Consumption = `polity.population × elapsedDays` supply units.
133
+ * Grain is clamped to 0 (no negative reserves).
134
+ *
135
+ * Returns the actual amount consumed (may be less than demand if reserves run low).
136
+ */
137
+ export function stepGranaryConsumption(polity, granary, elapsedDays) {
138
+ const demand = polity.population * elapsedDays;
139
+ const consumed = Math.min(demand, granary.grain_su);
140
+ granary.grain_su = Math.max(0, granary.grain_su - demand);
141
+ return consumed;
142
+ }
143
+ // ── Trade food ────────────────────────────────────────────────────────────────
144
+ /**
145
+ * Transfer grain from one polity's granary to another.
146
+ *
147
+ * Actual transfer is limited by:
148
+ * - Grain available in the source granary.
149
+ * - Remaining capacity in the destination granary.
150
+ *
151
+ * Returns the amount actually transferred.
152
+ * Integrate with Phase-83 trade routes: host calls this when resolving a food route.
153
+ */
154
+ export function tradeFoodSupply(fromGranary, toGranary, toPolity, amount_su) {
155
+ const toCap = computeCapacity(toPolity);
156
+ const toSpace = Math.max(0, toCap - toGranary.grain_su);
157
+ const available = fromGranary.grain_su;
158
+ const transferred = Math.min(amount_su, available, toSpace);
159
+ fromGranary.grain_su -= transferred;
160
+ toGranary.grain_su += transferred;
161
+ return transferred;
162
+ }
163
+ // ── Siege raid ────────────────────────────────────────────────────────────────
164
+ /**
165
+ * Plunder a granary after a successful siege.
166
+ *
167
+ * Removes `raidFraction_Q` of current grain reserves.
168
+ * Returns the amount plundered.
169
+ *
170
+ * Integrates with Phase-84 siege: call on `outcome === "attacker_victory"`.
171
+ *
172
+ * @param raidFraction_Q Fraction of reserves plundered [0, SCALE.Q].
173
+ * Defaults to `RAID_FRACTION_Q = q(0.40)`.
174
+ */
175
+ export function raidGranary(granary, raidFraction_Q) {
176
+ const fraction = raidFraction_Q ?? RAID_FRACTION_Q;
177
+ const plundered = Math.round(mulDiv(granary.grain_su, fraction, SCALE.Q));
178
+ granary.grain_su = Math.max(0, granary.grain_su - plundered);
179
+ return plundered;
180
+ }
@@ -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.31",
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",
@@ -110,6 +110,18 @@
110
110
  "./demography": {
111
111
  "import": "./dist/src/demography.js",
112
112
  "types": "./dist/src/demography.d.ts"
113
+ },
114
+ "./granary": {
115
+ "import": "./dist/src/granary.js",
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"
113
125
  }
114
126
  },
115
127
  "files": [