@rohal12/spindle 0.52.9 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1731 -581
- package/package.json +5 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +3 -1
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/types/index.d.ts +32 -8
|
@@ -9,10 +9,12 @@ defineMacro({
|
|
|
9
9
|
const code = ctx.collectText(children);
|
|
10
10
|
|
|
11
11
|
ctx.hooks.useLayoutEffect(() => {
|
|
12
|
+
// Before the code runs: it may navigate away from this passage
|
|
13
|
+
const location = ctx.sourceLocation();
|
|
12
14
|
try {
|
|
13
15
|
ctx.mutate(code);
|
|
14
16
|
} catch (err) {
|
|
15
|
-
logMacroError('do', err);
|
|
17
|
+
logMacroError('do', err, location);
|
|
16
18
|
}
|
|
17
19
|
}, []);
|
|
18
20
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
3
|
import { evaluatePassageName } from './macro-args';
|
|
4
|
+
import { logMacroError } from './MacroError';
|
|
4
5
|
|
|
5
6
|
defineMacro({
|
|
6
7
|
name: 'goto',
|
|
@@ -8,9 +9,18 @@ defineMacro({
|
|
|
8
9
|
parameters: [{ name: 'passage', type: 'expression', required: true }],
|
|
9
10
|
render(_props, ctx) {
|
|
10
11
|
ctx.hooks.useLayoutEffect(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Reported like an error in {do}: thrown out of an effect, it would
|
|
13
|
+
// abort the rest of the render's effects (e.g. the session could not
|
|
14
|
+
// be written because the state holds a function). The location is
|
|
15
|
+
// this passage's, taken before navigating away from it
|
|
16
|
+
const location = ctx.sourceLocation();
|
|
17
|
+
try {
|
|
18
|
+
useStoryStore
|
|
19
|
+
.getState()
|
|
20
|
+
.navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
|
|
21
|
+
} catch (err) {
|
|
22
|
+
logMacroError('goto', err, location);
|
|
23
|
+
}
|
|
14
24
|
}, []);
|
|
15
25
|
|
|
16
26
|
return null;
|
|
@@ -19,12 +19,18 @@ export function MacroError({
|
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Log an error in the macro `{label}` (its name, or name and arguments) at
|
|
24
|
+
* `location`, the source location of the macro's passage. Take it before
|
|
25
|
+
* running the code that failed (see currentSourceLocation): code that
|
|
26
|
+
* navigates changes the current passage.
|
|
27
|
+
*/
|
|
28
|
+
export function logMacroError(
|
|
29
|
+
label: string,
|
|
30
|
+
error: unknown,
|
|
31
|
+
location: string = currentSourceLocation(),
|
|
32
|
+
): void {
|
|
33
|
+
console.error(`spindle: Error in {${label}}${location}:`, error);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -38,11 +44,12 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
|
|
|
38
44
|
const failure = useRef<{ error: unknown } | null>();
|
|
39
45
|
if (failure.current === undefined) {
|
|
40
46
|
failure.current = null;
|
|
47
|
+
const location = currentSourceLocation();
|
|
41
48
|
try {
|
|
42
49
|
effect();
|
|
43
50
|
} catch (error) {
|
|
44
51
|
failure.current = { error };
|
|
45
|
-
logMacroError(`${macro} ${rawArgs}`, error);
|
|
52
|
+
logMacroError(`${macro} ${rawArgs}`, error, location);
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
renameSave,
|
|
18
18
|
exportSave,
|
|
19
19
|
importSave,
|
|
20
|
-
|
|
20
|
+
decodeSavePayload,
|
|
21
21
|
saveWithHooks,
|
|
22
22
|
type PlaythroughGroup,
|
|
23
23
|
} from '../../saves/save-manager';
|
|
@@ -160,7 +160,7 @@ export function SaveManagerContent() {
|
|
|
160
160
|
// Stored records hold serialized variables; the store expects live
|
|
161
161
|
// ones. The game moves to the save's playthrough.
|
|
162
162
|
loadFromPayload(
|
|
163
|
-
|
|
163
|
+
decodeSavePayload(save.payload),
|
|
164
164
|
undefined,
|
|
165
165
|
save.meta.playthroughId,
|
|
166
166
|
);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Runtime errors shown to the player on the page (see RuntimeErrors), for
|
|
2
|
+
// errors that would otherwise only reach the browser console.
|
|
3
|
+
|
|
4
|
+
import { errorMessage } from './utils/error-message';
|
|
5
|
+
|
|
6
|
+
/** A runtime error shown on the page until the player dismisses it. */
|
|
7
|
+
export interface RuntimeError {
|
|
8
|
+
/** Identifies the entry, for dismissing it. */
|
|
9
|
+
id: number;
|
|
10
|
+
/** What failed, for the player (e.g. "Could not save the game…"). */
|
|
11
|
+
context: string;
|
|
12
|
+
/** The error message, without the "spindle: " prefix. */
|
|
13
|
+
message: string;
|
|
14
|
+
/** How often the same error happened since it was shown. */
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let errors: readonly RuntimeError[] = [];
|
|
19
|
+
let nextId = 1;
|
|
20
|
+
const listeners = new Set<() => void>();
|
|
21
|
+
|
|
22
|
+
function update(next: readonly RuntimeError[]): void {
|
|
23
|
+
errors = next;
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Show `error` on the page, after `context` (what failed). The same error
|
|
29
|
+
* again, while it is shown, counts up instead of adding an entry. Logging
|
|
30
|
+
* it to the console is up to the caller.
|
|
31
|
+
*/
|
|
32
|
+
export function showRuntimeError(context: string, error: unknown): void {
|
|
33
|
+
const message = errorMessage(error).replace(/^spindle: /, '');
|
|
34
|
+
const same = errors.find(
|
|
35
|
+
(e) => e.context === context && e.message === message,
|
|
36
|
+
);
|
|
37
|
+
update(
|
|
38
|
+
same
|
|
39
|
+
? errors.map((e) => (e === same ? { ...e, count: e.count + 1 } : e))
|
|
40
|
+
: [...errors, { id: nextId++, context, message, count: 1 }],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remove the shown error `id`. */
|
|
45
|
+
export function dismissRuntimeError(id: number): void {
|
|
46
|
+
update(errors.filter((e) => e.id !== id));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Remove every shown error. */
|
|
50
|
+
export function clearRuntimeErrors(): void {
|
|
51
|
+
if (errors.length) update([]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The errors shown now, oldest first. */
|
|
55
|
+
export function getRuntimeErrors(): readonly RuntimeError[] {
|
|
56
|
+
return errors;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Call `listener` whenever the shown errors change; returns unsubscribe. */
|
|
60
|
+
export function subscribeRuntimeErrors(listener: () => void): () => void {
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// The stored form of a save payload: what saves, exports and the session
|
|
2
|
+
// hold, and the version of that form.
|
|
3
|
+
|
|
4
|
+
import { deserialize, serialize } from '../class-registry';
|
|
5
|
+
import { createCounts } from '../utils/namespace';
|
|
6
|
+
import type { SavePayload } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The version of the save format this build writes. Saves, exports and the
|
|
10
|
+
* session carry it. Bump it when the stored form changes, and add a
|
|
11
|
+
* migration from the previous version to MIGRATIONS.
|
|
12
|
+
*/
|
|
13
|
+
export const SAVE_FORMAT_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A payload as stored: `data` is the payload serialized in one piece (see
|
|
17
|
+
* serialize() in class-registry.ts), so values the history moments share
|
|
18
|
+
* are stored once, and cycles survive.
|
|
19
|
+
*/
|
|
20
|
+
export interface EncodedPayload {
|
|
21
|
+
formatVersion: number;
|
|
22
|
+
data: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A save, export or session from a version of Spindle this one cannot read. */
|
|
26
|
+
export class IncompatibleSaveError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('This save was made by an incompatible version of Spindle');
|
|
29
|
+
this.name = 'IncompatibleSaveError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Migrations of the stored data: MIGRATIONS[v] turns the `data` of format
|
|
35
|
+
* version v into that of version v + 1. Version 1 is the first versioned
|
|
36
|
+
* format; data from before it has no version and is refused.
|
|
37
|
+
*/
|
|
38
|
+
const MIGRATIONS: Readonly<Record<number, (data: string) => string>> = {};
|
|
39
|
+
|
|
40
|
+
/** Throw an IncompatibleSaveError unless this build reads format `version`. */
|
|
41
|
+
export function checkFormatVersion(
|
|
42
|
+
version: unknown,
|
|
43
|
+
): asserts version is number {
|
|
44
|
+
if (
|
|
45
|
+
typeof version !== 'number' ||
|
|
46
|
+
!Number.isInteger(version) ||
|
|
47
|
+
version < 1 ||
|
|
48
|
+
version > SAVE_FORMAT_VERSION
|
|
49
|
+
) {
|
|
50
|
+
throw new IncompatibleSaveError();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bring `data` of format `version` up to SAVE_FORMAT_VERSION. */
|
|
55
|
+
export function migrate(version: number, data: string): string {
|
|
56
|
+
checkFormatVersion(version);
|
|
57
|
+
let current = data;
|
|
58
|
+
for (let v = version; v < SAVE_FORMAT_VERSION; v++) {
|
|
59
|
+
const step = MIGRATIONS[v];
|
|
60
|
+
if (!step) throw new IncompatibleSaveError();
|
|
61
|
+
current = step(current);
|
|
62
|
+
}
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The passage counters as a Map: a passage may be named "__proto__", which
|
|
68
|
+
* serialize() refuses as an object key (as every story-state key).
|
|
69
|
+
*/
|
|
70
|
+
const countsToMap = (counts: Record<string, number> | undefined) =>
|
|
71
|
+
counts && new Map(Object.entries(counts));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The error of serialize() with its path told as a story variable:
|
|
75
|
+
* `(at .variables.inv[0])` becomes `(at $inv[0])`, and a value only an
|
|
76
|
+
* earlier history moment holds `(at $inv[0] in history moment 3)`.
|
|
77
|
+
*/
|
|
78
|
+
function variablePathError(err: unknown): unknown {
|
|
79
|
+
if (!(err instanceof TypeError)) return err;
|
|
80
|
+
const message = err.message
|
|
81
|
+
.replace(/\(at \.variables(?=[.[]|\))/, '(at $')
|
|
82
|
+
.replace(
|
|
83
|
+
/\(at \.history\[(\d+)\]\.variables(.*)\)$/,
|
|
84
|
+
(_, i: string, path: string) => `(at $${path} in history moment ${i})`,
|
|
85
|
+
)
|
|
86
|
+
.replace(/\(at \$\.?/, '(at $');
|
|
87
|
+
return message === err.message ? err : new TypeError(message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The stored form of `payload`. Throws, naming the variable, when the
|
|
92
|
+
* payload holds a value a save cannot (see serialize()).
|
|
93
|
+
*/
|
|
94
|
+
export function encodePayload(payload: SavePayload): EncodedPayload {
|
|
95
|
+
try {
|
|
96
|
+
return {
|
|
97
|
+
formatVersion: SAVE_FORMAT_VERSION,
|
|
98
|
+
data: serialize({
|
|
99
|
+
...payload,
|
|
100
|
+
visitCounts: countsToMap(payload.visitCounts),
|
|
101
|
+
renderCounts: countsToMap(payload.renderCounts),
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw variablePathError(err);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Thrown for stored data that is not a payload this format can hold. */
|
|
110
|
+
const invalid = () => new Error('Invalid save data');
|
|
111
|
+
|
|
112
|
+
/** A plain object or one without a prototype (a variable namespace). */
|
|
113
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
115
|
+
const proto = Object.getPrototypeOf(value) as object | null;
|
|
116
|
+
return proto === Object.prototype || proto === null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
|
|
120
|
+
function isOptionalPRNGSnapshot(value: unknown): boolean {
|
|
121
|
+
if (value == null) return true;
|
|
122
|
+
return (
|
|
123
|
+
isRecord(value) &&
|
|
124
|
+
typeof value.seed === 'string' &&
|
|
125
|
+
typeof value.pull === 'number' &&
|
|
126
|
+
Number.isInteger(value.pull) &&
|
|
127
|
+
value.pull >= 0
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const isMoment = (value: unknown): boolean =>
|
|
132
|
+
isRecord(value) &&
|
|
133
|
+
typeof value.passage === 'string' &&
|
|
134
|
+
isRecord(value.variables) &&
|
|
135
|
+
typeof value.timestamp === 'number' &&
|
|
136
|
+
isOptionalPRNGSnapshot(value.prng);
|
|
137
|
+
|
|
138
|
+
const isCountsMap = (value: unknown): boolean =>
|
|
139
|
+
value === undefined ||
|
|
140
|
+
(value instanceof Map &&
|
|
141
|
+
[...value.keys()].every((k) => typeof k === 'string'));
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether a deserialized payload is well formed: every history moment is,
|
|
145
|
+
* and `historyIndex` is an integer that points at a moment of the payload's
|
|
146
|
+
* passage.
|
|
147
|
+
*/
|
|
148
|
+
function isPayload(value: unknown): value is Record<string, unknown> {
|
|
149
|
+
if (!isRecord(value)) return false;
|
|
150
|
+
if (typeof value.passage !== 'string' || !isRecord(value.variables)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const { history, historyIndex } = value;
|
|
154
|
+
// Array.from: every() skips the holes of a sparse array
|
|
155
|
+
if (!Array.isArray(history) || history.length === 0) return false;
|
|
156
|
+
if (!Array.from(history).every(isMoment)) return false;
|
|
157
|
+
if (
|
|
158
|
+
typeof historyIndex !== 'number' ||
|
|
159
|
+
!Number.isInteger(historyIndex) ||
|
|
160
|
+
historyIndex < 0 ||
|
|
161
|
+
historyIndex >= history.length ||
|
|
162
|
+
(history[historyIndex] as { passage: string }).passage !== value.passage
|
|
163
|
+
) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
isCountsMap(value.visitCounts) &&
|
|
168
|
+
isCountsMap(value.renderCounts) &&
|
|
169
|
+
isOptionalPRNGSnapshot(value.prng)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mapToCounts = (counts: unknown) =>
|
|
174
|
+
counts instanceof Map
|
|
175
|
+
? createCounts(Object.fromEntries(counts as Map<string, unknown>))
|
|
176
|
+
: undefined;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The live payload of a stored one: class instances and built-ins
|
|
180
|
+
* restored. Throws an IncompatibleSaveError when `encoded` has no format
|
|
181
|
+
* version or one this build cannot read (saves from before versioning, or
|
|
182
|
+
* from a newer Spindle), and an error when its data is malformed.
|
|
183
|
+
*/
|
|
184
|
+
export function decodePayload(encoded: unknown): SavePayload {
|
|
185
|
+
if (typeof encoded !== 'object' || encoded === null) throw invalid();
|
|
186
|
+
const { formatVersion, data } = encoded as Record<string, unknown>;
|
|
187
|
+
checkFormatVersion(formatVersion);
|
|
188
|
+
if (typeof data !== 'string') throw invalid();
|
|
189
|
+
const body = deserialize(migrate(formatVersion, data));
|
|
190
|
+
if (!isPayload(body)) throw invalid();
|
|
191
|
+
const payload = {
|
|
192
|
+
...body,
|
|
193
|
+
visitCounts: mapToCounts(body.visitCounts),
|
|
194
|
+
renderCounts: mapToCounts(body.renderCounts),
|
|
195
|
+
} as SavePayload;
|
|
196
|
+
if (payload.visitCounts === undefined) delete payload.visitCounts;
|
|
197
|
+
if (payload.renderCounts === undefined) delete payload.renderCounts;
|
|
198
|
+
return payload;
|
|
199
|
+
}
|
|
@@ -7,9 +7,14 @@ import type {
|
|
|
7
7
|
SaveExport,
|
|
8
8
|
StorageInfo,
|
|
9
9
|
} from './types';
|
|
10
|
-
import {
|
|
10
|
+
import { checkSaveExport, estimatePayloadBytes } from './types';
|
|
11
|
+
import {
|
|
12
|
+
decodePayload,
|
|
13
|
+
encodePayload,
|
|
14
|
+
IncompatibleSaveError,
|
|
15
|
+
SAVE_FORMAT_VERSION,
|
|
16
|
+
} from './format';
|
|
11
17
|
import { getBackend, resetBackend } from './storage';
|
|
12
|
-
import { serialize, deserialize } from '../class-registry';
|
|
13
18
|
import { deepClone } from '../structural';
|
|
14
19
|
import { emit } from '../event-emitter';
|
|
15
20
|
import { withoutDraws } from '../prng';
|
|
@@ -272,30 +277,12 @@ export function saveWithHooks<T>(
|
|
|
272
277
|
|
|
273
278
|
// --- Save CRUD ---
|
|
274
279
|
|
|
275
|
-
/**
|
|
276
|
-
* `payload` with `convert` applied to its live variables and to those of
|
|
277
|
-
* every history moment.
|
|
278
|
-
*/
|
|
279
|
-
function mapVariables(
|
|
280
|
-
payload: SavePayload,
|
|
281
|
-
convert: (variables: Record<string, unknown>) => Record<string, unknown>,
|
|
282
|
-
): SavePayload {
|
|
283
|
-
return {
|
|
284
|
-
...payload,
|
|
285
|
-
variables: convert(payload.variables),
|
|
286
|
-
history: payload.history.map((m) => ({
|
|
287
|
-
...m,
|
|
288
|
-
variables: convert(m.variables),
|
|
289
|
-
})),
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** The record to store for a save: `payload` serialized, and its size. */
|
|
280
|
+
/** The record to store for a save: `payload` encoded, and its size. */
|
|
294
281
|
function storedRecord(meta: SaveMeta, payload: SavePayload): SaveRecord {
|
|
295
|
-
const
|
|
282
|
+
const encoded = encodePayload(payload);
|
|
296
283
|
return {
|
|
297
|
-
meta: { ...meta, estimatedBytes: estimatePayloadBytes(
|
|
298
|
-
payload:
|
|
284
|
+
meta: { ...meta, estimatedBytes: estimatePayloadBytes(encoded) },
|
|
285
|
+
payload: encoded,
|
|
299
286
|
};
|
|
300
287
|
}
|
|
301
288
|
|
|
@@ -372,17 +359,16 @@ async function overwriteSaveNow(
|
|
|
372
359
|
}
|
|
373
360
|
|
|
374
361
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
362
|
+
* The live payload of a stored save, if there is one (see decodePayload:
|
|
363
|
+
* throws for a save of an incompatible format). Payloads handed to the
|
|
364
|
+
* store's `loadFromPayload()` must be live.
|
|
378
365
|
*/
|
|
379
|
-
export function deserializePayload(payload: SavePayload): SavePayload {
|
|
380
|
-
return mapVariables(payload, deserialize);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** The live payload of a stored save, if there is one. */
|
|
384
366
|
const livePayload = (record: SaveRecord | undefined) =>
|
|
385
|
-
record &&
|
|
367
|
+
record && decodePayload(record.payload);
|
|
368
|
+
|
|
369
|
+
/** The live payload of a stored record's payload (see decodePayload). */
|
|
370
|
+
export const decodeSavePayload = (payload: SaveRecord['payload']) =>
|
|
371
|
+
decodePayload(payload);
|
|
386
372
|
|
|
387
373
|
export const loadSave = readingSave(livePayload);
|
|
388
374
|
|
|
@@ -650,7 +636,7 @@ export function loadSlotSave(
|
|
|
650
636
|
return inOrder(async () => {
|
|
651
637
|
const record = await slotRecord(ifid, slot);
|
|
652
638
|
if (!record) return undefined;
|
|
653
|
-
const payload =
|
|
639
|
+
const payload = decodePayload(record.payload);
|
|
654
640
|
const { playthroughId } = record.meta;
|
|
655
641
|
if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
|
|
656
642
|
return { payload, playthroughId };
|
|
@@ -715,28 +701,35 @@ export const deleteSlotSave = queued(async (ifid: string, slot?: string) => {
|
|
|
715
701
|
const SESSION_KEY_PREFIX = 'spindle.session.';
|
|
716
702
|
|
|
717
703
|
/**
|
|
718
|
-
* Write
|
|
719
|
-
*
|
|
704
|
+
* Write the session payload to sessionStorage, encoded like a save (see
|
|
705
|
+
* encodePayload). Throws, like a save, when the state holds a value a save
|
|
706
|
+
* cannot (a function, an instance of an unregistered class, a unique
|
|
707
|
+
* symbol); the session then keeps its previous copy.
|
|
720
708
|
*/
|
|
721
|
-
export function saveSession(ifid: string,
|
|
709
|
+
export function saveSession(ifid: string, payload: SavePayload): void {
|
|
710
|
+
const text = JSON.stringify(encodePayload(payload));
|
|
722
711
|
try {
|
|
723
|
-
sessionStorage.setItem(
|
|
724
|
-
`${SESSION_KEY_PREFIX}${ifid}`,
|
|
725
|
-
JSON.stringify(data),
|
|
726
|
-
);
|
|
712
|
+
sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
|
|
727
713
|
} catch {
|
|
728
714
|
// sessionStorage unavailable or full — silently ignore
|
|
729
715
|
}
|
|
730
716
|
}
|
|
731
717
|
|
|
718
|
+
/**
|
|
719
|
+
* The live payload of the stored session, if there is one that loads. A
|
|
720
|
+
* session written by an incompatible version of Spindle is dropped with a
|
|
721
|
+
* warning, and the story starts fresh.
|
|
722
|
+
*/
|
|
732
723
|
export function loadSession(ifid: string): SavePayload | undefined {
|
|
733
724
|
try {
|
|
734
725
|
const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
735
726
|
if (!raw) return undefined;
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
727
|
+
return decodePayload(JSON.parse(raw));
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (err instanceof IncompatibleSaveError) {
|
|
730
|
+
console.warn(`spindle: ${err.message}; the session was not restored.`);
|
|
731
|
+
clearSession(ifid);
|
|
732
|
+
}
|
|
740
733
|
return undefined;
|
|
741
734
|
}
|
|
742
735
|
}
|
|
@@ -755,7 +748,7 @@ export function clearSession(ifid: string): void {
|
|
|
755
748
|
function toExport(record: SaveRecord | undefined): SaveExport | undefined {
|
|
756
749
|
if (!record) return undefined;
|
|
757
750
|
return {
|
|
758
|
-
|
|
751
|
+
formatVersion: SAVE_FORMAT_VERSION,
|
|
759
752
|
ifid: record.meta.ifid,
|
|
760
753
|
exportedAt: new Date().toISOString(),
|
|
761
754
|
save: record,
|
|
@@ -769,23 +762,15 @@ export const exportSave = readingSave(toExport);
|
|
|
769
762
|
* a fresh save ID, and the playthrough record created if it doesn't exist yet.
|
|
770
763
|
* The returned record is not stored yet.
|
|
771
764
|
*/
|
|
772
|
-
async function prepareImport(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
)
|
|
776
|
-
if (data.version !== 1) {
|
|
777
|
-
throw new Error(`Unsupported save version: ${data.version}`);
|
|
778
|
-
}
|
|
765
|
+
async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
|
|
766
|
+
// Callers may pass parsed JSON; check the format version and the full
|
|
767
|
+
// structure before anything is stored or replaced
|
|
768
|
+
checkSaveExport(data);
|
|
779
769
|
if (data.ifid !== ifid) {
|
|
780
770
|
throw new Error(
|
|
781
771
|
`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`,
|
|
782
772
|
);
|
|
783
773
|
}
|
|
784
|
-
// Callers may pass parsed JSON; check the full structure before anything
|
|
785
|
-
// is stored or replaced
|
|
786
|
-
if (!isSaveExport(data)) {
|
|
787
|
-
throw new Error('Invalid save file format');
|
|
788
|
-
}
|
|
789
774
|
|
|
790
775
|
// Re-assign a new ID to avoid collisions
|
|
791
776
|
const record = deepClone(data.save);
|
|
@@ -804,10 +789,7 @@ async function prepareImport(
|
|
|
804
789
|
return record;
|
|
805
790
|
}
|
|
806
791
|
|
|
807
|
-
export function importSave(
|
|
808
|
-
data: SaveExport,
|
|
809
|
-
ifid: string,
|
|
810
|
-
): Promise<SaveRecord> {
|
|
792
|
+
export function importSave(data: unknown, ifid: string): Promise<SaveRecord> {
|
|
811
793
|
return inOrder(async () => {
|
|
812
794
|
const record = await prepareImport(data, ifid);
|
|
813
795
|
await (await getBackend()).putSave(record);
|
|
@@ -828,7 +810,7 @@ export const exportSlotSave = readingSlot(toExport);
|
|
|
828
810
|
* and slot index consistent.
|
|
829
811
|
*/
|
|
830
812
|
export function importSlotSave(
|
|
831
|
-
data:
|
|
813
|
+
data: unknown,
|
|
832
814
|
ifid: string,
|
|
833
815
|
slot?: string,
|
|
834
816
|
): Promise<SaveInfo> {
|