@its-not-rocket-science/ananke 0.1.40 → 0.1.42
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 +52 -0
- package/dist/src/climate.d.ts +108 -0
- package/dist/src/climate.js +240 -0
- package/dist/src/famine.d.ts +120 -0
- package/dist/src/famine.js +201 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.42] — 2026-03-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Phase 97 · Famine Relief & Rationing** (`src/famine.ts`)
|
|
14
|
+
- `FaminePhase`: `"none" | "shortage" | "famine" | "catastrophe"` — graduated severity above Phase-87 Granary's binary famine flag.
|
|
15
|
+
- `RationingPolicy`: `"none" | "tight" | "emergency" | "starvation_rations"` — active polity response.
|
|
16
|
+
- `FamineState { polityId, phase, daysInPhase, cumulativeSeverity_Q }` — per-polity mutable tracker stored externally.
|
|
17
|
+
- `FaminePressures { deathBonus_Q, migrationPush_Q, unrestPressure_Q }` — advisory bundle; callers pass fields into Phases 86/81/90.
|
|
18
|
+
- Phase thresholds: shortage < q(0.50), famine < q(0.20), catastrophe < q(0.05) of `computeFoodSupply_Q`.
|
|
19
|
+
- `FAMINE_PHASE_DEATH_Q`: +1%/year (shortage) → +3%/year (famine) → +7%/year (catastrophe); stacks with Phase-86 base famine death.
|
|
20
|
+
- `FAMINE_PHASE_MIGRATION_Q`: q(0.08) → q(0.25) → q(0.50) — feeds Phase-81.
|
|
21
|
+
- `RATIONING_REDUCTION_Q`: tight 20%, emergency 40%, starvation_rations 60% consumption cut.
|
|
22
|
+
- `RATIONING_UNREST_Q`: q(0.04) → q(0.12) → q(0.25) — rationing itself generates unrest.
|
|
23
|
+
- `SEVERITY_DELTA_PER_DAY`: none −5 (decay), shortage +2, famine +10, catastrophe +25 per day; `cumulativeSeverity_Q` models long-term famine damage.
|
|
24
|
+
- `createFamineState(polityId)` — factory.
|
|
25
|
+
- `computeFaminePhase(foodSupply_Q)` — classifies severity from granary output.
|
|
26
|
+
- `computeFaminePressures(state, policy?)` — combined famine + rationing advisory pressures.
|
|
27
|
+
- `stepFamine(state, foodSupply_Q, elapsedDays)` → `boolean` — advances state; returns `true` when phase changes.
|
|
28
|
+
- `computeRationedConsumption(polity, policy, elapsedDays)` — rationed su demand.
|
|
29
|
+
- `stepRationedGranary(polity, granary, policy, elapsedDays)` — replaces Phase-87 `stepGranaryConsumption` when rationing is active.
|
|
30
|
+
- `computeReliefImport(polity, granary, budget_cu, capacityCap_su)` — converts treasury into grain; mutates both in-place; capped by treasury, budget, and granary space.
|
|
31
|
+
- `isFamineActive(state)` / `isCatastrophicFamine(state)` — convenience predicates.
|
|
32
|
+
- Added `./famine` subpath export to `package.json`.
|
|
33
|
+
- 60 new tests; 5,082 total. Coverage: 100% statements/branches/functions/lines on `famine.ts`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## [0.1.41] — 2026-03-26
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Phase 96 · Climate Events & Natural Disasters** (`src/climate.ts`)
|
|
42
|
+
- `ClimateEventType`: `"drought" | "flood" | "harsh_winter" | "earthquake" | "plague_season" | "locust_swarm"`.
|
|
43
|
+
- `ClimateEvent { eventId, type, severity_Q, durationDays }` — immutable descriptor.
|
|
44
|
+
- `ActiveClimateEvent { event, remainingDays, elapsedDays }` — mutable progress tracker stored externally by host.
|
|
45
|
+
- `ClimateEffects { deathPressure_Q, harvestYieldPenalty_Q, epidemicGrowthBonus_Q, infrastructureDamage_Q, unrestPressure_Q, marchPenalty_Q }` — advisory bundle passed to Phases 86–93.
|
|
46
|
+
- `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)).
|
|
47
|
+
- `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.
|
|
48
|
+
- `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.
|
|
49
|
+
- `createClimateEvent(eventId, type, severity_Q, durationDays)` — factory; clamps severity and enforces minimum duration of 1.
|
|
50
|
+
- `activateClimateEvent(event)` → `ActiveClimateEvent` with `remainingDays = durationDays`, `elapsedDays = 0`.
|
|
51
|
+
- `computeClimateEffects(active)` → `ClimateEffects`; each field = `round(base × severity / SCALE.Q)`; returns zero bundle when expired.
|
|
52
|
+
- `stepClimateEvent(active, elapsedDays)` — decrements `remainingDays` (floor 0), increments `elapsedDays`; returns `true` when event expires.
|
|
53
|
+
- `isClimateEventExpired(active)` → `remainingDays <= 0`.
|
|
54
|
+
- `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.
|
|
55
|
+
- `aggregateClimateEffects(actives)` → combined `ClimateEffects` — sums per-field across all active events and clamps to SCALE.Q; expired events contribute zero.
|
|
56
|
+
- Added `./climate` subpath export to `package.json`.
|
|
57
|
+
- 41 new tests; 5,022 total. Coverage: 100% statements/branches/functions/lines on `climate.ts`.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
9
61
|
## [0.1.40] — 2026-03-26
|
|
10
62
|
|
|
11
63
|
### 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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Q } from "./units.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
import type { GranaryState } from "./granary.js";
|
|
4
|
+
/** Graduated severity of a food crisis. */
|
|
5
|
+
export type FaminePhase = "none" | "shortage" | "famine" | "catastrophe";
|
|
6
|
+
/** Polity policy for reducing per-capita food consumption below normal demand. */
|
|
7
|
+
export type RationingPolicy = "none" | "tight" | "emergency" | "starvation_rations";
|
|
8
|
+
/**
|
|
9
|
+
* Per-polity famine tracking state.
|
|
10
|
+
* Attach one to each polity; store externally (e.g. `Map<string, FamineState>`).
|
|
11
|
+
*/
|
|
12
|
+
export interface FamineState {
|
|
13
|
+
polityId: string;
|
|
14
|
+
phase: FaminePhase;
|
|
15
|
+
/** Days spent continuously in the current phase. Resets when phase changes. */
|
|
16
|
+
daysInPhase: number;
|
|
17
|
+
/**
|
|
18
|
+
* Long-term famine damage [0, SCALE.Q].
|
|
19
|
+
* Accrues during famine/catastrophe (depleted seed grain, dead workers, trauma).
|
|
20
|
+
* Decays slowly once food supply recovers to "none".
|
|
21
|
+
*/
|
|
22
|
+
cumulativeSeverity_Q: Q;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Advisory pressure bundle for downstream phases.
|
|
26
|
+
* All fields [0, SCALE.Q] unless noted.
|
|
27
|
+
*/
|
|
28
|
+
export interface FaminePressures {
|
|
29
|
+
/** Additional annual death rate. Supplement Phase-86 `deathPressure_Q`. */
|
|
30
|
+
deathBonus_Q: Q;
|
|
31
|
+
/** Migration push. Pass to Phase-81 `computePushPressure`. */
|
|
32
|
+
migrationPush_Q: Q;
|
|
33
|
+
/** Combined famine + rationing unrest. Pass to Phase-90 `computeUnrestLevel`. */
|
|
34
|
+
unrestPressure_Q: Q;
|
|
35
|
+
}
|
|
36
|
+
/** `foodSupply_Q` below this → shortage phase. */
|
|
37
|
+
export declare const SHORTAGE_THRESHOLD_Q: Q;
|
|
38
|
+
/** `foodSupply_Q` below this → famine phase. */
|
|
39
|
+
export declare const FAMINE_THRESHOLD_Q: Q;
|
|
40
|
+
/** `foodSupply_Q` below this → catastrophe phase. */
|
|
41
|
+
export declare const CATASTROPHE_THRESHOLD_Q: Q;
|
|
42
|
+
/**
|
|
43
|
+
* Additional annual death rate by famine phase [Q].
|
|
44
|
+
* Phase-86 already applies `FAMINE_DEATH_ANNUAL_Q = q(0.030)` at famine threshold;
|
|
45
|
+
* these bonuses are additive on top for graduated severity.
|
|
46
|
+
*/
|
|
47
|
+
export declare const FAMINE_PHASE_DEATH_Q: Record<FaminePhase, Q>;
|
|
48
|
+
/** Migration push pressure by famine phase [0, SCALE.Q]. */
|
|
49
|
+
export declare const FAMINE_PHASE_MIGRATION_Q: Record<FaminePhase, Q>;
|
|
50
|
+
/** Base unrest pressure by famine phase [0, SCALE.Q]. */
|
|
51
|
+
export declare const FAMINE_PHASE_UNREST_Q: Record<FaminePhase, Q>;
|
|
52
|
+
/**
|
|
53
|
+
* Consumption reduction fraction per rationing policy [0, SCALE.Q].
|
|
54
|
+
* Applied to `polity.population × elapsedDays` to give actual su demand.
|
|
55
|
+
*/
|
|
56
|
+
export declare const RATIONING_REDUCTION_Q: Record<RationingPolicy, Q>;
|
|
57
|
+
/** Unrest pressure added by rationing policy itself [0, SCALE.Q]. */
|
|
58
|
+
export declare const RATIONING_UNREST_Q: Record<RationingPolicy, Q>;
|
|
59
|
+
/** Treasury cost in cu per supply unit of emergency food import (1 su = 1 person-day). */
|
|
60
|
+
export declare const RELIEF_IMPORT_COST_CU_PER_SU = 2;
|
|
61
|
+
/**
|
|
62
|
+
* Cumulative severity change per day by famine phase [out of SCALE.Q].
|
|
63
|
+
* Negative values → decay; positive values → accrual.
|
|
64
|
+
*/
|
|
65
|
+
export declare const SEVERITY_DELTA_PER_DAY: Record<FaminePhase, number>;
|
|
66
|
+
/** Create a fresh `FamineState` for a polity (no active famine, zero severity). */
|
|
67
|
+
export declare function createFamineState(polityId: string): FamineState;
|
|
68
|
+
/**
|
|
69
|
+
* Classify the current famine phase from the granary food supply fraction.
|
|
70
|
+
*
|
|
71
|
+
* Obtain `foodSupply_Q` from Phase-87 `computeFoodSupply_Q(polity, granary)`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function computeFaminePhase(foodSupply_Q: Q): FaminePhase;
|
|
74
|
+
/**
|
|
75
|
+
* Compute the advisory pressure bundle for the current famine state and rationing policy.
|
|
76
|
+
*
|
|
77
|
+
* `unrestPressure_Q` sums famine unrest with rationing unrest, clamped to SCALE.Q.
|
|
78
|
+
*/
|
|
79
|
+
export declare function computeFaminePressures(state: FamineState, policy?: RationingPolicy): FaminePressures;
|
|
80
|
+
/**
|
|
81
|
+
* Advance famine state by `elapsedDays`.
|
|
82
|
+
*
|
|
83
|
+
* - Reclassifies `phase` from the current `foodSupply_Q`.
|
|
84
|
+
* - Resets `daysInPhase` to 0 on phase change; otherwise increments.
|
|
85
|
+
* - Accrues or decays `cumulativeSeverity_Q` at `SEVERITY_DELTA_PER_DAY`.
|
|
86
|
+
*
|
|
87
|
+
* Returns `true` if the famine phase changed this step.
|
|
88
|
+
*/
|
|
89
|
+
export declare function stepFamine(state: FamineState, foodSupply_Q: Q, elapsedDays: number): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Compute food demand in supply units after applying the rationing reduction.
|
|
92
|
+
*
|
|
93
|
+
* Normal demand = `polity.population × elapsedDays` su.
|
|
94
|
+
* `RATIONING_REDUCTION_Q[policy]` fraction is subtracted before multiplication.
|
|
95
|
+
*/
|
|
96
|
+
export declare function computeRationedConsumption(polity: Polity, policy: RationingPolicy, elapsedDays: number): number;
|
|
97
|
+
/**
|
|
98
|
+
* Drain rationed consumption from a granary.
|
|
99
|
+
*
|
|
100
|
+
* Use in place of Phase-87 `stepGranaryConsumption` when a rationing policy is
|
|
101
|
+
* active. Grain is clamped to 0; returns the actual supply units consumed.
|
|
102
|
+
*/
|
|
103
|
+
export declare function stepRationedGranary(polity: Polity, granary: GranaryState, policy: RationingPolicy, elapsedDays: number): number;
|
|
104
|
+
/**
|
|
105
|
+
* Spend treasury to import emergency food.
|
|
106
|
+
*
|
|
107
|
+
* Converts up to `budget_cu` of `polity.treasury_cu` into grain at
|
|
108
|
+
* `RELIEF_IMPORT_COST_CU_PER_SU` cu/su, limited by remaining granary space.
|
|
109
|
+
*
|
|
110
|
+
* Mutates `polity.treasury_cu` and `granary.grain_su`.
|
|
111
|
+
* Returns the actual supply units added.
|
|
112
|
+
*
|
|
113
|
+
* @param budget_cu Max treasury to spend (e.g. pass `polity.treasury_cu` for all-in).
|
|
114
|
+
* @param capacityCap_su Max granary capacity; derive via Phase-87 `computeCapacity(polity)`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function computeReliefImport(polity: Polity, granary: GranaryState, budget_cu: number, capacityCap_su: number): number;
|
|
117
|
+
/** Return `true` when the polity is in any active famine phase. */
|
|
118
|
+
export declare function isFamineActive(state: FamineState): boolean;
|
|
119
|
+
/** Return `true` when the polity has reached the most severe famine phase. */
|
|
120
|
+
export declare function isCatastrophicFamine(state: FamineState): boolean;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// src/famine.ts — Phase 97: Famine Relief & Rationing
|
|
2
|
+
//
|
|
3
|
+
// Graduated famine severity tracking, rationing policies, and emergency food
|
|
4
|
+
// relief for polities. Sits above Phase-87 (Granary) as the active response layer.
|
|
5
|
+
//
|
|
6
|
+
// Design:
|
|
7
|
+
// - Pure data layer — no Entity fields, no kernel changes.
|
|
8
|
+
// - `FamineState` tracks famine phase, days in phase, and cumulative severity.
|
|
9
|
+
// Store one per polity externally; pass to step functions each tick.
|
|
10
|
+
// - `computeFaminePressures` returns an advisory bundle; callers pass fields into
|
|
11
|
+
// Phase-86 (deathBonus), Phase-81 (migrationPush), and Phase-90 (unrest).
|
|
12
|
+
// - `stepRationedGranary` replaces Phase-87 `stepGranaryConsumption` when a
|
|
13
|
+
// rationing policy is active.
|
|
14
|
+
// - `computeReliefImport` converts `treasury_cu` to grain — treasury and granary
|
|
15
|
+
// are mutated in-place; caller supplies granary capacity.
|
|
16
|
+
//
|
|
17
|
+
// Integration:
|
|
18
|
+
// Phase 86 (Demography): deathBonus_Q supplements Phase-86 FAMINE_DEATH_ANNUAL_Q.
|
|
19
|
+
// Phase 87 (Granary): computeFoodSupply_Q → foodSupply_Q; stepRationedGranary.
|
|
20
|
+
// Phase 81 (Migration): migrationPush_Q passed to computePushPressure.
|
|
21
|
+
// Phase 90 (Unrest): unrestPressure_Q passed to computeUnrestLevel.
|
|
22
|
+
// Phase 96 (Climate): harvestYieldPenalty worsens foodSupply_Q that drives here.
|
|
23
|
+
import { q, SCALE, clampQ } from "./units.js";
|
|
24
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
25
|
+
/** `foodSupply_Q` below this → shortage phase. */
|
|
26
|
+
export const SHORTAGE_THRESHOLD_Q = q(0.50);
|
|
27
|
+
/** `foodSupply_Q` below this → famine phase. */
|
|
28
|
+
export const FAMINE_THRESHOLD_Q = q(0.20);
|
|
29
|
+
/** `foodSupply_Q` below this → catastrophe phase. */
|
|
30
|
+
export const CATASTROPHE_THRESHOLD_Q = q(0.05);
|
|
31
|
+
/**
|
|
32
|
+
* Additional annual death rate by famine phase [Q].
|
|
33
|
+
* Phase-86 already applies `FAMINE_DEATH_ANNUAL_Q = q(0.030)` at famine threshold;
|
|
34
|
+
* these bonuses are additive on top for graduated severity.
|
|
35
|
+
*/
|
|
36
|
+
export const FAMINE_PHASE_DEATH_Q = {
|
|
37
|
+
none: 0,
|
|
38
|
+
shortage: q(0.010), // +1%/year
|
|
39
|
+
famine: q(0.030), // +3%/year (stacks with Ph-86 +3%)
|
|
40
|
+
catastrophe: q(0.070), // +7%/year
|
|
41
|
+
};
|
|
42
|
+
/** Migration push pressure by famine phase [0, SCALE.Q]. */
|
|
43
|
+
export const FAMINE_PHASE_MIGRATION_Q = {
|
|
44
|
+
none: 0,
|
|
45
|
+
shortage: q(0.08),
|
|
46
|
+
famine: q(0.25),
|
|
47
|
+
catastrophe: q(0.50),
|
|
48
|
+
};
|
|
49
|
+
/** Base unrest pressure by famine phase [0, SCALE.Q]. */
|
|
50
|
+
export const FAMINE_PHASE_UNREST_Q = {
|
|
51
|
+
none: 0,
|
|
52
|
+
shortage: q(0.05),
|
|
53
|
+
famine: q(0.15),
|
|
54
|
+
catastrophe: q(0.30),
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Consumption reduction fraction per rationing policy [0, SCALE.Q].
|
|
58
|
+
* Applied to `polity.population × elapsedDays` to give actual su demand.
|
|
59
|
+
*/
|
|
60
|
+
export const RATIONING_REDUCTION_Q = {
|
|
61
|
+
none: 0,
|
|
62
|
+
tight: q(0.20),
|
|
63
|
+
emergency: q(0.40),
|
|
64
|
+
starvation_rations: q(0.60),
|
|
65
|
+
};
|
|
66
|
+
/** Unrest pressure added by rationing policy itself [0, SCALE.Q]. */
|
|
67
|
+
export const RATIONING_UNREST_Q = {
|
|
68
|
+
none: 0,
|
|
69
|
+
tight: q(0.04),
|
|
70
|
+
emergency: q(0.12),
|
|
71
|
+
starvation_rations: q(0.25),
|
|
72
|
+
};
|
|
73
|
+
/** Treasury cost in cu per supply unit of emergency food import (1 su = 1 person-day). */
|
|
74
|
+
export const RELIEF_IMPORT_COST_CU_PER_SU = 2;
|
|
75
|
+
/**
|
|
76
|
+
* Cumulative severity change per day by famine phase [out of SCALE.Q].
|
|
77
|
+
* Negative values → decay; positive values → accrual.
|
|
78
|
+
*/
|
|
79
|
+
export const SEVERITY_DELTA_PER_DAY = {
|
|
80
|
+
none: -5,
|
|
81
|
+
shortage: 2,
|
|
82
|
+
famine: 10,
|
|
83
|
+
catastrophe: 25,
|
|
84
|
+
};
|
|
85
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
86
|
+
/** Create a fresh `FamineState` for a polity (no active famine, zero severity). */
|
|
87
|
+
export function createFamineState(polityId) {
|
|
88
|
+
return {
|
|
89
|
+
polityId,
|
|
90
|
+
phase: "none",
|
|
91
|
+
daysInPhase: 0,
|
|
92
|
+
cumulativeSeverity_Q: 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// ── Phase classification ───────────────────────────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* Classify the current famine phase from the granary food supply fraction.
|
|
98
|
+
*
|
|
99
|
+
* Obtain `foodSupply_Q` from Phase-87 `computeFoodSupply_Q(polity, granary)`.
|
|
100
|
+
*/
|
|
101
|
+
export function computeFaminePhase(foodSupply_Q) {
|
|
102
|
+
if (foodSupply_Q < CATASTROPHE_THRESHOLD_Q)
|
|
103
|
+
return "catastrophe";
|
|
104
|
+
if (foodSupply_Q < FAMINE_THRESHOLD_Q)
|
|
105
|
+
return "famine";
|
|
106
|
+
if (foodSupply_Q < SHORTAGE_THRESHOLD_Q)
|
|
107
|
+
return "shortage";
|
|
108
|
+
return "none";
|
|
109
|
+
}
|
|
110
|
+
// ── Pressure computation ──────────────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Compute the advisory pressure bundle for the current famine state and rationing policy.
|
|
113
|
+
*
|
|
114
|
+
* `unrestPressure_Q` sums famine unrest with rationing unrest, clamped to SCALE.Q.
|
|
115
|
+
*/
|
|
116
|
+
export function computeFaminePressures(state, policy = "none") {
|
|
117
|
+
const phase = state.phase;
|
|
118
|
+
const unrest = clampQ(FAMINE_PHASE_UNREST_Q[phase] + RATIONING_UNREST_Q[policy], 0, SCALE.Q);
|
|
119
|
+
return {
|
|
120
|
+
deathBonus_Q: FAMINE_PHASE_DEATH_Q[phase],
|
|
121
|
+
migrationPush_Q: FAMINE_PHASE_MIGRATION_Q[phase],
|
|
122
|
+
unrestPressure_Q: unrest,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// ── State step ────────────────────────────────────────────────────────────────
|
|
126
|
+
/**
|
|
127
|
+
* Advance famine state by `elapsedDays`.
|
|
128
|
+
*
|
|
129
|
+
* - Reclassifies `phase` from the current `foodSupply_Q`.
|
|
130
|
+
* - Resets `daysInPhase` to 0 on phase change; otherwise increments.
|
|
131
|
+
* - Accrues or decays `cumulativeSeverity_Q` at `SEVERITY_DELTA_PER_DAY`.
|
|
132
|
+
*
|
|
133
|
+
* Returns `true` if the famine phase changed this step.
|
|
134
|
+
*/
|
|
135
|
+
export function stepFamine(state, foodSupply_Q, elapsedDays) {
|
|
136
|
+
const newPhase = computeFaminePhase(foodSupply_Q);
|
|
137
|
+
const phaseChanged = newPhase !== state.phase;
|
|
138
|
+
if (phaseChanged) {
|
|
139
|
+
state.phase = newPhase;
|
|
140
|
+
state.daysInPhase = 0;
|
|
141
|
+
}
|
|
142
|
+
state.daysInPhase += elapsedDays;
|
|
143
|
+
const delta = SEVERITY_DELTA_PER_DAY[state.phase] * elapsedDays;
|
|
144
|
+
state.cumulativeSeverity_Q = clampQ(state.cumulativeSeverity_Q + delta, 0, SCALE.Q);
|
|
145
|
+
return phaseChanged;
|
|
146
|
+
}
|
|
147
|
+
// ── Rationing ─────────────────────────────────────────────────────────────────
|
|
148
|
+
/**
|
|
149
|
+
* Compute food demand in supply units after applying the rationing reduction.
|
|
150
|
+
*
|
|
151
|
+
* Normal demand = `polity.population × elapsedDays` su.
|
|
152
|
+
* `RATIONING_REDUCTION_Q[policy]` fraction is subtracted before multiplication.
|
|
153
|
+
*/
|
|
154
|
+
export function computeRationedConsumption(polity, policy, elapsedDays) {
|
|
155
|
+
const reduction = RATIONING_REDUCTION_Q[policy];
|
|
156
|
+
const factor = SCALE.Q - reduction;
|
|
157
|
+
const dailyDemand = Math.round(polity.population * factor / SCALE.Q);
|
|
158
|
+
return dailyDemand * elapsedDays;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Drain rationed consumption from a granary.
|
|
162
|
+
*
|
|
163
|
+
* Use in place of Phase-87 `stepGranaryConsumption` when a rationing policy is
|
|
164
|
+
* active. Grain is clamped to 0; returns the actual supply units consumed.
|
|
165
|
+
*/
|
|
166
|
+
export function stepRationedGranary(polity, granary, policy, elapsedDays) {
|
|
167
|
+
const demand = computeRationedConsumption(polity, policy, elapsedDays);
|
|
168
|
+
const consumed = Math.min(demand, granary.grain_su);
|
|
169
|
+
granary.grain_su = Math.max(0, granary.grain_su - demand);
|
|
170
|
+
return consumed;
|
|
171
|
+
}
|
|
172
|
+
// ── Relief imports ─────────────────────────────────────────────────────────────
|
|
173
|
+
/**
|
|
174
|
+
* Spend treasury to import emergency food.
|
|
175
|
+
*
|
|
176
|
+
* Converts up to `budget_cu` of `polity.treasury_cu` into grain at
|
|
177
|
+
* `RELIEF_IMPORT_COST_CU_PER_SU` cu/su, limited by remaining granary space.
|
|
178
|
+
*
|
|
179
|
+
* Mutates `polity.treasury_cu` and `granary.grain_su`.
|
|
180
|
+
* Returns the actual supply units added.
|
|
181
|
+
*
|
|
182
|
+
* @param budget_cu Max treasury to spend (e.g. pass `polity.treasury_cu` for all-in).
|
|
183
|
+
* @param capacityCap_su Max granary capacity; derive via Phase-87 `computeCapacity(polity)`.
|
|
184
|
+
*/
|
|
185
|
+
export function computeReliefImport(polity, granary, budget_cu, capacityCap_su) {
|
|
186
|
+
const affordable = Math.floor(Math.min(budget_cu, polity.treasury_cu) / RELIEF_IMPORT_COST_CU_PER_SU);
|
|
187
|
+
const space = Math.max(0, capacityCap_su - granary.grain_su);
|
|
188
|
+
const added = Math.min(affordable, space);
|
|
189
|
+
granary.grain_su += added;
|
|
190
|
+
polity.treasury_cu -= added * RELIEF_IMPORT_COST_CU_PER_SU;
|
|
191
|
+
return added;
|
|
192
|
+
}
|
|
193
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
194
|
+
/** Return `true` when the polity is in any active famine phase. */
|
|
195
|
+
export function isFamineActive(state) {
|
|
196
|
+
return state.phase !== "none";
|
|
197
|
+
}
|
|
198
|
+
/** Return `true` when the polity has reached the most severe famine phase. */
|
|
199
|
+
export function isCatastrophicFamine(state) {
|
|
200
|
+
return state.phase === "catastrophe";
|
|
201
|
+
}
|
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.42",
|
|
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,14 @@
|
|
|
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"
|
|
153
|
+
},
|
|
154
|
+
"./famine": {
|
|
155
|
+
"import": "./dist/src/famine.js",
|
|
156
|
+
"types": "./dist/src/famine.d.ts"
|
|
149
157
|
}
|
|
150
158
|
},
|
|
151
159
|
"files": [
|