@its-not-rocket-science/ananke 0.1.17 → 0.1.23

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,313 @@
1
+ // src/narrative-prose.ts — Phase 74: Simulation Trace → Narrative Prose
2
+ //
3
+ // Cultural-tone rendering layer over Phase 45 ChronicleEntry events.
4
+ // Extends `narrative-render.ts` with tone-varied templates driven by
5
+ // `CultureProfile` (Phase 71) and `MythArchetype` (Phase 66).
6
+ //
7
+ // Design:
8
+ // - 6 prose tones: neutral | heroic | tragic | martial | spiritual | mercantile
9
+ // - Tone-varied sentence variants for all 19 ChronicleEventTypes
10
+ // - `deriveNarrativeTone(culture)` maps dominant cultural values → tone
11
+ // - `{name}` / `{target}` / `{variable}` substitution from entry.variables
12
+ // + entity name map (replaces numeric actor IDs with names)
13
+ // - `mythArchetypeFrame(archetype)` appends a myth-aware closing phrase
14
+ // - Fully deterministic — no Math.random()
15
+ import { getDominantValues } from "./culture.js";
16
+ // ── Tone → ValueId mapping ────────────────────────────────────────────────────
17
+ /** Maps cultural value ids to the prose tone they imply. */
18
+ const VALUE_TONE_MAP = {
19
+ martial_virtue: "martial",
20
+ spiritual_devotion: "spiritual",
21
+ commerce: "mercantile",
22
+ honour: "heroic",
23
+ fatalism: "tragic",
24
+ // honour + kin_loyalty lean heroic; others default to neutral
25
+ };
26
+ const TEMPLATES = {
27
+ entity_death: {
28
+ neutral: "{name} died{cause_str}{location_str}.",
29
+ heroic: "{name} fell in glorious battle, their deeds forever etched in memory.",
30
+ tragic: "The world grew darker as {name} breathed their last{cause_str}.",
31
+ martial: "{name} was cut down{cause_str} — a warrior's end.",
32
+ spiritual: "The gods finally claimed {name}{cause_str}, as had long been ordained.",
33
+ mercantile: "{name}'s final accounts were settled; the ledgers closed for the last time.",
34
+ },
35
+ entity_birth: {
36
+ neutral: "{entityName} was born{parents_str}{settlement_str}.",
37
+ heroic: "Into the world came {entityName}, destined for greatness{settlement_str}.",
38
+ tragic: "{entityName} entered a world that would not be kind to them{settlement_str}.",
39
+ spiritual: "The heavens marked the arrival of {entityName}{settlement_str}.",
40
+ mercantile: "A new soul joined the household{settlement_str}: {entityName}.",
41
+ martial: "{entityName} was born{settlement_str} — another sword arm for the generations.",
42
+ },
43
+ relationship_formed: {
44
+ neutral: "{actorA} and {actorB} formed a {bondType}{context_str}.",
45
+ heroic: "{actorA} and {actorB} swore a bond of {bondType} — an alliance the ages would remember.",
46
+ tragic: "{actorA} and {actorB} formed a {bondType}{context_str}, not knowing what lay ahead.",
47
+ martial: "{actorA} and {actorB} forged a pact of {bondType}{context_str}.",
48
+ spiritual: "Fate bound {actorA} and {actorB} together in {bondType}{context_str}.",
49
+ mercantile: "{actorA} and {actorB} entered a {bondType} arrangement{context_str}.",
50
+ },
51
+ relationship_broken: {
52
+ neutral: "{actorA} and {actorB}'s {bondType} ended{reason_str}.",
53
+ heroic: "The bond of {bondType} between {actorA} and {actorB} shattered{reason_str}.",
54
+ tragic: "What once bound {actorA} and {actorB} crumbled to dust{reason_str}.",
55
+ martial: "{actorA} and {actorB} severed their {bondType}{reason_str}.",
56
+ spiritual: "The ties between {actorA} and {actorB} were cut by forces beyond them{reason_str}.",
57
+ mercantile: "The {bondType} between {actorA} and {actorB} was dissolved{reason_str}.",
58
+ },
59
+ relationship_betrayal: {
60
+ neutral: "{betrayer} betrayed {victim}{context_str}, destroying their trust forever.",
61
+ heroic: "{betrayer} shamed themselves by betraying {victim}{context_str} — a dishonour never forgotten.",
62
+ tragic: "{betrayer} betrayed {victim}{context_str}, and so the tragedy unfolded as it had to.",
63
+ martial: "{betrayer} struck {victim} in the back{context_str}. Such treachery is not forgotten on the field.",
64
+ spiritual: "{betrayer}'s betrayal of {victim}{context_str} brought down divine displeasure upon them.",
65
+ mercantile: "{betrayer} broke faith with {victim}{context_str}, voiding every contract between them.",
66
+ },
67
+ quest_completed: {
68
+ neutral: "{actorName} completed the quest \"{questName}\"{reward_str}.",
69
+ heroic: "{actorName} returned triumphant from \"{questName}\"{reward_str}, glory well earned.",
70
+ tragic: "{actorName} completed \"{questName}\"{reward_str}, though the cost had been great.",
71
+ martial: "{actorName} accomplished \"{questName}\" through force and discipline{reward_str}.",
72
+ spiritual: "Providence guided {actorName} to complete \"{questName}\"{reward_str}.",
73
+ mercantile: "{actorName} fulfilled the terms of \"{questName}\"{reward_str}.",
74
+ },
75
+ quest_failed: {
76
+ neutral: "{actorName} failed the quest \"{questName}\"{reason_str}.",
77
+ heroic: "{actorName} fell short of completing \"{questName}\"{reason_str}, but the attempt was not without honour.",
78
+ tragic: "\"{questName}\" defeated {actorName}{reason_str} — some burdens are too great to bear.",
79
+ martial: "{actorName} was bested in \"{questName}\"{reason_str}.",
80
+ spiritual: "The gods turned their face from {actorName} in \"{questName}\"{reason_str}.",
81
+ mercantile: "{actorName} failed to deliver on \"{questName}\"{reason_str}.",
82
+ },
83
+ quest_accepted: {
84
+ neutral: "{actorName} accepted the quest \"{questName}\"{giver_str}.",
85
+ heroic: "{actorName} took up the challenge of \"{questName}\"{giver_str}, determined to prevail.",
86
+ tragic: "{actorName} accepted \"{questName}\"{giver_str}, setting foot on a road with no easy return.",
87
+ martial: "{actorName} took the mission \"{questName}\"{giver_str}.",
88
+ spiritual: "{actorName} heeded the call of \"{questName}\"{giver_str}.",
89
+ mercantile: "{actorName} contracted for \"{questName}\"{giver_str}.",
90
+ },
91
+ settlement_founded: {
92
+ neutral: "The settlement of {settlementName} was founded{founder_str}.",
93
+ heroic: "{founder} raised the banner over {settlementName} — a new stronghold in a dangerous land.",
94
+ tragic: "The settlement of {settlementName} was founded{founder_str}, its future unknown.",
95
+ martial: "{founder_str_cap} planted the flag at {settlementName} and began to fortify.",
96
+ spiritual: "The gods blessed the founding of {settlementName}{founder_str}.",
97
+ mercantile: "{settlementName} was established{founder_str} as a hub of exchange.",
98
+ },
99
+ settlement_upgraded: {
100
+ neutral: "{settlementName} grew from {oldTier} to {newTier}.",
101
+ heroic: "{settlementName} rose to become a mighty {newTier}, a symbol of what courage can build.",
102
+ tragic: "{settlementName} expanded to {newTier} — growth that would bring its own dangers.",
103
+ martial: "{settlementName} was fortified to {newTier} against all threats.",
104
+ spiritual: "The gods smiled on {settlementName} as it advanced to {newTier}.",
105
+ mercantile: "{settlementName} prospered, growing from {oldTier} to {newTier}.",
106
+ },
107
+ settlement_raided: {
108
+ neutral: "{settlementName} was raided by {raiders}{damage_str}.",
109
+ heroic: "{settlementName} withstood a raid by {raiders}, the defenders holding firm despite {damage_str}.",
110
+ tragic: "{raiders} descended on {settlementName}, leaving only ruin{damage_str}.",
111
+ martial: "{raiders} struck {settlementName}{damage_str} — a bold, brutal assault.",
112
+ spiritual: "Dark forces in the guise of {raiders} visited ruin upon {settlementName}{damage_str}.",
113
+ mercantile: "{raiders} raided {settlementName}, disrupting trade and costing heavily{damage_str}.",
114
+ },
115
+ settlement_destroyed: {
116
+ neutral: "{settlementName} fell to {destroyer}, ending its {age} history.",
117
+ heroic: "{settlementName} fell to {destroyer} after {age} — but its people's deeds will not be forgotten.",
118
+ tragic: "And so {settlementName} fell, its {age} of history extinguished by {destroyer}.",
119
+ martial: "{destroyer} razed {settlementName} after {age} — the victors' right of conquest.",
120
+ spiritual: "The gods withdrew their protection; {settlementName} fell to {destroyer} after {age}.",
121
+ mercantile: "{settlementName} ceased to exist after {age}, its markets silenced by {destroyer}.",
122
+ },
123
+ facility_completed: {
124
+ neutral: "A new {facilityType} was completed in {settlementName}.",
125
+ heroic: "The {facilityType} of {settlementName} stood complete — testament to collective will.",
126
+ martial: "The {facilityType} of {settlementName} was finished, strengthening the garrison.",
127
+ spiritual: "The sacred {facilityType} in {settlementName} was consecrated at last.",
128
+ mercantile: "The {facilityType} in {settlementName} opened for business.",
129
+ tragic: "The {facilityType} of {settlementName} was finally completed, though at great cost.",
130
+ },
131
+ masterwork_crafted: {
132
+ neutral: "{crafterName} forged {itemName}, a masterwork of exceptional quality.",
133
+ heroic: "{crafterName}'s {itemName} was a masterwork worthy of legend.",
134
+ tragic: "{crafterName} poured a lifetime's sorrow into {itemName} — the finest work they would ever do.",
135
+ martial: "{crafterName} hammered {itemName} into existence — a weapon that would turn battles.",
136
+ spiritual: "The gods guided {crafterName}'s hands as they crafted {itemName}.",
137
+ mercantile: "{crafterName}'s {itemName} commanded the highest price ever seen in the markets.",
138
+ },
139
+ first_contact: {
140
+ neutral: "{factionA} made first contact with {factionB}{location_str}.",
141
+ heroic: "{factionA} met {factionB} for the first time{location_str} — a meeting that would shape history.",
142
+ tragic: "{factionA} encountered {factionB}{location_str}; neither would be unchanged.",
143
+ martial: "{factionA} and {factionB} faced each other across the boundary{location_str}.",
144
+ spiritual: "Fate decreed that {factionA} and {factionB} would meet{location_str}.",
145
+ mercantile: "{factionA} opened relations with {factionB}{location_str}, eyeing mutual profit.",
146
+ },
147
+ combat_victory: {
148
+ neutral: "{victor} defeated {defeated}{method_str}.",
149
+ heroic: "{victor} stood triumphant over {defeated}{method_str} — glory well earned.",
150
+ tragic: "{victor} defeated {defeated}{method_str}, though victory came at a price.",
151
+ martial: "{victor} crushed {defeated}{method_str} through strength and discipline.",
152
+ spiritual: "Providence guided {victor}'s hand against {defeated}{method_str}.",
153
+ mercantile: "{victor} secured a decisive advantage over {defeated}{method_str}.",
154
+ },
155
+ combat_defeat: {
156
+ neutral: "{defeated} was overcome by {victor}{location_str}.",
157
+ heroic: "{defeated} fell before {victor}{location_str}, but fought with honour to the last.",
158
+ tragic: "{defeated} was overwhelmed by {victor}{location_str} — the outcome never in doubt.",
159
+ martial: "{victor} broke {defeated}{location_str} — outmatched from the first blow.",
160
+ spiritual: "The gods turned from {defeated} in {location_str}; {victor} prevailed.",
161
+ mercantile: "{defeated} lost ground to {victor}{location_str}, the balance of power shifting.",
162
+ },
163
+ rank_promotion: {
164
+ neutral: "{actorName} rose to the rank of {newRank}{faction_str}.",
165
+ heroic: "{actorName} was honoured with the rank of {newRank}{faction_str}, their deeds recognised at last.",
166
+ tragic: "{actorName} was elevated to {newRank}{faction_str} — responsibility they had never sought.",
167
+ martial: "{actorName} earned the rank of {newRank}{faction_str} through proven valour.",
168
+ spiritual: "The order elevated {actorName} to {newRank}{faction_str}, guided by higher purpose.",
169
+ mercantile: "{actorName} was promoted to {newRank}{faction_str}, gaining both status and obligation.",
170
+ },
171
+ legendary_deed: {
172
+ neutral: "{hero} performed a legendary deed: {deedDescription}",
173
+ heroic: "{hero} carved their name into history: {deedDescription}",
174
+ tragic: "{hero} achieved the impossible through {deedDescription} — at what cost, only time would tell.",
175
+ martial: "{hero} proved their supremacy: {deedDescription}",
176
+ spiritual: "The heavens bore witness as {hero} accomplished {deedDescription}",
177
+ mercantile: "{hero}'s deed — {deedDescription} — would be spoken of in every market for years.",
178
+ },
179
+ tragic_event: {
180
+ neutral: "Tragedy struck when {description}",
181
+ heroic: "Even heroes could not prevent the tragedy: {description}",
182
+ tragic: "As it was always destined to be: {description}",
183
+ martial: "The hard truth of war bore down: {description}",
184
+ spiritual: "The gods decreed it so: {description}",
185
+ mercantile: "No ledger could account for such loss: {description}",
186
+ },
187
+ };
188
+ // ── Myth archetype frames ─────────────────────────────────────────────────────
189
+ /** Returns a closing phrase appropriate to the myth archetype. */
190
+ export function mythArchetypeFrame(archetype) {
191
+ switch (archetype) {
192
+ case "hero": return "as heroes are destined to do";
193
+ case "monster": return "fulfilling the dark prophecy";
194
+ case "trickster": return "through cunning that none could have predicted";
195
+ case "great_plague": return "as the ancient sickness had foretold";
196
+ case "divine_wrath": return "by the judgment of wrathful gods";
197
+ case "golden_age": return "in an age that songs shall long remember";
198
+ }
199
+ }
200
+ // ── Tone derivation ───────────────────────────────────────────────────────────
201
+ /**
202
+ * Derive the best matching `ProseTone` from a `CultureProfile`.
203
+ *
204
+ * Uses the top-ranked cultural value; falls back to `"neutral"` for values
205
+ * without a direct tone mapping (hospitality, hierarchy, innovation, etc.).
206
+ */
207
+ export function deriveNarrativeTone(culture) {
208
+ const dominant = getDominantValues(culture, 1);
209
+ const topValue = dominant[0]?.id;
210
+ if (!topValue)
211
+ return "neutral";
212
+ return VALUE_TONE_MAP[topValue] ?? "neutral";
213
+ }
214
+ // ── Context builder ───────────────────────────────────────────────────────────
215
+ /**
216
+ * Create a `NarrativeContext` for a rendering pass.
217
+ *
218
+ * @param entityNames Map of entity id → display name (numeric ids are looked up here).
219
+ * @param culture Optional culture profile — used to derive tone automatically.
220
+ * @param myth Optional myth — used to append archetype-framing suffix.
221
+ */
222
+ export function createNarrativeContext(entityNames, culture, myth) {
223
+ const tone = culture ? deriveNarrativeTone(culture) : "neutral";
224
+ const ctx = { entityNames, tone };
225
+ if (myth)
226
+ ctx.mythFrame = mythArchetypeFrame(myth.archetype);
227
+ return ctx;
228
+ }
229
+ // ── Template substitution ─────────────────────────────────────────────────────
230
+ /**
231
+ * Replace `{varName}` placeholders in a template string.
232
+ *
233
+ * Resolution order:
234
+ * 1. `{name}` → display name of `actors[0]` from `ctx.entityNames`
235
+ * 2. `{target}` → display name of `actors[1]` from `ctx.entityNames`
236
+ * 3. All keys in `entry.variables`
237
+ * 4. Computed helpers: `{cause_str}`, `{location_str}`, etc.
238
+ * 5. Any remaining `{varName}` → removed (empty string)
239
+ */
240
+ function applyTemplate(template, entry, ctx) {
241
+ const vars = entry.variables;
242
+ const actors = entry.actors;
243
+ const names = ctx.entityNames;
244
+ const actorName = (id) => names.get(id) ?? `entity ${id}`;
245
+ const primaryName = actors[0] != null ? actorName(actors[0]) : (String(vars["actorName"] ?? "Unknown"));
246
+ const targetName = actors[1] != null ? actorName(actors[1]) : (String(vars["target"] ?? "Unknown"));
247
+ // Computed helper strings (empty when the variable is absent)
248
+ const helpers = {
249
+ cause_str: vars["cause"] ? ` from ${vars["cause"]}` : "",
250
+ location_str: vars["location"] ? ` in ${vars["location"]}` : "",
251
+ parents_str: vars["parents"] ? ` to ${vars["parents"]}` : "",
252
+ settlement_str: vars["settlement"] ? ` in ${vars["settlement"]}` : "",
253
+ context_str: vars["context"] ? ` after ${vars["context"]}` : "",
254
+ reason_str: vars["reason"] ? ` due to ${vars["reason"]}` : "",
255
+ reward_str: vars["reward"] ? ` and received ${vars["reward"]}` : "",
256
+ giver_str: vars["giver"] ? ` from ${vars["giver"]}` : "",
257
+ founder_str: vars["founder"] ? ` by ${vars["founder"]}` : "",
258
+ founder_str_cap: vars["founder"] ? String(vars["founder"]) : "Settlers",
259
+ damage_str: vars["damage"] ? `, suffering ${vars["damage"]}` : "",
260
+ method_str: vars["method"] ? ` by ${vars["method"]}` : "",
261
+ faction_str: vars["faction"] ? ` in the ${vars["faction"]}` : "",
262
+ };
263
+ let result = template;
264
+ // Named actor substitutions
265
+ result = result.replace(/\{name\}/g, primaryName);
266
+ result = result.replace(/\{target\}/g, targetName);
267
+ // Computed helpers
268
+ for (const [k, v] of Object.entries(helpers)) {
269
+ result = result.replace(new RegExp(`\\{${k}\\}`, "g"), v);
270
+ }
271
+ // Raw variables from entry
272
+ for (const [k, v] of Object.entries(vars)) {
273
+ result = result.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
274
+ }
275
+ // Remove any unresolved placeholders
276
+ result = result.replace(/\{[^}]+\}/g, "");
277
+ return result;
278
+ }
279
+ // ── Public render API ─────────────────────────────────────────────────────────
280
+ /**
281
+ * Render a single `ChronicleEntry` with cultural-tone awareness.
282
+ *
283
+ * Selects the tone variant for `entry.eventType`; falls back to `"neutral"` if
284
+ * the requested tone has no specific variant. Appends `ctx.mythFrame` if set.
285
+ *
286
+ * Does NOT mutate `entry.rendered` — call `entry.rendered = renderEntryWithTone(...)`
287
+ * manually if caching is desired.
288
+ */
289
+ export function renderEntryWithTone(entry, ctx) {
290
+ const toneVariants = TEMPLATES[entry.eventType];
291
+ const template = toneVariants?.[ctx.tone] ?? toneVariants?.["neutral"] ?? "";
292
+ if (!template) {
293
+ return `[${entry.eventType}] (tick ${entry.tick})`;
294
+ }
295
+ let prose = applyTemplate(template, entry, ctx);
296
+ if (ctx.mythFrame) {
297
+ // Append myth frame, replacing terminal period if present
298
+ prose = prose.replace(/\.$/, "") + `, ${ctx.mythFrame}.`;
299
+ }
300
+ return prose;
301
+ }
302
+ /**
303
+ * Render all entries in a `Chronicle` above `minSignificance` (default 50),
304
+ * returned in chronological order.
305
+ *
306
+ * Uses `renderEntryWithTone` for each entry.
307
+ */
308
+ export function renderChronicleWithTone(chronicle, ctx, minSignificance = 50) {
309
+ return chronicle.entries
310
+ .filter(e => e.significance >= minSignificance)
311
+ .sort((a, b) => a.tick - b.tick)
312
+ .map(e => renderEntryWithTone(e, ctx));
313
+ }
@@ -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
+ }
@@ -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;