@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- 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 +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- 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 +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- 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 +30 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- 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 +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- 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
|
-
|
|
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
|
|
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(
|
|
125
|
+
await backend.setMeta(playthroughCountKey(ifid), num);
|
|
126
|
+
await backend.setMeta(currentPlaythroughKey(ifid), id);
|
|
89
127
|
return id;
|
|
90
128
|
}
|
|
91
129
|
|
|
92
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
213
|
-
saveId
|
|
214
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
if ((await backend.getMeta<string>(metaKey)) !== saveId)
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
268
|
-
ifid
|
|
269
|
-
|
|
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
|
-
/**
|
|
349
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
452
|
-
ifid
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
594
|
-
saveId
|
|
595
|
-
|
|
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
|
|
838
|
+
export function importSave(
|
|
655
839
|
data: SaveExport,
|
|
656
840
|
ifid: string,
|
|
657
841
|
): Promise<SaveRecord> {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
853
|
+
export function exportSlotSave(
|
|
668
854
|
ifid: string,
|
|
669
855
|
slot?: string,
|
|
670
856
|
): Promise<SaveExport | undefined> {
|
|
671
|
-
|
|
672
|
-
await
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
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
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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(
|
|
990
|
+
await backend.deleteMeta(metaKey);
|
|
991
|
+
await removeFromSlotIndex(ifid, slot);
|
|
782
992
|
}
|
|
783
993
|
}
|
|
784
|
-
}
|
|
785
994
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
}
|