@its-not-rocket-science/ananke 0.1.31 → 0.1.32

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