@its-not-rocket-science/ananke 0.1.13 → 0.1.15

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.
@@ -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,58 @@
1
+ import type { WorldState } from "./sim/world.js";
2
+ interface PushExports {
3
+ MAX_ENTITIES: WebAssembly.Global;
4
+ writeEntity: (slot: number, posX: number, posY: number, alive: number) => void;
5
+ readDvX: (slot: number) => number;
6
+ readDvY: (slot: number) => number;
7
+ stepRepulsionPairs: (n: number, radius_m: number, repelAccel_mps2: number) => void;
8
+ }
9
+ interface InjuryExports {
10
+ MAX_ENTITIES: WebAssembly.Global;
11
+ writeVitals: (slot: number, fluidLoss: number, shock: number, consciousness: number, dead: number, fatigue: number, suffocation: number) => void;
12
+ writeRegion: (slot: number, r: number, bleedingRate: number, structuralDamage: number, internalDamage: number, surfaceDamage: number) => void;
13
+ readFluidLoss: (slot: number) => number;
14
+ readShock: (slot: number) => number;
15
+ readConsciousness: (slot: number) => number;
16
+ readDead: (slot: number) => number;
17
+ stepBleedAndShock: (n: number) => void;
18
+ }
19
+ export interface WasmEntityReport {
20
+ entityId: number;
21
+ /** Repulsion velocity delta computed by WASM push kernel (SCALE.mps units). */
22
+ pushDvX: number;
23
+ pushDvY: number;
24
+ /** Projected injury state after one WASM injury tick. */
25
+ projFluidLoss: number;
26
+ projShock: number;
27
+ projConsciousness: number;
28
+ projDead: boolean;
29
+ }
30
+ export interface WasmStepReport {
31
+ tick: number;
32
+ entities: WasmEntityReport[];
33
+ /** True if WASM is available and ran successfully. */
34
+ ok: boolean;
35
+ summary: string;
36
+ }
37
+ export declare class WasmKernel {
38
+ private readonly push;
39
+ private readonly injury;
40
+ private static readonly PUSH_RADIUS_M;
41
+ private static readonly PUSH_REPEL_MPS2;
42
+ constructor(push: PushExports, injury: InjuryExports);
43
+ /**
44
+ * Run WASM push + injury steps on the current world state (shadow mode — does not
45
+ * mutate world). Returns a per-entity report and a one-line summary string.
46
+ *
47
+ * Call this after stepWorld() each tick for validation / diagnostics.
48
+ */
49
+ shadowStep(world: WorldState, tick: number): WasmStepReport;
50
+ }
51
+ /**
52
+ * Load push.wasm and injury.wasm from dist/as/ (co-located with this compiled module)
53
+ * and return a WasmKernel ready for use.
54
+ *
55
+ * Throws if the WASM files are not found (e.g. npm run build:wasm:all not yet run).
56
+ */
57
+ export declare function loadWasmKernel(): Promise<WasmKernel>;
58
+ export {};