@its-not-rocket-science/ananke 0.1.12 → 0.1.14

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
@@ -10,6 +10,45 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ### Added
12
12
 
13
+ - **Phase 71 · Cultural Generation & Evolution Framework** (`src/culture.ts`)
14
+ - Reverse WOAC method: derives culture bottom-up from five forces (`environment`,
15
+ `power`, `exchange`, `legacy`, `belief`) scored from simulation state.
16
+ - `generateCulture(polity, registry, myths, vassals?, biome?)` → `CultureProfile`
17
+ with 10 possible `CulturalValue` types, `CulturalContradiction` pairs, and
18
+ `CulturalCycle` practices (CYCLES audit).
19
+ - `stepCultureYear(profile, techPressure_Q, militaryOutcome_Q, myths, worldSeed, tick)`
20
+ → `CultureYearResult { profile, schism? }`: tech diffusion pulls exchange force
21
+ upward; military outcomes shift power; new myths update legacy/belief; conservative
22
+ cultures with high tension fire deterministic `SchismEvent` (reform_movement,
23
+ heresy, or civil_unrest).
24
+ - `describeCulture(profile)` → `{ summary, values, contradictions, cycles }`:
25
+ human-readable output for writers and game designers.
26
+ - Query helpers: `getCulturalValue`, `getDominantValues`, `getSignificantContradictions`.
27
+ - Integrates with Phase 70 (vassal count → power force), Phase 66 (myths → legacy/belief),
28
+ Phase 68 (BiomeContext → environment harshness), Phase 23 dialogue and Phase 24
29
+ faction standing via exported profile queries.
30
+ - 45 tests in `test/culture.test.ts`; exported via `ananke/campaign` subpath.
31
+
32
+ - **Phase 70 · Stratified Political Simulation ("Vassal Web" Layer)** (`src/polity-vassals.ts`)
33
+ - `VassalNode` — intermediate layer between Entity and Polity with `territory_Q`,
34
+ `military_Q`, `treasury_cu`, and a `VassalLoyalty` block.
35
+ - Seven `LoyaltyType` variants with distinct `stepVassalLoyalty` dynamics:
36
+ `ideological` (slow, conviction-driven), `transactional` (treasury comparison),
37
+ `terrified` (instant collapse if liege appears weak), `honor_bound` (oath + grievance
38
+ spike), `opportunistic` (tracks liege/rival morale ratio), `kin_bound` (stable family
39
+ ties), `ideological_rival` (constant decay, cannot recover).
40
+ - `applyGrievanceEvent` — immutable grievance accumulation (host applies broken-promise,
41
+ tax-hike, kin-death events).
42
+ - `computeVassalContribution` — loyalty-scaled troop and treasury output; zero below
43
+ `CONTRIBUTION_FLOOR_Q` (q(0.20)), full above `CONTRIBUTION_FULL_Q` (q(0.50)).
44
+ - `computeEffectiveMilitary` — sums contributions for command-chain filtering before
45
+ passing force ratio to Phase 69 `resolveTacticalEngagement`.
46
+ - `detectRebellionRisk` — Q score (70% low-loyalty + 30% high-grievance) for AI queries.
47
+ - `resolveSuccessionCrisis` — deterministic heir-support rolls weighted by `military_Q`;
48
+ winners gain +q(0.05) loyalty, losers −q(0.08); `SuccessionResult` with `supportQ`
49
+ and per-vassal `loyaltyDeltas`.
50
+ - 40 tests in `test/polity-vassals.test.ts`; exported via `ananke/campaign` subpath.
51
+
13
52
  - **Option B · Tier 2 subpath exports** — eight new named import subpaths for all
14
53
  Tier 2 module groupings; deep imports remain supported as a fallback:
15
54
  - `ananke/character` → aging, sleep, disease, wound-aging, thermoregulation, nutrition,
@@ -20,3 +20,5 @@ export * from "./world-generation.js";
20
20
  export * from "./inheritance.js";
21
21
  export * from "./economy.js";
22
22
  export * from "./polity.js";
23
+ export * from "./polity-vassals.js";
24
+ export * from "./culture.js";
@@ -20,3 +20,5 @@ export * from "./world-generation.js";
20
20
  export * from "./inheritance.js";
21
21
  export * from "./economy.js";
22
22
  export * from "./polity.js";
