@its-not-rocket-science/ananke 0.1.34 → 0.1.36

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,40 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.36] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 91 · Technology Research** (`src/research.ts`)
14
+ - `ResearchState { polityId, progress }` — per-polity accumulator stored externally by the host.
15
+ - `RESEARCH_POINTS_REQUIRED: Record<number, number>` — numeric TechEra keys; Prehistoric 2 k → FarFuture 5 M; DeepSpace absent (no advancement).
16
+ - `computeDailyResearchPoints(polity, bonusPoints?)` → integer points/day: `baseUnits = max(1, floor(pop / RESEARCH_POP_DIVISOR=5000))`; `stabilityFactor ∈ [5000, 10000]`; `max(1, round(baseUnits × stabilityFactor / SCALE.Q)) + bonusPoints`.
17
+ - `stepResearch(polity, state, elapsedDays, bonusPoints?)` → `ResearchStepResult`: accumulates `daily × elapsedDays`; on threshold: increments `polity.techEra`, calls `deriveMilitaryStrength`, carries surplus; no-op at DeepSpace.
18
+ - `investInResearch(polity, state, amount)` — drains treasury at `RESEARCH_COST_PER_POINT = 10` cu/point; capped at available treasury; returns points added.
19
+ - `computeKnowledgeDiffusion(sourcePolity, targetPolity, contactIntensity_Q)` → bonus points/day: fires when `source.techEra > target.techEra`; `sourceDaily × eraDiff × KNOWLEDGE_DIFFUSION_RATE_Q(q(0.10)) × contactIntensity / SCALE.Q²`.
20
+ - `computeResearchProgress_Q(polity, state)` → Q [0, SCALE.Q]: fraction toward next era; SCALE.Q at DeepSpace.
21
+ - `estimateDaysToNextEra(polity, state, bonusPoints?)` → ceiling days; Infinity at DeepSpace or zero rate.
22
+ - Added `./research` subpath export to `package.json`.
23
+ - 57 new tests; 4,779 total. Coverage maintained above all thresholds.
24
+
25
+ ---
26
+
27
+ ## [0.1.35] — 2026-03-26
28
+
29
+ ### Added
30
+
31
+ - **Phase 90 · Civil Unrest & Rebellion** (`src/unrest.ts`)
32
+ - `UnrestFactors { faminePressure_Q?, epidemicPressure_Q?, heresyRisk_Q?, weakestBond_Q? }` — optional pressure inputs from Phases 85/87/88/79.
33
+ - `computeUnrestLevel(polity, factors?)` → Q: weighted composite of morale deficit (×q(0.30)), stability deficit (×q(0.25)), famine (×q(0.20)), epidemic (×q(0.10)), heresy (×q(0.10)), feudal bond deficit (×q(0.05)).
34
+ - `UNREST_ACTION_THRESHOLD_Q = q(0.30)` — excess above this drains morale/stability.
35
+ - `REBELLION_THRESHOLD_Q = q(0.65)` — above this `rebellionRisk` flag is set.
36
+ - `stepUnrest(polity, unrestLevel_Q, elapsedDays)` → `UnrestStepResult`: drains morale at `excess × UNREST_MORALE_DRAIN_Q = q(0.005)` per day, stability at `q(0.003)` per day; mutates polity in place; floor at 0.
37
+ - `resolveRebellion(polity, worldSeed, tick)` → `RebellionResult`: deterministic via `eventSeed`; outcomes `"quelled" | "uprising" | "civil_war"` weighted by polity `militaryStrength_Q` vs. unrest roll; each outcome applies morale/stability penalties and treasury raid (`REBELLION_TREASURY_RAID_Q = q(0.15)`; civil war = 2×).
38
+ - Added `./unrest` subpath export to `package.json`.
39
+ - 35 new tests; 4,722 total. Coverage maintained above all thresholds.
40
+
41
+ ---
42
+
9
43
  ## [0.1.34] — 2026-03-26
10
44
 
11
45
  ### Added
