@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 +40 -0
- package/dist/src/epidemic.d.ts +126 -0
- package/dist/src/epidemic.js +193 -0
- package/dist/src/infrastructure.d.ts +90 -0
- package/dist/src/infrastructure.js +147 -0
- package/package.json +9 -1
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.
|
|
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": [
|