@rohal12/spindle 0.52.4 → 0.52.5
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 +819 -945
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/execute-mutation.ts +17 -97
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
package/dist/pkg/headless.js
CHANGED
|
@@ -32,28 +32,192 @@ function create() {
|
|
|
32
32
|
return (initializer) => bindPreact(createStore()(initializer));
|
|
33
33
|
}
|
|
34
34
|
//#endregion
|
|
35
|
-
//#region src/
|
|
36
|
-
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
//#region src/utils/namespace.ts
|
|
36
|
+
/** The variable name no namespace can hold. */
|
|
37
|
+
var RESERVED_NAME = "__proto__";
|
|
38
|
+
/**
|
|
39
|
+
* Why `name` (without its sigil) cannot be a variable name, or undefined if
|
|
40
|
+
* it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
|
|
41
|
+
*/
|
|
42
|
+
function variableNameError(name, label = name) {
|
|
43
|
+
return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
|
|
44
|
+
}
|
|
45
|
+
/** Throw a TypeError if `name` cannot be a variable name. */
|
|
46
|
+
function checkVariableName(name, label = name) {
|
|
47
|
+
const error = variableNameError(name, label);
|
|
48
|
+
if (error) throw new TypeError(`spindle: ${error}`);
|
|
41
49
|
}
|
|
50
|
+
/** Whether `obj` holds `key` as an own property. */
|
|
51
|
+
var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
52
|
+
/** `ns[key]` if `ns` holds it as an own property, else undefined. */
|
|
53
|
+
var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
|
|
42
54
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* "constructor" (plain data) cannot hide or fake the class.
|
|
55
|
+
* A record with no prototype holding the own entries of `sources`, or only
|
|
56
|
+
* those whose value passes `keep`.
|
|
46
57
|
*/
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
58
|
+
function ownEntries(sources, keep = () => true) {
|
|
59
|
+
const record = Object.create(null);
|
|
60
|
+
for (const source of sources) {
|
|
61
|
+
if (!source) continue;
|
|
62
|
+
for (const key of Object.keys(source)) {
|
|
63
|
+
const value = source[key];
|
|
64
|
+
if (keep(value)) record[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return record;
|
|
50
68
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
/** A namespace with no prototype holding the own entries of `sources`. */
|
|
70
|
+
var createNamespace = (...sources) => ownEntries(sources);
|
|
71
|
+
/** Whether `value` is an object with no prototype. */
|
|
72
|
+
var isNamespace = (value) => Object.getPrototypeOf(value) === null;
|
|
73
|
+
/** `ns` itself if it has no prototype, else a namespace copy of it. */
|
|
74
|
+
var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
|
|
75
|
+
/** A namespace copy of `ns` with `key` set to `value`. */
|
|
76
|
+
function withEntry(ns, key, value) {
|
|
77
|
+
const next = createNamespace(ns);
|
|
78
|
+
next[key] = value;
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
/** An empty namespace that cannot change. */
|
|
82
|
+
var EMPTY_NAMESPACE = Object.freeze(createNamespace());
|
|
83
|
+
/** The names whose own entries differ between `a` and `b`, `a`'s first. */
|
|
84
|
+
function changedNames(a, b) {
|
|
85
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
|
|
86
|
+
}
|
|
87
|
+
var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
88
|
+
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
89
|
+
function countOf(counts, name) {
|
|
90
|
+
const value = ownValue(counts, name);
|
|
91
|
+
return isCount(value) ? value : 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Counters with no prototype holding the own entries of `source` that are
|
|
95
|
+
* counts, with `name` (if given) counted once more. Immer copies the
|
|
96
|
+
* counters on every write anyway, so counting into a fresh copy costs no
|
|
97
|
+
* more than counting in place.
|
|
98
|
+
*/
|
|
99
|
+
function createCounts(source, name) {
|
|
100
|
+
const counts = ownEntries([source], isCount);
|
|
101
|
+
if (name !== void 0) counts[name] = countOf(source, name) + 1;
|
|
102
|
+
return counts;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/utils/object-path.ts
|
|
106
|
+
/**
|
|
107
|
+
* Traverse dot-path segments on an object and return the nested value.
|
|
108
|
+
* Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
|
|
109
|
+
* are not story state: unless an object holds one as its own property, it
|
|
110
|
+
* reads as missing, as setByPath() treats it. Other inherited properties
|
|
111
|
+
* (class getters, `size` of a Map) are read.
|
|
112
|
+
*/
|
|
113
|
+
function getByPath(obj, segments) {
|
|
114
|
+
let current = obj;
|
|
115
|
+
for (const seg of segments) {
|
|
116
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
117
|
+
if (seg in Object.prototype && !hasOwn(current, seg)) return void 0;
|
|
118
|
+
current = current[seg];
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
/** Array indices ("0", "1", …) and "length": the keys an array holds. */
|
|
123
|
+
var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
|
|
124
|
+
/**
|
|
125
|
+
* Built-ins whose content is not their properties: a property written into
|
|
126
|
+
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
127
|
+
*/
|
|
128
|
+
var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
|
|
129
|
+
/**
|
|
130
|
+
* Shallow copy that keeps the prototype, so a registered class instance
|
|
131
|
+
* stays an instance of its class (with the same own keys deepClone copies).
|
|
132
|
+
*/
|
|
133
|
+
function shallowCopy(value) {
|
|
134
|
+
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
135
|
+
for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
|
|
136
|
+
value: value[key],
|
|
137
|
+
enumerable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
configurable: true
|
|
140
|
+
});
|
|
141
|
+
return copy;
|
|
55
142
|
}
|
|
56
143
|
/**
|
|
144
|
+
* Set a value at dot-path `segments` below `root`.
|
|
145
|
+
*
|
|
146
|
+
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
147
|
+
* class instances and everything below them) are copied on the way down and
|
|
148
|
+
* the copy is assigned to its parent, so the write produces new references
|
|
149
|
+
* up to the root. Writing into such an object directly would mutate the
|
|
150
|
+
* instance shared with the previous state and with history, and subscribers
|
|
151
|
+
* would see no change. Outside a draft (a private working copy) the path is
|
|
152
|
+
* written in place.
|
|
153
|
+
*
|
|
154
|
+
* The path goes through own properties of plain objects, class instances
|
|
155
|
+
* and arrays (by index); anything else throws a TypeError rather than
|
|
156
|
+
* writing where no clone or save would see it: an inherited property such
|
|
157
|
+
* as a method counts as missing, and Map, Set, Date and RegExp values and
|
|
158
|
+
* other keys of arrays are refused, as is a "__proto__" segment.
|
|
159
|
+
*/
|
|
160
|
+
function setByPath(root, segments, value, options = {}) {
|
|
161
|
+
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
162
|
+
parent[segments[segments.length - 1]] = value;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Delete the property at dot-path `segments` below `root`, copying
|
|
166
|
+
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
167
|
+
* intermediate is missing or not an object, or the property is absent (or
|
|
168
|
+
* only inherited).
|
|
169
|
+
*/
|
|
170
|
+
function deleteByPath(root, segments) {
|
|
171
|
+
const last = segments[segments.length - 1];
|
|
172
|
+
checkSegments(segments);
|
|
173
|
+
let holder = root;
|
|
174
|
+
for (const seg of segments.slice(0, -1)) {
|
|
175
|
+
if (holder == null || typeof holder !== "object" || !hasOwn(holder, seg)) return;
|
|
176
|
+
holder = holder[seg];
|
|
177
|
+
}
|
|
178
|
+
if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
|
|
179
|
+
const parent = walkToParent(root, segments, false);
|
|
180
|
+
delete parent[last];
|
|
181
|
+
}
|
|
182
|
+
/** Refuse a segment that would reach a prototype instead of story state. */
|
|
183
|
+
function checkSegments(segments) {
|
|
184
|
+
if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
|
|
185
|
+
}
|
|
186
|
+
/** Throw unless `holder` can take `key` as story state (see setByPath). */
|
|
187
|
+
function checkHolder(holder, key, segments, depth) {
|
|
188
|
+
const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
|
|
189
|
+
if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Walk to the object holding the last of `segments`, copying undrafted
|
|
193
|
+
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
194
|
+
* non-object intermediate is replaced by a plain object when
|
|
195
|
+
* `createMissing` is set, and throws a TypeError otherwise.
|
|
196
|
+
*/
|
|
197
|
+
function walkToParent(root, segments, createMissing) {
|
|
198
|
+
checkSegments(segments);
|
|
199
|
+
const copyOnWrite = isDraft(root);
|
|
200
|
+
let current = root;
|
|
201
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
202
|
+
const seg = segments[i];
|
|
203
|
+
checkHolder(current, seg, segments, i);
|
|
204
|
+
let next = hasOwn(current, seg) ? current[seg] : void 0;
|
|
205
|
+
if (next == null || typeof next !== "object") {
|
|
206
|
+
if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
|
|
207
|
+
next = {};
|
|
208
|
+
current[seg] = next;
|
|
209
|
+
} else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
|
|
210
|
+
next = shallowCopy(next);
|
|
211
|
+
current[seg] = next;
|
|
212
|
+
}
|
|
213
|
+
current = next;
|
|
214
|
+
}
|
|
215
|
+
checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
|
|
216
|
+
return current;
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/structural.ts
|
|
220
|
+
/**
|
|
57
221
|
* Set an own enumerable property. A "__proto__" key is defined as an own
|
|
58
222
|
* property (as JSON.parse does) instead of replacing the prototype.
|
|
59
223
|
*/
|
|
@@ -66,6 +230,11 @@ function setOwn(target, key, value) {
|
|
|
66
230
|
});
|
|
67
231
|
else target[key] = value;
|
|
68
232
|
}
|
|
233
|
+
function isPlainObject(value) {
|
|
234
|
+
if (typeof value !== "object" || value === null) return false;
|
|
235
|
+
const proto = Object.getPrototypeOf(value);
|
|
236
|
+
return proto === Object.prototype || proto === null;
|
|
237
|
+
}
|
|
69
238
|
function deepClone(value, options = {}) {
|
|
70
239
|
const seen = /* @__PURE__ */ new Map();
|
|
71
240
|
function clone(val) {
|
|
@@ -92,15 +261,9 @@ function deepClone(value, options = {}) {
|
|
|
92
261
|
for (const v of val) copy.add(clone(v));
|
|
93
262
|
return copy;
|
|
94
263
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
|
|
99
|
-
return copy;
|
|
100
|
-
}
|
|
101
|
-
const plain = isPlainObject(val);
|
|
102
|
-
if (!plain && options.keepUnregistered) return val;
|
|
103
|
-
const copy = plain ? Object.create(Object.getPrototypeOf(obj)) : {};
|
|
264
|
+
const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
|
|
265
|
+
if (!keepsProto && options.keepUnregistered) return val;
|
|
266
|
+
const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
|
|
104
267
|
seen.set(obj, copy);
|
|
105
268
|
for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
|
|
106
269
|
return copy;
|
|
@@ -150,10 +313,156 @@ function equal(a, b, assumed) {
|
|
|
150
313
|
const bo = b;
|
|
151
314
|
const keys = Object.keys(ao);
|
|
152
315
|
if (keys.length !== Object.keys(bo).length) return false;
|
|
153
|
-
for (const key of keys) if (!hasOwn
|
|
316
|
+
for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
154
317
|
return true;
|
|
155
318
|
}
|
|
156
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Objects merged property by property: plain objects and (registered)
|
|
321
|
+
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
322
|
+
* replaced as a whole, since their elements have no stable identity to
|
|
323
|
+
* merge by (a shift moves every index).
|
|
324
|
+
*/
|
|
325
|
+
function isMergeable(value) {
|
|
326
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether changes between `a` and `b` merge key by key: both are objects of
|
|
330
|
+
* one class (see isMergeable), or, with `arrays`, both are arrays.
|
|
331
|
+
*/
|
|
332
|
+
function mergesWith(a, b, arrays) {
|
|
333
|
+
if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
|
|
334
|
+
return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The keys at which `after` differs from `before`, two values that merge
|
|
338
|
+
* (see mergesWith): keys added, deleted, or holding another value. Store
|
|
339
|
+
* updates are immutable, so a value that kept its identity is no change,
|
|
340
|
+
* and one rebuilt with equal content (mutation code commits whole values)
|
|
341
|
+
* is none either. Values that merge are reported as 'nested', to be
|
|
342
|
+
* compared key by key; they may still be equal. With `arrays`, arrays
|
|
343
|
+
* merge too: elements at the indices both hold are compared, and the
|
|
344
|
+
* elements one has beyond the other's length are added or deleted.
|
|
345
|
+
*/
|
|
346
|
+
function keyChanges(before, after, arrays) {
|
|
347
|
+
const changes = [];
|
|
348
|
+
const compare = (key, b, a) => {
|
|
349
|
+
if (Object.is(b, a)) return;
|
|
350
|
+
if (mergesWith(b, a, arrays)) changes.push({
|
|
351
|
+
key,
|
|
352
|
+
kind: "nested",
|
|
353
|
+
before: b,
|
|
354
|
+
after: a
|
|
355
|
+
});
|
|
356
|
+
else if (!deepEqual(b, a)) changes.push({
|
|
357
|
+
key,
|
|
358
|
+
kind: "changed",
|
|
359
|
+
after: a
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
if (Array.isArray(after)) {
|
|
363
|
+
const b = before;
|
|
364
|
+
const a = after;
|
|
365
|
+
for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
|
|
366
|
+
for (let i = b.length; i < a.length; i++) changes.push({
|
|
367
|
+
key: String(i),
|
|
368
|
+
kind: "added",
|
|
369
|
+
after: a[i]
|
|
370
|
+
});
|
|
371
|
+
for (let i = a.length; i < b.length; i++) changes.push({
|
|
372
|
+
key: String(i),
|
|
373
|
+
kind: "deleted"
|
|
374
|
+
});
|
|
375
|
+
return changes;
|
|
376
|
+
}
|
|
377
|
+
for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
|
|
378
|
+
else changes.push({
|
|
379
|
+
key,
|
|
380
|
+
kind: "added",
|
|
381
|
+
after: after[key]
|
|
382
|
+
});
|
|
383
|
+
for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
|
|
384
|
+
key,
|
|
385
|
+
kind: "deleted"
|
|
386
|
+
});
|
|
387
|
+
return changes;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The property paths where `after` differs from `before`, two objects of
|
|
391
|
+
* one class: objects that merge (see isMergeable) are compared property by
|
|
392
|
+
* property, any other value as a whole.
|
|
393
|
+
*/
|
|
394
|
+
function diffPaths(before, after) {
|
|
395
|
+
const changes = [];
|
|
396
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
397
|
+
(function walk(b, a, path) {
|
|
398
|
+
if (ancestors.has(a)) return;
|
|
399
|
+
ancestors.add(a);
|
|
400
|
+
for (const change of keyChanges(b, a, false)) {
|
|
401
|
+
const at = [...path, change.key];
|
|
402
|
+
if (change.kind === "nested") walk(change.before, change.after, at);
|
|
403
|
+
else changes.push(change.kind === "deleted" ? {
|
|
404
|
+
path: at,
|
|
405
|
+
deleted: true
|
|
406
|
+
} : {
|
|
407
|
+
path: at,
|
|
408
|
+
deleted: false,
|
|
409
|
+
value: change.after
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
ancestors.delete(a);
|
|
413
|
+
})(before, after, []);
|
|
414
|
+
return changes;
|
|
415
|
+
}
|
|
416
|
+
/** Whether `target` already holds what `change` would write. */
|
|
417
|
+
function isApplied(target, change) {
|
|
418
|
+
const parent = getByPath(target, change.path.slice(0, -1));
|
|
419
|
+
if (parent === null || typeof parent !== "object") return change.deleted;
|
|
420
|
+
const key = change.path[change.path.length - 1];
|
|
421
|
+
if (!hasOwn(parent, key)) return change.deleted;
|
|
422
|
+
return !change.deleted && deepEqual(parent[key], change.value);
|
|
423
|
+
}
|
|
424
|
+
function applyChange(target, change) {
|
|
425
|
+
if (change.deleted) deleteByPath(target, change.path);
|
|
426
|
+
else setByPath(target, change.path, change.value);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Write how `after` differs from `before` (see keyChanges; arrays merge by
|
|
430
|
+
* index) into `target`, another version of the same object or array.
|
|
431
|
+
* Values that changed are written whole, as deep copies; for values that
|
|
432
|
+
* merge, `mergeNested` may merge them into `target` instead, returning
|
|
433
|
+
* whether it did. Into an array, elements removed from the end are removed
|
|
434
|
+
* at the same indices and elements added are appended, and changes at
|
|
435
|
+
* indices `target` lacks are dropped: where `target` was resized, its
|
|
436
|
+
* indices do not line up with `before`'s.
|
|
437
|
+
*/
|
|
438
|
+
function mergeKeys(target, before, after, mergeNested) {
|
|
439
|
+
const list = Array.isArray(target) ? target : void 0;
|
|
440
|
+
for (const change of keyChanges(before, after, true)) {
|
|
441
|
+
const { key } = change;
|
|
442
|
+
if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
|
|
443
|
+
else delete target[key];
|
|
444
|
+
else if (list && change.kind === "added") list.push(deepClone(change.after));
|
|
445
|
+
else if (list && Number(key) >= list.length) continue;
|
|
446
|
+
else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/class-registry.ts
|
|
451
|
+
var registry$1 = /* @__PURE__ */ new Map();
|
|
452
|
+
var ctorToName = /* @__PURE__ */ new Map();
|
|
453
|
+
function registerClass(name, ctor) {
|
|
454
|
+
registry$1.set(name, ctor);
|
|
455
|
+
ctorToName.set(ctor, name);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The registered name of the class `value` is an instance of, or undefined.
|
|
459
|
+
* Looks the constructor up on the prototype, so an own property named
|
|
460
|
+
* "constructor" (plain data) cannot hide or fake the class.
|
|
461
|
+
*/
|
|
462
|
+
function registeredClassName(value) {
|
|
463
|
+
const proto = Object.getPrototypeOf(value);
|
|
464
|
+
return proto === null ? void 0 : ctorToName.get(proto.constructor);
|
|
465
|
+
}
|
|
157
466
|
var CLASS_TAG = "__spindle_class__";
|
|
158
467
|
var DATA_TAG = "__spindle_data__";
|
|
159
468
|
/** A tagged value, as serialize() writes and deserialize() reads it. */
|
|
@@ -225,7 +534,7 @@ function serialize(value) {
|
|
|
225
534
|
const name = registeredClassName(val);
|
|
226
535
|
if (name !== void 0) return tagged(name, serKeys(val));
|
|
227
536
|
const data = serKeys(val);
|
|
228
|
-
return hasOwn
|
|
537
|
+
return hasOwn(val, CLASS_TAG) ? tagged("__Object__", data) : data;
|
|
229
538
|
}
|
|
230
539
|
return ser(value);
|
|
231
540
|
}
|
|
@@ -311,7 +620,7 @@ function isDeserializable(value) {
|
|
|
311
620
|
case "__Map__": return Array.isArray(data.entries) && Array.from(data.entries).every((entry) => Array.isArray(entry) && entry.length === 2 && check(entry[0]) && check(entry[1]));
|
|
312
621
|
case "__Set__": return Array.isArray(data.entries) && Array.from(data.entries).every(check);
|
|
313
622
|
case "__Undefined__": return true;
|
|
314
|
-
case "__Number__": return typeof data.value === "string" && hasOwn
|
|
623
|
+
case "__Number__": return typeof data.value === "string" && hasOwn(SPECIAL_NUMBERS, data.value);
|
|
315
624
|
case "__BigInt__": return typeof data.value === "string" && /^-?\d+$/.test(data.value);
|
|
316
625
|
default: return checkKeys(data);
|
|
317
626
|
}
|
|
@@ -382,6 +691,10 @@ function isSavePayload(value) {
|
|
|
382
691
|
if (typeof obj.historyIndex !== "number") return false;
|
|
383
692
|
return true;
|
|
384
693
|
}
|
|
694
|
+
/** Estimated byte size of a serialized save payload. */
|
|
695
|
+
function estimatePayloadBytes(payload) {
|
|
696
|
+
return JSON.stringify(payload).length;
|
|
697
|
+
}
|
|
385
698
|
//#endregion
|
|
386
699
|
//#region src/js-lexer.ts
|
|
387
700
|
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
@@ -2620,76 +2933,6 @@ function getPRNGPull() {
|
|
|
2620
2933
|
return currentPull;
|
|
2621
2934
|
}
|
|
2622
2935
|
//#endregion
|
|
2623
|
-
//#region src/utils/namespace.ts
|
|
2624
|
-
/** The variable name no namespace can hold. */
|
|
2625
|
-
var RESERVED_NAME = "__proto__";
|
|
2626
|
-
/**
|
|
2627
|
-
* Why `name` (without its sigil) cannot be a variable name, or undefined if
|
|
2628
|
-
* it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
|
|
2629
|
-
*/
|
|
2630
|
-
function variableNameError(name, label = name) {
|
|
2631
|
-
return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
|
|
2632
|
-
}
|
|
2633
|
-
/** Throw a TypeError if `name` cannot be a variable name. */
|
|
2634
|
-
function checkVariableName(name, label = name) {
|
|
2635
|
-
const error = variableNameError(name, label);
|
|
2636
|
-
if (error) throw new TypeError(`spindle: ${error}`);
|
|
2637
|
-
}
|
|
2638
|
-
/** Whether `obj` holds `key` as an own property. */
|
|
2639
|
-
var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
2640
|
-
/** `ns[key]` if `ns` holds it as an own property, else undefined. */
|
|
2641
|
-
var ownValue = (ns, key) => ns && hasOwn$2(ns, key) ? ns[key] : void 0;
|
|
2642
|
-
/**
|
|
2643
|
-
* A record with no prototype holding the own entries of `sources`, or only
|
|
2644
|
-
* those whose value passes `keep`.
|
|
2645
|
-
*/
|
|
2646
|
-
function ownEntries(sources, keep = () => true) {
|
|
2647
|
-
const record = Object.create(null);
|
|
2648
|
-
for (const source of sources) {
|
|
2649
|
-
if (!source) continue;
|
|
2650
|
-
for (const key of Object.keys(source)) {
|
|
2651
|
-
const value = source[key];
|
|
2652
|
-
if (keep(value)) record[key] = value;
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
return record;
|
|
2656
|
-
}
|
|
2657
|
-
/** A namespace with no prototype holding the own entries of `sources`. */
|
|
2658
|
-
var createNamespace = (...sources) => ownEntries(sources);
|
|
2659
|
-
/** Whether `value` is an object with no prototype. */
|
|
2660
|
-
var isNamespace = (value) => Object.getPrototypeOf(value) === null;
|
|
2661
|
-
/** `ns` itself if it has no prototype, else a namespace copy of it. */
|
|
2662
|
-
var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
|
|
2663
|
-
/** A namespace copy of `ns` with `key` set to `value`. */
|
|
2664
|
-
function withEntry(ns, key, value) {
|
|
2665
|
-
const next = createNamespace(ns);
|
|
2666
|
-
next[key] = value;
|
|
2667
|
-
return next;
|
|
2668
|
-
}
|
|
2669
|
-
/** An empty namespace that cannot change. */
|
|
2670
|
-
var EMPTY_NAMESPACE = Object.freeze(createNamespace());
|
|
2671
|
-
/** The names whose own entries differ between `a` and `b`, `a`'s first. */
|
|
2672
|
-
function changedNames(a, b) {
|
|
2673
|
-
return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
|
|
2674
|
-
}
|
|
2675
|
-
var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
2676
|
-
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
2677
|
-
function countOf(counts, name) {
|
|
2678
|
-
const value = ownValue(counts, name);
|
|
2679
|
-
return isCount(value) ? value : 0;
|
|
2680
|
-
}
|
|
2681
|
-
/**
|
|
2682
|
-
* Counters with no prototype holding the own entries of `source` that are
|
|
2683
|
-
* counts, with `name` (if given) counted once more. Immer copies the
|
|
2684
|
-
* counters on every write anyway, so counting into a fresh copy costs no
|
|
2685
|
-
* more than counting in place.
|
|
2686
|
-
*/
|
|
2687
|
-
function createCounts(source, name) {
|
|
2688
|
-
const counts = ownEntries([source], isCount);
|
|
2689
|
-
if (name !== void 0) counts[name] = countOf(source, name) + 1;
|
|
2690
|
-
return counts;
|
|
2691
|
-
}
|
|
2692
|
-
//#endregion
|
|
2693
2936
|
//#region src/expression.ts
|
|
2694
2937
|
var FN_CACHE_MAX = 500;
|
|
2695
2938
|
var fnCache = /* @__PURE__ */ new Map();
|
|
@@ -4322,6 +4565,54 @@ function renderNodes(nodes, options) {
|
|
|
4322
4565
|
}
|
|
4323
4566
|
//#endregion
|
|
4324
4567
|
//#region src/saves/storage.ts
|
|
4568
|
+
/** A backend's tables, made by its `table` function. */
|
|
4569
|
+
function makeTables(table) {
|
|
4570
|
+
return {
|
|
4571
|
+
saves: table("saves", {
|
|
4572
|
+
ifid: (r) => r.meta.ifid,
|
|
4573
|
+
playthroughId: (r) => r.meta.playthroughId
|
|
4574
|
+
}),
|
|
4575
|
+
playthroughs: table("playthroughs", { ifid: (r) => r.ifid }),
|
|
4576
|
+
meta: table("meta", {})
|
|
4577
|
+
};
|
|
4578
|
+
}
|
|
4579
|
+
function createBackend(type, { saves, playthroughs, meta }, destroy) {
|
|
4580
|
+
/** Delete the records `field` finds, resolving to their keys. */
|
|
4581
|
+
async function deleteFound(table, keyOf, field, value) {
|
|
4582
|
+
const keys = (await table.find(field, value)).map(keyOf);
|
|
4583
|
+
for (const key of keys) await table.delete(key);
|
|
4584
|
+
return keys;
|
|
4585
|
+
}
|
|
4586
|
+
async function deleteMetaWhere(test) {
|
|
4587
|
+
for (const key of await meta.keys()) if (test(key)) await meta.delete(key);
|
|
4588
|
+
}
|
|
4589
|
+
const saveId = (r) => r.meta.id;
|
|
4590
|
+
const playthroughId = (r) => r.id;
|
|
4591
|
+
return {
|
|
4592
|
+
type,
|
|
4593
|
+
putSave: (record) => saves.put(record.meta.id, record),
|
|
4594
|
+
getSave: (id) => saves.get(id),
|
|
4595
|
+
deleteSave: (id) => saves.delete(id),
|
|
4596
|
+
getSavesByIfid: (ifid) => saves.find("ifid", ifid),
|
|
4597
|
+
deleteSavesByIfid: async (ifid) => {
|
|
4598
|
+
await deleteFound(saves, saveId, "ifid", ifid);
|
|
4599
|
+
},
|
|
4600
|
+
deleteSavesByPlaythrough: (id) => deleteFound(saves, saveId, "playthroughId", id),
|
|
4601
|
+
putPlaythrough: (record) => playthroughs.put(record.id, record),
|
|
4602
|
+
getPlaythroughsByIfid: (ifid) => playthroughs.find("ifid", ifid),
|
|
4603
|
+
deletePlaythroughsByIfid: async (ifid) => {
|
|
4604
|
+
await deleteFound(playthroughs, playthroughId, "ifid", ifid);
|
|
4605
|
+
},
|
|
4606
|
+
deletePlaythroughById: (id) => playthroughs.delete(id),
|
|
4607
|
+
getMeta: (key) => meta.get(key),
|
|
4608
|
+
setMeta: (key, value) => meta.put(key, value),
|
|
4609
|
+
deleteMeta: (key) => meta.delete(key),
|
|
4610
|
+
deleteMetaByPrefix: (prefix) => deleteMetaWhere((key) => key.startsWith(prefix)),
|
|
4611
|
+
deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
|
|
4612
|
+
getAllMetaKeys: () => meta.keys(),
|
|
4613
|
+
destroy
|
|
4614
|
+
};
|
|
4615
|
+
}
|
|
4325
4616
|
/**
|
|
4326
4617
|
* In-memory storage. Like the other backends it stores copies: records and
|
|
4327
4618
|
* values go in and come out cloned, so changing a record after storing it,
|
|
@@ -4329,87 +4620,37 @@ function renderNodes(nodes, options) {
|
|
|
4329
4620
|
*/
|
|
4330
4621
|
function createMemoryBackend() {
|
|
4331
4622
|
const copy = (value) => structuredClone(value);
|
|
4332
|
-
const
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
}
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
for (const id of [...saves.keys()]) {
|
|
4352
|
-
const s = saves.get(id);
|
|
4353
|
-
if (s && s.meta.ifid === ifid) saves.delete(id);
|
|
4354
|
-
}
|
|
4355
|
-
},
|
|
4356
|
-
async deleteSavesByPlaythrough(playthroughId) {
|
|
4357
|
-
const deleted = [];
|
|
4358
|
-
for (const id of [...saves.keys()]) {
|
|
4359
|
-
const s = saves.get(id);
|
|
4360
|
-
if (s && s.meta.playthroughId === playthroughId) {
|
|
4361
|
-
saves.delete(id);
|
|
4362
|
-
deleted.push(id);
|
|
4363
|
-
}
|
|
4364
|
-
}
|
|
4365
|
-
return deleted;
|
|
4366
|
-
},
|
|
4367
|
-
async putPlaythrough(record) {
|
|
4368
|
-
playthroughs.set(record.id, copy(record));
|
|
4369
|
-
},
|
|
4370
|
-
async getPlaythroughsByIfid(ifid) {
|
|
4371
|
-
return [...playthroughs.values()].filter((p) => p.ifid === ifid).map(copy);
|
|
4372
|
-
},
|
|
4373
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4374
|
-
for (const id of [...playthroughs.keys()]) {
|
|
4375
|
-
const p = playthroughs.get(id);
|
|
4376
|
-
if (p && p.ifid === ifid) playthroughs.delete(id);
|
|
4377
|
-
}
|
|
4378
|
-
},
|
|
4379
|
-
async deletePlaythroughById(id) {
|
|
4380
|
-
playthroughs.delete(id);
|
|
4381
|
-
},
|
|
4382
|
-
async getMeta(key) {
|
|
4383
|
-
return copy(meta.get(key));
|
|
4384
|
-
},
|
|
4385
|
-
async setMeta(key, value) {
|
|
4386
|
-
meta.set(key, copy(value));
|
|
4387
|
-
},
|
|
4388
|
-
async deleteMeta(key) {
|
|
4389
|
-
meta.delete(key);
|
|
4390
|
-
},
|
|
4391
|
-
async deleteMetaByPrefix(prefix) {
|
|
4392
|
-
for (const key of [...meta.keys()]) if (key.startsWith(prefix)) meta.delete(key);
|
|
4393
|
-
},
|
|
4394
|
-
async deleteMetaByIfid(ifid) {
|
|
4395
|
-
for (const key of [...meta.keys()]) if (key.includes(ifid)) meta.delete(key);
|
|
4396
|
-
},
|
|
4397
|
-
async getAllMetaKeys() {
|
|
4398
|
-
return [...meta.keys()];
|
|
4399
|
-
},
|
|
4400
|
-
async destroy() {
|
|
4401
|
-
saves.clear();
|
|
4402
|
-
playthroughs.clear();
|
|
4403
|
-
meta.clear();
|
|
4404
|
-
}
|
|
4405
|
-
};
|
|
4623
|
+
const maps = [];
|
|
4624
|
+
function table(_name, indexes) {
|
|
4625
|
+
const rows = /* @__PURE__ */ new Map();
|
|
4626
|
+
maps.push(rows);
|
|
4627
|
+
return {
|
|
4628
|
+
get: async (key) => copy(rows.get(key)),
|
|
4629
|
+
put: async (key, value) => {
|
|
4630
|
+
rows.set(key, copy(value));
|
|
4631
|
+
},
|
|
4632
|
+
delete: async (key) => {
|
|
4633
|
+
rows.delete(key);
|
|
4634
|
+
},
|
|
4635
|
+
find: async (field, value) => [...rows.values()].filter((r) => indexes[field](r) === value).map(copy),
|
|
4636
|
+
keys: async () => [...rows.keys()]
|
|
4637
|
+
};
|
|
4638
|
+
}
|
|
4639
|
+
return createBackend("memory", makeTables(table), async () => {
|
|
4640
|
+
for (const rows of maps) rows.clear();
|
|
4641
|
+
});
|
|
4406
4642
|
}
|
|
4407
|
-
|
|
4408
|
-
var
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4643
|
+
/** Key prefixes: of the records, and of each index's ID lists. */
|
|
4644
|
+
var LS_PREFIXES = {
|
|
4645
|
+
saves: "spindle.save.",
|
|
4646
|
+
playthroughs: "spindle.pt.",
|
|
4647
|
+
meta: "spindle.meta."
|
|
4648
|
+
};
|
|
4649
|
+
var LS_INDEX_PREFIXES = {
|
|
4650
|
+
"saves.ifid": "spindle.idx.saves.",
|
|
4651
|
+
"saves.playthroughId": "spindle.idx.saves-pt.",
|
|
4652
|
+
"playthroughs.ifid": "spindle.idx.pt."
|
|
4653
|
+
};
|
|
4413
4654
|
function lsGet(key) {
|
|
4414
4655
|
const raw = localStorage.getItem(key);
|
|
4415
4656
|
if (raw === null) return void 0;
|
|
@@ -4435,107 +4676,55 @@ function lsIndexAdd(key, entry) {
|
|
|
4435
4676
|
lsSet(key, arr);
|
|
4436
4677
|
}
|
|
4437
4678
|
}
|
|
4679
|
+
/** Remove an ID from an index; an index left empty is removed. */
|
|
4438
4680
|
function lsIndexRemove(key, entry) {
|
|
4439
|
-
|
|
4681
|
+
const arr = lsIndex(key).filter((e) => e !== entry);
|
|
4682
|
+
if (arr.length > 0) lsSet(key, arr);
|
|
4683
|
+
else lsDel(key);
|
|
4440
4684
|
}
|
|
4685
|
+
/** The keys of every stored item that start with `prefix`. */
|
|
4686
|
+
var lsKeys = (prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix));
|
|
4441
4687
|
function createLocalStorageBackend() {
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
const ids = lsIndex(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
|
|
4483
|
-
for (const id of ids) {
|
|
4484
|
-
const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
|
|
4485
|
-
if (record) lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
|
|
4486
|
-
lsDel(`${LS_SAVE_PREFIX}${id}`);
|
|
4487
|
-
}
|
|
4488
|
-
lsDel(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
|
|
4489
|
-
return ids;
|
|
4490
|
-
},
|
|
4491
|
-
async putPlaythrough(record) {
|
|
4492
|
-
lsSet(`${LS_PT_PREFIX}${record.id}`, record);
|
|
4493
|
-
lsIndexAdd(`${LS_IDX_PT_PREFIX}${record.ifid}`, record.id);
|
|
4494
|
-
},
|
|
4495
|
-
async getPlaythroughsByIfid(ifid) {
|
|
4496
|
-
const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4497
|
-
const results = [];
|
|
4498
|
-
for (const id of ids) {
|
|
4499
|
-
const record = lsGet(`${LS_PT_PREFIX}${id}`);
|
|
4500
|
-
if (record) results.push(record);
|
|
4501
|
-
}
|
|
4502
|
-
return results;
|
|
4503
|
-
},
|
|
4504
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4505
|
-
const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4506
|
-
for (const id of ids) lsDel(`${LS_PT_PREFIX}${id}`);
|
|
4507
|
-
lsDel(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4508
|
-
},
|
|
4509
|
-
async deletePlaythroughById(id) {
|
|
4510
|
-
const record = lsGet(`${LS_PT_PREFIX}${id}`);
|
|
4511
|
-
if (record) lsIndexRemove(`${LS_IDX_PT_PREFIX}${record.ifid}`, id);
|
|
4512
|
-
lsDel(`${LS_PT_PREFIX}${id}`);
|
|
4513
|
-
},
|
|
4514
|
-
async getMeta(key) {
|
|
4515
|
-
return lsGet(`${LS_META_PREFIX}${key}`);
|
|
4516
|
-
},
|
|
4517
|
-
async setMeta(key, value) {
|
|
4518
|
-
lsSet(`${LS_META_PREFIX}${key}`, value);
|
|
4519
|
-
},
|
|
4520
|
-
async deleteMeta(key) {
|
|
4521
|
-
lsDel(`${LS_META_PREFIX}${key}`);
|
|
4522
|
-
},
|
|
4523
|
-
async deleteMetaByPrefix(prefix) {
|
|
4524
|
-
const fullPrefix = `${LS_META_PREFIX}${prefix}`;
|
|
4525
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(fullPrefix)) localStorage.removeItem(key);
|
|
4526
|
-
},
|
|
4527
|
-
async deleteMetaByIfid(ifid) {
|
|
4528
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX) && key.includes(ifid)) localStorage.removeItem(key);
|
|
4529
|
-
},
|
|
4530
|
-
async getAllMetaKeys() {
|
|
4531
|
-
const keys = [];
|
|
4532
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX)) keys.push(key.slice(13));
|
|
4533
|
-
return keys;
|
|
4534
|
-
},
|
|
4535
|
-
async destroy() {
|
|
4536
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith("spindle.")) localStorage.removeItem(key);
|
|
4537
|
-
}
|
|
4538
|
-
};
|
|
4688
|
+
function table(name, fields) {
|
|
4689
|
+
const prefix = LS_PREFIXES[name];
|
|
4690
|
+
const indexes = Object.entries(fields).map(([field, read]) => ({
|
|
4691
|
+
field,
|
|
4692
|
+
read,
|
|
4693
|
+
prefix: LS_INDEX_PREFIXES[`${name}.${field}`]
|
|
4694
|
+
}));
|
|
4695
|
+
/** The index lists `record` is in. */
|
|
4696
|
+
const listsOf = (record) => indexes.map((index) => `${index.prefix}${index.read(record)}`);
|
|
4697
|
+
return {
|
|
4698
|
+
get: async (key) => lsGet(`${prefix}${key}`),
|
|
4699
|
+
async put(key, value) {
|
|
4700
|
+
const previous = lsGet(`${prefix}${key}`);
|
|
4701
|
+
const lists = listsOf(value);
|
|
4702
|
+
if (previous !== void 0) {
|
|
4703
|
+
for (const list of listsOf(previous)) if (!lists.includes(list)) lsIndexRemove(list, key);
|
|
4704
|
+
}
|
|
4705
|
+
lsSet(`${prefix}${key}`, value);
|
|
4706
|
+
for (const list of lists) lsIndexAdd(list, key);
|
|
4707
|
+
},
|
|
4708
|
+
async delete(key) {
|
|
4709
|
+
const record = lsGet(`${prefix}${key}`);
|
|
4710
|
+
if (record !== void 0) for (const list of listsOf(record)) lsIndexRemove(list, key);
|
|
4711
|
+
lsDel(`${prefix}${key}`);
|
|
4712
|
+
},
|
|
4713
|
+
async find(field, value) {
|
|
4714
|
+
const index = indexes.find((i) => i.field === field);
|
|
4715
|
+
const results = [];
|
|
4716
|
+
for (const id of lsIndex(`${index.prefix}${value}`)) {
|
|
4717
|
+
const record = lsGet(`${prefix}${id}`);
|
|
4718
|
+
if (record !== void 0) results.push(record);
|
|
4719
|
+
}
|
|
4720
|
+
return results;
|
|
4721
|
+
},
|
|
4722
|
+
keys: async () => lsKeys(prefix).map((key) => key.slice(prefix.length))
|
|
4723
|
+
};
|
|
4724
|
+
}
|
|
4725
|
+
return createBackend("localstorage", makeTables(table), async () => {
|
|
4726
|
+
for (const key of lsKeys("spindle.")) lsDel(key);
|
|
4727
|
+
});
|
|
4539
4728
|
}
|
|
4540
4729
|
var IDB_DB_NAME = "spindle";
|
|
4541
4730
|
var IDB_DB_VERSION = 1;
|
|
@@ -4560,95 +4749,51 @@ function createIDBBackend() {
|
|
|
4560
4749
|
});
|
|
4561
4750
|
return dbPromise;
|
|
4562
4751
|
}
|
|
4563
|
-
function tx(storeName, mode) {
|
|
4564
|
-
return openDB().then((db) => db.transaction(storeName, mode).objectStore(storeName));
|
|
4565
|
-
}
|
|
4566
4752
|
function idbReq(req) {
|
|
4567
4753
|
return new Promise((resolve, reject) => {
|
|
4568
4754
|
req.onsuccess = () => resolve(req.result);
|
|
4569
4755
|
req.onerror = () => reject(req.error);
|
|
4570
4756
|
});
|
|
4571
4757
|
}
|
|
4572
|
-
|
|
4573
|
-
|
|
4758
|
+
/**
|
|
4759
|
+
* An object store. Records hold their own key (keyPath); meta values are
|
|
4760
|
+
* stored as `{ key, value }` rows.
|
|
4761
|
+
*/
|
|
4762
|
+
function table(name) {
|
|
4763
|
+
const rows = name === "meta";
|
|
4764
|
+
/** Run one request in a transaction of its own. */
|
|
4765
|
+
const request = async (mode, make) => {
|
|
4766
|
+
return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
|
|
4767
|
+
};
|
|
4768
|
+
return {
|
|
4769
|
+
async get(key) {
|
|
4770
|
+
const result = await request("readonly", (s) => s.get(key));
|
|
4771
|
+
return rows ? result?.value : result;
|
|
4772
|
+
},
|
|
4773
|
+
async put(key, value) {
|
|
4774
|
+
await request("readwrite", (s) => s.put(rows ? {
|
|
4775
|
+
key,
|
|
4776
|
+
value
|
|
4777
|
+
} : value));
|
|
4778
|
+
},
|
|
4779
|
+
async delete(key) {
|
|
4780
|
+
await request("readwrite", (s) => s.delete(key));
|
|
4781
|
+
},
|
|
4782
|
+
async find(field, value) {
|
|
4783
|
+
return await request("readonly", (s) => s.index(field).getAll(value)) ?? [];
|
|
4784
|
+
},
|
|
4785
|
+
keys: async () => await request("readonly", (s) => s.getAllKeys())
|
|
4786
|
+
};
|
|
4574
4787
|
}
|
|
4575
|
-
return {
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
}
|
|
4583
|
-
|
|
4584
|
-
await idbReq((await tx("saves", "readwrite")).delete(id));
|
|
4585
|
-
},
|
|
4586
|
-
async getSavesByIfid(ifid) {
|
|
4587
|
-
return getAllFromIndex("saves", "ifid", ifid);
|
|
4588
|
-
},
|
|
4589
|
-
async deleteSavesByIfid(ifid) {
|
|
4590
|
-
const records = await getAllFromIndex("saves", "ifid", ifid);
|
|
4591
|
-
const store = await tx("saves", "readwrite");
|
|
4592
|
-
for (const r of records) await idbReq(store.delete(r.meta.id));
|
|
4593
|
-
},
|
|
4594
|
-
async deleteSavesByPlaythrough(playthroughId) {
|
|
4595
|
-
const records = await getAllFromIndex("saves", "playthroughId", playthroughId);
|
|
4596
|
-
const store = await tx("saves", "readwrite");
|
|
4597
|
-
const ids = [];
|
|
4598
|
-
for (const r of records) {
|
|
4599
|
-
await idbReq(store.delete(r.meta.id));
|
|
4600
|
-
ids.push(r.meta.id);
|
|
4601
|
-
}
|
|
4602
|
-
return ids;
|
|
4603
|
-
},
|
|
4604
|
-
async putPlaythrough(record) {
|
|
4605
|
-
await idbReq((await tx("playthroughs", "readwrite")).put(record));
|
|
4606
|
-
},
|
|
4607
|
-
async getPlaythroughsByIfid(ifid) {
|
|
4608
|
-
return getAllFromIndex("playthroughs", "ifid", ifid);
|
|
4609
|
-
},
|
|
4610
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4611
|
-
const records = await getAllFromIndex("playthroughs", "ifid", ifid);
|
|
4612
|
-
const store = await tx("playthroughs", "readwrite");
|
|
4613
|
-
for (const r of records) await idbReq(store.delete(r.id));
|
|
4614
|
-
},
|
|
4615
|
-
async deletePlaythroughById(id) {
|
|
4616
|
-
await idbReq((await tx("playthroughs", "readwrite")).delete(id));
|
|
4617
|
-
},
|
|
4618
|
-
async getMeta(key) {
|
|
4619
|
-
const row = await idbReq((await tx("meta", "readonly")).get(key));
|
|
4620
|
-
return row ? row.value : void 0;
|
|
4621
|
-
},
|
|
4622
|
-
async setMeta(key, value) {
|
|
4623
|
-
await idbReq((await tx("meta", "readwrite")).put({
|
|
4624
|
-
key,
|
|
4625
|
-
value
|
|
4626
|
-
}));
|
|
4627
|
-
},
|
|
4628
|
-
async deleteMeta(key) {
|
|
4629
|
-
await idbReq((await tx("meta", "readwrite")).delete(key));
|
|
4630
|
-
},
|
|
4631
|
-
async deleteMetaByPrefix(prefix) {
|
|
4632
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
4633
|
-
for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
4634
|
-
},
|
|
4635
|
-
async deleteMetaByIfid(ifid) {
|
|
4636
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
4637
|
-
for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
4638
|
-
},
|
|
4639
|
-
async getAllMetaKeys() {
|
|
4640
|
-
return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
|
|
4641
|
-
},
|
|
4642
|
-
async destroy() {
|
|
4643
|
-
(await openDB()).close();
|
|
4644
|
-
dbPromise = null;
|
|
4645
|
-
await new Promise((resolve, reject) => {
|
|
4646
|
-
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
4647
|
-
req.onsuccess = () => resolve();
|
|
4648
|
-
req.onerror = () => reject(req.error);
|
|
4649
|
-
});
|
|
4650
|
-
}
|
|
4651
|
-
};
|
|
4788
|
+
return createBackend("indexeddb", makeTables(table), async () => {
|
|
4789
|
+
(await openDB()).close();
|
|
4790
|
+
dbPromise = null;
|
|
4791
|
+
await new Promise((resolve, reject) => {
|
|
4792
|
+
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
4793
|
+
req.onsuccess = () => resolve();
|
|
4794
|
+
req.onerror = () => reject(req.error);
|
|
4795
|
+
});
|
|
4796
|
+
});
|
|
4652
4797
|
}
|
|
4653
4798
|
var _backend = null;
|
|
4654
4799
|
async function detectBackend() {
|
|
@@ -4797,15 +4942,17 @@ function inOrder(op) {
|
|
|
4797
4942
|
operationQueue = run.catch(() => {});
|
|
4798
4943
|
return run;
|
|
4799
4944
|
}
|
|
4945
|
+
/** `op` (a `...Now` helper) as an operation that queues itself. */
|
|
4946
|
+
function queued(op) {
|
|
4947
|
+
return (...args) => inOrder(() => op(...args));
|
|
4948
|
+
}
|
|
4800
4949
|
/**
|
|
4801
4950
|
* Store a new playthrough and make it the story's current one. Pass `id` when
|
|
4802
4951
|
* the caller has already switched to the playthrough (restart() does, so
|
|
4803
4952
|
* saves issued before this resolves are tagged with it).
|
|
4804
4953
|
*/
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
}
|
|
4808
|
-
async function startNewPlaythroughNow(ifid, id) {
|
|
4954
|
+
var startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
4955
|
+
async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
|
|
4809
4956
|
const backend = await getBackend();
|
|
4810
4957
|
const num = await nextPlaythroughNumber(ifid);
|
|
4811
4958
|
const record = {
|
|
@@ -4850,19 +4997,26 @@ function currentPlaythroughKey(ifid) {
|
|
|
4850
4997
|
* recorded again as "Imported", as importing the save would: it was not
|
|
4851
4998
|
* started here, so it takes no number.
|
|
4852
4999
|
*/
|
|
4853
|
-
|
|
4854
|
-
return inOrder(() => adoptPlaythroughNow(ifid, id));
|
|
4855
|
-
}
|
|
5000
|
+
var adoptPlaythrough = queued(adoptPlaythroughNow);
|
|
4856
5001
|
async function adoptPlaythroughNow(ifid, id) {
|
|
5002
|
+
const backend = await getBackend();
|
|
5003
|
+
await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
|
|
5004
|
+
const key = currentPlaythroughKey(ifid);
|
|
5005
|
+
if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
|
|
5006
|
+
}
|
|
5007
|
+
/**
|
|
5008
|
+
* Record the playthrough `id` of the story as "Imported", created at
|
|
5009
|
+
* `createdAt`, unless it has a record: a playthrough not started here takes
|
|
5010
|
+
* no number.
|
|
5011
|
+
*/
|
|
5012
|
+
async function ensurePlaythrough(ifid, id, createdAt) {
|
|
4857
5013
|
const backend = await getBackend();
|
|
4858
5014
|
if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === id)) await backend.putPlaythrough({
|
|
4859
5015
|
id,
|
|
4860
5016
|
ifid,
|
|
4861
|
-
createdAt
|
|
5017
|
+
createdAt,
|
|
4862
5018
|
label: "Imported"
|
|
4863
5019
|
});
|
|
4864
|
-
const key = currentPlaythroughKey(ifid);
|
|
4865
|
-
if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
|
|
4866
5020
|
}
|
|
4867
5021
|
/**
|
|
4868
5022
|
* Set up the save system for a story that boots: the story's current
|
|
@@ -4872,7 +5026,7 @@ async function adoptPlaythroughNow(ifid, id) {
|
|
|
4872
5026
|
function establishPlaythrough(ifid) {
|
|
4873
5027
|
return inOrder(async () => {
|
|
4874
5028
|
return {
|
|
4875
|
-
id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid
|
|
5029
|
+
id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
|
|
4876
5030
|
knownSaves: await populateKnownSavesNow(ifid)
|
|
4877
5031
|
};
|
|
4878
5032
|
});
|
|
@@ -4907,12 +5061,35 @@ function saveWithHooks(slot, custom, beginCapture, write) {
|
|
|
4907
5061
|
return Promise.reject(err);
|
|
4908
5062
|
}
|
|
4909
5063
|
}
|
|
4910
|
-
|
|
4911
|
-
|
|
5064
|
+
/**
|
|
5065
|
+
* `payload` with `convert` applied to its live variables and to those of
|
|
5066
|
+
* every history moment.
|
|
5067
|
+
*/
|
|
5068
|
+
function mapVariables(payload, convert) {
|
|
5069
|
+
return {
|
|
5070
|
+
...payload,
|
|
5071
|
+
variables: convert(payload.variables),
|
|
5072
|
+
history: payload.history.map((m) => ({
|
|
5073
|
+
...m,
|
|
5074
|
+
variables: convert(m.variables)
|
|
5075
|
+
}))
|
|
5076
|
+
};
|
|
5077
|
+
}
|
|
5078
|
+
/** The record to store for a save: `payload` serialized, and its size. */
|
|
5079
|
+
function storedRecord(meta, payload) {
|
|
5080
|
+
const serialized = mapVariables(deepClone(payload), serialize);
|
|
5081
|
+
return {
|
|
5082
|
+
meta: {
|
|
5083
|
+
...meta,
|
|
5084
|
+
estimatedBytes: estimatePayloadBytes(serialized)
|
|
5085
|
+
},
|
|
5086
|
+
payload: serialized
|
|
5087
|
+
};
|
|
4912
5088
|
}
|
|
4913
|
-
|
|
5089
|
+
var createSave = queued(createSaveNow);
|
|
5090
|
+
async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
|
|
4914
5091
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4915
|
-
const
|
|
5092
|
+
const record = storedRecord({
|
|
4916
5093
|
id: crypto.randomUUID(),
|
|
4917
5094
|
ifid,
|
|
4918
5095
|
playthroughId,
|
|
@@ -4921,18 +5098,7 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
|
|
|
4921
5098
|
title: generateTitle(payload),
|
|
4922
5099
|
passage: payload.passage,
|
|
4923
5100
|
custom
|
|
4924
|
-
};
|
|
4925
|
-
const serializedPayload = deepClone(payload);
|
|
4926
|
-
serializedPayload.variables = serialize(serializedPayload.variables);
|
|
4927
|
-
serializedPayload.history = serializedPayload.history.map((m) => ({
|
|
4928
|
-
...m,
|
|
4929
|
-
variables: serialize(m.variables)
|
|
4930
|
-
}));
|
|
4931
|
-
const record = {
|
|
4932
|
-
meta,
|
|
4933
|
-
payload: serializedPayload
|
|
4934
|
-
};
|
|
4935
|
-
record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
5101
|
+
}, payload);
|
|
4936
5102
|
await (await getBackend()).putSave(record);
|
|
4937
5103
|
return record;
|
|
4938
5104
|
}
|
|
@@ -4951,34 +5117,22 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
|
|
|
4951
5117
|
* save itself rather than describing its content. `custom` is merged, as
|
|
4952
5118
|
* before, so slot keys and metadata not passed again are kept.
|
|
4953
5119
|
*/
|
|
4954
|
-
|
|
4955
|
-
return inOrder(() => overwriteSaveNow(saveId, payload, custom, playthroughId));
|
|
4956
|
-
}
|
|
5120
|
+
var overwriteSave = queued(overwriteSaveNow);
|
|
4957
5121
|
async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
|
|
4958
5122
|
const backend = await getBackend();
|
|
4959
5123
|
const existing = await backend.getSave(saveId);
|
|
4960
5124
|
if (!existing) return void 0;
|
|
4961
|
-
const
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
...
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
|
|
4973
|
-
passage: payload.passage,
|
|
4974
|
-
...custom != null ? { custom: {
|
|
4975
|
-
...existing.meta.custom,
|
|
4976
|
-
...custom
|
|
4977
|
-
} } : {}
|
|
4978
|
-
},
|
|
4979
|
-
payload: serializedPayload
|
|
4980
|
-
};
|
|
4981
|
-
updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
5125
|
+
const updated = storedRecord({
|
|
5126
|
+
...existing.meta,
|
|
5127
|
+
...playthroughId ? { playthroughId } : {},
|
|
5128
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5129
|
+
title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
|
|
5130
|
+
passage: payload.passage,
|
|
5131
|
+
...custom != null ? { custom: {
|
|
5132
|
+
...existing.meta.custom,
|
|
5133
|
+
...custom
|
|
5134
|
+
} } : {}
|
|
5135
|
+
}, payload);
|
|
4982
5136
|
await backend.putSave(updated);
|
|
4983
5137
|
return updated;
|
|
4984
5138
|
}
|
|
@@ -4988,15 +5142,11 @@ async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
|
|
|
4988
5142
|
* untouched. Payloads handed to the store's `loadFromPayload()` must be live.
|
|
4989
5143
|
*/
|
|
4990
5144
|
function deserializePayload(payload) {
|
|
4991
|
-
return
|
|
4992
|
-
...payload,
|
|
4993
|
-
variables: deserialize(payload.variables),
|
|
4994
|
-
history: payload.history.map((m) => ({
|
|
4995
|
-
...m,
|
|
4996
|
-
variables: deserialize(m.variables)
|
|
4997
|
-
}))
|
|
4998
|
-
};
|
|
5145
|
+
return mapVariables(payload, deserialize);
|
|
4999
5146
|
}
|
|
5147
|
+
/** The live payload of a stored save, if there is one. */
|
|
5148
|
+
var livePayload = (record) => record && deserializePayload(record.payload);
|
|
5149
|
+
queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
|
|
5000
5150
|
/**
|
|
5001
5151
|
* Delete a save record. If the default slot or a named slot holds it, that
|
|
5002
5152
|
* slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
|
|
@@ -5007,14 +5157,7 @@ function deleteSaveById(saveId) {
|
|
|
5007
5157
|
const record = await backend.getSave(saveId);
|
|
5008
5158
|
await backend.deleteSave(saveId);
|
|
5009
5159
|
if (!record) return;
|
|
5010
|
-
|
|
5011
|
-
const slots = [void 0, ...await getIndexedSlots(ifid)];
|
|
5012
|
-
for (const slot of slots) {
|
|
5013
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5014
|
-
if (await backend.getMeta(metaKey) !== saveId) continue;
|
|
5015
|
-
await backend.deleteMeta(metaKey);
|
|
5016
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5017
|
-
}
|
|
5160
|
+
await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
|
|
5018
5161
|
});
|
|
5019
5162
|
}
|
|
5020
5163
|
function renameSave(saveId, newTitle) {
|
|
@@ -5034,10 +5177,7 @@ function renameSave(saveId, newTitle) {
|
|
|
5034
5177
|
await backend.putSave(updated);
|
|
5035
5178
|
});
|
|
5036
5179
|
}
|
|
5037
|
-
|
|
5038
|
-
return inOrder(() => getSavesGroupedNow(ifid));
|
|
5039
|
-
}
|
|
5040
|
-
async function getSavesGroupedNow(ifid) {
|
|
5180
|
+
var getSavesGrouped = queued(async function getSavesGroupedNow(ifid) {
|
|
5041
5181
|
const backend = await getBackend();
|
|
5042
5182
|
const [allSaves, allPlaythroughs] = await Promise.all([backend.getSavesByIfid(ifid), backend.getPlaythroughsByIfid(ifid)]);
|
|
5043
5183
|
const ptMap = /* @__PURE__ */ new Map();
|
|
@@ -5069,7 +5209,7 @@ async function getSavesGroupedNow(ifid) {
|
|
|
5069
5209
|
saves
|
|
5070
5210
|
});
|
|
5071
5211
|
return result;
|
|
5072
|
-
}
|
|
5212
|
+
});
|
|
5073
5213
|
var AUTOSAVE_KEY_PREFIX = "autosave.";
|
|
5074
5214
|
var SLOT_KEY_PREFIX = "slot.";
|
|
5075
5215
|
var SLOT_INDEX_KEY_PREFIX = "slotIndex.";
|
|
@@ -5083,29 +5223,49 @@ function isNamedSlot(slot) {
|
|
|
5083
5223
|
function slotMetaKey(ifid, slot) {
|
|
5084
5224
|
return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
5085
5225
|
}
|
|
5226
|
+
var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
5086
5227
|
/** Read-modify-write the per-story slot index. */
|
|
5087
5228
|
async function updateSlotIndex(ifid, update) {
|
|
5088
5229
|
const backend = await getBackend();
|
|
5089
|
-
const
|
|
5090
|
-
const existing = await backend.getMeta(indexKey) ?? [];
|
|
5230
|
+
const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
|
|
5091
5231
|
const updated = update(existing);
|
|
5092
|
-
if (updated !== existing) await backend.setMeta(
|
|
5093
|
-
}
|
|
5094
|
-
/**
|
|
5095
|
-
function
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5232
|
+
if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
|
|
5233
|
+
}
|
|
5234
|
+
/** Every slot: the default one (undefined), then those in the slot index. */
|
|
5235
|
+
async function allSlots(ifid) {
|
|
5236
|
+
return [void 0, ...(await (await getBackend()).getMeta(slotIndexKey(ifid)) ?? []).filter(isNamedSlot)];
|
|
5237
|
+
}
|
|
5238
|
+
/** The ID of the save a slot holds, if any. */
|
|
5239
|
+
async function slotSaveId(ifid, slot) {
|
|
5240
|
+
return (await getBackend()).getMeta(slotMetaKey(ifid, slot));
|
|
5241
|
+
}
|
|
5242
|
+
/** The record of the save a slot holds, if there is one. */
|
|
5243
|
+
async function slotRecord(ifid, slot) {
|
|
5244
|
+
const id = await slotSaveId(ifid, slot);
|
|
5245
|
+
return id ? (await getBackend()).getSave(id) : void 0;
|
|
5246
|
+
}
|
|
5247
|
+
/** Make a slot hold the save `saveId`: its pointer, and the slot index. */
|
|
5248
|
+
async function fillSlot(ifid, slot, saveId) {
|
|
5249
|
+
await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
|
|
5250
|
+
if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
|
|
5251
|
+
}
|
|
5252
|
+
/** Clear a slot: its pointer, and its entry in the slot index. */
|
|
5253
|
+
async function emptySlot(ifid, slot) {
|
|
5254
|
+
await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
|
|
5255
|
+
if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
|
|
5256
|
+
}
|
|
5257
|
+
/** Clear every slot holding a save whose ID passes `test`. */
|
|
5258
|
+
async function emptySlotsHolding(ifid, test) {
|
|
5259
|
+
for (const slot of await allSlots(ifid)) {
|
|
5260
|
+
const id = await slotSaveId(ifid, slot);
|
|
5261
|
+
if (id && test(id)) await emptySlot(ifid, slot);
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
/** The `custom` keys that say which slot a save lives in. */
|
|
5265
|
+
var slotCustom = (slot) => ({
|
|
5266
|
+
isAutosave: !isNamedSlot(slot),
|
|
5267
|
+
...isNamedSlot(slot) ? { slot } : {}
|
|
5268
|
+
});
|
|
5109
5269
|
function toSaveInfo(record, slot) {
|
|
5110
5270
|
return {
|
|
5111
5271
|
slot: isNamedSlot(slot) ? slot : "",
|
|
@@ -5123,32 +5283,26 @@ function toSaveInfo(record, slot) {
|
|
|
5123
5283
|
* place in the order of operations when it is called.
|
|
5124
5284
|
*/
|
|
5125
5285
|
function quickSave(ifid, playthroughId, payload, slot, custom) {
|
|
5126
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5127
5286
|
return inOrder(async () => {
|
|
5128
5287
|
const ptId = await playthroughId;
|
|
5129
|
-
const
|
|
5130
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5288
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
5131
5289
|
if (existingId) {
|
|
5132
5290
|
const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
|
|
5133
5291
|
if (updated) return updated;
|
|
5134
5292
|
}
|
|
5135
5293
|
const record = await createSaveNow(ifid, ptId, payload, {
|
|
5136
|
-
|
|
5137
|
-
...isNamedSlot(slot) ? { slot } : {},
|
|
5294
|
+
...slotCustom(slot),
|
|
5138
5295
|
...custom
|
|
5139
5296
|
});
|
|
5140
|
-
await
|
|
5141
|
-
await addToSlotIndex(ifid, slot);
|
|
5297
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
5142
5298
|
return record;
|
|
5143
5299
|
});
|
|
5144
5300
|
}
|
|
5301
|
+
queued(hasQuickSaveNow);
|
|
5145
5302
|
async function hasQuickSaveNow(ifid, slot) {
|
|
5146
|
-
|
|
5147
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5148
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5149
|
-
if (!existingId) return false;
|
|
5150
|
-
return await backend.getSave(existingId) !== void 0;
|
|
5303
|
+
return await slotRecord(ifid, slot) !== void 0;
|
|
5151
5304
|
}
|
|
5305
|
+
queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
|
|
5152
5306
|
/**
|
|
5153
5307
|
* Read the save held in a slot (the default slot when `slot` is omitted) for
|
|
5154
5308
|
* the running game to load, and make its playthrough the story's current one
|
|
@@ -5157,12 +5311,8 @@ async function hasQuickSaveNow(ifid, slot) {
|
|
|
5157
5311
|
* and its playthrough, or undefined if the slot is empty.
|
|
5158
5312
|
*/
|
|
5159
5313
|
function loadSlotSave(ifid, slot) {
|
|
5160
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5161
5314
|
return inOrder(async () => {
|
|
5162
|
-
const
|
|
5163
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5164
|
-
if (!existingId) return void 0;
|
|
5165
|
-
const record = await backend.getSave(existingId);
|
|
5315
|
+
const record = await slotRecord(ifid, slot);
|
|
5166
5316
|
if (!record) return void 0;
|
|
5167
5317
|
const payload = deserializePayload(record.payload);
|
|
5168
5318
|
const { playthroughId } = record.meta;
|
|
@@ -5177,60 +5327,41 @@ function loadSlotSave(ifid, slot) {
|
|
|
5177
5327
|
* Check storage for all known saves and return a map of slot keys to true.
|
|
5178
5328
|
* The default (autosave) slot uses empty string as key.
|
|
5179
5329
|
*/
|
|
5180
|
-
|
|
5181
|
-
return inOrder(() => populateKnownSavesNow(ifid));
|
|
5182
|
-
}
|
|
5330
|
+
var populateKnownSaves = queued(populateKnownSavesNow);
|
|
5183
5331
|
async function populateKnownSavesNow(ifid) {
|
|
5184
5332
|
const result = Object.create(null);
|
|
5185
|
-
if (await hasQuickSaveNow(ifid)) result[""] = true;
|
|
5186
|
-
for (const slot of await getIndexedSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot] = true;
|
|
5333
|
+
for (const slot of await allSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ""] = true;
|
|
5187
5334
|
return result;
|
|
5188
5335
|
}
|
|
5189
5336
|
/**
|
|
5190
5337
|
* Get metadata for a specific save slot.
|
|
5191
5338
|
* Returns null if no save exists for that slot.
|
|
5192
5339
|
*/
|
|
5193
|
-
|
|
5194
|
-
return inOrder(() => getSlotSaveInfoNow(ifid, slot));
|
|
5195
|
-
}
|
|
5340
|
+
var getSlotSaveInfo = queued(getSlotSaveInfoNow);
|
|
5196
5341
|
async function getSlotSaveInfoNow(ifid, slot) {
|
|
5197
|
-
const
|
|
5198
|
-
|
|
5199
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5200
|
-
if (!existingId) return null;
|
|
5201
|
-
const record = await backend.getSave(existingId);
|
|
5202
|
-
if (!record) return null;
|
|
5203
|
-
return toSaveInfo(record, slot);
|
|
5342
|
+
const record = await slotRecord(ifid, slot);
|
|
5343
|
+
return record ? toSaveInfo(record, slot) : null;
|
|
5204
5344
|
}
|
|
5205
5345
|
/**
|
|
5206
5346
|
* List metadata for all known save slots (default + named).
|
|
5207
5347
|
*/
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
const
|
|
5212
|
-
if (
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
}
|
|
5217
|
-
return result;
|
|
5218
|
-
});
|
|
5219
|
-
}
|
|
5348
|
+
var listSlotSaves = queued(async (ifid) => {
|
|
5349
|
+
const result = [];
|
|
5350
|
+
for (const slot of await allSlots(ifid)) {
|
|
5351
|
+
const info = await getSlotSaveInfoNow(ifid, slot);
|
|
5352
|
+
if (info) result.push(info);
|
|
5353
|
+
}
|
|
5354
|
+
return result;
|
|
5355
|
+
});
|
|
5220
5356
|
/**
|
|
5221
5357
|
* Delete a save by slot name. Removes from slot index if named.
|
|
5222
5358
|
*/
|
|
5223
|
-
|
|
5224
|
-
const
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
await backend.deleteSave(existingId);
|
|
5230
|
-
await backend.deleteMeta(metaKey);
|
|
5231
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5232
|
-
});
|
|
5233
|
-
}
|
|
5359
|
+
var deleteSlotSave = queued(async (ifid, slot) => {
|
|
5360
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
5361
|
+
if (!existingId) return;
|
|
5362
|
+
await (await getBackend()).deleteSave(existingId);
|
|
5363
|
+
await emptySlot(ifid, slot);
|
|
5364
|
+
});
|
|
5234
5365
|
var SESSION_KEY_PREFIX = "spindle.session.";
|
|
5235
5366
|
/**
|
|
5236
5367
|
* Write a pre-serialized session payload to sessionStorage.
|
|
@@ -5257,11 +5388,8 @@ function clearSession(ifid) {
|
|
|
5257
5388
|
sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
5258
5389
|
} catch {}
|
|
5259
5390
|
}
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
}
|
|
5263
|
-
async function exportSaveNow(saveId) {
|
|
5264
|
-
const record = await (await getBackend()).getSave(saveId);
|
|
5391
|
+
/** The export of a stored save, if there is one. */
|
|
5392
|
+
function toExport(record) {
|
|
5265
5393
|
if (!record) return void 0;
|
|
5266
5394
|
return {
|
|
5267
5395
|
version: 1,
|
|
@@ -5270,6 +5398,7 @@ async function exportSaveNow(saveId) {
|
|
|
5270
5398
|
save: record
|
|
5271
5399
|
};
|
|
5272
5400
|
}
|
|
5401
|
+
var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
|
|
5273
5402
|
/**
|
|
5274
5403
|
* Validate an export against the running story and build the record to store:
|
|
5275
5404
|
* a fresh save ID, and the playthrough record created if it doesn't exist yet.
|
|
@@ -5279,20 +5408,11 @@ async function prepareImport(data, ifid) {
|
|
|
5279
5408
|
if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
|
|
5280
5409
|
if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
|
|
5281
5410
|
if (!isSaveExport(data)) throw new Error("Invalid save file format");
|
|
5282
|
-
const backend = await getBackend();
|
|
5283
5411
|
const record = deepClone(data.save);
|
|
5284
5412
|
record.meta.id = crypto.randomUUID();
|
|
5285
5413
|
record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5286
5414
|
record.meta.ifid = ifid;
|
|
5287
|
-
|
|
5288
|
-
const pt = {
|
|
5289
|
-
id: record.meta.playthroughId,
|
|
5290
|
-
ifid,
|
|
5291
|
-
createdAt: record.meta.createdAt,
|
|
5292
|
-
label: "Imported"
|
|
5293
|
-
};
|
|
5294
|
-
await backend.putPlaythrough(pt);
|
|
5295
|
-
}
|
|
5415
|
+
await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
|
|
5296
5416
|
return record;
|
|
5297
5417
|
}
|
|
5298
5418
|
function importSave(data, ifid) {
|
|
@@ -5306,13 +5426,7 @@ function importSave(data, ifid) {
|
|
|
5306
5426
|
* Export the save held in a slot (default autosave slot when `slot` is omitted).
|
|
5307
5427
|
* Returns undefined if the slot is empty.
|
|
5308
5428
|
*/
|
|
5309
|
-
|
|
5310
|
-
return inOrder(async () => {
|
|
5311
|
-
const saveId = await (await getBackend()).getMeta(slotMetaKey(ifid, slot));
|
|
5312
|
-
if (!saveId) return void 0;
|
|
5313
|
-
return exportSaveNow(saveId);
|
|
5314
|
-
});
|
|
5315
|
-
}
|
|
5429
|
+
var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
|
|
5316
5430
|
/**
|
|
5317
5431
|
* Import an exported save into a slot (default autosave slot when `slot` is
|
|
5318
5432
|
* omitted), replacing any save already in that slot. Applies the same
|
|
@@ -5320,35 +5434,29 @@ function exportSlotSave(ifid, slot) {
|
|
|
5320
5434
|
* and slot index consistent.
|
|
5321
5435
|
*/
|
|
5322
5436
|
function importSlotSave(data, ifid, slot) {
|
|
5323
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5324
5437
|
return inOrder(async () => {
|
|
5325
5438
|
const record = await prepareImport(data, ifid);
|
|
5326
5439
|
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
5327
5440
|
record.meta.custom = {
|
|
5328
5441
|
...custom,
|
|
5329
|
-
|
|
5330
|
-
...isNamedSlot(slot) ? { slot } : {}
|
|
5442
|
+
...slotCustom(slot)
|
|
5331
5443
|
};
|
|
5332
5444
|
const backend = await getBackend();
|
|
5333
|
-
const previousId = await
|
|
5445
|
+
const previousId = await slotSaveId(ifid, slot);
|
|
5334
5446
|
await backend.putSave(record);
|
|
5335
|
-
await
|
|
5336
|
-
await addToSlotIndex(ifid, slot);
|
|
5447
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
5337
5448
|
if (previousId) await backend.deleteSave(previousId);
|
|
5338
5449
|
return toSaveInfo(record, slot);
|
|
5339
5450
|
});
|
|
5340
5451
|
}
|
|
5341
|
-
|
|
5342
|
-
return inOrder(() => getStorageInfoNow(ifid));
|
|
5343
|
-
}
|
|
5344
|
-
async function getStorageInfoNow(ifid) {
|
|
5452
|
+
var getStorageInfo = queued(async function getStorageInfoNow(ifid) {
|
|
5345
5453
|
const backend = await getBackend();
|
|
5346
5454
|
const saves = await backend.getSavesByIfid(ifid);
|
|
5347
5455
|
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
5348
5456
|
let totalBytes = 0;
|
|
5349
5457
|
for (const save of saves) if (save.meta.estimatedBytes != null) totalBytes += save.meta.estimatedBytes;
|
|
5350
5458
|
else {
|
|
5351
|
-
const bytes =
|
|
5459
|
+
const bytes = estimatePayloadBytes(save.payload);
|
|
5352
5460
|
save.meta.estimatedBytes = bytes;
|
|
5353
5461
|
await backend.putSave(save);
|
|
5354
5462
|
totalBytes += bytes;
|
|
@@ -5359,7 +5467,7 @@ async function getStorageInfoNow(ifid) {
|
|
|
5359
5467
|
totalBytes,
|
|
5360
5468
|
backend: backend.type
|
|
5361
5469
|
};
|
|
5362
|
-
}
|
|
5470
|
+
});
|
|
5363
5471
|
function clearGameData(ifid) {
|
|
5364
5472
|
return inOrder(async () => {
|
|
5365
5473
|
const backend = await getBackend();
|
|
@@ -5369,9 +5477,7 @@ function clearGameData(ifid) {
|
|
|
5369
5477
|
clearSession(ifid);
|
|
5370
5478
|
});
|
|
5371
5479
|
}
|
|
5372
|
-
|
|
5373
|
-
return inOrder(clearAllDataNow);
|
|
5374
|
-
}
|
|
5480
|
+
var clearAllData = queued(clearAllDataNow);
|
|
5375
5481
|
async function clearAllDataNow() {
|
|
5376
5482
|
await (await getBackend()).destroy();
|
|
5377
5483
|
resetBackend();
|
|
@@ -5399,14 +5505,7 @@ function deletePlaythroughData(ifid, playthroughId, replacement) {
|
|
|
5399
5505
|
const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
|
|
5400
5506
|
await backend.deletePlaythroughById(playthroughId);
|
|
5401
5507
|
const deletedSet = new Set(deletedSaveIds);
|
|
5402
|
-
|
|
5403
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5404
|
-
const value = await backend.getMeta(metaKey);
|
|
5405
|
-
if (value && deletedSet.has(value)) {
|
|
5406
|
-
await backend.deleteMeta(metaKey);
|
|
5407
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5408
|
-
}
|
|
5409
|
-
}
|
|
5508
|
+
await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
|
|
5410
5509
|
if (replacement && await replacement.current === playthroughId) {
|
|
5411
5510
|
await startNewPlaythroughNow(ifid, replacement.id);
|
|
5412
5511
|
return true;
|
|
@@ -5467,120 +5566,6 @@ function initializeStory(session) {
|
|
|
5467
5566
|
if (!session) recordStoryInitState();
|
|
5468
5567
|
}
|
|
5469
5568
|
//#endregion
|
|
5470
|
-
//#region src/utils/object-path.ts
|
|
5471
|
-
/**
|
|
5472
|
-
* Traverse dot-path segments on an object and return the nested value.
|
|
5473
|
-
* Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
|
|
5474
|
-
* are not story state: unless an object holds one as its own property, it
|
|
5475
|
-
* reads as missing, as setByPath() treats it. Other inherited properties
|
|
5476
|
-
* (class getters, `size` of a Map) are read.
|
|
5477
|
-
*/
|
|
5478
|
-
function getByPath(obj, segments) {
|
|
5479
|
-
let current = obj;
|
|
5480
|
-
for (const seg of segments) {
|
|
5481
|
-
if (current == null || typeof current !== "object") return void 0;
|
|
5482
|
-
if (seg in Object.prototype && !hasOwn$2(current, seg)) return void 0;
|
|
5483
|
-
current = current[seg];
|
|
5484
|
-
}
|
|
5485
|
-
return current;
|
|
5486
|
-
}
|
|
5487
|
-
/** Array indices ("0", "1", …) and "length": the keys an array holds. */
|
|
5488
|
-
var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
|
|
5489
|
-
/**
|
|
5490
|
-
* Built-ins whose content is not their properties: a property written into
|
|
5491
|
-
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
5492
|
-
*/
|
|
5493
|
-
var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
|
|
5494
|
-
/**
|
|
5495
|
-
* Shallow copy that keeps the prototype, so a registered class instance
|
|
5496
|
-
* stays an instance of its class (with the same own keys deepClone copies).
|
|
5497
|
-
*/
|
|
5498
|
-
function shallowCopy(value) {
|
|
5499
|
-
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
5500
|
-
for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
|
|
5501
|
-
value: value[key],
|
|
5502
|
-
enumerable: true,
|
|
5503
|
-
writable: true,
|
|
5504
|
-
configurable: true
|
|
5505
|
-
});
|
|
5506
|
-
return copy;
|
|
5507
|
-
}
|
|
5508
|
-
/**
|
|
5509
|
-
* Set a value at dot-path `segments` below `root`.
|
|
5510
|
-
*
|
|
5511
|
-
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
5512
|
-
* class instances and everything below them) are copied on the way down and
|
|
5513
|
-
* the copy is assigned to its parent, so the write produces new references
|
|
5514
|
-
* up to the root. Writing into such an object directly would mutate the
|
|
5515
|
-
* instance shared with the previous state and with history, and subscribers
|
|
5516
|
-
* would see no change. Outside a draft (a private working copy) the path is
|
|
5517
|
-
* written in place.
|
|
5518
|
-
*
|
|
5519
|
-
* The path goes through own properties of plain objects, class instances
|
|
5520
|
-
* and arrays (by index); anything else throws a TypeError rather than
|
|
5521
|
-
* writing where no clone or save would see it: an inherited property such
|
|
5522
|
-
* as a method counts as missing, and Map, Set, Date and RegExp values and
|
|
5523
|
-
* other keys of arrays are refused, as is a "__proto__" segment.
|
|
5524
|
-
*/
|
|
5525
|
-
function setByPath(root, segments, value, options = {}) {
|
|
5526
|
-
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
5527
|
-
parent[segments[segments.length - 1]] = value;
|
|
5528
|
-
}
|
|
5529
|
-
/**
|
|
5530
|
-
* Delete the property at dot-path `segments` below `root`, copying
|
|
5531
|
-
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
5532
|
-
* intermediate is missing or not an object, or the property is absent (or
|
|
5533
|
-
* only inherited).
|
|
5534
|
-
*/
|
|
5535
|
-
function deleteByPath(root, segments) {
|
|
5536
|
-
const last = segments[segments.length - 1];
|
|
5537
|
-
checkSegments(segments);
|
|
5538
|
-
let holder = root;
|
|
5539
|
-
for (const seg of segments.slice(0, -1)) {
|
|
5540
|
-
if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, seg)) return;
|
|
5541
|
-
holder = holder[seg];
|
|
5542
|
-
}
|
|
5543
|
-
if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, last)) return;
|
|
5544
|
-
const parent = walkToParent(root, segments, false);
|
|
5545
|
-
delete parent[last];
|
|
5546
|
-
}
|
|
5547
|
-
/** Refuse a segment that would reach a prototype instead of story state. */
|
|
5548
|
-
function checkSegments(segments) {
|
|
5549
|
-
if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
|
|
5550
|
-
}
|
|
5551
|
-
/** Throw unless `holder` can take `key` as story state (see setByPath). */
|
|
5552
|
-
function checkHolder(holder, key, segments, depth) {
|
|
5553
|
-
const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
|
|
5554
|
-
if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
|
|
5555
|
-
}
|
|
5556
|
-
/**
|
|
5557
|
-
* Walk to the object holding the last of `segments`, copying undrafted
|
|
5558
|
-
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
5559
|
-
* non-object intermediate is replaced by a plain object when
|
|
5560
|
-
* `createMissing` is set, and throws a TypeError otherwise.
|
|
5561
|
-
*/
|
|
5562
|
-
function walkToParent(root, segments, createMissing) {
|
|
5563
|
-
checkSegments(segments);
|
|
5564
|
-
const copyOnWrite = isDraft(root);
|
|
5565
|
-
let current = root;
|
|
5566
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
5567
|
-
const seg = segments[i];
|
|
5568
|
-
checkHolder(current, seg, segments, i);
|
|
5569
|
-
let next = hasOwn$2(current, seg) ? current[seg] : void 0;
|
|
5570
|
-
if (next == null || typeof next !== "object") {
|
|
5571
|
-
if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
|
|
5572
|
-
next = {};
|
|
5573
|
-
current[seg] = next;
|
|
5574
|
-
} else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
|
|
5575
|
-
next = shallowCopy(next);
|
|
5576
|
-
current[seg] = next;
|
|
5577
|
-
}
|
|
5578
|
-
current = next;
|
|
5579
|
-
}
|
|
5580
|
-
checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
|
|
5581
|
-
return current;
|
|
5582
|
-
}
|
|
5583
|
-
//#endregion
|
|
5584
5569
|
//#region src/execute-mutation.ts
|
|
5585
5570
|
var NAMESPACES = [
|
|
5586
5571
|
"variables",
|
|
@@ -5622,54 +5607,6 @@ function frozenCopy(value) {
|
|
|
5622
5607
|
return freeze(cloneValue(value), true);
|
|
5623
5608
|
}
|
|
5624
5609
|
/**
|
|
5625
|
-
* Objects merged property by property: plain objects and (registered)
|
|
5626
|
-
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
5627
|
-
* replaced as a whole, since their elements have no stable identity to
|
|
5628
|
-
* merge by (a shift moves every index).
|
|
5629
|
-
*/
|
|
5630
|
-
function isMergeable(value) {
|
|
5631
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
5632
|
-
}
|
|
5633
|
-
var hasOwn$1 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
5634
|
-
/** Collect the property paths where `work` differs from `base`. */
|
|
5635
|
-
function diff(base, work, path, changes, ancestors) {
|
|
5636
|
-
if (ancestors.has(work)) return;
|
|
5637
|
-
ancestors.add(work);
|
|
5638
|
-
for (const key of Object.keys(work)) {
|
|
5639
|
-
const b = base[key];
|
|
5640
|
-
const w = work[key];
|
|
5641
|
-
if (!hasOwn$1(base, key)) changes.push({
|
|
5642
|
-
path: [...path, key],
|
|
5643
|
-
deleted: false,
|
|
5644
|
-
value: w
|
|
5645
|
-
});
|
|
5646
|
-
else if (Object.is(b, w)) continue;
|
|
5647
|
-
else if (isMergeable(b) && isMergeable(w) && Object.getPrototypeOf(b) === Object.getPrototypeOf(w)) diff(b, w, [...path, key], changes, ancestors);
|
|
5648
|
-
else if (!deepEqual(b, w)) changes.push({
|
|
5649
|
-
path: [...path, key],
|
|
5650
|
-
deleted: false,
|
|
5651
|
-
value: w
|
|
5652
|
-
});
|
|
5653
|
-
}
|
|
5654
|
-
for (const key of Object.keys(base)) if (!hasOwn$1(work, key)) changes.push({
|
|
5655
|
-
path: [...path, key],
|
|
5656
|
-
deleted: true
|
|
5657
|
-
});
|
|
5658
|
-
ancestors.delete(work);
|
|
5659
|
-
}
|
|
5660
|
-
/** Whether `ns` already holds what `change` would write. */
|
|
5661
|
-
function isApplied(ns, change) {
|
|
5662
|
-
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
5663
|
-
if (parent === null || typeof parent !== "object") return change.deleted;
|
|
5664
|
-
const key = change.path[change.path.length - 1];
|
|
5665
|
-
if (!hasOwn$1(parent, key)) return change.deleted;
|
|
5666
|
-
return !change.deleted && deepEqual(parent[key], change.value);
|
|
5667
|
-
}
|
|
5668
|
-
function applyChange(ns, change) {
|
|
5669
|
-
if (change.deleted) deleteByPath(ns, change.path);
|
|
5670
|
-
else setByPath(ns, change.path, change.value);
|
|
5671
|
-
}
|
|
5672
|
-
/**
|
|
5673
5610
|
* Apply a write that reached the store while mutations execute to their
|
|
5674
5611
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
5675
5612
|
* its commit neither drops it nor reverts it. Called from inside the store
|
|
@@ -5687,7 +5624,7 @@ function mirror(draft, ns, change, scopes = activeScopes) {
|
|
|
5687
5624
|
});
|
|
5688
5625
|
} catch {
|
|
5689
5626
|
const stored = draft[ns][root];
|
|
5690
|
-
if (hasOwn
|
|
5627
|
+
if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
|
|
5691
5628
|
else delete copy[root];
|
|
5692
5629
|
}
|
|
5693
5630
|
}
|
|
@@ -5711,7 +5648,7 @@ function changedPath(ns, segments) {
|
|
|
5711
5648
|
if (!isMergeable(node)) break;
|
|
5712
5649
|
const key = String(segment);
|
|
5713
5650
|
path.push(key);
|
|
5714
|
-
node = hasOwn
|
|
5651
|
+
node = hasOwn(node, key) ? node[key] : void 0;
|
|
5715
5652
|
}
|
|
5716
5653
|
return path;
|
|
5717
5654
|
}
|
|
@@ -5719,7 +5656,7 @@ function changedPath(ns, segments) {
|
|
|
5719
5656
|
function changeTo(ns, path) {
|
|
5720
5657
|
const parent = getByPath(ns, path.slice(0, -1));
|
|
5721
5658
|
const key = path[path.length - 1];
|
|
5722
|
-
return isMergeable(parent) && hasOwn
|
|
5659
|
+
return isMergeable(parent) && hasOwn(parent, key) ? {
|
|
5723
5660
|
path,
|
|
5724
5661
|
deleted: false,
|
|
5725
5662
|
value: parent[key]
|
|
@@ -5735,7 +5672,7 @@ function changeTo(ns, path) {
|
|
|
5735
5672
|
* replaces exactly what it replaced in program order.
|
|
5736
5673
|
*/
|
|
5737
5674
|
function writtenPaths(before, after, patches) {
|
|
5738
|
-
if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn
|
|
5675
|
+
if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn(before, root) || before[root] !== after[root]).map((root) => changeTo(after, [root]));
|
|
5739
5676
|
const seen = /* @__PURE__ */ new Set();
|
|
5740
5677
|
const changes = [];
|
|
5741
5678
|
for (const patch of patches) {
|
|
@@ -5807,7 +5744,7 @@ function routeStoreUpdate(recipe) {
|
|
|
5807
5744
|
try {
|
|
5808
5745
|
applyChange(namespaces[ns], owned(change));
|
|
5809
5746
|
} catch {
|
|
5810
|
-
if (hasOwn
|
|
5747
|
+
if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
5811
5748
|
else delete namespaces[ns][root];
|
|
5812
5749
|
}
|
|
5813
5750
|
mirror(namespaces, ns, change);
|
|
@@ -5838,11 +5775,7 @@ function commitScopes(commits) {
|
|
|
5838
5775
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
5839
5776
|
scope,
|
|
5840
5777
|
keepRunning,
|
|
5841
|
-
changes: NAMESPACES.map((ns) =>
|
|
5842
|
-
const list = [];
|
|
5843
|
-
diff(scope.base[ns], scope.work[ns], [], list, /* @__PURE__ */ new Set());
|
|
5844
|
-
return [ns, list];
|
|
5845
|
-
})
|
|
5778
|
+
changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
|
|
5846
5779
|
}));
|
|
5847
5780
|
const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
|
|
5848
5781
|
if (changed.length === 0) return;
|
|
@@ -5890,12 +5823,7 @@ var running = () => activeScopes.map((scope) => ({
|
|
|
5890
5823
|
*/
|
|
5891
5824
|
function resync(scope) {
|
|
5892
5825
|
const state = useStoryStore.getState();
|
|
5893
|
-
for (const ns of NAMESPACES)
|
|
5894
|
-
const work = scope.work[ns];
|
|
5895
|
-
const stored = state[ns];
|
|
5896
|
-
for (const key of Object.keys(work)) if (!hasOwn$1(stored, key)) delete work[key];
|
|
5897
|
-
for (const key of Object.keys(stored)) if (!hasOwn$1(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
|
|
5898
|
-
}
|
|
5826
|
+
for (const ns of NAMESPACES) mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
|
|
5899
5827
|
scope.base = cloneNamespaces(state);
|
|
5900
5828
|
}
|
|
5901
5829
|
/**
|
|
@@ -5934,7 +5862,7 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
|
|
|
5934
5862
|
keepRunning: false
|
|
5935
5863
|
}]);
|
|
5936
5864
|
for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
|
|
5937
|
-
for (const key of Object.keys(mergedLocals)) if (!hasOwn
|
|
5865
|
+
for (const key of Object.keys(mergedLocals)) if (!hasOwn(localsClone, key)) scopeUpdate(key, void 0);
|
|
5938
5866
|
}
|
|
5939
5867
|
//#endregion
|
|
5940
5868
|
//#region src/triggers.ts
|
|
@@ -6164,7 +6092,7 @@ function clonePatches(patches) {
|
|
|
6164
6092
|
function computeVarPatches(prev, curr) {
|
|
6165
6093
|
const [, forward, inverse] = produceWithPatches(prev, (draft) => {
|
|
6166
6094
|
const d = draft;
|
|
6167
|
-
for (const key of Object.keys(d)) if (!
|
|
6095
|
+
for (const key of Object.keys(d)) if (!hasOwn(curr, key)) delete d[key];
|
|
6168
6096
|
for (const [key, val] of Object.entries(curr)) d[key] = val;
|
|
6169
6097
|
});
|
|
6170
6098
|
return {
|
|
@@ -6181,6 +6109,26 @@ function rerecordNewestMoment(vars) {
|
|
|
6181
6109
|
if (last < 0) variableBase = vars;
|
|
6182
6110
|
else patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
|
|
6183
6111
|
}
|
|
6112
|
+
/**
|
|
6113
|
+
* Call `fn` with the variables recorded for each of the first `count`
|
|
6114
|
+
* history moments, replaying the patches once.
|
|
6115
|
+
*/
|
|
6116
|
+
function forEachRecorded(count, fn) {
|
|
6117
|
+
let vars = variableBase;
|
|
6118
|
+
for (let i = 0; i < count; i++) {
|
|
6119
|
+
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
|
|
6120
|
+
fn(vars, i);
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
/** A history moment as saves and the session hold it, with its variables. */
|
|
6124
|
+
function savedMoment(moment, variables) {
|
|
6125
|
+
return {
|
|
6126
|
+
passage: moment.passage,
|
|
6127
|
+
variables,
|
|
6128
|
+
timestamp: moment.timestamp,
|
|
6129
|
+
prng: moment.prng
|
|
6130
|
+
};
|
|
6131
|
+
}
|
|
6184
6132
|
/** Reconstruct variables at a given history moment by replaying patches. */
|
|
6185
6133
|
function reconstructVarsAt(index) {
|
|
6186
6134
|
let vars = variableBase;
|
|
@@ -6194,24 +6142,10 @@ function persistSession(get) {
|
|
|
6194
6142
|
if (serializedHistory.length > history.length) serializedHistory.length = history.length;
|
|
6195
6143
|
if (serializedHistory.length < history.length) if (history.length - serializedHistory.length === 1) {
|
|
6196
6144
|
const i = history.length - 1;
|
|
6197
|
-
serializedHistory[i] =
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
prng: history[i].prng
|
|
6202
|
-
};
|
|
6203
|
-
} else {
|
|
6204
|
-
let vars = variableBase;
|
|
6205
|
-
for (let i = 0; i < history.length; i++) {
|
|
6206
|
-
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
|
|
6207
|
-
if (i >= serializedHistory.length) serializedHistory[i] = {
|
|
6208
|
-
passage: history[i].passage,
|
|
6209
|
-
variables: serialize(vars),
|
|
6210
|
-
timestamp: history[i].timestamp,
|
|
6211
|
-
prng: history[i].prng
|
|
6212
|
-
};
|
|
6213
|
-
}
|
|
6214
|
-
}
|
|
6145
|
+
serializedHistory[i] = savedMoment(history[i], serialize(variables));
|
|
6146
|
+
} else forEachRecorded(history.length, (vars, i) => {
|
|
6147
|
+
if (i >= serializedHistory.length) serializedHistory[i] = savedMoment(history[i], serialize(vars));
|
|
6148
|
+
});
|
|
6215
6149
|
saveSession(storyData.ifid, {
|
|
6216
6150
|
passage: currentPassage,
|
|
6217
6151
|
variables: serialize(variables),
|
|
@@ -6283,6 +6217,58 @@ function resetModuleState(base) {
|
|
|
6283
6217
|
serializedHistory = [];
|
|
6284
6218
|
}
|
|
6285
6219
|
/**
|
|
6220
|
+
* Fresh variables from `defaults` for a game that starts over (init,
|
|
6221
|
+
* restart), recorded as the history base.
|
|
6222
|
+
*/
|
|
6223
|
+
function startVariables(defaults) {
|
|
6224
|
+
const initialVars = createNamespace(deepClone(defaults));
|
|
6225
|
+
resetModuleState(deepClone(initialVars));
|
|
6226
|
+
return initialVars;
|
|
6227
|
+
}
|
|
6228
|
+
/**
|
|
6229
|
+
* Enter the start passage with `variables`, as the only history moment
|
|
6230
|
+
* (init, restart). Call it inside a store update.
|
|
6231
|
+
*/
|
|
6232
|
+
function enterStart(state, passage, variables, transientDefaults) {
|
|
6233
|
+
state.currentPassage = passage;
|
|
6234
|
+
state.navigationId++;
|
|
6235
|
+
state.variables = variables;
|
|
6236
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6237
|
+
state.temporary = createNamespace();
|
|
6238
|
+
state.history = [{
|
|
6239
|
+
passage,
|
|
6240
|
+
timestamp: Date.now()
|
|
6241
|
+
}];
|
|
6242
|
+
state.historyIndex = 0;
|
|
6243
|
+
}
|
|
6244
|
+
/**
|
|
6245
|
+
* Move through history by `step` moments (back or forward), restoring the
|
|
6246
|
+
* snapshot recorded for the moment moved to: live variables may hold edits
|
|
6247
|
+
* made since the current moment was recorded.
|
|
6248
|
+
*/
|
|
6249
|
+
function moveInHistory(get, set, step) {
|
|
6250
|
+
const { historyIndex, history } = get();
|
|
6251
|
+
const target = historyIndex + step;
|
|
6252
|
+
if (target < 0 || target >= history.length) return;
|
|
6253
|
+
finishEnteredMoment(get, set);
|
|
6254
|
+
const previousPassage = get().currentPassage;
|
|
6255
|
+
const targetPassage = history[target].passage;
|
|
6256
|
+
emit("beforenavigate", targetPassage);
|
|
6257
|
+
const restoredVars = deepClone(reconstructVarsAt(target));
|
|
6258
|
+
set((state) => {
|
|
6259
|
+
state.historyIndex += step;
|
|
6260
|
+
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6261
|
+
state.navigationId++;
|
|
6262
|
+
state.variables = restoredVars;
|
|
6263
|
+
state.temporary = createNamespace();
|
|
6264
|
+
});
|
|
6265
|
+
reinitTriggerState();
|
|
6266
|
+
lastNavigationVars = get().variables;
|
|
6267
|
+
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6268
|
+
persistSession(get);
|
|
6269
|
+
emit("afternavigate", targetPassage, previousPassage);
|
|
6270
|
+
}
|
|
6271
|
+
/**
|
|
6286
6272
|
* Record the state StoryInit left behind as the start moment: its variable
|
|
6287
6273
|
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
6288
6274
|
* after the StoryInit passage has rendered, and again after the `storyinit`
|
|
@@ -6293,10 +6279,7 @@ function resetModuleState(base) {
|
|
|
6293
6279
|
function recordStoryInitState() {
|
|
6294
6280
|
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
6295
6281
|
if (history.length !== 1 || historyIndex !== 0) return;
|
|
6296
|
-
|
|
6297
|
-
patchEntries = [];
|
|
6298
|
-
lastNavigationVars = variables;
|
|
6299
|
-
serializedHistory = [];
|
|
6282
|
+
resetModuleState(variables);
|
|
6300
6283
|
const prng = snapshotPRNG();
|
|
6301
6284
|
useStoryStore.setState((state) => {
|
|
6302
6285
|
state.history[0].prng = prng;
|
|
@@ -6466,56 +6449,31 @@ function keepHookWrites(payload, before, after) {
|
|
|
6466
6449
|
if (!moment) return;
|
|
6467
6450
|
mergeHookWrites(moment.variables, before, after, /* @__PURE__ */ new Set());
|
|
6468
6451
|
}
|
|
6469
|
-
var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
6470
|
-
/**
|
|
6471
|
-
* Whether changes between `a` and `b` merge key by key: both are arrays,
|
|
6472
|
-
* or both are objects of one class (see isMergeable).
|
|
6473
|
-
*/
|
|
6474
|
-
function mergeable(a, b) {
|
|
6475
|
-
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b);
|
|
6476
|
-
return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
6477
|
-
}
|
|
6478
|
-
/**
|
|
6479
|
-
* Apply the hooks' change of a value, from `before` to `after`, to the
|
|
6480
|
-
* snapshot's copy of it, `target[key]`. Store updates are immutable, so a
|
|
6481
|
-
* value no hook touched keeps its identity; one rebuilt with equal content
|
|
6482
|
-
* (mutation code commits whole values) is no change either. Where `target`
|
|
6483
|
-
* lacks the key or holds another kind of value (the passage created or
|
|
6484
|
-
* replaced it), the hooks' whole value is written.
|
|
6485
|
-
*/
|
|
6486
|
-
function mergeHookWrite(target, key, before, after, ancestors) {
|
|
6487
|
-
if (Object.is(before, after)) return;
|
|
6488
|
-
if (mergeable(before, after) && hasOwn(target, key) && mergeable(after, target[key]) && !ancestors.has(after)) mergeHookWrites(target[key], before, after, ancestors);
|
|
6489
|
-
else if (!deepEqual(before, after)) target[key] = deepClone(after);
|
|
6490
|
-
}
|
|
6491
6452
|
/**
|
|
6492
|
-
* Apply the changes between `before` and `after`, two objects or two
|
|
6493
|
-
*
|
|
6494
|
-
* index.
|
|
6495
|
-
*
|
|
6496
|
-
*
|
|
6497
|
-
*
|
|
6453
|
+
* Apply the hooks' changes between `before` and `after`, two objects or two
|
|
6454
|
+
* arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
|
|
6455
|
+
* elements merge by index). A changed value is merged key by key where the
|
|
6456
|
+
* snapshot holds the same kind of value. Where it lacks the key or holds
|
|
6457
|
+
* another kind (the passage created or replaced it), the hooks' whole value
|
|
6458
|
+
* is written.
|
|
6498
6459
|
*/
|
|
6499
6460
|
function mergeHookWrites(target, before, after, ancestors) {
|
|
6500
6461
|
ancestors.add(after);
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
if (a.length < b.length) target.length = Math.min(target.length, a.length);
|
|
6507
|
-
for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
|
|
6508
|
-
} else {
|
|
6509
|
-
for (const key of Object.keys(before)) if (!hasOwn(after, key)) delete target[key];
|
|
6510
|
-
for (const key of Object.keys(after)) if (hasOwn(before, key)) mergeHookWrite(target, key, before[key], after[key], ancestors);
|
|
6511
|
-
else target[key] = deepClone(after[key]);
|
|
6512
|
-
}
|
|
6462
|
+
mergeKeys(target, before, after, (key, b, a) => {
|
|
6463
|
+
if (!hasOwn(target, key) || !mergesWith(a, target[key], true) || ancestors.has(a)) return false;
|
|
6464
|
+
mergeHookWrites(target[key], b, a, ancestors);
|
|
6465
|
+
return true;
|
|
6466
|
+
});
|
|
6513
6467
|
ancestors.delete(after);
|
|
6514
6468
|
}
|
|
6469
|
+
/** Restore the PRNG from a snapshot, or reset it without one. */
|
|
6470
|
+
function restorePRNGFrom(prng) {
|
|
6471
|
+
if (prng) restorePRNG(prng.seed, prng.pull);
|
|
6472
|
+
else resetPRNG();
|
|
6473
|
+
}
|
|
6515
6474
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
6516
6475
|
function restorePRNGFromMoment(moment) {
|
|
6517
|
-
if (moment
|
|
6518
|
-
else if (moment) resetPRNG();
|
|
6476
|
+
if (moment) restorePRNGFrom(moment.prng);
|
|
6519
6477
|
}
|
|
6520
6478
|
var NAMESPACE_KEYS = [
|
|
6521
6479
|
"variables",
|
|
@@ -6598,6 +6556,46 @@ function handled(p) {
|
|
|
6598
6556
|
p.catch(() => {});
|
|
6599
6557
|
return p;
|
|
6600
6558
|
}
|
|
6559
|
+
/**
|
|
6560
|
+
* `p` with its failure logged under `message` and passed to `onError`, and
|
|
6561
|
+
* still rejecting; marked as handled (see handled()).
|
|
6562
|
+
*/
|
|
6563
|
+
function reported(p, message, onError) {
|
|
6564
|
+
return handled(p.catch((err) => {
|
|
6565
|
+
console.error(message, err);
|
|
6566
|
+
onError?.(err);
|
|
6567
|
+
throw err;
|
|
6568
|
+
}));
|
|
6569
|
+
}
|
|
6570
|
+
/** Record in the slot cache whether `slot` holds a save. */
|
|
6571
|
+
function recordKnownSave(set, slot, known) {
|
|
6572
|
+
set((state) => {
|
|
6573
|
+
const key = slot ?? "";
|
|
6574
|
+
if (known) state.knownSaves = {
|
|
6575
|
+
...state.knownSaves,
|
|
6576
|
+
[key]: true
|
|
6577
|
+
};
|
|
6578
|
+
else {
|
|
6579
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
6580
|
+
state.knownSaves = rest;
|
|
6581
|
+
}
|
|
6582
|
+
});
|
|
6583
|
+
}
|
|
6584
|
+
/**
|
|
6585
|
+
* Queue the clearing of saved data, then restart now: the new playthrough
|
|
6586
|
+
* is stored after it, and operations issued from here on belong to the new
|
|
6587
|
+
* game. The slot cache empties once the clearing is done, after operations
|
|
6588
|
+
* issued before it have updated it.
|
|
6589
|
+
*/
|
|
6590
|
+
function clearAndRestart(get, set, clearing, message) {
|
|
6591
|
+
const cleared = clearing.then(() => {
|
|
6592
|
+
set((state) => {
|
|
6593
|
+
state.knownSaves = {};
|
|
6594
|
+
});
|
|
6595
|
+
});
|
|
6596
|
+
get().restart();
|
|
6597
|
+
return reported(cleared, message);
|
|
6598
|
+
}
|
|
6601
6599
|
var useStoryStore = create()(storyStore((set, get) => ({
|
|
6602
6600
|
storyData: null,
|
|
6603
6601
|
currentPassage: "",
|
|
@@ -6643,23 +6641,13 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6643
6641
|
init: (storyData, variableDefaults = {}, transientDefaults = {}) => {
|
|
6644
6642
|
const startPassage = storyData.passagesById.get(storyData.startNode);
|
|
6645
6643
|
if (!startPassage) throw new Error(`spindle: Start passage (pid=${storyData.startNode}) not found.`);
|
|
6646
|
-
const initialVars =
|
|
6647
|
-
resetModuleState(deepClone(initialVars));
|
|
6644
|
+
const initialVars = startVariables(variableDefaults);
|
|
6648
6645
|
set((state) => {
|
|
6649
6646
|
state.storyData = storyData;
|
|
6650
6647
|
state.playthroughId = "";
|
|
6651
|
-
state.currentPassage = startPassage.name;
|
|
6652
|
-
state.navigationId++;
|
|
6653
|
-
state.variables = initialVars;
|
|
6654
6648
|
state.variableDefaults = variableDefaults;
|
|
6655
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6656
6649
|
state.transientDefaults = transientDefaults;
|
|
6657
|
-
state.
|
|
6658
|
-
state.history = [{
|
|
6659
|
-
passage: startPassage.name,
|
|
6660
|
-
timestamp: Date.now()
|
|
6661
|
-
}];
|
|
6662
|
-
state.historyIndex = 0;
|
|
6650
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
6663
6651
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
6664
6652
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
6665
6653
|
});
|
|
@@ -6728,48 +6716,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6728
6716
|
emit("afternavigate", passageName, previousPassage);
|
|
6729
6717
|
for (const next of deferred) get().navigate(next);
|
|
6730
6718
|
},
|
|
6731
|
-
goBack: () =>
|
|
6732
|
-
|
|
6733
|
-
if (historyIndex <= 0) return;
|
|
6734
|
-
finishEnteredMoment(get, set);
|
|
6735
|
-
const previousPassage = get().currentPassage;
|
|
6736
|
-
const targetPassage = get().history[historyIndex - 1].passage;
|
|
6737
|
-
emit("beforenavigate", targetPassage);
|
|
6738
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
6739
|
-
set((state) => {
|
|
6740
|
-
state.historyIndex--;
|
|
6741
|
-
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6742
|
-
state.navigationId++;
|
|
6743
|
-
state.variables = restoredVars;
|
|
6744
|
-
state.temporary = createNamespace();
|
|
6745
|
-
});
|
|
6746
|
-
reinitTriggerState();
|
|
6747
|
-
lastNavigationVars = get().variables;
|
|
6748
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6749
|
-
persistSession(get);
|
|
6750
|
-
emit("afternavigate", targetPassage, previousPassage);
|
|
6751
|
-
},
|
|
6752
|
-
goForward: () => {
|
|
6753
|
-
const { historyIndex, history: hist } = get();
|
|
6754
|
-
if (historyIndex >= hist.length - 1) return;
|
|
6755
|
-
finishEnteredMoment(get, set);
|
|
6756
|
-
const previousPassage = get().currentPassage;
|
|
6757
|
-
const targetPassage = hist[historyIndex + 1].passage;
|
|
6758
|
-
emit("beforenavigate", targetPassage);
|
|
6759
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
6760
|
-
set((state) => {
|
|
6761
|
-
state.historyIndex++;
|
|
6762
|
-
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6763
|
-
state.navigationId++;
|
|
6764
|
-
state.variables = restoredVars;
|
|
6765
|
-
state.temporary = createNamespace();
|
|
6766
|
-
});
|
|
6767
|
-
reinitTriggerState();
|
|
6768
|
-
lastNavigationVars = get().variables;
|
|
6769
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6770
|
-
persistSession(get);
|
|
6771
|
-
emit("afternavigate", targetPassage, previousPassage);
|
|
6772
|
-
},
|
|
6719
|
+
goBack: () => moveInHistory(get, set, -1),
|
|
6720
|
+
goForward: () => moveInHistory(get, set, 1),
|
|
6773
6721
|
setVariable: (name, value) => {
|
|
6774
6722
|
set((state) => {
|
|
6775
6723
|
checkVariableName(name, `$${name}`);
|
|
@@ -6831,19 +6779,9 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6831
6779
|
cleanupRuntimeHandlers();
|
|
6832
6780
|
resetPRNG();
|
|
6833
6781
|
resetTriggers();
|
|
6834
|
-
const initialVars =
|
|
6835
|
-
resetModuleState(deepClone(initialVars));
|
|
6782
|
+
const initialVars = startVariables(variableDefaults);
|
|
6836
6783
|
set((state) => {
|
|
6837
|
-
state
|
|
6838
|
-
state.navigationId++;
|
|
6839
|
-
state.variables = initialVars;
|
|
6840
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6841
|
-
state.temporary = createNamespace();
|
|
6842
|
-
state.history = [{
|
|
6843
|
-
passage: startPassage.name,
|
|
6844
|
-
timestamp: Date.now()
|
|
6845
|
-
}];
|
|
6846
|
-
state.historyIndex = 0;
|
|
6784
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
6847
6785
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
6848
6786
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
6849
6787
|
if (!keepDeferred) state.renderDeferred = false;
|
|
@@ -6859,23 +6797,14 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6859
6797
|
const { storyData } = get();
|
|
6860
6798
|
if (!storyData) return Promise.resolve();
|
|
6861
6799
|
const playthrough = resolvePlaythroughId();
|
|
6862
|
-
return
|
|
6800
|
+
return reported(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
6863
6801
|
set((state) => {
|
|
6864
6802
|
state.saveError = null;
|
|
6865
6803
|
});
|
|
6866
6804
|
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
6867
|
-
set
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
[slot ?? ""]: true
|
|
6871
|
-
};
|
|
6872
|
-
});
|
|
6873
|
-
}).catch((err) => {
|
|
6874
|
-
console.error("spindle: failed to save", err);
|
|
6875
|
-
set((state) => {
|
|
6876
|
-
state.saveError = errorMessage(err, "Failed to save");
|
|
6877
|
-
});
|
|
6878
|
-
throw err;
|
|
6805
|
+
recordKnownSave(set, slot, true);
|
|
6806
|
+
}), "spindle: failed to save", (err) => set((state) => {
|
|
6807
|
+
state.saveError = errorMessage(err, "Failed to save");
|
|
6879
6808
|
}));
|
|
6880
6809
|
},
|
|
6881
6810
|
load: (slot) => {
|
|
@@ -6888,24 +6817,20 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6888
6817
|
const read = loadSlotSave(storyData.ifid, slot);
|
|
6889
6818
|
const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
|
|
6890
6819
|
const replacement = ++stateReplacementsIssued;
|
|
6891
|
-
return
|
|
6820
|
+
return reported(read.then(async (loaded) => {
|
|
6892
6821
|
await switched;
|
|
6893
6822
|
if (!loaded) return;
|
|
6894
6823
|
if (latestStateApplied > replacement) return;
|
|
6895
6824
|
slotLoadApplying = replacement;
|
|
6896
6825
|
get().loadFromPayload(loaded.payload, slot);
|
|
6897
|
-
})
|
|
6898
|
-
|
|
6899
|
-
set((state) => {
|
|
6900
|
-
state.loadError = errorMessage(err, "Failed to load");
|
|
6901
|
-
});
|
|
6902
|
-
throw err;
|
|
6826
|
+
}), "spindle: failed to load save", (err) => set((state) => {
|
|
6827
|
+
state.loadError = errorMessage(err, "Failed to load");
|
|
6903
6828
|
}));
|
|
6904
6829
|
},
|
|
6905
6830
|
hasSave: (slot) => {
|
|
6906
6831
|
const { storyData, knownSaves } = get();
|
|
6907
6832
|
if (!storyData) return false;
|
|
6908
|
-
return
|
|
6833
|
+
return hasOwn(knownSaves, slot ?? "");
|
|
6909
6834
|
},
|
|
6910
6835
|
getSaveInfo: async (slot) => {
|
|
6911
6836
|
const { storyData } = get();
|
|
@@ -6920,16 +6845,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6920
6845
|
deleteSave: (slot) => {
|
|
6921
6846
|
const { storyData } = get();
|
|
6922
6847
|
if (!storyData) return Promise.resolve();
|
|
6923
|
-
return
|
|
6924
|
-
set((state) => {
|
|
6925
|
-
const key = slot ?? "";
|
|
6926
|
-
const { [key]: _, ...rest } = state.knownSaves;
|
|
6927
|
-
state.knownSaves = rest;
|
|
6928
|
-
});
|
|
6929
|
-
}).catch((err) => {
|
|
6930
|
-
console.error("spindle: failed to delete save", err);
|
|
6931
|
-
throw err;
|
|
6932
|
-
}));
|
|
6848
|
+
return reported(deleteSlotSave(storyData.ifid, slot).then(() => recordKnownSave(set, slot, false)), "spindle: failed to delete save");
|
|
6933
6849
|
},
|
|
6934
6850
|
exportSave: async (slot) => {
|
|
6935
6851
|
const { storyData } = get();
|
|
@@ -6941,40 +6857,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6941
6857
|
if (!storyData) throw new Error("spindle: Story is not initialized.");
|
|
6942
6858
|
if (!isSaveExport(data)) throw new Error("Invalid save file format");
|
|
6943
6859
|
const info = await importSlotSave(data, storyData.ifid, slot);
|
|
6944
|
-
set
|
|
6945
|
-
state.knownSaves = {
|
|
6946
|
-
...state.knownSaves,
|
|
6947
|
-
[slot ?? ""]: true
|
|
6948
|
-
};
|
|
6949
|
-
});
|
|
6860
|
+
recordKnownSave(set, slot, true);
|
|
6950
6861
|
return info;
|
|
6951
6862
|
},
|
|
6952
6863
|
clearGameData: () => {
|
|
6953
6864
|
const { storyData } = get();
|
|
6954
6865
|
if (!storyData) return Promise.resolve();
|
|
6955
|
-
|
|
6956
|
-
set((state) => {
|
|
6957
|
-
state.knownSaves = {};
|
|
6958
|
-
});
|
|
6959
|
-
});
|
|
6960
|
-
get().restart();
|
|
6961
|
-
return handled(cleared.catch((err) => {
|
|
6962
|
-
console.error("spindle: failed to clear game data", err);
|
|
6963
|
-
throw err;
|
|
6964
|
-
}));
|
|
6965
|
-
},
|
|
6966
|
-
clearAllData: () => {
|
|
6967
|
-
const cleared = clearAllData().then(() => {
|
|
6968
|
-
set((state) => {
|
|
6969
|
-
state.knownSaves = {};
|
|
6970
|
-
});
|
|
6971
|
-
});
|
|
6972
|
-
get().restart();
|
|
6973
|
-
return handled(cleared.catch((err) => {
|
|
6974
|
-
console.error("spindle: failed to clear all data", err);
|
|
6975
|
-
throw err;
|
|
6976
|
-
}));
|
|
6866
|
+
return clearAndRestart(get, set, clearGameData(storyData.ifid), "spindle: failed to clear game data");
|
|
6977
6867
|
},
|
|
6868
|
+
clearAllData: () => clearAndRestart(get, set, clearAllData(), "spindle: failed to clear all data"),
|
|
6978
6869
|
deletePlaythrough: (playthroughId) => {
|
|
6979
6870
|
const { storyData } = get();
|
|
6980
6871
|
if (!storyData) return Promise.resolve();
|
|
@@ -6988,29 +6879,19 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6988
6879
|
});
|
|
6989
6880
|
if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
|
|
6990
6881
|
else if (current === "") switchToLookedUpPlaythrough(deletion.then((replaced) => replaced ? replacementId : established, () => established));
|
|
6991
|
-
return
|
|
6882
|
+
return reported(deletion.then(async () => {
|
|
6992
6883
|
const known = await populateKnownSaves(storyData.ifid);
|
|
6993
6884
|
set((state) => {
|
|
6994
6885
|
state.knownSaves = known;
|
|
6995
6886
|
});
|
|
6996
|
-
})
|
|
6997
|
-
console.error("spindle: failed to delete playthrough", err);
|
|
6998
|
-
throw err;
|
|
6999
|
-
}));
|
|
6887
|
+
}), "spindle: failed to delete playthrough");
|
|
7000
6888
|
},
|
|
7001
6889
|
getSavePayload: () => {
|
|
7002
6890
|
const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
|
|
7003
6891
|
const saveHistory = [];
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
saveHistory.push({
|
|
7008
|
-
passage: history[i].passage,
|
|
7009
|
-
variables: plainCopy(vars),
|
|
7010
|
-
timestamp: history[i].timestamp,
|
|
7011
|
-
prng: history[i].prng
|
|
7012
|
-
});
|
|
7013
|
-
}
|
|
6892
|
+
forEachRecorded(history.length, (vars, i) => {
|
|
6893
|
+
saveHistory.push(savedMoment(history[i], plainCopy(vars)));
|
|
6894
|
+
});
|
|
7014
6895
|
return {
|
|
7015
6896
|
passage: currentPassage,
|
|
7016
6897
|
variables: plainCopy(variables),
|
|
@@ -7051,12 +6932,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
7051
6932
|
}
|
|
7052
6933
|
variableBase = deepClone(base);
|
|
7053
6934
|
patchEntries = newPatchEntries;
|
|
7054
|
-
serializedHistory = payload.history.map((m) => (
|
|
7055
|
-
passage: m.passage,
|
|
7056
|
-
variables: serialize(m.variables),
|
|
7057
|
-
timestamp: m.timestamp,
|
|
7058
|
-
prng: m.prng
|
|
7059
|
-
}));
|
|
6935
|
+
serializedHistory = payload.history.map((m) => savedMoment(m, serialize(m.variables)));
|
|
7060
6936
|
set((state) => {
|
|
7061
6937
|
state.currentPassage = payload.passage;
|
|
7062
6938
|
state.navigationId++;
|
|
@@ -7075,9 +6951,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
7075
6951
|
});
|
|
7076
6952
|
reinitTriggerState();
|
|
7077
6953
|
lastNavigationVars = reconstructVarsAt(get().historyIndex);
|
|
7078
|
-
|
|
7079
|
-
if (prng) restorePRNG(prng.seed, prng.pull);
|
|
7080
|
-
else resetPRNG();
|
|
6954
|
+
restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
|
|
7081
6955
|
persistSession(get);
|
|
7082
6956
|
withoutDraws(() => emit("afterload", slot));
|
|
7083
6957
|
},
|
|
@@ -7443,7 +7317,7 @@ function loadFromStorage() {
|
|
|
7443
7317
|
/** Register a setting, starting from its default unless it has a value. */
|
|
7444
7318
|
function define(name, def) {
|
|
7445
7319
|
definitions.set(name, def);
|
|
7446
|
-
if (!hasOwn
|
|
7320
|
+
if (!hasOwn(values, name)) values[name] = def.config.default;
|
|
7447
7321
|
loadFromStorage();
|
|
7448
7322
|
}
|
|
7449
7323
|
/** The value of setting `name` if it has the type of `fallback`, else that. */
|
|
@@ -8857,7 +8731,7 @@ defineMacro({
|
|
|
8857
8731
|
function liveLocalsView(getValues) {
|
|
8858
8732
|
return new Proxy(createNamespace(), {
|
|
8859
8733
|
get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
|
|
8860
|
-
has: (_, key) => typeof key === "string" && hasOwn
|
|
8734
|
+
has: (_, key) => typeof key === "string" && hasOwn(getValues(), key),
|
|
8861
8735
|
ownKeys: () => Reflect.ownKeys(getValues()),
|
|
8862
8736
|
getOwnPropertyDescriptor: (_, key) => {
|
|
8863
8737
|
const desc = Object.getOwnPropertyDescriptor(getValues(), key);
|