@rohal12/spindle 0.51.2 → 0.51.4

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.
@@ -231,9 +231,13 @@ function parseHtmlAttributes(
231
231
  const attrName = input.slice(attrStart, j);
232
232
  if (!attrName) break;
233
233
 
234
- // Check for = value
235
- if (input[j] === '=') {
236
- j++; // skip =
234
+ // Check for = value. HTML allows whitespace on either side of the =
235
+ // (`id = "x"`); whitespace not followed by = ends a boolean attribute.
236
+ let eqIdx = j;
237
+ while (eqIdx < input.length && /\s/.test(input[eqIdx]!)) eqIdx++;
238
+ if (input[eqIdx] === '=') {
239
+ j = eqIdx + 1; // skip =
240
+ while (j < input.length && /\s/.test(input[j]!)) j++;
237
241
  if (input[j] === '"' || input[j] === "'") {
238
242
  const quote = input[j]!;
239
243
  j++; // skip opening quote
@@ -395,12 +399,22 @@ export function tokenize(input: string): Token[] {
395
399
  }
396
400
 
397
401
  while (i < input.length) {
398
- // Handle escaped braces: \{ and \}
399
- if (input[i] === '\\' && (input[i + 1] === '{' || input[i + 1] === '}')) {
400
- flushText(i);
401
- tokens.push({ type: 'text', value: input[i + 1]!, start: i, end: i + 2 });
402
- i += 2;
403
- textStart = i;
402
+ // Escaped braces: \{ and \}. Count the whole backslash run so \\{ is a
403
+ // backslash pair before a live brace. In an odd run the last backslash
404
+ // escapes the brace; the even rest stays text, which markdown collapses
405
+ // pair by pair like any other \\ in the passage.
406
+ if (input[i] === '\\') {
407
+ let k = i + 1;
408
+ while (input[k] === '\\') k++;
409
+ const next = input[k];
410
+ if ((next === '{' || next === '}') && (k - i) % 2 === 1) {
411
+ flushText(k - 1);
412
+ tokens.push({ type: 'text', value: next, start: k - 1, end: k + 1 });
413
+ i = k + 1;
414
+ textStart = i;
415
+ continue;
416
+ }
417
+ i = k;
404
418
  continue;
405
419
  }
406
420
 
@@ -71,12 +71,19 @@ export async function initSaveSystem(): Promise<void> {
71
71
 
72
72
  // --- Playthroughs ---
73
73
 
74
- export async function startNewPlaythrough(ifid: string): Promise<string> {
74
+ /**
75
+ * Store a new playthrough and make it the story's current one. Pass `id` when
76
+ * the caller has already switched to the playthrough (restart() does, so
77
+ * saves issued before this resolves are tagged with it).
78
+ */
79
+ export async function startNewPlaythrough(
80
+ ifid: string,
81
+ id: string = crypto.randomUUID(),
82
+ ): Promise<string> {
75
83
  const backend = await getBackend();
76
84
  const existing = await backend.getPlaythroughsByIfid(ifid);
77
85
  const num = existing.length + 1;
78
86
 
79
- const id = crypto.randomUUID();
80
87
  const record: PlaythroughRecord = {
81
88
  id,
82
89
  ifid,
@@ -99,9 +106,13 @@ export async function getCurrentPlaythroughId(
99
106
 
100
107
  /**
101
108
  * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
102
- * before `getPayload()` captures the state, so data a hook sets is part of the
103
- * save; `aftersave` fires once `write` has stored it. Every user-facing save
104
- * path goes through here.
109
+ * before the payload is captured, so data a hook sets is part of the save;
110
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
111
+ * goes through here.
112
+ *
113
+ * `beginCapture` is called just before the hooks and returns the function
114
+ * that captures the payload after them, so the capture can tell which
115
+ * variables the hooks changed (the store's `beginSave`).
105
116
  *
106
117
  * The hook and capture run synchronously, but an error they throw (a failing
107
118
  * hook, an unserializable payload) rejects the returned promise like a failed
@@ -110,12 +121,13 @@ export async function getCurrentPlaythroughId(
110
121
  export function saveWithHooks<T>(
111
122
  slot: string | undefined,
112
123
  custom: Record<string, unknown> | undefined,
113
- getPayload: () => SavePayload,
124
+ beginCapture: () => () => SavePayload,
114
125
  write: (payload: SavePayload) => Promise<T>,
115
126
  ): Promise<T> {
116
127
  try {
128
+ const capture = beginCapture();
117
129
  emit('beforesave', slot, custom);
118
- const payload = getPayload();
130
+ const payload = capture();
119
131
  return write(payload).then((result) => {
120
132
  emit('aftersave', slot);
121
133
  return result;
@@ -157,10 +169,26 @@ export async function createSave(
157
169
  return record;
158
170
  }
159
171
 
172
+ /**
173
+ * Replace the payload of an existing save, keeping its ID and `createdAt`.
174
+ * Pass the current `playthroughId` when the new payload comes from the
175
+ * running game: the save then holds that playthrough's state, so it is
176
+ * grouped and deleted with it rather than with the playthrough that first
177
+ * created the save.
178
+ *
179
+ * Metadata describing the content follows the new payload: `passage`,
180
+ * `updatedAt` and the title. A generated title (SaveTitle passage, title
181
+ * generator or the default "passage - time") is generated again, exactly as
182
+ * for a fresh save, so it never names a passage or state the save no longer
183
+ * holds. A title the player gave the save (renameSave) is kept: it names the
184
+ * save itself rather than describing its content. `custom` is merged, as
185
+ * before, so slot keys and metadata not passed again are kept.
186
+ */
160
187
  export async function overwriteSave(
161
188
  saveId: string,
162
189
  payload: SavePayload,
163
190
  custom?: Record<string, unknown>,
191
+ playthroughId?: string,
164
192
  ): Promise<SaveRecord | undefined> {
165
193
  const backend = await getBackend();
166
194
  const existing = await backend.getSave(saveId);
@@ -175,7 +203,12 @@ export async function overwriteSave(
175
203
  const updated: SaveRecord = {
176
204
  meta: {
177
205
  ...existing.meta,
206
+ ...(playthroughId ? { playthroughId } : {}),
178
207
  updatedAt: new Date().toISOString(),
208
+ title:
209
+ existing.meta.userTitle === true
210
+ ? existing.meta.title
211
+ : generateTitle(payload),
179
212
  passage: payload.passage,
180
213
  ...(custom != null
181
214
  ? { custom: { ...existing.meta.custom, ...custom } }
@@ -246,6 +279,7 @@ export async function renameSave(
246
279
  meta: {
247
280
  ...record.meta,
248
281
  title: newTitle,
282
+ userTitle: true,
249
283
  updatedAt: new Date().toISOString(),
250
284
  },
251
285
  };
@@ -426,7 +460,12 @@ export function quickSave(
426
460
  const existingId = await backend.getMeta<string>(metaKey);
427
461
 
428
462
  if (existingId) {
429
- const updated = await overwriteSave(existingId, payload, custom);
463
+ const updated = await overwriteSave(
464
+ existingId,
465
+ payload,
466
+ custom,
467
+ playthroughId,
468
+ );
430
469
  if (updated) return updated;
431
470
  }
432
471
 
@@ -154,6 +154,14 @@ export function createLocalStorageBackend(): StorageBackend {
154
154
 
155
155
  async putSave(record: SaveRecord): Promise<void> {
156
156
  const id = record.meta.id;
157
+ // A save rewritten under another playthrough (an overwrite after a
158
+ // restart) leaves its old playthrough's index
159
+ const previous = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
160
+ if (previous && previous.meta.playthroughId !== record.meta.playthroughId)
161
+ lsIndexRemove(
162
+ `${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`,
163
+ id,
164
+ );
157
165
  lsSet(`${LS_SAVE_PREFIX}${id}`, record);
158
166
  lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
159
167
  lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
@@ -1,4 +1,5 @@
1
1
  import type { PRNGSnapshot } from '../prng';
2
+ import { isDeserializable } from '../class-registry';
2
3
 
3
4
  /** History moment as persisted in saves (full variable snapshots). */
4
5
  export interface SaveHistoryMoment {
@@ -25,6 +26,11 @@ export interface SaveMeta {
25
26
  createdAt: string;
26
27
  updatedAt: string;
27
28
  title: string;
29
+ /**
30
+ * True once the player has named the save (renameSave). Overwriting the
31
+ * save keeps such a title; a generated one is generated anew.
32
+ */
33
+ userTitle?: boolean;
28
34
  passage: string;
29
35
  custom: Record<string, unknown>;
30
36
  estimatedBytes?: number;
@@ -97,11 +103,16 @@ function isOptionalPRNGSnapshot(value: unknown): boolean {
97
103
  );
98
104
  }
99
105
 
106
+ /** A variable container whose values deserialize() can restore. */
107
+ function isSerializedVariables(value: unknown): boolean {
108
+ return isRecord(value) && isDeserializable(value);
109
+ }
110
+
100
111
  function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
101
112
  return (
102
113
  isRecord(value) &&
103
114
  typeof value.passage === 'string' &&
104
- isRecord(value.variables) &&
115
+ isSerializedVariables(value.variables) &&
105
116
  typeof value.timestamp === 'number' &&
106
117
  isOptionalPRNGSnapshot(value.prng)
107
118
  );
@@ -109,14 +120,16 @@ function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
109
120
 
110
121
  /**
111
122
  * Full check of a payload from outside the running story (an imported save):
112
- * every history moment is well formed, and `historyIndex` is an integer that
113
- * points at a moment of the payload's passage. Anything that passes can be
114
- * stored and later loaded.
123
+ * every history moment is well formed, `historyIndex` is an integer that
124
+ * points at a moment of the payload's passage, and the encoded values (Map,
125
+ * Set, Date, RegExp, class instances) in the live variables and every
126
+ * moment's variables have the shape deserialize() expects. Anything that
127
+ * passes can be stored and later loaded.
115
128
  */
116
129
  function isImportablePayload(value: unknown): value is SavePayload {
117
130
  if (!isRecord(value)) return false;
118
131
  if (typeof value.passage !== 'string') return false;
119
- if (!isRecord(value.variables)) return false;
132
+ if (!isSerializedVariables(value.variables)) return false;
120
133
 
121
134
  const { history, historyIndex } = value;
122
135
  if (!Array.isArray(history) || history.length === 0) return false;
package/src/store.ts CHANGED
@@ -234,6 +234,34 @@ export function recordStoryInitState(): void {
234
234
  });
235
235
  }
236
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // Playthrough setup
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Settles once the latest playthrough setup (init's lookup or creation, or a
243
+ * restart's creation) is stored, with that setup's playthrough ID ('' if
244
+ * init could not establish one). Each setup chains on the previous one, so
245
+ * playthroughs are created and numbered in the order the game started them.
246
+ */
247
+ let playthroughSetup: Promise<string> = Promise.resolve('');
248
+
249
+ /** Bumped by every init()/restart(); a stale init must not adopt its ID. */
250
+ let playthroughGeneration = 0;
251
+
252
+ /**
253
+ * The playthrough a save issued now belongs to, once its record is stored.
254
+ * Read synchronously at the call: restart() switches the store's
255
+ * `playthroughId` at once, so a save issued after it (even before the new
256
+ * playthrough is stored) belongs to the new playthrough, and a later restart
257
+ * doesn't move it. Before init() has looked up the stored playthrough the
258
+ * store's ID is '', and the save takes the one init establishes.
259
+ */
260
+ export function resolvePlaythroughId(): Promise<string> {
261
+ const current = useStoryStore.getState().playthroughId;
262
+ return playthroughSetup.then((established) => current || established);
263
+ }
264
+
237
265
  // ---------------------------------------------------------------------------
238
266
  // Runtime handler cleanup (auto-unsub on restart)
239
267
  // ---------------------------------------------------------------------------
@@ -297,6 +325,33 @@ function loadedEntryMoment(
297
325
  return moment;
298
326
  }
299
327
 
328
+ /**
329
+ * Copy the variables that changed between `before` and `after` (the live
330
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
331
+ * saved moment. A load restores that snapshot, the state on entering the
332
+ * passage, and runs the passage again, so data a hook adds to a save would
333
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
334
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
335
+ * a value a hook did not touch keeps its identity.
336
+ */
337
+ function keepHookWrites(
338
+ payload: SavePayload,
339
+ before: Record<string, unknown>,
340
+ after: Record<string, unknown>,
341
+ ): void {
342
+ const moment = payload.history[payload.historyIndex];
343
+ if (!moment) return;
344
+ const has = (vars: Record<string, unknown>, key: string) =>
345
+ Object.prototype.hasOwnProperty.call(vars, key);
346
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
347
+ if (!has(after, key)) {
348
+ delete moment.variables[key];
349
+ } else if (!has(before, key) || after[key] !== before[key]) {
350
+ moment.variables[key] = deepClone(after[key]);
351
+ }
352
+ }
353
+ }
354
+
300
355
  /** Restore or reset PRNG from a history moment's snapshot. */
301
356
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
302
357
  if (moment?.prng) {
@@ -384,6 +439,12 @@ export interface StoryState {
384
439
  clearAllData: () => void;
385
440
  deletePlaythrough: (playthroughId: string) => void;
386
441
  getSavePayload: () => SavePayload;
442
+ /**
443
+ * Start capturing a save, before the `beforesave` hooks run. The returned
444
+ * function builds the payload after them, and keeps the variables the
445
+ * hooks changed in the saved moment's snapshot, so a load restores them.
446
+ */
447
+ beginSave: () => () => SavePayload;
387
448
  /**
388
449
  * Replace the game state with a live (deserialized) payload. `slot` is
389
450
  * passed to the `beforeload`/`afterload` events.
@@ -469,6 +530,8 @@ export const useStoryStore = create<StoryState>()(
469
530
 
470
531
  set((state) => {
471
532
  state.storyData = storyData as StoryData;
533
+ // Unknown until the save system has looked it up (below)
534
+ state.playthroughId = '';
472
535
  state.currentPassage = startPassage.name;
473
536
  state.navigationId++;
474
537
  state.variables = initialVars;
@@ -490,19 +553,20 @@ export const useStoryStore = create<StoryState>()(
490
553
  // Update lastNavigationVars to the Immer-produced reference
491
554
  lastNavigationVars = get().variables;
492
555
 
493
- // Init save system (fire-and-forget — DB will be ready before user opens dialog)
556
+ // Init save system in the background. Saves issued meanwhile wait for
557
+ // it (see resolvePlaythroughId), so they are tagged with the
558
+ // playthrough it establishes and recorded after the known saves.
494
559
  const ifid = storyData.ifid;
495
- initSaveSystem()
560
+ const generation = ++playthroughGeneration;
561
+ playthroughSetup = initSaveSystem()
496
562
  .then(async () => {
497
- const existingId = await getCurrentPlaythroughId(ifid);
498
- if (existingId) {
499
- set((state) => {
500
- state.playthroughId = existingId;
501
- });
502
- } else {
503
- const newId = await startNewPlaythrough(ifid);
563
+ const id =
564
+ (await getCurrentPlaythroughId(ifid)) ??
565
+ (await startNewPlaythrough(ifid));
566
+ // A restart issued meanwhile has already switched playthroughs
567
+ if (generation === playthroughGeneration) {
504
568
  set((state) => {
505
- state.playthroughId = newId;
569
+ state.playthroughId = id;
506
570
  });
507
571
  }
508
572
 
@@ -513,10 +577,12 @@ export const useStoryStore = create<StoryState>()(
513
577
  state.knownSaves = saves;
514
578
  });
515
579
  }
580
+ return id;
516
581
  })
517
- .catch((err) =>
518
- console.error('spindle: failed to init save system', err),
519
- );
582
+ .catch((err) => {
583
+ console.error('spindle: failed to init save system', err);
584
+ return '';
585
+ });
520
586
  },
521
587
 
522
588
  navigate: (passageName: string) => {
@@ -741,6 +807,26 @@ export const useStoryStore = create<StoryState>()(
741
807
 
742
808
  emit('beforerestart');
743
809
 
810
+ // Switch to the new playthrough now, after beforerestart (whose saves
811
+ // belong to the game being left) and before StoryInit, so every save
812
+ // issued from here on belongs to the new game. Storing its record is
813
+ // queued after the previous playthrough setup.
814
+ const newPlaythroughId = crypto.randomUUID();
815
+ ++playthroughGeneration;
816
+ set((state) => {
817
+ state.playthroughId = newPlaythroughId;
818
+ });
819
+ const ifid = storyData.ifid;
820
+ playthroughSetup = playthroughSetup
821
+ .then(() => startNewPlaythrough(ifid, newPlaythroughId))
822
+ .then(
823
+ () => newPlaythroughId,
824
+ (err) => {
825
+ console.error('spindle: failed to start new playthrough', err);
826
+ return newPlaythroughId;
827
+ },
828
+ );
829
+
744
830
  const keepDeferred = get().renderDeferred;
745
831
 
746
832
  // Clean up all runtime-phase handlers (after beforerestart has fired)
@@ -781,47 +867,28 @@ export const useStoryStore = create<StoryState>()(
781
867
  emit('storyinit');
782
868
  // The storyinit handlers' changes belong to the start moment too
783
869
  recordStoryInitState();
784
-
785
- // Start a new playthrough on restart
786
- startNewPlaythrough(storyData.ifid)
787
- .then((newId) => {
788
- set((state) => {
789
- state.playthroughId = newId;
790
- });
791
- })
792
- .catch((err) =>
793
- console.error('spindle: failed to start new playthrough', err),
794
- );
795
870
  },
796
871
 
797
872
  save: (slot?: string, custom?: Record<string, unknown>) => {
798
- const { storyData, playthroughId } = get();
873
+ const { storyData } = get();
799
874
  if (!storyData) return Promise.resolve();
875
+ // The playthrough current now, not when the write runs
876
+ const playthrough = resolvePlaythroughId();
800
877
 
801
878
  return handled(
802
- saveWithHooks(
803
- slot,
804
- custom,
805
- () => get().getSavePayload(),
806
- async (payload) => {
807
- set((state) => {
808
- state.saveError = null;
809
- });
810
- await quickSave(
811
- storyData.ifid,
812
- playthroughId,
813
- payload,
814
- slot,
815
- custom,
816
- );
817
- set((state) => {
818
- state.knownSaves = {
819
- ...state.knownSaves,
820
- [slot ?? '']: true,
821
- };
822
- });
823
- },
824
- ).catch((err) => {
879
+ saveWithHooks(slot, custom, get().beginSave, async (payload) => {
880
+ set((state) => {
881
+ state.saveError = null;
882
+ });
883
+ const playthroughId = await playthrough;
884
+ await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
885
+ set((state) => {
886
+ state.knownSaves = {
887
+ ...state.knownSaves,
888
+ [slot ?? '']: true,
889
+ };
890
+ });
891
+ }).catch((err) => {
825
892
  console.error('spindle: failed to save', err);
826
893
  set((state) => {
827
894
  state.saveError =
@@ -997,6 +1064,15 @@ export const useStoryStore = create<StoryState>()(
997
1064
  };
998
1065
  },
999
1066
 
1067
+ beginSave: () => {
1068
+ const before = get().variables;
1069
+ return () => {
1070
+ const payload = get().getSavePayload();
1071
+ keepHookWrites(payload, before, get().variables);
1072
+ return payload;
1073
+ };
1074
+ },
1075
+
1000
1076
  loadFromPayload: (payload: SavePayload, slot?: string) => {
1001
1077
  if (payload.history.length === 0) {
1002
1078
  console.warn('loadFromPayload: rejecting payload with empty history');
package/src/story-api.ts CHANGED
@@ -25,6 +25,13 @@ import {
25
25
  } from './saves/save-manager';
26
26
  import { getBackendType } from './saves/storage';
27
27
  import { registerClass } from './class-registry';
28
+ import {
29
+ frozenCopy,
30
+ getActiveMutationScope,
31
+ mirrorWriteToActiveScopes,
32
+ runWithCommittedMutations,
33
+ } from './execute-mutation';
34
+ import { getByPath, setByPath } from './utils/object-path';
28
35
  import { defineMacro } from './define-macro';
29
36
  import type { MacroDefinition } from './define-macro';
30
37
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -116,37 +123,6 @@ function ensureVariableChangedSubscription(): void {
116
123
  });
117
124
  }
118
125
 
119
- /** Traverse a dot-delimited path on an object and return the value. */
120
- function getByPath(obj: Record<string, unknown>, path: string): unknown {
121
- const segments = path.split('.');
122
- let current: unknown = obj[segments[0]!];
123
- for (let i = 1; i < segments.length; i++) {
124
- if (current == null) return undefined;
125
- current = (current as Record<string, unknown>)[segments[i]!];
126
- }
127
- return current;
128
- }
129
-
130
- /** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
131
- function setByPath(
132
- obj: Record<string, unknown>,
133
- path: string,
134
- value: unknown,
135
- ): void {
136
- const segments = path.split('.');
137
- let current: Record<string, unknown> = obj;
138
- for (let i = 0; i < segments.length - 1; i++) {
139
- const next = current[segments[i]!];
140
- if (next == null || typeof next !== 'object') {
141
- throw new TypeError(
142
- `spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
143
- );
144
- }
145
- current = next as Record<string, unknown>;
146
- }
147
- current[segments[segments.length - 1]!] = value;
148
- }
149
-
150
126
  export interface StoryAPI {
151
127
  get(name: string): unknown;
152
128
  set(name: string, value: unknown): void;
@@ -292,7 +268,7 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
292
268
  const namespace = isTransient ? draft.transient : draft.variables;
293
269
 
294
270
  if (key.includes('.')) {
295
- setByPath(namespace, key, value);
271
+ setByPath(namespace, key.split('.'), value);
296
272
  } else {
297
273
  namespace[key] = value;
298
274
  }
@@ -302,49 +278,74 @@ function createStoryAPI(): StoryAPI {
302
278
  return {
303
279
  get(name: string): unknown {
304
280
  const { isTransient, key } = parseName(name);
305
- const store = isTransient
306
- ? useStoryStore.getState().transient
307
- : useStoryStore.getState().variables;
308
- return key.includes('.') ? getByPath(store, key) : store[key];
281
+ // Mutation code running now ({do}, ctx.mutate, watcher run actions)
282
+ // has pending writes in its working copy: read that, so the code sees
283
+ // its own changes. The value is a frozen copy, like the frozen store
284
+ // values returned otherwise, so writing to it cannot change the
285
+ // pending state outside the code's own assignments.
286
+ const scope = getActiveMutationScope();
287
+ const source = scope ?? useStoryStore.getState();
288
+ const namespace = isTransient ? source.transient : source.variables;
289
+ const value = key.includes('.')
290
+ ? getByPath(namespace, key.split('.'))
291
+ : namespace[key];
292
+ return scope ? frozenCopy(value) : value;
309
293
  },
310
294
 
311
295
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
312
- const names =
313
- typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
314
- for (const name of names) {
296
+ const entries: [string, unknown][] =
297
+ typeof nameOrVars === 'string'
298
+ ? [[nameOrVars, value]]
299
+ : Object.entries(nameOrVars);
300
+ for (const [name] of entries) {
315
301
  const { isTransient, key } = parseName(name);
316
302
  warnIfUndeclared(isTransient, key);
317
303
  }
318
304
  // One store update for all keys, so watchers see them together
319
305
  useStoryStore.getState().updateVariables((draft) => {
320
- if (typeof nameOrVars === 'string') {
321
- setOne(draft, nameOrVars, value);
322
- } else {
323
- for (const [k, v] of Object.entries(nameOrVars)) {
324
- setOne(draft, k, v);
325
- }
306
+ for (const [k, v] of entries) setOne(draft, k, v);
307
+ // Mutation code running now ({do}, ctx.mutate, watcher run actions)
308
+ // works on copies of the namespaces and commits the paths it changed
309
+ // when it finishes. Apply the write to those copies too, so the code
310
+ // sees it and its commit keeps it in program order (#215). This runs
311
+ // inside the update, before watchers it triggers write.
312
+ for (const [k, v] of entries) {
313
+ const { isTransient, key } = parseName(k);
314
+ mirrorWriteToActiveScopes(
315
+ draft,
316
+ isTransient ? 'transient' : 'variables',
317
+ key.split('.'),
318
+ v,
319
+ );
326
320
  }
327
321
  });
328
322
  },
329
323
 
324
+ // Called from running mutation code, these commit the code's writes so
325
+ // far before they record, replace or save state, and the code goes on
326
+ // from the state they leave (see runWithCommittedMutations).
330
327
  goto(passageName: string): void {
331
- useStoryStore.getState().navigate(passageName);
328
+ runWithCommittedMutations(() =>
329
+ useStoryStore.getState().navigate(passageName),
330
+ );
332
331
  },
333
332
 
334
333
  back(): void {
335
- useStoryStore.getState().goBack();
334
+ runWithCommittedMutations(() => useStoryStore.getState().goBack());
336
335
  },
337
336
 
338
337
  forward(): void {
339
- useStoryStore.getState().goForward();
338
+ runWithCommittedMutations(() => useStoryStore.getState().goForward());
340
339
  },
341
340
 
342
341
  restart(): void {
343
- useStoryStore.getState().restart();
342
+ runWithCommittedMutations(() => useStoryStore.getState().restart());
344
343
  },
345
344
 
346
345
  save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
347
- return useStoryStore.getState().save(slot, custom);
346
+ return runWithCommittedMutations(() =>
347
+ useStoryStore.getState().save(slot, custom),
348
+ );
348
349
  },
349
350
 
350
351
  load(slot?: string): Promise<void> {
package/src/triggers.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { evaluate } from './expression';
2
- import { executeMutation } from './execute-mutation';
2
+ import { executeMutation, runWithCommittedMutations } from './execute-mutation';
3
3
  import { useStoryStore } from './store';
4
4
 
5
5
  export interface WatchOptions {
@@ -149,7 +149,10 @@ function fireTrigger(trigger: Trigger): void {
149
149
  }
150
150
 
151
151
  if (options.goto) {
152
- useStoryStore.getState().navigate(options.goto);
152
+ // A watcher fired by a Story.set in running code navigates after that
153
+ // code's writes so far, so they are part of the moment it leaves.
154
+ const passage = options.goto;
155
+ runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
153
156
  }
154
157
  }
155
158