@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/dist/pkg/format.js +1 -1
- package/dist/pkg/story-variables.js +125 -0
- package/dist/pkg/tooling.js +2 -0
- package/package.json +2 -1
- package/src/components/App.tsx +6 -4
- package/src/components/PassageDialog.tsx +10 -1
- package/src/components/TriggerDialogHost.tsx +1 -0
- package/src/components/macros/MenubarButtons.tsx +17 -6
- package/src/saves/save-manager.ts +88 -19
- package/src/store.ts +52 -1
- package/src/story-api.ts +95 -17
- package/src/triggers.ts +1 -0
- package/src/types-drift-check.ts +10 -0
- package/types/index.d.ts +82 -1
- package/types/tooling.d.ts +68 -0
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?: {
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?: {
|
|
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
package/src/types-drift-check.ts
CHANGED
|
@@ -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?: {
|
|
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>;
|