@rohal12/spindle 0.51.3 → 0.52.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/dist/pkg/headless.js +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +30 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/store.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { create } from './preact-store';
|
|
2
2
|
import { immer } from 'zustand/middleware/immer';
|
|
3
|
+
import type { StateCreator } from 'zustand/vanilla';
|
|
3
4
|
import {
|
|
5
|
+
current,
|
|
6
|
+
enableMapSet,
|
|
4
7
|
enablePatches,
|
|
8
|
+
isDraft,
|
|
5
9
|
produceWithPatches,
|
|
6
10
|
applyPatches,
|
|
11
|
+
type Draft,
|
|
7
12
|
type Patch,
|
|
8
13
|
} from 'immer';
|
|
9
14
|
import type { StoryData } from './parser';
|
|
@@ -23,12 +28,12 @@ import {
|
|
|
23
28
|
reinitTriggerState,
|
|
24
29
|
} from './triggers';
|
|
25
30
|
import {
|
|
26
|
-
|
|
31
|
+
establishPlaythrough,
|
|
27
32
|
startNewPlaythrough,
|
|
28
|
-
getCurrentPlaythroughId,
|
|
29
33
|
quickSave,
|
|
30
34
|
saveWithHooks,
|
|
31
|
-
|
|
35
|
+
loadSlotSave,
|
|
36
|
+
adoptPlaythrough,
|
|
32
37
|
populateKnownSaves,
|
|
33
38
|
getSlotSaveInfo,
|
|
34
39
|
listSlotSaves,
|
|
@@ -48,8 +53,22 @@ import {
|
|
|
48
53
|
resetPRNG,
|
|
49
54
|
type PRNGSnapshot,
|
|
50
55
|
} from './prng';
|
|
56
|
+
import { errorMessage } from './utils/error-message';
|
|
57
|
+
import {
|
|
58
|
+
routeStoreUpdate,
|
|
59
|
+
runWithCommittedMutations,
|
|
60
|
+
} from './execute-mutation';
|
|
61
|
+
import {
|
|
62
|
+
checkVariableName,
|
|
63
|
+
createNamespace,
|
|
64
|
+
isNamespace,
|
|
65
|
+
type Namespace,
|
|
66
|
+
} from './utils/namespace';
|
|
51
67
|
|
|
52
68
|
enablePatches();
|
|
69
|
+
// Story state holds Map and Set values: Immer must be able to draft them
|
|
70
|
+
// when a write (a dot path, a macro binding) reaches one
|
|
71
|
+
enableMapSet();
|
|
53
72
|
|
|
54
73
|
const SPECIAL_PASSAGES = new Set([
|
|
55
74
|
'StoryInit',
|
|
@@ -74,7 +93,7 @@ interface PatchEntry {
|
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
/** Full variable snapshot at history index 0. */
|
|
77
|
-
let variableBase:
|
|
96
|
+
let variableBase: Namespace = createNamespace();
|
|
78
97
|
|
|
79
98
|
/**
|
|
80
99
|
* Transitions between consecutive history moments.
|
|
@@ -84,7 +103,7 @@ let variableBase: Record<string, unknown> = {};
|
|
|
84
103
|
let patchEntries: PatchEntry[] = [];
|
|
85
104
|
|
|
86
105
|
/** Immer-produced reference to variables right after the last navigation. */
|
|
87
|
-
let lastNavigationVars:
|
|
106
|
+
let lastNavigationVars: Namespace = createNamespace();
|
|
88
107
|
|
|
89
108
|
/** Deep-clone patch values so they are independent of future mutations. */
|
|
90
109
|
function clonePatches(patches: Patch[]): Patch[] {
|
|
@@ -102,7 +121,9 @@ function computeVarPatches(
|
|
|
102
121
|
const [, forward, inverse] = produceWithPatches(prev, (draft) => {
|
|
103
122
|
const d = draft as Record<string, unknown>;
|
|
104
123
|
for (const key of Object.keys(d)) {
|
|
105
|
-
|
|
124
|
+
// Own keys only: `curr` may be a plain object (a loaded snapshot),
|
|
125
|
+
// whose inherited `constructor` is no variable
|
|
126
|
+
if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
|
|
106
127
|
}
|
|
107
128
|
for (const [key, val] of Object.entries(curr)) {
|
|
108
129
|
d[key] = val;
|
|
@@ -197,14 +218,88 @@ function persistSession(get: () => StoryState): void {
|
|
|
197
218
|
});
|
|
198
219
|
}
|
|
199
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Trim history to `state.maxHistory` moments: keep the newest moments that
|
|
223
|
+
* include the current one. After a navigation (the current moment is the
|
|
224
|
+
* newest) that drops the oldest; when the player has gone back further than
|
|
225
|
+
* the limit allows, the moments after the newest kept one are dropped too.
|
|
226
|
+
* Call it inside a store update; the module-level variable history
|
|
227
|
+
* (base, patches, session cache) is trimmed alongside.
|
|
228
|
+
*/
|
|
229
|
+
function trimHistory(state: {
|
|
230
|
+
history: HistoryMoment[];
|
|
231
|
+
historyIndex: number;
|
|
232
|
+
maxHistory: number;
|
|
233
|
+
}): boolean {
|
|
234
|
+
const excess = state.history.length - state.maxHistory;
|
|
235
|
+
if (excess <= 0) return false;
|
|
236
|
+
const start = Math.min(state.historyIndex, excess);
|
|
237
|
+
const end = start + state.maxHistory;
|
|
238
|
+
// Advance base through trimmed transitions
|
|
239
|
+
for (let i = 0; i < start; i++) {
|
|
240
|
+
variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
|
|
241
|
+
}
|
|
242
|
+
state.history = state.history.slice(start, end);
|
|
243
|
+
patchEntries = patchEntries.slice(start, end - 1);
|
|
244
|
+
serializedHistory = serializedHistory.slice(start, end);
|
|
245
|
+
state.historyIndex -= start;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
200
249
|
/** True while navigate() lets watchers react to the moment it entered. */
|
|
201
250
|
let navigationTriggerPhase = false;
|
|
202
251
|
|
|
203
252
|
/** Navigations requested during the trigger phase, run once it is over. */
|
|
204
253
|
let deferredNavigations: string[] = [];
|
|
205
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The moment navigate() entered, while its watchers may still change it:
|
|
257
|
+
* the navigation it belongs to and the variables it was recorded with.
|
|
258
|
+
*/
|
|
259
|
+
let enteredMoment: {
|
|
260
|
+
navigationId: number;
|
|
261
|
+
variables: Record<string, unknown>;
|
|
262
|
+
} | null = null;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Record the entered moment as it is now (watcher run actions and the PRNG
|
|
266
|
+
* rolls they made belong to it), unless the story has left it already.
|
|
267
|
+
* navigate() calls this after its watchers, and back/forward before they
|
|
268
|
+
* leave the moment: a watcher that moves through history must not have the
|
|
269
|
+
* moment it leaves recorded with the state of the one it arrives at.
|
|
270
|
+
*/
|
|
271
|
+
function finishEnteredMoment(
|
|
272
|
+
get: () => StoryState,
|
|
273
|
+
set: (recipe: (state: StoryState) => void) => void,
|
|
274
|
+
): void {
|
|
275
|
+
const moment = enteredMoment;
|
|
276
|
+
enteredMoment = null;
|
|
277
|
+
if (!moment || get().navigationId !== moment.navigationId) return;
|
|
278
|
+
if (get().variables !== moment.variables) {
|
|
279
|
+
rerecordNewestMoment(get().variables);
|
|
280
|
+
}
|
|
281
|
+
// The next navigate() diffs from this recorded snapshot. A watcher that
|
|
282
|
+
// left the moment has set it to the snapshot of the one it went to.
|
|
283
|
+
lastNavigationVars = get().variables;
|
|
284
|
+
const prng = snapshotPRNG();
|
|
285
|
+
const recorded = get().history[get().historyIndex]!.prng;
|
|
286
|
+
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
287
|
+
set((state) => {
|
|
288
|
+
state.history[state.historyIndex]!.prng = prng;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A deep copy of a variable namespace as save data: a plain object, as a
|
|
295
|
+
* loaded save holds it (the store turns it back into a namespace on load).
|
|
296
|
+
*/
|
|
297
|
+
const plainCopy = (ns: Namespace): Record<string, unknown> => ({
|
|
298
|
+
...deepClone(ns),
|
|
299
|
+
});
|
|
300
|
+
|
|
206
301
|
/** Reset all module-level state (called on init, restart, loadFromPayload). */
|
|
207
|
-
function resetModuleState(base:
|
|
302
|
+
function resetModuleState(base: Namespace): void {
|
|
208
303
|
variableBase = base;
|
|
209
304
|
patchEntries = [];
|
|
210
305
|
lastNavigationVars = base;
|
|
@@ -234,6 +329,144 @@ export function recordStoryInitState(): void {
|
|
|
234
329
|
});
|
|
235
330
|
}
|
|
236
331
|
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Playthrough setup
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Settles once the latest playthrough switch (init's lookup or creation, a
|
|
338
|
+
* restart's creation, the replacement of a deleted current playthrough, a
|
|
339
|
+
* load making the loaded save's playthrough current) is stored, with the
|
|
340
|
+
* playthrough ID it leaves the game in ('' if init could not establish one).
|
|
341
|
+
* Switches are storage operations, which run in call order, so playthroughs
|
|
342
|
+
* are created and numbered in the order the game started them, and a save
|
|
343
|
+
* issued after a switch is stored after it, in the playthrough it switched
|
|
344
|
+
* to.
|
|
345
|
+
*/
|
|
346
|
+
let playthroughSetup: Promise<string> = Promise.resolve('');
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Bumped by every playthrough switch; a switch that settles after a later
|
|
350
|
+
* one was issued must not set its ID.
|
|
351
|
+
*/
|
|
352
|
+
let playthroughGeneration = 0;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Whether the latest switch is to a playthrough not known until a storage
|
|
356
|
+
* operation has run: the one init looks up, the playthrough of the save a
|
|
357
|
+
* load from a slot reads, or (while one of those is pending) the one a
|
|
358
|
+
* playthrough deletion leaves the game in. Meanwhile the store's
|
|
359
|
+
* `playthroughId` is the one before ('' at boot), and saves issued take the
|
|
360
|
+
* one the switch establishes.
|
|
361
|
+
*/
|
|
362
|
+
let playthroughPending = false;
|
|
363
|
+
|
|
364
|
+
/** The game's playthrough now, or '' while a pending switch decides it. */
|
|
365
|
+
function knownPlaythroughId(): string {
|
|
366
|
+
return playthroughPending ? '' : useStoryStore.getState().playthroughId;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The playthrough a save issued now belongs to, once its record is stored.
|
|
371
|
+
* Read synchronously at the call: restart() switches the store's
|
|
372
|
+
* `playthroughId` at once, so a save issued after it (even before the new
|
|
373
|
+
* playthrough is stored) belongs to the new playthrough, and a later restart
|
|
374
|
+
* doesn't move it. While a switch whose playthrough is not known yet is
|
|
375
|
+
* pending (init's lookup, a load from a slot), the save takes the one that
|
|
376
|
+
* switch establishes.
|
|
377
|
+
*/
|
|
378
|
+
export function resolvePlaythroughId(): Promise<string> {
|
|
379
|
+
const current = knownPlaythroughId();
|
|
380
|
+
return playthroughSetup.then((established) => current || established);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function setPlaythroughId(id: string): void {
|
|
384
|
+
if (useStoryStore.getState().playthroughId === id) return;
|
|
385
|
+
useStoryStore.setState((state) => {
|
|
386
|
+
state.playthroughId = id;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Switch to the playthrough `lookup` (a storage operation queued now)
|
|
392
|
+
* resolves to. Saves issued meanwhile belong to it; the store's
|
|
393
|
+
* `playthroughId` is set once it is known, unless a later switch was issued.
|
|
394
|
+
*/
|
|
395
|
+
function switchToLookedUpPlaythrough(lookup: Promise<string>): Promise<string> {
|
|
396
|
+
const generation = ++playthroughGeneration;
|
|
397
|
+
playthroughPending = true;
|
|
398
|
+
playthroughSetup = lookup.then((id) => {
|
|
399
|
+
if (generation === playthroughGeneration) {
|
|
400
|
+
playthroughPending = false;
|
|
401
|
+
setPlaythroughId(id);
|
|
402
|
+
}
|
|
403
|
+
return id;
|
|
404
|
+
});
|
|
405
|
+
return playthroughSetup;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Move the running game to the playthrough `id` at once: saves issued from
|
|
410
|
+
* here on belong to it. `stored` is the storage operation recording the
|
|
411
|
+
* switch, queued now, after those already issued (its failure is reported
|
|
412
|
+
* by the caller).
|
|
413
|
+
*/
|
|
414
|
+
function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
|
|
415
|
+
++playthroughGeneration;
|
|
416
|
+
playthroughPending = false;
|
|
417
|
+
playthroughSetup = stored.then(
|
|
418
|
+
() => id,
|
|
419
|
+
() => id,
|
|
420
|
+
);
|
|
421
|
+
setPlaythroughId(id);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Move the running game to a new playthrough at once (see restart). */
|
|
425
|
+
function switchToNewPlaythrough(ifid: string): void {
|
|
426
|
+
const id = crypto.randomUUID();
|
|
427
|
+
const stored = startNewPlaythrough(ifid, id).catch((err) => {
|
|
428
|
+
console.error('spindle: failed to start new playthrough', err);
|
|
429
|
+
});
|
|
430
|
+
switchToPlaythrough(id, stored);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Move the running game to the playthrough of a save it loads, at once. A
|
|
435
|
+
* no-op if the game is in it already.
|
|
436
|
+
*/
|
|
437
|
+
function switchToLoadedPlaythrough(ifid: string, id: string): void {
|
|
438
|
+
if (knownPlaythroughId() === id) return;
|
|
439
|
+
const stored = adoptPlaythrough(ifid, id).catch((err) => {
|
|
440
|
+
console.error('spindle: failed to switch to the loaded playthrough', err);
|
|
441
|
+
});
|
|
442
|
+
switchToPlaythrough(id, stored);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Superseded loads
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A load from a slot reads the save in the order of storage operations and
|
|
451
|
+
* applies it when the read completes. A restart, a boot or a direct load
|
|
452
|
+
* (loadFromPayload) issued after it replaces the game state at once; the
|
|
453
|
+
* slot load, completing later, must not undo that. Every replacement of the
|
|
454
|
+
* game state takes a number in call order, and a slot load applies only if
|
|
455
|
+
* no replacement issued after it has been applied. Loads from slots apply in
|
|
456
|
+
* call order anyway (their reads are queued), so they never supersede one
|
|
457
|
+
* another.
|
|
458
|
+
*/
|
|
459
|
+
let stateReplacementsIssued = 0;
|
|
460
|
+
let latestStateApplied = 0;
|
|
461
|
+
|
|
462
|
+
/** The number of the slot load that is calling loadFromPayload. */
|
|
463
|
+
let slotLoadApplying: number | null = null;
|
|
464
|
+
|
|
465
|
+
/** A replacement of the game state applied at its call. */
|
|
466
|
+
function replaceStateNow(): void {
|
|
467
|
+
latestStateApplied = ++stateReplacementsIssued;
|
|
468
|
+
}
|
|
469
|
+
|
|
237
470
|
// ---------------------------------------------------------------------------
|
|
238
471
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
239
472
|
// ---------------------------------------------------------------------------
|
|
@@ -364,6 +597,12 @@ export interface StoryState {
|
|
|
364
597
|
visitCounts: Record<string, number>;
|
|
365
598
|
renderCounts: Record<string, number>;
|
|
366
599
|
knownSaves: Record<string, true>;
|
|
600
|
+
/**
|
|
601
|
+
* The playthrough the running game is in: its saves are grouped under it.
|
|
602
|
+
* Set by boot (the stored current playthrough), restart (a new one),
|
|
603
|
+
* loading a save (the save's) and deleting the current playthrough (a new
|
|
604
|
+
* one). '' until boot has looked it up.
|
|
605
|
+
*/
|
|
367
606
|
playthroughId: string;
|
|
368
607
|
maxHistory: number;
|
|
369
608
|
quickSaveKey: string | null;
|
|
@@ -400,6 +639,12 @@ export interface StoryState {
|
|
|
400
639
|
trackRender: (passageName: string) => void;
|
|
401
640
|
restart: () => void;
|
|
402
641
|
save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
|
|
642
|
+
/**
|
|
643
|
+
* Load the save in a slot and move the game to its playthrough. The switch
|
|
644
|
+
* takes effect in call order (a save issued after the load belongs to the
|
|
645
|
+
* loaded playthrough); the state is applied when the save has been read,
|
|
646
|
+
* unless a restart or another direct load was issued after this load.
|
|
647
|
+
*/
|
|
403
648
|
load: (slot?: string) => Promise<void>;
|
|
404
649
|
hasSave: (slot?: string) => boolean;
|
|
405
650
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
@@ -407,9 +652,18 @@ export interface StoryState {
|
|
|
407
652
|
deleteSave: (slot?: string) => Promise<void>;
|
|
408
653
|
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
409
654
|
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Delete the story's saves and playthroughs and restart. The restart is
|
|
657
|
+
* immediate; the promise settles once the data is deleted.
|
|
658
|
+
*/
|
|
659
|
+
clearGameData: () => Promise<void>;
|
|
660
|
+
/** As clearGameData, for all Spindle data (every story). */
|
|
661
|
+
clearAllData: () => Promise<void>;
|
|
662
|
+
/**
|
|
663
|
+
* Delete a playthrough and its saves. Deleting the current one moves the
|
|
664
|
+
* running game to a new playthrough.
|
|
665
|
+
*/
|
|
666
|
+
deletePlaythrough: (playthroughId: string) => Promise<void>;
|
|
413
667
|
getSavePayload: () => SavePayload;
|
|
414
668
|
/**
|
|
415
669
|
* Start capturing a save, before the `beforesave` hooks run. The returned
|
|
@@ -419,9 +673,15 @@ export interface StoryState {
|
|
|
419
673
|
beginSave: () => () => SavePayload;
|
|
420
674
|
/**
|
|
421
675
|
* Replace the game state with a live (deserialized) payload. `slot` is
|
|
422
|
-
* passed to the `beforeload`/`afterload` events.
|
|
676
|
+
* passed to the `beforeload`/`afterload` events. Pass the save's
|
|
677
|
+
* `playthroughId` when loading a save: the game moves to that playthrough
|
|
678
|
+
* (no change if it is the current one). Restoring the session passes none.
|
|
423
679
|
*/
|
|
424
|
-
loadFromPayload: (
|
|
680
|
+
loadFromPayload: (
|
|
681
|
+
payload: SavePayload,
|
|
682
|
+
slot?: string,
|
|
683
|
+
playthroughId?: string,
|
|
684
|
+
) => void;
|
|
425
685
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
426
686
|
setTransition: (config: TransitionConfig | null) => void;
|
|
427
687
|
setNextTransition: (config: TransitionConfig | null) => void;
|
|
@@ -430,6 +690,94 @@ export interface StoryState {
|
|
|
430
690
|
clearDeferredRender: () => void;
|
|
431
691
|
}
|
|
432
692
|
|
|
693
|
+
const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
|
|
694
|
+
|
|
695
|
+
type StoryRecipe = (draft: Draft<StoryState>) => void;
|
|
696
|
+
|
|
697
|
+
/** Replace a namespace an update left with a prototype by one without. */
|
|
698
|
+
function keepNamespacesBare(draft: Draft<StoryState>): void {
|
|
699
|
+
for (const key of NAMESPACE_KEYS) {
|
|
700
|
+
const ns = draft[key];
|
|
701
|
+
if (!isNamespace(ns)) {
|
|
702
|
+
draft[key] = createNamespace(isDraft(ns) ? current(ns) : ns);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Actions that record, replace or save story state. Called while mutation
|
|
709
|
+
* code runs (by Story.goto, a {link} or {back} the code performs, a
|
|
710
|
+
* watcher), they act in program order: the code's writes so far are
|
|
711
|
+
* committed first, and the code goes on from the state they leave (see
|
|
712
|
+
* runWithCommittedMutations). Outside mutation code they just run.
|
|
713
|
+
*
|
|
714
|
+
* A load from a slot (`load`) takes its place among the save operations
|
|
715
|
+
* (and switches playthroughs) at the call, but applies the save when its
|
|
716
|
+
* read completes, after the code has run.
|
|
717
|
+
*/
|
|
718
|
+
const PROGRAM_ORDER_ACTIONS = [
|
|
719
|
+
'navigate',
|
|
720
|
+
'goBack',
|
|
721
|
+
'goForward',
|
|
722
|
+
'restart',
|
|
723
|
+
'save',
|
|
724
|
+
'load',
|
|
725
|
+
'getSavePayload',
|
|
726
|
+
'loadFromPayload',
|
|
727
|
+
] as const;
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Store middleware (inside `immer`) that every update goes through: the
|
|
731
|
+
* store's own actions and outside `setState` calls alike.
|
|
732
|
+
*
|
|
733
|
+
* - The variable namespaces stay records without a prototype, whatever an
|
|
734
|
+
* update assigns (see utils/namespace.ts).
|
|
735
|
+
* - An update made while mutation code runs follows that code's pending
|
|
736
|
+
* writes, in program order, and reaches its working copies (see
|
|
737
|
+
* routeStoreUpdate in execute-mutation.ts).
|
|
738
|
+
* - The PROGRAM_ORDER_ACTIONS commit running mutation code first.
|
|
739
|
+
*/
|
|
740
|
+
function storyStateGuard(
|
|
741
|
+
creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
|
|
742
|
+
): StateCreator<StoryState, [['zustand/immer', never]], []> {
|
|
743
|
+
return (set, get, api) => {
|
|
744
|
+
const guarded = ((
|
|
745
|
+
updater: Partial<StoryState> | StoryRecipe,
|
|
746
|
+
replace?: boolean,
|
|
747
|
+
) => {
|
|
748
|
+
if (replace) {
|
|
749
|
+
(set as (u: unknown, r: true) => void)(updater, true);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const recipe: StoryRecipe =
|
|
753
|
+
typeof updater === 'function'
|
|
754
|
+
? updater
|
|
755
|
+
: (draft) => {
|
|
756
|
+
Object.assign(draft, updater);
|
|
757
|
+
};
|
|
758
|
+
const routed = routeStoreUpdate(recipe);
|
|
759
|
+
set((draft) => {
|
|
760
|
+
(routed ?? recipe)(draft);
|
|
761
|
+
keepNamespacesBare(draft);
|
|
762
|
+
});
|
|
763
|
+
}) as typeof set;
|
|
764
|
+
api.setState = guarded;
|
|
765
|
+
const state = creator(guarded, get, api);
|
|
766
|
+
for (const name of PROGRAM_ORDER_ACTIONS) {
|
|
767
|
+
const action = state[name] as (...args: unknown[]) => unknown;
|
|
768
|
+
(state as unknown as Record<string, unknown>)[name] = (
|
|
769
|
+
...args: unknown[]
|
|
770
|
+
) => runWithCommittedMutations(() => action(...args));
|
|
771
|
+
}
|
|
772
|
+
return state;
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** The story store's middleware: Immer updates, guarded (see above). */
|
|
777
|
+
const storyStore = (
|
|
778
|
+
creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
|
|
779
|
+
) => immer(storyStateGuard(creator));
|
|
780
|
+
|
|
433
781
|
/**
|
|
434
782
|
* Return `p` marked as handled: a caller that ignores the result of a
|
|
435
783
|
* fire-and-forget operation (whose failure is already logged) gets no
|
|
@@ -442,15 +790,15 @@ function handled<T>(p: Promise<T>): Promise<T> {
|
|
|
442
790
|
}
|
|
443
791
|
|
|
444
792
|
export const useStoryStore = create<StoryState>()(
|
|
445
|
-
|
|
793
|
+
storyStore((set, get) => ({
|
|
446
794
|
storyData: null,
|
|
447
795
|
currentPassage: '',
|
|
448
796
|
navigationId: 0,
|
|
449
|
-
variables:
|
|
797
|
+
variables: createNamespace(),
|
|
450
798
|
variableDefaults: {},
|
|
451
|
-
transient:
|
|
799
|
+
transient: createNamespace(),
|
|
452
800
|
transientDefaults: {},
|
|
453
|
-
temporary:
|
|
801
|
+
temporary: createNamespace(),
|
|
454
802
|
history: [],
|
|
455
803
|
historyIndex: -1,
|
|
456
804
|
visitCounts: {},
|
|
@@ -468,9 +816,13 @@ export const useStoryStore = create<StoryState>()(
|
|
|
468
816
|
renderDeferred: false,
|
|
469
817
|
|
|
470
818
|
setMaxHistory: (limit: number) => {
|
|
819
|
+
let trimmed = false;
|
|
471
820
|
set((state) => {
|
|
472
821
|
state.maxHistory = Math.max(1, Math.round(limit));
|
|
822
|
+
// A lower limit takes effect at once
|
|
823
|
+
trimmed = trimHistory(state);
|
|
473
824
|
});
|
|
825
|
+
if (trimmed) persistSession(get);
|
|
474
826
|
},
|
|
475
827
|
|
|
476
828
|
setQuickSaveKey: (key: string | null) => {
|
|
@@ -497,18 +849,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
497
849
|
);
|
|
498
850
|
}
|
|
499
851
|
|
|
500
|
-
const initialVars = deepClone(variableDefaults);
|
|
852
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
501
853
|
resetModuleState(deepClone(initialVars));
|
|
502
854
|
|
|
503
855
|
set((state) => {
|
|
504
856
|
state.storyData = storyData as StoryData;
|
|
857
|
+
// Unknown until the save system has looked it up (below)
|
|
858
|
+
state.playthroughId = '';
|
|
505
859
|
state.currentPassage = startPassage.name;
|
|
506
860
|
state.navigationId++;
|
|
507
861
|
state.variables = initialVars;
|
|
508
862
|
state.variableDefaults = variableDefaults;
|
|
509
|
-
state.transient = deepClone(transientDefaults);
|
|
863
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
510
864
|
state.transientDefaults = transientDefaults;
|
|
511
|
-
state.temporary =
|
|
865
|
+
state.temporary = createNamespace();
|
|
512
866
|
state.history = [
|
|
513
867
|
{
|
|
514
868
|
passage: startPassage.name,
|
|
@@ -523,33 +877,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
523
877
|
// Update lastNavigationVars to the Immer-produced reference
|
|
524
878
|
lastNavigationVars = get().variables;
|
|
525
879
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
880
|
+
replaceStateNow();
|
|
881
|
+
|
|
882
|
+
// Look up the story's playthrough and saves in the background, as a
|
|
883
|
+
// storage operation queued now: saves issued meanwhile are stored
|
|
884
|
+
// after it, tagged with the playthrough it establishes (see
|
|
885
|
+
// resolvePlaythroughId). The current playthrough is stored, so a page
|
|
886
|
+
// refresh stays in the playthrough the game was in, also after a load
|
|
887
|
+
// switched to the loaded save's.
|
|
888
|
+
switchToLookedUpPlaythrough(
|
|
889
|
+
establishPlaythrough(storyData.ifid)
|
|
890
|
+
.then(({ id, knownSaves }) => {
|
|
891
|
+
// So hasSave() works after a reload. Operations issued later
|
|
892
|
+
// update the cache after this.
|
|
537
893
|
set((state) => {
|
|
538
|
-
state.
|
|
894
|
+
state.knownSaves = knownSaves;
|
|
539
895
|
});
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
})
|
|
550
|
-
.catch((err) =>
|
|
551
|
-
console.error('spindle: failed to init save system', err),
|
|
552
|
-
);
|
|
896
|
+
return id;
|
|
897
|
+
})
|
|
898
|
+
.catch((err) => {
|
|
899
|
+
console.error('spindle: failed to init save system', err);
|
|
900
|
+
return '';
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
553
903
|
},
|
|
554
904
|
|
|
555
905
|
navigate: (passageName: string) => {
|
|
@@ -583,7 +933,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
583
933
|
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
584
934
|
|
|
585
935
|
set((state) => {
|
|
586
|
-
state.temporary =
|
|
936
|
+
state.temporary = createNamespace();
|
|
587
937
|
state.currentPassage = passageName;
|
|
588
938
|
state.navigationId++;
|
|
589
939
|
|
|
@@ -602,19 +952,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
602
952
|
prng: snapshotPRNG(),
|
|
603
953
|
});
|
|
604
954
|
|
|
605
|
-
// Trim oldest entries if over the limit
|
|
606
|
-
const overflow = state.history.length - state.maxHistory;
|
|
607
|
-
if (overflow > 0) {
|
|
608
|
-
// Advance base through trimmed transitions
|
|
609
|
-
for (let i = 0; i < overflow; i++) {
|
|
610
|
-
variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
|
|
611
|
-
}
|
|
612
|
-
state.history = state.history.slice(overflow);
|
|
613
|
-
patchEntries = patchEntries.slice(overflow);
|
|
614
|
-
serializedHistory = serializedHistory.slice(overflow);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
955
|
state.historyIndex = state.history.length - 1;
|
|
956
|
+
// Trim oldest entries if over the limit
|
|
957
|
+
trimHistory(state);
|
|
618
958
|
state.visitCounts[passageName] =
|
|
619
959
|
(state.visitCounts[passageName] ?? 0) + 1;
|
|
620
960
|
state.renderCounts[passageName] =
|
|
@@ -624,7 +964,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
624
964
|
// Watchers react to the completed transition (visit counts, cleared
|
|
625
965
|
// temporaries). Like beforenavigate changes, their run actions belong
|
|
626
966
|
// to the entered moment; navigations they request run afterwards.
|
|
627
|
-
|
|
967
|
+
enteredMoment = {
|
|
968
|
+
navigationId: get().navigationId,
|
|
969
|
+
variables: get().variables,
|
|
970
|
+
};
|
|
628
971
|
navigationTriggerPhase = true;
|
|
629
972
|
try {
|
|
630
973
|
checkTriggersOnNavigation();
|
|
@@ -633,18 +976,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
633
976
|
}
|
|
634
977
|
const deferred = deferredNavigations;
|
|
635
978
|
deferredNavigations = [];
|
|
636
|
-
|
|
637
|
-
rerecordNewestMoment(get().variables);
|
|
638
|
-
}
|
|
639
|
-
const prng = snapshotPRNG();
|
|
640
|
-
const recorded = get().history[get().historyIndex]!.prng;
|
|
641
|
-
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
642
|
-
set((state) => {
|
|
643
|
-
state.history[state.historyIndex]!.prng = prng;
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
lastNavigationVars = get().variables;
|
|
979
|
+
finishEnteredMoment(get, set);
|
|
648
980
|
persistSession(get);
|
|
649
981
|
|
|
650
982
|
emit('afternavigate', passageName, previousPassage);
|
|
@@ -655,6 +987,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
655
987
|
goBack: () => {
|
|
656
988
|
const { historyIndex } = get();
|
|
657
989
|
if (historyIndex <= 0) return;
|
|
990
|
+
finishEnteredMoment(get, set);
|
|
658
991
|
|
|
659
992
|
const previousPassage = get().currentPassage;
|
|
660
993
|
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
@@ -669,7 +1002,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
669
1002
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
670
1003
|
state.navigationId++;
|
|
671
1004
|
state.variables = restoredVars;
|
|
672
|
-
state.temporary =
|
|
1005
|
+
state.temporary = createNamespace();
|
|
673
1006
|
});
|
|
674
1007
|
|
|
675
1008
|
// Restored state is not a change watchers react to
|
|
@@ -684,6 +1017,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
684
1017
|
goForward: () => {
|
|
685
1018
|
const { historyIndex, history: hist } = get();
|
|
686
1019
|
if (historyIndex >= hist.length - 1) return;
|
|
1020
|
+
finishEnteredMoment(get, set);
|
|
687
1021
|
|
|
688
1022
|
const previousPassage = get().currentPassage;
|
|
689
1023
|
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
@@ -698,7 +1032,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
698
1032
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
699
1033
|
state.navigationId++;
|
|
700
1034
|
state.variables = restoredVars;
|
|
701
|
-
state.temporary =
|
|
1035
|
+
state.temporary = createNamespace();
|
|
702
1036
|
});
|
|
703
1037
|
|
|
704
1038
|
// Restored state is not a change watchers react to
|
|
@@ -712,36 +1046,42 @@ export const useStoryStore = create<StoryState>()(
|
|
|
712
1046
|
|
|
713
1047
|
setVariable: (name: string, value: unknown) => {
|
|
714
1048
|
set((state) => {
|
|
1049
|
+
checkVariableName(name, `$${name}`);
|
|
715
1050
|
state.variables[name] = value;
|
|
716
1051
|
});
|
|
717
1052
|
},
|
|
718
1053
|
|
|
719
1054
|
setTemporary: (name: string, value: unknown) => {
|
|
720
1055
|
set((state) => {
|
|
1056
|
+
checkVariableName(name, `_${name}`);
|
|
721
1057
|
state.temporary[name] = value;
|
|
722
1058
|
});
|
|
723
1059
|
},
|
|
724
1060
|
|
|
725
1061
|
deleteVariable: (name: string) => {
|
|
726
1062
|
set((state) => {
|
|
1063
|
+
checkVariableName(name, `$${name}`);
|
|
727
1064
|
delete state.variables[name];
|
|
728
1065
|
});
|
|
729
1066
|
},
|
|
730
1067
|
|
|
731
1068
|
deleteTemporary: (name: string) => {
|
|
732
1069
|
set((state) => {
|
|
1070
|
+
checkVariableName(name, `_${name}`);
|
|
733
1071
|
delete state.temporary[name];
|
|
734
1072
|
});
|
|
735
1073
|
},
|
|
736
1074
|
|
|
737
1075
|
setTransient: (name: string, value: unknown) => {
|
|
738
1076
|
set((state) => {
|
|
1077
|
+
checkVariableName(name, `%${name}`);
|
|
739
1078
|
state.transient[name] = value;
|
|
740
1079
|
});
|
|
741
1080
|
},
|
|
742
1081
|
|
|
743
1082
|
deleteTransient: (name: string) => {
|
|
744
1083
|
set((state) => {
|
|
1084
|
+
checkVariableName(name, `%${name}`);
|
|
745
1085
|
delete state.transient[name];
|
|
746
1086
|
});
|
|
747
1087
|
},
|
|
@@ -774,6 +1114,13 @@ export const useStoryStore = create<StoryState>()(
|
|
|
774
1114
|
|
|
775
1115
|
emit('beforerestart');
|
|
776
1116
|
|
|
1117
|
+
// Switch to the new playthrough now, after beforerestart (whose saves
|
|
1118
|
+
// belong to the game being left) and before StoryInit, so every save
|
|
1119
|
+
// issued from here on belongs to the new game.
|
|
1120
|
+
switchToNewPlaythrough(storyData.ifid);
|
|
1121
|
+
// A load from a slot issued before the restart must not apply
|
|
1122
|
+
replaceStateNow();
|
|
1123
|
+
|
|
777
1124
|
const keepDeferred = get().renderDeferred;
|
|
778
1125
|
|
|
779
1126
|
// Clean up all runtime-phase handlers (after beforerestart has fired)
|
|
@@ -781,15 +1128,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
781
1128
|
|
|
782
1129
|
resetPRNG();
|
|
783
1130
|
resetTriggers();
|
|
784
|
-
const initialVars = deepClone(variableDefaults);
|
|
1131
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
785
1132
|
resetModuleState(deepClone(initialVars));
|
|
786
1133
|
|
|
787
1134
|
set((state) => {
|
|
788
1135
|
state.currentPassage = startPassage.name;
|
|
789
1136
|
state.navigationId++;
|
|
790
1137
|
state.variables = initialVars;
|
|
791
|
-
state.transient = deepClone(transientDefaults);
|
|
792
|
-
state.temporary =
|
|
1138
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
1139
|
+
state.temporary = createNamespace();
|
|
793
1140
|
state.history = [
|
|
794
1141
|
{
|
|
795
1142
|
passage: startPassage.name,
|
|
@@ -814,29 +1161,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
814
1161
|
emit('storyinit');
|
|
815
1162
|
// The storyinit handlers' changes belong to the start moment too
|
|
816
1163
|
recordStoryInitState();
|
|
817
|
-
|
|
818
|
-
// Start a new playthrough on restart
|
|
819
|
-
startNewPlaythrough(storyData.ifid)
|
|
820
|
-
.then((newId) => {
|
|
821
|
-
set((state) => {
|
|
822
|
-
state.playthroughId = newId;
|
|
823
|
-
});
|
|
824
|
-
})
|
|
825
|
-
.catch((err) =>
|
|
826
|
-
console.error('spindle: failed to start new playthrough', err),
|
|
827
|
-
);
|
|
828
1164
|
},
|
|
829
1165
|
|
|
830
1166
|
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
831
|
-
const { storyData
|
|
1167
|
+
const { storyData } = get();
|
|
832
1168
|
if (!storyData) return Promise.resolve();
|
|
1169
|
+
// The playthrough current now, not when the write runs
|
|
1170
|
+
const playthrough = resolvePlaythroughId();
|
|
833
1171
|
|
|
834
1172
|
return handled(
|
|
835
1173
|
saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
836
1174
|
set((state) => {
|
|
837
1175
|
state.saveError = null;
|
|
838
1176
|
});
|
|
839
|
-
|
|
1177
|
+
// Queued now, in call order with other storage operations
|
|
1178
|
+
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
840
1179
|
set((state) => {
|
|
841
1180
|
state.knownSaves = {
|
|
842
1181
|
...state.knownSaves,
|
|
@@ -846,8 +1185,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
846
1185
|
}).catch((err) => {
|
|
847
1186
|
console.error('spindle: failed to save', err);
|
|
848
1187
|
set((state) => {
|
|
849
|
-
state.saveError =
|
|
850
|
-
err instanceof Error ? err.message : 'Failed to save';
|
|
1188
|
+
state.saveError = errorMessage(err, 'Failed to save');
|
|
851
1189
|
});
|
|
852
1190
|
throw err;
|
|
853
1191
|
}),
|
|
@@ -861,17 +1199,35 @@ export const useStoryStore = create<StoryState>()(
|
|
|
861
1199
|
set((state) => {
|
|
862
1200
|
state.loadError = null;
|
|
863
1201
|
});
|
|
1202
|
+
// The game moves to the loaded save's playthrough in call order: the
|
|
1203
|
+
// read, queued now, makes it the stored current playthrough, and saves
|
|
1204
|
+
// issued after the load belong to it (a later restart or load moves
|
|
1205
|
+
// the game on, as usual). An empty slot leaves the playthrough as it
|
|
1206
|
+
// is.
|
|
1207
|
+
const previous = resolvePlaythroughId();
|
|
1208
|
+
const read = loadSlotSave(storyData.ifid, slot);
|
|
1209
|
+
const switched = switchToLookedUpPlaythrough(
|
|
1210
|
+
Promise.all([previous, read.catch(() => undefined)]).then(
|
|
1211
|
+
([prev, loaded]) => loaded?.playthroughId || prev,
|
|
1212
|
+
),
|
|
1213
|
+
);
|
|
1214
|
+
const replacement = ++stateReplacementsIssued;
|
|
864
1215
|
return handled(
|
|
865
|
-
|
|
866
|
-
.then((
|
|
867
|
-
|
|
868
|
-
|
|
1216
|
+
read
|
|
1217
|
+
.then(async (loaded) => {
|
|
1218
|
+
// The store names the loaded playthrough before the loaded
|
|
1219
|
+
// state is applied (and `afterload` fires)
|
|
1220
|
+
await switched;
|
|
1221
|
+
if (!loaded) return;
|
|
1222
|
+
// A restart, boot or direct load issued after this one won
|
|
1223
|
+
if (latestStateApplied > replacement) return;
|
|
1224
|
+
slotLoadApplying = replacement;
|
|
1225
|
+
get().loadFromPayload(loaded.payload, slot);
|
|
869
1226
|
})
|
|
870
1227
|
.catch((err) => {
|
|
871
1228
|
console.error('spindle: failed to load save', err);
|
|
872
1229
|
set((state) => {
|
|
873
|
-
state.loadError =
|
|
874
|
-
err instanceof Error ? err.message : 'Failed to load';
|
|
1230
|
+
state.loadError = errorMessage(err, 'Failed to load');
|
|
875
1231
|
});
|
|
876
1232
|
throw err;
|
|
877
1233
|
}),
|
|
@@ -937,50 +1293,83 @@ export const useStoryStore = create<StoryState>()(
|
|
|
937
1293
|
|
|
938
1294
|
clearGameData: () => {
|
|
939
1295
|
const { storyData } = get();
|
|
940
|
-
if (!storyData) return;
|
|
1296
|
+
if (!storyData) return Promise.resolve();
|
|
941
1297
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
.catch((err) => {
|
|
950
|
-
console.error('spindle: failed to clear game data', err);
|
|
1298
|
+
// Queue the clearing, then restart now: the new playthrough is stored
|
|
1299
|
+
// after it, and operations issued from here on belong to the new game.
|
|
1300
|
+
// The slot cache empties once the clearing is done, after operations
|
|
1301
|
+
// issued before it have updated it.
|
|
1302
|
+
const cleared = smClearGameData(storyData.ifid).then(() => {
|
|
1303
|
+
set((state) => {
|
|
1304
|
+
state.knownSaves = {};
|
|
951
1305
|
});
|
|
1306
|
+
});
|
|
1307
|
+
get().restart();
|
|
1308
|
+
return handled(
|
|
1309
|
+
cleared.catch((err) => {
|
|
1310
|
+
console.error('spindle: failed to clear game data', err);
|
|
1311
|
+
throw err;
|
|
1312
|
+
}),
|
|
1313
|
+
);
|
|
952
1314
|
},
|
|
953
1315
|
|
|
954
1316
|
clearAllData: () => {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
.then(() => {
|
|
960
|
-
set((state) => {
|
|
961
|
-
state.knownSaves = {};
|
|
962
|
-
});
|
|
963
|
-
get().restart();
|
|
964
|
-
})
|
|
965
|
-
.catch((err) => {
|
|
966
|
-
console.error('spindle: failed to clear all data', err);
|
|
1317
|
+
// As clearGameData: queue the clearing, then restart now
|
|
1318
|
+
const cleared = smClearAllData().then(() => {
|
|
1319
|
+
set((state) => {
|
|
1320
|
+
state.knownSaves = {};
|
|
967
1321
|
});
|
|
1322
|
+
});
|
|
1323
|
+
get().restart();
|
|
1324
|
+
return handled(
|
|
1325
|
+
cleared.catch((err) => {
|
|
1326
|
+
console.error('spindle: failed to clear all data', err);
|
|
1327
|
+
throw err;
|
|
1328
|
+
}),
|
|
1329
|
+
);
|
|
968
1330
|
},
|
|
969
1331
|
|
|
970
1332
|
deletePlaythrough: (playthroughId: string) => {
|
|
971
1333
|
const { storyData } = get();
|
|
972
|
-
if (!storyData) return;
|
|
1334
|
+
if (!storyData) return Promise.resolve();
|
|
973
1335
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1336
|
+
// The running game can't go on in a deleted playthrough: its later
|
|
1337
|
+
// saves would belong to no playthrough. It moves to a new one, as on
|
|
1338
|
+
// restart but keeping its state. While the game's playthrough is not
|
|
1339
|
+
// known yet (init is looking it up, or a load from a slot is reading
|
|
1340
|
+
// the save that decides it), the deletion checks the one established.
|
|
1341
|
+
const ifid = storyData.ifid;
|
|
1342
|
+
const current = knownPlaythroughId();
|
|
1343
|
+
const established = playthroughSetup;
|
|
1344
|
+
const replacementId = crypto.randomUUID();
|
|
1345
|
+
const deletion = smDeletePlaythroughData(ifid, playthroughId, {
|
|
1346
|
+
current: current || established,
|
|
1347
|
+
id: replacementId,
|
|
1348
|
+
});
|
|
1349
|
+
if (playthroughId !== '' && playthroughId === current) {
|
|
1350
|
+
switchToPlaythrough(replacementId, deletion);
|
|
1351
|
+
} else if (current === '') {
|
|
1352
|
+
switchToLookedUpPlaythrough(
|
|
1353
|
+
deletion.then(
|
|
1354
|
+
(replaced) => (replaced ? replacementId : established),
|
|
1355
|
+
() => established,
|
|
1356
|
+
),
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return handled(
|
|
1361
|
+
deletion
|
|
1362
|
+
.then(async () => {
|
|
1363
|
+
const known = await populateKnownSaves(storyData.ifid);
|
|
1364
|
+
set((state) => {
|
|
1365
|
+
state.knownSaves = known;
|
|
1366
|
+
});
|
|
1367
|
+
})
|
|
1368
|
+
.catch((err) => {
|
|
1369
|
+
console.error('spindle: failed to delete playthrough', err);
|
|
1370
|
+
throw err;
|
|
1371
|
+
}),
|
|
1372
|
+
);
|
|
984
1373
|
},
|
|
985
1374
|
|
|
986
1375
|
getSavePayload: (): SavePayload => {
|
|
@@ -1002,7 +1391,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1002
1391
|
}
|
|
1003
1392
|
saveHistory.push({
|
|
1004
1393
|
passage: history[i]!.passage,
|
|
1005
|
-
variables:
|
|
1394
|
+
variables: plainCopy(vars),
|
|
1006
1395
|
timestamp: history[i]!.timestamp,
|
|
1007
1396
|
prng: history[i]!.prng,
|
|
1008
1397
|
});
|
|
@@ -1010,7 +1399,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1010
1399
|
|
|
1011
1400
|
return {
|
|
1012
1401
|
passage: currentPassage,
|
|
1013
|
-
variables:
|
|
1402
|
+
variables: plainCopy(variables),
|
|
1014
1403
|
history: saveHistory,
|
|
1015
1404
|
historyIndex,
|
|
1016
1405
|
visitCounts: { ...visitCounts },
|
|
@@ -1028,14 +1417,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1028
1417
|
};
|
|
1029
1418
|
},
|
|
1030
1419
|
|
|
1031
|
-
loadFromPayload: (
|
|
1420
|
+
loadFromPayload: (
|
|
1421
|
+
payload: SavePayload,
|
|
1422
|
+
slot?: string,
|
|
1423
|
+
playthroughId?: string,
|
|
1424
|
+
) => {
|
|
1425
|
+
const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
|
|
1426
|
+
slotLoadApplying = null;
|
|
1032
1427
|
if (payload.history.length === 0) {
|
|
1033
1428
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1034
1429
|
return;
|
|
1035
1430
|
}
|
|
1431
|
+
latestStateApplied = replacement;
|
|
1036
1432
|
|
|
1037
1433
|
emit('beforeload', slot);
|
|
1038
1434
|
|
|
1435
|
+
// Loading a save moves the game to the save's playthrough, after the
|
|
1436
|
+
// `beforeload` handlers (whose saves belong to the game being left).
|
|
1437
|
+
// Restoring the session passes none: the game stays in its playthrough.
|
|
1438
|
+
const ifid = get().storyData?.ifid;
|
|
1439
|
+
if (playthroughId && ifid) {
|
|
1440
|
+
switchToLoadedPlaythrough(ifid, playthroughId);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1039
1443
|
// Restore the state on entering the saved passage, not the payload's
|
|
1040
1444
|
// live variables: the passage remounts and runs its {set}/{do} again,
|
|
1041
1445
|
// so restoring their results as well would apply them twice. Changes
|
|
@@ -1046,12 +1450,16 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1046
1450
|
// The payload is already live (deserialized at the storage boundary by
|
|
1047
1451
|
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
1048
1452
|
// Convert full snapshots to patch entries
|
|
1049
|
-
const base =
|
|
1453
|
+
const base = createNamespace(
|
|
1454
|
+
deepClone(payload.history[0]?.variables ?? {}),
|
|
1455
|
+
);
|
|
1050
1456
|
const newPatchEntries: PatchEntry[] = [];
|
|
1051
1457
|
|
|
1052
1458
|
let prevVars: Record<string, unknown> = base;
|
|
1053
1459
|
for (let i = 1; i < payload.history.length; i++) {
|
|
1054
|
-
const currVars =
|
|
1460
|
+
const currVars = createNamespace(
|
|
1461
|
+
deepClone(payload.history[i]!.variables),
|
|
1462
|
+
);
|
|
1055
1463
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
1056
1464
|
prevVars = currVars;
|
|
1057
1465
|
}
|
|
@@ -1070,7 +1478,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1070
1478
|
set((state) => {
|
|
1071
1479
|
state.currentPassage = payload.passage;
|
|
1072
1480
|
state.navigationId++;
|
|
1073
|
-
state.variables =
|
|
1481
|
+
state.variables = createNamespace(
|
|
1482
|
+
deepClone(entry?.variables ?? payload.variables),
|
|
1483
|
+
);
|
|
1074
1484
|
state.history = payload.history.map((m) => ({
|
|
1075
1485
|
passage: m.passage,
|
|
1076
1486
|
timestamp: m.timestamp,
|
|
@@ -1080,10 +1490,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1080
1490
|
0,
|
|
1081
1491
|
Math.min(payload.historyIndex, state.history.length - 1),
|
|
1082
1492
|
);
|
|
1493
|
+
// A save made under a higher limit keeps no more than the limit
|
|
1494
|
+
trimHistory(state);
|
|
1083
1495
|
state.visitCounts = payload.visitCounts ?? {};
|
|
1084
1496
|
state.renderCounts = payload.renderCounts ?? {};
|
|
1085
|
-
state.temporary =
|
|
1086
|
-
state.transient = deepClone(get().transientDefaults);
|
|
1497
|
+
state.temporary = createNamespace();
|
|
1498
|
+
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1087
1499
|
});
|
|
1088
1500
|
|
|
1089
1501
|
// Loaded state is not a change watchers react to
|