@rohal12/spindle 0.52.2 → 0.52.3
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 +163 -162
- package/dist/pkg/story-variables.js +24 -7
- package/package.json +1 -1
- package/src/components/App.tsx +6 -3
- package/src/components/macros/ExprDisplay.tsx +2 -7
- package/src/components/macros/MenubarButtons.tsx +13 -12
- package/src/components/macros/PassageDisplay.tsx +8 -4
- package/src/components/macros/SaveManager.tsx +8 -4
- package/src/components/macros/VarDisplay.tsx +11 -12
- package/src/event-emitter.ts +15 -11
- package/src/expression.ts +44 -42
- package/src/hooks/use-merged-locals.ts +6 -4
- package/src/hooks/use-story-fields.ts +19 -0
- package/src/settings.ts +20 -22
- package/src/store.ts +2 -1
- package/src/story-api.ts +29 -77
- package/src/story-variables.ts +5 -9
- package/src/utils/live-locals.ts +2 -4
- package/src/utils/namespace.ts +89 -25
- package/src/utils/object-path.ts +1 -3
- package/src/utils/counts.ts +0 -45
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
3
4
|
import { settings } from '../../settings';
|
|
4
5
|
import { defineMacro } from '../../define-macro';
|
|
5
6
|
import { PassageDialog } from '../PassageDialog';
|
|
@@ -108,13 +109,10 @@ defineMenubarAction({
|
|
|
108
109
|
name: 'quicksave',
|
|
109
110
|
label: 'QuickSave',
|
|
110
111
|
actionType: 'save',
|
|
111
|
-
setup: () =>
|
|
112
|
-
|
|
113
|
-
title: hotkeyTitle(
|
|
114
|
-
|
|
115
|
-
useStoryStore((s) => s.quickSaveKey),
|
|
116
|
-
),
|
|
117
|
-
}),
|
|
112
|
+
setup: () => {
|
|
113
|
+
const { save, quickSaveKey } = useStoryFields('save', 'quickSaveKey');
|
|
114
|
+
return { perform: save, title: hotkeyTitle('Quick Save', quickSaveKey) };
|
|
115
|
+
},
|
|
118
116
|
});
|
|
119
117
|
|
|
120
118
|
defineMenubarAction({
|
|
@@ -123,14 +121,17 @@ defineMenubarAction({
|
|
|
123
121
|
actionType: 'load',
|
|
124
122
|
confirm: 'Load saved game? Current progress will be lost.',
|
|
125
123
|
setup: () => {
|
|
126
|
-
|
|
127
|
-
const hasSave =
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// knownSaves: re-render when the saves change, as hasSave() reads them
|
|
125
|
+
const { load, hasSave, quickLoadKey } = useStoryFields(
|
|
126
|
+
'load',
|
|
127
|
+
'hasSave',
|
|
128
|
+
'quickLoadKey',
|
|
129
|
+
'knownSaves',
|
|
130
|
+
);
|
|
130
131
|
return {
|
|
131
132
|
perform: () => load(),
|
|
132
133
|
disabled: !hasSave(),
|
|
133
|
-
title: hotkeyTitle('Quick Load',
|
|
134
|
+
title: hotkeyTitle('Quick Load', quickLoadKey),
|
|
134
135
|
};
|
|
135
136
|
},
|
|
136
137
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
3
4
|
import { Passage, renderPassageContent } from '../Passage';
|
|
4
5
|
import { defineMacro } from '../../define-macro';
|
|
5
6
|
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
@@ -69,10 +70,13 @@ defineMacro({
|
|
|
69
70
|
name: 'passage',
|
|
70
71
|
interpolate: true,
|
|
71
72
|
render(_props, ctx) {
|
|
72
|
-
const currentPassage
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const { currentPassage, navigationId, storyData, renderDeferred } =
|
|
74
|
+
useStoryFields(
|
|
75
|
+
'currentPassage',
|
|
76
|
+
'navigationId',
|
|
77
|
+
'storyData',
|
|
78
|
+
'renderDeferred',
|
|
79
|
+
);
|
|
76
80
|
|
|
77
81
|
// Render-gating: displayed controls which passage is visually shown.
|
|
78
82
|
// The store updates immediately, but the visual swap is deferred by the
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useContext,
|
|
7
7
|
} from 'preact/hooks';
|
|
8
8
|
import { useStoryStore, resolvePlaythroughId } from '../../store';
|
|
9
|
+
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
9
10
|
import { isSaveExport, type SaveRecord } from '../../saves/types';
|
|
10
11
|
import {
|
|
11
12
|
getSavesGrouped,
|
|
@@ -61,10 +62,13 @@ export function SaveManagerContent() {
|
|
|
61
62
|
|
|
62
63
|
const closeDialog = useContext(DialogCloseContext);
|
|
63
64
|
|
|
64
|
-
const storyData
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
const { storyData, playthroughId, beginSave, loadFromPayload } =
|
|
66
|
+
useStoryFields(
|
|
67
|
+
'storyData',
|
|
68
|
+
'playthroughId',
|
|
69
|
+
'beginSave',
|
|
70
|
+
'loadFromPayload',
|
|
71
|
+
);
|
|
68
72
|
const ifid = storyData?.ifid ?? '';
|
|
69
73
|
|
|
70
74
|
const refresh = useCallback(async () => {
|
|
@@ -2,7 +2,7 @@ import { useStoryStore } from '../../store';
|
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { LocalsValuesContext } from '../../markup/render';
|
|
4
4
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
5
|
-
import {
|
|
5
|
+
import { ownValue, variableNameError } from '../../utils/namespace';
|
|
6
6
|
|
|
7
7
|
const SIGILS = {
|
|
8
8
|
variable: '$',
|
|
@@ -11,6 +11,12 @@ const SIGILS = {
|
|
|
11
11
|
transient: '%',
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
+
const STORE_NAMESPACES = {
|
|
15
|
+
variable: 'variables',
|
|
16
|
+
temporary: 'temporary',
|
|
17
|
+
transient: 'transient',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
14
20
|
interface VarDisplayProps {
|
|
15
21
|
name: string;
|
|
16
22
|
scope: 'variable' | 'temporary' | 'local' | 'transient';
|
|
@@ -27,20 +33,13 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
27
33
|
const root = parts[0]!;
|
|
28
34
|
// Own entries only, as the expression engine reads them
|
|
29
35
|
const storeValue = useStoryStore((s) =>
|
|
30
|
-
scope === '
|
|
31
|
-
? ownValue(s.variables, root)
|
|
32
|
-
: scope === 'temporary'
|
|
33
|
-
? ownValue(s.temporary, root)
|
|
34
|
-
: scope === 'transient'
|
|
35
|
-
? ownValue(s.transient, root)
|
|
36
|
-
: undefined,
|
|
36
|
+
scope === 'local' ? undefined : ownValue(s[STORE_NAMESPACES[scope]], root),
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const nameError = variableNameError(root, SIGILS[scope] + root);
|
|
40
|
+
if (nameError) {
|
|
40
41
|
return (
|
|
41
|
-
<span class="error">
|
|
42
|
-
{`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
|
|
43
|
-
</span>
|
|
42
|
+
<span class="error">{`{${SIGILS[scope]}${name} error: ${nameError}}`}</span>
|
|
44
43
|
);
|
|
45
44
|
}
|
|
46
45
|
|
package/src/event-emitter.ts
CHANGED
|
@@ -57,16 +57,22 @@ export function on<E extends StoryEvent>(
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
/** Call `call` with each listener of `event`. */
|
|
61
|
+
function forEachListener(
|
|
62
|
+
event: StoryEvent,
|
|
63
|
+
call: (cb: Function) => void,
|
|
63
64
|
): void {
|
|
64
65
|
const set = listeners.get(event);
|
|
65
66
|
if (!set) return;
|
|
66
67
|
// Snapshot to tolerate unsubscription during iteration
|
|
67
|
-
for (const cb of [...set])
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
for (const cb of [...set]) call(cb);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function emit<E extends StoryEvent>(
|
|
72
|
+
event: E,
|
|
73
|
+
...args: Parameters<EventMap[E]>
|
|
74
|
+
): void {
|
|
75
|
+
forEachListener(event, (cb) => cb(...args));
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/**
|
|
@@ -78,15 +84,13 @@ export function emitFromRender<E extends StoryEvent>(
|
|
|
78
84
|
event: E,
|
|
79
85
|
...args: Parameters<EventMap[E]>
|
|
80
86
|
): void {
|
|
81
|
-
|
|
82
|
-
if (!set) return;
|
|
83
|
-
for (const cb of [...set]) {
|
|
87
|
+
forEachListener(event, (cb) => {
|
|
84
88
|
try {
|
|
85
|
-
|
|
89
|
+
cb(...args);
|
|
86
90
|
} catch (err) {
|
|
87
91
|
console.error(`spindle: Error in ${event} handler:`, err);
|
|
88
92
|
}
|
|
89
|
-
}
|
|
93
|
+
});
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** Test-only: clear all listeners. */
|
package/src/expression.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { random, randomInt } from './prng';
|
|
|
5
5
|
import { lexJs, type JsGoal, type Sigil } from './js-lexer';
|
|
6
6
|
import {
|
|
7
7
|
EMPTY_NAMESPACE,
|
|
8
|
-
RESERVED_NAME,
|
|
9
8
|
asNamespace,
|
|
9
|
+
countOf,
|
|
10
10
|
isNamespace,
|
|
11
|
+
variableNameError,
|
|
12
|
+
type Counts,
|
|
11
13
|
type Namespace,
|
|
12
14
|
} from './utils/namespace';
|
|
13
|
-
import { countOf, type Counts } from './utils/counts';
|
|
14
15
|
|
|
15
16
|
interface ExpressionFns {
|
|
16
17
|
currentPassage: () => Passage | undefined;
|
|
@@ -73,11 +74,8 @@ export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
|
73
74
|
result += text;
|
|
74
75
|
},
|
|
75
76
|
variable(sigil, name) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
77
|
+
const error = variableNameError(name, sigil + name);
|
|
78
|
+
if (error) throw new SyntaxError(`spindle: ${error}`);
|
|
81
79
|
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
82
80
|
if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
83
81
|
result += `${NAMESPACES[sigil]}["${name}"]`;
|
|
@@ -116,6 +114,35 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
116
114
|
return fn;
|
|
117
115
|
}
|
|
118
116
|
|
|
117
|
+
/** The passage being shown. */
|
|
118
|
+
export function currentPassage(): Passage | undefined {
|
|
119
|
+
const s = useStoryStore.getState();
|
|
120
|
+
return s.storyData?.passages.get(s.currentPassage);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The passage shown before the current one in history. */
|
|
124
|
+
export function previousPassage(): Passage | undefined {
|
|
125
|
+
const s = useStoryStore.getState();
|
|
126
|
+
if (s.historyIndex <= 0) return undefined;
|
|
127
|
+
const prevName = s.history[s.historyIndex - 1]?.passage;
|
|
128
|
+
return prevName ? s.storyData?.passages.get(prevName) : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* visited()/rendered() and their has-forms, counting in the counters
|
|
133
|
+
* `counts()` returns. A missing name means the current passage.
|
|
134
|
+
*/
|
|
135
|
+
export function countQueries(counts: () => Counts) {
|
|
136
|
+
const count = (name?: string): number =>
|
|
137
|
+
countOf(counts(), name ?? useStoryStore.getState().currentPassage);
|
|
138
|
+
return {
|
|
139
|
+
count,
|
|
140
|
+
has: (name?: string): boolean => count(name) > 0,
|
|
141
|
+
any: (...names: string[]): boolean => names.some((n) => count(n) > 0),
|
|
142
|
+
all: (...names: string[]): boolean => names.every((n) => count(n) > 0),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
let cachedFns: ExpressionFns | null = null;
|
|
120
147
|
let cachedVisitCounts: Counts | null = null;
|
|
121
148
|
let cachedRenderCounts: Counts | null = null;
|
|
@@ -132,44 +159,19 @@ export function buildExpressionFns() {
|
|
|
132
159
|
return cachedFns;
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
138
|
-
const hasVisitedAny = (...names: string[]): boolean =>
|
|
139
|
-
names.some((n) => visited(n) > 0);
|
|
140
|
-
const hasVisitedAll = (...names: string[]): boolean =>
|
|
141
|
-
names.every((n) => visited(n) > 0);
|
|
142
|
-
|
|
143
|
-
const rendered = (name?: string): number =>
|
|
144
|
-
countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
|
|
145
|
-
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
146
|
-
const hasRenderedAny = (...names: string[]): boolean =>
|
|
147
|
-
names.some((n) => rendered(n) > 0);
|
|
148
|
-
const hasRenderedAll = (...names: string[]): boolean =>
|
|
149
|
-
names.every((n) => rendered(n) > 0);
|
|
150
|
-
|
|
151
|
-
const currentPassage = (): Passage | undefined => {
|
|
152
|
-
const s = useStoryStore.getState();
|
|
153
|
-
return s.storyData?.passages.get(s.currentPassage);
|
|
154
|
-
};
|
|
155
|
-
const previousPassage = (): Passage | undefined => {
|
|
156
|
-
const s = useStoryStore.getState();
|
|
157
|
-
if (s.historyIndex <= 0) return undefined;
|
|
158
|
-
const prevName = s.history[s.historyIndex - 1]?.passage;
|
|
159
|
-
return prevName ? s.storyData?.passages.get(prevName) : undefined;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
+
const visits = countQueries(() => visitCounts);
|
|
163
|
+
const renders = countQueries(() => renderCounts);
|
|
162
164
|
cachedFns = {
|
|
163
165
|
currentPassage,
|
|
164
166
|
previousPassage,
|
|
165
|
-
visited,
|
|
166
|
-
hasVisited,
|
|
167
|
-
hasVisitedAny,
|
|
168
|
-
hasVisitedAll,
|
|
169
|
-
rendered,
|
|
170
|
-
hasRendered,
|
|
171
|
-
hasRenderedAny,
|
|
172
|
-
hasRenderedAll,
|
|
167
|
+
visited: visits.count,
|
|
168
|
+
hasVisited: visits.has,
|
|
169
|
+
hasVisitedAny: visits.any,
|
|
170
|
+
hasVisitedAll: visits.all,
|
|
171
|
+
rendered: renders.count,
|
|
172
|
+
hasRendered: renders.has,
|
|
173
|
+
hasRenderedAny: renders.any,
|
|
174
|
+
hasRenderedAll: renders.all,
|
|
173
175
|
random,
|
|
174
176
|
randomInt,
|
|
175
177
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useMemo } from 'preact/hooks';
|
|
2
|
-
import {
|
|
2
|
+
import { useStoryFields } from './use-story-fields';
|
|
3
3
|
import { LocalsValuesContext } from '../markup/render';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,9 +12,11 @@ export function useMergedLocals(): readonly [
|
|
|
12
12
|
Record<string, unknown>,
|
|
13
13
|
Record<string, unknown>,
|
|
14
14
|
] {
|
|
15
|
-
const variables =
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const { variables, temporary, transient } = useStoryFields(
|
|
16
|
+
'variables',
|
|
17
|
+
'temporary',
|
|
18
|
+
'transient',
|
|
19
|
+
);
|
|
18
20
|
const localsValues = useContext(LocalsValuesContext);
|
|
19
21
|
|
|
20
22
|
return useMemo(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
2
|
+
import { shallow } from 'zustand/vanilla/shallow';
|
|
3
|
+
import { useStoryStore, type StoryState } from '../store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
7
|
+
* useStoryStore() selector per field would). The result keeps its identity
|
|
8
|
+
* while the fields don't change, as the store's snapshot must.
|
|
9
|
+
*/
|
|
10
|
+
export function useStoryFields<K extends keyof StoryState>(
|
|
11
|
+
...keys: K[]
|
|
12
|
+
): Pick<StoryState, K> {
|
|
13
|
+
const prev = useRef<Pick<StoryState, K>>();
|
|
14
|
+
return useStoryStore((s) => {
|
|
15
|
+
const next = {} as Pick<StoryState, K>;
|
|
16
|
+
for (const key of keys) next[key] = s[key];
|
|
17
|
+
return shallow(prev.current, next) ? prev.current! : (prev.current = next);
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from './store';
|
|
2
|
-
import { createNamespace, ownValue } from './utils/namespace';
|
|
2
|
+
import { createNamespace, hasOwn, ownValue } from './utils/namespace';
|
|
3
3
|
|
|
4
4
|
export interface ToggleConfig {
|
|
5
5
|
label: string;
|
|
@@ -76,29 +76,30 @@ function loadFromStorage(): void {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Register a setting, starting from its default unless it has a value. */
|
|
80
|
+
function define(name: string, def: SettingDef): void {
|
|
81
|
+
definitions.set(name, def);
|
|
82
|
+
if (!hasOwn(values, name)) values[name] = def.config.default;
|
|
83
|
+
loadFromStorage();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The value of setting `name` if it has the type of `fallback`, else that. */
|
|
87
|
+
function valueOf<T>(name: string, fallback: T): T {
|
|
88
|
+
const v = ownValue(values, name);
|
|
89
|
+
return typeof v === typeof fallback ? (v as T) : fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export const settings = {
|
|
80
93
|
addToggle(name: string, config: ToggleConfig): void {
|
|
81
|
-
|
|
82
|
-
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
83
|
-
values[name] = config.default;
|
|
84
|
-
}
|
|
85
|
-
loadFromStorage();
|
|
94
|
+
define(name, { type: 'toggle', config });
|
|
86
95
|
},
|
|
87
96
|
|
|
88
97
|
addList(name: string, config: ListConfig): void {
|
|
89
|
-
|
|
90
|
-
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
91
|
-
values[name] = config.default;
|
|
92
|
-
}
|
|
93
|
-
loadFromStorage();
|
|
98
|
+
define(name, { type: 'list', config });
|
|
94
99
|
},
|
|
95
100
|
|
|
96
101
|
addRange(name: string, config: RangeConfig): void {
|
|
97
|
-
|
|
98
|
-
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
99
|
-
values[name] = config.default;
|
|
100
|
-
}
|
|
101
|
-
loadFromStorage();
|
|
102
|
+
define(name, { type: 'range', config });
|
|
102
103
|
},
|
|
103
104
|
|
|
104
105
|
get(name: string): unknown {
|
|
@@ -106,18 +107,15 @@ export const settings = {
|
|
|
106
107
|
},
|
|
107
108
|
|
|
108
109
|
getToggle(name: string): boolean {
|
|
109
|
-
|
|
110
|
-
return typeof v === 'boolean' ? v : false;
|
|
110
|
+
return valueOf(name, false);
|
|
111
111
|
},
|
|
112
112
|
|
|
113
113
|
getList(name: string): string {
|
|
114
|
-
|
|
115
|
-
return typeof v === 'string' ? v : '';
|
|
114
|
+
return valueOf(name, '');
|
|
116
115
|
},
|
|
117
116
|
|
|
118
117
|
getRange(name: string): number {
|
|
119
|
-
|
|
120
|
-
return typeof v === 'number' ? v : 0;
|
|
118
|
+
return valueOf(name, 0);
|
|
121
119
|
},
|
|
122
120
|
|
|
123
121
|
set(name: string, value: unknown): void {
|
package/src/store.ts
CHANGED
|
@@ -62,11 +62,12 @@ import {
|
|
|
62
62
|
} from './execute-mutation';
|
|
63
63
|
import {
|
|
64
64
|
checkVariableName,
|
|
65
|
+
createCounts,
|
|
65
66
|
createNamespace,
|
|
66
67
|
isNamespace,
|
|
68
|
+
type Counts,
|
|
67
69
|
type Namespace,
|
|
68
70
|
} from './utils/namespace';
|
|
69
|
-
import { createCounts, type Counts } from './utils/counts';
|
|
70
71
|
|
|
71
72
|
enablePatches();
|
|
72
73
|
// Story state holds Map and Set values: Immer must be able to draft them
|
package/src/story-api.ts
CHANGED
|
@@ -23,8 +23,8 @@ import { getBackendType } from './saves/storage';
|
|
|
23
23
|
import { registerClass } from './class-registry';
|
|
24
24
|
import { frozenCopy, getActiveMutationScope } from './execute-mutation';
|
|
25
25
|
import { getByPath, setByPath } from './utils/object-path';
|
|
26
|
-
import { checkVariableName } from './utils/namespace';
|
|
27
|
-
import {
|
|
26
|
+
import { changedNames, checkVariableName, ownValue } from './utils/namespace';
|
|
27
|
+
import { countQueries, currentPassage, previousPassage } from './expression';
|
|
28
28
|
import { defineMacro } from './define-macro';
|
|
29
29
|
import type { MacroDefinition } from './define-macro';
|
|
30
30
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
@@ -83,35 +83,24 @@ function ensureVariableChangedSubscription(): void {
|
|
|
83
83
|
let prevTrans = useStoryStore.getState().transient;
|
|
84
84
|
useStoryStore.subscribe((state) => {
|
|
85
85
|
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
hasChanges = true;
|
|
86
|
+
const collect = (
|
|
87
|
+
prev: Record<string, unknown>,
|
|
88
|
+
next: Record<string, unknown>,
|
|
89
|
+
prefix: string,
|
|
90
|
+
) => {
|
|
91
|
+
for (const key of changedNames(prev, next)) {
|
|
92
|
+
changed[prefix + key] = {
|
|
93
|
+
from: ownValue(prev, key),
|
|
94
|
+
to: ownValue(next, key),
|
|
95
|
+
};
|
|
97
96
|
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const allTransKeys = new Set([
|
|
102
|
-
...Object.keys(prevTrans),
|
|
103
|
-
...Object.keys(state.transient),
|
|
104
|
-
]);
|
|
105
|
-
for (const key of allTransKeys) {
|
|
106
|
-
if (state.transient[key] !== prevTrans[key]) {
|
|
107
|
-
changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] };
|
|
108
|
-
hasChanges = true;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
97
|
+
};
|
|
98
|
+
collect(prevVars, state.variables, '');
|
|
99
|
+
collect(prevTrans, state.transient, '%');
|
|
111
100
|
|
|
112
101
|
prevVars = state.variables;
|
|
113
102
|
prevTrans = state.transient;
|
|
114
|
-
if (
|
|
103
|
+
if (Object.keys(changed).length > 0) {
|
|
115
104
|
emit('variableChanged', changed);
|
|
116
105
|
}
|
|
117
106
|
});
|
|
@@ -271,6 +260,8 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
|
|
|
271
260
|
}
|
|
272
261
|
|
|
273
262
|
function createStoryAPI(): StoryAPI {
|
|
263
|
+
const visits = countQueries(() => useStoryStore.getState().visitCounts);
|
|
264
|
+
const renders = countQueries(() => useStoryStore.getState().renderCounts);
|
|
274
265
|
return {
|
|
275
266
|
get(name: string): unknown {
|
|
276
267
|
const { isTransient, key } = parseName(name);
|
|
@@ -284,7 +275,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
284
275
|
const namespace = isTransient ? source.transient : source.variables;
|
|
285
276
|
const value = key.includes('.')
|
|
286
277
|
? getByPath(namespace, key.split('.'))
|
|
287
|
-
: namespace
|
|
278
|
+
: ownValue(namespace, key);
|
|
288
279
|
return scope ? frozenCopy(value) : value;
|
|
289
280
|
},
|
|
290
281
|
|
|
@@ -357,55 +348,16 @@ function createStoryAPI(): StoryAPI {
|
|
|
357
348
|
return useStoryStore.getState().importSave(data, slot);
|
|
358
349
|
},
|
|
359
350
|
|
|
360
|
-
visited
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const { visitCounts } = useStoryStore.getState();
|
|
371
|
-
return names.some((n) => countOf(visitCounts, n) > 0);
|
|
372
|
-
},
|
|
373
|
-
|
|
374
|
-
hasVisitedAll(...names: string[]): boolean {
|
|
375
|
-
const { visitCounts } = useStoryStore.getState();
|
|
376
|
-
return names.every((n) => countOf(visitCounts, n) > 0);
|
|
377
|
-
},
|
|
378
|
-
|
|
379
|
-
rendered(name?: string): number {
|
|
380
|
-
const state = useStoryStore.getState();
|
|
381
|
-
return countOf(state.renderCounts, name ?? state.currentPassage);
|
|
382
|
-
},
|
|
383
|
-
|
|
384
|
-
hasRendered(name?: string): boolean {
|
|
385
|
-
return this.rendered(name) > 0;
|
|
386
|
-
},
|
|
387
|
-
|
|
388
|
-
hasRenderedAny(...names: string[]): boolean {
|
|
389
|
-
const { renderCounts } = useStoryStore.getState();
|
|
390
|
-
return names.some((n) => countOf(renderCounts, n) > 0);
|
|
391
|
-
},
|
|
392
|
-
|
|
393
|
-
hasRenderedAll(...names: string[]): boolean {
|
|
394
|
-
const { renderCounts } = useStoryStore.getState();
|
|
395
|
-
return names.every((n) => countOf(renderCounts, n) > 0);
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
currentPassage(): Passage | undefined {
|
|
399
|
-
const state = useStoryStore.getState();
|
|
400
|
-
return state.storyData?.passages.get(state.currentPassage);
|
|
401
|
-
},
|
|
402
|
-
|
|
403
|
-
previousPassage(): Passage | undefined {
|
|
404
|
-
const state = useStoryStore.getState();
|
|
405
|
-
if (state.historyIndex <= 0) return undefined;
|
|
406
|
-
const prevName = state.history[state.historyIndex - 1]?.passage;
|
|
407
|
-
return prevName ? state.storyData?.passages.get(prevName) : undefined;
|
|
408
|
-
},
|
|
351
|
+
visited: visits.count,
|
|
352
|
+
hasVisited: visits.has,
|
|
353
|
+
hasVisitedAny: visits.any,
|
|
354
|
+
hasVisitedAll: visits.all,
|
|
355
|
+
rendered: renders.count,
|
|
356
|
+
hasRendered: renders.has,
|
|
357
|
+
hasRenderedAny: renders.any,
|
|
358
|
+
hasRenderedAll: renders.all,
|
|
359
|
+
currentPassage,
|
|
360
|
+
previousPassage,
|
|
409
361
|
|
|
410
362
|
get title(): string {
|
|
411
363
|
return useStoryStore.getState().storyData?.name || '';
|
package/src/story-variables.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { tokenize, type Token } from './markup/tokenizer';
|
|
|
3
3
|
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
4
4
|
import { errorMessage } from './utils/error-message';
|
|
5
5
|
import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
|
|
6
|
-
import { createNamespace,
|
|
6
|
+
import { createNamespace, variableNameError } from './utils/namespace';
|
|
7
7
|
|
|
8
8
|
export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
|
|
9
9
|
|
|
@@ -80,11 +80,8 @@ export function parseStoryVariables(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const [, name, expr] = match as [string, string, string];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
83
|
+
const nameError = variableNameError(name, sigil + name);
|
|
84
|
+
if (nameError) throw new Error(`${passageName}: ${nameError}`);
|
|
88
85
|
let value: unknown;
|
|
89
86
|
try {
|
|
90
87
|
value = new Function('return (' + expr + ')')();
|
|
@@ -134,9 +131,8 @@ function validateRef(
|
|
|
134
131
|
const parts = ref.split('.');
|
|
135
132
|
const rootName = parts[0]!;
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
134
|
+
const nameError = variableNameError(rootName, '$' + rootName);
|
|
135
|
+
if (nameError) return nameError;
|
|
140
136
|
|
|
141
137
|
// Skip for-loop locals
|
|
142
138
|
if (forLocals.has(rootName)) return null;
|
package/src/utils/live-locals.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createNamespace, ownValue } from './namespace';
|
|
1
|
+
import { createNamespace, hasOwn, ownValue } from './namespace';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Read-only view of a locals scope that always reflects its current values.
|
|
@@ -16,9 +16,7 @@ export function liveLocalsView(
|
|
|
16
16
|
return new Proxy(createNamespace(), {
|
|
17
17
|
get: (_, key) =>
|
|
18
18
|
typeof key === 'string' ? ownValue(getValues(), key) : undefined,
|
|
19
|
-
has: (_, key) =>
|
|
20
|
-
typeof key === 'string' &&
|
|
21
|
-
Object.prototype.hasOwnProperty.call(getValues(), key),
|
|
19
|
+
has: (_, key) => typeof key === 'string' && hasOwn(getValues(), key),
|
|
22
20
|
ownKeys: () => Reflect.ownKeys(getValues()),
|
|
23
21
|
getOwnPropertyDescriptor: (_, key) => {
|
|
24
22
|
const desc = Object.getOwnPropertyDescriptor(getValues(), key);
|