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.
- package/dist/core/state.d.ts +57 -1
- package/dist/core/state.js +307 -35
- 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 +3626 -3136
- 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,8 +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>;
|
|
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;
|
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",
|
|
@@ -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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
this.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
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
|
-
|
|
1075
|
-
|
|
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
|