@its-not-rocket-science/ananke 0.1.24 → 0.1.26
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 +45 -0
- package/dist/src/diplomacy.d.ts +109 -0
- package/dist/src/diplomacy.js +167 -0
- package/dist/src/migration.d.ts +107 -0
- package/dist/src/migration.js +165 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,51 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.26] — 2026-03-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Phase 81 · Migration & Displacement** (`src/migration.ts`)
|
|
14
|
+
- `MigrationFlow { fromPolityId, toPolityId, population }` — a resolved daily population transfer.
|
|
15
|
+
- `MigrationContext { polityId, isAtWar?, lowestBondStr_Q? }` — optional per-polity war/feudal context passed by the host.
|
|
16
|
+
- `computePushPressure(polity, isAtWar?, lowestBondStr_Q?)` → Q — stability deficit + morale deficit + war bonus (`MIGRATION_WAR_PUSH_Q = q(0.20)`) + feudal-bond deficit below `MIGRATION_PUSH_FEUDAL_THRESHOLD = q(0.30)`.
|
|
17
|
+
- `computePullFactor(polity)` → Q — `stabilityQ × moraleQ / SCALE.Q`; both must be high to attract migrants.
|
|
18
|
+
- `computeMigrationFlow(from, to, push_Q, pull_Q)` → integer — 0 if push < `MIGRATION_PUSH_MIN_Q = q(0.05)` or pull = 0; floors to integer; max daily rate `MIGRATION_DAILY_RATE_Q = q(0.001)` (0.1% of population at full pressure).
|
|
19
|
+
- `resolveMigration(polities[], context?)` → `MigrationFlow[]` — collects all directed pair flows above threshold.
|
|
20
|
+
- `applyMigrationFlows(polityRegistry, flows)` — mutates `population` on sending and receiving polities; clamps to prevent negative populations.
|
|
21
|
+
- `estimateNetMigrationRate(polityId, flows, population)` → signed fraction — positive = net immigration, negative = net emigration.
|
|
22
|
+
- Integrates with Phase 61 (Polity), Phase 79 (Feudal bond strength), Phase 80 (Diplomacy) without direct imports — callers supply context.
|
|
23
|
+
- Added `./migration` subpath export to `package.json`.
|
|
24
|
+
- 41 new tests; 4,343 total. Coverage maintained above all thresholds.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## [0.1.25] — 2026-03-26
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Phase 80 · Diplomacy & Treaties** (`src/diplomacy.ts`)
|
|
33
|
+
- `TreatyType`: `"non_aggression" | "trade_pact" | "peace" | "military_alliance" | "royal_marriage"`.
|
|
34
|
+
- `Treaty { treatyId, polityAId, polityBId, type, strength_Q, signedTick, expiryTick, tributeFromA_Q, tributeFromB_Q }` — bilateral agreement with optional tribute clause and finite or permanent duration.
|
|
35
|
+
- `TreatyRegistry { treaties: Map<string, Treaty> }` — keyed by canonical sorted pair + type; order-independent.
|
|
36
|
+
- `TREATY_BASE_STRENGTH`: military_alliance q(0.80) → trade_pact q(0.50).
|
|
37
|
+
- `TREATY_DECAY_PER_DAY`: military_alliance q(0.001)/day → non_aggression q(0.003)/day.
|
|
38
|
+
- `TREATY_BREAK_INFAMY`: military_alliance q(0.25) → trade_pact q(0.05) — Phase 75 integration.
|
|
39
|
+
- `TREATY_FRAGILE_THRESHOLD = q(0.20)` — `isTreatyFragile(treaty)` returns true below this.
|
|
40
|
+
- `signTreaty(registry, polityAId, polityBId, type, tick?, duration?, tributeFromA?, tributeFromB?)` — creates or replaces a treaty.
|
|
41
|
+
- `getTreaty(registry, polityAId, polityBId, type)` — symmetric lookup.
|
|
42
|
+
- `getActiveTreaties(registry, polityId)` — all treaties for a given polity.
|
|
43
|
+
- `isTreatyExpired(treaty, currentTick)` — true at/after `expiryTick`; permanent (`-1`) never expires.
|
|
44
|
+
- `stepTreatyStrength(treaty, boostDelta_Q?)` — daily decay with optional event boost.
|
|
45
|
+
- `reinforceTreaty(treaty, deltaQ)` — clamped reinforcement.
|
|
46
|
+
- `breakTreaty(registry, polityAId, polityBId, type, breakerRulerId?, renownRegistry?)` — removes treaty; adds `TREATY_BREAK_INFAMY[type]` infamy to breaker.
|
|
47
|
+
- `computeDiplomaticPrestige(registry, polityId)` → Q — sum of active treaty strengths, clamped to SCALE.Q.
|
|
48
|
+
- `areInAnyTreaty(registry, polityAId, polityBId)` → boolean.
|
|
49
|
+
- Added `./diplomacy` subpath export to `package.json`.
|
|
50
|
+
- 55 new tests; 4,302 total. Coverage maintained above all thresholds.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
9
54
|
## [0.1.24] — 2026-03-26
|
|
10
55
|
|
|
11
56
|
### Added
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { RenownRegistry } from "./renown.js";
|
|
2
|
+
import type { Q } from "./units.js";
|
|
3
|
+
/** Category of diplomatic agreement. */
|
|
4
|
+
export type TreatyType = "non_aggression" | "trade_pact" | "peace" | "military_alliance" | "royal_marriage";
|
|
5
|
+
/**
|
|
6
|
+
* A bilateral diplomatic agreement between two polities.
|
|
7
|
+
* Stored in `TreatyRegistry`; keyed by canonical sorted pair + type.
|
|
8
|
+
*/
|
|
9
|
+
export interface Treaty {
|
|
10
|
+
/** Opaque unique id (host-assigned or from `treatyKey`). */
|
|
11
|
+
treatyId: string;
|
|
12
|
+
polityAId: string;
|
|
13
|
+
polityBId: string;
|
|
14
|
+
type: TreatyType;
|
|
15
|
+
/** Agreement health [0, SCALE.Q]. Below `TREATY_FRAGILE_THRESHOLD` → at risk. */
|
|
16
|
+
strength_Q: Q;
|
|
17
|
+
/** Simulation tick when signed. */
|
|
18
|
+
signedTick: number;
|
|
19
|
+
/**
|
|
20
|
+
* Tick when treaty expires. `-1` = permanent until broken.
|
|
21
|
+
* Hosts should call `isTreatyExpired` each tick and remove or renew.
|
|
22
|
+
*/
|
|
23
|
+
expiryTick: number;
|
|
24
|
+
/**
|
|
25
|
+
* Annual tribute from polityA to polityB as a fraction of polityA treasury.
|
|
26
|
+
* `0` = no tribute clause. Range [0, SCALE.Q].
|
|
27
|
+
*/
|
|
28
|
+
tributeFromA_Q: Q;
|
|
29
|
+
/**
|
|
30
|
+
* Annual tribute from polityB to polityA as a fraction of polityB treasury.
|
|
31
|
+
* `0` = no tribute clause. Range [0, SCALE.Q].
|
|
32
|
+
*/
|
|
33
|
+
tributeFromB_Q: Q;
|
|
34
|
+
}
|
|
35
|
+
/** Registry of all active treaties. */
|
|
36
|
+
export interface TreatyRegistry {
|
|
37
|
+
treaties: Map<string, Treaty>;
|
|
38
|
+
}
|
|
39
|
+
/** Treaty strength below this → `isTreatyFragile` returns true. */
|
|
40
|
+
export declare const TREATY_FRAGILE_THRESHOLD: Q;
|
|
41
|
+
/** Base strength at signing per treaty type. */
|
|
42
|
+
export declare const TREATY_BASE_STRENGTH: Record<TreatyType, Q>;
|
|
43
|
+
/** Daily strength decay per treaty type (per simulated day). */
|
|
44
|
+
export declare const TREATY_DECAY_PER_DAY: Record<TreatyType, Q>;
|
|
45
|
+
/**
|
|
46
|
+
* Infamy added to the breaker's renown record on treaty violation.
|
|
47
|
+
* Military alliances carry the gravest penalty; trade pacts the lightest.
|
|
48
|
+
*/
|
|
49
|
+
export declare const TREATY_BREAK_INFAMY: Record<TreatyType, Q>;
|
|
50
|
+
export declare function createTreatyRegistry(): TreatyRegistry;
|
|
51
|
+
/**
|
|
52
|
+
* Canonical treaty key — independent of argument order.
|
|
53
|
+
* Polity IDs are sorted lexicographically so `key(A,B,t) === key(B,A,t)`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function treatyKey(polityAId: string, polityBId: string, type: TreatyType): string;
|
|
56
|
+
/**
|
|
57
|
+
* Sign a new treaty between two polities and register it.
|
|
58
|
+
* If a treaty of the same type between the same pair already exists it is
|
|
59
|
+
* replaced (renewal).
|
|
60
|
+
*
|
|
61
|
+
* @param tick Current simulation tick (day).
|
|
62
|
+
* @param durationTicks How many ticks the treaty lasts; `-1` = permanent.
|
|
63
|
+
* @param tributeFromA_Q Annual tribute fraction from A to B (default 0).
|
|
64
|
+
* @param tributeFromB_Q Annual tribute fraction from B to A (default 0).
|
|
65
|
+
*/
|
|
66
|
+
export declare function signTreaty(registry: TreatyRegistry, polityAId: string, polityBId: string, type: TreatyType, tick?: number, durationTicks?: number, tributeFromA_Q?: Q, tributeFromB_Q?: Q): Treaty;
|
|
67
|
+
/** Return the treaty between two polities of the given type, or `undefined`. */
|
|
68
|
+
export declare function getTreaty(registry: TreatyRegistry, polityAId: string, polityBId: string, type: TreatyType): Treaty | undefined;
|
|
69
|
+
/** Return all active treaties involving `polityId` (as either party). */
|
|
70
|
+
export declare function getActiveTreaties(registry: TreatyRegistry, polityId: string): Treaty[];
|
|
71
|
+
/**
|
|
72
|
+
* Return `true` if the treaty has expired at `currentTick`.
|
|
73
|
+
* Permanent treaties (`expiryTick === -1`) never expire.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isTreatyExpired(treaty: Treaty, currentTick: number): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Advance treaty strength by one simulated day.
|
|
78
|
+
* Decays at `TREATY_DECAY_PER_DAY[type]`; `boostDelta_Q` is an optional
|
|
79
|
+
* signed daily bonus (e.g., tribute paid, joint victory, diplomatic summit).
|
|
80
|
+
* Mutates `treaty.strength_Q`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stepTreatyStrength(treaty: Treaty, boostDelta_Q?: Q): void;
|
|
83
|
+
/**
|
|
84
|
+
* Reinforce a treaty by a fixed delta (e.g., after a tribute payment, joint
|
|
85
|
+
* military victory, or diplomatic summit). Clamps to [0, SCALE.Q].
|
|
86
|
+
*/
|
|
87
|
+
export declare function reinforceTreaty(treaty: Treaty, deltaQ: Q): void;
|
|
88
|
+
/** Return `true` if treaty strength is below `TREATY_FRAGILE_THRESHOLD`. */
|
|
89
|
+
export declare function isTreatyFragile(treaty: Treaty): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Break a treaty and remove it from the registry.
|
|
92
|
+
* Adds `TREATY_BREAK_INFAMY[type]` to `breakerRulerId`'s renown record
|
|
93
|
+
* if `breakerRulerId` and `renownRegistry` are provided.
|
|
94
|
+
*
|
|
95
|
+
* @returns `true` if a treaty was found and removed; `false` otherwise.
|
|
96
|
+
*/
|
|
97
|
+
export declare function breakTreaty(registry: TreatyRegistry, polityAId: string, polityBId: string, type: TreatyType, breakerRulerId?: number, renownRegistry?: RenownRegistry): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Compute the diplomatic prestige of a polity as the sum of `strength_Q`
|
|
100
|
+
* of all its active treaties, normalised to [0, SCALE.Q].
|
|
101
|
+
*
|
|
102
|
+
* Hosts should pass only non-expired treaties; this function does no
|
|
103
|
+
* expiry filtering.
|
|
104
|
+
*/
|
|
105
|
+
export declare function computeDiplomaticPrestige(registry: TreatyRegistry, polityId: string): Q;
|
|
106
|
+
/**
|
|
107
|
+
* Return `true` if the two polities have at least one active treaty of any type.
|
|
108
|
+
*/
|
|
109
|
+
export declare function areInAnyTreaty(registry: TreatyRegistry, polityAId: string, polityBId: string): boolean;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/diplomacy.ts — Phase 80: Diplomacy & Treaties
|
|
2
|
+
//
|
|
3
|
+
// Formal agreements between polities. Each treaty has a type, strength,
|
|
4
|
+
// optional expiry, and optional tribute clause. Strength decays over time
|
|
5
|
+
// and recovers via upholding the terms. Breaking a treaty adds infamy to
|
|
6
|
+
// the breaker's renown record (Phase 75).
|
|
7
|
+
//
|
|
8
|
+
// Design:
|
|
9
|
+
// - Pure data layer — no Entity fields, no kernel changes.
|
|
10
|
+
// - `TreatyRegistry` is external to PolityRegistry; hosts maintain both.
|
|
11
|
+
// - Treaty keys are canonical (sorted polity IDs) so order doesn't matter.
|
|
12
|
+
// - `isTreatyExpired` is a host responsibility: expired treaties should be
|
|
13
|
+
// removed or renewed each tick.
|
|
14
|
+
import { getRenownRecord } from "./renown.js";
|
|
15
|
+
import { q, SCALE, clampQ } from "./units.js";
|
|
16
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
17
|
+
/** Treaty strength below this → `isTreatyFragile` returns true. */
|
|
18
|
+
export const TREATY_FRAGILE_THRESHOLD = q(0.20);
|
|
19
|
+
/** Base strength at signing per treaty type. */
|
|
20
|
+
export const TREATY_BASE_STRENGTH = {
|
|
21
|
+
military_alliance: q(0.80),
|
|
22
|
+
royal_marriage: q(0.75),
|
|
23
|
+
peace: q(0.60),
|
|
24
|
+
non_aggression: q(0.55),
|
|
25
|
+
trade_pact: q(0.50),
|
|
26
|
+
};
|
|
27
|
+
/** Daily strength decay per treaty type (per simulated day). */
|
|
28
|
+
export const TREATY_DECAY_PER_DAY = {
|
|
29
|
+
military_alliance: q(0.001), // very slow — costly to abandon
|
|
30
|
+
royal_marriage: q(0.001),
|
|
31
|
+
peace: q(0.002),
|
|
32
|
+
non_aggression: q(0.003),
|
|
33
|
+
trade_pact: q(0.002),
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Infamy added to the breaker's renown record on treaty violation.
|
|
37
|
+
* Military alliances carry the gravest penalty; trade pacts the lightest.
|
|
38
|
+
*/
|
|
39
|
+
export const TREATY_BREAK_INFAMY = {
|
|
40
|
+
military_alliance: q(0.25),
|
|
41
|
+
royal_marriage: q(0.20),
|
|
42
|
+
peace: q(0.15),
|
|
43
|
+
non_aggression: q(0.10),
|
|
44
|
+
trade_pact: q(0.05),
|
|
45
|
+
};
|
|
46
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
47
|
+
export function createTreatyRegistry() {
|
|
48
|
+
return { treaties: new Map() };
|
|
49
|
+
}
|
|
50
|
+
// ── Key helpers ───────────────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Canonical treaty key — independent of argument order.
|
|
53
|
+
* Polity IDs are sorted lexicographically so `key(A,B,t) === key(B,A,t)`.
|
|
54
|
+
*/
|
|
55
|
+
export function treatyKey(polityAId, polityBId, type) {
|
|
56
|
+
const [lo, hi] = polityAId < polityBId
|
|
57
|
+
? [polityAId, polityBId]
|
|
58
|
+
: [polityBId, polityAId];
|
|
59
|
+
return `${lo}:${hi}:${type}`;
|
|
60
|
+
}
|
|
61
|
+
// ── Treaty management ─────────────────────────────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* Sign a new treaty between two polities and register it.
|
|
64
|
+
* If a treaty of the same type between the same pair already exists it is
|
|
65
|
+
* replaced (renewal).
|
|
66
|
+
*
|
|
67
|
+
* @param tick Current simulation tick (day).
|
|
68
|
+
* @param durationTicks How many ticks the treaty lasts; `-1` = permanent.
|
|
69
|
+
* @param tributeFromA_Q Annual tribute fraction from A to B (default 0).
|
|
70
|
+
* @param tributeFromB_Q Annual tribute fraction from B to A (default 0).
|
|
71
|
+
*/
|
|
72
|
+
export function signTreaty(registry, polityAId, polityBId, type, tick = 0, durationTicks = -1, tributeFromA_Q = 0, tributeFromB_Q = 0) {
|
|
73
|
+
const key = treatyKey(polityAId, polityBId, type);
|
|
74
|
+
const treaty = {
|
|
75
|
+
treatyId: key,
|
|
76
|
+
polityAId,
|
|
77
|
+
polityBId,
|
|
78
|
+
type,
|
|
79
|
+
strength_Q: TREATY_BASE_STRENGTH[type],
|
|
80
|
+
signedTick: tick,
|
|
81
|
+
expiryTick: durationTicks < 0 ? -1 : tick + durationTicks,
|
|
82
|
+
tributeFromA_Q,
|
|
83
|
+
tributeFromB_Q,
|
|
84
|
+
};
|
|
85
|
+
registry.treaties.set(key, treaty);
|
|
86
|
+
return treaty;
|
|
87
|
+
}
|
|
88
|
+
/** Return the treaty between two polities of the given type, or `undefined`. */
|
|
89
|
+
export function getTreaty(registry, polityAId, polityBId, type) {
|
|
90
|
+
return registry.treaties.get(treatyKey(polityAId, polityBId, type));
|
|
91
|
+
}
|
|
92
|
+
/** Return all active treaties involving `polityId` (as either party). */
|
|
93
|
+
export function getActiveTreaties(registry, polityId) {
|
|
94
|
+
return [...registry.treaties.values()].filter(t => t.polityAId === polityId || t.polityBId === polityId);
|
|
95
|
+
}
|
|
96
|
+
// ── Expiry ────────────────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Return `true` if the treaty has expired at `currentTick`.
|
|
99
|
+
* Permanent treaties (`expiryTick === -1`) never expire.
|
|
100
|
+
*/
|
|
101
|
+
export function isTreatyExpired(treaty, currentTick) {
|
|
102
|
+
return treaty.expiryTick !== -1 && currentTick >= treaty.expiryTick;
|
|
103
|
+
}
|
|
104
|
+
// ── Strength dynamics ─────────────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Advance treaty strength by one simulated day.
|
|
107
|
+
* Decays at `TREATY_DECAY_PER_DAY[type]`; `boostDelta_Q` is an optional
|
|
108
|
+
* signed daily bonus (e.g., tribute paid, joint victory, diplomatic summit).
|
|
109
|
+
* Mutates `treaty.strength_Q`.
|
|
110
|
+
*/
|
|
111
|
+
export function stepTreatyStrength(treaty, boostDelta_Q = 0) {
|
|
112
|
+
const decay = TREATY_DECAY_PER_DAY[treaty.type];
|
|
113
|
+
treaty.strength_Q = clampQ(treaty.strength_Q - decay + boostDelta_Q, 0, SCALE.Q);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reinforce a treaty by a fixed delta (e.g., after a tribute payment, joint
|
|
117
|
+
* military victory, or diplomatic summit). Clamps to [0, SCALE.Q].
|
|
118
|
+
*/
|
|
119
|
+
export function reinforceTreaty(treaty, deltaQ) {
|
|
120
|
+
treaty.strength_Q = clampQ(treaty.strength_Q + deltaQ, 0, SCALE.Q);
|
|
121
|
+
}
|
|
122
|
+
/** Return `true` if treaty strength is below `TREATY_FRAGILE_THRESHOLD`. */
|
|
123
|
+
export function isTreatyFragile(treaty) {
|
|
124
|
+
return treaty.strength_Q < TREATY_FRAGILE_THRESHOLD;
|
|
125
|
+
}
|
|
126
|
+
// ── Treaty breaking ───────────────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Break a treaty and remove it from the registry.
|
|
129
|
+
* Adds `TREATY_BREAK_INFAMY[type]` to `breakerRulerId`'s renown record
|
|
130
|
+
* if `breakerRulerId` and `renownRegistry` are provided.
|
|
131
|
+
*
|
|
132
|
+
* @returns `true` if a treaty was found and removed; `false` otherwise.
|
|
133
|
+
*/
|
|
134
|
+
export function breakTreaty(registry, polityAId, polityBId, type, breakerRulerId, renownRegistry) {
|
|
135
|
+
const key = treatyKey(polityAId, polityBId, type);
|
|
136
|
+
const treaty = registry.treaties.get(key);
|
|
137
|
+
if (!treaty)
|
|
138
|
+
return false;
|
|
139
|
+
if (breakerRulerId != null && renownRegistry != null) {
|
|
140
|
+
const record = getRenownRecord(renownRegistry, breakerRulerId);
|
|
141
|
+
record.infamy_Q = clampQ(record.infamy_Q + TREATY_BREAK_INFAMY[type], 0, SCALE.Q);
|
|
142
|
+
}
|
|
143
|
+
registry.treaties.delete(key);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// ── Diplomatic prestige ───────────────────────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* Compute the diplomatic prestige of a polity as the sum of `strength_Q`
|
|
149
|
+
* of all its active treaties, normalised to [0, SCALE.Q].
|
|
150
|
+
*
|
|
151
|
+
* Hosts should pass only non-expired treaties; this function does no
|
|
152
|
+
* expiry filtering.
|
|
153
|
+
*/
|
|
154
|
+
export function computeDiplomaticPrestige(registry, polityId) {
|
|
155
|
+
const treaties = getActiveTreaties(registry, polityId);
|
|
156
|
+
if (treaties.length === 0)
|
|
157
|
+
return 0;
|
|
158
|
+
const total = treaties.reduce((sum, t) => sum + t.strength_Q, 0);
|
|
159
|
+
return clampQ(total, 0, SCALE.Q);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Return `true` if the two polities have at least one active treaty of any type.
|
|
163
|
+
*/
|
|
164
|
+
export function areInAnyTreaty(registry, polityAId, polityBId) {
|
|
165
|
+
return [...registry.treaties.values()].some(t => (t.polityAId === polityAId && t.polityBId === polityBId) ||
|
|
166
|
+
(t.polityAId === polityBId && t.polityBId === polityAId));
|
|
167
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Polity } from "./polity.js";
|
|
2
|
+
import type { PolityRegistry } from "./polity.js";
|
|
3
|
+
import type { Q } from "./units.js";
|
|
4
|
+
/** A resolved population transfer from one polity to another. */
|
|
5
|
+
export interface MigrationFlow {
|
|
6
|
+
fromPolityId: string;
|
|
7
|
+
toPolityId: string;
|
|
8
|
+
/** Positive integer — number of people moving. */
|
|
9
|
+
population: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Stability below this contributes to push pressure.
|
|
13
|
+
* A polity at q(0.40) stability has zero stability push; below it, pressure rises.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MIGRATION_PUSH_STABILITY_THRESHOLD: Q;
|
|
16
|
+
/**
|
|
17
|
+
* Morale below this contributes to push pressure.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MIGRATION_PUSH_MORALE_THRESHOLD: Q;
|
|
20
|
+
/**
|
|
21
|
+
* Feudal bond strength below this contributes to push pressure.
|
|
22
|
+
* Vassals under an oppressive liege (weak bonds) bleed population.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MIGRATION_PUSH_FEUDAL_THRESHOLD: Q;
|
|
25
|
+
/**
|
|
26
|
+
* Flat push bonus added when the polity is in an active war.
|
|
27
|
+
* Represents war refugees and general insecurity.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MIGRATION_WAR_PUSH_Q: Q;
|
|
30
|
+
/**
|
|
31
|
+
* Fraction of the source polity's population that migrates per simulated day
|
|
32
|
+
* at full combined pressure and full destination pull.
|
|
33
|
+
* q(0.001) = 0.1 % per day maximum.
|
|
34
|
+
*/
|
|
35
|
+
export declare const MIGRATION_DAILY_RATE_Q: Q;
|
|
36
|
+
/**
|
|
37
|
+
* Minimum push pressure required for migration to occur.
|
|
38
|
+
* Prevents trickle migration from perfectly stable polities.
|
|
39
|
+
*/
|
|
40
|
+
export declare const MIGRATION_PUSH_MIN_Q: Q;
|
|
41
|
+
/**
|
|
42
|
+
* Compute the push pressure of a polity — how strongly it repels its own
|
|
43
|
+
* population. Returns a Q in [0, SCALE.Q].
|
|
44
|
+
*
|
|
45
|
+
* @param polity Source polity.
|
|
46
|
+
* @param isAtWar True if the polity has any active war (Phase 61).
|
|
47
|
+
* @param lowestBondStr_Q Weakest feudal bond as vassal, or SCALE.Q if not a vassal (Phase 79).
|
|
48
|
+
*/
|
|
49
|
+
export declare function computePushPressure(polity: Polity, isAtWar?: boolean, lowestBondStr_Q?: Q): Q;
|
|
50
|
+
/**
|
|
51
|
+
* Compute the pull factor of a polity — how attractive it is as a destination.
|
|
52
|
+
* Pull = `stabilityQ × moraleQ / SCALE.Q` — both must be high to attract migrants.
|
|
53
|
+
* Returns a Q in [0, SCALE.Q].
|
|
54
|
+
*/
|
|
55
|
+
export declare function computePullFactor(polity: Polity): Q;
|
|
56
|
+
/**
|
|
57
|
+
* Compute the number of people that would migrate from `from` to `to` in one
|
|
58
|
+
* simulated day, given pre-computed push and pull values.
|
|
59
|
+
*
|
|
60
|
+
* Formula (integer arithmetic throughout):
|
|
61
|
+
* combined_Q = push_Q × pull_Q / SCALE.Q
|
|
62
|
+
* scaledPop = population × combined_Q / SCALE.Q
|
|
63
|
+
* flow = floor(scaledPop × DAILY_RATE_Q / SCALE.Q)
|
|
64
|
+
*
|
|
65
|
+
* Returns 0 if push < `MIGRATION_PUSH_MIN_Q`, pull ≤ 0, or from.population ≤ 0.
|
|
66
|
+
*/
|
|
67
|
+
export declare function computeMigrationFlow(from: Polity, to: Polity, push_Q: Q, pull_Q: Q): number;
|
|
68
|
+
/**
|
|
69
|
+
* Optional per-polity context for migration resolution.
|
|
70
|
+
* Callers supply war/feudal context without this module needing to import
|
|
71
|
+
* PolityRegistry or FeudalRegistry.
|
|
72
|
+
*/
|
|
73
|
+
export interface MigrationContext {
|
|
74
|
+
polityId: string;
|
|
75
|
+
isAtWar?: boolean;
|
|
76
|
+
lowestBondStr_Q?: Q;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve all migration flows for one simulated day across the provided
|
|
80
|
+
* polities. Returns a flat list of `MigrationFlow` objects with `population > 0`.
|
|
81
|
+
*
|
|
82
|
+
* The caller should pass all polities that may send or receive migrants.
|
|
83
|
+
* Flows are not applied here — call `applyMigrationFlows` to mutate state.
|
|
84
|
+
*
|
|
85
|
+
* @param polities Array of candidate polities.
|
|
86
|
+
* @param context Optional per-polity war / feudal context keyed by polityId.
|
|
87
|
+
*/
|
|
88
|
+
export declare function resolveMigration(polities: Polity[], context?: Map<string, MigrationContext>): MigrationFlow[];
|
|
89
|
+
/**
|
|
90
|
+
* Apply a list of migration flows to the polity registry.
|
|
91
|
+
* Mutates `population` on both sending and receiving polities.
|
|
92
|
+
* The actual population moved is clamped to the sender's current population
|
|
93
|
+
* to prevent negative populations.
|
|
94
|
+
*
|
|
95
|
+
* Unknown polity IDs in a flow are silently skipped.
|
|
96
|
+
*/
|
|
97
|
+
export declare function applyMigrationFlows(registry: PolityRegistry, flows: MigrationFlow[]): void;
|
|
98
|
+
/**
|
|
99
|
+
* Compute the net annual population change due to migration for a polity,
|
|
100
|
+
* expressed as a fraction of its current population.
|
|
101
|
+
*
|
|
102
|
+
* Positive = net immigration (pull exceeds push).
|
|
103
|
+
* Negative = net emigration (push exceeds pull).
|
|
104
|
+
*
|
|
105
|
+
* Useful for AI and diplomatic decision-making.
|
|
106
|
+
*/
|
|
107
|
+
export declare function estimateNetMigrationRate(polityId: string, flows: MigrationFlow[], population: number): number;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/migration.ts — Phase 81: Migration & Displacement
|
|
2
|
+
//
|
|
3
|
+
// Population movement between polities driven by push factors (instability,
|
|
4
|
+
// low morale, active war, feudal oppression) and pull factors (prosperity,
|
|
5
|
+
// stability). Integrates with Phase 61 (Polity), Phase 79 (Feudal), and
|
|
6
|
+
// Phase 80 (Diplomacy) without importing any of them directly — callers
|
|
7
|
+
// pass pre-computed context values.
|
|
8
|
+
//
|
|
9
|
+
// Design:
|
|
10
|
+
// - Pure computation layer — no Entity fields, no kernel changes.
|
|
11
|
+
// - `computePushPressure` and `computePullFactor` are the two primitives.
|
|
12
|
+
// - `computeMigrationFlow` derives the daily migrant count for a directed pair.
|
|
13
|
+
// - `resolveMigration` collects all flows above the threshold.
|
|
14
|
+
// - `applyMigrationFlows` mutates Polity population fields.
|
|
15
|
+
import { SCALE, q, clampQ, mulDiv } from "./units.js";
|
|
16
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* Stability below this contributes to push pressure.
|
|
19
|
+
* A polity at q(0.40) stability has zero stability push; below it, pressure rises.
|
|
20
|
+
*/
|
|
21
|
+
export const MIGRATION_PUSH_STABILITY_THRESHOLD = q(0.40);
|
|
22
|
+
/**
|
|
23
|
+
* Morale below this contributes to push pressure.
|
|
24
|
+
*/
|
|
25
|
+
export const MIGRATION_PUSH_MORALE_THRESHOLD = q(0.40);
|
|
26
|
+
/**
|
|
27
|
+
* Feudal bond strength below this contributes to push pressure.
|
|
28
|
+
* Vassals under an oppressive liege (weak bonds) bleed population.
|
|
29
|
+
*/
|
|
30
|
+
export const MIGRATION_PUSH_FEUDAL_THRESHOLD = q(0.30);
|
|
31
|
+
/**
|
|
32
|
+
* Flat push bonus added when the polity is in an active war.
|
|
33
|
+
* Represents war refugees and general insecurity.
|
|
34
|
+
*/
|
|
35
|
+
export const MIGRATION_WAR_PUSH_Q = q(0.20);
|
|
36
|
+
/**
|
|
37
|
+
* Fraction of the source polity's population that migrates per simulated day
|
|
38
|
+
* at full combined pressure and full destination pull.
|
|
39
|
+
* q(0.001) = 0.1 % per day maximum.
|
|
40
|
+
*/
|
|
41
|
+
export const MIGRATION_DAILY_RATE_Q = q(0.001);
|
|
42
|
+
/**
|
|
43
|
+
* Minimum push pressure required for migration to occur.
|
|
44
|
+
* Prevents trickle migration from perfectly stable polities.
|
|
45
|
+
*/
|
|
46
|
+
export const MIGRATION_PUSH_MIN_Q = q(0.05);
|
|
47
|
+
// ── Core computation ───────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Compute the push pressure of a polity — how strongly it repels its own
|
|
50
|
+
* population. Returns a Q in [0, SCALE.Q].
|
|
51
|
+
*
|
|
52
|
+
* @param polity Source polity.
|
|
53
|
+
* @param isAtWar True if the polity has any active war (Phase 61).
|
|
54
|
+
* @param lowestBondStr_Q Weakest feudal bond as vassal, or SCALE.Q if not a vassal (Phase 79).
|
|
55
|
+
*/
|
|
56
|
+
export function computePushPressure(polity, isAtWar = false, lowestBondStr_Q = SCALE.Q) {
|
|
57
|
+
const stabilityDeficit = Math.max(0, MIGRATION_PUSH_STABILITY_THRESHOLD - polity.stabilityQ);
|
|
58
|
+
const moraleDeficit = Math.max(0, MIGRATION_PUSH_MORALE_THRESHOLD - polity.moraleQ);
|
|
59
|
+
const warBonus = isAtWar ? MIGRATION_WAR_PUSH_Q : 0;
|
|
60
|
+
const feudalDeficit = Math.max(0, MIGRATION_PUSH_FEUDAL_THRESHOLD - lowestBondStr_Q);
|
|
61
|
+
return clampQ(stabilityDeficit + moraleDeficit + warBonus + feudalDeficit, 0, SCALE.Q);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compute the pull factor of a polity — how attractive it is as a destination.
|
|
65
|
+
* Pull = `stabilityQ × moraleQ / SCALE.Q` — both must be high to attract migrants.
|
|
66
|
+
* Returns a Q in [0, SCALE.Q].
|
|
67
|
+
*/
|
|
68
|
+
export function computePullFactor(polity) {
|
|
69
|
+
return clampQ(mulDiv(polity.stabilityQ, polity.moraleQ, SCALE.Q), 0, SCALE.Q);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compute the number of people that would migrate from `from` to `to` in one
|
|
73
|
+
* simulated day, given pre-computed push and pull values.
|
|
74
|
+
*
|
|
75
|
+
* Formula (integer arithmetic throughout):
|
|
76
|
+
* combined_Q = push_Q × pull_Q / SCALE.Q
|
|
77
|
+
* scaledPop = population × combined_Q / SCALE.Q
|
|
78
|
+
* flow = floor(scaledPop × DAILY_RATE_Q / SCALE.Q)
|
|
79
|
+
*
|
|
80
|
+
* Returns 0 if push < `MIGRATION_PUSH_MIN_Q`, pull ≤ 0, or from.population ≤ 0.
|
|
81
|
+
*/
|
|
82
|
+
export function computeMigrationFlow(from, to, push_Q, pull_Q) {
|
|
83
|
+
if (push_Q < MIGRATION_PUSH_MIN_Q)
|
|
84
|
+
return 0;
|
|
85
|
+
if (pull_Q <= 0)
|
|
86
|
+
return 0;
|
|
87
|
+
if (from.population <= 0)
|
|
88
|
+
return 0;
|
|
89
|
+
if (from.id === to.id)
|
|
90
|
+
return 0;
|
|
91
|
+
const combined_Q = mulDiv(push_Q, pull_Q, SCALE.Q);
|
|
92
|
+
const scaledPop = mulDiv(from.population, combined_Q, SCALE.Q);
|
|
93
|
+
const flow = Math.floor(scaledPop * MIGRATION_DAILY_RATE_Q / SCALE.Q);
|
|
94
|
+
return flow;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve all migration flows for one simulated day across the provided
|
|
98
|
+
* polities. Returns a flat list of `MigrationFlow` objects with `population > 0`.
|
|
99
|
+
*
|
|
100
|
+
* The caller should pass all polities that may send or receive migrants.
|
|
101
|
+
* Flows are not applied here — call `applyMigrationFlows` to mutate state.
|
|
102
|
+
*
|
|
103
|
+
* @param polities Array of candidate polities.
|
|
104
|
+
* @param context Optional per-polity war / feudal context keyed by polityId.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveMigration(polities, context = new Map()) {
|
|
107
|
+
const flows = [];
|
|
108
|
+
for (const from of polities) {
|
|
109
|
+
const ctx = context.get(from.id);
|
|
110
|
+
const push_Q = computePushPressure(from, ctx?.isAtWar ?? false, ctx?.lowestBondStr_Q ?? SCALE.Q);
|
|
111
|
+
if (push_Q < MIGRATION_PUSH_MIN_Q)
|
|
112
|
+
continue;
|
|
113
|
+
for (const to of polities) {
|
|
114
|
+
if (to.id === from.id)
|
|
115
|
+
continue;
|
|
116
|
+
const pull_Q = computePullFactor(to);
|
|
117
|
+
const n = computeMigrationFlow(from, to, push_Q, pull_Q);
|
|
118
|
+
if (n > 0)
|
|
119
|
+
flows.push({ fromPolityId: from.id, toPolityId: to.id, population: n });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return flows;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Apply a list of migration flows to the polity registry.
|
|
126
|
+
* Mutates `population` on both sending and receiving polities.
|
|
127
|
+
* The actual population moved is clamped to the sender's current population
|
|
128
|
+
* to prevent negative populations.
|
|
129
|
+
*
|
|
130
|
+
* Unknown polity IDs in a flow are silently skipped.
|
|
131
|
+
*/
|
|
132
|
+
export function applyMigrationFlows(registry, flows) {
|
|
133
|
+
for (const flow of flows) {
|
|
134
|
+
const from = registry.polities.get(flow.fromPolityId);
|
|
135
|
+
const to = registry.polities.get(flow.toPolityId);
|
|
136
|
+
if (!from || !to)
|
|
137
|
+
continue;
|
|
138
|
+
const actual = Math.min(flow.population, from.population);
|
|
139
|
+
if (actual <= 0)
|
|
140
|
+
continue;
|
|
141
|
+
from.population -= actual;
|
|
142
|
+
to.population += actual;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Compute the net annual population change due to migration for a polity,
|
|
147
|
+
* expressed as a fraction of its current population.
|
|
148
|
+
*
|
|
149
|
+
* Positive = net immigration (pull exceeds push).
|
|
150
|
+
* Negative = net emigration (push exceeds pull).
|
|
151
|
+
*
|
|
152
|
+
* Useful for AI and diplomatic decision-making.
|
|
153
|
+
*/
|
|
154
|
+
export function estimateNetMigrationRate(polityId, flows, population) {
|
|
155
|
+
if (population <= 0)
|
|
156
|
+
return 0;
|
|
157
|
+
let net = 0;
|
|
158
|
+
for (const f of flows) {
|
|
159
|
+
if (f.fromPolityId === polityId)
|
|
160
|
+
net -= f.population;
|
|
161
|
+
if (f.toPolityId === polityId)
|
|
162
|
+
net += f.population;
|
|
163
|
+
}
|
|
164
|
+
return net / population;
|
|
165
|
+
}
|
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.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -82,6 +82,14 @@
|
|
|
82
82
|
"./feudal": {
|
|
83
83
|
"import": "./dist/src/feudal.js",
|
|
84
84
|
"types": "./dist/src/feudal.d.ts"
|
|
85
|
+
},
|
|
86
|
+
"./diplomacy": {
|
|
87
|
+
"import": "./dist/src/diplomacy.js",
|
|
88
|
+
"types": "./dist/src/diplomacy.d.ts"
|
|
89
|
+
},
|
|
90
|
+
"./migration": {
|
|
91
|
+
"import": "./dist/src/migration.js",
|
|
92
|
+
"types": "./dist/src/migration.d.ts"
|
|
85
93
|
}
|
|
86
94
|
},
|
|
87
95
|
"files": [
|