@rohal12/spindle 0.59.5 → 0.59.7

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.5",
3
+ "version": "0.59.7",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -110,11 +110,22 @@ const defineData = (target: object, key: string, value: unknown): void => {
110
110
  });
111
111
  };
112
112
 
113
+ /**
114
+ * A reducer extracts string keys, so devalue never sees the symbol keys of
115
+ * the original object: refuse them here, as it does for a plain object.
116
+ */
117
+ function refuseSymbolKeys(value: object): void {
118
+ if (Object.getOwnPropertySymbols(value).length > 0) {
119
+ throw new TypeError('spindle: Cannot save an object with symbol keys');
120
+ }
121
+ }
122
+
113
123
  /**
114
124
  * Own enumerable keys of `value`, as a plain object; for an error also its
115
125
  * message, cause and (AggregateError) errors, which are not enumerable.
116
126
  */
117
127
  function ownData(value: object): Record<string, unknown> {
128
+ refuseSymbolKeys(value);
118
129
  const data: Record<string, unknown> = {};
119
130
  if (value instanceof Error) {
120
131
  for (const key of ERROR_HIDDEN_KEYS) {
@@ -163,6 +174,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
163
174
  }
164
175
  const keys = Object.keys(v);
165
176
  if (!keys.some(isEscapedOneCharKey)) return false;
177
+ refuseSymbolKeys(v);
166
178
  if (keys.includes('__proto__')) {
167
179
  throw new TypeError(
168
180
  'spindle: Cannot save a property named "__proto__"',
@@ -41,9 +41,7 @@ export function App() {
41
41
  </>
42
42
  );
43
43
 
44
- return nobr ? (
45
- <NobrContext.Provider value={true}>{content}</NobrContext.Provider>
46
- ) : (
47
- content
48
- );
44
+ // One provider whatever `nobr` is: a changing tree shape would remount the
45
+ // passage and run its mount-only macros again
46
+ return <NobrContext.Provider value={nobr}>{content}</NobrContext.Provider>;
49
47
  }
@@ -1,7 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { evaluate } from '../../expression';
3
3
  import { readState } from '../../execute-mutation';
4
- import { deepEqual } from '../../structural';
4
+ import { deepEqualStrict } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
7
  import { currentSourceLocation } from '../../utils/source-location';
@@ -67,7 +67,8 @@ function computeAndApply(
67
67
  return;
68
68
  }
69
69
 
70
- if (!deepEqual(prevRef.current, newValue)) {
70
+ // Strict: a change of only the aliases inside the result is a change
71
+ if (!deepEqualStrict(prevRef.current, newValue)) {
71
72
  prevRef.current = newValue;
72
73
  if (isLocal) {
73
74
  try {
@@ -131,9 +131,6 @@ defineMacro({
131
131
  const store = useStoryStore.getState();
132
132
  const { history, historyIndex, transitionConfig } = store;
133
133
 
134
- // Always consume the next transition (one-shot), regardless of what we do
135
- const next = store.consumeNextTransition();
136
-
137
134
  // Determine if this is a first load, restart, or save-load
138
135
  const prevLen = prevHistoryLenRef.current;
139
136
  prevHistoryLenRef.current = history.length;
@@ -152,6 +149,10 @@ defineMacro({
152
149
  return;
153
150
  }
154
151
 
152
+ // One-shot, used up by the first navigation, whatever we do with it
153
+ // (not by the initial render, which no navigation caused)
154
+ const next = store.consumeNextTransition();
155
+
155
156
  // Cancel any in-progress transition
156
157
  cancelTransition();
157
158
 
@@ -127,9 +127,8 @@ export function SaveManagerContent() {
127
127
  const playthrough = resolvePlaythroughId();
128
128
  try {
129
129
  await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
130
- const ptId = await playthrough;
131
- if (!ptId) throw new Error('No playthrough');
132
- return createSave(ifid, ptId, payload);
130
+ // Queued now, in call order with other storage operations
131
+ return createSave(ifid, playthrough, payload);
133
132
  });
134
133
  showStatus('Save created');
135
134
  await refresh();
@@ -143,8 +142,7 @@ export function SaveManagerContent() {
143
142
  try {
144
143
  await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
145
144
  // Only the current playthrough's saves offer "Save Here"
146
- const ptId = (await playthrough) || undefined;
147
- if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
145
+ if (!(await overwriteSave(saveId, payload, undefined, playthrough))) {
148
146
  throw new Error('Save not found');
149
147
  }
150
148
  });
@@ -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));
@@ -13,12 +13,13 @@ import {
13
13
  changesBetween,
14
14
  applyChange,
15
15
  deepClone,
16
- deepEqual,
17
16
  existingObjects,
17
+ getByEntryPath,
18
+ isEntryKey,
18
19
  isApplied,
19
20
  isMergeable,
20
21
  locateObjects,
21
- mergeKeys,
22
+ relinkPath,
22
23
  pathKey,
23
24
  sharedPaths,
24
25
  type PathChange,
@@ -94,18 +95,23 @@ function clonerFor(
94
95
  paths?: ReadonlyMap<object, string[]>,
95
96
  ): Cloner {
96
97
  const seen = paths ? existingObjects(paths, target) : new Map();
97
- // Immer does not finalize a draft held as a Map key, so an existing object
98
- // met there is put in the draft as the object itself: its base while the
99
- // code did not change it, which other references to it then agree with.
100
- const mapKey = (copy: object, original: object): object => {
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 => {
101
103
  const path = paths?.get(original);
102
104
  if (!path || !isDraft(copy)) return copy;
103
105
  const plain = currentDraft(copy) as object;
104
- setByPath(target as Record<string, unknown>, path, plain);
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
+ }
105
111
  seen.set(original, plain);
106
112
  return plain;
107
113
  };
108
- return (value) => deepClone(value, { keepUnregistered: true, seen, mapKey });
114
+ return (value) => deepClone(value, { keepUnregistered: true, seen, settle });
109
115
  }
110
116
 
111
117
  /** One cloner per target copy, made when first used. */
@@ -358,10 +364,10 @@ function relink(
358
364
  const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
359
365
  if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
360
366
  try {
361
- const object = getByPath(draft, first!);
367
+ const object = getByEntryPath(draft, first!);
362
368
  if (object === null || typeof object !== 'object') continue;
363
369
  for (const path of others)
364
- setByPath(draft as unknown as Record<string, unknown>, path, object);
370
+ relinkPath(draft as unknown as Record<string, unknown>, path, object);
365
371
  } catch {
366
372
  // A path the store does not hold: the code's view is not the store's
367
373
  }
@@ -375,9 +381,9 @@ function relink(
375
381
  function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
376
382
  const seen = new Map<object, object>();
377
383
  return {
378
- variables: deepClone(from.variables, { seen }),
379
- temporary: deepClone(from.temporary, { seen }),
380
- transient: deepClone(from.transient, { seen }),
384
+ variables: deepClone(from.variables, { keepUnregistered: true, seen }),
385
+ temporary: deepClone(from.temporary, { keepUnregistered: true, seen }),
386
+ transient: deepClone(from.transient, { keepUnregistered: true, seen }),
381
387
  };
382
388
  }
383
389
 
@@ -506,14 +512,31 @@ const running = (): Commit[] =>
506
512
  /**
507
513
  * Bring a suspended mutation's copies up to the store after an action
508
514
  * replaced or changed state under it (navigation clears temporaries, back
509
- * and restart replace variables). Roots that still match keep the objects
510
- * 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.
511
519
  */
512
520
  function resync(scope: MutationScope): void {
513
521
  const state = useStoryStore.getState();
514
- for (const ns of NAMESPACES) {
515
- // Root by root: a root that differs is replaced with a copy of the store's
516
- 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
+ }
517
540
  }
518
541
  scope.base = cloneNamespaces(state);
519
542
  }
@@ -611,11 +634,26 @@ export function executeMutation(
611
634
  // came before its own
612
635
  commitScopes([...running(), { scope, keepRunning: false }]);
613
636
 
614
- for (const key of Object.keys(localsClone)) {
615
- if (!deepEqual(localsClone[key], mergedLocals[key])) {
616
- 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
+ }
617
652
  }
618
653
  }
654
+ for (const key of Object.keys(localsClone)) {
655
+ if (changed.has(key)) scopeUpdate(key, localsClone[key]);
656
+ }
619
657
 
620
658
  // Detect deleted locals
621
659
  for (const key of Object.keys(mergedLocals)) {
package/src/index.tsx CHANGED
@@ -62,6 +62,21 @@ function stopWithErrors(errors: string[]): never {
62
62
  );
63
63
  }
64
64
 
65
+ /**
66
+ * The undeclared variables the passages refer to, with the macros known now:
67
+ * the input macros that bind a variable (registered by author JS or
68
+ * StoryInit) and the roles of their parameters decide what is a reference.
69
+ */
70
+ function variableErrors(
71
+ storyData: StoryData,
72
+ schema: Parameters<typeof validatePassages>[1],
73
+ ): string[] {
74
+ const storeVarMacros = getMacroRegistry()
75
+ .filter((m) => m.storeVar)
76
+ .map((m) => m.name);
77
+ return validatePassages(storyData.passages, schema, storeVarMacros);
78
+ }
79
+
65
80
  /**
66
81
  * The markup errors (malformed markup, unknown macros, syntax errors in
67
82
  * code) of the passages for which `only` holds, with the macros and widgets
@@ -134,10 +149,6 @@ export function boot() {
134
149
  }
135
150
 
136
151
  const schema = parseStoryVariables(storyVarsPassage.content);
137
- // Include input macros registered by author JS, which ran above.
138
- const storeVarMacros = getMacroRegistry()
139
- .filter((m) => m.storeVar)
140
- .map((m) => m.name);
141
152
  // Pass 1: Register the block widgets as block macros BEFORE any passage
142
153
  // is parsed (validation and StoryInit included), so that passages
143
154
  // invoking block widgets and widget bodies using other block widgets
@@ -146,7 +157,7 @@ export function boot() {
146
157
  registerBlockMacro(name);
147
158
  }
148
159
 
149
- const errors = validatePassages(storyData.passages, schema, storeVarMacros);
160
+ const errors = variableErrors(storyData, schema);
150
161
  // StoryInit's markup must be valid before it runs; the other passages are
151
162
  // validated once it has run, as it may define macros (see below).
152
163
  errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
@@ -186,8 +197,12 @@ export function boot() {
186
197
  // storyinit after all state is settled (defaults + StoryInit + session)
187
198
  initializeStory(loadSession(storyData.ifid));
188
199
 
189
- // Every other passage's markup, now that StoryInit may have defined macros
190
- const markup = markupErrors(storyData, (p) => p.name !== 'StoryInit');
200
+ // Every other passage's markup, now that StoryInit may have defined macros;
201
+ // so are the variable references, which macros' parameters decide
202
+ const markup = [
203
+ ...variableErrors(storyData, schema),
204
+ ...markupErrors(storyData, (p) => p.name !== 'StoryInit'),
205
+ ];
191
206
  if (markup.length > 0) stopWithErrors(markup);
192
207
 
193
208
  // Pass 2: Full parse and register widgets from passages tagged "widget"
@@ -291,10 +291,12 @@ export const createSave = queued(createSaveNow);
291
291
 
292
292
  async function createSaveNow(
293
293
  ifid: string,
294
- playthroughId: string,
294
+ playthroughPromise: string | PromiseLike<string>,
295
295
  payload: SavePayload,
296
296
  custom: Record<string, unknown> = {},
297
297
  ): Promise<SaveRecord> {
298
+ const playthroughId = await playthroughPromise;
299
+ if (!playthroughId) throw new Error('No playthrough');
298
300
  const now = new Date().toISOString();
299
301
  const meta: SaveMeta = {
300
302
  id: randomUUID(),
@@ -313,6 +315,10 @@ async function createSaveNow(
313
315
  }
314
316
 
315
317
  /**
318
+ * A playthrough passed as a promise (see resolvePlaythroughId) is awaited
319
+ * inside the operation, so the call takes its place in the order of
320
+ * operations when it is made.
321
+ *
316
322
  * Replace the payload of an existing save, keeping its ID and `createdAt`.
317
323
  * Pass the current `playthroughId` when the new payload comes from the
318
324
  * running game: the save then holds that playthrough's state, so it is
@@ -333,8 +339,9 @@ async function overwriteSaveNow(
333
339
  saveId: string,
334
340
  payload: SavePayload,
335
341
  custom?: Record<string, unknown>,
336
- playthroughId?: string,
342
+ playthroughPromise?: string | PromiseLike<string>,
337
343
  ): Promise<SaveRecord | undefined> {
344
+ const playthroughId = await playthroughPromise;
338
345
  const backend = await getBackend();
339
346
  const existing = await backend.getSave(saveId);
340
347
  if (!existing) return undefined;
package/src/store.ts CHANGED
@@ -765,28 +765,39 @@ function writeHookChange(
765
765
  const child = hasOwn(holder, key) ? holder[key] : undefined;
766
766
  const held = getByPath(after, path.slice(0, depth + 1));
767
767
  if (!mergesWith(child, held, true)) {
768
- writeKey(holder, key, false, own(held));
768
+ writeKey(holder, key, own(held));
769
769
  return;
770
770
  }
771
771
  holder = child as Record<string, unknown>;
772
772
  }
773
773
  const key = path[depth]!;
774
- if (change.deleted) writeKey(holder, key, true, undefined);
775
- else writeKey(holder, key, false, own(change.value), change.appended);
774
+ if (change.deleted) writeKey(holder, key, undefined, change);
775
+ else writeKey(holder, key, own(change.value), change);
776
776
  }
777
777
 
778
778
  function writeKey(
779
779
  holder: Record<string, unknown>,
780
780
  key: string,
781
- deleted: boolean,
782
781
  value: unknown,
783
- appended?: boolean,
782
+ change?: PathChange,
784
783
  ): void {
784
+ const deleted = change?.deleted ?? false;
785
785
  if (Array.isArray(holder)) {
786
786
  const index = Number(key);
787
- if (deleted) holder.length = Math.min(holder.length, index);
788
- else if (appended) holder.push(value);
789
- else if (index < holder.length) holder[index] = value;
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
+ }
790
801
  } else if (deleted) {
791
802
  delete holder[key];
792
803
  } else {