@rohal12/spindle 0.59.2 → 0.59.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 +171 -69
- package/package.json +1 -1
- package/src/components/macros/Include.tsx +7 -2
- package/src/execute-mutation.ts +161 -108
- package/src/saves/save-manager.ts +6 -6
- package/src/saves/storage.ts +24 -1
- package/src/story-api.ts +15 -1
- package/src/structural.ts +54 -28
- package/src/utils/object-path.ts +3 -5
package/src/execute-mutation.ts
CHANGED
|
@@ -21,10 +21,11 @@ import {
|
|
|
21
21
|
locateObjects,
|
|
22
22
|
mergeKeys,
|
|
23
23
|
pathKey,
|
|
24
|
+
sharedPaths,
|
|
24
25
|
underChange,
|
|
25
26
|
type PathChange,
|
|
26
27
|
} from './structural';
|
|
27
|
-
import { getByPath } from './utils/object-path';
|
|
28
|
+
import { getByPath, setByPath } from './utils/object-path';
|
|
28
29
|
import { asNamespace, hasOwn } from './utils/namespace';
|
|
29
30
|
|
|
30
31
|
type NamespaceName = keyof VariableNamespaces;
|
|
@@ -76,7 +77,11 @@ export function readState(): StoryState {
|
|
|
76
77
|
const cloneValue = <T>(value: T): T =>
|
|
77
78
|
deepClone(value, { keepUnregistered: true });
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Copies values for one target (the three variable namespaces, so an object
|
|
82
|
+
* shared between them is shared there too), and what the values share they
|
|
83
|
+
* share there too.
|
|
84
|
+
*/
|
|
80
85
|
type Cloner = <T>(value: T) => T;
|
|
81
86
|
|
|
82
87
|
/**
|
|
@@ -87,7 +92,7 @@ type Cloner = <T>(value: T) => T;
|
|
|
87
92
|
* one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
|
|
88
93
|
*/
|
|
89
94
|
function clonerFor(
|
|
90
|
-
target:
|
|
95
|
+
target: object,
|
|
91
96
|
paths?: ReadonlyMap<object, string[]>,
|
|
92
97
|
): Cloner {
|
|
93
98
|
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
@@ -96,10 +101,10 @@ function clonerFor(
|
|
|
96
101
|
|
|
97
102
|
/** One cloner per target copy, made when first used. */
|
|
98
103
|
const clonersFor = (
|
|
99
|
-
paths?: (target:
|
|
104
|
+
paths?: (target: object) => ReadonlyMap<object, string[]>,
|
|
100
105
|
) => {
|
|
101
106
|
const made = new Map<object, Cloner>();
|
|
102
|
-
return (target:
|
|
107
|
+
return (target: object): Cloner => {
|
|
103
108
|
let cloner = made.get(target);
|
|
104
109
|
if (!cloner) {
|
|
105
110
|
cloner = clonerFor(target, paths?.(target));
|
|
@@ -129,14 +134,13 @@ export function frozenCopy<T>(value: T): T {
|
|
|
129
134
|
*/
|
|
130
135
|
function mirror(
|
|
131
136
|
draft: VariableNamespaces,
|
|
132
|
-
ns: NamespaceName,
|
|
133
137
|
change: PathChange,
|
|
134
138
|
scopes: readonly MutationScope[] = activeScopes,
|
|
135
|
-
cloner: (target:
|
|
139
|
+
cloner: (target: object) => Cloner = clonerOf,
|
|
136
140
|
): void {
|
|
137
|
-
const root = change.path[
|
|
141
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
138
142
|
for (const scope of scopes) {
|
|
139
|
-
for (const copy of [scope.work
|
|
143
|
+
for (const copy of [scope.work, scope.base]) {
|
|
140
144
|
try {
|
|
141
145
|
applyChange(
|
|
142
146
|
copy,
|
|
@@ -147,11 +151,11 @@ function mirror(
|
|
|
147
151
|
} catch {
|
|
148
152
|
const stored = draft[ns][root];
|
|
149
153
|
if (hasOwn(draft[ns], root)) {
|
|
150
|
-
copy[root] = cloneValue(
|
|
154
|
+
copy[ns][root] = cloneValue(
|
|
151
155
|
isDraft(stored) ? currentDraft(stored) : stored,
|
|
152
156
|
);
|
|
153
157
|
} else {
|
|
154
|
-
delete copy[root];
|
|
158
|
+
delete copy[ns][root];
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
}
|
|
@@ -159,7 +163,7 @@ function mirror(
|
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
/** A cloner of a copy of its own (no references shared with others). */
|
|
162
|
-
const clonerOf = (target:
|
|
166
|
+
const clonerOf = (target: object): Cloner => clonerFor(target);
|
|
163
167
|
|
|
164
168
|
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
165
169
|
|
|
@@ -279,27 +283,23 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
279
283
|
view,
|
|
280
284
|
recipe as (draft: Draft<S>) => void,
|
|
281
285
|
);
|
|
282
|
-
|
|
286
|
+
// Paths below the namespaces, so one cloner and one application serve all
|
|
287
|
+
const changes = NAMESPACES.flatMap((ns) => {
|
|
283
288
|
const own = patches.filter((p) => p.path[0] === ns);
|
|
284
|
-
return
|
|
285
|
-
ns,
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
return own.length
|
|
290
|
+
? writtenPaths(view[ns], next[ns], own).map(
|
|
291
|
+
(change): PathChange => ({ ...change, path: [ns, ...change.path] }),
|
|
292
|
+
)
|
|
293
|
+
: [];
|
|
288
294
|
});
|
|
289
295
|
// Each target takes its own copies, which share what the written values do
|
|
290
296
|
const cloners = clonersFor();
|
|
291
|
-
const owned = (
|
|
292
|
-
change: PathChange,
|
|
293
|
-
target: Record<string, unknown>,
|
|
294
|
-
): PathChange =>
|
|
297
|
+
const owned = (change: PathChange, target: object): PathChange =>
|
|
295
298
|
change.deleted
|
|
296
299
|
? change
|
|
297
300
|
: { ...change, value: cloners(target)(change.value) };
|
|
298
|
-
for (const
|
|
299
|
-
|
|
300
|
-
applyChange(inner.work[ns], owned(change, inner.work[ns]));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
301
|
+
for (const change of changes)
|
|
302
|
+
applyChange(inner.work, owned(change, inner.work));
|
|
303
303
|
|
|
304
304
|
return (draft) => {
|
|
305
305
|
const target = draft as unknown as Record<string, unknown>;
|
|
@@ -313,29 +313,62 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
const namespaces = draft as unknown as VariableNamespaces;
|
|
316
|
-
for (const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
delete namespaces[ns][root];
|
|
326
|
-
}
|
|
316
|
+
for (const change of changes) {
|
|
317
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
318
|
+
try {
|
|
319
|
+
applyChange(namespaces, owned(change, namespaces));
|
|
320
|
+
} catch {
|
|
321
|
+
if (hasOwn(inner.work[ns], root)) {
|
|
322
|
+
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
323
|
+
} else {
|
|
324
|
+
delete namespaces[ns][root];
|
|
327
325
|
}
|
|
328
|
-
mirror(namespaces, ns, change, activeScopes, cloners);
|
|
329
326
|
}
|
|
327
|
+
mirror(namespaces, change, activeScopes, cloners);
|
|
330
328
|
}
|
|
331
329
|
};
|
|
332
330
|
}
|
|
333
331
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Make the objects `work` holds at several paths one object in `draft`
|
|
334
|
+
* again where `changes` wrote below one of them: a write through one path
|
|
335
|
+
* of a draft gives that path a new object, and the other paths would go on
|
|
336
|
+
* referring to the old one (`$a.n = 2` with `$b` the same object as `$a`).
|
|
337
|
+
*/
|
|
338
|
+
function relink(
|
|
339
|
+
draft: VariableNamespaces,
|
|
340
|
+
work: VariableNamespaces,
|
|
341
|
+
changes: readonly PathChange[],
|
|
342
|
+
): void {
|
|
343
|
+
const written = changes.map((c) => pathKey(c.path).slice(0, -1));
|
|
344
|
+
for (const [first, ...others] of sharedPaths(work)) {
|
|
345
|
+
// Written at or below one of the paths: the written key follows its
|
|
346
|
+
// JSON, which opens with the path's own
|
|
347
|
+
const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
|
|
348
|
+
if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
|
|
349
|
+
try {
|
|
350
|
+
const object = getByPath(draft, first!);
|
|
351
|
+
if (object === null || typeof object !== 'object') continue;
|
|
352
|
+
for (const path of others)
|
|
353
|
+
setByPath(draft as unknown as Record<string, unknown>, path, object);
|
|
354
|
+
} catch {
|
|
355
|
+
// A path the store does not hold: the code's view is not the store's
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Copies of the namespaces that keep their references to each other: an
|
|
362
|
+
* object held in two of them (`%copy = $a`) is one object in the copies too.
|
|
363
|
+
*/
|
|
364
|
+
function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
|
|
365
|
+
const seen = new Map<object, object>();
|
|
366
|
+
return {
|
|
367
|
+
variables: deepClone(from.variables, { seen }),
|
|
368
|
+
temporary: deepClone(from.temporary, { seen }),
|
|
369
|
+
transient: deepClone(from.transient, { seen }),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
339
372
|
|
|
340
373
|
/** A mutation to commit, and whether its code goes on running after. */
|
|
341
374
|
interface Commit {
|
|
@@ -362,13 +395,9 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
362
395
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
363
396
|
scope,
|
|
364
397
|
keepRunning,
|
|
365
|
-
changes:
|
|
366
|
-
(ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
|
|
367
|
-
),
|
|
398
|
+
changes: changesOf(scope.base, scope.work),
|
|
368
399
|
}));
|
|
369
|
-
const changed = all.filter(({ changes }) =>
|
|
370
|
-
changes.some(([, list]) => list.length > 0),
|
|
371
|
-
);
|
|
400
|
+
const changed = all.filter(({ changes }) => changes.length > 0);
|
|
372
401
|
if (changed.length === 0) return;
|
|
373
402
|
// Before the store update: watchers it fires may commit again (a goto)
|
|
374
403
|
for (const { scope, keepRunning } of changed) {
|
|
@@ -390,73 +419,72 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
390
419
|
all.forEach(({ scope, changes }, i) => {
|
|
391
420
|
// The copies the store and the enclosing mutations take are their own,
|
|
392
421
|
// and keep the references of the values written (one object written
|
|
393
|
-
// at two paths is one object there) and to
|
|
394
|
-
// already (see clonerFor). The code's working
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
);
|
|
398
|
-
const own = new Map(
|
|
399
|
-
changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
|
|
400
|
-
);
|
|
422
|
+
// at two paths, in one namespace or two, is one object there) and to
|
|
423
|
+
// the objects they hold already (see clonerFor). The code's working
|
|
424
|
+
// copy stays its own.
|
|
425
|
+
const paths = locateObjects(scope.work, changes);
|
|
426
|
+
const own = clonerFor(draft, paths);
|
|
401
427
|
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
402
|
-
const enclosingCloners =
|
|
403
|
-
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
// A changed path already holding the value keeps its reference;
|
|
416
|
-
// an alias change is one of reference only: it holds when the
|
|
417
|
-
// very object is there.
|
|
418
|
-
const value = change.alias ? own.get(ns)!(change.value) : undefined;
|
|
419
|
-
if (
|
|
420
|
-
change.alias
|
|
421
|
-
? Object.is(getByPath(draft[ns], change.path), value)
|
|
422
|
-
: isApplied(draft[ns], change)
|
|
423
|
-
) {
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
applyChange(draft[ns], {
|
|
427
|
-
...change,
|
|
428
|
-
value: change.alias ? value : own.get(ns)!(change.value),
|
|
429
|
-
});
|
|
430
|
-
} catch {
|
|
431
|
-
// An intermediate object the code wrote into is gone from the
|
|
432
|
-
// store: the code's view of the whole root wins.
|
|
433
|
-
draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
|
|
434
|
-
replaced.add(root);
|
|
428
|
+
const enclosingCloners = clonersFor(() => paths);
|
|
429
|
+
const replaced = new Set<string>();
|
|
430
|
+
for (const change of changes) {
|
|
431
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
432
|
+
const rootKey = pathKey([ns, root]);
|
|
433
|
+
if (replaced.has(rootKey)) continue;
|
|
434
|
+
try {
|
|
435
|
+
if (change.deleted) {
|
|
436
|
+
if (!isApplied(draft, change)) applyChange(draft, change);
|
|
437
|
+
continue;
|
|
435
438
|
}
|
|
439
|
+
// A changed path already holding the value keeps its reference;
|
|
440
|
+
// an alias change is one of reference only: it holds when the
|
|
441
|
+
// very object is there.
|
|
442
|
+
const value = change.alias ? own(change.value) : undefined;
|
|
443
|
+
if (
|
|
444
|
+
change.alias
|
|
445
|
+
? Object.is(getByPath(draft, change.path), value)
|
|
446
|
+
: isApplied(draft, change)
|
|
447
|
+
) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
applyChange(draft, {
|
|
451
|
+
...change,
|
|
452
|
+
value: change.alias ? value : own(change.value),
|
|
453
|
+
});
|
|
454
|
+
} catch {
|
|
455
|
+
// An intermediate object the code wrote into is gone from the
|
|
456
|
+
// store: the code's view of the whole root wins.
|
|
457
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
458
|
+
replaced.add(rootKey);
|
|
436
459
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
460
|
+
}
|
|
461
|
+
relink(draft, scope.work, changes);
|
|
462
|
+
// Hand the changes to the mutations this one runs inside, before
|
|
463
|
+
// watchers fired by this update run.
|
|
464
|
+
for (const change of changes) {
|
|
465
|
+
mirror(draft, change, enclosing, enclosingCloners);
|
|
442
466
|
}
|
|
443
467
|
});
|
|
444
468
|
}
|
|
445
469
|
}
|
|
446
470
|
|
|
447
471
|
/**
|
|
448
|
-
* The changes of the code to
|
|
449
|
-
* (see diffPaths), and those that hold
|
|
450
|
-
* than they did (see aliasChanges), those
|
|
472
|
+
* The changes of the code to the namespaces, as paths below them (`['variables',
|
|
473
|
+
* 'a']`): the property paths that differ (see diffPaths), and those that hold
|
|
474
|
+
* equal content but another object than they did (see aliasChanges), those
|
|
475
|
+
* first. Compared as one object, so an object the namespaces share is one
|
|
476
|
+
* object, whichever of them it is reached through.
|
|
451
477
|
*/
|
|
452
478
|
function changesOf(
|
|
453
|
-
base:
|
|
454
|
-
work:
|
|
479
|
+
base: VariableNamespaces,
|
|
480
|
+
work: VariableNamespaces,
|
|
455
481
|
): PathChange[] {
|
|
456
|
-
const
|
|
482
|
+
const before = base as unknown as Record<string, unknown>;
|
|
483
|
+
const after = work as unknown as Record<string, unknown>;
|
|
484
|
+
const written = diffPaths(before, after);
|
|
457
485
|
const keys = new Set(written.map((c) => pathKey(c.path)));
|
|
458
486
|
const aliases: PathChange[] = [];
|
|
459
|
-
for (const change of aliasChanges(
|
|
487
|
+
for (const change of aliasChanges(before, after)) {
|
|
460
488
|
// Written whole by a change at it or above it
|
|
461
489
|
if (underChange(change.path, keys)) continue;
|
|
462
490
|
keys.add(pathKey(change.path));
|
|
@@ -503,11 +531,27 @@ export function runWithCommittedMutations<T>(action: () => T): T {
|
|
|
503
531
|
}
|
|
504
532
|
}
|
|
505
533
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Run `run` on working copies of the story state and commit what it changed
|
|
536
|
+
* to the store, as mutation code does (see commitScopes): the references
|
|
537
|
+
* between values are kept, which a store update made on a draft does not do.
|
|
538
|
+
* Nothing is committed when `run` throws.
|
|
539
|
+
*/
|
|
540
|
+
export function mutateState(run: (work: VariableNamespaces) => void): void {
|
|
541
|
+
// The writes of the code this runs inside come first, and stay even if
|
|
542
|
+
// `run` throws
|
|
543
|
+
commitScopes(running());
|
|
544
|
+
const scope = startScope();
|
|
545
|
+
try {
|
|
546
|
+
run(scope.work);
|
|
547
|
+
} finally {
|
|
548
|
+
activeScopes.pop();
|
|
549
|
+
}
|
|
550
|
+
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Start a mutation, from the pending state of the one it runs inside. */
|
|
554
|
+
function startScope(): MutationScope {
|
|
511
555
|
// A mutation started while another executes (a watcher run action fired
|
|
512
556
|
// by a Story.set in its code) continues from the enclosing code's pending
|
|
513
557
|
// state rather than the store, as a direct call at that point would.
|
|
@@ -516,6 +560,15 @@ export function executeMutation(
|
|
|
516
560
|
work: cloneNamespaces(start),
|
|
517
561
|
base: cloneNamespaces(start),
|
|
518
562
|
};
|
|
563
|
+
activeScopes.push(scope);
|
|
564
|
+
return scope;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function executeMutation(
|
|
568
|
+
code: string,
|
|
569
|
+
mergedLocals: Record<string, unknown>,
|
|
570
|
+
scopeUpdate: (key: string, value: unknown) => void,
|
|
571
|
+
): void {
|
|
519
572
|
// Locals are deep-cloned like the store namespaces: a loop item or widget
|
|
520
573
|
// argument taken from story state is Immer-frozen, and an unfrozen object
|
|
521
574
|
// mutated in place would keep its reference, so a nested assignment
|
|
@@ -525,7 +578,7 @@ export function executeMutation(
|
|
|
525
578
|
deepClone(mergedLocals, { keepUnregistered: true }),
|
|
526
579
|
);
|
|
527
580
|
|
|
528
|
-
|
|
581
|
+
const scope = startScope();
|
|
529
582
|
try {
|
|
530
583
|
execute(
|
|
531
584
|
code,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
IncompatibleSaveError,
|
|
16
16
|
SAVE_FORMAT_VERSION,
|
|
17
17
|
} from './format';
|
|
18
|
-
import { getBackend, resetBackend } from './storage';
|
|
18
|
+
import { getBackend, resetBackend, META_PREFIXES } from './storage';
|
|
19
19
|
import { deepClone } from '../structural';
|
|
20
20
|
import { emit } from '../event-emitter';
|
|
21
21
|
import { withoutDraws } from '../prng';
|
|
@@ -152,7 +152,7 @@ async function startNewPlaythroughNow(
|
|
|
152
152
|
const PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
|
|
153
153
|
|
|
154
154
|
function playthroughCountKey(ifid: string): string {
|
|
155
|
-
return
|
|
155
|
+
return `${META_PREFIXES.playthroughCount}${ifid}`;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
@@ -173,7 +173,7 @@ async function nextPlaythroughNumber(ifid: string): Promise<number> {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
function currentPlaythroughKey(ifid: string): string {
|
|
176
|
-
return
|
|
176
|
+
return `${META_PREFIXES.currentPlaythrough}${ifid}`;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
export function getCurrentPlaythroughId(
|
|
@@ -476,9 +476,9 @@ export const getSavesGrouped = queued(async function getSavesGroupedNow(
|
|
|
476
476
|
|
|
477
477
|
// --- Quick Save / Slot Save ---
|
|
478
478
|
|
|
479
|
-
const AUTOSAVE_KEY_PREFIX =
|
|
480
|
-
const SLOT_KEY_PREFIX =
|
|
481
|
-
const SLOT_INDEX_KEY_PREFIX =
|
|
479
|
+
const AUTOSAVE_KEY_PREFIX = META_PREFIXES.autosave;
|
|
480
|
+
const SLOT_KEY_PREFIX = META_PREFIXES.slot;
|
|
481
|
+
const SLOT_INDEX_KEY_PREFIX = META_PREFIXES.slotIndex;
|
|
482
482
|
|
|
483
483
|
/**
|
|
484
484
|
* Whether `slot` names a slot. The default (autosave) slot is addressed by
|
package/src/saves/storage.ts
CHANGED
|
@@ -46,6 +46,28 @@ function makeTables(table: MakeTable): Tables {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The meta keys a story owns, in one place: save-manager builds them,
|
|
51
|
+
* deleteMetaByIfid() matches them. A story's key is its prefix followed by
|
|
52
|
+
* its IFID, except a named slot's pointer, `slot.<name>.<IFID>`.
|
|
53
|
+
*/
|
|
54
|
+
export const META_PREFIXES = {
|
|
55
|
+
autosave: 'autosave.',
|
|
56
|
+
slot: 'slot.',
|
|
57
|
+
slotIndex: 'slotIndex.',
|
|
58
|
+
playthroughCount: 'playthroughCount.',
|
|
59
|
+
currentPlaythrough: 'currentPlaythroughId.',
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Whether the meta `key` belongs to the story `ifid` (and no other). */
|
|
63
|
+
function isMetaKeyOf(key: string, ifid: string): boolean {
|
|
64
|
+
const { slot, ...others } = META_PREFIXES;
|
|
65
|
+
return (
|
|
66
|
+
Object.values(others).some((prefix) => key === prefix + ifid) ||
|
|
67
|
+
(key.startsWith(slot) && key.endsWith(`.${ifid}`))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function createBackend(
|
|
50
72
|
type: StorageBackend['type'],
|
|
51
73
|
{ saves, playthroughs, meta }: Tables,
|
|
@@ -96,7 +118,8 @@ function createBackend(
|
|
|
96
118
|
deleteMeta: (key) => meta.delete(key),
|
|
97
119
|
deleteMetaByPrefix: (prefix) =>
|
|
98
120
|
deleteMetaWhere((key) => key.startsWith(prefix)),
|
|
99
|
-
deleteMetaByIfid: (ifid) =>
|
|
121
|
+
deleteMetaByIfid: (ifid) =>
|
|
122
|
+
deleteMetaWhere((key) => isMetaKeyOf(key, ifid)),
|
|
100
123
|
getAllMetaKeys: () => meta.keys(),
|
|
101
124
|
|
|
102
125
|
destroy,
|
package/src/story-api.ts
CHANGED
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
} from './saves/save-manager';
|
|
22
22
|
import { getBackendType } from './saves/storage';
|
|
23
23
|
import { registerClass } from './class-registry';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
frozenCopy,
|
|
26
|
+
getActiveMutationScope,
|
|
27
|
+
mutateState,
|
|
28
|
+
} from './execute-mutation';
|
|
25
29
|
import { getByPath, setByPath } from './utils/object-path';
|
|
26
30
|
import { changedNames, checkVariableName, ownValue } from './utils/namespace';
|
|
27
31
|
import { historyQueries } from './expression';
|
|
@@ -298,6 +302,16 @@ function createStoryAPI(): StoryAPI {
|
|
|
298
302
|
// while mutation code runs ({do}, ctx.mutate, watcher run actions), it
|
|
299
303
|
// follows the code's own pending writes (program order, #215): see
|
|
300
304
|
// routeStoreUpdate.
|
|
305
|
+
if (entries.some(([k]) => k.includes('.'))) {
|
|
306
|
+
// A write below a variable goes through the commit mutation code
|
|
307
|
+
// uses (nested in the running code, if any: it continues from the
|
|
308
|
+
// code's pending writes, and they are committed first): an update of a draft would give the written path new
|
|
309
|
+
// objects and leave the other references to the old ones (#295).
|
|
310
|
+
mutateState((work) => {
|
|
311
|
+
for (const [k, v] of entries) setOne(work, k, v);
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
301
315
|
useStoryStore.getState().updateVariables((draft) => {
|
|
302
316
|
for (const [k, v] of entries) setOne(draft, k, v);
|
|
303
317
|
});
|
package/src/structural.ts
CHANGED
|
@@ -593,26 +593,54 @@ export function underChange(
|
|
|
593
593
|
}
|
|
594
594
|
|
|
595
595
|
/**
|
|
596
|
-
*
|
|
597
|
-
* depth-first order. Plain objects
|
|
598
|
-
*
|
|
596
|
+
* Every path each object (or array, or other value) of `root` is at, the
|
|
597
|
+
* first appearance first, in depth-first order. Plain objects and arrays are
|
|
598
|
+
* entered, where first met only (an object held in an array is one object,
|
|
599
|
+
* however it was reached); other values are leaves. A path at or below one
|
|
600
|
+
* of `skip` is left out.
|
|
599
601
|
*/
|
|
600
|
-
function
|
|
601
|
-
root:
|
|
602
|
+
function objectPaths(
|
|
603
|
+
root: object,
|
|
602
604
|
skip: ReadonlySet<string> = new Set(),
|
|
603
|
-
): Map<object, string[]> {
|
|
604
|
-
const
|
|
605
|
+
): Map<object, string[][]> {
|
|
606
|
+
const all = new Map<object, string[][]>();
|
|
605
607
|
(function walk(node: Record<string, unknown>, path: string[]): void {
|
|
606
608
|
for (const key of Object.keys(node)) {
|
|
607
609
|
const value = node[key];
|
|
608
|
-
if (!isObjectValue(value)
|
|
610
|
+
if (!isObjectValue(value)) continue;
|
|
609
611
|
const at = [...path, key];
|
|
610
612
|
if (skip.has(pathKey(at))) continue;
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
+
const known = all.get(value);
|
|
614
|
+
if (known) {
|
|
615
|
+
known.push(at);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
all.set(value, [at]);
|
|
619
|
+
if (isMergeable(value) || Array.isArray(value)) {
|
|
620
|
+
walk(value as Record<string, unknown>, at);
|
|
621
|
+
}
|
|
613
622
|
}
|
|
614
|
-
})(root, []);
|
|
615
|
-
return
|
|
623
|
+
})(root as Record<string, unknown>, []);
|
|
624
|
+
return all;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Where each object of `root` first appears (see objectPaths). */
|
|
628
|
+
function firstPaths(
|
|
629
|
+
root: object,
|
|
630
|
+
skip?: ReadonlySet<string>,
|
|
631
|
+
): Map<object, string[]> {
|
|
632
|
+
return new Map(
|
|
633
|
+
[...objectPaths(root, skip)].map(([object, paths]) => [object, paths[0]!]),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The objects `root` holds at more than one path (an object two variables
|
|
639
|
+
* refer to, or one that refers to itself), with every path they are at,
|
|
640
|
+
* parents before children.
|
|
641
|
+
*/
|
|
642
|
+
export function sharedPaths(root: object): string[][][] {
|
|
643
|
+
return [...objectPaths(root).values()].filter((paths) => paths.length > 1);
|
|
616
644
|
}
|
|
617
645
|
|
|
618
646
|
/**
|
|
@@ -650,12 +678,12 @@ export function aliasChanges(
|
|
|
650
678
|
// Only paths that are first appearances are entered: elsewhere the
|
|
651
679
|
// contents are those of the object met first.
|
|
652
680
|
if (
|
|
653
|
-
isMergeable(x) &&
|
|
654
|
-
mergesWith(x, y,
|
|
681
|
+
(isMergeable(x) || Array.isArray(x)) &&
|
|
682
|
+
mergesWith(x, y, true) &&
|
|
655
683
|
pathKey(isAt) === k &&
|
|
656
684
|
pathKey(wasAt) === k
|
|
657
685
|
) {
|
|
658
|
-
walk(x, y as Record<string, unknown>, at);
|
|
686
|
+
walk(x as Record<string, unknown>, y as Record<string, unknown>, at);
|
|
659
687
|
}
|
|
660
688
|
}
|
|
661
689
|
})(before, after, []);
|
|
@@ -667,10 +695,13 @@ export function aliasChanges(
|
|
|
667
695
|
* `changes` (which write something new there), for `existingObjects`.
|
|
668
696
|
*/
|
|
669
697
|
export function locateObjects(
|
|
670
|
-
source:
|
|
698
|
+
source: object,
|
|
671
699
|
changes: readonly PathChange[],
|
|
672
700
|
): Map<object, string[]> {
|
|
673
|
-
return firstPaths(
|
|
701
|
+
return firstPaths(
|
|
702
|
+
source as Record<string, unknown>,
|
|
703
|
+
new Set(changes.map((c) => pathKey(c.path))),
|
|
704
|
+
);
|
|
674
705
|
}
|
|
675
706
|
|
|
676
707
|
/**
|
|
@@ -682,7 +713,7 @@ export function locateObjects(
|
|
|
682
713
|
*/
|
|
683
714
|
export function existingObjects(
|
|
684
715
|
paths: ReadonlyMap<object, string[]>,
|
|
685
|
-
target:
|
|
716
|
+
target: object,
|
|
686
717
|
): Map<object, object> {
|
|
687
718
|
return new (class extends Map<object, object> {
|
|
688
719
|
override has(obj: object): boolean {
|
|
@@ -703,10 +734,7 @@ export function existingObjects(
|
|
|
703
734
|
}
|
|
704
735
|
|
|
705
736
|
/** Whether `target` already holds what `change` would write. */
|
|
706
|
-
export function isApplied(
|
|
707
|
-
target: Record<string, unknown>,
|
|
708
|
-
change: PathChange,
|
|
709
|
-
): boolean {
|
|
737
|
+
export function isApplied(target: object, change: PathChange): boolean {
|
|
710
738
|
const parent = getByPath(target, change.path.slice(0, -1));
|
|
711
739
|
if (parent === null || typeof parent !== 'object') return change.deleted;
|
|
712
740
|
const key = change.path[change.path.length - 1]!;
|
|
@@ -717,12 +745,10 @@ export function isApplied(
|
|
|
717
745
|
);
|
|
718
746
|
}
|
|
719
747
|
|
|
720
|
-
export function applyChange(
|
|
721
|
-
target
|
|
722
|
-
change
|
|
723
|
-
|
|
724
|
-
if (change.deleted) deleteByPath(target, change.path);
|
|
725
|
-
else setByPath(target, change.path, change.value);
|
|
748
|
+
export function applyChange(target: object, change: PathChange): void {
|
|
749
|
+
const root = target as Record<string, unknown>;
|
|
750
|
+
if (change.deleted) deleteByPath(root, change.path);
|
|
751
|
+
else setByPath(root, change.path, change.value);
|
|
726
752
|
}
|
|
727
753
|
|
|
728
754
|
/**
|
package/src/utils/object-path.ts
CHANGED
|
@@ -9,10 +9,7 @@ import { atomicName } from './value-kinds';
|
|
|
9
9
|
* reads as missing, as setByPath() treats it. Other inherited properties
|
|
10
10
|
* (class getters, `size` of a Map) are read.
|
|
11
11
|
*/
|
|
12
|
-
export function getByPath(
|
|
13
|
-
obj: Record<string, unknown>,
|
|
14
|
-
segments: readonly string[],
|
|
15
|
-
): unknown {
|
|
12
|
+
export function getByPath(obj: object, segments: readonly string[]): unknown {
|
|
16
13
|
let current: unknown = obj;
|
|
17
14
|
for (const seg of segments) {
|
|
18
15
|
if (current == null || typeof current !== 'object') return undefined;
|
|
@@ -38,7 +35,8 @@ const builtinName = atomicName;
|
|
|
38
35
|
*/
|
|
39
36
|
export function shallowCopy(value: object): Record<string, unknown> {
|
|
40
37
|
const copy = Array.isArray(value)
|
|
41
|
-
?
|
|
38
|
+
? // Keep the length: trailing holes are not among the keys
|
|
39
|
+
new Array<unknown>(value.length)
|
|
42
40
|
: (Object.create(Object.getPrototypeOf(value) as object | null) as object);
|
|
43
41
|
// Define rather than assign (Object.assign), so that a "__proto__" key
|
|
44
42
|
// stays a key instead of replacing the copy's prototype
|