@rohal12/spindle 0.50.1 → 0.51.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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +418 -129
- package/dist/pkg/story-variables.js +13 -0
- package/package.json +1 -1
- package/src/class-registry.ts +63 -1
- package/src/components/macros/Computed.tsx +2 -18
- package/src/components/macros/Include.tsx +57 -2
- package/src/components/macros/MacroLink.tsx +4 -3
- package/src/components/macros/SaveManager.tsx +7 -0
- package/src/components/macros/VarDisplay.tsx +3 -2
- package/src/components/macros/Widget.tsx +7 -1
- package/src/components/macros/WidgetInvocation.tsx +4 -2
- package/src/execute-mutation.ts +8 -54
- package/src/expression.ts +99 -4
- package/src/index.tsx +12 -20
- package/src/interpolation.ts +2 -1
- package/src/markup/ast.ts +9 -1
- package/src/markup/render.tsx +2 -1
- package/src/preact-store.ts +50 -0
- package/src/saves/save-manager.ts +109 -55
- package/src/saves/storage.ts +18 -4
- package/src/saves/types.ts +56 -7
- package/src/store.ts +86 -55
- package/src/story-api.ts +9 -9
- package/src/story-init.ts +23 -0
- package/src/story-variables.ts +18 -0
- package/types/index.d.ts +16 -6
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zustand's `create()` for Preact.
|
|
3
|
+
*
|
|
4
|
+
* Zustand's default entry (`zustand` / `zustand/react`) imports `react`, which
|
|
5
|
+
* Spindle doesn't depend on. Bundlers that alias `react` to `preact/compat`
|
|
6
|
+
* hide that, but the published headless entry keeps Zustand external and runs
|
|
7
|
+
* under plain Node resolution, where `react` doesn't exist. Building on the
|
|
8
|
+
* vanilla store and Preact's own hooks keeps `react` out of the module graph.
|
|
9
|
+
*/
|
|
10
|
+
import { useSyncExternalStore } from 'preact/compat';
|
|
11
|
+
import { useCallback } from 'preact/hooks';
|
|
12
|
+
import {
|
|
13
|
+
createStore,
|
|
14
|
+
type ExtractState,
|
|
15
|
+
type Mutate,
|
|
16
|
+
type StateCreator,
|
|
17
|
+
type StoreApi,
|
|
18
|
+
type StoreMutatorIdentifier,
|
|
19
|
+
} from 'zustand/vanilla';
|
|
20
|
+
|
|
21
|
+
/** A store API that is also a selector hook, like Zustand's bound stores. */
|
|
22
|
+
export type UseBoundStore<S extends StoreApi<unknown>> = S & {
|
|
23
|
+
(): ExtractState<S>;
|
|
24
|
+
<U>(selector: (state: ExtractState<S>) => U): U;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const identity = <T>(value: T): T => value;
|
|
28
|
+
|
|
29
|
+
function bindPreact<S extends StoreApi<unknown>>(api: S): UseBoundStore<S> {
|
|
30
|
+
function useBoundStore<U>(
|
|
31
|
+
selector: (state: ExtractState<S>) => U = identity as (
|
|
32
|
+
state: ExtractState<S>,
|
|
33
|
+
) => U,
|
|
34
|
+
): U {
|
|
35
|
+
const getSnapshot = useCallback(
|
|
36
|
+
() => selector(api.getState() as ExtractState<S>),
|
|
37
|
+
[selector],
|
|
38
|
+
);
|
|
39
|
+
return useSyncExternalStore(api.subscribe, getSnapshot);
|
|
40
|
+
}
|
|
41
|
+
return Object.assign(useBoundStore, api) as UseBoundStore<S>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Curried `create<State>()(initializer)`, as in Zustand. */
|
|
45
|
+
export function create<T>() {
|
|
46
|
+
return <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
|
|
47
|
+
initializer: StateCreator<T, [], Mos>,
|
|
48
|
+
): UseBoundStore<Mutate<StoreApi<T>, Mos>> =>
|
|
49
|
+
bindPreact(createStore<T>()(initializer));
|
|
50
|
+
}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SaveExport,
|
|
8
8
|
StorageInfo,
|
|
9
9
|
} from './types';
|
|
10
|
-
import { isSavePayload } from './types';
|
|
10
|
+
import { isSaveExport, isSavePayload } from './types';
|
|
11
11
|
import { getBackend, resetBackend } from './storage';
|
|
12
12
|
import { deepClone, serialize, deserialize } from '../class-registry';
|
|
13
13
|
import { emit } from '../event-emitter';
|
|
@@ -102,6 +102,10 @@ export async function getCurrentPlaythroughId(
|
|
|
102
102
|
* before `getPayload()` captures the state, so data a hook sets is part of the
|
|
103
103
|
* save; `aftersave` fires once `write` has stored it. Every user-facing save
|
|
104
104
|
* path goes through here.
|
|
105
|
+
*
|
|
106
|
+
* The hook and capture run synchronously, but an error they throw (a failing
|
|
107
|
+
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
108
|
+
* write rather than escaping to the caller.
|
|
105
109
|
*/
|
|
106
110
|
export function saveWithHooks<T>(
|
|
107
111
|
slot: string | undefined,
|
|
@@ -109,12 +113,16 @@ export function saveWithHooks<T>(
|
|
|
109
113
|
getPayload: () => SavePayload,
|
|
110
114
|
write: (payload: SavePayload) => Promise<T>,
|
|
111
115
|
): Promise<T> {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
try {
|
|
117
|
+
emit('beforesave', slot, custom);
|
|
118
|
+
const payload = getPayload();
|
|
119
|
+
return write(payload).then((result) => {
|
|
120
|
+
emit('aftersave', slot);
|
|
121
|
+
return result;
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return Promise.reject(err);
|
|
125
|
+
}
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
// --- Save CRUD ---
|
|
@@ -204,8 +212,26 @@ export async function loadSave(
|
|
|
204
212
|
return deserializePayload(record.payload);
|
|
205
213
|
}
|
|
206
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Delete a save record. If the default slot or a named slot holds it, that
|
|
217
|
+
* slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
|
|
218
|
+
*/
|
|
207
219
|
export async function deleteSaveById(saveId: string): Promise<void> {
|
|
208
|
-
|
|
220
|
+
const backend = await getBackend();
|
|
221
|
+
const record = await backend.getSave(saveId);
|
|
222
|
+
await backend.deleteSave(saveId);
|
|
223
|
+
if (!record) return;
|
|
224
|
+
|
|
225
|
+
const ifid = record.meta.ifid;
|
|
226
|
+
const slots = [undefined, ...(await getIndexedSlots(ifid))];
|
|
227
|
+
for (const slot of slots) {
|
|
228
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
229
|
+
await withSlot(metaKey, async () => {
|
|
230
|
+
if ((await backend.getMeta<string>(metaKey)) !== saveId) return;
|
|
231
|
+
await backend.deleteMeta(metaKey);
|
|
232
|
+
await removeFromSlotIndex(ifid, slot);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
209
235
|
}
|
|
210
236
|
|
|
211
237
|
export async function renameSave(
|
|
@@ -314,6 +340,25 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
314
340
|
: `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
315
341
|
}
|
|
316
342
|
|
|
343
|
+
/** Tail of each slot's operation queue, keyed by the slot's meta key. */
|
|
344
|
+
const slotQueues = new Map<string, Promise<unknown>>();
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Run a complete operation on one slot (save, import, delete) after the ones
|
|
348
|
+
* already queued for it. Each operation reads the slot pointer and writes the
|
|
349
|
+
* record and pointer as a unit, so concurrent operations on the same slot
|
|
350
|
+
* apply in call order instead of each creating its own record.
|
|
351
|
+
*/
|
|
352
|
+
function withSlot<T>(metaKey: string, op: () => Promise<T>): Promise<T> {
|
|
353
|
+
const run = (slotQueues.get(metaKey) ?? Promise.resolve()).then(op);
|
|
354
|
+
const tail = run.catch(() => {});
|
|
355
|
+
slotQueues.set(metaKey, tail);
|
|
356
|
+
void tail.then(() => {
|
|
357
|
+
if (slotQueues.get(metaKey) === tail) slotQueues.delete(metaKey);
|
|
358
|
+
});
|
|
359
|
+
return run;
|
|
360
|
+
}
|
|
361
|
+
|
|
317
362
|
/** Tail of the queue that serializes slot index updates. */
|
|
318
363
|
let slotIndexQueue: Promise<unknown> = Promise.resolve();
|
|
319
364
|
|
|
@@ -368,32 +413,34 @@ function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
|
|
|
368
413
|
};
|
|
369
414
|
}
|
|
370
415
|
|
|
371
|
-
export
|
|
416
|
+
export function quickSave(
|
|
372
417
|
ifid: string,
|
|
373
418
|
playthroughId: string,
|
|
374
419
|
payload: SavePayload,
|
|
375
420
|
slot?: string,
|
|
376
421
|
custom?: Record<string, unknown>,
|
|
377
422
|
): Promise<SaveRecord> {
|
|
378
|
-
const backend = await getBackend();
|
|
379
423
|
const metaKey = slotMetaKey(ifid, slot);
|
|
380
|
-
|
|
424
|
+
return withSlot(metaKey, async () => {
|
|
425
|
+
const backend = await getBackend();
|
|
426
|
+
const existingId = await backend.getMeta<string>(metaKey);
|
|
381
427
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
428
|
+
if (existingId) {
|
|
429
|
+
const updated = await overwriteSave(existingId, payload, custom);
|
|
430
|
+
if (updated) return updated;
|
|
431
|
+
}
|
|
386
432
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
433
|
+
// Create new save
|
|
434
|
+
const record = await createSave(ifid, playthroughId, payload, {
|
|
435
|
+
isAutosave: !isNamedSlot(slot),
|
|
436
|
+
...(isNamedSlot(slot) ? { slot } : {}),
|
|
437
|
+
...custom,
|
|
438
|
+
});
|
|
439
|
+
await backend.setMeta(metaKey, record.meta.id);
|
|
440
|
+
await addToSlotIndex(ifid, slot);
|
|
395
441
|
|
|
396
|
-
|
|
442
|
+
return record;
|
|
443
|
+
});
|
|
397
444
|
}
|
|
398
445
|
|
|
399
446
|
export async function hasQuickSave(
|
|
@@ -425,7 +472,8 @@ export async function loadQuickSave(
|
|
|
425
472
|
export async function populateKnownSaves(
|
|
426
473
|
ifid: string,
|
|
427
474
|
): Promise<Record<string, true>> {
|
|
428
|
-
|
|
475
|
+
// No prototype, so a slot named '__proto__' is recorded as an entry
|
|
476
|
+
const result = Object.create(null) as Record<string, true>;
|
|
429
477
|
|
|
430
478
|
// Check default autosave
|
|
431
479
|
if (await hasQuickSave(ifid)) {
|
|
@@ -481,20 +529,19 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
|
|
|
481
529
|
/**
|
|
482
530
|
* Delete a save by slot name. Removes from slot index if named.
|
|
483
531
|
*/
|
|
484
|
-
export
|
|
485
|
-
ifid: string,
|
|
486
|
-
slot?: string,
|
|
487
|
-
): Promise<void> {
|
|
488
|
-
const backend = await getBackend();
|
|
532
|
+
export function deleteSlotSave(ifid: string, slot?: string): Promise<void> {
|
|
489
533
|
const metaKey = slotMetaKey(ifid, slot);
|
|
490
|
-
|
|
491
|
-
|
|
534
|
+
return withSlot(metaKey, async () => {
|
|
535
|
+
const backend = await getBackend();
|
|
536
|
+
const existingId = await backend.getMeta<string>(metaKey);
|
|
537
|
+
if (!existingId) return;
|
|
492
538
|
|
|
493
|
-
|
|
494
|
-
|
|
539
|
+
await backend.deleteSave(existingId);
|
|
540
|
+
await backend.deleteMeta(metaKey);
|
|
495
541
|
|
|
496
|
-
|
|
497
|
-
|
|
542
|
+
// Remove from slot index if named
|
|
543
|
+
await removeFromSlotIndex(ifid, slot);
|
|
544
|
+
});
|
|
498
545
|
}
|
|
499
546
|
|
|
500
547
|
// --- Session Persistence (survives F5, cleared on tab close) ---
|
|
@@ -569,6 +616,11 @@ async function prepareImport(
|
|
|
569
616
|
`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`,
|
|
570
617
|
);
|
|
571
618
|
}
|
|
619
|
+
// Callers may pass parsed JSON; check the full structure before anything
|
|
620
|
+
// is stored or replaced
|
|
621
|
+
if (!isSaveExport(data)) {
|
|
622
|
+
throw new Error('Invalid save file format');
|
|
623
|
+
}
|
|
572
624
|
|
|
573
625
|
const backend = await getBackend();
|
|
574
626
|
|
|
@@ -624,32 +676,34 @@ export async function exportSlotSave(
|
|
|
624
676
|
* validation as `importSave` (version, IFID) and keeps the playthrough record
|
|
625
677
|
* and slot index consistent.
|
|
626
678
|
*/
|
|
627
|
-
export
|
|
679
|
+
export function importSlotSave(
|
|
628
680
|
data: SaveExport,
|
|
629
681
|
ifid: string,
|
|
630
682
|
slot?: string,
|
|
631
683
|
): Promise<SaveInfo> {
|
|
632
|
-
const record = await prepareImport(data, ifid);
|
|
633
|
-
|
|
634
|
-
// The slot keys in `custom` describe where the save lives, so they follow
|
|
635
|
-
// the target slot rather than the slot the save was exported from.
|
|
636
|
-
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
637
|
-
record.meta.custom = {
|
|
638
|
-
...custom,
|
|
639
|
-
isAutosave: !isNamedSlot(slot),
|
|
640
|
-
...(isNamedSlot(slot) ? { slot } : {}),
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
const backend = await getBackend();
|
|
644
684
|
const metaKey = slotMetaKey(ifid, slot);
|
|
645
|
-
|
|
685
|
+
return withSlot(metaKey, async () => {
|
|
686
|
+
const record = await prepareImport(data, ifid);
|
|
687
|
+
|
|
688
|
+
// The slot keys in `custom` describe where the save lives, so they follow
|
|
689
|
+
// the target slot rather than the slot the save was exported from.
|
|
690
|
+
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
691
|
+
record.meta.custom = {
|
|
692
|
+
...custom,
|
|
693
|
+
isAutosave: !isNamedSlot(slot),
|
|
694
|
+
...(isNamedSlot(slot) ? { slot } : {}),
|
|
695
|
+
};
|
|
646
696
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
await addToSlotIndex(ifid, slot);
|
|
650
|
-
if (previousId) await backend.deleteSave(previousId);
|
|
697
|
+
const backend = await getBackend();
|
|
698
|
+
const previousId = await backend.getMeta<string>(metaKey);
|
|
651
699
|
|
|
652
|
-
|
|
700
|
+
await backend.putSave(record);
|
|
701
|
+
await backend.setMeta(metaKey, record.meta.id);
|
|
702
|
+
await addToSlotIndex(ifid, slot);
|
|
703
|
+
if (previousId) await backend.deleteSave(previousId);
|
|
704
|
+
|
|
705
|
+
return toSaveInfo(record, slot);
|
|
706
|
+
});
|
|
653
707
|
}
|
|
654
708
|
|
|
655
709
|
// --- Storage Management ---
|
package/src/saves/storage.ts
CHANGED
|
@@ -548,14 +548,28 @@ async function detectBackend(): Promise<StorageBackend> {
|
|
|
548
548
|
return createMemoryBackend();
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Detection in flight. Concurrent first callers share it so they all get the
|
|
553
|
+
* same backend (the memory fallback makes a new, empty store per detection).
|
|
554
|
+
*/
|
|
555
|
+
let _backendPromise: Promise<StorageBackend> | null = null;
|
|
556
|
+
|
|
557
|
+
export function getBackend(): Promise<StorageBackend> {
|
|
558
|
+
if (_backend) return Promise.resolve(_backend);
|
|
559
|
+
if (!_backendPromise) {
|
|
560
|
+
const pending = detectBackend().then((backend) => {
|
|
561
|
+
// A reset while detecting discards this result
|
|
562
|
+
if (_backendPromise === pending) _backend = backend;
|
|
563
|
+
return backend;
|
|
564
|
+
});
|
|
565
|
+
_backendPromise = pending;
|
|
566
|
+
}
|
|
567
|
+
return _backendPromise;
|
|
555
568
|
}
|
|
556
569
|
|
|
557
570
|
export function resetBackend(): void {
|
|
558
571
|
_backend = null;
|
|
572
|
+
_backendPromise = null;
|
|
559
573
|
}
|
|
560
574
|
|
|
561
575
|
/**
|
package/src/saves/types.ts
CHANGED
|
@@ -78,15 +78,64 @@ export function isSaveExport(value: unknown): value is SaveExport {
|
|
|
78
78
|
if (typeof meta.updatedAt !== 'string') return false;
|
|
79
79
|
if (typeof meta.title !== 'string') return false;
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
return isImportablePayload(save.payload);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
|
|
89
|
+
function isOptionalPRNGSnapshot(value: unknown): boolean {
|
|
90
|
+
if (value == null) return true;
|
|
91
|
+
return (
|
|
92
|
+
isRecord(value) &&
|
|
93
|
+
typeof value.seed === 'string' &&
|
|
94
|
+
typeof value.pull === 'number' &&
|
|
95
|
+
Number.isInteger(value.pull) &&
|
|
96
|
+
value.pull >= 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
|
|
101
|
+
return (
|
|
102
|
+
isRecord(value) &&
|
|
103
|
+
typeof value.passage === 'string' &&
|
|
104
|
+
isRecord(value.variables) &&
|
|
105
|
+
typeof value.timestamp === 'number' &&
|
|
106
|
+
isOptionalPRNGSnapshot(value.prng)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Full check of a payload from outside the running story (an imported save):
|
|
112
|
+
* every history moment is well formed, and `historyIndex` is an integer that
|
|
113
|
+
* points at a moment of the payload's passage. Anything that passes can be
|
|
114
|
+
* stored and later loaded.
|
|
115
|
+
*/
|
|
116
|
+
function isImportablePayload(value: unknown): value is SavePayload {
|
|
117
|
+
if (!isRecord(value)) return false;
|
|
118
|
+
if (typeof value.passage !== 'string') return false;
|
|
119
|
+
if (!isRecord(value.variables)) return false;
|
|
120
|
+
|
|
121
|
+
const { history, historyIndex } = value;
|
|
122
|
+
if (!Array.isArray(history) || history.length === 0) return false;
|
|
123
|
+
if (!history.every(isSaveHistoryMoment)) return false;
|
|
124
|
+
if (
|
|
125
|
+
typeof historyIndex !== 'number' ||
|
|
126
|
+
!Number.isInteger(historyIndex) ||
|
|
127
|
+
historyIndex < 0 ||
|
|
128
|
+
historyIndex >= history.length
|
|
129
|
+
) {
|
|
87
130
|
return false;
|
|
131
|
+
}
|
|
132
|
+
if (history[historyIndex]!.passage !== value.passage) return false;
|
|
88
133
|
|
|
89
|
-
|
|
134
|
+
if (value.visitCounts !== undefined && !isRecord(value.visitCounts))
|
|
135
|
+
return false;
|
|
136
|
+
if (value.renderCounts !== undefined && !isRecord(value.renderCounts))
|
|
137
|
+
return false;
|
|
138
|
+
return isOptionalPRNGSnapshot(value.prng);
|
|
90
139
|
}
|
|
91
140
|
|
|
92
141
|
export function isSavePayload(value: unknown): value is SavePayload {
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { create } from '
|
|
1
|
+
import { create } from './preact-store';
|
|
2
2
|
import { immer } from 'zustand/middleware/immer';
|
|
3
3
|
import {
|
|
4
4
|
enablePatches,
|
|
@@ -214,8 +214,10 @@ function resetModuleState(base: Record<string, unknown>): void {
|
|
|
214
214
|
/**
|
|
215
215
|
* Record the state StoryInit left behind as the start moment: its variable
|
|
216
216
|
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
217
|
-
* after the StoryInit passage has rendered
|
|
218
|
-
*
|
|
217
|
+
* after the StoryInit passage has rendered, and again after the `storyinit`
|
|
218
|
+
* handlers have run at boot (unless a session was restored) and on restart;
|
|
219
|
+
* init()/restart() record the start moment before StoryInit runs. A no-op
|
|
220
|
+
* once the story has moved on.
|
|
219
221
|
*/
|
|
220
222
|
export function recordStoryInitState(): void {
|
|
221
223
|
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
@@ -370,12 +372,12 @@ export interface StoryState {
|
|
|
370
372
|
updateVariables: (recipe: (draft: VariableNamespaces) => void) => void;
|
|
371
373
|
trackRender: (passageName: string) => void;
|
|
372
374
|
restart: () => void;
|
|
373
|
-
save: (slot?: string, custom?: Record<string, unknown>) => void
|
|
374
|
-
load: (slot?: string) => void
|
|
375
|
+
save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
|
|
376
|
+
load: (slot?: string) => Promise<void>;
|
|
375
377
|
hasSave: (slot?: string) => boolean;
|
|
376
378
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
377
379
|
listSaves: () => Promise<SaveInfo[]>;
|
|
378
|
-
deleteSave: (slot?: string) => void
|
|
380
|
+
deleteSave: (slot?: string) => Promise<void>;
|
|
379
381
|
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
380
382
|
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
381
383
|
clearGameData: () => void;
|
|
@@ -395,6 +397,17 @@ export interface StoryState {
|
|
|
395
397
|
clearDeferredRender: () => void;
|
|
396
398
|
}
|
|
397
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Return `p` marked as handled: a caller that ignores the result of a
|
|
402
|
+
* fire-and-forget operation (whose failure is already logged) gets no
|
|
403
|
+
* unhandled-rejection report, while a caller that awaits it still sees the
|
|
404
|
+
* error.
|
|
405
|
+
*/
|
|
406
|
+
function handled<T>(p: Promise<T>): Promise<T> {
|
|
407
|
+
p.catch(() => {});
|
|
408
|
+
return p;
|
|
409
|
+
}
|
|
410
|
+
|
|
398
411
|
export const useStoryStore = create<StoryState>()(
|
|
399
412
|
immer((set, get) => ({
|
|
400
413
|
storyData: null,
|
|
@@ -766,6 +779,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
766
779
|
executeStoryInit();
|
|
767
780
|
clearSession(storyData.ifid);
|
|
768
781
|
emit('storyinit');
|
|
782
|
+
// The storyinit handlers' changes belong to the start moment too
|
|
783
|
+
recordStoryInitState();
|
|
769
784
|
|
|
770
785
|
// Start a new playthrough on restart
|
|
771
786
|
startNewPlaythrough(storyData.ifid)
|
|
@@ -781,58 +796,71 @@ export const useStoryStore = create<StoryState>()(
|
|
|
781
796
|
|
|
782
797
|
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
783
798
|
const { storyData, playthroughId } = get();
|
|
784
|
-
if (!storyData) return;
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
state
|
|
793
|
-
|
|
794
|
-
|
|
799
|
+
if (!storyData) return Promise.resolve();
|
|
800
|
+
|
|
801
|
+
return handled(
|
|
802
|
+
saveWithHooks(
|
|
803
|
+
slot,
|
|
804
|
+
custom,
|
|
805
|
+
() => get().getSavePayload(),
|
|
806
|
+
async (payload) => {
|
|
807
|
+
set((state) => {
|
|
808
|
+
state.saveError = null;
|
|
809
|
+
});
|
|
810
|
+
await quickSave(
|
|
811
|
+
storyData.ifid,
|
|
812
|
+
playthroughId,
|
|
813
|
+
payload,
|
|
814
|
+
slot,
|
|
815
|
+
custom,
|
|
816
|
+
);
|
|
817
|
+
set((state) => {
|
|
818
|
+
state.knownSaves = {
|
|
819
|
+
...state.knownSaves,
|
|
820
|
+
[slot ?? '']: true,
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
).catch((err) => {
|
|
825
|
+
console.error('spindle: failed to save', err);
|
|
795
826
|
set((state) => {
|
|
796
|
-
state.
|
|
797
|
-
|
|
798
|
-
[slot ?? '']: true,
|
|
799
|
-
};
|
|
827
|
+
state.saveError =
|
|
828
|
+
err instanceof Error ? err.message : 'Failed to save';
|
|
800
829
|
});
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
set((state) => {
|
|
805
|
-
state.saveError =
|
|
806
|
-
err instanceof Error ? err.message : 'Failed to save';
|
|
807
|
-
});
|
|
808
|
-
});
|
|
830
|
+
throw err;
|
|
831
|
+
}),
|
|
832
|
+
);
|
|
809
833
|
},
|
|
810
834
|
|
|
811
835
|
load: (slot?: string) => {
|
|
812
836
|
const { storyData } = get();
|
|
813
|
-
if (!storyData) return;
|
|
837
|
+
if (!storyData) return Promise.resolve();
|
|
814
838
|
|
|
815
839
|
set((state) => {
|
|
816
840
|
state.loadError = null;
|
|
817
841
|
});
|
|
818
|
-
|
|
819
|
-
.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
state
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
842
|
+
return handled(
|
|
843
|
+
loadQuickSave(storyData.ifid, slot)
|
|
844
|
+
.then((payload) => {
|
|
845
|
+
if (!payload) return;
|
|
846
|
+
get().loadFromPayload(payload, slot);
|
|
847
|
+
})
|
|
848
|
+
.catch((err) => {
|
|
849
|
+
console.error('spindle: failed to load save', err);
|
|
850
|
+
set((state) => {
|
|
851
|
+
state.loadError =
|
|
852
|
+
err instanceof Error ? err.message : 'Failed to load';
|
|
853
|
+
});
|
|
854
|
+
throw err;
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
830
857
|
},
|
|
831
858
|
|
|
832
859
|
hasSave: (slot?: string) => {
|
|
833
860
|
const { storyData, knownSaves } = get();
|
|
834
861
|
if (!storyData) return false;
|
|
835
|
-
|
|
862
|
+
// Own entries only: slot names like 'constructor' are not inherited saves
|
|
863
|
+
return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? '');
|
|
836
864
|
},
|
|
837
865
|
|
|
838
866
|
getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
|
|
@@ -849,19 +877,22 @@ export const useStoryStore = create<StoryState>()(
|
|
|
849
877
|
|
|
850
878
|
deleteSave: (slot?: string) => {
|
|
851
879
|
const { storyData } = get();
|
|
852
|
-
if (!storyData) return;
|
|
880
|
+
if (!storyData) return Promise.resolve();
|
|
853
881
|
|
|
854
|
-
|
|
855
|
-
.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
882
|
+
return handled(
|
|
883
|
+
deleteSlotSave(storyData.ifid, slot)
|
|
884
|
+
.then(() => {
|
|
885
|
+
set((state) => {
|
|
886
|
+
const key = slot ?? '';
|
|
887
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
888
|
+
state.knownSaves = rest as Record<string, true>;
|
|
889
|
+
});
|
|
890
|
+
})
|
|
891
|
+
.catch((err) => {
|
|
892
|
+
console.error('spindle: failed to delete save', err);
|
|
893
|
+
throw err;
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
865
896
|
},
|
|
866
897
|
|
|
867
898
|
exportSave: async (slot?: string): Promise<SaveExport | null> => {
|
package/src/story-api.ts
CHANGED
|
@@ -155,12 +155,12 @@ export interface StoryAPI {
|
|
|
155
155
|
back(): void;
|
|
156
156
|
forward(): void;
|
|
157
157
|
restart(): void;
|
|
158
|
-
save(slot?: string, custom?: Record<string, unknown>): void
|
|
159
|
-
load(slot?: string): void
|
|
158
|
+
save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
|
|
159
|
+
load(slot?: string): Promise<void>;
|
|
160
160
|
hasSave(slot?: string): boolean;
|
|
161
161
|
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
|
|
162
162
|
listSaves(): Promise<SaveInfo[]>;
|
|
163
|
-
deleteSave(slot?: string): void
|
|
163
|
+
deleteSave(slot?: string): Promise<void>;
|
|
164
164
|
exportSave(slot?: string): Promise<SaveExport | null>;
|
|
165
165
|
importSave(data: unknown, slot?: string): Promise<SaveInfo>;
|
|
166
166
|
visited(name?: string): number;
|
|
@@ -343,12 +343,12 @@ function createStoryAPI(): StoryAPI {
|
|
|
343
343
|
useStoryStore.getState().restart();
|
|
344
344
|
},
|
|
345
345
|
|
|
346
|
-
save(slot?: string, custom?: Record<string, unknown>): void {
|
|
347
|
-
useStoryStore.getState().save(slot, custom);
|
|
346
|
+
save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
|
|
347
|
+
return useStoryStore.getState().save(slot, custom);
|
|
348
348
|
},
|
|
349
349
|
|
|
350
|
-
load(slot?: string): void {
|
|
351
|
-
useStoryStore.getState().load(slot);
|
|
350
|
+
load(slot?: string): Promise<void> {
|
|
351
|
+
return useStoryStore.getState().load(slot);
|
|
352
352
|
},
|
|
353
353
|
|
|
354
354
|
hasSave(slot?: string): boolean {
|
|
@@ -363,8 +363,8 @@ function createStoryAPI(): StoryAPI {
|
|
|
363
363
|
return useStoryStore.getState().listSaves();
|
|
364
364
|
},
|
|
365
365
|
|
|
366
|
-
deleteSave(slot?: string): void {
|
|
367
|
-
useStoryStore.getState().deleteSave(slot);
|
|
366
|
+
deleteSave(slot?: string): Promise<void> {
|
|
367
|
+
return useStoryStore.getState().deleteSave(slot);
|
|
368
368
|
},
|
|
369
369
|
|
|
370
370
|
exportSave(slot?: string): Promise<SaveExport | null> {
|