holonovel 2026.9.3 → 2026.9.4

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.
@@ -53,6 +53,11 @@ export interface NpcState {
53
53
  dialogue: string;
54
54
  tag?: string;
55
55
  }[];
56
+ mind?: {
57
+ private_journal?: string[];
58
+ directive?: string;
59
+ auto_play?: boolean;
60
+ };
56
61
  conditions: string[];
57
62
  condition_rounds: Record<string, number>;
58
63
  memory?: {
@@ -259,6 +264,43 @@ export interface ConditionState {
259
264
  conditions: string[];
260
265
  condition_rounds: Record<string, number>;
261
266
  }
267
+ export interface FateState {
268
+ aspects: Array<{
269
+ name: string;
270
+ target: string;
271
+ }>;
272
+ fate_points: Record<string, number>;
273
+ stress: Record<string, {
274
+ physical: number;
275
+ mental: number;
276
+ consequences: string[];
277
+ }>;
278
+ }
279
+ export declare const FATE_REFRESH = 3;
280
+ export declare function normalizeFateState(raw: unknown): FateState;
281
+ export interface IronswornProgressTrack {
282
+ name: string;
283
+ rank: "troublesome" | "dangerous" | "formidable" | "extreme" | "epic";
284
+ ticks: number;
285
+ }
286
+ export interface IronswornState {
287
+ momentum: Record<string, number>;
288
+ progress_tracks: IronswornProgressTrack[];
289
+ }
290
+ export declare const IRONSWORN_MOMENTUM_DEFAULT = 2;
291
+ export declare const IRONSWORN_MOMENTUM_MIN = -6;
292
+ export declare const IRONSWORN_MOMENTUM_MAX = 10;
293
+ export declare const IRONSWORN_TRACK_BOXES = 10;
294
+ export declare function normalizeIronswornState(raw: unknown): IronswornState;
295
+ export interface ForgedCharacterState {
296
+ stress: number;
297
+ trauma: string[];
298
+ }
299
+ export interface ForgedState {
300
+ characters: Record<string, ForgedCharacterState>;
301
+ }
302
+ export declare const FORGED_STRESS_MAX = 8;
303
+ export declare function normalizeForgedState(raw: unknown): ForgedState;
262
304
  export interface WorkflowSnapshot {
263
305
  timestamp: string;
264
306
  state: any;
@@ -397,6 +439,7 @@ export interface NovelState {
397
439
  audit_gap: number;
398
440
  timestamp_gap_ms: number;
399
441
  recorded_at: string;
442
+ backup_index?: number;
400
443
  } | null;
401
444
  last_mutation_at: string | null;
402
445
  mutation_counts_by_group: Record<string, number>;
@@ -434,6 +477,9 @@ export interface NovelState {
434
477
  imported_at: string;
435
478
  codex_modified_at: string;
436
479
  }>;
480
+ fate: FateState;
481
+ ironsworn: IronswornState;
482
+ forged: ForgedState;
437
483
  }
438
484
  export interface RosterEntity extends NovelEntity {
439
485
  }
@@ -459,9 +505,10 @@ export declare class StateManager {
459
505
  narrative_tag?: string;
460
506
  }>;
461
507
  codex: Map<string, CodexEntry>;
462
- readonly dataFormat = "91fab184d376185c4a6cbfefd33c25242daf9e21f9837bcb0011657630a01980";
508
+ readonly dataFormat = "daff3d95da3bc1b51cf1b521a9d6a973aa95cccd3b6b7f777f54e11e95484161";
463
509
  staleData: Map<string, string>;
464
510
  corruptData: Map<string, string>;
511
+ private restoredThisSession;
465
512
  private npcCounter;
466
513
  private entityCounter;
467
514
  private stateDir;
@@ -477,7 +524,9 @@ export declare class StateManager {
477
524
  createNovel(name: string, ruleset?: string | null): NovelState;
478
525
  bindNovelRuleset(slug: string): NovelState;
479
526
  private computeStateRegression;
527
+ private hasValidChecksum;
480
528
  resumeNovel(slug: string): NovelState;
529
+ activateHydratedNovel(slug: string): NovelState;
481
530
  hydrateNovelsFromDisk(): void;
482
531
  private loadNovelFromData;
483
532
  switchNovel(slug: string): NovelState;
@@ -537,6 +586,10 @@ export declare class StateManager {
537
586
  };
538
587
  combatReport(novel: NovelState): string;
539
588
  worldHasRooms(novel: NovelState): boolean;
589
+ private backupCount;
590
+ private backupCandidates;
591
+ private restoreFromBackups;
592
+ private backupFilesOnDisk;
540
593
  saveNovel(novel: NovelState): void;
541
594
  saveRoster(): void;
542
595
  loadRoster(): void;
@@ -62,6 +62,33 @@ export function migrateNovelData(data) {
62
62
  }
63
63
  return out;
64
64
  }
65
+ export const FATE_REFRESH = 3;
66
+ export function normalizeFateState(raw) {
67
+ const r = (raw ?? {});
68
+ return {
69
+ aspects: Array.isArray(r.aspects) ? r.aspects : [],
70
+ fate_points: (r.fate_points && typeof r.fate_points === "object") ? r.fate_points : {},
71
+ stress: (r.stress && typeof r.stress === "object") ? r.stress : {},
72
+ };
73
+ }
74
+ export const IRONSWORN_MOMENTUM_DEFAULT = 2;
75
+ export const IRONSWORN_MOMENTUM_MIN = -6;
76
+ export const IRONSWORN_MOMENTUM_MAX = 10;
77
+ export const IRONSWORN_TRACK_BOXES = 10;
78
+ export function normalizeIronswornState(raw) {
79
+ const r = (raw ?? {});
80
+ return {
81
+ momentum: (r.momentum && typeof r.momentum === "object") ? r.momentum : {},
82
+ progress_tracks: Array.isArray(r.progress_tracks) ? r.progress_tracks : [],
83
+ };
84
+ }
85
+ export const FORGED_STRESS_MAX = 8;
86
+ export function normalizeForgedState(raw) {
87
+ const r = (raw ?? {});
88
+ return {
89
+ characters: (r.characters && typeof r.characters === "object") ? r.characters : {},
90
+ };
91
+ }
65
92
  export const DEFAULT_AUTONOMY = {
66
93
  level: "mechanical_prompt",
67
94
  confirmation: "prompt",
@@ -186,6 +213,11 @@ export class StateManager {
186
213
  // resume (unparseable JSON, checksum mismatch, or data-migration failure),
187
214
  // surfaced as a [WARNING] in spec_health.data_health.corrupted.
188
215
  corruptData = new Map();
216
+ // REQ-238 — slugs loaded from a backup this session. The first save after a
217
+ // backup restore skips copying the (corrupt/stale) on-disk primary into
218
+ // `.bak.1`, so a good backup is never overwritten and corruption is never
219
+ // propagated into the rotated chain.
220
+ restoredThisSession = new Set();
189
221
  npcCounter = 0;
190
222
  entityCounter = 0;
191
223
  stateDir;
@@ -345,6 +377,9 @@ export class StateManager {
345
377
  active_session_id: null,
346
378
  pending_vow_countdown_suggestion: null,
347
379
  codex_sources: [],
380
+ fate: { aspects: [], fate_points: {}, stress: {} },
381
+ ironsworn: { momentum: {}, progress_tracks: [] },
382
+ forged: { characters: {} },
348
383
  };
349
384
  this.novels.set(slug, novel);
350
385
  this.activeNovelId = slug;
@@ -377,32 +412,49 @@ export class StateManager {
377
412
  - new Date(bakData.metadata?.modified ?? new Date()).getTime();
378
413
  return { audit_gap: Math.max(0, auditGap), timestamp_gap_ms: Math.max(0, tsGap) };
379
414
  }
415
+ // REQ-092 — a payload's checksum is valid when absent (legacy writes) or when
416
+ // it matches the recomputed hash of the payload excluding the checksum field.
417
+ hasValidChecksum(data) {
418
+ if (!data._checksum)
419
+ return true;
420
+ const payload = { ...data };
421
+ delete payload._checksum;
422
+ const computed = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
423
+ return computed === data._checksum;
424
+ }
380
425
  resumeNovel(slug) {
381
426
  const filePath = path.join(this.stateDir, "novels", `${slug}.json`);
382
427
  if (!fs.existsSync(filePath))
383
428
  throw new Error(`[STATE_CONFLICT] Novel '${slug}' does not exist on disk.`);
384
- const raw = fs.readFileSync(filePath, "utf-8");
385
- const data = JSON.parse(raw);
386
- if (data._checksum) {
387
- const payload = { ...data };
388
- delete payload._checksum;
389
- const computed = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
390
- if (computed !== data._checksum) {
391
- const bakPath = filePath + ".bak";
392
- if (fs.existsSync(bakPath)) {
393
- const bakRaw = fs.readFileSync(bakPath, "utf-8");
394
- const bakData = JSON.parse(bakRaw);
395
- const loaded = this.loadNovelFromData(bakData);
396
- // REQ-406 surface the backup-restore content regression.
397
- loaded.state_regression = { ...this.computeStateRegression(data, bakData), recorded_at: new Date().toISOString() };
398
- this.novels.set(slug, loaded);
399
- this.activeNovelId = slug;
400
- this.audit(loaded, loaded.badge, "resume_novel", { slug, restored_from_backup: true });
401
- return loaded;
402
- }
403
- this.corruptData.set(slug, "checksum mismatch, no valid backup");
404
- throw new Error(`[STATE_CONFLICT] Novel '${slug}' is corrupted (checksum mismatch).`);
429
+ let data;
430
+ let primaryData = null;
431
+ try {
432
+ data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
433
+ primaryData = data;
434
+ }
435
+ catch {
436
+ data = null; // structurally corrupt primary — fall through to backup restore
437
+ }
438
+ // REQ-238a restore triggers on a structural JSON error or a checksum
439
+ // mismatch (REQ-092); the first valid backup wins and its index is audited.
440
+ if (data === null || !this.hasValidChecksum(data)) {
441
+ const restored = this.restoreFromBackups(filePath);
442
+ if (restored) {
443
+ const loaded = this.loadNovelFromData(restored.data);
444
+ // REQ-406 — surface the backup-restore content regression.
445
+ loaded.state_regression = {
446
+ ...this.computeStateRegression(primaryData ?? restored.data, restored.data),
447
+ backup_index: restored.index,
448
+ recorded_at: new Date().toISOString(),
449
+ };
450
+ this.novels.set(slug, loaded);
451
+ this.activeNovelId = slug;
452
+ this.restoredThisSession.add(slug);
453
+ this.audit(loaded, loaded.badge, "resume_novel", { slug, restored_from_backup: restored.index });
454
+ return loaded;
405
455
  }
456
+ this.corruptData.set(slug, "corrupt primary, no valid backup");
457
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' is corrupted.`);
406
458
  }
407
459
  const novel = this.loadNovelFromData(data);
408
460
  this.novels.set(slug, novel);
@@ -411,6 +463,23 @@ export class StateManager {
411
463
  this.checkWorkflowStaleness(novel);
412
464
  return novel;
413
465
  }
466
+ // REQ-088 — activate an already-hydrated Novel by its internal slug (registry
467
+ // key). Used by the TTRPG_NOVEL startup auto-load so a save file whose name
468
+ // diverges from its internal slug is activated without a second file read.
469
+ // Audits the resume and, when hydration restored from a backup, emits the
470
+ // `[restored-from-backup]` entry naming the backup index (REQ-238/T276).
471
+ activateHydratedNovel(slug) {
472
+ const novel = this.novels.get(slug);
473
+ if (!novel)
474
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' is not hydrated.`);
475
+ this.activeNovelId = slug;
476
+ const restoredIndex = novel.state_regression?.backup_index ?? null;
477
+ this.audit(novel, novel.badge, "resume_novel", restoredIndex !== null
478
+ ? { slug, restored_from_backup: restoredIndex }
479
+ : { slug });
480
+ this.checkWorkflowStaleness(novel);
481
+ return novel;
482
+ }
414
483
  // REQ-065 — hydrate the in-memory Novel registry from the on-disk novels/
415
484
  // directory at startup so list_novels reflects disk without an explicit
416
485
  // resume. Loads each save file (falling back to its .bak on a checksum
@@ -440,41 +509,27 @@ export class StateManager {
440
509
  const fileSlug = file.slice(0, -".json".length);
441
510
  const filePath = path.join(dir, file);
442
511
  let data;
512
+ let primaryData = null;
443
513
  try {
444
514
  data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
515
+ primaryData = data;
445
516
  }
446
517
  catch {
447
- this.corruptData.set(fileSlug, "unparseable JSON");
448
- process.stderr.write(`[holonovel] hydration: skipped unparseable novel file '${file}'.\n`);
449
- continue;
518
+ data = null; // structurally corrupt primary — fall through to backup restore
450
519
  }
451
- let restoredFromBackup = false;
452
- let bakData = null;
453
- const primaryData = data;
454
- if (data._checksum) {
455
- const payload = { ...data };
456
- delete payload._checksum;
457
- const computed = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
458
- if (computed !== data._checksum) {
459
- const bakPath = filePath + ".bak";
460
- if (fs.existsSync(bakPath)) {
461
- try {
462
- bakData = JSON.parse(fs.readFileSync(bakPath, "utf-8"));
463
- data = bakData;
464
- restoredFromBackup = true;
465
- }
466
- catch {
467
- this.corruptData.set(fileSlug, "checksum mismatch, unparseable backup");
468
- process.stderr.write(`[holonovel] hydration: skipped corrupt novel '${file}' (unreadable .bak).\n`);
469
- continue;
470
- }
471
- }
472
- else {
473
- this.corruptData.set(fileSlug, "checksum mismatch, no backup");
474
- process.stderr.write(`[holonovel] hydration: skipped corrupt novel '${file}' (no .bak).\n`);
475
- continue;
476
- }
520
+ let restoredIndex = null;
521
+ // REQ-238a a structural JSON error or checksum mismatch triggers the
522
+ // rotated-backup restore chain; no valid backup → the corrupt-novel path.
523
+ if (data === null || !this.hasValidChecksum(data)) {
524
+ const restored = this.restoreFromBackups(filePath);
525
+ if (!restored) {
526
+ const reason = data === null ? "unparseable JSON, no valid backup" : "checksum mismatch, no valid backup";
527
+ this.corruptData.set(fileSlug, reason);
528
+ process.stderr.write(`[holonovel] hydration: skipped corrupt novel '${file}' (${reason}).\n`);
529
+ continue;
477
530
  }
531
+ data = restored.data;
532
+ restoredIndex = restored.index;
478
533
  }
479
534
  let novel;
480
535
  try {
@@ -485,8 +540,13 @@ export class StateManager {
485
540
  process.stderr.write(`[holonovel] hydration: skipped novel '${file}' (${e.message}).\n`);
486
541
  continue;
487
542
  }
488
- if (restoredFromBackup && bakData) {
489
- novel.state_regression = { ...this.computeStateRegression(primaryData, bakData), recorded_at: new Date().toISOString() };
543
+ if (restoredIndex !== null) {
544
+ novel.state_regression = {
545
+ ...this.computeStateRegression(primaryData ?? data, data),
546
+ backup_index: restoredIndex,
547
+ recorded_at: new Date().toISOString(),
548
+ };
549
+ this.restoredThisSession.add(novel.slug);
490
550
  }
491
551
  const key = novel.slug || fileSlug;
492
552
  if (this.novels.has(key)) {
@@ -596,6 +656,9 @@ export class StateManager {
596
656
  active_session_id: data.active_session_id ?? null,
597
657
  pending_vow_countdown_suggestion: data.pending_vow_countdown_suggestion ?? null,
598
658
  codex_sources: data.codex_sources ?? [],
659
+ fate: normalizeFateState(data.fate),
660
+ ironsworn: normalizeIronswornState(data.ironsworn),
661
+ forged: normalizeForgedState(data.forged),
599
662
  };
600
663
  return novel;
601
664
  }
@@ -635,12 +698,14 @@ export class StateManager {
635
698
  const trashDir = path.join(this.stateDir, ".trash");
636
699
  fs.mkdirSync(trashDir, { recursive: true });
637
700
  const novelFile = path.join(this.stateDir, "novels", `${novel.slug}.json`);
638
- const bakFile = novelFile + ".bak";
639
701
  if (fs.existsSync(novelFile)) {
640
702
  fs.renameSync(novelFile, path.join(trashDir, `${novel.slug}-${Date.now()}.json`));
641
703
  }
642
- if (fs.existsSync(bakFile)) {
643
- fs.renameSync(bakFile, path.join(trashDir, `${novel.slug}-${Date.now()}.json.bak`));
704
+ // REQ-238b — move the whole backup chain (`.bak.N` + legacy `.bak`) to trash
705
+ // alongside the primary so no orphaned backups survive an ended Novel.
706
+ for (const bakPath of this.backupFilesOnDisk(novelFile)) {
707
+ const suffix = bakPath.slice(novelFile.length);
708
+ fs.renameSync(bakPath, path.join(trashDir, `${novel.slug}-${Date.now()}.json${suffix}`));
644
709
  }
645
710
  const archiveDir = path.join(this.stateDir, "archive");
646
711
  if (fs.existsSync(archiveDir)) {
@@ -669,9 +734,14 @@ export class StateManager {
669
734
  if (fs.existsSync(target))
670
735
  fs.unlinkSync(target);
671
736
  fs.renameSync(novelFile, target);
672
- const bak = novelFile + ".bak";
673
- if (fs.existsSync(bak))
674
- fs.renameSync(bak, target + ".bak");
737
+ // REQ-334 move the backup chain alongside the archived primary.
738
+ for (const bakPath of this.backupFilesOnDisk(novelFile)) {
739
+ const suffix = bakPath.slice(novelFile.length);
740
+ const dest = target + suffix;
741
+ if (fs.existsSync(dest))
742
+ fs.unlinkSync(dest);
743
+ fs.renameSync(bakPath, dest);
744
+ }
675
745
  this.novels.delete(slug);
676
746
  if (this.activeNovelId === slug)
677
747
  this.activeNovelId = null;
@@ -687,9 +757,14 @@ export class StateManager {
687
757
  if (fs.existsSync(target))
688
758
  throw new Error(`[STATE_CONFLICT] Novel '${slug}' already exists as active.`);
689
759
  fs.renameSync(archiveFile, target);
690
- const bak = archiveFile + ".bak";
691
- if (fs.existsSync(bak))
692
- fs.renameSync(bak, target + ".bak");
760
+ // REQ-334 restore the backup chain alongside the unarchived primary.
761
+ for (const bakPath of this.backupFilesOnDisk(archiveFile)) {
762
+ const suffix = bakPath.slice(archiveFile.length);
763
+ const dest = target + suffix;
764
+ if (fs.existsSync(dest))
765
+ fs.unlinkSync(dest);
766
+ fs.renameSync(bakPath, dest);
767
+ }
693
768
  return this.resumeNovel(slug);
694
769
  }
695
770
  archivedNovels() {
@@ -1151,12 +1226,68 @@ ${turnOrder}`;
1151
1226
  return novel.world.rooms.size > 0;
1152
1227
  }
1153
1228
  // ── Persistence ───────────────────────────────────────────────
1229
+ // REQ-238 — the number of rotated backups retained per Novel, configured via
1230
+ // TTRPG_NOVEL_BACKUP_COUNT (minimum 1; unset defaults to 1 = current behavior).
1231
+ backupCount() {
1232
+ const raw = parseInt(process.env.TTRPG_NOVEL_BACKUP_COUNT ?? "1", 10);
1233
+ return Number.isFinite(raw) && raw >= 1 ? raw : 1;
1234
+ }
1235
+ // REQ-238 — ordered restore candidates: `.bak.1..N` (newest first), then a
1236
+ // legacy singular `.bak` (pre-rotation saves) accepted as the index-1 candidate.
1237
+ backupCandidates(filePath) {
1238
+ const candidates = [];
1239
+ for (let i = 1; i <= this.backupCount(); i++)
1240
+ candidates.push({ path: `${filePath}.bak.${i}`, index: i });
1241
+ candidates.push({ path: `${filePath}.bak`, index: 1 });
1242
+ return candidates;
1243
+ }
1244
+ // REQ-238/REQ-092 — restore from the first parseable backup with a valid
1245
+ // checksum. Returns the winning data plus its backup index, or null when no
1246
+ // candidate is usable (REQ-238b hands off to the existing recovery path).
1247
+ restoreFromBackups(filePath) {
1248
+ for (const cand of this.backupCandidates(filePath)) {
1249
+ if (!fs.existsSync(cand.path))
1250
+ continue;
1251
+ try {
1252
+ const raw = fs.readFileSync(cand.path, "utf-8");
1253
+ const data = JSON.parse(raw);
1254
+ if (!this.hasValidChecksum(data))
1255
+ continue;
1256
+ return { data, index: cand.index };
1257
+ }
1258
+ catch {
1259
+ continue; // structurally corrupt or unreadable candidate — try the next
1260
+ }
1261
+ }
1262
+ return null;
1263
+ }
1264
+ // REQ-238 — enumerate every backup file on disk for a primary file path: the
1265
+ // rotated `<file>.bak.N` chain plus the legacy singular `<file>.bak`.
1266
+ backupFilesOnDisk(filePath) {
1267
+ const dir = path.dirname(filePath);
1268
+ const base = path.basename(filePath);
1269
+ const legacy = `${base}.bak`;
1270
+ const prefix = `${base}.bak.`;
1271
+ const found = [];
1272
+ let entries = [];
1273
+ try {
1274
+ entries = fs.readdirSync(dir);
1275
+ }
1276
+ catch {
1277
+ return found;
1278
+ }
1279
+ for (const e of entries) {
1280
+ if (e === legacy || e.startsWith(prefix))
1281
+ found.push(path.join(dir, e));
1282
+ }
1283
+ return found.sort();
1284
+ }
1154
1285
  saveNovel(novel) {
1155
1286
  const dir = path.join(this.stateDir, "novels");
1156
1287
  fs.mkdirSync(dir, { recursive: true });
1157
1288
  const filePath = path.join(dir, `${novel.slug}.json`);
1158
1289
  const tmpPath = filePath + `.${process.pid}-${Date.now()}.tmp`;
1159
- const bakPath = filePath + ".bak";
1290
+ const count = this.backupCount();
1160
1291
  novel.metadata.modified = new Date().toISOString();
1161
1292
  // Defensive guard: the undo/redo stacks are internal bookkeeping. If they
1162
1293
  // have grown pathologically (e.g. a snapshot regression embedded prior
@@ -1181,8 +1312,30 @@ ${turnOrder}`;
1181
1312
  payload.spec_version = SPEC_VERSION;
1182
1313
  payload._checksum = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1183
1314
  const out = JSON.stringify(payload, null, 2);
1184
- if (fs.existsSync(filePath)) {
1185
- fs.copyFileSync(filePath, bakPath);
1315
+ // REQ-238 — rotate the backup chain before committing the new primary:
1316
+ // `.bak.N-1` → `.bak.N`, … `.bak.1` → `.bak.2`, then the previous primary
1317
+ // becomes `.bak.1`. count=1 writes only `.bak.1` (the previous primary).
1318
+ if (count > 1) {
1319
+ for (let i = count - 1; i >= 1; i--) {
1320
+ const from = `${filePath}.bak.${i}`;
1321
+ const to = `${filePath}.bak.${i + 1}`;
1322
+ if (fs.existsSync(from)) {
1323
+ if (fs.existsSync(to))
1324
+ fs.unlinkSync(to);
1325
+ fs.renameSync(from, to);
1326
+ }
1327
+ }
1328
+ }
1329
+ if (fs.existsSync(filePath) && !this.restoredThisSession.delete(novel.slug)) {
1330
+ fs.copyFileSync(filePath, `${filePath}.bak.1`);
1331
+ }
1332
+ // Prune backups beyond the configured chain (e.g. TTRPG_NOVEL_BACKUP_COUNT
1333
+ // lowered between runs); breaks immediately when no stale index remains.
1334
+ for (let i = count + 1;; i++) {
1335
+ const stale = `${filePath}.bak.${i}`;
1336
+ if (!fs.existsSync(stale))
1337
+ break;
1338
+ fs.unlinkSync(stale);
1186
1339
  }
1187
1340
  const fd = fs.openSync(tmpPath, "w");
1188
1341
  fs.writeFileSync(fd, out, "utf-8");
@@ -1399,6 +1552,9 @@ function novelToJSON(novel) {
1399
1552
  mutation_counts_by_group: novel.mutation_counts_by_group,
1400
1553
  uncommitted_rolls: novel.uncommitted_rolls,
1401
1554
  metadata: novel.metadata,
1555
+ fate: novel.fate,
1556
+ ironsworn: novel.ironsworn,
1557
+ forged: novel.forged,
1402
1558
  };
1403
1559
  }
1404
1560
  // Snapshot-clone serialization: identical to novelToJSON but omits the
@@ -1505,6 +1661,9 @@ function novelFromJSON(data) {
1505
1661
  active_session_id: data.active_session_id ?? null,
1506
1662
  pending_vow_countdown_suggestion: data.pending_vow_countdown_suggestion ?? null,
1507
1663
  codex_sources: data.codex_sources ?? [],
1664
+ fate: normalizeFateState(data.fate),
1665
+ ironsworn: normalizeIronswornState(data.ironsworn),
1666
+ forged: normalizeForgedState(data.forged),
1508
1667
  };
1509
1668
  }
1510
1669
  // Serialize a Novel to its full interchange form (REQ-096). Returns the flat
@@ -1587,5 +1746,8 @@ export function applyNovelState(target, source) {
1587
1746
  target.mutation_counts_by_group = source.mutation_counts_by_group;
1588
1747
  target.uncommitted_rolls = source.uncommitted_rolls;
1589
1748
  target.metadata = source.metadata;
1749
+ target.fate = source.fate;
1750
+ target.ironsworn = source.ironsworn;
1751
+ target.forged = source.forged;
1590
1752
  }
1591
1753
  //# sourceMappingURL=state.js.map