@rohal12/spindle 0.59.0 → 0.59.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10040,7 +10040,7 @@ function unset(param) {
10040
10040
  return param.type === "options" ? {} : void 0;
10041
10041
  }
10042
10042
  var WORD_RE = /\w+/y;
10043
- var DIGITS_RE = /\d+/y;
10043
+ var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
10044
10044
  var WORD_OR_PUNCT_RE = /\S+/y;
10045
10045
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
10046
10046
  function matchAt(re, src, pos) {
@@ -10049,7 +10049,8 @@ function matchAt(re, src, pos) {
10049
10049
  }
10050
10050
  /**
10051
10051
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
10052
- * quoted string (with or without whitespace before it) or digit run after
10052
+ * quoted string (with or without whitespace before it) or number (signed,
10053
+ * fractional) after
10053
10054
  * it as its value; keywords that aren't declared, and their values, are
10054
10055
  * skipped. A keyword of a `string` parameter must be followed by a quoted
10055
10056
  * string.
@@ -10068,7 +10069,7 @@ function readOptions(params, src, { at, spans }) {
10068
10069
  let j = i;
10069
10070
  while (j < src.length && isWhitespace(src[j])) j++;
10070
10071
  const quoted = readQuoted(src, j);
10071
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
10072
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
10072
10073
  if (quoted) {
10073
10074
  val = quoted.value;
10074
10075
  i = quoted.end;
@@ -10392,15 +10393,22 @@ function* codeAndText(src, tokens, parametersOf) {
10392
10393
  const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
10393
10394
  for (let t = 0; t < tokens.length; t++) {
10394
10395
  const token = tokens[t];
10395
- if (token.type === "link") yield {
10396
- kind: "passage",
10397
- name: token.target,
10398
- offset: token.targetStart,
10399
- length: token.targetEnd - token.targetStart,
10400
- macro: "link",
10401
- label: src.slice(token.start, token.end)
10402
- };
10403
- else if (token.type === "expression") yield {
10396
+ if (token.type === "link") {
10397
+ yield {
10398
+ kind: "passage",
10399
+ name: token.target,
10400
+ offset: token.targetStart,
10401
+ length: token.targetEnd - token.targetStart,
10402
+ macro: "link",
10403
+ label: src.slice(token.start, token.end)
10404
+ };
10405
+ if (token.display.includes("{")) yield {
10406
+ kind: "text",
10407
+ text: token.display,
10408
+ offset: locate(src, token.display, token.start),
10409
+ where: "In the label of a link: "
10410
+ };
10411
+ } else if (token.type === "expression") yield {
10404
10412
  kind: "code",
10405
10413
  code: token.expression,
10406
10414
  offset: token.end - 1 - token.expression.length,
@@ -10566,7 +10574,7 @@ function* argPieces(args, offset, params, macro, label) {
10566
10574
  };
10567
10575
  }
10568
10576
  }
10569
- yield* visit(params, values);
10577
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
10570
10578
  }
10571
10579
  /**
10572
10580
  * Index of `part` in `text` from `from` on, else anywhere, else `from`: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.0",
3
+ "version": "0.59.2",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -8,6 +8,7 @@ import type {
8
8
  import type { StoryAPI } from '../story-api';
9
9
  import type { StoryAction } from '../action-registry';
10
10
  import { errorMessage } from '../utils/error-message';
11
+ import { deepEqual } from '../structural';
11
12
 
12
13
  export interface RunOptions {
13
14
  onStep?: (stepIndex: number, step: AutomationStep) => void;
@@ -88,7 +89,7 @@ async function executeStep(
88
89
  if (assert.variables) {
89
90
  for (const [key, expected] of Object.entries(assert.variables)) {
90
91
  const actual = story.get(key);
91
- if (actual !== expected) {
92
+ if (!deepEqual(actual, expected)) {
92
93
  errors.push({
93
94
  step: index,
94
95
  message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
package/src/code-check.ts CHANGED
@@ -249,6 +249,15 @@ export function* codeAndText(
249
249
  macro: 'link',
250
250
  label: src.slice(token.start, token.end),
251
251
  };
252
+ // The label is markup too (the link renders as `{link}`)
253
+ if (token.display.includes('{')) {
254
+ yield {
255
+ kind: 'text',
256
+ text: token.display,
257
+ offset: locate(src, token.display, token.start),
258
+ where: 'In the label of a link: ',
259
+ };
260
+ }
252
261
  } else if (token.type === 'expression') {
253
262
  yield {
254
263
  kind: 'code',
@@ -435,7 +444,8 @@ export function* argPieces(
435
444
  }
436
445
  }
437
446
  }
438
- yield* visit(params, values);
447
+ // Options are visited in metadata order, which is not the order written
448
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
439
449
  }
440
450
 
441
451
  /**
@@ -5,6 +5,7 @@ import { renderNodes, NobrContext } from '../markup/render';
5
5
  import { useStoryFields } from '../hooks/use-story-fields';
6
6
  import { emitFromRender } from '../event-emitter';
7
7
  import { useModalFocus } from '../hooks/use-modal-focus';
8
+ import { registerOpenDialog } from '../triggers';
8
9
  import { errorMessage } from '../utils/error-message';
9
10
 
10
11
  export const DialogCloseContext = createContext<(() => void) | null>(null);
@@ -70,6 +71,9 @@ export function PassageDialog({
70
71
  // Declared before the dialogrender effect so handlers can move focus.
71
72
  useModalFocus(panelRef, '.dialog-body', dismissible, stableOnClose);
72
73
 
74
+ // Be on the stack of displayed dialogs the Story dialog API works on.
75
+ useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
76
+
73
77
  // Signal that the dialog's DOM is committed (once per open).
74
78
  useLayoutEffect(() => {
75
79
  if (panelRef.current) {
@@ -16,7 +16,8 @@ defineMacro({
16
16
  { name: 'passage', type: 'passage' },
17
17
  ],
18
18
  render({ children = [] }, ctx) {
19
- const display = ctx.args.text ?? '';
19
+ // The label is markup: resolved once, for the element and its action
20
+ const display = ctx.resolve?.(ctx.args.text ?? '') ?? '';
20
21
  let passage: string | null = null;
21
22
  // Boxed: anything can be thrown, including null and other falsy values
22
23
  let failure: { error: unknown } | undefined;
@@ -44,7 +44,11 @@ defineMacro({
44
44
  ],
45
45
  render(_props, ctx) {
46
46
  try {
47
- const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
47
+ const { currentExpr, maxExpr, labelMode } = meterArgs({
48
+ ...ctx.args,
49
+ // The label is markup: resolved before reading none, % or a unit
50
+ label: ctx.resolve?.(ctx.args.label),
51
+ });
48
52
  const current = Number(ctx.evaluate!(currentExpr));
49
53
  const max = Number(ctx.evaluate!(maxExpr));
50
54
  const pct =
@@ -15,12 +15,14 @@ defineMacro({
15
15
  const visibleCharsRef = useRef(0);
16
16
  visibleCharsRef.current = visibleChars;
17
17
 
18
+ // Measure after every render: reactive content can become nonempty (or
19
+ // change length) after mounting. Setting an equal count is a no-op.
18
20
  useEffect(() => {
19
21
  if (containerRef.current) {
20
22
  const text = containerRef.current.textContent || '';
21
23
  setTotalChars(text.length);
22
24
  }
23
- }, []);
25
+ });
24
26
 
25
27
  // Typewriter interval
26
28
  useEffect(() => {
@@ -108,7 +108,7 @@ function unset(param: ParameterDef): unknown {
108
108
  }
109
109
 
110
110
  const WORD_RE = /\w+/y;
111
- const DIGITS_RE = /\d+/y;
111
+ const NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
112
112
  const WORD_OR_PUNCT_RE = /\S+/y;
113
113
 
114
114
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
@@ -119,7 +119,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
119
119
 
120
120
  /**
121
121
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
122
- * quoted string (with or without whitespace before it) or digit run after
122
+ * quoted string (with or without whitespace before it) or number (signed,
123
+ * fractional) after
123
124
  * it as its value; keywords that aren't declared, and their values, are
124
125
  * skipped. A keyword of a `string` parameter must be followed by a quoted
125
126
  * string.
@@ -141,13 +142,13 @@ function readOptions(
141
142
  i += key.length;
142
143
 
143
144
  // A value is a quoted string, which may follow the keyword directly
144
- // (`goto"Hall"`: a quote can't be part of one), or a digit run after
145
+ // (`goto"Hall"`: a quote can't be part of one), or a number after
145
146
  // whitespace.
146
147
  let val: string | undefined;
147
148
  let j = i;
148
149
  while (j < src.length && isWhitespace(src[j]!)) j++;
149
150
  const quoted = readQuoted(src, j);
150
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
151
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
151
152
  if (quoted) {
152
153
  val = quoted.value;
153
154
  i = quoted.end;
@@ -10,13 +10,18 @@ import { useStoryStore } from './store';
10
10
  import type { StoryState, VariableNamespaces } from './store';
11
11
  import { execute } from './expression';
12
12
  import {
13
+ aliasChanges,
13
14
  applyChange,
14
15
  deepClone,
15
16
  deepEqual,
16
17
  diffPaths,
18
+ existingObjects,
17
19
  isApplied,
18
20
  isMergeable,
21
+ locateObjects,
19
22
  mergeKeys,
23
+ pathKey,
24
+ underChange,
20
25
  type PathChange,
21
26
  } from './structural';
22
27
  import { getByPath } from './utils/object-path';
@@ -71,6 +76,39 @@ export function readState(): StoryState {
71
76
  const cloneValue = <T>(value: T): T =>
72
77
  deepClone(value, { keepUnregistered: true });
73
78
 
79
+ /** Copies values for one target, so what they share they share there too. */
80
+ type Cloner = <T>(value: T) => T;
81
+
82
+ /**
83
+ * A cloner of values written into `target`. The references among the values
84
+ * it copies are kept (`$b = $a` writes one array twice: the two paths get
85
+ * one copy), and, given where the objects of the source are (see
86
+ * locateObjects), an object that is one the target holds already is that
87
+ * one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
88
+ */
89
+ function clonerFor(
90
+ target: Record<string, unknown>,
91
+ paths?: ReadonlyMap<object, string[]>,
92
+ ): Cloner {
93
+ const seen = paths ? existingObjects(paths, target) : new Map();
94
+ return (value) => deepClone(value, { keepUnregistered: true, seen });
95
+ }
96
+
97
+ /** One cloner per target copy, made when first used. */
98
+ const clonersFor = (
99
+ paths?: (target: Record<string, unknown>) => ReadonlyMap<object, string[]>,
100
+ ) => {
101
+ const made = new Map<object, Cloner>();
102
+ return (target: Record<string, unknown>): Cloner => {
103
+ let cloner = made.get(target);
104
+ if (!cloner) {
105
+ cloner = clonerFor(target, paths?.(target));
106
+ made.set(target, cloner);
107
+ }
108
+ return cloner;
109
+ };
110
+ };
111
+
74
112
  /**
75
113
  * A frozen copy of a value read from a working copy, matching what reads
76
114
  * from the (frozen) store return: writing to it fails instead of changing
@@ -94,6 +132,7 @@ function mirror(
94
132
  ns: NamespaceName,
95
133
  change: PathChange,
96
134
  scopes: readonly MutationScope[] = activeScopes,
135
+ cloner: (target: Record<string, unknown>) => Cloner = clonerOf,
97
136
  ): void {
98
137
  const root = change.path[0]!;
99
138
  for (const scope of scopes) {
@@ -103,7 +142,7 @@ function mirror(
103
142
  copy,
104
143
  change.deleted
105
144
  ? change
106
- : { ...change, value: cloneValue(change.value) },
145
+ : { ...change, value: cloner(copy)(change.value) },
107
146
  );
108
147
  } catch {
109
148
  const stored = draft[ns][root];
@@ -119,6 +158,9 @@ function mirror(
119
158
  }
120
159
  }
121
160
 
161
+ /** A cloner of a copy of its own (no references shared with others). */
162
+ const clonerOf = (target: Record<string, unknown>): Cloner => clonerFor(target);
163
+
122
164
  const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
123
165
 
124
166
  /**
@@ -244,10 +286,19 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
244
286
  own.length ? writtenPaths(view[ns], next[ns], own) : [],
245
287
  ] as const;
246
288
  });
247
- const owned = (change: PathChange): PathChange =>
248
- change.deleted ? change : { ...change, value: cloneValue(change.value) };
289
+ // Each target takes its own copies, which share what the written values do
290
+ const cloners = clonersFor();
291
+ const owned = (
292
+ change: PathChange,
293
+ target: Record<string, unknown>,
294
+ ): PathChange =>
295
+ change.deleted
296
+ ? change
297
+ : { ...change, value: cloners(target)(change.value) };
249
298
  for (const [ns, list] of changes) {
250
- for (const change of list) applyChange(inner.work[ns], owned(change));
299
+ for (const change of list) {
300
+ applyChange(inner.work[ns], owned(change, inner.work[ns]));
301
+ }
251
302
  }
252
303
 
253
304
  return (draft) => {
@@ -266,7 +317,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
266
317
  for (const change of list) {
267
318
  const root = change.path[0]!;
268
319
  try {
269
- applyChange(namespaces[ns], owned(change));
320
+ applyChange(namespaces[ns], owned(change, namespaces[ns]));
270
321
  } catch {
271
322
  if (hasOwn(inner.work[ns], root)) {
272
323
  namespaces[ns][root] = cloneValue(inner.work[ns][root]);
@@ -274,7 +325,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
274
325
  delete namespaces[ns][root];
275
326
  }
276
327
  }
277
- mirror(namespaces, ns, change);
328
+ mirror(namespaces, ns, change, activeScopes, cloners);
278
329
  }
279
330
  }
280
331
  };
@@ -312,7 +363,7 @@ function commitScopes(commits: readonly Commit[]): void {
312
363
  scope,
313
364
  keepRunning,
314
365
  changes: NAMESPACES.map(
315
- (ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])] as const,
366
+ (ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
316
367
  ),
317
368
  }));
318
369
  const changed = all.filter(({ changes }) =>
@@ -336,35 +387,84 @@ function commitScopes(commits: readonly Commit[]): void {
336
387
  }
337
388
 
338
389
  function commitRecipe(draft: VariableNamespaces): void {
339
- all.forEach(({ scope, keepRunning, changes }, i) => {
340
- const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
390
+ all.forEach(({ scope, changes }, i) => {
391
+ // The copies the store and the enclosing mutations take are their own,
392
+ // and keep the references of the values written (one object written
393
+ // at two paths is one object there) and to the objects they hold
394
+ // already (see clonerFor). The code's working copy stays its own.
395
+ const paths = new Map(
396
+ changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]),
397
+ );
398
+ const own = new Map(
399
+ changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
400
+ );
401
+ const enclosing = all.slice(0, i).map((c) => c.scope);
402
+ const enclosingCloners = new Map(
403
+ changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns)!)]),
404
+ );
341
405
  for (const [ns, list] of changes) {
342
406
  const replaced = new Set<string>();
343
407
  for (const change of list) {
344
408
  const root = change.path[0]!;
345
- // A changed path already holding the value keeps its reference
346
- if (replaced.has(root) || isApplied(draft[ns], change)) continue;
409
+ if (replaced.has(root)) continue;
347
410
  try {
348
- applyChange(
349
- draft[ns],
350
- change.deleted ? change : { ...change, value: own(change.value) },
351
- );
411
+ if (change.deleted) {
412
+ if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
413
+ continue;
414
+ }
415
+ // A changed path already holding the value keeps its reference;
416
+ // an alias change is one of reference only: it holds when the
417
+ // very object is there.
418
+ const value = change.alias ? own.get(ns)!(change.value) : undefined;
419
+ if (
420
+ change.alias
421
+ ? Object.is(getByPath(draft[ns], change.path), value)
422
+ : isApplied(draft[ns], change)
423
+ ) {
424
+ continue;
425
+ }
426
+ applyChange(draft[ns], {
427
+ ...change,
428
+ value: change.alias ? value : own.get(ns)!(change.value),
429
+ });
352
430
  } catch {
353
431
  // An intermediate object the code wrote into is gone from the
354
432
  // store: the code's view of the whole root wins.
355
- draft[ns][root] = own(scope.work[ns][root]);
433
+ draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
356
434
  replaced.add(root);
357
435
  }
358
436
  }
359
437
  // Hand the changes to the mutations this one runs inside, before
360
438
  // watchers fired by this update run.
361
- const enclosing = all.slice(0, i).map((c) => c.scope);
362
- for (const change of list) mirror(draft, ns, change, enclosing);
439
+ for (const change of list) {
440
+ mirror(draft, ns, change, enclosing, enclosingCloners.get(ns)!);
441
+ }
363
442
  }
364
443
  });
365
444
  }
366
445
  }
367
446
 
447
+ /**
448
+ * The changes of the code to a namespace: its property paths that differ
449
+ * (see diffPaths), and those that hold equal content but another object
450
+ * than they did (see aliasChanges), those first.
451
+ */
452
+ function changesOf(
453
+ base: Record<string, unknown>,
454
+ work: Record<string, unknown>,
455
+ ): PathChange[] {
456
+ const written = diffPaths(base, work);
457
+ const keys = new Set(written.map((c) => pathKey(c.path)));
458
+ const aliases: PathChange[] = [];
459
+ for (const change of aliasChanges(base, work)) {
460
+ // Written whole by a change at it or above it
461
+ if (underChange(change.path, keys)) continue;
462
+ keys.add(pathKey(change.path));
463
+ aliases.push(change);
464
+ }
465
+ return [...aliases, ...written];
466
+ }
467
+
368
468
  /** Every running mutation, to commit with its code going on. */
369
469
  const running = (): Commit[] =>
370
470
  activeScopes.map((scope) => ({ scope, keepRunning: true }));
@@ -7,6 +7,7 @@ import type {
7
7
  SaveExport,
8
8
  StorageInfo,
9
9
  } from './types';
10
+ import { randomUUID } from '../utils/uuid';
10
11
  import { checkSaveExport, estimatePayloadBytes } from './types';
11
12
  import {
12
13
  decodePayload,
@@ -130,7 +131,7 @@ export const startNewPlaythrough = queued(startNewPlaythroughNow);
130
131
 
131
132
  async function startNewPlaythroughNow(
132
133
  ifid: string,
133
- id: string = crypto.randomUUID(),
134
+ id: string = randomUUID(),
134
135
  ): Promise<string> {
135
136
  const backend = await getBackend();
136
137
  const num = await nextPlaythroughNumber(ifid);
@@ -296,7 +297,7 @@ async function createSaveNow(
296
297
  ): Promise<SaveRecord> {
297
298
  const now = new Date().toISOString();
298
299
  const meta: SaveMeta = {
299
- id: crypto.randomUUID(),
300
+ id: randomUUID(),
300
301
  ifid,
301
302
  playthroughId,
302
303
  createdAt: now,
@@ -774,7 +775,7 @@ async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
774
775
 
775
776
  // Re-assign a new ID to avoid collisions
776
777
  const record = deepClone(data.save);
777
- record.meta.id = crypto.randomUUID();
778
+ record.meta.id = randomUUID();
778
779
  record.meta.updatedAt = new Date().toISOString();
779
780
  // The save now belongs to this story: it is listed, loaded and cleared
780
781
  // with it, whatever its own metadata says
@@ -289,7 +289,16 @@ function createIDBBackend(): StorageBackend {
289
289
  }
290
290
  };
291
291
 
292
- req.onsuccess = () => resolve(req.result);
292
+ req.onsuccess = () => {
293
+ const db = req.result;
294
+ // Let another tab delete or upgrade the database: close at once and
295
+ // reopen on the next use.
296
+ db.onversionchange = () => {
297
+ db.close();
298
+ dbPromise = null;
299
+ };
300
+ resolve(db);
301
+ };
293
302
  req.onerror = () => reject(req.error);
294
303
  });
295
304
 
@@ -315,7 +324,20 @@ function createIDBBackend(): StorageBackend {
315
324
  make: (store: IDBObjectStore) => IDBRequest<R>,
316
325
  ): Promise<R> => {
317
326
  const db = await openDB();
318
- return idbReq(make(db.transaction(name, mode).objectStore(name)));
327
+ const tx = db.transaction(name, mode);
328
+ const result = idbReq(make(tx.objectStore(name)));
329
+ // A request can succeed and its transaction still roll back, so a
330
+ // write is done only when the transaction completes.
331
+ if (mode === 'readonly') return result;
332
+ const committed = new Promise<void>((resolve, reject) => {
333
+ tx.oncomplete = () => resolve();
334
+ tx.onabort = () =>
335
+ reject(
336
+ tx.error ?? new DOMException('Transaction aborted', 'AbortError'),
337
+ );
338
+ });
339
+ const [value] = await Promise.all([result, committed]);
340
+ return value;
319
341
  };
320
342
 
321
343
  return {
@@ -348,6 +370,12 @@ function createIDBBackend(): StorageBackend {
348
370
  const req = indexedDB.deleteDatabase(IDB_DB_NAME);
349
371
  req.onsuccess = () => resolve();
350
372
  req.onerror = () => reject(req.error);
373
+ req.onblocked = () =>
374
+ reject(
375
+ new Error(
376
+ 'Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again.',
377
+ ),
378
+ );
351
379
  });
352
380
  });
