@rohal12/spindle 0.51.3 → 0.52.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.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
@@ -69,14 +69,51 @@ export async function initSaveSystem(): Promise<void> {
69
69
  initialized = true;
70
70
  }
71
71
 
72
+ // --- Operation order ---
73
+
74
+ /** Tail of the queue every storage operation runs through. */
75
+ let operationQueue: Promise<unknown> = Promise.resolve();
76
+
77
+ /**
78
+ * Run a storage operation once every operation issued before it has
79
+ * finished, so operations take effect in the order they are called. Each
80
+ * one reads and writes several records (a slot's pointer, the save, the
81
+ * slot index, playthrough records) as a unit; run concurrently they would
82
+ * interleave: a delete issued after a save could run before it, a rename
83
+ * could write a save's old payload back over an overwrite, or a playthrough
84
+ * deletion could miss a save being written.
85
+ *
86
+ * Exported operations queue themselves; the `...Now` helpers they share run
87
+ * inside an operation and must not be called from outside one. An operation
88
+ * must never wait for another operation queued after it.
89
+ */
90
+ function inOrder<T>(op: () => Promise<T>): Promise<T> {
91
+ const run = operationQueue.then(op);
92
+ operationQueue = run.catch(() => {});
93
+ return run;
94
+ }
95
+
72
96
  // --- Playthroughs ---
73
97
 
