@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.
@@ -0,0 +1,82 @@
1
+ import type { ChronicleEntry, Chronicle, ChronicleEventType } from "./chronicle.js";
2
+ import type { NarrativeContext } from "./narrative-prose.js";
3
+ import type { Q } from "./units.js";
4
+ /** Lightweight reference to a significant chronicle event in an entity's legend. */
5
+ export interface LegendEntry {
6
+ /** Unique chronicle entry id (reference to original ChronicleEntry). */
7
+ entryId: string;
8
+ tick: number;
9
+ eventType: ChronicleEventType;
10
+ /** Original significance score 0–100 from ChronicleEntry. */
11
+ significance: number;
12
+ }
13
+ /** Accumulated reputation record for a single entity. */
14
+ export interface RenownRecord {
15
+ entityId: number;
16
+ /** Fame from positive deeds, [0, SCALE.Q]. */
17
+ renown_Q: Q;
18
+ /** Infamy from negative deeds, [0, SCALE.Q]. */
19
+ infamy_Q: Q;
20
+ /** All legend entries attributed to this entity, in insertion order. */
21
+ entries: LegendEntry[];
22
+ }
23
+ /** Flat registry of RenownRecords, one per entity. */
24
+ export interface RenownRegistry {
25
+ records: Map<number, RenownRecord>;
26
+ }
27
+ /** Human-readable fame tier, derived from `renown_Q`. */
28
+ export type RenownLabel = "unknown" | "noted" | "known" | "renowned" | "legendary" | "mythic";
29
+ /** Human-readable infamy tier, derived from `infamy_Q`. */
30
+ export type InfamyLabel = "innocent" | "suspect" | "notorious" | "infamous" | "reviled" | "condemned";
31
+ /**
32
+ * Per-event renown/infamy contribution rate.
33
+ * A maximum-significance (100) event contributes `RENOWN_SCALE_Q` to the score.
34
+ * Scales linearly with `entry.significance`: `delta = round(sig * RENOWN_SCALE_Q / 100)`.
35
+ */
36
+ export declare const RENOWN_SCALE_Q: Q;
37
+ export declare function createRenownRegistry(): RenownRegistry;
38
+ /**
39
+ * Return the RenownRecord for `entityId`, creating a zero-initialised record
40
+ * if one does not yet exist.
41
+ */
42
+ export declare function getRenownRecord(registry: RenownRegistry, entityId: number): RenownRecord;
43
+ /**
44
+ * Scan `chronicle` for entries involving `entityId` and update the entity's
45
+ * RenownRecord accordingly.
46
+ *
47
+ * Idempotent: already-seen entryIds (tracked by `record.entries`) are skipped,
48
+ * so this can be called on every game tick without double-counting.
49
+ *
50
+ * @param minSignificance Only entries at or above this score are considered (default 50).
51
+ */
52
+ export declare function updateRenownFromChronicle(registry: RenownRegistry, chronicle: Chronicle, entityId: number, minSignificance?: number): void;
53
+ /** Map `renown_Q` to a human-readable fame tier. */
54
+ export declare function getRenownLabel(renown_Q: Q): RenownLabel;
55
+ /** Map `infamy_Q` to a human-readable infamy tier. */
56
+ export declare function getInfamyLabel(infamy_Q: Q): InfamyLabel;
57
+ /**
58
+ * Compute a signed faction standing delta based on entity renown and infamy.
59
+ *
60
+ * `allianceBias` controls how the faction weighs the two axes:
61
+ * - q(1.0) = fully heroic faction: rewards renown, punishes infamy
62
+ * - q(0.0) = fully criminal faction: rewards infamy, punishes renown
63
+ * - q(0.5) = neutral: both axes equally weighted, they cancel
64
+ *
65
+ * Result is clamped to [-SCALE.Q, SCALE.Q]. The caller is responsible for
66
+ * adding this delta to the current standing and re-clamping to [0, SCALE.Q].
67
+ */
68
+ export declare function deriveFactionStandingAdjustment(renown_Q: Q, infamy_Q: Q, allianceBias?: Q): Q;
69
+ /**
70
+ * Return up to `n` legend entries sorted by significance (descending).
71
+ * Ties are broken by tick (descending — more recent wins).
72
+ */
73
+ export declare function getTopLegendEntries(record: RenownRecord, n: number): LegendEntry[];
74
+ /**
75
+ * Render an entity's top legend entries as tone-aware prose strings.
76
+ *
77
+ * Requires `entryMap` — a Map of `entryId → ChronicleEntry` for full entry data.
78
+ * Missing entries fall back to a bracketed placeholder.
79
+ *
80
+ * @param maxEntries Maximum number of entries to render (default 5).
81
+ */
82
+ export declare function renderLegendWithTone(record: RenownRecord, entryMap: Map<string, ChronicleEntry>, ctx: NarrativeContext, maxEntries?: number): string[];
@@ -0,0 +1,175 @@
1
+ // src/renown.ts — Phase 75: Entity Renown & Legend Registry
2
+ //
3
+ // Tracks per-entity reputation derived from Chronicle events (Phase 45).
4
+ // Provides renown/infamy scores, faction standing adjustments, and prose
5
+ // legend rendering via the Phase 74 tone system.
6
+ //
7
+ // Design:
8
+ // - Additive, history-scoped: renown grows only from *new* chronicle entries.
9
+ // - Pure computation — no kernel changes, no new Entity fields.
10
+ // - Two orthogonal axes: renown (positive deeds) and infamy (negative deeds).
11
+ // - Faction standing adjustment: `deriveFactionStandingAdjustment` applies a
12
+ // signed bias so heroic factions reward renown and outlaw factions reward infamy.
13
+ import { renderEntryWithTone } from "./narrative-prose.js";
14
+ import { q, SCALE, clampQ } from "./units.js";
15
+ // ── Event classification ───────────────────────────────────────────────────────
16
+ /** Event types that add to `renown_Q` when the entity is the primary actor. */
17
+ const RENOWN_EVENT_TYPES = new Set([
18
+ "legendary_deed",
19
+ "quest_completed",
20
+ "combat_victory",
21
+ "masterwork_crafted",
22
+ "rank_promotion",
23
+ "settlement_founded",
24
+ "first_contact",
25
+ ]);
26
+ /** Event types that add to `infamy_Q` when the entity is the primary actor. */
27
+ const INFAMY_EVENT_TYPES = new Set([
28
+ "relationship_betrayal",
29
+ "settlement_raided",
30
+ "settlement_destroyed",
31
+ "quest_failed",
32
+ ]);
33
+ // ── Constants ─────────────────────────────────────────────────────────────────
34
+ /**
35
+ * Per-event renown/infamy contribution rate.
36
+ * A maximum-significance (100) event contributes `RENOWN_SCALE_Q` to the score.
37
+ * Scales linearly with `entry.significance`: `delta = round(sig * RENOWN_SCALE_Q / 100)`.
38
+ */
39
+ export const RENOWN_SCALE_Q = q(0.10);
40
+ // ── Factory ───────────────────────────────────────────────────────────────────
41
+ export function createRenownRegistry() {
42
+ return { records: new Map() };
43
+ }
44
+ // ── Record access ─────────────────────────────────────────────────────────────
45
+ /**
46
+ * Return the RenownRecord for `entityId`, creating a zero-initialised record
47
+ * if one does not yet exist.
48
+ */
49
+ export function getRenownRecord(registry, entityId) {
50
+ let record = registry.records.get(entityId);
51
+ if (!record) {
52
+ record = { entityId, renown_Q: 0, infamy_Q: 0, entries: [] };
53
+ registry.records.set(entityId, record);
54
+ }
55
+ return record;
56
+ }
57
+ // ── Chronicle integration ─────────────────────────────────────────────────────
58
+ /**
59
+ * Scan `chronicle` for entries involving `entityId` and update the entity's
60
+ * RenownRecord accordingly.
61
+ *
62
+ * Idempotent: already-seen entryIds (tracked by `record.entries`) are skipped,
63
+ * so this can be called on every game tick without double-counting.
64
+ *
65
+ * @param minSignificance Only entries at or above this score are considered (default 50).
66
+ */
67
+ export function updateRenownFromChronicle(registry, chronicle, entityId, minSignificance = 50) {
68
+ const record = getRenownRecord(registry, entityId);
69
+ const known = new Set(record.entries.map(e => e.entryId));
70
+ for (const entry of chronicle.entries) {
71
+ if (entry.significance < minSignificance)
72
+ continue;
73
+ if (!entry.actors.includes(entityId))
74
+ continue;
75
+ if (known.has(entry.entryId))
76
+ continue;
77
+ // Record the entry
78
+ record.entries.push({
79
+ entryId: entry.entryId,
80
+ tick: entry.tick,
81
+ eventType: entry.eventType,
82
+ significance: entry.significance,
83
+ });
84
+ known.add(entry.entryId);
85
+ // Compute contribution: scale linearly with significance
86
+ const delta = Math.round(entry.significance * RENOWN_SCALE_Q / 100);
87
+ if (RENOWN_EVENT_TYPES.has(entry.eventType)) {
88
+ record.renown_Q = clampQ(record.renown_Q + delta, 0, SCALE.Q);
89
+ }
90
+ else if (INFAMY_EVENT_TYPES.has(entry.eventType)) {
91
+ record.infamy_Q = clampQ(record.infamy_Q + delta, 0, SCALE.Q);
92
+ }
93
+ // Neutral event types (births, settlements, rank promotions as target) count
94
+ // in `entries` but do not move either axis.
95
+ }
96
+ }
97
+ // ── Label functions ───────────────────────────────────────────────────────────
98
+ /** Map `renown_Q` to a human-readable fame tier. */
99
+ export function getRenownLabel(renown_Q) {
100
+ if (renown_Q >= q(0.90))
101
+ return "mythic";
102
+ if (renown_Q >= q(0.70))
103
+ return "legendary";
104
+ if (renown_Q >= q(0.50))
105
+ return "renowned";
106
+ if (renown_Q >= q(0.30))
107
+ return "known";
108
+ if (renown_Q >= q(0.10))
109
+ return "noted";
110
+ return "unknown";
111
+ }
112
+ /** Map `infamy_Q` to a human-readable infamy tier. */
113
+ export function getInfamyLabel(infamy_Q) {
114
+ if (infamy_Q >= q(0.90))
115
+ return "condemned";
116
+ if (infamy_Q >= q(0.70))
117
+ return "reviled";
118
+ if (infamy_Q >= q(0.50))
119
+ return "infamous";
120
+ if (infamy_Q >= q(0.30))
121
+ return "notorious";
122
+ if (infamy_Q >= q(0.10))
123
+ return "suspect";
124
+ return "innocent";
125
+ }
126
+ // ── Faction standing adjustment ───────────────────────────────────────────────
127
+ /**
128
+ * Compute a signed faction standing delta based on entity renown and infamy.
129
+ *
130
+ * `allianceBias` controls how the faction weighs the two axes:
131
+ * - q(1.0) = fully heroic faction: rewards renown, punishes infamy
132
+ * - q(0.0) = fully criminal faction: rewards infamy, punishes renown
133
+ * - q(0.5) = neutral: both axes equally weighted, they cancel
134
+ *
135
+ * Result is clamped to [-SCALE.Q, SCALE.Q]. The caller is responsible for
136
+ * adding this delta to the current standing and re-clamping to [0, SCALE.Q].
137
+ */
138
+ export function deriveFactionStandingAdjustment(renown_Q, infamy_Q, allianceBias = q(0.5)) {
139
+ // Heroic contribution: renown boosts, infamy hurts, scaled by allianceBias
140
+ const heroicBias = allianceBias;
141
+ const criminalBias = (SCALE.Q - allianceBias);
142
+ const renownBoost = Math.round(renown_Q * heroicBias / SCALE.Q);
143
+ const infamyBoost = Math.round(infamy_Q * criminalBias / SCALE.Q);
144
+ const renownPenalty = Math.round(renown_Q * criminalBias / SCALE.Q);
145
+ const infamyPenalty = Math.round(infamy_Q * heroicBias / SCALE.Q);
146
+ const net = (renownBoost + infamyBoost) - (renownPenalty + infamyPenalty);
147
+ return clampQ(net, -SCALE.Q, SCALE.Q);
148
+ }
149
+ // ── Legend entry queries ──────────────────────────────────────────────────────
150
+ /**
151
+ * Return up to `n` legend entries sorted by significance (descending).
152
+ * Ties are broken by tick (descending — more recent wins).
153
+ */
154
+ export function getTopLegendEntries(record, n) {
155
+ return [...record.entries]
156
+ .sort((a, b) => b.significance - a.significance || b.tick - a.tick)
157
+ .slice(0, n);
158
+ }
159
+ // ── Prose rendering ───────────────────────────────────────────────────────────
160
+ /**
161
+ * Render an entity's top legend entries as tone-aware prose strings.
162
+ *
163
+ * Requires `entryMap` — a Map of `entryId → ChronicleEntry` for full entry data.
164
+ * Missing entries fall back to a bracketed placeholder.
165
+ *
166
+ * @param maxEntries Maximum number of entries to render (default 5).
167
+ */
168
+ export function renderLegendWithTone(record, entryMap, ctx, maxEntries = 5) {
169
+ return getTopLegendEntries(record, maxEntries).map(le => {
170
+ const entry = entryMap.get(le.entryId);
171
+ if (!entry)
172
+ return `[${le.eventType}] (tick ${le.tick})`;
173
+ return renderEntryWithTone(entry, ctx);
174
+ });
175
+ }
@@ -27,6 +27,42 @@ export interface DiseaseProfile {
27
27
  * -1 = permanent; 0 = no immunity (can be reinfected immediately).
28
28
  */
29
29
  immunityDuration_s: number;
30
+ /**
31
+ * Phase 73: opt-in to SEIR compartment tracking via `stepSEIR`.
32
+ * No effect on `stepDiseaseForEntity` — backward-compatible.
33
+ */
34
+ useSeir?: boolean;
35
+ }
36
+ /**
37
+ * Vaccination record granting partial-efficacy protection.
38
+ * Stored on `entity.vaccinations?`.
39
+ */
40
+ export interface VaccinationRecord {
41
+ diseaseId: string;
42
+ /** Fraction of transmission risk blocked [Q]. q(0.95) = 95 % efficacy. */
43
+ efficacy_Q: Q;
44
+ /** Number of doses received. Informational; efficacy reflects total dose schedule. */
45
+ doseCount: number;
46
+ }
47
+ /** Non-pharmaceutical intervention type. */
48
+ export type NPIType = "quarantine" | "mask_mandate";
49
+ /** An active NPI for a polity. */
50
+ export interface NPIRecord {
51
+ polityId: string;
52
+ npiType: NPIType;
53
+ }
54
+ /**
55
+ * Registry of active NPIs per polity.
56
+ * Key format: `"${polityId}:${npiType}"`.
57
+ */
58
+ export type NPIRegistry = Map<string, NPIRecord>;
59
+ /** Options for the extended `computeTransmissionRisk`. */
60
+ export interface TransmissionOptions {
61
+ /**
62
+ * Mask mandate NPI active for this pair's polity.
63
+ * Reduces airborne transmission by `NPI_MASK_REDUCTION_Q` (60 %).
64
+ */
65
+ maskMandate?: boolean;
30
66
  }