@@ -0,0 +1,103 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /** Per-polity research progress. Store one externally per polity. */
4
+ export interface ResearchState {
5
+ polityId: string;
6
+ /** Accumulated research points toward the next era. */
7
+ progress: number;
8
+ }
9
+ /** Result returned by `stepResearch`. */
10
+ export interface ResearchStepResult {
11
+ /** Raw points added this step. */
12
+ pointsGained: number;
13
+ /** Whether the polity advanced to a new era this step. */
14
+ advanced: boolean;
15
+ /** New era if `advanced === true`, otherwise `undefined`. */
16
+ newEra?: number;
17
+ }
18
+ /**
19
+ * Population divisor for base daily research units.
20
+ * `baseUnits = floor(population / RESEARCH_POP_DIVISOR)` — minimum 1.
21
+ */
22
+ export declare const RESEARCH_POP_DIVISOR = 5000;
23
+ /**
24
+ * Research points required to advance FROM each TechEra to the next.
25
+ * Keyed by numeric TechEra value. `Infinity` (absent) = max era, no advancement.
26
+ */
27
+ export declare const RESEARCH_POINTS_REQUIRED: Record<number, number>;
28
+ /**
29
+ * Treasury cost per research point when using `investInResearch`.
30
+ * 10 cost-units = 1 research point.
31
+ */
32
+ export declare const RESEARCH_COST_PER_POINT = 10;
33
+ /**
34
+ * Fraction of the source polity's daily research rate that diffuses to a
35
+ * less-advanced trade partner per era of difference.
36
+ */
37
+ export declare const KNOWLEDGE_DIFFUSION_RATE_Q: Q;
38
+ /** Create a fresh `ResearchState` with zero progress. */
39
+ export declare function createResearchState(polityId: string): ResearchState;
40
+ /**
41
+ * Points required to advance from the polity's current era.
42
+ * Returns `Infinity` at max era (no advancement possible).
43
+ */
44
+ export declare function pointsRequiredForNextEra(polity: Polity): number;
45
+ /**
46
+ * Compute the daily research rate for a polity [integer points/day].
47
+ *
48
+ * Formula:
49
+ * baseUnits = max(1, floor(population / RESEARCH_POP_DIVISOR))
50
+ * stabilityFactor = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
51
+ * ∈ [q(0.50), q(1.00)] = [5000, 10000]
52
+ * dailyPoints = max(1, round(baseUnits × stabilityFactor / SCALE.Q))
53
+ *
54
+ * @param bonusPoints Additional flat bonus points per day (e.g., from
55
+ * knowledge diffusion or Phase-89 infrastructure).
56
+ */
57
+ export declare function computeDailyResearchPoints(polity: Polity, bonusPoints?: number): number;
58
+ /**
59
+ * Advance research for `elapsedDays` days.
60
+ *
61
+ * Adds `computeDailyResearchPoints(polity) × elapsedDays` to `state.progress`.
62
+ * When progress meets or exceeds `pointsRequiredForNextEra`:
63
+ * - Excess progress carries over.
64
+ * - `polity.techEra` is incremented.
65
+ * - `deriveMilitaryStrength` is refreshed.
66
+ *
67
+ * Only one era advancement occurs per call regardless of elapsed days.
68
+ * At DeepSpace (max era) the call is a no-op.
69
+ *
70
+ * @param bonusPoints Flat daily bonus from knowledge diffusion or infrastructure.
71
+ */
72
+ export declare function stepResearch(polity: Polity, state: ResearchState, elapsedDays: number, bonusPoints?: number): ResearchStepResult;
73
+ /**
74
+ * Invest treasury into research, immediately adding points.
75
+ *
76
+ * Rate: `RESEARCH_COST_PER_POINT` cost-units = 1 point.
77
+ * Drains `min(amount, polity.treasury_cu)`. No-ops if treasury is empty.
78
+ *
79
+ * Returns the actual number of research points added.
80
+ */
81
+ export declare function investInResearch(polity: Polity, state: ResearchState, amount: number): number;
82
+ /**
83
+ * Compute daily knowledge diffusion bonus that a source polity grants to a
84
+ * less-advanced target polity through trade or diplomatic contact.
85
+ *
86
+ * Diffusion fires only when `sourcePolity.techEra > targetPolity.techEra`.
87
+ *
88
+ * Formula: `round(sourceDaily × eraDiff × DIFFUSION_RATE × contactIntensity / SCALE.Q²)`
89
+ *
90
+ * @param contactIntensity_Q Trade or diplomatic contact [0, SCALE.Q].
91
+ * Derive from Phase-83 route efficiency or Phase-80 treaty strength.
92
+ */
93
+ export declare function computeKnowledgeDiffusion(sourcePolity: Polity, targetPolity: Polity, contactIntensity_Q: Q): number;
94
+ /**
95
+ * Return current research progress as a Q fraction [0, SCALE.Q] toward the next era.
96
+ * Returns `SCALE.Q` at max era (DeepSpace).
97
+ */
98
+ export declare function computeResearchProgress_Q(polity: Polity, state: ResearchState): Q;
99
+ /**
100
+ * Estimate days until the next era advance at the current daily research rate.
101
+ * Returns `Infinity` at max era or when rate is zero.
102
+ */
103
+ export declare function estimateDaysToNextEra(polity: Polity, state: ResearchState, bonusPoints?: number): number;
@@ -0,0 +1,175 @@
1
+ // src/research.ts — Phase 91: Technology Research
2
+ //
3
+ // Polities accumulate research points from their population and stability.
4
+ // When accumulated points reach the era threshold the polity advances to the
5
+ // next TechEra; treasury investment buys additional progress; contact with a
6
+ // more advanced polity (via Phase-83 trade routes) grants knowledge diffusion.
7
+ //
8
+ // Design:
9
+ // - Pure data layer — no Entity fields, no kernel changes.
10
+ // - `ResearchState` is separate from Polity; host stores one per polity.
11
+ // - Uses numeric TechEra values (0–8) from Phase-11 tech.ts.
12
+ // - `stepResearch` mutates both `state.progress` and `polity.techEra`.
13
+ // - All arithmetic is integer fixed-point; no floating-point accumulation.
14
+ //
15
+ // Integration:
16
+ // Phase 11 (Tech): TechEra numeric enum — advancement increments polity.techEra.
17
+ // Phase 61 (Polity): population, stabilityQ, treasury_cu are read/mutated.
18
+ // Phase 83 (Trade): contactIntensity_Q drives knowledge diffusion.
19
+ // Phase 89 (Infra): hosts may add infrastructure bonuses to daily rate.
20
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
21
+ import { deriveMilitaryStrength } from "./polity.js";
22
+ import { TechEra } from "./sim/tech.js";
23
+ // ── Constants ─────────────────────────────────────────────────────────────────
24
+ /**
25
+ * Population divisor for base daily research units.
26
+ * `baseUnits = floor(population / RESEARCH_POP_DIVISOR)` — minimum 1.
27
+ */
28
+ export const RESEARCH_POP_DIVISOR = 5_000;
29
+ /**
30
+ * Research points required to advance FROM each TechEra to the next.
31
+ * Keyed by numeric TechEra value. `Infinity` (absent) = max era, no advancement.
32
+ */
33
+ export const RESEARCH_POINTS_REQUIRED = {
34
+ [TechEra.Prehistoric]: 2_000,
35
+ [TechEra.Ancient]: 8_000,
36
+ [TechEra.Medieval]: 30_000,
37
+ [TechEra.EarlyModern]: 80_000,
38
+ [TechEra.Industrial]: 200_000,
39
+ [TechEra.Modern]: 500_000,
40
+ [TechEra.NearFuture]: 1_500_000,
41
+ [TechEra.FarFuture]: 5_000_000,
42
+ // TechEra.DeepSpace (8): no entry → no advancement
43
+ };
44
+ /**
45
+ * Treasury cost per research point when using `investInResearch`.
46
+ * 10 cost-units = 1 research point.
47
+ */
48
+ export const RESEARCH_COST_PER_POINT = 10;
49
+ /**
50
+ * Fraction of the source polity's daily research rate that diffuses to a
51
+ * less-advanced trade partner per era of difference.
52
+ */
53
+ export const KNOWLEDGE_DIFFUSION_RATE_Q = q(0.10);
54
+ // ── Factory ───────────────────────────────────────────────────────────────────
55
+ /** Create a fresh `ResearchState` with zero progress. */
56
+ export function createResearchState(polityId) {
57
+ return { polityId, progress: 0 };
58
+ }
59
+ // ── Rate computation ──────────────────────────────────────────────────────────
60
+ /**
61
+ * Points required to advance from the polity's current era.
62
+ * Returns `Infinity` at max era (no advancement possible).
63
+ */
64
+ export function pointsRequiredForNextEra(polity) {
65
+ return RESEARCH_POINTS_REQUIRED[polity.techEra] ?? Infinity;
66
+ }
67
+ /**
68
+ * Compute the daily research rate for a polity [integer points/day].
69
+ *
70
+ * Formula:
71
+ * baseUnits = max(1, floor(population / RESEARCH_POP_DIVISOR))
72
+ * stabilityFactor = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
73
+ * ∈ [q(0.50), q(1.00)] = [5000, 10000]
74
+ * dailyPoints = max(1, round(baseUnits × stabilityFactor / SCALE.Q))
75
+ *
76
+ * @param bonusPoints Additional flat bonus points per day (e.g., from
77
+ * knowledge diffusion or Phase-89 infrastructure).
78
+ */
79
+ export function computeDailyResearchPoints(polity, bonusPoints = 0) {
80
+ const baseUnits = Math.max(1, Math.floor(polity.population / RESEARCH_POP_DIVISOR));
81
+ const stabilityFactor = SCALE.Q / 2 + mulDiv(SCALE.Q / 2, polity.stabilityQ, SCALE.Q);
82
+ const base = Math.max(1, Math.round(baseUnits * stabilityFactor / SCALE.Q));
83
+ return base + bonusPoints;
84
+ }
85
+ // ── Research step ─────────────────────────────────────────────────────────────
86
+ /**
87
+ * Advance research for `elapsedDays` days.
88
+ *
89
+ * Adds `computeDailyResearchPoints(polity) × elapsedDays` to `state.progress`.
90
+ * When progress meets or exceeds `pointsRequiredForNextEra`:
91
+ * - Excess progress carries over.
92
+ * - `polity.techEra` is incremented.
93
+ * - `deriveMilitaryStrength` is refreshed.
94
+ *
95
+ * Only one era advancement occurs per call regardless of elapsed days.
96
+ * At DeepSpace (max era) the call is a no-op.
97
+ *
98
+ * @param bonusPoints Flat daily bonus from knowledge diffusion or infrastructure.
99
+ */
100
+ export function stepResearch(polity, state, elapsedDays, bonusPoints = 0) {
101
+ const daily = computeDailyResearchPoints(polity, bonusPoints);
102
+ const gained = daily * elapsedDays;
103
+ state.progress += gained;
104
+ const required = pointsRequiredForNextEra(polity);
105
+ const maxEra = TechEra.DeepSpace;
106
+ const canAdvance = polity.techEra < maxEra && isFinite(required) && state.progress >= required;
107
+ if (canAdvance) {
108
+ state.progress -= required; // carry over surplus
109
+ polity.techEra = (polity.techEra + 1);
110
+ deriveMilitaryStrength(polity);
111
+ return { pointsGained: gained, advanced: true, newEra: polity.techEra };
112
+ }
113
+ return { pointsGained: gained, advanced: false };
114
+ }
115
+ // ── Treasury investment ───────────────────────────────────────────────────────
116
+ /**
117
+ * Invest treasury into research, immediately adding points.
118
+ *
119
+ * Rate: `RESEARCH_COST_PER_POINT` cost-units = 1 point.
120
+ * Drains `min(amount, polity.treasury_cu)`. No-ops if treasury is empty.
121
+ *
122
+ * Returns the actual number of research points added.
123
+ */
124
+ export function investInResearch(polity, state, amount) {
125
+ const actual = Math.min(amount, polity.treasury_cu);
126
+ const points = Math.floor(actual / RESEARCH_COST_PER_POINT);
127
+ polity.treasury_cu -= actual;
128
+ state.progress += points;
129
+ return points;
130
+ }
131
+ // ── Knowledge diffusion ───────────────────────────────────────────────────────
132
+ /**
133
+ * Compute daily knowledge diffusion bonus that a source polity grants to a
134
+ * less-advanced target polity through trade or diplomatic contact.
135
+ *
136
+ * Diffusion fires only when `sourcePolity.techEra > targetPolity.techEra`.
137
+ *
138
+ * Formula: `round(sourceDaily × eraDiff × DIFFUSION_RATE × contactIntensity / SCALE.Q²)`
139
+ *
140
+ * @param contactIntensity_Q Trade or diplomatic contact [0, SCALE.Q].
141
+ * Derive from Phase-83 route efficiency or Phase-80 treaty strength.
142
+ */
143
+ export function computeKnowledgeDiffusion(sourcePolity, targetPolity, contactIntensity_Q) {
144
+ if (sourcePolity.techEra <= targetPolity.techEra)
145
+ return 0;
146
+ const eraDiff = sourcePolity.techEra - targetPolity.techEra;
147
+ const sourceRate = computeDailyResearchPoints(sourcePolity);
148
+ const step1 = mulDiv(sourceRate * eraDiff, KNOWLEDGE_DIFFUSION_RATE_Q, SCALE.Q);
149
+ return Math.max(0, Math.round(step1 * contactIntensity_Q / SCALE.Q));
150
+ }
151
+ // ── Progress reporting ────────────────────────────────────────────────────────
152
+ /**
153
+ * Return current research progress as a Q fraction [0, SCALE.Q] toward the next era.
154
+ * Returns `SCALE.Q` at max era (DeepSpace).
155
+ */
156
+ export function computeResearchProgress_Q(polity, state) {
157
+ const required = pointsRequiredForNextEra(polity);
158
+ if (!isFinite(required))
159
+ return SCALE.Q;
160
+ return clampQ(Math.round(state.progress * SCALE.Q / required), 0, SCALE.Q);
161
+ }
162
+ /**
163
+ * Estimate days until the next era advance at the current daily research rate.
164
+ * Returns `Infinity` at max era or when rate is zero.
165
+ */
166
+ export function estimateDaysToNextEra(polity, state, bonusPoints = 0) {
167
+ const required = pointsRequiredForNextEra(polity);
168
+ if (!isFinite(required))
169
+ return Infinity;
170
+ const remaining = Math.max(0, required - state.progress);
171
+ const daily = computeDailyResearchPoints(polity, bonusPoints);
172
+ if (daily <= 0)
173
+ return Infinity;
174
+ return Math.ceil(remaining / daily);
175
+ }
@@ -0,0 +1,99 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /**
4
+ * Pressure signals fed into `computeUnrestLevel`.
5
+ * All fields are Q fractions [0, SCALE.Q]; omit any that are not applicable.
6
+ */
7
+ export interface UnrestFactors {
8
+ /** Phase-87 famine push pressure. */
9
+ faminePressure_Q?: Q;
10
+ /** Phase-88 epidemic flight pressure. */
11
+ epidemicPressure_Q?: Q;
12
+ /** Phase-85 heresy risk. */
13
+ heresyRisk_Q?: Q;
14
+ /**
15
+ * Weakest feudal bond strength [0, SCALE.Q] from Phase-79.
16
+ * Low value → high feudal unrest contribution.
17
+ */
18
+ weakestBond_Q?: Q;
19
+ }
20
+ /** Possible outcomes of a rebellion resolution. */
21
+ export type RebellionOutcome = "quelled" | "uprising" | "civil_war";
22
+ /** Result returned by `resolveRebellion`. */
23
+ export interface RebellionResult {
24
+ outcome: RebellionOutcome;
25
+ /** Morale penalty applied to the polity (always ≤ 0). */
26
+ moraleHit_Q: number;
27
+ /** Stability penalty applied to the polity (always ≤ 0). */
28
+ stabilityHit_Q: number;
29
+ /** Treasury plundered by rebels [cost units]. */
30
+ treasuryLoss: number;
31
+ }
32
+ /** Outcome of `stepUnrest` — the changes applied this step. */
33
+ export interface UnrestStepResult {
34
+ unrestLevel_Q: Q;
35
+ moraleDecay_Q: number;
36
+ stabilityDecay_Q: number;
37
+ /** Whether rebellion threshold was crossed (host should call resolveRebellion). */
38
+ rebellionRisk: boolean;
39
+ }
40
+ /** Weights applied to each pressure source in `computeUnrestLevel`. */
41
+ export declare const UNREST_MORALE_WEIGHT_Q: Q;
42
+ export declare const UNREST_STABILITY_WEIGHT_Q: Q;
43
+ export declare const UNREST_FAMINE_WEIGHT_Q: Q;
44
+ export declare const UNREST_EPIDEMIC_WEIGHT_Q: Q;
45
+ export declare const UNREST_HERESY_WEIGHT_Q: Q;
46
+ export declare const UNREST_FEUDAL_WEIGHT_Q: Q;
47
+ /** Unrest above this threshold → morale and stability begin draining. */
48
+ export declare const UNREST_ACTION_THRESHOLD_Q: Q;
49
+ /** Unrest above this threshold → rebellion risk flag raised. */
50
+ export declare const REBELLION_THRESHOLD_Q: Q;
51
+ /** Maximum daily morale drain from sustained unrest [Q/day]. */
52
+ export declare const UNREST_MORALE_DRAIN_Q: Q;
53
+ /** Maximum daily stability drain from sustained unrest [Q/day]. */
54
+ export declare const UNREST_STABILITY_DRAIN_Q: Q;
55
+ /** Fraction of treasury rebels plunder during an uprising or civil war. */
56
+ export declare const REBELLION_TREASURY_RAID_Q: Q;
57
+ /**
58
+ * Compute the composite unrest level [0, SCALE.Q] for a polity.
59
+ *
60
+ * Unrest is the weighted sum of:
61
+ * - Low morale (`(SCALE.Q - moraleQ) × MORALE_WEIGHT`)
62
+ * - Low stability (`(SCALE.Q - stabilityQ) × STABILITY_WEIGHT`)
63
+ * - Famine pressure × FAMINE_WEIGHT
64
+ * - Epidemic pressure × EPIDEMIC_WEIGHT
65
+ * - Heresy risk × HERESY_WEIGHT
66
+ * - Feudal deficit × FEUDAL_WEIGHT (`SCALE.Q − weakestBond_Q`)
67
+ *
68
+ * All inputs are optional; omitted factors contribute zero.
69
+ */
70
+ export declare function computeUnrestLevel(polity: Polity, factors?: UnrestFactors): Q;
71
+ /**
72
+ * Apply unrest consequences to a polity for `elapsedDays` days.
73
+ *
74
+ * When `unrestLevel_Q > UNREST_ACTION_THRESHOLD_Q`:
75
+ * - Drains morale at rate `(unrest − threshold) × MORALE_DRAIN_Q / SCALE.Q` per day.
76
+ * - Drains stability at a lower rate.
77
+ *
78
+ * Mutates `polity.moraleQ` and `polity.stabilityQ` in place.
79
+ * Returns the step result for host inspection.
80
+ */
81
+ export declare function stepUnrest(polity: Polity, unrestLevel_Q: Q, elapsedDays: number): UnrestStepResult;
82
+ /**
83
+ * Resolve a rebellion event deterministically.
84
+ *
85
+ * Outcomes:
86
+ * - `"quelled"`: rebels dispersed — morale/treasury hit only.
87
+ * - `"uprising"`: significant unrest — larger morale/stability hit + treasury raid.
88
+ * - `"civil_war"`: polity fractures — severe penalties across all stats.
89
+ *
90
+ * Outcome probability is weighted by unrest level vs. military strength:
91
+ * - High military strength + moderate unrest → likely `"quelled"`
92
+ * - Low military + high unrest → risk of `"civil_war"`
93
+ *
94
+ * Mutates polity morale, stability, and treasury.
95
+ *
96
+ * @param worldSeed World seed for deterministic resolution.
97
+ * @param tick Current simulation tick.
98
+ */
99
+ export declare function resolveRebellion(polity: Polity, worldSeed: number, tick: number): RebellionResult;
@@ -0,0 +1,147 @@
1
+ // src/unrest.ts — Phase 90: Civil Unrest & Rebellion
2
+ //
3
+ // Aggregates pressure signals from existing systems into a composite unrest
4
+ // level, drains polity morale and stability under sustained pressure, and
5
+ // resolves rebellion events deterministically.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `computeUnrestLevel` is a pure aggregator: callers pass pre-computed
10
+ // pressure values from Phase-85 (heresy), Phase-87 (famine), Phase-88
11
+ // (epidemic), Phase-79 (weakest feudal bond), etc.
12
+ // - `stepUnrest` mutates polity.moraleQ and polity.stabilityQ when unrest
13
+ // exceeds thresholds.
14
+ // - `resolveRebellion` uses eventSeed for full determinism and replay safety.
15
+ //
16
+ // Integration:
17
+ // Phase 61 (Polity): mutates moraleQ / stabilityQ; reads militaryStrength_Q.
18
+ // Phase 79 (Feudal): weakestBond_Q input.
19
+ // Phase 85 (Faith): heresyRisk_Q input.
20
+ // Phase 87 (Granary): faminePressure_Q input from computeFamineMigrationPush.
21
+ // Phase 88 (Epidemic): epidemicPressure_Q input from computeEpidemicMigrationPush.
22
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
23
+ import { eventSeed, hashString } from "./sim/seeds.js";
24
+ // ── Constants ─────────────────────────────────────────────────────────────────
25
+ /** Weights applied to each pressure source in `computeUnrestLevel`. */
26
+ export const UNREST_MORALE_WEIGHT_Q = q(0.30);
27
+ export const UNREST_STABILITY_WEIGHT_Q = q(0.25);
28
+ export const UNREST_FAMINE_WEIGHT_Q = q(0.20);
29
+ export const UNREST_EPIDEMIC_WEIGHT_Q = q(0.10);
30
+ export const UNREST_HERESY_WEIGHT_Q = q(0.10);
31
+ export const UNREST_FEUDAL_WEIGHT_Q = q(0.05);
32
+ /** Unrest above this threshold → morale and stability begin draining. */
33
+ export const UNREST_ACTION_THRESHOLD_Q = q(0.30);
34
+ /** Unrest above this threshold → rebellion risk flag raised. */
35
+ export const REBELLION_THRESHOLD_Q = q(0.65);
36
+ /** Maximum daily morale drain from sustained unrest [Q/day]. */
37
+ export const UNREST_MORALE_DRAIN_Q = q(0.005);
38
+ /** Maximum daily stability drain from sustained unrest [Q/day]. */
39
+ export const UNREST_STABILITY_DRAIN_Q = q(0.003);
40
+ /** Fraction of treasury rebels plunder during an uprising or civil war. */
41
+ export const REBELLION_TREASURY_RAID_Q = q(0.15);
42
+ // ── Unrest computation ────────────────────────────────────────────────────────
43
+ /**
44
+ * Compute the composite unrest level [0, SCALE.Q] for a polity.
45
+ *
46
+ * Unrest is the weighted sum of:
47
+ * - Low morale (`(SCALE.Q - moraleQ) × MORALE_WEIGHT`)
48
+ * - Low stability (`(SCALE.Q - stabilityQ) × STABILITY_WEIGHT`)
49
+ * - Famine pressure × FAMINE_WEIGHT
50
+ * - Epidemic pressure × EPIDEMIC_WEIGHT
51
+ * - Heresy risk × HERESY_WEIGHT
52
+ * - Feudal deficit × FEUDAL_WEIGHT (`SCALE.Q − weakestBond_Q`)
53
+ *
54
+ * All inputs are optional; omitted factors contribute zero.
55
+ */
56
+ export function computeUnrestLevel(polity, factors = {}) {
57
+ const moraleContrib = mulDiv(SCALE.Q - polity.moraleQ, UNREST_MORALE_WEIGHT_Q, SCALE.Q);
58
+ const stabilityContrib = mulDiv(SCALE.Q - polity.stabilityQ, UNREST_STABILITY_WEIGHT_Q, SCALE.Q);
59
+ const famineContrib = mulDiv(factors.faminePressure_Q ?? 0, UNREST_FAMINE_WEIGHT_Q, SCALE.Q);
60
+ const epidemicContrib = mulDiv(factors.epidemicPressure_Q ?? 0, UNREST_EPIDEMIC_WEIGHT_Q, SCALE.Q);
61
+ const heresyContrib = mulDiv(factors.heresyRisk_Q ?? 0, UNREST_HERESY_WEIGHT_Q, SCALE.Q);
62
+ const feudalDeficit = factors.weakestBond_Q != null
63
+ ? clampQ(SCALE.Q - factors.weakestBond_Q, 0, SCALE.Q)
64
+ : 0;
65
+ const feudalContrib = mulDiv(feudalDeficit, UNREST_FEUDAL_WEIGHT_Q, SCALE.Q);
66
+ const total = moraleContrib + stabilityContrib + famineContrib +
67
+ epidemicContrib + heresyContrib + feudalContrib;
68
+ return clampQ(total, 0, SCALE.Q);
69
+ }
70
+ // ── Unrest step ───────────────────────────────────────────────────────────────
71
+ /**
72
+ * Apply unrest consequences to a polity for `elapsedDays` days.
73
+ *
74
+ * When `unrestLevel_Q > UNREST_ACTION_THRESHOLD_Q`:
75
+ * - Drains morale at rate `(unrest − threshold) × MORALE_DRAIN_Q / SCALE.Q` per day.
76
+ * - Drains stability at a lower rate.
77
+ *
78
+ * Mutates `polity.moraleQ` and `polity.stabilityQ` in place.
79
+ * Returns the step result for host inspection.
80
+ */
81
+ export function stepUnrest(polity, unrestLevel_Q, elapsedDays) {
82
+ const excess = clampQ(unrestLevel_Q - UNREST_ACTION_THRESHOLD_Q, 0, SCALE.Q);
83
+ const moraleDecayPerDay = mulDiv(excess, UNREST_MORALE_DRAIN_Q, SCALE.Q);
84
+ const stabilityDecayPerDay = mulDiv(excess, UNREST_STABILITY_DRAIN_Q, SCALE.Q);
85
+ const totalMoraleDecay = Math.round(moraleDecayPerDay * elapsedDays);
86
+ const totalStabilityDecay = Math.round(stabilityDecayPerDay * elapsedDays);
87
+ polity.moraleQ = clampQ(polity.moraleQ - totalMoraleDecay, 0, SCALE.Q);
88
+ polity.stabilityQ = clampQ(polity.stabilityQ - totalStabilityDecay, 0, SCALE.Q);
89
+ return {
90
+ unrestLevel_Q,
91
+ moraleDecay_Q: totalMoraleDecay,
92
+ stabilityDecay_Q: totalStabilityDecay,
93
+ rebellionRisk: unrestLevel_Q > REBELLION_THRESHOLD_Q,
94
+ };
95
+ }
96
+ // ── Rebellion resolution ──────────────────────────────────────────────────────
97
+ /**
98
+ * Resolve a rebellion event deterministically.
99
+ *
100
+ * Outcomes:
101
+ * - `"quelled"`: rebels dispersed — morale/treasury hit only.
102
+ * - `"uprising"`: significant unrest — larger morale/stability hit + treasury raid.
103
+ * - `"civil_war"`: polity fractures — severe penalties across all stats.
104
+ *
105
+ * Outcome probability is weighted by unrest level vs. military strength:
106
+ * - High military strength + moderate unrest → likely `"quelled"`
107
+ * - Low military + high unrest → risk of `"civil_war"`
108
+ *
109
+ * Mutates polity morale, stability, and treasury.
110
+ *
111
+ * @param worldSeed World seed for deterministic resolution.
112
+ * @param tick Current simulation tick.
113
+ */
114
+ export function resolveRebellion(polity, worldSeed, tick) {
115
+ const polityHash = hashString(polity.id);
116
+ const seed = eventSeed(worldSeed, tick, polityHash, 0, 9001); // salt: rebellion
117
+ const roll = seed % SCALE.Q; // [0, SCALE.Q)
118
+ // Suppression capacity = military strength
119
+ const suppressCap = polity.militaryStrength_Q;
120
+ // Civil war threshold = low suppression + any roll in top quarter
121
+ const civilWarThresh = clampQ(SCALE.Q - suppressCap, 0, SCALE.Q);
122
+ const uprisingThresh = Math.round(civilWarThresh * 0.6);
123
+ let outcome;
124
+ if (roll >= civilWarThresh) {
125
+ outcome = "quelled";
126
+ }
127
+ else if (roll >= uprisingThresh) {
128
+ outcome = "uprising";
129
+ }
130
+ else {
131
+ outcome = "civil_war";
132
+ }
133
+ const moraleHit = outcome === "quelled" ? -q(0.05)
134
+ : outcome === "uprising" ? -q(0.15)
135
+ : -q(0.30);
136
+ const stabilityHit = outcome === "quelled" ? -q(0.03)
137
+ : outcome === "uprising" ? -q(0.10)
138
+ : -q(0.25);
139
+ const treasuryRaid = outcome === "quelled"
140
+ ? 0
141
+ : Math.floor(mulDiv(polity.treasury_cu, REBELLION_TREASURY_RAID_Q, SCALE.Q)
142
+ * (outcome === "civil_war" ? 2 : 1));
143
+ polity.moraleQ = clampQ(polity.moraleQ + moraleHit, 0, SCALE.Q);
144
+ polity.stabilityQ = clampQ(polity.stabilityQ + stabilityHit, 0, SCALE.Q);
145
+ polity.treasury_cu = Math.max(0, polity.treasury_cu - treasuryRaid);
146
+ return { outcome, moraleHit_Q: moraleHit, stabilityHit_Q: stabilityHit, treasuryLoss: treasuryRaid };
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -122,6 +122,14 @@
122
122
  "./infrastructure": {
123
123
  "import": "./dist/src/infrastructure.js",
124
124
  "types": "./dist/src/infrastructure.d.ts"
125
+ },
126
+ "./unrest": {
127
+ "import": "./dist/src/unrest.js",
128
+ "types": "./dist/src/unrest.d.ts"
129
+ },
130
+ "./research": {
131
+ "import": "./dist/src/research.js",
132
+ "types": "./dist/src/research.d.ts"
125
133
  }
126
134
  },
127
135
  "files": [