@its-not-rocket-science/ananke 0.1.17 → 0.1.23

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,137 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.23] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 78 · Seasonal Calendar & Agricultural Cycle** (`src/calendar.ts`)
14
+ - `CalendarState { year, dayOfYear }` — immutable; advanced via `stepCalendar(state, days)`.
15
+ - `computeSeason(dayOfYear)` → `"winter" | "spring" | "summer" | "autumn"` (91-day quarters).
16
+ - `computeHarvestPhase(dayOfYear)` → `"dormant" | "planting" | "growing" | "harvest"`.
17
+ - `isInHarvestWindow(dayOfYear)` — true for days 274–365 (Autumn).
18
+ - `SeasonalModifiers { thermalOffset, precipitationMul_Q, diseaseMul_Q, mobilityMul_Q, harvestYield_Q }`.
19
+ - `SEASONAL_MODIFIERS` table: winter (−10 °C, zero harvest, x1.20 disease, x0.70 mobility), spring (rain, x1.30 precip, planting), summer (+5 °C, optimal mobility), autumn (peak harvest q(1.0), x1.10 disease).
20
+ - `applySeasonalHarvest(polity, modifiers, baseDailyIncome)` → cost-unit gain for the day.
21
+ - `deriveSeasonalWeatherBias(season, intensity?)` → `Partial<WeatherState>` — advisory weather for Phase-18 hosts.
22
+ - `applySeasonalDiseaseMul(baseRate_Q, modifiers)` → scaled transmission rate for Phase-56/73 integration.
23
+ - Added `./calendar` subpath export to `package.json`.
24
+ - 47 new tests; 4,189 total. Coverage maintained above all thresholds.
25
+
26
+ ---
27
+
28
+ ## [0.1.22] — 2026-03-26
29
+
30
+ ### Added
31
+
32
+ - **Phase 77 · Dynasty & Succession** (`src/succession.ts`)
33
+ - `SuccessionRuleType`: `"primogeniture" | "renown_based" | "election"`.
34
+ - `SuccessionCandidate { entityId, kinshipDegree, renown_Q, inheritedRenown_Q, claimStrength_Q }`.
35
+ - `SuccessionResult { heirId, candidates, rule, stabilityImpact_Q }` — signed Q stability delta.
36
+ - `findSuccessionCandidates(lineage, deceasedId, renownRegistry, maxDegree?)` — BFS over family graph (Phase 76), computes `renown_Q` and `inheritedRenown_Q` per candidate.
37
+ - `resolveSuccession(lineage, deceasedId, renownRegistry, rule, worldSeed, tick)` → `SuccessionResult`:
38
+ - **primogeniture**: first-born child (lowest entityId) gets SCALE.Q claim; others by distance.
39
+ - **renown_based**: claim = 70% own renown + 30% inherited renown.
40
+ - **election**: renown-weighted deterministic lottery via `eventSeed`.
41
+ - Stability: `+STABILITY_CLEAN_SUCCESSION_Q` for uncontested direct heir; `−STABILITY_DISTANT_HEIR_Q` per extra degree; `−STABILITY_CONTESTED_Q` when top-two gap < q(0.10); `−STABILITY_NO_HEIR_Q` if no candidates.
42
+ - `applySuccessionToPolity(polity, result)` — applies `stabilityImpact_Q` to `polity.stabilityQ` (clamped).
43
+ - Added `./succession` subpath export to `package.json`.
44
+ - 21 new tests; 4,142 total. Coverage maintained above all thresholds.
45
+
46
+ ---
47
+
48
+ ## [0.1.21] — 2026-03-26
49
+
50
+ ### Added
51
+
52
+ - **Phase 76 · Kinship & Lineage** (`src/kinship.ts`)
53
+ - `LineageNode { entityId, parentIds, childIds, partnerIds }` — family links per entity.
54
+ - `LineageRegistry { nodes: Map<number, LineageNode> }` — flat registry, no Entity field changes.
55
+ - `createLineageRegistry()` / `getLineageNode(registry, entityId)` — factory and lazy-init accessor.
56
+ - `recordBirth(registry, childId, parentAId, parentBId?)` — links child to 1–2 parents; idempotent.
57
+ - `recordPartnership(registry, entityAId, entityBId)` — mutual partner link; idempotent.
58
+ - `getParents / getChildren / getSiblings` — direct family queries; siblings deduplicated.
59
+ - `findAncestors(registry, entityId, maxDepth?)` — BFS upward through parent links (default depth 4).
60
+ - `computeKinshipDegree(registry, entityA, entityB)` — BFS on undirected family graph (parents + children + partners); returns 0–4 or `null` beyond `MAX_KINSHIP_DEPTH = 4`.
61
+ - `isKin(registry, entityA, entityB, maxDegree?)` — convenience boolean.
62
+ - `getKinshipLabel(degree)` → `"self" | "immediate" | "close" | "extended" | "distant" | "unrelated"`.
63
+ - `computeInheritedRenown(lineage, entityId, renownRegistry, maxDepth?)` — sums ancestor `renown_Q` with geometric decay (`RENOWN_DEPTH_DECAY_Q = q(0.50)` per generation); clamped to SCALE.Q.
64
+ - Added `./kinship` subpath export to `package.json`.
65
+ - 42 new tests; 4,121 total. Coverage maintained above all thresholds.
66
+
67
+ ---
68
+
69
+ ## [0.1.20] — 2026-03-26
70
+
71
+ ### Added
72
+
73
+ - **Phase 75 · Entity Renown & Legend Registry** (`src/renown.ts`)
74
+ - `RenownRecord { entityId, renown_Q, infamy_Q, entries: LegendEntry[] }` — per-entity reputation on two orthogonal axes.
75
+ - `LegendEntry { entryId, tick, eventType, significance }` — lightweight reference to a significant `ChronicleEntry`.
76
+ - `RenownRegistry { records: Map<number, RenownRecord> }` — flat registry, one record per entity.
77
+ - `createRenownRegistry()` / `getRenownRecord(registry, entityId)` — factory and lazy-init accessor.
78
+ - `updateRenownFromChronicle(registry, chronicle, entityId, minSignificance?)` — idempotent scan; renown events (legendary_deed, quest_completed, combat_victory, masterwork_crafted, rank_promotion, settlement_founded, first_contact) add to `renown_Q`; infamy events (relationship_betrayal, settlement_raided, settlement_destroyed, quest_failed) add to `infamy_Q`; both capped at SCALE.Q.
79
+ - `getRenownLabel(renown_Q)` → `"unknown" | "noted" | "known" | "renowned" | "legendary" | "mythic"` (6 tiers at q(0.10) boundaries).
80
+ - `getInfamyLabel(infamy_Q)` → `"innocent" | "suspect" | "notorious" | "infamous" | "reviled" | "condemned"`.
81
+ - `deriveFactionStandingAdjustment(renown_Q, infamy_Q, allianceBias)` — signed Q adjustment; heroic factions (bias=1.0) reward renown and punish infamy; criminal factions (bias=0.0) the reverse; clamped to [-SCALE.Q, SCALE.Q].
82
+ - `getTopLegendEntries(record, n)` — top N entries by significance (tick-descending tie-break).
83
+ - `renderLegendWithTone(record, entryMap, ctx, maxEntries?)` — renders top entries as prose via Phase 74's `renderEntryWithTone`.
84
+ - Added `./narrative-prose` and `./renown` subpath exports to `package.json`.
85
+ - 42 new tests; 4,079 total. Coverage maintained above all thresholds.
86
+
87
+ ---
88
+
89
+ ## [0.1.19] — 2026-03-26
90
+
91
+ ### Added
92
+
93
+ - **Phase 74 · Simulation Trace → Narrative Prose** (`src/narrative-prose.ts`)
94
+ - 6 prose tones: `neutral | heroic | tragic | martial | spiritual | mercantile`
95
+ - Tone-varied templates for all 19 `ChronicleEventType` values.
96
+ - `deriveNarrativeTone(culture)` — maps dominant `CultureProfile` value → `ProseTone`
97
+ via `VALUE_TONE_MAP` (martial_virtue→martial, spiritual_devotion→spiritual,
98
+ commerce→mercantile, honour→heroic, fatalism→tragic; others fall back to neutral).
99
+ - `mythArchetypeFrame(archetype)` — returns a culturally-flavoured closing phrase for
100
+ each `MythArchetype` (hero, monster, trickster, great_plague, divine_wrath, golden_age).
101
+ - `createNarrativeContext(entityNames, culture?, myth?)` — bundles tone + name map + myth frame.
102
+ - `renderEntryWithTone(entry, ctx)` — picks the tone variant for each event, substitutes
103
+ `{name}`, `{target}`, computed helper strings (`{cause_str}`, `{location_str}`, etc.),
104
+ raw `entry.variables`, and appends the myth frame (replacing terminal period).
105
+ - `renderChronicleWithTone(chronicle, ctx, minSignificance?)` — filters by significance,
106
+ sorts chronologically, maps via `renderEntryWithTone`.
107
+ - **Success criterion met:** martial, spiritual, and mercantile tones produce clearly
108
+ distinguishable prose from the same chronicle events.
109
+ - 39 new tests; 4,037 total. Coverage: statements 96.81%, branches 86.87%, functions 94.80%.
110
+
111
+ ---
112
+
113
+ ## [0.1.18] — 2026-03-26
114
+
115
+ ### Added
116
+
117
+ - **CE-18 · External Agent Interface** (`tools/agent-server.ts`)
118
+ - WebSocket server (default port 3001) implementing an agent observation/action loop
119
+ over the existing `stepWorld` kernel — no src/ changes, no new npm exports.
120
+ - **Protocol:**
121
+ - Client → `{ type: "step", commands?: AgentCommand[] }` or `{ type: "reset" }`
122
+ - Server → `{ type: "obs", tick, entities: ObservationSlice[], done, winner? }`
123
+ - On connect → `{ type: "init", config, obs }`
124
+ - **`ObservationSlice`** — safe subset: position, velocity, fatigue, shock/consciousness/dead,
125
+ detected nearby enemies (filtered via Phase 52 `canDetect`). No raw internals exposed.
126
+ - **`AgentCommand`** — validated high-level actions: `attack | move | dodge | flee | idle`.
127
+ Invalid team targeting silently dropped; `decideCommandsForEntity` fills in missing commands.
128
+ - Configurable scenario: `TEAM1_SIZE` / `TEAM2_SIZE` (1–4 each), `SEED`, `MAX_TICKS` via env vars.
129
+ Default: 1v1, Knight (longsword + mail) vs Brawler (club).
130
+ - Agent-driven stepping: server advances only when client sends `step` — agent controls tick rate.
131
+ - Determinism preserved: external commands injected via existing `CommandMap` before `stepWorld`.
132
+ - HTTP endpoints: `GET /config`, `GET /status`, `POST /reset`.
133
+ - Run: `npm run agent-server`
134
+ - **Success criterion met:** An external Python script using only `websockets` can drive a single
135
+ entity through a 1v1 fight, receiving `ObservationSlice` observations each tick and submitting
136
+ `attack` / `move` commands, without importing any Ananke TypeScript.
137
+
138
+ ---
139
+
9
140
  ## [0.1.17] — 2026-03-26
