@rohal12/spindle 0.59.8 → 0.59.9
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 +197 -38
- package/package.json +1 -1
- package/src/class-registry.ts +22 -1
- package/src/components/PassageDialog.tsx +4 -3
- package/src/components/macros/Button.tsx +1 -0
- package/src/components/macros/Cycle.tsx +1 -0
- package/src/components/macros/Dialog.tsx +1 -0
- package/src/components/macros/Do.tsx +4 -12
- package/src/components/macros/MacroError.tsx +1 -1
- package/src/components/macros/MenubarButtons.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +5 -1
- package/src/components/macros/SaveManager.tsx +27 -1
- package/src/components/macros/Type.tsx +57 -8
- package/src/saves/format.ts +10 -1
- package/src/saves/save-manager.ts +52 -15
- package/src/saves/types.ts +3 -0
- package/src/store.ts +33 -2
- package/src/story-init.ts +13 -1
- package/src/structural.ts +6 -2
- package/src/styles.css +9 -0
- package/src/triggers.ts +30 -0
- package/src/utils/stable-key.ts +1 -1
|
@@ -83,9 +83,9 @@ export function PassageDialog({
|
|
|
83
83
|
|
|
84
84
|
const handleBackdrop = (e: MouseEvent) => {
|
|
85
85
|
if (!dismissible) return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
// Only a click on this overlay itself: a nested dialog's backdrop click
|
|
87
|
+
// bubbles through the outer overlay, whose target is not its own.
|
|
88
|
+
if (e.target === e.currentTarget) stableOnClose();
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
|
|
@@ -105,6 +105,7 @@ export function PassageDialog({
|
|
|
105
105
|
>
|
|
106
106
|
{showCloseButton && (
|
|
107
107
|
<button
|
|
108
|
+
type="button"
|
|
108
109
|
class="dialog-close"
|
|
109
110
|
aria-label="Close"
|
|
110
111
|
onClick={stableOnClose}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { useRunOnce } from './MacroError';
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'do',
|
|
@@ -8,16 +8,8 @@ defineMacro({
|
|
|
8
8
|
// The parser keeps the body verbatim as a single text node
|
|
9
9
|
const code = ctx.collectText(children);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
ctx.mutate(code);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
logMacroError('do', err, location);
|
|
18
|
-
}
|
|
19
|
-
}, []);
|
|
20
|
-
|
|
21
|
-
return null;
|
|
11
|
+
// During the first render, like {set}: the macros after it in the
|
|
12
|
+
// passage run later and see its writes (a layout effect ran after them).
|
|
13
|
+
return useRunOnce('do', '', () => ctx.mutate(code));
|
|
22
14
|
},
|
|
23
15
|
});
|
|
@@ -49,7 +49,7 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
|
|
|
49
49
|
effect();
|
|
50
50
|
} catch (error) {
|
|
51
51
|
failure.current = { error };
|
|
52
|
-
logMacroError(`${macro} ${rawArgs}
|
|
52
|
+
logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -8,7 +8,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
|
8
8
|
/**
|
|
9
9
|
* Create a snapshot clone of a .passage element for outgoing transitions.
|
|
10
10
|
* - Strips `id` attributes from the clone tree
|
|
11
|
-
* - Disables interaction (pointer-events, user-select)
|
|
11
|
+
* - Disables interaction (pointer-events, user-select, inert, aria-hidden)
|
|
12
12
|
* - Pauses/mutes any <audio>/<video>
|
|
13
13
|
* - Adds `.passage-snapshot` class and `data-transition` attribute
|
|
14
14
|
*/
|
|
@@ -28,6 +28,10 @@ function createSnapshot(
|
|
|
28
28
|
// Disable interaction
|
|
29
29
|
clone.style.pointerEvents = 'none';
|
|
30
30
|
clone.style.userSelect = 'none';
|
|
31
|
+
// Visual only: out of the tab order and the accessibility tree, and its
|
|
32
|
+
// controls (copied with their inline handlers) can not be activated
|
|
33
|
+
clone.setAttribute('inert', '');
|
|
34
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
31
35
|
|
|
32
36
|
// Pause/mute media
|
|
33
37
|
const mediaEls = clone.querySelectorAll('audio, video');
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
saveWithHooks,
|
|
22
22
|
type PlaythroughGroup,
|
|
23
23
|
} from '../../saves/save-manager';
|
|
24
|
+
import { getBackendType } from '../../saves/storage';
|
|
24
25
|
import { DialogCloseContext } from '../PassageDialog';
|
|
25
26
|
import { defineMacro } from '../../define-macro';
|
|
26
27
|
import { errorMessage } from '../../utils/error-message';
|
|
@@ -71,10 +72,15 @@ export function SaveManagerContent() {
|
|
|
71
72
|
'loadFromPayload',
|
|
72
73
|
);
|
|
73
74
|
const ifid = storyData?.ifid ?? '';
|
|
75
|
+
// Saves held in memory only (no browser storage is available) are lost
|
|
76
|
+
// when the page is reloaded or closed: told to the player, who can export
|
|
77
|
+
const [temporary, setTemporary] = useState(false);
|
|
74
78
|
|
|
75
79
|
const refresh = useCallback(async () => {
|
|
76
80
|
if (!ifid) return;
|
|
77
81
|
const data = await getSavesGrouped(ifid);
|
|
82
|
+
// After the read: the storage in use is known by now
|
|
83
|
+
setTemporary(getBackendType() === 'memory');
|
|
78
84
|
setGroups(data);
|
|
79
85
|
setLoading(false);
|
|
80
86
|
}, [ifid]);
|
|
@@ -131,7 +137,7 @@ export function SaveManagerContent() {
|
|
|
131
137
|
// Queued now, in call order with other storage operations
|
|
132
138
|
return createSave(ifid, playthrough, payload);
|
|
133
139
|
});
|
|
134
|
-
showStatus('Save created');
|
|
140
|
+
showStatus(temporary ? 'Save created (temporary)' : 'Save created');
|
|
135
141
|
await refresh();
|
|
136
142
|
} catch {
|
|
137
143
|
showStatus('Failed to create save', 'error');
|
|
@@ -276,12 +282,14 @@ export function SaveManagerContent() {
|
|
|
276
282
|
<div class="saves-header-left">
|
|
277
283
|
<div class="saves-mode-toggle">
|
|
278
284
|
<button
|
|
285
|
+
type="button"
|
|
279
286
|
class={mode === 'save' ? 'active' : ''}
|
|
280
287
|
onClick={() => setMode('save')}
|
|
281
288
|
>
|
|
282
289
|
Save
|
|
283
290
|
</button>
|
|
284
291
|
<button
|
|
292
|
+
type="button"
|
|
285
293
|
class={mode === 'load' ? 'active' : ''}
|
|
286
294
|
onClick={() => setMode('load')}
|
|
287
295
|
>
|
|
@@ -291,8 +299,19 @@ export function SaveManagerContent() {
|
|
|
291
299
|
</div>
|
|
292
300
|
</div>
|
|
293
301
|
|
|
302
|
+
{temporary && (
|
|
303
|
+
<div
|
|
304
|
+
class="saves-notice"
|
|
305
|
+
role="status"
|
|
306
|
+
>
|
|
307
|
+
Saves can not be kept in this browser: they are lost when the page is
|
|
308
|
+
reloaded or closed. Export a save to keep it.
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
|
|
294
312
|
<div class="saves-toolbar">
|
|
295
313
|
<button
|
|
314
|
+
type="button"
|
|
296
315
|
class="saves-toolbar-button"
|
|
297
316
|
onClick={handleImport}
|
|
298
317
|
>
|
|
@@ -376,6 +395,7 @@ export function SaveManagerContent() {
|
|
|
376
395
|
<div class="save-slot-actions">
|
|
377
396
|
{mode === 'save' ? (
|
|
378
397
|
<button
|
|
398
|
+
type="button"
|
|
379
399
|
class="save-slot-action primary"
|
|
380
400
|
onClick={() => handleOverwrite(save.meta.id)}
|
|
381
401
|
>
|
|
@@ -383,6 +403,7 @@ export function SaveManagerContent() {
|
|
|
383
403
|
</button>
|
|
384
404
|
) : (
|
|
385
405
|
<button
|
|
406
|
+
type="button"
|
|
386
407
|
class="save-slot-action primary"
|
|
387
408
|
onClick={() => handleLoad(save)}
|
|
388
409
|
>
|
|
@@ -390,18 +411,21 @@ export function SaveManagerContent() {
|
|
|
390
411
|
</button>
|
|
391
412
|
)}
|
|
392
413
|
<button
|
|
414
|
+
type="button"
|
|
393
415
|
class="save-slot-action"
|
|
394
416
|
onClick={() => handleRenameStart(save)}
|
|
395
417
|
>
|
|
396
418
|
Rename
|
|
397
419
|
</button>
|
|
398
420
|
<button
|
|
421
|
+
type="button"
|
|
399
422
|
class="save-slot-action"
|
|
400
423
|
onClick={() => handleExport(save.meta.id)}
|
|
401
424
|
>
|
|
402
425
|
Export
|
|
403
426
|
</button>
|
|
404
427
|
<button
|
|
428
|
+
type="button"
|
|
405
429
|
class="save-slot-action danger"
|
|
406
430
|
onClick={() => handleDelete(save.meta.id)}
|
|
407
431
|
>
|
|
@@ -413,6 +437,7 @@ export function SaveManagerContent() {
|
|
|
413
437
|
|
|
414
438
|
{mode === 'save' && isCurrentPt && (
|
|
415
439
|
<button
|
|
440
|
+
type="button"
|
|
416
441
|
class="save-slot-new"
|
|
417
442
|
onClick={handleNewSave}
|
|
418
443
|
>
|
|
@@ -432,6 +457,7 @@ export function SaveManagerContent() {
|
|
|
432
457
|
<div class="playthrough-group">
|
|
433
458
|
<div class="playthrough-saves">
|
|
434
459
|
<button
|
|
460
|
+
type="button"
|
|
435
461
|
class="save-slot-new"
|
|
436
462
|
onClick={handleNewSave}
|
|
437
463
|
>
|
|
@@ -1,15 +1,54 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { DELAY_PARAMETER } from './macro-args';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The clip-path that shows the first `count` characters of `root`'s text in
|
|
6
|
+
* reading order: the boxes the browser laid out for them, each wrapped line
|
|
7
|
+
* as far as its last revealed character, as one path in the coordinates of
|
|
8
|
+
* `inner`'s box. Undefined where there is no layout to measure.
|
|
9
|
+
*/
|
|
10
|
+
function revealedPath(
|
|
11
|
+
root: HTMLElement,
|
|
12
|
+
inner: HTMLElement,
|
|
13
|
+
count: number,
|
|
14
|
+
): string | undefined {
|
|
15
|
+
const range = document.createRange();
|
|
16
|
+
if (!range.getClientRects) return undefined;
|
|
17
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
18
|
+
let left = count;
|
|
19
|
+
let end: { node: Node; offset: number } | undefined;
|
|
20
|
+
for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
|
|
21
|
+
const length = n.nodeValue?.length ?? 0;
|
|
22
|
+
end = { node: n, offset: Math.min(left, length) };
|
|
23
|
+
left -= length;
|
|
24
|
+
}
|
|
25
|
+
if (!end) return 'inset(0 100% 0 0)';
|
|
26
|
+
range.setStart(root, 0);
|
|
27
|
+
range.setEnd(end.node, end.offset);
|
|
28
|
+
const rects = range.getClientRects();
|
|
29
|
+
if (rects.length === 0) return undefined;
|
|
30
|
+
const origin = inner.getBoundingClientRect();
|
|
31
|
+
let path = '';
|
|
32
|
+
for (const r of Array.from(rects)) {
|
|
33
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
34
|
+
const x = (r.left - origin.left).toFixed(2);
|
|
35
|
+
const y = (r.top - origin.top).toFixed(2);
|
|
36
|
+
path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
|
|
37
|
+
}
|
|
38
|
+
return path ? `path("${path}")` : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
4
41
|
defineMacro({
|
|
5
42
|
name: 'type',
|
|
6
43
|
block: true,
|
|
7
44
|
interpolate: true,
|
|
8
45
|
parameters: [DELAY_PARAMETER],
|
|
9
46
|
render({ children = [] }, ctx) {
|
|
10
|
-
const { useState, useEffect, useRef } = ctx.hooks;
|
|
47
|
+
const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
|
|
11
48
|
const speed = ctx.args.delay ?? 0;
|
|
12
49
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
50
|
+
const innerRef = useRef<HTMLSpanElement>(null);
|
|
51
|
+
const [clip, setClip] = useState<string | undefined>();
|
|
13
52
|
const [totalChars, setTotalChars] = useState(0);
|
|
14
53
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
15
54
|
const visibleCharsRef = useRef(0);
|
|
@@ -44,6 +83,21 @@ defineMacro({
|
|
|
44
83
|
|
|
45
84
|
const done = visibleChars >= totalChars && totalChars > 0;
|
|
46
85
|
|
|
86
|
+
// Clip to the characters revealed so far, as laid out: remeasured when
|
|
87
|
+
// the count changes and when the width the text wraps in does.
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
const root = containerRef.current;
|
|
90
|
+
const inner = innerRef.current;
|
|
91
|
+
if (!root || !inner || totalChars === 0 || done) {
|
|
92
|
+
setClip(undefined);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const measure = () => setClip(revealedPath(root, inner, visibleChars));
|
|
96
|
+
measure();
|
|
97
|
+
window.addEventListener('resize', measure);
|
|
98
|
+
return () => window.removeEventListener('resize', measure);
|
|
99
|
+
}, [visibleChars, totalChars, done]);
|
|
100
|
+
|
|
47
101
|
const cls = [
|
|
48
102
|
'macro-type',
|
|
49
103
|
done ? 'macro-type-done' : '',
|
|
@@ -57,19 +111,14 @@ defineMacro({
|
|
|
57
111
|
id={ctx.id}
|
|
58
112
|
class={cls}
|
|
59
113
|
ref={containerRef}
|
|
60
|
-
style={{
|
|
61
|
-
clipPath: totalChars > 0 && !done ? undefined : undefined,
|
|
62
|
-
}}
|
|
63
114
|
>
|
|
64
115
|
<span
|
|
116
|
+
ref={innerRef}
|
|
65
117
|
class="macro-type-inner"
|
|
66
118
|
style={{
|
|
67
119
|
display: 'inline',
|
|
68
120
|
visibility: totalChars === 0 ? 'hidden' : 'visible',
|
|
69
|
-
clipPath:
|
|
70
|
-
totalChars > 0 && !done
|
|
71
|
-
? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
|
|
72
|
-
: undefined,
|
|
121
|
+
clipPath: clip,
|
|
73
122
|
}}
|
|
74
123
|
>
|
|
75
124
|
{ctx.renderInlineNodes(children)}
|
package/src/saves/format.ts
CHANGED
|
@@ -135,6 +135,14 @@ const isMoment = (value: unknown): boolean =>
|
|
|
135
135
|
typeof value.timestamp === 'number' &&
|
|
136
136
|
isOptionalPRNGSnapshot(value.prng);
|
|
137
137
|
|
|
138
|
+
const isWatchers = (value: unknown): boolean =>
|
|
139
|
+
value === undefined ||
|
|
140
|
+
(Array.isArray(value) &&
|
|
141
|
+
Array.from(value).every(
|
|
142
|
+
(w) =>
|
|
143
|
+
isRecord(w) && typeof w.condition === 'string' && isRecord(w.options),
|
|
144
|
+
));
|
|
145
|
+
|
|
138
146
|
const isCountsMap = (value: unknown): boolean =>
|
|
139
147
|
value === undefined ||
|
|
140
148
|
(value instanceof Map &&
|
|
@@ -166,7 +174,8 @@ function isPayload(value: unknown): value is Record<string, unknown> {
|
|
|
166
174
|
return (
|
|
167
175
|
isCountsMap(value.visitCounts) &&
|
|
168
176
|
isCountsMap(value.renderCounts) &&
|
|
169
|
-
isOptionalPRNGSnapshot(value.prng)
|
|
177
|
+
isOptionalPRNGSnapshot(value.prng) &&
|
|
178
|
+
isWatchers(value.watchers)
|
|
170
179
|
);
|
|
171
180
|
}
|
|
172
181
|
|
|
@@ -134,17 +134,18 @@ async function startNewPlaythroughNow(
|
|
|
134
134
|
id: string = randomUUID(),
|
|
135
135
|
): Promise<string> {
|
|
136
136
|
const backend = await getBackend();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
137
|
+
// Taking the next number is a read-modify-write other tabs do too
|
|
138
|
+
await withTabLock(`spindle-playthrough-count:${ifid}`, async () => {
|
|
139
|
+
const num = await nextPlaythroughNumber(ifid);
|
|
140
|
+
const record: PlaythroughRecord = {
|
|
141
|
+
id,
|
|
142
|
+
ifid,
|
|
143
|
+
createdAt: new Date().toISOString(),
|
|
144
|
+
label: `Playthrough ${num}`,
|
|
145
|
+
};
|
|
146
|
+
await backend.putPlaythrough(record);
|
|
147
|
+
await backend.setMeta(playthroughCountKey(ifid), num);
|
|
148
|
+
});
|
|
148
149
|
await backend.setMeta(currentPlaythroughKey(ifid), id);
|
|
149
150
|
return id;
|
|
150
151
|
}
|
|
@@ -503,15 +504,51 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
503
504
|
|
|
504
505
|
const slotIndexKey = (ifid: string) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
505
506
|
|
|
506
|
-
/**
|
|
507
|
+
/**
|
|
508
|
+
* Run `op` holding the browser's lock `name`, so other tabs of the story
|
|
509
|
+
* (the operation queue only orders this document's operations) wait for it.
|
|
510
|
+
* Runs `op` without a lock where the browser offers none (or refuses it, as
|
|
511
|
+
* in a sandboxed frame, which has no other tabs sharing its storage).
|
|
512
|
+
*/
|
|
513
|
+
async function withTabLock<T>(name: string, op: () => Promise<T>): Promise<T> {
|
|
514
|
+
let locks: LockManager | undefined;
|
|
515
|
+
try {
|
|
516
|
+
locks = navigator.locks;
|
|
517
|
+
} catch {
|
|
518
|
+
// Access refused: no lock
|
|
519
|
+
}
|
|
520
|
+
if (!locks) return op();
|
|
521
|
+
let started = false;
|
|
522
|
+
try {
|
|
523
|
+
return await locks.request(name, () => {
|
|
524
|
+
started = true;
|
|
525
|
+
return op();
|
|
526
|
+
});
|
|
527
|
+
} catch (err) {
|
|
528
|
+
// Only a failure to take the lock falls back; op's own errors are its
|
|
529
|
+
if (started) throw err;
|
|
530
|
+
return op();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Read-modify-write the per-story slot index. Under a lock shared by the
|
|
536
|
+
* story's tabs: two tabs adding a slot at once would both read the old index
|
|
537
|
+
* and the later write would drop the other's slot.
|
|
538
|
+
*/
|
|
507
539
|
async function updateSlotIndex(
|
|
508
540
|
ifid: string,
|
|
509
541
|
update: (slots: string[]) => string[],
|
|
510
542
|
): Promise<void> {
|
|
511
543
|
const backend = await getBackend();
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
544
|
+
await withTabLock(`spindle-slot-index:${ifid}`, async () => {
|
|
545
|
+
const existing =
|
|
546
|
+
(await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
|
|
547
|
+
const updated = update(existing);
|
|
548
|
+
if (updated !== existing) {
|
|
549
|
+
await backend.setMeta(slotIndexKey(ifid), updated);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
515
552
|
}
|
|
516
553
|
|
|
517
554
|
/** Every slot: the default one (undefined), then those in the slot index. */
|
package/src/saves/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PRNGSnapshot } from '../prng';
|
|
2
|
+
import type { SavedWatcher } from '../triggers';
|
|
2
3
|
import { hasOwn } from '../utils/namespace';
|
|
3
4
|
import {
|
|
4
5
|
checkFormatVersion,
|
|
@@ -25,6 +26,8 @@ export interface SavePayload {
|
|
|
25
26
|
visitCounts?: Record<string, number>;
|
|
26
27
|
renderCounts?: Record<string, number>;
|
|
27
28
|
prng?: PRNGSnapshot | null;
|
|
29
|
+
/** The {watch} macro watchers registered (absent in older saves). */
|
|
30
|
+
watchers?: SavedWatcher[];
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export interface SaveMeta {
|
package/src/store.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { emit } from './event-emitter';
|
|
|
22
22
|
import {
|
|
23
23
|
resetTriggers,
|
|
24
24
|
checkTriggersOnNavigation,
|
|
25
|
+
restoreMacroWatchers,
|
|
26
|
+
savedMacroWatchers,
|
|
25
27
|
reinitTriggerState,
|
|
26
28
|
} from './triggers';
|
|
27
29
|
import {
|
|
@@ -302,6 +304,7 @@ function persistSession(get: () => StoryState): { error: unknown } | undefined {
|
|
|
302
304
|
visitCounts,
|
|
303
305
|
renderCounts,
|
|
304
306
|
prng: snapshotPRNG(),
|
|
307
|
+
watchers: savedMacroWatchers(),
|
|
305
308
|
});
|
|
306
309
|
}
|
|
307
310
|
|
|
@@ -947,6 +950,21 @@ export interface StoryState {
|
|
|
947
950
|
clearDeferredRender: () => void;
|
|
948
951
|
}
|
|
949
952
|
|
|
953
|
+
/**
|
|
954
|
+
* The first passage of `payload` (its current passage, then its history)
|
|
955
|
+
* that `storyData` does not have, as when the story was updated since the
|
|
956
|
+
* save; undefined if the story has them all (or is not loaded).
|
|
957
|
+
*/
|
|
958
|
+
export function missingPassage(
|
|
959
|
+
storyData: StoryData | null | undefined,
|
|
960
|
+
payload: SavePayload,
|
|
961
|
+
): string | undefined {
|
|
962
|
+
if (!storyData) return undefined;
|
|
963
|
+
return [payload.passage, ...payload.history.map((m) => m.passage)].find(
|
|
964
|
+
(name) => !storyData.passages.has(name),
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
950
968
|
const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
|
|
951
969
|
|
|
952
970
|
type StoryRecipe = (draft: Draft<StoryState>) => void;
|
|
@@ -1601,6 +1619,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1601
1619
|
visitCounts: { ...visitCounts },
|
|
1602
1620
|
renderCounts: { ...renderCounts },
|
|
1603
1621
|
prng: snapshotPRNG(),
|
|
1622
|
+
watchers: savedMacroWatchers(),
|
|
1604
1623
|
};
|
|
1605
1624
|
},
|
|
1606
1625
|
|
|
@@ -1618,12 +1637,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1618
1637
|
slot?: string,
|
|
1619
1638
|
playthroughId?: string,
|
|
1620
1639
|
) => {
|
|
1621
|
-
const
|
|
1640
|
+
const applying = slotLoadApplying;
|
|
1622
1641
|
slotLoadApplying = null;
|
|
1642
|
+
const replacement = applying ?? ++stateReplacementsIssued;
|
|
1623
1643
|
if (payload.history.length === 0) {
|
|
1624
1644
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1625
1645
|
return;
|
|
1626
1646
|
}
|
|
1647
|
+
// Before anything is replaced: an incompatible payload leaves the
|
|
1648
|
+
// running game as it is
|
|
1649
|
+
const missing = missingPassage(get().storyData, payload);
|
|
1650
|
+
if (missing !== undefined) {
|
|
1651
|
+
throw new Error(
|
|
1652
|
+
`The save refers to the passage "${missing}", which this version of the story does not have`,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1627
1655
|
latestStateApplied = replacement;
|
|
1628
1656
|
|
|
1629
1657
|
emit('beforeload', slot);
|
|
@@ -1684,7 +1712,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1684
1712
|
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1685
1713
|
});
|
|
1686
1714
|
|
|
1687
|
-
//
|
|
1715
|
+
// The watchers of the loaded game (a payload without any, from an
|
|
1716
|
+
// older version, leaves the registered ones), then: loaded state is
|
|
1717
|
+
// not a change watchers react to
|
|
1718
|
+
if (payload.watchers) restoreMacroWatchers(payload.watchers);
|
|
1688
1719
|
reinitTriggerState();
|
|
1689
1720
|
|
|
1690
1721
|
// The next navigate() diffs from the snapshot recorded for the current
|
package/src/story-init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
|
-
import { useStoryStore, recordStoryInitState } from './store';
|
|
2
|
+
import { useStoryStore, recordStoryInitState, missingPassage } from './store';
|
|
3
3
|
import { parseMarkup } from './markup/parse';
|
|
4
4
|
import { renderNodes } from './markup/render';
|
|
5
5
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
@@ -71,6 +71,18 @@ export function executeStoryInit() {
|
|
|
71
71
|
export function initializeStory(session?: SavePayload): void {
|
|
72
72
|
executeStoryInit();
|
|
73
73
|
|
|
74
|
+
// A session that refers to a passage the story no longer has (it was
|
|
75
|
+
// updated since) is dropped: the game starts from the beginning
|
|
76
|
+
const missing = session
|
|
77
|
+
? missingPassage(useStoryStore.getState().storyData, session)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (missing !== undefined) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`spindle: discarding the saved session: the story no longer has the passage "${missing}"`,
|
|
82
|
+
);
|
|
83
|
+
session = undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
if (session) {
|
|
75
87
|
useStoryStore.getState().loadFromPayload(session);
|
|
76
88
|
}
|
package/src/structural.ts
CHANGED
|
@@ -85,7 +85,9 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
85
85
|
function cloneBuiltin(val: object): object | undefined {
|
|
86
86
|
if (val instanceof Date) return keep(val, new Date(val.getTime()));
|
|
87
87
|
if (val instanceof RegExp) {
|
|
88
|
-
|
|
88
|
+
const copy = new RegExp(val.source, val.flags);
|
|
89
|
+
copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
|
|
90
|
+
return keep(val, copy);
|
|
89
91
|
}
|
|
90
92
|
if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
|
|
91
93
|
if (ArrayBuffer.isView(val)) {
|
|
@@ -343,7 +345,9 @@ function equalBuiltin(
|
|
|
343
345
|
assumed: Pairs,
|
|
344
346
|
): boolean | undefined {
|
|
345
347
|
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
346
|
-
if (a instanceof RegExp)
|
|
348
|
+
if (a instanceof RegExp) {
|
|
349
|
+
return String(a) === String(b) && a.lastIndex === (b as RegExp).lastIndex;
|
|
350
|
+
}
|
|
347
351
|
if (ArrayBuffer.isView(a)) {
|
|
348
352
|
// A view is its place in its backing buffer, and the whole buffer (which
|
|
349
353
|
// other views can share): equal only if both match.
|
package/src/styles.css
CHANGED
|
@@ -571,6 +571,15 @@ tw-storydata {
|
|
|
571
571
|
background: rgba(100, 181, 246, 0.05);
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
.saves-notice {
|
|
575
|
+
padding: 0.5em 1.25em;
|
|
576
|
+
font-size: 0.8em;
|
|
577
|
+
color: #ffb74d;
|
|
578
|
+
border-bottom: 1px solid rgba(255, 183, 77, 0.3);
|
|
579
|
+
background: rgba(255, 183, 77, 0.08);
|
|
580
|
+
flex-shrink: 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
574
583
|
.saves-status {
|
|
575
584
|
padding: 0.5em 1.25em;
|
|
576
585
|
font-size: 0.8em;
|
package/src/triggers.ts
CHANGED
|
@@ -115,6 +115,36 @@ export function addMacroTrigger(
|
|
|
115
115
|
registerTrigger(condition, options, macroKey);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** A {watch} macro's watcher as a save and the session hold it. */
|
|
119
|
+
export interface SavedWatcher {
|
|
120
|
+
condition: string;
|
|
121
|
+
options: WatchOptions;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The {watch} macro watchers now registered, for a save or the session:
|
|
126
|
+
* watchers outlive the passage that declared them, so a game restored
|
|
127
|
+
* where it stopped needs the ones its earlier passages registered (and
|
|
128
|
+
* not the ones {unwatch} or `once` removed).
|
|
129
|
+
*/
|
|
130
|
+
export function savedMacroWatchers(): SavedWatcher[] {
|
|
131
|
+
return triggers
|
|
132
|
+
.filter((t) => t.macroKey !== undefined && t.options)
|
|
133
|
+
.map((t) => ({ condition: t.condition, options: { ...t.options } }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Make the {watch} macro watchers those of a loaded game: the ones of
|
|
138
|
+
* `watchers` replace the registered ones (watchers added by Story.watch
|
|
139
|
+
* code stay). The passage shown mounts its own {watch} macros again.
|
|
140
|
+
*/
|
|
141
|
+
export function restoreMacroWatchers(watchers: readonly SavedWatcher[]): void {
|
|
142
|
+
triggers = triggers.filter((t) => t.macroKey === undefined);
|
|
143
|
+
for (const { condition, options } of watchers) {
|
|
144
|
+
addMacroTrigger(condition, options);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
export function removeTrigger(name: string): void {
|
|
119
149
|
triggers = triggers.filter((t) => t.name !== name);
|
|
120
150
|
}
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -49,7 +49,7 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
49
49
|
if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
|
|
50
50
|
|
|
51
51
|
if (val instanceof Date) return `Date(${val.getTime()})`;
|
|
52
|
-
if (val instanceof RegExp) return `RegExp(${String(val)})`;
|
|
52
|
+
if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
|
|
53
53
|
|
|
54
54
|
ancestors.push(obj);
|
|
55
55
|
try {
|