@rohal12/spindle 0.51.4 → 0.52.1
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 +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
|
@@ -11,6 +11,7 @@ import { isSaveExport, isSavePayload } from './types';
|
|
|
11
11
|
import { getBackend, resetBackend } from './storage';
|
|
12
12
|
import { deepClone, serialize, deserialize } from '../class-registry';
|
|
13
13
|
import { emit } from '../event-emitter';
|
|
14
|
+
import { withoutDraws } from '../prng';
|
|
14
15
|
|
|
15
16
|
type TitleGenerator = (payload: SavePayload) => string;
|
|
16
17
|
|
|
@@ -69,6 +70,30 @@ export async function initSaveSystem(): Promise<void> {
|
|
|
69
70
|
initialized = true;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// --- Operation order ---
|
|
74
|
+
|
|
75
|
+
/** Tail of the queue every storage operation runs through. */
|
|
76
|
+
let operationQueue: Promise<unknown> = Promise.resolve();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run a storage operation once every operation issued before it has
|
|
80
|
+
* finished, so operations take effect in the order they are called. Each
|
|
81
|
+
* one reads and writes several records (a slot's pointer, the save, the
|
|
82
|
+
* slot index, playthrough records) as a unit; run concurrently they would
|
|
83
|
+
* interleave: a delete issued after a save could run before it, a rename
|
|
84
|
+
* could write a save's old payload back over an overwrite, or a playthrough
|
|
85
|
+
* deletion could miss a save being written.
|
|
86
|
+
*
|
|
87
|
+
* Exported operations queue themselves; the `...Now` helpers they share run
|
|
88
|
+
* inside an operation and must not be called from outside one. An operation
|
|
89
|
+
* must never wait for another operation queued after it.
|
|
90
|
+
*/
|
|
91
|
+
function inOrder<T>(op: () => Promise<T>): Promise<T> {
|
|
92
|
+
const run = operationQueue.then(op);
|
|
93
|
+
operationQueue = run.catch(() => {});
|
|
94
|
+
return run;
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
// --- Playthroughs ---
|
|
73
98
|
|
|
74
99
|
/**
|
|
@@ -76,13 +101,19 @@ export async function initSaveSystem(): Promise<void> {
|
|
|
76
101
|
* the caller has already switched to the playthrough (restart() does, so
|
|
77
102
|
* saves issued before this resolves are tagged with it).
|
|
78
103
|
*/
|
|
79
|
-
export
|
|
104
|
+
export function startNewPlaythrough(
|
|
80
105
|
ifid: string,
|
|
81
106
|
id: string = crypto.randomUUID(),
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
return inOrder(() => startNewPlaythroughNow(ifid, id));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function startNewPlaythroughNow(
|
|
112
|
+
ifid: string,
|
|
113
|
+
id: string,
|
|
82
114
|
): Promise<string> {
|
|
83
115
|
const backend = await getBackend();
|
|
84
|
-
const
|
|
85
|
-
const num = existing.length + 1;
|
|
116
|
+
const num = await nextPlaythroughNumber(ifid);
|
|
86
117
|
|
|
87
118
|
const record: PlaythroughRecord = {
|
|
88
119
|
id,
|
|
@@ -92,14 +123,90 @@ export async function startNewPlaythrough(
|
|
|
92
123
|
};
|
|
93
124
|
|
|
94
125
|
await backend.putPlaythrough(record);
|
|
95
|
-
await backend.setMeta(
|
|
126
|
+
await backend.setMeta(playthroughCountKey(ifid), num);
|
|
127
|
+
await backend.setMeta(currentPlaythroughKey(ifid), id);
|
|
96
128
|
return id;
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
|
|
131
|
+
const PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
|
|
132
|
+
|
|
133
|
+
function playthroughCountKey(ifid: string): string {
|
|
134
|
+
return `playthroughCount.${ifid}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The number of the next playthrough the story starts: one past the highest
|
|
139
|
+
* number given so far. The count is stored, so deleting a playthrough never
|
|
140
|
+
* frees its number for another (two groups labelled alike); the highest
|
|
141
|
+
* existing label covers data stored before the count was. Imported
|
|
142
|
+
* playthroughs take no number.
|
|
143
|
+
*/
|
|
144
|
+
async function nextPlaythroughNumber(ifid: string): Promise<number> {
|
|
145
|
+
const backend = await getBackend();
|
|
146
|
+
let highest = (await backend.getMeta<number>(playthroughCountKey(ifid))) ?? 0;
|
|
147
|
+
for (const pt of await backend.getPlaythroughsByIfid(ifid)) {
|
|
148
|
+
const match = PLAYTHROUGH_LABEL.exec(pt.label);
|
|
149
|
+
if (match) highest = Math.max(highest, Number(match[1]));
|
|
150
|
+
}
|
|
151
|
+
return highest + 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function currentPlaythroughKey(ifid: string): string {
|
|
155
|
+
return `currentPlaythroughId.${ifid}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getCurrentPlaythroughId(
|
|
100
159
|
ifid: string,
|
|
101
160
|
): Promise<string | undefined> {
|
|
102
|
-
return (
|
|
161
|
+
return inOrder(async () =>
|
|
162
|
+
(await getBackend()).getMeta<string>(currentPlaythroughKey(ifid)),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Make `id` the story's current playthrough: the playthrough of a save the
|
|
168
|
+
* game has loaded. A no-op if it already is. A playthrough without a record
|
|
169
|
+
* in this browser (that of an imported save whose record was deleted since,
|
|
170
|
+
* or of a save a dialog still showed after its playthrough was deleted) is
|
|
171
|
+
* recorded again as "Imported", as importing the save would: it was not
|
|
172
|
+
* started here, so it takes no number.
|
|
173
|
+
*/
|
|
174
|
+
export function adoptPlaythrough(ifid: string, id: string): Promise<void> {
|
|
175
|
+
return inOrder(() => adoptPlaythroughNow(ifid, id));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function adoptPlaythroughNow(ifid: string, id: string): Promise<void> {
|
|
179
|
+
const backend = await getBackend();
|
|
180
|
+
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
181
|
+
if (!playthroughs.some((p) => p.id === id)) {
|
|
182
|
+
await backend.putPlaythrough({
|
|
183
|
+
id,
|
|
184
|
+
ifid,
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
label: 'Imported',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const key = currentPlaythroughKey(ifid);
|
|
190
|
+
if ((await backend.getMeta<string>(key)) !== id) {
|
|
191
|
+
await backend.setMeta(key, id);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Set up the save system for a story that boots: the story's current
|
|
197
|
+
* playthrough (a new one if it has none) and the slots holding saves (see
|
|
198
|
+
* populateKnownSaves), looked up together in one operation.
|
|
199
|
+
*/
|
|
200
|
+
export function establishPlaythrough(
|
|
201
|
+
ifid: string,
|
|
202
|
+
): Promise<{ id: string; knownSaves: Record<string, true> }> {
|
|
203
|
+
return inOrder(async () => {
|
|
204
|
+
const backend = await getBackend();
|
|
205
|
+
const id =
|
|
206
|
+
(await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
|
|
207
|
+
(await startNewPlaythroughNow(ifid, crypto.randomUUID()));
|
|
208
|
+
return { id, knownSaves: await populateKnownSavesNow(ifid) };
|
|
209
|
+
});
|
|
103
210
|
}
|
|
104
211
|
|
|
105
212
|
// --- Save Hooks ---
|
|
@@ -114,6 +221,10 @@ export async function getCurrentPlaythroughId(
|
|
|
114
221
|
* that captures the payload after them, so the capture can tell which
|
|
115
222
|
* variables the hooks changed (the store's `beginSave`).
|
|
116
223
|
*
|
|
224
|
+
* Random numbers the hooks draw do not advance the story's PRNG (see
|
|
225
|
+
* withoutDraws()): the game goes on with the sequence a load of the save
|
|
226
|
+
* continues with, which replays the saved passage without the hooks.
|
|
227
|
+
*
|
|
117
228
|
* The hook and capture run synchronously, but an error they throw (a failing
|
|
118
229
|
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
119
230
|
* write rather than escaping to the caller.
|
|
@@ -126,10 +237,10 @@ export function saveWithHooks<T>(
|
|
|
126
237
|
): Promise<T> {
|
|
127
238
|
try {
|
|
128
239
|
const capture = beginCapture();
|
|
129
|
-
emit('beforesave', slot, custom);
|
|
240
|
+
withoutDraws(() => emit('beforesave', slot, custom));
|
|
130
241
|
const payload = capture();
|
|
131
242
|
return write(payload).then((result) => {
|
|
132
|
-
emit('aftersave', slot);
|
|
243
|
+
withoutDraws(() => emit('aftersave', slot));
|
|
133
244
|
return result;
|
|
134
245
|
});
|
|
135
246
|
} catch (err) {
|
|
@@ -139,11 +250,20 @@ export function saveWithHooks<T>(
|
|
|
139
250
|
|
|
140
251
|
// --- Save CRUD ---
|
|
141
252
|
|
|
142
|
-
export
|
|
253
|
+
export function createSave(
|
|
143
254
|
ifid: string,
|
|
144
255
|
playthroughId: string,
|
|
145
256
|
payload: SavePayload,
|
|
146
257
|
custom: Record<string, unknown> = {},
|
|
258
|
+
): Promise<SaveRecord> {
|
|
259
|
+
return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function createSaveNow(
|
|
263
|
+
ifid: string,
|
|
264
|
+
playthroughId: string,
|
|
265
|
+
payload: SavePayload,
|
|
266
|
+
custom: Record<string, unknown>,
|
|
147
267
|
): Promise<SaveRecord> {
|
|
148
268
|
const now = new Date().toISOString();
|
|
149
269
|
const meta: SaveMeta = {
|
|
@@ -184,7 +304,18 @@ export async function createSave(
|
|
|
184
304
|
* save itself rather than describing its content. `custom` is merged, as
|
|
185
305
|
* before, so slot keys and metadata not passed again are kept.
|
|
186
306
|
*/
|
|
187
|
-
export
|
|
307
|
+
export function overwriteSave(
|
|
308
|
+
saveId: string,
|
|
309
|
+
payload: SavePayload,
|
|
310
|
+
custom?: Record<string, unknown>,
|
|
311
|
+
playthroughId?: string,
|
|
312
|
+
): Promise<SaveRecord | undefined> {
|
|
313
|
+
return inOrder(() =>
|
|
314
|
+
overwriteSaveNow(saveId, payload, custom, playthroughId),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function overwriteSaveNow(
|
|
188
319
|
saveId: string,
|
|
189
320
|
payload: SavePayload,
|
|
190
321
|
custom?: Record<string, unknown>,
|
|
@@ -237,9 +368,11 @@ export function deserializePayload(payload: SavePayload): SavePayload {
|
|
|
237
368
|
};
|
|
238
369
|
}
|
|
239
370
|
|
|
240
|
-
export
|
|
241
|
-
saveId
|
|
242
|
-
|
|
371
|
+
export function loadSave(saveId: string): Promise<SavePayload | undefined> {
|
|
372
|
+
return inOrder(() => loadSaveNow(saveId));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function loadSaveNow(saveId: string): Promise<SavePayload | undefined> {
|
|
243
376
|
const record = await (await getBackend()).getSave(saveId);
|
|
244
377
|
if (!record) return undefined;
|
|
245
378
|
return deserializePayload(record.payload);
|
|
@@ -249,41 +382,40 @@ export async function loadSave(
|
|
|
249
382
|
* Delete a save record. If the default slot or a named slot holds it, that
|
|
250
383
|
* slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
|
|
251
384
|
*/
|
|
252
|
-
export
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
if ((await backend.getMeta<string>(metaKey)) !== saveId)
|
|
385
|
+
export function deleteSaveById(saveId: string): Promise<void> {
|
|
386
|
+
return inOrder(async () => {
|
|
387
|
+
const backend = await getBackend();
|
|
388
|
+
const record = await backend.getSave(saveId);
|
|
389
|
+
await backend.deleteSave(saveId);
|
|
390
|
+
if (!record) return;
|
|
391
|
+
|
|
392
|
+
const ifid = record.meta.ifid;
|
|
393
|
+
const slots = [undefined, ...(await getIndexedSlots(ifid))];
|
|
394
|
+
for (const slot of slots) {
|
|
395
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
396
|
+
if ((await backend.getMeta<string>(metaKey)) !== saveId) continue;
|
|
264
397
|
await backend.deleteMeta(metaKey);
|
|
265
398
|
await removeFromSlotIndex(ifid, slot);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
399
|
+
}
|
|
400
|
+
});
|
|
268
401
|
}
|
|
269
402
|
|
|
270
|
-
export
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
};
|
|
286
|
-
await backend.putSave(updated);
|
|
403
|
+
export function renameSave(saveId: string, newTitle: string): Promise<void> {
|
|
404
|
+
return inOrder(async () => {
|
|
405
|
+
const backend = await getBackend();
|
|
406
|
+
const record = await backend.getSave(saveId);
|
|
407
|
+
if (!record) return;
|
|
408
|
+
const updated: SaveRecord = {
|
|
409
|
+
...record,
|
|
410
|
+
meta: {
|
|
411
|
+
...record.meta,
|
|
412
|
+
title: newTitle,
|
|
413
|
+
userTitle: true,
|
|
414
|
+
updatedAt: new Date().toISOString(),
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
await backend.putSave(updated);
|
|
418
|
+
});
|
|
287
419
|
}
|
|
288
420
|
|
|
289
421
|
// --- Grouped Retrieval ---
|
|
@@ -293,9 +425,11 @@ export interface PlaythroughGroup {
|
|
|
293
425
|
saves: SaveRecord[];
|
|
294
426
|
}
|
|
295
427
|
|
|
296
|
-
export
|
|
297
|
-
ifid
|
|
298
|
-
|
|
428
|
+
export function getSavesGrouped(ifid: string): Promise<PlaythroughGroup[]> {
|
|
429
|
+
return inOrder(() => getSavesGroupedNow(ifid));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
|
|
299
433
|
const backend = await getBackend();
|
|
300
434
|
const [allSaves, allPlaythroughs] = await Promise.all([
|
|
301
435
|
backend.getSavesByIfid(ifid),
|
|
@@ -374,45 +508,16 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
374
508
|
: `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
375
509
|
}
|
|
376
510
|
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Run a complete operation on one slot (save, import, delete) after the ones
|
|
382
|
-
* already queued for it. Each operation reads the slot pointer and writes the
|
|
383
|
-
* record and pointer as a unit, so concurrent operations on the same slot
|
|
384
|
-
* apply in call order instead of each creating its own record.
|
|
385
|
-
*/
|
|
386
|
-
function withSlot<T>(metaKey: string, op: () => Promise<T>): Promise<T> {
|
|
387
|
-
const run = (slotQueues.get(metaKey) ?? Promise.resolve()).then(op);
|
|
388
|
-
const tail = run.catch(() => {});
|
|
389
|
-
slotQueues.set(metaKey, tail);
|
|
390
|
-
void tail.then(() => {
|
|
391
|
-
if (slotQueues.get(metaKey) === tail) slotQueues.delete(metaKey);
|
|
392
|
-
});
|
|
393
|
-
return run;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** Tail of the queue that serializes slot index updates. */
|
|
397
|
-
let slotIndexQueue: Promise<unknown> = Promise.resolve();
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Read-modify-write the per-story slot index. Updates are queued so that
|
|
401
|
-
* concurrent saves, imports and deletes can't overwrite each other's change.
|
|
402
|
-
*/
|
|
403
|
-
function updateSlotIndex(
|
|
511
|
+
/** Read-modify-write the per-story slot index. */
|
|
512
|
+
async function updateSlotIndex(
|
|
404
513
|
ifid: string,
|
|
405
514
|
update: (slots: string[]) => string[],
|
|
406
515
|
): Promise<void> {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (updated !== existing) await backend.setMeta(indexKey, updated);
|
|
413
|
-
});
|
|
414
|
-
slotIndexQueue = run.catch(() => {});
|
|
415
|
-
return run;
|
|
516
|
+
const backend = await getBackend();
|
|
517
|
+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
518
|
+
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
519
|
+
const updated = update(existing);
|
|
520
|
+
if (updated !== existing) await backend.setMeta(indexKey, updated);
|
|
416
521
|
}
|
|
417
522
|
|
|
418
523
|
/** Record a named slot in the per-story slot index (no-op for the default slot). */
|
|
@@ -447,30 +552,32 @@ function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
|
|
|
447
552
|
};
|
|
448
553
|
}
|
|
449
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Save `payload` to a slot (the default slot when `slot` is omitted),
|
|
557
|
+
* overwriting the save it holds. `playthroughId` may be a promise of the ID
|
|
558
|
+
* (settled by operations issued before this call), so the save takes its
|
|
559
|
+
* place in the order of operations when it is called.
|
|
560
|
+
*/
|
|
450
561
|
export function quickSave(
|
|
451
562
|
ifid: string,
|
|
452
|
-
playthroughId: string
|
|
563
|
+
playthroughId: string | PromiseLike<string>,
|
|
453
564
|
payload: SavePayload,
|
|
454
565
|
slot?: string,
|
|
455
566
|
custom?: Record<string, unknown>,
|
|
456
567
|
): Promise<SaveRecord> {
|
|
457
568
|
const metaKey = slotMetaKey(ifid, slot);
|
|
458
|
-
return
|
|
569
|
+
return inOrder(async () => {
|
|
570
|
+
const ptId = await playthroughId;
|
|
459
571
|
const backend = await getBackend();
|
|
460
572
|
const existingId = await backend.getMeta<string>(metaKey);
|
|
461
573
|
|
|
462
574
|
if (existingId) {
|
|
463
|
-
const updated = await
|
|
464
|
-
existingId,
|
|
465
|
-
payload,
|
|
466
|
-
custom,
|
|
467
|
-
playthroughId,
|
|
468
|
-
);
|
|
575
|
+
const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
|
|
469
576
|
if (updated) return updated;
|
|
470
577
|
}
|
|
471
578
|
|
|
472
579
|
// Create new save
|
|
473
|
-
const record = await
|
|
580
|
+
const record = await createSaveNow(ifid, ptId, payload, {
|
|
474
581
|
isAutosave: !isNamedSlot(slot),
|
|
475
582
|
...(isNamedSlot(slot) ? { slot } : {}),
|
|
476
583
|
...custom,
|
|
@@ -482,10 +589,11 @@ export function quickSave(
|
|
|
482
589
|
});
|
|
483
590
|
}
|
|
484
591
|
|
|
485
|
-
export
|
|
486
|
-
ifid
|
|
487
|
-
|
|
488
|
-
|
|
592
|
+
export function hasQuickSave(ifid: string, slot?: string): Promise<boolean> {
|
|
593
|
+
return inOrder(() => hasQuickSaveNow(ifid, slot));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function hasQuickSaveNow(ifid: string, slot?: string): Promise<boolean> {
|
|
489
597
|
const backend = await getBackend();
|
|
490
598
|
const metaKey = slotMetaKey(ifid, slot);
|
|
491
599
|
const existingId = await backend.getMeta<string>(metaKey);
|
|
@@ -494,34 +602,67 @@ export async function hasQuickSave(
|
|
|
494
602
|
return record !== undefined;
|
|
495
603
|
}
|
|
496
604
|
|
|
497
|
-
export
|
|
605
|
+
export function loadQuickSave(
|
|
498
606
|
ifid: string,
|
|
499
607
|
slot?: string,
|
|
500
608
|
): Promise<SavePayload | undefined> {
|
|
609
|
+
return inOrder(async () => {
|
|
610
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
611
|
+
const existingId = await (await getBackend()).getMeta<string>(metaKey);
|
|
612
|
+
if (!existingId) return undefined;
|
|
613
|
+
return loadSaveNow(existingId);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Read the save held in a slot (the default slot when `slot` is omitted) for
|
|
619
|
+
* the running game to load, and make its playthrough the story's current one
|
|
620
|
+
* (see adoptPlaythrough) in the same operation: operations issued after the
|
|
621
|
+
* load take effect in the loaded playthrough. Resolves to the live payload
|
|
622
|
+
* and its playthrough, or undefined if the slot is empty.
|
|
623
|
+
*/
|
|
624
|
+
export function loadSlotSave(
|
|
625
|
+
ifid: string,
|
|
626
|
+
slot?: string,
|
|
627
|
+
): Promise<{ payload: SavePayload; playthroughId: string } | undefined> {
|
|
501
628
|
const metaKey = slotMetaKey(ifid, slot);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
629
|
+
return inOrder(async () => {
|
|
630
|
+
const backend = await getBackend();
|
|
631
|
+
const existingId = await backend.getMeta<string>(metaKey);
|
|
632
|
+
if (!existingId) return undefined;
|
|
633
|
+
const record = await backend.getSave(existingId);
|
|
634
|
+
if (!record) return undefined;
|
|
635
|
+
const payload = deserializePayload(record.payload);
|
|
636
|
+
const { playthroughId } = record.meta;
|
|
637
|
+
if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
|
|
638
|
+
return { payload, playthroughId };
|
|
639
|
+
});
|
|
505
640
|
}
|
|
506
641
|
|
|
507
642
|
/**
|
|
508
643
|
* Check storage for all known saves and return a map of slot keys to true.
|
|
509
644
|
* The default (autosave) slot uses empty string as key.
|
|
510
645
|
*/
|
|
511
|
-
export
|
|
646
|
+
export function populateKnownSaves(
|
|
647
|
+
ifid: string,
|
|
648
|
+
): Promise<Record<string, true>> {
|
|
649
|
+
return inOrder(() => populateKnownSavesNow(ifid));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function populateKnownSavesNow(
|
|
512
653
|
ifid: string,
|
|
513
654
|
): Promise<Record<string, true>> {
|
|
514
655
|
// No prototype, so a slot named '__proto__' is recorded as an entry
|
|
515
656
|
const result = Object.create(null) as Record<string, true>;
|
|
516
657
|
|
|
517
658
|
// Check default autosave
|
|
518
|
-
if (await
|
|
659
|
+
if (await hasQuickSaveNow(ifid)) {
|
|
519
660
|
result[''] = true;
|
|
520
661
|
}
|
|
521
662
|
|
|
522
663
|
// Check named slots from the index
|
|
523
664
|
for (const slot of await getIndexedSlots(ifid)) {
|
|
524
|
-
if (await
|
|
665
|
+
if (await hasQuickSaveNow(ifid, slot)) {
|
|
525
666
|
result[slot] = true;
|
|
526
667
|
}
|
|
527
668
|
}
|
|
@@ -533,7 +674,14 @@ export async function populateKnownSaves(
|
|
|
533
674
|
* Get metadata for a specific save slot.
|
|
534
675
|
* Returns null if no save exists for that slot.
|
|
535
676
|
*/
|
|
536
|
-
export
|
|
677
|
+
export function getSlotSaveInfo(
|
|
678
|
+
ifid: string,
|
|
679
|
+
slot?: string,
|
|
680
|
+
): Promise<SaveInfo | null> {
|
|
681
|
+
return inOrder(() => getSlotSaveInfoNow(ifid, slot));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function getSlotSaveInfoNow(
|
|
537
685
|
ifid: string,
|
|
538
686
|
slot?: string,
|
|
539
687
|
): Promise<SaveInfo | null> {
|
|
@@ -549,20 +697,22 @@ export async function getSlotSaveInfo(
|
|
|
549
697
|
/**
|
|
550
698
|
* List metadata for all known save slots (default + named).
|
|
551
699
|
*/
|
|
552
|
-
export
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
700
|
+
export function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
|
|
701
|
+
return inOrder(async () => {
|
|
702
|
+
const result: SaveInfo[] = [];
|
|
703
|
+
|
|
704
|
+
// Check default autosave
|
|
705
|
+
const defaultInfo = await getSlotSaveInfoNow(ifid);
|
|
706
|
+
if (defaultInfo) result.push(defaultInfo);
|
|
707
|
+
|
|
708
|
+
// Check named slots from the index
|
|
709
|
+
for (const slot of await getIndexedSlots(ifid)) {
|
|
710
|
+
const info = await getSlotSaveInfoNow(ifid, slot);
|
|
711
|
+
if (info) result.push(info);
|
|
712
|
+
}
|
|
564
713
|
|
|
565
|
-
|
|
714
|
+
return result;
|
|
715
|
+
});
|
|
566
716
|
}
|
|
567
717
|
|
|
568
718
|
/**
|
|
@@ -570,7 +720,7 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
|
|
|
570
720
|
*/
|
|
571
721
|
export function deleteSlotSave(ifid: string, slot?: string): Promise<void> {
|
|
572
722
|
const metaKey = slotMetaKey(ifid, slot);
|
|
573
|
-
return
|
|
723
|
+
return inOrder(async () => {
|
|
574
724
|
const backend = await getBackend();
|
|
575
725
|
const existingId = await backend.getMeta<string>(metaKey);
|
|
576
726
|
if (!existingId) return;
|
|
@@ -624,9 +774,11 @@ export function clearSession(ifid: string): void {
|
|
|
624
774
|
|
|
625
775
|
// --- Export / Import ---
|
|
626
776
|
|
|
627
|
-
export
|
|
628
|
-
saveId
|
|
629
|
-
|
|
777
|
+
export function exportSave(saveId: string): Promise<SaveExport | undefined> {
|
|
778
|
+
return inOrder(() => exportSaveNow(saveId));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
|
|
630
782
|
const record = await (await getBackend()).getSave(saveId);
|
|
631
783
|
if (!record) return undefined;
|
|
632
784
|
|
|
@@ -667,6 +819,9 @@ async function prepareImport(
|
|
|
667
819
|
const record = deepClone(data.save);
|
|
668
820
|
record.meta.id = crypto.randomUUID();
|
|
669
821
|
record.meta.updatedAt = new Date().toISOString();
|
|
822
|
+
// The save now belongs to this story: it is listed, loaded and cleared
|
|
823
|
+
// with it, whatever its own metadata says
|
|
824
|
+
record.meta.ifid = ifid;
|
|
670
825
|
|
|
671
826
|
// Ensure the playthrough exists
|
|
672
827
|
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
@@ -685,28 +840,32 @@ async function prepareImport(
|
|
|
685
840
|
return record;
|
|
686
841
|
}
|
|
687
842
|
|
|
688
|
-
export
|
|
843
|
+
export function importSave(
|
|
689
844
|
data: SaveExport,
|
|
690
845
|
ifid: string,
|
|
691
846
|
): Promise<SaveRecord> {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
847
|
+
return inOrder(async () => {
|
|
848
|
+
const record = await prepareImport(data, ifid);
|
|
849
|
+
await (await getBackend()).putSave(record);
|
|
850
|
+
return record;
|
|
851
|
+
});
|
|
695
852
|
}
|
|
696
853
|
|
|
697
854
|
/**
|
|
698
855
|
* Export the save held in a slot (default autosave slot when `slot` is omitted).
|
|
699
856
|
* Returns undefined if the slot is empty.
|
|
700
857
|
*/
|
|
701
|
-
export
|
|
858
|
+
export function exportSlotSave(
|
|
702
859
|
ifid: string,
|
|
703
860
|
slot?: string,
|
|
704
861
|
): Promise<SaveExport | undefined> {
|
|
705
|
-
|
|
706
|
-
await
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
862
|
+
return inOrder(async () => {
|
|
863
|
+
const saveId = await (
|
|
864
|
+
await getBackend()
|
|
865
|
+
).getMeta<string>(slotMetaKey(ifid, slot));
|
|
866
|
+
if (!saveId) return undefined;
|
|
867
|
+
return exportSaveNow(saveId);
|
|
868
|
+
});
|
|
710
869
|
}
|
|
711
870
|
|
|
712
871
|
/**
|
|
@@ -721,7 +880,7 @@ export function importSlotSave(
|
|
|
721
880
|
slot?: string,
|
|
722
881
|
): Promise<SaveInfo> {
|
|
723
882
|
const metaKey = slotMetaKey(ifid, slot);
|
|
724
|
-
return
|
|
883
|
+
return inOrder(async () => {
|
|
725
884
|
const record = await prepareImport(data, ifid);
|
|
726
885
|
|
|
727
886
|
// The slot keys in `custom` describe where the save lives, so they follow
|
|
@@ -747,7 +906,11 @@ export function importSlotSave(
|
|
|
747
906
|
|
|
748
907
|
// --- Storage Management ---
|
|
749
908
|
|
|
750
|
-
export
|
|
909
|
+
export function getStorageInfo(ifid: string): Promise<StorageInfo> {
|
|
910
|
+
return inOrder(() => getStorageInfoNow(ifid));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
|
|
751
914
|
const backend = await getBackend();
|
|
752
915
|
const saves = await backend.getSavesByIfid(ifid);
|
|
753
916
|
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
@@ -773,15 +936,21 @@ export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
|
|
|
773
936
|
};
|
|
774
937
|
}
|
|
775
938
|
|
|
776
|
-
export
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
939
|
+
export function clearGameData(ifid: string): Promise<void> {
|
|
940
|
+
return inOrder(async () => {
|
|
941
|
+
const backend = await getBackend();
|
|
942
|
+
await backend.deleteSavesByIfid(ifid);
|
|
943
|
+
await backend.deletePlaythroughsByIfid(ifid);
|
|
944
|
+
await backend.deleteMetaByIfid(ifid);
|
|
945
|
+
clearSession(ifid);
|
|
946
|
+
});
|
|
782
947
|
}
|
|
783
948
|
|
|
784
|
-
export
|
|
949
|
+
export function clearAllData(): Promise<void> {
|
|
950
|
+
return inOrder(clearAllDataNow);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function clearAllDataNow(): Promise<void> {
|
|
785
954
|
const backend = await getBackend();
|
|
786
955
|
await backend.destroy();
|
|
787
956
|
resetBackend();
|
|
@@ -797,30 +966,47 @@ export async function clearAllData(): Promise<void> {
|
|
|
797
966
|
}
|
|
798
967
|
}
|
|
799
968
|
|
|
800
|
-
|
|
969
|
+
/**
|
|
970
|
+
* Delete a playthrough with its saves, emptying the slots that held them.
|
|
971
|
+
*
|
|
972
|
+
* `replacement` is for deleting the playthrough a running game is in:
|
|
973
|
+
* `current` is (a promise of) that game's playthrough, settled by operations
|
|
974
|
+
* issued before this call. If it is the deleted one, the playthrough
|
|
975
|
+
* `replacement.id` is started in the same operation, before any operation
|
|
976
|
+
* issued later. Resolves to whether it was.
|
|
977
|
+
*/
|
|
978
|
+
export function deletePlaythroughData(
|
|
801
979
|
ifid: string,
|
|
802
980
|
playthroughId: string,
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
981
|
+
replacement?: { current: string | PromiseLike<string>; id: string },
|
|
982
|
+
): Promise<boolean> {
|
|
983
|
+
return inOrder(async () => {
|
|
984
|
+
const backend = await getBackend();
|
|
985
|
+
const deletedSaveIds =
|
|
986
|
+
await backend.deleteSavesByPlaythrough(playthroughId);
|
|
987
|
+
await backend.deletePlaythroughById(playthroughId);
|
|
988
|
+
|
|
989
|
+
// Clear the slots that held deleted saves (pointer and slot index)
|
|
990
|
+
const deletedSet = new Set(deletedSaveIds);
|
|
991
|
+
for (const slot of [undefined, ...(await getIndexedSlots(ifid))]) {
|
|
992
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
993
|
+
const value = await backend.getMeta<string>(metaKey);
|
|
814
994
|
if (value && deletedSet.has(value)) {
|
|
815
|
-
await backend.deleteMeta(
|
|
995
|
+
await backend.deleteMeta(metaKey);
|
|
996
|
+
await removeFromSlotIndex(ifid, slot);
|
|
816
997
|
}
|
|
817
998
|
}
|
|
818
|
-
}
|
|
819
999
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1000
|
+
if (replacement && (await replacement.current) === playthroughId) {
|
|
1001
|
+
await startNewPlaythroughNow(ifid, replacement.id);
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Clear currentPlaythroughId if it was this one
|
|
1006
|
+
const currentPtKey = currentPlaythroughKey(ifid);
|
|
1007
|
+
if ((await backend.getMeta<string>(currentPtKey)) === playthroughId) {
|
|
1008
|
+
await backend.deleteMeta(currentPtKey);
|
|
1009
|
+
}
|
|
1010
|
+
return false;
|
|
1011
|
+
});
|
|
826
1012
|
}
|