@rohal12/spindle 0.59.5 → 0.59.7
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 +277 -113
- package/package.json +1 -1
- package/src/class-registry.ts +12 -0
- package/src/components/App.tsx +3 -5
- package/src/components/macros/Computed.tsx +3 -2
- package/src/components/macros/PassageDisplay.tsx +4 -3
- package/src/components/macros/SaveManager.tsx +3 -5
- package/src/components/macros/Unset.tsx +5 -1
- package/src/execute-mutation.ts +59 -21
- package/src/index.tsx +22 -7
- package/src/saves/save-manager.ts +9 -2
- package/src/store.ts +19 -8
- package/src/structural.ts +231 -115
- package/src/utils/object-path.ts +6 -3
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -110,11 +110,22 @@ const defineData = (target: object, key: string, value: unknown): void => {
|
|
|
110
110
|
});
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A reducer extracts string keys, so devalue never sees the symbol keys of
|
|
115
|
+
* the original object: refuse them here, as it does for a plain object.
|
|
116
|
+
*/
|
|
117
|
+
function refuseSymbolKeys(value: object): void {
|
|
118
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
119
|
+
throw new TypeError('spindle: Cannot save an object with symbol keys');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
/**
|
|
114
124
|
* Own enumerable keys of `value`, as a plain object; for an error also its
|
|
115
125
|
* message, cause and (AggregateError) errors, which are not enumerable.
|
|
116
126
|
*/
|
|
117
127
|
function ownData(value: object): Record<string, unknown> {
|
|
128
|
+
refuseSymbolKeys(value);
|
|
118
129
|
const data: Record<string, unknown> = {};
|
|
119
130
|
if (value instanceof Error) {
|
|
120
131
|
for (const key of ERROR_HIDDEN_KEYS) {
|
|
@@ -163,6 +174,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
163
174
|
}
|
|
164
175
|
const keys = Object.keys(v);
|
|
165
176
|
if (!keys.some(isEscapedOneCharKey)) return false;
|
|
177
|
+
refuseSymbolKeys(v);
|
|
166
178
|
if (keys.includes('__proto__')) {
|
|
167
179
|
throw new TypeError(
|
|
168
180
|
'spindle: Cannot save a property named "__proto__"',
|
package/src/components/App.tsx
CHANGED
|
@@ -41,9 +41,7 @@ export function App() {
|
|
|
41
41
|
</>
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
content
|
|
48
|
-
);
|
|
44
|
+
// One provider whatever `nobr` is: a changing tree shape would remount the
|
|
45
|
+
// passage and run its mount-only macros again
|
|
46
|
+
return <NobrContext.Provider value={nobr}>{content}</NobrContext.Provider>;
|
|
49
47
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
3
|
import { readState } from '../../execute-mutation';
|
|
4
|
-
import {
|
|
4
|
+
import { deepEqualStrict } from '../../structural';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
import { MacroError, logMacroError } from './MacroError';
|
|
7
7
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
@@ -67,7 +67,8 @@ function computeAndApply(
|
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// Strict: a change of only the aliases inside the result is a change
|
|
71
|
+
if (!deepEqualStrict(prevRef.current, newValue)) {
|
|
71
72
|
prevRef.current = newValue;
|
|
72
73
|
if (isLocal) {
|
|
73
74
|
try {
|
|
@@ -131,9 +131,6 @@ defineMacro({
|
|
|
131
131
|
const store = useStoryStore.getState();
|
|
132
132
|
const { history, historyIndex, transitionConfig } = store;
|
|
133
133
|
|
|
134
|
-
// Always consume the next transition (one-shot), regardless of what we do
|
|
135
|
-
const next = store.consumeNextTransition();
|
|
136
|
-
|
|
137
134
|
// Determine if this is a first load, restart, or save-load
|
|
138
135
|
const prevLen = prevHistoryLenRef.current;
|
|
139
136
|
prevHistoryLenRef.current = history.length;
|
|
@@ -152,6 +149,10 @@ defineMacro({
|
|
|
152
149
|
return;
|
|
153
150
|
}
|
|
154
151
|
|
|
152
|
+
// One-shot, used up by the first navigation, whatever we do with it
|
|
153
|
+
// (not by the initial render, which no navigation caused)
|
|
154
|
+
const next = store.consumeNextTransition();
|
|
155
|
+
|
|
155
156
|
// Cancel any in-progress transition
|
|
156
157
|
cancelTransition();
|
|
157
158
|
|
|
@@ -127,9 +127,8 @@ export function SaveManagerContent() {
|
|
|
127
127
|
const playthrough = resolvePlaythroughId();
|
|
128
128
|
try {
|
|
129
129
|
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return createSave(ifid, ptId, payload);
|
|
130
|
+
// Queued now, in call order with other storage operations
|
|
131
|
+
return createSave(ifid, playthrough, payload);
|
|
133
132
|
});
|
|
134
133
|
showStatus('Save created');
|
|
135
134
|
await refresh();
|
|
@@ -143,8 +142,7 @@ export function SaveManagerContent() {
|
|
|
143
142
|
try {
|
|
144
143
|
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
145
144
|
// Only the current playthrough's saves offer "Save Here"
|
|
146
|
-
|
|
147
|
-
if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
|
|
145
|
+
if (!(await overwriteSave(saveId, payload, undefined, playthrough))) {
|
|
148
146
|
throw new Error('Save not found');
|
|
149
147
|
}
|
|
150
148
|
});
|
|
@@ -10,7 +10,11 @@ defineMacro({
|
|
|
10
10
|
const state = useStoryStore.getState();
|
|
11
11
|
const name = ctx.args.variable ?? '';
|
|
12
12
|
|
|
13
|
-
if (name
|
|
13
|
+
if (/^[$_%@][^.]*\./.test(name)) {
|
|
14
|
+
// A field of a variable: delete it as mutation code does, which
|
|
15
|
+
// resolves the path in every namespace
|
|
16
|
+
ctx.mutate(`delete ${name}`);
|
|
17
|
+
} else if (name.startsWith('$')) {
|
|
14
18
|
state.deleteVariable(name.slice(1));
|
|
15
19
|
} else if (name.startsWith('_')) {
|
|
16
20
|
state.deleteTemporary(name.slice(1));
|
package/src/execute-mutation.ts
CHANGED
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
changesBetween,
|
|
14
14
|
applyChange,
|
|
15
15
|
deepClone,
|
|
16
|
-
deepEqual,
|
|
17
16
|
existingObjects,
|
|
17
|
+
getByEntryPath,
|
|
18
|
+
isEntryKey,
|
|
18
19
|
isApplied,
|
|
19
20
|
isMergeable,
|
|
20
21
|
locateObjects,
|
|
21
|
-
|
|
22
|
+
relinkPath,
|
|
22
23
|
pathKey,
|
|
23
24
|
sharedPaths,
|
|
24
25
|
type PathChange,
|
|
@@ -94,18 +95,23 @@ function clonerFor(
|
|
|
94
95
|
paths?: ReadonlyMap<object, string[]>,
|
|
95
96
|
): Cloner {
|
|
96
97
|
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
97
|
-
// Immer does not finalize a draft held as a Map key, so an
|
|
98
|
-
// met there is put in the draft as the object itself: its
|
|
99
|
-
// code did not change it, which other references to it then
|
|
100
|
-
|
|
98
|
+
// Immer does not finalize a draft held as a Map key or Set member, so an
|
|
99
|
+
// existing object met there is put in the draft as the object itself: its
|
|
100
|
+
// base while the code did not change it, which other references to it then
|
|
101
|
+
// agree with.
|
|
102
|
+
const settle = (copy: object, original: object): object => {
|
|
101
103
|
const path = paths?.get(original);
|
|
102
104
|
if (!path || !isDraft(copy)) return copy;
|
|
103
105
|
const plain = currentDraft(copy) as object;
|
|
104
|
-
|
|
106
|
+
// Where the object is in an entry of a Map or Set the path cannot be
|
|
107
|
+
// written (see getByEntryPath): that entry keeps its own
|
|
108
|
+
if (!path.some(isEntryKey)) {
|
|
109
|
+
setByPath(target as Record<string, unknown>, path, plain);
|
|
110
|
+
}
|
|
105
111
|
seen.set(original, plain);
|
|
106
112
|
return plain;
|
|
107
113
|
};
|
|
108
|
-
return (value) => deepClone(value, { keepUnregistered: true, seen,
|
|
114
|
+
return (value) => deepClone(value, { keepUnregistered: true, seen, settle });
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
/** One cloner per target copy, made when first used. */
|
|
@@ -358,10 +364,10 @@ function relink(
|
|
|
358
364
|
const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
|
|
359
365
|
if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
|
|
360
366
|
try {
|
|
361
|
-
const object =
|
|
367
|
+
const object = getByEntryPath(draft, first!);
|
|
362
368
|
if (object === null || typeof object !== 'object') continue;
|
|
363
369
|
for (const path of others)
|
|
364
|
-
|
|
370
|
+
relinkPath(draft as unknown as Record<string, unknown>, path, object);
|
|
365
371
|
} catch {
|
|
366
372
|
// A path the store does not hold: the code's view is not the store's
|
|
367
373
|
}
|
|
@@ -375,9 +381,9 @@ function relink(
|
|
|
375
381
|
function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
|
|
376
382
|
const seen = new Map<object, object>();
|
|
377
383
|
return {
|
|
378
|
-
variables: deepClone(from.variables, { seen }),
|
|
379
|
-
temporary: deepClone(from.temporary, { seen }),
|
|
380
|
-
transient: deepClone(from.transient, { seen }),
|
|
384
|
+
variables: deepClone(from.variables, { keepUnregistered: true, seen }),
|
|
385
|
+
temporary: deepClone(from.temporary, { keepUnregistered: true, seen }),
|
|
386
|
+
transient: deepClone(from.transient, { keepUnregistered: true, seen }),
|
|
381
387
|
};
|
|
382
388
|
}
|
|
383
389
|
|
|
@@ -506,14 +512,31 @@ const running = (): Commit[] =>
|
|
|
506
512
|
/**
|
|
507
513
|
* Bring a suspended mutation's copies up to the store after an action
|
|
508
514
|
* replaced or changed state under it (navigation clears temporaries, back
|
|
509
|
-
* and restart replace variables).
|
|
510
|
-
* the code
|
|
515
|
+
* and restart replace variables). What the action changed (see
|
|
516
|
+
* changesBetween) is written into the code's working copy, which keeps the
|
|
517
|
+
* objects the code may hold where nothing changed, and the references among
|
|
518
|
+
* the values written, and to the objects it holds already, as a commit does.
|
|
511
519
|
*/
|
|
512
520
|
function resync(scope: MutationScope): void {
|
|
513
521
|
const state = useStoryStore.getState();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
522
|
+
const now = namespacesOf(state);
|
|
523
|
+
const changes = changesBetween(
|
|
524
|
+
scope.base as unknown as Record<string, unknown>,
|
|
525
|
+
now as unknown as Record<string, unknown>,
|
|
526
|
+
);
|
|
527
|
+
const copy = clonerFor(scope.work, locateObjects(now, changes));
|
|
528
|
+
for (const change of changes) {
|
|
529
|
+
try {
|
|
530
|
+
applyChange(
|
|
531
|
+
scope.work,
|
|
532
|
+
change.deleted ? change : { ...change, value: copy(change.value) },
|
|
533
|
+
);
|
|
534
|
+
} catch {
|
|
535
|
+
// A path the working copy does not hold: replace its root instead
|
|
536
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
537
|
+
if (hasOwn(now[ns], root)) scope.work[ns][root] = copy(now[ns][root]);
|
|
538
|
+
else delete scope.work[ns][root];
|
|
539
|
+
}
|
|
517
540
|
}
|
|
518
541
|
scope.base = cloneNamespaces(state);
|
|
519
542
|
}
|
|
@@ -611,11 +634,26 @@ export function executeMutation(
|
|
|
611
634
|
// came before its own
|
|
612
635
|
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
613
636
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
637
|
+
// Locals the code changed, as content or as the objects they refer to
|
|
638
|
+
// (`@a = @b` over an equal object), and the locals that share an object
|
|
639
|
+
// with one of those: the updater takes each one's value, so they must all
|
|
640
|
+
// be written for the scope to hold one object again
|
|
641
|
+
const changed = new Set(
|
|
642
|
+
changesBetween(mergedLocals, localsClone, true).map((c) => c.path[0]!),
|
|
643
|
+
);
|
|
644
|
+
for (const group of sharedPaths(localsClone)) {
|
|
645
|
+
if (group.some((path) => changed.has(path[0]!))) {
|
|
646
|
+
// Not a value kept by reference (an unregistered class instance)
|
|
647
|
+
for (const path of group) {
|
|
648
|
+
if (localsClone[path[0]!] !== mergedLocals[path[0]!]) {
|
|
649
|
+
changed.add(path[0]!);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
617
652
|
}
|
|
618
653
|
}
|
|
654
|
+
for (const key of Object.keys(localsClone)) {
|
|
655
|
+
if (changed.has(key)) scopeUpdate(key, localsClone[key]);
|
|
656
|
+
}
|
|
619
657
|
|
|
620
658
|
// Detect deleted locals
|
|
621
659
|
for (const key of Object.keys(mergedLocals)) {
|
package/src/index.tsx
CHANGED
|
@@ -62,6 +62,21 @@ function stopWithErrors(errors: string[]): never {
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The undeclared variables the passages refer to, with the macros known now:
|
|
67
|
+
* the input macros that bind a variable (registered by author JS or
|
|
68
|
+
* StoryInit) and the roles of their parameters decide what is a reference.
|
|
69
|
+
*/
|
|
70
|
+
function variableErrors(
|
|
71
|
+
storyData: StoryData,
|
|
72
|
+
schema: Parameters<typeof validatePassages>[1],
|
|
73
|
+
): string[] {
|
|
74
|
+
const storeVarMacros = getMacroRegistry()
|
|
75
|
+
.filter((m) => m.storeVar)
|
|
76
|
+
.map((m) => m.name);
|
|
77
|
+
return validatePassages(storyData.passages, schema, storeVarMacros);
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
/**
|
|
66
81
|
* The markup errors (malformed markup, unknown macros, syntax errors in
|
|
67
82
|
* code) of the passages for which `only` holds, with the macros and widgets
|
|
@@ -134,10 +149,6 @@ export function boot() {
|
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
137
|
-
// Include input macros registered by author JS, which ran above.
|
|
138
|
-
const storeVarMacros = getMacroRegistry()
|
|
139
|
-
.filter((m) => m.storeVar)
|
|
140
|
-
.map((m) => m.name);
|
|
141
152
|
// Pass 1: Register the block widgets as block macros BEFORE any passage
|
|
142
153
|
// is parsed (validation and StoryInit included), so that passages
|
|
143
154
|
// invoking block widgets and widget bodies using other block widgets
|
|
@@ -146,7 +157,7 @@ export function boot() {
|
|
|
146
157
|
registerBlockMacro(name);
|
|
147
158
|
}
|
|
148
159
|
|
|
149
|
-
const errors =
|
|
160
|
+
const errors = variableErrors(storyData, schema);
|
|
150
161
|
// StoryInit's markup must be valid before it runs; the other passages are
|
|
151
162
|
// validated once it has run, as it may define macros (see below).
|
|
152
163
|
errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
|
|
@@ -186,8 +197,12 @@ export function boot() {
|
|
|
186
197
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
187
198
|
initializeStory(loadSession(storyData.ifid));
|
|
188
199
|
|
|
189
|
-
// Every other passage's markup, now that StoryInit may have defined macros
|
|
190
|
-
|
|
200
|
+
// Every other passage's markup, now that StoryInit may have defined macros;
|
|
201
|
+
// so are the variable references, which macros' parameters decide
|
|
202
|
+
const markup = [
|
|
203
|
+
...variableErrors(storyData, schema),
|
|
204
|
+
...markupErrors(storyData, (p) => p.name !== 'StoryInit'),
|
|
205
|
+
];
|
|
191
206
|
if (markup.length > 0) stopWithErrors(markup);
|
|
192
207
|
|
|
193
208
|
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
@@ -291,10 +291,12 @@ export const createSave = queued(createSaveNow);
|
|
|
291
291
|
|
|
292
292
|
async function createSaveNow(
|
|
293
293
|
ifid: string,
|
|
294
|
-
|
|
294
|
+
playthroughPromise: string | PromiseLike<string>,
|
|
295
295
|
payload: SavePayload,
|
|
296
296
|
custom: Record<string, unknown> = {},
|
|
297
297
|
): Promise<SaveRecord> {
|
|
298
|
+
const playthroughId = await playthroughPromise;
|
|
299
|
+
if (!playthroughId) throw new Error('No playthrough');
|
|
298
300
|
const now = new Date().toISOString();
|
|
299
301
|
const meta: SaveMeta = {
|
|
300
302
|
id: randomUUID(),
|
|
@@ -313,6 +315,10 @@ async function createSaveNow(
|
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
/**
|
|
318
|
+
* A playthrough passed as a promise (see resolvePlaythroughId) is awaited
|
|
319
|
+
* inside the operation, so the call takes its place in the order of
|
|
320
|
+
* operations when it is made.
|
|
321
|
+
*
|
|
316
322
|
* Replace the payload of an existing save, keeping its ID and `createdAt`.
|
|
317
323
|
* Pass the current `playthroughId` when the new payload comes from the
|
|
318
324
|
* running game: the save then holds that playthrough's state, so it is
|
|
@@ -333,8 +339,9 @@ async function overwriteSaveNow(
|
|
|
333
339
|
saveId: string,
|
|
334
340
|
payload: SavePayload,
|
|
335
341
|
custom?: Record<string, unknown>,
|
|
336
|
-
|
|
342
|
+
playthroughPromise?: string | PromiseLike<string>,
|
|
337
343
|
): Promise<SaveRecord | undefined> {
|
|
344
|
+
const playthroughId = await playthroughPromise;
|
|
338
345
|
const backend = await getBackend();
|
|
339
346
|
const existing = await backend.getSave(saveId);
|
|
340
347
|
if (!existing) return undefined;
|
package/src/store.ts
CHANGED
|
@@ -765,28 +765,39 @@ function writeHookChange(
|
|
|
765
765
|
const child = hasOwn(holder, key) ? holder[key] : undefined;
|
|
766
766
|
const held = getByPath(after, path.slice(0, depth + 1));
|
|
767
767
|
if (!mergesWith(child, held, true)) {
|
|
768
|
-
writeKey(holder, key,
|
|
768
|
+
writeKey(holder, key, own(held));
|
|
769
769
|
return;
|
|
770
770
|
}
|
|
771
771
|
holder = child as Record<string, unknown>;
|
|
772
772
|
}
|
|
773
773
|
const key = path[depth]!;
|
|
774
|
-
if (change.deleted) writeKey(holder, key,
|
|
775
|
-
else writeKey(holder, key,
|
|
774
|
+
if (change.deleted) writeKey(holder, key, undefined, change);
|
|
775
|
+
else writeKey(holder, key, own(change.value), change);
|
|
776
776
|
}
|
|
777
777
|
|
|
778
778
|
function writeKey(
|
|
779
779
|
holder: Record<string, unknown>,
|
|
780
780
|
key: string,
|
|
781
|
-
deleted: boolean,
|
|
782
781
|
value: unknown,
|
|
783
|
-
|
|
782
|
+
change?: PathChange,
|
|
784
783
|
): void {
|
|
784
|
+
const deleted = change?.deleted ?? false;
|
|
785
785
|
if (Array.isArray(holder)) {
|
|
786
786
|
const index = Number(key);
|
|
787
|
-
if (deleted)
|
|
788
|
-
|
|
789
|
-
|
|
787
|
+
if (deleted) {
|
|
788
|
+
// A hole stays where it was; anything else was cut off
|
|
789
|
+
if (change && 'hole' in change) delete holder[index];
|
|
790
|
+
else holder.length = Math.min(holder.length, index);
|
|
791
|
+
} else if (change && 'appended' in change) {
|
|
792
|
+
// After the holes it followed
|
|
793
|
+
holder.length += change.gap ?? 0;
|
|
794
|
+
holder.push(value);
|
|
795
|
+
} else if (key === 'length' && change) {
|
|
796
|
+
// Holes added at the end
|
|
797
|
+
holder.length += value as number;
|
|
798
|
+
} else if (index < holder.length) {
|
|
799
|
+
holder[index] = value;
|
|
800
|
+
}
|
|
790
801
|
} else if (deleted) {
|
|
791
802
|
delete holder[key];
|
|
792
803
|
} else {
|