@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.
@@ -1,7 +1,20 @@
1
- import { useRef } from 'preact/hooks';
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);
@@ -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
- rawArgs={node.rawArgs}
701
- className={node.className}
702
- id={node.id}
707
+ {...invocation}
703
708
  children={node.children}
704
709
  branches={node.branches}
705
710
  />
@@ -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
- const num = await nextPlaythroughNumber(ifid);
138
-
139
- const record: PlaythroughRecord = {
140
- id,
141
- ifid,
142
- createdAt: new Date().toISOString(),
143
- label: `Playthrough ${num}`,
144
- };
145
-
146
- await backend.putPlaythrough(record);
147
- await backend.setMeta(playthroughCountKey(ifid), num);
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
- const id =
234
- (await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
235
- (await startNewPlaythroughNow(ifid));
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
- /** Read-modify-write the per-story slot index. */
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
- const existing = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
513
- const updated = update(existing);
514
- if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
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. */
@@ -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 replacement = slotLoadApplying ?? ++stateReplacementsIssued;
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
- // Loaded state is not a change watchers react to
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
- return keep(val, new RegExp(val.source, val.flags));
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) return String(a) === String(b);
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
  }
@@ -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 {