@rohal12/spindle 0.43.7 → 0.45.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
@@ -10,6 +10,7 @@ import { settings } from './settings';
10
10
  import type {
11
11
  SavePayload,
12
12
  SaveInfo,
13
+ SaveExport,
13
14
  StorageInfo,
14
15
  StorageQuota,
15
16
  } from './saves/types';
@@ -113,6 +114,37 @@ function ensureVariableChangedSubscription(): void {
113
114
  });
114
115
  }
115
116
 
117
+ /** Traverse a dot-delimited path on an object and return the value. */
118
+ function getByPath(obj: Record<string, unknown>, path: string): unknown {
119
+ const segments = path.split('.');
120
+ let current: unknown = obj[segments[0]!];
121
+ for (let i = 1; i < segments.length; i++) {
122
+ if (current == null) return undefined;
123
+ current = (current as Record<string, unknown>)[segments[i]!];
124
+ }
125
+ return current;
126
+ }
127
+
128
+ /** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
129
+ function setByPath(
130
+ obj: Record<string, unknown>,
131
+ path: string,
132
+ value: unknown,
133
+ ): void {
134
+ const segments = path.split('.');
135
+ let current: Record<string, unknown> = obj;
136
+ for (let i = 0; i < segments.length - 1; i++) {
137
+ const next = current[segments[i]!];
138
+ if (next == null || typeof next !== 'object') {
139
+ throw new TypeError(
140
+ `spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
141
+ );
142
+ }
143
+ current = next as Record<string, unknown>;
144
+ }
145
+ current[segments[segments.length - 1]!] = value;
146
+ }
147
+
116
148
  export interface StoryAPI {
117
149
  get(name: string): unknown;
118
150
  set(name: string, value: unknown): void;
@@ -127,6 +159,8 @@ export interface StoryAPI {
127
159
  getSaveInfo(slot?: string): Promise<SaveInfo | null>;
128
160
  listSaves(): Promise<SaveInfo[]>;
129
161
  deleteSave(slot?: string): void;
162
+ exportSave(slot?: string): Promise<SaveExport | null>;
163
+ importSave(data: unknown, slot?: string): Promise<SaveInfo>;
130
164
  visited(name?: string): number;
131
165
  hasVisited(name?: string): boolean;
132
166
  hasVisitedAny(...names: string[]): boolean;
@@ -168,7 +202,11 @@ export interface StoryAPI {
168
202
  unwatch(name: string): void;
169
203
  openDialog(
170
204
  passageName: string,
171
- options?: { panelClass?: string; showCloseButton?: boolean },
205
+ options?: {
206
+ panelClass?: string;
207
+ showCloseButton?: boolean;
208
+ dismissible?: boolean;
209
+ },
172
210
  ): void;
173
211
  closeDialog(): void;
174
212
  closeAllDialogs(): void;
@@ -183,6 +221,8 @@ export interface StoryAPI {
183
221
  randomInt(min: number, max: number): number;
184
222
  readonly config: {
185
223
  maxHistory: number;
224
+ quickSaveKey: string | null;
225
+ quickLoadKey: string | null;
186
226
  };
187
227
  readonly prng: {
188
228
  init(seed?: string, useEntropy?: boolean): void;
@@ -192,30 +232,43 @@ export interface StoryAPI {
192
232
  };
193
233
  }
194
234
 
235
+ /** Set a single variable, resolving dot-paths if present. */
236
+ function setOne(name: string, value: unknown): void {
237
+ const isTransient = name.startsWith('%');
238
+ const key = isTransient ? name.slice(1) : name;
239
+ const namespace = isTransient ? 'transient' : 'variables';
240
+
241
+ if (key.includes('.')) {
242
+ useStoryStore.setState((state) => {
243
+ setByPath(state[namespace] as Record<string, unknown>, key, value);
244
+ });
245
+ } else {
246
+ const state = useStoryStore.getState();
247
+ if (isTransient) {
248
+ state.setTransient(key, value);
249
+ } else {
250
+ state.setVariable(key, value);
251
+ }
252
+ }
253
+ }
254
+
195
255
  function createStoryAPI(): StoryAPI {
196
256
  return {
197
257
  get(name: string): unknown {
198
- if (name.startsWith('%')) {
199
- return useStoryStore.getState().transient[name.slice(1)];
200
- }
201
- return useStoryStore.getState().variables[name];
258
+ const isTransient = name.startsWith('%');
259
+ const key = isTransient ? name.slice(1) : name;
260
+ const store = isTransient
261
+ ? useStoryStore.getState().transient
262
+ : useStoryStore.getState().variables;
263
+ return key.includes('.') ? getByPath(store, key) : store[key];
202
264
  },
203
265
 
204
266
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
205
- const state = useStoryStore.getState();
206
267
  if (typeof nameOrVars === 'string') {
207
- if (nameOrVars.startsWith('%')) {
208
- state.setTransient(nameOrVars.slice(1), value);
209
- } else {
210
- state.setVariable(nameOrVars, value);
211
- }
268
+ setOne(nameOrVars, value);
212
269
  } else {
213
270
  for (const [k, v] of Object.entries(nameOrVars)) {
214
- if (k.startsWith('%')) {
215
- state.setTransient(k.slice(1), v);
216
- } else {
217
- state.setVariable(k, v);
218
- }
271
+ setOne(k, v);
219
272
  }
220
273
  }
221
274
  },
@@ -260,6 +313,14 @@ function createStoryAPI(): StoryAPI {
260
313
  useStoryStore.getState().deleteSave(slot);
261
314
  },
262
315
 
316
+ exportSave(slot?: string): Promise<SaveExport | null> {
317
+ return useStoryStore.getState().exportSave(slot);
318
+ },
319
+
320
+ importSave(data: unknown, slot?: string): Promise<SaveInfo> {
321
+ return useStoryStore.getState().importSave(data, slot);
322
+ },
323
+
263
324
  visited(name?: string): number {
264
325
  const state = useStoryStore.getState();
265
326
  return state.visitCounts[name ?? state.currentPassage] ?? 0;
@@ -450,12 +511,17 @@ function createStoryAPI(): StoryAPI {
450
511
 
451
512
  openDialog(
452
513
  passageName: string,
453
- options?: { panelClass?: string; showCloseButton?: boolean },
514
+ options?: {
515
+ panelClass?: string;
516
+ showCloseButton?: boolean;
517
+ dismissible?: boolean;
518
+ },
454
519
  ): void {
455
520
  pushDialog({
456
521
  passageName,
457
522
  panelClass: options?.panelClass,
458
523
  showCloseButton: options?.showCloseButton,
524
+ dismissible: options?.dismissible,
459
525
  });
460
526
  },
461
527
 
@@ -518,6 +584,18 @@ function createStoryAPI(): StoryAPI {
518
584
  set maxHistory(limit: number) {
519
585
  useStoryStore.getState().setMaxHistory(limit);
520
586
  },
587
+ get quickSaveKey(): string | null {
588
+ return useStoryStore.getState().quickSaveKey;
589
+ },
590
+ set quickSaveKey(key: string | null) {
591
+ useStoryStore.getState().setQuickSaveKey(key);
592
+ },
593
+ get quickLoadKey(): string | null {
594
+ return useStoryStore.getState().quickLoadKey;
595
+ },
596
+ set quickLoadKey(key: string | null) {
597
+ useStoryStore.getState().setQuickLoadKey(key);
598
+ },
521
599
  },
522
600
 
523
601
  prng: {
package/src/triggers.ts CHANGED
@@ -14,6 +14,7 @@ export interface QueuedDialog {
14
14
  passageName: string;
15
15
  panelClass?: string;
16
16
  showCloseButton?: boolean;
17
+ dismissible?: boolean;
17
18
  }
18
19
 
19
20
  interface Trigger {
@@ -13,3 +13,13 @@ import type { StoryAPI as PublishedAPI } from '../types/index';
13
13
  const _sourceToPublished: PublishedAPI = {} as SourceAPI;
14
14
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
15
  const _publishedToSource: SourceAPI = {} as PublishedAPI;
16
+
17
+ // Tooling entry point (`@rohal12/spindle/tooling`): types/tooling.d.ts must
18
+ // match the parser that dist/pkg/tooling.js re-exports.
19
+ import type { parseStoryVariables as SourceParse } from './story-variables';
20
+ import type { parseStoryVariables as PublishedParse } from '../types/tooling';
21
+
22
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
+ const _parseSourceToPublished: typeof PublishedParse = {} as typeof SourceParse;
24
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
25
+ const _parsePublishedToSource: typeof SourceParse = {} as typeof PublishedParse;
package/types/index.d.ts CHANGED
@@ -359,6 +359,54 @@ export interface SaveInfo {
359
359
  custom: Record<string, unknown>;
360
360
  }
361
361
 
362
+ /**
363
+ * Metadata stored with each save record.
364
+ * @see {@link ../../src/saves/types.ts} for the implementation.
365
+ */
366
+ export interface SaveMeta {
367
+ /** Unique save ID. */
368
+ id: string;
369
+ /** IFID of the story that created the save. */
370
+ ifid: string;
371
+ /** Playthrough the save belongs to. */
372
+ playthroughId: string;
373
+ /** ISO 8601 timestamp when the save was first created. */
374
+ createdAt: string;
375
+ /** ISO 8601 timestamp when the save was last updated. */
376
+ updatedAt: string;
377
+ /** Save title (generated or custom). */
378
+ title: string;
379
+ /** Passage name at the time of saving. */
380
+ passage: string;
381
+ /** Custom metadata passed when saving. */
382
+ custom: Record<string, unknown>;
383
+ /** Estimated size of the stored payload in bytes. */
384
+ estimatedBytes?: number;
385
+ }
386
+
387
+ /**
388
+ * A stored save: metadata plus the serialized payload.
389
+ * @see {@link ../../src/saves/types.ts} for the implementation.
390
+ */
391
+ export interface SaveRecord {
392
+ meta: SaveMeta;
393
+ payload: SavePayload;
394
+ }
395
+
396
+ /**
397
+ * Portable save file produced by `exportSave()` and accepted by `importSave()`.
398
+ * Plain JSON: `JSON.stringify` it to write a file, `JSON.parse` to read one back.
399
+ * @see {@link ../../src/saves/types.ts} for the implementation.
400
+ */
401
+ export interface SaveExport {
402
+ version: 1;
403
+ /** IFID of the story the save belongs to. Imports into other stories are rejected. */
404
+ ifid: string;
405
+ /** ISO 8601 timestamp of the export. */
406
+ exportedAt: string;
407
+ save: SaveRecord;
408
+ }
409
+
362
410
  /**
363
411
  * The main Story API available as `window.Story` at runtime.
364
412
  * Provides access to variables, navigation, save/load, and visit tracking.
@@ -411,6 +459,21 @@ export interface StoryAPI {
411
459
  /** Delete a save by slot name. */
412
460
  deleteSave(slot?: string): void;
413
461
 
462
+ /**
463
+ * Export the save in a slot as a portable object (plain JSON).
464
+ * Resolves to null if the slot is empty.
465
+ * @example const data = await Story.exportSave('slot-1'); // JSON.stringify(data) to download
466
+ */
467
+ exportSave(slot?: string): Promise<SaveExport | null>;
468
+
469
+ /**
470
+ * Import an exported save into a slot, replacing whatever the slot held.
471
+ * Rejects if `data` is not a save export or belongs to a different story (IFID).
472
+ * Resolves to the slot's new metadata.
473
+ * @example await Story.importSave(JSON.parse(text), 'slot-2')
474
+ */
475
+ importSave(data: unknown, slot?: string): Promise<SaveInfo>;
476
+
414
477
  /** Return the number of times a passage has been visited. */
415
478
  visited(name?: string): number;
416
479
 
@@ -460,10 +523,18 @@ export interface StoryAPI {
460
523
  * Open a dialog rendering the given passage.
461
524
  * @param passageName - The passage to render inside the dialog.
462
525
  * @param options - Optional configuration for the dialog panel.
526
+ * @param options.panelClass - CSS class added to the dialog panel.
527
+ * @param options.showCloseButton - Show the default `✕` button (defaults to `dismissible`).
528
+ * @param options.dismissible - When `false`, backdrop clicks are ignored and the
529
+ * `✕` button is hidden; close the dialog with `closeDialog()`. Default: `true`.
463
530
  */
464
531
  openDialog(
465
532
  passageName: string,
466
- options?: { panelClass?: string; showCloseButton?: boolean },
533
+ options?: {
534
+ panelClass?: string;
535
+ showCloseButton?: boolean;
536
+ dismissible?: boolean;
537
+ },
467
538
  ): void;
468
539
 
469
540
  /** Close the topmost open dialog. */
@@ -552,6 +623,16 @@ export interface StoryAPI {
552
623
  readonly config: {
553
624
  /** Maximum number of history moments to retain. */
554
625
  maxHistory: number;
626
+ /**
627
+ * Key that triggers a quick save (`KeyboardEvent.key`, default `'F6'`).
628
+ * Set to `null` to disable the shortcut.
629
+ */
630
+ quickSaveKey: string | null;
631
+ /**
632
+ * Key that triggers a quick load (`KeyboardEvent.key`, default `'F9'`).
633
+ * Set to `null` to disable the shortcut.
634
+ */
635
+ quickLoadKey: string | null;
555
636
  };
556
637
 
557
638
  /** Seedable pseudo-random number generator. */
@@ -0,0 +1,68 @@
1
+ export interface ParameterDef {
2
+ name: string;
3
+ required?: boolean;
4
+ description?: string;
5
+ }
6
+
7
+ export interface MacroMetadata {
8
+ name: string;
9
+ block: boolean;
10
+ subMacros: string[];
11
+ storeVar?: boolean;
12
+ interpolate?: boolean;
13
+ merged?: boolean;
14
+ source: 'builtin' | 'user';
15
+ description?: string;
16
+ parameters?: ParameterDef[];
17
+ }
18
+
19
+ export interface MacroDefinition {
20
+ name: string;
21
+ subMacros?: string[];
22
+ block?: boolean;
23
+ interpolate?: boolean;
24
+ merged?: boolean;
25
+ storeVar?: boolean;
26
+ description?: string;
27
+ parameters?: ParameterDef[];
28
+ render: (...args: any[]) => any;
29
+ }
30
+
31
+ /**
32
+ * Metadata-only defineMacro for tooling.
33
+ * Captures macro metadata without creating Preact components.
34
+ * LSP servers call this to register user-defined macros discovered in story scripts.
35
+ */
36
+ export declare function defineMacro(config: MacroDefinition): void;
37
+
38
+ /**
39
+ * Return metadata for all registered macros (built-in + user-defined).
40
+ */
41
+ export declare function getMacroRegistry(): MacroMetadata[];
42
+
43
+ /** Variable type inferred from a StoryVariables/StoryTransients default value. */
44
+ export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
45
+
46
+ /** Inferred shape of a declared variable (or one of its object fields). */
47
+ export interface FieldSchema {
48
+ type: VarType;
49
+ /** Field schemas, only present for objects. */
50
+ fields?: Map<string, FieldSchema>;
51
+ }
52
+
53
+ /** A declared variable: its inferred schema plus its default value. */
54
+ export interface VariableSchema extends FieldSchema {
55
+ name: string;
56
+ default: unknown;
57
+ }
58
+
59
+ /**
60
+ * Parse the content of a `StoryVariables` (`$name = value`) or
61
+ * `StoryTransients` (`%name = value`, pass `sigil: '%'`) passage into a
62
+ * schema map, exactly as Spindle does at boot.
63
+ * Throws on invalid declarations or unsupported value types (e.g. `null`).
64
+ */
65
+ export declare function parseStoryVariables(
66
+ content: string,
67
+ sigil?: '$' | '%',
68
+ ): Map<string, VariableSchema>;