@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.
@@ -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
- useStoryStore
12
- .getState()
13
- .navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
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
- /** Log an error in the macro `{label}` (its name, or name and arguments). */
23
- export function logMacroError(label: string, error: unknown): void {
24
- console.error(
25
- `spindle: Error in {${label}}${currentSourceLocation()}:`,
26
- error,
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
- deserializePayload,
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
- deserializePayload(save.payload),
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 { estimatePayloadBytes, isSaveExport, isSavePayload } from './types';
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 serialized = mapVariables(deepClone(payload), serialize);
282
+ const encoded = encodePayload(payload);
296
283
  return {
297
- meta: { ...meta, estimatedBytes: estimatePayloadBytes(serialized) },
298
- payload: serialized,
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
- * Turn a stored (serialized) payload into a live one, restoring class
376
- * instances and built-ins. Returns a new payload; the stored one is left
377
- * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
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 && deserializePayload(record.payload);
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 = deserializePayload(record.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 a pre-serialized session payload to sessionStorage.
719
- * Callers are responsible for serializing variables (see persistSession in store.ts).
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, data: unknown): void {
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
- const parsed: unknown = JSON.parse(raw);
737
- if (!isSavePayload(parsed)) return undefined;
738
- return deserializePayload(parsed);
739
- } catch {
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
- version: 1,
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
- data: SaveExport,
774
- ifid: string,
775
- ): Promise<SaveRecord> {
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: SaveExport,
813
+ data: unknown,
832
814
  ifid: string,
833
815
  slot?: string,
834
816
  ): Promise<SaveInfo> {