@rohal12/spindle 0.51.3 → 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 +1294 -737
- 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 +14 -6
- 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 +37 -3
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +70 -25
- 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,
|
|
@@ -162,10 +169,26 @@ export async function createSave(
|
|
|
162
169
|
return record;
|
|
163
170
|
}
|
|
164
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
|
+
*/
|
|
165
187
|
export async function overwriteSave(
|
|
166
188
|
saveId: string,
|
|
167
189
|
payload: SavePayload,
|
|
168
190
|
custom?: Record<string, unknown>,
|
|
191
|
+
playthroughId?: string,
|
|
169
192
|
): Promise<SaveRecord | undefined> {
|
|
170
193
|
const backend = await getBackend();
|
|
171
194
|
const existing = await backend.getSave(saveId);
|
|
@@ -180,7 +203,12 @@ export async function overwriteSave(
|
|
|
180
203
|
const updated: SaveRecord = {
|
|
181
204
|
meta: {
|
|
182
205
|
...existing.meta,
|
|
206
|
+
...(playthroughId ? { playthroughId } : {}),
|
|
183
207
|
updatedAt: new Date().toISOString(),
|
|
208
|
+
title:
|
|
209
|
+
existing.meta.userTitle === true
|
|
210
|
+
? existing.meta.title
|
|
211
|
+
: generateTitle(payload),
|
|
184
212
|
passage: payload.passage,
|
|
185
213
|
...(custom != null
|
|
186
214
|
? { custom: { ...existing.meta.custom, ...custom } }
|
|
@@ -251,6 +279,7 @@ export async function renameSave(
|
|
|
251
279
|
meta: {
|
|
252
280
|
...record.meta,
|
|
253
281
|
title: newTitle,
|
|
282
|
+
userTitle: true,
|
|
254
283
|
updatedAt: new Date().toISOString(),
|
|
255
284
|
},
|
|
256
285
|
};
|
|
@@ -431,7 +460,12 @@ export function quickSave(
|
|
|
431
460
|
const existingId = await backend.getMeta<string>(metaKey);
|
|
432
461
|
|
|
433
462
|
if (existingId) {
|
|
434
|
-
const updated = await overwriteSave(
|
|
463
|
+
const updated = await overwriteSave(
|
|
464
|
+
existingId,
|
|
465
|
+
payload,
|
|
466
|
+
custom,
|
|
467
|
+
playthroughId,
|
|
468
|
+
);
|
|
435
469
|
if (updated) return updated;
|
|
436
470
|
}
|
|
437
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
|
// ---------------------------------------------------------------------------
|
|
@@ -502,6 +530,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
502
530
|
|
|
503
531
|
set((state) => {
|
|
504
532
|
state.storyData = storyData as StoryData;
|
|
533
|
+
// Unknown until the save system has looked it up (below)
|
|
534
|
+
state.playthroughId = '';
|
|
505
535
|
state.currentPassage = startPassage.name;
|
|
506
536
|
state.navigationId++;
|
|
507
537
|
state.variables = initialVars;
|
|
@@ -523,19 +553,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
523
553
|
// Update lastNavigationVars to the Immer-produced reference
|
|
524
554
|
lastNavigationVars = get().variables;
|
|
525
555
|
|
|
526
|
-
// 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.
|
|
527
559
|
const ifid = storyData.ifid;
|
|
528
|
-
|
|
560
|
+
const generation = ++playthroughGeneration;
|
|
561
|
+
playthroughSetup = initSaveSystem()
|
|
529
562
|
.then(async () => {
|
|
530
|
-
const
|
|
531
|
-
|
|
563
|
+
const id =
|
|
564
|
+
(await getCurrentPlaythroughId(ifid)) ??
|
|
565
|
+
(await startNewPlaythrough(ifid));
|
|
566
|
+
// A restart issued meanwhile has already switched playthroughs
|
|
567
|
+
if (generation === playthroughGeneration) {
|
|
532
568
|
set((state) => {
|
|
533
|
-
state.playthroughId =
|
|
534
|
-
});
|
|
535
|
-
} else {
|
|
536
|
-
const newId = await startNewPlaythrough(ifid);
|
|
537
|
-
set((state) => {
|
|
538
|
-
state.playthroughId = newId;
|
|
569
|
+
state.playthroughId = id;
|
|
539
570
|
});
|
|
540
571
|
}
|
|
541
572
|
|
|
@@ -546,10 +577,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
546
577
|
state.knownSaves = saves;
|
|
547
578
|
});
|
|
548
579
|
}
|
|
580
|
+
return id;
|
|
549
581
|
})
|
|
550
|
-
.catch((err) =>
|
|
551
|
-
console.error('spindle: failed to init save system', err)
|
|
552
|
-
|
|
582
|
+
.catch((err) => {
|
|
583
|
+
console.error('spindle: failed to init save system', err);
|
|
584
|
+
return '';
|
|
585
|
+
});
|
|
553
586
|
},
|
|
554
587
|
|
|
555
588
|
navigate: (passageName: string) => {
|
|
@@ -774,6 +807,26 @@ export const useStoryStore = create<StoryState>()(
|
|
|
774
807
|
|
|
775
808
|
emit('beforerestart');
|
|
776
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
|
+
|
|
777
830
|
const keepDeferred = get().renderDeferred;
|
|
778
831
|
|
|
779
832
|
// Clean up all runtime-phase handlers (after beforerestart has fired)
|
|
@@ -814,28 +867,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
814
867
|
emit('storyinit');
|
|
815
868
|
// The storyinit handlers' changes belong to the start moment too
|
|
816
869
|
recordStoryInitState();
|
|
817
|
-
|
|
818
|
-
// Start a new playthrough on restart
|
|
819
|
-
startNewPlaythrough(storyData.ifid)
|
|
820
|
-
.then((newId) => {
|
|
821
|
-
set((state) => {
|
|
822
|
-
state.playthroughId = newId;
|
|
823
|
-
});
|
|
824
|
-
})
|
|
825
|
-
.catch((err) =>
|
|
826
|
-
console.error('spindle: failed to start new playthrough', err),
|
|
827
|
-
);
|
|
828
870
|
},
|
|
829
871
|
|
|
830
872
|
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
831
|
-
const { storyData
|
|
873
|
+
const { storyData } = get();
|
|
832
874
|
if (!storyData) return Promise.resolve();
|
|
875
|
+
// The playthrough current now, not when the write runs
|
|
876
|
+
const playthrough = resolvePlaythroughId();
|
|
833
877
|
|
|
834
878
|
return handled(
|
|
835
879
|
saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
836
880
|
set((state) => {
|
|
837
881
|
state.saveError = null;
|
|
838
882
|
});
|
|
883
|
+
const playthroughId = await playthrough;
|
|
839
884
|
await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
|
|
840
885
|
set((state) => {
|
|
841
886
|
state.knownSaves = {
|
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
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { isDraft } from 'immer';
|
|
2
|
+
|
|
3
|
+
/** Traverse dot-path segments on an object and return the nested value. */
|
|
4
|
+
export function getByPath(
|
|
5
|
+
obj: Record<string, unknown>,
|
|
6
|
+
segments: readonly string[],
|
|
7
|
+
): unknown {
|
|
8
|
+
let current: unknown = obj;
|
|
9
|
+
for (const seg of segments) {
|
|
10
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
11
|
+
current = (current as Record<string, unknown>)[seg];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shallow copy that keeps the prototype, so a registered class instance
|
|
18
|
+
* stays an instance of its class (with the same own keys deepClone copies).
|
|
19
|
+
*/
|
|
20
|
+
function shallowCopy(value: object): Record<string, unknown> {
|
|
21
|
+
let copy: object;
|
|
22
|
+
if (Array.isArray(value)) copy = [];
|
|
23
|
+
else if (value instanceof Map) copy = new Map(value);
|
|
24
|
+
else if (value instanceof Set) copy = new Set(value);
|
|
25
|
+
else if (value instanceof Date) copy = new Date(value.getTime());
|
|
26
|
+
else copy = Object.create(Object.getPrototypeOf(value)) as object;
|
|
27
|
+
return Object.assign(copy, value) as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SetByPathOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Create a plain object for a missing or non-object intermediate instead
|
|
33
|
+
* of throwing.
|
|
34
|
+
*/
|
|
35
|
+
createMissing?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set a value at dot-path `segments` below `root`.
|
|
40
|
+
*
|
|
41
|
+
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
42
|
+
* class instances and everything below them) are copied on the way down and
|
|
43
|
+
* the copy is assigned to its parent, so the write produces new references
|
|
44
|
+
* up to the root. Writing into such an object directly would mutate the
|
|
45
|
+
* instance shared with the previous state and with history, and subscribers
|
|
46
|
+
* would see no change. Outside a draft (a private working copy) the path is
|
|
47
|
+
* written in place.
|
|
48
|
+
*/
|
|
49
|
+
export function setByPath(
|
|
50
|
+
root: Record<string, unknown>,
|
|
51
|
+
segments: readonly string[],
|
|
52
|
+
value: unknown,
|
|
53
|
+
options: SetByPathOptions = {},
|
|
54
|
+
): void {
|
|
55
|
+
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
56
|
+
parent[segments[segments.length - 1]!] = value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Delete the property at dot-path `segments` below `root`, copying
|
|
61
|
+
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
62
|
+
* intermediate is missing or not an object, or the property is absent.
|
|
63
|
+
*/
|
|
64
|
+
export function deleteByPath(
|
|
65
|
+
root: Record<string, unknown>,
|
|
66
|
+
segments: readonly string[],
|
|
67
|
+
): void {
|
|
68
|
+
const last = segments[segments.length - 1]!;
|
|
69
|
+
// Check first, so a no-op delete copies nothing.
|
|
70
|
+
const holder = getByPath(root, segments.slice(0, -1));
|
|
71
|
+
if (holder == null || typeof holder !== 'object' || !(last in holder)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const parent = walkToParent(root, segments, false);
|
|
75
|
+
delete parent[last];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk to the object holding the last of `segments`, copying undrafted
|
|
80
|
+
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
81
|
+
* non-object intermediate is replaced by a plain object when
|
|
82
|
+
* `createMissing` is set, and throws a TypeError otherwise.
|
|
83
|
+
*/
|
|
84
|
+
function walkToParent(
|
|
85
|
+
root: Record<string, unknown>,
|
|
86
|
+
segments: readonly string[],
|
|
87
|
+
createMissing: boolean,
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
const copyOnWrite = isDraft(root);
|
|
90
|
+
let current: Record<string, unknown> = root;
|
|
91
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
92
|
+
const seg = segments[i]!;
|
|
93
|
+
let next = current[seg];
|
|
94
|
+
if (next == null || typeof next !== 'object') {
|
|
95
|
+
if (!createMissing) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? 'null' : typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
next = {};
|
|
101
|
+
current[seg] = next;
|
|
102
|
+
} else if (copyOnWrite && !isDraft(next)) {
|
|
103
|
+
next = shallowCopy(next);
|
|
104
|
+
current[seg] = next;
|
|
105
|
+
}
|
|
106
|
+
current = next as Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
return current;
|
|
109
|
+
}
|