@its-not-rocket-science/ananke 0.1.36 → 0.1.39
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 +61 -0
- package/dist/src/governance.d.ts +125 -0
- package/dist/src/governance.js +251 -0
- package/dist/src/military-campaign.d.ts +210 -0
- package/dist/src/military-campaign.js +316 -0
- package/dist/src/taxation.d.ts +101 -0
- package/dist/src/taxation.js +160 -0
- package/package.json +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,67 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.39] — 2026-03-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Phase 94 · Laws & Governance Codes** (`src/governance.ts`)
|
|
14
|
+
- `GovernanceType`: `"tribal" | "monarchy" | "oligarchy" | "republic" | "empire" | "theocracy"`.
|
|
15
|
+
- `GovernanceModifiers { taxEfficiencyMul_Q, mobilizationMax_Q, researchBonus, unrestMitigation_Q, stabilityIncrement_Q }` — aggregate modifier bundle applied to downstream phases.
|
|
16
|
+
- `GOVERNANCE_BASE: Record<GovernanceType, GovernanceModifiers>` — baseline modifiers per type; tribal maximises mobilisation (q(0.20)) but has lowest tax efficiency (q(0.60)); oligarchy and empire share highest tax efficiency (q(1.00)); theocracy has highest unrest mitigation (q(0.18)); republic has highest research bonus (+3).
|
|
17
|
+
- `LawCode { lawId, name, taxBonus_Q, researchBonus, mobilizationBonus_Q, unrestBonus_Q, stabilityCostPerDay_Q }` — discrete enacted policies.
|
|
18
|
+
- Five preset laws: `LAW_CONSCRIPTION` (+mobilisation, stability cost), `LAW_TAX_REFORM` (+tax), `LAW_SCHOLAR_PATRONAGE` (+5 research), `LAW_RULE_OF_LAW` (+tax +unrest mitigation), `LAW_MARTIAL_LAW` (+unrest mitigation, heavy stability drain).
|
|
19
|
+
- `GovernanceState { polityId, governanceType, activeLawIds, changeCooldown }`.
|
|
20
|
+
- `computeGovernanceModifiers(state, lawRegistry?)` — stacks law bonuses on governance baseline; clamps all outputs.
|
|
21
|
+
- `enactLaw(state, lawId)` / `repealLaw(state, lawId)` — add/remove laws; enforces `MAX_ACTIVE_LAWS = 5`.
|
|
22
|
+
- `changeGovernance(polity, state, newType)` — hits `polity.stabilityQ` by q(0.20); sets 365-day cooldown; no-op on same type or during cooldown.
|
|
23
|
+
- `stepGovernanceCooldown(state, elapsedDays)` — ticks down cooldown.
|
|
24
|
+
- `stepGovernanceStability(polity, state, elapsedDays, lawRegistry?)` — applies net `stabilityIncrement_Q` per day to `polity.stabilityQ`; no-op when law costs cancel the baseline.
|
|
25
|
+
- Added `./governance` subpath export to `package.json`.
|
|
26
|
+
- 48 new tests; 4,932 total. 100% statement/branch/function/line coverage. All thresholds met.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [0.1.38] — 2026-03-26
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Phase 93 · Military Campaigns & War Resolution** (`src/military-campaign.ts`)
|
|
35
|
+
- `CampaignState { campaignId, attackerPolityId, defenderPolityId, phase, startTick, daysElapsed, marchProgress_Q, attackerArmySize, attackerStrength_Q, defenderStrength_Q, outcome? }` — mutable live state stored externally per conflict.
|
|
36
|
+
- `CampaignPhase`: `"mobilization" | "march" | "battle" | "resolved"`.
|
|
37
|
+
- `BattleOutcome`: `"attacker_victory" | "defender_holds" | "stalemate"`.
|
|
38
|
+
- `computeArmySize(polity, mobilizationFrac_Q?)` — default q(0.05); clamped to `MAX_MOBILIZATION_Q = q(0.15)`.
|
|
39
|
+
- `computeBattleStrength(polity, armySize)` → Q: `militaryStrength_Q × armySize / REFERENCE_ARMY_SIZE × TECH_SOLDIER_MUL[techEra] × stabilityMul`; clamped to SCALE.Q.
|
|
40
|
+
- `mobilizeCampaign(campaign, attacker, mobilizationFrac_Q?)` — drains `MOBILIZATION_COST_PER_SOLDIER = 5` cu per soldier (capped at treasury); transitions to `"march"`.
|
|
41
|
+
- `prepareDefender(campaign, defender, wallBonus_Q?)` — sets defender strength; Phase-89 wall bonus increases effective defence.
|
|
42
|
+
- `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.
|
|
43
|
+
- `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.
|
|
44
|
+
- `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)`.
|
|
45
|
+
- `computeWarUnrestPressure(campaign)` → Q: `WAR_UNREST_PRESSURE_Q = q(0.15)` during active campaign; 0 when resolved — feeds Phase-90 `computeUnrestLevel`.
|
|
46
|
+
- `computeDailyUpkeep(campaign)` → cu/day.
|
|
47
|
+
- Added `./military-campaign` subpath export to `package.json`.
|
|
48
|
+
- 56 new tests; 4,884 total. Coverage maintained above all thresholds.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## [0.1.37] — 2026-03-26
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **Phase 92 · Taxation & Treasury Revenue** (`src/taxation.ts`)
|
|
57
|
+
- `TaxPolicy { polityId, taxRate_Q, exemptFraction_Q? }` — per-polity config stored externally by the host.
|
|
58
|
+
- `TAX_REVENUE_PER_CAPITA_ANNUAL: Record<number, number>` — numeric TechEra keys; Prehistoric 0 → DeepSpace 20 k cu/person/year.
|
|
59
|
+
- `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.
|
|
60
|
+
- `computeDailyTaxRevenue(polity, policy)` → cu/day: annual ÷ 365 with rounding.
|
|
61
|
+
- `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.
|
|
62
|
+
- `stepTaxCollection(polity, policy, elapsedDays)` → `TaxCollectionResult`: adds `round(annual × days / 365)` to `polity.treasury_cu`; returns revenue and unrest pressure.
|
|
63
|
+
- `estimateDaysToTreasuryTarget(polity, policy, targetAmount)` → ceiling days; Infinity at zero daily rate.
|
|
64
|
+
- `computeRequiredTaxRate(polity, desiredAnnual)` → Q: reverse-solves for the rate needed to meet a target; clamped to MAX_TAX_RATE_Q.
|
|
65
|
+
- Added `./taxation` subpath export to `package.json`.
|
|
66
|
+
- 49 new tests; 4,828 total. Coverage maintained above all thresholds.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
9
70
|
## [0.1.36] — 2026-03-26
|
|
10
71
|
|
|
11
72
|
### Added
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Q } from "./units.js";
|
|
2
|
+
import type { Polity } from "./polity.js";
|
|
3
|
+
/** Governance form of a polity. */
|
|
4
|
+
export type GovernanceType = "tribal" | "monarchy" | "oligarchy" | "republic" | "empire" | "theocracy";
|
|
5
|
+
/**
|
|
6
|
+
* Modifier bundle derived from a polity's governance type and enacted laws.
|
|
7
|
+
* Each field is a Q multiplier or bonus that callers pass to downstream phases.
|
|
8
|
+
*/
|
|
9
|
+
export interface GovernanceModifiers {
|
|
10
|
+
/** Multiplier on Phase-92 annual tax revenue [Q]. */
|
|
11
|
+
taxEfficiencyMul_Q: Q;
|
|
12
|
+
/** Maximum mobilisation fraction override for Phase-93 [Q]. */
|
|
13
|
+
mobilizationMax_Q: Q;
|
|
14
|
+
/** Flat daily research point bonus for Phase-91 [integer]. */
|
|
15
|
+
researchBonus: number;
|
|
16
|
+
/**
|
|
17
|
+
* Passive unrest mitigation [Q].
|
|
18
|
+
* Subtract from raw unrest level before Phase-90 thresholds.
|
|
19
|
+
*/
|
|
20
|
+
unrestMitigation_Q: Q;
|
|
21
|
+
/** Passive daily stability increment [Q/day × 100 to avoid sub-unit loss]. */
|
|
22
|
+
stabilityIncrement_Q: Q;
|
|
23
|
+
}
|
|
24
|
+
/** A discrete enacted law providing targeted modifiers. */
|
|
25
|
+
export interface LawCode {
|
|
26
|
+
lawId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
/** Additive bonus to `taxEfficiencyMul_Q` [Q]. */
|
|
29
|
+
taxBonus_Q: Q;
|
|
30
|
+
/** Additive bonus to `researchBonus` [integer]. */
|
|
31
|
+
researchBonus: number;
|
|
32
|
+
/** Additive bonus to `mobilizationMax_Q` [Q]. */
|
|
33
|
+
mobilizationBonus_Q: Q;
|
|
34
|
+
/** Additive bonus to `unrestMitigation_Q` [Q]. */
|
|
35
|
+
unrestBonus_Q: Q;
|
|
36
|
+
/** Stability cost per day while this law is active [Q]. */
|
|
37
|
+
stabilityCostPerDay_Q: Q;
|
|
38
|
+
}
|
|
39
|
+
/** Per-polity governance state. Store one externally per polity. */
|
|
40
|
+
export interface GovernanceState {
|
|
41
|
+
polityId: string;
|
|
42
|
+
governanceType: GovernanceType;
|
|
43
|
+
/** IDs of currently enacted laws. Max `MAX_ACTIVE_LAWS`. */
|
|
44
|
+
activeLawIds: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Cooldown days before governance type can be changed again.
|
|
47
|
+
* `stepGovernanceCooldown` decrements this.
|
|
48
|
+
*/
|
|
49
|
+
changeCooldown: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Maximum number of laws that can be active simultaneously.
|
|
53
|
+
*/
|
|
54
|
+
export declare const MAX_ACTIVE_LAWS = 5;
|
|
55
|
+
/**
|
|
56
|
+
* Stability penalty applied when changing governance type [Q].
|
|
57
|
+
* Represents the upheaval of political transition.
|
|
58
|
+
*/
|
|
59
|
+
export declare const GOVERNANCE_CHANGE_STABILITY_HIT_Q: Q;
|
|
60
|
+
/**
|
|
61
|
+
* Cooldown days after a governance change before another is allowed.
|
|
62
|
+
*/
|
|
63
|
+
export declare const GOVERNANCE_CHANGE_COOLDOWN_DAYS = 365;
|
|
64
|
+
/**
|
|
65
|
+
* Baseline governance modifiers for each type.
|
|
66
|
+
* Callers layer law-code bonuses on top.
|
|
67
|
+
*/
|
|
68
|
+
export declare const GOVERNANCE_BASE: Record<GovernanceType, GovernanceModifiers>;
|
|
69
|
+
/** Conscription law: larger armies, minor stability cost. */
|
|
70
|
+
export declare const LAW_CONSCRIPTION: LawCode;
|
|
71
|
+
/** Tax reform: better tax efficiency, minor unrest from displacing old collectors. */
|
|
72
|
+
export declare const LAW_TAX_REFORM: LawCode;
|
|
73
|
+
/** Patronage of scholars: research bonus, expensive. */
|
|
74
|
+
export declare const LAW_SCHOLAR_PATRONAGE: LawCode;
|
|
75
|
+
/** Rule of law: stability bonus, research bonus, small unrest mitigation. */
|
|
76
|
+
export declare const LAW_RULE_OF_LAW: LawCode;
|
|
77
|
+
/** Martial law: strong unrest mitigation but heavy stability drain. */
|
|
78
|
+
export declare const LAW_MARTIAL_LAW: LawCode;
|
|
79
|
+
export declare const PRESET_LAW_CODES: LawCode[];
|
|
80
|
+
/** Create a fresh `GovernanceState` with no laws and no cooldown. */
|
|
81
|
+
export declare function createGovernanceState(polityId: string, governanceType?: GovernanceType): GovernanceState;
|
|
82
|
+
/**
|
|
83
|
+
* Compute the aggregate `GovernanceModifiers` for the given state plus active laws.
|
|
84
|
+
*
|
|
85
|
+
* Each law's bonuses are added on top of the governance baseline.
|
|
86
|
+
* `taxEfficiencyMul_Q` is clamped to SCALE.Q; others to [0, SCALE.Q].
|
|
87
|
+
*
|
|
88
|
+
* @param lawRegistry Map of lawId → LawCode. Pass only enacted laws.
|
|
89
|
+
*/
|
|
90
|
+
export declare function computeGovernanceModifiers(state: GovernanceState, lawRegistry?: Map<string, LawCode>): GovernanceModifiers;
|
|
91
|
+
/**
|
|
92
|
+
* Enact a new law. Returns `false` if already enacted or at `MAX_ACTIVE_LAWS`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function enactLaw(state: GovernanceState, lawId: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Repeal an active law. Returns `false` if the law was not active.
|
|
97
|
+
*/
|
|
98
|
+
export declare function repealLaw(state: GovernanceState, lawId: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Change the governance type of a polity.
|
|
101
|
+
*
|
|
102
|
+
* Applies `GOVERNANCE_CHANGE_STABILITY_HIT_Q` to `polity.stabilityQ` and
|
|
103
|
+
* sets `state.changeCooldown = GOVERNANCE_CHANGE_COOLDOWN_DAYS`.
|
|
104
|
+
*
|
|
105
|
+
* Returns `false` (no-op) if:
|
|
106
|
+
* - `newType` is the same as current type.
|
|
107
|
+
* - `state.changeCooldown > 0` (still cooling down).
|
|
108
|
+
*/
|
|
109
|
+
export declare function changeGovernance(polity: Polity, state: GovernanceState, newType: GovernanceType): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Tick down the governance change cooldown.
|
|
112
|
+
* Mutates `state.changeCooldown`; never goes below 0.
|
|
113
|
+
*/
|
|
114
|
+
export declare function stepGovernanceCooldown(state: GovernanceState, elapsedDays: number): void;
|
|
115
|
+
/**
|
|
116
|
+
* Apply the governance passive stability increment per elapsed days.
|
|
117
|
+
*
|
|
118
|
+
* Uses `computeGovernanceModifiers` to get the net `stabilityIncrement_Q`,
|
|
119
|
+
* then adds `increment × elapsedDays` to `polity.stabilityQ`.
|
|
120
|
+
*
|
|
121
|
+
* No-op if net increment is 0 (law costs cancel the baseline bonus).
|
|
122
|
+
*
|
|
123
|
+
* @param lawRegistry Active law registry.
|
|
124
|
+
*/
|
|
125
|
+
export declare function stepGovernanceStability(polity: Polity, state: GovernanceState, elapsedDays: number, lawRegistry?: Map<string, LawCode>): void;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/governance.ts — Phase 94: Laws & Governance Codes
|
|
2
|
+
//
|
|
3
|
+
// The governance type of a polity shapes how effectively it taxes, mobilises
|
|
4
|
+
// armies, maintains stability, and advances research. Law codes are discrete
|
|
5
|
+
// enacted policies that provide targeted bonuses and penalties on top of the
|
|
6
|
+
// governance baseline.
|
|
7
|
+
//
|
|
8
|
+
// Design:
|
|
9
|
+
// - Pure data layer — no Entity fields, no kernel changes.
|
|
10
|
+
// - `GovernanceState` is stored externally per polity by the host.
|
|
11
|
+
// - `computeGovernanceModifiers` returns a single struct; callers apply each
|
|
12
|
+
// field to the appropriate Phase (92 tax, 91 research, 93 mobilisation, 90 unrest).
|
|
13
|
+
// - Governance changes trigger a stability hit and a cooldown before the
|
|
14
|
+
// next change is allowed.
|
|
15
|
+
// - All arithmetic is integer fixed-point; no floating-point accumulation.
|
|
16
|
+
//
|
|
17
|
+
// Integration:
|
|
18
|
+
// Phase 61 (Polity): stabilityQ mutated on governance change.
|
|
19
|
+
// Phase 90 (Unrest): unrestMitigation_Q reduces effective unrest.
|
|
20
|
+
// Phase 91 (Research): researchBonus added as flat bonus points/day.
|
|
21
|
+
// Phase 92 (Taxation): taxEfficiencyMul scales annual revenue.
|
|
22
|
+
// Phase 93 (Campaign): mobilizationMax_Q overrides default mobilisation cap.
|
|
23
|
+
import { q, SCALE, clampQ } from "./units.js";
|
|
24
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Maximum number of laws that can be active simultaneously.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_ACTIVE_LAWS = 5;
|
|
29
|
+
/**
|
|
30
|
+
* Stability penalty applied when changing governance type [Q].
|
|
31
|
+
* Represents the upheaval of political transition.
|
|
32
|
+
*/
|
|
33
|
+
export const GOVERNANCE_CHANGE_STABILITY_HIT_Q = q(0.20);
|
|
34
|
+
/**
|
|
35
|
+
* Cooldown days after a governance change before another is allowed.
|
|
36
|
+
*/
|
|
37
|
+
export const GOVERNANCE_CHANGE_COOLDOWN_DAYS = 365;
|
|
38
|
+
/**
|
|
39
|
+
* Baseline governance modifiers for each type.
|
|
40
|
+
* Callers layer law-code bonuses on top.
|
|
41
|
+
*/
|
|
42
|
+
export const GOVERNANCE_BASE = {
|
|
43
|
+
tribal: {
|
|
44
|
+
taxEfficiencyMul_Q: q(0.60),
|
|
45
|
+
mobilizationMax_Q: q(0.20), // can field a larger fraction but untrained
|
|
46
|
+
researchBonus: 0,
|
|
47
|
+
unrestMitigation_Q: q(0.05),
|
|
48
|
+
stabilityIncrement_Q: 0,
|
|
49
|
+
},
|
|
50
|
+
monarchy: {
|
|
51
|
+
taxEfficiencyMul_Q: q(0.80),
|
|
52
|
+
mobilizationMax_Q: q(0.12),
|
|
53
|
+
researchBonus: 1,
|
|
54
|
+
unrestMitigation_Q: q(0.08),
|
|
55
|
+
stabilityIncrement_Q: q(0.001),
|
|
56
|
+
},
|
|
57
|
+
oligarchy: {
|
|
58
|
+
taxEfficiencyMul_Q: q(1.00), // efficient tax extraction for the elite
|
|
59
|
+
mobilizationMax_Q: q(0.08), // mercenary-reliant, smaller citizen levy
|
|
60
|
+
researchBonus: 2,
|
|
61
|
+
unrestMitigation_Q: q(0.03),
|
|
62
|
+
stabilityIncrement_Q: 0,
|
|
63
|
+
},
|
|
64
|
+
republic: {
|
|
65
|
+
taxEfficiencyMul_Q: q(0.90),
|
|
66
|
+
mobilizationMax_Q: q(0.10),
|
|
67
|
+
researchBonus: 3,
|
|
68
|
+
unrestMitigation_Q: q(0.10),
|
|
69
|
+
stabilityIncrement_Q: q(0.002),
|
|
70
|
+
},
|
|
71
|
+
empire: {
|
|
72
|
+
taxEfficiencyMul_Q: q(1.00),
|
|
73
|
+
mobilizationMax_Q: q(0.15),
|
|
74
|
+
researchBonus: 2,
|
|
75
|
+
unrestMitigation_Q: q(0.12),
|
|
76
|
+
stabilityIncrement_Q: q(0.001),
|
|
77
|
+
},
|
|
78
|
+
theocracy: {
|
|
79
|
+
taxEfficiencyMul_Q: q(0.70), // tithes are less efficient than direct tax
|
|
80
|
+
mobilizationMax_Q: q(0.10),
|
|
81
|
+
researchBonus: 1,
|
|
82
|
+
unrestMitigation_Q: q(0.18), // religious legitimacy suppresses unrest
|
|
83
|
+
stabilityIncrement_Q: q(0.002),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// ── Preset law codes ──────────────────────────────────────────────────────────
|
|
87
|
+
/** Conscription law: larger armies, minor stability cost. */
|
|
88
|
+
export const LAW_CONSCRIPTION = {
|
|
89
|
+
lawId: "conscription",
|
|
90
|
+
name: "Conscription",
|
|
91
|
+
taxBonus_Q: 0,
|
|
92
|
+
researchBonus: 0,
|
|
93
|
+
mobilizationBonus_Q: q(0.03),
|
|
94
|
+
unrestBonus_Q: 0,
|
|
95
|
+
stabilityCostPerDay_Q: q(0.001),
|
|
96
|
+
};
|
|
97
|
+
/** Tax reform: better tax efficiency, minor unrest from displacing old collectors. */
|
|
98
|
+
export const LAW_TAX_REFORM = {
|
|
99
|
+
lawId: "tax_reform",
|
|
100
|
+
name: "Tax Reform",
|
|
101
|
+
taxBonus_Q: q(0.10),
|
|
102
|
+
researchBonus: 0,
|
|
103
|
+
mobilizationBonus_Q: 0,
|
|
104
|
+
unrestBonus_Q: q(0.02),
|
|
105
|
+
stabilityCostPerDay_Q: 0,
|
|
106
|
+
};
|
|
107
|
+
/** Patronage of scholars: research bonus, expensive. */
|
|
108
|
+
export const LAW_SCHOLAR_PATRONAGE = {
|
|
109
|
+
lawId: "scholar_patronage",
|
|
110
|
+
name: "Scholar Patronage",
|
|
111
|
+
taxBonus_Q: 0,
|
|
112
|
+
researchBonus: 5,
|
|
113
|
+
mobilizationBonus_Q: 0,
|
|
114
|
+
unrestBonus_Q: 0,
|
|
115
|
+
stabilityCostPerDay_Q: 0,
|
|
116
|
+
};
|
|
117
|
+
/** Rule of law: stability bonus, research bonus, small unrest mitigation. */
|
|
118
|
+
export const LAW_RULE_OF_LAW = {
|
|
119
|
+
lawId: "rule_of_law",
|
|
120
|
+
name: "Rule of Law",
|
|
121
|
+
taxBonus_Q: q(0.05),
|
|
122
|
+
researchBonus: 1,
|
|
123
|
+
mobilizationBonus_Q: 0,
|
|
124
|
+
unrestBonus_Q: q(0.05),
|
|
125
|
+
stabilityCostPerDay_Q: 0,
|
|
126
|
+
};
|
|
127
|
+
/** Martial law: strong unrest mitigation but heavy stability drain. */
|
|
128
|
+
export const LAW_MARTIAL_LAW = {
|
|
129
|
+
lawId: "martial_law",
|
|
130
|
+
name: "Martial Law",
|
|
131
|
+
taxBonus_Q: 0,
|
|
132
|
+
researchBonus: 0,
|
|
133
|
+
mobilizationBonus_Q: q(0.02),
|
|
134
|
+
unrestBonus_Q: q(0.12),
|
|
135
|
+
stabilityCostPerDay_Q: q(0.003),
|
|
136
|
+
};
|
|
137
|
+
export const PRESET_LAW_CODES = [
|
|
138
|
+
LAW_CONSCRIPTION,
|
|
139
|
+
LAW_TAX_REFORM,
|
|
140
|
+
LAW_SCHOLAR_PATRONAGE,
|
|
141
|
+
LAW_RULE_OF_LAW,
|
|
142
|
+
LAW_MARTIAL_LAW,
|
|
143
|
+
];
|
|
144
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
145
|
+
/** Create a fresh `GovernanceState` with no laws and no cooldown. */
|
|
146
|
+
export function createGovernanceState(polityId, governanceType = "monarchy") {
|
|
147
|
+
return { polityId, governanceType, activeLawIds: [], changeCooldown: 0 };
|
|
148
|
+
}
|
|
149
|
+
// ── Modifier computation ──────────────────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* Compute the aggregate `GovernanceModifiers` for the given state plus active laws.
|
|
152
|
+
*
|
|
153
|
+
* Each law's bonuses are added on top of the governance baseline.
|
|
154
|
+
* `taxEfficiencyMul_Q` is clamped to SCALE.Q; others to [0, SCALE.Q].
|
|
155
|
+
*
|
|
156
|
+
* @param lawRegistry Map of lawId → LawCode. Pass only enacted laws.
|
|
157
|
+
*/
|
|
158
|
+
export function computeGovernanceModifiers(state, lawRegistry = new Map()) {
|
|
159
|
+
const base = GOVERNANCE_BASE[state.governanceType];
|
|
160
|
+
let taxMul = base.taxEfficiencyMul_Q;
|
|
161
|
+
let mobilMax = base.mobilizationMax_Q;
|
|
162
|
+
let research = base.researchBonus;
|
|
163
|
+
let unrestMit = base.unrestMitigation_Q;
|
|
164
|
+
let stabilityInc = base.stabilityIncrement_Q;
|
|
165
|
+
for (const lawId of state.activeLawIds) {
|
|
166
|
+
const law = lawRegistry.get(lawId);
|
|
167
|
+
if (!law)
|
|
168
|
+
continue;
|
|
169
|
+
taxMul = clampQ(taxMul + law.taxBonus_Q, 0, SCALE.Q);
|
|
170
|
+
mobilMax = clampQ(mobilMax + law.mobilizationBonus_Q, 0, SCALE.Q);
|
|
171
|
+
research += law.researchBonus;
|
|
172
|
+
unrestMit = clampQ(unrestMit + law.unrestBonus_Q, 0, SCALE.Q);
|
|
173
|
+
// stability cost from active laws reduces the passive increment
|
|
174
|
+
stabilityInc = clampQ(stabilityInc - law.stabilityCostPerDay_Q, 0, SCALE.Q);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
taxEfficiencyMul_Q: taxMul,
|
|
178
|
+
mobilizationMax_Q: mobilMax,
|
|
179
|
+
researchBonus: Math.max(0, research),
|
|
180
|
+
unrestMitigation_Q: unrestMit,
|
|
181
|
+
stabilityIncrement_Q: stabilityInc,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ── Law management ────────────────────────────────────────────────────────────
|
|
185
|
+
/**
|
|
186
|
+
* Enact a new law. Returns `false` if already enacted or at `MAX_ACTIVE_LAWS`.
|
|
187
|
+
*/
|
|
188
|
+
export function enactLaw(state, lawId) {
|
|
189
|
+
if (state.activeLawIds.includes(lawId))
|
|
190
|
+
return false;
|
|
191
|
+
if (state.activeLawIds.length >= MAX_ACTIVE_LAWS)
|
|
192
|
+
return false;
|
|
193
|
+
state.activeLawIds.push(lawId);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Repeal an active law. Returns `false` if the law was not active.
|
|
198
|
+
*/
|
|
199
|
+
export function repealLaw(state, lawId) {
|
|
200
|
+
const idx = state.activeLawIds.indexOf(lawId);
|
|
201
|
+
if (idx === -1)
|
|
202
|
+
return false;
|
|
203
|
+
state.activeLawIds.splice(idx, 1);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// ── Governance change ─────────────────────────────────────────────────────────
|
|
207
|
+
/**
|
|
208
|
+
* Change the governance type of a polity.
|
|
209
|
+
*
|
|
210
|
+
* Applies `GOVERNANCE_CHANGE_STABILITY_HIT_Q` to `polity.stabilityQ` and
|
|
211
|
+
* sets `state.changeCooldown = GOVERNANCE_CHANGE_COOLDOWN_DAYS`.
|
|
212
|
+
*
|
|
213
|
+
* Returns `false` (no-op) if:
|
|
214
|
+
* - `newType` is the same as current type.
|
|
215
|
+
* - `state.changeCooldown > 0` (still cooling down).
|
|
216
|
+
*/
|
|
217
|
+
export function changeGovernance(polity, state, newType) {
|
|
218
|
+
if (state.governanceType === newType)
|
|
219
|
+
return false;
|
|
220
|
+
if (state.changeCooldown > 0)
|
|
221
|
+
return false;
|
|
222
|
+
state.governanceType = newType;
|
|
223
|
+
state.changeCooldown = GOVERNANCE_CHANGE_COOLDOWN_DAYS;
|
|
224
|
+
polity.stabilityQ = clampQ(polity.stabilityQ - GOVERNANCE_CHANGE_STABILITY_HIT_Q, 0, SCALE.Q);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Tick down the governance change cooldown.
|
|
229
|
+
* Mutates `state.changeCooldown`; never goes below 0.
|
|
230
|
+
*/
|
|
231
|
+
export function stepGovernanceCooldown(state, elapsedDays) {
|
|
232
|
+
state.changeCooldown = Math.max(0, state.changeCooldown - elapsedDays);
|
|
233
|
+
}
|
|
234
|
+
// ── Passive stability tick ────────────────────────────────────────────────────
|
|
235
|
+
/**
|
|
236
|
+
* Apply the governance passive stability increment per elapsed days.
|
|
237
|
+
*
|
|
238
|
+
* Uses `computeGovernanceModifiers` to get the net `stabilityIncrement_Q`,
|
|
239
|
+
* then adds `increment × elapsedDays` to `polity.stabilityQ`.
|
|
240
|
+
*
|
|
241
|
+
* No-op if net increment is 0 (law costs cancel the baseline bonus).
|
|
242
|
+
*
|
|
243
|
+
* @param lawRegistry Active law registry.
|
|
244
|
+
*/
|
|
245
|
+
export function stepGovernanceStability(polity, state, elapsedDays, lawRegistry = new Map()) {
|
|
246
|
+
const mods = computeGovernanceModifiers(state, lawRegistry);
|
|
247
|
+
if (mods.stabilityIncrement_Q <= 0)
|
|
248
|
+
return;
|
|
249
|
+
const delta = Math.round(mods.stabilityIncrement_Q * elapsedDays);
|
|
250
|
+
polity.stabilityQ = clampQ(polity.stabilityQ + delta, 0, SCALE.Q);
|
|
251
|
+
}
|
|
@@ -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,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.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -130,6 +130,18 @@
|
|
|
130
130
|
"./research": {
|
|
131
131
|
"import": "./dist/src/research.js",
|
|
132
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"
|
|
141
|
+
},
|
|
142
|
+
"./governance": {
|
|
143
|
+
"import": "./dist/src/governance.js",
|
|
144
|
+
"types": "./dist/src/governance.d.ts"
|
|
133
145
|
}
|
|
134
146
|
},
|
|
135
147
|
"files": [
|
|
@@ -150,7 +162,7 @@
|
|
|
150
162
|
},
|
|
151
163
|
"repository": {
|
|
152
164
|
"type": "git",
|
|
153
|
-
"url": "https://github.com/its-not-rocket-science/ananke.git"
|
|
165
|
+
"url": "git+https://github.com/its-not-rocket-science/ananke.git"
|
|
154
166
|
},
|
|
155
167
|
"keywords": [
|
|
156
168
|
"simulation",
|