holonovel 2026.9.2 → 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,8 +505,10 @@ export declare class StateManager {
459
505
  narrative_tag?: string;
460
506
  }>;
461
507
  codex: Map<string, CodexEntry>;
462
- readonly dataFormat = "a425bc2a1642c23d554dee2ef867ab7acbfb527245838a6423701d03fca77ffb";
508
+ readonly dataFormat = "daff3d95da3bc1b51cf1b521a9d6a973aa95cccd3b6b7f777f54e11e95484161";
463
509
  staleData: Map<string, string>;
510
+ corruptData: Map<string, string>;
511
+ private restoredThisSession;
464
512
  private npcCounter;
465
513
  private entityCounter;
466
514
  private stateDir;
@@ -475,7 +523,11 @@ export declare class StateManager {
475
523
  resolveEntityNullable(entityId?: string): NovelEntity | undefined;
476
524
  createNovel(name: string, ruleset?: string | null): NovelState;
477
525
  bindNovelRuleset(slug: string): NovelState;
526
+ private computeStateRegression;
527
+ private hasValidChecksum;
478
528
  resumeNovel(slug: string): NovelState;
529
+ activateHydratedNovel(slug: string): NovelState;
530
+ hydrateNovelsFromDisk(): void;
479
531
  private loadNovelFromData;
480
532
  switchNovel(slug: string): NovelState;
481
533
  renameNovel(novel: NovelState, newSlug: string): NovelState;
@@ -534,6 +586,10 @@ export declare class StateManager {
534
586
  };
535
587
  combatReport(novel: NovelState): string;
536
588
  worldHasRooms(novel: NovelState): boolean;
589
+ private backupCount;
590
+ private backupCandidates;
591
+ private restoreFromBackups;
592
+ private backupFilesOnDisk;
537
593
  saveNovel(novel: NovelState): void;
538
594
  saveRoster(): void;
539
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",
@@ -182,6 +209,15 @@ export class StateManager {
182
209
  // persisted artifacts whose fingerprint differs (flagged [data-stale]).
183
210
  dataFormat = DATA_FORMAT;
184
211
  staleData = new Map();
212
+ // REQ-001a — Novels whose save file could not be loaded at hydration or
213
+ // resume (unparseable JSON, checksum mismatch, or data-migration failure),
214
+ // surfaced as a [WARNING] in spec_health.data_health.corrupted.
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();
185
221
  npcCounter = 0;
186
222
  entityCounter = 0;
187
223
  stateDir;
@@ -341,6 +377,9 @@ export class StateManager {
341
377
  active_session_id: null,
342
378
  pending_vow_countdown_suggestion: null,
343
379
  codex_sources: [],
380
+ fate: { aspects: [], fate_points: {}, stress: {} },
381
+ ironsworn: { momentum: {}, progress_tracks: [] },
382
+ forged: { characters: {} },
344
383
  };
345
384
  this.novels.set(slug, novel);
346
385
  this.activeNovelId = slug;
@@ -363,34 +402,59 @@ export class StateManager {
363
402
  this.saveNovel(novel);
364
403
  return novel;
365
404
  }
405
+ // REQ-406 — compute the content regression incurred by a backup restore:
406
+ // the audit-log entries and wall-clock age lost relative to the corrupted
407
+ // primary. Shared by resumeNovel and hydrateNovelsFromDisk so the two paths
408
+ // report identical state_regression values.
409
+ computeStateRegression(data, bakData) {
410
+ const auditGap = (data.audit_log?.length ?? 0) - (bakData.audit_log?.length ?? 0);
411
+ const tsGap = (new Date((data.metadata?.modified ?? bakData.metadata?.modified) ?? new Date()).getTime())
412
+ - new Date(bakData.metadata?.modified ?? new Date()).getTime();
413
+ return { audit_gap: Math.max(0, auditGap), timestamp_gap_ms: Math.max(0, tsGap) };
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
+ }
366
425
  resumeNovel(slug) {
367
426
  const filePath = path.join(this.stateDir, "novels", `${slug}.json`);
368
427
  if (!fs.existsSync(filePath))
369
428
  throw new Error(`[STATE_CONFLICT] Novel '${slug}' does not exist on disk.`);
370
- const raw = fs.readFileSync(filePath, "utf-8");
371
- const data = JSON.parse(raw);
372
- if (data._checksum) {
373
- const payload = { ...data };
374
- delete payload._checksum;
375
- const computed = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
376
- if (computed !== data._checksum) {
377
- const bakPath = filePath + ".bak";
378
- if (fs.existsSync(bakPath)) {
379
- const bakRaw = fs.readFileSync(bakPath, "utf-8");
380
- const bakData = JSON.parse(bakRaw);
381
- const loaded = this.loadNovelFromData(bakData);
382
- // REQ-406 surface the backup-restore content regression.
383
- const auditGap = (data.audit_log?.length ?? 0) - (bakData.audit_log?.length ?? 0);
384
- const tsGap = (new Date((data.metadata?.modified ?? bakData.metadata?.modified) ?? new Date()).getTime())
385
- - new Date(bakData.metadata?.modified ?? new Date()).getTime();
386
- loaded.state_regression = { audit_gap: Math.max(0, auditGap), timestamp_gap_ms: Math.max(0, tsGap), recorded_at: new Date().toISOString() };
387
- this.novels.set(slug, loaded);
388
- this.activeNovelId = slug;
389
- this.audit(loaded, loaded.badge, "resume_novel", { slug, restored_from_backup: true });
390
- return loaded;
391
- }
392
- 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;
393
455
  }
456
+ this.corruptData.set(slug, "corrupt primary, no valid backup");
457
+ throw new Error(`[STATE_CONFLICT] Novel '${slug}' is corrupted.`);
394
458
  }
395
459
  const novel = this.loadNovelFromData(data);
396
460
  this.novels.set(slug, novel);
@@ -399,6 +463,107 @@ export class StateManager {
399
463
  this.checkWorkflowStaleness(novel);
400
464
  return novel;
401
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
+ }
483
+ // REQ-065 — hydrate the in-memory Novel registry from the on-disk novels/
484
+ // directory at startup so list_novels reflects disk without an explicit
485
+ // resume. Loads each save file (falling back to its .bak on a checksum
486
+ // mismatch) without activating a Novel, auditing, or bumping session count.
487
+ //
488
+ // Keying note: the registry is keyed by each Novel's *internal* slug (from
489
+ // data.slug, falling back to the filename), so list/info/create/switch/
490
+ // archive/rename/resume all agree even when a save file's name diverges from
491
+ // its slug (e.g. a file copied in by consolidate-novels.ts). Duplicate
492
+ // internal slugs resolve deterministically: the canonical `<slug>.json`
493
+ // filename wins; otherwise the first file in sorted order is kept.
494
+ //
495
+ // REQ-193a/REQ-224a — hydration is not a "connection" to a Novel, so it
496
+ // deliberately does NOT advance the pending-workflow staleness counter.
497
+ hydrateNovelsFromDisk() {
498
+ const dir = path.join(this.stateDir, "novels");
499
+ let entries;
500
+ try {
501
+ entries = fs.readdirSync(dir);
502
+ }
503
+ catch {
504
+ return; // no novels dir yet — nothing to hydrate
505
+ }
506
+ entries = entries.filter((f) => f.endsWith(".json")).sort();
507
+ const canonicalKeys = new Set();
508
+ for (const file of entries) {
509
+ const fileSlug = file.slice(0, -".json".length);
510
+ const filePath = path.join(dir, file);
511
+ let data;
512
+ let primaryData = null;
513
+ try {
514
+ data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
515
+ primaryData = data;
516
+ }
517
+ catch {
518
+ data = null; // structurally corrupt primary — fall through to backup restore
519
+ }
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;
530
+ }
531
+ data = restored.data;
532
+ restoredIndex = restored.index;
533
+ }
534
+ let novel;
535
+ try {
536
+ novel = this.loadNovelFromData(data);
537
+ }
538
+ catch (e) {
539
+ this.corruptData.set(fileSlug, `load failed: ${e.message}`);
540
+ process.stderr.write(`[holonovel] hydration: skipped novel '${file}' (${e.message}).\n`);
541
+ continue;
542
+ }
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);
550
+ }
551
+ const key = novel.slug || fileSlug;
552
+ if (this.novels.has(key)) {
553
+ const candidateCanonical = fileSlug === key;
554
+ const existingCanonical = canonicalKeys.has(key);
555
+ if (candidateCanonical && !existingCanonical) {
556
+ this.novels.set(key, novel); // canonical filename displaces a misnamed copy
557
+ canonicalKeys.add(key);
558
+ }
559
+ process.stderr.write(`[holonovel] hydration: duplicate slug '${key}' — skipped '${file}'.\n`);
560
+ continue;
561
+ }
562
+ this.novels.set(key, novel);
563
+ if (fileSlug === key)
564
+ canonicalKeys.add(key);
565
+ }
566
+ }
402
567
  loadNovelFromData(data) {
403
568
  // REQ-423 — a Novel written under a prior (or absent) data-format
404
569
  // fingerprint is flagged [data-stale]; it still loads per REQ-065.
@@ -491,6 +656,9 @@ export class StateManager {
491
656
  active_session_id: data.active_session_id ?? null,
492
657
  pending_vow_countdown_suggestion: data.pending_vow_countdown_suggestion ?? null,
493
658
  codex_sources: data.codex_sources ?? [],
659
+ fate: normalizeFateState(data.fate),
660
+ ironsworn: normalizeIronswornState(data.ironsworn),
661
+ forged: normalizeForgedState(data.forged),
494
662
  };
495
663
  return novel;
496
664
  }