31
67
  /** One active disease infection on an entity. */
32
68
  export interface DiseaseState {
@@ -67,10 +103,38 @@ export interface SpreadResult {
67
103
  }
68
104
  /** Maximum distance for contact/vector/waterborne transmission [SCALE.m]. */
69
105
  export declare const CONTACT_RANGE_Sm = 20000;
106
+ /**
107
+ * Airborne transmission reduction from mask mandate NPI [Q].
108
+ * Risk is multiplied by (SCALE.Q − NPI_MASK_REDUCTION_Q) / SCALE.Q → ×0.40 remaining.
109
+ */
110
+ export declare const NPI_MASK_REDUCTION_Q: number;
111
+ /**
112
+ * Daily contacts-per-entity estimate for `computeR0`.
113
+ * Community-setting assumption; capped by actual population size.
114
+ */
115
+ export declare const DAILY_CONTACTS_ESTIMATE = 15;
70
116
  /** All disease profiles indexed by id. */
71
117
  export declare const DISEASE_PROFILES: DiseaseProfile[];
72
118
  /** Look up a disease profile by id. Returns undefined for unknown ids. */
73
119
  export declare function getDiseaseProfile(id: string): DiseaseProfile | undefined;
120
+ /**
121
+ * Register a custom disease profile so it can be used with
122
+ * `exposeToDisease`, `spreadDisease`, and `stepDiseaseForEntity`.
123
+ *
124
+ * Does not modify `DISEASE_PROFILES`. Use this to add `MEASLES` or other
125
+ * Phase 73 / host-defined profiles to the lookup map.
126
+ */
127
+ export declare function registerDiseaseProfile(profile: DiseaseProfile): void;
128
+ /**
129
+ * Measles — highly contagious SEIR airborne disease.
130
+ *
131
+ * R0 ≈ 12–18 in populations of 15+ (DAILY_CONTACTS_ESTIMATE × 14 days × baseRate).
132
+ * Use with `registerDiseaseProfile(MEASLES)` before calling `exposeToDisease`.
133
+ *
134
+ * Validation target: epidemic curve peaks days 10–20, burns out by day 60,
135
+ * matching standard SIR model output within ±15 % for 95 % susceptible population.
136
+ */
137
+ export declare const MEASLES: DiseaseProfile;
74
138
  /**
75
139
  * Attempt to expose an entity to a disease.
76
140
  *
@@ -116,12 +180,18 @@ export declare function stepDiseaseForEntity(entity: Entity, delta_s: number, wo
116
180
  * Returns q(0) if the carrier has no symptomatic instance of this disease,
117
181
  * or if target already has immunity / active infection for this disease.
118
182
  *
183
+ * **Phase 73 extensions (backward-compatible):**
184
+ * - If `target.age` is set, applies age-stratified susceptibility multiplier.
185
+ * - If `target.vaccinations` contains a record for this disease, reduces risk by efficacy.
186
+ * - If `options.maskMandate` is true and disease is airborne, reduces risk by `NPI_MASK_REDUCTION_Q`.
187
+ *
119
188
  * @param carrier The potentially infectious entity.
120
189
  * @param target The potentially susceptible entity.
121
190
  * @param dist_Sm Distance between them [SCALE.m].
122
191
  * @param disease The disease profile to evaluate.
192
+ * @param options Phase 73 optional NPI modifiers.
123
193
  */
124
- export declare function computeTransmissionRisk(carrier: Entity, target: Entity, dist_Sm: number, disease: DiseaseProfile): Q;
194
+ export declare function computeTransmissionRisk(carrier: Entity, target: Entity, dist_Sm: number, disease: DiseaseProfile, options?: TransmissionOptions): Q;
125
195
  /**
126
196
  * Attempt to spread disease across a set of nearby entity pairs.
127
197
  *
@@ -139,3 +209,76 @@ export declare function computeTransmissionRisk(carrier: Entity, target: Entity,
139
209
  * @returns Number of new exposures created.
140
210
  */
141
211
  export declare function spreadDisease(entityMap: Map<number, Entity>, pairs: NearbyPair[], worldSeed: number, tick: number): SpreadResult;
212
+ /**
213
+ * Age-stratified susceptibility multiplier [Q].
214
+ *
215
+ * Returns a value that may exceed SCALE.Q (increased susceptibility) or fall
216
+ * below it (relative protection). Applied in `computeTransmissionRisk` when
217
+ * `target.age` is set.
218
+ *
219
+ * | Age range | Multiplier | Notes |
220
+ * |-----------|-----------|-------------------------------|
221
+ * | 0–4 yrs | ×1.30 | High infant susceptibility |
222
+ * | 5–14 yrs | ×0.80 | Children — lower risk |
223
+ * | 15–59 yrs | ×1.00 | Adult baseline |
224
+ * | 60–74 yrs | ×1.20 | Early elderly |
225
+ * | 75 + yrs | ×1.50 | Late elderly / ancient |
226
+ */
227
+ export declare function ageSusceptibility_Q(ageYears: number): Q;
228
+ /**
229
+ * Add or update a vaccination record on an entity.
230
+ *
231
+ * If the entity already has a record for this disease, updates `efficacy_Q`
232
+ * and increments `doseCount` (booster model). Otherwise creates a new record.
233
+ *
234
+ * @param entity Target entity to vaccinate.
235
+ * @param diseaseId Disease being vaccinated against.
236
+ * @param efficacy_Q Protection level [Q]; q(0.95) = 95 % efficacy.
237
+ */
238
+ export declare function vaccinate(entity: Entity, diseaseId: string, efficacy_Q: Q): void;
239
+ /**
240
+ * Activate an NPI for a polity.
241
+ *
242
+ * `"mask_mandate"` — reduces airborne transmission in `computeTransmissionRisk`
243
+ * by `NPI_MASK_REDUCTION_Q` when the caller passes `options.maskMandate = true`.
244
+ *
245
+ * `"quarantine"` — recorded in the registry; the host is responsible for halving
246
+ * the contact-range pairs passed to `spreadDisease` (spatial filtering).
247
+ */
248
+ export declare function applyNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): void;
249
+ /** Remove an NPI from a polity's registry entry. */
250
+ export declare function removeNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): void;
251
+ /** Returns true if the specified NPI is currently active for the polity. */
252
+ export declare function hasNPI(npiRegistry: NPIRegistry, npiType: NPIType, polityId: string): boolean;
253
+ /**
254
+ * Estimate the basic reproductive number R0 for a disease profile.
255
+ *
256
+ * Formula: R0 = beta × D × c
257
+ * - beta = baseTransmissionRate_Q / SCALE.Q (per-contact daily probability)
258
+ * - D = symptomaticDuration_s / 86400 (infectious period in days)
259
+ * - c = min(DAILY_CONTACTS_ESTIMATE, entityMap.size − 1) (daily contacts)
260
+ *
261
+ * Used for validation — not a simulation path value.
262
+ *
263
+ * @param profile Disease profile to evaluate.
264
+ * @param entityMap Population map (size determines contact estimate).
265
+ * @returns Estimated R0 (float; not fixed-point).
266
+ */
267
+ export declare function computeR0(profile: DiseaseProfile, entityMap: Map<number, Entity>): number;
268
+ /**
269
+ * Advance a single SEIR-enabled disease on an entity by `delta_s` seconds.
270
+ *
271
+ * Functionally equivalent to `stepDiseaseForEntity` for this profile only —
272
+ * isolates the target disease so other active diseases are not advanced.
273
+ * Backward-compatible: calls through to the Phase 56 step function.
274
+ *
275
+ * Intended for use with `profile.useSeir === true` diseases, but works with
276
+ * any profile registered via `registerDiseaseProfile`.
277
+ *
278
+ * @param entity Entity to advance.
279
+ * @param delta_s Elapsed seconds.
280
+ * @param profile Disease profile to process.
281
+ * @param worldSeed World seed for deterministic mortality roll.
282
+ * @param tick Current tick for deterministic mortality roll.
283
+ */
284
+ export declare function stepSEIR(entity: Entity, delta_s: number, profile: DiseaseProfile, worldSeed: number, tick: number): EntityDiseaseResult;