@rohal12/spindle 0.16.1 → 0.18.1

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.
Files changed (60) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +2 -2
  3. package/src/action-registry.ts +2 -1
  4. package/src/components/Passage.tsx +69 -4
  5. package/src/components/PassageDialog.tsx +69 -0
  6. package/src/components/StoryInterface.tsx +2 -2
  7. package/src/components/macros/Button.tsx +33 -46
  8. package/src/components/macros/Checkbox.tsx +32 -35
  9. package/src/components/macros/Computed.tsx +49 -54
  10. package/src/components/macros/Cycle.tsx +43 -53
  11. package/src/components/macros/Dialog.tsx +40 -0
  12. package/src/components/macros/Do.tsx +15 -21
  13. package/src/components/macros/For.tsx +57 -75
  14. package/src/components/macros/Goto.tsx +18 -22
  15. package/src/components/macros/If.tsx +39 -53
  16. package/src/components/macros/Include.tsx +30 -46
  17. package/src/components/macros/Listbox.tsx +38 -48
  18. package/src/components/macros/MacroError.tsx +18 -0
  19. package/src/components/macros/MacroLink.tsx +38 -53
  20. package/src/components/macros/MenubarButtons.tsx +158 -0
  21. package/src/components/macros/Meter.tsx +35 -52
  22. package/src/components/macros/Numberbox.tsx +30 -47
  23. package/src/components/macros/PassageDisplay.tsx +37 -30
  24. package/src/components/macros/Print.tsx +19 -36
  25. package/src/components/macros/Radiobutton.tsx +33 -38
  26. package/src/components/macros/Repeat.tsx +49 -54
  27. package/src/components/macros/SaveManager.tsx +400 -0
  28. package/src/components/macros/Set.tsx +19 -24
  29. package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
  30. package/src/components/macros/Span.tsx +8 -0
  31. package/src/components/macros/Stop.tsx +11 -12
  32. package/src/components/macros/StoryTitle.tsx +16 -16
  33. package/src/components/macros/Switch.tsx +41 -59
  34. package/src/components/macros/Textarea.tsx +26 -45
  35. package/src/components/macros/Textbox.tsx +27 -44
  36. package/src/components/macros/Timed.tsx +54 -55
  37. package/src/components/macros/Type.tsx +61 -62
  38. package/src/components/macros/Unset.tsx +21 -22
  39. package/src/components/macros/Widget.tsx +11 -15
  40. package/src/components/macros/option-utils.ts +13 -0
  41. package/src/define-macro.ts +154 -0
  42. package/src/expression.ts +10 -8
  43. package/src/index.tsx +7 -0
  44. package/src/macros/register-builtins.ts +5 -7
  45. package/src/markup/ast.ts +11 -16
  46. package/src/markup/render.tsx +0 -13
  47. package/src/saves/save-manager.ts +43 -0
  48. package/src/saves/types.ts +9 -2
  49. package/src/store.ts +283 -52
  50. package/src/story-api.ts +33 -12
  51. package/src/styles.css +37 -68
  52. package/src/components/PassageLink.tsx +0 -49
  53. package/src/components/SaveLoadDialog.tsx +0 -408
  54. package/src/components/macros/Back.tsx +0 -32
  55. package/src/components/macros/Forward.tsx +0 -34
  56. package/src/components/macros/QuickLoad.tsx +0 -42
  57. package/src/components/macros/QuickSave.tsx +0 -30
  58. package/src/components/macros/Restart.tsx +0 -35
  59. package/src/components/macros/Saves.tsx +0 -24
  60. package/src/components/macros/SettingsButton.tsx +0 -28
package/src/store.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { create } from 'zustand';
2
2
  import { immer } from 'zustand/middleware/immer';
3
+ import {
4
+ enablePatches,
5
+ produceWithPatches,
6
+ applyPatches,
7
+ type Patch,
8
+ } from 'immer';
3
9
  import type { StoryData } from './parser';
