@rohal12/spindle 0.41.0 → 0.43.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/story-api.ts CHANGED
@@ -76,20 +76,37 @@ function ensureVariableChangedSubscription(): void {
76
76
  if (variableChangedSubActive) return;
77
77
  variableChangedSubActive = true;
78
78
  let prevVars = { ...useStoryStore.getState().variables };
79
+ let prevTrans = { ...useStoryStore.getState().transient };
79
80
  useStoryStore.subscribe((state) => {
80
81
  const changed: Record<string, { from: unknown; to: unknown }> = {};
81
82
  let hasChanges = false;
82
- const allKeys = new Set([
83
+
84
+ // Check $variables
85
+ const allVarKeys = new Set([
83
86
  ...Object.keys(prevVars),
84
87
  ...Object.keys(state.variables),
85
88
  ]);
86
- for (const key of allKeys) {
89
+ for (const key of allVarKeys) {
87
90
  if (state.variables[key] !== prevVars[key]) {
88
91
  changed[key] = { from: prevVars[key], to: state.variables[key] };
89
92
  hasChanges = true;
90
93
  }
91
94
  }
95
+
96
+ // Check %transient
97
+ const allTransKeys = new Set([
98
+ ...Object.keys(prevTrans),
99
+ ...Object.keys(state.transient),
100
+ ]);
101
+ for (const key of allTransKeys) {
102
+ if (state.transient[key] !== prevTrans[key]) {
103
+ changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] };
104
+ hasChanges = true;
105
+ }
106
+ }
107
+
92
108
  prevVars = { ...state.variables };
109
+ prevTrans = { ...state.transient };
93
110
  if (hasChanges) {
94
111
  emit('variableChanged', changed);
95
112
  }
@@ -178,16 +195,27 @@ export interface StoryAPI {
178
195
  function createStoryAPI(): StoryAPI {
179
196
  return {
180
197
  get(name: string): unknown {
198
+ if (name.startsWith('%')) {
199
+ return useStoryStore.getState().transient[name.slice(1)];
200
+ }
181
201
  return useStoryStore.getState().variables[name];
182
202
  },
183
203
 
184
204
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
185
205
  const state = useStoryStore.getState();
186
206
  if (typeof nameOrVars === 'string') {
187
- state.setVariable(nameOrVars, value);
207
+ if (nameOrVars.startsWith('%')) {
208
+ state.setTransient(nameOrVars.slice(1), value);
209
+ } else {
210
+ state.setVariable(nameOrVars, value);
211
+ }
188
212
  } else {
189
213
  for (const [k, v] of Object.entries(nameOrVars)) {
190
- state.setVariable(k, v);
214
+ if (k.startsWith('%')) {
215
+ state.setTransient(k.slice(1), v);
216
+ } else {
217
+ state.setVariable(k, v);
218
+ }
191
219
  }
192
220
  }
193
221
  },
@@ -12,7 +12,10 @@ export interface VariableSchema extends FieldSchema {
12
12
  default: unknown;
13
13
  }
14
14
 
15
- const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
15
+ function declarationRegex(sigil: string): RegExp {
16
+ const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
18
+ }
16
19
  const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
17
20
  const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
18
21
 
@@ -32,20 +35,23 @@ function inferSchema(value: unknown): FieldSchema {
32
35
  const jsType = typeof value;
33
36
  if (!VALID_VAR_TYPES.has(jsType)) {
34
37
  throw new Error(
35
- `StoryVariables: Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
38
+ `Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
36
39
  );
37
40
  }
38
41
  return { type: jsType as VarType };
39
42
  }
40
43
 
41
44
  /**
42
- * Parse a StoryVariables passage content into a schema map.
43
- * Each line: `$varName = expression`
45
+ * Parse a StoryVariables or StoryTransients passage content into a schema map.
46
+ * Each line: `$varName = expression` (or `%varName = expression` for transients)
44
47
  */
45
48
  export function parseStoryVariables(
46
49
  content: string,
50
+ sigil: '$' | '%' = '$',
47
51
  ): Map<string, VariableSchema> {
48
52
  const schema = new Map<string, VariableSchema>();
53
+ const DECLARATION_RE = declarationRegex(sigil);
54
+ const passageName = sigil === '%' ? 'StoryTransients' : 'StoryVariables';
49
55
 
50
56
  for (const rawLine of content.split('\n')) {
51
57
  const line = rawLine.trim();
@@ -54,7 +60,7 @@ export function parseStoryVariables(
54
60
  const match = line.match(DECLARATION_RE);
55
61
  if (!match) {
56
62
  throw new Error(
57
- `StoryVariables: Invalid declaration: "${line}". Expected: $name = value`,
63
+ `${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`,
58
64
  );
59
65
  }
60
66
 
@@ -64,11 +70,18 @@ export function parseStoryVariables(
64
70
  value = new Function('return (' + expr + ')')();
65
71
  } catch (err) {
66
72
  throw new Error(
67
- `StoryVariables: Failed to evaluate "$${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
73
+ `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
68
74
  );
69
75
  }
70
76
 
71
- const fieldSchema = inferSchema(value);
77
+ let fieldSchema: FieldSchema;
78
+ try {
79
+ fieldSchema = inferSchema(value);
80
+ } catch (err) {
81
+ throw new Error(
82
+ `${passageName}: ${err instanceof Error ? err.message : err}`,
83
+ );
84
+ }
72
85
  schema.set(name, { ...fieldSchema, name, default: value });
73
86
  }
74
87
 
@@ -146,8 +159,8 @@ export function validatePassages(
146
159
  const errors: string[] = [];
147
160
 
148
161
  for (const [name, passage] of passages) {
149
- // Don't validate the StoryVariables passage itself
150
- if (name === 'StoryVariables') continue;
162
+ // Don't validate the StoryVariables/StoryTransients passages themselves
163
+ if (name === 'StoryVariables' || name === 'StoryTransients') continue;
151
164
 
152
165
  const forLocals = extractForLocals(passage.content);
153
166
 
package/src/triggers.ts CHANGED
@@ -45,7 +45,13 @@ let dialogHostCallbacks: DialogHostCallbacks | null = null;
45
45
  function evalCondition(condition: string): boolean {
46
46
  const state = useStoryStore.getState();
47
47
  try {
48
- return !!evaluate(condition, state.variables, state.temporary);
48
+ return !!evaluate(
49
+ condition,
50
+ state.variables,
51
+ state.temporary,
52
+ {},
53
+ state.transient,
54
+ );
49
55
  } catch {
50
56
  return false;
51
57
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compile-time check: the hand-written types/index.d.ts must stay in sync
3
+ * with the source StoryAPI interface. If this file fails to compile,
4
+ * the published types have drifted from the implementation.
5
+ *
6
+ * Run: npx tsc --noEmit
7
+ */
8
+ import type { StoryAPI as SourceAPI } from './story-api';
9
+ import type { StoryAPI as PublishedAPI } from '../types/index';
10
+
11
+ // Both directions — if either fails, the types have drifted.
12
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
+ const _sourceToPublished: PublishedAPI = {} as SourceAPI;
14
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
+ const _publishedToSource: SourceAPI = {} as PublishedAPI;
package/types/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface HistoryMoment {
14
14
  passage: string;
15
15
  variables: Record<string, unknown>;
16
16
  timestamp: number;
17
+ prng?: { seed: string; pull: number } | null;
17
18
  }
18
19
 
19
20
  /**
@@ -27,6 +28,7 @@ export interface SavePayload {
27
28
  historyIndex: number;
28
29
  visitCounts?: Record<string, number>;
29
30
  renderCounts?: Record<string, number>;
31
+ prng?: { seed: string; pull: number } | null;
30
32
  }
31
33
 
32
34
  /**
@@ -79,6 +81,9 @@ export interface SettingsAPI {
79
81
  addList(name: string, config: ListConfig): void;
80
82
  addRange(name: string, config: RangeConfig): void;
81
83
  get(name: string): unknown;
84
+ getToggle(name: string): boolean;
85
+ getList(name: string): string;
86
+ getRange(name: string): number;
82
87
  set(name: string, value: unknown): void;
83
88
  getAll(): Record<string, unknown>;
84
89
  getDefinitions(): Map<string, SettingDef>;
@@ -102,6 +107,239 @@ export interface Passage {
102
107
  content: string;
103
108
  }
104
109
 
110
+ /**
111
+ * Map of story event names to their callback signatures.
112
+ * @see {@link ../../src/event-emitter.ts} for the implementation.
113
+ */
114
+ export interface StoryEventMap {
115
+ storyinit: () => void;
116
+ beforerestart: () => void;
117
+ actionsChanged: () => void;
118
+ variableChanged: (
119
+ changed: Record<string, { from: unknown; to: unknown }>,
120
+ ) => void;
121
+ beforesave: (
122
+ slot: string | undefined,
123
+ custom: Record<string, unknown> | undefined,
124
+ ) => void;
125
+ aftersave: (slot: string | undefined) => void;
126
+ beforeload: (slot: string | undefined) => void;
127
+ afterload: (slot: string | undefined) => void;
128
+ beforenavigate: (passageName: string) => void;
129
+ afternavigate: (to: string, from: string) => void;
130
+ }
131
+
132
+ /** Event name that can be passed to `Story.on()`. */
133
+ export type StoryEvent = keyof StoryEventMap;
134
+
135
+ /** Callback type for a given story event. */
136
+ export type StoryEventCallback<E extends StoryEvent> = StoryEventMap[E];
137
+
138
+ /** Transition animation type. */
139
+ export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade';
140
+
141
+ /**
142
+ * Configuration for passage transitions.
143
+ * @see {@link ../../src/transition.ts} for the implementation.
144
+ */
145
+ export interface TransitionConfig {
146
+ type: TransitionType;
147
+ duration?: number;
148
+ pause?: number;
149
+ }
150
+
151
+ /**
152
+ * Options for `Story.watch()` trigger registration.
153
+ * @see {@link ../../src/triggers.ts} for the implementation.
154
+ */
155
+ export interface WatchOptions {
156
+ goto?: string;
157
+ dialog?: string;
158
+ run?: string;
159
+ once?: boolean;
160
+ name?: string;
161
+ priority?: number;
162
+ }
163
+
164
+ /** Type of interactive action registered by a macro. */
165
+ export type ActionType =
166
+ | 'link'
167
+ | 'button'
168
+ | 'cycle'
169
+ | 'textbox'
170
+ | 'numberbox'
171
+ | 'textarea'
172
+ | 'checkbox'
173
+ | 'radiobutton'
174
+ | 'listbox'
175
+ | 'back'
176
+ | 'forward'
177
+ | 'restart'
178
+ | 'save'
179
+ | 'load'
180
+ | 'dialog';
181
+
182
+ /**
183
+ * A registered interactive action (link, button, input, etc.).
184
+ * @see {@link ../../src/action-registry.ts} for the implementation.
185
+ */
186
+ export interface StoryAction {
187
+ id: string;
188
+ type: ActionType;
189
+ label: string;
190
+ target?: string;
191
+ variable?: string;
192
+ options?: string[];
193
+ value?: unknown;
194
+ disabled?: boolean;
195
+ perform: (value?: unknown) => void;
196
+ }
197
+
198
+ /**
199
+ * Storage usage information returned by `Story.storage.getInfo()`.
200
+ * @see {@link ../../src/saves/types.ts} for the implementation.
201
+ */
202
+ export interface StorageInfo {
203
+ saveCount: number;
204
+ playthroughCount: number;
205
+ totalBytes: number;
206
+ backend: 'indexeddb' | 'localstorage' | 'memory';
207
+ }
208
+
209
+ /**
210
+ * Browser storage quota estimate returned by `Story.storage.getQuota()`.
211
+ * @see {@link ../../src/saves/types.ts} for the implementation.
212
+ */
213
+ export interface StorageQuota {
214
+ usage: number;
215
+ quota: number;
216
+ estimateSupported: boolean;
217
+ }
218
+
219
+ /**
220
+ * Parameter metadata for a macro definition.
221
+ * @see {@link ../../src/registry.ts} for the implementation.
222
+ */
223
+ export interface ParameterDef {
224
+ name: string;
225
+ required?: boolean;
226
+ description?: string;
227
+ }
228
+
229
+ /**
230
+ * Metadata about a registered macro, returned by `Story.getMacroRegistry()`.
231
+ * @see {@link ../../src/registry.ts} for the implementation.
232
+ */
233
+ export interface MacroMetadata {
234
+ name: string;
235
+ block: boolean;
236
+ subMacros: string[];
237
+ storeVar?: boolean;
238
+ interpolate?: boolean;
239
+ merged?: boolean;
240
+ source: 'builtin' | 'user';
241
+ description?: string;
242
+ parameters?: ParameterDef[];
243
+ }
244
+
245
+ /**
246
+ * Props passed to a macro's render function.
247
+ * @see {@link ../../src/registry.ts} for the implementation.
248
+ */
249
+ export interface MacroProps {
250
+ rawArgs: string;
251
+ className?: string;
252
+ id?: string;
253
+ children?: any[];
254
+ branches?: Array<{
255
+ rawArgs: string;
256
+ className?: string;
257
+ id?: string;
258
+ children: any[];
259
+ }>;
260
+ }
261
+
262
+ /**
263
+ * Options for registering an interactive action via `ctx.useAction`.
264
+ * @see {@link ../../src/hooks/use-action.ts} for the implementation.
265
+ */
266
+ export interface UseActionOptions {
267
+ type: ActionType;
268
+ key: string;
269
+ authorId?: string;
270
+ label: string;
271
+ target?: string;
272
+ variable?: string;
273
+ options?: string[];
274
+ value?: unknown;
275
+ disabled?: boolean;
276
+ perform: (value?: unknown) => void;
277
+ }
278
+
279
+ /**
280
+ * Context object passed to a macro's render function alongside props.
281
+ * Internal Preact/AST types are represented as `any` since consumers
282
+ * may not have Preact type definitions installed.
283
+ * @see {@link ../../src/define-macro.ts} for the implementation.
284
+ */
285
+ export interface MacroContext {
286
+ className?: string;
287
+ id?: string;
288
+ resolve?: (s: string | undefined) => string | undefined;
289
+ cls: string;
290
+ mutate: (code: string) => void;
291
+ update: (key: string, value: unknown) => void;
292
+ getValues: () => Record<string, unknown>;
293
+ merged?: readonly [
294
+ Record<string, unknown>,
295
+ Record<string, unknown>,
296
+ Record<string, unknown>,
297
+ Record<string, unknown>,
298
+ ];
299
+ varName?: string;
300
+ value?: unknown;
301
+ setValue?: (value: unknown) => void;
302
+ getValue?: () => unknown;
303
+ evaluate?: (expr: string) => unknown;
304
+ collectText: (nodes: any[]) => string;
305
+ sourceLocation: () => string;
306
+ parseVarArgs: (rawArgs: string) => { varName: string; placeholder: string };
307
+ extractOptions: (children: any[]) => string[];
308
+ wrap: (content: any) => any;
309
+ useAction: (opts: UseActionOptions) => string;
310
+ h: (type: any, props: any, ...children: any[]) => any;
311
+ renderNodes: (
312
+ nodes: any[],
313
+ options?: { nobr?: boolean; locals?: Record<string, unknown> },
314
+ ) => any;
315
+ renderInlineNodes: (nodes: any[]) => any;
316
+ hooks: {
317
+ useState: any;
318
+ useRef: any;
319
+ useEffect: any;
320
+ useLayoutEffect: any;
321
+ useCallback: any;
322
+ useMemo: any;
323
+ useContext: any;
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Configuration object for `Story.defineMacro()`.
329
+ * @see {@link ../../src/define-macro.ts} for the implementation.
330
+ */
331
+ export interface MacroDefinition {
332
+ name: string;
333
+ subMacros?: string[];
334
+ block?: boolean;
335
+ interpolate?: boolean;
336
+ merged?: boolean;
337
+ storeVar?: boolean;
338
+ description?: string;
339
+ parameters?: ParameterDef[];
340
+ render: (props: MacroProps, ctx: MacroContext) => any;
341
+ }
342
+
105
343
  /**
106
344
  * Metadata about a save slot, returned by `getSaveInfo()` and `listSaves()`.
107
345
  * @see {@link ../../src/saves/types.ts} for the implementation.
@@ -127,12 +365,20 @@ export interface SaveInfo {
127
365
  * @see {@link ../../src/story-api.ts} for the implementation.
128
366
  */
129
367
  export interface StoryAPI {
130
- /** Get the value of a story variable. */
368
+ /**
369
+ * Get a variable value. Use '%name' prefix for transient variables.
370
+ * @example Story.get('health') // $health
371
+ * @example Story.get('%npcList') // %npcList (transient)
372
+ */
131
373
  get(name: string): unknown;
132
374
 
133
- /** Set a single story variable. */
375
+ /**
376
+ * Set one or more variables. Use '%name' prefix for transient variables.
377
+ * @example Story.set('health', 100)
378
+ * @example Story.set('%npcList', [...])
379
+ * @example Story.set({ health: 100, '%npcList': [...] })
380
+ */
134
381
  set(name: string, value: unknown): void;
135
- /** Set multiple story variables at once. */
136
382
  set(vars: Record<string, unknown>): void;
137
383
 
138
384
  /** Navigate to a passage by name. */
@@ -166,10 +412,10 @@ export interface StoryAPI {
166
412
  deleteSave(slot?: string): void;
167
413
 
168
414
  /** Return the number of times a passage has been visited. */
169
- visited(name: string): number;
415
+ visited(name?: string): number;
170
416
 
171
417
  /** Check if a passage has been visited at least once. */
172
- hasVisited(name: string): boolean;
418
+ hasVisited(name?: string): boolean;
173
419
 
174
420
  /** Check if any of the given passages have been visited. */
175
421
  hasVisitedAny(...names: string[]): boolean;
@@ -178,10 +424,10 @@ export interface StoryAPI {
178
424
  hasVisitedAll(...names: string[]): boolean;
179
425
 
180
426
  /** Return the number of times a passage has been rendered. */
181
- rendered(name: string): number;
427
+ rendered(name?: string): number;
182
428
 
183
429
  /** Check if a passage has been rendered at least once. */
184
- hasRendered(name: string): boolean;
430
+ hasRendered(name?: string): boolean;
185
431
 
186
432
  /** Check if any of the given passages have been rendered. */
187
433
  hasRenderedAny(...names: string[]): boolean;
@@ -228,4 +474,95 @@ export interface StoryAPI {
228
474
 
229
475
  /** Check whether any dialog is currently open. */
230
476
  isDialogOpen(): boolean;
477
+
478
+ /** Register a class constructor for use in story expressions. */
479
+ registerClass(name: string, ctor: new (...args: any[]) => any): void;
480
+
481
+ /** Register a custom macro. */
482
+ defineMacro(config: MacroDefinition): void;
483
+
484
+ /** Return metadata for all registered macros. */
485
+ getMacroRegistry(): MacroMetadata[];
486
+
487
+ /** Storage management API. */
488
+ readonly storage: {
489
+ /** Get storage usage information (save count, byte size, backend type). */
490
+ getInfo(): Promise<StorageInfo>;
491
+ /** Get browser storage quota estimate. */
492
+ getQuota(): Promise<StorageQuota>;
493
+ /** Delete all saves for the current game. */
494
+ clearGameData(): Promise<void>;
495
+ /** Delete all Spindle data across all games. */
496
+ clearAllData(): Promise<void>;
497
+ /** Delete a specific playthrough and its saves. */
498
+ deletePlaythrough(playthroughId: string): Promise<void>;
499
+ /** The active storage backend. */
500
+ readonly backend: 'indexeddb' | 'localstorage' | 'memory';
501
+ };
502
+
503
+ /** Return all registered interactive actions. */
504
+ getActions(): StoryAction[];
505
+
506
+ /** Perform a registered action by ID. */
507
+ performAction(id: string, value?: unknown): void;
508
+
509
+ /** Subscribe to a story event. Returns an unsubscribe function. */
510
+ on<E extends StoryEvent>(
511
+ event: E,
512
+ callback: StoryEventCallback<E>,
513
+ ): () => void;
514
+
515
+ /** Wait for the next frame's actions to be registered, then return them. */
516
+ waitForActions(): Promise<StoryAction[]>;
517
+
518
+ /** Register a trigger that fires when a condition expression becomes truthy. Returns an unsubscribe function. */
519
+ watch(
520
+ condition: string,
521
+ callbackOrOptions: (() => void) | WatchOptions,
522
+ ): () => void;
523
+
524
+ /** Remove a named trigger registered with `watch()`. */
525
+ unwatch(name: string): void;
526
+
527
+ /** Enable or disable the `{nobr}` (no line breaks) rendering mode globally. */
528
+ setNobr(enabled: boolean): void;
529
+
530
+ /** Enable or disable the story stylesheet. */
531
+ setCSS(enabled: boolean): void;
532
+
533
+ /** Set the default passage transition. Pass `null` to clear. */
534
+ setTransition(config: TransitionConfig | null): void;
535
+
536
+ /** Set a one-time transition for the next navigation only. Pass `null` to clear. */
537
+ setNextTransition(config: TransitionConfig | null): void;
538
+
539
+ /** Defer initial passage rendering until `ready()` is called. */
540
+ deferRender(): void;
541
+
542
+ /** Unblock deferred rendering (call after `deferRender()`). */
543
+ ready(): void;
544
+
545
+ /** Return a random float in [0, 1). Uses the seeded PRNG if enabled, otherwise Math.random(). */
546
+ random(): number;
547
+
548
+ /** Return a random integer in [min, max] (inclusive). */
549
+ randomInt(min: number, max: number): number;
550
+
551
+ /** Story configuration. */
552
+ readonly config: {
553
+ /** Maximum number of history moments to retain. */
554
+ maxHistory: number;
555
+ };
556
+
557
+ /** Seedable pseudo-random number generator. */
558
+ readonly prng: {
559
+ /** Initialize the PRNG with an optional seed. */
560
+ init(seed?: string, useEntropy?: boolean): void;
561
+ /** Check whether the seeded PRNG is active. */
562
+ isEnabled(): boolean;
563
+ /** The current PRNG seed. */
564
+ readonly seed: string;
565
+ /** The number of values pulled from the current seed. */
566
+ readonly pull: number;
567
+ };
231
568
  }