@its-not-rocket-science/ananke 0.1.35 → 0.1.38

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,64 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.38] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 93 · Military Campaigns & War Resolution** (`src/military-campaign.ts`)
14
+ - `CampaignState { campaignId, attackerPolityId, defenderPolityId, phase, startTick, daysElapsed, marchProgress_Q, attackerArmySize, attackerStrength_Q, defenderStrength_Q, outcome? }` — mutable live state stored externally per conflict.
15
+ - `CampaignPhase`: `"mobilization" | "march" | "battle" | "resolved"`.
16
+ - `BattleOutcome`: `"attacker_victory" | "defender_holds" | "stalemate"`.
17
+ - `computeArmySize(polity, mobilizationFrac_Q?)` — default q(0.05); clamped to `MAX_MOBILIZATION_Q = q(0.15)`.
18
+ - `computeBattleStrength(polity, armySize)` → Q: `militaryStrength_Q × armySize / REFERENCE_ARMY_SIZE × TECH_SOLDIER_MUL[techEra] × stabilityMul`; clamped to SCALE.Q.
19
+ - `mobilizeCampaign(campaign, attacker, mobilizationFrac_Q?)` — drains `MOBILIZATION_COST_PER_SOLDIER = 5` cu per soldier (capped at treasury); transitions to `"march"`.
20
+ - `prepareDefender(campaign, defender, wallBonus_Q?)` — sets defender strength; Phase-89 wall bonus increases effective defence.
21
+ - `stepCampaignMarch(campaign, attacker, elapsedDays, roadBonus_Q?)` — advances march at `BASE_MARCH_RATE_Q = q(0.05)` + road bonus; drains `CAMPAIGN_UPKEEP_PER_SOLDIER = 1` cu/soldier/day; triggers battle when progress reaches SCALE.Q.
22
+ - `resolveBattle(campaign, attacker, defender, worldSeed, tick)` → `BattleResult` — `eventSeed`-deterministic; outcome weighted by strength ratio; `VICTORY_TRIBUTE_Q = q(0.20)` of defender treasury on victory; reduces both sides' strength by casualty rates.
23
+ - `applyBattleConsequences(result, attacker, defender)` — applies morale/stability deltas; winner gains `VICTORY_MORALE_BONUS_Q = q(0.10)`; loser loses `DEFEAT_MORALE_HIT_Q = q(0.20)` + `DEFEAT_STABILITY_HIT_Q = q(0.15)`; both pay `COMBAT_STABILITY_DRAIN_Q = q(0.05)`.
24
+ - `computeWarUnrestPressure(campaign)` → Q: `WAR_UNREST_PRESSURE_Q = q(0.15)` during active campaign; 0 when resolved — feeds Phase-90 `computeUnrestLevel`.
25
+ - `computeDailyUpkeep(campaign)` → cu/day.
26
+ - Added `./military-campaign` subpath export to `package.json`.
27
+ - 56 new tests; 4,884 total. Coverage maintained above all thresholds.
28
+
29
+ ---
30
+
31
+ ## [0.1.37] — 2026-03-26
32
+
33
+ ### Added
34
+
35
+ - **Phase 92 · Taxation & Treasury Revenue** (`src/taxation.ts`)
36
+ - `TaxPolicy { polityId, taxRate_Q, exemptFraction_Q? }` — per-polity config stored externally by the host.
37
+ - `TAX_REVENUE_PER_CAPITA_ANNUAL: Record<number, number>` — numeric TechEra keys; Prehistoric 0 → DeepSpace 20 k cu/person/year.
38
+ - `computeAnnualTaxRevenue(polity, policy)` → cu/year: `taxablePop × perCapita × taxRate × stabilityMul / SCALE.Q`; `stabilityMul ∈ [q(0.50), q(1.00)]` models collection efficiency; zero at Prehistoric era.
39
+ - `computeDailyTaxRevenue(polity, policy)` → cu/day: annual ÷ 365 with rounding.
40
+ - `computeTaxUnrestPressure(policy)` → Q [0, `MAX_TAX_UNREST_Q = q(0.30)`]: zero at/below `OPTIMAL_TAX_RATE_Q = q(0.15)`; linear ramp to max at `MAX_TAX_RATE_Q = q(0.50)`; passes directly into Phase-90 `computeUnrestLevel` as an additional factor.
41
+ - `stepTaxCollection(polity, policy, elapsedDays)` → `TaxCollectionResult`: adds `round(annual × days / 365)` to `polity.treasury_cu`; returns revenue and unrest pressure.
42
+ - `estimateDaysToTreasuryTarget(polity, policy, targetAmount)` → ceiling days; Infinity at zero daily rate.
43
+ - `computeRequiredTaxRate(polity, desiredAnnual)` → Q: reverse-solves for the rate needed to meet a target; clamped to MAX_TAX_RATE_Q.
44
+ - Added `./taxation` subpath export to `package.json`.
45
+ - 49 new tests; 4,828 total. Coverage maintained above all thresholds.
46
+
47
+ ---
48
+
49
+ ## [0.1.36] — 2026-03-26
50
+
51
+ ### Added
52
+
53
+ - **Phase 91 · Technology Research** (`src/research.ts`)
54
+ - `ResearchState { polityId, progress }` — per-polity accumulator stored externally by the host.
55
+ - `RESEARCH_POINTS_REQUIRED: Record<number, number>` — numeric TechEra keys; Prehistoric 2 k → FarFuture 5 M; DeepSpace absent (no advancement).
56
+ - `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`.
57
+ - `stepResearch(polity, state, elapsedDays, bonusPoints?)` → `ResearchStepResult`: accumulates `daily × elapsedDays`; on threshold: increments `polity.techEra`, calls `deriveMilitaryStrength`, carries surplus; no-op at DeepSpace.
58
+ - `investInResearch(polity, state, amount)` — drains treasury at `RESEARCH_COST_PER_POINT = 10` cu/point; capped at available treasury; returns points added.
59
+ - `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²`.
60
+ - `computeResearchProgress_Q(polity, state)` → Q [0, SCALE.Q]: fraction toward next era; SCALE.Q at DeepSpace.
61
+ - `estimateDaysToNextEra(polity, state, bonusPoints?)` → ceiling days; Infinity at DeepSpace or zero rate.
62
+ - Added `./research` subpath export to `package.json`.
63
+ - 57 new tests; 4,779 total. Coverage maintained above all thresholds.
64
+
65
+ ---
66
+
9
67
  ## [0.1.35] — 2026-03-26
10
68
 
11
69
  ### Added
