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