@rohal12/spindle 0.44.0 → 0.45.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/story-variables.js +125 -0
- package/dist/pkg/tooling.js +2 -0
- package/package.json +2 -1
- package/src/components/App.tsx +6 -4
- package/src/components/PassageDialog.tsx +10 -1
- package/src/components/TriggerDialogHost.tsx +1 -0
- package/src/components/macros/MenubarButtons.tsx +17 -6
- package/src/saves/save-manager.ts +88 -19
- package/src/store.ts +52 -1
- package/src/story-api.ts +36 -2
- package/src/triggers.ts +1 -0
- package/src/types-drift-check.ts +10 -0
- package/types/index.d.ts +82 -1
- package/types/tooling.d.ts +68 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
//#region src/story-variables.ts
|
|
2
|
+
function declarationRegex(sigil) {
|
|
3
|
+
const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
|
|
5
|
+
}
|
|
6
|
+
var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
|
|
7
|
+
var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
8
|
+
var VALID_VAR_TYPES = new Set([
|
|
9
|
+
"number",
|
|
10
|
+
"string",
|
|
11
|
+
"boolean"
|
|
12
|
+
]);
|
|
13
|
+
function inferSchema(value) {
|
|
14
|
+
if (Array.isArray(value)) return { type: "array" };
|
|
15
|
+
if (value !== null && typeof value === "object") {
|
|
16
|
+
const fields = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
|
|
18
|
+
return {
|
|
19
|
+
type: "object",
|
|
20
|
+
fields
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const jsType = typeof value;
|
|
24
|
+
if (!VALID_VAR_TYPES.has(jsType)) throw new Error(`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`);
|
|
25
|
+
return { type: jsType };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a StoryVariables or StoryTransients passage content into a schema map.
|
|
29
|
+
* Each line: `$varName = expression` (or `%varName = expression` for transients)
|
|
30
|
+
*/
|
|
31
|
+
function parseStoryVariables(content, sigil = "$") {
|
|
32
|
+
const schema = /* @__PURE__ */ new Map();
|
|
33
|
+
const DECLARATION_RE = declarationRegex(sigil);
|
|
34
|
+
const passageName = sigil === "%" ? "StoryTransients" : "StoryVariables";
|
|
35
|
+
for (const rawLine of content.split("\n")) {
|
|
36
|
+
const line = rawLine.trim();
|
|
37
|
+
if (!line) continue;
|
|
38
|
+
const match = line.match(DECLARATION_RE);
|
|
39
|
+
if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
|
|
40
|
+
const [, name, expr] = match;
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
value = new Function("return (" + expr + ")")();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`);
|
|
46
|
+
}
|
|
47
|
+
let fieldSchema;
|
|
48
|
+
try {
|
|
49
|
+
fieldSchema = inferSchema(value);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`${passageName}: ${err instanceof Error ? err.message : err}`);
|
|
52
|
+
}
|
|
53
|
+
schema.set(name, {
|
|
54
|
+
...fieldSchema,
|
|
55
|
+
name,
|
|
56
|
+
default: value
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return schema;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extract for-loop local variable names from passage content.
|
|
63
|
+
* `{for @item of ...}` → "item"
|
|
64
|
+
* `{for @index, @item of ...}` → "index", "item"
|
|
65
|
+
*/
|
|
66
|
+
function extractForLocals(content) {
|
|
67
|
+
const locals = /* @__PURE__ */ new Set();
|
|
68
|
+
let match;
|
|
69
|
+
FOR_LOCAL_RE.lastIndex = 0;
|
|
70
|
+
while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
|
|
71
|
+
locals.add(match[1]);
|
|
72
|
+
if (match[2]) locals.add(match[2]);
|
|
73
|
+
}
|
|
74
|
+
return locals;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate a single variable reference path (e.g. "player.health") against
|
|
78
|
+
* the schema. Returns an error message or null if valid.
|
|
79
|
+
*/
|
|
80
|
+
function validateRef(ref, schema, forLocals) {
|
|
81
|
+
const parts = ref.split(".");
|
|
82
|
+
const rootName = parts[0];
|
|
83
|
+
if (forLocals.has(rootName)) return null;
|
|
84
|
+
const rootSchema = schema.get(rootName);
|
|
85
|
+
if (!rootSchema) return `Undeclared variable: $${ref}`;
|
|
86
|
+
let current = rootSchema;
|
|
87
|
+
for (let i = 1; i < parts.length; i++) {
|
|
88
|
+
const part = parts[i];
|
|
89
|
+
if (current.type === "array") return null;
|
|
90
|
+
if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
|
|
91
|
+
const fieldSchema = current.fields.get(part);
|
|
92
|
+
if (!fieldSchema) return null;
|
|
93
|
+
current = fieldSchema;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Scan all passages for $var references, check against schema.
|
|
99
|
+
* Returns list of error messages (empty = valid).
|
|
100
|
+
*/
|
|
101
|
+
function validatePassages(passages, schema) {
|
|
102
|
+
const errors = [];
|
|
103
|
+
for (const [name, passage] of passages) {
|
|
104
|
+
if (name === "StoryVariables" || name === "StoryTransients") continue;
|
|
105
|
+
const forLocals = extractForLocals(passage.content);
|
|
106
|
+
let match;
|
|
107
|
+
VAR_REF_RE.lastIndex = 0;
|
|
108
|
+
while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
|
|
109
|
+
const ref = match[1];
|
|
110
|
+
const error = validateRef(ref, schema, forLocals);
|
|
111
|
+
if (error) errors.push(`Passage "${name}": ${error}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return errors;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extract default values from the schema as a plain object.
|
|
118
|
+
*/
|
|
119
|
+
function extractDefaults(schema) {
|
|
120
|
+
const defaults = {};
|
|
121
|
+
for (const [name, varSchema] of schema) defaults[name] = varSchema.default;
|
|
122
|
+
return defaults;
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
export { extractDefaults, parseStoryVariables, validatePassages };
|
package/dist/pkg/tooling.js
CHANGED
|
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
+
export { parseStoryVariables } from './story-variables.js';
|
|
6
|
+
|
|
5
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
8
|
|
|
7
9
|
// Load built-in macro metadata generated at build time
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dist/pkg/index.js",
|
|
36
36
|
"dist/pkg/format.js",
|
|
37
37
|
"dist/pkg/tooling.js",
|
|
38
|
+
"dist/pkg/story-variables.js",
|
|
38
39
|
"dist/pkg/macro-registry.json",
|
|
39
40
|
"types",
|
|
40
41
|
"src"
|
package/src/components/App.tsx
CHANGED
|
@@ -11,12 +11,14 @@ export function App() {
|
|
|
11
11
|
|
|
12
12
|
useEffect(() => {
|
|
13
13
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
14
|
-
|
|
14
|
+
const { quickSaveKey, quickLoadKey, save, load } =
|
|
15
|
+
useStoryStore.getState();
|
|
16
|
+
if (quickSaveKey !== null && e.key === quickSaveKey) {
|
|
15
17
|
e.preventDefault();
|
|
16
|
-
|
|
17
|
-
} else if (e.key ===
|
|
18
|
+
save();
|
|
19
|
+
} else if (quickLoadKey !== null && e.key === quickLoadKey) {
|
|
18
20
|
e.preventDefault();
|
|
19
|
-
|
|
21
|
+
load();
|
|
20
22
|
}
|
|
21
23
|
};
|
|
22
24
|
document.addEventListener('keydown', onKeyDown);
|
|
@@ -12,7 +12,14 @@ interface PassageDialogProps {
|
|
|
12
12
|
fallbackMarkup?: string;
|
|
13
13
|
panelClass?: string;
|
|
14
14
|
onClose: () => void;
|
|
15
|
+
/** Show the default `✕` button. Defaults to `dismissible`. */
|
|
15
16
|
showCloseButton?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* When `false`, the player cannot dismiss the dialog: backdrop clicks are
|
|
19
|
+
* ignored and the `✕` button is hidden (unless `showCloseButton` is set
|
|
20
|
+
* explicitly). The dialog can still be closed programmatically.
|
|
21
|
+
*/
|
|
22
|
+
dismissible?: boolean;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function PassageDialog({
|
|
@@ -20,7 +27,8 @@ export function PassageDialog({
|
|
|
20
27
|
fallbackMarkup,
|
|
21
28
|
panelClass,
|
|
22
29
|
onClose,
|
|
23
|
-
|
|
30
|
+
dismissible = true,
|
|
31
|
+
showCloseButton = dismissible,
|
|
24
32
|
}: PassageDialogProps) {
|
|
25
33
|
// Stabilize onClose so DialogCloseContext value doesn't change identity
|
|
26
34
|
const onCloseRef = useRef(onClose);
|
|
@@ -52,6 +60,7 @@ export function PassageDialog({
|
|
|
52
60
|
}, [markup]);
|
|
53
61
|
|
|
54
62
|
const handleBackdrop = (e: MouseEvent) => {
|
|
63
|
+
if (!dismissible) return;
|
|
55
64
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
|
56
65
|
stableOnClose();
|
|
57
66
|
}
|
|
@@ -13,7 +13,7 @@ interface MenubarActionConfig {
|
|
|
13
13
|
title?: string;
|
|
14
14
|
confirm?: string;
|
|
15
15
|
hidden?: () => boolean;
|
|
16
|
-
setup?: () => { perform: () => void; disabled?: boolean };
|
|
16
|
+
setup?: () => { perform: () => void; disabled?: boolean; title?: string };
|
|
17
17
|
dialog?: {
|
|
18
18
|
passageName: string;
|
|
19
19
|
fallbackMarkup?: string;
|
|
@@ -22,6 +22,10 @@ interface MenubarActionConfig {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function hotkeyTitle(label: string, key: string | null): string {
|
|
26
|
+
return key ? `${label} (${key})` : label;
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
export function defineMenubarAction(config: MenubarActionConfig) {
|
|
26
30
|
defineMacro({
|
|
27
31
|
name: config.name,
|
|
@@ -31,7 +35,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
|
|
|
31
35
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
32
36
|
|
|
33
37
|
const setup = config.setup?.() ?? { perform: () => {} };
|
|
34
|
-
const { disabled } = setup;
|
|
38
|
+
const { disabled, title = config.title } = setup;
|
|
35
39
|
|
|
36
40
|
const cls = ctx.className
|
|
37
41
|
? `menubar-button ${ctx.className}`
|
|
@@ -61,7 +65,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
|
|
|
61
65
|
class={cls}
|
|
62
66
|
onClick={perform}
|
|
63
67
|
disabled={disabled}
|
|
64
|
-
title={
|
|
68
|
+
title={title}
|
|
65
69
|
>
|
|
66
70
|
{config.label}
|
|
67
71
|
</button>
|
|
@@ -104,9 +108,12 @@ defineMenubarAction({
|
|
|
104
108
|
name: 'quicksave',
|
|
105
109
|
label: 'QuickSave',
|
|
106
110
|
actionType: 'save',
|
|
107
|
-
title: 'Quick Save (F6)',
|
|
108
111
|
setup: () => ({
|
|
109
112
|
perform: useStoryStore((s) => s.save),
|
|
113
|
+
title: hotkeyTitle(
|
|
114
|
+
'Quick Save',
|
|
115
|
+
useStoryStore((s) => s.quickSaveKey),
|
|
116
|
+
),
|
|
110
117
|
}),
|
|
111
118
|
});
|
|
112
119
|
|
|
@@ -114,13 +121,17 @@ defineMenubarAction({
|
|
|
114
121
|
name: 'quickload',
|
|
115
122
|
label: 'QuickLoad',
|
|
116
123
|
actionType: 'load',
|
|
117
|
-
title: 'Quick Load (F9)',
|
|
118
124
|
confirm: 'Load saved game? Current progress will be lost.',
|
|
119
125
|
setup: () => {
|
|
120
126
|
const load = useStoryStore((s) => s.load);
|
|
121
127
|
const hasSave = useStoryStore((s) => s.hasSave);
|
|
128
|
+
const key = useStoryStore((s) => s.quickLoadKey);
|
|
122
129
|
useStoryStore((s) => s.knownSaves);
|
|
123
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
perform: () => load(),
|
|
132
|
+
disabled: !hasSave(),
|
|
133
|
+
title: hotkeyTitle('Quick Load', key),
|
|
134
|
+
};
|
|
124
135
|
},
|
|
125
136
|
});
|
|
126
137
|
|
|
@@ -273,6 +273,28 @@ function slotMetaKey(ifid: string, slot?: string): string {
|
|
|
273
273
|
: `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/** Record a named slot in the per-story slot index (no-op for the default slot). */
|
|
277
|
+
async function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
|
|
278
|
+
if (slot == null) return;
|
|
279
|
+
const backend = await getBackend();
|
|
280
|
+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
281
|
+
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
282
|
+
if (!existing.includes(slot)) {
|
|
283
|
+
await backend.setMeta(indexKey, [...existing, slot]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
|
|
288
|
+
return {
|
|
289
|
+
slot: slot ?? '',
|
|
290
|
+
title: record.meta.title,
|
|
291
|
+
passage: record.meta.passage,
|
|
292
|
+
createdAt: record.meta.createdAt,
|
|
293
|
+
updatedAt: record.meta.updatedAt,
|
|
294
|
+
custom: record.meta.custom ?? {},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
276
298
|
export async function quickSave(
|
|
277
299
|
ifid: string,
|
|
278
300
|
playthroughId: string,
|
|
@@ -296,15 +318,7 @@ export async function quickSave(
|
|
|
296
318
|
...custom,
|
|
297
319
|
});
|
|
298
320
|
await backend.setMeta(metaKey, record.meta.id);
|
|
299
|
-
|
|
300
|
-
// Track named slots in the index
|
|
301
|
-
if (slot != null) {
|
|
302
|
-
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
303
|
-
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
304
|
-
if (!existing.includes(slot)) {
|
|
305
|
-
await backend.setMeta(indexKey, [...existing, slot]);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
321
|
+
await addToSlotIndex(ifid, slot);
|
|
308
322
|
|
|
309
323
|
return record;
|
|
310
324
|
}
|
|
@@ -371,14 +385,7 @@ export async function getSlotSaveInfo(
|
|
|
371
385
|
if (!existingId) return null;
|
|
372
386
|
const record = await backend.getSave(existingId);
|
|
373
387
|
if (!record) return null;
|
|
374
|
-
return
|
|
375
|
-
slot: slot ?? '',
|
|
376
|
-
title: record.meta.title,
|
|
377
|
-
passage: record.meta.passage,
|
|
378
|
-
createdAt: record.meta.createdAt,
|
|
379
|
-
updatedAt: record.meta.updatedAt,
|
|
380
|
-
custom: record.meta.custom ?? {},
|
|
381
|
-
};
|
|
388
|
+
return toSaveInfo(record, slot);
|
|
382
389
|
}
|
|
383
390
|
|
|
384
391
|
/**
|
|
@@ -487,7 +494,12 @@ export async function exportSave(
|
|
|
487
494
|
};
|
|
488
495
|
}
|
|
489
496
|
|
|
490
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Validate an export against the running story and build the record to store:
|
|
499
|
+
* a fresh save ID, and the playthrough record created if it doesn't exist yet.
|
|
500
|
+
* The returned record is not stored yet.
|
|
501
|
+
*/
|
|
502
|
+
async function prepareImport(
|
|
491
503
|
data: SaveExport,
|
|
492
504
|
ifid: string,
|
|
493
505
|
): Promise<SaveRecord> {
|
|
@@ -521,10 +533,67 @@ export async function importSave(
|
|
|
521
533
|
await backend.putPlaythrough(pt);
|
|
522
534
|
}
|
|
523
535
|
|
|
524
|
-
await backend.putSave(record);
|
|
525
536
|
return record;
|
|
526
537
|
}
|
|
527
538
|
|
|
539
|
+
export async function importSave(
|
|
540
|
+
data: SaveExport,
|
|
541
|
+
ifid: string,
|
|
542
|
+
): Promise<SaveRecord> {
|
|
543
|
+
const record = await prepareImport(data, ifid);
|
|
544
|
+
await (await getBackend()).putSave(record);
|
|
545
|
+
return record;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Export the save held in a slot (default autosave slot when `slot` is omitted).
|
|
550
|
+
* Returns undefined if the slot is empty.
|
|
551
|
+
*/
|
|
552
|
+
export async function exportSlotSave(
|
|
553
|
+
ifid: string,
|
|
554
|
+
slot?: string,
|
|
555
|
+
): Promise<SaveExport | undefined> {
|
|
556
|
+
const saveId = await (
|
|
557
|
+
await getBackend()
|
|
558
|
+
).getMeta<string>(slotMetaKey(ifid, slot));
|
|
559
|
+
if (!saveId) return undefined;
|
|
560
|
+
return exportSave(saveId);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Import an exported save into a slot (default autosave slot when `slot` is
|
|
565
|
+
* omitted), replacing any save already in that slot. Applies the same
|
|
566
|
+
* validation as `importSave` (version, IFID) and keeps the playthrough record
|
|
567
|
+
* and slot index consistent.
|
|
568
|
+
*/
|
|
569
|
+
export async function importSlotSave(
|
|
570
|
+
data: SaveExport,
|
|
571
|
+
ifid: string,
|
|
572
|
+
slot?: string,
|
|
573
|
+
): Promise<SaveInfo> {
|
|
574
|
+
const record = await prepareImport(data, ifid);
|
|
575
|
+
|
|
576
|
+
// The slot keys in `custom` describe where the save lives, so they follow
|
|
577
|
+
// the target slot rather than the slot the save was exported from.
|
|
578
|
+
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
579
|
+
record.meta.custom = {
|
|
580
|
+
...custom,
|
|
581
|
+
isAutosave: !slot,
|
|
582
|
+
...(slot != null ? { slot } : {}),
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const backend = await getBackend();
|
|
586
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
587
|
+
const previousId = await backend.getMeta<string>(metaKey);
|
|
588
|
+
|
|
589
|
+
await backend.putSave(record);
|
|
590
|
+
await backend.setMeta(metaKey, record.meta.id);
|
|
591
|
+
await addToSlotIndex(ifid, slot);
|
|
592
|
+
if (previousId) await backend.deleteSave(previousId);
|
|
593
|
+
|
|
594
|
+
return toSaveInfo(record, slot);
|
|
595
|
+
}
|
|
596
|
+
|
|
528
597
|
// --- Storage Management ---
|
|
529
598
|
|
|
530
599
|
export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
|
package/src/store.ts
CHANGED
|
@@ -8,7 +8,13 @@ import {
|
|
|
8
8
|
} from 'immer';
|
|
9
9
|
import type { StoryData } from './parser';
|
|
10
10
|
import type { TransitionConfig } from './transition';
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
SavePayload,
|
|
13
|
+
SaveHistoryMoment,
|
|
14
|
+
SaveInfo,
|
|
15
|
+
SaveExport,
|
|
16
|
+
} from './saves/types';
|
|
17
|
+
import { isSaveExport } from './saves/types';
|
|
12
18
|
import { executeStoryInit } from './story-init';
|
|
13
19
|
import { emit } from './event-emitter';
|
|
14
20
|
import { resetTriggers } from './triggers';
|
|
@@ -22,6 +28,8 @@ import {
|
|
|
22
28
|
getSlotSaveInfo,
|
|
23
29
|
listSlotSaves,
|
|
24
30
|
deleteSlotSave,
|
|
31
|
+
exportSlotSave,
|
|
32
|
+
importSlotSave,
|
|
25
33
|
saveSession,
|
|
26
34
|
clearSession,
|
|
27
35
|
clearGameData as smClearGameData,
|
|
@@ -218,6 +226,11 @@ export function _resetRuntimePhase(): void {
|
|
|
218
226
|
// Store
|
|
219
227
|
// ---------------------------------------------------------------------------
|
|
220
228
|
|
|
229
|
+
/** A hotkey is a `KeyboardEvent.key` value; anything else disables it. */
|
|
230
|
+
function normalizeHotkey(key: unknown): string | null {
|
|
231
|
+
return typeof key === 'string' && key !== '' ? key : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
222
235
|
function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
223
236
|
if (moment?.prng) {
|
|
@@ -248,6 +261,8 @@ export interface StoryState {
|
|
|
248
261
|
knownSaves: Record<string, true>;
|
|
249
262
|
playthroughId: string;
|
|
250
263
|
maxHistory: number;
|
|
264
|
+
quickSaveKey: string | null;
|
|
265
|
+
quickLoadKey: string | null;
|
|
251
266
|
saveError: string | null;
|
|
252
267
|
loadError: string | null;
|
|
253
268
|
transitionConfig: TransitionConfig | null;
|
|
@@ -256,6 +271,8 @@ export interface StoryState {
|
|
|
256
271
|
renderDeferred: boolean;
|
|
257
272
|
|
|
258
273
|
setMaxHistory: (limit: number) => void;
|
|
274
|
+
setQuickSaveKey: (key: string | null) => void;
|
|
275
|
+
setQuickLoadKey: (key: string | null) => void;
|
|
259
276
|
init: (
|
|
260
277
|
storyData: StoryData,
|
|
261
278
|
variableDefaults?: Record<string, unknown>,
|
|
@@ -278,6 +295,8 @@ export interface StoryState {
|
|
|
278
295
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
279
296
|
listSaves: () => Promise<SaveInfo[]>;
|
|
280
297
|
deleteSave: (slot?: string) => void;
|
|
298
|
+
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
299
|
+
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
281
300
|
clearGameData: () => void;
|
|
282
301
|
clearAllData: () => void;
|
|
283
302
|
deletePlaythrough: (playthroughId: string) => void;
|
|
@@ -307,6 +326,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
307
326
|
knownSaves: {},
|
|
308
327
|
playthroughId: '',
|
|
309
328
|
maxHistory: 40,
|
|
329
|
+
quickSaveKey: 'F6',
|
|
330
|
+
quickLoadKey: 'F9',
|
|
310
331
|
saveError: null,
|
|
311
332
|
loadError: null,
|
|
312
333
|
transitionConfig: null,
|
|
@@ -320,6 +341,18 @@ export const useStoryStore = create<StoryState>()(
|
|
|
320
341
|
});
|
|
321
342
|
},
|
|
322
343
|
|
|
344
|
+
setQuickSaveKey: (key: string | null) => {
|
|
345
|
+
set((state) => {
|
|
346
|
+
state.quickSaveKey = normalizeHotkey(key);
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
setQuickLoadKey: (key: string | null) => {
|
|
351
|
+
set((state) => {
|
|
352
|
+
state.quickLoadKey = normalizeHotkey(key);
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
|
|
323
356
|
init: (
|
|
324
357
|
storyData: StoryData,
|
|
325
358
|
variableDefaults: Record<string, unknown> = {},
|
|
@@ -697,6 +730,24 @@ export const useStoryStore = create<StoryState>()(
|
|
|
697
730
|
});
|
|
698
731
|
},
|
|
699
732
|
|
|
733
|
+
exportSave: async (slot?: string): Promise<SaveExport | null> => {
|
|
734
|
+
const { storyData } = get();
|
|
735
|
+
if (!storyData) return null;
|
|
736
|
+
return (await exportSlotSave(storyData.ifid, slot)) ?? null;
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
importSave: async (data: unknown, slot?: string): Promise<SaveInfo> => {
|
|
740
|
+
const { storyData } = get();
|
|
741
|
+
if (!storyData) throw new Error('spindle: Story is not initialized.');
|
|
742
|
+
if (!isSaveExport(data)) throw new Error('Invalid save file format');
|
|
743
|
+
|
|
744
|
+
const info = await importSlotSave(data, storyData.ifid, slot);
|
|
745
|
+
set((state) => {
|
|
746
|
+
state.knownSaves = { ...state.knownSaves, [slot ?? '']: true };
|
|
747
|
+
});
|
|
748
|
+
return info;
|
|
749
|
+
},
|
|
750
|
+
|
|
700
751
|
clearGameData: () => {
|
|
701
752
|
const { storyData } = get();
|
|
702
753
|
if (!storyData) return;
|
package/src/story-api.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { settings } from './settings';
|
|
|
10
10
|
import type {
|
|
11
11
|
SavePayload,
|
|
12
12
|
SaveInfo,
|
|
13
|
+
SaveExport,
|
|
13
14
|
StorageInfo,
|
|
14
15
|
StorageQuota,
|
|
15
16
|
} from './saves/types';
|
|
@@ -158,6 +159,8 @@ export interface StoryAPI {
|
|
|
158
159
|
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
|
|
159
160
|
listSaves(): Promise<SaveInfo[]>;
|
|
160
161
|
deleteSave(slot?: string): void;
|
|
162
|
+
exportSave(slot?: string): Promise<SaveExport | null>;
|
|
163
|
+
importSave(data: unknown, slot?: string): Promise<SaveInfo>;
|
|
161
164
|
visited(name?: string): number;
|
|
162
165
|
hasVisited(name?: string): boolean;
|
|
163
166
|
hasVisitedAny(...names: string[]): boolean;
|
|
@@ -199,7 +202,11 @@ export interface StoryAPI {
|
|
|
199
202
|
unwatch(name: string): void;
|
|
200
203
|
openDialog(
|
|
201
204
|
passageName: string,
|
|
202
|
-
options?: {
|
|
205
|
+
options?: {
|
|
206
|
+
panelClass?: string;
|
|
207
|
+
showCloseButton?: boolean;
|
|
208
|
+
dismissible?: boolean;
|
|
209
|
+
},
|
|
203
210
|
): void;
|
|
204
211
|
closeDialog(): void;
|
|
205
212
|
closeAllDialogs(): void;
|
|
@@ -214,6 +221,8 @@ export interface StoryAPI {
|
|
|
214
221
|
randomInt(min: number, max: number): number;
|
|
215
222
|
readonly config: {
|
|
216
223
|
maxHistory: number;
|
|
224
|
+
quickSaveKey: string | null;
|
|
225
|
+
quickLoadKey: string | null;
|
|
217
226
|
};
|
|
218
227
|
readonly prng: {
|
|
219
228
|
init(seed?: string, useEntropy?: boolean): void;
|
|
@@ -304,6 +313,14 @@ function createStoryAPI(): StoryAPI {
|
|
|
304
313
|
useStoryStore.getState().deleteSave(slot);
|
|
305
314
|
},
|
|
306
315
|
|
|
316
|
+
exportSave(slot?: string): Promise<SaveExport | null> {
|
|
317
|
+
return useStoryStore.getState().exportSave(slot);
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
importSave(data: unknown, slot?: string): Promise<SaveInfo> {
|
|
321
|
+
return useStoryStore.getState().importSave(data, slot);
|
|
322
|
+
},
|
|
323
|
+
|
|
307
324
|
visited(name?: string): number {
|
|
308
325
|
const state = useStoryStore.getState();
|
|
309
326
|
return state.visitCounts[name ?? state.currentPassage] ?? 0;
|
|
@@ -494,12 +511,17 @@ function createStoryAPI(): StoryAPI {
|
|
|
494
511
|
|
|
495
512
|
openDialog(
|
|
496
513
|
passageName: string,
|
|
497
|
-
options?: {
|
|
514
|
+
options?: {
|
|
515
|
+
panelClass?: string;
|
|
516
|
+
showCloseButton?: boolean;
|
|
517
|
+
dismissible?: boolean;
|
|
518
|
+
},
|
|
498
519
|
): void {
|
|
499
520
|
pushDialog({
|
|
500
521
|
passageName,
|
|
501
522
|
panelClass: options?.panelClass,
|
|
502
523
|
showCloseButton: options?.showCloseButton,
|
|
524
|
+
dismissible: options?.dismissible,
|
|
503
525
|
});
|
|
504
526
|
},
|
|
505
527
|
|
|
@@ -562,6 +584,18 @@ function createStoryAPI(): StoryAPI {
|
|
|
562
584
|
set maxHistory(limit: number) {
|
|
563
585
|
useStoryStore.getState().setMaxHistory(limit);
|
|
564
586
|
},
|
|
587
|
+
get quickSaveKey(): string | null {
|
|
588
|
+
return useStoryStore.getState().quickSaveKey;
|
|
589
|
+
},
|
|
590
|
+
set quickSaveKey(key: string | null) {
|
|
591
|
+
useStoryStore.getState().setQuickSaveKey(key);
|
|
592
|
+
},
|
|
593
|
+
get quickLoadKey(): string | null {
|
|
594
|
+
return useStoryStore.getState().quickLoadKey;
|
|
595
|
+
},
|
|
596
|
+
set quickLoadKey(key: string | null) {
|
|
597
|
+
useStoryStore.getState().setQuickLoadKey(key);
|
|
598
|
+
},
|
|
565
599
|
},
|
|
566
600
|
|
|
567
601
|
prng: {
|
package/src/triggers.ts
CHANGED
package/src/types-drift-check.ts
CHANGED
|
@@ -13,3 +13,13 @@ import type { StoryAPI as PublishedAPI } from '../types/index';
|
|
|
13
13
|
const _sourceToPublished: PublishedAPI = {} as SourceAPI;
|
|
14
14
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
15
15
|
const _publishedToSource: SourceAPI = {} as PublishedAPI;
|
|
16
|
+
|
|
17
|
+
// Tooling entry point (`@rohal12/spindle/tooling`): types/tooling.d.ts must
|
|
18
|
+
// match the parser that dist/pkg/tooling.js re-exports.
|
|
19
|
+
import type { parseStoryVariables as SourceParse } from './story-variables';
|
|
20
|
+
import type { parseStoryVariables as PublishedParse } from '../types/tooling';
|
|
21
|
+
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
23
|
+
const _parseSourceToPublished: typeof PublishedParse = {} as typeof SourceParse;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
25
|
+
const _parsePublishedToSource: typeof SourceParse = {} as typeof PublishedParse;
|