10
141
 
11
142
  ### Added
@@ -0,0 +1,118 @@
1
+ import type { Polity } from "./polity.js";
2
+ import type { WeatherState } from "./sim/weather.js";
3
+ import type { Q } from "./units.js";
4
+ export declare const DAYS_PER_YEAR = 365;
5
+ export declare const SPRING_START_DAY = 92;
6
+ export declare const SUMMER_START_DAY = 183;
7
+ export declare const AUTUMN_START_DAY = 274;
8
+ export declare const HARVEST_PLANTING_START = 92;
9
+ export declare const HARVEST_PLANTING_END = 136;
10
+ export declare const HARVEST_GROWING_START = 137;
11
+ export declare const HARVEST_GROWING_END = 273;
12
+ export declare const HARVEST_WINDOW_START = 274;
13
+ export declare const HARVEST_WINDOW_END = 365;
14
+ /**
15
+ * Approximate Q units per °C in Phase-29 thermal encoding.
16
+ * Matches `WEATHER_Q_PER_DEG_C` in `src/sim/weather.ts`.
17
+ */
18
+ export declare const CALENDAR_Q_PER_DEG_C = 185;
19
+ /** Macro-scale season driven by `computeSeason(dayOfYear)`. */
20
+ export type Season = "winter" | "spring" | "summer" | "autumn";
21
+ /** Agricultural phase for the current day. */
22
+ export type HarvestPhase = "dormant" | "planting" | "growing" | "harvest";
23
+ /**
24
+ * Persistent calendar state. Advances via `stepCalendar(state, days)`.
25
+ * Year and dayOfYear are both 1-based.
26
+ */
27
+ export interface CalendarState {
28
+ /** Simulated year number (starts at 1). */
29
+ year: number;
30
+ /** Day within the current year, 1–365. */
31
+ dayOfYear: number;
32
+ }
33
+ /**
34
+ * Seasonal multipliers and offsets for subsystem integration.
35
+ * All Q values follow the SCALE.Q convention (q(1.0) = no change).
36
+ */
37
+ export interface SeasonalModifiers {
38
+ /**
39
+ * Additive thermal offset in Phase-29 Q units (1 °C ≈ CALENDAR_Q_PER_DEG_C).
40
+ * Negative = colder than baseline.
41
+ */
42
+ thermalOffset: number;
43
+ /**
44
+ * Multiplier on precipitation intensity and frequency [0, SCALE.Q].
45
+ * q(1.0) = average; q(1.30) = wet spring; q(0.70) = dry winter.
46
+ */
47
+ precipitationMul_Q: Q;
48
+ /**
49
+ * Multiplier on airborne disease transmission rate [0, SCALE.Q].
50
+ * q(1.0) = no change; q(1.20) = winter crowding boost.
51
+ */
52
+ diseaseMul_Q: Q;
53
+ /**
54
+ * Multiplier on overland travel speed [0, SCALE.Q].
55
+ * q(1.0) = normal; q(0.70) = winter snow or spring mud.
56
+ */
57
+ mobilityMul_Q: Q;
58
+ /**
59
+ * Harvest yield fraction for this season [0, SCALE.Q].
60
+ * q(0) = off-season (no harvest); q(1.0) = peak autumn harvest.
61
+ */
62
+ harvestYield_Q: Q;
63
+ }
64
+ export declare const SEASONAL_MODIFIERS: Record<Season, SeasonalModifiers>;
65
+ /**
66
+ * Create a new `CalendarState` at the given year and day.
67
+ * Defaults to year 1, day 1 (first day of winter).
68
+ */
69
+ export declare function createCalendar(startYear?: number, startDay?: number): CalendarState;
70
+ /**
71
+ * Advance the calendar by `days` days (must be ≥ 0).
72
+ * Returns a new `CalendarState`; does NOT mutate the input.
73
+ */
74
+ export declare function stepCalendar(state: CalendarState, days: number): CalendarState;
75
+ /** Derive the current `Season` from `dayOfYear` (1–365). */
76
+ export declare function computeSeason(dayOfYear: number): Season;
77
+ /** Derive the current `HarvestPhase` from `dayOfYear`. */
78
+ export declare function computeHarvestPhase(dayOfYear: number): HarvestPhase;
79
+ /** Return `true` if the day falls within the autumn harvest window. */
80
+ export declare function isInHarvestWindow(dayOfYear: number): boolean;
81
+ /**
82
+ * Return the `SeasonalModifiers` for the given `dayOfYear`.
83
+ * Convenience wrapper over `SEASONAL_MODIFIERS[computeSeason(day)]`.
84
+ */
85
+ export declare function getSeasonalModifiers(dayOfYear: number): SeasonalModifiers;
86
+ /**
87
+ * Compute the treasury income for one simulated day, scaled by the seasonal
88
+ * harvest yield.
89
+ *
90
+ * @param polity Current polity (provides treasury_cu as economic base).
91
+ * @param modifiers Seasonal modifiers for this day.
92
+ * @param baseDailyIncome Base income in cost-units per day (host-defined).
93
+ * @returns Integer cost-unit gain for this day (≥ 0).
94
+ */
95
+ export declare function applySeasonalHarvest(polity: Polity, modifiers: SeasonalModifiers, baseDailyIncome: number): number;
96
+ /**
97
+ * Derive a suggested `WeatherState` biased toward the current season.
98
+ *
99
+ * The result is advisory — hosts can override or blend with their own weather
100
+ * system. Precipitation type:
101
+ * - winter + heavy → "blizzard"; winter + light → "snow"
102
+ * - spring/summer → "rain"; autumn → "rain" or dry depending on yield
103
+ *
104
+ * @param season Current season.
105
+ * @param intensity 0–1 float: how extreme the seasonal weather should be.
106
+ * 0 = clear; 1 = full seasonal character.
107
+ */
108
+ export declare function deriveSeasonalWeatherBias(season: Season, intensity?: number): Partial<WeatherState>;
109
+ /**
110
+ * Compute the effective disease transmission rate multiplier for a given
111
+ * base rate, applying the seasonal modifier.
112
+ *
113
+ * Result is clamped to [0, SCALE.Q × 2] (allows doubling but prevents runaway).
114
+ *
115
+ * @param baseRate_Q Disease baseTransmissionRate_Q from DiseaseProfile.
116
+ * @param modifiers Seasonal modifiers.
117
+ */
118
+ export declare function applySeasonalDiseaseMul(baseRate_Q: Q, modifiers: SeasonalModifiers): Q;
@@ -0,0 +1,182 @@
1
+ // src/calendar.ts — Phase 78: Seasonal Calendar & Agricultural Cycle
2
+ //
3
+ // A campaign-scale time layer. One `CalendarState` advances by simulated days
4
+ // and drives seasonal modifiers for weather, disease, mobility, and harvest income.
5
+ //
6
+ // Design:
7
+ // - Pure computation — no Entity fields, no kernel changes.
8
+ // - Immutable step: `stepCalendar` returns a new `CalendarState`.
9
+ // - Year divided into 4 equal seasons of 91–92 days (Northern-hemisphere convention).
10
+ // - `SeasonalModifiers` are the canonical interface between the calendar and
11
+ // subsystem-specific application helpers.
12
+ // - `applySeasonalHarvest` integrates directly with Phase 61 `Polity`.
13
+ // - `deriveSeasonalWeatherBias` produces a suggested `WeatherState` for the host.
14
+ import { q, SCALE, clampQ } from "./units.js";
15
+ // ── Constants ─────────────────────────────────────────────────────────────────
16
+ export const DAYS_PER_YEAR = 365;
17
+ // Season day boundaries (1-based, Northern hemisphere)
18
+ // Winter: 1–91 (Dec 21 – Mar 20)
19
+ // Spring: 92–182 (Mar 21 – Jun 20)
20
+ // Summer: 183–273 (Jun 21 – Sep 21)
21
+ // Autumn: 274–365 (Sep 22 – Dec 20)
22
+ export const SPRING_START_DAY = 92;
23
+ export const SUMMER_START_DAY = 183;
24
+ export const AUTUMN_START_DAY = 274;
25
+ // Harvest window within Autumn: full harvest days 274–365
26
+ export const HARVEST_PLANTING_START = 92; // spring planting begins
27
+ export const HARVEST_PLANTING_END = 136; // planting ends mid-spring
28
+ export const HARVEST_GROWING_START = 137; // growing begins
29
+ export const HARVEST_GROWING_END = 273; // growing ends (end of summer)
30
+ export const HARVEST_WINDOW_START = 274; // harvest opens
31
+ export const HARVEST_WINDOW_END = 365; // harvest closes
32
+ /**
33
+ * Approximate Q units per °C in Phase-29 thermal encoding.
34
+ * Matches `WEATHER_Q_PER_DEG_C` in `src/sim/weather.ts`.
35
+ */
36
+ export const CALENDAR_Q_PER_DEG_C = 185;
37
+ // ── Season modifiers table ────────────────────────────────────────────────────
38
+ export const SEASONAL_MODIFIERS = {
39
+ winter: {
40
+ thermalOffset: -CALENDAR_Q_PER_DEG_C * 10, // ~−10 °C
41
+ precipitationMul_Q: q(0.70), // drier (frozen precip, less liquid)
42
+ diseaseMul_Q: q(1.20), // crowding and cold stress boosts disease
43
+ mobilityMul_Q: q(0.70), // snow and ice hinder travel
44
+ harvestYield_Q: q(0.00), // dormant; no harvest
45
+ },
46
+ spring: {
47
+ thermalOffset: 0, // baseline temperature
48
+ precipitationMul_Q: q(1.30), // spring rains
49
+ diseaseMul_Q: q(0.80), // mild weather reduces transmission
50
+ mobilityMul_Q: q(0.80), // mud season slows travel
51
+ harvestYield_Q: q(0.10), // minor early crops (spring vegetables)
52
+ },
53
+ summer: {
54
+ thermalOffset: CALENDAR_Q_PER_DEG_C * 5, // ~+5 °C
55
+ precipitationMul_Q: q(1.00), // average precipitation
56
+ diseaseMul_Q: q(0.90), // better hygiene conditions
57
+ mobilityMul_Q: q(1.00), // optimal travel conditions
58
+ harvestYield_Q: q(0.30), // some summer crops (hay, early grain)
59
+ },
60
+ autumn: {
61
+ thermalOffset: -CALENDAR_Q_PER_DEG_C * 3, // ~−3 °C
62
+ precipitationMul_Q: q(1.00), // average
63
+ diseaseMul_Q: q(1.10), // early cold season uptick
64
+ mobilityMul_Q: q(0.90), // cooling, shorter days
65
+ harvestYield_Q: q(1.00), // peak harvest
66
+ },
67
+ };
68
+ // ── Factory ───────────────────────────────────────────────────────────────────
69
+ /**
70
+ * Create a new `CalendarState` at the given year and day.
71
+ * Defaults to year 1, day 1 (first day of winter).
72
+ */
73
+ export function createCalendar(startYear = 1, startDay = 1) {
74
+ return {
75
+ year: Math.max(1, startYear),
76
+ dayOfYear: Math.max(1, Math.min(DAYS_PER_YEAR, startDay)),
77
+ };
78
+ }
79
+ // ── Step ──────────────────────────────────────────────────────────────────────
80
+ /**
81
+ * Advance the calendar by `days` days (must be ≥ 0).
82
+ * Returns a new `CalendarState`; does NOT mutate the input.
83
+ */
84
+ export function stepCalendar(state, days) {
85
+ if (days <= 0)
86
+ return { ...state };
87
+ const total = state.dayOfYear - 1 + days;
88
+ const yearDelta = Math.trunc(total / DAYS_PER_YEAR);
89
+ const dayOfYear = (total % DAYS_PER_YEAR) + 1;
90
+ return {
91
+ year: state.year + yearDelta,
92
+ dayOfYear,
93
+ };
94
+ }
95
+ // ── Derived state ─────────────────────────────────────────────────────────────
96
+ /** Derive the current `Season` from `dayOfYear` (1–365). */
97
+ export function computeSeason(dayOfYear) {
98
+ if (dayOfYear >= AUTUMN_START_DAY)
99
+ return "autumn";
100
+ if (dayOfYear >= SUMMER_START_DAY)
101
+ return "summer";
102
+ if (dayOfYear >= SPRING_START_DAY)
103
+ return "spring";
104
+ return "winter";
105
+ }
106
+ /** Derive the current `HarvestPhase` from `dayOfYear`. */
107
+ export function computeHarvestPhase(dayOfYear) {
108
+ if (dayOfYear >= HARVEST_WINDOW_START)
109
+ return "harvest";
110
+ if (dayOfYear >= HARVEST_GROWING_START)
111
+ return "growing";
112
+ if (dayOfYear >= HARVEST_PLANTING_START)
113
+ return "planting";
114
+ return "dormant";
115
+ }
116
+ /** Return `true` if the day falls within the autumn harvest window. */
117
+ export function isInHarvestWindow(dayOfYear) {
118
+ return dayOfYear >= HARVEST_WINDOW_START && dayOfYear <= HARVEST_WINDOW_END;
119
+ }
120
+ /**
121
+ * Return the `SeasonalModifiers` for the given `dayOfYear`.
122
+ * Convenience wrapper over `SEASONAL_MODIFIERS[computeSeason(day)]`.
123
+ */
124
+ export function getSeasonalModifiers(dayOfYear) {
125
+ return SEASONAL_MODIFIERS[computeSeason(dayOfYear)];
126
+ }
127
+ // ── Subsystem integration ─────────────────────────────────────────────────────
128
+ /**
129
+ * Compute the treasury income for one simulated day, scaled by the seasonal
130
+ * harvest yield.
131
+ *
132
+ * @param polity Current polity (provides treasury_cu as economic base).
133
+ * @param modifiers Seasonal modifiers for this day.
134
+ * @param baseDailyIncome Base income in cost-units per day (host-defined).
135
+ * @returns Integer cost-unit gain for this day (≥ 0).
136
+ */
137
+ export function applySeasonalHarvest(polity, modifiers, baseDailyIncome) {
138
+ if (baseDailyIncome <= 0)
139
+ return 0;
140
+ const raw = Math.round(baseDailyIncome * modifiers.harvestYield_Q / SCALE.Q);
141
+ return Math.max(0, raw);
142
+ }
143
+ /**
144
+ * Derive a suggested `WeatherState` biased toward the current season.
145
+ *
146
+ * The result is advisory — hosts can override or blend with their own weather
147
+ * system. Precipitation type:
148
+ * - winter + heavy → "blizzard"; winter + light → "snow"
149
+ * - spring/summer → "rain"; autumn → "rain" or dry depending on yield
150
+ *
151
+ * @param season Current season.
152
+ * @param intensity 0–1 float: how extreme the seasonal weather should be.
153
+ * 0 = clear; 1 = full seasonal character.
154
+ */
155
+ export function deriveSeasonalWeatherBias(season, intensity = 0.5) {
156
+ if (intensity <= 0)
157
+ return {};
158
+ switch (season) {
159
+ case "winter":
160
+ return {
161
+ precipitation: intensity >= 0.7 ? "blizzard" : "snow",
162
+ };
163
+ case "spring":
164
+ return intensity >= 0.5 ? { precipitation: "rain" } : {};
165
+ case "summer":
166
+ return {}; // dry summer default
167
+ case "autumn":
168
+ return intensity >= 0.6 ? { precipitation: "rain" } : {};
169
+ }
170
+ }
171
+ /**
172
+ * Compute the effective disease transmission rate multiplier for a given
173
+ * base rate, applying the seasonal modifier.
174
+ *
175
+ * Result is clamped to [0, SCALE.Q × 2] (allows doubling but prevents runaway).
176
+ *
177
+ * @param baseRate_Q Disease baseTransmissionRate_Q from DiseaseProfile.
178
+ * @param modifiers Seasonal modifiers.
179
+ */
180
+ export function applySeasonalDiseaseMul(baseRate_Q, modifiers) {
181
+ return clampQ(Math.round(baseRate_Q * modifiers.diseaseMul_Q / SCALE.Q), 0, SCALE.Q * 2);
182
+ }
@@ -0,0 +1,99 @@
1
+ import type { Polity } from "./polity.js";
2
+ import type { RenownRegistry } from "./renown.js";
3
+ import type { Q } from "./units.js";
4
+ /** How the vassal bond was established — affects base strength and decay rate. */
5
+ export type LoyaltyType = "kin_bound" | "oath_sworn" | "conquered" | "voluntary";
6
+ /**
7
+ * A directional bond from a vassal polity to a liege polity.
8
+ * Stored once per directed pair (vassal → liege).
9
+ */
10
+ export interface VassalBond {
11
+ vassalPolityId: string;
12
+ liegePolityId: string;
13
+ loyaltyType: LoyaltyType;
14
+ /** Fraction of vassal `treasury_cu` paid as annual tribute [0, SCALE.Q]. */
15
+ tributeRate_Q: Q;
16
+ /** Fraction of vassal `militaryStrength_Q` available to liege as levy [0, SCALE.Q]. */
17
+ levyRate_Q: Q;
18
+ /**
19
+ * Bond strength [0, SCALE.Q].
20
+ * Below `REBELLION_THRESHOLD` the vassal is at risk of revolt.
21
+ */
22
+ strength_Q: Q;
23
+ /** Tick when the bond was created (for age-based decay calculations). */
24
+ establishedTick: number;
25
+ }
26
+ /** Registry of all vassal bonds, keyed by `"vassalId:liegeId"`. */
27
+ export interface FeudalRegistry {
28
+ bonds: Map<string, VassalBond>;
29
+ }
30
+ /** Bond strength below this → `isRebellionRisk` returns true. */
31
+ export declare const REBELLION_THRESHOLD: Q;
32
+ /** Daily strength decay for each loyalty type (per simulated day). */
33
+ export declare const LOYALTY_DECAY_PER_DAY: Record<LoyaltyType, Q>;
34
+ /** Base strength at bond creation per loyalty type. */
35
+ export declare const LOYALTY_BASE_STRENGTH: Record<LoyaltyType, Q>;
36
+ /**
37
+ * Infamy added to the vassal's renown record when breaking an `oath_sworn` bond.
38
+ * `kin_bound` and `conquered` breaks carry no oath infamy.
39
+ */
40
+ export declare const OATH_BREAK_INFAMY_Q: Q;
41
+ /** Tribute paid daily = `TRIBUTE_DAILY_FRAC` × annual rate × treasury_cu */
42
+ export declare const TRIBUTE_DAYS_PER_YEAR = 365;
43
+ export declare function createFeudalRegistry(): FeudalRegistry;
44
+ /**
45
+ * Create a vassal bond and register it.
46
+ * If a bond between this pair already exists it is overwritten.
47
+ *
48
+ * @param tributeRate_Q Annual tribute as fraction of vassal treasury (default q(0.10)).
49
+ * @param levyRate_Q Fraction of military available as levy (default q(0.20)).
50
+ * @param tick Current simulation tick.
51
+ */
52
+ export declare function createVassalBond(registry: FeudalRegistry, vassalPolityId: string, liegePolityId: string, loyaltyType: LoyaltyType, tributeRate_Q?: Q, levyRate_Q?: Q, tick?: number): VassalBond;
53
+ /** Return the bond from `vassalId` to `liegeId`, or `undefined` if none. */
54
+ export declare function getBond(registry: FeudalRegistry, vassalId: string, liegeId: string): VassalBond | undefined;
55
+ /** Return all active bonds where `liegeId` is the lord. */
56
+ export declare function getVassals(registry: FeudalRegistry, liegeId: string): VassalBond[];
57
+ /** Return the bond where `vassalId` is the vassal, or `undefined`. */
58
+ export declare function getLiege(registry: FeudalRegistry, vassalId: string): VassalBond | undefined;
59
+ /**
60
+ * Compute the tribute owed for one simulated day.
61
+ * Scales linearly: `daily = floor(treasury_cu × tributeRate_Q / SCALE.Q / DAYS_PER_YEAR)`.
62
+ * Returns 0 if the vassal treasury is empty.
63
+ */
64
+ export declare function computeDailyTribute(vassal: Polity, bond: VassalBond): number;
65
+ /**
66
+ * Apply one day of tribute: deduct from vassal treasury and add to liege treasury.
67
+ * Mutates both polity objects.
68
+ * No-op if computed tribute is 0.
69
+ */
70
+ export declare function applyDailyTribute(vassal: Polity, liege: Polity, bond: VassalBond): number;
71
+ /**
72
+ * Compute the military strength available to the liege as a levy.
73
+ * = `vassal.militaryStrength_Q × levyRate_Q × bond.strength_Q`.
74
+ * A weakened bond reduces the effective levy.
75
+ */
76
+ export declare function computeLevyStrength(vassal: Polity, bond: VassalBond): Q;
77
+ /**
78
+ * Advance bond strength by one simulated day.
79
+ * Strength decays at `LOYALTY_DECAY_PER_DAY[loyaltyType]`.
80
+ * `boostDelta_Q` is an optional signed daily bonus (e.g., from kinship, shared victory,
81
+ * good governance). Positive = strengthen; negative = additional stress.
82
+ * Mutates `bond.strength_Q` directly.
83
+ */
84
+ export declare function stepBondStrength(bond: VassalBond, boostDelta_Q?: Q): void;
85
+ /**
86
+ * Strengthen a bond by a fixed delta (e.g., after a kinship event or tribute payment).
87
+ * Clamps to [0, SCALE.Q].
88
+ */
89
+ export declare function reinforceBond(bond: VassalBond, deltaQ: Q): void;
90
+ /** Return `true` if the bond is at rebellion risk (`strength_Q < REBELLION_THRESHOLD`). */
91
+ export declare function isRebellionRisk(bond: VassalBond): boolean;
92
+ /**
93
+ * Break a vassal bond and remove it from the registry.
94
+ * For `oath_sworn` bonds, adds `OATH_BREAK_INFAMY_Q` to the vassal ruler's renown
95
+ * record if `vassalRulerId` and `renownRegistry` are provided.
96
+ *
97
+ * @returns `true` if a bond was found and removed; `false` otherwise.
98
+ */
99
+ export declare function breakVassalBond(registry: FeudalRegistry, vassalPolityId: string, liegePolityId: string, vassalRulerId?: number, renownRegistry?: RenownRegistry): boolean;