@rohal12/spindle 0.17.0 → 0.18.1
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/package.json +2 -2
- package/src/components/Passage.tsx +69 -4
- package/src/components/StoryInterface.tsx +2 -2
- package/src/components/macros/MacroLink.tsx +2 -2
- package/src/components/macros/PassageDisplay.tsx +11 -1
- package/src/components/macros/Span.tsx +8 -0
- package/src/components/macros/Timed.tsx +6 -4
- package/src/expression.ts +10 -8
- package/src/index.tsx +7 -0
- package/src/macros/register-builtins.ts +1 -0
- package/src/markup/ast.ts +1 -0
- package/src/saves/save-manager.ts +43 -0
- package/src/saves/types.ts +9 -2
- package/src/store.ts +283 -52
- package/src/story-api.ts +26 -12
package/src/store.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import { immer } from 'zustand/middleware/immer';
|
|
3
|
+
import {
|
|
4
|
+
enablePatches,
|
|
5
|
+
produceWithPatches,
|
|
6
|
+
applyPatches,
|
|
7
|
+
type Patch,
|
|
8
|
+
} from 'immer';
|
|
3
9
|
import type { StoryData } from './parser';
|
|
4
|
-
import type { SavePayload } from './saves/types';
|
|
10
|
+
import type { SavePayload, SaveHistoryMoment } from './saves/types';
|
|
5
11
|
import { executeStoryInit } from './story-init';
|
|
6
12
|
import {
|
|
7
13
|
initSaveSystem,
|
|
@@ -9,8 +15,10 @@ import {
|
|
|
9
15
|
getCurrentPlaythroughId,
|
|
10
16
|
quickSave,
|
|
11
17
|
loadQuickSave,
|
|
18
|
+
saveSession,
|
|
19
|
+
clearSession,
|
|
12
20
|
} from './saves/save-manager';
|
|
13
|
-
import { deepClone, deserialize } from './class-registry';
|
|
21
|
+
import { deepClone, serialize, deserialize } from './class-registry';
|
|
14
22
|
import {
|
|
15
23
|
snapshotPRNG,
|
|
16
24
|
restorePRNG,
|
|
@@ -18,6 +26,151 @@ import {
|
|
|
18
26
|
type PRNGSnapshot,
|
|
19
27
|
} from './prng';
|
|
20
28
|
|
|
29
|
+
enablePatches();
|
|
30
|
+
|
|
31
|
+
const SPECIAL_PASSAGES = new Set([
|
|
32
|
+
'StoryInit',
|
|
33
|
+
'StoryInterface',
|
|
34
|
+
'StoryVariables',
|
|
35
|
+
'SaveTitle',
|
|
36
|
+
'PassageReady',
|
|
37
|
+
'PassageHeader',
|
|
38
|
+
'PassageFooter',
|
|
39
|
+
'PassageDone',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Patch-based variable history (module-level, outside Zustand)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
interface PatchEntry {
|
|
47
|
+
forward: Patch[];
|
|
48
|
+
inverse: Patch[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Full variable snapshot at history index 0. */
|
|
52
|
+
let variableBase: Record<string, unknown> = {};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Transitions between consecutive history moments.
|
|
56
|
+
* patchEntries[i] transforms the variables at moment i into those at moment i+1.
|
|
57
|
+
* Length is always history.length − 1.
|
|
58
|
+
*/
|
|
59
|
+
let patchEntries: PatchEntry[] = [];
|
|
60
|
+
|
|
61
|
+
/** Immer-produced reference to variables right after the last navigation. */
|
|
62
|
+
let lastNavigationVars: Record<string, unknown> = {};
|
|
63
|
+
|
|
64
|
+
/** Deep-clone patch values so they are independent of future mutations. */
|
|
65
|
+
function clonePatches(patches: Patch[]): Patch[] {
|
|
66
|
+
return patches.map((p) => ({
|
|
67
|
+
...p,
|
|
68
|
+
value: p.value !== undefined ? deepClone(p.value) : undefined,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Compute forward + inverse patches that transform `prev` into `curr`. */
|
|
73
|
+
function computeVarPatches(
|
|
74
|
+
prev: Record<string, unknown>,
|
|
75
|
+
curr: Record<string, unknown>,
|
|
76
|
+
): PatchEntry {
|
|
77
|
+
const [, forward, inverse] = produceWithPatches(prev, (draft) => {
|
|
78
|
+
const d = draft as Record<string, unknown>;
|
|
79
|
+
for (const key of Object.keys(d)) {
|
|
80
|
+
if (!(key in curr)) delete d[key];
|
|
81
|
+
}
|
|
82
|
+
for (const [key, val] of Object.entries(curr)) {
|
|
83
|
+
d[key] = val;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return { forward: clonePatches(forward), inverse: clonePatches(inverse) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reconstruct variables at a given history moment by replaying patches. */
|
|
90
|
+
function reconstructVarsAt(index: number): Record<string, unknown> {
|
|
91
|
+
let vars: Record<string, unknown> = variableBase;
|
|
92
|
+
for (let i = 0; i < index; i++) {
|
|
93
|
+
vars = applyPatches(vars, patchEntries[i]!.forward);
|
|
94
|
+
}
|
|
95
|
+
return vars;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Session persistence (sessionStorage — survives F5, cleared on tab close)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
let serializedHistory: unknown[] = [];
|
|
103
|
+
|
|
104
|
+
function persistSession(get: () => StoryState): void {
|
|
105
|
+
const {
|
|
106
|
+
storyData,
|
|
107
|
+
currentPassage,
|
|
108
|
+
variables,
|
|
109
|
+
history,
|
|
110
|
+
historyIndex,
|
|
111
|
+
visitCounts,
|
|
112
|
+
renderCounts,
|
|
113
|
+
} = get();
|
|
114
|
+
if (!storyData) return;
|
|
115
|
+
|
|
116
|
+
// Trim cache when history was truncated (goBack then navigate)
|
|
117
|
+
if (serializedHistory.length > history.length) {
|
|
118
|
+
serializedHistory.length = history.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Append new entries
|
|
122
|
+
if (serializedHistory.length < history.length) {
|
|
123
|
+
const gap = history.length - serializedHistory.length;
|
|
124
|
+
if (gap === 1) {
|
|
125
|
+
// Common path: one new moment at the end — use current variables directly
|
|
126
|
+
const i = history.length - 1;
|
|
127
|
+
serializedHistory[i] = {
|
|
128
|
+
passage: history[i]!.passage,
|
|
129
|
+
variables: serialize(variables),
|
|
130
|
+
timestamp: history[i]!.timestamp,
|
|
131
|
+
prng: history[i]!.prng,
|
|
132
|
+
};
|
|
133
|
+
} else {
|
|
134
|
+
// Bulk fill (after loadFromPayload) — reconstruct incrementally
|
|
135
|
+
let vars = variableBase;
|
|
136
|
+
for (let i = 0; i < history.length; i++) {
|
|
137
|
+
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
138
|
+
if (i >= serializedHistory.length) {
|
|
139
|
+
const v = i === history.length - 1 ? variables : vars;
|
|
140
|
+
serializedHistory[i] = {
|
|
141
|
+
passage: history[i]!.passage,
|
|
142
|
+
variables: serialize(v),
|
|
143
|
+
timestamp: history[i]!.timestamp,
|
|
144
|
+
prng: history[i]!.prng,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
saveSession(storyData.ifid, {
|
|
152
|
+
passage: currentPassage,
|
|
153
|
+
variables: serialize(variables),
|
|
154
|
+
history: serializedHistory,
|
|
155
|
+
historyIndex,
|
|
156
|
+
visitCounts,
|
|
157
|
+
renderCounts,
|
|
158
|
+
prng: snapshotPRNG(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reset all module-level state (called on init, restart, loadFromPayload). */
|
|
163
|
+
function resetModuleState(base: Record<string, unknown>): void {
|
|
164
|
+
variableBase = base;
|
|
165
|
+
patchEntries = [];
|
|
166
|
+
lastNavigationVars = base;
|
|
167
|
+
serializedHistory = [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Store
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
21
174
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
22
175
|
function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
23
176
|
if (moment?.prng) {
|
|
@@ -29,7 +182,6 @@ function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
|
29
182
|
|
|
30
183
|
export interface HistoryMoment {
|
|
31
184
|
passage: string;
|
|
32
|
-
variables: Record<string, unknown>;
|
|
33
185
|
timestamp: number;
|
|
34
186
|
prng?: PRNGSnapshot | null;
|
|
35
187
|
}
|
|
@@ -46,9 +198,11 @@ export interface StoryState {
|
|
|
46
198
|
renderCounts: Record<string, number>;
|
|
47
199
|
saveVersion: number;
|
|
48
200
|
playthroughId: string;
|
|
201
|
+
maxHistory: number;
|
|
49
202
|
saveError: string | null;
|
|
50
203
|
loadError: string | null;
|
|
51
204
|
|
|
205
|
+
setMaxHistory: (limit: number) => void;
|
|
52
206
|
init: (
|
|
53
207
|
storyData: StoryData,
|
|
54
208
|
variableDefaults?: Record<string, unknown>,
|
|
@@ -67,6 +221,7 @@ export interface StoryState {
|
|
|
67
221
|
hasSave: (slot?: string) => boolean;
|
|
68
222
|
getSavePayload: () => SavePayload;
|
|
69
223
|
loadFromPayload: (payload: SavePayload) => void;
|
|
224
|
+
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
70
225
|
}
|
|
71
226
|
|
|
72
227
|
export const useStoryStore = create<StoryState>()(
|
|
@@ -82,9 +237,16 @@ export const useStoryStore = create<StoryState>()(
|
|
|
82
237
|
renderCounts: {},
|
|
83
238
|
saveVersion: 0,
|
|
84
239
|
playthroughId: '',
|
|
240
|
+
maxHistory: 40,
|
|
85
241
|
saveError: null,
|
|
86
242
|
loadError: null,
|
|
87
243
|
|
|
244
|
+
setMaxHistory: (limit: number) => {
|
|
245
|
+
set((state) => {
|
|
246
|
+
state.maxHistory = Math.max(1, Math.round(limit));
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
|
|
88
250
|
init: (
|
|
89
251
|
storyData: StoryData,
|
|
90
252
|
variableDefaults: Record<string, unknown> = {},
|
|
@@ -97,6 +259,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
97
259
|
}
|
|
98
260
|
|
|
99
261
|
const initialVars = deepClone(variableDefaults);
|
|
262
|
+
resetModuleState(deepClone(initialVars));
|
|
100
263
|
|
|
101
264
|
set((state) => {
|
|
102
265
|
state.storyData = storyData as StoryData;
|
|
@@ -107,7 +270,6 @@ export const useStoryStore = create<StoryState>()(
|
|
|
107
270
|
state.history = [
|
|
108
271
|
{
|
|
109
272
|
passage: startPassage.name,
|
|
110
|
-
variables: deepClone(initialVars),
|
|
111
273
|
timestamp: Date.now(),
|
|
112
274
|
},
|
|
113
275
|
];
|
|
@@ -116,6 +278,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
116
278
|
state.renderCounts = { [startPassage.name]: 1 };
|
|
117
279
|
});
|
|
118
280
|
|
|
281
|
+
// Update lastNavigationVars to the Immer-produced reference
|
|
282
|
+
lastNavigationVars = get().variables;
|
|
283
|
+
|
|
119
284
|
// Init save system (fire-and-forget — DB will be ready before user opens dialog)
|
|
120
285
|
const ifid = storyData.ifid;
|
|
121
286
|
initSaveSystem()
|
|
@@ -138,57 +303,103 @@ export const useStoryStore = create<StoryState>()(
|
|
|
138
303
|
},
|
|
139
304
|
|
|
140
305
|
navigate: (passageName: string) => {
|
|
141
|
-
const { storyData } = get();
|
|
306
|
+
const { storyData, variables: currVars } = get();
|
|
142
307
|
if (!storyData) return;
|
|
143
308
|
|
|
309
|
+
if (SPECIAL_PASSAGES.has(passageName)) {
|
|
310
|
+
console.error(
|
|
311
|
+
`spindle: Cannot navigate to special passage "${passageName}".`,
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
144
316
|
if (!storyData.passages.has(passageName)) {
|
|
145
317
|
console.error(`spindle: Passage "${passageName}" not found.`);
|
|
146
318
|
return;
|
|
147
319
|
}
|
|
148
320
|
|
|
321
|
+
// Compute variable delta before Immer set()
|
|
322
|
+
const patchEntry = computeVarPatches(lastNavigationVars, currVars);
|
|
323
|
+
|
|
149
324
|
set((state) => {
|
|
150
325
|
state.temporary = {};
|
|
151
326
|
state.currentPassage = passageName;
|
|
152
327
|
|
|
153
328
|
// Truncate forward history if we navigated back then chose a new path
|
|
154
329
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
330
|
+
patchEntries.length = state.historyIndex;
|
|
155
331
|
|
|
332
|
+
// Push new transition and moment
|
|
333
|
+
patchEntries.push(patchEntry);
|
|
156
334
|
state.history.push({
|
|
157
335
|
passage: passageName,
|
|
158
|
-
variables: deepClone(state.variables),
|
|
159
336
|
timestamp: Date.now(),
|
|
160
337
|
prng: snapshotPRNG(),
|
|
161
338
|
});
|
|
339
|
+
|
|
340
|
+
// Trim oldest entries if over the limit
|
|
341
|
+
const overflow = state.history.length - state.maxHistory;
|
|
342
|
+
if (overflow > 0) {
|
|
343
|
+
// Advance base through trimmed transitions
|
|
344
|
+
for (let i = 0; i < overflow; i++) {
|
|
345
|
+
variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
|
|
346
|
+
}
|
|
347
|
+
state.history = state.history.slice(overflow);
|
|
348
|
+
patchEntries = patchEntries.slice(overflow);
|
|
349
|
+
serializedHistory = serializedHistory.slice(overflow);
|
|
350
|
+
}
|
|
351
|
+
|
|
162
352
|
state.historyIndex = state.history.length - 1;
|
|
163
353
|
state.visitCounts[passageName] =
|
|
164
354
|
(state.visitCounts[passageName] ?? 0) + 1;
|
|
165
355
|
state.renderCounts[passageName] =
|
|
166
356
|
(state.renderCounts[passageName] ?? 0) + 1;
|
|
167
357
|
});
|
|
358
|
+
|
|
359
|
+
lastNavigationVars = get().variables;
|
|
360
|
+
persistSession(get);
|
|
168
361
|
},
|
|
169
362
|
|
|
170
363
|
goBack: () => {
|
|
364
|
+
const { historyIndex, variables } = get();
|
|
365
|
+
if (historyIndex <= 0) return;
|
|
366
|
+
|
|
367
|
+
// Apply inverse transition: moment historyIndex → historyIndex−1
|
|
368
|
+
const restoredVars = deepClone(
|
|
369
|
+
applyPatches(variables, patchEntries[historyIndex - 1]!.inverse),
|
|
370
|
+
);
|
|
371
|
+
|
|
171
372
|
set((state) => {
|
|
172
|
-
if (state.historyIndex <= 0) return;
|
|
173
373
|
state.historyIndex--;
|
|
174
|
-
|
|
175
|
-
state.
|
|
176
|
-
state.variables = deepClone(moment.variables);
|
|
374
|
+
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
375
|
+
state.variables = restoredVars;
|
|
177
376
|
state.temporary = {};
|
|
178
377
|
});
|
|
378
|
+
|
|
379
|
+
lastNavigationVars = get().variables;
|
|
179
380
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
381
|
+
persistSession(get);
|
|
180
382
|
},
|
|
181
383
|
|
|
182
384
|
goForward: () => {
|
|
385
|
+
const { historyIndex, history: hist, variables } = get();
|
|
386
|
+
if (historyIndex >= hist.length - 1) return;
|
|
387
|
+
|
|
388
|
+
// Apply forward transition: moment historyIndex → historyIndex+1
|
|
389
|
+
const restoredVars = deepClone(
|
|
390
|
+
applyPatches(variables, patchEntries[historyIndex]!.forward),
|
|
391
|
+
);
|
|
392
|
+
|
|
183
393
|
set((state) => {
|
|
184
|
-
if (state.historyIndex >= state.history.length - 1) return;
|
|
185
394
|
state.historyIndex++;
|
|
186
|
-
|
|
187
|
-
state.
|
|
188
|
-
state.variables = deepClone(moment.variables);
|
|
395
|
+
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
396
|
+
state.variables = restoredVars;
|
|
189
397
|
state.temporary = {};
|
|
190
398
|
});
|
|
399
|
+
|
|
400
|
+
lastNavigationVars = get().variables;
|
|
191
401
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
402
|
+
persistSession(get);
|
|
192
403
|
},
|
|
193
404
|
|
|
194
405
|
setVariable: (name: string, value: unknown) => {
|
|
@@ -231,6 +442,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
231
442
|
|
|
232
443
|
resetPRNG();
|
|
233
444
|
const initialVars = deepClone(variableDefaults);
|
|
445
|
+
resetModuleState(deepClone(initialVars));
|
|
234
446
|
|
|
235
447
|
set((state) => {
|
|
236
448
|
state.currentPassage = startPassage.name;
|
|
@@ -239,7 +451,6 @@ export const useStoryStore = create<StoryState>()(
|
|
|
239
451
|
state.history = [
|
|
240
452
|
{
|
|
241
453
|
passage: startPassage.name,
|
|
242
|
-
variables: deepClone(initialVars),
|
|
243
454
|
timestamp: Date.now(),
|
|
244
455
|
},
|
|
245
456
|
];
|
|
@@ -248,7 +459,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
248
459
|
state.renderCounts = { [startPassage.name]: 1 };
|
|
249
460
|
});
|
|
250
461
|
|
|
462
|
+
lastNavigationVars = get().variables;
|
|
251
463
|
executeStoryInit();
|
|
464
|
+
clearSession(storyData.ifid);
|
|
252
465
|
|
|
253
466
|
// Start a new playthrough on restart
|
|
254
467
|
startNewPlaythrough(storyData.ifid)
|
|
@@ -263,27 +476,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
263
476
|
},
|
|
264
477
|
|
|
265
478
|
save: () => {
|
|
266
|
-
const {
|
|
267
|
-
storyData,
|
|
268
|
-
playthroughId,
|
|
269
|
-
currentPassage,
|
|
270
|
-
variables,
|
|
271
|
-
history,
|
|
272
|
-
historyIndex,
|
|
273
|
-
visitCounts,
|
|
274
|
-
renderCounts,
|
|
275
|
-
} = get();
|
|
479
|
+
const { storyData, playthroughId } = get();
|
|
276
480
|
if (!storyData) return;
|
|
277
481
|
|
|
278
|
-
const payload
|
|
279
|
-
passage: currentPassage,
|
|
280
|
-
variables: deepClone(variables),
|
|
281
|
-
history: deepClone(history),
|
|
282
|
-
historyIndex,
|
|
283
|
-
visitCounts: { ...visitCounts },
|
|
284
|
-
renderCounts: { ...renderCounts },
|
|
285
|
-
prng: snapshotPRNG(),
|
|
286
|
-
};
|
|
482
|
+
const payload = get().getSavePayload();
|
|
287
483
|
|
|
288
484
|
set((state) => {
|
|
289
485
|
state.saveError = null;
|
|
@@ -313,20 +509,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
313
509
|
loadQuickSave(storyData.ifid)
|
|
314
510
|
.then((payload) => {
|
|
315
511
|
if (!payload) return;
|
|
316
|
-
|
|
317
|
-
state.currentPassage = payload.passage;
|
|
318
|
-
state.variables = payload.variables;
|
|
319
|
-
state.history = payload.history;
|
|
320
|
-
state.historyIndex = payload.historyIndex;
|
|
321
|
-
state.visitCounts = payload.visitCounts ?? {};
|
|
322
|
-
state.renderCounts = payload.renderCounts ?? {};
|
|
323
|
-
state.temporary = {};
|
|
324
|
-
});
|
|
325
|
-
if (payload.prng) {
|
|
326
|
-
restorePRNG(payload.prng.seed, payload.prng.pull);
|
|
327
|
-
} else {
|
|
328
|
-
resetPRNG();
|
|
329
|
-
}
|
|
512
|
+
get().loadFromPayload(payload);
|
|
330
513
|
})
|
|
331
514
|
.catch((err) => {
|
|
332
515
|
console.error('spindle: failed to load quick save', err);
|
|
@@ -353,10 +536,26 @@ export const useStoryStore = create<StoryState>()(
|
|
|
353
536
|
visitCounts,
|
|
354
537
|
renderCounts,
|
|
355
538
|
} = get();
|
|
539
|
+
|
|
540
|
+
// Reconstruct full variable snapshots from base + patches
|
|
541
|
+
const saveHistory: SaveHistoryMoment[] = [];
|
|
542
|
+
let vars = variableBase;
|
|
543
|
+
for (let i = 0; i < history.length; i++) {
|
|
544
|
+
if (i > 0) {
|
|
545
|
+
vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
546
|
+
}
|
|
547
|
+
saveHistory.push({
|
|
548
|
+
passage: history[i]!.passage,
|
|
549
|
+
variables: deepClone(vars),
|
|
550
|
+
timestamp: history[i]!.timestamp,
|
|
551
|
+
prng: history[i]!.prng,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
356
555
|
return {
|
|
357
556
|
passage: currentPassage,
|
|
358
557
|
variables: deepClone(variables),
|
|
359
|
-
history:
|
|
558
|
+
history: saveHistory,
|
|
360
559
|
historyIndex,
|
|
361
560
|
visitCounts: { ...visitCounts },
|
|
362
561
|
renderCounts: { ...renderCounts },
|
|
@@ -365,23 +564,55 @@ export const useStoryStore = create<StoryState>()(
|
|
|
365
564
|
},
|
|
366
565
|
|
|
367
566
|
loadFromPayload: (payload: SavePayload) => {
|
|
567
|
+
// Convert full snapshots to patch entries
|
|
568
|
+
const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
|
|
569
|
+
string,
|
|
570
|
+
unknown
|
|
571
|
+
>;
|
|
572
|
+
const newPatchEntries: PatchEntry[] = [];
|
|
573
|
+
|
|
574
|
+
let prevVars: Record<string, unknown> = base;
|
|
575
|
+
for (let i = 1; i < payload.history.length; i++) {
|
|
576
|
+
const currVars = deserialize(payload.history[i]!.variables) as Record<
|
|
577
|
+
string,
|
|
578
|
+
unknown
|
|
579
|
+
>;
|
|
580
|
+
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
581
|
+
prevVars = currVars;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
variableBase = deepClone(base);
|
|
585
|
+
patchEntries = newPatchEntries;
|
|
586
|
+
serializedHistory = [];
|
|
587
|
+
|
|
368
588
|
set((state) => {
|
|
369
589
|
state.currentPassage = payload.passage;
|
|
370
|
-
state.variables = deserialize(payload.variables)
|
|
590
|
+
state.variables = deserialize(payload.variables) as Record<
|
|
591
|
+
string,
|
|
592
|
+
unknown
|
|
593
|
+
>;
|
|
371
594
|
state.history = payload.history.map((m) => ({
|
|
372
|
-
|
|
373
|
-
|
|
595
|
+
passage: m.passage,
|
|
596
|
+
timestamp: m.timestamp,
|
|
597
|
+
prng: m.prng,
|
|
374
598
|
}));
|
|
375
599
|
state.historyIndex = payload.historyIndex;
|
|
376
600
|
state.visitCounts = payload.visitCounts ?? {};
|
|
377
601
|
state.renderCounts = payload.renderCounts ?? {};
|
|
378
602
|
state.temporary = {};
|
|
379
603
|
});
|
|
604
|
+
|
|
605
|
+
lastNavigationVars = get().variables;
|
|
606
|
+
|
|
380
607
|
if (payload.prng) {
|
|
381
608
|
restorePRNG(payload.prng.seed, payload.prng.pull);
|
|
382
609
|
} else {
|
|
383
610
|
resetPRNG();
|
|
384
611
|
}
|
|
385
612
|
},
|
|
613
|
+
|
|
614
|
+
getHistoryVariables: (index: number): Record<string, unknown> => {
|
|
615
|
+
return deepClone(reconstructVarsAt(index));
|
|
616
|
+
},
|
|
386
617
|
})),
|
|
387
618
|
);
|
package/src/story-api.ts
CHANGED
|
@@ -41,12 +41,12 @@ export interface StoryAPI {
|
|
|
41
41
|
save(slot?: string): void;
|
|
42
42
|
load(slot?: string): void;
|
|
43
43
|
hasSave(slot?: string): boolean;
|
|
44
|
-
visited(name
|
|
45
|
-
hasVisited(name
|
|
44
|
+
visited(name?: string): number;
|
|
45
|
+
hasVisited(name?: string): boolean;
|
|
46
46
|
hasVisitedAny(...names: string[]): boolean;
|
|
47
47
|
hasVisitedAll(...names: string[]): boolean;
|
|
48
|
-
rendered(name
|
|
49
|
-
hasRendered(name
|
|
48
|
+
rendered(name?: string): number;
|
|
49
|
+
hasRendered(name?: string): boolean;
|
|
50
50
|
hasRenderedAny(...names: string[]): boolean;
|
|
51
51
|
hasRenderedAll(...names: string[]): boolean;
|
|
52
52
|
currentPassage(): Passage | undefined;
|
|
@@ -67,6 +67,9 @@ export interface StoryAPI {
|
|
|
67
67
|
waitForActions(): Promise<StoryAction[]>;
|
|
68
68
|
random(): number;
|
|
69
69
|
randomInt(min: number, max: number): number;
|
|
70
|
+
readonly config: {
|
|
71
|
+
maxHistory: number;
|
|
72
|
+
};
|
|
70
73
|
readonly prng: {
|
|
71
74
|
init(seed?: string, useEntropy?: boolean): void;
|
|
72
75
|
isEnabled(): boolean;
|
|
@@ -120,12 +123,13 @@ function createStoryAPI(): StoryAPI {
|
|
|
120
123
|
return useStoryStore.getState().hasSave(slot);
|
|
121
124
|
},
|
|
122
125
|
|
|
123
|
-
visited(name
|
|
124
|
-
|
|
126
|
+
visited(name?: string): number {
|
|
127
|
+
const state = useStoryStore.getState();
|
|
128
|
+
return state.visitCounts[name ?? state.currentPassage] ?? 0;
|
|
125
129
|
},
|
|
126
130
|
|
|
127
|
-
hasVisited(name
|
|
128
|
-
return
|
|
131
|
+
hasVisited(name?: string): boolean {
|
|
132
|
+
return this.visited(name) > 0;
|
|
129
133
|
},
|
|
130
134
|
|
|
131
135
|
hasVisitedAny(...names: string[]): boolean {
|
|
@@ -138,12 +142,13 @@ function createStoryAPI(): StoryAPI {
|
|
|
138
142
|
return names.every((n) => (visitCounts[n] ?? 0) > 0);
|
|
139
143
|
},
|
|
140
144
|
|
|
141
|
-
rendered(name
|
|
142
|
-
|
|
145
|
+
rendered(name?: string): number {
|
|
146
|
+
const state = useStoryStore.getState();
|
|
147
|
+
return state.renderCounts[name ?? state.currentPassage] ?? 0;
|
|
143
148
|
},
|
|
144
149
|
|
|
145
|
-
hasRendered(name
|
|
146
|
-
return
|
|
150
|
+
hasRendered(name?: string): boolean {
|
|
151
|
+
return this.rendered(name) > 0;
|
|
147
152
|
},
|
|
148
153
|
|
|
149
154
|
hasRenderedAny(...names: string[]): boolean {
|
|
@@ -266,6 +271,15 @@ function createStoryAPI(): StoryAPI {
|
|
|
266
271
|
return randomInt(min, max);
|
|
267
272
|
},
|
|
268
273
|
|
|
274
|
+
config: {
|
|
275
|
+
get maxHistory(): number {
|
|
276
|
+
return useStoryStore.getState().maxHistory;
|
|
277
|
+
},
|
|
278
|
+
set maxHistory(limit: number) {
|
|
279
|
+
useStoryStore.getState().setMaxHistory(limit);
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
|
|
269
283
|
prng: {
|
|
270
284
|
init(seed?: string, useEntropy?: boolean): void {
|
|
271
285
|
initPRNG(seed, useEntropy);
|