@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +38 -9
- package/package.json +1 -1
- package/src/components/macros/SaveManager.tsx +7 -12
- package/src/saves/save-manager.ts +10 -5
- package/src/store.ts +54 -23
package/dist/pkg/headless.js
CHANGED
|
@@ -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
|
|
2813
|
-
*
|
|
2814
|
-
*
|
|
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,
|
|
2824
|
+
function saveWithHooks(slot, custom, beginCapture, write) {
|
|
2821
2825
|
try {
|
|
2826
|
+
const capture = beginCapture();
|
|
2822
2827
|
emit("beforesave", slot, custom);
|
|
2823
|
-
return write(
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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');
|