@rohal12/spindle 0.51.2 → 0.51.4
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 +1360 -774
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +19 -16
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +47 -8
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +124 -48
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
package/src/markup/tokenizer.ts
CHANGED
|
@@ -231,9 +231,13 @@ function parseHtmlAttributes(
|
|
|
231
231
|
const attrName = input.slice(attrStart, j);
|
|
232
232
|
if (!attrName) break;
|
|
233
233
|
|
|
234
|
-
// Check for = value
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
// Check for = value. HTML allows whitespace on either side of the =
|
|
235
|
+
// (`id = "x"`); whitespace not followed by = ends a boolean attribute.
|
|
236
|
+
let eqIdx = j;
|
|
237
|
+
while (eqIdx < input.length && /\s/.test(input[eqIdx]!)) eqIdx++;
|
|
238
|
+
if (input[eqIdx] === '=') {
|
|
239
|
+
j = eqIdx + 1; // skip =
|
|
240
|
+
while (j < input.length && /\s/.test(input[j]!)) j++;
|
|
237
241
|
if (input[j] === '"' || input[j] === "'") {
|
|
238
242
|
const quote = input[j]!;
|
|
239
243
|
j++; // skip opening quote
|
|
@@ -395,12 +399,22 @@ export function tokenize(input: string): Token[] {
|
|
|
395
399
|
}
|
|
396
400
|
|
|
397
401
|
while (i < input.length) {
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
// Escaped braces: \{ and \}. Count the whole backslash run so \\{ is a
|
|
403
|
+
// backslash pair before a live brace. In an odd run the last backslash
|
|
404
|
+
// escapes the brace; the even rest stays text, which markdown collapses
|
|
405
|
+
// pair by pair like any other \\ in the passage.
|
|
406
|
+
if (input[i] === '\\') {
|
|
407
|
+
let k = i + 1;
|
|
408
|
+
while (input[k] === '\\') k++;
|
|
409
|
+
const next = input[k];
|
|
410
|
+
if ((next === '{' || next === '}') && (k - i) % 2 === 1) {
|
|
411
|
+
flushText(k - 1);
|
|
412
|
+
tokens.push({ type: 'text', value: next, start: k - 1, end: k + 1 });
|
|
413
|
+
i = k + 1;
|
|
414
|
+
textStart = i;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
i = k;
|
|
404
418
|
continue;
|
|
405
419
|
}
|
|
406
420
|
|
|
@@ -71,12 +71,19 @@ export async function initSaveSystem(): Promise<void> {
|
|
|
71
71
|
|
|
72
72
|
// --- Playthroughs ---
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Store a new playthrough and make it the story's current one. Pass `id` when
|
|
76
|
+
* the caller has already switched to the playthrough (restart() does, so
|
|
77
|
+
* saves issued before this resolves are tagged with it).
|
|
78
|
+
*/
|
|
79
|
+
export async function startNewPlaythrough(
|
|
80
|
+
ifid: string,
|
|
81
|
+
id: string = crypto.randomUUID(),
|
|
82
|
+
): Promise<string> {
|
|
75
83
|
const backend = await getBackend();
|
|
76
84
|
const existing = await backend.getPlaythroughsByIfid(ifid);
|
|
77
85
|
const num = existing.length + 1;
|
|
78
86
|
|
|
79
|
-
const id = crypto.randomUUID();
|
|
80
87
|
const record: PlaythroughRecord = {
|
|
81
88
|
id,
|
|
82
89
|
ifid,
|
|
@@ -99,9 +106,13 @@ export async function getCurrentPlaythroughId(
|
|
|
99
106
|
|
|
100
107
|
/**
|
|
101
108
|
* Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
|
|
102
|
-
* before
|
|
103
|
-
*
|
|
104
|
-
*
|
|
109
|
+
* before the payload is captured, so data a hook sets is part of the save;
|
|
110
|
+
* `aftersave` fires once `write` has stored it. Every user-facing save path
|
|
111
|
+
* goes through here.
|
|
112
|
+
*
|
|
113
|
+
* `beginCapture` is called just before the hooks and returns the function
|
|
114
|
+
* that captures the payload after them, so the capture can tell which
|
|
115
|
+
* variables the hooks changed (the store's `beginSave`).
|
|
105
116
|
*
|
|
106
117
|
* The hook and capture run synchronously, but an error they throw (a failing
|
|
107
118
|
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
@@ -110,12 +121,13 @@ export async function getCurrentPlaythroughId(
|
|
|
110
121
|
export function saveWithHooks<T>(
|
|
111
122
|
slot: string | undefined,
|
|
112
123
|
custom: Record<string, unknown> | undefined,
|
|
113
|
-
|
|
124
|
+
beginCapture: () => () => SavePayload,
|
|
114
125
|
write: (payload: SavePayload) => Promise<T>,
|
|
115
126
|
): Promise<T> {
|
|
116
127
|
try {
|
|
128
|
+
const capture = beginCapture();
|
|
117
129
|
emit('beforesave', slot, custom);
|
|
118
|
-
const payload =
|
|
130
|
+
const payload = capture();
|
|
119
131
|
return write(payload).then((result) => {
|
|
120
132
|
emit('aftersave', slot);
|
|
121
133
|
return result;
|
|
@@ -157,10 +169,26 @@ export async function createSave(
|
|
|
157
169
|
return record;
|
|
158
170
|
}
|
|
159
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Replace the payload of an existing save, keeping its ID and `createdAt`.
|
|
174
|
+
* Pass the current `playthroughId` when the new payload comes from the
|
|
175
|
+
* running game: the save then holds that playthrough's state, so it is
|
|
176
|
+
* grouped and deleted with it rather than with the playthrough that first
|
|
177
|
+
* created the save.
|
|
178
|
+
*
|
|
179
|
+
* Metadata describing the content follows the new payload: `passage`,
|
|
180
|
+
* `updatedAt` and the title. A generated title (SaveTitle passage, title
|
|
181
|
+
* generator or the default "passage - time") is generated again, exactly as
|
|
182
|
+
* for a fresh save, so it never names a passage or state the save no longer
|
|
183
|
+
* holds. A title the player gave the save (renameSave) is kept: it names the
|
|
184
|
+
* save itself rather than describing its content. `custom` is merged, as
|
|
185
|
+
* before, so slot keys and metadata not passed again are kept.
|
|
186
|
+
*/
|
|
160
187
|
export async function overwriteSave(
|
|
161
188
|
saveId: string,
|
|
162
189
|
payload: SavePayload,
|
|
163
190
|
custom?: Record<string, unknown>,
|
|
191
|
+
playthroughId?: string,
|
|
164
192
|
): Promise<SaveRecord | undefined> {
|
|
165
193
|
const backend = await getBackend();
|
|
166
194
|
const existing = await backend.getSave(saveId);
|
|
@@ -175,7 +203,12 @@ export async function overwriteSave(
|
|
|
175
203
|
const updated: SaveRecord = {
|
|
176
204
|
meta: {
|
|
177
205
|
...existing.meta,
|
|
206
|
+
...(playthroughId ? { playthroughId } : {}),
|
|
178
207
|
updatedAt: new Date().toISOString(),
|
|
208
|
+
title:
|
|
209
|
+
existing.meta.userTitle === true
|
|
210
|
+
? existing.meta.title
|
|
211
|
+
: generateTitle(payload),
|
|
179
212
|
passage: payload.passage,
|
|
180
213
|
...(custom != null
|
|
181
214
|
? { custom: { ...existing.meta.custom, ...custom } }
|
|
@@ -246,6 +279,7 @@ export async function renameSave(
|
|
|
246
279
|
meta: {
|
|
247
280
|
...record.meta,
|
|
248
281
|
title: newTitle,
|
|
282
|
+
userTitle: true,
|
|
249
283
|
updatedAt: new Date().toISOString(),
|
|
250
284
|
},
|
|
251
285
|
};
|
|
@@ -426,7 +460,12 @@ export function quickSave(
|
|
|
426
460
|
const existingId = await backend.getMeta<string>(metaKey);
|
|
427
461
|
|
|
428
462
|
if (existingId) {
|
|
429
|
-
const updated = await overwriteSave(
|
|
463
|
+
const updated = await overwriteSave(
|
|
464
|
+
existingId,
|
|
465
|
+
payload,
|
|
466
|
+
custom,
|
|
467
|
+
playthroughId,
|
|
468
|
+
);
|
|
430
469
|
if (updated) return updated;
|
|
431
470
|
}
|
|
432
471
|
|
package/src/saves/storage.ts
CHANGED
|
@@ -154,6 +154,14 @@ export function createLocalStorageBackend(): StorageBackend {
|
|
|
154
154
|
|
|
155
155
|
async putSave(record: SaveRecord): Promise<void> {
|
|
156
156
|
const id = record.meta.id;
|
|
157
|
+
// A save rewritten under another playthrough (an overwrite after a
|
|
158
|
+
// restart) leaves its old playthrough's index
|
|
159
|
+
const previous = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
|
|
160
|
+
if (previous && previous.meta.playthroughId !== record.meta.playthroughId)
|
|
161
|
+
lsIndexRemove(
|
|
162
|
+
`${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`,
|
|
163
|
+
id,
|
|
164
|
+
);
|
|
157
165
|
lsSet(`${LS_SAVE_PREFIX}${id}`, record);
|
|
158
166
|
lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
|
|
159
167
|
lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
|
package/src/saves/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PRNGSnapshot } from '../prng';
|
|
2
|
+
import { isDeserializable } from '../class-registry';
|
|
2
3
|
|
|
3
4
|
/** History moment as persisted in saves (full variable snapshots). */
|
|
4
5
|
export interface SaveHistoryMoment {
|
|
@@ -25,6 +26,11 @@ export interface SaveMeta {
|
|
|
25
26
|
createdAt: string;
|
|
26
27
|
updatedAt: string;
|
|
27
28
|
title: string;
|
|
29
|
+
/**
|
|
30
|
+
* True once the player has named the save (renameSave). Overwriting the
|
|
31
|
+
* save keeps such a title; a generated one is generated anew.
|
|
32
|
+
*/
|
|
33
|
+
userTitle?: boolean;
|
|
28
34
|
passage: string;
|
|
29
35
|
custom: Record<string, unknown>;
|
|
30
36
|
estimatedBytes?: number;
|
|
@@ -97,11 +103,16 @@ function isOptionalPRNGSnapshot(value: unknown): boolean {
|
|
|
97
103
|
);
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
/** A variable container whose values deserialize() can restore. */
|
|
107
|
+
function isSerializedVariables(value: unknown): boolean {
|
|
108
|
+
return isRecord(value) && isDeserializable(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
|
|
101
112
|
return (
|
|
102
113
|
isRecord(value) &&
|
|
103
114
|
typeof value.passage === 'string' &&
|
|
104
|
-
|
|
115
|
+
isSerializedVariables(value.variables) &&
|
|
105
116
|
typeof value.timestamp === 'number' &&
|
|
106
117
|
isOptionalPRNGSnapshot(value.prng)
|
|
107
118
|
);
|
|
@@ -109,14 +120,16 @@ function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
|
|
|
109
120
|
|
|
110
121
|
/**
|
|
111
122
|
* Full check of a payload from outside the running story (an imported save):
|
|
112
|
-
* every history moment is well formed,
|
|
113
|
-
* points at a moment of the payload's passage
|
|
114
|
-
*
|
|
123
|
+
* every history moment is well formed, `historyIndex` is an integer that
|
|
124
|
+
* points at a moment of the payload's passage, and the encoded values (Map,
|
|
125
|
+
* Set, Date, RegExp, class instances) in the live variables and every
|
|
126
|
+
* moment's variables have the shape deserialize() expects. Anything that
|
|
127
|
+
* passes can be stored and later loaded.
|
|
115
128
|
*/
|
|
116
129
|
function isImportablePayload(value: unknown): value is SavePayload {
|
|
117
130
|
if (!isRecord(value)) return false;
|
|
118
131
|
if (typeof value.passage !== 'string') return false;
|
|
119
|
-
if (!
|
|
132
|
+
if (!isSerializedVariables(value.variables)) return false;
|
|
120
133
|
|
|
121
134
|
const { history, historyIndex } = value;
|
|
122
135
|
if (!Array.isArray(history) || history.length === 0) return false;
|
package/src/store.ts
CHANGED
|
@@ -234,6 +234,34 @@ export function recordStoryInitState(): void {
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Playthrough setup
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Settles once the latest playthrough setup (init's lookup or creation, or a
|
|
243
|
+
* restart's creation) is stored, with that setup's playthrough ID ('' if
|
|
244
|
+
* init could not establish one). Each setup chains on the previous one, so
|
|
245
|
+
* playthroughs are created and numbered in the order the game started them.
|
|
246
|
+
*/
|
|
247
|
+
let playthroughSetup: Promise<string> = Promise.resolve('');
|
|
248
|
+
|
|
249
|
+
/** Bumped by every init()/restart(); a stale init must not adopt its ID. */
|
|
250
|
+
let playthroughGeneration = 0;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The playthrough a save issued now belongs to, once its record is stored.
|
|
254
|
+
* Read synchronously at the call: restart() switches the store's
|
|
255
|
+
* `playthroughId` at once, so a save issued after it (even before the new
|
|
256
|
+
* playthrough is stored) belongs to the new playthrough, and a later restart
|
|
257
|
+
* doesn't move it. Before init() has looked up the stored playthrough the
|
|
258
|
+
* store's ID is '', and the save takes the one init establishes.
|
|
259
|
+
*/
|
|
260
|
+
export function resolvePlaythroughId(): Promise<string> {
|
|
261
|
+
const current = useStoryStore.getState().playthroughId;
|
|
262
|
+
return playthroughSetup.then((established) => current || established);
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
// ---------------------------------------------------------------------------
|
|
238
266
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
239
267
|
// ---------------------------------------------------------------------------
|
|
@@ -297,6 +325,33 @@ function loadedEntryMoment(
|
|
|
297
325
|
return moment;
|
|
298
326
|
}
|
|
299
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Copy the variables that changed between `before` and `after` (the live
|
|
330
|
+
* variables around the `beforesave` hooks) into the payload's snapshot of the
|
|
331
|
+
* saved moment. A load restores that snapshot, the state on entering the
|
|
332
|
+
* passage, and runs the passage again, so data a hook adds to a save would
|
|
333
|
+
* otherwise be lost on load (#227). Only the payload's copy changes: the live
|
|
334
|
+
* history keeps the recorded snapshot (#159). Store updates are immutable, so
|
|
335
|
+
* a value a hook did not touch keeps its identity.
|
|
336
|
+
*/
|
|
337
|
+
function keepHookWrites(
|
|
338
|
+
payload: SavePayload,
|
|
339
|
+
before: Record<string, unknown>,
|
|
340
|
+
after: Record<string, unknown>,
|
|
341
|
+
): void {
|
|
342
|
+
const moment = payload.history[payload.historyIndex];
|
|
343
|
+
if (!moment) return;
|
|
344
|
+
const has = (vars: Record<string, unknown>, key: string) =>
|
|
345
|
+
Object.prototype.hasOwnProperty.call(vars, key);
|
|
346
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
347
|
+
if (!has(after, key)) {
|
|
348
|
+
delete moment.variables[key];
|
|
349
|
+
} else if (!has(before, key) || after[key] !== before[key]) {
|
|
350
|
+
moment.variables[key] = deepClone(after[key]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
300
355
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
301
356
|
function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
302
357
|
if (moment?.prng) {
|
|
@@ -384,6 +439,12 @@ export interface StoryState {
|
|
|
384
439
|
clearAllData: () => void;
|
|
385
440
|
deletePlaythrough: (playthroughId: string) => void;
|
|
386
441
|
getSavePayload: () => SavePayload;
|
|
442
|
+
/**
|
|
443
|
+
* Start capturing a save, before the `beforesave` hooks run. The returned
|
|
444
|
+
* function builds the payload after them, and keeps the variables the
|
|
445
|
+
* hooks changed in the saved moment's snapshot, so a load restores them.
|
|
446
|
+
*/
|
|
447
|
+
beginSave: () => () => SavePayload;
|
|
387
448
|
/**
|
|
388
449
|
* Replace the game state with a live (deserialized) payload. `slot` is
|
|
389
450
|
* passed to the `beforeload`/`afterload` events.
|
|
@@ -469,6 +530,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
469
530
|
|
|
470
531
|
set((state) => {
|
|
471
532
|
state.storyData = storyData as StoryData;
|
|
533
|
+
// Unknown until the save system has looked it up (below)
|
|
534
|
+
state.playthroughId = '';
|
|
472
535
|
state.currentPassage = startPassage.name;
|
|
473
536
|
state.navigationId++;
|
|
474
537
|
state.variables = initialVars;
|
|
@@ -490,19 +553,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
490
553
|
// Update lastNavigationVars to the Immer-produced reference
|
|
491
554
|
lastNavigationVars = get().variables;
|
|
492
555
|
|
|
493
|
-
// Init save system
|
|
556
|
+
// Init save system in the background. Saves issued meanwhile wait for
|
|
557
|
+
// it (see resolvePlaythroughId), so they are tagged with the
|
|
558
|
+
// playthrough it establishes and recorded after the known saves.
|
|
494
559
|
const ifid = storyData.ifid;
|
|
495
|
-
|
|
560
|
+
const generation = ++playthroughGeneration;
|
|
561
|
+
playthroughSetup = initSaveSystem()
|
|
496
562
|
.then(async () => {
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
} else {
|
|
503
|
-
const newId = await startNewPlaythrough(ifid);
|
|
563
|
+
const id =
|
|
564
|
+
(await getCurrentPlaythroughId(ifid)) ??
|
|
565
|
+
(await startNewPlaythrough(ifid));
|
|
566
|
+
// A restart issued meanwhile has already switched playthroughs
|
|
567
|
+
if (generation === playthroughGeneration) {
|
|
504
568
|
set((state) => {
|
|
505
|
-
state.playthroughId =
|
|
569
|
+
state.playthroughId = id;
|
|
506
570
|
});
|
|
507
571
|
}
|
|
508
572
|
|
|
@@ -513,10 +577,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
513
577
|
state.knownSaves = saves;
|
|
514
578
|
});
|
|
515
579
|
}
|
|
580
|
+
return id;
|
|
516
581
|
})
|
|
517
|
-
.catch((err) =>
|
|
518
|
-
console.error('spindle: failed to init save system', err)
|
|
519
|
-
|
|
582
|
+
.catch((err) => {
|
|
583
|
+
console.error('spindle: failed to init save system', err);
|
|
584
|
+
return '';
|
|
585
|
+
});
|
|
520
586
|
},
|
|
521
587
|
|
|
522
588
|
navigate: (passageName: string) => {
|
|
@@ -741,6 +807,26 @@ export const useStoryStore = create<StoryState>()(
|
|
|
741
807
|
|
|
742
808
|
emit('beforerestart');
|
|
743
809
|
|
|
810
|
+
// Switch to the new playthrough now, after beforerestart (whose saves
|
|
811
|
+
// belong to the game being left) and before StoryInit, so every save
|
|
812
|
+
// issued from here on belongs to the new game. Storing its record is
|
|
813
|
+
// queued after the previous playthrough setup.
|
|
814
|
+
const newPlaythroughId = crypto.randomUUID();
|
|
815
|
+
++playthroughGeneration;
|
|
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
|
+
);
|
|
829
|
+
|
|
744
830
|
const keepDeferred = get().renderDeferred;
|
|
745
831
|
|
|
746
832
|
// Clean up all runtime-phase handlers (after beforerestart has fired)
|
|
@@ -781,47 +867,28 @@ export const useStoryStore = create<StoryState>()(
|
|
|
781
867
|
emit('storyinit');
|
|
782
868
|
// The storyinit handlers' changes belong to the start moment too
|
|
783
869
|
recordStoryInitState();
|
|
784
|
-
|
|
785
|
-
// Start a new playthrough on restart
|
|
786
|
-
startNewPlaythrough(storyData.ifid)
|
|
787
|
-
.then((newId) => {
|
|
788
|
-
set((state) => {
|
|
789
|
-
state.playthroughId = newId;
|
|
790
|
-
});
|
|
791
|
-
})
|
|
792
|
-
.catch((err) =>
|
|
793
|
-
console.error('spindle: failed to start new playthrough', err),
|
|
794
|
-
);
|
|
795
870
|
},
|
|
796
871
|
|
|
797
872
|
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
798
|
-
const { storyData
|
|
873
|
+
const { storyData } = get();
|
|
799
874
|
if (!storyData) return Promise.resolve();
|
|
875
|
+
// The playthrough current now, not when the write runs
|
|
876
|
+
const playthrough = resolvePlaythroughId();
|
|
800
877
|
|
|
801
878
|
return handled(
|
|
802
|
-
saveWithHooks(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
custom,
|
|
816
|
-
);
|
|
817
|
-
set((state) => {
|
|
818
|
-
state.knownSaves = {
|
|
819
|
-
...state.knownSaves,
|
|
820
|
-
[slot ?? '']: true,
|
|
821
|
-
};
|
|
822
|
-
});
|
|
823
|
-
},
|
|
824
|
-
).catch((err) => {
|
|
879
|
+
saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
880
|
+
set((state) => {
|
|
881
|
+
state.saveError = null;
|
|
882
|
+
});
|
|
883
|
+
const playthroughId = await playthrough;
|
|
884
|
+
await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
|
|
885
|
+
set((state) => {
|
|
886
|
+
state.knownSaves = {
|
|
887
|
+
...state.knownSaves,
|
|
888
|
+
[slot ?? '']: true,
|
|
889
|
+
};
|
|
890
|
+
});
|
|
891
|
+
}).catch((err) => {
|
|
825
892
|
console.error('spindle: failed to save', err);
|
|
826
893
|
set((state) => {
|
|
827
894
|
state.saveError =
|
|
@@ -997,6 +1064,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
997
1064
|
};
|
|
998
1065
|
},
|
|
999
1066
|
|
|
1067
|
+
beginSave: () => {
|
|
1068
|
+
const before = get().variables;
|
|
1069
|
+
return () => {
|
|
1070
|
+
const payload = get().getSavePayload();
|
|
1071
|
+
keepHookWrites(payload, before, get().variables);
|
|
1072
|
+
return payload;
|
|
1073
|
+
};
|
|
1074
|
+
},
|
|
1075
|
+
|
|
1000
1076
|
loadFromPayload: (payload: SavePayload, slot?: string) => {
|
|
1001
1077
|
if (payload.history.length === 0) {
|
|
1002
1078
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
package/src/story-api.ts
CHANGED
|
@@ -25,6 +25,13 @@ import {
|
|
|
25
25
|
} from './saves/save-manager';
|
|
26
26
|
import { getBackendType } from './saves/storage';
|
|
27
27
|
import { registerClass } from './class-registry';
|
|
28
|
+
import {
|
|
29
|
+
frozenCopy,
|
|
30
|
+
getActiveMutationScope,
|
|
31
|
+
mirrorWriteToActiveScopes,
|
|
32
|
+
runWithCommittedMutations,
|
|
33
|
+
} from './execute-mutation';
|
|
34
|
+
import { getByPath, setByPath } from './utils/object-path';
|
|
28
35
|
import { defineMacro } from './define-macro';
|
|
29
36
|
import type { MacroDefinition } from './define-macro';
|
|
30
37
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
@@ -116,37 +123,6 @@ function ensureVariableChangedSubscription(): void {
|
|
|
116
123
|
});
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
/** Traverse a dot-delimited path on an object and return the value. */
|
|
120
|
-
function getByPath(obj: Record<string, unknown>, path: string): unknown {
|
|
121
|
-
const segments = path.split('.');
|
|
122
|
-
let current: unknown = obj[segments[0]!];
|
|
123
|
-
for (let i = 1; i < segments.length; i++) {
|
|
124
|
-
if (current == null) return undefined;
|
|
125
|
-
current = (current as Record<string, unknown>)[segments[i]!];
|
|
126
|
-
}
|
|
127
|
-
return current;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
|
|
131
|
-
function setByPath(
|
|
132
|
-
obj: Record<string, unknown>,
|
|
133
|
-
path: string,
|
|
134
|
-
value: unknown,
|
|
135
|
-
): void {
|
|
136
|
-
const segments = path.split('.');
|
|
137
|
-
let current: Record<string, unknown> = obj;
|
|
138
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
139
|
-
const next = current[segments[i]!];
|
|
140
|
-
if (next == null || typeof next !== 'object') {
|
|
141
|
-
throw new TypeError(
|
|
142
|
-
`spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
current = next as Record<string, unknown>;
|
|
146
|
-
}
|
|
147
|
-
current[segments[segments.length - 1]!] = value;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
126
|
export interface StoryAPI {
|
|
151
127
|
get(name: string): unknown;
|
|
152
128
|
set(name: string, value: unknown): void;
|
|
@@ -292,7 +268,7 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
|
|
|
292
268
|
const namespace = isTransient ? draft.transient : draft.variables;
|
|
293
269
|
|
|
294
270
|
if (key.includes('.')) {
|
|
295
|
-
setByPath(namespace, key, value);
|
|
271
|
+
setByPath(namespace, key.split('.'), value);
|
|
296
272
|
} else {
|
|
297
273
|
namespace[key] = value;
|
|
298
274
|
}
|
|
@@ -302,49 +278,74 @@ function createStoryAPI(): StoryAPI {
|
|
|
302
278
|
return {
|
|
303
279
|
get(name: string): unknown {
|
|
304
280
|
const { isTransient, key } = parseName(name);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
281
|
+
// Mutation code running now ({do}, ctx.mutate, watcher run actions)
|
|
282
|
+
// has pending writes in its working copy: read that, so the code sees
|
|
283
|
+
// its own changes. The value is a frozen copy, like the frozen store
|
|
284
|
+
// values returned otherwise, so writing to it cannot change the
|
|
285
|
+
// pending state outside the code's own assignments.
|
|
286
|
+
const scope = getActiveMutationScope();
|
|
287
|
+
const source = scope ?? useStoryStore.getState();
|
|
288
|
+
const namespace = isTransient ? source.transient : source.variables;
|
|
289
|
+
const value = key.includes('.')
|
|
290
|
+
? getByPath(namespace, key.split('.'))
|
|
291
|
+
: namespace[key];
|
|
292
|
+
return scope ? frozenCopy(value) : value;
|
|
309
293
|
},
|
|
310
294
|
|
|
311
295
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
|
312
|
-
const
|
|
313
|
-
typeof nameOrVars === 'string'
|
|
314
|
-
|
|
296
|
+
const entries: [string, unknown][] =
|
|
297
|
+
typeof nameOrVars === 'string'
|
|
298
|
+
? [[nameOrVars, value]]
|
|
299
|
+
: Object.entries(nameOrVars);
|
|
300
|
+
for (const [name] of entries) {
|
|
315
301
|
const { isTransient, key } = parseName(name);
|
|
316
302
|
warnIfUndeclared(isTransient, key);
|
|
317
303
|
}
|
|
318
304
|
// One store update for all keys, so watchers see them together
|
|
319
305
|
useStoryStore.getState().updateVariables((draft) => {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
306
|
+
for (const [k, v] of entries) setOne(draft, k, v);
|
|
307
|
+
// Mutation code running now ({do}, ctx.mutate, watcher run actions)
|
|
308
|
+
// works on copies of the namespaces and commits the paths it changed
|
|
309
|
+
// when it finishes. Apply the write to those copies too, so the code
|
|
310
|
+
// sees it and its commit keeps it in program order (#215). This runs
|
|
311
|
+
// inside the update, before watchers it triggers write.
|
|
312
|
+
for (const [k, v] of entries) {
|
|
313
|
+
const { isTransient, key } = parseName(k);
|
|
314
|
+
mirrorWriteToActiveScopes(
|
|
315
|
+
draft,
|
|
316
|
+
isTransient ? 'transient' : 'variables',
|
|
317
|
+
key.split('.'),
|
|
318
|
+
v,
|
|
319
|
+
);
|
|
326
320
|
}
|
|
327
321
|
});
|
|
328
322
|
},
|
|
329
323
|
|
|
324
|
+
// Called from running mutation code, these commit the code's writes so
|
|
325
|
+
// far before they record, replace or save state, and the code goes on
|
|
326
|
+
// from the state they leave (see runWithCommittedMutations).
|
|
330
327
|
goto(passageName: string): void {
|
|
331
|
-
|
|
328
|
+
runWithCommittedMutations(() =>
|
|
329
|
+
useStoryStore.getState().navigate(passageName),
|
|
330
|
+
);
|
|
332
331
|
},
|
|
333
332
|
|
|
334
333
|
back(): void {
|
|
335
|
-
useStoryStore.getState().goBack();
|
|
334
|
+
runWithCommittedMutations(() => useStoryStore.getState().goBack());
|
|
336
335
|
},
|
|
337
336
|
|
|
338
337
|
forward(): void {
|
|
339
|
-
useStoryStore.getState().goForward();
|
|
338
|
+
runWithCommittedMutations(() => useStoryStore.getState().goForward());
|
|
340
339
|
},
|
|
341
340
|
|
|
342
341
|
restart(): void {
|
|
343
|
-
useStoryStore.getState().restart();
|
|
342
|
+
runWithCommittedMutations(() => useStoryStore.getState().restart());
|
|
344
343
|
},
|
|
345
344
|
|
|
346
345
|
save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
|
|
347
|
-
return
|
|
346
|
+
return runWithCommittedMutations(() =>
|
|
347
|
+
useStoryStore.getState().save(slot, custom),
|
|
348
|
+
);
|
|
348
349
|
},
|
|
349
350
|
|
|
350
351
|
load(slot?: string): Promise<void> {
|
package/src/triggers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluate } from './expression';
|
|
2
|
-
import { executeMutation } from './execute-mutation';
|
|
2
|
+
import { executeMutation, runWithCommittedMutations } from './execute-mutation';
|
|
3
3
|
import { useStoryStore } from './store';
|
|
4
4
|
|
|
5
5
|
export interface WatchOptions {
|
|
@@ -149,7 +149,10 @@ function fireTrigger(trigger: Trigger): void {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
if (options.goto) {
|
|
152
|
-
|
|
152
|
+
// A watcher fired by a Story.set in running code navigates after that
|
|
153
|
+
// code's writes so far, so they are part of the moment it leaves.
|
|
154
|
+
const passage = options.goto;
|
|
155
|
+
runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
|
|
153
156
|
}
|
|
154
157
|
}
|
|
155
158
|
|