@its-not-rocket-science/ananke 0.1.26 → 0.1.27

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,29 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.27] — 2026-03-26
10
+
11
+ ### Added
12
+
13
+ - **Phase 82 · Espionage & Intelligence Networks** (`src/espionage.ts`)
14
+ - `OperationType`: `"intelligence_gather" | "treaty_sabotage" | "bond_subversion" | "treasury_theft" | "incite_migration"`.
15
+ - `AgentStatus`: `"active" | "compromised" | "captured"`.
16
+ - `SpyAgent { agentId, ownerPolityId, targetPolityId, operation, status, deployedTick, skill_Q }`.
17
+ - `EspionageRegistry { agents: Map<number, SpyAgent> }` — keyed by entity ID.
18
+ - `OperationResult { success, detected, effectDelta_Q }`.
19
+ - `OPERATION_BASE_SUCCESS_Q`: intelligence_gather q(0.70) → treasury_theft q(0.35).
20
+ - `OPERATION_DETECTION_RISK_Q`: treasury_theft q(0.40) → intelligence_gather q(0.10).
21
+ - `OPERATION_EFFECT_Q`: incite_migration q(0.15) → intelligence_gather q(0.00).
22
+ - `COVER_DECAY_PER_DAY = q(0.005)` — daily base cover-loss risk, mitigated by skill.
23
+ - `resolveOperation(agent, worldSeed, tick)` → `OperationResult` — deterministic via `eventSeed`; idempotent for same inputs; no-op for non-active agents.
24
+ - `stepAgentCover(agent, worldSeed, tick)` — daily cover check; may flip status to `"compromised"` or `"captured"` (50/50 split via secondary seed).
25
+ - `deployAgent`, `recallAgent`, `getAgentsByOwner`, `getAgentsByTarget`.
26
+ - `computeCounterIntelligence(registry, targetPolityId)` → Q — `compromised` agent count × `COUNTER_INTEL_PER_AGENT = q(0.05)`, clamped to SCALE.Q.
27
+ - Added `./espionage` subpath export to `package.json`.
28
+ - 34 new tests; 4,377 total. Coverage maintained above all thresholds.
29
+
30
+ ---
31
+
9
32
  ## [0.1.26] — 2026-03-26
10
33
 
11
34
  ### Added
