@rohal12/spindle 0.59.8 → 0.59.10
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 +321 -74
- 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 +61 -11
- package/src/components/macros/SaveManager.tsx +27 -1
- package/src/components/macros/Type.tsx +57 -8
- package/src/components/macros/WidgetInvocation.tsx +20 -1
- package/src/components/macros/detached-body.tsx +22 -16
- package/src/components/macros/input-macro.ts +18 -3
- package/src/hooks/use-merged-locals.ts +5 -2
- package/src/hooks/use-story-fields.ts +16 -1
- package/src/markup/render.tsx +9 -4
- package/src/saves/format.ts +10 -1
- package/src/saves/save-manager.ts +99 -18
- package/src/saves/types.ts +3 -0
- package/src/store.ts +44 -3
- 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
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import { useContext, useRef } from 'preact/hooks';
|
|
2
3
|
import { shallow } from 'zustand/vanilla/shallow';
|
|
3
4
|
import { useStoryStore, type StoryState } from '../store';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Tells the components below when to keep the state they last rendered with,
|
|
8
|
+
* given the store's current state: an owner (a click body, see
|
|
9
|
+
* useDetachedBody; a passage on its way out, see PassageDisplay) provides it
|
|
10
|
+
* so that a branch skipped earlier cannot run against later state (#351,
|
|
11
|
+
* #352). It runs inside the store's own notification, before the owner could
|
|
12
|
+
* re-render, which is why it is a predicate and not a flag.
|
|
13
|
+
*/
|
|
14
|
+
export const FrozenStateContext = createContext<
|
|
15
|
+
((state: StoryState) => boolean) | null
|
|
16
|
+
>(null);
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
7
20
|
* useStoryStore() selector per field would). The result keeps its identity
|
|
@@ -11,7 +24,9 @@ export function useStoryFields<K extends keyof StoryState>(
|
|
|
11
24
|
...keys: K[]
|
|
12
25
|
): Pick<StoryState, K> {
|
|
13
26
|
const prev = useRef<Pick<StoryState, K>>();
|
|
27
|
+
const freeze = useContext(FrozenStateContext);
|
|
14
28
|
return useStoryStore((s) => {
|
|
29
|
+
if (prev.current && freeze?.(s)) return prev.current;
|
|
15
30
|
const next = {} as Pick<StoryState, K>;
|
|
16
31
|
for (const key of keys) next[key] = s[key];
|
|
17
32
|
return shallow(prev.current, next) ? prev.current! : (prev.current = next);
|
package/src/markup/render.tsx
CHANGED
|
@@ -679,14 +679,21 @@ function nodeKey(node: ASTNode): string {
|
|
|
679
679
|
function renderMacro(node: MacroNode, key: string) {
|
|
680
680
|
if (isSubMacro(node.name)) return null;
|
|
681
681
|
|
|
682
|
+
// What an invocation passes on to a widget and to a built-in macro alike
|
|
683
|
+
const invocation = {
|
|
684
|
+
rawArgs: node.rawArgs,
|
|
685
|
+
className: node.className,
|
|
686
|
+
id: node.id,
|
|
687
|
+
};
|
|
688
|
+
|
|
682
689
|
const widget = getWidget(node.name);
|
|
683
690
|
if (widget) {
|
|
684
691
|
return (
|
|
685
692
|
<WidgetInvocation
|
|
686
693
|
key={key}
|
|
694
|
+
{...invocation}
|
|
687
695
|
body={widget.body}
|
|
688
696
|
params={widget.params}
|
|
689
|
-
rawArgs={node.rawArgs}
|
|
690
697
|
invocationChildren={node.children}
|
|
691
698
|
/>
|
|
692
699
|
);
|
|
@@ -697,9 +704,7 @@ function renderMacro(node: MacroNode, key: string) {
|
|
|
697
704
|
return (
|
|
698
705
|
<Component
|
|
699
706
|
key={key}
|
|
700
|
-
|
|
701
|
-
className={node.className}
|
|
702
|
-
id={node.id}
|
|
707
|
+
{...invocation}
|
|
703
708
|
children={node.children}
|
|
704
709
|
branches={node.branches}
|
|
705
710
|
/>
|
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,18 +134,20 @@ 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);
|
|
150
|
+
saveSessionPlaythrough(ifid, id);
|
|
149
151
|
return id;
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -201,6 +203,7 @@ async function adoptPlaythroughNow(ifid: string, id: string): Promise<void> {
|
|
|
201
203
|
if ((await backend.getMeta<string>(key)) !== id) {
|
|
202
204
|
await backend.setMeta(key, id);
|
|
203
205
|
}
|
|
206
|
+
saveSessionPlaythrough(ifid, id);
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
/**
|
|
@@ -230,9 +233,18 @@ export function establishPlaythrough(
|
|
|
230
233
|
): Promise<{ id: string; knownSaves: Record<string, true> }> {
|
|
231
234
|
return inOrder(async () => {
|
|
232
235
|
const backend = await getBackend();
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
// A tab that restores its session continues the session's playthrough;
|
|
237
|
+
// the shared current one is the default for a visit with no session,
|
|
238
|
+
// and may belong to what another tab has done since (#356).
|
|
239
|
+
const own = loadSessionPlaythrough(ifid);
|
|
240
|
+
const ownExists =
|
|
241
|
+
own !== undefined &&
|
|
242
|
+
(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === own);
|
|
243
|
+
const id = ownExists
|
|
244
|
+
? own
|
|
245
|
+
: ((await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
|
|
246
|
+
(await startNewPlaythroughNow(ifid)));
|
|
247
|
+
saveSessionPlaythrough(ifid, id);
|
|
236
248
|
return { id, knownSaves: await populateKnownSavesNow(ifid) };
|
|
237
249
|
});
|
|
238
250
|
}
|
|
@@ -503,15 +515,51 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
503
515
|
|
|
504
516
|
const slotIndexKey = (ifid: string) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
505
517
|
|
|
506
|
-
/**
|
|
518
|
+
/**
|
|
519
|
+
* Run `op` holding the browser's lock `name`, so other tabs of the story
|
|
520
|
+
* (the operation queue only orders this document's operations) wait for it.
|
|
521
|
+
* Runs `op` without a lock where the browser offers none (or refuses it, as
|
|
522
|
+
* in a sandboxed frame, which has no other tabs sharing its storage).
|
|
523
|
+
*/
|
|
524
|
+
async function withTabLock<T>(name: string, op: () => Promise<T>): Promise<T> {
|
|
525
|
+
let locks: LockManager | undefined;
|
|
526
|
+
try {
|
|
527
|
+
locks = navigator.locks;
|
|
528
|
+
} catch {
|
|
529
|
+
// Access refused: no lock
|
|
530
|
+
}
|
|
531
|
+
if (!locks) return op();
|
|
532
|
+
let started = false;
|
|
533
|
+
try {
|
|
534
|
+
return await locks.request(name, () => {
|
|
535
|
+
started = true;
|
|
536
|
+
return op();
|
|
537
|
+
});
|
|
538
|
+
} catch (err) {
|
|
539
|
+
// Only a failure to take the lock falls back; op's own errors are its
|
|
540
|
+
if (started) throw err;
|
|
541
|
+
return op();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Read-modify-write the per-story slot index. Under a lock shared by the
|
|
547
|
+
* story's tabs: two tabs adding a slot at once would both read the old index
|
|
548
|
+
* and the later write would drop the other's slot.
|
|
549
|
+
*/
|
|
507
550
|
async function updateSlotIndex(
|
|
508
551
|
ifid: string,
|
|
509
552
|
update: (slots: string[]) => string[],
|
|
510
553
|
): Promise<void> {
|
|
511
554
|
const backend = await getBackend();
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
555
|
+
await withTabLock(`spindle-slot-index:${ifid}`, async () => {
|
|
556
|
+
const existing =
|
|
557
|
+
(await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
|
|
558
|
+
const updated = update(existing);
|
|
559
|
+
if (updated !== existing) {
|
|
560
|
+
await backend.setMeta(slotIndexKey(ifid), updated);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
515
563
|
}
|
|
516
564
|
|
|
517
565
|
/** Every slot: the default one (undefined), then those in the slot index. */
|
|
@@ -636,15 +684,20 @@ export const loadQuickSave = readingSlot(livePayload);
|
|
|
636
684
|
* (see adoptPlaythrough) in the same operation: operations issued after the
|
|
637
685
|
* load take effect in the loaded playthrough. Resolves to the live payload
|
|
638
686
|
* and its playthrough, or undefined if the slot is empty.
|
|
687
|
+
*
|
|
688
|
+
* `check` is run on the payload first: when it throws, the load rejects and
|
|
689
|
+
* the game stays in its playthrough (#357).
|
|
639
690
|
*/
|
|
640
691
|
export function loadSlotSave(
|
|
641
692
|
ifid: string,
|
|
642
693
|
slot?: string,
|
|
694
|
+
check?: (payload: SavePayload) => void,
|
|
643
695
|
): Promise<{ payload: SavePayload; playthroughId: string } | undefined> {
|
|
644
696
|
return inOrder(async () => {
|
|
645
697
|
const record = await slotRecord(ifid, slot);
|
|
646
698
|
if (!record) return undefined;
|
|
647
699
|
const payload = decodePayload(record.payload);
|
|
700
|
+
check?.(payload);
|
|
648
701
|
const { playthroughId } = record.meta;
|
|
649
702
|
if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
|
|
650
703
|
return { payload, playthroughId };
|
|
@@ -758,6 +811,34 @@ export function clearSession(ifid: string): void {
|
|
|
758
811
|
}
|
|
759
812
|
}
|
|
760
813
|
|
|
814
|
+
/**
|
|
815
|
+
* The playthrough this tab's game is in, kept with its session: the shared
|
|
816
|
+
* current playthrough (a stored meta value) can be changed by another tab.
|
|
817
|
+
* Written where this tab's storage operations set the current playthrough,
|
|
818
|
+
* so it follows them in call order. Under the session prefix, so clearing
|
|
819
|
+
* all data clears it.
|
|
820
|
+
*/
|
|
821
|
+
const SESSION_PLAYTHROUGH_PREFIX = `${SESSION_KEY_PREFIX}playthrough.`;
|
|
822
|
+
|
|
823
|
+
function saveSessionPlaythrough(ifid: string, id: string): void {
|
|
824
|
+
try {
|
|
825
|
+
sessionStorage.setItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`, id);
|
|
826
|
+
} catch {
|
|
827
|
+
// the session cannot be stored: a reload takes the shared playthrough
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function loadSessionPlaythrough(ifid: string): string | undefined {
|
|
832
|
+
try {
|
|
833
|
+
return (
|
|
834
|
+
sessionStorage.getItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`) ||
|
|
835
|
+
undefined
|
|
836
|
+
);
|
|
837
|
+
} catch {
|
|
838
|
+
return undefined;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
761
842
|
// --- Export / Import ---
|
|
762
843
|
|
|
763
844
|
/** The export of a stored save, if there is one. */
|
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,34 @@ 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
|
+
|
|
968
|
+
/** Throw if `payload` refers to a passage `storyData` does not have. */
|
|
969
|
+
function assertPassagesExist(
|
|
970
|
+
storyData: StoryData | null | undefined,
|
|
971
|
+
payload: SavePayload,
|
|
972
|
+
): void {
|
|
973
|
+
const missing = missingPassage(storyData, payload);
|
|
974
|
+
if (missing !== undefined) {
|
|
975
|
+
throw new Error(
|
|
976
|
+
`The save refers to the passage "${missing}", which this version of the story does not have`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
950
981
|
const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
|
|
951
982
|
|
|
952
983
|
type StoryRecipe = (draft: Draft<StoryState>) => void;
|
|
@@ -1431,7 +1462,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1431
1462
|
// the game on, as usual). An empty slot leaves the playthrough as it
|
|
1432
1463
|
// is.
|
|
1433
1464
|
const previous = resolvePlaythroughId();
|
|
1434
|
-
const read = loadSlotSave(storyData.ifid, slot)
|
|
1465
|
+
const read = loadSlotSave(storyData.ifid, slot, (payload) =>
|
|
1466
|
+
assertPassagesExist(storyData, payload),
|
|
1467
|
+
);
|
|
1435
1468
|
const switched = switchToLookedUpPlaythrough(
|
|
1436
1469
|
Promise.all([previous, read.catch(() => undefined)]).then(
|
|
1437
1470
|
([prev, loaded]) => loaded?.playthroughId || prev,
|
|
@@ -1601,6 +1634,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1601
1634
|
visitCounts: { ...visitCounts },
|
|
1602
1635
|
renderCounts: { ...renderCounts },
|
|
1603
1636
|
prng: snapshotPRNG(),
|
|
1637
|
+
watchers: savedMacroWatchers(),
|
|
1604
1638
|
};
|
|
1605
1639
|
},
|
|
1606
1640
|
|
|
@@ -1618,12 +1652,16 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1618
1652
|
slot?: string,
|
|
1619
1653
|
playthroughId?: string,
|
|
1620
1654
|
) => {
|
|
1621
|
-
const
|
|
1655
|
+
const applying = slotLoadApplying;
|
|
1622
1656
|
slotLoadApplying = null;
|
|
1657
|
+
const replacement = applying ?? ++stateReplacementsIssued;
|
|
1623
1658
|
if (payload.history.length === 0) {
|
|
1624
1659
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1625
1660
|
return;
|
|
1626
1661
|
}
|
|
1662
|
+
// Before anything is replaced: an incompatible payload leaves the
|
|
1663
|
+
// running game as it is
|
|
1664
|
+
assertPassagesExist(get().storyData, payload);
|
|
1627
1665
|
latestStateApplied = replacement;
|
|
1628
1666
|
|
|
1629
1667
|
emit('beforeload', slot);
|
|
@@ -1684,7 +1722,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1684
1722
|
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1685
1723
|
});
|
|
1686
1724
|
|
|
1687
|
-
//
|
|
1725
|
+
// The watchers of the loaded game (a payload without any, from an
|
|
1726
|
+
// older version, leaves the registered ones), then: loaded state is
|
|
1727
|
+
// not a change watchers react to
|
|
1728
|
+
if (payload.watchers) restoreMacroWatchers(payload.watchers);
|
|
1688
1729
|
reinitTriggerState();
|
|
1689
1730
|
|
|
1690
1731
|
// 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 {
|