@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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// src/kinship.ts — Phase 76: Kinship & Lineage
|
|
2
|
+
//
|
|
3
|
+
// Tracks parent-child-partner links for entities and provides ancestry queries,
|
|
4
|
+
// degree-of-kinship computation, and inherited renown from the Phase 75 system.
|
|
5
|
+
//
|
|
6
|
+
// Design:
|
|
7
|
+
// - Separate from Entity — the lineage graph lives in a `LineageRegistry`,
|
|
8
|
+
// not in entity fields, so no kernel changes are required.
|
|
9
|
+
// - `computeKinshipDegree` uses BFS over the undirected family graph (parents,
|
|
10
|
+
// children, partners counted at degree 1).
|
|
11
|
+
// - `inheritedRenown` sums ancestor renown_Q with geometric depth-decay so that
|
|
12
|
+
// a mythic grandparent grants a modest but real reputation bonus.
|
|
13
|
+
// - Deterministic: no Math.random(); only pure data queries.
|
|
14
|
+
import { q, SCALE, clampQ } from "./units.js";
|
|
15
|
+
/** Maximum BFS depth for kinship searches; beyond this entities are "unrelated". */
|
|
16
|
+
export const MAX_KINSHIP_DEPTH = 4;
|
|
17
|
+
/**
|
|
18
|
+
* Depth-decay factor for inherited renown.
|
|
19
|
+
* Each generation reduces the renown contribution by this fraction:
|
|
20
|
+
* depth 1 (parent) → q(0.50) × parent renown
|
|
21
|
+
* depth 2 (grandparent) → q(0.25) × grandparent renown
|
|
22
|
+
*/
|
|
23
|
+
export const RENOWN_DEPTH_DECAY_Q = q(0.50);
|
|
24
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
25
|
+
export function createLineageRegistry() {
|
|
26
|
+
return { nodes: new Map() };
|
|
27
|
+
}
|
|
28
|
+
// ── Node access ───────────────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Return the `LineageNode` for `entityId`, creating a root node (no parents,
|
|
31
|
+
* no children, no partners) if one does not yet exist.
|
|
32
|
+
*/
|
|
33
|
+
export function getLineageNode(registry, entityId) {
|
|
34
|
+
let node = registry.nodes.get(entityId);
|
|
35
|
+
if (!node) {
|
|
36
|
+
node = { entityId, parentIds: [], childIds: [], partnerIds: [] };
|
|
37
|
+
registry.nodes.set(entityId, node);
|
|
38
|
+
}
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
// ── Mutation helpers ──────────────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Register a birth: create a node for `childId` and link it to up to two parents.
|
|
44
|
+
* Parent nodes are created if they do not already exist.
|
|
45
|
+
* No-op if `childId` already has a node (idempotent).
|
|
46
|
+
*/
|
|
47
|
+
export function recordBirth(registry, childId, parentAId, parentBId) {
|
|
48
|
+
// Ensure child node exists with the given parents
|
|
49
|
+
const existing = registry.nodes.get(childId);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
const parentIds = parentBId != null
|
|
52
|
+
? [parentAId, parentBId]
|
|
53
|
+
: [parentAId];
|
|
54
|
+
registry.nodes.set(childId, { entityId: childId, parentIds, childIds: [], partnerIds: [] });
|
|
55
|
+
}
|
|
56
|
+
// Ensure parent nodes exist and include childId
|
|
57
|
+
const nodeA = getLineageNode(registry, parentAId);
|
|
58
|
+
if (!nodeA.childIds.includes(childId))
|
|
59
|
+
nodeA.childIds.push(childId);
|
|
60
|
+
if (parentBId != null) {
|
|
61
|
+
const nodeB = getLineageNode(registry, parentBId);
|
|
62
|
+
if (!nodeB.childIds.includes(childId))
|
|
63
|
+
nodeB.childIds.push(childId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Record a partnership between two entities.
|
|
68
|
+
* Partners are considered degree-1 kin (immediate).
|
|
69
|
+
* Idempotent: duplicate calls are safe.
|
|
70
|
+
*/
|
|
71
|
+
export function recordPartnership(registry, entityAId, entityBId) {
|
|
72
|
+
const nodeA = getLineageNode(registry, entityAId);
|
|
73
|
+
const nodeB = getLineageNode(registry, entityBId);
|
|
74
|
+
if (!nodeA.partnerIds.includes(entityBId))
|
|
75
|
+
nodeA.partnerIds.push(entityBId);
|
|
76
|
+
if (!nodeB.partnerIds.includes(entityAId))
|
|
77
|
+
nodeB.partnerIds.push(entityAId);
|
|
78
|
+
}
|
|
79
|
+
// ── Family queries ────────────────────────────────────────────────────────────
|
|
80
|
+
/** Return the parent IDs of `entityId` (0–2 elements). */
|
|
81
|
+
export function getParents(registry, entityId) {
|
|
82
|
+
return registry.nodes.get(entityId)?.parentIds ?? [];
|
|
83
|
+
}
|
|
84
|
+
/** Return the child IDs of `entityId`. */
|
|
85
|
+
export function getChildren(registry, entityId) {
|
|
86
|
+
return registry.nodes.get(entityId)?.childIds ?? [];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Return the sibling IDs of `entityId` — entities that share at least one parent,
|
|
90
|
+
* excluding `entityId` itself.
|
|
91
|
+
*/
|
|
92
|
+
export function getSiblings(registry, entityId) {
|
|
93
|
+
const parents = getParents(registry, entityId);
|
|
94
|
+
const result = new Set();
|
|
95
|
+
for (const pid of parents) {
|
|
96
|
+
for (const sibId of getChildren(registry, pid)) {
|
|
97
|
+
if (sibId !== entityId)
|
|
98
|
+
result.add(sibId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...result];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Return all ancestors of `entityId` within `maxDepth` generations.
|
|
105
|
+
* Uses BFS upward through parent links only.
|
|
106
|
+
*/
|
|
107
|
+
export function findAncestors(registry, entityId, maxDepth = MAX_KINSHIP_DEPTH) {
|
|
108
|
+
const ancestors = new Set();
|
|
109
|
+
const queue = [{ id: entityId, depth: 0 }];
|
|
110
|
+
while (queue.length > 0) {
|
|
111
|
+
const item = queue.shift();
|
|
112
|
+
if (item.depth >= maxDepth)
|
|
113
|
+
continue;
|
|
114
|
+
for (const pid of getParents(registry, item.id)) {
|
|
115
|
+
if (!ancestors.has(pid)) {
|
|
116
|
+
ancestors.add(pid);
|
|
117
|
+
queue.push({ id: pid, depth: item.depth + 1 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return ancestors;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Compute the degree of kinship between two entities via BFS on the undirected
|
|
125
|
+
* family graph (parents, children, and partners are all degree-1 neighbours).
|
|
126
|
+
*
|
|
127
|
+
* Returns:
|
|
128
|
+
* - `0` if `entityA === entityB`
|
|
129
|
+
* - `1`–`MAX_KINSHIP_DEPTH` for kin within range
|
|
130
|
+
* - `null` if no path exists within `MAX_KINSHIP_DEPTH`
|
|
131
|
+
*/
|
|
132
|
+
export function computeKinshipDegree(registry, entityA, entityB) {
|
|
133
|
+
if (entityA === entityB)
|
|
134
|
+
return 0;
|
|
135
|
+
const visited = new Set([entityA]);
|
|
136
|
+
const queue = [{ id: entityA, depth: 0 }];
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const item = queue.shift();
|
|
139
|
+
if (item.depth >= MAX_KINSHIP_DEPTH)
|
|
140
|
+
continue;
|
|
141
|
+
const node = registry.nodes.get(item.id);
|
|
142
|
+
if (!node)
|
|
143
|
+
continue;
|
|
144
|
+
// BFS over parents + children + partners (undirected)
|
|
145
|
+
const neighbours = [
|
|
146
|
+
...node.parentIds,
|
|
147
|
+
...node.childIds,
|
|
148
|
+
...node.partnerIds,
|
|
149
|
+
];
|
|
150
|
+
for (const nbr of neighbours) {
|
|
151
|
+
if (nbr === entityB)
|
|
152
|
+
return item.depth + 1;
|
|
153
|
+
if (!visited.has(nbr)) {
|
|
154
|
+
visited.add(nbr);
|
|
155
|
+
queue.push({ id: nbr, depth: item.depth + 1 });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null; // unrelated within MAX_KINSHIP_DEPTH
|
|
160
|
+
}
|
|
161
|
+
/** Whether two entities are kin within `maxDegree` (default `MAX_KINSHIP_DEPTH`). */
|
|
162
|
+
export function isKin(registry, entityA, entityB, maxDegree = MAX_KINSHIP_DEPTH) {
|
|
163
|
+
const degree = computeKinshipDegree(registry, entityA, entityB);
|
|
164
|
+
return degree !== null && degree <= maxDegree;
|
|
165
|
+
}
|
|
166
|
+
// ── Label ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Map a numeric kinship degree (or `null`) to a `KinshipLabel`.
|
|
169
|
+
*
|
|
170
|
+
* @param degree Result of `computeKinshipDegree`; pass `null` for unrelated.
|
|
171
|
+
*/
|
|
172
|
+
export function getKinshipLabel(degree) {
|
|
173
|
+
if (degree === null)
|
|
174
|
+
return "unrelated";
|
|
175
|
+
if (degree === 0)
|
|
176
|
+
return "self";
|
|
177
|
+
if (degree === 1)
|
|
178
|
+
return "immediate";
|
|
179
|
+
if (degree === 2)
|
|
180
|
+
return "close";
|
|
181
|
+
if (degree === 3)
|
|
182
|
+
return "extended";
|
|
183
|
+
if (degree <= MAX_KINSHIP_DEPTH)
|
|
184
|
+
return "distant";
|
|
185
|
+
return "unrelated";
|
|
186
|
+
}
|
|
187
|
+
// ── Inherited renown ──────────────────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Compute the renown bonus an entity inherits from their ancestors.
|
|
190
|
+
*
|
|
191
|
+
* For each ancestor at depth d, contribution = `ancestor.renown_Q × decay^d`
|
|
192
|
+
* where `decay = RENOWN_DEPTH_DECAY_Q / SCALE.Q` (default 0.5 per generation).
|
|
193
|
+
* The sum is clamped to `[0, SCALE.Q]`.
|
|
194
|
+
*
|
|
195
|
+
* Entities with no renown records or no ancestors return 0.
|
|
196
|
+
*
|
|
197
|
+
* @param registry Lineage registry.
|
|
198
|
+
* @param entityId Entity whose ancestors are being summed.
|
|
199
|
+
* @param renownRegistry Phase 75 renown registry.
|
|
200
|
+
* @param maxDepth How many generations to look back (default 3).
|
|
201
|
+
*/
|
|
202
|
+
export function computeInheritedRenown(lineage, entityId, renownRegistry, maxDepth = 3) {
|
|
203
|
+
// BFS upward through parent links only (not children/partners)
|
|
204
|
+
let total = 0;
|
|
205
|
+
const visited = new Set([entityId]);
|
|
206
|
+
const queue = [];
|
|
207
|
+
for (const pid of getParents(lineage, entityId)) {
|
|
208
|
+
if (!visited.has(pid)) {
|
|
209
|
+
visited.add(pid);
|
|
210
|
+
queue.push({ id: pid, depth: 1 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
while (queue.length > 0) {
|
|
214
|
+
const item = queue.shift();
|
|
215
|
+
if (item.depth > maxDepth)
|
|
216
|
+
continue;
|
|
217
|
+
const record = renownRegistry.records.get(item.id);
|
|
218
|
+
if (record && record.renown_Q > 0) {
|
|
219
|
+
// decay^depth applied via repeated integer multiplication
|
|
220
|
+
let contribution = record.renown_Q;
|
|
221
|
+
for (let i = 0; i < item.depth; i++) {
|
|
222
|
+
contribution = Math.round(contribution * RENOWN_DEPTH_DECAY_Q / SCALE.Q);
|
|
223
|
+
}
|
|
224
|
+
total += contribution;
|
|
225
|
+
}
|
|
226
|
+
for (const pid of getParents(lineage, item.id)) {
|
|
227
|
+
if (!visited.has(pid)) {
|
|
228
|
+
visited.add(pid);
|
|
229
|
+
queue.push({ id: pid, depth: item.depth + 1 });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return clampQ(total, 0, SCALE.Q);
|
|
234
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ChronicleEntry } from "./chronicle.js";
|
|
2
|
+
import type { Chronicle } from "./chronicle.js";
|
|
3
|
+
import type { CultureProfile } from "./culture.js";
|
|
4
|
+
import type { MythArchetype, Myth } from "./mythology.js";
|
|
5
|
+
/**
|
|
6
|
+
* Voice tone used when rendering chronicle entries.
|
|
7
|
+
* Derived from the dominant cultural values of the originating polity.
|
|
8
|
+
*/
|
|
9
|
+
export type ProseTone = "neutral" | "heroic" | "tragic" | "martial" | "spiritual" | "mercantile";
|
|
10
|
+
/**
|
|
11
|
+
* Context bundle for a tone-aware rendering pass.
|
|
12
|
+
* Created by `createNarrativeContext` and passed to render functions.
|
|
13
|
+
*/
|
|
14
|
+
export interface NarrativeContext {
|
|
15
|
+
/** Map of entity id → display name. Missing ids fall back to "entity {id}". */
|
|
16
|
+
entityNames: Map<number, string>;
|
|
17
|
+
/** Prose tone for this rendering pass. */
|
|
18
|
+
tone: ProseTone;
|
|
19
|
+
/**
|
|
20
|
+
* Optional myth-archetype closing phrase appended to each rendered sentence.
|
|
21
|
+
* Produced by `mythArchetypeFrame(archetype)`.
|
|
22
|
+
*/
|
|
23
|
+
mythFrame?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Returns a closing phrase appropriate to the myth archetype. */
|
|
26
|
+
export declare function mythArchetypeFrame(archetype: MythArchetype): string;
|
|
27
|
+
/**
|
|
28
|
+
* Derive the best matching `ProseTone` from a `CultureProfile`.
|
|
29
|
+
*
|
|
30
|
+
* Uses the top-ranked cultural value; falls back to `"neutral"` for values
|
|
31
|
+
* without a direct tone mapping (hospitality, hierarchy, innovation, etc.).
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveNarrativeTone(culture: CultureProfile): ProseTone;
|
|
34
|
+
/**
|
|
35
|
+
* Create a `NarrativeContext` for a rendering pass.
|
|
36
|
+
*
|
|
37
|
+
* @param entityNames Map of entity id → display name (numeric ids are looked up here).
|
|
38
|
+
* @param culture Optional culture profile — used to derive tone automatically.
|
|
39
|
+
* @param myth Optional myth — used to append archetype-framing suffix.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createNarrativeContext(entityNames: Map<number, string>, culture?: CultureProfile, myth?: Myth): NarrativeContext;
|
|
42
|
+
/**
|
|
43
|
+
* Render a single `ChronicleEntry` with cultural-tone awareness.
|
|
44
|
+
*
|
|
45
|
+
* Selects the tone variant for `entry.eventType`; falls back to `"neutral"` if
|
|
46
|
+
* the requested tone has no specific variant. Appends `ctx.mythFrame` if set.
|
|
47
|
+
*
|
|
48
|
+
* Does NOT mutate `entry.rendered` — call `entry.rendered = renderEntryWithTone(...)`
|
|
49
|
+
* manually if caching is desired.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderEntryWithTone(entry: ChronicleEntry, ctx: NarrativeContext): string;
|
|
52
|
+
/**
|
|
53
|
+
* Render all entries in a `Chronicle` above `minSignificance` (default 50),
|
|
54
|
+
* returned in chronological order.
|
|
55
|
+
*
|
|
56
|
+
* Uses `renderEntryWithTone` for each entry.
|
|
57
|
+
*/
|
|
58
|
+
export declare function renderChronicleWithTone(chronicle: Chronicle, ctx: NarrativeContext, minSignificance?: number): string[];
|
|
@@ -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
|
+
}
|