@@ -0,0 +1,210 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /** Phase of a military campaign. */
4
+ export type CampaignPhase = "mobilization" | "march" | "battle" | "resolved";
5
+ /** How an open-field battle resolved. */
6
+ export type BattleOutcome = "attacker_victory" | "defender_holds" | "stalemate";
7
+ /** Live state of an ongoing or resolved campaign. */
8
+ export interface CampaignState {
9
+ campaignId: string;
10
+ attackerPolityId: string;
11
+ defenderPolityId: string;
12
+ phase: CampaignPhase;
13
+ /** Day the campaign started. */
14
+ startTick: number;
15
+ /** Total days elapsed since campaign start. */
16
+ daysElapsed: number;
17
+ /**
18
+ * March progress toward the defender [0, SCALE.Q].
19
+ * Advances each day during `"march"` phase; battle triggers at SCALE.Q.
20
+ */
21
+ marchProgress_Q: Q;
22
+ /**
23
+ * Attacker army size at mobilization (integer soldiers).
24
+ * Does not change after mobilization; casualties reduce `attackerStrength_Q`.
25
+ */
26
+ attackerArmySize: number;
27
+ /** Attacker battle strength [0, SCALE.Q]; reduced by casualties. */
28
+ attackerStrength_Q: Q;
29
+ /** Defender battle strength [0, SCALE.Q]; reduced by casualties. */
30
+ defenderStrength_Q: Q;
31
+ /** Outcome when `phase === "resolved"`. */
32
+ outcome?: BattleOutcome;
33
+ }
34
+ /** Result of `mobilizeCampaign`. */
35
+ export interface MobilizationResult {
36
+ /** Soldiers raised. */
37
+ armySize: number;
38
+ /** Cost-units drained from `polity.treasury_cu`. */
39
+ cost_cu: number;
40
+ /** Initial battle strength of the raised army [0, SCALE.Q]. */
41
+ armyStrength_Q: Q;
42
+ }
43
+ /** Result of `stepCampaignMarch`. */
44
+ export interface MarchStepResult {
45
+ /** March progress added this step [Q]. */
46
+ progressAdded_Q: Q;
47
+ /** Cost-units drained from attacker treasury (daily upkeep). */
48
+ upkeep_cu: number;
49
+ /** Whether battle has been triggered this step. */
50
+ battleTriggered: boolean;
51
+ }
52
+ /** Result of `resolveBattle`. */
53
+ export interface BattleResult {
54
+ outcome: BattleOutcome;
55
+ /** Fractional strength lost by attacker [0, SCALE.Q]. */
56
+ attackerCasualties_Q: Q;
57
+ /** Fractional strength lost by defender [0, SCALE.Q]. */
58
+ defenderCasualties_Q: Q;
59
+ /**
60
+ * Treasury tribute taken from defeated polity.
61
+ * Set only on `"attacker_victory"`.
62
+ */
63
+ tributeAmount?: number;
64
+ }
65
+ /**
66
+ * Default fraction of the population available as soldiers [Q].
67
+ * 5% mobilization is a sustainable wartime levy.
68
+ */
69
+ export declare const MOBILIZATION_POP_FRACTION_Q: Q;
70
+ /**
71
+ * Maximum fraction of population that can be mobilized [Q].
72
+ * Above this, domestic stability collapses.
73
+ */
74
+ export declare const MAX_MOBILIZATION_Q: Q;
75
+ /**
76
+ * Treasury cost per soldier for initial mobilization (equipment, muster pay).
77
+ * In cost-units per soldier.
78
+ */
79
+ export declare const MOBILIZATION_COST_PER_SOLDIER = 5;
80
+ /**
81
+ * Daily treasury upkeep per soldier [cost-units/soldier/day].
82
+ */
83
+ export declare const CAMPAIGN_UPKEEP_PER_SOLDIER = 1;
84
+ /**
85
+ * Base daily march progress [Q/day] at no road bonus.
86
+ * At this rate, full march (SCALE.Q) takes 20 days.
87
+ */
88
+ export declare const BASE_MARCH_RATE_Q: Q;
89
+ /**
90
+ * Fraction of the defeated polity's treasury taken as tribute on victory [Q].
91
+ */
92
+ export declare const VICTORY_TRIBUTE_Q: Q;
93
+ /**
94
+ * Per-soldier strength multiplier by tech era [Q/soldier].
95
+ * Higher eras have better weapons, tactics, and logistics.
96
+ */
97
+ export declare const TECH_SOLDIER_MUL: Record<number, Q>;
98
+ /**
99
+ * Reference army size used as denominator for strength scaling.
100
+ * An army of this size at q(1.0) military strength = battle strength q(1.0).
101
+ */
102
+ export declare const REFERENCE_ARMY_SIZE = 10000;
103
+ /**
104
+ * Unrest pressure on attacker polity during an active campaign [Q].
105
+ * Pass as extra unrest factor into Phase-90 `computeUnrestLevel`.
106
+ */
107
+ export declare const WAR_UNREST_PRESSURE_Q: Q;
108
+ /**
109
+ * Casualty rates per battle outcome.
110
+ * These are fractional strength losses applied to each side.
111
+ */
112
+ export declare const ATTACKER_CASUALTY_ON_VICTORY_Q: Q;
113
+ export declare const ATTACKER_CASUALTY_ON_DEFEAT_Q: Q;
114
+ export declare const ATTACKER_CASUALTY_ON_STALEMATE_Q: Q;
115
+ export declare const DEFENDER_CASUALTY_ON_VICTORY_Q: Q;
116
+ export declare const DEFENDER_CASUALTY_ON_DEFEAT_Q: Q;
117
+ export declare const DEFENDER_CASUALTY_ON_STALEMATE_Q: Q;
118
+ /** Create a new campaign in `"mobilization"` phase. */
119
+ export declare function createCampaign(campaignId: string, attackerPolityId: string, defenderPolityId: string, tick: number): CampaignState;
120
+ /**
121
+ * Compute battle strength for a polity with a given army size [Q].
122
+ *
123
+ * Formula:
124
+ * soldierMul = TECH_SOLDIER_MUL[techEra] (default q(0.80))
125
+ * stabilityMul = q(0.50) + mulDiv(q(0.50), stabilityQ, SCALE.Q) ∈ [q(0.50), q(1.00)]
126
+ * rawStrength = round(militaryStrength_Q × armySize / REFERENCE_ARMY_SIZE)
127
+ * adjusted = round(rawStrength × soldierMul / SCALE.Q)
128
+ * final = clampQ(round(adjusted × stabilityMul / SCALE.Q), 0, SCALE.Q)
129
+ *
130
+ * @param armySize Number of soldiers (capped at population for safety).
131
+ */
132
+ export declare function computeBattleStrength(polity: Polity, armySize: number): Q;
133
+ /**
134
+ * Compute army size for a given mobilization fraction [soldiers].
135
+ * Clamped to `[0, floor(population × MAX_MOBILIZATION_Q / SCALE.Q)]`.
136
+ */
137
+ export declare function computeArmySize(polity: Polity, mobilizationFrac_Q?: Q): number;
138
+ /**
139
+ * Raise an army and transition campaign to `"march"` phase.
140
+ *
141
+ * Drains `armySize × MOBILIZATION_COST_PER_SOLDIER` from `polity.treasury_cu`
142
+ * (capped at available treasury — a treasury-poor polity raises a smaller
143
+ * effective force than planned).
144
+ *
145
+ * Mutates `campaign` and `polity.treasury_cu`.
146
+ */
147
+ export declare function mobilizeCampaign(campaign: CampaignState, attacker: Polity, mobilizationFrac_Q?: Q): MobilizationResult;
148
+ /**
149
+ * Set the defender's battle strength. Call before `stepCampaignMarch` starts.
150
+ *
151
+ * @param wallBonus_Q Phase-89 wall infrastructure bonus [0, SCALE.Q].
152
+ * Increases defender effective strength by this fraction.
153
+ */
154
+ export declare function prepareDefender(campaign: CampaignState, defender: Polity, wallBonus_Q?: Q): Q;
155
+ /**
156
+ * Advance the campaign march for one tick.
157
+ *
158
+ * Daily march rate = `BASE_MARCH_RATE_Q + roadBonus_Q`.
159
+ * Daily upkeep = `attackerArmySize × CAMPAIGN_UPKEEP_PER_SOLDIER`.
160
+ *
161
+ * When `marchProgress_Q` reaches SCALE.Q the phase transitions to `"battle"`.
162
+ *
163
+ * Mutates `campaign` and `attacker.treasury_cu`.
164
+ *
165
+ * @param roadBonus_Q Phase-89 road infrastructure bonus [0, SCALE.Q].
166
+ */
167
+ export declare function stepCampaignMarch(campaign: CampaignState, attacker: Polity, elapsedDays: number, roadBonus_Q?: Q): MarchStepResult;
168
+ /**
169
+ * Resolve the field battle deterministically.
170
+ *
171
+ * Outcome probability is weighted by the strength ratio between attacker and
172
+ * defender, modified by a `eventSeed`-derived roll.
173
+ *
174
+ * Roll:
175
+ * seed = eventSeed(worldSeed, tick, hashString(attackerId), hashString(defenderId), 9301)
176
+ * roll = seed % SCALE.Q ∈ [0, 9999]
177
+ * threshold_victory = round(attackerStr × q(0.80) / SCALE.Q) — min roll to win
178
+ * threshold_stalemate = threshold_victory + round(q(0.15) × SCALE.Q / SCALE.Q)
179
+ *
180
+ * This ensures that a stronger attacker has a proportionally higher chance
181
+ * of victory, while weaker attackers still occasionally succeed.
182
+ *
183
+ * Mutates `campaign.outcome`, `campaign.phase`, and `attacker.treasury_cu`/
184
+ * `defender.treasury_cu` (tribute on victory).
185
+ */
186
+ export declare function resolveBattle(campaign: CampaignState, attacker: Polity, defender: Polity, worldSeed: number, tick: number): BattleResult;
187
+ /**
188
+ * Apply morale and stability penalties to both sides after a resolved battle.
189
+ *
190
+ * - Loser: morale −`DEFEAT_MORALE_HIT_Q`, stability −`DEFEAT_STABILITY_HIT_Q`.
191
+ * - Winner: morale +`VICTORY_MORALE_BONUS_Q` (capped at SCALE.Q).
192
+ * - Both: stability drained by `COMBAT_STABILITY_DRAIN_Q` (war is always costly).
193
+ *
194
+ * Mutates `attacker` and `defender` in place.
195
+ */
196
+ export declare const DEFEAT_MORALE_HIT_Q: Q;
197
+ export declare const DEFEAT_STABILITY_HIT_Q: Q;
198
+ export declare const VICTORY_MORALE_BONUS_Q: Q;
199
+ export declare const COMBAT_STABILITY_DRAIN_Q: Q;
200
+ export declare function applyBattleConsequences(result: BattleResult, attacker: Polity, defender: Polity): void;
201
+ /**
202
+ * Compute daily treasury upkeep for an active campaign [cost-units/day].
203
+ */
204
+ export declare function computeDailyUpkeep(campaign: CampaignState): number;
205
+ /**
206
+ * Return the war unrest pressure on the attacker polity during an active campaign.
207
+ * Pass as an extra factor into Phase-90 `computeUnrestLevel`.
208
+ * Returns 0 when campaign is resolved.
209
+ */
210
+ export declare function computeWarUnrestPressure(campaign: CampaignState): Q;
@@ -0,0 +1,316 @@
1
+ // src/military-campaign.ts — Phase 93: Military Campaigns & War Resolution
2
+ //
3
+ // Models field army mobilization, campaign march, and open-battle resolution
4
+ // between polities. Siege warfare against fortified positions is handled
5
+ // separately by Phase-84 (src/siege.ts).
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `CampaignState` is the mutable live state; hosts store one per conflict.
10
+ // - All random outcomes use `eventSeed` for full determinism.
11
+ // - Battle strength derives from polity military strength × army size;
12
+ // tech era scales the per-soldier multiplier.
13
+ // - Phase-89 roads shorten march duration; Phase-89 walls add defender bonus.
14
+ // - Phase-90 unrest pressure increases during active campaigns.
15
+ // - Phase-92 treasury is drained daily by upkeep.
16
+ //
17
+ // Integration:
18
+ // Phase 11 (Tech): techEra gates per-soldier strength multiplier.
19
+ // Phase 61 (Polity): population, militaryStrength_Q, treasury_cu, stabilityQ mutated.
20
+ // Phase 89 (Infra): road bonus (march speed), wall bonus (defender strength).
21
+ // Phase 90 (Unrest): warUnrestPressure_Q as extra unrest factor.
22
+ // Phase 92 (Taxation): daily upkeep drains treasury alongside tax revenue.
23
+ import { eventSeed, hashString } from "./sim/seeds.js";
24
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
25
+ import { TechEra } from "./sim/tech.js";
26
+ // ── Constants ─────────────────────────────────────────────────────────────────
27
+ /**
28
+ * Default fraction of the population available as soldiers [Q].
29
+ * 5% mobilization is a sustainable wartime levy.
30
+ */
31
+ export const MOBILIZATION_POP_FRACTION_Q = q(0.05);
32
+ /**
33
+ * Maximum fraction of population that can be mobilized [Q].
34
+ * Above this, domestic stability collapses.
35
+ */
36
+ export const MAX_MOBILIZATION_Q = q(0.15);
37
+ /**
38
+ * Treasury cost per soldier for initial mobilization (equipment, muster pay).
39
+ * In cost-units per soldier.
40
+ */
41
+ export const MOBILIZATION_COST_PER_SOLDIER = 5;
42
+ /**
43
+ * Daily treasury upkeep per soldier [cost-units/soldier/day].
44
+ */
45
+ export const CAMPAIGN_UPKEEP_PER_SOLDIER = 1;
46
+ /**
47
+ * Base daily march progress [Q/day] at no road bonus.
48
+ * At this rate, full march (SCALE.Q) takes 20 days.
49
+ */
50
+ export const BASE_MARCH_RATE_Q = q(0.05);
51
+ /**
52
+ * Fraction of the defeated polity's treasury taken as tribute on victory [Q].
53
+ */
54
+ export const VICTORY_TRIBUTE_Q = q(0.20);
55
+ /**
56
+ * Per-soldier strength multiplier by tech era [Q/soldier].
57
+ * Higher eras have better weapons, tactics, and logistics.
58
+ */
59
+ export const TECH_SOLDIER_MUL = {
60
+ [TechEra.Prehistoric]: q(0.50),
61
+ [TechEra.Ancient]: q(0.70),
62
+ [TechEra.Medieval]: q(0.80),
63
+ [TechEra.EarlyModern]: q(0.90),
64
+ [TechEra.Industrial]: q(1.00),
65
+ [TechEra.Modern]: q(1.00),
66
+ [TechEra.NearFuture]: q(1.00),
67
+ [TechEra.FarFuture]: q(1.00),
68
+ [TechEra.DeepSpace]: q(1.00),
69
+ };
70
+ /**
71
+ * Reference army size used as denominator for strength scaling.
72
+ * An army of this size at q(1.0) military strength = battle strength q(1.0).
73
+ */
74
+ export const REFERENCE_ARMY_SIZE = 10_000;
75
+ /**
76
+ * Unrest pressure on attacker polity during an active campaign [Q].
77
+ * Pass as extra unrest factor into Phase-90 `computeUnrestLevel`.
78
+ */
79
+ export const WAR_UNREST_PRESSURE_Q = q(0.15);
80
+ /**
81
+ * Casualty rates per battle outcome.
82
+ * These are fractional strength losses applied to each side.
83
+ */
84
+ export const ATTACKER_CASUALTY_ON_VICTORY_Q = q(0.20);
85
+ export const ATTACKER_CASUALTY_ON_DEFEAT_Q = q(0.40);
86
+ export const ATTACKER_CASUALTY_ON_STALEMATE_Q = q(0.25);
87
+ export const DEFENDER_CASUALTY_ON_VICTORY_Q = q(0.50);
88
+ export const DEFENDER_CASUALTY_ON_DEFEAT_Q = q(0.15);
89
+ export const DEFENDER_CASUALTY_ON_STALEMATE_Q = q(0.25);
90
+ // ── Factory ───────────────────────────────────────────────────────────────────
91
+ /** Create a new campaign in `"mobilization"` phase. */
92
+ export function createCampaign(campaignId, attackerPolityId, defenderPolityId, tick) {
93
+ return {
94
+ campaignId,
95
+ attackerPolityId,
96
+ defenderPolityId,
97
+ phase: "mobilization",
98
+ startTick: tick,
99
+ daysElapsed: 0,
100
+ marchProgress_Q: 0,
101
+ attackerArmySize: 0,
102
+ attackerStrength_Q: 0,
103
+ defenderStrength_Q: 0,
104
+ };
105
+ }
106
+ // ── Army strength ─────────────────────────────────────────────────────────────
107
+ /**
108
+ * Compute battle strength for a polity with a given army size [Q].
109
+ *
110
+ * Formula:
111
+ * soldierMul = TECH_SOLDIER_MUL[techEra] (default q(0.80))
112
+ * stabilityMul = q(0.50) + mulDiv(q(0.50), stabilityQ, SCALE.Q) ∈ [q(0.50), q(1.00)]
113
+ * rawStrength = round(militaryStrength_Q × armySize / REFERENCE_ARMY_SIZE)
114
+ * adjusted = round(rawStrength × soldierMul / SCALE.Q)
115
+ * final = clampQ(round(adjusted × stabilityMul / SCALE.Q), 0, SCALE.Q)
116
+ *
117
+ * @param armySize Number of soldiers (capped at population for safety).
118
+ */
119
+ export function computeBattleStrength(polity, armySize) {
120
+ const soldierMul = (TECH_SOLDIER_MUL[polity.techEra] ?? q(0.80));
121
+ const stabilityMul = SCALE.Q / 2 + mulDiv(SCALE.Q / 2, polity.stabilityQ, SCALE.Q);
122
+ const raw = Math.round(polity.militaryStrength_Q * armySize / REFERENCE_ARMY_SIZE);
123
+ const adjusted = Math.round(raw * soldierMul / SCALE.Q);
124
+ return clampQ(Math.round(adjusted * stabilityMul / SCALE.Q), 0, SCALE.Q);
125
+ }
126
+ /**
127
+ * Compute army size for a given mobilization fraction [soldiers].
128
+ * Clamped to `[0, floor(population × MAX_MOBILIZATION_Q / SCALE.Q)]`.
129
+ */
130
+ export function computeArmySize(polity, mobilizationFrac_Q = MOBILIZATION_POP_FRACTION_Q) {
131
+ const frac = clampQ(mobilizationFrac_Q, 0, MAX_MOBILIZATION_Q);
132
+ return Math.floor(polity.population * frac / SCALE.Q);
133
+ }
134
+ // ── Mobilization ──────────────────────────────────────────────────────────────
135
+ /**
136
+ * Raise an army and transition campaign to `"march"` phase.
137
+ *
138
+ * Drains `armySize × MOBILIZATION_COST_PER_SOLDIER` from `polity.treasury_cu`
139
+ * (capped at available treasury — a treasury-poor polity raises a smaller
140
+ * effective force than planned).
141
+ *
142
+ * Mutates `campaign` and `polity.treasury_cu`.
143
+ */
144
+ export function mobilizeCampaign(campaign, attacker, mobilizationFrac_Q = MOBILIZATION_POP_FRACTION_Q) {
145
+ const armySize = computeArmySize(attacker, mobilizationFrac_Q);
146
+ const fullCost = armySize * MOBILIZATION_COST_PER_SOLDIER;
147
+ const cost_cu = Math.min(fullCost, attacker.treasury_cu);
148
+ attacker.treasury_cu -= cost_cu;
149
+ // Scale army size if treasury couldn't cover full cost
150
+ const fundedFrac = fullCost > 0 ? cost_cu / fullCost : 1;
151
+ const effectiveSize = Math.floor(armySize * fundedFrac);
152
+ const armyStrength_Q = computeBattleStrength(attacker, effectiveSize);
153
+ campaign.attackerArmySize = effectiveSize;
154
+ campaign.attackerStrength_Q = armyStrength_Q;
155
+ campaign.phase = "march";
156
+ return { armySize: effectiveSize, cost_cu, armyStrength_Q };
157
+ }
158
+ // ── Defender preparation ──────────────────────────────────────────────────────
159
+ /**
160
+ * Set the defender's battle strength. Call before `stepCampaignMarch` starts.
161
+ *
162
+ * @param wallBonus_Q Phase-89 wall infrastructure bonus [0, SCALE.Q].
163
+ * Increases defender effective strength by this fraction.
164
+ */
165
+ export function prepareDefender(campaign, defender, wallBonus_Q = 0) {
166
+ const armySize = computeArmySize(defender);
167
+ const baseStr = computeBattleStrength(defender, armySize);
168
+ const wallBoost = mulDiv(baseStr, wallBonus_Q, SCALE.Q);
169
+ const final = clampQ(baseStr + wallBoost, 0, SCALE.Q);
170
+ campaign.defenderStrength_Q = final;
171
+ return final;
172
+ }
173
+ // ── March ─────────────────────────────────────────────────────────────────────
174
+ /**
175
+ * Advance the campaign march for one tick.
176
+ *
177
+ * Daily march rate = `BASE_MARCH_RATE_Q + roadBonus_Q`.
178
+ * Daily upkeep = `attackerArmySize × CAMPAIGN_UPKEEP_PER_SOLDIER`.
179
+ *
180
+ * When `marchProgress_Q` reaches SCALE.Q the phase transitions to `"battle"`.
181
+ *
182
+ * Mutates `campaign` and `attacker.treasury_cu`.
183
+ *
184
+ * @param roadBonus_Q Phase-89 road infrastructure bonus [0, SCALE.Q].
185
+ */
186
+ export function stepCampaignMarch(campaign, attacker, elapsedDays, roadBonus_Q = 0) {
187
+ const dailyProgress = clampQ(BASE_MARCH_RATE_Q + roadBonus_Q, 0, SCALE.Q);
188
+ const added = clampQ(Math.min(dailyProgress * elapsedDays, SCALE.Q - campaign.marchProgress_Q), 0, SCALE.Q);
189
+ campaign.marchProgress_Q = clampQ(campaign.marchProgress_Q + added, 0, SCALE.Q);
190
+ campaign.daysElapsed += elapsedDays;
191
+ const upkeep_cu = Math.min(campaign.attackerArmySize * CAMPAIGN_UPKEEP_PER_SOLDIER * elapsedDays, attacker.treasury_cu);
192
+ attacker.treasury_cu -= upkeep_cu;
193
+ const battleTriggered = campaign.marchProgress_Q >= SCALE.Q;
194
+ if (battleTriggered && campaign.phase === "march") {
195
+ campaign.phase = "battle";
196
+ }
197
+ return { progressAdded_Q: added, upkeep_cu, battleTriggered };
198
+ }
199
+ // ── Battle resolution ─────────────────────────────────────────────────────────
200
+ /**
201
+ * Resolve the field battle deterministically.
202
+ *
203
+ * Outcome probability is weighted by the strength ratio between attacker and
204
+ * defender, modified by a `eventSeed`-derived roll.
205
+ *
206
+ * Roll:
207
+ * seed = eventSeed(worldSeed, tick, hashString(attackerId), hashString(defenderId), 9301)
208
+ * roll = seed % SCALE.Q ∈ [0, 9999]
209
+ * threshold_victory = round(attackerStr × q(0.80) / SCALE.Q) — min roll to win
210
+ * threshold_stalemate = threshold_victory + round(q(0.15) × SCALE.Q / SCALE.Q)
211
+ *
212
+ * This ensures that a stronger attacker has a proportionally higher chance
213
+ * of victory, while weaker attackers still occasionally succeed.
214
+ *
215
+ * Mutates `campaign.outcome`, `campaign.phase`, and `attacker.treasury_cu`/
216
+ * `defender.treasury_cu` (tribute on victory).
217
+ */
218
+ export function resolveBattle(campaign, attacker, defender, worldSeed, tick) {
219
+ const seed = eventSeed(worldSeed, tick, hashString(attacker.id), hashString(defender.id), 9301);
220
+ const roll = seed % SCALE.Q;
221
+ // Compute victory threshold based on relative strength
222
+ const atkStr = campaign.attackerStrength_Q;
223
+ const defStr = campaign.defenderStrength_Q;
224
+ const totalStr = atkStr + defStr;
225
+ const atkFrac = totalStr > 0 ? Math.round(atkStr * SCALE.Q / totalStr) : SCALE.Q / 2;
226
+ // Thresholds:
227
+ // [0, victoryThreshold) → attacker_victory
228
+ // [victoryThreshold, stalemateThreshold) → stalemate
229
+ // [stalemateThreshold, SCALE.Q) → defender_holds
230
+ const victoryThreshold = Math.round(atkFrac * 0.7);
231
+ const stalemateThreshold = Math.round(atkFrac * 0.9);
232
+ let outcome;
233
+ let attackerCas;
234
+ let defenderCas;
235
+ let tributeAmount;
236
+ if (roll < victoryThreshold) {
237
+ outcome = "attacker_victory";
238
+ attackerCas = ATTACKER_CASUALTY_ON_VICTORY_Q;
239
+ defenderCas = DEFENDER_CASUALTY_ON_VICTORY_Q;
240
+ tributeAmount = Math.floor(mulDiv(defender.treasury_cu, VICTORY_TRIBUTE_Q, SCALE.Q));
241
+ defender.treasury_cu -= tributeAmount;
242
+ attacker.treasury_cu += tributeAmount;
243
+ }
244
+ else if (roll < stalemateThreshold) {
245
+ outcome = "stalemate";
246
+ attackerCas = ATTACKER_CASUALTY_ON_STALEMATE_Q;
247
+ defenderCas = DEFENDER_CASUALTY_ON_STALEMATE_Q;
248
+ }
249
+ else {
250
+ outcome = "defender_holds";
251
+ attackerCas = ATTACKER_CASUALTY_ON_DEFEAT_Q;
252
+ defenderCas = DEFENDER_CASUALTY_ON_DEFEAT_Q;
253
+ }
254
+ // Apply strength reduction
255
+ campaign.attackerStrength_Q = clampQ(campaign.attackerStrength_Q - mulDiv(campaign.attackerStrength_Q, attackerCas, SCALE.Q), 0, SCALE.Q);
256
+ campaign.defenderStrength_Q = clampQ(campaign.defenderStrength_Q - mulDiv(campaign.defenderStrength_Q, defenderCas, SCALE.Q), 0, SCALE.Q);
257
+ campaign.outcome = outcome;
258
+ campaign.phase = "resolved";
259
+ return {
260
+ outcome,
261
+ attackerCasualties_Q: attackerCas,
262
+ defenderCasualties_Q: defenderCas,
263
+ ...(tributeAmount !== undefined ? { tributeAmount } : {}),
264
+ };
265
+ }
266
+ // ── Post-battle consequences ──────────────────────────────────────────────────
267
+ /**
268
+ * Apply morale and stability penalties to both sides after a resolved battle.
269
+ *
270
+ * - Loser: morale −`DEFEAT_MORALE_HIT_Q`, stability −`DEFEAT_STABILITY_HIT_Q`.
271
+ * - Winner: morale +`VICTORY_MORALE_BONUS_Q` (capped at SCALE.Q).
272
+ * - Both: stability drained by `COMBAT_STABILITY_DRAIN_Q` (war is always costly).
273
+ *
274
+ * Mutates `attacker` and `defender` in place.
275
+ */
276
+ export const DEFEAT_MORALE_HIT_Q = q(0.20);
277
+ export const DEFEAT_STABILITY_HIT_Q = q(0.15);
278
+ export const VICTORY_MORALE_BONUS_Q = q(0.10);
279
+ export const COMBAT_STABILITY_DRAIN_Q = q(0.05);
280
+ export function applyBattleConsequences(result, attacker, defender) {
281
+ // Both sides pay a stability toll for the war
282
+ attacker.stabilityQ = clampQ(attacker.stabilityQ - COMBAT_STABILITY_DRAIN_Q, 0, SCALE.Q);
283
+ defender.stabilityQ = clampQ(defender.stabilityQ - COMBAT_STABILITY_DRAIN_Q, 0, SCALE.Q);
284
+ if (result.outcome === "attacker_victory") {
285
+ attacker.moraleQ = clampQ(attacker.moraleQ + VICTORY_MORALE_BONUS_Q, 0, SCALE.Q);
286
+ defender.moraleQ = clampQ(defender.moraleQ - DEFEAT_MORALE_HIT_Q, 0, SCALE.Q);
287
+ defender.stabilityQ = clampQ(defender.stabilityQ - DEFEAT_STABILITY_HIT_Q, 0, SCALE.Q);
288
+ }
289
+ else if (result.outcome === "defender_holds") {
290
+ defender.moraleQ = clampQ(defender.moraleQ + VICTORY_MORALE_BONUS_Q, 0, SCALE.Q);
291
+ attacker.moraleQ = clampQ(attacker.moraleQ - DEFEAT_MORALE_HIT_Q, 0, SCALE.Q);
292
+ attacker.stabilityQ = clampQ(attacker.stabilityQ - DEFEAT_STABILITY_HIT_Q, 0, SCALE.Q);
293
+ }
294
+ else {
295
+ // Stalemate: minor morale drain on both
296
+ attacker.moraleQ = clampQ(attacker.moraleQ - mulDiv(DEFEAT_MORALE_HIT_Q, q(0.40), SCALE.Q), 0, SCALE.Q);
297
+ defender.moraleQ = clampQ(defender.moraleQ - mulDiv(DEFEAT_MORALE_HIT_Q, q(0.40), SCALE.Q), 0, SCALE.Q);
298
+ }
299
+ }
300
+ // ── Upkeep & attrition ────────────────────────────────────────────────────────
301
+ /**
302
+ * Compute daily treasury upkeep for an active campaign [cost-units/day].
303
+ */
304
+ export function computeDailyUpkeep(campaign) {
305
+ return campaign.attackerArmySize * CAMPAIGN_UPKEEP_PER_SOLDIER;
306
+ }
307
+ /**
308
+ * Return the war unrest pressure on the attacker polity during an active campaign.
309
+ * Pass as an extra factor into Phase-90 `computeUnrestLevel`.
310
+ * Returns 0 when campaign is resolved.
311
+ */
312
+ export function computeWarUnrestPressure(campaign) {
313
+ if (campaign.phase === "resolved")
314
+ return 0;
315
+ return WAR_UNREST_PRESSURE_Q;
316
+ }
@@ -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,101 @@
1
+ import type { Q } from "./units.js";
2
+ import type { Polity } from "./polity.js";
3
+ /** Per-polity tax configuration. Store one externally per polity. */
4
+ export interface TaxPolicy {
5
+ polityId: string;
6
+ /**
7
+ * Fraction of the theoretical maximum revenue to collect [0, SCALE.Q].
8
+ * q(0.15) = standard rate; q(0.25) or above triggers unrest pressure.
9
+ */
10
+ taxRate_Q: Q;
11
+ /**
12
+ * Fraction of the population exempt from taxation (clergy, nobility, etc.)
13
+ * [0, SCALE.Q]. Reduces taxable base proportionally. Defaults to 0.
14
+ */
15
+ exemptFraction_Q?: Q;
16
+ }
17
+ /** Result returned by `stepTaxCollection`. */
18
+ export interface TaxCollectionResult {
19
+ /** Cost-units added to polity treasury this step. */
20
+ revenue_cu: number;
21
+ /** Unrest pressure generated by the current tax rate [0, SCALE.Q]. */
22
+ unrestPressure_Q: Q;
23
+ }
24
+ /**
25
+ * Annual tax revenue per capita at full (q(1.0)) tax rate, keyed by TechEra.
26
+ * Prehistoric has no monetary economy — yield is zero.
27
+ * Values are in cost-units per person per year.
28
+ */
29
+ export declare const TAX_REVENUE_PER_CAPITA_ANNUAL: Record<number, number>;
30
+ /**
31
+ * Tax rate below which no unrest pressure is generated [Q].
32
+ * Rates at or below this are considered politically acceptable.
33
+ */
34
+ export declare const OPTIMAL_TAX_RATE_Q: Q;
35
+ /**
36
+ * Tax rate above which unrest pressure reaches maximum [Q].
37
+ * Between OPTIMAL and MAX, pressure scales linearly.
38
+ */
39
+ export declare const MAX_TAX_RATE_Q: Q;
40
+ /**
41
+ * Maximum unrest pressure that taxation alone can generate [Q].
42
+ * Passed as an extra additive factor into Phase-90 `computeUnrestLevel`.
43
+ */
44
+ export declare const MAX_TAX_UNREST_Q: Q;
45
+ /** Create a default TaxPolicy with a standard rate and no exemptions. */
46
+ export declare function createTaxPolicy(polityId: string, taxRate_Q?: Q): TaxPolicy;
47
+ /**
48
+ * Compute annual tax revenue for a polity [cost-units/year].
49
+ *
50
+ * Formula:
51
+ * taxablePopulation = population × (SCALE.Q − exemptFraction) / SCALE.Q
52
+ * perCapita = TAX_REVENUE_PER_CAPITA_ANNUAL[techEra] (default 0)
53
+ * stabilityMul = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
54
+ * ∈ [5000, 10000] = [q(0.50), q(1.00)]
55
+ * gross = taxablePopulation × perCapita × taxRate / SCALE.Q
56
+ * annual = round(gross × stabilityMul / SCALE.Q)
57
+ *
58
+ * Stability models collection efficiency: a fractured polity cannot collect
59
+ * the full assessed tax. At zero stability, only half the theoretical
60
+ * revenue is gathered.
61
+ */
62
+ export declare function computeAnnualTaxRevenue(polity: Polity, policy: TaxPolicy): number;
63
+ /**
64
+ * Compute daily tax revenue [cost-units/day].
65
+ * Derived from `computeAnnualTaxRevenue` with day-fraction rounding.
66
+ */
67
+ export declare function computeDailyTaxRevenue(polity: Polity, policy: TaxPolicy): number;
68
+ /**
69
+ * Compute the unrest pressure generated by the current tax rate [Q].
70
+ *
71
+ * - At or below `OPTIMAL_TAX_RATE_Q`: pressure = 0.
72
+ * - Between OPTIMAL and MAX_TAX_RATE_Q: linear ramp 0 → MAX_TAX_UNREST_Q.
73
+ * - At or above MAX_TAX_RATE_Q: pressure = MAX_TAX_UNREST_Q.
74
+ *
75
+ * Pass the result as an extra additive unrest factor to Phase-90
76
+ * `computeUnrestLevel`.
77
+ */
78
+ export declare function computeTaxUnrestPressure(policy: TaxPolicy): Q;
79
+ /**
80
+ * Collect taxes for `elapsedDays` days and add to `polity.treasury_cu`.
81
+ *
82
+ * Mutates `polity.treasury_cu`.
83
+ *
84
+ * @returns Revenue added and the unrest pressure the current rate generates.
85
+ */
86
+ export declare function stepTaxCollection(polity: Polity, policy: TaxPolicy, elapsedDays: number): TaxCollectionResult;
87
+ /**
88
+ * Estimate how many days until the treasury reaches a target amount at the
89
+ * current daily tax revenue. Returns `Infinity` if daily revenue is zero.
90
+ */
91
+ export declare function estimateDaysToTreasuryTarget(polity: Polity, policy: TaxPolicy, targetAmount: number): number;
92
+ /**
93
+ * Compute the effective tax rate needed to hit a desired annual revenue,
94
+ * clamped to [0, MAX_TAX_RATE_Q].
95
+ *
96
+ * Useful for host AI: "what rate do I need to fund X?"
97
+ *
98
+ * Returns MAX_TAX_RATE_Q if the desired revenue exceeds what full taxation
99
+ * can provide.
100
+ */
101
+ export declare function computeRequiredTaxRate(polity: Polity, desiredAnnual: number): Q;
@@ -0,0 +1,160 @@
1
+ // src/taxation.ts — Phase 92: Taxation & Treasury Revenue
2
+ //
3
+ // Polities derive the bulk of their income from taxing population and trade.
4
+ // This module models per-capita tax yields by tech era, stability-modulated
5
+ // collection efficiency, and the unrest pressure that high rates generate.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `TaxPolicy` is stored externally per polity by the host.
10
+ // - Uses numeric TechEra values (0–8) from Phase-11 tech.ts.
11
+ // - All arithmetic is integer fixed-point; no floating-point accumulation.
12
+ // - Trade-route income (Phase-83/89) is separate and additive.
13
+ //
14
+ // Integration:
15
+ // Phase 11 (Tech): TechEra gates per-capita base yield.
16
+ // Phase 61 (Polity): population, stabilityQ, treasury_cu are read/mutated.
17
+ // Phase 90 (Unrest): computeTaxUnrestPressure returns a faminePressure_Q-style
18
+ // value for use as an additional unrest factor.
19
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
20
+ import { TechEra } from "./sim/tech.js";
21
+ // ── Constants ─────────────────────────────────────────────────────────────────
22
+ /**
23
+ * Annual tax revenue per capita at full (q(1.0)) tax rate, keyed by TechEra.
24
+ * Prehistoric has no monetary economy — yield is zero.
25
+ * Values are in cost-units per person per year.
26
+ */
27
+ export const TAX_REVENUE_PER_CAPITA_ANNUAL = {
28
+ [TechEra.Prehistoric]: 0,
29
+ [TechEra.Ancient]: 2,
30
+ [TechEra.Medieval]: 5,
31
+ [TechEra.EarlyModern]: 15,
32
+ [TechEra.Industrial]: 50,
33
+ [TechEra.Modern]: 200,
34
+ [TechEra.NearFuture]: 1_000,
35
+ [TechEra.FarFuture]: 5_000,
36
+ [TechEra.DeepSpace]: 20_000,
37
+ };
38
+ /**
39
+ * Tax rate below which no unrest pressure is generated [Q].
40
+ * Rates at or below this are considered politically acceptable.
41
+ */
42
+ export const OPTIMAL_TAX_RATE_Q = q(0.15);
43
+ /**
44
+ * Tax rate above which unrest pressure reaches maximum [Q].
45
+ * Between OPTIMAL and MAX, pressure scales linearly.
46
+ */
47
+ export const MAX_TAX_RATE_Q = q(0.50);
48
+ /**
49
+ * Maximum unrest pressure that taxation alone can generate [Q].
50
+ * Passed as an extra additive factor into Phase-90 `computeUnrestLevel`.
51
+ */
52
+ export const MAX_TAX_UNREST_Q = q(0.30);
53
+ // ── Factory ───────────────────────────────────────────────────────────────────
54
+ /** Create a default TaxPolicy with a standard rate and no exemptions. */
55
+ export function createTaxPolicy(polityId, taxRate_Q = q(0.15)) {
56
+ return { polityId, taxRate_Q };
57
+ }
58
+ // ── Core computation ──────────────────────────────────────────────────────────
59
+ /**
60
+ * Compute annual tax revenue for a polity [cost-units/year].
61
+ *
62
+ * Formula:
63
+ * taxablePopulation = population × (SCALE.Q − exemptFraction) / SCALE.Q
64
+ * perCapita = TAX_REVENUE_PER_CAPITA_ANNUAL[techEra] (default 0)
65
+ * stabilityMul = SCALE.Q/2 + mulDiv(SCALE.Q/2, stabilityQ, SCALE.Q)
66
+ * ∈ [5000, 10000] = [q(0.50), q(1.00)]
67
+ * gross = taxablePopulation × perCapita × taxRate / SCALE.Q
68
+ * annual = round(gross × stabilityMul / SCALE.Q)
69
+ *
70
+ * Stability models collection efficiency: a fractured polity cannot collect
71
+ * the full assessed tax. At zero stability, only half the theoretical
72
+ * revenue is gathered.
73
+ */
74
+ export function computeAnnualTaxRevenue(polity, policy) {
75
+ const perCapita = TAX_REVENUE_PER_CAPITA_ANNUAL[polity.techEra] ?? 0;
76
+ if (perCapita === 0)
77
+ return 0;
78
+ const exempt = policy.exemptFraction_Q ?? 0;
79
+ const taxablePop = Math.round(polity.population * (SCALE.Q - exempt) / SCALE.Q);
80
+ const gross = Math.round(taxablePop * perCapita * policy.taxRate_Q / SCALE.Q);
81
+ const stabilityMul = SCALE.Q / 2 + mulDiv(SCALE.Q / 2, polity.stabilityQ, SCALE.Q);
82
+ return Math.max(0, Math.round(gross * stabilityMul / SCALE.Q));
83
+ }
84
+ /**
85
+ * Compute daily tax revenue [cost-units/day].
86
+ * Derived from `computeAnnualTaxRevenue` with day-fraction rounding.
87
+ */
88
+ export function computeDailyTaxRevenue(polity, policy) {
89
+ const annual = computeAnnualTaxRevenue(polity, policy);
90
+ return Math.max(0, Math.round(annual / 365));
91
+ }
92
+ /**
93
+ * Compute the unrest pressure generated by the current tax rate [Q].
94
+ *
95
+ * - At or below `OPTIMAL_TAX_RATE_Q`: pressure = 0.
96
+ * - Between OPTIMAL and MAX_TAX_RATE_Q: linear ramp 0 → MAX_TAX_UNREST_Q.
97
+ * - At or above MAX_TAX_RATE_Q: pressure = MAX_TAX_UNREST_Q.
98
+ *
99
+ * Pass the result as an extra additive unrest factor to Phase-90
100
+ * `computeUnrestLevel`.
101
+ */
102
+ export function computeTaxUnrestPressure(policy) {
103
+ if (policy.taxRate_Q <= OPTIMAL_TAX_RATE_Q)
104
+ return 0;
105
+ const excess = policy.taxRate_Q - OPTIMAL_TAX_RATE_Q;
106
+ const range = MAX_TAX_RATE_Q - OPTIMAL_TAX_RATE_Q;
107
+ const frac = clampQ(Math.round(excess * SCALE.Q / range), 0, SCALE.Q);
108
+ return clampQ(Math.round(frac * MAX_TAX_UNREST_Q / SCALE.Q), 0, MAX_TAX_UNREST_Q);
109
+ }
110
+ // ── Treasury step ─────────────────────────────────────────────────────────────
111
+ /**
112
+ * Collect taxes for `elapsedDays` days and add to `polity.treasury_cu`.
113
+ *
114
+ * Mutates `polity.treasury_cu`.
115
+ *
116
+ * @returns Revenue added and the unrest pressure the current rate generates.
117
+ */
118
+ export function stepTaxCollection(polity, policy, elapsedDays) {
119
+ const annual = computeAnnualTaxRevenue(polity, policy);
120
+ const revenue_cu = Math.max(0, Math.round(annual * elapsedDays / 365));
121
+ polity.treasury_cu += revenue_cu;
122
+ const unrestPressure_Q = computeTaxUnrestPressure(policy);
123
+ return { revenue_cu, unrestPressure_Q };
124
+ }
125
+ // ── Reporting ─────────────────────────────────────────────────────────────────
126
+ /**
127
+ * Estimate how many days until the treasury reaches a target amount at the
128
+ * current daily tax revenue. Returns `Infinity` if daily revenue is zero.
129
+ */
130
+ export function estimateDaysToTreasuryTarget(polity, policy, targetAmount) {
131
+ const daily = computeDailyTaxRevenue(polity, policy);
132
+ if (daily <= 0)
133
+ return Infinity;
134
+ const needed = Math.max(0, targetAmount - polity.treasury_cu);
135
+ if (needed === 0)
136
+ return 0;
137
+ return Math.ceil(needed / daily);
138
+ }
139
+ /**
140
+ * Compute the effective tax rate needed to hit a desired annual revenue,
141
+ * clamped to [0, MAX_TAX_RATE_Q].
142
+ *
143
+ * Useful for host AI: "what rate do I need to fund X?"
144
+ *
145
+ * Returns MAX_TAX_RATE_Q if the desired revenue exceeds what full taxation
146
+ * can provide.
147
+ */
148
+ export function computeRequiredTaxRate(polity, desiredAnnual) {
149
+ const perCapita = TAX_REVENUE_PER_CAPITA_ANNUAL[polity.techEra] ?? 0;
150
+ if (perCapita === 0 || polity.population === 0)
151
+ return MAX_TAX_RATE_Q;
152
+ const stabilityMul = SCALE.Q / 2 + mulDiv(SCALE.Q / 2, polity.stabilityQ, SCALE.Q);
153
+ // reverse-solve: rate = desiredAnnual × SCALE.Q² / (population × perCapita × stabilityMul)
154
+ const numerator = desiredAnnual * SCALE.Q * SCALE.Q;
155
+ const denominator = polity.population * perCapita * stabilityMul;
156
+ if (denominator <= 0)
157
+ return MAX_TAX_RATE_Q;
158
+ const rate = Math.ceil(numerator / denominator);
159
+ return clampQ(rate, 0, MAX_TAX_RATE_Q);
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.35",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -126,6 +126,18 @@
126
126
  "./unrest": {
127
127
  "import": "./dist/src/unrest.js",
128
128
  "types": "./dist/src/unrest.d.ts"
129
+ },
130
+ "./research": {
131
+ "import": "./dist/src/research.js",
132
+ "types": "./dist/src/research.d.ts"
133
+ },
134
+ "./taxation": {
135
+ "import": "./dist/src/taxation.js",
136
+ "types": "./dist/src/taxation.d.ts"
137
+ },
138
+ "./military-campaign": {
139
+ "import": "./dist/src/military-campaign.js",
140
+ "types": "./dist/src/military-campaign.d.ts"
129
141
  }
130
142
  },
131
143
  "files": [