@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/settings.ts CHANGED
@@ -26,6 +26,7 @@ export type SettingDef =
26
26
 
27
27
  const definitions = new Map<string, SettingDef>();
28
28
  let values: Record<string, unknown> = {};
29
+ let storageLoaded = false;
29
30
 
30
31
  function storageKey(): string {
31
32
  const storyData = useStoryStore.getState().storyData;
@@ -38,10 +39,19 @@ function persist(): void {
38
39
  }
39
40
 
40
41
  function loadFromStorage(): void {
42
+ if (storageLoaded) return;
43
+ storageLoaded = true;
41
44
  try {
42
45
  const raw = localStorage.getItem(storageKey());
43
46
  if (raw) {
44
- values = { ...values, ...JSON.parse(raw) };
47
+ const parsed = JSON.parse(raw);
48
+ if (
49
+ typeof parsed === 'object' &&
50
+ parsed !== null &&
51
+ !Array.isArray(parsed)
52
+ ) {
53
+ values = { ...values, ...parsed };
54
+ }
45
55
  }
46
56
  } catch {
47
57
  // ignore corrupted data
@@ -77,6 +87,21 @@ export const settings = {
77
87
  return values[name];
78
88
  },
79
89
 
90
+ getToggle(name: string): boolean {
91
+ const v = values[name];
92
+ return typeof v === 'boolean' ? v : false;
93
+ },
94
+
95
+ getList(name: string): string {
96
+ const v = values[name];
97
+ return typeof v === 'string' ? v : '';
98
+ },
99
+
100
+ getRange(name: string): number {
101
+ const v = values[name];
102
+ return typeof v === 'number' ? v : 0;
103
+ },
104
+
80
105
  set(name: string, value: unknown): void {
81
106
  values[name] = value;
82
107
  persist();
package/src/store.ts CHANGED
@@ -11,11 +11,27 @@ import {
11
11
  loadQuickSave,
12
12
  } from './saves/save-manager';
13
13
  import { deepClone, deserialize } from './class-registry';
14
+ import {
15
+ snapshotPRNG,
16
+ restorePRNG,
17
+ resetPRNG,
18
+ type PRNGSnapshot,
19
+ } from './prng';
20
+
21
+ /** Restore or reset PRNG from a history moment's snapshot. */
22
+ function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
23
+ if (moment?.prng) {
24
+ restorePRNG(moment.prng.seed, moment.prng.pull);
25
+ } else if (moment) {
26
+ resetPRNG();
27
+ }
28
+ }
14
29
 
15
30
  export interface HistoryMoment {
16
31
  passage: string;
17
32
  variables: Record<string, unknown>;
18
33
  timestamp: number;
34
+ prng?: PRNGSnapshot | null;
19
35
  }
20
36
 
21
37
  export interface StoryState {
@@ -30,6 +46,8 @@ export interface StoryState {
30
46
  renderCounts: Record<string, number>;
31
47
  saveVersion: number;
32
48
  playthroughId: string;
49
+ saveError: string | null;
50
+ loadError: string | null;
33
51
 
34
52
  init: (
35
53
  storyData: StoryData,
@@ -64,6 +82,8 @@ export const useStoryStore = create<StoryState>()(
64
82
  renderCounts: {},
65
83
  saveVersion: 0,
66
84
  playthroughId: '',
85
+ saveError: null,
86
+ loadError: null,
67
87
 
68
88
  init: (
69
89
  storyData: StoryData,
@@ -98,19 +118,23 @@ export const useStoryStore = create<StoryState>()(
98
118
 
99
119
  // Init save system (fire-and-forget — DB will be ready before user opens dialog)
100
120
  const ifid = storyData.ifid;
101
- initSaveSystem().then(async () => {
102
- const existingId = await getCurrentPlaythroughId(ifid);
103
- if (existingId) {
104
- set((state) => {
105
- state.playthroughId = existingId;
106
- });
107
- } else {
108
- const newId = await startNewPlaythrough(ifid);
109
- set((state) => {
110
- state.playthroughId = newId;
111
- });
112
- }
113
- });
121
+ initSaveSystem()
122
+ .then(async () => {
123
+ const existingId = await getCurrentPlaythroughId(ifid);
124
+ if (existingId) {
125
+ set((state) => {
126
+ state.playthroughId = existingId;
127
+ });
128
+ } else {
129
+ const newId = await startNewPlaythrough(ifid);
130
+ set((state) => {
131
+ state.playthroughId = newId;
132
+ });
133
+ }
134
+ })
135
+ .catch((err) =>
136
+ console.error('spindle: failed to init save system', err),
137
+ );
114
138
  },
115
139
 
116
140
  navigate: (passageName: string) => {
@@ -133,6 +157,7 @@ export const useStoryStore = create<StoryState>()(
133
157
  passage: passageName,
134
158
  variables: deepClone(state.variables),
135
159
  timestamp: Date.now(),
160
+ prng: snapshotPRNG(),
136
161
  });
137
162
  state.historyIndex = state.history.length - 1;
138
163
  state.visitCounts[passageName] =
@@ -146,22 +171,24 @@ export const useStoryStore = create<StoryState>()(
146
171
  set((state) => {
147
172
  if (state.historyIndex <= 0) return;
148
173
  state.historyIndex--;
149
- const moment = state.history[state.historyIndex];
174
+ const moment = state.history[state.historyIndex]!;
150
175
  state.currentPassage = moment.passage;
151
176
  state.variables = deepClone(moment.variables);
152
177
  state.temporary = {};
153
178
  });
179
+ restorePRNGFromMoment(get().history[get().historyIndex]);
154
180
  },
155
181
 
156
182
  goForward: () => {
157
183
  set((state) => {
158
184
  if (state.historyIndex >= state.history.length - 1) return;
159
185
  state.historyIndex++;
160
- const moment = state.history[state.historyIndex];
186
+ const moment = state.history[state.historyIndex]!;
161
187
  state.currentPassage = moment.passage;
162
188
  state.variables = deepClone(moment.variables);
163
189
  state.temporary = {};
164
190
  });
191
+ restorePRNGFromMoment(get().history[get().historyIndex]);
165
192
  },
166
193
 
167
194
  setVariable: (name: string, value: unknown) => {
@@ -202,6 +229,7 @@ export const useStoryStore = create<StoryState>()(
202
229
  const startPassage = storyData.passagesById.get(storyData.startNode);
203
230
  if (!startPassage) return;
204
231
 
232
+ resetPRNG();
205
233
  const initialVars = deepClone(variableDefaults);
206
234
 
207
235
  set((state) => {
@@ -223,11 +251,15 @@ export const useStoryStore = create<StoryState>()(
223
251
  executeStoryInit();
224
252
 
225
253
  // Start a new playthrough on restart
226
- startNewPlaythrough(storyData.ifid).then((newId) => {
227
- set((state) => {
228
- state.playthroughId = newId;
229
- });
230
- });
254
+ startNewPlaythrough(storyData.ifid)
255
+ .then((newId) => {
256
+ set((state) => {
257
+ state.playthroughId = newId;
258
+ });
259
+ })
260
+ .catch((err) =>
261
+ console.error('spindle: failed to start new playthrough', err),
262
+ );
231
263
  },
232
264
 
233
265
  save: () => {
@@ -250,31 +282,59 @@ export const useStoryStore = create<StoryState>()(
250
282
  historyIndex,
251
283
  visitCounts: { ...visitCounts },
252
284
  renderCounts: { ...renderCounts },
285
+ prng: snapshotPRNG(),
253
286
  };
254
287
 
255
- quickSave(storyData.ifid, playthroughId, payload).then(() => {
256
- set((state) => {
257
- state.saveVersion++;
258
- });
288
+ set((state) => {
289
+ state.saveError = null;
259
290
  });
291
+ quickSave(storyData.ifid, playthroughId, payload)
292
+ .then(() => {
293
+ set((state) => {
294
+ state.saveVersion++;
295
+ });
296
+ })
297
+ .catch((err) => {
298
+ console.error('spindle: failed to quick save', err);
299
+ set((state) => {
300
+ state.saveError =
301
+ err instanceof Error ? err.message : 'Failed to save';
302
+ });
303
+ });
260
304
  },
261
305
 
262
306
  load: () => {
263
307
  const { storyData } = get();
264
308
  if (!storyData) return;
265
309
 
266
- loadQuickSave(storyData.ifid).then((payload) => {
267
- if (!payload) return;
268
- set((state) => {
269
- state.currentPassage = payload.passage;
270
- state.variables = payload.variables;
271
- state.history = payload.history;
272
- state.historyIndex = payload.historyIndex;
273
- state.visitCounts = payload.visitCounts ?? {};
274
- state.renderCounts = payload.renderCounts ?? {};
275
- state.temporary = {};
276
- });
310
+ set((state) => {
311
+ state.loadError = null;
277
312
  });
313
+ loadQuickSave(storyData.ifid)
314
+ .then((payload) => {
315
+ if (!payload) return;
316
+ set((state) => {
317
+ state.currentPassage = payload.passage;
318
+ state.variables = payload.variables;
319
+ state.history = payload.history;
320
+ state.historyIndex = payload.historyIndex;
321
+ state.visitCounts = payload.visitCounts ?? {};
322
+ state.renderCounts = payload.renderCounts ?? {};
323
+ state.temporary = {};
324
+ });
325
+ if (payload.prng) {
326
+ restorePRNG(payload.prng.seed, payload.prng.pull);
327
+ } else {
328
+ resetPRNG();
329
+ }
330
+ })
331
+ .catch((err) => {
332
+ console.error('spindle: failed to load quick save', err);
333
+ set((state) => {
334
+ state.loadError =
335
+ err instanceof Error ? err.message : 'Failed to load';
336
+ });
337
+ });
278
338
  },
279
339
 
280
340
  hasSave: () => {
@@ -300,6 +360,7 @@ export const useStoryStore = create<StoryState>()(
300
360
  historyIndex,
301
361
  visitCounts: { ...visitCounts },
302
362
  renderCounts: { ...renderCounts },
363
+ prng: snapshotPRNG(),
303
364
  };
304
365
  },
305
366
 
@@ -316,6 +377,11 @@ export const useStoryStore = create<StoryState>()(
316
377
  state.renderCounts = payload.renderCounts ?? {};
317
378
  state.temporary = {};
318
379
  });
380
+ if (payload.prng) {
381
+ restorePRNG(payload.prng.seed, payload.prng.pull);
382
+ } else {
383
+ resetPRNG();
384
+ }
319
385
  },
320
386
  })),
321
387
  );
package/src/story-api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from './store';
2
+ import type { Passage } from './parser';
2
3
  import { settings } from './settings';
3
4
  import type { SavePayload } from './saves/types';
4
5
  import { setTitleGenerator } from './saves/save-manager';
@@ -9,6 +10,15 @@ import {
9
10
  onActionsChanged,
10
11
  type StoryAction,
11
12
  } from './action-registry';
13
+ import {
14
+ initPRNG,
15
+ isPRNGEnabled,
16
+ getPRNGSeed,
17
+ getPRNGPull,
18
+ random,
19
+ randomInt,
20
+ snapshotPRNG,
21
+ } from './prng';
12
22
 
13
23
  export type { StoryAction };
14
24
 
@@ -37,6 +47,8 @@ export interface StoryAPI {
37
47
  hasRendered(name: string): boolean;
38
48
  hasRenderedAny(...names: string[]): boolean;
39
49
  hasRenderedAll(...names: string[]): boolean;
50
+ currentPassage(): Passage | undefined;
51
+ previousPassage(): Passage | undefined;
40
52
  readonly title: string;
41
53
  readonly passage: string;
42
54
  readonly settings: typeof settings;
@@ -50,6 +62,14 @@ export interface StoryAPI {
50
62
  on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void;
51
63
  on(event: 'variableChanged', callback: VariableChangedCallback): () => void;
52
64
  waitForActions(): Promise<StoryAction[]>;
65
+ random(): number;
66
+ randomInt(min: number, max: number): number;
67
+ readonly prng: {
68
+ init(seed?: string, useEntropy?: boolean): void;
69
+ isEnabled(): boolean;
70
+ readonly seed: string;
71
+ readonly pull: number;
72
+ };
53
73
  }
54
74
 
55
75
  function createStoryAPI(): StoryAPI {
@@ -133,6 +153,18 @@ function createStoryAPI(): StoryAPI {
133
153
  return names.every((n) => (renderCounts[n] ?? 0) > 0);
134
154
  },
135
155
 
156
+ currentPassage(): Passage | undefined {
157
+ const state = useStoryStore.getState();
158
+ return state.storyData?.passages.get(state.currentPassage);
159
+ },
160
+
161
+ previousPassage(): Passage | undefined {
162
+ const state = useStoryStore.getState();
163
+ if (state.historyIndex <= 0) return undefined;
164
+ const prevName = state.history[state.historyIndex - 1]?.passage;
165
+ return prevName ? state.storyData?.passages.get(prevName) : undefined;
166
+ },
167
+
136
168
  get title(): string {
137
169
  return useStoryStore.getState().storyData?.name || '';
138
170
  },
@@ -218,6 +250,37 @@ function createStoryAPI(): StoryAPI {
218
250
  });
219
251
  });
220
252
  },
253
+
254
+ random(): number {
255
+ return random();
256
+ },
257
+
258
+ randomInt(min: number, max: number): number {
259
+ return randomInt(min, max);
260
+ },
261
+
262
+ prng: {
263
+ init(seed?: string, useEntropy?: boolean): void {
264
+ initPRNG(seed, useEntropy);
265
+ // Update current history moment's snapshot via immer
266
+ const { historyIndex } = useStoryStore.getState();
267
+ useStoryStore.setState((state) => {
268
+ const moment = state.history[historyIndex];
269
+ if (moment) {
270
+ moment.prng = snapshotPRNG();
271
+ }
272
+ });
273
+ },
274
+ isEnabled(): boolean {
275
+ return isPRNGEnabled();
276
+ },
277
+ get seed(): string {
278
+ return getPRNGSeed();
279
+ },
280
+ get pull(): number {
281
+ return getPRNGPull();
282
+ },
283
+ },
221
284
  };
222
285
  }
223
286
 
@@ -16,6 +16,8 @@ const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
16
16
  const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
17
17
  const FOR_LOCAL_RE = /\{for\s+(\$\w+)(?:\s*,\s*(\$\w+))?\s+of\b/g;
18
18
 
19
+ const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
20
+
19
21
  function inferSchema(value: unknown): FieldSchema {
20
22
  if (Array.isArray(value)) {
21
23
  return { type: 'array' };
@@ -27,7 +29,13 @@ function inferSchema(value: unknown): FieldSchema {
27
29
  }
28
30
  return { type: 'object', fields };
29
31
  }
30
- return { type: typeof value as VarType };
32
+ const jsType = typeof value;
33
+ if (!VALID_VAR_TYPES.has(jsType)) {
34
+ throw new Error(
35
+ `StoryVariables: Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
36
+ );
37
+ }
38
+ return { type: jsType as VarType };
31
39
  }
32
40
 
33
41
  /**
@@ -50,7 +58,7 @@ export function parseStoryVariables(
50
58
  );
51
59
  }
52
60
 
53
- const [, name, expr] = match;
61
+ const [, name, expr] = match as [string, string, string];
54
62
  let value: unknown;
55
63
  try {
56
64
  value = new Function('return (' + expr + ')')();
@@ -77,8 +85,8 @@ function extractForLocals(content: string): Set<string> {
77
85
  let match: RegExpExecArray | null;
78
86
  FOR_LOCAL_RE.lastIndex = 0;
79
87
  while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
80
- locals.add(match[1].slice(1)); // strip $
81
- if (match[2]) locals.add(match[2].slice(1));
88
+ locals.add(match[1]!.slice(1)); // strip $
89
+ if (match[2]) locals.add(match[2]!.slice(1));
82
90
  }
83
91
  return locals;
84
92
  }
@@ -93,7 +101,7 @@ function validateRef(
93
101
  forLocals: Set<string>,
94
102
  ): string | null {
95
103
  const parts = ref.split('.');
96
- const rootName = parts[0];
104
+ const rootName = parts[0]!;
97
105
 
98
106
  // Skip for-loop locals
99
107
  if (forLocals.has(rootName)) return null;
@@ -107,12 +115,13 @@ function validateRef(
107
115
  let current: FieldSchema = rootSchema;
108
116
  for (let i = 1; i < parts.length; i++) {
109
117
  // Arrays have a .length property
110
- if (current.type === 'array' && parts[i] === 'length') return null;
118
+ const part = parts[i] as string;
119
+ if (current.type === 'array' && part === 'length') return null;
111
120
 
112
121
  if (current.type !== 'object' || !current.fields) {
113
- return `Cannot access field "${parts[i]}" on $${parts.slice(0, i).join('.')} (type: ${current.type})`;
122
+ return `Cannot access field "${part}" on $${parts.slice(0, i).join('.')} (type: ${current.type})`;
114
123
  }
115
- const fieldSchema = current.fields.get(parts[i]);
124
+ const fieldSchema = current.fields.get(part);
116
125
  if (!fieldSchema) {
117
126
  // Unknown fields on objects are allowed — classes registered via
118
127
  // Story.registerClass() can add methods/getters not in the defaults.
@@ -143,7 +152,7 @@ export function validatePassages(
143
152
  let match: RegExpExecArray | null;
144
153
  VAR_REF_RE.lastIndex = 0;
145
154
  while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
146
- const ref = match[1];
155
+ const ref = match[1]!;
147
156
  const error = validateRef(ref, schema, forLocals);
148
157
  if (error) {
149
158
  errors.push(`Passage "${name}": ${error}`);