@rohal12/spindle 0.51.0 → 0.51.2
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 +411 -134
- package/dist/pkg/story-variables.js +13 -0
- package/package.json +1 -1
- package/src/class-registry.ts +63 -1
- package/src/components/Passage.tsx +9 -3
- 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/PassageDisplay.tsx +1 -0
- 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/passage-render-state.ts +9 -8
- 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 +9 -4
- package/src/story-api.ts +13 -7
- package/src/story-init.ts +23 -0
- package/src/story-variables.ts +18 -0
package/src/interpolation.ts
CHANGED
|
@@ -11,7 +11,8 @@ export function hasInterpolation(s: string): boolean {
|
|
|
11
11
|
function resolveDotPath(root: unknown, parts: string[]): unknown {
|
|
12
12
|
let value = root;
|
|
13
13
|
for (let i = 1; i < parts.length; i++) {
|
|
14
|
-
|
|
14
|
+
// Primitives box on access, so `{$name.length}` works too.
|
|
15
|
+
if (value == null) return undefined;
|
|
15
16
|
value = (value as Record<string, unknown>)[parts[i]!];
|
|
16
17
|
}
|
|
17
18
|
return value;
|
package/src/markup/ast.ts
CHANGED
|
@@ -91,6 +91,14 @@ const BRANCH_PARENT: Record<string, string> = {
|
|
|
91
91
|
/** Block macros that use the branches[] array */
|
|
92
92
|
const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Quote a bracket-link value as a {link} argument, escaping backslashes and
|
|
96
|
+
* double quotes so MacroLink reads the value back unchanged (#200).
|
|
97
|
+
*/
|
|
98
|
+
function quoteArg(value: string): string {
|
|
99
|
+
return `"${value.replace(/[\\"]/g, '\\$&')}"`;
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
/**
|
|
95
103
|
* Build an AST from a token array. Block macros are nested into trees
|
|
96
104
|
* using a stack. Throws on unclosed or mismatched macros.
|
|
@@ -118,7 +126,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
118
126
|
break;
|
|
119
127
|
|
|
120
128
|
case 'link': {
|
|
121
|
-
const rawArgs =
|
|
129
|
+
const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
|
|
122
130
|
const macroNode: MacroNode = {
|
|
123
131
|
type: 'macro',
|
|
124
132
|
name: 'link',
|
package/src/markup/render.tsx
CHANGED
|
@@ -339,7 +339,8 @@ function getVariableTextValue(
|
|
|
339
339
|
else value = locals[root];
|
|
340
340
|
|
|
341
341
|
for (let i = 1; i < parts.length; i++) {
|
|
342
|
-
|
|
342
|
+
// Primitives box on access, so `{$name.length}` works too.
|
|
343
|
+
if (value == null) return '';
|
|
343
344
|
value = (value as Record<string, unknown>)[parts[i]!];
|
|
344
345
|
}
|
|
345
346
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* first render, e.g. when the story
|
|
4
|
-
* Lets Story.waitForActions() tell whether the
|
|
2
|
+
* Navigation id (the store's `navigationId`) of the passage most recently
|
|
3
|
+
* mounted by <Passage> (null until the first render, e.g. when the story
|
|
4
|
+
* interface has no {passage}). Lets Story.waitForActions() tell whether the
|
|
5
|
+
* current navigation is on screen, also when it revisits the passage shown.
|
|
5
6
|
*/
|
|
6
|
-
let
|
|
7
|
+
let renderedNavigationId: number | null = null;
|
|
7
8
|
|
|
8
|
-
export function markPassageRendered(
|
|
9
|
-
|
|
9
|
+
export function markPassageRendered(navigationId: number): void {
|
|
10
|
+
renderedNavigationId = navigationId;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function
|
|
13
|
-
return
|
|
13
|
+
export function getRenderedNavigationId(): number | null {
|
|
14
|
+
return renderedNavigationId;
|
|
14
15
|
}
|
|
@@ -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();
|
|
@@ -777,6 +779,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
777
779
|
executeStoryInit();
|
|
778
780
|
clearSession(storyData.ifid);
|
|
779
781
|
emit('storyinit');
|
|
782
|
+
// The storyinit handlers' changes belong to the start moment too
|
|
783
|
+
recordStoryInitState();
|
|
780
784
|
|
|
781
785
|
// Start a new playthrough on restart
|
|
782
786
|
startNewPlaythrough(storyData.ifid)
|
|
@@ -855,7 +859,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
855
859
|
hasSave: (slot?: string) => {
|
|
856
860
|
const { storyData, knownSaves } = get();
|
|
857
861
|
if (!storyData) return false;
|
|
858
|
-
|
|
862
|
+
// Own entries only: slot names like 'constructor' are not inherited saves
|
|
863
|
+
return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? '');
|
|
859
864
|
},
|
|
860
865
|
|
|
861
866
|
getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
|
package/src/story-api.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { MacroDefinition } from './define-macro';
|
|
|
30
30
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
31
31
|
import type { MacroMetadata } from './registry';
|
|
32
32
|
import { getActions, getAction, type StoryAction } from './action-registry';
|
|
33
|
-
import {
|
|
33
|
+
import { getRenderedNavigationId } from './passage-render-state';
|
|
34
34
|
import {
|
|
35
35
|
initPRNG,
|
|
36
36
|
isPRNGEnabled,
|
|
@@ -548,13 +548,19 @@ function createStoryAPI(): StoryAPI {
|
|
|
548
548
|
});
|
|
549
549
|
// A navigation can still be rendering: the passage display mounts the
|
|
550
550
|
// new passage from an effect, after a fade-through's outgoing phase.
|
|
551
|
-
// Its actions exist only once it is mounted, so wait for that.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
551
|
+
// Its actions exist only once it is mounted, so wait for that. Compare
|
|
552
|
+
// navigations, not passage names: revisiting the passage shown mounts
|
|
553
|
+
// it anew (#228).
|
|
554
|
+
const isRendered = () =>
|
|
555
|
+
getRenderedNavigationId() === useStoryStore.getState().navigationId;
|
|
556
|
+
if (
|
|
557
|
+
getRenderedNavigationId() !== null &&
|
|
558
|
+
!isRendered() &&
|
|
559
|
+
!useStoryStore.getState().renderDeferred
|
|
560
|
+
) {
|
|
555
561
|
await new Promise<void>((resolve) => {
|
|
556
|
-
const off = emitterOn('passagerender', (
|
|
557
|
-
if (
|
|
562
|
+
const off = emitterOn('passagerender', () => {
|
|
563
|
+
if (isRendered()) {
|
|
558
564
|
off();
|
|
559
565
|
resolve();
|
|
560
566
|
}
|
package/src/story-init.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { tokenize } from './markup/tokenizer';
|
|
|
4
4
|
import { buildAST } from './markup/ast';
|
|
5
5
|
import { renderNodes } from './markup/render';
|
|
6
6
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
7
|
+
import { emit } from './event-emitter';
|
|
8
|
+
import type { SavePayload } from './saves/types';
|
|
7
9
|
|
|
8
10
|
/** Hidden container holding the currently mounted StoryInit tree. */
|
|
9
11
|
let storyInitContainer: HTMLElement | null = null;
|
|
@@ -60,3 +62,24 @@ export function executeStoryInit() {
|
|
|
60
62
|
setSaveTitlePassage(saveTitlePassage.content);
|
|
61
63
|
}
|
|
62
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Initialize a freshly booted story: run StoryInit, restore `session` (the
|
|
68
|
+
* state a page refresh left behind) if there is one, then fire `storyinit`
|
|
69
|
+
* once all state is settled. Without a session, what the `storyinit`
|
|
70
|
+
* handlers set synchronously belongs to the start moment, like StoryInit's
|
|
71
|
+
* changes; a restored session keeps the history it recorded.
|
|
72
|
+
*/
|
|
73
|
+
export function initializeStory(session?: SavePayload): void {
|
|
74
|
+
executeStoryInit();
|
|
75
|
+
|
|
76
|
+
if (session) {
|
|
77
|
+
useStoryStore.getState().loadFromPayload(session);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
emit('storyinit');
|
|
81
|
+
|
|
82
|
+
if (!session) {
|
|
83
|
+
recordStoryInitState();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/story-variables.ts
CHANGED
|
@@ -26,6 +26,13 @@ const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
|
26
26
|
|
|
27
27
|
const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
|
|
28
28
|
|
|
29
|
+
/** Boxed sample values whose members a primitive of each type can access. */
|
|
30
|
+
const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
|
|
31
|
+
string: Object(''),
|
|
32
|
+
number: Object(0),
|
|
33
|
+
boolean: Object(false),
|
|
34
|
+
};
|
|
35
|
+
|
|
29
36
|
function inferSchema(value: unknown): FieldSchema {
|
|
30
37
|
if (Array.isArray(value)) {
|
|
31
38
|
return { type: 'array' };
|
|
@@ -138,6 +145,17 @@ function validateRef(
|
|
|
138
145
|
// so any field access on an array is allowed.
|
|
139
146
|
if (current.type === 'array') return null;
|
|
140
147
|
|
|
148
|
+
// Primitives expose their wrapper's built-ins (length, toUpperCase,
|
|
149
|
+
// toFixed, etc.). Keep validating past properties of primitive type.
|
|
150
|
+
const sample = PRIMITIVE_SAMPLES[current.type];
|
|
151
|
+
if (sample && part in sample) {
|
|
152
|
+
const member: unknown = sample[part as keyof typeof sample];
|
|
153
|
+
const memberType = typeof member;
|
|
154
|
+
if (!VALID_VAR_TYPES.has(memberType)) return null;
|
|
155
|
+
current = { type: memberType as VarType };
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
if (current.type !== 'object' || !current.fields) {
|
|
142
160
|
return `Cannot access field "${part}" on $${parts.slice(0, i).join('.')} (type: ${current.type})`;
|
|
143
161
|
}
|