@rohal12/spindle 0.51.2 → 0.51.3

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.
@@ -2809,18 +2809,23 @@ async function getCurrentPlaythroughId(ifid) {
2809
2809
  }
2810
2810
  /**
2811
2811
  * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
2812
- * before `getPayload()` captures the state, so data a hook sets is part of the
2813
- * save; `aftersave` fires once `write` has stored it. Every user-facing save
2814
- * path goes through here.
2812
+ * before the payload is captured, so data a hook sets is part of the save;
2813
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
2814
+ * goes through here.
2815
+ *
2816
+ * `beginCapture` is called just before the hooks and returns the function
2817
+ * that captures the payload after them, so the capture can tell which
2818
+ * variables the hooks changed (the store's `beginSave`).
2815
2819
  *
2816
2820
  * The hook and capture run synchronously, but an error they throw (a failing
2817
2821
  * hook, an unserializable payload) rejects the returned promise like a failed
2818
2822
  * write rather than escaping to the caller.
2819
2823
  */
2820
- function saveWithHooks(slot, custom, getPayload, write) {
2824
+ function saveWithHooks(slot, custom, beginCapture, write) {
2821
2825
  try {
2826
+ const capture = beginCapture();
2822
2827
  emit("beforesave", slot, custom);
2823
- return write(getPayload()).then((result) => {
2828
+ return write(capture()).then((result) => {
2824
2829
  emit("aftersave", slot);
2825
2830
  return result;
2826
2831
  });
@@ -3703,6 +3708,22 @@ function loadedEntryMoment(payload) {
3703
3708
  if (typeof moment.variables !== "object" || moment.variables === null) return;
3704
3709
  return moment;
3705
3710
  }
3711
+ /**
3712
+ * Copy the variables that changed between `before` and `after` (the live
3713
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
3714
+ * saved moment. A load restores that snapshot, the state on entering the
3715
+ * passage, and runs the passage again, so data a hook adds to a save would
3716
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
3717
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
3718
+ * a value a hook did not touch keeps its identity.
3719
+ */
3720
+ function keepHookWrites(payload, before, after) {
3721
+ const moment = payload.history[payload.historyIndex];
3722
+ if (!moment) return;
3723
+ const has = (vars, key) => Object.prototype.hasOwnProperty.call(vars, key);
3724
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) if (!has(after, key)) delete moment.variables[key];
3725
+ else if (!has(before, key) || after[key] !== before[key]) moment.variables[key] = deepClone(after[key]);
3726
+ }
3706
3727
  /** Restore or reset PRNG from a history moment's snapshot. */
3707
3728
  function restorePRNGFromMoment(moment) {
3708
3729
  if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
@@ -3985,7 +4006,7 @@ var useStoryStore = create()(immer((set, get) => ({
3985
4006
  save: (slot, custom) => {
3986
4007
  const { storyData, playthroughId } = get();
3987
4008
  if (!storyData) return Promise.resolve();
3988
- return handled(saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
4009
+ return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
3989
4010
  set((state) => {
3990
4011
  state.saveError = null;
3991
4012
  });
@@ -4127,6 +4148,14 @@ var useStoryStore = create()(immer((set, get) => ({
4127
4148
  prng: snapshotPRNG()
4128
4149
  };
4129
4150
  },
4151
+ beginSave: () => {
4152
+ const before = get().variables;
4153
+ return () => {
4154
+ const payload = get().getSavePayload();
4155
+ keepHookWrites(payload, before, get().variables);
4156
+ return payload;
4157
+ };
4158
+ },
4130
4159
  loadFromPayload: (payload, slot) => {
4131
4160
  if (payload.history.length === 0) {
4132
4161
  console.warn("loadFromPayload: rejecting payload with empty history");
@@ -6424,7 +6453,7 @@ function SaveManagerContent() {
6424
6453
  const closeDialog = useContext(DialogCloseContext);
6425
6454
  const storyData = useStoryStore((s) => s.storyData);
6426
6455
  const playthroughId = useStoryStore((s) => s.playthroughId);
6427
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
6456
+ const beginSave = useStoryStore((s) => s.beginSave);
6428
6457
  const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
6429
6458
  const ifid = storyData?.ifid ?? "";
6430
6459
  const refresh = useCallback(async () => {
@@ -6464,7 +6493,7 @@ function SaveManagerContent() {
6464
6493
  const handleNewSave = async () => {
6465
6494
  if (!ifid || !playthroughId) return;
6466
6495
  try {
6467
- await saveWithHooks(void 0, void 0, getSavePayload, (payload) => createSave(ifid, playthroughId, payload));
6496
+ await saveWithHooks(void 0, void 0, beginSave, (payload) => createSave(ifid, playthroughId, payload));
6468
6497
  showStatus("Save created");
6469
6498
  await refresh();
6470
6499
  } catch {
@@ -6473,7 +6502,7 @@ function SaveManagerContent() {
6473
6502
  };
6474
6503
  const handleOverwrite = async (saveId) => {
6475
6504
  try {
6476
- await saveWithHooks(void 0, void 0, getSavePayload, async (payload) => {
6505
+ await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
6477
6506
  if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
6478
6507
  });
6479
6508
  showStatus("Save overwritten");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.51.2",
3
+ "version": "0.51.3",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -62,7 +62,7 @@ export function SaveManagerContent() {
62
62
 
63
63
  const storyData = useStoryStore((s) => s.storyData);
64
64
  const playthroughId = useStoryStore((s) => s.playthroughId);
65
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
65
+ const beginSave = useStoryStore((s) => s.beginSave);
66
66
  const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
67
67
  const ifid = storyData?.ifid ?? '';
68
68
 
@@ -108,7 +108,7 @@ export function SaveManagerContent() {
108
108
  const handleNewSave = async () => {
109
109
  if (!ifid || !playthroughId) return;
110
110
  try {
111
- await saveWithHooks(undefined, undefined, getSavePayload, (payload) =>
111
+ await saveWithHooks(undefined, undefined, beginSave, (payload) =>
112
112
  createSave(ifid, playthroughId, payload),
113
113
  );
114
114
  showStatus('Save created');
@@ -120,16 +120,11 @@ export function SaveManagerContent() {
120
120
 
121
121
  const handleOverwrite = async (saveId: string) => {
122
122
  try {
123
- await saveWithHooks(
124
- undefined,
125
- undefined,
126
- getSavePayload,
127
- async (payload) => {
128
- if (!(await overwriteSave(saveId, payload))) {
129
- throw new Error('Save not found');
130
- }
131
- },
132
- );
123
+ await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
124
+ if (!(await overwriteSave(saveId, payload))) {
125
+ throw new Error('Save not found');
126
+ }
127
+ });
133
128
  showStatus('Save overwritten');
134
129
  await refresh();
135
130
  } catch {
@@ -99,9 +99,13 @@ export async function getCurrentPlaythroughId(
99
99
 
100
100
  /**
101
101
  * 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.
102
+ * before the payload is captured, so data a hook sets is part of the save;
103
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
104
+ * goes through here.
105
+ *
106
+ * `beginCapture` is called just before the hooks and returns the function
107
+ * that captures the payload after them, so the capture can tell which
108
+ * variables the hooks changed (the store's `beginSave`).
105
109
  *
106
110
  * The hook and capture run synchronously, but an error they throw (a failing
107
111
  * hook, an unserializable payload) rejects the returned promise like a failed
@@ -110,12 +114,13 @@ export async function getCurrentPlaythroughId(
110
114
  export function saveWithHooks<T>(
111
115
  slot: string | undefined,
112
116
  custom: Record<string, unknown> | undefined,
113
- getPayload: () => SavePayload,
117
+ beginCapture: () => () => SavePayload,
114
118
  write: (payload: SavePayload) => Promise<T>,
115
119
  ): Promise<T> {
116
120
  try {
121
+ const capture = beginCapture();
117
122
  emit('beforesave', slot, custom);
118
- const payload = getPayload();
123
+ const payload = capture();
119
124
  return write(payload).then((result) => {
120
125
  emit('aftersave', slot);
121
126
  return result;
package/src/store.ts CHANGED
@@ -297,6 +297,33 @@ function loadedEntryMoment(
297
297
  return moment;
298
298
  }
299
299
 
300
+ /**
301
+ * Copy the variables that changed between `before` and `after` (the live
302
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
303
+ * saved moment. A load restores that snapshot, the state on entering the
304
+ * passage, and runs the passage again, so data a hook adds to a save would
305
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
306
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
307
+ * a value a hook did not touch keeps its identity.
308
+ */
309
+ function keepHookWrites(
310
+ payload: SavePayload,
311
+ before: Record<string, unknown>,
312
+ after: Record<string, unknown>,
313
+ ): void {
314
+ const moment = payload.history[payload.historyIndex];
315
+ if (!moment) return;
316
+ const has = (vars: Record<string, unknown>, key: string) =>
317
+ Object.prototype.hasOwnProperty.call(vars, key);
318
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
319
+ if (!has(after, key)) {
320
+ delete moment.variables[key];
321
+ } else if (!has(before, key) || after[key] !== before[key]) {
322
+ moment.variables[key] = deepClone(after[key]);
323
+ }
324
+ }
325
+ }
326
+
300
327
  /** Restore or reset PRNG from a history moment's snapshot. */
301
328
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
302
329
  if (moment?.prng) {
@@ -384,6 +411,12 @@ export interface StoryState {
384
411
  clearAllData: () => void;
385
412
  deletePlaythrough: (playthroughId: string) => void;
386
413
  getSavePayload: () => SavePayload;
414
+ /**
415
+ * Start capturing a save, before the `beforesave` hooks run. The returned
416
+ * function builds the payload after them, and keeps the variables the
417
+ * hooks changed in the saved moment's snapshot, so a load restores them.
418
+ */
419
+ beginSave: () => () => SavePayload;
387
420
  /**
388
421
  * Replace the game state with a live (deserialized) payload. `slot` is
389
422
  * passed to the `beforeload`/`afterload` events.
@@ -799,29 +832,18 @@ export const useStoryStore = create<StoryState>()(
799
832
  if (!storyData) return Promise.resolve();
800
833
 
801
834
  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) => {
835
+ saveWithHooks(slot, custom, get().beginSave, async (payload) => {
836
+ set((state) => {
837
+ state.saveError = null;
838
+ });
839
+ await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
840
+ set((state) => {
841
+ state.knownSaves = {
842
+ ...state.knownSaves,
843
+ [slot ?? '']: true,
844
+ };
845
+ });
846
+ }).catch((err) => {
825
847
  console.error('spindle: failed to save', err);
826
848
  set((state) => {
827
849
  state.saveError =
@@ -997,6 +1019,15 @@ export const useStoryStore = create<StoryState>()(
997
1019
  };
998
1020
  },
999
1021
 
1022
+ beginSave: () => {
1023
+ const before = get().variables;
1024
+ return () => {
1025
+ const payload = get().getSavePayload();
1026
+ keepHookWrites(payload, before, get().variables);
1027
+ return payload;
1028
+ };
1029
+ },
1030
+
1000
1031
  loadFromPayload: (payload: SavePayload, slot?: string) => {
1001
1032
  if (payload.history.length === 0) {
1002
1033
  console.warn('loadFromPayload: rejecting payload with empty history');