@its-not-rocket-science/ananke 0.1.22 → 0.1.24

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,46 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.24] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 79 · Feudal Bonds & Vassal Tribute** (`src/feudal.ts`)
14
+ - `LoyaltyType`: `"kin_bound" | "oath_sworn" | "conquered" | "voluntary"` — governs base strength and daily decay rate.
15
+ - `VassalBond { vassalPolityId, liegePolityId, loyaltyType, tributeRate_Q, levyRate_Q, strength_Q, establishedTick }` — directed lord-vassal record.
16
+ - `FeudalRegistry { bonds: Map<string, VassalBond> }` keyed by `"vassalId:liegeId"`.
17
+ - `LOYALTY_BASE_STRENGTH`: kin_bound q(0.90) → oath_sworn q(0.70) → voluntary q(0.65) → conquered q(0.40).
18
+ - `LOYALTY_DECAY_PER_DAY`: kin_bound q(0.001)/day → conquered q(0.005)/day.
19
+ - `REBELLION_THRESHOLD = q(0.25)` — `isRebellionRisk(bond)` returns true below this.
20
+ - `computeDailyTribute` / `applyDailyTribute` — floor-based tribute scaled by `tributeRate_Q / SCALE.Q / 365`.
21
+ - `computeLevyStrength(vassal, bond)` — effective levy reduced proportionally by bond weakness (`strength_Q`).
22
+ - `stepBondStrength(bond, boostDelta_Q?)` — daily decay with optional event boost.
23
+ - `reinforceBond(bond, deltaQ)` — clamped-to-SCALE.Q reinforcement for kinship events and tribute.
24
+ - `breakVassalBond(registry, vassalId, liegeId, vassalRulerId?, renownRegistry?)` — removes bond; adds `OATH_BREAK_INFAMY_Q = q(0.15)` infamy to the vassal ruler for `oath_sworn` breaks (Phase 75 integration).
25
+ - Added `./feudal` subpath export to `package.json`.
26
+ - 58 new tests; 4,247 total. Coverage maintained above all thresholds.
27
+
28
+ ---
29
+
30
+ ## [0.1.23] — 2026-03-26
31
+
32
+ ### Added
33
+
34
+ - **Phase 78 · Seasonal Calendar & Agricultural Cycle** (`src/calendar.ts`)
35
+ - `CalendarState { year, dayOfYear }` — immutable; advanced via `stepCalendar(state, days)`.
36
+ - `computeSeason(dayOfYear)` → `"winter" | "spring" | "summer" | "autumn"` (91-day quarters).
37
+ - `computeHarvestPhase(dayOfYear)` → `"dormant" | "planting" | "growing" | "harvest"`.
38
+ - `isInHarvestWindow(dayOfYear)` — true for days 274–365 (Autumn).
39
+ - `SeasonalModifiers { thermalOffset, precipitationMul_Q, diseaseMul_Q, mobilityMul_Q, harvestYield_Q }`.
40
+ - `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).
41
+ - `applySeasonalHarvest(polity, modifiers, baseDailyIncome)` → cost-unit gain for the day.
42
+ - `deriveSeasonalWeatherBias(season, intensity?)` → `Partial<WeatherState>` — advisory weather for Phase-18 hosts.
43
+ - `applySeasonalDiseaseMul(baseRate_Q, modifiers)` → scaled transmission rate for Phase-56/73 integration.
44
+ - Added `./calendar` subpath export to `package.json`.
45
+ - 47 new tests; 4,189 total. Coverage maintained above all thresholds.
46
+
47
+ ---
48
+
9
49
  ## [0.1.22] — 2026-03-26
10
50
 
11
51
  ### 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;
@@ -0,0 +1,161 @@
1
+ // src/feudal.ts — Phase 79: Feudal Bonds & Vassal Tribute
2
+ //
3
+ // Tracks lord-vassal polity relationships including tribute, military levies,
4
+ // bond strength, and revolt risk. Integrates with Phase 61 (Polity) for
5
+ // treasury/military and Phase 75 (Renown) for oath-breaking infamy.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `FeudalRegistry` is external to PolityRegistry; hosts maintain both.
10
+ // - Bond strength decays over time and recovers via positive events
11
+ // (kinship ties, shared victories, tribute payment).
12
+ // - `isRebellionRisk` provides a clear boolean hook for AI and event triggers.
13
+ import { getRenownRecord } from "./renown.js";
14
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
15
+ // ── Constants ─────────────────────────────────────────────────────────────────
16
+ /** Bond strength below this → `isRebellionRisk` returns true. */
17
+ export const REBELLION_THRESHOLD = q(0.25);
18
+ /** Daily strength decay for each loyalty type (per simulated day). */
19
+ export const LOYALTY_DECAY_PER_DAY = {
20
+ kin_bound: q(0.001), // very slow — family ties are resilient
21
+ oath_sworn: q(0.002),
22
+ voluntary: q(0.003),
23
+ conquered: q(0.005), // fastest — resentment grows quickly
24
+ };
25
+ /** Base strength at bond creation per loyalty type. */
26
+ export const LOYALTY_BASE_STRENGTH = {
27
+ kin_bound: q(0.90),
28
+ oath_sworn: q(0.70),
29
+ voluntary: q(0.65),
30
+ conquered: q(0.40),
31
+ };
32
+ /**
33
+ * Infamy added to the vassal's renown record when breaking an `oath_sworn` bond.
34
+ * `kin_bound` and `conquered` breaks carry no oath infamy.
35
+ */
36
+ export const OATH_BREAK_INFAMY_Q = q(0.15);
37
+ /** Tribute paid daily = `TRIBUTE_DAILY_FRAC` × annual rate × treasury_cu */
38
+ export const TRIBUTE_DAYS_PER_YEAR = 365;
39
+ // ── Factory ───────────────────────────────────────────────────────────────────
40
+ export function createFeudalRegistry() {
41
+ return { bonds: new Map() };
42
+ }
43
+ // ── Bond key ──────────────────────────────────────────────────────────────────
44
+ function bondKey(vassalId, liegeId) {
45
+ return `${vassalId}:${liegeId}`;
46
+ }
47
+ // ── Bond management ───────────────────────────────────────────────────────────
48
+ /**
49
+ * Create a vassal bond and register it.
50
+ * If a bond between this pair already exists it is overwritten.
51
+ *
52
+ * @param tributeRate_Q Annual tribute as fraction of vassal treasury (default q(0.10)).
53
+ * @param levyRate_Q Fraction of military available as levy (default q(0.20)).
54
+ * @param tick Current simulation tick.
55
+ */
56
+ export function createVassalBond(registry, vassalPolityId, liegePolityId, loyaltyType, tributeRate_Q = q(0.10), levyRate_Q = q(0.20), tick = 0) {
57
+ const bond = {
58
+ vassalPolityId,
59
+ liegePolityId,
60
+ loyaltyType,
61
+ tributeRate_Q,
62
+ levyRate_Q,
63
+ strength_Q: LOYALTY_BASE_STRENGTH[loyaltyType],
64
+ establishedTick: tick,
65
+ };
66
+ registry.bonds.set(bondKey(vassalPolityId, liegePolityId), bond);
67
+ return bond;
68
+ }
69
+ /** Return the bond from `vassalId` to `liegeId`, or `undefined` if none. */
70
+ export function getBond(registry, vassalId, liegeId) {
71
+ return registry.bonds.get(bondKey(vassalId, liegeId));
72
+ }
73
+ /** Return all active bonds where `liegeId` is the lord. */
74
+ export function getVassals(registry, liegeId) {
75
+ return [...registry.bonds.values()].filter(b => b.liegePolityId === liegeId);
76
+ }
77
+ /** Return the bond where `vassalId` is the vassal, or `undefined`. */
78
+ export function getLiege(registry, vassalId) {
79
+ return [...registry.bonds.values()].find(b => b.vassalPolityId === vassalId);
80
+ }
81
+ // ── Tribute computation ───────────────────────────────────────────────────────
82
+ /**
83
+ * Compute the tribute owed for one simulated day.
84
+ * Scales linearly: `daily = floor(treasury_cu × tributeRate_Q / SCALE.Q / DAYS_PER_YEAR)`.
85
+ * Returns 0 if the vassal treasury is empty.
86
+ */
87
+ export function computeDailyTribute(vassal, bond) {
88
+ if (vassal.treasury_cu <= 0)
89
+ return 0;
90
+ return Math.floor(vassal.treasury_cu * bond.tributeRate_Q / SCALE.Q / TRIBUTE_DAYS_PER_YEAR);
91
+ }
92
+ /**
93
+ * Apply one day of tribute: deduct from vassal treasury and add to liege treasury.
94
+ * Mutates both polity objects.
95
+ * No-op if computed tribute is 0.
96
+ */
97
+ export function applyDailyTribute(vassal, liege, bond) {
98
+ const tribute = computeDailyTribute(vassal, bond);
99
+ if (tribute <= 0)
100
+ return 0;
101
+ vassal.treasury_cu = Math.max(0, vassal.treasury_cu - tribute);
102
+ liege.treasury_cu += tribute;
103
+ return tribute;
104
+ }
105
+ // ── Levy computation ──────────────────────────────────────────────────────────
106
+ /**
107
+ * Compute the military strength available to the liege as a levy.
108
+ * = `vassal.militaryStrength_Q × levyRate_Q × bond.strength_Q`.
109
+ * A weakened bond reduces the effective levy.
110
+ */
111
+ export function computeLevyStrength(vassal, bond) {
112
+ const raw = mulDiv(mulDiv(vassal.militaryStrength_Q, bond.levyRate_Q, SCALE.Q), bond.strength_Q, SCALE.Q);
113
+ return clampQ(raw, 0, SCALE.Q);
114
+ }
115
+ // ── Bond strength ─────────────────────────────────────────────────────────────
116
+ /**
117
+ * Advance bond strength by one simulated day.
118
+ * Strength decays at `LOYALTY_DECAY_PER_DAY[loyaltyType]`.
119
+ * `boostDelta_Q` is an optional signed daily bonus (e.g., from kinship, shared victory,
120
+ * good governance). Positive = strengthen; negative = additional stress.
121
+ * Mutates `bond.strength_Q` directly.
122
+ */
123
+ export function stepBondStrength(bond, boostDelta_Q = 0) {
124
+ const decay = LOYALTY_DECAY_PER_DAY[bond.loyaltyType];
125
+ bond.strength_Q = clampQ(bond.strength_Q - decay + boostDelta_Q, 0, SCALE.Q);
126
+ }
127
+ /**
128
+ * Strengthen a bond by a fixed delta (e.g., after a kinship event or tribute payment).
129
+ * Clamps to [0, SCALE.Q].
130
+ */
131
+ export function reinforceBond(bond, deltaQ) {
132
+ bond.strength_Q = clampQ(bond.strength_Q + deltaQ, 0, SCALE.Q);
133
+ }
134
+ // ── Rebellion risk ────────────────────────────────────────────────────────────
135
+ /** Return `true` if the bond is at rebellion risk (`strength_Q < REBELLION_THRESHOLD`). */
136
+ export function isRebellionRisk(bond) {
137
+ return bond.strength_Q < REBELLION_THRESHOLD;
138
+ }
139
+ // ── Bond breaking ─────────────────────────────────────────────────────────────
140
+ /**
141
+ * Break a vassal bond and remove it from the registry.
142
+ * For `oath_sworn` bonds, adds `OATH_BREAK_INFAMY_Q` to the vassal ruler's renown
143
+ * record if `vassalRulerId` and `renownRegistry` are provided.
144
+ *
145
+ * @returns `true` if a bond was found and removed; `false` otherwise.
146
+ */
147
+ export function breakVassalBond(registry, vassalPolityId, liegePolityId, vassalRulerId, renownRegistry) {
148
+ const key = bondKey(vassalPolityId, liegePolityId);
149
+ const bond = registry.bonds.get(key);
150
+ if (!bond)
151
+ return false;
152
+ // Oath-breaking infamy
153
+ if (bond.loyaltyType === "oath_sworn" &&
154
+ vassalRulerId != null &&
155
+ renownRegistry != null) {
156
+ const record = getRenownRecord(renownRegistry, vassalRulerId);
157
+ record.infamy_Q = clampQ(record.infamy_Q + OATH_BREAK_INFAMY_Q, 0, SCALE.Q);
158
+ }
159
+ registry.bonds.delete(key);
160
+ return true;
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -74,6 +74,14 @@
74
74
  "./succession": {
75
75
  "import": "./dist/src/succession.js",
76
76
  "types": "./dist/src/succession.d.ts"
77
+ },
78
+ "./calendar": {
79
+ "import": "./dist/src/calendar.js",
80
+ "types": "./dist/src/calendar.d.ts"
81
+ },
82
+ "./feudal": {
83
+ "import": "./dist/src/feudal.js",
84
+ "types": "./dist/src/feudal.d.ts"
77
85
  }
78
86
  },
79
87
  "files": [