@its-not-rocket-science/ananke 0.1.39 → 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 +47 -0
- package/dist/src/climate.d.ts +108 -0
- package/dist/src/climate.js +240 -0
- package/dist/src/resources.d.ts +149 -0
- package/dist/src/resources.js +219 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,53 @@ 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
|
+
|
|
33
|
+
## [0.1.40] — 2026-03-26
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Phase 95 · Natural Resources & Extraction** (`src/resources.ts`)
|
|
38
|
+
- `ResourceType`: `"iron" | "silver" | "timber" | "stone" | "horses"`.
|
|
39
|
+
- `ResourceDeposit { depositId, polityId, type, richness_Q, maxWorkers }` — immutable site descriptor.
|
|
40
|
+
- `ExtractionState { depositId, assignedWorkers, cumulativeYield_cu }` — mutable accumulator stored externally.
|
|
41
|
+
- `BASE_YIELD_PER_WORKER: Record<ResourceType, number>` — silver 8, horses 5, iron 3, timber/stone 2 cu/worker/day at base.
|
|
42
|
+
- `TECH_EXTRACTION_MUL: Record<number, Q>` — numeric TechEra keys; Prehistoric q(0.40) → DeepSpace q(4.00).
|
|
43
|
+
- `computeDailyYield(deposit, state, techEra)` → cu/day: `workers × baseRate × techMul × richnessMul`; `richnessMul ∈ [q(0.50), q(1.00)]`; 0 when exhausted or no workers.
|
|
44
|
+
- `assignWorkers(deposit, state, workers)` — clamps to `[0, deposit.maxWorkers]`.
|
|
45
|
+
- `depleteDeposit(deposit, yield_cu)` — reduces `richness_Q` by `DEPLETION_RATE_PER_1000_CU = q(0.005)` per 1000 cu extracted.
|
|
46
|
+
- `stepExtraction(deposit, state, polity, elapsedDays)` → `ExtractionStepResult`: adds yield to `polity.treasury_cu`; depletes richness; returns `{ yield_cu, richness_Q, exhausted }`.
|
|
47
|
+
- `computeTotalDailyResourceIncome(deposits, states, techEra)` → cu/day total across all deposits.
|
|
48
|
+
- Secondary bonus sets: `MILITARY_BONUS_RESOURCES` (iron, horses), `CONSTRUCTION_BONUS_RESOURCES` (timber, stone), `MOBILITY_BONUS_RESOURCES` (horses) — advisory flags for Phase-61/89/93.
|
|
49
|
+
- `hasMilitaryBonus / hasConstructionBonus / hasMobilityBonus` helpers.
|
|
50
|
+
- `estimateDaysToExhaustion(deposit, state, techEra)` → ceiling days; Infinity with no workers; 0 when already exhausted.
|
|
51
|
+
- Added `./resources` subpath export to `package.json`.
|
|
52
|
+
- 49 new tests; 4,981 total. Coverage maintained above all thresholds.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
9
56
|
## [0.1.39] — 2026-03-26
|
|
10
57
|
|
|
11
58
|
### 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,149 @@
|
|
|
1
|
+
import type { Q } from "./units.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
/** Classification of natural resource. */
|
|
4
|
+
export type ResourceType = "iron" | "silver" | "timber" | "stone" | "horses";
|
|
5
|
+
/** Immutable descriptor for a natural resource deposit. */
|
|
6
|
+
export interface ResourceDeposit {
|
|
7
|
+
depositId: string;
|
|
8
|
+
polityId: string;
|
|
9
|
+
type: ResourceType;
|
|
10
|
+
/**
|
|
11
|
+
* Initial richness [0, SCALE.Q]. Declines with extraction via `depleteDeposit`.
|
|
12
|
+
* A richness of SCALE.Q represents an exceptionally rich find.
|
|
13
|
+
*/
|
|
14
|
+
richness_Q: Q;
|
|
15
|
+
/**
|
|
16
|
+
* Maximum workers that can be productively assigned to this deposit.
|
|
17
|
+
* Additional workers beyond this are wasted.
|
|
18
|
+
*/
|
|
19
|
+
maxWorkers: number;
|
|
20
|
+
}
|
|
21
|
+
/** Mutable extraction state — store one externally per deposit per polity. */
|
|
22
|
+
export interface ExtractionState {
|
|
23
|
+
depositId: string;
|
|
24
|
+
/** Current worker count (may not exceed `deposit.maxWorkers`). */
|
|
25
|
+
assignedWorkers: number;
|
|
26
|
+
/** Cumulative cost-units yielded since deposit was first worked. */
|
|
27
|
+
cumulativeYield_cu: number;
|
|
28
|
+
}
|
|
29
|
+
/** Output of `stepExtraction`. */
|
|
30
|
+
export interface ExtractionStepResult {
|
|
31
|
+
/** Cost-units added to polity treasury this step. */
|
|
32
|
+
yield_cu: number;
|
|
33
|
+
/** Current richness after any depletion [0, SCALE.Q]. */
|
|
34
|
+
richness_Q: Q;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the deposit is now exhausted (`richness_Q <= DEPLETION_EXHAUSTED_Q`).
|
|
37
|
+
*/
|
|
38
|
+
exhausted: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Base daily yield per worker [cost-units/worker/day] at full richness and
|
|
42
|
+
* base tech era (Ancient = 1, the reference point).
|
|
43
|
+
*/
|
|
44
|
+
export declare const BASE_YIELD_PER_WORKER: Record<ResourceType, number>;
|
|
45
|
+
/**
|
|
46
|
+
* Tech era extraction efficiency multiplier [Q].
|
|
47
|
+
* Better tools, techniques, and logistics improve yield per worker.
|
|
48
|
+
*/
|
|
49
|
+
export declare const TECH_EXTRACTION_MUL: Record<number, Q>;
|
|
50
|
+
/**
|
|
51
|
+
* Fraction of base yield that richness scales against [Q].
|
|
52
|
+
* At richness q(0.50), yield = base × (0.50 + 0.50×0.50) = base × 0.75
|
|
53
|
+
* — partial depletion still produces meaningful income.
|
|
54
|
+
*/
|
|
55
|
+
export declare const RICHNESS_FLOOR_Q: Q;
|
|
56
|
+
/**
|
|
57
|
+
* Richness reduction per 1000 cost-units of cumulative yield.
|
|
58
|
+
* Controls the depletion rate. Lower values mean longer-lived deposits.
|
|
59
|
+
*/
|
|
60
|
+
export declare const DEPLETION_RATE_PER_1000_CU: Q;
|
|
61
|
+
/**
|
|
62
|
+
* Richness threshold below which the deposit is considered exhausted [Q].
|
|
63
|
+
* Extraction becomes uneconomical below this level.
|
|
64
|
+
*/
|
|
65
|
+
export declare const DEPLETION_EXHAUSTED_Q: Q;
|
|
66
|
+
/**
|
|
67
|
+
* Maximum fraction of polity population that can be assigned as resource
|
|
68
|
+
* workers without impacting farming/tax base [Q].
|
|
69
|
+
* Hosts should warn if this is exceeded.
|
|
70
|
+
*/
|
|
71
|
+
export declare const WORKER_POP_FRACTION_Q: Q;
|
|
72
|
+
/**
|
|
73
|
+
* Resource types that provide a military equipment bonus when worked.
|
|
74
|
+
* Hosts apply this to Phase-61 `deriveMilitaryStrength` or Phase-93 strength.
|
|
75
|
+
*/
|
|
76
|
+
export declare const MILITARY_BONUS_RESOURCES: ReadonlySet<ResourceType>;
|
|
77
|
+
/**
|
|
78
|
+
* Resource types that provide a construction cost discount when worked.
|
|
79
|
+
* Hosts apply this to Phase-89 `investInProject` cost calculations.
|
|
80
|
+
*/
|
|
81
|
+
export declare const CONSTRUCTION_BONUS_RESOURCES: ReadonlySet<ResourceType>;
|
|
82
|
+
/**
|
|
83
|
+
* Resource types that improve march rate when worked.
|
|
84
|
+
* Hosts add a road-equivalent bonus to Phase-93 `stepCampaignMarch`.
|
|
85
|
+
*/
|
|
86
|
+
export declare const MOBILITY_BONUS_RESOURCES: ReadonlySet<ResourceType>;
|
|
87
|
+
/** Create a new `ResourceDeposit`. */
|
|
88
|
+
export declare function createDeposit(depositId: string, polityId: string, type: ResourceType, richness_Q?: Q, maxWorkers?: number): ResourceDeposit;
|
|
89
|
+
/** Create a fresh `ExtractionState` with no workers assigned. */
|
|
90
|
+
export declare function createExtractionState(depositId: string): ExtractionState;
|
|
91
|
+
/**
|
|
92
|
+
* Assign workers to a deposit.
|
|
93
|
+
* Clamps to `[0, deposit.maxWorkers]`.
|
|
94
|
+
* Returns the effective worker count after clamping.
|
|
95
|
+
*/
|
|
96
|
+
export declare function assignWorkers(deposit: ResourceDeposit, state: ExtractionState, workers: number): number;
|
|
97
|
+
/**
|
|
98
|
+
* Compute the daily extraction yield [cost-units/day].
|
|
99
|
+
*
|
|
100
|
+
* Formula:
|
|
101
|
+
* techMul = TECH_EXTRACTION_MUL[techEra] (default q(0.60))
|
|
102
|
+
* richnessScale = RICHNESS_FLOOR_Q + mulDiv(SCALE.Q - RICHNESS_FLOOR_Q, richness_Q, SCALE.Q)
|
|
103
|
+
* ∈ [q(0.50), q(1.00)]
|
|
104
|
+
* base = workers × BASE_YIELD_PER_WORKER[type]
|
|
105
|
+
* daily = max(0, round(base × techMul / SCALE.Q × richnessScale / SCALE.Q))
|
|
106
|
+
*
|
|
107
|
+
* Returns 0 if the deposit is exhausted or no workers assigned.
|
|
108
|
+
*/
|
|
109
|
+
export declare function computeDailyYield(deposit: ResourceDeposit, state: ExtractionState, techEra: number): number;
|
|
110
|
+
/**
|
|
111
|
+
* Reduce deposit richness based on cumulative yield extracted.
|
|
112
|
+
*
|
|
113
|
+
* `richnessDrain = round(yield_cu × DEPLETION_RATE_PER_1000_CU / 1000)`
|
|
114
|
+
*
|
|
115
|
+
* Mutates `deposit.richness_Q`.
|
|
116
|
+
*/
|
|
117
|
+
export declare function depleteDeposit(deposit: ResourceDeposit, yieldThisStep_cu: number): void;
|
|
118
|
+
/**
|
|
119
|
+
* Advance extraction for `elapsedDays` days.
|
|
120
|
+
*
|
|
121
|
+
* 1. Computes `computeDailyYield × elapsedDays`.
|
|
122
|
+
* 2. Adds yield to `polity.treasury_cu` and `state.cumulativeYield_cu`.
|
|
123
|
+
* 3. Depletes deposit richness proportional to yield.
|
|
124
|
+
*
|
|
125
|
+
* Mutates `polity.treasury_cu`, `state.cumulativeYield_cu`, and `deposit.richness_Q`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function stepExtraction(deposit: ResourceDeposit, state: ExtractionState, polity: Polity, elapsedDays: number): ExtractionStepResult;
|
|
128
|
+
/**
|
|
129
|
+
* Estimate daily bonus income from resource extraction across multiple deposits.
|
|
130
|
+
* Useful for treasury planning alongside Phase-92 tax revenue.
|
|
131
|
+
*/
|
|
132
|
+
export declare function computeTotalDailyResourceIncome(deposits: ResourceDeposit[], states: Map<string, ExtractionState>, techEra: number): number;
|
|
133
|
+
/**
|
|
134
|
+
* Return true if this resource type provides a military bonus.
|
|
135
|
+
*/
|
|
136
|
+
export declare function hasMilitaryBonus(type: ResourceType): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Return true if this resource type provides a construction bonus.
|
|
139
|
+
*/
|
|
140
|
+
export declare function hasConstructionBonus(type: ResourceType): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Return true if this resource type provides a mobility bonus.
|
|
143
|
+
*/
|
|
144
|
+
export declare function hasMobilityBonus(type: ResourceType): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Estimate how many days until the deposit is exhausted at the current
|
|
147
|
+
* extraction rate. Returns `Infinity` if no workers or already exhausted.
|
|
148
|
+
*/
|
|
149
|
+
export declare function estimateDaysToExhaustion(deposit: ResourceDeposit, state: ExtractionState, techEra: number): number;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// src/resources.ts — Phase 95: Natural Resources & Extraction
|
|
2
|
+
//
|
|
3
|
+
// Polity economies are not purely population-driven. Mines, forests, quarries,
|
|
4
|
+
// and pastures provide resource income that is largely independent of population
|
|
5
|
+
// size. This module tracks resource deposits, worker assignment, daily yield,
|
|
6
|
+
// and gradual depletion.
|
|
7
|
+
//
|
|
8
|
+
// Design:
|
|
9
|
+
// - Pure data layer — no Entity fields, no kernel changes.
|
|
10
|
+
// - `ResourceDeposit` is the immutable site descriptor; `ExtractionState` is
|
|
11
|
+
// the mutable accumulator stored externally by the host.
|
|
12
|
+
// - Yield scales with workers, tech era, and deposit richness.
|
|
13
|
+
// - Richness slowly declines with cumulative extraction (depletion model).
|
|
14
|
+
// - Resource income is expressed in treasury cost-units for uniform integration
|
|
15
|
+
// with Phase-92 taxation and Phase-89 infrastructure.
|
|
16
|
+
// - Secondary bonus flags (`militaryBonus`, `constructionBonus`, `mobilityBonus`)
|
|
17
|
+
// are advisory — the host applies them to Phase-61/89/93 calls.
|
|
18
|
+
//
|
|
19
|
+
// Integration:
|
|
20
|
+
// Phase 11 (Tech): techEra multiplier improves extraction efficiency.
|
|
21
|
+
// Phase 61 (Polity): treasury_cu receives daily yield; population caps workers.
|
|
22
|
+
// Phase 89 (Infra): timber/stone → construction discount advisory flag.
|
|
23
|
+
// Phase 93 (Campaign): horses → march-rate advisory flag.
|
|
24
|
+
// Phase 92 (Taxation): resource income is additive to tax revenue.
|
|
25
|
+
import { q, SCALE, clampQ, mulDiv } from "./units.js";
|
|
26
|
+
import { TechEra } from "./sim/tech.js";
|
|
27
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Base daily yield per worker [cost-units/worker/day] at full richness and
|
|
30
|
+
* base tech era (Ancient = 1, the reference point).
|
|
31
|
+
*/
|
|
32
|
+
export const BASE_YIELD_PER_WORKER = {
|
|
33
|
+
iron: 3, // ore smelting; military equipment
|
|
34
|
+
silver: 8, // coinage; highest raw value
|
|
35
|
+
timber: 2, // construction material
|
|
36
|
+
stone: 2, // construction material
|
|
37
|
+
horses: 5, // breeding; cavalry
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Tech era extraction efficiency multiplier [Q].
|
|
41
|
+
* Better tools, techniques, and logistics improve yield per worker.
|
|
42
|
+
*/
|
|
43
|
+
export const TECH_EXTRACTION_MUL = {
|
|
44
|
+
[TechEra.Prehistoric]: q(0.40),
|
|
45
|
+
[TechEra.Ancient]: q(0.60),
|
|
46
|
+
[TechEra.Medieval]: q(0.80),
|
|
47
|
+
[TechEra.EarlyModern]: q(1.00),
|
|
48
|
+
[TechEra.Industrial]: q(1.50),
|
|
49
|
+
[TechEra.Modern]: q(2.00),
|
|
50
|
+
[TechEra.NearFuture]: q(2.50),
|
|
51
|
+
[TechEra.FarFuture]: q(3.00),
|
|
52
|
+
[TechEra.DeepSpace]: q(4.00),
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Fraction of base yield that richness scales against [Q].
|
|
56
|
+
* At richness q(0.50), yield = base × (0.50 + 0.50×0.50) = base × 0.75
|
|
57
|
+
* — partial depletion still produces meaningful income.
|
|
58
|
+
*/
|
|
59
|
+
export const RICHNESS_FLOOR_Q = q(0.50);
|
|
60
|
+
/**
|
|
61
|
+
* Richness reduction per 1000 cost-units of cumulative yield.
|
|
62
|
+
* Controls the depletion rate. Lower values mean longer-lived deposits.
|
|
63
|
+
*/
|
|
64
|
+
export const DEPLETION_RATE_PER_1000_CU = q(0.005);
|
|
65
|
+
/**
|
|
66
|
+
* Richness threshold below which the deposit is considered exhausted [Q].
|
|
67
|
+
* Extraction becomes uneconomical below this level.
|
|
68
|
+
*/
|
|
69
|
+
export const DEPLETION_EXHAUSTED_Q = q(0.05);
|
|
70
|
+
/**
|
|
71
|
+
* Maximum fraction of polity population that can be assigned as resource
|
|
72
|
+
* workers without impacting farming/tax base [Q].
|
|
73
|
+
* Hosts should warn if this is exceeded.
|
|
74
|
+
*/
|
|
75
|
+
export const WORKER_POP_FRACTION_Q = q(0.10);
|
|
76
|
+
// ── Secondary bonus flags ─────────────────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Resource types that provide a military equipment bonus when worked.
|
|
79
|
+
* Hosts apply this to Phase-61 `deriveMilitaryStrength` or Phase-93 strength.
|
|
80
|
+
*/
|
|
81
|
+
export const MILITARY_BONUS_RESOURCES = new Set(["iron", "horses"]);
|
|
82
|
+
/**
|
|
83
|
+
* Resource types that provide a construction cost discount when worked.
|
|
84
|
+
* Hosts apply this to Phase-89 `investInProject` cost calculations.
|
|
85
|
+
*/
|
|
86
|
+
export const CONSTRUCTION_BONUS_RESOURCES = new Set(["timber", "stone"]);
|
|
87
|
+
/**
|
|
88
|
+
* Resource types that improve march rate when worked.
|
|
89
|
+
* Hosts add a road-equivalent bonus to Phase-93 `stepCampaignMarch`.
|
|
90
|
+
*/
|
|
91
|
+
export const MOBILITY_BONUS_RESOURCES = new Set(["horses"]);
|
|
92
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
93
|
+
/** Create a new `ResourceDeposit`. */
|
|
94
|
+
export function createDeposit(depositId, polityId, type, richness_Q = q(0.80), maxWorkers = 500) {
|
|
95
|
+
return { depositId, polityId, type, richness_Q: clampQ(richness_Q, 0, SCALE.Q), maxWorkers };
|
|
96
|
+
}
|
|
97
|
+
/** Create a fresh `ExtractionState` with no workers assigned. */
|
|
98
|
+
export function createExtractionState(depositId) {
|
|
99
|
+
return { depositId, assignedWorkers: 0, cumulativeYield_cu: 0 };
|
|
100
|
+
}
|
|
101
|
+
// ── Worker management ─────────────────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Assign workers to a deposit.
|
|
104
|
+
* Clamps to `[0, deposit.maxWorkers]`.
|
|
105
|
+
* Returns the effective worker count after clamping.
|
|
106
|
+
*/
|
|
107
|
+
export function assignWorkers(deposit, state, workers) {
|
|
108
|
+
state.assignedWorkers = Math.max(0, Math.min(workers, deposit.maxWorkers));
|
|
109
|
+
return state.assignedWorkers;
|
|
110
|
+
}
|
|
111
|
+
// ── Yield computation ─────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Compute the daily extraction yield [cost-units/day].
|
|
114
|
+
*
|
|
115
|
+
* Formula:
|
|
116
|
+
* techMul = TECH_EXTRACTION_MUL[techEra] (default q(0.60))
|
|
117
|
+
* richnessScale = RICHNESS_FLOOR_Q + mulDiv(SCALE.Q - RICHNESS_FLOOR_Q, richness_Q, SCALE.Q)
|
|
118
|
+
* ∈ [q(0.50), q(1.00)]
|
|
119
|
+
* base = workers × BASE_YIELD_PER_WORKER[type]
|
|
120
|
+
* daily = max(0, round(base × techMul / SCALE.Q × richnessScale / SCALE.Q))
|
|
121
|
+
*
|
|
122
|
+
* Returns 0 if the deposit is exhausted or no workers assigned.
|
|
123
|
+
*/
|
|
124
|
+
export function computeDailyYield(deposit, state, techEra) {
|
|
125
|
+
if (deposit.richness_Q <= DEPLETION_EXHAUSTED_Q)
|
|
126
|
+
return 0;
|
|
127
|
+
if (state.assignedWorkers <= 0)
|
|
128
|
+
return 0;
|
|
129
|
+
const techMul = (TECH_EXTRACTION_MUL[techEra] ?? q(0.60));
|
|
130
|
+
const richnessMul = RICHNESS_FLOOR_Q + mulDiv(SCALE.Q - RICHNESS_FLOOR_Q, deposit.richness_Q, SCALE.Q);
|
|
131
|
+
const basePerWorker = BASE_YIELD_PER_WORKER[deposit.type] ?? 0;
|
|
132
|
+
const base = state.assignedWorkers * basePerWorker;
|
|
133
|
+
const withTech = Math.round(base * techMul / SCALE.Q);
|
|
134
|
+
return Math.max(0, Math.round(withTech * richnessMul / SCALE.Q));
|
|
135
|
+
}
|
|
136
|
+
// ── Depletion ─────────────────────────────────────────────────────────────────
|
|
137
|
+
/**
|
|
138
|
+
* Reduce deposit richness based on cumulative yield extracted.
|
|
139
|
+
*
|
|
140
|
+
* `richnessDrain = round(yield_cu × DEPLETION_RATE_PER_1000_CU / 1000)`
|
|
141
|
+
*
|
|
142
|
+
* Mutates `deposit.richness_Q`.
|
|
143
|
+
*/
|
|
144
|
+
export function depleteDeposit(deposit, yieldThisStep_cu) {
|
|
145
|
+
if (yieldThisStep_cu <= 0)
|
|
146
|
+
return;
|
|
147
|
+
const drain = Math.round(yieldThisStep_cu * DEPLETION_RATE_PER_1000_CU / 1000);
|
|
148
|
+
deposit.richness_Q = clampQ(deposit.richness_Q - drain, 0, SCALE.Q);
|
|
149
|
+
}
|
|
150
|
+
// ── Extraction step ───────────────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Advance extraction for `elapsedDays` days.
|
|
153
|
+
*
|
|
154
|
+
* 1. Computes `computeDailyYield × elapsedDays`.
|
|
155
|
+
* 2. Adds yield to `polity.treasury_cu` and `state.cumulativeYield_cu`.
|
|
156
|
+
* 3. Depletes deposit richness proportional to yield.
|
|
157
|
+
*
|
|
158
|
+
* Mutates `polity.treasury_cu`, `state.cumulativeYield_cu`, and `deposit.richness_Q`.
|
|
159
|
+
*/
|
|
160
|
+
export function stepExtraction(deposit, state, polity, elapsedDays) {
|
|
161
|
+
const daily = computeDailyYield(deposit, state, polity.techEra);
|
|
162
|
+
const yield_cu = daily * elapsedDays;
|
|
163
|
+
polity.treasury_cu += yield_cu;
|
|
164
|
+
state.cumulativeYield_cu += yield_cu;
|
|
165
|
+
depleteDeposit(deposit, yield_cu);
|
|
166
|
+
const exhausted = deposit.richness_Q <= DEPLETION_EXHAUSTED_Q;
|
|
167
|
+
return { yield_cu, richness_Q: deposit.richness_Q, exhausted };
|
|
168
|
+
}
|
|
169
|
+
// ── Reporting ─────────────────────────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Estimate daily bonus income from resource extraction across multiple deposits.
|
|
172
|
+
* Useful for treasury planning alongside Phase-92 tax revenue.
|
|
173
|
+
*/
|
|
174
|
+
export function computeTotalDailyResourceIncome(deposits, states, techEra) {
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (const deposit of deposits) {
|
|
177
|
+
const state = states.get(deposit.depositId);
|
|
178
|
+
if (!state)
|
|
179
|
+
continue;
|
|
180
|
+
total += computeDailyYield(deposit, state, techEra);
|
|
181
|
+
}
|
|
182
|
+
return total;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Return true if this resource type provides a military bonus.
|
|
186
|
+
*/
|
|
187
|
+
export function hasMilitaryBonus(type) {
|
|
188
|
+
return MILITARY_BONUS_RESOURCES.has(type);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Return true if this resource type provides a construction bonus.
|
|
192
|
+
*/
|
|
193
|
+
export function hasConstructionBonus(type) {
|
|
194
|
+
return CONSTRUCTION_BONUS_RESOURCES.has(type);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Return true if this resource type provides a mobility bonus.
|
|
198
|
+
*/
|
|
199
|
+
export function hasMobilityBonus(type) {
|
|
200
|
+
return MOBILITY_BONUS_RESOURCES.has(type);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Estimate how many days until the deposit is exhausted at the current
|
|
204
|
+
* extraction rate. Returns `Infinity` if no workers or already exhausted.
|
|
205
|
+
*/
|
|
206
|
+
export function estimateDaysToExhaustion(deposit, state, techEra) {
|
|
207
|
+
if (deposit.richness_Q <= DEPLETION_EXHAUSTED_Q)
|
|
208
|
+
return 0;
|
|
209
|
+
const daily = computeDailyYield(deposit, state, techEra);
|
|
210
|
+
if (daily <= 0)
|
|
211
|
+
return Infinity;
|
|
212
|
+
// richness that needs to be drained = richness_Q - DEPLETION_EXHAUSTED_Q
|
|
213
|
+
// drain per day = daily × DEPLETION_RATE_PER_1000_CU / 1000
|
|
214
|
+
const drainPerDay = daily * DEPLETION_RATE_PER_1000_CU / 1000;
|
|
215
|
+
if (drainPerDay <= 0)
|
|
216
|
+
return Infinity;
|
|
217
|
+
const remainingRichness = deposit.richness_Q - DEPLETION_EXHAUSTED_Q;
|
|
218
|
+
return Math.ceil(remainingRichness / drainPerDay);
|
|
219
|
+
}
|
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.41",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -142,6 +142,14 @@
|
|
|
142
142
|
"./governance": {
|
|
143
143
|
"import": "./dist/src/governance.js",
|
|
144
144
|
"types": "./dist/src/governance.d.ts"
|
|
145
|
+
},
|
|
146
|
+
"./resources": {
|
|
147
|
+
"import": "./dist/src/resources.js",
|
|
148
|
+
"types": "./dist/src/resources.d.ts"
|
|
149
|
+
},
|
|
150
|
+
"./climate": {
|
|
151
|
+
"import": "./dist/src/climate.js",
|
|
152
|
+
"types": "./dist/src/climate.d.ts"
|
|
145
153
|
}
|
|
146
154
|
},
|
|
147
155
|
"files": [
|