@its-not-rocket-science/ananke 0.1.40 → 0.1.41

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,30 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.41] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 96 · Climate Events & Natural Disasters** (`src/climate.ts`)
14
+ - `ClimateEventType`: `"drought" | "flood" | "harsh_winter" | "earthquake" | "plague_season" | "locust_swarm"`.
15
+ - `ClimateEvent { eventId, type, severity_Q, durationDays }` — immutable descriptor.
16
+ - `ActiveClimateEvent { event, remainingDays, elapsedDays }` — mutable progress tracker stored externally by host.
17
+ - `ClimateEffects { deathPressure_Q, harvestYieldPenalty_Q, epidemicGrowthBonus_Q, infrastructureDamage_Q, unrestPressure_Q, marchPenalty_Q }` — advisory bundle passed to Phases 86–93.
18
+ - `BASE_EFFECTS: Record<ClimateEventType, ClimateEffects>` — full-severity baselines: locust_swarm has highest harvest penalty (q(0.80)), plague_season highest epidemic growth (q(0.40)), earthquake highest infrastructure damage (q(0.20)), harsh_winter highest march penalty (q(0.40)).
19
+ - `EVENT_DAILY_PROBABILITY_Q: Record<ClimateEventType, number>` — direct daily integer probabilities out of SCALE.Q=10000: harsh_winter 50, flood 40, drought 30, plague_season 20, locust_swarm 10, earthquake 5.
20
+ - `EVENT_DURATION_RANGE: Record<ClimateEventType, [number, number]>` — duration ranges in days: drought 60–180, plague_season 30–120, harsh_winter 30–90, flood 7–30, locust_swarm 7–21, earthquake 1–3.
21
+ - `createClimateEvent(eventId, type, severity_Q, durationDays)` — factory; clamps severity and enforces minimum duration of 1.
22
+ - `activateClimateEvent(event)` → `ActiveClimateEvent` with `remainingDays = durationDays`, `elapsedDays = 0`.
23
+ - `computeClimateEffects(active)` → `ClimateEffects`; each field = `round(base × severity / SCALE.Q)`; returns zero bundle when expired.
24
+ - `stepClimateEvent(active, elapsedDays)` — decrements `remainingDays` (floor 0), increments `elapsedDays`; returns `true` when event expires.
25
+ - `isClimateEventExpired(active)` → `remainingDays <= 0`.
26
+ - `generateClimateEvent(polityHash, worldSeed, tick)` → `ClimateEvent | undefined` — deterministic random generation via `eventSeed`; rolls each type independently; severity ∈ [q(0.20), q(0.90)]; duration interpolated within type range.
27
+ - `aggregateClimateEffects(actives)` → combined `ClimateEffects` — sums per-field across all active events and clamps to SCALE.Q; expired events contribute zero.
28
+ - Added `./climate` subpath export to `package.json`.
29
+ - 41 new tests; 5,022 total. Coverage: 100% statements/branches/functions/lines on `climate.ts`.
30
+
31
+ ---
32
+
9
33
  ## [0.1.40] — 2026-03-26
10
34
 
11
35
  ### Added
