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.
- package/dist/core/state.d.ts +54 -1
- package/dist/core/state.js +226 -64
- package/dist/core/state.js.map +1 -1
- package/dist/generated/contract-fingerprints.d.ts +2 -2
- package/dist/generated/contract-fingerprints.js +2 -2
- package/dist/index.js +711 -33
- package/dist/index.js.map +1 -1
- package/dist/rulesets.d.ts +13 -0
- package/dist/rulesets.js +103 -4
- package/dist/rulesets.js.map +1 -1
- package/package.json +6 -2
package/dist/core/state.d.ts
CHANGED
|
@@ -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 = "
|
|
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;
|
package/dist/core/state.js
CHANGED
|
@@ -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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (data.
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 (
|
|
489
|
-
novel.state_regression = {
|
|
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
|
-
|
|
643
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
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
|
-
|
|
1185
|
-
|
|
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
|