@rohal12/spindle 0.3.2 → 0.4.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/README.md +9 -0
- package/dist/pkg/format.js +1 -1
- package/package.json +8 -2
- package/src/automation/runner.ts +1 -1
- package/src/components/SaveLoadDialog.tsx +8 -3
- package/src/components/macros/Checkbox.tsx +2 -2
- package/src/components/macros/Computed.tsx +22 -17
- package/src/components/macros/For.tsx +12 -19
- package/src/components/macros/If.tsx +3 -15
- package/src/components/macros/MacroLink.tsx +3 -3
- package/src/components/macros/Meter.tsx +12 -22
- package/src/components/macros/Numberbox.tsx +1 -1
- package/src/components/macros/Print.tsx +3 -15
- package/src/components/macros/Radiobutton.tsx +5 -5
- package/src/components/macros/Switch.tsx +5 -15
- package/src/components/macros/Textarea.tsx +1 -1
- package/src/components/macros/Textbox.tsx +1 -1
- package/src/components/macros/Timed.tsx +13 -14
- package/src/components/macros/VarDisplay.tsx +3 -2
- package/src/expression.ts +82 -10
- package/src/hooks/use-merged-locals.ts +26 -0
- package/src/markup/ast.ts +12 -7
- package/src/markup/render.tsx +13 -6
- package/src/markup/tokenizer.ts +12 -12
- package/src/parser.ts +16 -1
- package/src/prng.ts +128 -0
- package/src/saves/save-manager.ts +25 -10
- package/src/saves/types.ts +31 -0
- package/src/settings.ts +26 -1
- package/src/store.ts +101 -35
- package/src/story-api.ts +63 -0
- package/src/story-variables.ts +18 -9
package/src/expression.ts
CHANGED
|
@@ -1,35 +1,88 @@
|
|
|
1
1
|
import type { StoryState } from './store';
|
|
2
2
|
import { useStoryStore } from './store';
|
|
3
|
+
import type { Passage } from './parser';
|
|
4
|
+
import { random, randomInt } from './prng';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
interface ExpressionFns {
|
|
7
|
+
currentPassage: () => Passage | undefined;
|
|
8
|
+
previousPassage: () => Passage | undefined;
|
|
9
|
+
visited: (name: string) => number;
|
|
10
|
+
hasVisited: (name: string) => boolean;
|
|
11
|
+
hasVisitedAny: (...names: string[]) => boolean;
|
|
12
|
+
hasVisitedAll: (...names: string[]) => boolean;
|
|
13
|
+
rendered: (name: string) => number;
|
|
14
|
+
hasRendered: (name: string) => boolean;
|
|
15
|
+
hasRenderedAny: (...names: string[]) => boolean;
|
|
16
|
+
hasRenderedAll: (...names: string[]) => boolean;
|
|
17
|
+
random: () => number;
|
|
18
|
+
randomInt: (min: number, max: number) => number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CompiledExpression = (
|
|
22
|
+
variables: Record<string, unknown>,
|
|
23
|
+
temporary: Record<string, unknown>,
|
|
24
|
+
__fns: ExpressionFns,
|
|
25
|
+
) => unknown;
|
|
26
|
+
|
|
27
|
+
const FN_CACHE_MAX = 500;
|
|
28
|
+
const fnCache = new Map<string, CompiledExpression>();
|
|
5
29
|
|
|
6
30
|
/**
|
|
7
31
|
* Transform expression: $var → variables["var"], _var → temporary["var"]
|
|
8
32
|
* Only transforms when $ or _ appears as a word boundary (not inside strings naively,
|
|
9
33
|
* but authors already have full JS access so this is acceptable).
|
|
10
34
|
*/
|
|
35
|
+
const VAR_RE = /\$(\w+)/g;
|
|
36
|
+
const TEMP_RE = /\b_(\w+)/g;
|
|
37
|
+
|
|
11
38
|
function transform(expr: string): string {
|
|
12
39
|
return expr
|
|
13
|
-
.replace(
|
|
14
|
-
.replace(
|
|
40
|
+
.replace(VAR_RE, 'variables["$1"]')
|
|
41
|
+
.replace(TEMP_RE, 'temporary["$1"]');
|
|
15
42
|
}
|
|
16
43
|
|
|
17
44
|
const preamble =
|
|
18
|
-
'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll}=__fns;';
|
|
45
|
+
'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
|
|
19
46
|
|
|
20
|
-
function getOrCompile(key: string, body: string):
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
fnCache.
|
|
47
|
+
function getOrCompile(key: string, body: string): CompiledExpression {
|
|
48
|
+
const cached = fnCache.get(key);
|
|
49
|
+
if (cached) {
|
|
50
|
+
// Move to end for LRU ordering (Map preserves insertion order)
|
|
51
|
+
fnCache.delete(key);
|
|
52
|
+
fnCache.set(key, cached);
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
const fn = new Function(
|
|
56
|
+
'variables',
|
|
57
|
+
'temporary',
|
|
58
|
+
'__fns',
|
|
59
|
+
preamble + body,
|
|
60
|
+
) as CompiledExpression;
|
|
61
|
+
fnCache.set(key, fn);
|
|
62
|
+
if (fnCache.size > FN_CACHE_MAX) {
|
|
63
|
+
// Evict oldest entry
|
|
64
|
+
const oldest = fnCache.keys().next().value;
|
|
65
|
+
if (oldest !== undefined) fnCache.delete(oldest);
|
|
25
66
|
}
|
|
26
67
|
return fn;
|
|
27
68
|
}
|
|
28
69
|
|
|
70
|
+
let cachedFns: ExpressionFns | null = null;
|
|
71
|
+
let cachedVisitCounts: Record<string, number> | null = null;
|
|
72
|
+
let cachedRenderCounts: Record<string, number> | null = null;
|
|
73
|
+
|
|
29
74
|
export function buildExpressionFns() {
|
|
30
75
|
const state = useStoryStore.getState();
|
|
31
76
|
const { visitCounts, renderCounts } = state;
|
|
32
77
|
|
|
78
|
+
if (
|
|
79
|
+
cachedFns &&
|
|
80
|
+
cachedVisitCounts === visitCounts &&
|
|
81
|
+
cachedRenderCounts === renderCounts
|
|
82
|
+
) {
|
|
83
|
+
return cachedFns;
|
|
84
|
+
}
|
|
85
|
+
|
|
33
86
|
const visited = (name: string): number => visitCounts[name] ?? 0;
|
|
34
87
|
const hasVisited = (name: string): boolean => visited(name) > 0;
|
|
35
88
|
const hasVisitedAny = (...names: string[]): boolean =>
|
|
@@ -44,7 +97,20 @@ export function buildExpressionFns() {
|
|
|
44
97
|
const hasRenderedAll = (...names: string[]): boolean =>
|
|
45
98
|
names.every((n) => rendered(n) > 0);
|
|
46
99
|
|
|
47
|
-
|
|
100
|
+
const currentPassage = (): Passage | undefined => {
|
|
101
|
+
const s = useStoryStore.getState();
|
|
102
|
+
return s.storyData?.passages.get(s.currentPassage);
|
|
103
|
+
};
|
|
104
|
+
const previousPassage = (): Passage | undefined => {
|
|
105
|
+
const s = useStoryStore.getState();
|
|
106
|
+
if (s.historyIndex <= 0) return undefined;
|
|
107
|
+
const prevName = s.history[s.historyIndex - 1]?.passage;
|
|
108
|
+
return prevName ? s.storyData?.passages.get(prevName) : undefined;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
cachedFns = {
|
|
112
|
+
currentPassage,
|
|
113
|
+
previousPassage,
|
|
48
114
|
visited,
|
|
49
115
|
hasVisited,
|
|
50
116
|
hasVisitedAny,
|
|
@@ -53,7 +119,13 @@ export function buildExpressionFns() {
|
|
|
53
119
|
hasRendered,
|
|
54
120
|
hasRenderedAny,
|
|
55
121
|
hasRenderedAll,
|
|
122
|
+
random,
|
|
123
|
+
randomInt,
|
|
56
124
|
};
|
|
125
|
+
cachedVisitCounts = visitCounts;
|
|
126
|
+
cachedRenderCounts = renderCounts;
|
|
127
|
+
|
|
128
|
+
return cachedFns;
|
|
57
129
|
}
|
|
58
130
|
|
|
59
131
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useContext, useMemo } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../store';
|
|
3
|
+
import { LocalsContext } from '../markup/render';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Merge store variables/temporary with LocalsContext values.
|
|
7
|
+
* Locals prefixed with `$` go into variables, `_` into temporary.
|
|
8
|
+
*/
|
|
9
|
+
export function useMergedLocals(): readonly [
|
|
10
|
+
Record<string, unknown>,
|
|
11
|
+
Record<string, unknown>,
|
|
12
|
+
] {
|
|
13
|
+
const variables = useStoryStore((s) => s.variables);
|
|
14
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
15
|
+
const locals = useContext(LocalsContext);
|
|
16
|
+
|
|
17
|
+
return useMemo(() => {
|
|
18
|
+
const vars = { ...variables };
|
|
19
|
+
const temps = { ...temporary };
|
|
20
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
21
|
+
if (key.startsWith('$')) vars[key.slice(1)] = val;
|
|
22
|
+
else if (key.startsWith('_')) temps[key.slice(1)] = val;
|
|
23
|
+
}
|
|
24
|
+
return [vars, temps] as const;
|
|
25
|
+
}, [variables, temporary, locals]);
|
|
26
|
+
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -87,10 +87,10 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
87
87
|
|
|
88
88
|
function current(): ASTNode[] {
|
|
89
89
|
if (stack.length === 0) return root;
|
|
90
|
-
const top = stack[stack.length - 1]
|
|
90
|
+
const top = stack[stack.length - 1]!.node;
|
|
91
91
|
// For if-blocks, append to the last branch's children
|
|
92
92
|
if (top.type === 'macro' && top.branches && top.branches.length > 0) {
|
|
93
|
-
return top.branches[top.branches.length - 1]
|
|
93
|
+
return top.branches[top.branches.length - 1]!.children;
|
|
94
94
|
}
|
|
95
95
|
return top.children;
|
|
96
96
|
}
|
|
@@ -145,7 +145,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
const top = stack[stack.length - 1]
|
|
148
|
+
const top = stack[stack.length - 1]!;
|
|
149
149
|
if (top.node.type !== 'html' || top.node.tag !== token.tag) {
|
|
150
150
|
const expected =
|
|
151
151
|
top.node.type === 'html'
|
|
@@ -181,7 +181,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
181
181
|
);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
const top = stack[stack.length - 1]
|
|
184
|
+
const top = stack[stack.length - 1]!;
|
|
185
185
|
if (top.node.type !== 'macro' || top.node.name !== token.name) {
|
|
186
186
|
const expected =
|
|
187
187
|
top.node.type === 'macro'
|
|
@@ -199,9 +199,9 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
199
199
|
|
|
200
200
|
// Handle branch macros (elseif/else, case/default, next)
|
|
201
201
|
if (BRANCH_PARENT[token.name]) {
|
|
202
|
-
const expectedParent = BRANCH_PARENT[token.name]
|
|
202
|
+
const expectedParent = BRANCH_PARENT[token.name]!;
|
|
203
203
|
const topNode =
|
|
204
|
-
stack.length > 0 ? stack[stack.length - 1]
|
|
204
|
+
stack.length > 0 ? stack[stack.length - 1]!.node : null;
|
|
205
205
|
if (
|
|
206
206
|
!topNode ||
|
|
207
207
|
topNode.type !== 'macro' ||
|
|
@@ -263,11 +263,16 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
263
263
|
}
|
|
264
264
|
break;
|
|
265
265
|
}
|
|
266
|
+
|
|
267
|
+
default: {
|
|
268
|
+
const _exhaustive: never = token;
|
|
269
|
+
throw new Error(`Unknown token type: ${(_exhaustive as Token).type}`);
|
|
270
|
+
}
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
273
|
|
|
269
274
|
if (stack.length > 0) {
|
|
270
|
-
const unclosed = stack[stack.length - 1]
|
|
275
|
+
const unclosed = stack[stack.length - 1]!;
|
|
271
276
|
const label =
|
|
272
277
|
unclosed.node.type === 'html'
|
|
273
278
|
? `<${unclosed.node.tag}>`
|
package/src/markup/render.tsx
CHANGED
|
@@ -38,10 +38,12 @@ import { getWidget } from '../widgets/widget-registry';
|
|
|
38
38
|
import { getMacro } from '../registry';
|
|
39
39
|
import { markdownToHtml } from './markdown';
|
|
40
40
|
import { h } from 'preact';
|
|
41
|
-
import type { ASTNode, MacroNode } from './ast';
|
|
41
|
+
import type { ASTNode, Branch, MacroNode } from './ast';
|
|
42
42
|
|
|
43
43
|
export const LocalsContext = createContext<Record<string, unknown>>({});
|
|
44
44
|
|
|
45
|
+
const EMPTY_BRANCHES: Branch[] = [];
|
|
46
|
+
|
|
45
47
|
/**
|
|
46
48
|
* Convert an HTML string (from micromark) to Preact VNodes,
|
|
47
49
|
* replacing <span data-tw="N"> placeholder elements with pre-rendered components.
|
|
@@ -77,7 +79,7 @@ function convertDomNode(
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
// Convert attributes
|
|
80
|
-
const props: Record<string,
|
|
82
|
+
const props: Record<string, string | number> = { key };
|
|
81
83
|
for (const attr of Array.from(el.attributes)) {
|
|
82
84
|
props[attr.name] = attr.value;
|
|
83
85
|
}
|
|
@@ -134,7 +136,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
134
136
|
return (
|
|
135
137
|
<If
|
|
136
138
|
key={key}
|
|
137
|
-
branches={node.branches
|
|
139
|
+
branches={node.branches ?? EMPTY_BRANCHES}
|
|
138
140
|
/>
|
|
139
141
|
);
|
|
140
142
|
|
|
@@ -354,7 +356,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
354
356
|
<Switch
|
|
355
357
|
key={key}
|
|
356
358
|
rawArgs={node.rawArgs}
|
|
357
|
-
branches={node.branches
|
|
359
|
+
branches={node.branches ?? EMPTY_BRANCHES}
|
|
358
360
|
/>
|
|
359
361
|
);
|
|
360
362
|
|
|
@@ -364,7 +366,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
364
366
|
key={key}
|
|
365
367
|
rawArgs={node.rawArgs}
|
|
366
368
|
children={node.children}
|
|
367
|
-
branches={node.branches
|
|
369
|
+
branches={node.branches ?? EMPTY_BRANCHES}
|
|
368
370
|
className={node.className}
|
|
369
371
|
id={node.id}
|
|
370
372
|
/>
|
|
@@ -488,6 +490,11 @@ function renderSingleNode(
|
|
|
488
490
|
{ key, ...node.attributes },
|
|
489
491
|
node.children.length > 0 ? renderNodes(node.children) : undefined,
|
|
490
492
|
);
|
|
493
|
+
|
|
494
|
+
default: {
|
|
495
|
+
const _exhaustive: never = node;
|
|
496
|
+
return _exhaustive;
|
|
497
|
+
}
|
|
491
498
|
}
|
|
492
499
|
}
|
|
493
500
|
|
|
@@ -526,7 +533,7 @@ export function renderNodes(nodes: ASTNode[]): preact.ComponentChildren {
|
|
|
526
533
|
let combined = '';
|
|
527
534
|
|
|
528
535
|
for (let i = 0; i < nodes.length; i++) {
|
|
529
|
-
const node = nodes[i]
|
|
536
|
+
const node = nodes[i]!;
|
|
530
537
|
if (node.type === 'text') {
|
|
531
538
|
combined += node.value;
|
|
532
539
|
} else {
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -196,10 +196,10 @@ function parseSelectors(
|
|
|
196
196
|
let i = startIdx;
|
|
197
197
|
|
|
198
198
|
while (i < input.length && (input[i] === '.' || input[i] === '#')) {
|
|
199
|
-
const prefix = input[i]
|
|
199
|
+
const prefix = input[i]!;
|
|
200
200
|
i++; // skip the . or #
|
|
201
201
|
const nameStart = i;
|
|
202
|
-
while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i])) i++;
|
|
202
|
+
while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i]!)) i++;
|
|
203
203
|
if (i > nameStart) {
|
|
204
204
|
const name = input.slice(nameStart, i);
|
|
205
205
|
if (prefix === '.') {
|
|
@@ -225,7 +225,7 @@ function parseHtmlAttributes(
|
|
|
225
225
|
|
|
226
226
|
while (j < input.length) {
|
|
227
227
|
// Skip whitespace
|
|
228
|
-
while (j < input.length && /\s/.test(input[j])) j++;
|
|
228
|
+
while (j < input.length && /\s/.test(input[j]!)) j++;
|
|
229
229
|
// End of tag?
|
|
230
230
|
if (
|
|
231
231
|
j >= input.length ||
|
|
@@ -236,7 +236,7 @@ function parseHtmlAttributes(
|
|
|
236
236
|
|
|
237
237
|
// Read attribute name
|
|
238
238
|
const attrStart = j;
|
|
239
|
-
while (j < input.length && /[a-zA-Z0-9_-]/.test(input[j])) j++;
|
|
239
|
+
while (j < input.length && /[a-zA-Z0-9_-]/.test(input[j]!)) j++;
|
|
240
240
|
const attrName = input.slice(attrStart, j);
|
|
241
241
|
if (!attrName) break;
|
|
242
242
|
|
|
@@ -244,7 +244,7 @@ function parseHtmlAttributes(
|
|
|
244
244
|
if (input[j] === '=') {
|
|
245
245
|
j++; // skip =
|
|
246
246
|
if (input[j] === '"' || input[j] === "'") {
|
|
247
|
-
const quote = input[j]
|
|
247
|
+
const quote = input[j]!;
|
|
248
248
|
j++; // skip opening quote
|
|
249
249
|
const valStart = j;
|
|
250
250
|
while (j < input.length && input[j] !== quote) j++;
|
|
@@ -253,7 +253,7 @@ function parseHtmlAttributes(
|
|
|
253
253
|
} else {
|
|
254
254
|
// Unquoted value
|
|
255
255
|
const valStart = j;
|
|
256
|
-
while (j < input.length && /[^\s>]/.test(input[j])) j++;
|
|
256
|
+
while (j < input.length && /[^\s>]/.test(input[j]!)) j++;
|
|
257
257
|
attributes[attrName] = input.slice(valStart, j);
|
|
258
258
|
}
|
|
259
259
|
} else {
|
|
@@ -367,7 +367,7 @@ export function tokenize(input: string): Token[] {
|
|
|
367
367
|
// {.class#id $variable.field}
|
|
368
368
|
i = afterSelectors + 1;
|
|
369
369
|
const nameStart = i;
|
|
370
|
-
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
370
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
371
371
|
const name = input.slice(nameStart, i);
|
|
372
372
|
|
|
373
373
|
if (input[i] === '}') {
|
|
@@ -395,7 +395,7 @@ export function tokenize(input: string): Token[] {
|
|
|
395
395
|
// {.class#id _temporary.field}
|
|
396
396
|
i = afterSelectors + 1;
|
|
397
397
|
const nameStart = i;
|
|
398
|
-
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
398
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
399
399
|
const name = input.slice(nameStart, i);
|
|
400
400
|
|
|
401
401
|
if (input[i] === '}') {
|
|
@@ -468,7 +468,7 @@ export function tokenize(input: string): Token[] {
|
|
|
468
468
|
flushText(i);
|
|
469
469
|
i += 2;
|
|
470
470
|
const nameStart = i;
|
|
471
|
-
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
471
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
472
472
|
const name = input.slice(nameStart, i);
|
|
473
473
|
|
|
474
474
|
if (input[i] === '}') {
|
|
@@ -494,7 +494,7 @@ export function tokenize(input: string): Token[] {
|
|
|
494
494
|
flushText(i);
|
|
495
495
|
i += 2;
|
|
496
496
|
const nameStart = i;
|
|
497
|
-
while (i < input.length && /[\w.]/.test(input[i])) i++;
|
|
497
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
498
498
|
const name = input.slice(nameStart, i);
|
|
499
499
|
|
|
500
500
|
if (input[i] === '}') {
|
|
@@ -572,14 +572,14 @@ export function tokenize(input: string): Token[] {
|
|
|
572
572
|
|
|
573
573
|
// Read tag name
|
|
574
574
|
const tagStart = j;
|
|
575
|
-
while (j < input.length && /[a-zA-Z0-9]/.test(input[j])) j++;
|
|
575
|
+
while (j < input.length && /[a-zA-Z0-9]/.test(input[j]!)) j++;
|
|
576
576
|
const tag = input.slice(tagStart, j).toLowerCase();
|
|
577
577
|
|
|
578
578
|
// Only handle known HTML tags
|
|
579
579
|
if (tag && HTML_TAGS.has(tag)) {
|
|
580
580
|
if (isClose) {
|
|
581
581
|
// Closing tag: skip whitespace, expect >
|
|
582
|
-
while (j < input.length && /\s/.test(input[j])) j++;
|
|
582
|
+
while (j < input.length && /\s/.test(input[j]!)) j++;
|
|
583
583
|
if (input[j] === '>') {
|
|
584
584
|
j++;
|
|
585
585
|
flushText(start);
|
package/src/parser.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface Passage {
|
|
|
2
2
|
pid: number;
|
|
3
3
|
name: string;
|
|
4
4
|
tags: string[];
|
|
5
|
+
metadata: Record<string, string>;
|
|
5
6
|
content: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
@@ -53,7 +54,21 @@ export function parseStoryData(): StoryData {
|
|
|
53
54
|
.filter((t) => t.length > 0);
|
|
54
55
|
const content = el.textContent || '';
|
|
55
56
|
|
|
56
|
-
const
|
|
57
|
+
const metadata: Record<string, string> = {};
|
|
58
|
+
const skipAttrs = new Set(['pid', 'name', 'tags']);
|
|
59
|
+
for (const attr of el.attributes) {
|
|
60
|
+
if (!skipAttrs.has(attr.name)) {
|
|
61
|
+
metadata[attr.name] = attr.value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const passage: Passage = {
|
|
66
|
+
pid,
|
|
67
|
+
name: passageName,
|
|
68
|
+
tags,
|
|
69
|
+
metadata,
|
|
70
|
+
content,
|
|
71
|
+
};
|
|
57
72
|
passages.set(passageName, passage);
|
|
58
73
|
passagesById.set(pid, passage);
|
|
59
74
|
}
|
package/src/prng.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seedable PRNG for Spindle — Mulberry32 algorithm.
|
|
3
|
+
*
|
|
4
|
+
* Provides deterministic random number generation that survives save/load
|
|
5
|
+
* cycles via a pull-counter approach: recreate from seed, fast-forward N pulls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Core algorithm
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** Mulberry32: fast, high-quality 32-bit PRNG. */
|
|
13
|
+
function mulberry32(seed: number): () => number {
|
|
14
|
+
let t = seed | 0;
|
|
15
|
+
return () => {
|
|
16
|
+
t = (t + 0x6d2b79f5) | 0;
|
|
17
|
+
let r = Math.imul(t ^ (t >>> 15), t | 1);
|
|
18
|
+
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
|
|
19
|
+
return ((r ^ (r >>> 14)) >>> 0) / 0x100000000;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** FNV-1a hash — converts a string seed to a 32-bit integer. */
|
|
24
|
+
function hashSeed(seed: string): number {
|
|
25
|
+
let h = 0x811c9dc5;
|
|
26
|
+
for (let i = 0; i < seed.length; i++) {
|
|
27
|
+
h ^= seed.charCodeAt(i);
|
|
28
|
+
h = Math.imul(h, 0x01000193);
|
|
29
|
+
}
|
|
30
|
+
return h;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Module state
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
let enabled = false;
|
|
38
|
+
let currentSeed = '';
|
|
39
|
+
let currentPull = 0;
|
|
40
|
+
let generator: (() => number) | null = null;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Public API
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface PRNGSnapshot {
|
|
47
|
+
readonly seed: string;
|
|
48
|
+
readonly pull: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initialize the PRNG.
|
|
53
|
+
* @param seed Optional seed string. If omitted, a random seed is generated.
|
|
54
|
+
* @param useEntropy If true (default), mix `Date.now()` and `Math.random()`
|
|
55
|
+
* into the seed for uniqueness across playthroughs.
|
|
56
|
+
* Set to false for fully deterministic sequences.
|
|
57
|
+
*/
|
|
58
|
+
export function initPRNG(seed?: string, useEntropy = true): void {
|
|
59
|
+
let resolvedSeed: string;
|
|
60
|
+
if (seed === undefined) {
|
|
61
|
+
resolvedSeed = String(Date.now()) + String(Math.random());
|
|
62
|
+
} else if (useEntropy) {
|
|
63
|
+
resolvedSeed = seed + '|' + Date.now() + '|' + Math.random();
|
|
64
|
+
} else {
|
|
65
|
+
resolvedSeed = seed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
currentSeed = resolvedSeed;
|
|
69
|
+
currentPull = 0;
|
|
70
|
+
generator = mulberry32(hashSeed(resolvedSeed));
|
|
71
|
+
enabled = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Restore PRNG state from a snapshot (used on save/load).
|
|
76
|
+
* Recreates the generator from the seed and fast-forwards to the saved pull count.
|
|
77
|
+
*/
|
|
78
|
+
export function restorePRNG(seed: string, pull: number): void {
|
|
79
|
+
currentSeed = seed;
|
|
80
|
+
currentPull = 0;
|
|
81
|
+
generator = mulberry32(hashSeed(seed));
|
|
82
|
+
enabled = true;
|
|
83
|
+
|
|
84
|
+
// Fast-forward
|
|
85
|
+
for (let i = 0; i < pull; i++) {
|
|
86
|
+
generator();
|
|
87
|
+
}
|
|
88
|
+
currentPull = pull;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Disable the PRNG (used on restart before StoryInit re-enables it). */
|
|
92
|
+
export function resetPRNG(): void {
|
|
93
|
+
enabled = false;
|
|
94
|
+
currentSeed = '';
|
|
95
|
+
currentPull = 0;
|
|
96
|
+
generator = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns the current PRNG state for snapshotting, or null if disabled. */
|
|
100
|
+
export function snapshotPRNG(): PRNGSnapshot | null {
|
|
101
|
+
if (!enabled) return null;
|
|
102
|
+
return { seed: currentSeed, pull: currentPull };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Returns a seeded random number [0, 1), or falls back to Math.random(). */
|
|
106
|
+
export function random(): number {
|
|
107
|
+
if (!enabled || !generator) return Math.random();
|
|
108
|
+
currentPull++;
|
|
109
|
+
return generator();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Returns a random integer between min and max (inclusive). */
|
|
113
|
+
export function randomInt(min: number, max: number): number {
|
|
114
|
+
if (min > max) [min, max] = [max, min];
|
|
115
|
+
return Math.floor(random() * (max - min + 1)) + min;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isPRNGEnabled(): boolean {
|
|
119
|
+
return enabled;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function getPRNGSeed(): string {
|
|
123
|
+
return currentSeed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getPRNGPull(): number {
|
|
127
|
+
return currentPull;
|
|
128
|
+
}
|
|
@@ -137,17 +137,22 @@ export async function overwriteSave(
|
|
|
137
137
|
const existing = await getSave(saveId);
|
|
138
138
|
if (!existing) return undefined;
|
|
139
139
|
|
|
140
|
-
existing.meta.updatedAt = new Date().toISOString();
|
|
141
|
-
existing.meta.passage = payload.passage;
|
|
142
140
|
const serializedPayload = deepClone(payload);
|
|
143
141
|
serializedPayload.variables = serialize(serializedPayload.variables);
|
|
144
142
|
serializedPayload.history = serializedPayload.history.map((m) => ({
|
|
145
143
|
...m,
|
|
146
144
|
variables: serialize(m.variables),
|
|
147
145
|
}));
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
const updated: SaveRecord = {
|
|
147
|
+
meta: {
|
|
148
|
+
...existing.meta,
|
|
149
|
+
updatedAt: new Date().toISOString(),
|
|
150
|
+
passage: payload.passage,
|
|
151
|
+
},
|
|
152
|
+
payload: serializedPayload,
|
|
153
|
+
};
|
|
154
|
+
await putSave(updated);
|
|
155
|
+
return updated;
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
export async function loadSave(
|
|
@@ -174,9 +179,15 @@ export async function renameSave(
|
|
|
174
179
|
): Promise<void> {
|
|
175
180
|
const record = await getSave(saveId);
|
|
176
181
|
if (!record) return;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
const updated: SaveRecord = {
|
|
183
|
+
...record,
|
|
184
|
+
meta: {
|
|
185
|
+
...record.meta,
|
|
186
|
+
title: newTitle,
|
|
187
|
+
updatedAt: new Date().toISOString(),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
await putSave(updated);
|
|
180
191
|
}
|
|
181
192
|
|
|
182
193
|
// --- Grouped Retrieval ---
|
|
@@ -200,8 +211,12 @@ export async function getSavesGrouped(
|
|
|
200
211
|
const groups = new Map<string, SaveRecord[]>();
|
|
201
212
|
for (const save of allSaves) {
|
|
202
213
|
const pid = save.meta.playthroughId;
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
const existing = groups.get(pid);
|
|
215
|
+
if (existing) {
|
|
216
|
+
existing.push(save);
|
|
217
|
+
} else {
|
|
218
|
+
groups.set(pid, [save]);
|
|
219
|
+
}
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
// Sort saves within each group newest-first
|
package/src/saves/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HistoryMoment } from '../store';
|
|
2
|
+
import type { PRNGSnapshot } from '../prng';
|
|
2
3
|
|
|
3
4
|
export interface SavePayload {
|
|
4
5
|
passage: string;
|
|
@@ -7,6 +8,7 @@ export interface SavePayload {
|
|
|
7
8
|
historyIndex: number;
|
|
8
9
|
visitCounts?: Record<string, number>;
|
|
9
10
|
renderCounts?: Record<string, number>;
|
|
11
|
+
prng?: PRNGSnapshot | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface SaveMeta {
|
|
@@ -38,3 +40,32 @@ export interface SaveExport {
|
|
|
38
40
|
exportedAt: string;
|
|
39
41
|
save: SaveRecord;
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
export function isSaveExport(value: unknown): value is SaveExport {
|
|
45
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
46
|
+
const obj = value as Record<string, unknown>;
|
|
47
|
+
if (obj.version !== 1 || typeof obj.ifid !== 'string') return false;
|
|
48
|
+
if (typeof obj.save !== 'object' || obj.save === null) return false;
|
|
49
|
+
|
|
50
|
+
const save = obj.save as Record<string, unknown>;
|
|
51
|
+
if (typeof save.meta !== 'object' || save.meta === null) return false;
|
|
52
|
+
if (typeof save.payload !== 'object' || save.payload === null) return false;
|
|
53
|
+
|
|
54
|
+
const meta = save.meta as Record<string, unknown>;
|
|
55
|
+
if (typeof meta.id !== 'string' || typeof meta.passage !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
if (typeof meta.ifid !== 'string') return false;
|
|
58
|
+
if (typeof meta.playthroughId !== 'string') return false;
|
|
59
|
+
if (typeof meta.createdAt !== 'string') return false;
|
|
60
|
+
if (typeof meta.updatedAt !== 'string') return false;
|
|
61
|
+
if (typeof meta.title !== 'string') return false;
|
|
62
|
+
|
|
63
|
+
const payload = save.payload as Record<string, unknown>;
|
|
64
|
+
if (typeof payload.passage !== 'string') return false;
|
|
65
|
+
if (!Array.isArray(payload.history)) return false;
|
|
66
|
+
if (typeof payload.historyIndex !== 'number') return false;
|
|
67
|
+
if (typeof payload.variables !== 'object' || payload.variables === null)
|
|
68
|
+
return false;
|
|
69
|
+
|
|
70
|
+
return true;
|
|
71
|
+
}
|