@@ -0,0 +1,108 @@
1
+ import type { Q } from "./units.js";
2
+ /** Classification of climate event. */
3
+ export type ClimateEventType = "drought" | "flood" | "harsh_winter" | "earthquake" | "plague_season" | "locust_swarm";
4
+ /** Immutable descriptor for a climate event. */
5
+ export interface ClimateEvent {
6
+ eventId: string;
7
+ type: ClimateEventType;
8
+ /**
9
+ * Severity [0, SCALE.Q]. Scales all effect magnitudes.
10
+ * q(0.30) = minor; q(0.60) = severe; q(0.90) = catastrophic.
11
+ */
12
+ severity_Q: Q;
13
+ /**
14
+ * Total duration in simulated days.
15
+ * Remaining days tracked in `ActiveClimateEvent.remainingDays`.
16
+ */
17
+ durationDays: number;
18
+ }
19
+ /** Mutable tracking state for an ongoing climate event. */
20
+ export interface ActiveClimateEvent {
21
+ event: ClimateEvent;
22
+ remainingDays: number;
23
+ /** Total days this event has been active on this polity. */
24
+ elapsedDays: number;
25
+ }
26
+ /**
27
+ * Advisory effect bundle derived from a climate event.
28
+ * Pass individual fields into the relevant downstream phase calls.
29
+ * All fields are [0, SCALE.Q] unless noted.
30
+ */
31
+ export interface ClimateEffects {
32
+ /** Extra famine death pressure for Phase-86 `deathPressure_Q`. */
33
+ deathPressure_Q: Q;
34
+ /** Harvest yield penalty for Phase-87 `deriveHarvestYieldFactor`. */
35
+ harvestYieldPenalty_Q: Q;
36
+ /** Epidemic growth bonus for Phase-88 `stepEpidemic` health capacity. */
37
+ epidemicGrowthBonus_Q: Q;
38
+ /**
39
+ * Infrastructure damage fraction per day for Phase-89.
40
+ * Hosts subtract `investedCost × damage / SCALE.Q` from project progress.
41
+ */
42
+ infrastructureDamage_Q: Q;
43
+ /** Extra unrest pressure for Phase-90 `computeUnrestLevel`. */
44
+ unrestPressure_Q: Q;
45
+ /** March rate penalty for Phase-93 `stepCampaignMarch` road bonus reduction. */
46
+ marchPenalty_Q: Q;
47
+ }
48
+ /**
49
+ * Base effect magnitudes at full severity (q(1.0)) for each event type.
50
+ * Actual effects = base × severity_Q / SCALE.Q.
51
+ */
52
+ export declare const BASE_EFFECTS: Record<ClimateEventType, ClimateEffects>;
53
+ /**
54
+ * Typical duration ranges in days [min, max] for each event type.
55
+ * Used by `generateClimateEvent` to set `durationDays`.
56
+ */
57
+ export declare const EVENT_DURATION_RANGE: Record<ClimateEventType, [number, number]>;
58
+ /**
59
+ * Daily probability of each event type triggering [Q].
60
+ * Roll = `eventSeed(...) % SCALE.Q`; triggers when roll < dailyProb.
61
+ *
62
+ * These correspond to rough annual frequencies:
63
+ * harsh_winter: q(0.005) ≈ 0.5%/day ≈ ~50% chance within a year
64
+ * flood: q(0.004) ≈ 0.4%/day ≈ ~40% within a year
65
+ * drought: q(0.003) ≈ 0.3%/day
66
+ * plague_season:q(0.002) ≈ 0.2%/day
67
+ * locust_swarm: q(0.001) ≈ 0.1%/day
68
+ * earthquake: q(0.0005)≈ 0.05%/day (rare)
69
+ */
70
+ export declare const EVENT_DAILY_PROBABILITY_Q: Record<ClimateEventType, number>;
71
+ /** Create a `ClimateEvent` with explicit parameters. */
72
+ export declare function createClimateEvent(eventId: string, type: ClimateEventType, severity_Q: Q, durationDays: number): ClimateEvent;
73
+ /** Start tracking an active climate event. */
74
+ export declare function activateClimateEvent(event: ClimateEvent): ActiveClimateEvent;
75
+ /**
76
+ * Compute the `ClimateEffects` bundle for a given event at its current severity.
77
+ *
78
+ * Each field = `round(BASE_EFFECTS[type][field] × severity_Q / SCALE.Q)`.
79
+ * Returns a zero bundle if `active.remainingDays <= 0`.
80
+ */
81
+ export declare function computeClimateEffects(active: ActiveClimateEvent): ClimateEffects;
82
+ /**
83
+ * Advance an active climate event by `elapsedDays`.
84
+ * Decrements `remainingDays` (floor at 0) and increments `elapsedDays`.
85
+ * Returns `true` if the event has expired this step.
86
+ */
87
+ export declare function stepClimateEvent(active: ActiveClimateEvent, elapsedDays: number): boolean;
88
+ /** Return true if the event has run its full duration. */
89
+ export declare function isClimateEventExpired(active: ActiveClimateEvent): boolean;
90
+ /**
91
+ * Attempt to generate a random climate event for a polity on the given tick.
92
+ *
93
+ * Each event type is rolled independently. Returns the first event whose
94
+ * annual probability roll succeeds, or `undefined` if none trigger.
95
+ *
96
+ * Roll: `eventSeed(worldSeed, tick, polityHash, 0, typeSalt) % SCALE.Q`
97
+ * vs daily probability = `round(annualProb / 365)`.
98
+ *
99
+ * @param polityHash `hashString(polity.id)` from Phase-61.
100
+ * @param worldSeed World-level seed.
101
+ * @param tick Current simulation tick (day).
102
+ */
103
+ export declare function generateClimateEvent(polityHash: number, worldSeed: number, tick: number): ClimateEvent | undefined;
104
+ /**
105
+ * Combine effects from multiple simultaneous active events (e.g. drought + locust).
106
+ * Each field is summed and clamped to SCALE.Q.
107
+ */
108
+ export declare function aggregateClimateEffects(actives: ActiveClimateEvent[]): ClimateEffects;
@@ -0,0 +1,240 @@
1
+ // src/climate.ts — Phase 96: Climate Events & Natural Disasters
2
+ //
3
+ // Multi-day climate events affect polity populations, granaries, infrastructure,
4
+ // epidemic spread, and military campaigns. This is distinct from Phase-51
5
+ // (tactical weather): Phase 96 operates at the polity/campaign timescale —
6
+ // weeks to seasons — rather than the second-to-second combat tick.
7
+ //
8
+ // Design:
9
+ // - Pure data layer — no Entity fields, no kernel changes.
10
+ // - `ClimateEvent` is an immutable descriptor; `ActiveClimateEvent` tracks progress.
11
+ // - `computeClimateEffects` returns an advisory `ClimateEffects` bundle;
12
+ // callers pass individual fields to Phase-86/87/88/89/90/93 as needed.
13
+ // - `generateClimateEvent` uses `eventSeed` for deterministic random occurrence.
14
+ // - Severity and duration determine effect magnitude; effects scale linearly with
15
+ // severity via mulDiv.
16
+ //
17
+ // Integration:
18
+ // Phase 86 (Demography): deathPressure_Q elevated by drought/harsh_winter.
19
+ // Phase 87 (Granary): harvestYieldPenalty_Q reduces harvest output.
20
+ // Phase 88 (Epidemic): epidemicGrowthBonus_Q accelerates disease spread.
21
+ // Phase 89 (Infra): infrastructureDamage_Q models flood/earthquake damage.
22
+ // Phase 90 (Unrest): unrestPressure_Q adds to computeUnrestLevel factors.
23
+ // Phase 93 (Campaign): marchPenalty_Q reduces daily march progress.
24
+ import { eventSeed } from "./sim/seeds.js";
25
+ import { q, SCALE, clampQ } from "./units.js";
26
+ // ── Constants ─────────────────────────────────────────────────────────────────
27
+ /**
28
+ * Base effect magnitudes at full severity (q(1.0)) for each event type.
29
+ * Actual effects = base × severity_Q / SCALE.Q.
30
+ */
31
+ export const BASE_EFFECTS = {
32
+ drought: {
33
+ deathPressure_Q: q(0.10),
34
+ harvestYieldPenalty_Q: q(0.60), // major crop loss
35
+ epidemicGrowthBonus_Q: q(0.10),
36
+ infrastructureDamage_Q: 0,
37
+ unrestPressure_Q: q(0.15),
38
+ marchPenalty_Q: 0,
39
+ },
40
+ flood: {
41
+ deathPressure_Q: q(0.08),
42
+ harvestYieldPenalty_Q: q(0.40),
43
+ epidemicGrowthBonus_Q: q(0.20), // waterborne disease
44
+ infrastructureDamage_Q: q(0.05), // per day
45
+ unrestPressure_Q: q(0.10),
46
+ marchPenalty_Q: q(0.30), // mud impedes armies
47
+ },
48
+ harsh_winter: {
49
+ deathPressure_Q: q(0.08),
50
+ harvestYieldPenalty_Q: q(0.20),
51
+ epidemicGrowthBonus_Q: q(0.15), // respiratory illness
52
+ infrastructureDamage_Q: q(0.01),
53
+ unrestPressure_Q: q(0.08),
54
+ marchPenalty_Q: q(0.40), // historical winter campaign penalty
55
+ },
56
+ earthquake: {
57
+ deathPressure_Q: q(0.15), // immediate casualties
58
+ harvestYieldPenalty_Q: q(0.10),
59
+ epidemicGrowthBonus_Q: q(0.10),
60
+ infrastructureDamage_Q: q(0.20), // heavy structural damage
61
+ unrestPressure_Q: q(0.20),
62
+ marchPenalty_Q: q(0.10),
63
+ },
64
+ plague_season: {
65
+ deathPressure_Q: q(0.20), // epidemic peak
66
+ harvestYieldPenalty_Q: q(0.15), // insufficient labour
67
+ epidemicGrowthBonus_Q: q(0.40), // primary driver
68
+ infrastructureDamage_Q: 0,
69
+ unrestPressure_Q: q(0.18),
70
+ marchPenalty_Q: q(0.15), // sick soldiers
71
+ },
72
+ locust_swarm: {
73
+ deathPressure_Q: q(0.05),
74
+ harvestYieldPenalty_Q: q(0.80), // near-total crop destruction
75
+ epidemicGrowthBonus_Q: 0,
76
+ infrastructureDamage_Q: 0,
77
+ unrestPressure_Q: q(0.20),
78
+ marchPenalty_Q: 0,
79
+ },
80
+ };
81
+ /**
82
+ * Typical duration ranges in days [min, max] for each event type.
83
+ * Used by `generateClimateEvent` to set `durationDays`.
84
+ */
85
+ export const EVENT_DURATION_RANGE = {
86
+ drought: [60, 180],
87
+ flood: [7, 30],
88
+ harsh_winter: [30, 90],
89
+ earthquake: [1, 3],
90
+ plague_season: [30, 120],
91
+ locust_swarm: [7, 21],
92
+ };
93
+ /**
94
+ * Daily probability of each event type triggering [Q].
95
+ * Roll = `eventSeed(...) % SCALE.Q`; triggers when roll < dailyProb.
96
+ *
97
+ * These correspond to rough annual frequencies:
98
+ * harsh_winter: q(0.005) ≈ 0.5%/day ≈ ~50% chance within a year
99
+ * flood: q(0.004) ≈ 0.4%/day ≈ ~40% within a year
100
+ * drought: q(0.003) ≈ 0.3%/day
101
+ * plague_season:q(0.002) ≈ 0.2%/day
102
+ * locust_swarm: q(0.001) ≈ 0.1%/day
103
+ * earthquake: q(0.0005)≈ 0.05%/day (rare)
104
+ */
105
+ export const EVENT_DAILY_PROBABILITY_Q = {
106
+ harsh_winter: 50, // 50/10000 = 0.5% per day
107
+ flood: 40,
108
+ drought: 30,
109
+ plague_season: 20,
110
+ locust_swarm: 10,
111
+ earthquake: 5,
112
+ };
113
+ // ── Factory ───────────────────────────────────────────────────────────────────
114
+ /** Create a `ClimateEvent` with explicit parameters. */
115
+ export function createClimateEvent(eventId, type, severity_Q, durationDays) {
116
+ return {
117
+ eventId,
118
+ type,
119
+ severity_Q: clampQ(severity_Q, 0, SCALE.Q),
120
+ durationDays: Math.max(1, durationDays),
121
+ };
122
+ }
123
+ /** Start tracking an active climate event. */
124
+ export function activateClimateEvent(event) {
125
+ return { event, remainingDays: event.durationDays, elapsedDays: 0 };
126
+ }
127
+ // ── Effect computation ────────────────────────────────────────────────────────
128
+ /**
129
+ * Compute the `ClimateEffects` bundle for a given event at its current severity.
130
+ *
131
+ * Each field = `round(BASE_EFFECTS[type][field] × severity_Q / SCALE.Q)`.
132
+ * Returns a zero bundle if `active.remainingDays <= 0`.
133
+ */
134
+ export function computeClimateEffects(active) {
135
+ if (active.remainingDays <= 0) {
136
+ return zeroEffects();
137
+ }
138
+ const base = BASE_EFFECTS[active.event.type];
139
+ const sev = active.event.severity_Q;
140
+ return {
141
+ deathPressure_Q: scale(base.deathPressure_Q, sev),
142
+ harvestYieldPenalty_Q: scale(base.harvestYieldPenalty_Q, sev),
143
+ epidemicGrowthBonus_Q: scale(base.epidemicGrowthBonus_Q, sev),
144
+ infrastructureDamage_Q: scale(base.infrastructureDamage_Q, sev),
145
+ unrestPressure_Q: scale(base.unrestPressure_Q, sev),
146
+ marchPenalty_Q: scale(base.marchPenalty_Q, sev),
147
+ };
148
+ }
149
+ function scale(base, severity_Q) {
150
+ return clampQ(Math.round(base * severity_Q / SCALE.Q), 0, SCALE.Q);
151
+ }
152
+ function zeroEffects() {
153
+ return {
154
+ deathPressure_Q: 0,
155
+ harvestYieldPenalty_Q: 0,
156
+ epidemicGrowthBonus_Q: 0,
157
+ infrastructureDamage_Q: 0,
158
+ unrestPressure_Q: 0,
159
+ marchPenalty_Q: 0,
160
+ };
161
+ }
162
+ // ── Event step ────────────────────────────────────────────────────────────────
163
+ /**
164
+ * Advance an active climate event by `elapsedDays`.
165
+ * Decrements `remainingDays` (floor at 0) and increments `elapsedDays`.
166
+ * Returns `true` if the event has expired this step.
167
+ */
168
+ export function stepClimateEvent(active, elapsedDays) {
169
+ active.elapsedDays += elapsedDays;
170
+ active.remainingDays = Math.max(0, active.remainingDays - elapsedDays);
171
+ return active.remainingDays === 0;
172
+ }
173
+ /** Return true if the event has run its full duration. */
174
+ export function isClimateEventExpired(active) {
175
+ return active.remainingDays <= 0;
176
+ }
177
+ // ── Random event generation ───────────────────────────────────────────────────
178
+ /**
179
+ * Attempt to generate a random climate event for a polity on the given tick.
180
+ *
181
+ * Each event type is rolled independently. Returns the first event whose
182
+ * annual probability roll succeeds, or `undefined` if none trigger.
183
+ *
184
+ * Roll: `eventSeed(worldSeed, tick, polityHash, 0, typeSalt) % SCALE.Q`
185
+ * vs daily probability = `round(annualProb / 365)`.
186
+ *
187
+ * @param polityHash `hashString(polity.id)` from Phase-61.
188
+ * @param worldSeed World-level seed.
189
+ * @param tick Current simulation tick (day).
190
+ */
191
+ export function generateClimateEvent(polityHash, worldSeed, tick) {
192
+ const types = [
193
+ "harsh_winter", "flood", "drought", "plague_season", "locust_swarm", "earthquake",
194
+ ];
195
+ for (let i = 0; i < types.length; i++) {
196
+ const type = types[i];
197
+ const dailyProb = EVENT_DAILY_PROBABILITY_Q[type];
198
+ const seed = eventSeed(worldSeed, tick, polityHash, 0, i + 1);
199
+ const roll = seed % SCALE.Q;
200
+ if (roll < dailyProb) {
201
+ // Determine severity: another seed roll, maps to q(0.20)–q(0.90)
202
+ const sevSeed = eventSeed(worldSeed, tick, polityHash, i + 1, 42);
203
+ const sevRoll = sevSeed % SCALE.Q;
204
+ const severity_Q = clampQ(q(0.20) + Math.round(sevRoll * q(0.70) / SCALE.Q), 0, SCALE.Q);
205
+ // Duration: interpolate within the type's range
206
+ const [minDays, maxDays] = EVENT_DURATION_RANGE[type];
207
+ const durSeed = eventSeed(worldSeed, tick, polityHash, i + 2, 7);
208
+ const durRange = maxDays - minDays;
209
+ const durationDays = minDays + (durSeed % (durRange + 1));
210
+ const eventId = `${type}_${tick}_${polityHash % 10000}`;
211
+ return createClimateEvent(eventId, type, severity_Q, durationDays);
212
+ }
213
+ }
214
+ return undefined;
215
+ }
216
+ // ── Aggregate helpers ─────────────────────────────────────────────────────────
217
+ /**
218
+ * Combine effects from multiple simultaneous active events (e.g. drought + locust).
219
+ * Each field is summed and clamped to SCALE.Q.
220
+ */
221
+ export function aggregateClimateEffects(actives) {
222
+ let death = 0, harvest = 0, epidemic = 0, infra = 0, unrest = 0, march = 0;
223
+ for (const active of actives) {
224
+ const fx = computeClimateEffects(active);
225
+ death += fx.deathPressure_Q;
226
+ harvest += fx.harvestYieldPenalty_Q;
227
+ epidemic += fx.epidemicGrowthBonus_Q;
228
+ infra += fx.infrastructureDamage_Q;
229
+ unrest += fx.unrestPressure_Q;
230
+ march += fx.marchPenalty_Q;
231
+ }
232
+ return {
233
+ deathPressure_Q: clampQ(death, 0, SCALE.Q),
234
+ harvestYieldPenalty_Q: clampQ(harvest, 0, SCALE.Q),
235
+ epidemicGrowthBonus_Q: clampQ(epidemic, 0, SCALE.Q),
236
+ infrastructureDamage_Q: clampQ(infra, 0, SCALE.Q),
237
+ unrestPressure_Q: clampQ(unrest, 0, SCALE.Q),
238
+ marchPenalty_Q: clampQ(march, 0, SCALE.Q),
239
+ };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -146,6 +146,10 @@
146
146
  "./resources": {
147
147
  "import": "./dist/src/resources.js",
148
148
  "types": "./dist/src/resources.d.ts"
149
+ },
150
+ "./climate": {
151
+ "import": "./dist/src/climate.js",
152
+ "types": "./dist/src/climate.d.ts"
149
153
  }
150
154
  },
151
155
  "files": [