@rohal12/spindle 0.59.7 → 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 +332 -87
- package/dist/pkg/story-variables.js +13 -2
- package/package.json +1 -1
- package/src/class-registry.ts +22 -1
- package/src/components/App.tsx +4 -4
- 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 +9 -5
- package/src/components/macros/PassageDisplay.tsx +19 -5
- package/src/components/macros/SaveManager.tsx +42 -3
- package/src/components/macros/Type.tsx +57 -8
- package/src/components/macros/detached-body.tsx +16 -3
- package/src/expression.ts +9 -21
- package/src/hooks/use-modal-focus.ts +15 -1
- package/src/quick-actions.ts +23 -0
- package/src/saves/format.ts +10 -1
- package/src/saves/save-manager.ts +66 -19
- package/src/saves/storage.ts +10 -8
- package/src/saves/types.ts +3 -0
- package/src/settings.ts +5 -1
- package/src/store.ts +41 -6
- package/src/story-init.ts +13 -1
- package/src/structural.ts +6 -2
- package/src/styles.css +14 -0
- package/src/triggers.ts +30 -0
- package/src/utils/stable-key.ts +1 -1
- package/src/widgets/widget-def.ts +15 -3
|
@@ -13,9 +13,23 @@ const FOCUSABLE = [
|
|
|
13
13
|
'[tabindex]:not([tabindex="-1"])',
|
|
14
14
|
].join(',');
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
|
|
18
|
+
* (inherited, so the computed value covers ancestors) and not inside a
|
|
19
|
+
* `display: none` element.
|
|
20
|
+
*/
|
|
21
|
+
function isRendered(el: HTMLElement): boolean {
|
|
22
|
+
const { visibility } = getComputedStyle(el);
|
|
23
|
+
if (visibility === 'hidden' || visibility === 'collapse') return false;
|
|
24
|
+
for (let e: HTMLElement | null = el; e; e = e.parentElement) {
|
|
25
|
+
if (getComputedStyle(e).display === 'none') return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
17
31
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
18
|
-
(el) => !el.hidden && !el.closest('[hidden], [inert]'),
|
|
32
|
+
(el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
|
|
19
33
|
);
|
|
20
34
|
}
|
|
21
35
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useStoryStore } from './store';
|
|
2
|
+
import { showRuntimeError } from './runtime-errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
|
|
6
|
+
* a failure is shown on the page (the Story API's save() and load() only
|
|
7
|
+
* reject, for the code that awaits them).
|
|
8
|
+
*/
|
|
9
|
+
export async function quickSave(): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
await useStoryStore.getState().save();
|
|
12
|
+
} catch (error) {
|
|
13
|
+
showRuntimeError('The game could not be saved:', error);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function quickLoad(): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await useStoryStore.getState().load();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
showRuntimeError('The saved game could not be loaded:', error);
|
|
22
|
+
}
|
|
23
|
+
}
|
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. */
|
|
@@ -713,14 +750,22 @@ const SESSION_KEY_PREFIX = 'spindle.session.';
|
|
|
713
750
|
* encodePayload). Throws, like a save, when the state holds a value a save
|
|
714
751
|
* cannot (a function, an instance of an unregistered class, a unique
|
|
715
752
|
* symbol); the session then keeps its previous copy.
|
|
753
|
+
*
|
|
754
|
+
* A storage that is unavailable or full is returned, not thrown: the session
|
|
755
|
+
* keeps its previous copy then too, but the story can go on, so the caller
|
|
756
|
+
* decides how to tell the player.
|
|
716
757
|
*/
|
|
717
|
-
export function saveSession(
|
|
758
|
+
export function saveSession(
|
|
759
|
+
ifid: string,
|
|
760
|
+
payload: SavePayload,
|
|
761
|
+
): { error: unknown } | undefined {
|
|
718
762
|
const text = JSON.stringify(encodePayload(payload));
|
|
719
763
|
try {
|
|
720
764
|
sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
|
|
721
|
-
} catch {
|
|
722
|
-
|
|
765
|
+
} catch (error) {
|
|
766
|
+
return { error };
|
|
723
767
|
}
|
|
768
|
+
return undefined;
|
|
724
769
|
}
|
|
725
770
|
|
|
726
771
|
/**
|
|
@@ -889,13 +934,15 @@ async function clearAllDataNow(): Promise<void> {
|
|
|
889
934
|
resetBackend();
|
|
890
935
|
|
|
891
936
|
// Clear all Spindle session keys from sessionStorage
|
|
892
|
-
|
|
937
|
+
try {
|
|
893
938
|
const toRemove: string[] = [];
|
|
894
939
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
895
940
|
const key = sessionStorage.key(i);
|
|
896
941
|
if (key?.startsWith('spindle.session.')) toRemove.push(key);
|
|
897
942
|
}
|
|
898
943
|
for (const key of toRemove) sessionStorage.removeItem(key);
|
|
944
|
+
} catch {
|
|
945
|
+
// no sessionStorage (or access denied): nothing to clear
|
|
899
946
|
}
|
|
900
947
|
}
|
|
901
948
|
|
package/src/saves/storage.ts
CHANGED
|
@@ -410,8 +410,10 @@ function createIDBBackend(): StorageBackend {
|
|
|
410
410
|
let _backend: StorageBackend | null = null;
|
|
411
411
|
|
|
412
412
|
async function detectBackend(): Promise<StorageBackend> {
|
|
413
|
-
|
|
414
|
-
|
|
413
|
+
// Even looking up a storage API can throw (a sandboxed frame without
|
|
414
|
+
// allow-same-origin denies the localStorage property), hence the try.
|
|
415
|
+
try {
|
|
416
|
+
if (typeof indexedDB !== 'undefined') {
|
|
415
417
|
await new Promise<void>((resolve, reject) => {
|
|
416
418
|
const req = indexedDB.open('__spindle_probe__', 1);
|
|
417
419
|
req.onsuccess = () => {
|
|
@@ -422,21 +424,21 @@ async function detectBackend(): Promise<StorageBackend> {
|
|
|
422
424
|
req.onerror = () => reject(req.error);
|
|
423
425
|
});
|
|
424
426
|
return createIDBBackend();
|
|
425
|
-
} catch {
|
|
426
|
-
// fall through
|
|
427
427
|
}
|
|
428
|
+
} catch {
|
|
429
|
+
// fall through
|
|
428
430
|
}
|
|
429
431
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
+
try {
|
|
433
|
+
if (typeof localStorage !== 'undefined') {
|
|
432
434
|
const probeKey = '__spindle_probe__';
|
|
433
435
|
localStorage.setItem(probeKey, '1');
|
|
434
436
|
const val = localStorage.getItem(probeKey);
|
|
435
437
|
localStorage.removeItem(probeKey);
|
|
436
438
|
if (val === '1') return createLocalStorageBackend();
|
|
437
|
-
} catch {
|
|
438
|
-
// fall through
|
|
439
439
|
}
|
|
440
|
+
} catch {
|
|
441
|
+
// fall through
|
|
440
442
|
}
|
|
441
443
|
|
|
442
444
|
return createMemoryBackend();
|
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/settings.ts
CHANGED
|
@@ -39,7 +39,11 @@ function storageKey(): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function persist(): void {
|
|
42
|
-
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(storageKey(), JSON.stringify(values));
|
|
44
|
+
} catch {
|
|
45
|
+
// storage unavailable or full: the setting holds for this session only
|
|
46
|
+
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
function loadFromStorage(): void {
|
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 {
|
|
@@ -259,7 +261,7 @@ let sessionMoments: SaveHistoryMoment[] = [];
|
|
|
259
261
|
* Write the game to the session: the whole payload, encoded in one piece
|
|
260
262
|
* on every navigation.
|
|
261
263
|
*/
|
|
262
|
-
function persistSession(get: () => StoryState):
|
|
264
|
+
function persistSession(get: () => StoryState): { error: unknown } | undefined {
|
|
263
265
|
const {
|
|
264
266
|
storyData,
|
|
265
267
|
currentPassage,
|
|
@@ -269,7 +271,7 @@ function persistSession(get: () => StoryState): void {
|
|
|
269
271
|
visitCounts,
|
|
270
272
|
renderCounts,
|
|
271
273
|
} = get();
|
|
272
|
-
if (!storyData) return;
|
|
274
|
+
if (!storyData) return undefined;
|
|
273
275
|
|
|
274
276
|
// Trim cache when history shrank (navigate() drops discarded forward
|
|
275
277
|
// moments itself, since a replacement branch may keep the same length)
|
|
@@ -294,7 +296,7 @@ function persistSession(get: () => StoryState): void {
|
|
|
294
296
|
}
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
saveSession(storyData.ifid, {
|
|
299
|
+
return saveSession(storyData.ifid, {
|
|
298
300
|
passage: currentPassage,
|
|
299
301
|
variables,
|
|
300
302
|
history: sessionMoments,
|
|
@@ -302,6 +304,7 @@ function persistSession(get: () => StoryState): void {
|
|
|
302
304
|
visitCounts,
|
|
303
305
|
renderCounts,
|
|
304
306
|
prng: snapshotPRNG(),
|
|
307
|
+
watchers: savedMacroWatchers(),
|
|
305
308
|
});
|
|
306
309
|
}
|
|
307
310
|
|
|
@@ -324,7 +327,11 @@ const NAVIGATION_ERROR_CONTEXT = 'The story could not go to another passage:';
|
|
|
324
327
|
function persistSessionThen(get: () => StoryState, after?: () => void): void {
|
|
325
328
|
let failure: { error: unknown } | undefined;
|
|
326
329
|
try {
|
|
327
|
-
persistSession(get);
|
|
330
|
+
const storageFailure = persistSession(get);
|
|
331
|
+
// The story goes on, but a reload would go back to the last copy
|
|
332
|
+
if (storageFailure) {
|
|
333
|
+
showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
|
|
334
|
+
}
|
|
328
335
|
} catch (error) {
|
|
329
336
|
failure = { error };
|
|
330
337
|
// The player sees it too: a reload would go back to the last moment
|
|
@@ -943,6 +950,21 @@ export interface StoryState {
|
|
|
943
950
|
clearDeferredRender: () => void;
|
|
944
951
|
}
|
|
945
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
|
+
|
|
946
968
|
const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
|
|
947
969
|
|
|
948
970
|
type StoryRecipe = (draft: Draft<StoryState>) => void;
|
|
@@ -1597,6 +1619,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1597
1619
|
visitCounts: { ...visitCounts },
|
|
1598
1620
|
renderCounts: { ...renderCounts },
|
|
1599
1621
|
prng: snapshotPRNG(),
|
|
1622
|
+
watchers: savedMacroWatchers(),
|
|
1600
1623
|
};
|
|
1601
1624
|
},
|
|
1602
1625
|
|
|
@@ -1614,12 +1637,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1614
1637
|
slot?: string,
|
|
1615
1638
|
playthroughId?: string,
|
|
1616
1639
|
) => {
|
|
1617
|
-
const
|
|
1640
|
+
const applying = slotLoadApplying;
|
|
1618
1641
|
slotLoadApplying = null;
|
|
1642
|
+
const replacement = applying ?? ++stateReplacementsIssued;
|
|
1619
1643
|
if (payload.history.length === 0) {
|
|
1620
1644
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1621
1645
|
return;
|
|
1622
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
|
+
}
|
|
1623
1655
|
latestStateApplied = replacement;
|
|
1624
1656
|
|
|
1625
1657
|
emit('beforeload', slot);
|
|
@@ -1680,7 +1712,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1680
1712
|
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1681
1713
|
});
|
|
1682
1714
|
|
|
1683
|
-
//
|
|
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);
|
|
1684
1719
|
reinitTriggerState();
|
|
1685
1720
|
|
|
1686
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
|
@@ -261,6 +261,11 @@ tw-storydata {
|
|
|
261
261
|
max-width: 400px;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/* Keep the first control clear of the close button above it */
|
|
265
|
+
.dialog-settings .dialog-body {
|
|
266
|
+
padding-top: 2.25em;
|
|
267
|
+
}
|
|
268
|
+
|
|
264
269
|
.dialog-close {
|
|
265
270
|
position: absolute;
|
|
266
271
|
top: 0.75em;
|
|
@@ -566,6 +571,15 @@ tw-storydata {
|
|
|
566
571
|
background: rgba(100, 181, 246, 0.05);
|
|
567
572
|
}
|
|
568
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
|
+
|
|
569
583
|
.saves-status {
|
|
570
584
|
padding: 0.5em 1.25em;
|
|
571
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 {
|
|
@@ -36,7 +36,14 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
|
|
|
36
36
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* A definition: its arguments (read like a {widget} macro's, see
|
|
41
|
+
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
42
|
+
*/
|
|
43
|
+
const WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
44
|
+
|
|
45
|
+
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
46
|
+
const CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
40
47
|
|
|
41
48
|
/**
|
|
42
49
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
@@ -52,8 +59,13 @@ export function blockWidgetNames(
|
|
|
52
59
|
if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
|
|
53
60
|
continue;
|
|
54
61
|
}
|
|
55
|
-
for (const match of passage.content.matchAll(
|
|
56
|
-
if (
|
|
62
|
+
for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
|
|
63
|
+
if (!CHILDREN_PLACEHOLDER.test(match[2]!)) continue;
|
|
64
|
+
try {
|
|
65
|
+
names.push(parseWidgetDef(match[1]!).name);
|
|
66
|
+
} catch {
|
|
67
|
+
// The {widget} macro reports a definition it cannot read
|
|
68
|
+
}
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
return names;
|