@@ -0,0 +1,103 @@
1
+ import type { Q } from "./units.js";
2
+ /** What the spy is trying to achieve. */
3
+ export type OperationType = "intelligence_gather" | "treaty_sabotage" | "bond_subversion" | "treasury_theft" | "incite_migration";
4
+ /** Current cover status of the agent in the target polity. */
5
+ export type AgentStatus = "active" | "compromised" | "captured";
6
+ /**
7
+ * A spy deployed by one polity against another.
8
+ * Stored in `EspionageRegistry`, keyed by `agentId` (entity ID).
9
+ */
10
+ export interface SpyAgent {
11
+ /** Entity ID of the spy character. */
12
+ agentId: number;
13
+ ownerPolityId: string;
14
+ targetPolityId: string;
15
+ operation: OperationType;
16
+ status: AgentStatus;
17
+ /** Simulation tick when the agent was deployed. */
18
+ deployedTick: number;
19
+ /**
20
+ * Agent skill [0, SCALE.Q].
21
+ * Higher skill → better success rate and lower detection risk.
22
+ */
23
+ skill_Q: Q;
24
+ }
25
+ /** Registry of all deployed spy agents. */
26
+ export interface EspionageRegistry {
27
+ agents: Map<number, SpyAgent>;
28
+ }
29
+ /** Outcome of a single operation resolution. */
30
+ export interface OperationResult {
31
+ success: boolean;
32
+ detected: boolean;
33
+ /**
34
+ * Magnitude of the effect in Q units.
35
+ * For `treasury_theft` this is a fraction of treasury; host scales to cu.
36
+ * For `intelligence_gather` this is always 0 (effect is information).
37
+ */
38
+ effectDelta_Q: Q;
39
+ }
40
+ /**
41
+ * Base success probability per operation at agent skill = SCALE.Q.
42
+ * Actual threshold = `skill_Q × BASE_SUCCESS_Q / SCALE.Q`.
43
+ */
44
+ export declare const OPERATION_BASE_SUCCESS_Q: Record<OperationType, Q>;
45
+ /**
46
+ * Detection probability on failure for each operation.
47
+ * High-impact operations (treasury_theft) are riskier.
48
+ */
49
+ export declare const OPERATION_DETECTION_RISK_Q: Record<OperationType, Q>;
50
+ /**
51
+ * Maximum effect delta per successful operation, scaled by `skill_Q`.
52
+ * `intelligence_gather` has no Q delta (information is the outcome).
53
+ */
54
+ export declare const OPERATION_EFFECT_Q: Record<OperationType, Q>;
55
+ /**
56
+ * Daily base probability that an active agent's cover is blown
57
+ * regardless of operations. Low but non-zero.
58
+ */
59
+ export declare const COVER_DECAY_PER_DAY: Q;
60
+ export declare function createEspionageRegistry(): EspionageRegistry;
61
+ /**
62
+ * Deploy an agent and register them.
63
+ * If an agent with this ID is already registered they are replaced.
64
+ */
65
+ export declare function deployAgent(registry: EspionageRegistry, agentId: number, ownerPolityId: string, targetPolityId: string, operation: OperationType, skill_Q: Q, tick?: number): SpyAgent;
66
+ /** Recall (remove) an active agent. Returns `true` if found and removed. */
67
+ export declare function recallAgent(registry: EspionageRegistry, agentId: number): boolean;
68
+ /** Return all agents deployed by `ownerPolityId`. */
69
+ export declare function getAgentsByOwner(registry: EspionageRegistry, ownerPolityId: string): SpyAgent[];
70
+ /** Return all agents currently operating against `targetPolityId`. */
71
+ export declare function getAgentsByTarget(registry: EspionageRegistry, targetPolityId: string): SpyAgent[];
72
+ /**
73
+ * Resolve one tick of an operation. Idempotent for the same (worldSeed, tick).
74
+ *
75
+ * Success check:
76
+ * successThreshold = skill_Q × BASE_SUCCESS_Q[op] / SCALE.Q
77
+ * successRoll = eventSeed(…, opSalt) % SCALE.Q
78
+ * success = successRoll < successThreshold
79
+ *
80
+ * Detection check (only on failure):
81
+ * detectionRoll = eventSeed(…, opSalt+1) % SCALE.Q
82
+ * detected = detectionRoll < DETECTION_RISK_Q[op]
83
+ *
84
+ * Does NOT mutate `agent.status` — call `stepAgentCover` for passive detection.
85
+ */
86
+ export declare function resolveOperation(agent: SpyAgent, worldSeed: number, tick: number): OperationResult;
87
+ /**
88
+ * Run a daily cover check for an active agent.
89
+ * If the check fires, the agent transitions to "compromised" or "captured"
90
+ * (50/50 split via a secondary roll).
91
+ * Mutates `agent.status` directly.
92
+ * No-op if agent is already compromised or captured.
93
+ */
94
+ export declare function stepAgentCover(agent: SpyAgent, worldSeed: number, tick: number): void;
95
+ /**
96
+ * Compute the counterintelligence strength of a polity based on the number
97
+ * of known (compromised) agents inside its borders.
98
+ * Returns a Q modifier applied by hosts to reduce incoming operation success.
99
+ *
100
+ * `knownAgentCount × COUNTER_INTEL_PER_AGENT`, clamped to [0, SCALE.Q].
101
+ */
102
+ export declare const COUNTER_INTEL_PER_AGENT: Q;
103
+ export declare function computeCounterIntelligence(registry: EspionageRegistry, targetPolityId: string): Q;
@@ -0,0 +1,166 @@
1
+ // src/espionage.ts — Phase 82: Espionage & Intelligence Networks
2
+ //
3
+ // Covert operations between polities. Each deployed spy runs a specific
4
+ // operation that resolves deterministically via eventSeed. Detection
5
+ // risk rises with operation severity and falls with agent skill.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - `EspionageRegistry` tracks deployed agents by entity ID.
10
+ // - `resolveOperation` returns success/detection/effectDelta each time it
11
+ // is called — idempotent for the same (worldSeed, tick) inputs.
12
+ // - Callers apply `effectDelta_Q` to the relevant Phase-79/80 registry.
13
+ // - `stepAgentCover` is called once per simulated day and may flip an
14
+ // "active" agent to "compromised" or "captured".
15
+ import { eventSeed, hashString } from "./sim/seeds.js";
16
+ import { q, SCALE, clampQ, mulDiv } from "./units.js";
17
+ // ── Constants ─────────────────────────────────────────────────────────────────
18
+ /**
19
+ * Base success probability per operation at agent skill = SCALE.Q.
20
+ * Actual threshold = `skill_Q × BASE_SUCCESS_Q / SCALE.Q`.
21
+ */
22
+ export const OPERATION_BASE_SUCCESS_Q = {
23
+ intelligence_gather: q(0.70),
24
+ treaty_sabotage: q(0.50),
25
+ bond_subversion: q(0.45),
26
+ treasury_theft: q(0.35),
27
+ incite_migration: q(0.55),
28
+ };
29
+ /**
30
+ * Detection probability on failure for each operation.
31
+ * High-impact operations (treasury_theft) are riskier.
32
+ */
33
+ export const OPERATION_DETECTION_RISK_Q = {
34
+ intelligence_gather: q(0.10),
35
+ incite_migration: q(0.15),
36
+ treaty_sabotage: q(0.20),
37
+ bond_subversion: q(0.25),
38
+ treasury_theft: q(0.40),
39
+ };
40
+ /**
41
+ * Maximum effect delta per successful operation, scaled by `skill_Q`.
42
+ * `intelligence_gather` has no Q delta (information is the outcome).
43
+ */
44
+ export const OPERATION_EFFECT_Q = {
45
+ intelligence_gather: q(0.00),
46
+ treaty_sabotage: q(0.10),
47
+ bond_subversion: q(0.08),
48
+ treasury_theft: q(0.05),
49
+ incite_migration: q(0.15),
50
+ };
51
+ /**
52
+ * Daily base probability that an active agent's cover is blown
53
+ * regardless of operations. Low but non-zero.
54
+ */
55
+ export const COVER_DECAY_PER_DAY = q(0.005);
56
+ // ── Salt constants (deterministic per operation type) ──────────────────────────
57
+ const OP_SALT = {
58
+ intelligence_gather: 1001,
59
+ treaty_sabotage: 1002,
60
+ bond_subversion: 1003,
61
+ treasury_theft: 1004,
62
+ incite_migration: 1005,
63
+ };
64
+ const COVER_CHECK_SALT = 9901;
65
+ // ── Factory ───────────────────────────────────────────────────────────────────
66
+ export function createEspionageRegistry() {
67
+ return { agents: new Map() };
68
+ }
69
+ // ── Agent management ───────────────────────────────────────────────────────────
70
+ /**
71
+ * Deploy an agent and register them.
72
+ * If an agent with this ID is already registered they are replaced.
73
+ */
74
+ export function deployAgent(registry, agentId, ownerPolityId, targetPolityId, operation, skill_Q, tick = 0) {
75
+ const agent = {
76
+ agentId,
77
+ ownerPolityId,
78
+ targetPolityId,
79
+ operation,
80
+ status: "active",
81
+ deployedTick: tick,
82
+ skill_Q,
83
+ };
84
+ registry.agents.set(agentId, agent);
85
+ return agent;
86
+ }
87
+ /** Recall (remove) an active agent. Returns `true` if found and removed. */
88
+ export function recallAgent(registry, agentId) {
89
+ return registry.agents.delete(agentId);
90
+ }
91
+ /** Return all agents deployed by `ownerPolityId`. */
92
+ export function getAgentsByOwner(registry, ownerPolityId) {
93
+ return [...registry.agents.values()].filter(a => a.ownerPolityId === ownerPolityId);
94
+ }
95
+ /** Return all agents currently operating against `targetPolityId`. */
96
+ export function getAgentsByTarget(registry, targetPolityId) {
97
+ return [...registry.agents.values()].filter(a => a.targetPolityId === targetPolityId);
98
+ }
99
+ // ── Operation resolution ───────────────────────────────────────────────────────
100
+ /**
101
+ * Resolve one tick of an operation. Idempotent for the same (worldSeed, tick).
102
+ *
103
+ * Success check:
104
+ * successThreshold = skill_Q × BASE_SUCCESS_Q[op] / SCALE.Q
105
+ * successRoll = eventSeed(…, opSalt) % SCALE.Q
106
+ * success = successRoll < successThreshold
107
+ *
108
+ * Detection check (only on failure):
109
+ * detectionRoll = eventSeed(…, opSalt+1) % SCALE.Q
110
+ * detected = detectionRoll < DETECTION_RISK_Q[op]
111
+ *
112
+ * Does NOT mutate `agent.status` — call `stepAgentCover` for passive detection.
113
+ */
114
+ export function resolveOperation(agent, worldSeed, tick) {
115
+ if (agent.status !== "active") {
116
+ return { success: false, detected: false, effectDelta_Q: 0 };
117
+ }
118
+ const targetHash = hashString(agent.targetPolityId);
119
+ const salt = OP_SALT[agent.operation];
120
+ const successSeed = eventSeed(worldSeed, tick, agent.agentId, targetHash, salt);
121
+ const successRoll = successSeed % SCALE.Q;
122
+ const successThresh = mulDiv(agent.skill_Q, OPERATION_BASE_SUCCESS_Q[agent.operation], SCALE.Q);
123
+ const success = successRoll < successThresh;
124
+ const detectSeed = eventSeed(worldSeed, tick, agent.agentId, targetHash, salt + 1);
125
+ const detectRoll = detectSeed % SCALE.Q;
126
+ const detected = !success && detectRoll < OPERATION_DETECTION_RISK_Q[agent.operation];
127
+ const effectDelta_Q = success
128
+ ? clampQ(mulDiv(agent.skill_Q, OPERATION_EFFECT_Q[agent.operation], SCALE.Q), 0, SCALE.Q)
129
+ : 0;
130
+ return { success, detected, effectDelta_Q };
131
+ }
132
+ /**
133
+ * Run a daily cover check for an active agent.
134
+ * If the check fires, the agent transitions to "compromised" or "captured"
135
+ * (50/50 split via a secondary roll).
136
+ * Mutates `agent.status` directly.
137
+ * No-op if agent is already compromised or captured.
138
+ */
139
+ export function stepAgentCover(agent, worldSeed, tick) {
140
+ if (agent.status !== "active")
141
+ return;
142
+ const targetHash = hashString(agent.targetPolityId);
143
+ // Skill reduces detection: effective risk = COVER_DECAY × (1 - skill / SCALE.Q)
144
+ const skillMitigation = mulDiv(COVER_DECAY_PER_DAY, agent.skill_Q, SCALE.Q);
145
+ const effectiveRisk = Math.max(0, COVER_DECAY_PER_DAY - skillMitigation);
146
+ const coverSeed = eventSeed(worldSeed, tick, agent.agentId, targetHash, COVER_CHECK_SALT);
147
+ const coverRoll = coverSeed % SCALE.Q;
148
+ if (coverRoll < effectiveRisk) {
149
+ const captSeed = eventSeed(worldSeed, tick, agent.agentId, targetHash, COVER_CHECK_SALT + 1);
150
+ agent.status = (captSeed % 2 === 0) ? "captured" : "compromised";
151
+ }
152
+ }
153
+ // ── Counterintelligence ────────────────────────────────────────────────────────
154
+ /**
155
+ * Compute the counterintelligence strength of a polity based on the number
156
+ * of known (compromised) agents inside its borders.
157
+ * Returns a Q modifier applied by hosts to reduce incoming operation success.
158
+ *
159
+ * `knownAgentCount × COUNTER_INTEL_PER_AGENT`, clamped to [0, SCALE.Q].
160
+ */
161
+ export const COUNTER_INTEL_PER_AGENT = q(0.05);
162
+ export function computeCounterIntelligence(registry, targetPolityId) {
163
+ const known = getAgentsByTarget(registry, targetPolityId)
164
+ .filter(a => a.status === "compromised").length;
165
+ return clampQ(known * COUNTER_INTEL_PER_AGENT, 0, SCALE.Q);
166
+ }
@@ -0,0 +1,108 @@
1
+ import type { Polity } from "./polity.js";
2
+ import type { Q } from "./units.js";
3
+ /**
4
+ * A bilateral trade route between two polities.
5
+ * Both parties earn income each day a route is active.
6
+ */
7
+ export interface TradeRoute {
8
+ /** Canonical key (sorted polity IDs). */
9
+ routeId: string;
10
+ polityAId: string;
11
+ polityBId: string;
12
+ /**
13
+ * Annual trade value in cost-units at full efficiency.
14
+ * Each polity earns `floor(baseVolume_cu × efficiency / 365)` per day.
15
+ */
16
+ baseVolume_cu: number;
17
+ /**
18
+ * Current route health [0, SCALE.Q].
19
+ * Below `ROUTE_VIABLE_THRESHOLD` the route is considered inactive.
20
+ */
21
+ efficiency_Q: Q;
22
+ /** Simulation tick (day) when the route was established. */
23
+ establishedTick: number;
24
+ }
25
+ /** Registry of all active trade routes. */
26
+ export interface TradeRegistry {
27
+ routes: Map<string, TradeRoute>;
28
+ }
29
+ /** Daily income produced for both polities from a single route resolution. */
30
+ export interface TradeIncome {
31
+ incomeA_cu: number;
32
+ incomeB_cu: number;
33
+ }
34
+ /** Route efficiency below this → `isRouteViable` returns false. */
35
+ export declare const ROUTE_VIABLE_THRESHOLD: Q;
36
+ /** Daily efficiency decay (without maintenance). */
37
+ export declare const ROUTE_DECAY_PER_DAY: Q;
38
+ /** Multiplier applied to both parties' income when a trade pact is active. */
39
+ export declare const TREATY_TRADE_BONUS_Q: Q;
40
+ /** Days per year used in the daily trade fraction calculation. */
41
+ export declare const TRADE_DAYS_PER_YEAR = 365;
42
+ export declare function createTradeRegistry(): TradeRegistry;
43
+ /**
44
+ * Canonical route key — independent of argument order.
45
+ * Polity IDs are sorted lexicographically so `key(A,B) === key(B,A)`.
46
+ */
47
+ export declare function routeKey(polityAId: string, polityBId: string): string;
48
+ /**
49
+ * Establish a new trade route (or replace an existing one).
50
+ *
51
+ * @param baseVolume_cu Annual trade value in cost-units at 100% efficiency.
52
+ * @param tick Current simulation tick.
53
+ */
54
+ export declare function establishRoute(registry: TradeRegistry, polityAId: string, polityBId: string, baseVolume_cu: number, tick?: number): TradeRoute;
55
+ /** Return the route between two polities, or `undefined` if none. */
56
+ export declare function getRoute(registry: TradeRegistry, polityAId: string, polityBId: string): TradeRoute | undefined;
57
+ /** Return all routes involving `polityId` (as either party). */
58
+ export declare function getRoutesForPolity(registry: TradeRegistry, polityId: string): TradeRoute[];
59
+ /** Remove a route from the registry. Returns `true` if found and removed. */
60
+ export declare function abandonRoute(registry: TradeRegistry, polityAId: string, polityBId: string): boolean;
61
+ /** Return `true` if the route is efficient enough to trade. */
62
+ export declare function isRouteViable(route: TradeRoute): boolean;
63
+ /**
64
+ * Compute the daily trade income for both polities from one route.
65
+ *
66
+ * Formula:
67
+ * base = floor(baseVolume_cu × efficiency_Q / SCALE.Q / TRADE_DAYS_PER_YEAR)
68
+ * bonus multiplier = SCALE.Q + (hasTradePact ? TREATY_TRADE_BONUS_Q : 0)
69
+ * seasonal multiplier = seasonalMul_Q (default SCALE.Q = no modification)
70
+ * income = floor(base × bonusMul / SCALE.Q × seasonalMul / SCALE.Q)
71
+ *
72
+ * Returns `{ incomeA_cu: 0, incomeB_cu: 0 }` if the route is not viable.
73
+ *
74
+ * @param hasTradePact True if a Phase-80 trade_pact treaty is active between the pair.
75
+ * @param seasonalMul_Q Phase-78 seasonal modifier (default SCALE.Q = no change).
76
+ */
77
+ export declare function computeDailyTradeIncome(route: TradeRoute, hasTradePact?: boolean, seasonalMul_Q?: Q): TradeIncome;
78
+ /**
79
+ * Apply one day of trade: add computed income to both polity treasuries.
80
+ * Mutates both polity objects.
81
+ * Returns the `TradeIncome` applied (both zero if route not viable).
82
+ */
83
+ export declare function applyDailyTrade(polityA: Polity, polityB: Polity, route: TradeRoute, hasTradePact?: boolean, seasonalMul_Q?: Q): TradeIncome;
84
+ /**
85
+ * Advance route efficiency by one simulated day.
86
+ * Decays at `ROUTE_DECAY_PER_DAY`; `boostDelta_Q` is an optional signed
87
+ * daily bonus (e.g., from road maintenance, diplomatic investment).
88
+ * Mutates `route.efficiency_Q`.
89
+ */
90
+ export declare function stepRouteEfficiency(route: TradeRoute, boostDelta_Q?: Q): void;
91
+ /**
92
+ * Reinforce a route (e.g., road investment, diplomatic summit).
93
+ * Clamps to [0, SCALE.Q].
94
+ */
95
+ export declare function reinforceRoute(route: TradeRoute, deltaQ: Q): void;
96
+ /**
97
+ * Disrupt a route by reducing efficiency by `disruption_Q`.
98
+ * Used by callers applying espionage results (Phase 82), war declarations,
99
+ * or hazard events.
100
+ * Clamps to 0.
101
+ */
102
+ export declare function disruptRoute(route: TradeRoute, disruption_Q: Q): void;
103
+ /**
104
+ * Compute the total annual trade volume flowing through all viable routes
105
+ * for a given polity (sum of `baseVolume_cu × efficiency_Q / SCALE.Q`).
106
+ * Useful for AI and diplomatic valuation.
107
+ */
108
+ export declare function computeAnnualTradeVolume(registry: TradeRegistry, polityId: string): number;
@@ -0,0 +1,146 @@
1
+ // src/trade-routes.ts — Phase 83: Trade Routes & Inter-Polity Commerce
2
+ //
3
+ // World-scale bilateral trade routes between polities. Each route has an
4
+ // annual base volume (cost-units), a current efficiency score, and optional
5
+ // treaty / seasonal multipliers supplied by the host.
6
+ //
7
+ // Design:
8
+ // - Pure data layer — no Entity fields, no kernel changes.
9
+ // - Route keys are canonical (sorted polity IDs) — symmetric lookup.
10
+ // - Trade is mutually beneficial: both polities earn income each day.
11
+ // - `disruptRoute` integrates with Phase 82 (espionage) and Phase 61 (war).
12
+ // - `TREATY_TRADE_BONUS_Q` rewards Phase-80 trade pacts without a direct import.
13
+ import { q, SCALE, clampQ } from "./units.js";
14
+ // ── Constants ─────────────────────────────────────────────────────────────────
15
+ /** Route efficiency below this → `isRouteViable` returns false. */
16
+ export const ROUTE_VIABLE_THRESHOLD = q(0.10);
17
+ /** Daily efficiency decay (without maintenance). */
18
+ export const ROUTE_DECAY_PER_DAY = q(0.001);
19
+ /** Multiplier applied to both parties' income when a trade pact is active. */
20
+ export const TREATY_TRADE_BONUS_Q = q(0.20);
21
+ /** Days per year used in the daily trade fraction calculation. */
22
+ export const TRADE_DAYS_PER_YEAR = 365;
23
+ // ── Factory ───────────────────────────────────────────────────────────────────
24
+ export function createTradeRegistry() {
25
+ return { routes: new Map() };
26
+ }
27
+ // ── Key helper ─────────────────────────────────────────────────────────────────
28
+ /**
29
+ * Canonical route key — independent of argument order.
30
+ * Polity IDs are sorted lexicographically so `key(A,B) === key(B,A)`.
31
+ */
32
+ export function routeKey(polityAId, polityBId) {
33
+ const [lo, hi] = polityAId < polityBId
34
+ ? [polityAId, polityBId]
35
+ : [polityBId, polityAId];
36
+ return `${lo}:${hi}`;
37
+ }
38
+ // ── Route management ───────────────────────────────────────────────────────────
39
+ /**
40
+ * Establish a new trade route (or replace an existing one).
41
+ *
42
+ * @param baseVolume_cu Annual trade value in cost-units at 100% efficiency.
43
+ * @param tick Current simulation tick.
44
+ */
45
+ export function establishRoute(registry, polityAId, polityBId, baseVolume_cu, tick = 0) {
46
+ const key = routeKey(polityAId, polityBId);
47
+ const route = {
48
+ routeId: key,
49
+ polityAId,
50
+ polityBId,
51
+ baseVolume_cu,
52
+ efficiency_Q: SCALE.Q,
53
+ establishedTick: tick,
54
+ };
55
+ registry.routes.set(key, route);
56
+ return route;
57
+ }
58
+ /** Return the route between two polities, or `undefined` if none. */
59
+ export function getRoute(registry, polityAId, polityBId) {
60
+ return registry.routes.get(routeKey(polityAId, polityBId));
61
+ }
62
+ /** Return all routes involving `polityId` (as either party). */
63
+ export function getRoutesForPolity(registry, polityId) {
64
+ return [...registry.routes.values()].filter(r => r.polityAId === polityId || r.polityBId === polityId);
65
+ }
66
+ /** Remove a route from the registry. Returns `true` if found and removed. */
67
+ export function abandonRoute(registry, polityAId, polityBId) {
68
+ return registry.routes.delete(routeKey(polityAId, polityBId));
69
+ }
70
+ // ── Viability ──────────────────────────────────────────────────────────────────
71
+ /** Return `true` if the route is efficient enough to trade. */
72
+ export function isRouteViable(route) {
73
+ return route.efficiency_Q >= ROUTE_VIABLE_THRESHOLD;
74
+ }
75
+ // ── Income computation ─────────────────────────────────────────────────────────
76
+ /**
77
+ * Compute the daily trade income for both polities from one route.
78
+ *
79
+ * Formula:
80
+ * base = floor(baseVolume_cu × efficiency_Q / SCALE.Q / TRADE_DAYS_PER_YEAR)
81
+ * bonus multiplier = SCALE.Q + (hasTradePact ? TREATY_TRADE_BONUS_Q : 0)
82
+ * seasonal multiplier = seasonalMul_Q (default SCALE.Q = no modification)
83
+ * income = floor(base × bonusMul / SCALE.Q × seasonalMul / SCALE.Q)
84
+ *
85
+ * Returns `{ incomeA_cu: 0, incomeB_cu: 0 }` if the route is not viable.
86
+ *
87
+ * @param hasTradePact True if a Phase-80 trade_pact treaty is active between the pair.
88
+ * @param seasonalMul_Q Phase-78 seasonal modifier (default SCALE.Q = no change).
89
+ */
90
+ export function computeDailyTradeIncome(route, hasTradePact = false, seasonalMul_Q = SCALE.Q) {
91
+ if (!isRouteViable(route))
92
+ return { incomeA_cu: 0, incomeB_cu: 0 };
93
+ const effBase = Math.floor(route.baseVolume_cu * route.efficiency_Q / SCALE.Q / TRADE_DAYS_PER_YEAR);
94
+ const bonusMul = SCALE.Q + (hasTradePact ? TREATY_TRADE_BONUS_Q : 0);
95
+ const withBonus = Math.floor(effBase * bonusMul / SCALE.Q);
96
+ const income = Math.floor(withBonus * seasonalMul_Q / SCALE.Q);
97
+ return { incomeA_cu: income, incomeB_cu: income };
98
+ }
99
+ /**
100
+ * Apply one day of trade: add computed income to both polity treasuries.
101
+ * Mutates both polity objects.
102
+ * Returns the `TradeIncome` applied (both zero if route not viable).
103
+ */
104
+ export function applyDailyTrade(polityA, polityB, route, hasTradePact = false, seasonalMul_Q = SCALE.Q) {
105
+ const income = computeDailyTradeIncome(route, hasTradePact, seasonalMul_Q);
106
+ polityA.treasury_cu += income.incomeA_cu;
107
+ polityB.treasury_cu += income.incomeB_cu;
108
+ return income;
109
+ }
110
+ // ── Efficiency dynamics ────────────────────────────────────────────────────────
111
+ /**
112
+ * Advance route efficiency by one simulated day.
113
+ * Decays at `ROUTE_DECAY_PER_DAY`; `boostDelta_Q` is an optional signed
114
+ * daily bonus (e.g., from road maintenance, diplomatic investment).
115
+ * Mutates `route.efficiency_Q`.
116
+ */
117
+ export function stepRouteEfficiency(route, boostDelta_Q = 0) {
118
+ route.efficiency_Q = clampQ(route.efficiency_Q - ROUTE_DECAY_PER_DAY + boostDelta_Q, 0, SCALE.Q);
119
+ }
120
+ /**
121
+ * Reinforce a route (e.g., road investment, diplomatic summit).
122
+ * Clamps to [0, SCALE.Q].
123
+ */
124
+ export function reinforceRoute(route, deltaQ) {
125
+ route.efficiency_Q = clampQ(route.efficiency_Q + deltaQ, 0, SCALE.Q);
126
+ }
127
+ /**
128
+ * Disrupt a route by reducing efficiency by `disruption_Q`.
129
+ * Used by callers applying espionage results (Phase 82), war declarations,
130
+ * or hazard events.
131
+ * Clamps to 0.
132
+ */
133
+ export function disruptRoute(route, disruption_Q) {
134
+ route.efficiency_Q = clampQ(route.efficiency_Q - disruption_Q, 0, SCALE.Q);
135
+ }
136
+ // ── Network summary ────────────────────────────────────────────────────────────
137
+ /**
138
+ * Compute the total annual trade volume flowing through all viable routes
139
+ * for a given polity (sum of `baseVolume_cu × efficiency_Q / SCALE.Q`).
140
+ * Useful for AI and diplomatic valuation.
141
+ */
142
+ export function computeAnnualTradeVolume(registry, polityId) {
143
+ return getRoutesForPolity(registry, polityId)
144
+ .filter(isRouteViable)
145
+ .reduce((sum, r) => sum + Math.floor(r.baseVolume_cu * r.efficiency_Q / SCALE.Q), 0);
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -90,6 +90,10 @@
90
90
  "./migration": {
91
91
  "import": "./dist/src/migration.js",
92
92
  "types": "./dist/src/migration.d.ts"
93
+ },
94
+ "./espionage": {
95
+ "import": "./dist/src/espionage.js",
96
+ "types": "./dist/src/espionage.d.ts"
93
97
  }
94
98
  },
95
99
  "files": [