23
+ export * from "./polity-vassals.js";
24
+ export * from "./culture.js";
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Phase 71 — Cultural Generation & Evolution Framework
3
+ *
4
+ * Derives culture bottom-up from five environmental forces (Environment, Power,
5
+ * Exchange, Legacy, Belief) using the Reverse WOAC method. Given a Polity and
6
+ * its world context, `generateCulture` produces a `CultureProfile` of values,
7
+ * internal contradictions, and recurring practices (CYCLES). `stepCultureYear`
8
+ * evolves the profile over simulated time. `describeCulture` renders it as
9
+ * human-readable prose for writers and game designers.
10
+ *
11
+ * No kernel import — pure data-management module, fixed-point arithmetic only.
12
+ */
13
+ import type { Q } from "./units.js";
14
+ import type { Polity, PolityRegistry } from "./polity.js";
15
+ import type { Myth } from "./mythology.js";
16
+ import type { BiomeContext } from "./sim/biome.js";
17
+ import type { VassalNode } from "./polity-vassals.js";
18
+ /** The five environmental forces that drive culture generation. */
19
+ export type CultureForce = "environment" | "power" | "exchange" | "legacy" | "belief";
20
+ /**
21
+ * CYCLES audit: the six recurring cultural practice categories.
22
+ * (Celebration, Yes-or-no rules, Conflict resolution, Lifecycle rites,
23
+ * Exchange norms, Status markers)
24
+ */
25
+ export type CycleType = "celebration" | "taboo" | "conflict_resolution" | "lifecycle" | "exchange_norm" | "status_marker";
26
+ /** Named cultural values a society may hold. */
27
+ export type ValueId = "honour" | "martial_virtue" | "commerce" | "fatalism" | "hospitality" | "hierarchy" | "spiritual_devotion" | "innovation" | "kin_loyalty" | "craft_mastery";
28
+ /** Type of cultural schism that can emerge from unresolved contradictions. */
29
+ export type SchismType = "reform_movement" | "heresy" | "civil_unrest";
30
+ export interface CulturalValue {
31
+ id: ValueId;
32
+ /** Strength of this value in the culture [0, SCALE.Q]. */
33
+ strength_Q: Q;
34
+ }
35
+ export interface CulturalContradiction {
36
+ valueA: ValueId;
37
+ valueB: ValueId;
38
+ /**
39
+ * Tension level [0, SCALE.Q].
40
+ * High tension → more likely to produce internal conflict events.
41
+ */
42
+ tension_Q: Q;
43
+ }
44
+ export interface CulturalCycle {
45
+ type: CycleType;
46
+ name: string;
47
+ description: string;
48
+ }
49
+ export interface CultureProfile {
50
+ /** Unique id, typically `"culture_${polityId}"`. */
51
+ id: string;
52
+ polityId: string;
53
+ /** Strength of each driving force [0, SCALE.Q]. */
54
+ forces: Record<CultureForce, Q>;
55
+ /** Derived value list, sorted descending by strength. */
56
+ values: CulturalValue[];
57
+ /** Value pairs in tension; only pairs with tension > CONTRADICTION_THRESHOLD included. */
58
+ contradictions: CulturalContradiction[];
59
+ /** Recurring cultural practices that resolve the dominant tensions. */
60
+ cycles: CulturalCycle[];
61
+ /**
62
+ * Openness to cultural change [0, SCALE.Q].
63
+ * Low = conservative; high = receptive to drift.
64
+ */
65
+ driftTendency_Q: Q;
66
+ }
67
+ export interface CultureDescription {
68
+ /** One-paragraph cultural summary. */
69
+ summary: string;
70
+ /** Plain-English description of each significant value. */
71
+ values: string[];
72
+ /** What conflicts each contradiction tends to generate. */
73
+ contradictions: string[];
74
+ /** Narrative descriptions of key recurring practices. */
75
+ cycles: string[];
76
+ }
77
+ export interface SchismEvent {
78
+ polityId: string;
79
+ triggeringContradiction: CulturalContradiction;
80
+ type: SchismType;
81
+ /** How disruptive the schism is [0, SCALE.Q]. */
82
+ severity_Q: Q;
83
+ }
84
+ export interface CultureYearResult {
85
+ profile: CultureProfile;
86
+ /** Populated if a contradiction triggered a schism this year. */
87
+ schism?: SchismEvent;
88
+ }
89
+ /** Minimum value strength to be included in the profile. */
90
+ export declare const VALUE_THRESHOLD_Q: Q;
91
+ /** Minimum tension to qualify as a significant contradiction. */
92
+ export declare const CONTRADICTION_THRESHOLD_Q: Q;
93
+ /** Maximum number of values retained in a profile. */
94
+ export declare const MAX_VALUES = 6;
95
+ /** Maximum number of contradictions tracked. */
96
+ export declare const MAX_CONTRADICTIONS = 4;
97
+ /** Maximum number of CYCLES retained. */
98
+ export declare const MAX_CYCLES = 3;
99
+ /** Annual drift step magnitude for force evolution. */
100
+ export declare const DRIFT_STEP_Q: Q;
101
+ /** Annual tech pressure on the exchange force per tech-era gap. */
102
+ export declare const TECH_DIFFUSION_PULL_Q: Q;
103
+ /** eventSeed salt for schism rolls. */
104
+ export declare const SCHISM_SALT: number;
105
+ /**
106
+ * Generate a `CultureProfile` for a polity from its current simulation state.
107
+ *
108
+ * All five forces are derived automatically:
109
+ * - `environment` from `biome` physics overrides
110
+ * - `power` from `polity.techEra` + vassal count
111
+ * - `exchange` from treasury per capita
112
+ * - `legacy` + `belief` from myth registry
113
+ *
114
+ * @param polity The polity to generate culture for.
115
+ * @param _registry PolityRegistry (reserved for future neighbour-context use).
116
+ * @param myths Active myths that the polity's factions believe.
117
+ * @param vassals Current vassal roster (Phase 70; pass `[]` if not available).
118
+ * @param biome Optional BiomeContext affecting the environment force.
119
+ */
120
+ export declare function generateCulture(polity: Polity, _registry: PolityRegistry, myths: readonly Myth[], vassals?: readonly VassalNode[], biome?: BiomeContext): CultureProfile;
121
+ /**
122
+ * Evolve a culture profile by one simulated year.
123
+ *
124
+ * Three pressures are applied:
125
+ * 1. **Tech diffusion** (`techPressure_Q`): pulls exchange force upward when
126
+ * neighbouring polities have a higher tech era (Phase 67). Pass q(0) if
127
+ * the polity is technologically isolated.
128
+ * 2. **Military outcome** (`militaryOutcome_Q`): q(0) = crushing defeat,
129
+ * q(0.50) = neutral, q(1.0) = great victory. Shifts martial_virtue in the
130
+ * dominant values and the power force.
131
+ * 3. **New myths** (`myths`): re-derives the legacy and belief forces from the
132
+ * current myth state.
133
+ *
134
+ * If any contradiction exceeds the schism threshold, a `SchismEvent` is
135
+ * returned alongside the updated profile. The schism reduces the tension of
136
+ * the triggering contradiction by damping both values slightly.
137
+ *
138
+ * @param profile Current culture profile.
139
+ * @param techPressure_Q Exchange-force pull from tech-advanced neighbours.
140
+ * @param militaryOutcome_Q Season military result [0, SCALE.Q].
141
+ * @param myths Current myth registry entries.
142
+ * @param worldSeed
143
+ * @param tick Current campaign tick (used for schism roll).
144
+ */
145
+ export declare function stepCultureYear(profile: CultureProfile, techPressure_Q: Q, militaryOutcome_Q: Q, myths: readonly Myth[], worldSeed: number, tick: number): CultureYearResult;
146
+ /**
147
+ * Return the strength of a named value in the culture, or q(0) if absent.
148
+ */
149
+ export declare function getCulturalValue(profile: CultureProfile, id: ValueId): Q;
150
+ /**
151
+ * Return the top N values by strength (default 3).
152
+ */
153
+ export declare function getDominantValues(profile: CultureProfile, n?: number): CulturalValue[];
154
+ /**
155
+ * Return only contradictions above CONTRADICTION_THRESHOLD_Q,
156
+ * sorted by tension descending.
157
+ */
158
+ export declare function getSignificantContradictions(profile: CultureProfile): CulturalContradiction[];
159
+ /**
160
+ * Render a `CultureProfile` as human-readable prose and bullet lists.
161
+ *
162
+ * Suitable for game designers, writers, and procedural quest/dialogue generation.
163
+ */
164
+ export declare function describeCulture(profile: CultureProfile): CultureDescription;
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Phase 71 — Cultural Generation & Evolution Framework
3
+ *
4
+ * Derives culture bottom-up from five environmental forces (Environment, Power,
5
+ * Exchange, Legacy, Belief) using the Reverse WOAC method. Given a Polity and
6
+ * its world context, `generateCulture` produces a `CultureProfile` of values,
7
+ * internal contradictions, and recurring practices (CYCLES). `stepCultureYear`
8
+ * evolves the profile over simulated time. `describeCulture` renders it as
9
+ * human-readable prose for writers and game designers.
10
+ *
11
+ * No kernel import — pure data-management module, fixed-point arithmetic only.
12
+ */
13
+ import { SCALE, q, clampQ, mulDiv } from "./units.js";
14
+ import { eventSeed, hashString } from "./sim/seeds.js";
15
+ // ── Constants ─────────────────────────────────────────────────────────────────
16
+ /** Minimum value strength to be included in the profile. */
17
+ export const VALUE_THRESHOLD_Q = q(0.10);
18
+ /** Minimum tension to qualify as a significant contradiction. */
19
+ export const CONTRADICTION_THRESHOLD_Q = q(0.30);
20
+ /** Maximum number of values retained in a profile. */
21
+ export const MAX_VALUES = 6;
22
+ /** Maximum number of contradictions tracked. */
23
+ export const MAX_CONTRADICTIONS = 4;
24
+ /** Maximum number of CYCLES retained. */
25
+ export const MAX_CYCLES = 3;
26
+ /** Annual drift step magnitude for force evolution. */
27
+ export const DRIFT_STEP_Q = q(0.02);
28
+ /** Annual tech pressure on the exchange force per tech-era gap. */
29
+ export const TECH_DIFFUSION_PULL_Q = q(0.03);
30
+ /** eventSeed salt for schism rolls. */
31
+ export const SCHISM_SALT = 0xC17E;
32
+ // ── Internal lookup tables ────────────────────────────────────────────────────
33
+ /** Baseline power force (authority centralisation) by tech era. */
34
+ const TECH_POWER = {
35
+ prehistoric: q(0.20),
36
+ ancient: q(0.40),
37
+ medieval: q(0.70),
38
+ early_modern: q(0.55),
39
+ industrial: q(0.45),
40
+ contemporary: q(0.35),
41
+ };
42
+ /**
43
+ * Known-tension pairs: [valueA, valueB, base tension when both are at SCALE.Q].
44
+ * Actual tension is scaled by min(strengthA, strengthB).
45
+ */
46
+ const TENSION_PAIRS = [
47
+ ["honour", "commerce", q(0.80)], // reputation vs bargaining
48
+ ["hierarchy", "innovation", q(0.70)], // authority vs change
49
+ ["fatalism", "commerce", q(0.60)], // why strive vs strive
50
+ ["spiritual_devotion", "innovation", q(0.65)], // sacred vs secular
51
+ ["martial_virtue", "hospitality", q(0.55)], // warrior vs peace-weaver
52
+ ["kin_loyalty", "hierarchy", q(0.50)], // family vs state
53
+ ];
54
+ /**
55
+ * Cycle assigned to each contradiction to resolve the tension.
56
+ * Ordered to match TENSION_PAIRS.
57
+ */
58
+ const RESOLUTION_CYCLES = [
59
+ { type: "exchange_norm", name: "Gift Exchange Ceremony", description: "Formal gift rituals preserve social honour while enabling commerce — trade dressed as generosity." },
60
+ { type: "conflict_resolution", name: "Trial by Tradition", description: "New ideas must survive an ordeal judged by elders; innovation that earns approval gains legitimacy." },
61
+ { type: "celebration", name: "Harvest Gratitude Feast", description: "Communities celebrate what was achieved and accept loss without shame, reconciling effort with fate." },
62
+ { type: "taboo", name: "Sacred Knowledge Seal", description: "Certain fields of inquiry are quarantined as 'holy mystery', allowing other innovation to proceed freely." },
63
+ { type: "lifecycle", name: "Warrior's Hospitality", description: "Feasting enemies before and after battle transforms potential atrocity into ritual; violence is bounded by courtesy." },
64
+ { type: "status_marker", name: "Adoption Ceremony", description: "Political allies are formally adopted into the family, converting external obligation into kin loyalty." },
65
+ ];
66
+ /** Cycles for dominant single values (no contradiction needed). */
67
+ const VALUE_CYCLES = [
68
+ ["martial_virtue", { type: "lifecycle", name: "Warrior's Rite", description: "Coming-of-age trials test martial ability; those who pass gain full social standing." }],
69
+ ["spiritual_devotion", { type: "celebration", name: "Propitiation Festival", description: "Seasonal ceremonies appease the supernatural and reinforce communal bonds." }],
70
+ ["kin_loyalty", { type: "celebration", name: "Ancestor Feast", description: "Regular remembrance of forebears reaffirms family lineage as the core social unit." }],
71
+ ["commerce", { type: "exchange_norm", name: "Market Day", description: "Scheduled communal markets with enforced rules create safe space for strangers to trade." }],
72
+ ["hierarchy", { type: "status_marker", name: "Tribute Ceremony", description: "Regular displays of wealth offered upward reinforce the legitimacy of the ruling rank." }],
73
+ ["craft_mastery", { type: "lifecycle", name: "Masterwork Presentation", description: "Artisans publicly present their finest work to earn the title of master and social respect." }],
74
+ ];
75
+ // ── Force derivation ──────────────────────────────────────────────────────────
76
+ function deriveEnvironmentForce(biome) {
77
+ if (!biome)
78
+ return q(0.50);
79
+ // Vacuum/no-sound environment: most extreme
80
+ if (biome.soundPropagation === 0)
81
+ return q(0.85);
82
+ // High-drag (underwater): physically demanding
83
+ if (biome.dragMul !== undefined && biome.dragMul < q(0.50))
84
+ return q(0.75);
85
+ // Some non-default biome is set: treat as unusual environment
86
+ return q(0.60);
87
+ }
88
+ function derivePowerForce(polity, vassals) {
89
+ const base = TECH_POWER[polity.techEra] ?? q(0.50);
90
+ // Many vassals reinforce feudal hierarchy → raise power force slightly
91
+ const vassalBonus = Math.min(vassals.length * q(0.01), q(0.15));
92
+ return clampQ(base + vassalBonus, 0, SCALE.Q);
93
+ }
94
+ function deriveExchangeForce(polity) {
95
+ if (polity.population <= 0)
96
+ return q(0.30);
97
+ const wealthPerCapita = polity.treasury_cu / polity.population;
98
+ // 0 cu/person → q(0.20); 5 cu/person → q(0.70); normalised linearly
99
+ const NORM = 5;
100
+ return clampQ(q(0.20) + Math.round(Math.min(wealthPerCapita, NORM) * q(0.50) / NORM), 0, SCALE.Q);
101
+ }
102
+ function deriveLegacyForce(myths) {
103
+ if (myths.length === 0)
104
+ return { legacy_Q: q(0.10), positivity_Q: q(0.50) };
105
+ const sumBelief = myths.reduce((s, m) => s + m.belief_Q, 0);
106
+ const avgBelief = Math.round(sumBelief / myths.length);
107
+ const posCount = myths.filter(m => m.archetype === "hero" || m.archetype === "golden_age").length;
108
+ const positivity = Math.round(posCount * SCALE.Q / myths.length);
109
+ return {
110
+ legacy_Q: clampQ(avgBelief, 0, SCALE.Q),
111
+ positivity_Q: clampQ(positivity, 0, SCALE.Q),
112
+ };
113
+ }
114
+ function deriveBeliefForce(myths) {
115
+ if (myths.length === 0)
116
+ return q(0.30);
117
+ const supernaturalCount = myths.filter(m => m.archetype === "great_plague" ||
118
+ m.archetype === "divine_wrath").length;
119
+ // Each supernatural myth adds belief pressure
120
+ return clampQ(q(0.30) + Math.round(supernaturalCount * q(0.15)), 0, SCALE.Q);
121
+ }
122
+ // ── Value derivation ──────────────────────────────────────────────────────────
123
+ function deriveValues(forces, positivity_Q) {
124
+ const { environment, power, exchange, legacy, belief } = forces;
125
+ const antiEnv = (SCALE.Q - environment);
126
+ const antiPower = (SCALE.Q - power);
127
+ const antiExchange = (SCALE.Q - exchange);
128
+ const antiPositive = (SCALE.Q - positivity_Q);
129
+ const raw = [
130
+ ["honour", mulDiv(power, q(0.50), SCALE.Q) + mulDiv(legacy, q(0.30), SCALE.Q)],
131
+ ["martial_virtue", mulDiv(environment, q(0.40), SCALE.Q) + mulDiv(power, q(0.30), SCALE.Q)],
132
+ ["commerce", mulDiv(exchange, q(0.60), SCALE.Q) + mulDiv(antiEnv, q(0.15), SCALE.Q)],
133
+ ["fatalism", mulDiv(environment, q(0.30), SCALE.Q) + mulDiv(antiPositive, q(0.40), SCALE.Q)],
134
+ ["hospitality", mulDiv(exchange, q(0.30), SCALE.Q) + mulDiv(antiPower, q(0.25), SCALE.Q)],
135
+ ["hierarchy", mulDiv(power, q(0.65), SCALE.Q)],
136
+ ["spiritual_devotion", mulDiv(belief, q(0.60), SCALE.Q) + mulDiv(environment, q(0.20), SCALE.Q)],
137
+ ["innovation", mulDiv(exchange, q(0.25), SCALE.Q) + mulDiv(antiPower, q(0.20), SCALE.Q)],
138
+ ["kin_loyalty", mulDiv(antiExchange, q(0.30), SCALE.Q) + mulDiv(environment, q(0.20), SCALE.Q)],
139
+ ["craft_mastery", mulDiv(exchange, q(0.20), SCALE.Q) + mulDiv(power, q(0.15), SCALE.Q)],
140
+ ];
141
+ return raw
142
+ .map(([id, strength]) => ({ id, strength_Q: clampQ(strength, 0, SCALE.Q) }))
143
+ .filter(v => v.strength_Q >= VALUE_THRESHOLD_Q)
144
+ .sort((a, b) => b.strength_Q - a.strength_Q)
145
+ .slice(0, MAX_VALUES);
146
+ }
147
+ // ── Contradiction detection ───────────────────────────────────────────────────
148
+ function deriveContradictions(values) {
149
+ const strengthMap = new Map(values.map(v => [v.id, v.strength_Q]));
150
+ const result = [];
151
+ for (const [a, b, baseTension] of TENSION_PAIRS) {
152
+ const sa = strengthMap.get(a) ?? q(0.0);
153
+ const sb = strengthMap.get(b) ?? q(0.0);
154
+ if (sa < VALUE_THRESHOLD_Q || sb < VALUE_THRESHOLD_Q)
155
+ continue;
156
+ // Tension scales with the weaker of the two values and the base tension rate.
157
+ const minStrength = Math.min(sa, sb);
158
+ const tension = clampQ(mulDiv(minStrength, baseTension, SCALE.Q), 0, SCALE.Q);
159
+ if (tension >= CONTRADICTION_THRESHOLD_Q) {
160
+ result.push({ valueA: a, valueB: b, tension_Q: tension });
161
+ }
162
+ }
163
+ return result
164
+ .sort((a, b) => b.tension_Q - a.tension_Q)
165
+ .slice(0, MAX_CONTRADICTIONS);
166
+ }
167
+ // ── CYCLES derivation ─────────────────────────────────────────────────────────
168
+ function deriveCycles(values, contradictions) {
169
+ const cycles = [];
170
+ const seen = new Set();
171
+ // First: one cycle per contradiction (tension-resolving practice)
172
+ for (const c of contradictions) {
173
+ const idx = TENSION_PAIRS.findIndex(([a, b]) => a === c.valueA && b === c.valueB);
174
+ if (idx >= 0 && idx < RESOLUTION_CYCLES.length) {
175
+ const cycle = RESOLUTION_CYCLES[idx];
176
+ if (!seen.has(cycle.name)) {
177
+ cycles.push(cycle);
178
+ seen.add(cycle.name);
179
+ }
180
+ }
181
+ }
182
+ // Then: dominant-value cycles to fill remaining slots
183
+ const dominantIds = new Set(values.slice(0, 3).map(v => v.id));
184
+ for (const [vid, cycle] of VALUE_CYCLES) {
185
+ if (cycles.length >= MAX_CYCLES)
186
+ break;
187
+ if (dominantIds.has(vid) && !seen.has(cycle.name)) {
188
+ cycles.push(cycle);
189
+ seen.add(cycle.name);
190
+ }
191
+ }
192
+ return cycles.slice(0, MAX_CYCLES);
193
+ }
194
+ // ── Drift tendency ────────────────────────────────────────────────────────────
195
+ function deriveDriftTendency(forces) {
196
+ // High exchange + high innovation → open; high power + high belief → conservative.
197
+ const openness = mulDiv(forces.exchange, q(0.40), SCALE.Q)
198
+ + mulDiv(SCALE.Q - forces.power, q(0.30), SCALE.Q)
199
+ + mulDiv(SCALE.Q - forces.belief, q(0.20), SCALE.Q);
200
+ return clampQ(openness, q(0.10), q(0.90));
201
+ }
202
+ // ── Public API ────────────────────────────────────────────────────────────────
203
+ /**
204
+ * Generate a `CultureProfile` for a polity from its current simulation state.
205
+ *
206
+ * All five forces are derived automatically:
207
+ * - `environment` from `biome` physics overrides
208
+ * - `power` from `polity.techEra` + vassal count
209
+ * - `exchange` from treasury per capita
210
+ * - `legacy` + `belief` from myth registry
211
+ *
212
+ * @param polity The polity to generate culture for.
213
+ * @param _registry PolityRegistry (reserved for future neighbour-context use).
214
+ * @param myths Active myths that the polity's factions believe.
215
+ * @param vassals Current vassal roster (Phase 70; pass `[]` if not available).
216
+ * @param biome Optional BiomeContext affecting the environment force.
217
+ */
218
+ export function generateCulture(polity, _registry, myths, vassals = [], biome) {
219
+ const envForce = deriveEnvironmentForce(biome);
220
+ const powForce = derivePowerForce(polity, vassals);
221
+ const exchForce = deriveExchangeForce(polity);
222
+ const { legacy_Q, positivity_Q } = deriveLegacyForce(myths);
223
+ const beliefForce = deriveBeliefForce(myths);
224
+ const forces = {
225
+ environment: envForce,
226
+ power: powForce,
227
+ exchange: exchForce,
228
+ legacy: legacy_Q,
229
+ belief: beliefForce,
230
+ };
231
+ const values = deriveValues(forces, positivity_Q);
232
+ const contradictions = deriveContradictions(values);
233
+ const cycles = deriveCycles(values, contradictions);
234
+ const driftTendency = deriveDriftTendency(forces);
235
+ return {
236
+ id: `culture_${polity.id}`,
237
+ polityId: polity.id,
238
+ forces,
239
+ values,
240
+ contradictions,
241
+ cycles,
242
+ driftTendency_Q: driftTendency,
243
+ };
244
+ }
245
+ /**
246
+ * Evolve a culture profile by one simulated year.
247
+ *
248
+ * Three pressures are applied:
249
+ * 1. **Tech diffusion** (`techPressure_Q`): pulls exchange force upward when
250
+ * neighbouring polities have a higher tech era (Phase 67). Pass q(0) if
251
+ * the polity is technologically isolated.
252
+ * 2. **Military outcome** (`militaryOutcome_Q`): q(0) = crushing defeat,
253
+ * q(0.50) = neutral, q(1.0) = great victory. Shifts martial_virtue in the
254
+ * dominant values and the power force.
255
+ * 3. **New myths** (`myths`): re-derives the legacy and belief forces from the
256
+ * current myth state.
257
+ *
258
+ * If any contradiction exceeds the schism threshold, a `SchismEvent` is
259
+ * returned alongside the updated profile. The schism reduces the tension of
260
+ * the triggering contradiction by damping both values slightly.
261
+ *
262
+ * @param profile Current culture profile.
263
+ * @param techPressure_Q Exchange-force pull from tech-advanced neighbours.
264
+ * @param militaryOutcome_Q Season military result [0, SCALE.Q].
265
+ * @param myths Current myth registry entries.
266
+ * @param worldSeed
267
+ * @param tick Current campaign tick (used for schism roll).
268
+ */
269
+ export function stepCultureYear(profile, techPressure_Q, militaryOutcome_Q, myths, worldSeed, tick) {
270
+ const polityHash = hashString(profile.polityId);
271
+ // ── 1. Drift forces ────────────────────────────────────────────────────────
272
+ const { legacy_Q, positivity_Q } = deriveLegacyForce(myths);
273
+ const beliefForce = deriveBeliefForce(myths);
274
+ // Exchange: tech-diffusion pulls upward; drift tendency amplifies openness
275
+ const exchDrift = mulDiv(techPressure_Q, profile.driftTendency_Q, SCALE.Q);
276
+ const newExchange = clampQ(profile.forces.exchange + Math.max(0, exchDrift), 0, SCALE.Q);
277
+ // Power: military victory reinforces authority; defeat weakens it
278
+ const militaryDelta = mulDiv(militaryOutcome_Q - q(0.50), DRIFT_STEP_Q * 2, SCALE.Q);
279
+ const newPower = clampQ(profile.forces.power + militaryDelta, 0, SCALE.Q);
280
+ const newForces = {
281
+ environment: profile.forces.environment, // geography doesn't change year-to-year
282
+ power: newPower,
283
+ exchange: newExchange,
284
+ legacy: legacy_Q,
285
+ belief: beliefForce,
286
+ };
287
+ // ── 2. Re-derive values and contradictions ─────────────────────────────────
288
+ let newValues = deriveValues(newForces, positivity_Q);
289
+ let newContradictions = deriveContradictions(newValues);
290
+ const newCycles = deriveCycles(newValues, newContradictions);
291
+ const newDrift = deriveDriftTendency(newForces);
292
+ // ── 3. Check for schism ────────────────────────────────────────────────────
293
+ let schism;
294
+ const topContradiction = newContradictions[0];
295
+ if (topContradiction !== undefined) {
296
+ const tensionHash = hashString(topContradiction.valueA + topContradiction.valueB);
297
+ const seed = eventSeed(worldSeed, tick, polityHash, tensionHash, SCHISM_SALT);
298
+ const roll = seed % (SCALE.Q + 1);
299
+ // Schism probability = tension × (1 - driftTendency) [conservative cultures crack harder]
300
+ const probability = mulDiv(topContradiction.tension_Q, SCALE.Q - profile.driftTendency_Q, SCALE.Q);
301
+ if (roll < probability) {
302
+ const severity = clampQ(mulDiv(topContradiction.tension_Q, SCALE.Q - profile.driftTendency_Q, SCALE.Q), 0, SCALE.Q);
303
+ const schismType = topContradiction.tension_Q >= q(0.75) ? "civil_unrest" :
304
+ topContradiction.tension_Q >= q(0.55) ? "heresy" :
305
+ "reform_movement";
306
+ schism = {
307
+ polityId: profile.polityId,
308
+ triggeringContradiction: topContradiction,
309
+ type: schismType,
310
+ severity_Q: severity,
311
+ };
312
+ // Schism partially resolves the tension: damp both values slightly
313
+ const dampFactor = q(0.90);
314
+ newValues = newValues.map(v => v.id === topContradiction.valueA || v.id === topContradiction.valueB
315
+ ? { ...v, strength_Q: mulDiv(v.strength_Q, dampFactor, SCALE.Q) }
316
+ : v);
317
+ newContradictions = deriveContradictions(newValues);
318
+ }
319
+ }
320
+ const updatedProfile = {
321
+ ...profile,
322
+ forces: newForces,
323
+ values: newValues,
324
+ contradictions: newContradictions,
325
+ cycles: newCycles,
326
+ driftTendency_Q: newDrift,
327
+ };
328
+ return { profile: updatedProfile, ...(schism ? { schism } : {}) };
329
+ }
330
+ // ── Query helpers ─────────────────────────────────────────────────────────────
331
+ /**
332
+ * Return the strength of a named value in the culture, or q(0) if absent.
333
+ */
334
+ export function getCulturalValue(profile, id) {
335
+ return profile.values.find(v => v.id === id)?.strength_Q ?? q(0.0);
336
+ }
337
+ /**
338
+ * Return the top N values by strength (default 3).
339
+ */
340
+ export function getDominantValues(profile, n = 3) {
341
+ return profile.values.slice(0, n);
342
+ }
343
+ /**
344
+ * Return only contradictions above CONTRADICTION_THRESHOLD_Q,
345
+ * sorted by tension descending.
346
+ */
347
+ export function getSignificantContradictions(profile) {
348
+ return profile.contradictions.filter(c => c.tension_Q >= CONTRADICTION_THRESHOLD_Q);
349
+ }
350
+ // ── Human-readable description ────────────────────────────────────────────────
351
+ const VALUE_PROSE = {
352
+ honour: "Social reputation and oath-keeping are central; broken promises carry severe consequences.",
353
+ martial_virtue: "Courage and prowess in battle are prized above most other qualities.",
354
+ commerce: "Trade and wealth accumulation are respected pursuits; markets thrive.",
355
+ fatalism: "Hardship is accepted with stoicism; fate is not resisted but endured.",
356
+ hospitality: "Generosity to strangers is a moral obligation and source of social prestige.",
357
+ hierarchy: "Rank and authority are respected; social order is seen as natural and necessary.",
358
+ spiritual_devotion: "Supernatural forces are central to daily life; ritual and propitiation are constant.",
359
+ innovation: "New ideas and methods are embraced; tradition is weighed against pragmatism.",
360
+ kin_loyalty: "Family obligations supersede external duties; lineage is the core identity.",
361
+ craft_mastery: "Skilled artisans are highly respected; excellence in craft carries social status.",
362
+ };
363
+ const CONTRADICTION_PROSE = {
364
+ "honour+commerce": "Tension between maintaining dignity and striking profitable deals; bargaining can feel like a loss of face.",
365
+ "hierarchy+innovation": "Authority structures resist change; new ideas must be framed as tradition to gain acceptance.",
366
+ "fatalism+commerce": "The belief that outcomes are preordained clashes with the drive to accumulate and improve; some see striving as futile.",
367
+ "spiritual_devotion+innovation": "New discoveries threaten sacred explanations; the boundary between sacred knowledge and secular inquiry is contested.",
368
+ "martial_virtue+hospitality": "Warrior culture and the duty to welcome strangers create awkward social choreography around guests who may become enemies.",
369
+ "kin_loyalty+hierarchy": "Obligation to family can conflict with loyalty to lord or state; both claim the same person's ultimate allegiance.",
370
+ };
371
+ /**
372
+ * Render a `CultureProfile` as human-readable prose and bullet lists.
373
+ *
374
+ * Suitable for game designers, writers, and procedural quest/dialogue generation.
375
+ */
376
+ export function describeCulture(profile) {
377
+ const dominant = getDominantValues(profile, 3);
378
+ const topTwo = dominant.slice(0, 2).map(v => v.id.replace(/_/g, " "));
379
+ // Build one-paragraph summary
380
+ const opening = topTwo.length >= 2
381
+ ? `This culture places strong emphasis on ${topTwo[0]} and ${topTwo[1]}.`
382
+ : topTwo.length === 1
383
+ ? `This culture places strong emphasis on ${topTwo[0]}.`
384
+ : "This culture has no dominant values yet established.";
385
+ const envNote = profile.forces.environment >= q(0.70)
386
+ ? " Shaped by harsh conditions, survival demands constant collective effort."
387
+ : "";
388
+ const exchNote = profile.forces.exchange >= q(0.65)
389
+ ? " Trade and exchange are woven into everyday social life."
390
+ : profile.forces.exchange <= q(0.30)
391
+ ? " Material exchange is subordinate to gift-giving and reciprocal obligation."
392
+ : "";
393
+ const beliefNote = profile.forces.belief >= q(0.60)
394
+ ? " The supernatural is not distant — it is present in every significant decision."
395
+ : "";
396
+ const topContra = profile.contradictions[0];
397
+ const contradNote = topContra !== undefined
398
+ ? ` The culture harbours a live internal tension between ${topContra.valueA.replace(/_/g, " ")} and ${topContra.valueB.replace(/_/g, " ")}.`
399
+ : "";
400
+ const summary = opening + envNote + exchNote + beliefNote + contradNote;
401
+ // Value bullet list
402
+ const values = dominant.map(v => `${v.id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}: ${VALUE_PROSE[v.id]}`);
403
+ // Contradiction descriptions
404
+ const contradictions = getSignificantContradictions(profile).map(c => {
405
+ const key = `${c.valueA}+${c.valueB}`;
406
+ const prose = CONTRADICTION_PROSE[key] ?? `Tension between ${c.valueA.replace(/_/g, " ")} and ${c.valueB.replace(/_/g, " ")}.`;
407
+ return prose;
408
+ });
409
+ // Cycle descriptions
410
+ const cycles = profile.cycles.map(c => `${c.name} (${c.type.replace(/_/g, " ")}): ${c.description}`);
411
+ return { summary, values, contradictions, cycles };
412
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Phase 70 — Stratified Political Simulation ("Vassal Web" Layer)
3
+ *
4
+ * Introduces a `VassalNode` between the individual Entity and the Polity.
5
+ * Seven loyalty types with distinct step dynamics allow political crises
6
+ * (rebellions, succession disputes, noble defections) to emerge from
7
+ * simulation state rather than scripted events.
8
+ *
9
+ * No kernel import — pure data-management module, fixed-point arithmetic only.
10
+ */
11
+ import type { Q } from "./units.js";
12
+ import type { Polity } from "./polity.js";
13
+ /**
14
+ * The basis of a vassal's loyalty to their liege.
15
+ *
16
+ * Determines how `stepVassalLoyalty` updates `loyaltyQ` each campaign tick
17
+ * and what events cause loyalty to spike or collapse.
18
+ */
19
+ export type LoyaltyType = "ideological" | "transactional" | "terrified" | "honor_bound" | "opportunistic" | "kin_bound" | "ideological_rival";
20
+ export interface VassalLoyalty {
21
+ type: LoyaltyType;
22
+ /** Current loyalty level [0, SCALE.Q]. q(0) = open rebellion; q(1) = unconditional. */
23
+ loyaltyQ: Q;
24
+ /**
25
+ * Accumulated grievances [0, SCALE.Q].
26
+ * Drains loyalty each tick; applied by `applyGrievanceEvent` or set directly by the host.
27
+ */
28
+ grievance_Q: Q;
29
+ }
30
+ export interface VassalNode {
31
+ /** Unique identifier, e.g. "house_harlow", "guild_weavers". */
32
+ id: string;
33
+ /** The liege polity this vassal owes service to. */
34
+ polityId: string;
35
+ /** Fractional share of polity territory controlled [0, SCALE.Q]. */
36
+ territory_Q: Q;
37
+ /**
38
+ * Fractional share of polity military strength contracted from this vassal
39
+ * when loyalty is full [0, SCALE.Q].
40
+ */
41
+ military_Q: Q;
42
+ /** Vassal's own treasury reserves in cost-units (independent of polity). */
43
+ treasury_cu: number;
44
+ loyalty: VassalLoyalty;
45
+ }
46
+ export interface VassalContribution {
47
+ /** Actual troop fraction provided this tick (after loyalty scaling). */
48
+ troops_Q: Q;
49
+ /** Actual treasury contribution in cost-units this tick (after loyalty scaling). */
50
+ treasury_cu: number;
51
+ }
52
+ export interface SuccessionResult {
53
+ /** The heir identifier that was evaluated. */
54
+ heirId: string;
55
+ /** True if the heir secured majority military support; false = contested succession. */
56
+ successful: boolean;
57
+ /** Weighted military support fraction for the heir [0, SCALE.Q]. */
58
+ supportQ: Q;
59
+ /**
60
+ * Loyalty delta for each vassal this tick (id → delta Q, can be negative).
61
+ * Supporters of the winning side gain loyalty; supporters of the losing side lose it.
62
+ */
63
+ loyaltyDeltas: Map<string, Q>;
64
+ }
65
+ /** Natural grievance decay per tick for most loyalty types. */
66
+ export declare const GRIEVANCE_DECAY_Q: Q;
67
+ /** Slower natural decay for honor_bound — oaths and grudges linger. */
68
+ export declare const GRIEVANCE_DECAY_HONOR_Q: Q;
69
+ /** Hard loyalty ceiling for terrified vassals (fear ≠ devotion). */
70
+ export declare const TERRIFIED_MAX_LOYALTY_Q: Q;
71
+ /** Loyalty target kin_bound vassals gravitate toward in the absence of grievance. */
72
+ export declare const KIN_BOUND_BASE_Q: Q;
73
+ /** Constant loyalty decay per tick for ideological_rival — undermining is ceaseless. */
74
+ export declare const RIVAL_DECAY_Q: Q;
75
+ /** Below this loyalty Q, contribution drops to zero (passive defiance). */
76
+ export declare const CONTRIBUTION_FLOOR_Q: Q;
77
+ /** At or above this loyalty Q, full contribution is provided. */
78
+ export declare const CONTRIBUTION_FULL_Q: Q;
79
+ /**
80
+ * Treasury difference (in cost-units) that shifts transactional loyalty by q(0.40).
81
+ * A liege with 50 000 cu more than the richest rival earns maximum loyalty advantage.
82
+ */
83
+ export declare const TRANSACTIONAL_TREASURY_NORM = 50000;
84
+ /** eventSeed salt for succession crisis rolls. */
85
+ export declare const SUCCESSION_SALT: number;
86
+ /**
87
+ * Apply a grievance event to a vassal and return the updated node.
88
+ * Typical events: broken promise, tax hike, kin killed in service, territory seized.
89
+ *
90
+ * @param delta_Q Grievance increment [0, SCALE.Q]; positive = more aggrieved.
91
+ */
92
+ export declare function applyGrievanceEvent(node: VassalNode, delta_Q: Q): VassalNode;
93
+ /**
94
+ * Advance a vassal's loyalty state by one campaign tick.
95
+ *
96
+ * Loyalty dynamics are determined entirely by the vassal's `LoyaltyType`.
97
+ * Returns a new `VassalNode` — the input is never mutated.
98
+ *
99
+ * @param node Current vassal state.
100
+ * @param liege The liege polity.
101
+ * @param rivals All other polities the vassal might consider as alternatives.
102
+ * @param worldSeed Deterministic RNG seed (unused directly — reserved for future variance).
103
+ * @param tick Current campaign tick.
104
+ */
105
+ export declare function stepVassalLoyalty(node: VassalNode, liege: Polity, rivals: readonly Polity[], _worldSeed: number, _tick: number): VassalNode;
106
+ /**
107
+ * Compute the actual troop and treasury contribution a vassal provides this tick.
108
+ *
109
+ * - `loyaltyQ >= CONTRIBUTION_FULL_Q` (q(0.50)): full contracted contribution.
110
+ * - `loyaltyQ <= CONTRIBUTION_FLOOR_Q` (q(0.20)): zero (passive defiance).
111
+ * - Between floor and full: linear interpolation.
112
+ */
113
+ export declare function computeVassalContribution(node: VassalNode): VassalContribution;
114
+ /**
115
+ * Aggregate the effective military strength a polity can actually field,
116
+ * accounting for disloyal vassals.
117
+ *
118
+ * Pass this value as the force multiplier to `resolveTacticalEngagement` (Phase 69)
119
+ * instead of the polity's nominal `militaryStrength_Q`.
120
+ *
121
+ * ```typescript
122
+ * const effective = computeEffectiveMilitary(vassals);
123
+ * // scale polity's military by effective fraction
124
+ * const scaledForce = mulDiv(polity.militaryStrength_Q, effective, SCALE.Q);
125
+ * ```
126
+ */
127
+ export declare function computeEffectiveMilitary(vassals: readonly VassalNode[]): Q;
128
+ /**
129
+ * Compute the rebellion risk for a vassal [0, SCALE.Q].
130
+ *
131
+ * Risk = 70 % from low loyalty + 30 % from high grievance.
132
+ * Use as an AI query or host-side event trigger threshold.
133
+ */
134
+ export declare function detectRebellionRisk(node: VassalNode): Q;
135
+ /**
136
+ * Resolve a succession crisis: determine whether the intended heir secures
137
+ * enough vassal support to rule unchallenged.
138
+ *
139
+ * Each vassal "votes" based on their loyalty type, current loyalty level,
140
+ * and a deterministic roll from `eventSeed`. The vote is weighted by
141
+ * `military_Q` so powerful nobles matter more.
142
+ *
143
+ * On success: supporters gain +q(0.05) loyalty; opponents lose −q(0.08).
144
+ * On failure: deltas are inverted (the pretender's faction benefits).
145
+ *
146
+ * @param polity The polity undergoing succession.
147
+ * @param vassals Current vassal roster.
148
+ * @param heirId Identifier of the intended heir.
149
+ * @param worldSeed
150
+ * @param tick
151
+ */
152
+ export declare function resolveSuccessionCrisis(polity: Polity, vassals: readonly VassalNode[], heirId: string, worldSeed: number, tick: number): SuccessionResult;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Phase 70 — Stratified Political Simulation ("Vassal Web" Layer)
3
+ *
4
+ * Introduces a `VassalNode` between the individual Entity and the Polity.
5
+ * Seven loyalty types with distinct step dynamics allow political crises
6
+ * (rebellions, succession disputes, noble defections) to emerge from
7
+ * simulation state rather than scripted events.
8
+ *
9
+ * No kernel import — pure data-management module, fixed-point arithmetic only.
10
+ */
11
+ import { SCALE, q, clampQ, mulDiv } from "./units.js";
12
+ import { eventSeed, hashString } from "./sim/seeds.js";
13
+ // ── Constants ─────────────────────────────────────────────────────────────────
14
+ /** Natural grievance decay per tick for most loyalty types. */
15
+ export const GRIEVANCE_DECAY_Q = q(0.010);
16
+ /** Slower natural decay for honor_bound — oaths and grudges linger. */
17
+ export const GRIEVANCE_DECAY_HONOR_Q = q(0.004);
18
+ /** Hard loyalty ceiling for terrified vassals (fear ≠ devotion). */
19
+ export const TERRIFIED_MAX_LOYALTY_Q = q(0.70);
20
+ /** Loyalty target kin_bound vassals gravitate toward in the absence of grievance. */
21
+ export const KIN_BOUND_BASE_Q = q(0.85);
22
+ /** Constant loyalty decay per tick for ideological_rival — undermining is ceaseless. */
23
+ export const RIVAL_DECAY_Q = q(0.005);
24
+ /** Below this loyalty Q, contribution drops to zero (passive defiance). */
25
+ export const CONTRIBUTION_FLOOR_Q = q(0.20);
26
+ /** At or above this loyalty Q, full contribution is provided. */
27
+ export const CONTRIBUTION_FULL_Q = q(0.50);
28
+ /**
29
+ * Treasury difference (in cost-units) that shifts transactional loyalty by q(0.40).
30
+ * A liege with 50 000 cu more than the richest rival earns maximum loyalty advantage.
31
+ */
32
+ export const TRANSACTIONAL_TREASURY_NORM = 50_000;
33
+ /** eventSeed salt for succession crisis rolls. */
34
+ export const SUCCESSION_SALT = 0x5ECC;
35
+ // ── Grievance events ──────────────────────────────────────────────────────────
36
+ /**
37
+ * Apply a grievance event to a vassal and return the updated node.
38
+ * Typical events: broken promise, tax hike, kin killed in service, territory seized.
39
+ *
40
+ * @param delta_Q Grievance increment [0, SCALE.Q]; positive = more aggrieved.
41
+ */
42
+ export function applyGrievanceEvent(node, delta_Q) {
43
+ return {
44
+ ...node,
45
+ loyalty: {
46
+ ...node.loyalty,
47
+ grievance_Q: clampQ(node.loyalty.grievance_Q + delta_Q, 0, SCALE.Q),
48
+ },
49
+ };
50
+ }
51
+ // ── Loyalty step ──────────────────────────────────────────────────────────────
52
+ /**
53
+ * Advance a vassal's loyalty state by one campaign tick.
54
+ *
55
+ * Loyalty dynamics are determined entirely by the vassal's `LoyaltyType`.
56
+ * Returns a new `VassalNode` — the input is never mutated.
57
+ *
58
+ * @param node Current vassal state.
59
+ * @param liege The liege polity.
60
+ * @param rivals All other polities the vassal might consider as alternatives.
61
+ * @param worldSeed Deterministic RNG seed (unused directly — reserved for future variance).
62
+ * @param tick Current campaign tick.
63
+ */
64
+ export function stepVassalLoyalty(node, liege, rivals, _worldSeed, _tick) {
65
+ const { loyalty } = node;
66
+ const { type, loyaltyQ, grievance_Q } = loyalty;
67
+ // ── Step 1: decay grievance naturally ─────────────────────────────────────
68
+ const decayRate = type === "honor_bound" ? GRIEVANCE_DECAY_HONOR_Q : GRIEVANCE_DECAY_Q;
69
+ const newGrievance = clampQ(grievance_Q - decayRate, 0, SCALE.Q);
70
+ // ── Step 2: compute loyalty delta by type ─────────────────────────────────
71
+ let deltaNum = 0;
72
+ switch (type) {
73
+ case "ideological": {
74
+ // Slow ideological drift: grievance has minimal effect; passive recovery when content.
75
+ if (newGrievance > q(0.30)) {
76
+ deltaNum = -mulDiv(newGrievance, q(0.08), SCALE.Q);
77
+ }
78
+ else {
79
+ // Gentle recovery toward conviction
80
+ deltaNum = loyaltyQ < SCALE.Q ? q(0.003) : 0;
81
+ }
82
+ break;
83
+ }
84
+ case "transactional": {
85
+ // Target loyalty tracks how much richer the liege is than the best-paying rival.
86
+ const maxRivalTreasury = rivals.reduce((best, r) => Math.max(best, r.treasury_cu), 0);
87
+ const diff = Math.max(-TRANSACTIONAL_TREASURY_NORM, Math.min(TRANSACTIONAL_TREASURY_NORM, liege.treasury_cu - maxRivalTreasury));
88
+ const targetQ = clampQ(q(0.50) + Math.round(diff * q(0.40) / TRANSACTIONAL_TREASURY_NORM), 0, SCALE.Q);
89
+ // Move 5 %/tick toward target; grievance also bleeds loyalty.
90
+ deltaNum = mulDiv(targetQ - loyaltyQ, q(0.05), SCALE.Q);
91
+ deltaNum -= mulDiv(newGrievance, q(0.20), SCALE.Q);
92
+ break;
93
+ }
94
+ case "terrified": {
95
+ // Instant collapse if the liege is no stronger than the vassal.
96
+ if (liege.militaryStrength_Q <= node.military_Q) {
97
+ return {
98
+ ...node,
99
+ loyalty: { type, loyaltyQ: q(0.0), grievance_Q: newGrievance },
100
+ };
101
+ }
102
+ // Slow recovery toward the terrified ceiling; grievance undermines it.
103
+ deltaNum = mulDiv(TERRIFIED_MAX_LOYALTY_Q - loyaltyQ, q(0.03), SCALE.Q);
104
+ deltaNum -= mulDiv(newGrievance, q(0.25), SCALE.Q);
105
+ break;
106
+ }
107
+ case "honor_bound": {
108
+ // Heavy grievance causes sharp loyalty drain; without it, loyalty recovers.
109
+ if (grievance_Q > q(0.40)) {
110
+ deltaNum = -mulDiv(grievance_Q, q(0.30), SCALE.Q);
111
+ }
112
+ else {
113
+ deltaNum = loyaltyQ < q(0.90) ? q(0.008) : 0;
114
+ deltaNum -= mulDiv(newGrievance, q(0.10), SCALE.Q);
115
+ }
116
+ break;
117
+ }
118
+ case "opportunistic": {
119
+ // Target loyalty = liege.moraleQ: stays loyal when the liege is thriving,
120
+ // drifts away when the liege looks weak compared to rivals.
121
+ // Rivals pull target down proportionally if any of them out-morale the liege.
122
+ let maxRivalMorale = 0;
123
+ for (const r of rivals)
124
+ if (r.moraleQ > maxRivalMorale)
125
+ maxRivalMorale = r.moraleQ;
126
+ // If a rival outperforms the liege, drag the target below liege.moraleQ.
127
+ const relativeQ = maxRivalMorale > liege.moraleQ
128
+ ? clampQ(mulDiv(liege.moraleQ, SCALE.Q, maxRivalMorale), 0, SCALE.Q)
129
+ : liege.moraleQ;
130
+ deltaNum = mulDiv(relativeQ - loyaltyQ, q(0.08), SCALE.Q);
131
+ deltaNum -= mulDiv(newGrievance, q(0.15), SCALE.Q);
132
+ break;
133
+ }
134
+ case "kin_bound": {
135
+ // Very stable; slow recovery; grievance has half the normal weight.
136
+ deltaNum = loyaltyQ < KIN_BOUND_BASE_Q ? q(0.01) : 0;
137
+ deltaNum -= mulDiv(newGrievance, q(0.10), SCALE.Q);
138
+ break;
139
+ }
140
+ case "ideological_rival": {
141
+ // Constant, inexorable decay — no incentive can reverse it.
142
+ deltaNum = -RIVAL_DECAY_Q;
143
+ break;
144
+ }
145
+ }
146
+ const rawLoyalty = clampQ(loyaltyQ + deltaNum, 0, SCALE.Q);
147
+ const newLoyalty = type === "terrified"
148
+ ? clampQ(rawLoyalty, 0, TERRIFIED_MAX_LOYALTY_Q)
149
+ : rawLoyalty;
150
+ return {
151
+ ...node,
152
+ loyalty: { type, loyaltyQ: newLoyalty, grievance_Q: newGrievance },
153
+ };
154
+ }
155
+ // ── Contribution ──────────────────────────────────────────────────────────────
156
+ /**
157
+ * Compute the actual troop and treasury contribution a vassal provides this tick.
158
+ *
159
+ * - `loyaltyQ >= CONTRIBUTION_FULL_Q` (q(0.50)): full contracted contribution.
160
+ * - `loyaltyQ <= CONTRIBUTION_FLOOR_Q` (q(0.20)): zero (passive defiance).
161
+ * - Between floor and full: linear interpolation.
162
+ */
163
+ export function computeVassalContribution(node) {
164
+ const { loyaltyQ } = node.loyalty;
165
+ const range = CONTRIBUTION_FULL_Q - CONTRIBUTION_FLOOR_Q; // q(0.30)
166
+ let factor;
167
+ if (loyaltyQ >= CONTRIBUTION_FULL_Q) {
168
+ factor = SCALE.Q;
169
+ }
170
+ else if (loyaltyQ <= CONTRIBUTION_FLOOR_Q) {
171
+ factor = 0;
172
+ }
173
+ else {
174
+ factor = Math.round((loyaltyQ - CONTRIBUTION_FLOOR_Q) * SCALE.Q / range);
175
+ }
176
+ return {
177
+ troops_Q: mulDiv(node.military_Q, factor, SCALE.Q),
178
+ treasury_cu: Math.round(node.treasury_cu * factor / SCALE.Q),
179
+ };
180
+ }
181
+ /**
182
+ * Aggregate the effective military strength a polity can actually field,
183
+ * accounting for disloyal vassals.
184
+ *
185
+ * Pass this value as the force multiplier to `resolveTacticalEngagement` (Phase 69)
186
+ * instead of the polity's nominal `militaryStrength_Q`.
187
+ *
188
+ * ```typescript
189
+ * const effective = computeEffectiveMilitary(vassals);
190
+ * // scale polity's military by effective fraction
191
+ * const scaledForce = mulDiv(polity.militaryStrength_Q, effective, SCALE.Q);
192
+ * ```
193
+ */
194
+ export function computeEffectiveMilitary(vassals) {
195
+ return clampQ(vassals.reduce((sum, v) => sum + computeVassalContribution(v).troops_Q, 0), 0, SCALE.Q);
196
+ }
197
+ // ── Rebellion risk ────────────────────────────────────────────────────────────
198
+ /**
199
+ * Compute the rebellion risk for a vassal [0, SCALE.Q].
200
+ *
201
+ * Risk = 70 % from low loyalty + 30 % from high grievance.
202
+ * Use as an AI query or host-side event trigger threshold.
203
+ */
204
+ export function detectRebellionRisk(node) {
205
+ const { loyaltyQ, grievance_Q } = node.loyalty;
206
+ const loyaltyContrib = mulDiv(SCALE.Q - loyaltyQ, q(0.70), SCALE.Q);
207
+ const grievanceContrib = mulDiv(grievance_Q, q(0.30), SCALE.Q);
208
+ return clampQ(loyaltyContrib + grievanceContrib, 0, SCALE.Q);
209
+ }
210
+ // ── Succession crisis ─────────────────────────────────────────────────────────
211
+ /**
212
+ * Resolve a succession crisis: determine whether the intended heir secures
213
+ * enough vassal support to rule unchallenged.
214
+ *
215
+ * Each vassal "votes" based on their loyalty type, current loyalty level,
216
+ * and a deterministic roll from `eventSeed`. The vote is weighted by
217
+ * `military_Q` so powerful nobles matter more.
218
+ *
219
+ * On success: supporters gain +q(0.05) loyalty; opponents lose −q(0.08).
220
+ * On failure: deltas are inverted (the pretender's faction benefits).
221
+ *
222
+ * @param polity The polity undergoing succession.
223
+ * @param vassals Current vassal roster.
224
+ * @param heirId Identifier of the intended heir.
225
+ * @param worldSeed
226
+ * @param tick
227
+ */
228
+ export function resolveSuccessionCrisis(polity, vassals, heirId, worldSeed, tick) {
229
+ const polityHash = hashString(polity.id);
230
+ const heirHash = hashString(heirId);
231
+ let supportMilitary = 0;
232
+ let totalMilitary = 0;
233
+ const supportsHeir = new Map();
234
+ for (const vassal of vassals) {
235
+ const vassalHash = hashString(vassal.id);
236
+ const seed = eventSeed(worldSeed, tick, vassalHash, heirHash, (polityHash + SUCCESSION_SALT) & 0x7fff);
237
+ const roll = seed % (SCALE.Q + 1); // 0 .. SCALE.Q
238
+ // Support threshold: vassal supports heir if roll < threshold
239
+ let threshold;
240
+ switch (vassal.loyalty.type) {
241
+ case "ideological":
242
+ // Ideologically committed to the current regime → likely backs the heir.
243
+ threshold = q(0.70);
244
+ break;
245
+ case "transactional":
246
+ // Support proportional to current loyalty (loyalty ≈ economic satisfaction).
247
+ threshold = vassal.loyalty.loyaltyQ;
248
+ break;
249
+ case "terrified":
250
+ // Terrified vassals back whoever they think will win; proxy: loyalty.
251
+ threshold = vassal.loyalty.loyaltyQ;
252
+ break;
253
+ case "honor_bound":
254
+ // Oath-bound: strong support unless grievance has eroded trust.
255
+ threshold = vassal.loyalty.grievance_Q > q(0.60) ? q(0.25) : q(0.80);
256
+ break;
257
+ case "opportunistic":
258
+ // Genuinely uncertain — 50/50 until the outcome is clear.
259
+ threshold = q(0.50);
260
+ break;
261
+ case "kin_bound":
262
+ // Family ties: strongly backs the heir.
263
+ threshold = clampQ(vassal.loyalty.loyaltyQ + q(0.10), 0, SCALE.Q);
264
+ break;
265
+ case "ideological_rival":
266
+ // Almost never supports the heir — opposition is their purpose.
267
+ threshold = q(0.10);
268
+ break;
269
+ }
270
+ const backs = roll < threshold;
271
+ supportsHeir.set(vassal.id, backs);
272
+ totalMilitary += vassal.military_Q;
273
+ if (backs)
274
+ supportMilitary += vassal.military_Q;
275
+ }
276
+ const supportQ = totalMilitary > 0
277
+ ? clampQ(Math.round(supportMilitary * SCALE.Q / totalMilitary), 0, SCALE.Q)
278
+ : q(0.0);
279
+ const successful = supportQ > q(0.50);
280
+ const loyaltyDeltas = new Map();
281
+ for (const vassal of vassals) {
282
+ const backed = supportsHeir.get(vassal.id) ?? false;
283
+ // Winners gain loyalty; losers lose it (winning/losing is relative to outcome).
284
+ const backedHeir = backed;
285
+ const onWinningSide = successful ? backedHeir : !backedHeir;
286
+ loyaltyDeltas.set(vassal.id, (onWinningSide ? q(0.05) : -q(0.08)));
287
+ }
288
+ return { heirId, successful, supportQ, loyaltyDeltas };
289
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",