@rohal12/spindle 0.52.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.0",
3
+ "version": "0.52.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -29,9 +29,35 @@ export interface StoryAction {
29
29
  perform: (value?: unknown) => void;
30
30
  }
31
31
 
32
- const actions = new Map<string, StoryAction>();
32
+ /** A mounted control's hold on its action ID. */
33
+ export interface ActionRegistration {
34
+ /**
35
+ * Replace what the action exposes, in place: one `actionsChanged`
36
+ * notification, and it keeps its position in getActions().
37
+ */
38
+ update(action: StoryAction): void;
39
+ /** Remove this registration, and nothing that has taken its ID since. */
40
+ unregister(): void;
41
+ }
42
+
43
+ interface Entry {
44
+ action: StoryAction;
45
+ }
46
+
47
+ /**
48
+ * The registrations holding each ID, oldest first; the newest is the one
49
+ * listed. Generated IDs don't collide, but author IDs can, and one control's
50
+ * cleanup must not remove another control holding the same ID (#233).
51
+ */
52
+ const actions = new Map<string, Entry[]>();
33
53
  const idCounters = new Map<string, number>();
34
54
 
55
+ /**
56
+ * The counters reset on every navigation, so a passage's controls get the
57
+ * same IDs each time it is shown. Controls that stay mounted across
58
+ * navigations (e.g. in StoryInterface) keep theirs, so allocation skips every
59
+ * ID still registered (#233).
60
+ */
35
61
  export function generateActionId(
36
62
  type: ActionType,
37
63
  key: string,
@@ -40,35 +66,61 @@ export function generateActionId(
40
66
  if (authorId) return authorId;
41
67
 
42
68
  const base = `${type}:${key}`;
43
- const count = (idCounters.get(base) ?? 0) + 1;
69
+ let count = idCounters.get(base) ?? 0;
70
+ let id: string;
71
+ do {
72
+ count++;
73
+ id = count === 1 ? base : `${base}:${count}`;
74
+ } while (actions.has(id));
44
75
  idCounters.set(base, count);
45
- return count === 1 ? base : `${base}:${count}`;
76
+ return id;
46
77
  }
47
78
 
48
- export function registerAction(action: StoryAction): () => void {
49
- actions.set(action.id, action);
79
+ export function registerAction(action: StoryAction): ActionRegistration {
80
+ const id = action.id;
81
+ const entry: Entry = { action };
82
+ const entries = actions.get(id);
83
+ if (entries) {
84
+ console.warn(
85
+ `spindle: two mounted controls share the action ID "${id}"; ` +
86
+ 'Story.getActions() lists only the newer one.',
87
+ );
88
+ entries.push(entry);
89
+ } else {
90
+ actions.set(id, [entry]);
91
+ }
50
92
  notify();
51
- return () => {
52
- actions.delete(action.id);
53
- notify();
54
- };
55
- }
56
93
 
57
- export function updateAction(action: StoryAction): () => void {
58
- actions.set(action.id, action);
59
- notify();
60
- return () => {
61
- actions.delete(action.id);
62
- notify();
94
+ return {
95
+ update(next) {
96
+ const current = actions.get(id);
97
+ if (!current?.includes(entry)) return;
98
+ entry.action = next;
99
+ // An older registration of a shared ID isn't listed
100
+ if (current[current.length - 1] === entry) notify();
101
+ },
102
+ unregister() {
103
+ const current = actions.get(id);
104
+ const index = current ? current.indexOf(entry) : -1;
105
+ if (!current || index === -1) return;
106
+ current.splice(index, 1);
107
+ if (current.length === 0) actions.delete(id);
108
+ if (index === current.length) notify(); // it was the listed one
109
+ },
63
110
  };
64
111
  }
65
112
 
66
113
  export function getActions(): StoryAction[] {
67
- return Array.from(actions.values());
114
+ return Array.from(actions.values(), listed);
68
115
  }
69
116
 
70
117
  export function getAction(id: string): StoryAction | undefined {
71
- return actions.get(id);
118
+ const entries = actions.get(id);
119
+ return entries && listed(entries);
120
+ }
121
+
122
+ function listed(entries: Entry[]): StoryAction {
123
+ return entries[entries.length - 1]!.action;
72
124
  }
73
125
 
74
126
  export function clearActions(): void {
@@ -7,6 +7,13 @@ import { defineMacro } from '../../define-macro';
7
7
  import { MacroError } from './MacroError';
8
8
  import { checkVariableName } from '../../utils/namespace';
9
9
 
10
+ /**
11
+ * The previous output before the first successful evaluation. Distinct from
12
+ * every value an expression can produce, so the first result is always
13
+ * applied, even `undefined`; a first evaluation that throws leaves it unset.
14
+ */
15
+ const UNSET: unique symbol = Symbol('unset');
16
+
10
17
  function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
11
18
  const trimmed = rawArgs.trim();
12
19
 
@@ -44,10 +51,11 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
44
51
  /**
45
52
  * Evaluate `expr` against the current values — the store's state and the
46
53
  * enclosing locals scope's live values (`getLocals`) — and write the result
47
- * to the target if it changed. Reading live values rather than the render's
48
- * snapshot means the first computation sees a local a preceding {set} just
49
- * assigned (the scope's context value only catches up on its re-render), and
50
- * the first computation and later recomputations read the same source.
54
+ * to the target if it differs from the previous output (always, the first
55
+ * time). Reading live values rather than the render's snapshot means the
56
+ * first computation sees a local a preceding {set} just assigned (the scope's
57
+ * context value only catches up on its re-render), and the first computation
58
+ * and later recomputations read the same source.
51
59
  */
52
60
  function computeAndApply(
53
61
  expr: string,
@@ -114,7 +122,7 @@ defineMacro({
114
122
  const name = target.slice(1);
115
123
  const localsUpdate = isLocal ? ctx.update : null;
116
124
 
117
- const prevOutput = ctx.hooks.useRef<unknown>(undefined);
125
+ const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
118
126
 
119
127
  const ran = ctx.hooks.useRef(false);
120
128
  if (!ran.current) {
@@ -5,7 +5,8 @@ defineMacro({
5
5
  name: 'set',
6
6
  render({ rawArgs }, ctx) {
7
7
  const ran = ctx.hooks.useRef(false);
8
- const error = ctx.hooks.useRef<unknown>(null);
8
+ // Boxed: anything can be thrown, including null and other falsy values
9
+ const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
9
10
 
10
11
  if (!ran.current) {
11
12
  ran.current = true;
@@ -13,7 +14,7 @@ defineMacro({
13
14
  try {
14
15
  ctx.mutate(rawArgs);
15
16
  } catch (err) {
16
- error.current = err;
17
+ failure.current = { error: err };
17
18
  console.error(
18
19
  `spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
19
20
  err,
@@ -21,11 +22,11 @@ defineMacro({
21
22
  }
22
23
  }
23
24
 
24
- if (error.current) {
25
+ if (failure.current) {
25
26
  return (
26
27
  <MacroError
27
28
  macro="set"
28
- error={error.current}
29
+ error={failure.current.error}
29
30
  />
30
31
  );
31
32
  }
@@ -6,7 +6,8 @@ defineMacro({
6
6
  name: 'unset',
7
7
  render({ rawArgs }, ctx) {
8
8
  const ran = ctx.hooks.useRef(false);
9
- const error = ctx.hooks.useRef<unknown>(null);
9
+ // Boxed: anything can be thrown, including null and other falsy values
10
+ const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
10
11
 
11
12
  if (!ran.current) {
12
13
  ran.current = true;
@@ -29,7 +30,7 @@ defineMacro({
29
30
  );
30
31
  }
31
32
  } catch (err) {
32
- error.current = err;
33
+ failure.current = { error: err };
33
34
  console.error(
34
35
  `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
35
36
  err,
@@ -37,11 +38,11 @@ defineMacro({
37
38
  }
38
39
  }
39
40
 
40
- if (error.current) {
41
+ if (failure.current) {
41
42
  return (
42
43
  <MacroError
43
44
  macro="unset"
44
- error={error.current}
45
+ error={failure.current.error}
45
46
  />
46
47
  );
47
48
  }
@@ -82,7 +82,7 @@ export function frozenCopy<T>(value: T): T {
82
82
  * replaced as a whole, since their elements have no stable identity to
83
83
  * merge by (a shift moves every index).
84
84
  */
85
- function isMergeable(value: unknown): value is Record<string, unknown> {
85
+ export function isMergeable(value: unknown): value is Record<string, unknown> {
86
86
  return (
87
87
  typeof value === 'object' &&
88
88
  value !== null &&
package/src/expression.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isNamespace,
11
11
  type Namespace,
12
12
  } from './utils/namespace';
13
+ import { countOf, type Counts } from './utils/counts';
13
14
 
14
15
  interface ExpressionFns {
15
16
  currentPassage: () => Passage | undefined;
@@ -116,8 +117,8 @@ function getOrCompile(key: string, body: string): CompiledExpression {
116
117
  }
117
118
 
118
119
  let cachedFns: ExpressionFns | null = null;
119
- let cachedVisitCounts: Record<string, number> | null = null;
120
- let cachedRenderCounts: Record<string, number> | null = null;
120
+ let cachedVisitCounts: Counts | null = null;
121
+ let cachedRenderCounts: Counts | null = null;
121
122
 
122
123
  export function buildExpressionFns() {
123
124
  const state = useStoryStore.getState();
@@ -132,7 +133,7 @@ export function buildExpressionFns() {
132
133
  }
133
134
 
134
135
  const visited = (name?: string): number =>
135
- visitCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
136
+ countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
136
137
  const hasVisited = (name?: string): boolean => visited(name) > 0;
137
138
  const hasVisitedAny = (...names: string[]): boolean =>
138
139
  names.some((n) => visited(n) > 0);
@@ -140,7 +141,7 @@ export function buildExpressionFns() {
140
141
  names.every((n) => visited(n) > 0);
141
142
 
142
143
  const rendered = (name?: string): number =>
143
- renderCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
144
+ countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
144
145
  const hasRendered = (name?: string): boolean => rendered(name) > 0;
145
146
  const hasRenderedAny = (...names: string[]): boolean =>
146
147
  names.some((n) => rendered(n) > 0);
@@ -1,8 +1,8 @@
1
1
  import { useLayoutEffect, useRef } from 'preact/hooks';
2
2
  import {
3
3
  registerAction,
4
- updateAction,
5
4
  generateActionId,
5
+ type ActionRegistration,
6
6
  type ActionType,
7
7
  type StoryAction,
8
8
  } from '../action-registry';
@@ -47,8 +47,14 @@ export function useAction(opts: UseActionOptions): string {
47
47
  return action;
48
48
  };
49
49
 
50
- // Registered for the component's lifetime.
51
- useLayoutEffect(() => registerAction(buildAction()), [id]);
50
+ // Registered for the component's lifetime. Unregistering removes only this
51
+ // registration, never another control's holding the same ID (#233).
52
+ const registration = useRef<ActionRegistration | null>(null);
53
+ useLayoutEffect(() => {
54
+ const own = registerAction(buildAction());
55
+ registration.current = own;
56
+ return () => own.unregister();
57
+ }, [id]);
52
58
 
53
59
  // When what the action exposes changes (e.g. an input's value), replace it
54
60
  // in place: one `actionsChanged` notification, and it keeps its position in
@@ -60,7 +66,7 @@ export function useAction(opts: UseActionOptions): string {
60
66
  registered.current = true; // the registration above is current
61
67
  return;
62
68
  }
63
- updateAction(buildAction());
69
+ registration.current?.update(buildAction());
64
70
  }, [
65
71
  opts.type,
66
72
  opts.label,
package/src/index.tsx CHANGED
@@ -201,7 +201,8 @@ export function boot() {
201
201
  }
202
202
 
203
203
  // Reset action ID counters on every navigation (the passage remounts even
204
- // when its name is unchanged)
204
+ // when its name is unchanged). Controls that stay mounted, e.g. in
205
+ // StoryInterface, keep their IDs: allocation skips IDs still registered.
205
206
  let prevNavigationId = useStoryStore.getState().navigationId;
206
207
  useStoryStore.subscribe((state) => {
207
208
  if (state.navigationId !== prevNavigationId) {
package/src/markup/ast.ts CHANGED
@@ -242,8 +242,13 @@ export function buildAST(tokens: Token[]): ASTNode[] {
242
242
  }
243
243
 
244
244
  // Handle branch macros (elseif/else, case/default, next)
245
- if (BRANCH_PARENT[name]) {
246
- const expectedParent = BRANCH_PARENT[name]!;
245
+ const expectedParent = Object.prototype.hasOwnProperty.call(
246
+ BRANCH_PARENT,
247
+ name,
248
+ )
249
+ ? BRANCH_PARENT[name]
250
+ : undefined;
251
+ if (expectedParent) {
247
252
  const topNode =
248
253
  stack.length > 0 ? stack[stack.length - 1]!.node : null;
249
254
  if (
package/src/prng.ts CHANGED
@@ -40,6 +40,8 @@ let enabled = false;
40
40
  let currentSeed = '';
41
41
  let currentPull = 0;
42
42
  let generator: (() => number) | null = null;
43
+ /** Incremented whenever the generator is replaced (init, restore, reset). */
44
+ let generation = 0;
43
45
 
44
46
  // ---------------------------------------------------------------------------
45
47
  // Public API
@@ -71,6 +73,7 @@ export function initPRNG(seed?: string, useEntropy = true): void {
71
73
  currentPull = 0;
72
74
  generator = mulberry32(hashSeed(resolvedSeed));
73
75
  enabled = true;
76
+ generation++;
74
77
  }
75
78
 
76
79
  /**
@@ -78,6 +81,12 @@ export function initPRNG(seed?: string, useEntropy = true): void {
78
81
  * Recreates the generator from the seed and fast-forwards to the saved pull count.
79
82
  */
80
83
  export function restorePRNG(seed: string, pull: number): void {
84
+ generation++;
85
+ moveTo(seed, pull);
86
+ }
87
+
88
+ /** Recreate the generator at `pull` pulls from `seed`. */
89
+ function moveTo(seed: string, pull: number): void {
81
90
  currentSeed = seed;
82
91
  // Fast-forward in constant time: each pull adds MULBERRY_STEP to the
83
92
  // 32-bit state, so `pull` pulls add pull × MULBERRY_STEP (mod 2^32). A
@@ -90,12 +99,36 @@ export function restorePRNG(seed: string, pull: number): void {
90
99
 
91
100
  /** Disable the PRNG (used on restart before StoryInit re-enables it). */
92
101
  export function resetPRNG(): void {
102
+ generation++;
93
103
  enabled = false;
94
104
  currentSeed = '';
95
105
  currentPull = 0;
96
106
  generator = null;
97
107
  }
98
108
 
109
+ /**
110
+ * Run `fn` without its draws advancing the sequence: the draws it makes are
111
+ * taken back afterwards (also when it throws), so the next draws return the
112
+ * same values again. Save and load event handlers run like this, so that
113
+ * saving or loading never shifts the story's random sequence (a load replays
114
+ * the saved passage from its entry state, without the save's handlers). A
115
+ * generator `fn` replaced (a restart or load it started) is kept.
116
+ */
117
+ export function withoutDraws<T>(fn: () => T): T {
118
+ const start = { generation, seed: currentSeed, pull: currentPull };
119
+ try {
120
+ return fn();
121
+ } finally {
122
+ if (
123
+ enabled &&
124
+ generation === start.generation &&
125
+ currentPull !== start.pull
126
+ ) {
127
+ moveTo(start.seed, start.pull);
128
+ }
129
+ }
130
+ }
131
+
99
132
  /** Returns the current PRNG state for snapshotting, or null if disabled. */
100
133
  export function snapshotPRNG(): PRNGSnapshot | null {
101
134
  if (!enabled) return null;
@@ -11,6 +11,7 @@ import { isSaveExport, isSavePayload } from './types';
11
11
  import { getBackend, resetBackend } from './storage';
12
12
  import { deepClone, serialize, deserialize } from '../class-registry';
13
13
  import { emit } from '../event-emitter';
14
+ import { withoutDraws } from '../prng';
14
15
 
15
16
  type TitleGenerator = (payload: SavePayload) => string;
16
17
 
@@ -220,6 +221,10 @@ export function establishPlaythrough(
220
221
  * that captures the payload after them, so the capture can tell which
221
222
  * variables the hooks changed (the store's `beginSave`).
222
223
  *
224
+ * Random numbers the hooks draw do not advance the story's PRNG (see
225
+ * withoutDraws()): the game goes on with the sequence a load of the save
226
+ * continues with, which replays the saved passage without the hooks.
227
+ *
223
228
  * The hook and capture run synchronously, but an error they throw (a failing
224
229
  * hook, an unserializable payload) rejects the returned promise like a failed
225
230
  * write rather than escaping to the caller.
@@ -232,10 +237,10 @@ export function saveWithHooks<T>(
232
237
  ): Promise<T> {
233
238
  try {
234
239
  const capture = beginCapture();
235
- emit('beforesave', slot, custom);
240
+ withoutDraws(() => emit('beforesave', slot, custom));
236
241
  const payload = capture();
237
242
  return write(payload).then((result) => {
238
- emit('aftersave', slot);
243
+ withoutDraws(() => emit('aftersave', slot));
239
244
  return result;
240
245
  });
241
246
  } catch (err) {
package/src/settings.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from './store';
2
+ import { createNamespace, ownValue } from './utils/namespace';
2
3
 
3
4
  export interface ToggleConfig {
4
5
  label: string;
@@ -25,7 +26,9 @@ export type SettingDef =
25
26
  | { type: 'range'; config: RangeConfig };
26
27
 
27
28
  const definitions = new Map<string, SettingDef>();
28
- let values: Record<string, unknown> = {};
29
+ // No prototype, so a setting may be named `constructor` or `__proto__`:
30
+ // every name is plain storage (see utils/namespace.ts)
31
+ let values: Record<string, unknown> = createNamespace();
29
32
  let storageLoaded = false;
30
33
  let unsubscribeStoryData: (() => void) | null = null;
31
34
 
@@ -65,7 +68,7 @@ function loadFromStorage(): void {
65
68
  parsed !== null &&
66
69
  !Array.isArray(parsed)
67
70
  ) {
68
- values = { ...values, ...parsed };
71
+ values = createNamespace(values, parsed);
69
72
  }
70
73
  }
71
74
  } catch {
@@ -76,7 +79,7 @@ function loadFromStorage(): void {
76
79
  export const settings = {
77
80
  addToggle(name: string, config: ToggleConfig): void {
78
81
  definitions.set(name, { type: 'toggle', config });
79
- if (!(name in values)) {
82
+ if (!Object.prototype.hasOwnProperty.call(values, name)) {
80
83
  values[name] = config.default;
81
84
  }
82
85
  loadFromStorage();
@@ -84,7 +87,7 @@ export const settings = {
84
87
 
85
88
  addList(name: string, config: ListConfig): void {
86
89
  definitions.set(name, { type: 'list', config });
87
- if (!(name in values)) {
90
+ if (!Object.prototype.hasOwnProperty.call(values, name)) {
88
91
  values[name] = config.default;
89
92
  }
90
93
  loadFromStorage();
@@ -92,28 +95,28 @@ export const settings = {
92
95
 
93
96
  addRange(name: string, config: RangeConfig): void {
94
97
  definitions.set(name, { type: 'range', config });
95
- if (!(name in values)) {
98
+ if (!Object.prototype.hasOwnProperty.call(values, name)) {
96
99
  values[name] = config.default;
97
100
  }
98
101
  loadFromStorage();
99
102
  },
100
103
 
101
104
  get(name: string): unknown {
102
- return values[name];
105
+ return ownValue(values, name);
103
106
  },
104
107
 
105
108
  getToggle(name: string): boolean {
106
- const v = values[name];
109
+ const v = ownValue(values, name);
107
110
  return typeof v === 'boolean' ? v : false;
108
111
  },
109
112
 
110
113
  getList(name: string): string {
111
- const v = values[name];
114
+ const v = ownValue(values, name);
112
115
  return typeof v === 'string' ? v : '';
113
116
  },
114
117
 
115
118
  getRange(name: string): number {
116
- const v = values[name];
119
+ const v = ownValue(values, name);
117
120
  return typeof v === 'number' ? v : 0;
118
121
  },
119
122