74
- export async function startNewPlaythrough(ifid: string): Promise<string> {
98
+ /**
99
+ * Store a new playthrough and make it the story's current one. Pass `id` when
100
+ * the caller has already switched to the playthrough (restart() does, so
101
+ * saves issued before this resolves are tagged with it).
102
+ */
103
+ export function startNewPlaythrough(
104
+ ifid: string,
105
+ id: string = crypto.randomUUID(),
106
+ ): Promise<string> {
107
+ return inOrder(() => startNewPlaythroughNow(ifid, id));
108
+ }
109
+
110
+ async function startNewPlaythroughNow(
111
+ ifid: string,
112
+ id: string,
113
+ ): Promise<string> {
75
114
  const backend = await getBackend();
76
- const existing = await backend.getPlaythroughsByIfid(ifid);
77
- const num = existing.length + 1;
115
+ const num = await nextPlaythroughNumber(ifid);
78
116
 
79
- const id = crypto.randomUUID();
80
117
  const record: PlaythroughRecord = {
81
118
  id,
82
119
  ifid,
@@ -85,14 +122,90 @@ export async function startNewPlaythrough(ifid: string): Promise<string> {
85
122
  };
86
123
 
87
124
  await backend.putPlaythrough(record);
88
- await backend.setMeta(`currentPlaythroughId.${ifid}`, id);
125
+ await backend.setMeta(playthroughCountKey(ifid), num);
126
+ await backend.setMeta(currentPlaythroughKey(ifid), id);
89
127
  return id;
90
128
  }
91
129
 
92
- export async function getCurrentPlaythroughId(
130
+ const PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
131
+
132
+ function playthroughCountKey(ifid: string): string {
133
+ return `playthroughCount.${ifid}`;
134
+ }
135
+
136
+ /**
137
+ * The number of the next playthrough the story starts: one past the highest
138
+ * number given so far. The count is stored, so deleting a playthrough never
139
+ * frees its number for another (two groups labelled alike); the highest
140
+ * existing label covers data stored before the count was. Imported
141
+ * playthroughs take no number.
142
+ */
143
+ async function nextPlaythroughNumber(ifid: string): Promise<number> {
144
+ const backend = await getBackend();
145
+ let highest = (await backend.getMeta<number>(playthroughCountKey(ifid))) ?? 0;
146
+ for (const pt of await backend.getPlaythroughsByIfid(ifid)) {
147
+ const match = PLAYTHROUGH_LABEL.exec(pt.label);
148
+ if (match) highest = Math.max(highest, Number(match[1]));
149
+ }
150
+ return highest + 1;
151
+ }
152
+
153
+ function currentPlaythroughKey(ifid: string): string {
154
+ return `currentPlaythroughId.${ifid}`;
155
+ }
156
+
157
+ export function getCurrentPlaythroughId(
93
158
  ifid: string,
94
159
  ): Promise<string | undefined> {
95
- return (await getBackend()).getMeta<string>(`currentPlaythroughId.${ifid}`);
160
+ return inOrder(async () =>
161
+ (await getBackend()).getMeta<string>(currentPlaythroughKey(ifid)),
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Make `id` the story's current playthrough: the playthrough of a save the
167
+ * game has loaded. A no-op if it already is. A playthrough without a record
168
+ * in this browser (that of an imported save whose record was deleted since,
169
+ * or of a save a dialog still showed after its playthrough was deleted) is
170
+ * recorded again as "Imported", as importing the save would: it was not
171
+ * started here, so it takes no number.
172
+ */
173
+ export function adoptPlaythrough(ifid: string, id: string): Promise<void> {
174
+ return inOrder(() => adoptPlaythroughNow(ifid, id));
175
+ }
176
+
177
+ async function adoptPlaythroughNow(ifid: string, id: string): Promise<void> {
178
+ const backend = await getBackend();
179
+ const playthroughs = await backend.getPlaythroughsByIfid(ifid);
180
+ if (!playthroughs.some((p) => p.id === id)) {
181
+ await backend.putPlaythrough({
182
+ id,
183
+ ifid,
184
+ createdAt: new Date().toISOString(),
185
+ label: 'Imported',
186
+ });
187
+ }
188
+ const key = currentPlaythroughKey(ifid);
189
+ if ((await backend.getMeta<string>(key)) !== id) {
190
+ await backend.setMeta(key, id);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Set up the save system for a story that boots: the story's current
196
+ * playthrough (a new one if it has none) and the slots holding saves (see
197
+ * populateKnownSaves), looked up together in one operation.
198
+ */
199
+ export function establishPlaythrough(
200
+ ifid: string,
201
+ ): Promise<{ id: string; knownSaves: Record<string, true> }> {
202
+ return inOrder(async () => {
203
+ const backend = await getBackend();
204
+ const id =
205
+ (await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
206
+ (await startNewPlaythroughNow(ifid, crypto.randomUUID()));
207
+ return { id, knownSaves: await populateKnownSavesNow(ifid) };
208
+ });
96
209
  }
97
210
 
98
211
  // --- Save Hooks ---
@@ -132,11 +245,20 @@ export function saveWithHooks<T>(
132
245
 
133
246
  // --- Save CRUD ---
134
247
 
135
- export async function createSave(
248
+ export function createSave(
136
249
  ifid: string,
137
250
  playthroughId: string,
138
251
  payload: SavePayload,
139
252
  custom: Record<string, unknown> = {},
253
+ ): Promise<SaveRecord> {
254
+ return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
255
+ }
256
+
257
+ async function createSaveNow(
258
+ ifid: string,
259
+ playthroughId: string,
260
+ payload: SavePayload,
261
+ custom: Record<string, unknown>,
140
262
  ): Promise<SaveRecord> {
141
263
  const now = new Date().toISOString();
142
264
  const meta: SaveMeta = {
@@ -162,10 +284,37 @@ export async function createSave(
162
284
  return record;
163
285
  }
164
286
 
165
- export async function overwriteSave(
287
+ /**
288
+ * Replace the payload of an existing save, keeping its ID and `createdAt`.
289
+ * Pass the current `playthroughId` when the new payload comes from the
290
+ * running game: the save then holds that playthrough's state, so it is
291
+ * grouped and deleted with it rather than with the playthrough that first
292
+ * created the save.
293
+ *
294
+ * Metadata describing the content follows the new payload: `passage`,
295
+ * `updatedAt` and the title. A generated title (SaveTitle passage, title
296
+ * generator or the default "passage - time") is generated again, exactly as
297
+ * for a fresh save, so it never names a passage or state the save no longer
298
+ * holds. A title the player gave the save (renameSave) is kept: it names the
299
+ * save itself rather than describing its content. `custom` is merged, as
300
+ * before, so slot keys and metadata not passed again are kept.
301
+ */
302
+ export function overwriteSave(
303
+ saveId: string,
304
+ payload: SavePayload,
305
+ custom?: Record<string, unknown>,
306
+ playthroughId?: string,
307
+ ): Promise<SaveRecord | undefined> {
308
+ return inOrder(() =>
309
+ overwriteSaveNow(saveId, payload, custom, playthroughId),
310
+ );
311
+ }
312
+
313
+ async function overwriteSaveNow(
166
314
  saveId: string,
167
315
  payload: SavePayload,
168
316
  custom?: Record<string, unknown>,
317
+ playthroughId?: string,
169
318
  ): Promise<SaveRecord | undefined> {
170
319
  const backend = await getBackend();
171
320
  const existing = await backend.getSave(saveId);
@@ -180,7 +329,12 @@ export async function overwriteSave(
180
329
  const updated: SaveRecord = {
181
330
  meta: {
182
331
  ...existing.meta,
332
+ ...(playthroughId ? { playthroughId } : {}),
183
333
  updatedAt: new Date().toISOString(),
334
+ title:
335
+ existing.meta.userTitle === true
336
+ ? existing.meta.title
337
+ : generateTitle(payload),
184
338
  passage: payload.passage,
185
339
  ...(custom != null
186
340
  ? { custom: { ...existing.meta.custom, ...custom } }
@@ -209,9 +363,11 @@ export function deserializePayload(payload: SavePayload): SavePayload {
209
363
  };
210
364
  }
211
365
 
212
- export async function loadSave(
213
- saveId: string,
214
- ): Promise<SavePayload | undefined> {
366
+ export function loadSave(saveId: string): Promise<SavePayload | undefined> {
367
+ return inOrder(() => loadSaveNow(saveId));
368
+ }
369
+
370
+ async function loadSaveNow(saveId: string): Promise<SavePayload | undefined> {
215
371
  const record = await (await getBackend()).getSave(saveId);
216
372
  if (!record) return undefined;
217
373
  return deserializePayload(record.payload);
@@ -221,40 +377,40 @@ export async function loadSave(
221
377
  * Delete a save record. If the default slot or a named slot holds it, that
222
378
  * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
223
379
  */
224
- export async function deleteSaveById(saveId: string): Promise<void> {
225
- const backend = await getBackend();
226
- const record = await backend.getSave(saveId);
227
- await backend.deleteSave(saveId);
228
- if (!record) return;
229
-
230
- const ifid = record.meta.ifid;
231
- const slots = [undefined, ...(await getIndexedSlots(ifid))];
232
- for (const slot of slots) {
233
- const metaKey = slotMetaKey(ifid, slot);
234
- await withSlot(metaKey, async () => {
235
- if ((await backend.getMeta<string>(metaKey)) !== saveId) return;
380
+ export function deleteSaveById(saveId: string): Promise<void> {
381
+ return inOrder(async () => {
382
+ const backend = await getBackend();
383
+ const record = await backend.getSave(saveId);
384
+ await backend.deleteSave(saveId);
385
+ if (!record) return;
386
+
387
+ const ifid = record.meta.ifid;
388
+ const slots = [undefined, ...(await getIndexedSlots(ifid))];
389
+ for (const slot of slots) {
390
+ const metaKey = slotMetaKey(ifid, slot);
391
+ if ((await backend.getMeta<string>(metaKey)) !== saveId) continue;
236
392
  await backend.deleteMeta(metaKey);
237
393
  await removeFromSlotIndex(ifid, slot);
238
- });
239
- }
394
+ }
395
+ });
240
396
  }
241
397
 
242
- export async function renameSave(
243
- saveId: string,
244
- newTitle: string,
245
- ): Promise<void> {
246
- const backend = await getBackend();
247
- const record = await backend.getSave(saveId);
248
- if (!record) return;
249
- const updated: SaveRecord = {
250
- ...record,
251
- meta: {
252
- ...record.meta,
253
- title: newTitle,
254
- updatedAt: new Date().toISOString(),
255
- },
256
- };
257
- await backend.putSave(updated);
398
+ export function renameSave(saveId: string, newTitle: string): Promise<void> {
399
+ return inOrder(async () => {
400
+ const backend = await getBackend();
401
+ const record = await backend.getSave(saveId);
402
+ if (!record) return;
403
+ const updated: SaveRecord = {
404
+ ...record,
405
+ meta: {
406
+ ...record.meta,
407
+ title: newTitle,
408
+ userTitle: true,
409
+ updatedAt: new Date().toISOString(),
410
+ },
411
+ };
412
+ await backend.putSave(updated);
413
+ });
258
414
  }
259
415
 
260
416
  // --- Grouped Retrieval ---
@@ -264,9 +420,11 @@ export interface PlaythroughGroup {
264
420
  saves: SaveRecord[];
265
421
  }
266
422
 
267
- export async function getSavesGrouped(
268
- ifid: string,
269
- ): Promise<PlaythroughGroup[]> {
423
+ export function getSavesGrouped(ifid: string): Promise<PlaythroughGroup[]> {
424
+ return inOrder(() => getSavesGroupedNow(ifid));
425
+ }
426
+
427
+ async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
270
428
  const backend = await getBackend();
271
429
  const [allSaves, allPlaythroughs] = await Promise.all([
272
430
  backend.getSavesByIfid(ifid),
@@ -345,45 +503,16 @@ function slotMetaKey(ifid: string, slot?: string): string {
345
503
  : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
346
504
  }
347
505
 
348
- /** Tail of each slot's operation queue, keyed by the slot's meta key. */
349
- const slotQueues = new Map<string, Promise<unknown>>();
350
-
351
- /**
352
- * Run a complete operation on one slot (save, import, delete) after the ones
353
- * already queued for it. Each operation reads the slot pointer and writes the
354
- * record and pointer as a unit, so concurrent operations on the same slot
355
- * apply in call order instead of each creating its own record.
356
- */
357
- function withSlot<T>(metaKey: string, op: () => Promise<T>): Promise<T> {
358
- const run = (slotQueues.get(metaKey) ?? Promise.resolve()).then(op);
359
- const tail = run.catch(() => {});
360
- slotQueues.set(metaKey, tail);
361
- void tail.then(() => {
362
- if (slotQueues.get(metaKey) === tail) slotQueues.delete(metaKey);
363
- });
364
- return run;
365
- }
366
-
367
- /** Tail of the queue that serializes slot index updates. */
368
- let slotIndexQueue: Promise<unknown> = Promise.resolve();
369
-
370
- /**
371
- * Read-modify-write the per-story slot index. Updates are queued so that
372
- * concurrent saves, imports and deletes can't overwrite each other's change.
373
- */
374
- function updateSlotIndex(
506
+ /** Read-modify-write the per-story slot index. */
507
+ async function updateSlotIndex(
375
508
  ifid: string,
376
509
  update: (slots: string[]) => string[],
377
510
  ): Promise<void> {
378
- const run = slotIndexQueue.then(async () => {
379
- const backend = await getBackend();
380
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
381
- const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
382
- const updated = update(existing);
383
- if (updated !== existing) await backend.setMeta(indexKey, updated);
384
- });
385
- slotIndexQueue = run.catch(() => {});
386
- return run;
511
+ const backend = await getBackend();
512
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
513
+ const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
514
+ const updated = update(existing);
515
+ if (updated !== existing) await backend.setMeta(indexKey, updated);
387
516
  }
388
517
 
389
518
  /** Record a named slot in the per-story slot index (no-op for the default slot). */
@@ -418,25 +547,32 @@ function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
418
547
  };
419
548
  }
420
549
 
550
+ /**
551
+ * Save `payload` to a slot (the default slot when `slot` is omitted),
552
+ * overwriting the save it holds. `playthroughId` may be a promise of the ID
553
+ * (settled by operations issued before this call), so the save takes its
554
+ * place in the order of operations when it is called.
555
+ */
421
556
  export function quickSave(
422
557
  ifid: string,
423
- playthroughId: string,
558
+ playthroughId: string | PromiseLike<string>,
424
559
  payload: SavePayload,
425
560
  slot?: string,
426
561
  custom?: Record<string, unknown>,
427
562
  ): Promise<SaveRecord> {
428
563
  const metaKey = slotMetaKey(ifid, slot);
429
- return withSlot(metaKey, async () => {
564
+ return inOrder(async () => {
565
+ const ptId = await playthroughId;
430
566
  const backend = await getBackend();
431
567
  const existingId = await backend.getMeta<string>(metaKey);
432
568
 
433
569
  if (existingId) {
434
- const updated = await overwriteSave(existingId, payload, custom);
570
+ const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
435
571
  if (updated) return updated;
436
572
  }
437
573
 
438
574
  // Create new save
439
- const record = await createSave(ifid, playthroughId, payload, {
575
+ const record = await createSaveNow(ifid, ptId, payload, {
440
576
  isAutosave: !isNamedSlot(slot),
441
577
  ...(isNamedSlot(slot) ? { slot } : {}),
442
578
  ...custom,
@@ -448,10 +584,11 @@ export function quickSave(
448
584
  });
449
585
  }
450
586
 
451
- export async function hasQuickSave(
452
- ifid: string,
453
- slot?: string,
454
- ): Promise<boolean> {
587
+ export function hasQuickSave(ifid: string, slot?: string): Promise<boolean> {
588
+ return inOrder(() => hasQuickSaveNow(ifid, slot));
589
+ }
590
+
591
+ async function hasQuickSaveNow(ifid: string, slot?: string): Promise<boolean> {
455
592
  const backend = await getBackend();
456
593
  const metaKey = slotMetaKey(ifid, slot);
457
594
  const existingId = await backend.getMeta<string>(metaKey);
@@ -460,34 +597,67 @@ export async function hasQuickSave(
460
597
  return record !== undefined;
461
598
  }
462
599
 
463
- export async function loadQuickSave(
600
+ export function loadQuickSave(
464
601
  ifid: string,
465
602
  slot?: string,
466
603
  ): Promise<SavePayload | undefined> {
604
+ return inOrder(async () => {
605
+ const metaKey = slotMetaKey(ifid, slot);
606
+ const existingId = await (await getBackend()).getMeta<string>(metaKey);
607
+ if (!existingId) return undefined;
608
+ return loadSaveNow(existingId);
609
+ });
610
+ }
611
+
612
+ /**
613
+ * Read the save held in a slot (the default slot when `slot` is omitted) for
614
+ * the running game to load, and make its playthrough the story's current one
615
+ * (see adoptPlaythrough) in the same operation: operations issued after the
616
+ * load take effect in the loaded playthrough. Resolves to the live payload
617
+ * and its playthrough, or undefined if the slot is empty.
618
+ */
619
+ export function loadSlotSave(
620
+ ifid: string,
621
+ slot?: string,
622
+ ): Promise<{ payload: SavePayload; playthroughId: string } | undefined> {
467
623
  const metaKey = slotMetaKey(ifid, slot);
468
- const existingId = await (await getBackend()).getMeta<string>(metaKey);
469
- if (!existingId) return undefined;
470
- return loadSave(existingId);
624
+ return inOrder(async () => {
625
+ const backend = await getBackend();
626
+ const existingId = await backend.getMeta<string>(metaKey);
627
+ if (!existingId) return undefined;
628
+ const record = await backend.getSave(existingId);
629
+ if (!record) return undefined;
630
+ const payload = deserializePayload(record.payload);
631
+ const { playthroughId } = record.meta;
632
+ if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
633
+ return { payload, playthroughId };
634
+ });
471
635
  }
472
636
 
473
637
  /**
474
638
  * Check storage for all known saves and return a map of slot keys to true.
475
639
  * The default (autosave) slot uses empty string as key.
476
640
  */
477
- export async function populateKnownSaves(
641
+ export function populateKnownSaves(
642
+ ifid: string,
643
+ ): Promise<Record<string, true>> {
644
+ return inOrder(() => populateKnownSavesNow(ifid));
645
+ }
646
+
647
+ async function populateKnownSavesNow(
478
648
  ifid: string,
479
649
  ): Promise<Record<string, true>> {
480
650
  // No prototype, so a slot named '__proto__' is recorded as an entry
481
651
  const result = Object.create(null) as Record<string, true>;
482
652
 
483
653
  // Check default autosave
484
- if (await hasQuickSave(ifid)) {
654
+ if (await hasQuickSaveNow(ifid)) {
485
655
  result[''] = true;
486
656
  }
487
657
 
488
658
  // Check named slots from the index
489
659
  for (const slot of await getIndexedSlots(ifid)) {
490
- if (await hasQuickSave(ifid, slot)) {
660
+ if (await hasQuickSaveNow(ifid, slot)) {
491
661
  result[slot] = true;
492
662
  }
493
663
  }
@@ -499,7 +669,14 @@ export async function populateKnownSaves(
499
669
  * Get metadata for a specific save slot.
500
670
  * Returns null if no save exists for that slot.
501
671
  */
502
- export async function getSlotSaveInfo(
672
+ export function getSlotSaveInfo(
673
+ ifid: string,
674
+ slot?: string,
675
+ ): Promise<SaveInfo | null> {
676
+ return inOrder(() => getSlotSaveInfoNow(ifid, slot));
677
+ }
678
+
679
+ async function getSlotSaveInfoNow(
503
680
  ifid: string,
504
681
  slot?: string,
505
682
  ): Promise<SaveInfo | null> {
@@ -515,20 +692,22 @@ export async function getSlotSaveInfo(
515
692
  /**
516
693
  * List metadata for all known save slots (default + named).
517
694
  */
518
- export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
519
- const result: SaveInfo[] = [];
520
-
521
- // Check default autosave
522
- const defaultInfo = await getSlotSaveInfo(ifid);
523
- if (defaultInfo) result.push(defaultInfo);
524
-
525
- // Check named slots from the index
526
- for (const slot of await getIndexedSlots(ifid)) {
527
- const info = await getSlotSaveInfo(ifid, slot);
528
- if (info) result.push(info);
529
- }
695
+ export function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
696
+ return inOrder(async () => {
697
+ const result: SaveInfo[] = [];
698
+
699
+ // Check default autosave
700
+ const defaultInfo = await getSlotSaveInfoNow(ifid);
701
+ if (defaultInfo) result.push(defaultInfo);
702
+
703
+ // Check named slots from the index
704
+ for (const slot of await getIndexedSlots(ifid)) {
705
+ const info = await getSlotSaveInfoNow(ifid, slot);
706
+ if (info) result.push(info);
707
+ }
530
708
 
531
- return result;
709
+ return result;
710
+ });
532
711
  }
533
712
 
534
713
  /**
@@ -536,7 +715,7 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
536
715
  */
537
716
  export function deleteSlotSave(ifid: string, slot?: string): Promise<void> {
538
717
  const metaKey = slotMetaKey(ifid, slot);
539
- return withSlot(metaKey, async () => {
718
+ return inOrder(async () => {
540
719
  const backend = await getBackend();
541
720
  const existingId = await backend.getMeta<string>(metaKey);
542
721
  if (!existingId) return;
@@ -590,9 +769,11 @@ export function clearSession(ifid: string): void {
590
769
 
591
770
  // --- Export / Import ---
592
771
 
593
- export async function exportSave(
594
- saveId: string,
595
- ): Promise<SaveExport | undefined> {
772
+ export function exportSave(saveId: string): Promise<SaveExport | undefined> {
773
+ return inOrder(() => exportSaveNow(saveId));
774
+ }
775
+
776
+ async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
596
777
  const record = await (await getBackend()).getSave(saveId);
597
778
  if (!record) return undefined;
598
779
 
@@ -633,6 +814,9 @@ async function prepareImport(
633
814
  const record = deepClone(data.save);
634
815
  record.meta.id = crypto.randomUUID();
635
816
  record.meta.updatedAt = new Date().toISOString();
817
+ // The save now belongs to this story: it is listed, loaded and cleared
818
+ // with it, whatever its own metadata says
819
+ record.meta.ifid = ifid;
636
820
 
637
821
  // Ensure the playthrough exists
638
822
  const playthroughs = await backend.getPlaythroughsByIfid(ifid);
@@ -651,28 +835,32 @@ async function prepareImport(
651
835
  return record;
652
836
  }
653
837
 
654
- export async function importSave(
838
+ export function importSave(
655
839
  data: SaveExport,
656
840
  ifid: string,
657
841
  ): Promise<SaveRecord> {
658
- const record = await prepareImport(data, ifid);
659
- await (await getBackend()).putSave(record);
660
- return record;
842
+ return inOrder(async () => {
843
+ const record = await prepareImport(data, ifid);
844
+ await (await getBackend()).putSave(record);
845
+ return record;
846
+ });
661
847
  }
662
848
 
663
849
  /**
664
850
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
665
851
  * Returns undefined if the slot is empty.
666
852
  */
667
- export async function exportSlotSave(
853
+ export function exportSlotSave(
668
854
  ifid: string,
669
855
  slot?: string,
670
856
  ): Promise<SaveExport | undefined> {
671
- const saveId = await (
672
- await getBackend()
673
- ).getMeta<string>(slotMetaKey(ifid, slot));
674
- if (!saveId) return undefined;
675
- return exportSave(saveId);
857
+ return inOrder(async () => {
858
+ const saveId = await (
859
+ await getBackend()
860
+ ).getMeta<string>(slotMetaKey(ifid, slot));
861
+ if (!saveId) return undefined;
862
+ return exportSaveNow(saveId);
863
+ });
676
864
  }
677
865
 
678
866
  /**
@@ -687,7 +875,7 @@ export function importSlotSave(
687
875
  slot?: string,
688
876
  ): Promise<SaveInfo> {
689
877
  const metaKey = slotMetaKey(ifid, slot);
690
- return withSlot(metaKey, async () => {
878
+ return inOrder(async () => {
691
879
  const record = await prepareImport(data, ifid);
692
880
 
693
881
  // The slot keys in `custom` describe where the save lives, so they follow
@@ -713,7 +901,11 @@ export function importSlotSave(
713
901
 
714
902
  // --- Storage Management ---
715
903
 
716
- export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
904
+ export function getStorageInfo(ifid: string): Promise<StorageInfo> {
905
+ return inOrder(() => getStorageInfoNow(ifid));
906
+ }
907
+
908
+ async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
717
909
  const backend = await getBackend();
718
910
  const saves = await backend.getSavesByIfid(ifid);
719
911
  const playthroughs = await backend.getPlaythroughsByIfid(ifid);
@@ -739,15 +931,21 @@ export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
739
931
  };
740
932
  }
741
933
 
742
- export async function clearGameData(ifid: string): Promise<void> {
743
- const backend = await getBackend();
744
- await backend.deleteSavesByIfid(ifid);
745
- await backend.deletePlaythroughsByIfid(ifid);
746
- await backend.deleteMetaByIfid(ifid);
747
- clearSession(ifid);
934
+ export function clearGameData(ifid: string): Promise<void> {
935
+ return inOrder(async () => {
936
+ const backend = await getBackend();
937
+ await backend.deleteSavesByIfid(ifid);
938
+ await backend.deletePlaythroughsByIfid(ifid);
939
+ await backend.deleteMetaByIfid(ifid);
940
+ clearSession(ifid);
941
+ });
748
942
  }
749
943
 
750
- export async function clearAllData(): Promise<void> {
944
+ export function clearAllData(): Promise<void> {
945
+ return inOrder(clearAllDataNow);
946
+ }
947
+
948
+ async function clearAllDataNow(): Promise<void> {
751
949
  const backend = await getBackend();
752
950
  await backend.destroy();
753
951
  resetBackend();
@@ -763,30 +961,47 @@ export async function clearAllData(): Promise<void> {
763
961
  }
764
962
  }
765
963
 
766
- export async function deletePlaythroughData(
964
+ /**
965
+ * Delete a playthrough with its saves, emptying the slots that held them.
966
+ *
967
+ * `replacement` is for deleting the playthrough a running game is in:
968
+ * `current` is (a promise of) that game's playthrough, settled by operations
969
+ * issued before this call. If it is the deleted one, the playthrough
970
+ * `replacement.id` is started in the same operation, before any operation
971
+ * issued later. Resolves to whether it was.
972
+ */
973
+ export function deletePlaythroughData(
767
974
  ifid: string,
768
975
  playthroughId: string,
769
- ): Promise<void> {
770
- const backend = await getBackend();
771
- const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
772
- await backend.deletePlaythroughById(playthroughId);
773
-
774
- // Clean up slot/autosave meta keys pointing to deleted saves
775
- const deletedSet = new Set(deletedSaveIds);
776
- const allKeys = await backend.getAllMetaKeys();
777
- for (const key of allKeys) {
778
- if (key.startsWith('slot.') || key.startsWith('autosave.')) {
779
- const value = await backend.getMeta<string>(key);
976
+ replacement?: { current: string | PromiseLike<string>; id: string },
977
+ ): Promise<boolean> {
978
+ return inOrder(async () => {
979
+ const backend = await getBackend();
980
+ const deletedSaveIds =
981
+ await backend.deleteSavesByPlaythrough(playthroughId);
982
+ await backend.deletePlaythroughById(playthroughId);
983
+
984
+ // Clear the slots that held deleted saves (pointer and slot index)
985
+ const deletedSet = new Set(deletedSaveIds);
986
+ for (const slot of [undefined, ...(await getIndexedSlots(ifid))]) {
987
+ const metaKey = slotMetaKey(ifid, slot);
988
+ const value = await backend.getMeta<string>(metaKey);
780
989
  if (value && deletedSet.has(value)) {
781
- await backend.deleteMeta(key);
990
+ await backend.deleteMeta(metaKey);
991
+ await removeFromSlotIndex(ifid, slot);
782
992
  }
783
993
  }
784
- }
785
994
 
786
- // Clear currentPlaythroughId if it was this one
787
- const currentPtKey = `currentPlaythroughId.${ifid}`;
788
- const currentPt = await backend.getMeta<string>(currentPtKey);
789
- if (currentPt === playthroughId) {
790
- await backend.deleteMeta(currentPtKey);
791
- }
995
+ if (replacement && (await replacement.current) === playthroughId) {
996
+ await startNewPlaythroughNow(ifid, replacement.id);
997
+ return true;
998
+ }
999
+
1000
+ // Clear currentPlaythroughId if it was this one
1001
+ const currentPtKey = currentPlaythroughKey(ifid);
1002
+ if ((await backend.getMeta<string>(currentPtKey)) === playthroughId) {
1003
+ await backend.deleteMeta(currentPtKey);
1004
+ }
1005
+ return false;
1006
+ });
792
1007
  }