353
381
  }
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { create } from './preact-store';
2
+ import { randomUUID } from './utils/uuid';
2
3
  import { immer } from 'zustand/middleware/immer';
3
4
  import type { StateCreator } from 'zustand/vanilla';
4
5
  import {
@@ -120,7 +121,10 @@ let lastNavigationVars: Namespace = createNamespace();
120
121
  * object) or deleted. A recorded value is a deep copy, so that changing a
121
122
  * live value in place (a class instance is not frozen) cannot change
122
123
  * history; instances of unregistered classes are kept, as the store keeps
123
- * them (a save then refuses them).
124
+ * them (a save then refuses them). The copies are made together, so what
125
+ * variables share (an object, a cycle) stays shared in them, and a variable
126
+ * left as it is that shares with a replaced one is recorded again, to share
127
+ * with its copy.
124
128
  */
125
129
  function computeVarPatches(
126
130
  prev: Record<string, unknown>,
@@ -132,13 +136,32 @@ function computeVarPatches(
132
136
  // whose inherited `constructor` is no variable
133
137
  if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
134
138
  }
139
+ const seen = new Map<object, object>();
140
+ const record = (key: string) =>
141
+ forward.push({
142
+ key,
143
+ deleted: false,
144
+ value: deepClone(curr[key], { keepUnregistered: true, seen }),
145
+ });
146
+ const unchanged: string[] = [];
135
147
  for (const key of Object.keys(curr)) {
136
- if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) {
137
- forward.push({
138
- key,
139
- deleted: false,
140
- value: deepClone(curr[key], { keepUnregistered: true }),
141
- });
148
+ if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
149
+ else if (typeof curr[key] === 'object' && curr[key] !== null) {
150
+ unchanged.push(key);
151
+ }
152
+ }
153
+ // Probe an unchanged variable with a copy of its own: it shares with a
154
+ // recorded one if the copy meets an object already copied
155
+ for (let found = seen.size > 0; found; ) {
156
+ found = false;
157
+ for (const key of unchanged) {
158
+ const probe = new Map<object, object>();
159
+ deepClone(curr[key], { keepUnregistered: true, seen: probe });
160
+ if (![...probe.keys()].some((object) => seen.has(object))) continue;
161
+ record(key);
162
+ unchanged.splice(unchanged.indexOf(key), 1);
163
+ found = true;
164
+ break;
142
165
  }
143
166
  }
144
167
  return { forward };
@@ -573,7 +596,7 @@ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
573
596
 
574
597
  /** Move the running game to a new playthrough at once (see restart). */
575
598
  function switchToNewPlaythrough(ifid: string): void {
576
- const id = crypto.randomUUID();
599
+ const id = randomUUID();
577
600
  const stored = startNewPlaythrough(ifid, id).catch((err) => {
578
601
  console.error('spindle: failed to start new playthrough', err);
579
602
  });
@@ -1462,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
1462
1485
  const ifid = storyData.ifid;
1463
1486
  const current = knownPlaythroughId();
1464
1487
  const established = playthroughSetup;
1465
- const replacementId = crypto.randomUUID();
1488
+ const replacementId = randomUUID();
1466
1489
  const deletion = smDeletePlaythroughData(ifid, playthroughId, {
1467
1490
  current: current || established,
1468
1491
  id: replacementId,