@rohal12/spindle 0.3.2 → 0.4.0

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/src/expression.ts CHANGED
@@ -1,35 +1,88 @@
1
1
  import type { StoryState } from './store';
2
2
  import { useStoryStore } from './store';
3
+ import type { Passage } from './parser';
4
+ import { random, randomInt } from './prng';
3
5
 
4
- const fnCache = new Map<string, Function>();
6
+ interface ExpressionFns {
7
+ currentPassage: () => Passage | undefined;
8
+ previousPassage: () => Passage | undefined;
9
+ visited: (name: string) => number;
10
+ hasVisited: (name: string) => boolean;
11
+ hasVisitedAny: (...names: string[]) => boolean;
12
+ hasVisitedAll: (...names: string[]) => boolean;
13
+ rendered: (name: string) => number;
14
+ hasRendered: (name: string) => boolean;
15
+ hasRenderedAny: (...names: string[]) => boolean;
16
+ hasRenderedAll: (...names: string[]) => boolean;
17
+ random: () => number;
18
+ randomInt: (min: number, max: number) => number;
19
+ }
20
+
21
+ type CompiledExpression = (
22
+ variables: Record<string, unknown>,
23
+ temporary: Record<string, unknown>,
24
+ __fns: ExpressionFns,
25
+ ) => unknown;
26
+
27
+ const FN_CACHE_MAX = 500;
28
+ const fnCache = new Map<string, CompiledExpression>();
5
29
 
6
30
  /**
7
31
  * Transform expression: $var → variables["var"], _var → temporary["var"]
8
32
  * Only transforms when $ or _ appears as a word boundary (not inside strings naively,
9
33
  * but authors already have full JS access so this is acceptable).
10
34
  */
35
+ const VAR_RE = /\$(\w+)/g;
36
+ const TEMP_RE = /\b_(\w+)/g;
37
+
11
38
  function transform(expr: string): string {
12
39
  return expr
13
- .replace(/\$(\w+)/g, 'variables["$1"]')
14
- .replace(/\b_(\w+)/g, 'temporary["$1"]');
40
+ .replace(VAR_RE, 'variables["$1"]')
41
+ .replace(TEMP_RE, 'temporary["$1"]');
15
42
  }
16
43
 
17
44
  const preamble =
18
- 'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll}=__fns;';
45
+ 'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
19
46
 