@@ -506,6 +674,11 @@ export class StateManager {
506
674
  }
507
675
  renameNovel(novel, newSlug) {
508
676
  const oldSlug = novel.slug;
677
+ // REQ-256 — refuse a rename onto an existing slug (in-memory or on disk)
678
+ // rather than silently overwriting the target's save file.
679
+ if (newSlug !== oldSlug && (this.novels.has(newSlug) || fs.existsSync(path.join(this.stateDir, "novels", `${newSlug}.json`)))) {
680
+ throw new Error(`[STATE_CONFLICT] Novel '${newSlug}' already exists.`);
681
+ }
509
682
  novel.slug = newSlug;
510
683
  novel.name = newSlug;
511
684
  this.novels.delete(oldSlug);
@@ -525,12 +698,14 @@ export class StateManager {
525
698
  const trashDir = path.join(this.stateDir, ".trash");
526
699
  fs.mkdirSync(trashDir, { recursive: true });
527
700
  const novelFile = path.join(this.stateDir, "novels", `${novel.slug}.json`);
528
- const bakFile = novelFile + ".bak";
529
701
  if (fs.existsSync(novelFile)) {
530
702
  fs.renameSync(novelFile, path.join(trashDir, `${novel.slug}-${Date.now()}.json`));
531
703
  }
532
- if (fs.existsSync(bakFile)) {
533
- 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}`));
534
709
  }
535
710
  const archiveDir = path.join(this.stateDir, "archive");
536
711
  if (fs.existsSync(archiveDir)) {
@@ -559,9 +734,14 @@ export class StateManager {
559
734
  if (fs.existsSync(target))
560
735
  fs.unlinkSync(target);
561
736
  fs.renameSync(novelFile, target);
562
- const bak = novelFile + ".bak";
563
- if (fs.existsSync(bak))
564
- 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
+ }
565
745
  this.novels.delete(slug);
566
746
  if (this.activeNovelId === slug)
567
747
  this.activeNovelId = null;
@@ -577,9 +757,14 @@ export class StateManager {
577
757
  if (fs.existsSync(target))
578
758
  throw new Error(`[STATE_CONFLICT] Novel '${slug}' already exists as active.`);
579
759
  fs.renameSync(archiveFile, target);
580
- const bak = archiveFile + ".bak";
581
- if (fs.existsSync(bak))
582
- 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
+ }
583
768
  return this.resumeNovel(slug);
584
769
  }
585
770
  archivedNovels() {
@@ -1041,12 +1226,68 @@ ${turnOrder}`;
1041
1226
  return novel.world.rooms.size > 0;
1042
1227
  }
1043
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
+ }
1044
1285
  saveNovel(novel) {
1045
1286
  const dir = path.join(this.stateDir, "novels");
1046
1287
  fs.mkdirSync(dir, { recursive: true });
1047
1288
  const filePath = path.join(dir, `${novel.slug}.json`);
1048
1289
  const tmpPath = filePath + `.${process.pid}-${Date.now()}.tmp`;
1049
- const bakPath = filePath + ".bak";
1290
+ const count = this.backupCount();
1050
1291
  novel.metadata.modified = new Date().toISOString();
1051
1292
  // Defensive guard: the undo/redo stacks are internal bookkeeping. If they
1052
1293
  // have grown pathologically (e.g. a snapshot regression embedded prior
@@ -1071,8 +1312,30 @@ ${turnOrder}`;
1071
1312
  payload.spec_version = SPEC_VERSION;
1072
1313
  payload._checksum = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1073
1314
  const out = JSON.stringify(payload, null, 2);
1074
- if (fs.existsSync(filePath)) {
1075
- 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);
1076
1339
  }
1077
1340
  const fd = fs.openSync(tmpPath, "w");
1078
1341
  fs.writeFileSync(fd, out, "utf-8");
@@ -1289,6 +1552,9 @@ function novelToJSON(novel) {
1289
1552
  mutation_counts_by_group: novel.mutation_counts_by_group,
1290
1553
  uncommitted_rolls: novel.uncommitted_rolls,
1291
1554
  metadata: novel.metadata,
1555
+ fate: novel.fate,
1556
+ ironsworn: novel.ironsworn,
1557
+ forged: novel.forged,
1292
1558
  };
1293
1559
  }
1294
1560
  // Snapshot-clone serialization: identical to novelToJSON but omits the
@@ -1395,6 +1661,9 @@ function novelFromJSON(data) {
1395
1661
  active_session_id: data.active_session_id ?? null,
1396
1662
  pending_vow_countdown_suggestion: data.pending_vow_countdown_suggestion ?? null,
1397
1663
  codex_sources: data.codex_sources ?? [],
1664
+ fate: normalizeFateState(data.fate),
1665
+ ironsworn: normalizeIronswornState(data.ironsworn),
1666
+ forged: normalizeForgedState(data.forged),
1398
1667
  };
1399
1668
  }
1400
1669
  // Serialize a Novel to its full interchange form (REQ-096). Returns the flat
@@ -1477,5 +1746,8 @@ export function applyNovelState(target, source) {
1477
1746
  target.mutation_counts_by_group = source.mutation_counts_by_group;
1478
1747
  target.uncommitted_rolls = source.uncommitted_rolls;
1479
1748
  target.metadata = source.metadata;
1749
+ target.fate = source.fate;
1750
+ target.ironsworn = source.ironsworn;
1751
+ target.forged = source.forged;
1480
1752
  }
1481
1753
  //# sourceMappingURL=state.js.map