@rohal12/spindle 0.51.4 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- 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 +28 -14
- 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/Set.tsx +5 -4
- 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 +31 -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 +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- 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 +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- 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,
|
|
@@ -41,15 +46,32 @@ import {
|
|
|
41
46
|
clearAllData as smClearAllData,
|
|
42
47
|
deletePlaythroughData as smDeletePlaythroughData,
|
|
43
48
|
} from './saves/save-manager';
|
|
44
|
-
import { deepClone, serialize } from './class-registry';
|
|
49
|
+
import { deepClone, deepEqual, serialize } from './class-registry';
|
|
45
50
|
import {
|
|
46
51
|
snapshotPRNG,
|
|
47
52
|
restorePRNG,
|
|
48
53
|
resetPRNG,
|
|
54
|
+
withoutDraws,
|
|
49
55
|
type PRNGSnapshot,
|
|
50
56
|
} from './prng';
|
|
57
|
+
import { errorMessage } from './utils/error-message';
|
|
58
|
+
import {
|
|
59
|
+
isMergeable,
|
|
60
|
+
routeStoreUpdate,
|
|
61
|
+
runWithCommittedMutations,
|
|
62
|
+
} from './execute-mutation';
|
|
63
|
+
import {
|
|
64
|
+
checkVariableName,
|
|
65
|
+
createNamespace,
|
|
66
|
+
isNamespace,
|
|
67
|
+
type Namespace,
|
|
68
|
+
} from './utils/namespace';
|
|
69
|
+
import { createCounts, type Counts } from './utils/counts';
|
|
51
70
|
|
|
52
71
|
enablePatches();
|
|
72
|
+
// Story state holds Map and Set values: Immer must be able to draft them
|
|
73
|
+
// when a write (a dot path, a macro binding) reaches one
|
|
74
|
+
enableMapSet();
|
|
53
75
|
|
|
54
76
|
const SPECIAL_PASSAGES = new Set([
|
|
55
77
|
'StoryInit',
|
|
@@ -74,7 +96,7 @@ interface PatchEntry {
|
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
/** Full variable snapshot at history index 0. */
|
|
77
|
-
let variableBase:
|
|
99
|
+
let variableBase: Namespace = createNamespace();
|
|
78
100
|
|
|
79
101
|
/**
|
|
80
102
|
* Transitions between consecutive history moments.
|
|
@@ -84,7 +106,7 @@ let variableBase: Record<string, unknown> = {};
|
|
|
84
106
|
let patchEntries: PatchEntry[] = [];
|
|
85
107
|
|
|
86
108
|
/** Immer-produced reference to variables right after the last navigation. */
|
|
87
|
-
let lastNavigationVars:
|
|
109
|
+
let lastNavigationVars: Namespace = createNamespace();
|
|
88
110
|
|
|
89
111
|
/** Deep-clone patch values so they are independent of future mutations. */
|
|
90
112
|
function clonePatches(patches: Patch[]): Patch[] {
|
|
@@ -102,7 +124,9 @@ function computeVarPatches(
|
|
|
102
124
|
const [, forward, inverse] = produceWithPatches(prev, (draft) => {
|
|
103
125
|
const d = draft as Record<string, unknown>;
|
|
104
126
|
for (const key of Object.keys(d)) {
|
|
105
|
-
|
|
127
|
+
// Own keys only: `curr` may be a plain object (a loaded snapshot),
|
|
128
|
+
// whose inherited `constructor` is no variable
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
|
|
106
130
|
}
|
|
107
131
|
for (const [key, val] of Object.entries(curr)) {
|
|
108
132
|
d[key] = val;
|
|
@@ -197,14 +221,88 @@ function persistSession(get: () => StoryState): void {
|
|
|
197
221
|
});
|
|
198
222
|
}
|
|
199
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Trim history to `state.maxHistory` moments: keep the newest moments that
|
|
226
|
+
* include the current one. After a navigation (the current moment is the
|
|
227
|
+
* newest) that drops the oldest; when the player has gone back further than
|
|
228
|
+
* the limit allows, the moments after the newest kept one are dropped too.
|
|
229
|
+
* Call it inside a store update; the module-level variable history
|
|
230
|
+
* (base, patches, session cache) is trimmed alongside.
|
|
231
|
+
*/
|
|
232
|
+
function trimHistory(state: {
|
|
233
|
+
history: HistoryMoment[];
|
|
234
|
+
historyIndex: number;
|
|
235
|
+
maxHistory: number;
|
|
236
|
+
}): boolean {
|
|
237
|
+
const excess = state.history.length - state.maxHistory;
|
|
238
|
+
if (excess <= 0) return false;
|
|
239
|
+
const start = Math.min(state.historyIndex, excess);
|
|
240
|
+
const end = start + state.maxHistory;
|
|
241
|
+
// Advance base through trimmed transitions
|
|
242
|
+
for (let i = 0; i < start; i++) {
|
|
243
|
+
variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
|
|
244
|
+
}
|
|
245
|
+
state.history = state.history.slice(start, end);
|
|
246
|
+
patchEntries = patchEntries.slice(start, end - 1);
|
|
247
|
+
serializedHistory = serializedHistory.slice(start, end);
|
|
248
|
+
state.historyIndex -= start;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
200
252
|
/** True while navigate() lets watchers react to the moment it entered. */
|
|
201
253
|
let navigationTriggerPhase = false;
|
|
202
254
|
|
|
203
255
|
/** Navigations requested during the trigger phase, run once it is over. */
|
|
204
256
|
let deferredNavigations: string[] = [];
|
|
205
257
|
|
|
258
|
+
/**
|
|
259
|
+
* The moment navigate() entered, while its watchers may still change it:
|
|
260
|
+
* the navigation it belongs to and the variables it was recorded with.
|
|
261
|
+
*/
|
|
262
|
+
let enteredMoment: {
|
|
263
|
+
navigationId: number;
|
|
264
|
+
variables: Record<string, unknown>;
|
|
265
|
+
} | null = null;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Record the entered moment as it is now (watcher run actions and the PRNG
|
|
269
|
+
* rolls they made belong to it), unless the story has left it already.
|
|
270
|
+
* navigate() calls this after its watchers, and back/forward before they
|
|
271
|
+
* leave the moment: a watcher that moves through history must not have the
|
|
272
|
+
* moment it leaves recorded with the state of the one it arrives at.
|
|
273
|
+
*/
|
|
274
|
+
function finishEnteredMoment(
|
|
275
|
+
get: () => StoryState,
|
|
276
|
+
set: (recipe: (state: StoryState) => void) => void,
|
|
277
|
+
): void {
|
|
278
|
+
const moment = enteredMoment;
|
|
279
|
+
enteredMoment = null;
|
|
280
|
+
if (!moment || get().navigationId !== moment.navigationId) return;
|
|
281
|
+
if (get().variables !== moment.variables) {
|
|
282
|
+
rerecordNewestMoment(get().variables);
|
|
283
|
+
}
|
|
284
|
+
// The next navigate() diffs from this recorded snapshot. A watcher that
|
|
285
|
+
// left the moment has set it to the snapshot of the one it went to.
|
|
286
|
+
lastNavigationVars = get().variables;
|
|
287
|
+
const prng = snapshotPRNG();
|
|
288
|
+
const recorded = get().history[get().historyIndex]!.prng;
|
|
289
|
+
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
290
|
+
set((state) => {
|
|
291
|
+
state.history[state.historyIndex]!.prng = prng;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A deep copy of a variable namespace as save data: a plain object, as a
|
|
298
|
+
* loaded save holds it (the store turns it back into a namespace on load).
|
|
299
|
+
*/
|
|
300
|
+
const plainCopy = (ns: Namespace): Record<string, unknown> => ({
|
|
301
|
+
...deepClone(ns),
|
|
302
|
+
});
|
|
303
|
+
|
|
206
304
|
/** Reset all module-level state (called on init, restart, loadFromPayload). */
|
|
207
|
-
function resetModuleState(base:
|
|
305
|
+
function resetModuleState(base: Namespace): void {
|
|
208
306
|
variableBase = base;
|
|
209
307
|
patchEntries = [];
|
|
210
308
|
lastNavigationVars = base;
|
|
@@ -239,29 +337,139 @@ export function recordStoryInitState(): void {
|
|
|
239
337
|
// ---------------------------------------------------------------------------
|
|
240
338
|
|
|
241
339
|
/**
|
|
242
|
-
* Settles once the latest playthrough
|
|
243
|
-
* restart's creation
|
|
244
|
-
*
|
|
245
|
-
*
|
|
340
|
+
* Settles once the latest playthrough switch (init's lookup or creation, a
|
|
341
|
+
* restart's creation, the replacement of a deleted current playthrough, a
|
|
342
|
+
* load making the loaded save's playthrough current) is stored, with the
|
|
343
|
+
* playthrough ID it leaves the game in ('' if init could not establish one).
|
|
344
|
+
* Switches are storage operations, which run in call order, so playthroughs
|
|
345
|
+
* are created and numbered in the order the game started them, and a save
|
|
346
|
+
* issued after a switch is stored after it, in the playthrough it switched
|
|
347
|
+
* to.
|
|
246
348
|
*/
|
|
247
349
|
let playthroughSetup: Promise<string> = Promise.resolve('');
|
|
248
350
|
|
|
249
|
-
/**
|
|
351
|
+
/**
|
|
352
|
+
* Bumped by every playthrough switch; a switch that settles after a later
|
|
353
|
+
* one was issued must not set its ID.
|
|
354
|
+
*/
|
|
250
355
|
let playthroughGeneration = 0;
|
|
251
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Whether the latest switch is to a playthrough not known until a storage
|
|
359
|
+
* operation has run: the one init looks up, the playthrough of the save a
|
|
360
|
+
* load from a slot reads, or (while one of those is pending) the one a
|
|
361
|
+
* playthrough deletion leaves the game in. Meanwhile the store's
|
|
362
|
+
* `playthroughId` is the one before ('' at boot), and saves issued take the
|
|
363
|
+
* one the switch establishes.
|
|
364
|
+
*/
|
|
365
|
+
let playthroughPending = false;
|
|
366
|
+
|
|
367
|
+
/** The game's playthrough now, or '' while a pending switch decides it. */
|
|
368
|
+
function knownPlaythroughId(): string {
|
|
369
|
+
return playthroughPending ? '' : useStoryStore.getState().playthroughId;
|
|
370
|
+
}
|
|
371
|
+
|
|
252
372
|
/**
|
|
253
373
|
* The playthrough a save issued now belongs to, once its record is stored.
|
|
254
374
|
* Read synchronously at the call: restart() switches the store's
|
|
255
375
|
* `playthroughId` at once, so a save issued after it (even before the new
|
|
256
376
|
* playthrough is stored) belongs to the new playthrough, and a later restart
|
|
257
|
-
* doesn't move it.
|
|
258
|
-
*
|
|
377
|
+
* doesn't move it. While a switch whose playthrough is not known yet is
|
|
378
|
+
* pending (init's lookup, a load from a slot), the save takes the one that
|
|
379
|
+
* switch establishes.
|
|
259
380
|
*/
|
|
260
381
|
export function resolvePlaythroughId(): Promise<string> {
|
|
261
|
-
const current =
|
|
382
|
+
const current = knownPlaythroughId();
|
|
262
383
|
return playthroughSetup.then((established) => current || established);
|
|
263
384
|
}
|
|
264
385
|
|
|
386
|
+
function setPlaythroughId(id: string): void {
|
|
387
|
+
if (useStoryStore.getState().playthroughId === id) return;
|
|
388
|
+
useStoryStore.setState((state) => {
|
|
389
|
+
state.playthroughId = id;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Switch to the playthrough `lookup` (a storage operation queued now)
|
|
395
|
+
* resolves to. Saves issued meanwhile belong to it; the store's
|
|
396
|
+
* `playthroughId` is set once it is known, unless a later switch was issued.
|
|
397
|
+
*/
|
|
398
|
+
function switchToLookedUpPlaythrough(lookup: Promise<string>): Promise<string> {
|
|
399
|
+
const generation = ++playthroughGeneration;
|
|
400
|
+
playthroughPending = true;
|
|
401
|
+
playthroughSetup = lookup.then((id) => {
|
|
402
|
+
if (generation === playthroughGeneration) {
|
|
403
|
+
playthroughPending = false;
|
|
404
|
+
setPlaythroughId(id);
|
|
405
|
+
}
|
|
406
|
+
return id;
|
|
407
|
+
});
|
|
408
|
+
return playthroughSetup;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Move the running game to the playthrough `id` at once: saves issued from
|
|
413
|
+
* here on belong to it. `stored` is the storage operation recording the
|
|
414
|
+
* switch, queued now, after those already issued (its failure is reported
|
|
415
|
+
* by the caller).
|
|
416
|
+
*/
|
|
417
|
+
function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
|
|
418
|
+
++playthroughGeneration;
|
|
419
|
+
playthroughPending = false;
|
|
420
|
+
playthroughSetup = stored.then(
|
|
421
|
+
() => id,
|
|
422
|
+
() => id,
|
|
423
|
+
);
|
|
424
|
+
setPlaythroughId(id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Move the running game to a new playthrough at once (see restart). */
|
|
428
|
+
function switchToNewPlaythrough(ifid: string): void {
|
|
429
|
+
const id = crypto.randomUUID();
|
|
430
|
+
const stored = startNewPlaythrough(ifid, id).catch((err) => {
|
|
431
|
+
console.error('spindle: failed to start new playthrough', err);
|
|
432
|
+
});
|
|
433
|
+
switchToPlaythrough(id, stored);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Move the running game to the playthrough of a save it loads, at once. A
|
|
438
|
+
* no-op if the game is in it already.
|
|
439
|
+
*/
|
|
440
|
+
function switchToLoadedPlaythrough(ifid: string, id: string): void {
|
|
441
|
+
if (knownPlaythroughId() === id) return;
|
|
442
|
+
const stored = adoptPlaythrough(ifid, id).catch((err) => {
|
|
443
|
+
console.error('spindle: failed to switch to the loaded playthrough', err);
|
|
444
|
+
});
|
|
445
|
+
switchToPlaythrough(id, stored);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Superseded loads
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A load from a slot reads the save in the order of storage operations and
|
|
454
|
+
* applies it when the read completes. A restart, a boot or a direct load
|
|
455
|
+
* (loadFromPayload) issued after it replaces the game state at once; the
|
|
456
|
+
* slot load, completing later, must not undo that. Every replacement of the
|
|
457
|
+
* game state takes a number in call order, and a slot load applies only if
|
|
458
|
+
* no replacement issued after it has been applied. Loads from slots apply in
|
|
459
|
+
* call order anyway (their reads are queued), so they never supersede one
|
|
460
|
+
* another.
|
|
461
|
+
*/
|
|
462
|
+
let stateReplacementsIssued = 0;
|
|
463
|
+
let latestStateApplied = 0;
|
|
464
|
+
|
|
465
|
+
/** The number of the slot load that is calling loadFromPayload. */
|
|
466
|
+
let slotLoadApplying: number | null = null;
|
|
467
|
+
|
|
468
|
+
/** A replacement of the game state applied at its call. */
|
|
469
|
+
function replaceStateNow(): void {
|
|
470
|
+
latestStateApplied = ++stateReplacementsIssued;
|
|
471
|
+
}
|
|
472
|
+
|
|
265
473
|
// ---------------------------------------------------------------------------
|
|
266
474
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
267
475
|
// ---------------------------------------------------------------------------
|
|
@@ -326,13 +534,14 @@ function loadedEntryMoment(
|
|
|
326
534
|
}
|
|
327
535
|
|
|
328
536
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
537
|
+
* Write the changes between `before` and `after` (the live variables around
|
|
538
|
+
* the `beforesave` hooks) into the payload's snapshot of the saved moment. A
|
|
539
|
+
* load restores that snapshot, the state on entering the passage, and runs
|
|
540
|
+
* the passage again, so data a hook adds to a save would otherwise be lost
|
|
541
|
+
* on load (#227). Only the property paths the hooks changed are written: a
|
|
542
|
+
* whole variable would bring along what the passage did to the rest of it,
|
|
543
|
+
* which the passage then does again on load (#232). Only the payload's copy
|
|
544
|
+
* changes: the live history keeps the recorded snapshot (#159).
|
|
336
545
|
*/
|
|
337
546
|
function keepHookWrites(
|
|
338
547
|
payload: SavePayload,
|
|
@@ -341,15 +550,100 @@ function keepHookWrites(
|
|
|
341
550
|
): void {
|
|
342
551
|
const moment = payload.history[payload.historyIndex];
|
|
343
552
|
if (!moment) return;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
553
|
+
mergeHookWrites(moment.variables, before, after, new Set());
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const hasOwn = (obj: object, key: string): boolean =>
|
|
557
|
+
Object.prototype.hasOwnProperty.call(obj, key);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Whether changes between `a` and `b` merge key by key: both are arrays,
|
|
561
|
+
* or both are objects of one class (see isMergeable).
|
|
562
|
+
*/
|
|
563
|
+
function mergeable(a: unknown, b: unknown): boolean {
|
|
564
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
565
|
+
return Array.isArray(a) && Array.isArray(b);
|
|
566
|
+
}
|
|
567
|
+
return (
|
|
568
|
+
isMergeable(a) &&
|
|
569
|
+
isMergeable(b) &&
|
|
570
|
+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Apply the hooks' change of a value, from `before` to `after`, to the
|
|
576
|
+
* snapshot's copy of it, `target[key]`. Store updates are immutable, so a
|
|
577
|
+
* value no hook touched keeps its identity; one rebuilt with equal content
|
|
578
|
+
* (mutation code commits whole values) is no change either. Where `target`
|
|
579
|
+
* lacks the key or holds another kind of value (the passage created or
|
|
580
|
+
* replaced it), the hooks' whole value is written.
|
|
581
|
+
*/
|
|
582
|
+
function mergeHookWrite(
|
|
583
|
+
target: Record<string, unknown>,
|
|
584
|
+
key: string,
|
|
585
|
+
before: unknown,
|
|
586
|
+
after: unknown,
|
|
587
|
+
ancestors: Set<object>,
|
|
588
|
+
): void {
|
|
589
|
+
if (Object.is(before, after)) return;
|
|
590
|
+
if (
|
|
591
|
+
mergeable(before, after) &&
|
|
592
|
+
hasOwn(target, key) &&
|
|
593
|
+
mergeable(after, target[key]) &&
|
|
594
|
+
// Stop at cycles: deepEqual() and deepClone() handle them
|
|
595
|
+
!ancestors.has(after as object)
|
|
596
|
+
) {
|
|
597
|
+
mergeHookWrites(
|
|
598
|
+
target[key] as Record<string, unknown>,
|
|
599
|
+
before as Record<string, unknown>,
|
|
600
|
+
after as Record<string, unknown>,
|
|
601
|
+
ancestors,
|
|
602
|
+
);
|
|
603
|
+
} else if (!deepEqual(before, after)) {
|
|
604
|
+
target[key] = deepClone(after);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Apply the changes between `before` and `after`, two objects or two arrays
|
|
610
|
+
* (see mergeable()), to the snapshot's `target`. Array elements merge by
|
|
611
|
+
* index. Elements the hooks removed from an array's end are removed from
|
|
612
|
+
* the snapshot's array at the same indices, and elements they added are
|
|
613
|
+
* appended to it: where the passage resized the array, its indices do not
|
|
614
|
+
* line up with the snapshot's, and the passage resizes it again on load.
|
|
615
|
+
*/
|
|
616
|
+
function mergeHookWrites(
|
|
617
|
+
target: Record<string, unknown>,
|
|
618
|
+
before: Record<string, unknown>,
|
|
619
|
+
after: Record<string, unknown>,
|
|
620
|
+
ancestors: Set<object>,
|
|
621
|
+
): void {
|
|
622
|
+
ancestors.add(after);
|
|
623
|
+
if (Array.isArray(target)) {
|
|
624
|
+
const b = before as unknown as unknown[];
|
|
625
|
+
const a = after as unknown as unknown[];
|
|
626
|
+
const shared = Math.min(b.length, a.length, target.length);
|
|
627
|
+
for (let i = 0; i < shared; i++) {
|
|
628
|
+
mergeHookWrite(target, String(i), b[i], a[i], ancestors);
|
|
629
|
+
}
|
|
630
|
+
if (a.length < b.length) {
|
|
631
|
+
target.length = Math.min(target.length, a.length);
|
|
632
|
+
}
|
|
633
|
+
for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
|
|
634
|
+
} else {
|
|
635
|
+
for (const key of Object.keys(before)) {
|
|
636
|
+
if (!hasOwn(after, key)) delete target[key];
|
|
637
|
+
}
|
|
638
|
+
for (const key of Object.keys(after)) {
|
|
639
|
+
if (hasOwn(before, key)) {
|
|
640
|
+
mergeHookWrite(target, key, before[key], after[key], ancestors);
|
|
641
|
+
} else {
|
|
642
|
+
target[key] = deepClone(after[key]);
|
|
643
|
+
}
|
|
351
644
|
}
|
|
352
645
|
}
|
|
646
|
+
ancestors.delete(after);
|
|
353
647
|
}
|
|
354
648
|
|
|
355
649
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
@@ -389,9 +683,15 @@ export interface StoryState {
|
|
|
389
683
|
temporary: Record<string, unknown>;
|
|
390
684
|
history: HistoryMoment[];
|
|
391
685
|
historyIndex: number;
|
|
392
|
-
visitCounts:
|
|
393
|
-
renderCounts:
|
|
686
|
+
visitCounts: Counts;
|
|
687
|
+
renderCounts: Counts;
|
|
394
688
|
knownSaves: Record<string, true>;
|
|
689
|
+
/**
|
|
690
|
+
* The playthrough the running game is in: its saves are grouped under it.
|
|
691
|
+
* Set by boot (the stored current playthrough), restart (a new one),
|
|
692
|
+
* loading a save (the save's) and deleting the current playthrough (a new
|
|
693
|
+
* one). '' until boot has looked it up.
|
|
694
|
+
*/
|
|
395
695
|
playthroughId: string;
|
|
396
696
|
maxHistory: number;
|
|
397
697
|
quickSaveKey: string | null;
|
|
@@ -428,6 +728,12 @@ export interface StoryState {
|
|
|
428
728
|
trackRender: (passageName: string) => void;
|
|
429
729
|
restart: () => void;
|
|
430
730
|
save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
|
|
731
|
+
/**
|
|
732
|
+
* Load the save in a slot and move the game to its playthrough. The switch
|
|
733
|
+
* takes effect in call order (a save issued after the load belongs to the
|
|
734
|
+
* loaded playthrough); the state is applied when the save has been read,
|
|
735
|
+
* unless a restart or another direct load was issued after this load.
|
|
736
|
+
*/
|
|
431
737
|
load: (slot?: string) => Promise<void>;
|
|
432
738
|
hasSave: (slot?: string) => boolean;
|
|
433
739
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
@@ -435,9 +741,18 @@ export interface StoryState {
|
|
|
435
741
|
deleteSave: (slot?: string) => Promise<void>;
|
|
436
742
|
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
437
743
|
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
744
|
+
/**
|
|
745
|
+
* Delete the story's saves and playthroughs and restart. The restart is
|
|
746
|
+
* immediate; the promise settles once the data is deleted.
|
|
747
|
+
*/
|
|
748
|
+
clearGameData: () => Promise<void>;
|
|
749
|
+
/** As clearGameData, for all Spindle data (every story). */
|
|
750
|
+
clearAllData: () => Promise<void>;
|
|
751
|
+
/**
|
|
752
|
+
* Delete a playthrough and its saves. Deleting the current one moves the
|
|
753
|
+
* running game to a new playthrough.
|
|
754
|
+
*/
|
|
755
|
+
deletePlaythrough: (playthroughId: string) => Promise<void>;
|
|
441
756
|
getSavePayload: () => SavePayload;
|
|
442
757
|
/**
|
|
443
758
|
* Start capturing a save, before the `beforesave` hooks run. The returned
|
|
@@ -447,9 +762,15 @@ export interface StoryState {
|
|
|
447
762
|
beginSave: () => () => SavePayload;
|
|
448
763
|
/**
|
|
449
764
|
* Replace the game state with a live (deserialized) payload. `slot` is
|
|
450
|
-
* passed to the `beforeload`/`afterload` events.
|
|
765
|
+
* passed to the `beforeload`/`afterload` events. Pass the save's
|
|
766
|
+
* `playthroughId` when loading a save: the game moves to that playthrough
|
|
767
|
+
* (no change if it is the current one). Restoring the session passes none.
|
|
451
768
|
*/
|
|
452
|
-
loadFromPayload: (
|
|
769
|
+
loadFromPayload: (
|
|
770
|
+
payload: SavePayload,
|
|
771
|
+
slot?: string,
|
|
772
|
+
playthroughId?: string,
|
|
773
|
+
) => void;
|
|
453
774
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
454
775
|
setTransition: (config: TransitionConfig | null) => void;
|
|
455
776
|
setNextTransition: (config: TransitionConfig | null) => void;
|
|
@@ -458,6 +779,94 @@ export interface StoryState {
|
|
|
458
779
|
clearDeferredRender: () => void;
|
|
459
780
|
}
|
|
460
781
|
|
|
782
|
+
const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
|
|
783
|
+
|
|
784
|
+
type StoryRecipe = (draft: Draft<StoryState>) => void;
|
|
785
|
+
|
|
786
|
+
/** Replace a namespace an update left with a prototype by one without. */
|
|
787
|
+
function keepNamespacesBare(draft: Draft<StoryState>): void {
|
|
788
|
+
for (const key of NAMESPACE_KEYS) {
|
|
789
|
+
const ns = draft[key];
|
|
790
|
+
if (!isNamespace(ns)) {
|
|
791
|
+
draft[key] = createNamespace(isDraft(ns) ? current(ns) : ns);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Actions that record, replace or save story state. Called while mutation
|
|
798
|
+
* code runs (by Story.goto, a {link} or {back} the code performs, a
|
|
799
|
+
* watcher), they act in program order: the code's writes so far are
|
|
800
|
+
* committed first, and the code goes on from the state they leave (see
|
|
801
|
+
* runWithCommittedMutations). Outside mutation code they just run.
|
|
802
|
+
*
|
|
803
|
+
* A load from a slot (`load`) takes its place among the save operations
|
|
804
|
+
* (and switches playthroughs) at the call, but applies the save when its
|
|
805
|
+
* read completes, after the code has run.
|
|
806
|
+
*/
|
|
807
|
+
const PROGRAM_ORDER_ACTIONS = [
|
|
808
|
+
'navigate',
|
|
809
|
+
'goBack',
|
|
810
|
+
'goForward',
|
|
811
|
+
'restart',
|
|
812
|
+
'save',
|
|
813
|
+
'load',
|
|
814
|
+
'getSavePayload',
|
|
815
|
+
'loadFromPayload',
|
|
816
|
+
] as const;
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Store middleware (inside `immer`) that every update goes through: the
|
|
820
|
+
* store's own actions and outside `setState` calls alike.
|
|
821
|
+
*
|
|
822
|
+
* - The variable namespaces stay records without a prototype, whatever an
|
|
823
|
+
* update assigns (see utils/namespace.ts).
|
|
824
|
+
* - An update made while mutation code runs follows that code's pending
|
|
825
|
+
* writes, in program order, and reaches its working copies (see
|
|
826
|
+
* routeStoreUpdate in execute-mutation.ts).
|
|
827
|
+
* - The PROGRAM_ORDER_ACTIONS commit running mutation code first.
|
|
828
|
+
*/
|
|
829
|
+
function storyStateGuard(
|
|
830
|
+
creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
|
|
831
|
+
): StateCreator<StoryState, [['zustand/immer', never]], []> {
|
|
832
|
+
return (set, get, api) => {
|
|
833
|
+
const guarded = ((
|
|
834
|
+
updater: Partial<StoryState> | StoryRecipe,
|
|
835
|
+
replace?: boolean,
|
|
836
|
+
) => {
|
|
837
|
+
if (replace) {
|
|
838
|
+
(set as (u: unknown, r: true) => void)(updater, true);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const recipe: StoryRecipe =
|
|
842
|
+
typeof updater === 'function'
|
|
843
|
+
? updater
|
|
844
|
+
: (draft) => {
|
|
845
|
+
Object.assign(draft, updater);
|
|
846
|
+
};
|
|
847
|
+
const routed = routeStoreUpdate(recipe);
|
|
848
|
+
set((draft) => {
|
|
849
|
+
(routed ?? recipe)(draft);
|
|
850
|
+
keepNamespacesBare(draft);
|
|
851
|
+
});
|
|
852
|
+
}) as typeof set;
|
|
853
|
+
api.setState = guarded;
|
|
854
|
+
const state = creator(guarded, get, api);
|
|
855
|
+
for (const name of PROGRAM_ORDER_ACTIONS) {
|
|
856
|
+
const action = state[name] as (...args: unknown[]) => unknown;
|
|
857
|
+
(state as unknown as Record<string, unknown>)[name] = (
|
|
858
|
+
...args: unknown[]
|
|
859
|
+
) => runWithCommittedMutations(() => action(...args));
|
|
860
|
+
}
|
|
861
|
+
return state;
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The story store's middleware: Immer updates, guarded (see above). */
|
|
866
|
+
const storyStore = (
|
|
867
|
+
creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
|
|
868
|
+
) => immer(storyStateGuard(creator));
|
|
869
|
+
|
|
461
870
|
/**
|
|
462
871
|
* Return `p` marked as handled: a caller that ignores the result of a
|
|
463
872
|
* fire-and-forget operation (whose failure is already logged) gets no
|
|
@@ -470,19 +879,19 @@ function handled<T>(p: Promise<T>): Promise<T> {
|
|
|
470
879
|
}
|
|
471
880
|
|
|
472
881
|
export const useStoryStore = create<StoryState>()(
|
|
473
|
-
|
|
882
|
+
storyStore((set, get) => ({
|
|
474
883
|
storyData: null,
|
|
475
884
|
currentPassage: '',
|
|
476
885
|
navigationId: 0,
|
|
477
|
-
variables:
|
|
886
|
+
variables: createNamespace(),
|
|
478
887
|
variableDefaults: {},
|
|
479
|
-
transient:
|
|
888
|
+
transient: createNamespace(),
|
|
480
889
|
transientDefaults: {},
|
|
481
|
-
temporary:
|
|
890
|
+
temporary: createNamespace(),
|
|
482
891
|
history: [],
|
|
483
892
|
historyIndex: -1,
|
|
484
|
-
visitCounts:
|
|
485
|
-
renderCounts:
|
|
893
|
+
visitCounts: createCounts(),
|
|
894
|
+
renderCounts: createCounts(),
|
|
486
895
|
knownSaves: {},
|
|
487
896
|
playthroughId: '',
|
|
488
897
|
maxHistory: 40,
|
|
@@ -496,9 +905,13 @@ export const useStoryStore = create<StoryState>()(
|
|
|
496
905
|
renderDeferred: false,
|
|
497
906
|
|
|
498
907
|
setMaxHistory: (limit: number) => {
|
|
908
|
+
let trimmed = false;
|
|
499
909
|
set((state) => {
|
|
500
910
|
state.maxHistory = Math.max(1, Math.round(limit));
|
|
911
|
+
// A lower limit takes effect at once
|
|
912
|
+
trimmed = trimHistory(state);
|
|
501
913
|
});
|
|
914
|
+
if (trimmed) persistSession(get);
|
|
502
915
|
},
|
|
503
916
|
|
|
504
917
|
setQuickSaveKey: (key: string | null) => {
|
|
@@ -525,7 +938,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
525
938
|
);
|
|
526
939
|
}
|
|
527
940
|
|
|
528
|
-
const initialVars = deepClone(variableDefaults);
|
|
941
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
529
942
|
resetModuleState(deepClone(initialVars));
|
|
530
943
|
|
|
531
944
|
set((state) => {
|
|
@@ -536,9 +949,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
536
949
|
state.navigationId++;
|
|
537
950
|
state.variables = initialVars;
|
|
538
951
|
state.variableDefaults = variableDefaults;
|
|
539
|
-
state.transient = deepClone(transientDefaults);
|
|
952
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
540
953
|
state.transientDefaults = transientDefaults;
|
|
541
|
-
state.temporary =
|
|
954
|
+
state.temporary = createNamespace();
|
|
542
955
|
state.history = [
|
|
543
956
|
{
|
|
544
957
|
passage: startPassage.name,
|
|
@@ -546,43 +959,36 @@ export const useStoryStore = create<StoryState>()(
|
|
|
546
959
|
},
|
|
547
960
|
];
|
|
548
961
|
state.historyIndex = 0;
|
|
549
|
-
state.visitCounts =
|
|
550
|
-
state.renderCounts =
|
|
962
|
+
state.visitCounts = createCounts(null, startPassage.name);
|
|
963
|
+
state.renderCounts = createCounts(null, startPassage.name);
|
|
551
964
|
});
|
|
552
965
|
|
|
553
966
|
// Update lastNavigationVars to the Immer-produced reference
|
|
554
967
|
lastNavigationVars = get().variables;
|
|
555
968
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
state.playthroughId = id;
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Populate knownSaves from IDB so hasSave() works after reload
|
|
574
|
-
const saves = await populateKnownSaves(ifid);
|
|
575
|
-
if (Object.keys(saves).length > 0) {
|
|
969
|
+
replaceStateNow();
|
|
970
|
+
|
|
971
|
+
// Look up the story's playthrough and saves in the background, as a
|
|
972
|
+
// storage operation queued now: saves issued meanwhile are stored
|
|
973
|
+
// after it, tagged with the playthrough it establishes (see
|
|
974
|
+
// resolvePlaythroughId). The current playthrough is stored, so a page
|
|
975
|
+
// refresh stays in the playthrough the game was in, also after a load
|
|
976
|
+
// switched to the loaded save's.
|
|
977
|
+
switchToLookedUpPlaythrough(
|
|
978
|
+
establishPlaythrough(storyData.ifid)
|
|
979
|
+
.then(({ id, knownSaves }) => {
|
|
980
|
+
// So hasSave() works after a reload. Operations issued later
|
|
981
|
+
// update the cache after this.
|
|
576
982
|
set((state) => {
|
|
577
|
-
state.knownSaves =
|
|
983
|
+
state.knownSaves = knownSaves;
|
|
578
984
|
});
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
985
|
+
return id;
|
|
986
|
+
})
|
|
987
|
+
.catch((err) => {
|
|
988
|
+
console.error('spindle: failed to init save system', err);
|
|
989
|
+
return '';
|
|
990
|
+
}),
|
|
991
|
+
);
|
|
586
992
|
},
|
|
587
993
|
|
|
588
994
|
navigate: (passageName: string) => {
|
|
@@ -616,7 +1022,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
616
1022
|
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
617
1023
|
|
|
618
1024
|
set((state) => {
|
|
619
|
-
state.temporary =
|
|
1025
|
+
state.temporary = createNamespace();
|
|
620
1026
|
state.currentPassage = passageName;
|
|
621
1027
|
state.navigationId++;
|
|
622
1028
|
|
|
@@ -635,29 +1041,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
635
1041
|
prng: snapshotPRNG(),
|
|
636
1042
|
});
|
|
637
1043
|
|
|
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
1044
|
state.historyIndex = state.history.length - 1;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
state.
|
|
654
|
-
|
|
1045
|
+
// Trim oldest entries if over the limit
|
|
1046
|
+
trimHistory(state);
|
|
1047
|
+
state.visitCounts = createCounts(state.visitCounts, passageName);
|
|
1048
|
+
state.renderCounts = createCounts(state.renderCounts, passageName);
|
|
655
1049
|
});
|
|
656
1050
|
|
|
657
1051
|
// Watchers react to the completed transition (visit counts, cleared
|
|
658
1052
|
// temporaries). Like beforenavigate changes, their run actions belong
|
|
659
1053
|
// to the entered moment; navigations they request run afterwards.
|
|
660
|
-
|
|
1054
|
+
enteredMoment = {
|
|
1055
|
+
navigationId: get().navigationId,
|
|
1056
|
+
variables: get().variables,
|
|
1057
|
+
};
|
|
661
1058
|
navigationTriggerPhase = true;
|
|
662
1059
|
try {
|
|
663
1060
|
checkTriggersOnNavigation();
|
|
@@ -666,18 +1063,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
666
1063
|
}
|
|
667
1064
|
const deferred = deferredNavigations;
|
|
668
1065
|
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;
|
|
1066
|
+
finishEnteredMoment(get, set);
|
|
681
1067
|
persistSession(get);
|
|
682
1068
|
|
|
683
1069
|
emit('afternavigate', passageName, previousPassage);
|
|
@@ -688,6 +1074,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
688
1074
|
goBack: () => {
|
|
689
1075
|
const { historyIndex } = get();
|
|
690
1076
|
if (historyIndex <= 0) return;
|
|
1077
|
+
finishEnteredMoment(get, set);
|
|
691
1078
|
|
|
692
1079
|
const previousPassage = get().currentPassage;
|
|
693
1080
|
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
@@ -702,7 +1089,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
702
1089
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
703
1090
|
state.navigationId++;
|
|
704
1091
|
state.variables = restoredVars;
|
|
705
|
-
state.temporary =
|
|
1092
|
+
state.temporary = createNamespace();
|
|
706
1093
|
});
|
|
707
1094
|
|
|
708
1095
|
// Restored state is not a change watchers react to
|
|
@@ -717,6 +1104,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
717
1104
|
goForward: () => {
|
|
718
1105
|
const { historyIndex, history: hist } = get();
|
|
719
1106
|
if (historyIndex >= hist.length - 1) return;
|
|
1107
|
+
finishEnteredMoment(get, set);
|
|
720
1108
|
|
|
721
1109
|
const previousPassage = get().currentPassage;
|
|
722
1110
|
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
@@ -731,7 +1119,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
731
1119
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
732
1120
|
state.navigationId++;
|
|
733
1121
|
state.variables = restoredVars;
|
|
734
|
-
state.temporary =
|
|
1122
|
+
state.temporary = createNamespace();
|
|
735
1123
|
});
|
|
736
1124
|
|
|
737
1125
|
// Restored state is not a change watchers react to
|
|
@@ -745,36 +1133,42 @@ export const useStoryStore = create<StoryState>()(
|
|
|
745
1133
|
|
|
746
1134
|
setVariable: (name: string, value: unknown) => {
|
|
747
1135
|
set((state) => {
|
|
1136
|
+
checkVariableName(name, `$${name}`);
|
|
748
1137
|
state.variables[name] = value;
|
|
749
1138
|
});
|
|
750
1139
|
},
|
|
751
1140
|
|
|
752
1141
|
setTemporary: (name: string, value: unknown) => {
|
|
753
1142
|
set((state) => {
|
|
1143
|
+
checkVariableName(name, `_${name}`);
|
|
754
1144
|
state.temporary[name] = value;
|
|
755
1145
|
});
|
|
756
1146
|
},
|
|
757
1147
|
|
|
758
1148
|
deleteVariable: (name: string) => {
|
|
759
1149
|
set((state) => {
|
|
1150
|
+
checkVariableName(name, `$${name}`);
|
|
760
1151
|
delete state.variables[name];
|
|
761
1152
|
});
|
|
762
1153
|
},
|
|
763
1154
|
|
|
764
1155
|
deleteTemporary: (name: string) => {
|
|
765
1156
|
set((state) => {
|
|
1157
|
+
checkVariableName(name, `_${name}`);
|
|
766
1158
|
delete state.temporary[name];
|
|
767
1159
|
});
|
|
768
1160
|
},
|
|
769
1161
|
|
|
770
1162
|
setTransient: (name: string, value: unknown) => {
|
|
771
1163
|
set((state) => {
|
|
1164
|
+
checkVariableName(name, `%${name}`);
|
|
772
1165
|
state.transient[name] = value;
|
|
773
1166
|
});
|
|
774
1167
|
},
|
|
775
1168
|
|
|
776
1169
|
deleteTransient: (name: string) => {
|
|
777
1170
|
set((state) => {
|
|
1171
|
+
checkVariableName(name, `%${name}`);
|
|
778
1172
|
delete state.transient[name];
|
|
779
1173
|
});
|
|
780
1174
|
},
|
|
@@ -787,8 +1181,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
787
1181
|
|
|
788
1182
|
trackRender: (passageName: string) => {
|
|
789
1183
|
set((state) => {
|
|
790
|
-
state.renderCounts
|
|
791
|
-
(state.renderCounts[passageName] ?? 0) + 1;
|
|
1184
|
+
state.renderCounts = createCounts(state.renderCounts, passageName);
|
|
792
1185
|
});
|
|
793
1186
|
},
|
|
794
1187
|
|
|
@@ -809,23 +1202,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
809
1202
|
|
|
810
1203
|
// Switch to the new playthrough now, after beforerestart (whose saves
|
|
811
1204
|
// 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
|
-
);
|
|
1205
|
+
// issued from here on belongs to the new game.
|
|
1206
|
+
switchToNewPlaythrough(storyData.ifid);
|
|
1207
|
+
// A load from a slot issued before the restart must not apply
|
|
1208
|
+
replaceStateNow();
|
|
829
1209
|
|
|
830
1210
|
const keepDeferred = get().renderDeferred;
|
|
831
1211
|
|
|
@@ -834,15 +1214,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
834
1214
|
|
|
835
1215
|
resetPRNG();
|
|
836
1216
|
resetTriggers();
|
|
837
|
-
const initialVars = deepClone(variableDefaults);
|
|
1217
|
+
const initialVars = createNamespace(deepClone(variableDefaults));
|
|
838
1218
|
resetModuleState(deepClone(initialVars));
|
|
839
1219
|
|
|
840
1220
|
set((state) => {
|
|
841
1221
|
state.currentPassage = startPassage.name;
|
|
842
1222
|
state.navigationId++;
|
|
843
1223
|
state.variables = initialVars;
|
|
844
|
-
state.transient = deepClone(transientDefaults);
|
|
845
|
-
state.temporary =
|
|
1224
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
1225
|
+
state.temporary = createNamespace();
|
|
846
1226
|
state.history = [
|
|
847
1227
|
{
|
|
848
1228
|
passage: startPassage.name,
|
|
@@ -850,8 +1230,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
850
1230
|
},
|
|
851
1231
|
];
|
|
852
1232
|
state.historyIndex = 0;
|
|
853
|
-
state.visitCounts =
|
|
854
|
-
state.renderCounts =
|
|
1233
|
+
state.visitCounts = createCounts(null, startPassage.name);
|
|
1234
|
+
state.renderCounts = createCounts(null, startPassage.name);
|
|
855
1235
|
if (!keepDeferred) {
|
|
856
1236
|
state.renderDeferred = false;
|
|
857
1237
|
}
|
|
@@ -880,8 +1260,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
880
1260
|
set((state) => {
|
|
881
1261
|
state.saveError = null;
|
|
882
1262
|
});
|
|
883
|
-
|
|
884
|
-
await quickSave(storyData.ifid,
|
|
1263
|
+
// Queued now, in call order with other storage operations
|
|
1264
|
+
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
885
1265
|
set((state) => {
|
|
886
1266
|
state.knownSaves = {
|
|
887
1267
|
...state.knownSaves,
|
|
@@ -891,8 +1271,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
891
1271
|
}).catch((err) => {
|
|
892
1272
|
console.error('spindle: failed to save', err);
|
|
893
1273
|
set((state) => {
|
|
894
|
-
state.saveError =
|
|
895
|
-
err instanceof Error ? err.message : 'Failed to save';
|
|
1274
|
+
state.saveError = errorMessage(err, 'Failed to save');
|
|
896
1275
|
});
|
|
897
1276
|
throw err;
|
|
898
1277
|
}),
|
|
@@ -906,17 +1285,35 @@ export const useStoryStore = create<StoryState>()(
|
|
|
906
1285
|
set((state) => {
|
|
907
1286
|
state.loadError = null;
|
|
908
1287
|
});
|
|
1288
|
+
// The game moves to the loaded save's playthrough in call order: the
|
|
1289
|
+
// read, queued now, makes it the stored current playthrough, and saves
|
|
1290
|
+
// issued after the load belong to it (a later restart or load moves
|
|
1291
|
+
// the game on, as usual). An empty slot leaves the playthrough as it
|
|
1292
|
+
// is.
|
|
1293
|
+
const previous = resolvePlaythroughId();
|
|
1294
|
+
const read = loadSlotSave(storyData.ifid, slot);
|
|
1295
|
+
const switched = switchToLookedUpPlaythrough(
|
|
1296
|
+
Promise.all([previous, read.catch(() => undefined)]).then(
|
|
1297
|
+
([prev, loaded]) => loaded?.playthroughId || prev,
|
|
1298
|
+
),
|
|
1299
|
+
);
|
|
1300
|
+
const replacement = ++stateReplacementsIssued;
|
|
909
1301
|
return handled(
|
|
910
|
-
|
|
911
|
-
.then((
|
|
912
|
-
|
|
913
|
-
|
|
1302
|
+
read
|
|
1303
|
+
.then(async (loaded) => {
|
|
1304
|
+
// The store names the loaded playthrough before the loaded
|
|
1305
|
+
// state is applied (and `afterload` fires)
|
|
1306
|
+
await switched;
|
|
1307
|
+
if (!loaded) return;
|
|
1308
|
+
// A restart, boot or direct load issued after this one won
|
|
1309
|
+
if (latestStateApplied > replacement) return;
|
|
1310
|
+
slotLoadApplying = replacement;
|
|
1311
|
+
get().loadFromPayload(loaded.payload, slot);
|
|
914
1312
|
})
|
|
915
1313
|
.catch((err) => {
|
|
916
1314
|
console.error('spindle: failed to load save', err);
|
|
917
1315
|
set((state) => {
|
|
918
|
-
state.loadError =
|
|
919
|
-
err instanceof Error ? err.message : 'Failed to load';
|
|
1316
|
+
state.loadError = errorMessage(err, 'Failed to load');
|
|
920
1317
|
});
|
|
921
1318
|
throw err;
|
|
922
1319
|
}),
|
|
@@ -982,50 +1379,83 @@ export const useStoryStore = create<StoryState>()(
|
|
|
982
1379
|
|
|
983
1380
|
clearGameData: () => {
|
|
984
1381
|
const { storyData } = get();
|
|
985
|
-
if (!storyData) return;
|
|
1382
|
+
if (!storyData) return Promise.resolve();
|
|
986
1383
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
.catch((err) => {
|
|
995
|
-
console.error('spindle: failed to clear game data', err);
|
|
1384
|
+
// Queue the clearing, then restart now: the new playthrough is stored
|
|
1385
|
+
// after it, and operations issued from here on belong to the new game.
|
|
1386
|
+
// The slot cache empties once the clearing is done, after operations
|
|
1387
|
+
// issued before it have updated it.
|
|
1388
|
+
const cleared = smClearGameData(storyData.ifid).then(() => {
|
|
1389
|
+
set((state) => {
|
|
1390
|
+
state.knownSaves = {};
|
|
996
1391
|
});
|
|
1392
|
+
});
|
|
1393
|
+
get().restart();
|
|
1394
|
+
return handled(
|
|
1395
|
+
cleared.catch((err) => {
|
|
1396
|
+
console.error('spindle: failed to clear game data', err);
|
|
1397
|
+
throw err;
|
|
1398
|
+
}),
|
|
1399
|
+
);
|
|
997
1400
|
},
|
|
998
1401
|
|
|
999
1402
|
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);
|
|
1403
|
+
// As clearGameData: queue the clearing, then restart now
|
|
1404
|
+
const cleared = smClearAllData().then(() => {
|
|
1405
|
+
set((state) => {
|
|
1406
|
+
state.knownSaves = {};
|
|
1012
1407
|
});
|
|
1408
|
+
});
|
|
1409
|
+
get().restart();
|
|
1410
|
+
return handled(
|
|
1411
|
+
cleared.catch((err) => {
|
|
1412
|
+
console.error('spindle: failed to clear all data', err);
|
|
1413
|
+
throw err;
|
|
1414
|
+
}),
|
|
1415
|
+
);
|
|
1013
1416
|
},
|
|
1014
1417
|
|
|
1015
1418
|
deletePlaythrough: (playthroughId: string) => {
|
|
1016
1419
|
const { storyData } = get();
|
|
1017
|
-
if (!storyData) return;
|
|
1420
|
+
if (!storyData) return Promise.resolve();
|
|
1018
1421
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1422
|
+
// The running game can't go on in a deleted playthrough: its later
|
|
1423
|
+
// saves would belong to no playthrough. It moves to a new one, as on
|
|
1424
|
+
// restart but keeping its state. While the game's playthrough is not
|
|
1425
|
+
// known yet (init is looking it up, or a load from a slot is reading
|
|
1426
|
+
// the save that decides it), the deletion checks the one established.
|
|
1427
|
+
const ifid = storyData.ifid;
|
|
1428
|
+
const current = knownPlaythroughId();
|
|
1429
|
+
const established = playthroughSetup;
|
|
1430
|
+
const replacementId = crypto.randomUUID();
|
|
1431
|
+
const deletion = smDeletePlaythroughData(ifid, playthroughId, {
|
|
1432
|
+
current: current || established,
|
|
1433
|
+
id: replacementId,
|
|
1434
|
+
});
|
|
1435
|
+
if (playthroughId !== '' && playthroughId === current) {
|
|
1436
|
+
switchToPlaythrough(replacementId, deletion);
|
|
1437
|
+
} else if (current === '') {
|
|
1438
|
+
switchToLookedUpPlaythrough(
|
|
1439
|
+
deletion.then(
|
|
1440
|
+
(replaced) => (replaced ? replacementId : established),
|
|
1441
|
+
() => established,
|
|
1442
|
+
),
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return handled(
|
|
1447
|
+
deletion
|
|
1448
|
+
.then(async () => {
|
|
1449
|
+
const known = await populateKnownSaves(storyData.ifid);
|
|
1450
|
+
set((state) => {
|
|
1451
|
+
state.knownSaves = known;
|
|
1452
|
+
});
|
|
1453
|
+
})
|
|
1454
|
+
.catch((err) => {
|
|
1455
|
+
console.error('spindle: failed to delete playthrough', err);
|
|
1456
|
+
throw err;
|
|
1457
|
+
}),
|
|
1458
|
+
);
|
|
1029
1459
|
},
|
|
1030
1460
|
|
|
1031
1461
|
getSavePayload: (): SavePayload => {
|
|
@@ -1047,7 +1477,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1047
1477
|
}
|
|
1048
1478
|
saveHistory.push({
|
|
1049
1479
|
passage: history[i]!.passage,
|
|
1050
|
-
variables:
|
|
1480
|
+
variables: plainCopy(vars),
|
|
1051
1481
|
timestamp: history[i]!.timestamp,
|
|
1052
1482
|
prng: history[i]!.prng,
|
|
1053
1483
|
});
|
|
@@ -1055,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1055
1485
|
|
|
1056
1486
|
return {
|
|
1057
1487
|
passage: currentPassage,
|
|
1058
|
-
variables:
|
|
1488
|
+
variables: plainCopy(variables),
|
|
1059
1489
|
history: saveHistory,
|
|
1060
1490
|
historyIndex,
|
|
1061
1491
|
visitCounts: { ...visitCounts },
|
|
@@ -1073,14 +1503,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1073
1503
|
};
|
|
1074
1504
|
},
|
|
1075
1505
|
|
|
1076
|
-
loadFromPayload: (
|
|
1506
|
+
loadFromPayload: (
|
|
1507
|
+
payload: SavePayload,
|
|
1508
|
+
slot?: string,
|
|
1509
|
+
playthroughId?: string,
|
|
1510
|
+
) => {
|
|
1511
|
+
const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
|
|
1512
|
+
slotLoadApplying = null;
|
|
1077
1513
|
if (payload.history.length === 0) {
|
|
1078
1514
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
1079
1515
|
return;
|
|
1080
1516
|
}
|
|
1517
|
+
latestStateApplied = replacement;
|
|
1081
1518
|
|
|
1082
1519
|
emit('beforeload', slot);
|
|
1083
1520
|
|
|
1521
|
+
// Loading a save moves the game to the save's playthrough, after the
|
|
1522
|
+
// `beforeload` handlers (whose saves belong to the game being left).
|
|
1523
|
+
// Restoring the session passes none: the game stays in its playthrough.
|
|
1524
|
+
const ifid = get().storyData?.ifid;
|
|
1525
|
+
if (playthroughId && ifid) {
|
|
1526
|
+
switchToLoadedPlaythrough(ifid, playthroughId);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1084
1529
|
// Restore the state on entering the saved passage, not the payload's
|
|
1085
1530
|
// live variables: the passage remounts and runs its {set}/{do} again,
|
|
1086
1531
|
// so restoring their results as well would apply them twice. Changes
|
|
@@ -1091,12 +1536,16 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1091
1536
|
// The payload is already live (deserialized at the storage boundary by
|
|
1092
1537
|
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
1093
1538
|
// Convert full snapshots to patch entries
|
|
1094
|
-
const base =
|
|
1539
|
+
const base = createNamespace(
|
|
1540
|
+
deepClone(payload.history[0]?.variables ?? {}),
|
|
1541
|
+
);
|
|
1095
1542
|
const newPatchEntries: PatchEntry[] = [];
|
|
1096
1543
|
|
|
1097
1544
|
let prevVars: Record<string, unknown> = base;
|
|
1098
1545
|
for (let i = 1; i < payload.history.length; i++) {
|
|
1099
|
-
const currVars =
|
|
1546
|
+
const currVars = createNamespace(
|
|
1547
|
+
deepClone(payload.history[i]!.variables),
|
|
1548
|
+
);
|
|
1100
1549
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
1101
1550
|
prevVars = currVars;
|
|
1102
1551
|
}
|
|
@@ -1115,7 +1564,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1115
1564
|
set((state) => {
|
|
1116
1565
|
state.currentPassage = payload.passage;
|
|
1117
1566
|
state.navigationId++;
|
|
1118
|
-
state.variables =
|
|
1567
|
+
state.variables = createNamespace(
|
|
1568
|
+
deepClone(entry?.variables ?? payload.variables),
|
|
1569
|
+
);
|
|
1119
1570
|
state.history = payload.history.map((m) => ({
|
|
1120
1571
|
passage: m.passage,
|
|
1121
1572
|
timestamp: m.timestamp,
|
|
@@ -1125,10 +1576,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1125
1576
|
0,
|
|
1126
1577
|
Math.min(payload.historyIndex, state.history.length - 1),
|
|
1127
1578
|
);
|
|
1128
|
-
|
|
1129
|
-
state
|
|
1130
|
-
state.
|
|
1131
|
-
state.
|
|
1579
|
+
// A save made under a higher limit keeps no more than the limit
|
|
1580
|
+
trimHistory(state);
|
|
1581
|
+
state.visitCounts = createCounts(payload.visitCounts);
|
|
1582
|
+
state.renderCounts = createCounts(payload.renderCounts);
|
|
1583
|
+
state.temporary = createNamespace();
|
|
1584
|
+
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1132
1585
|
});
|
|
1133
1586
|
|
|
1134
1587
|
// Loaded state is not a change watchers react to
|
|
@@ -1150,7 +1603,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1150
1603
|
// Write the loaded game to the session so a refresh restores it
|
|
1151
1604
|
persistSession(get);
|
|
1152
1605
|
|
|
1153
|
-
|
|
1606
|
+
// Draws made by the handlers would shift the passage's replayed rolls
|
|
1607
|
+
withoutDraws(() => emit('afterload', slot));
|
|
1154
1608
|
},
|
|
1155
1609
|
|
|
1156
1610
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|