@rohal12/spindle 0.52.9 → 0.54.0
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/README.md +5 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6484 -2558
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +6 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +4 -2
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/Include.tsx +2 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +219 -0
- package/src/parser.ts +19 -4
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +32 -8
- package/types/tooling.d.ts +42 -0
- package/src/markup/tokenizer.ts +0 -1112
package/src/store.ts
CHANGED
|
@@ -6,10 +6,7 @@ import {
|
|
|
6
6
|
enableMapSet,
|
|
7
7
|
enablePatches,
|
|
8
8
|
isDraft,
|
|
9
|
-
produceWithPatches,
|
|
10
|
-
applyPatches,
|
|
11
9
|
type Draft,
|
|
12
|
-
type Patch,
|
|
13
10
|
} from 'immer';
|
|
14
11
|
import type { StoryData } from './parser';
|
|
15
12
|
import type { TransitionConfig } from './transition';
|
|
@@ -19,7 +16,6 @@ import type {
|
|
|
19
16
|
SaveInfo,
|
|
20
17
|
SaveExport,
|
|
21
18
|
} from './saves/types';
|
|
22
|
-
import { isSaveExport } from './saves/types';
|
|
23
19
|
import { executeStoryInit } from './story-init';
|
|
24
20
|
import { emit } from './event-emitter';
|
|
25
21
|
import {
|
|
@@ -46,8 +42,10 @@ import {
|
|
|
46
42
|
clearAllData as smClearAllData,
|
|
47
43
|
deletePlaythroughData as smDeletePlaythroughData,
|
|
48
44
|
} from './saves/save-manager';
|
|
49
|
-
|
|
50
|
-
import { deepClone, mergeKeys, mergesWith } from './structural';
|
|
45
|
+
|
|
46
|
+
import { deepClone, mergeKeys, mergesWith, shareEqual } from './structural';
|
|
47
|
+
import { shallowCopy } from './utils/object-path';
|
|
48
|
+
import { showRuntimeError } from './runtime-errors';
|
|
51
49
|
import {
|
|
52
50
|
snapshotPRNG,
|
|
53
51
|
restorePRNG,
|
|
@@ -66,6 +64,7 @@ import {
|
|
|
66
64
|
createNamespace,
|
|
67
65
|
hasOwn,
|
|
68
66
|
isNamespace,
|
|
67
|
+
setOwn,
|
|
69
68
|
type Counts,
|
|
70
69
|
type Namespace,
|
|
71
70
|
} from './utils/namespace';
|
|
@@ -92,9 +91,14 @@ const SPECIAL_PASSAGES = new Set([
|
|
|
92
91
|
// Patch-based variable history (module-level, outside Zustand)
|
|
93
92
|
// ---------------------------------------------------------------------------
|
|
94
93
|
|
|
94
|
+
/** A change to one variable between two history moments. */
|
|
95
|
+
type VarChange =
|
|
96
|
+
| { key: string; deleted: true }
|
|
97
|
+
| { key: string; deleted: false; value: unknown };
|
|
98
|
+
|
|
99
|
+
/** The changes that turn one history moment's variables into the next's. */
|
|
95
100
|
interface PatchEntry {
|
|
96
|
-
forward:
|
|
97
|
-
inverse: Patch[];
|
|
101
|
+
forward: VarChange[];
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** Full variable snapshot at history index 0. */
|
|
@@ -110,31 +114,55 @@ let patchEntries: PatchEntry[] = [];
|
|
|
110
114
|
/** Immer-produced reference to variables right after the last navigation. */
|
|
111
115
|
let lastNavigationVars: Namespace = createNamespace();
|
|
112
116
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
/** Compute forward + inverse patches that transform `prev` into `curr`. */
|
|
117
|
+
/**
|
|
118
|
+
* The variables that differ between `prev` and `curr`: added, replaced (by
|
|
119
|
+
* reference: store values are immutable, so an unchanged variable keeps its
|
|
120
|
+
* object) or deleted. A recorded value is a deep copy, so that changing a
|
|
121
|
+
* live value in place (a class instance is not frozen) cannot change
|
|
122
|
+
* history; instances of unregistered classes are kept, as the store keeps
|
|
123
|
+
* them (a save then refuses them).
|
|
124
|
+
*/
|
|
122
125
|
function computeVarPatches(
|
|
123
126
|
prev: Record<string, unknown>,
|
|
124
127
|
curr: Record<string, unknown>,
|
|
125
128
|
): PatchEntry {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
const forward: VarChange[] = [];
|
|
130
|
+
for (const key of Object.keys(prev)) {
|
|
131
|
+
// Own keys only: `curr` may be a plain object (a loaded snapshot),
|
|
132
|
+
// whose inherited `constructor` is no variable
|
|
133
|
+
if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
|
|
134
|
+
}
|
|
135
|
+
for (const key of Object.keys(curr)) {
|
|
136
|
+
if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) {
|
|
137
|
+
forward.push({
|
|
138
|
+
key,
|
|
139
|
+
deleted: false,
|
|
140
|
+
value: deepClone(curr[key], { keepUnregistered: true }),
|
|
141
|
+
});
|
|
135
142
|
}
|
|
136
|
-
}
|
|
137
|
-
return { forward
|
|
143
|
+
}
|
|
144
|
+
return { forward };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The variables of the moment after `vars`, by `changes`. The recorded
|
|
149
|
+
* values are used as they are, not copied, so values the moments share
|
|
150
|
+
* stay shared, and cycles and shared references in them hold (Immer's
|
|
151
|
+
* applyPatches copies each value, and recurses forever on a cycle).
|
|
152
|
+
*/
|
|
153
|
+
function applyVarPatches(
|
|
154
|
+
vars: Record<string, unknown>,
|
|
155
|
+
changes: readonly VarChange[],
|
|
156
|
+
): Record<string, unknown> {
|
|
157
|
+
const next = Object.create(
|
|
158
|
+
Object.getPrototypeOf(vars) as object | null,
|
|
159
|
+
) as Record<string, unknown>;
|
|
160
|
+
for (const key of Object.keys(vars)) setOwn(next, key, vars[key]);
|
|
161
|
+
for (const change of changes) {
|
|
162
|
+
if (change.deleted) delete next[change.key];
|
|
163
|
+
else setOwn(next, change.key, change.value);
|
|
164
|
+
}
|
|
165
|
+
return next;
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
/**
|
|
@@ -160,7 +188,7 @@ function forEachRecorded(
|
|
|
160
188
|
): void {
|
|
161
189
|
let vars: Record<string, unknown> = variableBase;
|
|
162
190
|
for (let i = 0; i < count; i++) {
|
|
163
|
-
if (i > 0) vars =
|
|
191
|
+
if (i > 0) vars = applyVarPatches(vars, patchEntries[i - 1]!.forward);
|
|
164
192
|
fn(vars, i);
|
|
165
193
|
}
|
|
166
194
|
}
|
|
@@ -179,7 +207,7 @@ function savedMoment<V>(moment: HistoryMoment, variables: V) {
|
|
|
179
207
|
function reconstructVarsAt(index: number): Record<string, unknown> {
|
|
180
208
|
let vars: Record<string, unknown> = variableBase;
|
|
181
209
|
for (let i = 0; i < index; i++) {
|
|
182
|
-
vars =
|
|
210
|
+
vars = applyVarPatches(vars, patchEntries[i]!.forward);
|
|
183
211
|
}
|
|
184
212
|
return vars;
|
|
185
213
|
}
|
|
@@ -188,8 +216,18 @@ function reconstructVarsAt(index: number): Record<string, unknown> {
|
|
|
188
216
|
// Session persistence (sessionStorage — survives F5, cleared on tab close)
|
|
189
217
|
// ---------------------------------------------------------------------------
|
|
190
218
|
|
|
191
|
-
|
|
219
|
+
/**
|
|
220
|
+
* The history moments the session holds, with the variables recorded for
|
|
221
|
+
* each. Store state is immutable, so holding the recorded objects keeps
|
|
222
|
+
* them as they were; moments share the values that did not change between
|
|
223
|
+
* them, and the session stores those once (see encodePayload).
|
|
224
|
+
*/
|
|
225
|
+
let sessionMoments: SaveHistoryMoment[] = [];
|
|
192
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Write the game to the session: the whole payload, encoded in one piece
|
|
229
|
+
* on every navigation.
|
|
230
|
+
*/
|
|
193
231
|
function persistSession(get: () => StoryState): void {
|
|
194
232
|
const {
|
|
195
233
|
storyData,
|
|
@@ -204,22 +242,22 @@ function persistSession(get: () => StoryState): void {
|
|
|
204
242
|
|
|
205
243
|
// Trim cache when history shrank (navigate() drops discarded forward
|
|
206
244
|
// moments itself, since a replacement branch may keep the same length)
|
|
207
|
-
if (
|
|
208
|
-
|
|
245
|
+
if (sessionMoments.length > history.length) {
|
|
246
|
+
sessionMoments.length = history.length;
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
// Append new entries
|
|
212
|
-
if (
|
|
213
|
-
const gap = history.length -
|
|
250
|
+
if (sessionMoments.length < history.length) {
|
|
251
|
+
const gap = history.length - sessionMoments.length;
|
|
214
252
|
if (gap === 1) {
|
|
215
253
|
// Common path: one new moment at the end — use current variables directly
|
|
216
254
|
const i = history.length - 1;
|
|
217
|
-
|
|
255
|
+
sessionMoments[i] = savedMoment(history[i]!, variables);
|
|
218
256
|
} else {
|
|
219
257
|
// Bulk fill (after loadFromPayload) — reconstruct incrementally
|
|
220
258
|
forEachRecorded(history.length, (vars, i) => {
|
|
221
|
-
if (i >=
|
|
222
|
-
|
|
259
|
+
if (i >= sessionMoments.length) {
|
|
260
|
+
sessionMoments[i] = savedMoment(history[i]!, vars);
|
|
223
261
|
}
|
|
224
262
|
});
|
|
225
263
|
}
|
|
@@ -227,8 +265,8 @@ function persistSession(get: () => StoryState): void {
|
|
|
227
265
|
|
|
228
266
|
saveSession(storyData.ifid, {
|
|
229
267
|
passage: currentPassage,
|
|
230
|
-
variables
|
|
231
|
-
history:
|
|
268
|
+
variables,
|
|
269
|
+
history: sessionMoments,
|
|
232
270
|
historyIndex,
|
|
233
271
|
visitCounts,
|
|
234
272
|
renderCounts,
|
|
@@ -236,6 +274,36 @@ function persistSession(get: () => StoryState): void {
|
|
|
236
274
|
});
|
|
237
275
|
}
|
|
238
276
|
|
|
277
|
+
/** What the page shows before a session write error (see RuntimeErrors). */
|
|
278
|
+
const SESSION_ERROR_CONTEXT =
|
|
279
|
+
'The game could not be saved for a page reload; a reload goes back to the last passage it could save:';
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Write the session, then run `after` (the rest of the operation: its
|
|
283
|
+
* events and queued navigations), then throw the session's error if writing
|
|
284
|
+
* it failed: a value a save cannot hold (a function, an instance of an
|
|
285
|
+
* unregistered class, a unique symbol) fails the operation that put it in
|
|
286
|
+
* the state's history, as it fails a save, but only after the operation is
|
|
287
|
+
* complete, so the story is not left half-way through it. The error is also
|
|
288
|
+
* shown on the page, and the session keeps its last good copy.
|
|
289
|
+
*/
|
|
290
|
+
function persistSessionThen(get: () => StoryState, after?: () => void): void {
|
|
291
|
+
let failure: { error: unknown } | undefined;
|
|
292
|
+
try {
|
|
293
|
+
persistSession(get);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
failure = { error };
|
|
296
|
+
// The player sees it too: a reload would go back to the last moment
|
|
297
|
+
// the session could hold
|
|
298
|
+
showRuntimeError(SESSION_ERROR_CONTEXT, error);
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
after?.();
|
|
302
|
+
} finally {
|
|
303
|
+
if (failure) throw failure.error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
239
307
|
/**
|
|
240
308
|
* Trim history to `state.maxHistory` moments: keep the newest moments that
|
|
241
309
|
* include the current one. After a navigation (the current moment is the
|
|
@@ -255,11 +323,11 @@ function trimHistory(state: {
|
|
|
255
323
|
const end = start + state.maxHistory;
|
|
256
324
|
// Advance base through trimmed transitions
|
|
257
325
|
for (let i = 0; i < start; i++) {
|
|
258
|
-
variableBase =
|
|
326
|
+
variableBase = applyVarPatches(variableBase, patchEntries[i]!.forward);
|
|
259
327
|
}
|
|
260
328
|
state.history = state.history.slice(start, end);
|
|
261
329
|
patchEntries = patchEntries.slice(start, end - 1);
|
|
262
|
-
|
|
330
|
+
sessionMoments = sessionMoments.slice(start, end);
|
|
263
331
|
state.historyIndex -= start;
|
|
264
332
|
return true;
|
|
265
333
|
}
|
|
@@ -309,19 +377,20 @@ function finishEnteredMoment(get: StoreGet, set: StoreSet): void {
|
|
|
309
377
|
}
|
|
310
378
|
|
|
311
379
|
/**
|
|
312
|
-
* A
|
|
313
|
-
*
|
|
380
|
+
* A variable namespace as save data: a plain object, as a loaded save holds
|
|
381
|
+
* it (the store turns it back into a namespace on load). The values are the
|
|
382
|
+
* store's own, immutable ones, not copies: the moments of a payload share
|
|
383
|
+
* the values that did not change between them, and a save stores those
|
|
384
|
+
* once (see encodePayload).
|
|
314
385
|
*/
|
|
315
|
-
const plainCopy = (ns: Namespace): Record<string, unknown> => ({
|
|
316
|
-
...deepClone(ns),
|
|
317
|
-
});
|
|
386
|
+
const plainCopy = (ns: Namespace): Record<string, unknown> => ({ ...ns });
|
|
318
387
|
|
|
319
388
|
/** Reset all module-level state (called on init, restart, loadFromPayload). */
|
|
320
389
|
function resetModuleState(base: Namespace): void {
|
|
321
390
|
variableBase = base;
|
|
322
391
|
patchEntries = [];
|
|
323
392
|
lastNavigationVars = base;
|
|
324
|
-
|
|
393
|
+
sessionMoments = [];
|
|
325
394
|
}
|
|
326
395
|
|
|
327
396
|
/**
|
|
@@ -382,9 +451,9 @@ function moveInHistory(get: StoreGet, set: StoreSet, step: -1 | 1): void {
|
|
|
382
451
|
reinitTriggerState();
|
|
383
452
|
lastNavigationVars = get().variables;
|
|
384
453
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
454
|
+
persistSessionThen(get, () =>
|
|
455
|
+
emit('afternavigate', targetPassage, previousPassage),
|
|
456
|
+
);
|
|
388
457
|
}
|
|
389
458
|
|
|
390
459
|
/**
|
|
@@ -613,7 +682,8 @@ function loadedEntryMoment(
|
|
|
613
682
|
* the `beforesave` hooks) into the payload's snapshot of the saved moment. A
|
|
614
683
|
* load restores that snapshot, the state on entering the passage, and runs
|
|
615
684
|
* the passage again, so data a hook adds to a save would otherwise be lost
|
|
616
|
-
* on load (#227). Only the property paths the hooks changed are written
|
|
685
|
+
* on load (#227). Only the property paths the hooks changed are written (into
|
|
686
|
+
* copies of the objects on those paths, the rest staying shared): a
|
|
617
687
|
* whole variable would bring along what the passage did to the rest of it,
|
|
618
688
|
* which the passage then does again on load (#232). Only the payload's copy
|
|
619
689
|
* changes: the live history keeps the recorded snapshot (#159).
|
|
@@ -652,7 +722,11 @@ function mergeHookWrites(
|
|
|
652
722
|
) {
|
|
653
723
|
return false;
|
|
654
724
|
}
|
|
655
|
-
|
|
725
|
+
// The snapshot's values are the history's own (immutable, see
|
|
726
|
+
// plainCopy): merge into a copy, made along the merged path only
|
|
727
|
+
const copy = shallowCopy(target[key] as object);
|
|
728
|
+
target[key] = copy;
|
|
729
|
+
mergeHookWrites(copy, b, a, ancestors);
|
|
656
730
|
return true;
|
|
657
731
|
});
|
|
658
732
|
ancestors.delete(after);
|
|
@@ -984,7 +1058,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
984
1058
|
// A lower limit takes effect at once
|
|
985
1059
|
trimmed = trimHistory(state);
|
|
986
1060
|
});
|
|
987
|
-
if (trimmed)
|
|
1061
|
+
if (trimmed) persistSessionThen(get);
|
|
988
1062
|
},
|
|
989
1063
|
|
|
990
1064
|
setQuickSaveKey: (key: string | null) => {
|
|
@@ -1090,8 +1164,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1090
1164
|
// Truncate forward history if we navigated back then chose a new path
|
|
1091
1165
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
1092
1166
|
patchEntries.length = state.historyIndex;
|
|
1093
|
-
if (
|
|
1094
|
-
|
|
1167
|
+
if (sessionMoments.length > state.historyIndex + 1) {
|
|
1168
|
+
sessionMoments.length = state.historyIndex + 1;
|
|
1095
1169
|
}
|
|
1096
1170
|
|
|
1097
1171
|
// Push new transition and moment
|
|
@@ -1125,11 +1199,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1125
1199
|
const deferred = deferredNavigations;
|
|
1126
1200
|
deferredNavigations = [];
|
|
1127
1201
|
finishEnteredMoment(get, set);
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
for (const next of deferred) get().navigate(next);
|
|
1202
|
+
persistSessionThen(get, () => {
|
|
1203
|
+
emit('afternavigate', passageName, previousPassage);
|
|
1204
|
+
for (const next of deferred) get().navigate(next);
|
|
1205
|
+
});
|
|
1133
1206
|
},
|
|
1134
1207
|
|
|
1135
1208
|
goBack: () => moveInHistory(get, set, -1),
|
|
@@ -1344,8 +1417,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1344
1417
|
importSave: async (data: unknown, slot?: string): Promise<SaveInfo> => {
|
|
1345
1418
|
const { storyData } = get();
|
|
1346
1419
|
if (!storyData) throw new Error('spindle: Story is not initialized.');
|
|
1347
|
-
if (!isSaveExport(data)) throw new Error('Invalid save file format');
|
|
1348
1420
|
|
|
1421
|
+
// Checked in full (format version, structure) before it is stored
|
|
1349
1422
|
const info = await importSlotSave(data, storyData.ifid, slot);
|
|
1350
1423
|
recordKnownSave(set, slot, true);
|
|
1351
1424
|
return info;
|
|
@@ -1420,15 +1493,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1420
1493
|
renderCounts,
|
|
1421
1494
|
} = get();
|
|
1422
1495
|
|
|
1423
|
-
// Reconstruct full variable snapshots from base + patches
|
|
1496
|
+
// Reconstruct full variable snapshots from base + patches. Replaying
|
|
1497
|
+
// the (deep-copied) patches gives every moment its own copy of a
|
|
1498
|
+
// variable that changed; share what equals the previous moment's, so
|
|
1499
|
+
// that a save stores it once (see encodePayload).
|
|
1424
1500
|
const saveHistory: SaveHistoryMoment[] = [];
|
|
1425
1501
|
forEachRecorded(history.length, (vars, i) => {
|
|
1426
|
-
|
|
1502
|
+
const recorded = plainCopy(vars);
|
|
1503
|
+
saveHistory.push(
|
|
1504
|
+
savedMoment(
|
|
1505
|
+
history[i]!,
|
|
1506
|
+
i === 0
|
|
1507
|
+
? recorded
|
|
1508
|
+
: shareEqual(saveHistory[i - 1]!.variables, recorded),
|
|
1509
|
+
),
|
|
1510
|
+
);
|
|
1427
1511
|
});
|
|
1428
1512
|
|
|
1429
1513
|
return {
|
|
1430
1514
|
passage: currentPassage,
|
|
1431
|
-
variables:
|
|
1515
|
+
variables: shareEqual(
|
|
1516
|
+
saveHistory[historyIndex]?.variables,
|
|
1517
|
+
plainCopy(variables),
|
|
1518
|
+
),
|
|
1432
1519
|
history: saveHistory,
|
|
1433
1520
|
historyIndex,
|
|
1434
1521
|
visitCounts: { ...visitCounts },
|
|
@@ -1478,27 +1565,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1478
1565
|
|
|
1479
1566
|
// The payload is already live (deserialized at the storage boundary by
|
|
1480
1567
|
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
1481
|
-
// Convert full snapshots to patch entries
|
|
1482
|
-
|
|
1483
|
-
|
|
1568
|
+
// Convert full snapshots to patch entries. They are copied in one
|
|
1569
|
+
// piece, so values the moments share stay shared, and a transition's
|
|
1570
|
+
// patches hold only the variables that changed.
|
|
1571
|
+
const snapshots = deepClone(payload.history.map((m) => m.variables)).map(
|
|
1572
|
+
(vars) => createNamespace(vars),
|
|
1484
1573
|
);
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
const currVars = createNamespace(
|
|
1490
|
-
deepClone(payload.history[i]!.variables),
|
|
1491
|
-
);
|
|
1492
|
-
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
1493
|
-
prevVars = currVars;
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
variableBase = deepClone(base);
|
|
1497
|
-
patchEntries = newPatchEntries;
|
|
1574
|
+
variableBase = deepClone(snapshots[0]!);
|
|
1575
|
+
patchEntries = snapshots
|
|
1576
|
+
.slice(1)
|
|
1577
|
+
.map((curr, i) => computeVarPatches(snapshots[i]!, curr));
|
|
1498
1578
|
// Seed the session cache from the payload's own snapshots, so
|
|
1499
1579
|
// persistSession does not rebuild them from the live variables.
|
|
1500
|
-
|
|
1501
|
-
savedMoment(m,
|
|
1580
|
+
sessionMoments = payload.history.map((m, i) =>
|
|
1581
|
+
savedMoment(m, snapshots[i]!),
|
|
1502
1582
|
);
|
|
1503
1583
|
|
|
1504
1584
|
set((state) => {
|
|
@@ -1535,11 +1615,11 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1535
1615
|
// whose moments predate PRNG snapshots use the payload's.
|
|
1536
1616
|
restorePRNGFrom(entry?.prng !== undefined ? entry.prng : payload.prng);
|
|
1537
1617
|
|
|
1538
|
-
// Write the loaded game to the session so a refresh restores it
|
|
1539
|
-
persistSession(get);
|
|
1540
|
-
|
|
1618
|
+
// Write the loaded game to the session so a refresh restores it.
|
|
1541
1619
|
// Draws made by the handlers would shift the passage's replayed rolls
|
|
1542
|
-
|
|
1620
|
+
persistSessionThen(get, () =>
|
|
1621
|
+
withoutDraws(() => emit('afterload', slot)),
|
|
1622
|
+
);
|
|
1543
1623
|
},
|
|
1544
1624
|
|
|
1545
1625
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|
package/src/story-init.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
2
|
import { useStoryStore, recordStoryInitState } from './store';
|
|
3
|
-
import {
|
|
4
|
-
import { buildAST } from './markup/ast';
|
|
3
|
+
import { parseMarkup } from './markup/parse';
|
|
5
4
|
import { renderNodes } from './markup/render';
|
|
6
5
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
7
6
|
import { emit } from './event-emitter';
|
|
@@ -22,7 +21,7 @@ function unmountStoryInit(): void {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* Execute the StoryInit passage:
|
|
24
|
+
* Execute the StoryInit passage: parse it and render all macros
|
|
26
25
|
* into a detached DOM node so their side effects fire through the normal
|
|
27
26
|
* Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
|
|
28
27
|
* Re-executing (on restart) first unmounts the previous StoryInit tree.
|
|
@@ -35,8 +34,7 @@ export function executeStoryInit() {
|
|
|
35
34
|
|
|
36
35
|
const storyInit = state.storyData.passages.get('StoryInit');
|
|
37
36
|
if (storyInit) {
|
|
38
|
-
const
|
|
39
|
-
const ast = buildAST(tokens);
|
|
37
|
+
const ast = parseMarkup(storyInit.content);
|
|
40
38
|
|
|
41
39
|
// Mount into a persistent hidden container. It stays mounted until the
|
|
42
40
|
// next execution (restart) — this lets async effects (useEffect,
|
package/src/story-variables.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { Passage } from './parser';
|
|
2
|
-
import {
|
|
2
|
+
import type { Token } from './markup/tokens';
|
|
3
|
+
import { MarkupError, tokenizeMarkup } from './markup/parse';
|
|
3
4
|
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
4
5
|
import { errorMessage } from './utils/error-message';
|
|
5
6
|
import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
|
|
6
7
|
import { createNamespace, variableNameError } from './utils/namespace';
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The tokens of markup, or none if it is malformed: validateMarkup reports
|
|
11
|
+
* that, with its position.
|
|
12
|
+
*/
|
|
13
|
+
function tokensOf(text: string, textMode: boolean): Token[] {
|
|
14
|
+
try {
|
|
15
|
+
return tokenizeMarkup(text, { text: textMode });
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof MarkupError) return [];
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
|
|
9
23
|
|
|
10
24
|
export interface FieldSchema {
|
|
@@ -217,7 +231,7 @@ function scanInterpolations(
|
|
|
217
231
|
storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
|
|
218
232
|
): void {
|
|
219
233
|
if (!text.includes('{')) return;
|
|
220
|
-
collectTokenRefs(text,
|
|
234
|
+
collectTokenRefs(text, tokensOf(text, true), storeVarMacros, onRef);
|
|
221
235
|
}
|
|
222
236
|
|
|
223
237
|
/**
|
|
@@ -281,7 +295,7 @@ function collectPassageRefs(
|
|
|
281
295
|
storeVarMacros: ReadonlySet<string>,
|
|
282
296
|
onRef: RefCallback,
|
|
283
297
|
): void {
|
|
284
|
-
collectTokenRefs(content,
|
|
298
|
+
collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef);
|
|
285
299
|
}
|
|
286
300
|
|
|
287
301
|
/** Report the `$var` references in the tokens of `content`. */
|
|
@@ -313,7 +327,7 @@ function collectTokenRefs(
|
|
|
313
327
|
|
|
314
328
|
if (token.name === 'do') {
|
|
315
329
|
// A {do} body is JavaScript: scan its source text as code, however
|
|
316
|
-
// the markup
|
|
330
|
+
// the markup tokens split it up.
|
|
317
331
|
let close = t + 1;
|
|
318
332
|
while (close < tokens.length) {
|
|
319
333
|
const c = tokens[close]!;
|