holonovel 2026.8.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,1080 @@
1
+ // State Manager — Novels, Roster, NPCs, World Model, Snapshots, Audit, Badge Gating, Persistence
2
+ // REQ-040, REQ-041, REQ-043, REQ-055, REQ-065, REQ-073, REQ-074, REQ-075,
3
+ // REQ-076, REQ-077, REQ-079, REQ-081, REQ-082, REQ-083, REQ-084,
4
+ // REQ-088, REQ-089, REQ-090, REQ-091, REQ-092, REQ-093, REQ-095, REQ-096,
5
+ // REQ-097, REQ-116, REQ-195, REQ-198, REQ-199, REQ-218, REQ-219,
6
+ // REQ-232, REQ-233, REQ-234, REQ-236, REQ-238, REQ-239, REQ-240,
7
+ // REQ-241, REQ-242, REQ-246, REQ-256, REQ-257, REQ-258, REQ-285, REQ-289
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as crypto from "crypto";
11
+ import { createEmptyWorldModel } from "../world/model.js";
12
+ const SPEC_VERSION = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
13
+ function computeSourceHash() {
14
+ try {
15
+ const srcDir = new URL("../../src", import.meta.url).pathname;
16
+ const h = crypto.createHash("sha256");
17
+ const entries = fs.readdirSync(srcDir, { recursive: true }).sort();
18
+ for (const entry of entries) {
19
+ const p = path.join(srcDir, entry);
20
+ try {
21
+ if (fs.statSync(p).isFile()) {
22
+ h.update(entry);
23
+ h.update(fs.readFileSync(p));
24
+ }
25
+ }
26
+ catch { /* skip */ }
27
+ }
28
+ return h.digest("hex");
29
+ }
30
+ catch {
31
+ return "unavailable";
32
+ }
33
+ }
34
+ export function normalizeBadge(raw) {
35
+ if (raw === "player" || raw === "game_master" || raw === "observer")
36
+ return raw;
37
+ return "none"; // null, undefined, "none", and unknown values resolve to the Editor badge
38
+ }
39
+ export function migrateNovelData(data) {
40
+ const out = { ...data };
41
+ out.badge = normalizeBadge(data.badge ?? data.hat);
42
+ if (data.lore) {
43
+ out.lore = Object.fromEntries(Object.entries(data.lore).map(([k, v]) => [
44
+ k, { ...v, badge_scope: v.badge_scope ?? v.hat_scope ?? "game_master" },
45
+ ]));
46
+ }
47
+ if (data.secrets) {
48
+ out.secrets = data.secrets.map((s) => ({ ...s, badge_scope: s.badge_scope ?? s.hat_scope ?? "game_master" }));
49
+ }
50
+ if (data.notes) {
51
+ out.notes = data.notes.map((n) => ({ ...n, badge_scope: n.badge_scope ?? n.hat_scope ?? "game_master" }));
52
+ }
53
+ return out;
54
+ }
55
+ export const DEFAULT_AUTONOMY = {
56
+ level: "mechanical_prompt",
57
+ confirmation: "prompt",
58
+ safety: "safe",
59
+ creativity: "standard",
60
+ confirmed_safety_tiers: ["safe"],
61
+ };
62
+ export function normalizeAutonomy(raw) {
63
+ const r = (raw ?? {});
64
+ const level = ["full", "mechanical_prompt", "manual"].includes(r.level) ? r.level : DEFAULT_AUTONOMY.level;
65
+ const confirmation = ["auto", "confirm", "prompt"].includes(r.confirmation) ? r.confirmation : DEFAULT_AUTONOMY.confirmation;
66
+ const safety = ["safe", "moderate", "hardcore"].includes(r.safety) ? r.safety : DEFAULT_AUTONOMY.safety;
67
+ const creativity = ["predictable", "standard", "chaotic"].includes(r.creativity) ? r.creativity : DEFAULT_AUTONOMY.creativity;
68
+ const confirmed = Array.isArray(r.confirmed_safety_tiers) ? r.confirmed_safety_tiers : [safety];
69
+ return { level, confirmation, safety, creativity, confirmed_safety_tiers: confirmed };
70
+ }
71
+ // REQ-306 / §7.6 — TTRPG_AUTONOMY launch preset seeds new-Novel defaults as a
72
+ // comma-separated `level,confirmation,safety,creativity` list.
73
+ export function autonomyDefaultsFromEnv() {
74
+ const raw = process.env.TTRPG_AUTONOMY;
75
+ if (!raw)
76
+ return { ...DEFAULT_AUTONOMY, confirmed_safety_tiers: ["safe"] };
77
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
78
+ const seeded = {};
79
+ if (parts[0])
80
+ seeded.level = parts[0];
81
+ if (parts[1])
82
+ seeded.confirmation = parts[1];
83
+ if (parts[2])
84
+ seeded.safety = parts[2];
85
+ if (parts[3])
86
+ seeded.creativity = parts[3];
87
+ return normalizeAutonomy({ ...DEFAULT_AUTONOMY, ...seeded });
88
+ }
89
+ export const DIFFICULTY_TRACKS = {
90
+ troublesome: 12, dangerous: 8, formidable: 4, extreme: 2, epic: 1,
91
+ };
92
+ const VALID_SCENE_TYPES = ["combat", "social", "exploration", "neutral"];
93
+ function normalizeSceneType(raw) {
94
+ if (!raw)
95
+ return ["neutral"];
96
+ if (Array.isArray(raw)) {
97
+ return raw.filter((t) => VALID_SCENE_TYPES.includes(t));
98
+ }
99
+ if (typeof raw === "string" && VALID_SCENE_TYPES.includes(raw)) {
100
+ return [raw];
101
+ }
102
+ return ["neutral"];
103
+ }
104
+ function worldToJSON(world) {
105
+ return {
106
+ rooms: Object.fromEntries(world.rooms),
107
+ things: Object.fromEntries(world.things),
108
+ };
109
+ }
110
+ function worldFromJSON(data) {
111
+ const world = createEmptyWorldModel();
112
+ if (data?.rooms) {
113
+ for (const [key, room] of Object.entries(data.rooms)) {
114
+ const r = room;
115
+ world.rooms.set(key, {
116
+ name: r.name,
117
+ description: r.description || "",
118
+ exits: new Map(Object.entries(r.exits || {})),
119
+ doorRefs: new Map(Object.entries(r.doorRefs || {})),
120
+ annotations: r.annotations || {},
121
+ });
122
+ }
123
+ }
124
+ if (data?.things) {
125
+ for (const [key, thing] of Object.entries(data.things)) {
126
+ const t = thing;
127
+ world.things.set(key, {
128
+ name: t.name,
129
+ description: t.description || "",
130
+ kind: t.kind || "thing",
131
+ location: t.location ?? null,
132
+ locationType: t.locationType ?? null,
133
+ portable: t.portable ?? true,
134
+ openable: t.openable ?? false,
135
+ open: t.open ?? false,
136
+ lockable: t.lockable ?? false,
137
+ locked: t.locked ?? false,
138
+ lit: t.lit ?? false,
139
+ capacity: t.capacity,
140
+ doorConnects: t.doorConnects,
141
+ switchable: t.switchable ?? false,
142
+ switched_on: t.switched_on ?? false,
143
+ enterable: t.enterable ?? false,
144
+ vehicleInterior: t.vehicleInterior,
145
+ vehiclePassengers: t.vehiclePassengers ?? [],
146
+ wearable: t.wearable ?? false,
147
+ worn_by: t.worn_by ?? null,
148
+ readable: t.readable ?? false,
149
+ read_text: t.read_text ?? null,
150
+ edible: t.edible ?? false,
151
+ drinkable: t.drinkable ?? false,
152
+ climbable: t.climbable ?? false,
153
+ transparent: t.transparent ?? false,
154
+ annotations: t.annotations || {},
155
+ });
156
+ }
157
+ }
158
+ return world;
159
+ }
160
+ // ── State Manager ──────────────────────────────────────────────────
161
+ export class StateManager {
162
+ novels = new Map();
163
+ roster = new Map();
164
+ activeNovelId = null;
165
+ buildFingerprint;
166
+ enriched = false;
167
+ enrichmentManifest = null;
168
+ maxLoreTokens = null;
169
+ serverNotes = new Map();
170
+ codex = new Map();
171
+ npcCounter = 0;
172
+ entityCounter = 0;
173
+ stateDir;
174
+ constructor(stateDir) {
175
+ this.stateDir = stateDir;
176
+ this.loadServerNotes();
177
+ this.loadCodex();
178
+ this.buildFingerprint = {
179
+ specVersion: SPEC_VERSION,
180
+ specRepoUrl: "https://github.com/anomalyco/Holonovel",
181
+ specHash: "unknown",
182
+ sourceHash: computeSourceHash(),
183
+ rulesetHash: "ruleset-free",
184
+ buildTimestamp: new Date().toISOString(),
185
+ };
186
+ const budgetRaw = process.env.TTRPG_MAX_LORE_TOKENS;
187
+ if (budgetRaw) {
188
+ const budget = parseInt(budgetRaw, 10);
189
+ if (!isNaN(budget) && budget > 0)
190
+ this.maxLoreTokens = budget;
191
+ }
192
+ }
193
+ get activeNovel() {
194
+ return this.activeNovelId ? this.novels.get(this.activeNovelId) : undefined;
195
+ }
196
+ // ── Badge Gating ────────────────────────────────────────────────
197
+ requireGM(badge) {
198
+ if (badge === "observer")
199
+ throw new Error("[FORBIDDEN] Observer mode is read-only. Corrective action: switch badges with set_badge to interact.");
200
+ if (badge === "player")
201
+ throw new Error("[FORBIDDEN] This tool is Game Master only. Corrective action: use set_badge to switch.");
202
+ }
203
+ requirePlayer(badge) {
204
+ if (badge === "observer")
205
+ throw new Error("[FORBIDDEN] Observer mode is read-only. Corrective action: switch badges with set_badge to interact.");
206
+ if (badge === "game_master")
207
+ throw new Error("[FORBIDDEN] This tool is Player only. Corrective action: use set_badge to switch.");
208
+ }
209
+ requireNotObserver(badge) {
210
+ if (badge === "observer")
211
+ throw new Error("[FORBIDDEN] Observer mode is read-only. Corrective action: switch badges with set_badge to interact.");
212
+ }
213
+ requireNovel() {
214
+ const novel = this.activeNovel;
215
+ if (!novel)
216
+ throw new Error("[STATE_CONFLICT] No active Novel. Corrective action: create_novel or resume_novel first.");
217
+ return novel;
218
+ }
219
+ // ── Entity Management ─────────────────────────────────────────
220
+ getActiveEntity() {
221
+ const novel = this.activeNovel;
222
+ if (!novel || !novel.active_entity_id)
223
+ return undefined;
224
+ return novel.entities.get(novel.active_entity_id);
225
+ }
226
+ resolveEntity(entityId) {
227
+ const novel = this.requireNovel();
228
+ const id = entityId ?? novel.active_entity_id;
229
+ if (!id)
230
+ throw new Error("[INVALID_INPUT] No entity_id provided and no active entity set. Corrective action: pass entity_id or call set_active_entity first.");
231
+ const entity = novel.entities.get(id);
232
+ if (!entity)
233
+ throw new Error(`[NOT_FOUND] Entity '${id}' not found. Corrective action: list_novels or list_roster_characters to see available ids.`);
234
+ return entity;
235
+ }
236
+ resolveEntityNullable(entityId) {
237
+ const novel = this.activeNovel;
238
+ if (!novel)
239
+ return undefined;
240
+ const id = entityId ?? novel.active_entity_id;
241
+ if (!id)
242
+ return undefined;
243
+ return novel.entities.get(id);
244
+ }
245
+ // ── Novel Lifecycle ───────────────────────────────────────────
246
+ createNovel(name, ruleset = null) {
247
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
248
+ if (this.novels.has(slug))
249
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' already exists.`);
250
+ const novel = {
251
+ slug,
252
+ name,
253
+ ruleset: ruleset ?? null,
254
+ badge: "none",
255
+ entities: new Map(),
256
+ active_entity_id: null,
257
+ npcs: new Map(),
258
+ scene_description: "",
259
+ scene_location: undefined,
260
+ scene_time_of_day: undefined,
261
+ scene_atmosphere: undefined,
262
+ scene_history: [],
263
+ scene_type: ["neutral"],
264
+ narrative_directive: "",
265
+ combat: null,
266
+ countdowns: new Map(),
267
+ lore: new Map(),
268
+ briefing_assembly_count: 0,
269
+ player_signals: {},
270
+ adventure_slug: null,
271
+ generated_adventure: null,
272
+ audit_log: [],
273
+ undo_stacks: { player: [], game_master: [], observer: [], none: [] },
274
+ redo_stacks: { player: [], game_master: [], observer: [], none: [] },
275
+ briefing_order: [],
276
+ action_patterns_enabled: false,
277
+ session_zero_completed: false,
278
+ characters_present: false,
279
+ characters_present_ids: [],
280
+ adventure_set: false,
281
+ pending_workflow: null,
282
+ connection_counter: 0,
283
+ pending_staleness_counter: 0,
284
+ pov_mode: "character",
285
+ autonomy: autonomyDefaultsFromEnv(),
286
+ help_category_overrides: {},
287
+ story_journal: [],
288
+ factions: [],
289
+ secrets: [],
290
+ relationships: [],
291
+ gm_context: {},
292
+ constraint_overrides: [],
293
+ synthesis_activated: {},
294
+ synthesis_module_enabled: {},
295
+ notes: [],
296
+ vows: [],
297
+ checkpoints: [],
298
+ description: "",
299
+ genre: "",
300
+ adventure_index: null,
301
+ adventure_scene_waypoint: null,
302
+ world: createEmptyWorldModel(),
303
+ metadata: {
304
+ created: new Date().toISOString(),
305
+ modified: new Date().toISOString(),
306
+ session_count: 0,
307
+ total_combat_rounds: 0,
308
+ last_scene_anchor: "",
309
+ },
310
+ };
311
+ this.novels.set(slug, novel);
312
+ this.activeNovelId = slug;
313
+ this.audit(novel, novel.badge, "create_novel", { name, ruleset: ruleset ?? null });
314
+ this.saveNovel(novel);
315
+ return novel;
316
+ }
317
+ // Bind a ruleset-free Novel to an installed ruleset slug (REQ-380c). One-way:
318
+ // refuse when the Novel already carries a ruleset (a different slug or any
319
+ // existing binding). Audited and persisted.
320
+ bindNovelRuleset(slug) {
321
+ const novel = this.activeNovel;
322
+ if (!novel)
323
+ throw new Error(`[STATE_CONFLICT] No active Novel to bind.`);
324
+ if (novel.ruleset) {
325
+ throw new Error(`[STATE_CONFLICT] Novel '${novel.slug}' is already bound to ruleset '${novel.ruleset}'. Binding is one-way.`);
326
+ }
327
+ novel.ruleset = slug;
328
+ this.audit(novel, novel.badge, "bind_novel_ruleset", { slug });
329
+ this.saveNovel(novel);
330
+ return novel;
331
+ }
332
+ resumeNovel(slug) {
333
+ const filePath = path.join(this.stateDir, "novels", `${slug}.json`);
334
+ if (!fs.existsSync(filePath))
335
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' does not exist on disk.`);
336
+ const raw = fs.readFileSync(filePath, "utf-8");
337
+ const data = JSON.parse(raw);
338
+ if (data._checksum) {
339
+ const payload = { ...data };
340
+ delete payload._checksum;
341
+ const computed = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
342
+ if (computed !== data._checksum) {
343
+ const bakPath = filePath + ".bak";
344
+ if (fs.existsSync(bakPath)) {
345
+ const bakRaw = fs.readFileSync(bakPath, "utf-8");
346
+ const bakData = JSON.parse(bakRaw);
347
+ const loaded = this.loadNovelFromData(bakData);
348
+ this.novels.set(slug, loaded);
349
+ this.activeNovelId = slug;
350
+ this.audit(loaded, loaded.badge, "resume_novel", { slug, restored_from_backup: true });
351
+ return loaded;
352
+ }
353
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' is corrupted (checksum mismatch).`);
354
+ }
355
+ }
356
+ const novel = this.loadNovelFromData(data);
357
+ this.novels.set(slug, novel);
358
+ this.activeNovelId = slug;
359
+ this.audit(novel, novel.badge, "resume_novel", { slug });
360
+ return novel;
361
+ }
362
+ loadNovelFromData(data) {
363
+ data = migrateNovelData(data);
364
+ const novel = {
365
+ slug: data.slug,
366
+ name: data.name,
367
+ ruleset: data.ruleset ?? null,
368
+ badge: data.badge,
369
+ entities: new Map(Object.entries(data.entities ?? {})),
370
+ active_entity_id: data.active_entity_id ?? null,
371
+ npcs: new Map(Object.entries(data.npcs ?? {})),
372
+ scene_description: data.scene_description ?? "",
373
+ scene_location: data.scene_location,
374
+ scene_time_of_day: data.scene_time_of_day,
375
+ scene_atmosphere: data.scene_atmosphere,
376
+ scene_history: data.scene_history ?? [],
377
+ scene_type: normalizeSceneType(data.scene_type),
378
+ narrative_directive: data.narrative_directive ?? "",
379
+ combat: data.combat ?? null,
380
+ countdowns: new Map(Object.entries(data.countdowns ?? {})),
381
+ lore: new Map(Object.entries(data.lore ?? {})),
382
+ briefing_assembly_count: data.briefing_assembly_count ?? 0,
383
+ player_signals: data.player_signals ?? {},
384
+ adventure_slug: data.adventure_slug ?? null,
385
+ generated_adventure: data.generated_adventure ?? null,
386
+ audit_log: data.audit_log ?? [],
387
+ undo_stacks: {
388
+ player: data.undo_stacks?.player ?? [],
389
+ game_master: data.undo_stacks?.game_master ?? [],
390
+ observer: data.undo_stacks?.observer ?? [],
391
+ none: data.undo_stacks?.none ?? data.undo_stacks?.null ?? [],
392
+ },
393
+ redo_stacks: {
394
+ player: data.redo_stacks?.player ?? [],
395
+ game_master: data.redo_stacks?.game_master ?? [],
396
+ observer: data.redo_stacks?.observer ?? [],
397
+ none: data.redo_stacks?.none ?? data.redo_stacks?.null ?? [],
398
+ },
399
+ briefing_order: data.briefing_order ?? [],
400
+ action_patterns_enabled: data.action_patterns_enabled ?? false,
401
+ session_zero_completed: data.session_zero_completed ?? false,
402
+ characters_present: data.characters_present ?? false,
403
+ characters_present_ids: data.characters_present_ids ?? [],
404
+ adventure_set: data.adventure_set ?? false,
405
+ pending_workflow: data.pending_workflow ?? null,
406
+ connection_counter: data.connection_counter ?? 0,
407
+ pending_staleness_counter: data.pending_staleness_counter ?? 0,
408
+ pov_mode: data.pov_mode ?? "character",
409
+ autonomy: normalizeAutonomy(data.autonomy),
410
+ help_category_overrides: data.help_category_overrides ?? {},
411
+ story_journal: data.story_journal ?? [],
412
+ factions: data.factions ?? [],
413
+ secrets: data.secrets ?? [],
414
+ relationships: data.relationships ?? [],
415
+ gm_context: data.gm_context ?? {},
416
+ constraint_overrides: data.constraint_overrides ?? [],
417
+ synthesis_activated: data.synthesis_activated ?? {},
418
+ synthesis_module_enabled: data.synthesis_module_enabled ?? {},
419
+ notes: data.notes ?? [],
420
+ vows: data.vows ?? [],
421
+ checkpoints: data.checkpoints ?? [],
422
+ description: data.description ?? "",
423
+ genre: data.genre ?? "",
424
+ adventure_index: data.adventure_index ?? null,
425
+ adventure_scene_waypoint: data.adventure_scene_waypoint ?? null,
426
+ world: worldFromJSON(data.world),
427
+ metadata: data.metadata ?? {
428
+ created: new Date().toISOString(),
429
+ modified: new Date().toISOString(),
430
+ session_count: 0,
431
+ total_combat_rounds: 0,
432
+ last_scene_anchor: "",
433
+ },
434
+ };
435
+ return novel;
436
+ }
437
+ switchNovel(slug) {
438
+ if (!this.novels.has(slug) && !fs.existsSync(path.join(this.stateDir, "novels", `${slug}.json`))) {
439
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' does not exist.`);
440
+ }
441
+ if (!this.novels.has(slug)) {
442
+ return this.resumeNovel(slug);
443
+ }
444
+ this.activeNovelId = slug;
445
+ return this.novels.get(slug);
446
+ }
447
+ endNovel(novel, dispose) {
448
+ if (dispose === "cancel")
449
+ return { removed: false };
450
+ const trashDir = path.join(this.stateDir, ".trash");
451
+ fs.mkdirSync(trashDir, { recursive: true });
452
+ const novelFile = path.join(this.stateDir, "novels", `${novel.slug}.json`);
453
+ const bakFile = novelFile + ".bak";
454
+ if (fs.existsSync(novelFile)) {
455
+ fs.renameSync(novelFile, path.join(trashDir, `${novel.slug}-${Date.now()}.json`));
456
+ }
457
+ if (fs.existsSync(bakFile)) {
458
+ fs.renameSync(bakFile, path.join(trashDir, `${novel.slug}-${Date.now()}.json.bak`));
459
+ }
460
+ this.novels.delete(novel.slug);
461
+ if (this.activeNovelId === novel.slug) {
462
+ this.activeNovelId = null;
463
+ }
464
+ return { removed: true };
465
+ }
466
+ cleanupExpiredTrash() {
467
+ const trashDir = path.join(this.stateDir, ".trash");
468
+ if (!fs.existsSync(trashDir))
469
+ return;
470
+ const retentionDays = parseInt(process.env.TTRPG_NOVEL_RETENTION_DAYS ?? "0", 10);
471
+ if (!retentionDays || retentionDays <= 0)
472
+ return;
473
+ const cutoff = Date.now() - retentionDays * 86400_000;
474
+ for (const entry of fs.readdirSync(trashDir)) {
475
+ const full = path.join(trashDir, entry);
476
+ const stat = fs.statSync(full);
477
+ if (stat.mtimeMs < cutoff) {
478
+ fs.unlinkSync(full);
479
+ }
480
+ }
481
+ }
482
+ // ── Snapshots, Undo, Redo ─────────────────────────────────────
483
+ snapshot(novel, badge) {
484
+ const clone = JSON.parse(JSON.stringify(novelToSnapshotJSON(novel)));
485
+ const stackKey = badge;
486
+ novel.undo_stacks[stackKey].push(clone);
487
+ if (novel.undo_stacks[stackKey].length > 10) {
488
+ novel.undo_stacks[stackKey].shift();
489
+ }
490
+ novel.redo_stacks[stackKey] = [];
491
+ }
492
+ undo(novel, badge) {
493
+ const stackKey = badge;
494
+ const stack = novel.undo_stacks[stackKey];
495
+ if (stack.length === 0)
496
+ throw new Error("[STATE_CONFLICT] Nothing to undo.");
497
+ const current = JSON.parse(JSON.stringify(novelToSnapshotJSON(novel)));
498
+ novel.redo_stacks[stackKey].push(current);
499
+ const restore = stack.pop();
500
+ const restored = novelFromJSON(restore);
501
+ novel.entities = restored.entities;
502
+ novel.active_entity_id = restored.active_entity_id;
503
+ novel.npcs = restored.npcs;
504
+ novel.scene_description = restored.scene_description;
505
+ novel.scene_location = restored.scene_location;
506
+ novel.scene_time_of_day = restored.scene_time_of_day;
507
+ novel.scene_atmosphere = restored.scene_atmosphere;
508
+ novel.scene_history = restored.scene_history;
509
+ novel.scene_type = restored.scene_type;
510
+ novel.narrative_directive = restored.narrative_directive;
511
+ novel.combat = restored.combat;
512
+ novel.countdowns = restored.countdowns;
513
+ novel.lore = restored.lore;
514
+ novel.badge = restored.badge;
515
+ novel.player_signals = restored.player_signals;
516
+ novel.briefing_assembly_count = restored.briefing_assembly_count;
517
+ novel.story_journal = restored.story_journal;
518
+ novel.factions = restored.factions;
519
+ novel.secrets = restored.secrets;
520
+ novel.relationships = restored.relationships;
521
+ novel.gm_context = restored.gm_context;
522
+ novel.notes = restored.notes;
523
+ novel.vows = restored.vows;
524
+ novel.checkpoints = restored.checkpoints;
525
+ novel.world = restored.world;
526
+ novel.metadata = restored.metadata;
527
+ this.saveNovel(novel);
528
+ return { data: restore };
529
+ }
530
+ redo(novel, badge) {
531
+ const stackKey = badge;
532
+ const stack = novel.redo_stacks[stackKey];
533
+ if (stack.length === 0)
534
+ throw new Error("[STATE_CONFLICT] Nothing to redo.");
535
+ const current = JSON.parse(JSON.stringify(novelToSnapshotJSON(novel)));
536
+ novel.undo_stacks[stackKey].push(current);
537
+ const restore = stack.pop();
538
+ const restored = novelFromJSON(restore);
539
+ novel.entities = restored.entities;
540
+ novel.active_entity_id = restored.active_entity_id;
541
+ novel.npcs = restored.npcs;
542
+ novel.scene_description = restored.scene_description;
543
+ novel.scene_location = restored.scene_location;
544
+ novel.scene_time_of_day = restored.scene_time_of_day;
545
+ novel.scene_atmosphere = restored.scene_atmosphere;
546
+ novel.scene_history = restored.scene_history;
547
+ novel.scene_type = restored.scene_type;
548
+ novel.narrative_directive = restored.narrative_directive;
549
+ novel.combat = restored.combat;
550
+ novel.countdowns = restored.countdowns;
551
+ novel.lore = restored.lore;
552
+ novel.badge = restored.badge;
553
+ novel.player_signals = restored.player_signals;
554
+ novel.briefing_assembly_count = restored.briefing_assembly_count;
555
+ novel.story_journal = restored.story_journal;
556
+ novel.factions = restored.factions;
557
+ novel.secrets = restored.secrets;
558
+ novel.relationships = restored.relationships;
559
+ novel.gm_context = restored.gm_context;
560
+ novel.notes = restored.notes;
561
+ novel.vows = restored.vows;
562
+ novel.checkpoints = restored.checkpoints;
563
+ novel.world = restored.world;
564
+ novel.metadata = restored.metadata;
565
+ this.saveNovel(novel);
566
+ return { data: restore };
567
+ }
568
+ // ── Audit ─────────────────────────────────────────────────────
569
+ audit(novel, badge, tool, args, output_prefix) {
570
+ const prevHash = novel.audit_log.length > 0 ? novel.audit_log[novel.audit_log.length - 1].hash : "00000000";
571
+ const entry = {
572
+ timestamp: new Date().toISOString(),
573
+ badge,
574
+ tool,
575
+ args: JSON.stringify(args),
576
+ output_prefix: output_prefix ?? "",
577
+ hash: crypto.createHash("sha256").update(prevHash + tool + JSON.stringify(args)).digest("hex").substring(0, 8),
578
+ };
579
+ novel.audit_log.push(entry);
580
+ }
581
+ auditForbidden(badge, tool, args) {
582
+ const novel = this.activeNovel;
583
+ if (!novel)
584
+ return;
585
+ const prevHash = novel.audit_log.length > 0 ? novel.audit_log[novel.audit_log.length - 1].hash : "00000000";
586
+ const entry = {
587
+ timestamp: new Date().toISOString(),
588
+ badge,
589
+ tool,
590
+ args: JSON.stringify(args),
591
+ output_prefix: "[BOUNDARY_VIOLATION]",
592
+ hash: crypto.createHash("sha256").update(prevHash + tool + JSON.stringify(args)).digest("hex").substring(0, 8),
593
+ };
594
+ entry.violation_type = "boundary";
595
+ novel.audit_log.push(entry);
596
+ }
597
+ verifyAuditChain(novel) {
598
+ const entries = novel.audit_log;
599
+ if (entries.length === 0)
600
+ return { valid: true, entries: 0 };
601
+ let prevHash = "00000000";
602
+ for (let i = 0; i < entries.length; i++) {
603
+ const entry = entries[i];
604
+ const expected = crypto.createHash("sha256").update(prevHash + entry.tool + entry.args).digest("hex").substring(0, 8);
605
+ if (entry.hash !== expected) {
606
+ return { valid: false, entries: entries.length, first_broken_index: i };
607
+ }
608
+ prevHash = entry.hash;
609
+ }
610
+ return { valid: true, entries: entries.length };
611
+ }
612
+ getEnrichmentHealth() {
613
+ const manifest = this.enrichmentManifest;
614
+ if (!manifest)
615
+ return {
616
+ enrichment_active: this.enriched,
617
+ module_counts: {},
618
+ stale_count: 0,
619
+ activated_count: 0,
620
+ fingerprint: null,
621
+ };
622
+ const staleDays = parseInt(process.env.TTRPG_SYNTHESIS_STALE_DAYS ?? "90", 10);
623
+ const cutoff = Date.now() - staleDays * 86400_000;
624
+ let staleCount = 0;
625
+ let activatedCount = 0;
626
+ const moduleCounts = {};
627
+ const modules = ["voice_examples", "briefing_order", "lore_templates", "action_patterns", "supplementary_guidance", "adventure_advice", "narrative_voices"];
628
+ for (const mod of modules) {
629
+ const items = (manifest[mod] ?? []);
630
+ moduleCounts[mod] = items.length;
631
+ for (const item of items) {
632
+ if (item.collected_at && new Date(item.collected_at).getTime() < cutoff)
633
+ staleCount++;
634
+ if (item.activated)
635
+ activatedCount++;
636
+ }
637
+ }
638
+ return {
639
+ enrichment_active: this.enriched,
640
+ module_counts: moduleCounts,
641
+ stale_count: staleCount,
642
+ activated_count: activatedCount,
643
+ fingerprint: manifest._fingerprint ?? null,
644
+ };
645
+ }
646
+ // ── Combat ────────────────────────────────────────────────────
647
+ initCombat(novel, participants, dangers, seedStr) {
648
+ const turn_order = [];
649
+ // Simple ordering: entities first, then dangers — no initiative rolling in ruleset-free mode
650
+ for (const pid of participants) {
651
+ if (!turn_order.includes(pid))
652
+ turn_order.push(pid);
653
+ }
654
+ for (const d of dangers) {
655
+ if (!turn_order.includes(d.name))
656
+ turn_order.push(d.name);
657
+ }
658
+ const combat = {
659
+ participants,
660
+ dangers: dangers.map(d => ({ ...d, hp: d.max_hp ?? d.hp ?? 1, max_hp: d.max_hp ?? d.hp ?? 1 })),
661
+ round: 1,
662
+ turn_order,
663
+ current_turn: 0,
664
+ active: true,
665
+ };
666
+ novel.combat = combat;
667
+ this.audit(novel, novel.badge, "init_combat", { participants, dangers });
668
+ return combat;
669
+ }
670
+ advanceCombat(novel) {
671
+ if (!novel.combat || !novel.combat.active)
672
+ throw new Error("[STATE_CONFLICT] No active combat.");
673
+ const combat = novel.combat;
674
+ const currentName = combat.turn_order[combat.current_turn];
675
+ const isEntity = novel.entities.has(currentName);
676
+ const isNpc = novel.npcs.has(currentName);
677
+ const isDanger = combat.dangers.some(d => d.name === currentName);
678
+ // In ruleset-free mode, everyone auto-advances
679
+ combat.current_turn++;
680
+ if (combat.current_turn >= combat.turn_order.length) {
681
+ combat.current_turn = 0;
682
+ combat.round++;
683
+ novel.metadata.total_combat_rounds++;
684
+ for (const [, cd] of novel.countdowns) {
685
+ if (cd.type === "round") {
686
+ cd.ticks--;
687
+ if (cd.ticks <= 0) {
688
+ this.audit(novel, novel.badge, "countdown_expired", { name: cd.name });
689
+ }
690
+ }
691
+ }
692
+ }
693
+ this.audit(novel, novel.badge, "advance_combat", {
694
+ participant: currentName,
695
+ round: combat.round,
696
+ turn: combat.current_turn,
697
+ statless: true,
698
+ });
699
+ return combat;
700
+ }
701
+ endCombat(novel, outcome) {
702
+ if (!novel.combat)
703
+ throw new Error("[STATE_CONFLICT] No active combat.");
704
+ novel.combat.active = false;
705
+ this.audit(novel, novel.badge, "end_combat", { outcome, rounds_played: novel.combat.round });
706
+ novel.combat = null;
707
+ }
708
+ addCombatParticipant(novel, participantId) {
709
+ if (!novel.combat || !novel.combat.active)
710
+ throw new Error("[STATE_CONFLICT] No active combat.");
711
+ if (!novel.entities.has(participantId) && !novel.npcs.has(participantId)) {
712
+ const valid = [...novel.entities.keys(), ...novel.npcs.keys()];
713
+ throw new Error(`[NOT_FOUND] Participant '${participantId}' not found. Valid: ${valid.join(", ") || "(none)"}`);
714
+ }
715
+ if (novel.combat.turn_order.includes(participantId)) {
716
+ throw new Error(`[STATE_CONFLICT] Participant '${participantId}' is already in combat.`);
717
+ }
718
+ const combat = novel.combat;
719
+ const insertIdx = combat.current_turn + 1;
720
+ combat.turn_order.splice(insertIdx, 0, participantId);
721
+ if (combat.current_turn >= insertIdx) {
722
+ combat.current_turn++;
723
+ }
724
+ if (!combat.participants.includes(participantId)) {
725
+ combat.participants.push(participantId);
726
+ }
727
+ this.audit(novel, novel.badge, "add_combat_participant", { participant_id: participantId });
728
+ return combat;
729
+ }
730
+ removeCombatParticipant(novel, participantId) {
731
+ if (!novel.combat || !novel.combat.active)
732
+ throw new Error("[STATE_CONFLICT] No active combat.");
733
+ const combat = novel.combat;
734
+ const idx = combat.turn_order.indexOf(participantId);
735
+ if (idx === -1) {
736
+ throw new Error(`[NOT_FOUND] Participant '${participantId}' is not in combat.`);
737
+ }
738
+ if (combat.turn_order.length <= 1) {
739
+ combat.active = false;
740
+ this.audit(novel, novel.badge, "end_combat", { outcome: "All participants removed.", rounds_played: combat.round });
741
+ novel.combat = null;
742
+ return { combat: null, ended: true, outcome: "All participants removed." };
743
+ }
744
+ if (idx === combat.current_turn) {
745
+ combat.current_turn = (combat.current_turn + 1) % combat.turn_order.length;
746
+ }
747
+ combat.turn_order.splice(idx, 1);
748
+ if (combat.current_turn >= combat.turn_order.length) {
749
+ combat.current_turn = 0;
750
+ }
751
+ if (idx < combat.current_turn) {
752
+ combat.current_turn--;
753
+ }
754
+ combat.participants = combat.participants.filter(p => p !== participantId);
755
+ this.audit(novel, novel.badge, "remove_combat_participant", { participant_id: participantId });
756
+ return { combat, ended: false };
757
+ }
758
+ combatReport(novel) {
759
+ if (!novel.combat || !novel.combat.active)
760
+ return "\nNone";
761
+ const c = novel.combat;
762
+ const gm = novel.badge === "game_master";
763
+ const turnOrder = c.turn_order.map((name, i) => {
764
+ const marker = i === c.current_turn ? "← current" : "";
765
+ const isEntity = novel.entities.has(name);
766
+ if (!gm && !isEntity)
767
+ return null;
768
+ return ` ${i + 1}. ${name}${marker}`;
769
+ }).filter(Boolean).join("\n");
770
+ return `\nRound ${c.round} — Turn ${c.current_turn + 1} of ${c.turn_order.length}
771
+ ${turnOrder}`;
772
+ }
773
+ // ── World-model helpers ───────────────────────────────────────
774
+ worldHasRooms(novel) {
775
+ return novel.world.rooms.size > 0;
776
+ }
777
+ // ── Persistence ───────────────────────────────────────────────
778
+ saveNovel(novel) {
779
+ const dir = path.join(this.stateDir, "novels");
780
+ fs.mkdirSync(dir, { recursive: true });
781
+ const filePath = path.join(dir, `${novel.slug}.json`);
782
+ const tmpPath = filePath + `.${process.pid}-${Date.now()}.tmp`;
783
+ const bakPath = filePath + ".bak";
784
+ novel.metadata.modified = new Date().toISOString();
785
+ // Defensive guard: the undo/redo stacks are internal bookkeeping. If they
786
+ // have grown pathologically (e.g. a snapshot regression embedded prior
787
+ // stacks), trim them rather than let an unbounded save brick all writes.
788
+ const SANE_STACK_BYTES = 16 * 1024 * 1024; // 16 MiB per badge
789
+ for (const key of Object.keys(novel.undo_stacks)) {
790
+ const stack = novel.undo_stacks[key];
791
+ while (stack.length > 0 && estimateJsonBytes(stack) > SANE_STACK_BYTES) {
792
+ stack.shift();
793
+ }
794
+ }
795
+ for (const key of Object.keys(novel.redo_stacks)) {
796
+ const stack = novel.redo_stacks[key];
797
+ while (stack.length > 0 && estimateJsonBytes(stack) > SANE_STACK_BYTES) {
798
+ stack.shift();
799
+ }
800
+ }
801
+ const json = JSON.stringify(novelToJSON(novel));
802
+ const payload = JSON.parse(json);
803
+ payload._checksum = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
804
+ const out = JSON.stringify(payload, null, 2);
805
+ if (fs.existsSync(filePath)) {
806
+ fs.copyFileSync(filePath, bakPath);
807
+ }
808
+ const fd = fs.openSync(tmpPath, "w");
809
+ fs.writeFileSync(fd, out, "utf-8");
810
+ fs.fsyncSync(fd);
811
+ fs.closeSync(fd);
812
+ fs.renameSync(tmpPath, filePath);
813
+ }
814
+ saveRoster() {
815
+ const dir = this.stateDir;
816
+ fs.mkdirSync(dir, { recursive: true });
817
+ const rosterData = {};
818
+ for (const [id, entity] of this.roster) {
819
+ rosterData[id] = {
820
+ id: entity.id,
821
+ name: entity.name,
822
+ personality: entity.personality,
823
+ voice_examples: entity.voice_examples,
824
+ inventory: entity.inventory,
825
+ current_room: entity.current_room,
826
+ conditions: entity.conditions,
827
+ condition_rounds: entity.condition_rounds,
828
+ stats: entity.stats,
829
+ };
830
+ }
831
+ fs.writeFileSync(path.join(dir, "roster.json"), JSON.stringify(rosterData, null, 2), "utf-8");
832
+ }
833
+ loadRoster() {
834
+ const filePath = path.join(this.stateDir, "roster.json");
835
+ if (!fs.existsSync(filePath))
836
+ return;
837
+ const raw = fs.readFileSync(filePath, "utf-8");
838
+ const data = JSON.parse(raw);
839
+ for (const [id, entity] of Object.entries(data)) {
840
+ const e = entity;
841
+ this.roster.set(id, {
842
+ id: e.id || id,
843
+ name: e.name,
844
+ personality: e.personality,
845
+ voice_examples: e.voice_examples,
846
+ inventory: e.inventory || [],
847
+ current_room: e.current_room || null,
848
+ conditions: e.conditions || [],
849
+ condition_rounds: e.condition_rounds || {},
850
+ stats: e.stats,
851
+ });
852
+ }
853
+ }
854
+ // ── Server Notes (REQ-285) ─────────────────────────────────────
855
+ // Stage an entity into the roster (REQ-219 roster staging). Persists the
856
+ // roster. The entity keeps its stats, personality, and inventory baseline.
857
+ addToRoster(entity) {
858
+ const id = entity.id;
859
+ this.roster.set(id, {
860
+ id: entity.id,
861
+ name: entity.name,
862
+ personality: entity.personality,
863
+ voice_examples: entity.voice_examples,
864
+ inventory: entity.inventory || [],
865
+ current_room: entity.current_room || null,
866
+ conditions: entity.conditions || [],
867
+ condition_rounds: entity.condition_rounds || {},
868
+ stats: entity.stats,
869
+ });
870
+ this.saveRoster();
871
+ return id;
872
+ }
873
+ loadServerNotes() {
874
+ const filePath = path.join(this.stateDir, "server-notes.json");
875
+ if (!fs.existsSync(filePath))
876
+ return;
877
+ try {
878
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
879
+ for (const [key, content] of Object.entries(data)) {
880
+ this.serverNotes.set(key, content);
881
+ }
882
+ }
883
+ catch { /* ignore corrupt */ }
884
+ }
885
+ saveServerNotes() {
886
+ fs.mkdirSync(this.stateDir, { recursive: true });
887
+ fs.writeFileSync(path.join(this.stateDir, "server-notes.json"), JSON.stringify(Object.fromEntries(this.serverNotes), null, 2), "utf-8");
888
+ }
889
+ loadCodex() {
890
+ const filePath = path.join(this.stateDir, "codex.json");
891
+ if (!fs.existsSync(filePath))
892
+ return;
893
+ try {
894
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
895
+ for (const [id, entry] of Object.entries(data)) {
896
+ this.codex.set(id, entry);
897
+ }
898
+ }
899
+ catch { /* ignore corrupt */ }
900
+ }
901
+ saveCodex() {
902
+ fs.mkdirSync(this.stateDir, { recursive: true });
903
+ fs.writeFileSync(path.join(this.stateDir, "codex.json"), JSON.stringify(Object.fromEntries(this.codex), null, 2), "utf-8");
904
+ }
905
+ // ── Entity Factory (ruleset-free, REQ-219) ────────────────────
906
+ createEntity(name, personality, stats) {
907
+ this.entityCounter++;
908
+ const id = `character_${String(this.entityCounter).padStart(2, "0")}`;
909
+ const entity = {
910
+ id,
911
+ name,
912
+ personality,
913
+ inventory: [],
914
+ current_room: null,
915
+ conditions: [],
916
+ condition_rounds: {},
917
+ stats,
918
+ };
919
+ return entity;
920
+ }
921
+ addEntity(novel, entity) {
922
+ novel.entities.set(entity.id, entity);
923
+ if (!novel.active_entity_id) {
924
+ novel.active_entity_id = entity.id;
925
+ }
926
+ novel.characters_present = true;
927
+ }
928
+ }
929
+ // ── Serialization helpers ──────────────────────────────────────────
930
+ function novelToJSON(novel) {
931
+ return {
932
+ slug: novel.slug,
933
+ name: novel.name,
934
+ ruleset: novel.ruleset,
935
+ badge: novel.badge,
936
+ entities: Object.fromEntries(novel.entities),
937
+ active_entity_id: novel.active_entity_id,
938
+ npcs: Object.fromEntries(novel.npcs),
939
+ scene_description: novel.scene_description,
940
+ scene_location: novel.scene_location,
941
+ scene_time_of_day: novel.scene_time_of_day,
942
+ scene_atmosphere: novel.scene_atmosphere,
943
+ scene_history: novel.scene_history,
944
+ scene_type: novel.scene_type,
945
+ narrative_directive: novel.narrative_directive,
946
+ combat: novel.combat,
947
+ countdowns: Object.fromEntries(novel.countdowns),
948
+ lore: Object.fromEntries(novel.lore),
949
+ briefing_assembly_count: novel.briefing_assembly_count,
950
+ player_signals: novel.player_signals,
951
+ adventure_slug: novel.adventure_slug,
952
+ generated_adventure: novel.generated_adventure,
953
+ audit_log: novel.audit_log,
954
+ undo_stacks: novel.undo_stacks,
955
+ redo_stacks: novel.redo_stacks,
956
+ briefing_order: novel.briefing_order,
957
+ action_patterns_enabled: novel.action_patterns_enabled,
958
+ session_zero_completed: novel.session_zero_completed,
959
+ characters_present: novel.characters_present,
960
+ characters_present_ids: novel.characters_present_ids,
961
+ adventure_set: novel.adventure_set,
962
+ pending_workflow: novel.pending_workflow,
963
+ connection_counter: novel.connection_counter,
964
+ pending_staleness_counter: novel.pending_staleness_counter,
965
+ pov_mode: novel.pov_mode,
966
+ autonomy: novel.autonomy,
967
+ help_category_overrides: novel.help_category_overrides,
968
+ story_journal: novel.story_journal,
969
+ factions: novel.factions,
970
+ secrets: novel.secrets,
971
+ relationships: novel.relationships,
972
+ gm_context: novel.gm_context,
973
+ constraint_overrides: novel.constraint_overrides,
974
+ synthesis_activated: novel.synthesis_activated,
975
+ synthesis_module_enabled: novel.synthesis_module_enabled,
976
+ notes: novel.notes,
977
+ vows: novel.vows,
978
+ checkpoints: novel.checkpoints,
979
+ description: novel.description,
980
+ genre: novel.genre,
981
+ adventure_index: novel.adventure_index,
982
+ adventure_scene_waypoint: novel.adventure_scene_waypoint,
983
+ world: worldToJSON(novel.world),
984
+ metadata: novel.metadata,
985
+ };
986
+ }
987
+ // Snapshot-clone serialization: identical to novelToJSON but omits the
988
+ // undo/redo stacks. Stacks are internal bookkeeping; embedding them in a
989
+ // snapshot would recursively capture every prior snapshot, causing
990
+ // exponential growth (see snapshot/undo/redo).
991
+ function novelToSnapshotJSON(novel) {
992
+ const base = novelToJSON(novel);
993
+ delete base.undo_stacks;
994
+ delete base.redo_stacks;
995
+ return base;
996
+ }
997
+ // Cheap size estimate for the undo/redo stack health guard in saveNovel.
998
+ function estimateJsonBytes(value) {
999
+ try {
1000
+ return JSON.stringify(value).length;
1001
+ }
1002
+ catch {
1003
+ return Number.MAX_SAFE_INTEGER;
1004
+ }
1005
+ }
1006
+ function novelFromJSON(data) {
1007
+ data = migrateNovelData(data);
1008
+ return {
1009
+ slug: data.slug,
1010
+ name: data.name,
1011
+ ruleset: data.ruleset ?? null,
1012
+ badge: data.badge,
1013
+ entities: new Map(Object.entries(data.entities ?? {})),
1014
+ active_entity_id: data.active_entity_id ?? null,
1015
+ npcs: new Map(Object.entries(data.npcs ?? {})),
1016
+ scene_description: data.scene_description ?? "",
1017
+ scene_location: data.scene_location,
1018
+ scene_time_of_day: data.scene_time_of_day,
1019
+ scene_atmosphere: data.scene_atmosphere,
1020
+ scene_history: data.scene_history ?? [],
1021
+ scene_type: normalizeSceneType(data.scene_type),
1022
+ narrative_directive: data.narrative_directive ?? "",
1023
+ combat: data.combat ?? null,
1024
+ countdowns: new Map(Object.entries(data.countdowns ?? {})),
1025
+ lore: new Map(Object.entries(data.lore ?? {})),
1026
+ briefing_assembly_count: data.briefing_assembly_count ?? 0,
1027
+ player_signals: data.player_signals ?? {},
1028
+ adventure_slug: data.adventure_slug ?? null,
1029
+ generated_adventure: data.generated_adventure ?? null,
1030
+ audit_log: data.audit_log ?? [],
1031
+ undo_stacks: {
1032
+ player: data.undo_stacks?.player ?? [],
1033
+ game_master: data.undo_stacks?.game_master ?? [],
1034
+ observer: data.undo_stacks?.observer ?? [],
1035
+ none: data.undo_stacks?.none ?? data.undo_stacks?.null ?? [],
1036
+ },
1037
+ redo_stacks: {
1038
+ player: data.redo_stacks?.player ?? [],
1039
+ game_master: data.redo_stacks?.game_master ?? [],
1040
+ observer: data.redo_stacks?.observer ?? [],
1041
+ none: data.redo_stacks?.none ?? data.redo_stacks?.null ?? [],
1042
+ },
1043
+ briefing_order: data.briefing_order ?? [],
1044
+ action_patterns_enabled: data.action_patterns_enabled ?? false,
1045
+ session_zero_completed: data.session_zero_completed ?? false,
1046
+ characters_present: data.characters_present ?? false,
1047
+ characters_present_ids: data.characters_present_ids ?? [],
1048
+ adventure_set: data.adventure_set ?? false,
1049
+ pending_workflow: data.pending_workflow ?? null,
1050
+ connection_counter: data.connection_counter ?? 0,
1051
+ pending_staleness_counter: data.pending_staleness_counter ?? 0,
1052
+ pov_mode: data.pov_mode ?? "character",
1053
+ autonomy: normalizeAutonomy(data.autonomy),
1054
+ help_category_overrides: data.help_category_overrides ?? {},
1055
+ story_journal: data.story_journal ?? [],
1056
+ factions: data.factions ?? [],
1057
+ secrets: data.secrets ?? [],
1058
+ relationships: data.relationships ?? [],
1059
+ gm_context: data.gm_context ?? {},
1060
+ constraint_overrides: data.constraint_overrides ?? [],
1061
+ synthesis_activated: data.synthesis_activated ?? {},
1062
+ synthesis_module_enabled: data.synthesis_module_enabled ?? {},
1063
+ notes: data.notes ?? [],
1064
+ vows: data.vows ?? [],
1065
+ checkpoints: data.checkpoints ?? [],
1066
+ description: data.description ?? "",
1067
+ genre: data.genre ?? "",
1068
+ adventure_index: data.adventure_index ?? null,
1069
+ adventure_scene_waypoint: data.adventure_scene_waypoint ?? null,
1070
+ world: worldFromJSON(data.world),
1071
+ metadata: data.metadata ?? {
1072
+ created: new Date().toISOString(),
1073
+ modified: new Date().toISOString(),
1074
+ session_count: 0,
1075
+ total_combat_rounds: 0,
1076
+ last_scene_anchor: "",
1077
+ },
1078
+ };
1079
+ }
1080
+ //# sourceMappingURL=state.js.map