@rohal12/spindle 0.51.4 → 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 +4313 -1640
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- 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 +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- 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 +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- 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/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +270 -67
- package/src/expression.ts +86 -55
- 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 +1231 -97
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +339 -158
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/store.ts +521 -154
- package/src/story-api.ts +31 -67
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -5
- 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 +99 -14
- package/src/utils/stable-key.ts +14 -9
- 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;
|
|
@@ -239,29 +334,139 @@ export function recordStoryInitState(): void {
|
|
|
239
334
|
// ---------------------------------------------------------------------------
|
|
240
335
|
|
|
241
336
|
/**
|
|
242
|
-
* Settles once the latest playthrough
|
|
243
|
-
* restart's creation
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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.
|
|
246
345
|
*/
|
|
247
346
|
let playthroughSetup: Promise<string> = Promise.resolve('');
|
|
248
347
|
|
|
249
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* Bumped by every playthrough switch; a switch that settles after a later
|
|
350
|
+
* one was issued must not set its ID.
|
|
351
|
+
*/
|
|
250
352
|
let playthroughGeneration = 0;
|
|
251
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
|
+
|
|
252
369
|
/**
|
|
253
370
|
* The playthrough a save issued now belongs to, once its record is stored.
|
|
254
371
|
* Read synchronously at the call: restart() switches the store's
|
|
255
372
|
* `playthroughId` at once, so a save issued after it (even before the new
|
|
256
373
|
* playthrough is stored) belongs to the new playthrough, and a later restart
|
|
257
|
-
* doesn't move it.
|
|
258
|
-
*
|
|
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.
|
|
259
377
|
*/
|
|
260
378
|
export function resolvePlaythroughId(): Promise<string> {
|
|
261
|
-
const current =
|
|
379
|
+
const current = knownPlaythroughId();
|
|
262
380
|
return playthroughSetup.then((established) => current || established);
|
|
263
381
|
}
|
|
264
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
|
+
|
|
265
470
|
// ---------------------------------------------------------------------------
|
|
266
471
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
267
472
|
// ---------------------------------------------------------------------------
|
|
@@ -392,6 +597,12 @@ export interface StoryState {
|
|
|
392
597
|
visitCounts: Record<string, number>;
|
|
393
598
|
renderCounts: Record<string, number>;
|
|
394
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
|
+
*/
|
|
395
606
|
playthroughId: string;
|
|
396
607
|
maxHistory: number;
|
|
397
608
|
quickSaveKey: string | null;
|
|
@@ -428,6 +639,12 @@ export interface StoryState {
|
|
|
428
639
|
trackRender: (passageName: string) => void;
|
|
429
640
|
restart: () => void;
|
|
430
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
|
+
*/
|
|
431
648
|
load: (slot?: string) => Promise<void>;
|
|
432
649
|
hasSave: (slot?: string) => boolean;
|
|
433
650
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
@@ -435,9 +652,18 @@ export interface StoryState {
|
|
|
435
652
|
deleteSave: (slot?: string) => Promise<void>;
|
|
436
653
|
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
437
654
|
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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>;
|
|
441
667
|
getSavePayload: () => SavePayload;
|
|
442
668
|
/**
|
|
443
669
|
* Start capturing a save, before the `beforesave` hooks run. The returned
|
|
@@ -447,9 +673,15 @@ export interface StoryState {
|
|
|
447
673
|
beginSave: () => () => SavePayload;
|
|
448
674
|
/**
|
|
449
675
|
* Replace the game state with a live (deserialized) payload. `slot` is
|
|
450
|
-
* 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.
|
|
451
679
|
*/
|
|
452
|
-
loadFromPayload: (
|
|
680
|
+
loadFromPayload: (
|
|
681
|
+
payload: SavePayload,
|
|
682
|
+
slot?: string,
|
|
683
|
+
playthroughId?: string,
|
|
684
|
+
) => void;
|
|
453
685
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
454
686
|
setTransition: (config: TransitionConfig | null) => void;
|
|
455
687
|
setNextTransition: (config: TransitionConfig | null) => void;
|
|
@@ -458,6 +690,94 @@ export interface StoryState {
|
|
|
458
690
|
clearDeferredRender: () => void;
|
|
459
691
|
}
|
|
460
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
|
+
|
|
461
781
|
/**
|
|
462
782
|
* Return `p` marked as handled: a caller that ignores the result of a
|
|
463
783
|
* fire-and-forget operation (whose failure is already logged) gets no
|
|
@@ -470,15 +790,15 @@ function handled<T>(p: Promise<T>): Promise<T> {
|
|
|
470
790
|
}
|
|
471
791
|
|
|
472
792
|
export const useStoryStore = create<StoryState>()(
|
|
473
|
-
|
|
793
|
+
storyStore((set, get) => ({
|
|
474
794
|
storyData: null,
|
|
475
795
|
currentPassage: '',
|
|
476
796
|
navigationId: 0,
|
|
477
|
-
variables:
|
|
797
|
+
variables: createNamespace(),
|
|
478
798
|
variableDefaults: {},
|
|
479
|
-
transient:
|
|
799
|
+
transient: createNamespace(),
|
|
480
800
|
transientDefaults: {},
|
|
481
|
-
temporary:
|
|
801
|
+
temporary: createNamespace(),
|
|
482
802
|
history: [],
|
|
483
803
|
historyIndex: -1,
|
|
484
804
|
visitCounts: {},
|
|
@@ -496,9 +816,13 @@ export const useStoryStore = create<StoryState>()(
|
|
|
496
816
|
renderDeferred: false,
|
|
497
817
|
|
|
498
818
|
setMaxHistory: (limit: number) => {
|
|
819
|
+
let trimmed = false;
|
|
499
820
|
set((state) => {
|
|
500
821
|
state.maxHistory = Math.max(1, Math.round(limit));
|
|
822
|
+
// A lower limit takes effect at once
|
|
823
|
+
trimmed = trimHistory(state);
|
|
501
824
|
});
|
|
825
|
+
if (trimmed) persistSession(get);
|
|
502
826
|
},
|
|
503
827
|
|
|
504
828
|
setQuickSaveKey: (key: string | null) => {
|
|
@@ -525,7 +849,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
525
849
|
);
|
|
526
850
|
}
|
|
527
851
|
|
|
528
|
-
const initialVars = deepClone(variableDefaults);
|
|
852
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
529
853
|
resetModuleState(deepClone(initialVars));
|
|
530
854
|
|
|
531
855
|
set((state) => {
|
|
@@ -536,9 +860,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
536
860
|
state.navigationId++;
|
|
537
861
|
state.variables = initialVars;
|
|
538
862
|
state.variableDefaults = variableDefaults;
|
|
539
|
-
state.transient = deepClone(transientDefaults);
|
|
863
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
540
864
|
state.transientDefaults = transientDefaults;
|
|
541
|
-
state.temporary =
|
|
865
|
+
state.temporary = createNamespace();
|
|
542
866
|
state.history = [
|
|
543
867
|
{
|
|
544
868
|
passage: startPassage.name,
|
|
@@ -553,36 +877,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
553
877
|
// Update lastNavigationVars to the Immer-produced reference
|
|
554
878
|
lastNavigationVars = get().variables;
|
|
555
879
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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.
|
|
568
893
|
set((state) => {
|
|
569
|
-
state.
|
|
894
|
+
state.knownSaves = knownSaves;
|
|
570
895
|
});
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
return id;
|
|
581
|
-
})
|
|
582
|
-
.catch((err) => {
|
|
583
|
-
console.error('spindle: failed to init save system', err);
|
|
584
|
-
return '';
|
|
585
|
-
});
|
|
896
|
+
return id;
|
|
897
|
+
})
|
|
898
|
+
.catch((err) => {
|
|
899
|
+
console.error('spindle: failed to init save system', err);
|
|
900
|
+
return '';
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
586
903
|
},
|
|
587
904
|
|
|
588
905
|
navigate: (passageName: string) => {
|
|
@@ -616,7 +933,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
616
933
|
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
617
934
|
|
|
618
935
|
set((state) => {
|
|
619
|
-
state.temporary =
|
|
936
|
+
state.temporary = createNamespace();
|
|
620
937
|
state.currentPassage = passageName;
|
|
621
938
|
state.navigationId++;
|
|
622
939
|
|
|
@@ -635,19 +952,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
635
952
|
prng: snapshotPRNG(),
|
|
636
953
|
});
|
|
637
954
|
|
|
638
|
-
// Trim oldest entries if over the limit
|
|
639
|
-
const overflow = state.history.length - state.maxHistory;
|
|
640
|
-
if (overflow > 0) {
|
|
641
|
-
// Advance base through trimmed transitions
|
|
642
|
-
for (let i = 0; i < overflow; i++) {
|
|
643
|
-
variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
|
|
644
|
-
}
|
|
645
|
-
state.history = state.history.slice(overflow);
|
|
646
|
-
patchEntries = patchEntries.slice(overflow);
|
|
647
|
-
serializedHistory = serializedHistory.slice(overflow);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
955
|
state.historyIndex = state.history.length - 1;
|
|
956
|
+
// Trim oldest entries if over the limit
|
|
957
|
+
trimHistory(state);
|
|
651
958
|
state.visitCounts[passageName] =
|
|
652
959
|
(state.visitCounts[passageName] ?? 0) + 1;
|
|
653
960
|
state.renderCounts[passageName] =
|
|
@@ -657,7 +964,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
657
964
|
// Watchers react to the completed transition (visit counts, cleared
|
|
658
965
|
// temporaries). Like beforenavigate changes, their run actions belong
|
|
659
966
|
// to the entered moment; navigations they request run afterwards.
|
|
660
|
-
|
|
967
|
+
enteredMoment = {
|
|
968
|
+
navigationId: get().navigationId,
|
|
969
|
+
variables: get().variables,
|
|
970
|
+
};
|
|
661
971
|
navigationTriggerPhase = true;
|
|
662
972
|
try {
|
|
663
973
|
checkTriggersOnNavigation();
|
|
@@ -666,18 +976,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
666
976
|
}
|
|
667
977
|
const deferred = deferredNavigations;
|
|
668
978
|
deferredNavigations = [];
|
|
669
|
-
|
|
670
|
-
rerecordNewestMoment(get().variables);
|
|
671
|
-
}
|
|
672
|
-
const prng = snapshotPRNG();
|
|
673
|
-
const recorded = get().history[get().historyIndex]!.prng;
|
|
674
|
-
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
675
|
-
set((state) => {
|
|
676
|
-
state.history[state.historyIndex]!.prng = prng;
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
lastNavigationVars = get().variables;
|
|
979
|
+
finishEnteredMoment(get, set);
|
|
681
980
|
persistSession(get);
|
|
682
981
|
|
|
683
982
|
emit('afternavigate', passageName, previousPassage);
|
|
@@ -688,6 +987,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
688
987
|
goBack: () => {
|
|
689
988
|
const { historyIndex } = get();
|
|
690
989
|
if (historyIndex <= 0) return;
|
|
990
|
+
finishEnteredMoment(get, set);
|
|
691
991
|
|
|
692
992
|
const previousPassage = get().currentPassage;
|
|
693
993
|
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
@@ -702,7 +1002,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
702
1002
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
703
1003
|
state.navigationId++;
|
|
704
1004
|
state.variables = restoredVars;
|
|
705
|
-
state.temporary =
|
|
1005
|
+
state.temporary = createNamespace();
|
|
706
1006
|
});
|
|
707
1007
|
|
|
708
1008
|
// Restored state is not a change watchers react to
|
|
@@ -717,6 +1017,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
717
1017
|
goForward: () => {
|
|
718
1018
|
const { historyIndex, history: hist } = get();
|
|
719
1019
|
if (historyIndex >= hist.length - 1) return;
|
|
1020
|
+
finishEnteredMoment(get, set);
|
|
720
1021
|
|
|
721
1022
|
const previousPassage = get().currentPassage;
|
|
722
1023
|
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
@@ -731,7 +1032,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
731
1032
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
732
1033
|
state.navigationId++;
|
|
733
1034
|
state.variables = restoredVars;
|
|
734
|
-
state.temporary =
|
|
1035
|
+
state.temporary = createNamespace();
|
|
735
1036
|
});
|
|
736
1037
|
|
|
737
1038
|
// Restored state is not a change watchers react to
|
|
@@ -745,36 +1046,42 @@ export const useStoryStore = create<StoryState>()(
|
|
|
745
1046
|
|
|
746
1047
|
setVariable: (name: string, value: unknown) => {
|
|
747
1048
|
set((state) => {
|
|
1049
|
+
checkVariableName(name, `$${name}`);
|
|
748
1050
|
state.variables[name] = value;
|
|
749
1051
|
});
|
|
750
1052
|
},
|
|
751
1053
|
|
|
752
1054
|
setTemporary: (name: string, value: unknown) => {
|
|
753
1055
|
set((state) => {
|
|
1056
|
+
checkVariableName(name, `_${name}`);
|
|
754
1057
|
state.temporary[name] = value;
|
|
755
1058
|
});
|
|
756
1059
|
},
|
|
757
1060
|
|
|
758
1061
|
deleteVariable: (name: string) => {
|
|
759
1062
|
set((state) => {
|
|
1063
|
+
checkVariableName(name, `$${name}`);
|
|
760
1064
|
delete state.variables[name];
|
|
761
1065
|
});
|
|
762
1066
|
},
|
|
763
1067
|
|
|
764
1068
|
deleteTemporary: (name: string) => {
|
|
765
1069
|
set((state) => {
|
|
1070
|
+
checkVariableName(name, `_${name}`);
|
|
766
1071
|
delete state.temporary[name];
|
|
767
1072
|
});
|
|
768
1073
|
},
|
|
769
1074
|
|
|
770
1075
|
setTransient: (name: string, value: unknown) => {
|
|
771
1076
|
set((state) => {
|
|
1077
|
+
checkVariableName(name, `%${name}`);
|
|
772
1078
|
state.transient[name] = value;
|
|
773
1079
|
});
|
|
774
1080
|
},
|
|
775
1081
|
|
|
776
1082
|
deleteTransient: (name: string) => {
|
|
777
1083
|
set((state) => {
|
|
1084
|
+
checkVariableName(name, `%${name}`);
|
|
778
1085
|
delete state.transient[name];
|
|
779
1086
|
});
|
|
780
1087
|
},
|
|
@@ -809,23 +1116,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
809
1116
|
|
|
810
1117
|
// Switch to the new playthrough now, after beforerestart (whose saves
|
|
811
1118
|
// belong to the game being left) and before StoryInit, so every save
|
|
812
|
-
// issued from here on belongs to the new game.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
set((state) => {
|
|
817
|
-
state.playthroughId = newPlaythroughId;
|
|
818
|
-
});
|
|
819
|
-
const ifid = storyData.ifid;
|
|
820
|
-
playthroughSetup = playthroughSetup
|
|
821
|
-
.then(() => startNewPlaythrough(ifid, newPlaythroughId))
|
|
822
|
-
.then(
|
|
823
|
-
() => newPlaythroughId,
|
|
824
|
-
(err) => {
|
|
825
|
-
console.error('spindle: failed to start new playthrough', err);
|
|
826
|
-
return newPlaythroughId;
|
|
827
|
-
},
|
|
828
|
-
);
|
|
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();
|
|
829
1123
|
|
|
830
1124
|
const keepDeferred = get().renderDeferred;
|
|
831
1125
|
|
|
@@ -834,15 +1128,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
834
1128
|
|
|
835
1129
|
resetPRNG();
|
|
836
1130
|
resetTriggers();
|
|
837
|
-
const initialVars = deepClone(variableDefaults);
|
|
1131
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
838
1132
|
resetModuleState(deepClone(initialVars));
|
|
839
1133
|
|
|
840
1134
|
set((state) => {
|
|
841
1135
|
state.currentPassage = startPassage.name;
|
|
842
1136
|
state.navigationId++;
|
|
843
1137
|
state.variables = initialVars;
|
|
844
|
-
state.transient = deepClone(transientDefaults);
|
|
845
|
-
state.temporary =
|
|
1138
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
1139
|
+
state.temporary = createNamespace();
|
|
846
1140
|
state.history = [
|
|
847
1141
|
{
|
|
848
1142
|
passage: startPassage.name,
|
|
@@ -880,8 +1174,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
880
1174
|
set((state) => {
|
|
881
1175
|
state.saveError = null;
|
|
882
1176
|
});
|
|
883
|
-
|
|
884
|
-
await quickSave(storyData.ifid,
|
|
1177
|
+
// Queued now, in call order with other storage operations
|
|
1178
|
+
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
885
1179
|
set((state) => {
|
|
886
1180
|
state.knownSaves = {
|
|
887
1181
|
...state.knownSaves,
|
|
@@ -891,8 +1185,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
891
1185
|
}).catch((err) => {
|
|
892
1186
|
console.error('spindle: failed to save', err);
|
|
893
1187
|
set((state) => {
|
|
894
|
-
state.saveError =
|
|
895
|
-
err instanceof Error ? err.message : 'Failed to save';
|
|
1188
|
+
state.saveError = errorMessage(err, 'Failed to save');
|
|
896
1189
|
});
|
|
897
1190
|
throw err;
|
|
898
1191
|
}),
|
|
@@ -906,17 +1199,35 @@ export const useStoryStore = create<StoryState>()(
|
|
|
906
1199
|
set((state) => {
|
|
907
1200
|
state.loadError = null;
|
|
908
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;
|
|
909
1215
|
return handled(
|
|
910
|
-
|
|
911
|
-
.then((
|
|
912
|
-
|
|
913
|
-
|
|
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);
|
|
914
1226
|
})
|
|
915
1227
|
.catch((err) => {
|
|
916
1228
|
console.error('spindle: failed to load save', err);
|
|
917
1229
|
set((state) => {
|
|
918
|
-
state.loadError =
|
|
919
|
-
err instanceof Error ? err.message : 'Failed to load';
|
|
1230
|
+
state.loadError = errorMessage(err, 'Failed to load');
|
|
920
1231
|
});
|
|
921
1232
|
throw err;
|
|
922
1233
|
}),
|
|
@@ -982,50 +1293,83 @@ export const useStoryStore = create<StoryState>()(
|
|
|
982
1293
|
|
|
983
1294
|
clearGameData: () => {
|
|
984
1295
|
const { storyData } = get();
|
|
985
|
-
if (!storyData) return;
|
|
1296
|
+
if (!storyData) return Promise.resolve();
|
|
986
1297
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
.catch((err) => {
|
|
995
|
-
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 = {};
|
|
996
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
|
+
);
|
|
997
1314
|
},
|
|
998
1315
|
|
|
999
1316
|
clearAllData: () => {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
.then(() => {
|
|
1005
|
-
set((state) => {
|
|
1006
|
-
state.knownSaves = {};
|
|
1007
|
-
});
|
|
1008
|
-
get().restart();
|
|
1009
|
-
})
|
|
1010
|
-
.catch((err) => {
|
|
1011
|
-
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 = {};
|
|
1012
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
|
+
);
|
|
1013
1330
|
},
|
|
1014
1331
|
|
|
1015
1332
|
deletePlaythrough: (playthroughId: string) => {
|
|
1016
1333
|
const { storyData } = get();
|
|
1017
|
-
if (!storyData) return;
|
|
1334
|
+
if (!storyData) return Promise.resolve();
|
|
1018
1335
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
+
);
|
|
1029
1373
|
},
|
|
1030
1374
|
|
|
1031
1375
|
getSavePayload: (): SavePayload => {
|
|
@@ -1047,7 +1391,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1047
1391
|
}
|
|
1048
1392
|
saveHistory.push({
|
|
1049
1393
|
passage: history[i]!.passage,
|
|
1050
|
-
variables:
|
|
1394
|
+
variables: plainCopy(vars),
|
|
1051
1395
|
timestamp: history[i]!.timestamp,
|
|
1052
1396
|
prng: history[i]!.prng,
|
|
1053
1397
|
});
|
|
@@ -1055,7 +1399,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1055
1399
|
|
|
1056
1400
|
return {
|
|
1057
1401
|
passage: currentPassage,
|
|
1058
|
-
variables:
|
|
1402
|
+
variables: plainCopy(variables),
|
|
1059
1403
|
history: saveHistory,
|
|
1060
1404
|
historyIndex,
|
|
1061
1405
|
visitCounts: { ...visitCounts },
|
|
@@ -1073,14 +1417,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1073
1417
|
};
|
|
1074
1418
|
},
|
|
1075
1419
|
|
|
1076
|
-
loadFromPayload: (
|
|
1420
|
+
loadFromPayload: (
|
|
1421
|
+
payload: SavePayload,
|
|
1422
|
+
slot?: string,
|
|
1423
|
+
playthroughId?: string,
|
|
1424
|
+
) => {
|
|
1425
|
+
const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
|
|
1426
|
+
slotLoadApplying = null;
|
|
1077
1427
|
if (payload.history.length === 0) {
|
|
1078
1428
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1079
1429
|
return;
|
|
1080
1430
|
}
|
|
1431
|
+
latestStateApplied = replacement;
|
|
1081
1432
|
|
|
1082
1433
|
emit('beforeload', slot);
|
|
1083
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
|
+
|
|
1084
1443
|
// Restore the state on entering the saved passage, not the payload's
|
|
1085
1444
|
// live variables: the passage remounts and runs its {set}/{do} again,
|
|
1086
1445
|
// so restoring their results as well would apply them twice. Changes
|
|
@@ -1091,12 +1450,16 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1091
1450
|
// The payload is already live (deserialized at the storage boundary by
|
|
1092
1451
|
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
1093
1452
|
// Convert full snapshots to patch entries
|
|
1094
|
-
const base =
|
|
1453
|
+
const base = createNamespace(
|
|
1454
|
+
deepClone(payload.history[0]?.variables ?? {}),
|
|
1455
|
+
);
|
|
1095
1456
|
const newPatchEntries: PatchEntry[] = [];
|
|
1096
1457
|
|
|
1097
1458
|
let prevVars: Record<string, unknown> = base;
|
|
1098
1459
|
for (let i = 1; i < payload.history.length; i++) {
|
|
1099
|
-
const currVars =
|
|
1460
|
+
const currVars = createNamespace(
|
|
1461
|
+
deepClone(payload.history[i]!.variables),
|
|
1462
|
+
);
|
|
1100
1463
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
1101
1464
|
prevVars = currVars;
|
|
1102
1465
|
}
|
|
@@ -1115,7 +1478,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1115
1478
|
set((state) => {
|
|
1116
1479
|
state.currentPassage = payload.passage;
|
|
1117
1480
|
state.navigationId++;
|
|
1118
|
-
state.variables =
|
|
1481
|
+
state.variables = createNamespace(
|
|
1482
|
+
deepClone(entry?.variables ?? payload.variables),
|
|
1483
|
+
);
|
|
1119
1484
|
state.history = payload.history.map((m) => ({
|
|
1120
1485
|
passage: m.passage,
|
|
1121
1486
|
timestamp: m.timestamp,
|
|
@@ -1125,10 +1490,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1125
1490
|
0,
|
|
1126
1491
|
Math.min(payload.historyIndex, state.history.length - 1),
|
|
1127
1492
|
);
|
|
1493
|
+
// A save made under a higher limit keeps no more than the limit
|
|
1494
|
+
trimHistory(state);
|
|
1128
1495
|
state.visitCounts = payload.visitCounts ?? {};
|
|
1129
1496
|
state.renderCounts = payload.renderCounts ?? {};
|
|
1130
|
-
state.temporary =
|
|
1131
|
-
state.transient = deepClone(get().transientDefaults);
|
|
1497
|
+
state.temporary = createNamespace();
|
|
1498
|
+
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1132
1499
|
});
|
|
1133
1500
|
|
|
1134
1501
|
// Loaded state is not a change watchers react to
|