@its-not-rocket-science/ananke 0.1.16 → 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 +145 -0
- package/dist/src/economy-gen.d.ts +133 -0
- package/dist/src/economy-gen.js +261 -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/sim/disease.d.ts +144 -1
- package/dist/src/sim/disease.js +212 -6
- package/dist/src/sim/entity.d.ts +6 -1
- package/dist/src/succession.d.ts +86 -0
- package/dist/src/succession.js +197 -0
- package/package.json +19 -1
package/dist/src/sim/disease.js
CHANGED
|
@@ -22,6 +22,19 @@ export const CONTACT_RANGE_Sm = 20_000; // 2 m
|
|
|
22
22
|
const FEVER_AIRBORNE_Sm = 100_000; // 10 m
|
|
23
23
|
/** Plague airborne range [SCALE.m]. */
|
|
24
24
|
const PLAGUE_AIRBORNE_Sm = 50_000; // 5 m
|
|
25
|
+
// Phase 73 constants ───────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Airborne transmission reduction from mask mandate NPI [Q].
|
|
28
|
+
* Risk is multiplied by (SCALE.Q − NPI_MASK_REDUCTION_Q) / SCALE.Q → ×0.40 remaining.
|
|
29
|
+
*/
|
|
30
|
+
export const NPI_MASK_REDUCTION_Q = q(0.60);
|
|
31
|
+
/**
|
|
32
|
+
* Daily contacts-per-entity estimate for `computeR0`.
|
|
33
|
+
* Community-setting assumption; capped by actual population size.
|
|
34
|
+
*/
|
|
35
|
+
export const DAILY_CONTACTS_ESTIMATE = 15;
|
|
36
|
+
/** Seconds per year — mirrored from aging.ts to avoid circular import. */
|
|
37
|
+
const _SECS_PER_YEAR = 365 * 86_400;
|
|
25
38
|
// ── Disease Catalogue ─────────────────────────────────────────────────────────
|
|
26
39
|
/**
|
|
27
40
|
* Common fever — mild respiratory infection.
|
|
@@ -133,6 +146,39 @@ const _PROFILE_MAP = new Map(DISEASE_PROFILES.map(p => [p.id, p]));
|
|
|
133
146
|
export function getDiseaseProfile(id) {
|
|
134
147
|
return _PROFILE_MAP.get(id);
|
|
135
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Register a custom disease profile so it can be used with
|
|
151
|
+
* `exposeToDisease`, `spreadDisease`, and `stepDiseaseForEntity`.
|
|
152
|
+
*
|
|
153
|
+
* Does not modify `DISEASE_PROFILES`. Use this to add `MEASLES` or other
|
|
154
|
+
* Phase 73 / host-defined profiles to the lookup map.
|
|
155
|
+
*/
|
|
156
|
+
export function registerDiseaseProfile(profile) {
|
|
157
|
+
_PROFILE_MAP.set(profile.id, profile);
|
|
158
|
+
}
|
|
159
|
+
// ── Phase 73: MEASLES profile ─────────────────────────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Measles — highly contagious SEIR airborne disease.
|
|
162
|
+
*
|
|
163
|
+
* R0 ≈ 12–18 in populations of 15+ (DAILY_CONTACTS_ESTIMATE × 14 days × baseRate).
|
|
164
|
+
* Use with `registerDiseaseProfile(MEASLES)` before calling `exposeToDisease`.
|
|
165
|
+
*
|
|
166
|
+
* Validation target: epidemic curve peaks days 10–20, burns out by day 60,
|
|
167
|
+
* matching standard SIR model output within ±15 % for 95 % susceptible population.
|
|
168
|
+
*/
|
|
169
|
+
export const MEASLES = {
|
|
170
|
+
id: "measles",
|
|
171
|
+
name: "Measles",
|
|
172
|
+
transmissionRoute: "airborne",
|
|
173
|
+
baseTransmissionRate_Q: q(0.072), // R0 ≈ 15.1 with 15 daily contacts, 14-day duration
|
|
174
|
+
incubationPeriod_s: 14 * 86_400, // 14-day latent period
|
|
175
|
+
symptomaticDuration_s: 14 * 86_400, // 14-day infectious period
|
|
176
|
+
mortalityRate_Q: q(0.002), // 0.2 % IFR (developed world)
|
|
177
|
+
symptomSeverity_Q: q(0.15),
|
|
178
|
+
airborneRange_Sm: 100_000, // 10 m
|
|
179
|
+
immunityDuration_s: -1, // permanent lifelong immunity
|
|
180
|
+
useSeir: true,
|
|
181
|
+
};
|
|
136
182
|
// ── Entity-level API ──────────────────────────────────────────────────────────
|
|
137
183
|
/**
|
|
138
184
|
* Attempt to expose an entity to a disease.
|
|
@@ -268,12 +314,18 @@ export function stepDiseaseForEntity(entity, delta_s, worldSeed, tick) {
|
|
|
268
314
|
* Returns q(0) if the carrier has no symptomatic instance of this disease,
|
|
269
315
|
* or if target already has immunity / active infection for this disease.
|
|
270
316
|
*
|
|
317
|
+
* **Phase 73 extensions (backward-compatible):**
|
|
318
|
+
* - If `target.age` is set, applies age-stratified susceptibility multiplier.
|
|
319
|
+
* - If `target.vaccinations` contains a record for this disease, reduces risk by efficacy.
|
|
320
|
+
* - If `options.maskMandate` is true and disease is airborne, reduces risk by `NPI_MASK_REDUCTION_Q`.
|
|
321
|
+
*
|
|
271
322
|
* @param carrier The potentially infectious entity.
|
|
272
323
|
* @param target The potentially susceptible entity.
|
|
273
324
|
* @param dist_Sm Distance between them [SCALE.m].
|
|
274
325
|
* @param disease The disease profile to evaluate.
|
|
326
|
+
* @param options Phase 73 optional NPI modifiers.
|
|
275
327
|
*/
|
|
276
|
-
export function computeTransmissionRisk(carrier, target, dist_Sm, disease) {
|
|
328
|
+
export function computeTransmissionRisk(carrier, target, dist_Sm, disease, options) {
|
|
277
329
|
// Carrier must be symptomatic with this disease
|
|
278
330
|
const carrierState = carrier.activeDiseases?.find(d => d.diseaseId === disease.id && d.phase === "symptomatic");
|
|
279
331
|
if (!carrierState)
|
|
@@ -285,16 +337,37 @@ export function computeTransmissionRisk(carrier, target, dist_Sm, disease) {
|
|
|
285
337
|
const immune = target.immunity?.some(r => r.diseaseId === disease.id && (r.remainingSeconds === -1 || r.remainingSeconds > 0));
|
|
286
338
|
if (immune)
|
|
287
339
|
return q(0);
|
|
340
|
+
// ── Compute distance-based base risk ───────────────────────────────────────
|
|
341
|
+
let risk;
|
|
288
342
|
if (disease.transmissionRoute === "airborne") {
|
|
289
343
|
if (disease.airborneRange_Sm <= 0 || dist_Sm >= disease.airborneRange_Sm)
|
|
290
344
|
return q(0);
|
|
291
345
|
const proximity_Q = Math.round((disease.airborneRange_Sm - dist_Sm) * SCALE.Q / disease.airborneRange_Sm);
|
|
292
|
-
|
|
346
|
+
risk = Math.round(disease.baseTransmissionRate_Q * proximity_Q / SCALE.Q);
|
|
293
347
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
348
|
+
else {
|
|
349
|
+
// contact / vector / waterborne: flat risk within CONTACT_RANGE
|
|
350
|
+
if (dist_Sm > CONTACT_RANGE_Sm)
|
|
351
|
+
return q(0);
|
|
352
|
+
risk = disease.baseTransmissionRate_Q;
|
|
353
|
+
}
|
|
354
|
+
// ── Phase 73: age-stratified susceptibility ────────────────────────────────
|
|
355
|
+
if (target.age) {
|
|
356
|
+
const ageYears = target.age.ageSeconds / _SECS_PER_YEAR;
|
|
357
|
+
const ageMultiplier = ageSusceptibility_Q(ageYears);
|
|
358
|
+
risk = clampQ(Math.round(risk * ageMultiplier / SCALE.Q), 0, SCALE.Q);
|
|
359
|
+
}
|
|
360
|
+
// ── Phase 73: vaccination efficacy reduction ───────────────────────────────
|
|
361
|
+
const vacc = target.vaccinations?.find(v => v.diseaseId === disease.id);
|
|
362
|
+
if (vacc && vacc.efficacy_Q > 0) {
|
|
363
|
+
const blocked = Math.round(risk * vacc.efficacy_Q / SCALE.Q);
|
|
364
|
+
risk = Math.max(0, risk - blocked);
|
|
365
|
+
}
|
|
366
|
+
// ── Phase 73: NPI mask mandate (airborne only) ─────────────────────────────
|
|
367
|
+
if (options?.maskMandate && disease.transmissionRoute === "airborne") {
|
|
368
|
+
risk = Math.round(risk * (SCALE.Q - NPI_MASK_REDUCTION_Q) / SCALE.Q);
|
|
369
|
+
}
|
|
370
|
+
return risk;
|
|
298
371
|
}
|
|
299
372
|
/**
|
|
300
373
|
* Attempt to spread disease across a set of nearby entity pairs.
|
|
@@ -351,3 +424,136 @@ function diseaseIdSalt(id) {
|
|
|
351
424
|
h = (h + id.charCodeAt(i)) & 0xFFFFFF;
|
|
352
425
|
return h;
|
|
353
426
|
}
|
|
427
|
+
// ── Phase 73: Enhanced Epidemiology Functions ─────────────────────────────────
|
|
428
|
+
/**
|
|
429
|
+
* Age-stratified susceptibility multiplier [Q].
|
|
430
|
+
*
|
|
431
|
+
* Returns a value that may exceed SCALE.Q (increased susceptibility) or fall
|
|
432
|
+
* below it (relative protection). Applied in `computeTransmissionRisk` when
|
|
433
|
+
* `target.age` is set.
|
|
434
|
+
*
|
|
435
|
+
* | Age range | Multiplier | Notes |
|
|
436
|
+
* |-----------|-----------|-------------------------------|
|
|
437
|
+
* | 0–4 yrs | ×1.30 | High infant susceptibility |
|
|
438
|
+
* | 5–14 yrs | ×0.80 | Children — lower risk |
|
|
439
|
+
* | 15–59 yrs | ×1.00 | Adult baseline |
|
|
440
|
+
* | 60–74 yrs | ×1.20 | Early elderly |
|
|
441
|
+
* | 75 + yrs | ×1.50 | Late elderly / ancient |
|
|
442
|
+
*/
|
|
443
|
+
export function ageSusceptibility_Q(ageYears) {
|
|
444
|
+
if (ageYears < 5)
|
|
445
|
+
return 13_000; // ×1.30
|
|
446
|
+
if (ageYears < 15)
|
|
447
|
+
return 8_000; // ×0.80
|
|
448
|
+
if (ageYears < 60)
|
|
449
|
+
return 10_000; // ×1.00 baseline
|
|
450
|
+
if (ageYears < 75)
|
|
451
|
+
return 12_000; // ×1.20
|
|
452
|
+
return 15_000; // ×1.50
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Add or update a vaccination record on an entity.
|
|
456
|
+
*
|
|
457
|
+
* If the entity already has a record for this disease, updates `efficacy_Q`
|
|
458
|
+
* and increments `doseCount` (booster model). Otherwise creates a new record.
|
|
459
|
+
*
|
|
460
|
+
* @param entity Target entity to vaccinate.
|
|
461
|
+
* @param diseaseId Disease being vaccinated against.
|
|
462
|
+
* @param efficacy_Q Protection level [Q]; q(0.95) = 95 % efficacy.
|
|
463
|
+
*/
|
|
464
|
+
export function vaccinate(entity, diseaseId, efficacy_Q) {
|
|
465
|
+
if (!entity.vaccinations)
|
|
466
|
+
entity.vaccinations = [];
|
|
467
|
+
const existing = entity.vaccinations.find(v => v.diseaseId === diseaseId);
|
|
468
|
+
if (existing) {
|
|
469
|
+
existing.efficacy_Q = efficacy_Q;
|
|
470
|
+
existing.doseCount++;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
entity.vaccinations.push({ diseaseId, efficacy_Q, doseCount: 1 });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ── NPI registry helpers ───────────────────────────────────────────────────────
|
|
477
|
+
function _npiKey(polityId, npiType) {
|
|
478
|
+
return `${polityId}:${npiType}`;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Activate an NPI for a polity.
|
|
482
|
+
*
|
|
483
|
+
* `"mask_mandate"` — reduces airborne transmission in `computeTransmissionRisk`
|
|
484
|
+
* by `NPI_MASK_REDUCTION_Q` when the caller passes `options.maskMandate = true`.
|
|
485
|
+
*
|
|
486
|
+
* `"quarantine"` — recorded in the registry; the host is responsible for halving
|
|
487
|
+
* the contact-range pairs passed to `spreadDisease` (spatial filtering).
|
|
488
|
+
*/
|
|
489
|
+
export function applyNPI(npiRegistry, npiType, polityId) {
|
|
490
|
+
npiRegistry.set(_npiKey(polityId, npiType), { polityId, npiType });
|
|
491
|
+
}
|
|
492
|
+
/** Remove an NPI from a polity's registry entry. */
|
|
493
|
+
export function removeNPI(npiRegistry, npiType, polityId) {
|
|
494
|
+
npiRegistry.delete(_npiKey(polityId, npiType));
|
|
495
|
+
}
|
|
496
|
+
/** Returns true if the specified NPI is currently active for the polity. */
|
|
497
|
+
export function hasNPI(npiRegistry, npiType, polityId) {
|
|
498
|
+
return npiRegistry.has(_npiKey(polityId, npiType));
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Estimate the basic reproductive number R0 for a disease profile.
|
|
502
|
+
*
|
|
503
|
+
* Formula: R0 = beta × D × c
|
|
504
|
+
* - beta = baseTransmissionRate_Q / SCALE.Q (per-contact daily probability)
|
|
505
|
+
* - D = symptomaticDuration_s / 86400 (infectious period in days)
|
|
506
|
+
* - c = min(DAILY_CONTACTS_ESTIMATE, entityMap.size − 1) (daily contacts)
|
|
507
|
+
*
|
|
508
|
+
* Used for validation — not a simulation path value.
|
|
509
|
+
*
|
|
510
|
+
* @param profile Disease profile to evaluate.
|
|
511
|
+
* @param entityMap Population map (size determines contact estimate).
|
|
512
|
+
* @returns Estimated R0 (float; not fixed-point).
|
|
513
|
+
*/
|
|
514
|
+
export function computeR0(profile, entityMap) {
|
|
515
|
+
const infectiousDays = profile.symptomaticDuration_s / 86_400;
|
|
516
|
+
const beta = profile.baseTransmissionRate_Q / SCALE.Q;
|
|
517
|
+
const contacts = Math.min(DAILY_CONTACTS_ESTIMATE, Math.max(1, entityMap.size - 1));
|
|
518
|
+
return beta * infectiousDays * contacts;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Advance a single SEIR-enabled disease on an entity by `delta_s` seconds.
|
|
522
|
+
*
|
|
523
|
+
* Functionally equivalent to `stepDiseaseForEntity` for this profile only —
|
|
524
|
+
* isolates the target disease so other active diseases are not advanced.
|
|
525
|
+
* Backward-compatible: calls through to the Phase 56 step function.
|
|
526
|
+
*
|
|
527
|
+
* Intended for use with `profile.useSeir === true` diseases, but works with
|
|
528
|
+
* any profile registered via `registerDiseaseProfile`.
|
|
529
|
+
*
|
|
530
|
+
* @param entity Entity to advance.
|
|
531
|
+
* @param delta_s Elapsed seconds.
|
|
532
|
+
* @param profile Disease profile to process.
|
|
533
|
+
* @param worldSeed World seed for deterministic mortality roll.
|
|
534
|
+
* @param tick Current tick for deterministic mortality roll.
|
|
535
|
+
*/
|
|
536
|
+
export function stepSEIR(entity, delta_s, profile, worldSeed, tick) {
|
|
537
|
+
const empty = {
|
|
538
|
+
advancedToSymptomatic: [],
|
|
539
|
+
recovered: [],
|
|
540
|
+
died: false,
|
|
541
|
+
fatigueApplied: 0,
|
|
542
|
+
};
|
|
543
|
+
if (entity.injury.dead)
|
|
544
|
+
return empty;
|
|
545
|
+
const diseaseState = entity.activeDiseases?.find(d => d.diseaseId === profile.id);
|
|
546
|
+
if (!diseaseState)
|
|
547
|
+
return empty;
|
|
548
|
+
// Isolate this disease so stepDiseaseForEntity only processes it
|
|
549
|
+
const others = entity.activeDiseases.filter(d => d.diseaseId !== profile.id);
|
|
550
|
+
entity.activeDiseases = [diseaseState];
|
|
551
|
+
const result = stepDiseaseForEntity(entity, delta_s, worldSeed, tick);
|
|
552
|
+
// Reattach other diseases (preserving any mutations stepDiseaseForEntity made)
|
|
553
|
+
const remaining = entity.activeDiseases ?? [];
|
|
554
|
+
entity.activeDiseases = [...others, ...remaining];
|
|
555
|
+
if (entity.activeDiseases.length === 0) {
|
|
556
|
+
delete entity.activeDiseases;
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
}
|
package/dist/src/sim/entity.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { LimbState } from "./limb.js";
|
|
|
17
17
|
import type { ExtendedSenses } from "./sensory-extended.js";
|
|
18
18
|
import type { ActiveIngestedToxin, CumulativeExposureRecord, WithdrawalState } from "./systemic-toxicology.js";
|
|
19
19
|
import type { TraumaState } from "./wound-aging.js";
|
|
20
|
-
import type { DiseaseState, ImmunityRecord } from "./disease.js";
|
|
20
|
+
import type { DiseaseState, ImmunityRecord, VaccinationRecord } from "./disease.js";
|
|
21
21
|
import type { AgeState } from "./aging.js";
|
|
22
22
|
import type { SleepState } from "./sleep.js";
|
|
23
23
|
import type { MountState } from "./mount.js";
|
|
@@ -219,6 +219,11 @@ export interface Entity {
|
|
|
219
219
|
* Consumed by `src/sim/disease.ts`.
|
|
220
220
|
*/
|
|
221
221
|
immunity?: ImmunityRecord[];
|
|
222
|
+
/**
|
|
223
|
+
* @subsystem(disease/seir) Phase 73: vaccination records granting partial-efficacy protection.
|
|
224
|
+
* Consumed by `computeTransmissionRisk` in `src/sim/disease.ts`.
|
|
225
|
+
*/
|
|
226
|
+
vaccinations?: VaccinationRecord[];
|
|
222
227
|
/**
|
|
223
228
|
* @subsystem(aging) Elapsed life-seconds for aging calculations.
|
|
224
229
|
* Consumed by `src/sim/aging.ts`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { LineageRegistry } from "./kinship.js";
|
|
2
|
+
import type { RenownRegistry } from "./renown.js";
|
|
3
|
+
import type { Polity } from "./polity.js";
|
|
4
|
+
import type { Q } from "./units.js";
|
|
5
|
+
/** How the succession contest is resolved. */
|
|
6
|
+
export type SuccessionRuleType = "primogeniture" | "renown_based" | "election";
|
|
7
|
+
export interface SuccessionRule {
|
|
8
|
+
type: SuccessionRuleType;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum kinship degree to search for candidates (default `MAX_KINSHIP_DEPTH`).
|
|
11
|
+
* Closer kin are always preferred at equal claim strength.
|
|
12
|
+
*/
|
|
13
|
+
maxDegree?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A single candidate in a succession contest.
|
|
17
|
+
*/
|
|
18
|
+
export interface SuccessionCandidate {
|
|
19
|
+
entityId: number;
|
|
20
|
+
/** Degree of kinship to the deceased (1 = child/parent, 2 = grandchild, etc.). */
|
|
21
|
+
kinshipDegree: number;
|
|
22
|
+
/** Candidate's own renown from Phase 75. */
|
|
23
|
+
renown_Q: Q;
|
|
24
|
+
/**
|
|
25
|
+
* Ancestor-inherited renown bonus from Phase 76.
|
|
26
|
+
* Provides legitimacy even for candidates who have not yet distinguished themselves.
|
|
27
|
+
*/
|
|
28
|
+
inheritedRenown_Q: Q;
|
|
29
|
+
/**
|
|
30
|
+
* Final composite claim strength [0, SCALE.Q].
|
|
31
|
+
* For primogeniture: 0 for all except first-born (which gets SCALE.Q).
|
|
32
|
+
* For renown_based / election: weighted combination of renown + inherited.
|
|
33
|
+
*/
|
|
34
|
+
claimStrength_Q: Q;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Outcome of a succession resolution.
|
|
38
|
+
*/
|
|
39
|
+
export interface SuccessionResult {
|
|
40
|
+
/** Winning heir, or `null` if no eligible candidates were found. */
|
|
41
|
+
heirId: number | null;
|
|
42
|
+
/** All candidates evaluated, sorted by claimStrength_Q descending. */
|
|
43
|
+
candidates: SuccessionCandidate[];
|
|
44
|
+
rule: SuccessionRuleType;
|
|
45
|
+
/**
|
|
46
|
+
* Signed stability delta [−SCALE.Q, +SCALE.Q].
|
|
47
|
+
* Negative = destabilising (distant heir, no heir, contested succession).
|
|
48
|
+
* Positive = stabilising (clear close-kin heir).
|
|
49
|
+
*/
|
|
50
|
+
stabilityImpact_Q: Q;
|
|
51
|
+
}
|
|
52
|
+
/** Weight of own renown vs. inherited renown when computing claim strength. */
|
|
53
|
+
export declare const CLAIM_OWN_RENOWN_WEIGHT_Q: Q;
|
|
54
|
+
export declare const CLAIM_INHERITED_RENOWN_WEIGHT_Q: Q;
|
|
55
|
+
/** Stability penalty per extra degree of kinship beyond 1 (direct child/parent). */
|
|
56
|
+
export declare const STABILITY_DISTANT_HEIR_Q: Q;
|
|
57
|
+
/** Stability penalty when no heir is found. */
|
|
58
|
+
export declare const STABILITY_NO_HEIR_Q: Q;
|
|
59
|
+
/** Additional penalty when the top two candidates are within this band. */
|
|
60
|
+
export declare const CONTESTED_THRESHOLD_Q: Q;
|
|
61
|
+
export declare const STABILITY_CONTESTED_Q: Q;
|
|
62
|
+
/** Stability bonus when a direct child inherits with no contest. */
|
|
63
|
+
export declare const STABILITY_CLEAN_SUCCESSION_Q: Q;
|
|
64
|
+
/**
|
|
65
|
+
* Find all kin of `deceasedId` up to `maxDegree` and compute their claim strength.
|
|
66
|
+
* Candidates are sorted by claimStrength_Q descending, then kinshipDegree ascending.
|
|
67
|
+
*/
|
|
68
|
+
export declare function findSuccessionCandidates(lineage: LineageRegistry, deceasedId: number, renownRegistry: RenownRegistry, maxDegree?: number): SuccessionCandidate[];
|
|
69
|
+
/**
|
|
70
|
+
* Resolve succession after `deceasedId` dies.
|
|
71
|
+
*
|
|
72
|
+
* @param lineage Kinship registry (Phase 76).
|
|
73
|
+
* @param deceasedId The entity whose position must be inherited.
|
|
74
|
+
* @param renownRegistry Renown registry (Phase 75).
|
|
75
|
+
* @param rule Succession rule to apply.
|
|
76
|
+
* @param worldSeed For deterministic election roll.
|
|
77
|
+
* @param tick Current simulation tick.
|
|
78
|
+
*/
|
|
79
|
+
export declare function resolveSuccession(lineage: LineageRegistry, deceasedId: number, renownRegistry: RenownRegistry, rule: SuccessionRule, worldSeed: number, tick: number): SuccessionResult;
|
|
80
|
+
/**
|
|
81
|
+
* Apply a succession result to a polity.
|
|
82
|
+
* Adjusts `stabilityQ` by `result.stabilityImpact_Q`.
|
|
83
|
+
* Does NOT change the ruler field (Polity has no rulerId); callers update faction
|
|
84
|
+
* leadership separately if needed.
|
|
85
|
+
*/
|
|
86
|
+
export declare function applySuccessionToPolity(polity: Polity, result: SuccessionResult): void;
|
|
@@ -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": [
|
|
@@ -116,10 +132,12 @@
|
|
|
116
132
|
"example:species": "node dist/examples/quickstart-species.js",
|
|
117
133
|
"generate-fixtures": "node dist/tools/generate-fixtures.js",
|
|
118
134
|
"generate-zoo": "node dist/tools/generate-zoo.js",
|
|
135
|
+
"generate-playground": "node dist/tools/generate-playground.js",
|
|
119
136
|
"generate-map": "node dist/tools/generate-map.js",
|
|
120
137
|
"world-server": "node dist/tools/world-server.js",
|
|
121
138
|
"persistent-world": "node dist/tools/persistent-world.js",
|
|
122
139
|
"replication-server": "node dist/tools/replication-server.js",
|
|
140
|
+
"agent-server": "node dist/tools/agent-server.js",
|
|
123
141
|
"benchmark-check": "node dist/tools/benchmark-check.js",
|
|
124
142
|
"benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
|
|
125
143
|
"benchmark-check:update": "node dist/tools/benchmark-check.js --update-baseline",
|