20
- function getOrCompile(key: string, body: string): Function {
21
- let fn = fnCache.get(key);
22
- if (!fn) {
23
- fn = new Function('variables', 'temporary', '__fns', preamble + body);
24
- fnCache.set(key, fn);
47
+ function getOrCompile(key: string, body: string): CompiledExpression {
48
+ const cached = fnCache.get(key);
49
+ if (cached) {
50
+ // Move to end for LRU ordering (Map preserves insertion order)
51
+ fnCache.delete(key);
52
+ fnCache.set(key, cached);
53
+ return cached;
54
+ }
55
+ const fn = new Function(
56
+ 'variables',
57
+ 'temporary',
58
+ '__fns',
59
+ preamble + body,
60
+ ) as CompiledExpression;
61
+ fnCache.set(key, fn);
62
+ if (fnCache.size > FN_CACHE_MAX) {
63
+ // Evict oldest entry
64
+ const oldest = fnCache.keys().next().value;
65
+ if (oldest !== undefined) fnCache.delete(oldest);
25
66
  }
26
67
  return fn;
27
68
  }
28
69
 
70
+ let cachedFns: ExpressionFns | null = null;
71
+ let cachedVisitCounts: Record<string, number> | null = null;
72
+ let cachedRenderCounts: Record<string, number> | null = null;
73
+
29
74
  export function buildExpressionFns() {
30
75
  const state = useStoryStore.getState();
31
76
  const { visitCounts, renderCounts } = state;
32
77
 
78
+ if (
79
+ cachedFns &&
80
+ cachedVisitCounts === visitCounts &&
81
+ cachedRenderCounts === renderCounts
82
+ ) {
83
+ return cachedFns;
84
+ }
85
+
33
86
  const visited = (name: string): number => visitCounts[name] ?? 0;
34
87
  const hasVisited = (name: string): boolean => visited(name) > 0;
35
88
  const hasVisitedAny = (...names: string[]): boolean =>
@@ -44,7 +97,20 @@ export function buildExpressionFns() {
44
97
  const hasRenderedAll = (...names: string[]): boolean =>
45
98
  names.every((n) => rendered(n) > 0);
46
99
 
47
- return {
100
+ const currentPassage = (): Passage | undefined => {
101
+ const s = useStoryStore.getState();
102
+ return s.storyData?.passages.get(s.currentPassage);
103
+ };
104
+ const previousPassage = (): Passage | undefined => {
105
+ const s = useStoryStore.getState();
106
+ if (s.historyIndex <= 0) return undefined;
107
+ const prevName = s.history[s.historyIndex - 1]?.passage;
108
+ return prevName ? s.storyData?.passages.get(prevName) : undefined;
109
+ };
110
+
111
+ cachedFns = {
112
+ currentPassage,
113
+ previousPassage,
48
114
  visited,
49
115
  hasVisited,
50
116
  hasVisitedAny,
@@ -53,7 +119,13 @@ export function buildExpressionFns() {
53
119
  hasRendered,
54
120
  hasRenderedAny,
55
121
  hasRenderedAll,
122
+ random,
123
+ randomInt,
56
124
  };
125
+ cachedVisitCounts = visitCounts;
126
+ cachedRenderCounts = renderCounts;
127
+
128
+ return cachedFns;
57
129
  }
58
130
 
59
131
  /**
@@ -0,0 +1,26 @@
1
+ import { useContext, useMemo } from 'preact/hooks';
2
+ import { useStoryStore } from '../store';
3
+ import { LocalsContext } from '../markup/render';
4
+
5
+ /**
6
+ * Merge store variables/temporary with LocalsContext values.
7
+ * Locals prefixed with `$` go into variables, `_` into temporary.
8
+ */
9
+ export function useMergedLocals(): readonly [
10
+ Record<string, unknown>,
11
+ Record<string, unknown>,
12
+ ] {
13
+ const variables = useStoryStore((s) => s.variables);
14
+ const temporary = useStoryStore((s) => s.temporary);
15
+ const locals = useContext(LocalsContext);
16
+
17
+ return useMemo(() => {
18
+ const vars = { ...variables };
19
+ const temps = { ...temporary };
20
+ for (const [key, val] of Object.entries(locals)) {
21
+ if (key.startsWith('$')) vars[key.slice(1)] = val;
22
+ else if (key.startsWith('_')) temps[key.slice(1)] = val;
23
+ }
24
+ return [vars, temps] as const;
25
+ }, [variables, temporary, locals]);
26
+ }
package/src/markup/ast.ts CHANGED
@@ -87,10 +87,10 @@ export function buildAST(tokens: Token[]): ASTNode[] {
87
87
 
88
88
  function current(): ASTNode[] {
89
89
  if (stack.length === 0) return root;
90
- const top = stack[stack.length - 1].node;
90
+ const top = stack[stack.length - 1]!.node;
91
91
  // For if-blocks, append to the last branch's children
92
92
  if (top.type === 'macro' && top.branches && top.branches.length > 0) {
93
- return top.branches[top.branches.length - 1].children;
93
+ return top.branches[top.branches.length - 1]!.children;
94
94
  }
95
95
  return top.children;
96
96
  }
@@ -145,7 +145,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
145
145
  );
146
146
  }
147
147
 
148
- const top = stack[stack.length - 1];
148
+ const top = stack[stack.length - 1]!;
149
149
  if (top.node.type !== 'html' || top.node.tag !== token.tag) {
150
150
  const expected =
151
151
  top.node.type === 'html'
@@ -181,7 +181,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
181
181
  );
182
182
  }
183
183
 
184
- const top = stack[stack.length - 1];
184
+ const top = stack[stack.length - 1]!;
185
185
  if (top.node.type !== 'macro' || top.node.name !== token.name) {
186
186
  const expected =
187
187
  top.node.type === 'macro'
@@ -199,9 +199,9 @@ export function buildAST(tokens: Token[]): ASTNode[] {
199
199
 
200
200
  // Handle branch macros (elseif/else, case/default, next)
201
201
  if (BRANCH_PARENT[token.name]) {
202
- const expectedParent = BRANCH_PARENT[token.name];
202
+ const expectedParent = BRANCH_PARENT[token.name]!;
203
203
  const topNode =
204
- stack.length > 0 ? stack[stack.length - 1].node : null;
204
+ stack.length > 0 ? stack[stack.length - 1]!.node : null;
205
205
  if (
206
206
  !topNode ||
207
207
  topNode.type !== 'macro' ||
@@ -263,11 +263,16 @@ export function buildAST(tokens: Token[]): ASTNode[] {
263
263
  }
264
264
  break;
265
265
  }
266
+
267
+ default: {
268
+ const _exhaustive: never = token;
269
+ throw new Error(`Unknown token type: ${(_exhaustive as Token).type}`);
270
+ }
266
271
  }
267
272
  }
268
273
 
269
274
  if (stack.length > 0) {
270
- const unclosed = stack[stack.length - 1];
275
+ const unclosed = stack[stack.length - 1]!;
271
276
  const label =
272
277
  unclosed.node.type === 'html'
273
278
  ? `<${unclosed.node.tag}>`
@@ -38,10 +38,12 @@ import { getWidget } from '../widgets/widget-registry';
38
38
  import { getMacro } from '../registry';
39
39
  import { markdownToHtml } from './markdown';
40
40
  import { h } from 'preact';
41
- import type { ASTNode, MacroNode } from './ast';
41
+ import type { ASTNode, Branch, MacroNode } from './ast';
42
42
 
43
43
  export const LocalsContext = createContext<Record<string, unknown>>({});
44
44
 
45
+ const EMPTY_BRANCHES: Branch[] = [];
46
+
45
47
  /**
46
48
  * Convert an HTML string (from micromark) to Preact VNodes,
47
49
  * replacing <span data-tw="N"> placeholder elements with pre-rendered components.
@@ -77,7 +79,7 @@ function convertDomNode(
77
79
  }
78
80
 
79
81
  // Convert attributes
80
- const props: Record<string, any> = { key };
82
+ const props: Record<string, string | number> = { key };
81
83
  for (const attr of Array.from(el.attributes)) {
82
84
  props[attr.name] = attr.value;
83
85
  }
@@ -134,7 +136,7 @@ function renderMacro(node: MacroNode, key: number) {
134
136
  return (
135
137
  <If
136
138
  key={key}
137
- branches={node.branches!}
139
+ branches={node.branches ?? EMPTY_BRANCHES}
138
140
  />
139
141
  );
140
142
 
@@ -354,7 +356,7 @@ function renderMacro(node: MacroNode, key: number) {
354
356
  <Switch
355
357
  key={key}
356
358
  rawArgs={node.rawArgs}
357
- branches={node.branches!}
359
+ branches={node.branches ?? EMPTY_BRANCHES}
358
360
  />
359
361
  );
360
362
 
@@ -364,7 +366,7 @@ function renderMacro(node: MacroNode, key: number) {
364
366
  key={key}
365
367
  rawArgs={node.rawArgs}
366
368
  children={node.children}
367
- branches={node.branches!}
369
+ branches={node.branches ?? EMPTY_BRANCHES}
368
370
  className={node.className}
369
371
  id={node.id}
370
372
  />
@@ -488,6 +490,11 @@ function renderSingleNode(
488
490
  { key, ...node.attributes },
489
491
  node.children.length > 0 ? renderNodes(node.children) : undefined,
490
492
  );
493
+
494
+ default: {
495
+ const _exhaustive: never = node;
496
+ return _exhaustive;
497
+ }
491
498
  }
492
499
  }
493
500
 
@@ -526,7 +533,7 @@ export function renderNodes(nodes: ASTNode[]): preact.ComponentChildren {
526
533
  let combined = '';
527
534
 
528
535
  for (let i = 0; i < nodes.length; i++) {
529
- const node = nodes[i];
536
+ const node = nodes[i]!;
530
537
  if (node.type === 'text') {
531
538
  combined += node.value;
532
539
  } else {
@@ -196,10 +196,10 @@ function parseSelectors(
196
196
  let i = startIdx;
197
197
 
198
198
  while (i < input.length && (input[i] === '.' || input[i] === '#')) {
199
- const prefix = input[i];
199
+ const prefix = input[i]!;
200
200
  i++; // skip the . or #
201
201
  const nameStart = i;
202
- while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i])) i++;
202
+ while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i]!)) i++;
203
203
  if (i > nameStart) {
204
204
  const name = input.slice(nameStart, i);
205
205
  if (prefix === '.') {
@@ -225,7 +225,7 @@ function parseHtmlAttributes(
225
225
 
226
226
  while (j < input.length) {
227
227
  // Skip whitespace
228
- while (j < input.length && /\s/.test(input[j])) j++;
228
+ while (j < input.length && /\s/.test(input[j]!)) j++;
229
229
  // End of tag?
230
230
  if (
231
231
  j >= input.length ||
@@ -236,7 +236,7 @@ function parseHtmlAttributes(
236
236
 
237
237
  // Read attribute name
238
238
  const attrStart = j;
239
- while (j < input.length && /[a-zA-Z0-9_-]/.test(input[j])) j++;
239
+ while (j < input.length && /[a-zA-Z0-9_-]/.test(input[j]!)) j++;
240
240
  const attrName = input.slice(attrStart, j);
241
241
  if (!attrName) break;
242
242
 
@@ -244,7 +244,7 @@ function parseHtmlAttributes(
244
244
  if (input[j] === '=') {
245
245
  j++; // skip =
246
246
  if (input[j] === '"' || input[j] === "'") {
247
- const quote = input[j];
247
+ const quote = input[j]!;
248
248
  j++; // skip opening quote
249
249
  const valStart = j;
250
250
  while (j < input.length && input[j] !== quote) j++;
@@ -253,7 +253,7 @@ function parseHtmlAttributes(
253
253
  } else {
254
254
  // Unquoted value
255
255
  const valStart = j;
256
- while (j < input.length && /[^\s>]/.test(input[j])) j++;
256
+ while (j < input.length && /[^\s>]/.test(input[j]!)) j++;
257
257
  attributes[attrName] = input.slice(valStart, j);
258
258
  }
259
259
  } else {
@@ -367,7 +367,7 @@ export function tokenize(input: string): Token[] {
367
367
  // {.class#id $variable.field}
368
368
  i = afterSelectors + 1;
369
369
  const nameStart = i;
370
- while (i < input.length && /[\w.]/.test(input[i])) i++;
370
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
371
371
  const name = input.slice(nameStart, i);
372
372
 
373
373
  if (input[i] === '}') {
@@ -395,7 +395,7 @@ export function tokenize(input: string): Token[] {
395
395
  // {.class#id _temporary.field}
396
396
  i = afterSelectors + 1;
397
397
  const nameStart = i;
398
- while (i < input.length && /[\w.]/.test(input[i])) i++;
398
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
399
399
  const name = input.slice(nameStart, i);
400
400
 
401
401
  if (input[i] === '}') {
@@ -468,7 +468,7 @@ export function tokenize(input: string): Token[] {
468
468
  flushText(i);
469
469
  i += 2;
470
470
  const nameStart = i;
471
- while (i < input.length && /[\w.]/.test(input[i])) i++;
471
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
472
472
  const name = input.slice(nameStart, i);
473
473
 
474
474
  if (input[i] === '}') {
@@ -494,7 +494,7 @@ export function tokenize(input: string): Token[] {
494
494
  flushText(i);
495
495
  i += 2;
496
496
  const nameStart = i;
497
- while (i < input.length && /[\w.]/.test(input[i])) i++;
497
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
498
498
  const name = input.slice(nameStart, i);
499
499
 
500
500
  if (input[i] === '}') {
@@ -572,14 +572,14 @@ export function tokenize(input: string): Token[] {
572
572
 
573
573
  // Read tag name
574
574
  const tagStart = j;
575
- while (j < input.length && /[a-zA-Z0-9]/.test(input[j])) j++;
575
+ while (j < input.length && /[a-zA-Z0-9]/.test(input[j]!)) j++;
576
576
  const tag = input.slice(tagStart, j).toLowerCase();
577
577
 
578
578
  // Only handle known HTML tags
579
579
  if (tag && HTML_TAGS.has(tag)) {
580
580
  if (isClose) {
581
581
  // Closing tag: skip whitespace, expect >
582
- while (j < input.length && /\s/.test(input[j])) j++;
582
+ while (j < input.length && /\s/.test(input[j]!)) j++;
583
583
  if (input[j] === '>') {
584
584
  j++;
585
585
  flushText(start);
package/src/parser.ts CHANGED
@@ -2,6 +2,7 @@ export interface Passage {
2
2
  pid: number;
3
3
  name: string;
4
4
  tags: string[];
5
+ metadata: Record<string, string>;
5
6
  content: string;
6
7
  }
7
8
 
@@ -53,7 +54,21 @@ export function parseStoryData(): StoryData {
53
54
  .filter((t) => t.length > 0);
54
55
  const content = el.textContent || '';
55
56
 
56
- const passage: Passage = { pid, name: passageName, tags, content };
57
+ const metadata: Record<string, string> = {};
58
+ const skipAttrs = new Set(['pid', 'name', 'tags']);
59
+ for (const attr of el.attributes) {
60
+ if (!skipAttrs.has(attr.name)) {
61
+ metadata[attr.name] = attr.value;
62
+ }
63
+ }
64
+
65
+ const passage: Passage = {
66
+ pid,
67
+ name: passageName,
68
+ tags,
69
+ metadata,
70
+ content,
71
+ };
57
72
  passages.set(passageName, passage);
58
73
  passagesById.set(pid, passage);
59
74
  }
package/src/prng.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Seedable PRNG for Spindle — Mulberry32 algorithm.
3
+ *
4
+ * Provides deterministic random number generation that survives save/load
5
+ * cycles via a pull-counter approach: recreate from seed, fast-forward N pulls.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Core algorithm
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Mulberry32: fast, high-quality 32-bit PRNG. */
13
+ function mulberry32(seed: number): () => number {
14
+ let t = seed | 0;
15
+ return () => {
16
+ t = (t + 0x6d2b79f5) | 0;
17
+ let r = Math.imul(t ^ (t >>> 15), t | 1);
18
+ r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
19
+ return ((r ^ (r >>> 14)) >>> 0) / 0x100000000;
20
+ };
21
+ }
22
+
23
+ /** FNV-1a hash — converts a string seed to a 32-bit integer. */
24
+ function hashSeed(seed: string): number {
25
+ let h = 0x811c9dc5;
26
+ for (let i = 0; i < seed.length; i++) {
27
+ h ^= seed.charCodeAt(i);
28
+ h = Math.imul(h, 0x01000193);
29
+ }
30
+ return h;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Module state
35
+ // ---------------------------------------------------------------------------
36
+
37
+ let enabled = false;
38
+ let currentSeed = '';
39
+ let currentPull = 0;
40
+ let generator: (() => number) | null = null;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Public API
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export interface PRNGSnapshot {
47
+ readonly seed: string;
48
+ readonly pull: number;
49
+ }
50
+
51
+ /**
52
+ * Initialize the PRNG.
53
+ * @param seed Optional seed string. If omitted, a random seed is generated.
54
+ * @param useEntropy If true (default), mix `Date.now()` and `Math.random()`
55
+ * into the seed for uniqueness across playthroughs.
56
+ * Set to false for fully deterministic sequences.
57
+ */
58
+ export function initPRNG(seed?: string, useEntropy = true): void {
59
+ let resolvedSeed: string;
60
+ if (seed === undefined) {
61
+ resolvedSeed = String(Date.now()) + String(Math.random());
62
+ } else if (useEntropy) {
63
+ resolvedSeed = seed + '|' + Date.now() + '|' + Math.random();
64
+ } else {
65
+ resolvedSeed = seed;
66
+ }
67
+
68
+ currentSeed = resolvedSeed;
69
+ currentPull = 0;
70
+ generator = mulberry32(hashSeed(resolvedSeed));
71
+ enabled = true;
72
+ }
73
+
74
+ /**
75
+ * Restore PRNG state from a snapshot (used on save/load).
76
+ * Recreates the generator from the seed and fast-forwards to the saved pull count.
77
+ */
78
+ export function restorePRNG(seed: string, pull: number): void {
79
+ currentSeed = seed;
80
+ currentPull = 0;
81
+ generator = mulberry32(hashSeed(seed));
82
+ enabled = true;
83
+
84
+ // Fast-forward
85
+ for (let i = 0; i < pull; i++) {
86
+ generator();
87
+ }
88
+ currentPull = pull;
89
+ }
90
+
91
+ /** Disable the PRNG (used on restart before StoryInit re-enables it). */
92
+ export function resetPRNG(): void {
93
+ enabled = false;
94
+ currentSeed = '';
95
+ currentPull = 0;
96
+ generator = null;
97
+ }
98
+
99
+ /** Returns the current PRNG state for snapshotting, or null if disabled. */
100
+ export function snapshotPRNG(): PRNGSnapshot | null {
101
+ if (!enabled) return null;
102
+ return { seed: currentSeed, pull: currentPull };
103
+ }
104
+
105
+ /** Returns a seeded random number [0, 1), or falls back to Math.random(). */
106
+ export function random(): number {
107
+ if (!enabled || !generator) return Math.random();
108
+ currentPull++;
109
+ return generator();
110
+ }
111
+
112
+ /** Returns a random integer between min and max (inclusive). */
113
+ export function randomInt(min: number, max: number): number {
114
+ if (min > max) [min, max] = [max, min];
115
+ return Math.floor(random() * (max - min + 1)) + min;
116
+ }
117
+
118
+ export function isPRNGEnabled(): boolean {
119
+ return enabled;
120
+ }
121
+
122
+ export function getPRNGSeed(): string {
123
+ return currentSeed;
124
+ }
125
+
126
+ export function getPRNGPull(): number {
127
+ return currentPull;
128
+ }
@@ -137,17 +137,22 @@ export async function overwriteSave(
137
137
  const existing = await getSave(saveId);
138
138
  if (!existing) return undefined;
139
139
 
140
- existing.meta.updatedAt = new Date().toISOString();
141
- existing.meta.passage = payload.passage;
142
140
  const serializedPayload = deepClone(payload);
143
141
  serializedPayload.variables = serialize(serializedPayload.variables);
144
142
  serializedPayload.history = serializedPayload.history.map((m) => ({
145
143
  ...m,
146
144
  variables: serialize(m.variables),
147
145
  }));
148
- existing.payload = serializedPayload;
149
- await putSave(existing);
150
- return existing;
146
+ const updated: SaveRecord = {
147
+ meta: {
148
+ ...existing.meta,
149
+ updatedAt: new Date().toISOString(),
150
+ passage: payload.passage,
151
+ },
152
+ payload: serializedPayload,
153
+ };
154
+ await putSave(updated);
155
+ return updated;
151
156
  }
152
157
 
153
158
  export async function loadSave(
@@ -174,9 +179,15 @@ export async function renameSave(
174
179
  ): Promise<void> {
175
180
  const record = await getSave(saveId);
176
181
  if (!record) return;
177
- record.meta.title = newTitle;
178
- record.meta.updatedAt = new Date().toISOString();
179
- await putSave(record);
182
+ const updated: SaveRecord = {
183
+ ...record,
184
+ meta: {
185
+ ...record.meta,
186
+ title: newTitle,
187
+ updatedAt: new Date().toISOString(),
188
+ },
189
+ };
190
+ await putSave(updated);
180
191
  }
181
192
 
182
193
  // --- Grouped Retrieval ---
@@ -200,8 +211,12 @@ export async function getSavesGrouped(
200
211
  const groups = new Map<string, SaveRecord[]>();
201
212
  for (const save of allSaves) {
202
213
  const pid = save.meta.playthroughId;
203
- if (!groups.has(pid)) groups.set(pid, []);
204
- groups.get(pid)!.push(save);
214
+ const existing = groups.get(pid);
215
+ if (existing) {
216
+ existing.push(save);
217
+ } else {
218
+ groups.set(pid, [save]);
219
+ }
205
220
  }
206
221
 
207
222
  // Sort saves within each group newest-first
@@ -1,4 +1,5 @@
1
1
  import type { HistoryMoment } from '../store';
2
+ import type { PRNGSnapshot } from '../prng';
2
3
 
3
4
  export interface SavePayload {
4
5
  passage: string;
@@ -7,6 +8,7 @@ export interface SavePayload {
7
8
  historyIndex: number;
8
9
  visitCounts?: Record<string, number>;
9
10
  renderCounts?: Record<string, number>;
11
+ prng?: PRNGSnapshot | null;
10
12
  }
11
13
 
12
14
  export interface SaveMeta {
@@ -38,3 +40,32 @@ export interface SaveExport {
38
40
  exportedAt: string;
39
41
  save: SaveRecord;
40
42
  }
43
+
44
+ export function isSaveExport(value: unknown): value is SaveExport {
45
+ if (typeof value !== 'object' || value === null) return false;
46
+ const obj = value as Record<string, unknown>;
47
+ if (obj.version !== 1 || typeof obj.ifid !== 'string') return false;
48
+ if (typeof obj.save !== 'object' || obj.save === null) return false;
49
+
50
+ const save = obj.save as Record<string, unknown>;
51
+ if (typeof save.meta !== 'object' || save.meta === null) return false;
52
+ if (typeof save.payload !== 'object' || save.payload === null) return false;
53
+
54
+ const meta = save.meta as Record<string, unknown>;
55
+ if (typeof meta.id !== 'string' || typeof meta.passage !== 'string')
56
+ return false;
57
+ if (typeof meta.ifid !== 'string') return false;
58
+ if (typeof meta.playthroughId !== 'string') return false;
59
+ if (typeof meta.createdAt !== 'string') return false;
60
+ if (typeof meta.updatedAt !== 'string') return false;
61
+ if (typeof meta.title !== 'string') return false;
62
+
63
+ const payload = save.payload as Record<string, unknown>;
64
+ if (typeof payload.passage !== 'string') return false;
65
+ if (!Array.isArray(payload.history)) return false;
66
+ if (typeof payload.historyIndex !== 'number') return false;
67
+ if (typeof payload.variables !== 'object' || payload.variables === null)
68
+ return false;
69
+
70
+ return true;
71
+ }