@rohal12/spindle 0.52.1 → 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.
@@ -1,8 +1,6 @@
1
- import { useContext } from 'preact/hooks';
2
- import { useStoryStore } from '../../store';
3
1
  import { evaluate } from '../../expression';
4
- import { LocalsValuesContext } from '../../markup/render';
5
2
  import { useInterpolate } from '../../hooks/use-interpolate';
3
+ import { useMergedLocals } from '../../hooks/use-merged-locals';
6
4
 
7
5
  interface ExprDisplayProps {
8
6
  expression: string;
@@ -14,10 +12,7 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
14
12
  const resolve = useInterpolate();
15
13
  className = resolve(className);
16
14
  id = resolve(id);
17
- const localsValues = useContext(LocalsValuesContext);
18
- const variables = useStoryStore((s) => s.variables);
19
- const temporary = useStoryStore((s) => s.temporary);
20
- const transient = useStoryStore((s) => s.transient);
15
+ const [variables, temporary, localsValues, transient] = useMergedLocals();
21
16
 
22
17
  let display: string;
23
18
  try {
@@ -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
- perform: useStoryStore((s) => s.save),
113
- title: hotkeyTitle(
114
- 'Quick Save',
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
- const load = useStoryStore((s) => s.load);
127
- const hasSave = useStoryStore((s) => s.hasSave);
128
- const key = useStoryStore((s) => s.quickLoadKey);
129
- useStoryStore((s) => s.knownSaves);
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', key),
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 = useStoryStore((s) => s.currentPassage);
73
- const navigationId = useStoryStore((s) => s.navigationId);
74
- const storyData = useStoryStore((s) => s.storyData);
75
- const renderDeferred = useStoryStore((s) => s.renderDeferred);
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 = useStoryStore((s) => s.storyData);
65
- const playthroughId = useStoryStore((s) => s.playthroughId);
66
- const beginSave = useStoryStore((s) => s.beginSave);
67
- const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
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 { RESERVED_NAME, ownValue } from '../../utils/namespace';
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 === 'variable'
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
- if (root === RESERVED_NAME) {
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
 
@@ -57,16 +57,22 @@ export function on<E extends StoryEvent>(
57
57
  };
58
58
  }
59
59
 
60
- export function emit<E extends StoryEvent>(
61
- event: E,
62
- ...args: Parameters<EventMap[E]>
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
- (cb as Function)(...args);
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
- const set = listeners.get(event);
82
- if (!set) return;
83
- for (const cb of [...set]) {
87
+ forEachListener(event, (cb) => {
84
88
  try {
85
- (cb as Function)(...args);
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
- if (name === RESERVED_NAME) {
77
- throw new SyntaxError(
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 visited = (name?: string): number =>
136
- countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
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 { useStoryStore } from '../store';
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 = useStoryStore((s) => s.variables);
16
- const temporary = useStoryStore((s) => s.temporary);
17
- const transient = useStoryStore((s) => s.transient);
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
- definitions.set(name, { type: 'toggle', config });
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
- definitions.set(name, { type: 'list', config });
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
- definitions.set(name, { type: 'range', config });
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
- const v = ownValue(values, name);
110
- return typeof v === 'boolean' ? v : false;
110
+ return valueOf(name, false);
111
111
  },
112
112
 
113
113
  getList(name: string): string {
114
- const v = ownValue(values, name);
115
- return typeof v === 'string' ? v : '';
114
+ return valueOf(name, '');
116
115
  },
117
116
 
118
117
  getRange(name: string): number {
119
- const v = ownValue(values, name);
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 { countOf } from './utils/counts';
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
- let hasChanges = false;
87
-
88
- // Check $variables
89
- const allVarKeys = new Set([
90
- ...Object.keys(prevVars),
91
- ...Object.keys(state.variables),
92
- ]);
93
- for (const key of allVarKeys) {
94
- if (state.variables[key] !== prevVars[key]) {
95
- changed[key] = { from: prevVars[key], to: state.variables[key] };
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
- // Check %transient
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 (hasChanges) {
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[key];
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(name?: string): number {
361
- const state = useStoryStore.getState();
362
- return countOf(state.visitCounts, name ?? state.currentPassage);
363
- },
364
-
365
- hasVisited(name?: string): boolean {
366
- return this.visited(name) > 0;
367
- },
368
-
369
- hasVisitedAny(...names: string[]): boolean {
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 || '';
@@ -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, RESERVED_NAME } from './utils/namespace';
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
- if (name === RESERVED_NAME) {
84
- throw new Error(
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
- if (rootName === RESERVED_NAME) {
138
- return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
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;
@@ -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);