@rohal12/spindle 0.59.4 → 0.59.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.4",
3
+ "version": "0.59.6",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -10,7 +10,11 @@ defineMacro({
10
10
  const state = useStoryStore.getState();
11
11
  const name = ctx.args.variable ?? '';
12
12
 
13
- if (name.startsWith('$')) {
13
+ if (/^[$_%@][^.]*\./.test(name)) {
14
+ // A field of a variable: delete it as mutation code does, which
15
+ // resolves the path in every namespace
16
+ ctx.mutate(`delete ${name}`);
17
+ } else if (name.startsWith('$')) {
14
18
  state.deleteVariable(name.slice(1));
15
19
  } else if (name.startsWith('_')) {
16
20
  state.deleteTemporary(name.slice(1));
@@ -18,7 +18,7 @@ import {
18
18
  renderInlineNodes,
19
19
  } from './markup/render';
20
20
  import type { ASTNode } from './markup/ast';
21
- import { executeMutation, readState } from './execute-mutation';
21
+ import { executeMutation, mutateState, readState } from './execute-mutation';
22
22
  import { evaluate } from './expression';
23
23
  import { useStoryStore } from './store';
24
24
  import { getByPath, setByPath } from './utils/object-path';
@@ -228,8 +228,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
228
228
  // In program order, also when mutation code performs the input
229
229
  ctx.getValue = () => getByPath(readState().variables, segments);
230
230
  ctx.setValue = (value: unknown) => {
231
- useStoryStore.setState((state) => {
232
- setByPath(state.variables, segments, value, {
231
+ // The commit mutation code uses, which keeps the references among
232
+ // variables: a write to a draft copies only the path written, and
233
+ // other variables sharing the object would keep the old one.
234
+ mutateState((work, adopt) => {
235
+ setByPath(work.variables, segments, adopt(value), {
233
236
  createMissing: true,
234
237
  });
235
238
  });
@@ -10,19 +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
+ changesBetween,
14
14
  applyChange,
15
15
  deepClone,
16
- deepEqual,
17
- diffPaths,
18
16
  existingObjects,
17
+ getByEntryPath,
18
+ isEntryKey,
19
19
  isApplied,
20
20
  isMergeable,
21
21
  locateObjects,
22
- mergeKeys,
22
+ relinkPath,
23
23
  pathKey,
24
24
  sharedPaths,
25
- underChange,
26
25
  type PathChange,
27
26
  } from './structural';
28
27
  import { getByPath, setByPath } from './utils/object-path';
@@ -96,7 +95,23 @@ function clonerFor(
96
95
  paths?: ReadonlyMap<object, string[]>,
97
96
  ): Cloner {
98
97
  const seen = paths ? existingObjects(paths, target) : new Map();
99
- return (value) => deepClone(value, { keepUnregistered: true, seen });
98
+ // Immer does not finalize a draft held as a Map key or Set member, so an
99
+ // existing object met there is put in the draft as the object itself: its
100
+ // base while the code did not change it, which other references to it then
101
+ // agree with.
102
+ const settle = (copy: object, original: object): object => {
103
+ const path = paths?.get(original);
104
+ if (!path || !isDraft(copy)) return copy;
105
+ const plain = currentDraft(copy) as object;
106
+ // Where the object is in an entry of a Map or Set the path cannot be
107
+ // written (see getByEntryPath): that entry keeps its own
108
+ if (!path.some(isEntryKey)) {
109
+ setByPath(target as Record<string, unknown>, path, plain);
110
+ }
111
+ seen.set(original, plain);
112
+ return plain;
113
+ };
114
+ return (value) => deepClone(value, { keepUnregistered: true, seen, settle });
100
115
  }
101
116
 
102
117
  /** One cloner per target copy, made when first used. */
@@ -292,8 +307,10 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
292
307
  )
293
308
  : [];
294
309
  });
295
- // Each target takes its own copies, which share what the written values do
296
- const cloners = clonersFor();
310
+ // Each target takes its own copies, which share what the written values do,
311
+ // and an object the code holds elsewhere stays that one (`Story.set('b', $a)`)
312
+ const held = locateObjects(namespacesOf(inner.work), changes);
313
+ const cloners = clonersFor(() => held);
297
314
  const owned = (change: PathChange, target: object): PathChange =>
298
315
  change.deleted
299
316
  ? change
@@ -347,10 +364,10 @@ function relink(
347
364
  const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
348
365
  if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
349
366
  try {
350
- const object = getByPath(draft, first!);
367
+ const object = getByEntryPath(draft, first!);
351
368
  if (object === null || typeof object !== 'object') continue;
352
369
  for (const path of others)
353
- setByPath(draft as unknown as Record<string, unknown>, path, object);
370
+ relinkPath(draft as unknown as Record<string, unknown>, path, object);
354
371
  } catch {
355
372
  // A path the store does not hold: the code's view is not the store's
356
373
  }
@@ -370,6 +387,13 @@ function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
370
387
  };
371
388
  }
372
389
 
390
+ /** The three variable namespaces of `state`, as an object to walk. */
391
+ const namespacesOf = (state: VariableNamespaces): VariableNamespaces => ({
392
+ variables: state.variables,
393
+ temporary: state.temporary,
394
+ transient: state.transient,
395
+ });
396
+
373
397
  /** A mutation to commit, and whether its code goes on running after. */
374
398
  interface Commit {
375
399
  scope: MutationScope;
@@ -470,28 +494,16 @@ function commitScopes(commits: readonly Commit[]): void {
470
494
 
471
495
  /**
472
496
  * The changes of the code to the namespaces, as paths below them (`['variables',
473
- * 'a']`): the property paths that differ (see diffPaths), and those that hold
474
- * equal content but another object than they did (see aliasChanges), those
475
- * first. Compared as one object, so an object the namespaces share is one
476
- * object, whichever of them it is reached through.
497
+ * 'a']`; see changesBetween).
477
498
  */
478
- function changesOf(
499
+ const changesOf = (
479
500
  base: VariableNamespaces,
480
501
  work: VariableNamespaces,
481
- ): PathChange[] {
482
- const before = base as unknown as Record<string, unknown>;
483
- const after = work as unknown as Record<string, unknown>;
484
- const written = diffPaths(before, after);
485
- const keys = new Set(written.map((c) => pathKey(c.path)));
486
- const aliases: PathChange[] = [];
487
- for (const change of aliasChanges(before, after)) {
488
- // Written whole by a change at it or above it
489
- if (underChange(change.path, keys)) continue;
490
- keys.add(pathKey(change.path));
491
- aliases.push(change);
492
- }
493
- return [...aliases, ...written];
494
- }
502
+ ): PathChange[] =>
503
+ changesBetween(
504
+ base as unknown as Record<string, unknown>,
505
+ work as unknown as Record<string, unknown>,
506
+ );
495
507
 
496
508
  /** Every running mutation, to commit with its code going on. */
497
509
  const running = (): Commit[] =>
@@ -500,14 +512,31 @@ const running = (): Commit[] =>
500
512
  /**
501
513
  * Bring a suspended mutation's copies up to the store after an action
502
514
  * replaced or changed state under it (navigation clears temporaries, back
503
- * and restart replace variables). Roots that still match keep the objects
504
- * the code may hold.
515
+ * and restart replace variables). What the action changed (see
516
+ * changesBetween) is written into the code's working copy, which keeps the
517
+ * objects the code may hold where nothing changed, and the references among
518
+ * the values written, and to the objects it holds already, as a commit does.
505
519
  */
506
520
  function resync(scope: MutationScope): void {
507
521
  const state = useStoryStore.getState();
508
- for (const ns of NAMESPACES) {
509
- // Root by root: a root that differs is replaced with a copy of the store's
510
- mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
522
+ const now = namespacesOf(state);
523
+ const changes = changesBetween(
524
+ scope.base as unknown as Record<string, unknown>,
525
+ now as unknown as Record<string, unknown>,
526
+ );
527
+ const copy = clonerFor(scope.work, locateObjects(now, changes));
528
+ for (const change of changes) {
529
+ try {
530
+ applyChange(
531
+ scope.work,
532
+ change.deleted ? change : { ...change, value: copy(change.value) },
533
+ );
534
+ } catch {
535
+ // A path the working copy does not hold: replace its root instead
536
+ const [ns, root] = change.path as [NamespaceName, string];
537
+ if (hasOwn(now[ns], root)) scope.work[ns][root] = copy(now[ns][root]);
538
+ else delete scope.work[ns][root];
539
+ }
511
540
  }
512
541
  scope.base = cloneNamespaces(state);
513
542
  }
@@ -537,13 +566,23 @@ export function runWithCommittedMutations<T>(action: () => T): T {
537
566
  * between values are kept, which a store update made on a draft does not do.
538
567
  * Nothing is committed when `run` throws.
539
568
  */
540
- export function mutateState(run: (work: VariableNamespaces) => void): void {
569
+ export function mutateState(
570
+ run: (work: VariableNamespaces, adopt: Cloner) => void,
571
+ ): void {
541
572
  // The writes of the code this runs inside come first, and stay even if
542
573
  // `run` throws
543
574
  commitScopes(running());
575
+ // Where the objects `run` may be handed are: in the state it continues from
576
+ const held = locateObjects(
577
+ namespacesOf(getActiveMutationScope() ?? useStoryStore.getState()),
578
+ [],
579
+ );
544
580
  const scope = startScope();
581
+ // Copies values into the working copy, an object it holds already staying
582
+ // that one, and what the values share staying shared
583
+ const adopt = clonerFor(scope.work, held);
545
584
  try {
546
- run(scope.work);
585
+ run(scope.work, adopt);
547
586
  } finally {
548
587
  activeScopes.pop();
549
588
  }
@@ -595,11 +634,26 @@ export function executeMutation(
595
634
  // came before its own
596
635
  commitScopes([...running(), { scope, keepRunning: false }]);
597
636
 
598
- for (const key of Object.keys(localsClone)) {
599
- if (!deepEqual(localsClone[key], mergedLocals[key])) {
600
- scopeUpdate(key, localsClone[key]);
637
+ // Locals the code changed, as content or as the objects they refer to
638
+ // (`@a = @b` over an equal object), and the locals that share an object
639
+ // with one of those: the updater takes each one's value, so they must all
640
+ // be written for the scope to hold one object again
641
+ const changed = new Set(
642
+ changesBetween(mergedLocals, localsClone, true).map((c) => c.path[0]!),
643
+ );
644
+ for (const group of sharedPaths(localsClone)) {
645
+ if (group.some((path) => changed.has(path[0]!))) {
646
+ // Not a value kept by reference (an unregistered class instance)
647
+ for (const path of group) {
648
+ if (localsClone[path[0]!] !== mergedLocals[path[0]!]) {
649
+ changed.add(path[0]!);
650
+ }
651
+ }
601
652
  }
602
653
  }
654
+ for (const key of Object.keys(localsClone)) {
655
+ if (changed.has(key)) scopeUpdate(key, localsClone[key]);
656
+ }
603
657
 
604
658
  // Detect deleted locals
605
659
  for (const key of Object.keys(mergedLocals)) {
package/src/store.ts CHANGED
@@ -44,8 +44,16 @@ import {
44
44
  deletePlaythroughData as smDeletePlaythroughData,
45
45
  } from './saves/save-manager';
46
46
 
47
- import { deepClone, mergeKeys, mergesWith, shareEqual } from './structural';
48
- import { shallowCopy } from './utils/object-path';
47
+ import {
48
+ changesBetween,
49
+ deepClone,
50
+ existingObjects,
51
+ locateObjects,
52
+ mergesWith,
53
+ shareEqual,
54
+ type PathChange,
55
+ } from './structural';
56
+ import { getByPath } from './utils/object-path';
49
57
  import { noPassageError, showRuntimeError } from './runtime-errors';
50
58
  import {
51
59
  snapshotPRNG,
@@ -708,11 +716,13 @@ function loadedEntryMoment(
708
716
  * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
709
717
  * load restores that snapshot, the state on entering the passage, and runs
710
718
  * the passage again, so data a hook adds to a save would otherwise be lost
711
- * on load (#227). Only the property paths the hooks changed are written (into
712
- * copies of the objects on those paths, the rest staying shared): a
713
- * whole variable would bring along what the passage did to the rest of it,
714
- * which the passage then does again on load (#232). Only the payload's copy
715
- * changes: the live history keeps the recorded snapshot (#159).
719
+ * on load (#227). Only the property paths the hooks changed are written (see
720
+ * changesBetween): a whole variable would bring along what the passage did
721
+ * to the rest of it, which the passage then does again on load (#232). The
722
+ * references the hooks made stay: an object they put in two variables is one
723
+ * object in the snapshot, and one they made another variable refer to is the
724
+ * snapshot's own object of it (#302). Only the payload's copy changes: the
725
+ * live history keeps the recorded snapshot (#159).
716
726
  */
717
727
  function keepHookWrites(
718
728
  payload: SavePayload,
@@ -721,41 +731,78 @@ function keepHookWrites(
721
731
  ): void {
722
732
  const moment = payload.history[payload.historyIndex];
723
733
  if (!moment) return;
724
- mergeHookWrites(moment.variables, before, after, new Set());
734
+ const changes = changesBetween(before, after, true);
735
+ if (changes.length === 0) return;
736
+ // A copy of its own, so the writes keep the snapshot's references (which
737
+ // the history shares with other moments) as they are
738
+ const work = deepClone(moment.variables);
739
+ const seen = existingObjects(locateObjects(after, changes), work);
740
+ const own = <T>(value: T): T => deepClone(value, { seen });
741
+ for (const change of changes) writeHookChange(work, after, change, own);
742
+ moment.variables = shareEqual(moment.variables, work);
725
743
  }
726
744
 
727
745
  /**
728
- * Apply the hooks' changes between `before` and `after`, two objects or two
729
- * arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
730
- * elements merge by index). A changed value is merged key by key where the
731
- * snapshot holds the same kind of value. Where it lacks the key or holds
732
- * another kind (the passage created or replaced it), the hooks' whole value
733
- * is written.
746
+ * Write `change` (of the hooks, to `after`) into `work`. The value goes in at
747
+ * the first place `work` lacks the objects on the path of one of the kind
748
+ * `after` holds (the passage created or replaced it): there, the hooks' whole
749
+ * value of it is written. Into an array, elements removed from the end are
750
+ * removed at the same indices and elements added are appended; changes at
751
+ * indices the array lacks are dropped (it was resized, so its indices do not
752
+ * line up with the live ones).
734
753
  */
735
- function mergeHookWrites(
736
- target: Record<string, unknown>,
737
- before: Record<string, unknown>,
754
+ function writeHookChange(
755
+ work: Record<string, unknown>,
738
756
  after: Record<string, unknown>,
739
- ancestors: Set<object>,
757
+ change: PathChange,
758
+ own: <T>(value: T) => T,
740
759
  ): void {
741
- ancestors.add(after);
742
- mergeKeys(target, before, after, (key, b, a) => {
743
- if (
744
- !hasOwn(target, key) ||
745
- !mergesWith(a, target[key], true) ||
746
- // Stop at cycles: deepEqual() and deepClone() handle them
747
- ancestors.has(a)
748
- ) {
749
- return false;
760
+ const { path } = change;
761
+ let holder: Record<string, unknown> = work;
762
+ let depth = 0;
763
+ for (; depth < path.length - 1; depth++) {
764
+ const key = path[depth]!;
765
+ const child = hasOwn(holder, key) ? holder[key] : undefined;
766
+ const held = getByPath(after, path.slice(0, depth + 1));
767
+ if (!mergesWith(child, held, true)) {
768
+ writeKey(holder, key, own(held));
769
+ return;
750
770
  }
751
- // The snapshot's values are the history's own (immutable, see
752
- // plainCopy): merge into a copy, made along the merged path only
753
- const copy = shallowCopy(target[key] as object);
754
- target[key] = copy;
755
- mergeHookWrites(copy, b, a, ancestors);
756
- return true;
757
- });
758
- ancestors.delete(after);
771
+ holder = child as Record<string, unknown>;
772
+ }
773
+ const key = path[depth]!;
774
+ if (change.deleted) writeKey(holder, key, undefined, change);
775
+ else writeKey(holder, key, own(change.value), change);
776
+ }
777
+
778
+ function writeKey(
779
+ holder: Record<string, unknown>,
780
+ key: string,
781
+ value: unknown,
782
+ change?: PathChange,
783
+ ): void {
784
+ const deleted = change?.deleted ?? false;
785
+ if (Array.isArray(holder)) {
786
+ const index = Number(key);
787
+ if (deleted) {
788
+ // A hole stays where it was; anything else was cut off
789
+ if (change && 'hole' in change) delete holder[index];
790
+ else holder.length = Math.min(holder.length, index);
791
+ } else if (change && 'appended' in change) {
792
+ // After the holes it followed
793
+ holder.length += change.gap ?? 0;
794
+ holder.push(value);
795
+ } else if (key === 'length' && change) {
796
+ // Holes added at the end
797
+ holder.length += value as number;
798
+ } else if (index < holder.length) {
799
+ holder[index] = value;
800
+ }
801
+ } else if (deleted) {
802
+ delete holder[key];
803
+ } else {
804
+ setOwn(holder, key, value);
805
+ }
759
806
  }
760
807
 
761
808
  /** Restore the PRNG from a snapshot, or reset it without one. */
package/src/story-api.ts CHANGED
@@ -307,8 +307,8 @@ function createStoryAPI(): StoryAPI {
307
307
  // uses (nested in the running code, if any: it continues from the
308
308
  // code's pending writes, and they are committed first): an update of a draft would give the written path new
309
309
  // objects and leave the other references to the old ones (#295).
310
- mutateState((work) => {
311
- for (const [k, v] of entries) setOne(work, k, v);
310
+ mutateState((work, adopt) => {
311
+ for (const [k, v] of entries) setOne(work, k, adopt(v));
312
312
  });
313
313
  return;
314
314
  }