@rohal12/spindle 0.31.3 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/saves/save-manager.ts +75 -1
- package/src/saves/types.ts +10 -0
- package/src/store.ts +39 -4
- package/src/story-api.ts +19 -4
- package/types/index.d.ts +30 -2
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
SavePayload,
|
|
3
3
|
SaveMeta,
|
|
4
4
|
SaveRecord,
|
|
5
|
+
SaveInfo,
|
|
5
6
|
PlaythroughRecord,
|
|
6
7
|
SaveExport,
|
|
7
8
|
} from './types';
|
|
@@ -134,6 +135,7 @@ export async function createSave(
|
|
|
134
135
|
export async function overwriteSave(
|
|
135
136
|
saveId: string,
|
|
136
137
|
payload: SavePayload,
|
|
138
|
+
custom?: Record<string, unknown>,
|
|
137
139
|
): Promise<SaveRecord | undefined> {
|
|
138
140
|
const existing = await getSave(saveId);
|
|
139
141
|
if (!existing) return undefined;
|
|
@@ -149,6 +151,9 @@ export async function overwriteSave(
|
|
|
149
151
|
...existing.meta,
|
|
150
152
|
updatedAt: new Date().toISOString(),
|
|
151
153
|
passage: payload.passage,
|
|
154
|
+
...(custom != null
|
|
155
|
+
? { custom: { ...existing.meta.custom, ...custom } }
|
|
156
|
+
: {}),
|
|
152
157
|
},
|
|
153
158
|
payload: serializedPayload,
|
|
154
159
|
};
|
|
@@ -275,12 +280,13 @@ export async function quickSave(
|
|
|
275
280
|
playthroughId: string,
|
|
276
281
|
payload: SavePayload,
|
|
277
282
|
slot?: string,
|
|
283
|
+
custom?: Record<string, unknown>,
|
|
278
284
|
): Promise<SaveRecord> {
|
|
279
285
|
const metaKey = slotMetaKey(ifid, slot);
|
|
280
286
|
const existingId = await getMeta<string>(metaKey);
|
|
281
287
|
|
|
282
288
|
if (existingId) {
|
|
283
|
-
const updated = await overwriteSave(existingId, payload);
|
|
289
|
+
const updated = await overwriteSave(existingId, payload, custom);
|
|
284
290
|
if (updated) return updated;
|
|
285
291
|
}
|
|
286
292
|
|
|
@@ -288,6 +294,7 @@ export async function quickSave(
|
|
|
288
294
|
const record = await createSave(ifid, playthroughId, payload, {
|
|
289
295
|
isAutosave: !slot,
|
|
290
296
|
...(slot != null ? { slot } : {}),
|
|
297
|
+
...custom,
|
|
291
298
|
});
|
|
292
299
|
await setMeta(metaKey, record.meta.id);
|
|
293
300
|
|
|
@@ -350,6 +357,73 @@ export async function populateKnownSaves(
|
|
|
350
357
|
return result;
|
|
351
358
|
}
|
|
352
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Get metadata for a specific save slot.
|
|
362
|
+
* Returns null if no save exists for that slot.
|
|
363
|
+
*/
|
|
364
|
+
export async function getSlotSaveInfo(
|
|
365
|
+
ifid: string,
|
|
366
|
+
slot?: string,
|
|
367
|
+
): Promise<SaveInfo | null> {
|
|
368
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
369
|
+
const existingId = await getMeta<string>(metaKey);
|
|
370
|
+
if (!existingId) return null;
|
|
371
|
+
const record = await getSave(existingId);
|
|
372
|
+
if (!record) return null;
|
|
373
|
+
return {
|
|
374
|
+
slot: slot ?? '',
|
|
375
|
+
title: record.meta.title,
|
|
376
|
+
passage: record.meta.passage,
|
|
377
|
+
createdAt: record.meta.createdAt,
|
|
378
|
+
updatedAt: record.meta.updatedAt,
|
|
379
|
+
custom: record.meta.custom ?? {},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* List metadata for all known save slots (default + named).
|
|
385
|
+
*/
|
|
386
|
+
export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
|
|
387
|
+
const result: SaveInfo[] = [];
|
|
388
|
+
|
|
389
|
+
// Check default autosave
|
|
390
|
+
const defaultInfo = await getSlotSaveInfo(ifid);
|
|
391
|
+
if (defaultInfo) result.push(defaultInfo);
|
|
392
|
+
|
|
393
|
+
// Check named slots from the index
|
|
394
|
+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
395
|
+
const slots = (await getMeta<string[]>(indexKey)) ?? [];
|
|
396
|
+
for (const slot of slots) {
|
|
397
|
+
const info = await getSlotSaveInfo(ifid, slot);
|
|
398
|
+
if (info) result.push(info);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Delete a save by slot name. Removes from slot index if named.
|
|
406
|
+
*/
|
|
407
|
+
export async function deleteSlotSave(
|
|
408
|
+
ifid: string,
|
|
409
|
+
slot?: string,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
412
|
+
const existingId = await getMeta<string>(metaKey);
|
|
413
|
+
if (!existingId) return;
|
|
414
|
+
|
|
415
|
+
await idbDeleteSave(existingId);
|
|
416
|
+
await setMeta(metaKey, undefined);
|
|
417
|
+
|
|
418
|
+
// Remove from slot index if named
|
|
419
|
+
if (slot != null) {
|
|
420
|
+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
421
|
+
const existing = (await getMeta<string[]>(indexKey)) ?? [];
|
|
422
|
+
const updated = existing.filter((s) => s !== slot);
|
|
423
|
+
await setMeta(indexKey, updated);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
353
427
|
// --- Session Persistence (survives F5, cleared on tab close) ---
|
|
354
428
|
|
|
355
429
|
const SESSION_KEY_PREFIX = 'spindle.session.';
|
package/src/saves/types.ts
CHANGED
|
@@ -41,6 +41,16 @@ export interface PlaythroughRecord {
|
|
|
41
41
|
label: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Public-facing save metadata for the Story API. */
|
|
45
|
+
export interface SaveInfo {
|
|
46
|
+
slot: string;
|
|
47
|
+
title: string;
|
|
48
|
+
passage: string;
|
|
49
|
+
createdAt: string;
|
|
50
|
+
updatedAt: string;
|
|
51
|
+
custom: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
export interface SaveExport {
|
|
45
55
|
version: 1;
|
|
46
56
|
ifid: string;
|
package/src/store.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from 'immer';
|
|
9
9
|
import type { StoryData } from './parser';
|
|
10
10
|
import type { TransitionConfig } from './transition';
|
|
11
|
-
import type { SavePayload, SaveHistoryMoment } from './saves/types';
|
|
11
|
+
import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types';
|
|
12
12
|
import { executeStoryInit } from './story-init';
|
|
13
13
|
import { resetTriggers } from './triggers';
|
|
14
14
|
import {
|
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
quickSave,
|
|
19
19
|
loadQuickSave,
|
|
20
20
|
populateKnownSaves,
|
|
21
|
+
getSlotSaveInfo,
|
|
22
|
+
listSlotSaves,
|
|
23
|
+
deleteSlotSave,
|
|
21
24
|
saveSession,
|
|
22
25
|
clearSession,
|
|
23
26
|
} from './saves/save-manager';
|
|
@@ -222,9 +225,12 @@ export interface StoryState {
|
|
|
222
225
|
deleteTemporary: (name: string) => void;
|
|
223
226
|
trackRender: (passageName: string) => void;
|
|
224
227
|
restart: () => void;
|
|
225
|
-
save: (slot?: string) => void;
|
|
228
|
+
save: (slot?: string, custom?: Record<string, unknown>) => void;
|
|
226
229
|
load: (slot?: string) => void;
|
|
227
230
|
hasSave: (slot?: string) => boolean;
|
|
231
|
+
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
232
|
+
listSaves: () => Promise<SaveInfo[]>;
|
|
233
|
+
deleteSave: (slot?: string) => void;
|
|
228
234
|
getSavePayload: () => SavePayload;
|
|
229
235
|
loadFromPayload: (payload: SavePayload) => void;
|
|
230
236
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
@@ -496,7 +502,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
496
502
|
);
|
|
497
503
|
},
|
|
498
504
|
|
|
499
|
-
save: (slot?: string) => {
|
|
505
|
+
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
500
506
|
const { storyData, playthroughId } = get();
|
|
501
507
|
if (!storyData) return;
|
|
502
508
|
|
|
@@ -505,7 +511,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
505
511
|
set((state) => {
|
|
506
512
|
state.saveError = null;
|
|
507
513
|
});
|
|
508
|
-
quickSave(storyData.ifid, playthroughId, payload, slot)
|
|
514
|
+
quickSave(storyData.ifid, playthroughId, payload, slot, custom)
|
|
509
515
|
.then(() => {
|
|
510
516
|
set((state) => {
|
|
511
517
|
state.knownSaves = {
|
|
@@ -550,6 +556,35 @@ export const useStoryStore = create<StoryState>()(
|
|
|
550
556
|
return (slot ?? '') in knownSaves;
|
|
551
557
|
},
|
|
552
558
|
|
|
559
|
+
getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
|
|
560
|
+
const { storyData } = get();
|
|
561
|
+
if (!storyData) return null;
|
|
562
|
+
return getSlotSaveInfo(storyData.ifid, slot);
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
listSaves: async (): Promise<SaveInfo[]> => {
|
|
566
|
+
const { storyData } = get();
|
|
567
|
+
if (!storyData) return [];
|
|
568
|
+
return listSlotSaves(storyData.ifid);
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
deleteSave: (slot?: string) => {
|
|
572
|
+
const { storyData } = get();
|
|
573
|
+
if (!storyData) return;
|
|
574
|
+
|
|
575
|
+
deleteSlotSave(storyData.ifid, slot)
|
|
576
|
+
.then(() => {
|
|
577
|
+
set((state) => {
|
|
578
|
+
const key = slot ?? '';
|
|
579
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
580
|
+
state.knownSaves = rest as Record<string, true>;
|
|
581
|
+
});
|
|
582
|
+
})
|
|
583
|
+
.catch((err) => {
|
|
584
|
+
console.error('spindle: failed to delete save', err);
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
|
|
553
588
|
getSavePayload: (): SavePayload => {
|
|
554
589
|
const {
|
|
555
590
|
currentPassage,
|
package/src/story-api.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from './store';
|
|
2
2
|
import type { Passage } from './parser';
|
|
3
3
|
import { settings } from './settings';
|
|
4
|
-
import type { SavePayload } from './saves/types';
|
|
4
|
+
import type { SavePayload, SaveInfo } from './saves/types';
|
|
5
5
|
import { setTitleGenerator } from './saves/save-manager';
|
|
6
6
|
import { registerClass } from './class-registry';
|
|
7
7
|
import { defineMacro } from './define-macro';
|
|
@@ -48,9 +48,12 @@ export interface StoryAPI {
|
|
|
48
48
|
back(): void;
|
|
49
49
|
forward(): void;
|
|
50
50
|
restart(): void;
|
|
51
|
-
save(slot?: string): void;
|
|
51
|
+
save(slot?: string, custom?: Record<string, unknown>): void;
|
|
52
52
|
load(slot?: string): void;
|
|
53
53
|
hasSave(slot?: string): boolean;
|
|
54
|
+
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
|
|
55
|
+
listSaves(): Promise<SaveInfo[]>;
|
|
56
|
+
deleteSave(slot?: string): void;
|
|
54
57
|
visited(name?: string): number;
|
|
55
58
|
hasVisited(name?: string): boolean;
|
|
56
59
|
hasVisitedAny(...names: string[]): boolean;
|
|
@@ -137,8 +140,8 @@ function createStoryAPI(): StoryAPI {
|
|
|
137
140
|
useStoryStore.getState().restart();
|
|
138
141
|
},
|
|
139
142
|
|
|
140
|
-
save(slot?: string): void {
|
|
141
|
-
useStoryStore.getState().save(slot);
|
|
143
|
+
save(slot?: string, custom?: Record<string, unknown>): void {
|
|
144
|
+
useStoryStore.getState().save(slot, custom);
|
|
142
145
|
},
|
|
143
146
|
|
|
144
147
|
load(slot?: string): void {
|
|
@@ -149,6 +152,18 @@ function createStoryAPI(): StoryAPI {
|
|
|
149
152
|
return useStoryStore.getState().hasSave(slot);
|
|
150
153
|
},
|
|
151
154
|
|
|
155
|
+
getSaveInfo(slot?: string): Promise<SaveInfo | null> {
|
|
156
|
+
return useStoryStore.getState().getSaveInfo(slot);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
listSaves(): Promise<SaveInfo[]> {
|
|
160
|
+
return useStoryStore.getState().listSaves();
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
deleteSave(slot?: string): void {
|
|
164
|
+
useStoryStore.getState().deleteSave(slot);
|
|
165
|
+
},
|
|
166
|
+
|
|
152
167
|
visited(name?: string): number {
|
|
153
168
|
const state = useStoryStore.getState();
|
|
154
169
|
return state.visitCounts[name ?? state.currentPassage] ?? 0;
|
package/types/index.d.ts
CHANGED
|
@@ -102,6 +102,25 @@ export interface Passage {
|
|
|
102
102
|
content: string;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Metadata about a save slot, returned by `getSaveInfo()` and `listSaves()`.
|
|
107
|
+
* @see {@link ../../src/saves/types.ts} for the implementation.
|
|
108
|
+
*/
|
|
109
|
+
export interface SaveInfo {
|
|
110
|
+
/** Slot name (empty string for the default autosave slot). */
|
|
111
|
+
slot: string;
|
|
112
|
+
/** Save title (generated or custom). */
|
|
113
|
+
title: string;
|
|
114
|
+
/** Passage name at the time of saving. */
|
|
115
|
+
passage: string;
|
|
116
|
+
/** ISO 8601 timestamp when the save was first created. */
|
|
117
|
+
createdAt: string;
|
|
118
|
+
/** ISO 8601 timestamp when the save was last updated. */
|
|
119
|
+
updatedAt: string;
|
|
120
|
+
/** Custom metadata passed when saving. */
|
|
121
|
+
custom: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
/**
|
|
106
125
|
* The main Story API available as `window.Story` at runtime.
|
|
107
126
|
* Provides access to variables, navigation, save/load, and visit tracking.
|
|
@@ -128,8 +147,8 @@ export interface StoryAPI {
|
|
|
128
147
|
/** Restart the story from the beginning. */
|
|
129
148
|
restart(): void;
|
|
130
149
|
|
|
131
|
-
/** Save the current state
|
|
132
|
-
save(slot?: string): void;
|
|
150
|
+
/** Save the current state. Pass `slot` for a named save, `custom` for metadata. */
|
|
151
|
+
save(slot?: string, custom?: Record<string, unknown>): void;
|
|
133
152
|
|
|
134
153
|
/** Load a saved state (quick load). */
|
|
135
154
|
load(slot?: string): void;
|
|
@@ -137,6 +156,15 @@ export interface StoryAPI {
|
|
|
137
156
|
/** Check whether a save exists. */
|
|
138
157
|
hasSave(slot?: string): boolean;
|
|
139
158
|
|
|
159
|
+
/** Get metadata for a specific save slot. Returns null if no save exists. */
|
|
160
|
+
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
|
|
161
|
+
|
|
162
|
+
/** List metadata for all known save slots. */
|
|
163
|
+
listSaves(): Promise<SaveInfo[]>;
|
|
164
|
+
|
|
165
|
+
/** Delete a save by slot name. */
|
|
166
|
+
deleteSave(slot?: string): void;
|
|
167
|
+
|
|
140
168
|
/** Return the number of times a passage has been visited. */
|
|
141
169
|
visited(name: string): number;
|
|
142
170
|
|