@rohal12/spindle 0.52.9 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6484 -2558
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +6 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +4 -2
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/Include.tsx +2 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +219 -0
- package/src/parser.ts +19 -4
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +32 -8
- package/types/tooling.d.ts +42 -0
- package/src/markup/tokenizer.ts +0 -1112
|
@@ -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> {
|
package/src/saves/types.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { PRNGSnapshot } from '../prng';
|
|
2
|
-
import {
|
|
2
|
+
import { hasOwn } from '../utils/namespace';
|
|
3
|
+
import {
|
|
4
|
+
checkFormatVersion,
|
|
5
|
+
decodePayload,
|
|
6
|
+
IncompatibleSaveError,
|
|
7
|
+
type EncodedPayload,
|
|
8
|
+
} from './format';
|
|
9
|
+
|
|
10
|
+
export type { EncodedPayload } from './format';
|
|
3
11
|
|
|
4
12
|
/** History moment as persisted in saves (full variable snapshots). */
|
|
5
13
|
export interface SaveHistoryMoment {
|
|
@@ -38,7 +46,8 @@ export interface SaveMeta {
|
|
|
38
46
|
|
|
39
47
|
export interface SaveRecord {
|
|
40
48
|
meta: SaveMeta;
|
|
41
|
-
payload
|
|
49
|
+
/** The payload as stored, with its format version (see format.ts). */
|
|
50
|
+
payload: EncodedPayload;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export interface PlaythroughRecord {
|
|
@@ -59,111 +68,85 @@ export interface SaveInfo {
|
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export interface SaveExport {
|
|
62
|
-
version
|
|
71
|
+
/** The save format version of the export (see SAVE_FORMAT_VERSION). */
|
|
72
|
+
formatVersion: number;
|
|
63
73
|
ifid: string;
|
|
64
74
|
exportedAt: string;
|
|
65
75
|
save: SaveRecord;
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
70
|
-
const obj = value as Record<string, unknown>;
|
|
71
|
-
if (obj.version !== 1 || typeof obj.ifid !== 'string') return false;
|
|
72
|
-
if (typeof obj.save !== 'object' || obj.save === null) return false;
|
|
73
|
-
|
|
74
|
-
const save = obj.save as Record<string, unknown>;
|
|
75
|
-
if (typeof save.meta !== 'object' || save.meta === null) return false;
|
|
76
|
-
if (typeof save.payload !== 'object' || save.payload === null) return false;
|
|
77
|
-
|
|
78
|
-
const meta = save.meta as Record<string, unknown>;
|
|
79
|
-
if (typeof meta.id !== 'string' || typeof meta.passage !== 'string')
|
|
80
|
-
return false;
|
|
81
|
-
if (typeof meta.ifid !== 'string') return false;
|
|
82
|
-
if (typeof meta.playthroughId !== 'string') return false;
|
|
83
|
-
if (typeof meta.createdAt !== 'string') return false;
|
|
84
|
-
if (typeof meta.updatedAt !== 'string') return false;
|
|
85
|
-
if (typeof meta.title !== 'string') return false;
|
|
86
|
-
|
|
87
|
-
return isImportablePayload(save.payload);
|
|
88
|
-
}
|
|
78
|
+
const INVALID_FILE = 'Invalid save file format';
|
|
89
79
|
|
|
90
80
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
91
81
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
92
82
|
}
|
|
93
83
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
isRecord(value) &&
|
|
114
|
-
typeof value.passage === 'string' &&
|
|
115
|
-
isSerializedVariables(value.variables) &&
|
|
116
|
-
typeof value.timestamp === 'number' &&
|
|
117
|
-
isOptionalPRNGSnapshot(value.prng)
|
|
118
|
-
);
|
|
84
|
+
const META_STRINGS = [
|
|
85
|
+
'id',
|
|
86
|
+
'passage',
|
|
87
|
+
'ifid',
|
|
88
|
+
'playthroughId',
|
|
89
|
+
'createdAt',
|
|
90
|
+
'updatedAt',
|
|
91
|
+
'title',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
/** decodePayload(), without the warnings for unregistered classes. */
|
|
95
|
+
function decodeQuietly(encoded: unknown): void {
|
|
96
|
+
const warn = console.warn;
|
|
97
|
+
console.warn = () => {};
|
|
98
|
+
try {
|
|
99
|
+
decodePayload(encoded);
|
|
100
|
+
} finally {
|
|
101
|
+
console.warn = warn;
|
|
102
|
+
}
|
|
119
103
|
}
|
|
120
104
|
|
|
121
105
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* passes can be stored and later loaded.
|
|
106
|
+
* Check data from outside the running story (an imported save) in full:
|
|
107
|
+
* the export's and the payload's format versions, the record's metadata,
|
|
108
|
+
* and that the payload decodes (see decodePayload). Throws an
|
|
109
|
+
* IncompatibleSaveError for a missing or unknown format version, and an
|
|
110
|
+
* "Invalid save file format" error for anything else that is wrong.
|
|
111
|
+
* Anything that passes can be stored and later loaded.
|
|
128
112
|
*/
|
|
129
|
-
function
|
|
130
|
-
if (!isRecord(value))
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
) {
|
|
144
|
-
|
|
113
|
+
export function checkSaveExport(value: unknown): asserts value is SaveExport {
|
|
114
|
+
if (!isRecord(value)) throw new Error(INVALID_FILE);
|
|
115
|
+
// An export from before format versions has `version: 1`: incompatible.
|
|
116
|
+
// Data that is no export at all is invalid.
|
|
117
|
+
if (!hasOwn(value, 'formatVersion')) {
|
|
118
|
+
throw value.version === 1
|
|
119
|
+
? new IncompatibleSaveError()
|
|
120
|
+
: new Error(INVALID_FILE);
|
|
121
|
+
}
|
|
122
|
+
checkFormatVersion(value.formatVersion);
|
|
123
|
+
if (typeof value.ifid !== 'string') throw new Error(INVALID_FILE);
|
|
124
|
+
const save = value.save;
|
|
125
|
+
if (!isRecord(save) || !isRecord(save.meta)) throw new Error(INVALID_FILE);
|
|
126
|
+
const meta = save.meta;
|
|
127
|
+
if (META_STRINGS.some((key) => typeof meta[key] !== 'string')) {
|
|
128
|
+
throw new Error(INVALID_FILE);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
decodeQuietly(save.payload);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof IncompatibleSaveError) throw err;
|
|
134
|
+
throw new Error(INVALID_FILE);
|
|
145
135
|
}
|
|
146
|
-
if (history[historyIndex]!.passage !== value.passage) return false;
|
|
147
|
-
|
|
148
|
-
if (value.visitCounts !== undefined && !isRecord(value.visitCounts))
|
|
149
|
-
return false;
|
|
150
|
-
if (value.renderCounts !== undefined && !isRecord(value.renderCounts))
|
|
151
|
-
return false;
|
|
152
|
-
return isOptionalPRNGSnapshot(value.prng);
|
|
153
136
|
}
|
|
154
137
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
138
|
+
/** Whether checkSaveExport() accepts `value`. */
|
|
139
|
+
export function isSaveExport(value: unknown): value is SaveExport {
|
|
140
|
+
try {
|
|
141
|
+
checkSaveExport(value);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
163
146
|
}
|
|
164
147
|
|
|
165
|
-
/** Estimated byte size of a
|
|
166
|
-
export function estimatePayloadBytes(payload:
|
|
148
|
+
/** Estimated byte size of a stored payload. */
|
|
149
|
+
export function estimatePayloadBytes(payload: EncodedPayload): number {
|
|
167
150
|
return JSON.stringify(payload).length;
|
|
168
151
|
}
|
|
169
152
|
|