@its-not-rocket-science/ananke 0.1.17 → 0.1.22
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 +112 -0
- package/dist/src/kinship.d.ts +92 -0
- package/dist/src/kinship.js +234 -0
- package/dist/src/narrative-prose.d.ts +58 -0
- package/dist/src/narrative-prose.js +313 -0
- package/dist/src/renown.d.ts +82 -0
- package/dist/src/renown.js +175 -0
- package/dist/src/succession.d.ts +86 -0
- package/dist/src/succession.js +197 -0
- package/package.json +18 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/succession.ts — Phase 77: Dynasty & Succession
|
|
2
|
+
//
|
|
3
|
+
// Resolves inheritance of political leadership when a ruler dies.
|
|
4
|
+
// Integrates Phase 76 (Kinship) for candidate discovery and Phase 75 (Renown)
|
|
5
|
+
// for claim-strength weighting. No kernel changes; no new Entity fields.
|
|
6
|
+
//
|
|
7
|
+
// Succession rules:
|
|
8
|
+
// primogeniture — first-born child (lowest entityId as proxy for birth order)
|
|
9
|
+
// renown_based — candidate with highest `claimStrength_Q` (renown + inherited)
|
|
10
|
+
// election — renown-weighted deterministic selection via eventSeed
|
|
11
|
+
//
|
|
12
|
+
// Stability impact:
|
|
13
|
+
// Direct heir (degree 1) → ±0 base impact
|
|
14
|
+
// Distant heir (degree 2+) → stability penalty per extra degree
|
|
15
|
+
// No heir found → large stability hit
|
|
16
|
+
// Contested succession (top-2 candidates within q(0.10)) → additional penalty
|
|
17
|
+
import { computeInheritedRenown, MAX_KINSHIP_DEPTH, } from "./kinship.js";
|
|
18
|
+
import { getRenownRecord } from "./renown.js";
|
|
19
|
+
import { eventSeed } from "./sim/seeds.js";
|
|
20
|
+
import { q, SCALE, clampQ, mulDiv } from "./units.js";
|
|
21
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
|
+
/** Weight of own renown vs. inherited renown when computing claim strength. */
|
|
23
|
+
export const CLAIM_OWN_RENOWN_WEIGHT_Q = q(0.70);
|
|
24
|
+
export const CLAIM_INHERITED_RENOWN_WEIGHT_Q = q(0.30);
|
|
25
|
+
/** Stability penalty per extra degree of kinship beyond 1 (direct child/parent). */
|
|
26
|
+
export const STABILITY_DISTANT_HEIR_Q = q(0.05);
|
|
27
|
+
/** Stability penalty when no heir is found. */
|
|
28
|
+
export const STABILITY_NO_HEIR_Q = q(0.20);
|
|
29
|
+
/** Additional penalty when the top two candidates are within this band. */
|
|
30
|
+
export const CONTESTED_THRESHOLD_Q = q(0.10);
|
|
31
|
+
export const STABILITY_CONTESTED_Q = q(0.05);
|
|
32
|
+
/** Stability bonus when a direct child inherits with no contest. */
|
|
33
|
+
export const STABILITY_CLEAN_SUCCESSION_Q = q(0.03);
|
|
34
|
+
// ── Candidate discovery ───────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Find all kin of `deceasedId` up to `maxDegree` and compute their claim strength.
|
|
37
|
+
* Candidates are sorted by claimStrength_Q descending, then kinshipDegree ascending.
|
|
38
|
+
*/
|
|
39
|
+
export function findSuccessionCandidates(lineage, deceasedId, renownRegistry, maxDegree = MAX_KINSHIP_DEPTH) {
|
|
40
|
+
// BFS over the family graph to collect all kin within maxDegree
|
|
41
|
+
const visited = new Set([deceasedId]);
|
|
42
|
+
const queue = [];
|
|
43
|
+
// Seed with immediate family
|
|
44
|
+
const deceasedNode = lineage.nodes.get(deceasedId);
|
|
45
|
+
if (!deceasedNode)
|
|
46
|
+
return [];
|
|
47
|
+
const seeds = [
|
|
48
|
+
...deceasedNode.childIds,
|
|
49
|
+
...deceasedNode.parentIds,
|
|
50
|
+
...deceasedNode.partnerIds,
|
|
51
|
+
];
|
|
52
|
+
for (const id of seeds) {
|
|
53
|
+
if (!visited.has(id)) {
|
|
54
|
+
visited.add(id);
|
|
55
|
+
queue.push({ id, degree: 1 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const candidates = [];
|
|
59
|
+
while (queue.length > 0) {
|
|
60
|
+
const item = queue.shift();
|
|
61
|
+
if (item.degree > maxDegree)
|
|
62
|
+
continue;
|
|
63
|
+
// Compute claim
|
|
64
|
+
const record = getRenownRecord(renownRegistry, item.id);
|
|
65
|
+
const renown_Q = record.renown_Q;
|
|
66
|
+
const inheritedRenown = computeInheritedRenown(lineage, item.id, renownRegistry, 3);
|
|
67
|
+
candidates.push({
|
|
68
|
+
entityId: item.id,
|
|
69
|
+
kinshipDegree: item.degree,
|
|
70
|
+
renown_Q,
|
|
71
|
+
inheritedRenown_Q: inheritedRenown,
|
|
72
|
+
claimStrength_Q: 0, // filled in per-rule below
|
|
73
|
+
});
|
|
74
|
+
// Expand neighbours
|
|
75
|
+
const node = lineage.nodes.get(item.id);
|
|
76
|
+
if (!node)
|
|
77
|
+
continue;
|
|
78
|
+
for (const nbr of [...node.childIds, ...node.parentIds, ...node.partnerIds]) {
|
|
79
|
+
if (!visited.has(nbr)) {
|
|
80
|
+
visited.add(nbr);
|
|
81
|
+
queue.push({ id: nbr, degree: item.degree + 1 });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return candidates;
|
|
86
|
+
}
|
|
87
|
+
// ── Claim strength computation ─────────────────────────────────────────────────
|
|
88
|
+
/** Compute `claimStrength_Q` for a candidate under the given rule. */
|
|
89
|
+
function computeClaimStrength(candidate, rule, firstBornId) {
|
|
90
|
+
switch (rule) {
|
|
91
|
+
case "primogeniture":
|
|
92
|
+
// First-born (lowest entityId among children at degree 1) gets full claim;
|
|
93
|
+
// all others get proportionally less based on distance
|
|
94
|
+
if (candidate.entityId === firstBornId)
|
|
95
|
+
return SCALE.Q;
|
|
96
|
+
// Other children score by closeness only
|
|
97
|
+
return clampQ(SCALE.Q - candidate.kinshipDegree * STABILITY_DISTANT_HEIR_Q, 0, SCALE.Q);
|
|
98
|
+
case "renown_based":
|
|
99
|
+
case "election": {
|
|
100
|
+
// Weighted combination of own renown and inherited renown
|
|
101
|
+
const ownPart = mulDiv(candidate.renown_Q, CLAIM_OWN_RENOWN_WEIGHT_Q, SCALE.Q);
|
|
102
|
+
const inhPart = mulDiv(candidate.inheritedRenown_Q, CLAIM_INHERITED_RENOWN_WEIGHT_Q, SCALE.Q);
|
|
103
|
+
return clampQ(ownPart + inhPart, 0, SCALE.Q);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── Succession resolution ─────────────────────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Resolve succession after `deceasedId` dies.
|
|
110
|
+
*
|
|
111
|
+
* @param lineage Kinship registry (Phase 76).
|
|
112
|
+
* @param deceasedId The entity whose position must be inherited.
|
|
113
|
+
* @param renownRegistry Renown registry (Phase 75).
|
|
114
|
+
* @param rule Succession rule to apply.
|
|
115
|
+
* @param worldSeed For deterministic election roll.
|
|
116
|
+
* @param tick Current simulation tick.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveSuccession(lineage, deceasedId, renownRegistry, rule, worldSeed, tick) {
|
|
119
|
+
const maxDegree = rule.maxDegree ?? MAX_KINSHIP_DEPTH;
|
|
120
|
+
const ruleType = rule.type;
|
|
121
|
+
// Find candidates
|
|
122
|
+
const raw = findSuccessionCandidates(lineage, deceasedId, renownRegistry, maxDegree);
|
|
123
|
+
if (raw.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
heirId: null,
|
|
126
|
+
candidates: [],
|
|
127
|
+
rule: ruleType,
|
|
128
|
+
stabilityImpact_Q: -STABILITY_NO_HEIR_Q,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Identify first-born (lowest entityId among direct children)
|
|
132
|
+
const directChildren = raw.filter(c => c.kinshipDegree === 1 &&
|
|
133
|
+
lineage.nodes.get(deceasedId)?.childIds.includes(c.entityId));
|
|
134
|
+
const firstBornId = directChildren.length > 0
|
|
135
|
+
? Math.min(...directChildren.map(c => c.entityId))
|
|
136
|
+
: null;
|
|
137
|
+
// Fill claim strength
|
|
138
|
+
for (const c of raw) {
|
|
139
|
+
c.claimStrength_Q = computeClaimStrength(c, ruleType, firstBornId);
|
|
140
|
+
}
|
|
141
|
+
// Sort: claim strength desc, then kinshipDegree asc (closer kin breaks ties)
|
|
142
|
+
raw.sort((a, b) => b.claimStrength_Q - a.claimStrength_Q || a.kinshipDegree - b.kinshipDegree);
|
|
143
|
+
// Select heir
|
|
144
|
+
let heirId;
|
|
145
|
+
if (ruleType === "election" && raw.length > 1) {
|
|
146
|
+
// Renown-weighted lottery: for each candidate, roll eventSeed and weight by claimStrength
|
|
147
|
+
const totalClaim = raw.reduce((s, c) => s + c.claimStrength_Q, 0);
|
|
148
|
+
const roll = eventSeed(worldSeed, tick, deceasedId, 0, 77) % Math.max(totalClaim, 1);
|
|
149
|
+
let cumulative = 0;
|
|
150
|
+
let elected = raw[0].entityId;
|
|
151
|
+
for (const c of raw) {
|
|
152
|
+
cumulative += c.claimStrength_Q;
|
|
153
|
+
if (roll < cumulative) {
|
|
154
|
+
elected = c.entityId;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
heirId = elected;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
heirId = raw[0].entityId;
|
|
162
|
+
}
|
|
163
|
+
const winner = raw.find(c => c.entityId === heirId);
|
|
164
|
+
// Compute stability impact
|
|
165
|
+
let stability = 0;
|
|
166
|
+
// Bonus for clean direct succession
|
|
167
|
+
if (winner.kinshipDegree === 1 && raw.length === 1) {
|
|
168
|
+
stability += STABILITY_CLEAN_SUCCESSION_Q;
|
|
169
|
+
}
|
|
170
|
+
// Penalty for distant heir
|
|
171
|
+
if (winner.kinshipDegree > 1) {
|
|
172
|
+
stability -= (winner.kinshipDegree - 1) * STABILITY_DISTANT_HEIR_Q;
|
|
173
|
+
}
|
|
174
|
+
// Penalty for contested succession
|
|
175
|
+
if (raw.length >= 2) {
|
|
176
|
+
const gap = raw[0].claimStrength_Q - raw[1].claimStrength_Q;
|
|
177
|
+
if (gap < CONTESTED_THRESHOLD_Q) {
|
|
178
|
+
stability -= STABILITY_CONTESTED_Q;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
heirId,
|
|
183
|
+
candidates: raw,
|
|
184
|
+
rule: ruleType,
|
|
185
|
+
stabilityImpact_Q: clampQ(stability, -SCALE.Q, SCALE.Q),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ── Polity integration ────────────────────────────────────────────────────────
|
|
189
|
+
/**
|
|
190
|
+
* Apply a succession result to a polity.
|
|
191
|
+
* Adjusts `stabilityQ` by `result.stabilityImpact_Q`.
|
|
192
|
+
* Does NOT change the ruler field (Polity has no rulerId); callers update faction
|
|
193
|
+
* leadership separately if needed.
|
|
194
|
+
*/
|
|
195
|
+
export function applySuccessionToPolity(polity, result) {
|
|
196
|
+
polity.stabilityQ = clampQ(polity.stabilityQ + result.stabilityImpact_Q, 0, SCALE.Q);
|
|
197
|
+
}
|
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.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,22 @@
|
|
|
58
58
|
"./wasm-kernel": {
|
|
59
59
|
"import": "./dist/src/wasm-kernel.js",
|
|
60
60
|
"types": "./dist/src/wasm-kernel.d.ts"
|
|
61
|
+
},
|
|
62
|
+
"./narrative-prose": {
|
|
63
|
+
"import": "./dist/src/narrative-prose.js",
|
|
64
|
+
"types": "./dist/src/narrative-prose.d.ts"
|
|
65
|
+
},
|
|
66
|
+
"./renown": {
|
|
67
|
+
"import": "./dist/src/renown.js",
|
|
68
|
+
"types": "./dist/src/renown.d.ts"
|
|
69
|
+
},
|
|
70
|
+
"./kinship": {
|
|
71
|
+
"import": "./dist/src/kinship.js",
|
|
72
|
+
"types": "./dist/src/kinship.d.ts"
|
|
73
|
+
},
|
|
74
|
+
"./succession": {
|
|
75
|
+
"import": "./dist/src/succession.js",
|
|
76
|
+
"types": "./dist/src/succession.d.ts"
|
|
61
77
|
}
|
|
62
78
|
},
|
|
63
79
|
"files": [
|
|
@@ -121,6 +137,7 @@
|
|
|
121
137
|
"world-server": "node dist/tools/world-server.js",
|
|
122
138
|
"persistent-world": "node dist/tools/persistent-world.js",
|
|
123
139
|
"replication-server": "node dist/tools/replication-server.js",
|
|
140
|
+
"agent-server": "node dist/tools/agent-server.js",
|
|
124
141
|
"benchmark-check": "node dist/tools/benchmark-check.js",
|
|
125
142
|
"benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
|
|
126
143
|
"benchmark-check:update": "node dist/tools/benchmark-check.js --update-baseline",
|