@rohal12/spindle 0.52.4 → 0.52.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +819 -945
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/execute-mutation.ts +17 -97
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SaveExport,
|
|
8
8
|
StorageInfo,
|
|
9
9
|
} from './types';
|
|
10
|
-
import { isSaveExport, isSavePayload } from './types';
|
|
10
|
+
import { estimatePayloadBytes, 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';
|
|
@@ -94,6 +94,13 @@ function inOrder<T>(op: () => Promise<T>): Promise<T> {
|
|
|
94
94
|
return run;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** `op` (a `...Now` helper) as an operation that queues itself. */
|
|
98
|
+
function queued<A extends unknown[], R>(
|
|
99
|
+
op: (...args: A) => Promise<R>,
|
|
100
|
+
): (...args: A) => Promise<R> {
|
|
101
|
+
return (...args) => inOrder(() => op(...args));
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
// --- Playthroughs ---
|
|
98
105
|
|
|
99
106
|
/**
|
|
@@ -101,16 +108,11 @@ function inOrder<T>(op: () => Promise<T>): Promise<T> {
|
|
|
101
108
|
* the caller has already switched to the playthrough (restart() does, so
|
|
102
109
|
* saves issued before this resolves are tagged with it).
|
|
103
110
|
*/
|
|
104
|
-
export
|
|
105
|
-
ifid: string,
|
|
106
|
-
id: string = crypto.randomUUID(),
|
|
107
|
-
): Promise<string> {
|
|
108
|
-
return inOrder(() => startNewPlaythroughNow(ifid, id));
|
|
109
|
-
}
|
|
111
|
+
export const startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
110
112
|
|
|
111
113
|
async function startNewPlaythroughNow(
|
|
112
114
|
ifid: string,
|
|
113
|
-
id: string,
|
|
115
|
+
id: string = crypto.randomUUID(),
|
|
114
116
|
): Promise<string> {
|
|
115
117
|
const backend = await getBackend();
|
|
116
118
|
const num = await nextPlaythroughNumber(ifid);
|
|
@@ -171,27 +173,34 @@ export function getCurrentPlaythroughId(
|
|
|
171
173
|
* recorded again as "Imported", as importing the save would: it was not
|
|
172
174
|
* started here, so it takes no number.
|
|
173
175
|
*/
|
|
174
|
-
export
|
|
175
|
-
return inOrder(() => adoptPlaythroughNow(ifid, id));
|
|
176
|
-
}
|
|
176
|
+
export const adoptPlaythrough = queued(adoptPlaythroughNow);
|
|
177
177
|
|
|
178
178
|
async function adoptPlaythroughNow(ifid: string, id: string): Promise<void> {
|
|
179
179
|
const backend = await getBackend();
|
|
180
|
-
|
|
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
|
-
}
|
|
180
|
+
await ensurePlaythrough(ifid, id, new Date().toISOString());
|
|
189
181
|
const key = currentPlaythroughKey(ifid);
|
|
190
182
|
if ((await backend.getMeta<string>(key)) !== id) {
|
|
191
183
|
await backend.setMeta(key, id);
|
|
192
184
|
}
|
|
193
185
|
}
|
|
194
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Record the playthrough `id` of the story as "Imported", created at
|
|
189
|
+
* `createdAt`, unless it has a record: a playthrough not started here takes
|
|
190
|
+
* no number.
|
|
191
|
+
*/
|
|
192
|
+
async function ensurePlaythrough(
|
|
193
|
+
ifid: string,
|
|
194
|
+
id: string,
|
|
195
|
+
createdAt: string,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const backend = await getBackend();
|
|
198
|
+
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
199
|
+
if (!playthroughs.some((p) => p.id === id)) {
|
|
200
|
+
await backend.putPlaythrough({ id, ifid, createdAt, label: 'Imported' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
195
204
|
/**
|
|
196
205
|
* Set up the save system for a story that boots: the story's current
|
|
197
206
|
* playthrough (a new one if it has none) and the slots holding saves (see
|
|
@@ -204,7 +213,7 @@ export function establishPlaythrough(
|
|
|
204
213
|
const backend = await getBackend();
|
|
205
214
|
const id =
|
|
206
215
|
(await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
|
|
207
|
-
(await startNewPlaythroughNow(ifid
|
|
216
|
+
(await startNewPlaythroughNow(ifid));
|
|
208
217
|
return { id, knownSaves: await populateKnownSavesNow(ifid) };
|
|
209
218
|
});
|
|
210
219
|
}
|
|
@@ -250,20 +259,40 @@ export function saveWithHooks<T>(
|
|
|
250
259
|
|
|
251
260
|
// --- Save CRUD ---
|
|
252
261
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
/**
|
|
263
|
+
* `payload` with `convert` applied to its live variables and to those of
|
|
264
|
+
* every history moment.
|
|
265
|
+
*/
|
|
266
|
+
function mapVariables(
|
|
256
267
|
payload: SavePayload,
|
|
257
|
-
|
|
258
|
-
):
|
|
259
|
-
return
|
|
268
|
+
convert: (variables: Record<string, unknown>) => Record<string, unknown>,
|
|
269
|
+
): SavePayload {
|
|
270
|
+
return {
|
|
271
|
+
...payload,
|
|
272
|
+
variables: convert(payload.variables),
|
|
273
|
+
history: payload.history.map((m) => ({
|
|
274
|
+
...m,
|
|
275
|
+
variables: convert(m.variables),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The record to store for a save: `payload` serialized, and its size. */
|
|
281
|
+
function storedRecord(meta: SaveMeta, payload: SavePayload): SaveRecord {
|
|
282
|
+
const serialized = mapVariables(deepClone(payload), serialize);
|
|
283
|
+
return {
|
|
284
|
+
meta: { ...meta, estimatedBytes: estimatePayloadBytes(serialized) },
|
|
285
|
+
payload: serialized,
|
|
286
|
+
};
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
export const createSave = queued(createSaveNow);
|
|
290
|
+
|
|
262
291
|
async function createSaveNow(
|
|
263
292
|
ifid: string,
|
|
264
293
|
playthroughId: string,
|
|
265
294
|
payload: SavePayload,
|
|
266
|
-
custom: Record<string, unknown
|
|
295
|
+
custom: Record<string, unknown> = {},
|
|
267
296
|
): Promise<SaveRecord> {
|
|
268
297
|
const now = new Date().toISOString();
|
|
269
298
|
const meta: SaveMeta = {
|
|
@@ -277,14 +306,7 @@ async function createSaveNow(
|
|
|
277
306
|
custom,
|
|
278
307
|
};
|
|
279
308
|
|
|
280
|
-
const
|
|
281
|
-
serializedPayload.variables = serialize(serializedPayload.variables);
|
|
282
|
-
serializedPayload.history = serializedPayload.history.map((m) => ({
|
|
283
|
-
...m,
|
|
284
|
-
variables: serialize(m.variables),
|
|
285
|
-
}));
|
|
286
|
-
const record: SaveRecord = { meta, payload: serializedPayload };
|
|
287
|
-
record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
309
|
+
const record = storedRecord(meta, payload);
|
|
288
310
|
await (await getBackend()).putSave(record);
|
|
289
311
|
return record;
|
|
290
312
|
}
|
|
@@ -304,16 +326,7 @@ async function createSaveNow(
|
|
|
304
326
|
* save itself rather than describing its content. `custom` is merged, as
|
|
305
327
|
* before, so slot keys and metadata not passed again are kept.
|
|
306
328
|
*/
|
|
307
|
-
export
|
|
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
|
-
}
|
|
329
|
+
export const overwriteSave = queued(overwriteSaveNow);
|
|
317
330
|
|
|
318
331
|
async function overwriteSaveNow(
|
|
319
332
|
saveId: string,
|
|
@@ -325,14 +338,8 @@ async function overwriteSaveNow(
|
|
|
325
338
|
const existing = await backend.getSave(saveId);
|
|
326
339
|
if (!existing) return undefined;
|
|
327
340
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
serializedPayload.history = serializedPayload.history.map((m) => ({
|
|
331
|
-
...m,
|
|
332
|
-
variables: serialize(m.variables),
|
|
333
|
-
}));
|
|
334
|
-
const updated: SaveRecord = {
|
|
335
|
-
meta: {
|
|
341
|
+
const updated = storedRecord(
|
|
342
|
+
{
|
|
336
343
|
...existing.meta,
|
|
337
344
|
...(playthroughId ? { playthroughId } : {}),
|
|
338
345
|
updatedAt: new Date().toISOString(),
|
|
@@ -345,9 +352,8 @@ async function overwriteSaveNow(
|
|
|
345
352
|
? { custom: { ...existing.meta.custom, ...custom } }
|
|
346
353
|
: {}),
|
|
347
354
|
},
|
|
348
|
-
payload
|
|
349
|
-
|
|
350
|
-
updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
355
|
+
payload,
|
|
356
|
+
);
|
|
351
357
|
await backend.putSave(updated);
|
|
352
358
|
return updated;
|
|
353
359
|
}
|
|
@@ -358,25 +364,16 @@ async function overwriteSaveNow(
|
|
|
358
364
|
* untouched. Payloads handed to the store's `loadFromPayload()` must be live.
|
|
359
365
|
*/
|
|
360
366
|
export function deserializePayload(payload: SavePayload): SavePayload {
|
|
361
|
-
return
|
|
362
|
-
...payload,
|
|
363
|
-
variables: deserialize(payload.variables),
|
|
364
|
-
history: payload.history.map((m) => ({
|
|
365
|
-
...m,
|
|
366
|
-
variables: deserialize(m.variables),
|
|
367
|
-
})),
|
|
368
|
-
};
|
|
367
|
+
return mapVariables(payload, deserialize);
|
|
369
368
|
}
|
|
370
369
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
370
|
+
/** The live payload of a stored save, if there is one. */
|
|
371
|
+
const livePayload = (record: SaveRecord | undefined) =>
|
|
372
|
+
record && deserializePayload(record.payload);
|
|
374
373
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return deserializePayload(record.payload);
|
|
379
|
-
}
|
|
374
|
+
export const loadSave = queued(async (saveId: string) =>
|
|
375
|
+
livePayload(await (await getBackend()).getSave(saveId)),
|
|
376
|
+
);
|
|
380
377
|
|
|
381
378
|
/**
|
|
382
379
|
* Delete a save record. If the default slot or a named slot holds it, that
|
|
@@ -389,14 +386,7 @@ export function deleteSaveById(saveId: string): Promise<void> {
|
|
|
389
386
|
await backend.deleteSave(saveId);
|
|
390
387
|
if (!record) return;
|
|
391
388
|
|
|
392
|
-
|
|
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;
|
|
397
|
-
await backend.deleteMeta(metaKey);
|
|
398
|
-
await removeFromSlotIndex(ifid, slot);
|
|
399
|
-
}
|
|
389
|
+
await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
|
|
400
390
|
});
|
|
401
391
|
}
|
|
402
392
|
|
|
@@ -425,11 +415,9 @@ export interface PlaythroughGroup {
|
|
|
425
415
|
saves: SaveRecord[];
|
|
426
416
|
}
|
|
427
417
|
|
|
428
|
-
export
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
|
|
418
|
+
export const getSavesGrouped = queued(async function getSavesGroupedNow(
|
|
419
|
+
ifid: string,
|
|
420
|
+
): Promise<PlaythroughGroup[]> {
|
|
433
421
|
const backend = await getBackend();
|
|
434
422
|
const [allSaves, allPlaythroughs] = await Promise.all([
|
|
435
423
|
backend.getSavesByIfid(ifid),
|
|
@@ -486,7 +474,7 @@ async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
|
|
|
486
474
|
}
|
|
487
475
|
|
|
488
476
|
return result;
|
|
489
|
-
}
|
|
477
|
+
});
|
|
490
478
|
|
|
491
479
|
// --- Quick Save / Slot Save ---
|
|
492
480
|
|
|
@@ -508,39 +496,83 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
508
496
|
: `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
509
497
|
}
|
|
510
498
|
|
|
499
|
+
const slotIndexKey = (ifid: string) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
500
|
+
|
|
511
501
|
/** Read-modify-write the per-story slot index. */
|
|
512
502
|
async function updateSlotIndex(
|
|
513
503
|
ifid: string,
|
|
514
504
|
update: (slots: string[]) => string[],
|
|
515
505
|
): Promise<void> {
|
|
516
506
|
const backend = await getBackend();
|
|
517
|
-
const
|
|
518
|
-
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
507
|
+
const existing = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
|
|
519
508
|
const updated = update(existing);
|
|
520
|
-
if (updated !== existing) await backend.setMeta(
|
|
509
|
+
if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
|
|
521
510
|
}
|
|
522
511
|
|
|
523
|
-
/**
|
|
524
|
-
function
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
512
|
+
/** Every slot: the default one (undefined), then those in the slot index. */
|
|
513
|
+
async function allSlots(ifid: string): Promise<(string | undefined)[]> {
|
|
514
|
+
const backend = await getBackend();
|
|
515
|
+
const slots = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
|
|
516
|
+
return [undefined, ...slots.filter(isNamedSlot)];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The ID of the save a slot holds, if any. */
|
|
520
|
+
async function slotSaveId(
|
|
521
|
+
ifid: string,
|
|
522
|
+
slot?: string,
|
|
523
|
+
): Promise<string | undefined> {
|
|
524
|
+
return (await getBackend()).getMeta<string>(slotMetaKey(ifid, slot));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The record of the save a slot holds, if there is one. */
|
|
528
|
+
async function slotRecord(
|
|
529
|
+
ifid: string,
|
|
530
|
+
slot?: string,
|
|
531
|
+
): Promise<SaveRecord | undefined> {
|
|
532
|
+
const id = await slotSaveId(ifid, slot);
|
|
533
|
+
return id ? (await getBackend()).getSave(id) : undefined;
|
|
529
534
|
}
|
|
530
535
|
|
|
531
|
-
/**
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
|
|
536
|
+
/** Make a slot hold the save `saveId`: its pointer, and the slot index. */
|
|
537
|
+
async function fillSlot(
|
|
538
|
+
ifid: string,
|
|
539
|
+
slot: string | undefined,
|
|
540
|
+
saveId: string,
|
|
541
|
+
): Promise<void> {
|
|
542
|
+
await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
|
|
543
|
+
// A named slot is recorded in the slot index
|
|
544
|
+
if (isNamedSlot(slot)) {
|
|
545
|
+
await updateSlotIndex(ifid, (slots) =>
|
|
546
|
+
slots.includes(slot) ? slots : [...slots, slot],
|
|
547
|
+
);
|
|
548
|
+
}
|
|
535
549
|
}
|
|
536
550
|
|
|
537
|
-
/**
|
|
538
|
-
async function
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
551
|
+
/** Clear a slot: its pointer, and its entry in the slot index. */
|
|
552
|
+
async function emptySlot(ifid: string, slot?: string): Promise<void> {
|
|
553
|
+
await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
|
|
554
|
+
if (isNamedSlot(slot)) {
|
|
555
|
+
await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
|
|
556
|
+
}
|
|
542
557
|
}
|
|
543
558
|
|
|
559
|
+
/** Clear every slot holding a save whose ID passes `test`. */
|
|
560
|
+
async function emptySlotsHolding(
|
|
561
|
+
ifid: string,
|
|
562
|
+
test: (saveId: string) => boolean,
|
|
563
|
+
): Promise<void> {
|
|
564
|
+
for (const slot of await allSlots(ifid)) {
|
|
565
|
+
const id = await slotSaveId(ifid, slot);
|
|
566
|
+
if (id && test(id)) await emptySlot(ifid, slot);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The `custom` keys that say which slot a save lives in. */
|
|
571
|
+
const slotCustom = (slot: string | undefined) => ({
|
|
572
|
+
isAutosave: !isNamedSlot(slot),
|
|
573
|
+
...(isNamedSlot(slot) ? { slot } : {}),
|
|
574
|
+
});
|
|
575
|
+
|
|
544
576
|
function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
|
|
545
577
|
return {
|
|
546
578
|
slot: isNamedSlot(slot) ? slot : '',
|
|
@@ -565,11 +597,9 @@ export function quickSave(
|
|
|
565
597
|
slot?: string,
|
|
566
598
|
custom?: Record<string, unknown>,
|
|
567
599
|
): Promise<SaveRecord> {
|
|
568
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
569
600
|
return inOrder(async () => {
|
|
570
601
|
const ptId = await playthroughId;
|
|
571
|
-
const
|
|
572
|
-
const existingId = await backend.getMeta<string>(metaKey);
|
|
602
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
573
603
|
|
|
574
604
|
if (existingId) {
|
|
575
605
|
const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
|
|
@@ -578,41 +608,24 @@ export function quickSave(
|
|
|
578
608
|
|
|
579
609
|
// Create new save
|
|
580
610
|
const record = await createSaveNow(ifid, ptId, payload, {
|
|
581
|
-
|
|
582
|
-
...(isNamedSlot(slot) ? { slot } : {}),
|
|
611
|
+
...slotCustom(slot),
|
|
583
612
|
...custom,
|
|
584
613
|
});
|
|
585
|
-
await
|
|
586
|
-
await addToSlotIndex(ifid, slot);
|
|
614
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
587
615
|
|
|
588
616
|
return record;
|
|
589
617
|
});
|
|
590
618
|
}
|
|
591
619
|
|
|
592
|
-
export
|
|
593
|
-
return inOrder(() => hasQuickSaveNow(ifid, slot));
|
|
594
|
-
}
|
|
620
|
+
export const hasQuickSave = queued(hasQuickSaveNow);
|
|
595
621
|
|
|
596
622
|
async function hasQuickSaveNow(ifid: string, slot?: string): Promise<boolean> {
|
|
597
|
-
|
|
598
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
599
|
-
const existingId = await backend.getMeta<string>(metaKey);
|
|
600
|
-
if (!existingId) return false;
|
|
601
|
-
const record = await backend.getSave(existingId);
|
|
602
|
-
return record !== undefined;
|
|
623
|
+
return (await slotRecord(ifid, slot)) !== undefined;
|
|
603
624
|
}
|
|
604
625
|
|
|
605
|
-
export
|
|
606
|
-
ifid
|
|
607
|
-
|
|
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
|
-
}
|
|
626
|
+
export const loadQuickSave = queued(async (ifid: string, slot?: string) =>
|
|
627
|
+
livePayload(await slotRecord(ifid, slot)),
|
|
628
|
+
);
|
|
616
629
|
|
|
617
630
|
/**
|
|
618
631
|
* Read the save held in a slot (the default slot when `slot` is omitted) for
|
|
@@ -625,12 +638,8 @@ export function loadSlotSave(
|
|
|
625
638
|
ifid: string,
|
|
626
639
|
slot?: string,
|
|
627
640
|
): Promise<{ payload: SavePayload; playthroughId: string } | undefined> {
|
|
628
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
629
641
|
return inOrder(async () => {
|
|
630
|
-
const
|
|
631
|
-
const existingId = await backend.getMeta<string>(metaKey);
|
|
632
|
-
if (!existingId) return undefined;
|
|
633
|
-
const record = await backend.getSave(existingId);
|
|
642
|
+
const record = await slotRecord(ifid, slot);
|
|
634
643
|
if (!record) return undefined;
|
|
635
644
|
const payload = deserializePayload(record.payload);
|
|
636
645
|
const { playthroughId } = record.meta;
|
|
@@ -643,30 +652,16 @@ export function loadSlotSave(
|
|
|
643
652
|
* Check storage for all known saves and return a map of slot keys to true.
|
|
644
653
|
* The default (autosave) slot uses empty string as key.
|
|
645
654
|
*/
|
|
646
|
-
export
|
|
647
|
-
ifid: string,
|
|
648
|
-
): Promise<Record<string, true>> {
|
|
649
|
-
return inOrder(() => populateKnownSavesNow(ifid));
|
|
650
|
-
}
|
|
655
|
+
export const populateKnownSaves = queued(populateKnownSavesNow);
|
|
651
656
|
|
|
652
657
|
async function populateKnownSavesNow(
|
|
653
658
|
ifid: string,
|
|
654
659
|
): Promise<Record<string, true>> {
|
|
655
660
|
// No prototype, so a slot named '__proto__' is recorded as an entry
|
|
656
661
|
const result = Object.create(null) as Record<string, true>;
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (await hasQuickSaveNow(ifid)) {
|
|
660
|
-
result[''] = true;
|
|
662
|
+
for (const slot of await allSlots(ifid)) {
|
|
663
|
+
if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ''] = true;
|
|
661
664
|
}
|
|
662
|
-
|
|
663
|
-
// Check named slots from the index
|
|
664
|
-
for (const slot of await getIndexedSlots(ifid)) {
|
|
665
|
-
if (await hasQuickSaveNow(ifid, slot)) {
|
|
666
|
-
result[slot] = true;
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
665
|
return result;
|
|
671
666
|
}
|
|
672
667
|
|
|
@@ -674,64 +669,37 @@ async function populateKnownSavesNow(
|
|
|
674
669
|
* Get metadata for a specific save slot.
|
|
675
670
|
* Returns null if no save exists for that slot.
|
|
676
671
|
*/
|
|
677
|
-
export
|
|
678
|
-
ifid: string,
|
|
679
|
-
slot?: string,
|
|
680
|
-
): Promise<SaveInfo | null> {
|
|
681
|
-
return inOrder(() => getSlotSaveInfoNow(ifid, slot));
|
|
682
|
-
}
|
|
672
|
+
export const getSlotSaveInfo = queued(getSlotSaveInfoNow);
|
|
683
673
|
|
|
684
674
|
async function getSlotSaveInfoNow(
|
|
685
675
|
ifid: string,
|
|
686
676
|
slot?: string,
|
|
687
677
|
): Promise<SaveInfo | null> {
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
const existingId = await backend.getMeta<string>(metaKey);
|
|
691
|
-
if (!existingId) return null;
|
|
692
|
-
const record = await backend.getSave(existingId);
|
|
693
|
-
if (!record) return null;
|
|
694
|
-
return toSaveInfo(record, slot);
|
|
678
|
+
const record = await slotRecord(ifid, slot);
|
|
679
|
+
return record ? toSaveInfo(record, slot) : null;
|
|
695
680
|
}
|
|
696
681
|
|
|
697
682
|
/**
|
|
698
683
|
* List metadata for all known save slots (default + named).
|
|
699
684
|
*/
|
|
700
|
-
export
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
}
|
|
713
|
-
|
|
714
|
-
return result;
|
|
715
|
-
});
|
|
716
|
-
}
|
|
685
|
+
export const listSlotSaves = queued(async (ifid: string) => {
|
|
686
|
+
const result: SaveInfo[] = [];
|
|
687
|
+
for (const slot of await allSlots(ifid)) {
|
|
688
|
+
const info = await getSlotSaveInfoNow(ifid, slot);
|
|
689
|
+
if (info) result.push(info);
|
|
690
|
+
}
|
|
691
|
+
return result;
|
|
692
|
+
});
|
|
717
693
|
|
|
718
694
|
/**
|
|
719
695
|
* Delete a save by slot name. Removes from slot index if named.
|
|
720
696
|
*/
|
|
721
|
-
export
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
await backend.deleteSave(existingId);
|
|
729
|
-
await backend.deleteMeta(metaKey);
|
|
730
|
-
|
|
731
|
-
// Remove from slot index if named
|
|
732
|
-
await removeFromSlotIndex(ifid, slot);
|
|
733
|
-
});
|
|
734
|
-
}
|
|
697
|
+
export const deleteSlotSave = queued(async (ifid: string, slot?: string) => {
|
|
698
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
699
|
+
if (!existingId) return;
|
|
700
|
+
await (await getBackend()).deleteSave(existingId);
|
|
701
|
+
await emptySlot(ifid, slot);
|
|
702
|
+
});
|
|
735
703
|
|
|
736
704
|
// --- Session Persistence (survives F5, cleared on tab close) ---
|
|
737
705
|
|
|
@@ -774,14 +742,9 @@ export function clearSession(ifid: string): void {
|
|
|
774
742
|
|
|
775
743
|
// --- Export / Import ---
|
|
776
744
|
|
|
777
|
-
export
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
|
|
782
|
-
const record = await (await getBackend()).getSave(saveId);
|
|
745
|
+
/** The export of a stored save, if there is one. */
|
|
746
|
+
function toExport(record: SaveRecord | undefined): SaveExport | undefined {
|
|
783
747
|
if (!record) return undefined;
|
|
784
|
-
|
|
785
748
|
return {
|
|
786
749
|
version: 1,
|
|
787
750
|
ifid: record.meta.ifid,
|
|
@@ -790,6 +753,10 @@ async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
|
|
|
790
753
|
};
|
|
791
754
|
}
|
|
792
755
|
|
|
756
|
+
export const exportSave = queued(async (saveId: string) =>
|
|
757
|
+
toExport(await (await getBackend()).getSave(saveId)),
|
|
758
|
+
);
|
|
759
|
+
|
|
793
760
|
/**
|
|
794
761
|
* Validate an export against the running story and build the record to store:
|
|
795
762
|
* a fresh save ID, and the playthrough record created if it doesn't exist yet.
|
|
@@ -813,8 +780,6 @@ async function prepareImport(
|
|
|
813
780
|
throw new Error('Invalid save file format');
|
|
814
781
|
}
|
|
815
782
|
|
|
816
|
-
const backend = await getBackend();
|
|
817
|
-
|
|
818
783
|
// Re-assign a new ID to avoid collisions
|
|
819
784
|
const record = deepClone(data.save);
|
|
820
785
|
record.meta.id = crypto.randomUUID();
|
|
@@ -823,19 +788,11 @@ async function prepareImport(
|
|
|
823
788
|
// with it, whatever its own metadata says
|
|
824
789
|
record.meta.ifid = ifid;
|
|
825
790
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
const pt: PlaythroughRecord = {
|
|
832
|
-
id: record.meta.playthroughId,
|
|
833
|
-
ifid,
|
|
834
|
-
createdAt: record.meta.createdAt,
|
|
835
|
-
label: 'Imported',
|
|
836
|
-
};
|
|
837
|
-
await backend.putPlaythrough(pt);
|
|
838
|
-
}
|
|
791
|
+
await ensurePlaythrough(
|
|
792
|
+
ifid,
|
|
793
|
+
record.meta.playthroughId,
|
|
794
|
+
record.meta.createdAt,
|
|
795
|
+
);
|
|
839
796
|
|
|
840
797
|
return record;
|
|
841
798
|
}
|
|
@@ -855,18 +812,9 @@ export function importSave(
|
|
|
855
812
|
* Export the save held in a slot (default autosave slot when `slot` is omitted).
|
|
856
813
|
* Returns undefined if the slot is empty.
|
|
857
814
|
*/
|
|
858
|
-
export
|
|
859
|
-
ifid
|
|
860
|
-
|
|
861
|
-
): Promise<SaveExport | undefined> {
|
|
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
|
-
});
|
|
869
|
-
}
|
|
815
|
+
export const exportSlotSave = queued(async (ifid: string, slot?: string) =>
|
|
816
|
+
toExport(await slotRecord(ifid, slot)),
|
|
817
|
+
);
|
|
870
818
|
|
|
871
819
|
/**
|
|
872
820
|
* Import an exported save into a slot (default autosave slot when `slot` is
|
|
@@ -879,25 +827,19 @@ export function importSlotSave(
|
|
|
879
827
|
ifid: string,
|
|
880
828
|
slot?: string,
|
|
881
829
|
): Promise<SaveInfo> {
|
|
882
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
883
830
|
return inOrder(async () => {
|
|
884
831
|
const record = await prepareImport(data, ifid);
|
|
885
832
|
|
|
886
833
|
// The slot keys in `custom` describe where the save lives, so they follow
|
|
887
834
|
// the target slot rather than the slot the save was exported from.
|
|
888
835
|
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
889
|
-
record.meta.custom = {
|
|
890
|
-
...custom,
|
|
891
|
-
isAutosave: !isNamedSlot(slot),
|
|
892
|
-
...(isNamedSlot(slot) ? { slot } : {}),
|
|
893
|
-
};
|
|
836
|
+
record.meta.custom = { ...custom, ...slotCustom(slot) };
|
|
894
837
|
|
|
895
838
|
const backend = await getBackend();
|
|
896
|
-
const previousId = await
|
|
839
|
+
const previousId = await slotSaveId(ifid, slot);
|
|
897
840
|
|
|
898
841
|
await backend.putSave(record);
|
|
899
|
-
await
|
|
900
|
-
await addToSlotIndex(ifid, slot);
|
|
842
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
901
843
|
if (previousId) await backend.deleteSave(previousId);
|
|
902
844
|
|
|
903
845
|
return toSaveInfo(record, slot);
|
|
@@ -906,11 +848,9 @@ export function importSlotSave(
|
|
|
906
848
|
|
|
907
849
|
// --- Storage Management ---
|
|
908
850
|
|
|
909
|
-
export
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
|
|
851
|
+
export const getStorageInfo = queued(async function getStorageInfoNow(
|
|
852
|
+
ifid: string,
|
|
853
|
+
): Promise<StorageInfo> {
|
|
914
854
|
const backend = await getBackend();
|
|
915
855
|
const saves = await backend.getSavesByIfid(ifid);
|
|
916
856
|
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
@@ -921,7 +861,7 @@ async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
|
|
|
921
861
|
totalBytes += save.meta.estimatedBytes;
|
|
922
862
|
} else {
|
|
923
863
|
// Lazy backfill for saves created before estimatedBytes was added
|
|
924
|
-
const bytes =
|
|
864
|
+
const bytes = estimatePayloadBytes(save.payload);
|
|
925
865
|
save.meta.estimatedBytes = bytes;
|
|
926
866
|
await backend.putSave(save);
|
|
927
867
|
totalBytes += bytes;
|
|
@@ -934,7 +874,7 @@ async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
|
|
|
934
874
|
totalBytes,
|
|
935
875
|
backend: backend.type,
|
|
936
876
|
};
|
|
937
|
-
}
|
|
877
|
+
});
|
|
938
878
|
|
|
939
879
|
export function clearGameData(ifid: string): Promise<void> {
|
|
940
880
|
return inOrder(async () => {
|
|
@@ -946,9 +886,7 @@ export function clearGameData(ifid: string): Promise<void> {
|
|
|
946
886
|
});
|
|
947
887
|
}
|
|
948
888
|
|
|
949
|
-
export
|
|
950
|
-
return inOrder(clearAllDataNow);
|
|
951
|
-
}
|
|
889
|
+
export const clearAllData = queued(clearAllDataNow);
|
|
952
890
|
|
|
953
891
|
async function clearAllDataNow(): Promise<void> {
|
|
954
892
|
const backend = await getBackend();
|
|
@@ -988,14 +926,7 @@ export function deletePlaythroughData(
|
|
|
988
926
|
|
|
989
927
|
// Clear the slots that held deleted saves (pointer and slot index)
|
|
990
928
|
const deletedSet = new Set(deletedSaveIds);
|
|
991
|
-
|
|
992
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
993
|
-
const value = await backend.getMeta<string>(metaKey);
|
|
994
|
-
if (value && deletedSet.has(value)) {
|
|
995
|
-
await backend.deleteMeta(metaKey);
|
|
996
|
-
await removeFromSlotIndex(ifid, slot);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
929
|
+
await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
|
|
999
930
|
|
|
1000
931
|
if (replacement && (await replacement.current) === playthroughId) {
|
|
1001
932
|
await startNewPlaythroughNow(ifid, replacement.id);
|