4
- import type { SavePayload } from './saves/types';
10
+ import type { SavePayload, SaveHistoryMoment } from './saves/types';
5
11
  import { executeStoryInit } from './story-init';
6
12
  import {
7
13
  initSaveSystem,
@@ -9,8 +15,10 @@ import {
9
15
  getCurrentPlaythroughId,
10
16
  quickSave,
11
17
  loadQuickSave,
18
+ saveSession,
19
+ clearSession,
12
20
  } from './saves/save-manager';
13
- import { deepClone, deserialize } from './class-registry';
21
+ import { deepClone, serialize, deserialize } from './class-registry';
14
22
  import {
15
23
  snapshotPRNG,
16
24
  restorePRNG,
@@ -18,6 +26,151 @@ import {
18
26
  type PRNGSnapshot,
19
27
  } from './prng';
20
28
 
29
+ enablePatches();
30
+
31
+ const SPECIAL_PASSAGES = new Set([
32
+ 'StoryInit',
33
+ 'StoryInterface',
34
+ 'StoryVariables',
35
+ 'SaveTitle',
36
+ 'PassageReady',
37
+ 'PassageHeader',
38
+ 'PassageFooter',
39
+ 'PassageDone',
40
+ ]);
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Patch-based variable history (module-level, outside Zustand)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface PatchEntry {
47
+ forward: Patch[];
48
+ inverse: Patch[];
49
+ }
50
+
51
+ /** Full variable snapshot at history index 0. */
52
+ let variableBase: Record<string, unknown> = {};
53
+
54
+ /**
55
+ * Transitions between consecutive history moments.
56
+ * patchEntries[i] transforms the variables at moment i into those at moment i+1.
57
+ * Length is always history.length − 1.
58
+ */
59
+ let patchEntries: PatchEntry[] = [];
60
+
61
+ /** Immer-produced reference to variables right after the last navigation. */
62
+ let lastNavigationVars: Record<string, unknown> = {};
63
+
64
+ /** Deep-clone patch values so they are independent of future mutations. */
65
+ function clonePatches(patches: Patch[]): Patch[] {
66
+ return patches.map((p) => ({
67
+ ...p,
68
+ value: p.value !== undefined ? deepClone(p.value) : undefined,
69
+ }));
70
+ }
71
+
72
+ /** Compute forward + inverse patches that transform `prev` into `curr`. */
73
+ function computeVarPatches(
74
+ prev: Record<string, unknown>,
75
+ curr: Record<string, unknown>,
76
+ ): PatchEntry {
77
+ const [, forward, inverse] = produceWithPatches(prev, (draft) => {
78
+ const d = draft as Record<string, unknown>;
79
+ for (const key of Object.keys(d)) {
80
+ if (!(key in curr)) delete d[key];
81
+ }
82
+ for (const [key, val] of Object.entries(curr)) {
83
+ d[key] = val;
84
+ }
85
+ });
86
+ return { forward: clonePatches(forward), inverse: clonePatches(inverse) };
87
+ }
88
+
89
+ /** Reconstruct variables at a given history moment by replaying patches. */
90
+ function reconstructVarsAt(index: number): Record<string, unknown> {
91
+ let vars: Record<string, unknown> = variableBase;
92
+ for (let i = 0; i < index; i++) {
93
+ vars = applyPatches(vars, patchEntries[i]!.forward);
94
+ }
95
+ return vars;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Session persistence (sessionStorage — survives F5, cleared on tab close)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ let serializedHistory: unknown[] = [];
103
+
104
+ function persistSession(get: () => StoryState): void {
105
+ const {
106
+ storyData,
107
+ currentPassage,
108
+ variables,
109
+ history,
110
+ historyIndex,
111
+ visitCounts,
112
+ renderCounts,
113
+ } = get();
114
+ if (!storyData) return;
115
+
116
+ // Trim cache when history was truncated (goBack then navigate)
117
+ if (serializedHistory.length > history.length) {
118
+ serializedHistory.length = history.length;
119
+ }
120
+
121
+ // Append new entries
122
+ if (serializedHistory.length < history.length) {
123
+ const gap = history.length - serializedHistory.length;
124
+ if (gap === 1) {
125
+ // Common path: one new moment at the end — use current variables directly
126
+ const i = history.length - 1;
127
+ serializedHistory[i] = {
128
+ passage: history[i]!.passage,
129
+ variables: serialize(variables),
130
+ timestamp: history[i]!.timestamp,
131
+ prng: history[i]!.prng,
132
+ };
133
+ } else {
134
+ // Bulk fill (after loadFromPayload) — reconstruct incrementally
135
+ let vars = variableBase;
136
+ for (let i = 0; i < history.length; i++) {
137
+ if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
138
+ if (i >= serializedHistory.length) {
139
+ const v = i === history.length - 1 ? variables : vars;
140
+ serializedHistory[i] = {
141
+ passage: history[i]!.passage,
142
+ variables: serialize(v),
143
+ timestamp: history[i]!.timestamp,
144
+ prng: history[i]!.prng,
145
+ };
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ saveSession(storyData.ifid, {
152
+ passage: currentPassage,
153
+ variables: serialize(variables),
154
+ history: serializedHistory,
155
+ historyIndex,
156
+ visitCounts,
157
+ renderCounts,
158
+ prng: snapshotPRNG(),
159
+ });
160
+ }
161
+
162
+ /** Reset all module-level state (called on init, restart, loadFromPayload). */
163
+ function resetModuleState(base: Record<string, unknown>): void {
164
+ variableBase = base;
165
+ patchEntries = [];
166
+ lastNavigationVars = base;
167
+ serializedHistory = [];
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Store
172
+ // ---------------------------------------------------------------------------
173
+
21
174
  /** Restore or reset PRNG from a history moment's snapshot. */
22
175
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
23
176
  if (moment?.prng) {
@@ -29,7 +182,6 @@ function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
29
182
 
30
183
  export interface HistoryMoment {
31
184
  passage: string;
32
- variables: Record<string, unknown>;
33
185
  timestamp: number;
34
186
  prng?: PRNGSnapshot | null;
35
187
  }
@@ -46,9 +198,11 @@ export interface StoryState {
46
198
  renderCounts: Record<string, number>;
47
199
  saveVersion: number;
48
200
  playthroughId: string;
201
+ maxHistory: number;
49
202
  saveError: string | null;
50
203
  loadError: string | null;
51
204
 
205
+ setMaxHistory: (limit: number) => void;
52
206
  init: (
53
207
  storyData: StoryData,
54
208
  variableDefaults?: Record<string, unknown>,
@@ -67,6 +221,7 @@ export interface StoryState {
67
221
  hasSave: (slot?: string) => boolean;
68
222
  getSavePayload: () => SavePayload;
69
223
  loadFromPayload: (payload: SavePayload) => void;
224
+ getHistoryVariables: (index: number) => Record<string, unknown>;
70
225
  }
71
226
 
72
227
  export const useStoryStore = create<StoryState>()(
@@ -82,9 +237,16 @@ export const useStoryStore = create<StoryState>()(
82
237
  renderCounts: {},
83
238
  saveVersion: 0,
84
239
  playthroughId: '',
240
+ maxHistory: 40,
85
241
  saveError: null,
86
242
  loadError: null,
87
243
 
244
+ setMaxHistory: (limit: number) => {
245
+ set((state) => {
246
+ state.maxHistory = Math.max(1, Math.round(limit));
247
+ });
248
+ },
249
+
88
250
  init: (
89
251
  storyData: StoryData,
90
252
  variableDefaults: Record<string, unknown> = {},
@@ -97,6 +259,7 @@ export const useStoryStore = create<StoryState>()(
97
259
  }
98
260
 
99
261
  const initialVars = deepClone(variableDefaults);
262
+ resetModuleState(deepClone(initialVars));
100
263
 
101
264
  set((state) => {
102
265
  state.storyData = storyData as StoryData;
@@ -107,7 +270,6 @@ export const useStoryStore = create<StoryState>()(
107
270
  state.history = [
108
271
  {
109
272
  passage: startPassage.name,
110
- variables: deepClone(initialVars),
111
273
  timestamp: Date.now(),
112
274
  },
113
275
  ];
@@ -116,6 +278,9 @@ export const useStoryStore = create<StoryState>()(
116
278
  state.renderCounts = { [startPassage.name]: 1 };
117
279
  });
118
280
 
281
+ // Update lastNavigationVars to the Immer-produced reference
282
+ lastNavigationVars = get().variables;
283
+
119
284
  // Init save system (fire-and-forget — DB will be ready before user opens dialog)
120
285
  const ifid = storyData.ifid;
121
286
  initSaveSystem()
@@ -138,57 +303,103 @@ export const useStoryStore = create<StoryState>()(
138
303
  },
139
304
 
140
305
  navigate: (passageName: string) => {
141
- const { storyData } = get();
306
+ const { storyData, variables: currVars } = get();
142
307
  if (!storyData) return;
143
308
 
309
+ if (SPECIAL_PASSAGES.has(passageName)) {
310
+ console.error(
311
+ `spindle: Cannot navigate to special passage "${passageName}".`,
312
+ );
313
+ return;
314
+ }
315
+
144
316
  if (!storyData.passages.has(passageName)) {
145
317
  console.error(`spindle: Passage "${passageName}" not found.`);
146
318
  return;
147
319
  }
148
320
 
321
+ // Compute variable delta before Immer set()
322
+ const patchEntry = computeVarPatches(lastNavigationVars, currVars);
323
+
149
324
  set((state) => {
150
325
  state.temporary = {};
151
326
  state.currentPassage = passageName;
152
327
 
153
328
  // Truncate forward history if we navigated back then chose a new path
154
329
  state.history = state.history.slice(0, state.historyIndex + 1);
330
+ patchEntries.length = state.historyIndex;
155
331
 
332
+ // Push new transition and moment
333
+ patchEntries.push(patchEntry);
156
334
  state.history.push({
157
335
  passage: passageName,
158
- variables: deepClone(state.variables),
159
336
  timestamp: Date.now(),
160
337
  prng: snapshotPRNG(),
161
338
  });
339
+
340
+ // Trim oldest entries if over the limit
341
+ const overflow = state.history.length - state.maxHistory;
342
+ if (overflow > 0) {
343
+ // Advance base through trimmed transitions
344
+ for (let i = 0; i < overflow; i++) {
345
+ variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
346
+ }
347
+ state.history = state.history.slice(overflow);
348
+ patchEntries = patchEntries.slice(overflow);
349
+ serializedHistory = serializedHistory.slice(overflow);
350
+ }
351
+
162
352
  state.historyIndex = state.history.length - 1;
163
353
  state.visitCounts[passageName] =
164
354
  (state.visitCounts[passageName] ?? 0) + 1;
165
355
  state.renderCounts[passageName] =
166
356
  (state.renderCounts[passageName] ?? 0) + 1;
167
357
  });
358
+
359
+ lastNavigationVars = get().variables;
360
+ persistSession(get);
168
361
  },
169
362
 
170
363
  goBack: () => {
364
+ const { historyIndex, variables } = get();
365
+ if (historyIndex <= 0) return;
366
+
367
+ // Apply inverse transition: moment historyIndex → historyIndex−1
368
+ const restoredVars = deepClone(
369
+ applyPatches(variables, patchEntries[historyIndex - 1]!.inverse),
370
+ );
371
+
171
372
  set((state) => {
172
- if (state.historyIndex <= 0) return;
173
373
  state.historyIndex--;
174
- const moment = state.history[state.historyIndex]!;
175
- state.currentPassage = moment.passage;
176
- state.variables = deepClone(moment.variables);
374
+ state.currentPassage = state.history[state.historyIndex]!.passage;
375
+ state.variables = restoredVars;
177
376
  state.temporary = {};
178
377
  });
378
+
379
+ lastNavigationVars = get().variables;
179
380
  restorePRNGFromMoment(get().history[get().historyIndex]);
381
+ persistSession(get);
180
382
  },
181
383
 
182
384
  goForward: () => {
385
+ const { historyIndex, history: hist, variables } = get();
386
+ if (historyIndex >= hist.length - 1) return;
387
+
388
+ // Apply forward transition: moment historyIndex → historyIndex+1
389
+ const restoredVars = deepClone(
390
+ applyPatches(variables, patchEntries[historyIndex]!.forward),
391
+ );
392
+
183
393
  set((state) => {
184
- if (state.historyIndex >= state.history.length - 1) return;
185
394
  state.historyIndex++;
186
- const moment = state.history[state.historyIndex]!;
187
- state.currentPassage = moment.passage;
188
- state.variables = deepClone(moment.variables);
395
+ state.currentPassage = state.history[state.historyIndex]!.passage;
396
+ state.variables = restoredVars;
189
397
  state.temporary = {};
190
398
  });
399
+
400
+ lastNavigationVars = get().variables;
191
401
  restorePRNGFromMoment(get().history[get().historyIndex]);
402
+ persistSession(get);
192
403
  },
193
404
 
194
405
  setVariable: (name: string, value: unknown) => {
@@ -231,6 +442,7 @@ export const useStoryStore = create<StoryState>()(
231
442
 
232
443
  resetPRNG();
233
444
  const initialVars = deepClone(variableDefaults);
445
+ resetModuleState(deepClone(initialVars));
234
446
 
235
447
  set((state) => {
236
448
  state.currentPassage = startPassage.name;
@@ -239,7 +451,6 @@ export const useStoryStore = create<StoryState>()(
239
451
  state.history = [
240
452
  {
241
453
  passage: startPassage.name,
242
- variables: deepClone(initialVars),
243
454
  timestamp: Date.now(),
244
455
  },
245
456
  ];
@@ -248,7 +459,9 @@ export const useStoryStore = create<StoryState>()(
248
459
  state.renderCounts = { [startPassage.name]: 1 };
249
460
  });
250
461
 
462
+ lastNavigationVars = get().variables;
251
463
  executeStoryInit();
464
+ clearSession(storyData.ifid);
252
465
 
253
466
  // Start a new playthrough on restart
254
467
  startNewPlaythrough(storyData.ifid)
@@ -263,27 +476,10 @@ export const useStoryStore = create<StoryState>()(
263
476
  },
264
477
 
265
478
  save: () => {
266
- const {
267
- storyData,
268
- playthroughId,
269
- currentPassage,
270
- variables,
271
- history,
272
- historyIndex,
273
- visitCounts,
274
- renderCounts,
275
- } = get();
479
+ const { storyData, playthroughId } = get();
276
480
  if (!storyData) return;
277
481
 
278
- const payload: SavePayload = {
279
- passage: currentPassage,
280
- variables: deepClone(variables),
281
- history: deepClone(history),
282
- historyIndex,
283
- visitCounts: { ...visitCounts },
284
- renderCounts: { ...renderCounts },
285
- prng: snapshotPRNG(),
286
- };
482
+ const payload = get().getSavePayload();
287
483
 
288
484
  set((state) => {
289
485
  state.saveError = null;
@@ -313,20 +509,7 @@ export const useStoryStore = create<StoryState>()(
313
509
  loadQuickSave(storyData.ifid)
314
510
  .then((payload) => {
315
511
  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
- }
512
+ get().loadFromPayload(payload);
330
513
  })
331
514
  .catch((err) => {
332
515
  console.error('spindle: failed to load quick save', err);
@@ -353,10 +536,26 @@ export const useStoryStore = create<StoryState>()(
353
536
  visitCounts,
354
537
  renderCounts,
355
538
  } = get();
539
+
540
+ // Reconstruct full variable snapshots from base + patches
541
+ const saveHistory: SaveHistoryMoment[] = [];
542
+ let vars = variableBase;
543
+ for (let i = 0; i < history.length; i++) {
544
+ if (i > 0) {
545
+ vars = applyPatches(vars, patchEntries[i - 1]!.forward);
546
+ }
547
+ saveHistory.push({
548
+ passage: history[i]!.passage,
549
+ variables: deepClone(vars),
550
+ timestamp: history[i]!.timestamp,
551
+ prng: history[i]!.prng,
552
+ });
553
+ }
554
+
356
555
  return {
357
556
  passage: currentPassage,
358
557
  variables: deepClone(variables),
359
- history: deepClone(history),
558
+ history: saveHistory,
360
559
  historyIndex,
361
560
  visitCounts: { ...visitCounts },
362
561
  renderCounts: { ...renderCounts },
@@ -365,23 +564,55 @@ export const useStoryStore = create<StoryState>()(
365
564
  },
366
565
 
367
566
  loadFromPayload: (payload: SavePayload) => {
567
+ // Convert full snapshots to patch entries
568
+ const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
569
+ string,
570
+ unknown
571
+ >;
572
+ const newPatchEntries: PatchEntry[] = [];
573
+
574
+ let prevVars: Record<string, unknown> = base;
575
+ for (let i = 1; i < payload.history.length; i++) {
576
+ const currVars = deserialize(payload.history[i]!.variables) as Record<
577
+ string,
578
+ unknown
579
+ >;
580
+ newPatchEntries.push(computeVarPatches(prevVars, currVars));
581
+ prevVars = currVars;
582
+ }
583
+
584
+ variableBase = deepClone(base);
585
+ patchEntries = newPatchEntries;
586
+ serializedHistory = [];
587
+
368
588
  set((state) => {
369
589
  state.currentPassage = payload.passage;
370
- state.variables = deserialize(payload.variables);
590
+ state.variables = deserialize(payload.variables) as Record<
591
+ string,
592
+ unknown
593
+ >;
371
594
  state.history = payload.history.map((m) => ({
372
- ...m,
373
- variables: deserialize(m.variables),
595
+ passage: m.passage,
596
+ timestamp: m.timestamp,
597
+ prng: m.prng,
374
598
  }));
375
599
  state.historyIndex = payload.historyIndex;
376
600
  state.visitCounts = payload.visitCounts ?? {};
377
601
  state.renderCounts = payload.renderCounts ?? {};
378
602
  state.temporary = {};
379
603
  });
604
+
605
+ lastNavigationVars = get().variables;
606
+
380
607
  if (payload.prng) {
381
608
  restorePRNG(payload.prng.seed, payload.prng.pull);
382
609
  } else {
383
610
  resetPRNG();
384
611
  }
385
612
  },
613
+
614
+ getHistoryVariables: (index: number): Record<string, unknown> => {
615
+ return deepClone(reconstructVarsAt(index));
616
+ },
386
617
  })),
387
618
  );
package/src/story-api.ts CHANGED
@@ -4,6 +4,8 @@ import { settings } from './settings';
4
4
  import type { SavePayload } from './saves/types';
5
5
  import { setTitleGenerator } from './saves/save-manager';
6
6
  import { registerClass } from './class-registry';
7
+ import { defineMacro } from './define-macro';
8
+ import type { MacroDefinition } from './define-macro';
7
9
  import {
8
10
  getActions,
9
11
  getAction,
@@ -39,12 +41,12 @@ export interface StoryAPI {
39
41
  save(slot?: string): void;
40
42
  load(slot?: string): void;
41
43
  hasSave(slot?: string): boolean;
42
- visited(name: string): number;
43
- hasVisited(name: string): boolean;
44
+ visited(name?: string): number;
45
+ hasVisited(name?: string): boolean;
44
46
  hasVisitedAny(...names: string[]): boolean;
45
47
  hasVisitedAll(...names: string[]): boolean;
46
- rendered(name: string): number;
47
- hasRendered(name: string): boolean;
48
+ rendered(name?: string): number;
49
+ hasRendered(name?: string): boolean;
48
50
  hasRenderedAny(...names: string[]): boolean;
49
51
  hasRenderedAll(...names: string[]): boolean;
50
52
  currentPassage(): Passage | undefined;
@@ -53,6 +55,7 @@ export interface StoryAPI {
53
55
  readonly passage: string;
54
56
  readonly settings: typeof settings;
55
57
  registerClass(name: string, ctor: new (...args: any[]) => any): void;
58
+ defineMacro(config: MacroDefinition): void;
56
59
  readonly saves: {
57
60
  setTitleGenerator(fn: (payload: SavePayload) => string): void;
58
61
  };
@@ -64,6 +67,9 @@ export interface StoryAPI {
64
67
  waitForActions(): Promise<StoryAction[]>;
65
68
  random(): number;
66
69
  randomInt(min: number, max: number): number;
70
+ readonly config: {
71
+ maxHistory: number;
72
+ };
67
73
  readonly prng: {
68
74
  init(seed?: string, useEntropy?: boolean): void;
69
75
  isEnabled(): boolean;
@@ -117,12 +123,13 @@ function createStoryAPI(): StoryAPI {
117
123
  return useStoryStore.getState().hasSave(slot);
118
124
  },
119
125
 
120
- visited(name: string): number {
121
- return useStoryStore.getState().visitCounts[name] ?? 0;
126
+ visited(name?: string): number {
127
+ const state = useStoryStore.getState();
128
+ return state.visitCounts[name ?? state.currentPassage] ?? 0;
122
129
  },
123
130
 
124
- hasVisited(name: string): boolean {
125
- return (useStoryStore.getState().visitCounts[name] ?? 0) > 0;
131
+ hasVisited(name?: string): boolean {
132
+ return this.visited(name) > 0;
126
133
  },
127
134
 
128
135
  hasVisitedAny(...names: string[]): boolean {
@@ -135,12 +142,13 @@ function createStoryAPI(): StoryAPI {
135
142
  return names.every((n) => (visitCounts[n] ?? 0) > 0);
136
143
  },
137
144
 
138
- rendered(name: string): number {
139
- return useStoryStore.getState().renderCounts[name] ?? 0;
145
+ rendered(name?: string): number {
146
+ const state = useStoryStore.getState();
147
+ return state.renderCounts[name ?? state.currentPassage] ?? 0;
140
148
  },
141
149
 
142
- hasRendered(name: string): boolean {
143
- return (useStoryStore.getState().renderCounts[name] ?? 0) > 0;
150
+ hasRendered(name?: string): boolean {
151
+ return this.rendered(name) > 0;
144
152
  },
145
153
 
146
154
  hasRenderedAny(...names: string[]): boolean {
@@ -179,6 +187,10 @@ function createStoryAPI(): StoryAPI {
179
187
  registerClass(name, ctor);
180
188
  },
181
189
 
190
+ defineMacro(config: MacroDefinition): void {
191
+ defineMacro(config);
192
+ },
193
+
182
194
  saves: {
183
195
  setTitleGenerator(fn: (payload: SavePayload) => string): void {
184
196
  setTitleGenerator(fn);
@@ -259,6 +271,15 @@ function createStoryAPI(): StoryAPI {
259
271
  return randomInt(min, max);
260
272
  },
261
273
 
274
+ config: {
275
+ get maxHistory(): number {
276
+ return useStoryStore.getState().maxHistory;
277
+ },
278
+ set maxHistory(limit: number) {
279
+ useStoryStore.getState().setMaxHistory(limit);
280
+ },
281
+ },
282
+
262
283
  prng: {
263
284
  init(seed?: string, useEntropy?: boolean): void {
264
285
  initPRNG(seed, useEntropy);