@rohal12/spindle 0.18.1 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,6 +1,7 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../store';
3
3
  import { StoryInterface } from './StoryInterface';
4
+ import { TriggerDialogHost } from './TriggerDialogHost';
4
5
 
5
6
  export function App() {
6
7
  const storyData = useStoryStore((s) => s.storyData);
@@ -24,5 +25,10 @@ export function App() {
24
25
  return <div class="loading">Loading...</div>;
25
26
  }
26
27
 
27
- return <StoryInterface />;
28
+ return (
29
+ <>
30
+ <StoryInterface />
31
+ <TriggerDialogHost />
32
+ </>
33
+ );
28
34
  }
@@ -0,0 +1,42 @@
1
+ import { useState, useEffect, useCallback } from 'preact/hooks';
2
+ import { PassageDialog } from './PassageDialog';
3
+ import {
4
+ subscribeTriggerDialogs,
5
+ shiftDialogQueue,
6
+ dialogQueueLength,
7
+ } from '../triggers';
8
+
9
+ export function TriggerDialogHost() {
10
+ const [current, setCurrent] = useState<string | null>(null);
11
+
12
+ const advance = useCallback(() => {
13
+ const next = shiftDialogQueue();
14
+ setCurrent(next ?? null);
15
+ }, []);
16
+
17
+ useEffect(() => {
18
+ return subscribeTriggerDialogs(() => {
19
+ setCurrent((prev) => {
20
+ if (prev !== null) return prev; // already showing one
21
+ return shiftDialogQueue() ?? null;
22
+ });
23
+ });
24
+ }, []);
25
+
26
+ const handleClose = useCallback(() => {
27
+ setCurrent(null);
28
+ // Show next queued dialog after a tick
29
+ if (dialogQueueLength() > 0) {
30
+ requestAnimationFrame(advance);
31
+ }
32
+ }, [advance]);
33
+
34
+ if (!current) return null;
35
+
36
+ return (
37
+ <PassageDialog
38
+ passageName={current}
39
+ onClose={handleClose}
40
+ />
41
+ );
42
+ }
@@ -1,25 +1,40 @@
1
+ import { h, render } from 'preact';
1
2
  import { defineMacro } from '../../define-macro';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
2
8
 
3
9
  defineMacro({
4
10
  name: 'button',
5
11
  interpolate: true,
6
12
  render({ rawArgs, children = [] }, ctx) {
13
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
14
+
7
15
  const handleClick = () => {
8
- try {
9
- ctx.mutate(rawArgs);
10
- } catch (err) {
11
- console.error(
12
- `spindle: Error in {button ${rawArgs}}${ctx.sourceLocation()}:`,
13
- err,
14
- );
15
- }
16
+ // Render children into a detached DOM node — all macro side effects
17
+ // ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
18
+ // Wrap with locals context so @local variables from for-loops are available.
19
+ const container = document.createElement('div');
20
+ const vnode = h(
21
+ LocalsUpdateContext.Provider,
22
+ { value: { update: ctx.update, getValues: ctx.getValues } },
23
+ h(
24
+ LocalsValuesContext.Provider,
25
+ { value: ctx.getValues() },
26
+ renderNodes(children),
27
+ ),
28
+ );
29
+ render(vnode, container);
30
+ render(null, container);
16
31
  };
17
32
 
18
33
  ctx.useAction({
19
34
  type: 'button',
20
35
  key: rawArgs,
21
36
  authorId: ctx.id,
22
- label: ctx.collectText(children) || rawArgs,
37
+ label,
23
38
  perform: handleClick,
24
39
  });
25
40
 
@@ -29,7 +44,7 @@ defineMacro({
29
44
  class={ctx.cls}
30
45
  onClick={handleClick}
31
46
  >
32
- {ctx.renderInlineNodes(children)}
47
+ {label}
33
48
  </button>
34
49
  );
35
50
  },
@@ -3,21 +3,15 @@ import { PassageDialog } from '../PassageDialog';
3
3
 
4
4
  defineMacro({
5
5
  name: 'dialog',
6
- merged: true,
6
+ interpolate: true,
7
7
  render({ rawArgs, children = [] }, ctx) {
8
8
  const [open, setOpen] = ctx.hooks.useState(false);
9
9
 
10
- let passageName: string;
11
- try {
12
- const result = ctx.evaluate!(rawArgs);
13
- passageName = String(result);
14
- } catch {
15
- passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
16
- }
17
-
18
- const label = children.length
19
- ? ctx.renderInlineNodes(children)
20
- : passageName;
10
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
11
+ const passageName = ctx
12
+ .collectText(children)
13
+ .trim()
14
+ .replace(/^["']|["']$/g, '');
21
15
 
22
16
  return (
23
17
  <>
@@ -1,8 +1,10 @@
1
+ import { h, render } from 'preact';
1
2
  import { useStoryStore } from '../../store';
2
- import type { ASTNode } from '../../markup/ast';
3
- import { executeMutation } from '../../execute-mutation';
4
- import { collectText } from '../../utils/extract-text';
5
- import { currentSourceLocation } from '../../utils/source-location';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
6
8
  import { defineMacro } from '../../define-macro';
7
9
 
8
10
  function parseArgs(rawArgs: string): {
@@ -26,37 +28,23 @@ function parseArgs(rawArgs: string): {
26
28
  return { display: rawArgs.trim(), passage: null };
27
29
  }
28
30
 
29
- /**
30
- * Execute the children imperatively: walk AST for {set} and {do} macros.
31
- */
32
- function executeChildren(
33
- children: ASTNode[],
34
- mergedLocals: Record<string, unknown>,
35
- scopeUpdate: (key: string, value: unknown) => void,
31
+ function renderChildrenDetached(
32
+ children: import('../../markup/ast').ASTNode[],
33
+ getValues: () => Record<string, unknown>,
34
+ update: (key: string, value: unknown) => void,
36
35
  ) {
37
- for (const node of children) {
38
- if (node.type !== 'macro') continue;
39
- if (node.name === 'set') {
40
- try {
41
- executeMutation(node.rawArgs, mergedLocals, scopeUpdate);
42
- } catch (err) {
43
- console.error(
44
- `spindle: Error in {link} child {set}${currentSourceLocation()}:`,
45
- err,
46
- );
47
- }
48
- } else if (node.name === 'do') {
49
- const code = collectText(node.children);
50
- try {
51
- executeMutation(code, mergedLocals, scopeUpdate);
52
- } catch (err) {
53
- console.error(
54
- `spindle: Error in {link} child {do}${currentSourceLocation()}:`,
55
- err,
56
- );
57
- }
58
- }
59
- }
36
+ const container = document.createElement('div');
37
+ const vnode = h(
38
+ LocalsUpdateContext.Provider,
39
+ { value: { update, getValues } },
40
+ h(
41
+ LocalsValuesContext.Provider,
42
+ { value: getValues() },
43
+ renderNodes(children),
44
+ ),
45
+ );
46
+ render(vnode, container);
47
+ render(null, container);
60
48
  }
61
49
 
62
50
  defineMacro({
@@ -67,7 +55,7 @@ defineMacro({
67
55
 
68
56
  const handleClick = (e: Event) => {
69
57
  e.preventDefault();
70
- executeChildren(children, ctx.getValues(), ctx.update);
58
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
71
59
  if (passage) {
72
60
  useStoryStore.getState().navigate(passage);
73
61
  }
@@ -80,7 +68,7 @@ defineMacro({
80
68
  label: display,
81
69
  target: passage ?? undefined,
82
70
  perform: () => {
83
- executeChildren(children, ctx.getValues(), ctx.update);
71
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
84
72
  if (passage) {
85
73
  useStoryStore.getState().navigate(passage);
86
74
  }
@@ -0,0 +1,75 @@
1
+ import { defineMacro } from '../../define-macro';
2
+ import { addTrigger, removeTrigger } from '../../triggers';
3
+ import type { WatchOptions } from '../../triggers';
4
+
5
+ defineMacro({
6
+ name: 'watch',
7
+ render(props, ctx) {
8
+ const { hooks } = ctx;
9
+
10
+ // Parse: condition string + keyword options
11
+ const raw = props.rawArgs.trim();
12
+ // First quoted string is the condition
13
+ const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
14
+ if (!condMatch) return null;
15
+
16
+ const condition = condMatch[2]!;
17
+ const rest = raw.slice(condMatch[0].length);
18
+
19
+ const options: WatchOptions = {};
20
+ // Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
21
+ const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
22
+ let m: RegExpExecArray | null;
23
+ while ((m = kwRe.exec(rest)) !== null) {
24
+ const key = m[1] ?? m[5];
25
+ const val = m[2] ?? m[3] ?? m[4];
26
+ if (!key) continue;
27
+ switch (key) {
28
+ case 'goto':
29
+ options.goto = val;
30
+ break;
31
+ case 'dialog':
32
+ options.dialog = val;
33
+ break;
34
+ case 'run':
35
+ options.run = val;
36
+ break;
37
+ case 'name':
38
+ options.name = val;
39
+ break;
40
+ case 'priority':
41
+ options.priority = val ? Number(val) : 0;
42
+ break;
43
+ case 'once':
44
+ options.once = true;
45
+ break;
46
+ }
47
+ }
48
+
49
+ // Register during render (like {set}) — triggers survive navigation
50
+ // and are cleaned up via resetTriggers() on restart or {unwatch}.
51
+ const registered = hooks.useRef(false);
52
+ if (!registered.current) {
53
+ registered.current = true;
54
+ addTrigger(condition, options);
55
+ }
56
+
57
+ return null;
58
+ },
59
+ });
60
+
61
+ defineMacro({
62
+ name: 'unwatch',
63
+ render(props, ctx) {
64
+ const raw = props.rawArgs.trim();
65
+ const name = raw.replace(/^['"]|['"]$/g, '');
66
+
67
+ const ran = ctx.hooks.useRef(false);
68
+ if (!ran.current) {
69
+ ran.current = true;
70
+ removeTrigger(name);
71
+ }
72
+
73
+ return null;
74
+ },
75
+ });
package/src/index.tsx CHANGED
@@ -5,6 +5,7 @@ import { useStoryStore } from './store';
5
5
  import { installStoryAPI } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
8
+ import { checkTriggers, reinitTriggerState } from './triggers';
8
9
  import { loadSession } from './saves/save-manager';
9
10
  import {
10
11
  parseStoryVariables,
@@ -126,6 +127,21 @@ function boot() {
126
127
  }
127
128
  });
128
129
 
130
+ // Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
131
+ let prevVars = useStoryStore.getState().variables;
132
+ let prevHistoryIndex = useStoryStore.getState().historyIndex;
133
+ useStoryStore.subscribe((state) => {
134
+ if (state.variables !== prevVars) {
135
+ if (state.historyIndex === prevHistoryIndex) {
136
+ checkTriggers();
137
+ } else {
138
+ reinitTriggerState();
139
+ }
140
+ }
141
+ prevVars = state.variables;
142
+ prevHistoryIndex = state.historyIndex;
143
+ });
144
+
129
145
  // Warn if StoryInterface passage exists but doesn't contain {passage}
130
146
  const storyInterfacePassage = storyData.passages.get('StoryInterface');
131
147
  if (
@@ -31,3 +31,4 @@ import '../components/macros/Listbox';
31
31
  import '../components/macros/Cycle';
32
32
  import '../components/macros/Span';
33
33
  import '../components/macros/PassageDisplay';
34
+ import '../components/macros/Watch';
package/src/store.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import type { StoryData } from './parser';
10
10
  import type { SavePayload, SaveHistoryMoment } from './saves/types';
11
11
  import { executeStoryInit } from './story-init';
12
+ import { resetTriggers } from './triggers';
12
13
  import {
13
14
  initSaveSystem,
14
15
  startNewPlaythrough,
@@ -441,6 +442,7 @@ export const useStoryStore = create<StoryState>()(
441
442
  if (!startPassage) return;
442
443
 
443
444
  resetPRNG();
445
+ resetTriggers();
444
446
  const initialVars = deepClone(variableDefaults);
445
447
  resetModuleState(deepClone(initialVars));
446
448
 
package/src/story-api.ts CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  randomInt,
22
22
  snapshotPRNG,
23
23
  } from './prng';
24
+ import { addTrigger, removeTrigger } from './triggers';
25
+ import type { WatchOptions } from './triggers';
24
26
 
25
27
  export type { StoryAction };
26
28
 
@@ -65,6 +67,11 @@ export interface StoryAPI {
65
67
  on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void;
66
68
  on(event: 'variableChanged', callback: VariableChangedCallback): () => void;
67
69
  waitForActions(): Promise<StoryAction[]>;
70
+ watch(
71
+ condition: string,
72
+ callbackOrOptions: (() => void) | WatchOptions,
73
+ ): () => void;
74
+ unwatch(name: string): void;
68
75
  random(): number;
69
76
  randomInt(min: number, max: number): number;
70
77
  readonly config: {
@@ -263,6 +270,17 @@ function createStoryAPI(): StoryAPI {
263
270
  });
264
271
  },
265
272
 
273
+ watch(
274
+ condition: string,
275
+ callbackOrOptions: (() => void) | WatchOptions,
276
+ ): () => void {
277
+ return addTrigger(condition, callbackOrOptions);
278
+ },
279
+
280
+ unwatch(name: string): void {
281
+ removeTrigger(name);
282
+ },
283
+
266
284
  random(): number {
267
285
  return random();
268
286
  },
package/src/story-init.ts CHANGED
@@ -1,63 +1,30 @@
1
+ import { h, render } from 'preact';
1
2
  import { useStoryStore } from './store';
2
3
  import { tokenize } from './markup/tokenizer';
3
4
  import { buildAST } from './markup/ast';
4
- import { execute } from './expression';
5
- import type { ASTNode } from './markup/ast';
5
+ import { renderNodes } from './markup/render';
6
6
  import { setSaveTitlePassage } from './saves/save-manager';
7
- import { deepClone } from './class-registry';
8
7
 
9
8
  /**
10
- * Walk AST nodes from StoryInit and execute {set} and {do} imperatively
11
- * (no Preact rendering needed for initialization).
12
- */
13
- function walkAndExecute(
14
- nodes: ASTNode[],
15
- vars: Record<string, unknown>,
16
- temps: Record<string, unknown>,
17
- ) {
18
- for (const node of nodes) {
19
- if (node.type !== 'macro') continue;
20
-
21
- if (node.name === 'set') {
22
- execute(node.rawArgs, vars, temps);
23
- } else if (node.name === 'do') {
24
- const code = node.children
25
- .map((n) => (n.type === 'text' ? n.value : ''))
26
- .join('');
27
- execute(code, vars, temps);
28
- }
29
- }
30
- }
31
-
32
- /**
33
- * Execute the StoryInit passage: tokenize, parse, and run all {set}/{do} macros.
34
- * Called during boot and after restart() to ensure variables are initialized.
9
+ * Execute the StoryInit passage: tokenize, parse, and render all macros
10
+ * into a detached DOM node so their side effects fire through the normal
11
+ * Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
35
12
  */
36
13
  export function executeStoryInit() {
37
14
  const state = useStoryStore.getState();
38
15
  if (!state.storyData) return;
39
16
 
40
17
  const storyInit = state.storyData.passages.get('StoryInit');
41
- if (!storyInit) return;
42
-
43
- const tokens = tokenize(storyInit.content);
44
- const ast = buildAST(tokens);
45
-
46
- const vars = deepClone(state.variables);
47
- const temps = deepClone(state.temporary);
48
-
49
- walkAndExecute(ast, vars, temps);
50
-
51
- // Apply all changes to the store
52
- for (const key of Object.keys(vars)) {
53
- if (vars[key] !== state.variables[key]) {
54
- state.setVariable(key, vars[key]);
55
- }
56
- }
57
- for (const key of Object.keys(temps)) {
58
- if (temps[key] !== state.temporary[key]) {
59
- state.setTemporary(key, temps[key]);
60
- }
18
+ if (storyInit) {
19
+ const tokens = tokenize(storyInit.content);
20
+ const ast = buildAST(tokens);
21
+
22
+ const container = document.createElement('div');
23
+ render(
24
+ h(() => renderNodes(ast) as any, null),
25
+ container,
26
+ );
27
+ render(null, container);
61
28
  }
62
29
 
63
30
  // Register SaveTitle passage if it exists
@@ -0,0 +1,161 @@
1
+ import { evaluate, execute } from './expression';
2
+ import { useStoryStore } from './store';
3
+
4
+ export interface WatchOptions {
5
+ goto?: string;
6
+ dialog?: string;
7
+ run?: string;
8
+ once?: boolean;
9
+ name?: string;
10
+ priority?: number;
11
+ }
12
+
13
+ interface Trigger {
14
+ id: number;
15
+ name?: string;
16
+ condition: string;
17
+ callback?: () => void;
18
+ options?: WatchOptions;
19
+ lastResult: boolean;
20
+ priority: number;
21
+ }
22
+
23
+ let nextId = 0;
24
+ let triggers: Trigger[] = [];
25
+ let checking = false;
26
+
27
+ type DialogCallback = () => void;
28
+ let dialogQueue: string[] = [];
29
+ let dialogNotify: DialogCallback | null = null;
30
+
31
+ function evalCondition(condition: string): boolean {
32
+ const state = useStoryStore.getState();
33
+ try {
34
+ return !!evaluate(condition, state.variables, state.temporary);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export function addTrigger(
41
+ condition: string,
42
+ callbackOrOptions: (() => void) | WatchOptions,
43
+ ): () => void {
44
+ const id = nextId++;
45
+ const isCallback = typeof callbackOrOptions === 'function';
46
+ const options = isCallback ? undefined : callbackOrOptions;
47
+ const callback = isCallback ? callbackOrOptions : undefined;
48
+
49
+ const trigger: Trigger = {
50
+ id,
51
+ name: options?.name,
52
+ condition,
53
+ callback,
54
+ options,
55
+ lastResult: evalCondition(condition),
56
+ priority: options?.priority ?? 0,
57
+ };
58
+
59
+ triggers.push(trigger);
60
+ triggers.sort((a, b) => b.priority - a.priority);
61
+
62
+ return () => {
63
+ triggers = triggers.filter((t) => t.id !== id);
64
+ };
65
+ }
66
+
67
+ export function removeTrigger(name: string): void {
68
+ triggers = triggers.filter((t) => t.name !== name);
69
+ }
70
+
71
+ function fireTrigger(trigger: Trigger): void {
72
+ const { callback, options } = trigger;
73
+
74
+ if (callback) {
75
+ callback();
76
+ return;
77
+ }
78
+
79
+ if (!options) return;
80
+
81
+ if (options.run) {
82
+ const state = useStoryStore.getState();
83
+ execute(options.run, state.variables, state.temporary);
84
+ }
85
+
86
+ if (options.dialog) {
87
+ dialogQueue.push(options.dialog);
88
+ dialogNotify?.();
89
+ }
90
+
91
+ if (options.goto) {
92
+ useStoryStore.getState().navigate(options.goto);
93
+ }
94
+ }
95
+
96
+ const MAX_RECHECK_DEPTH = 10;
97
+
98
+ export function checkTriggers(): void {
99
+ if (checking) return;
100
+ checking = true;
101
+
102
+ try {
103
+ for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
104
+ let anyFired = false;
105
+
106
+ // Snapshot triggers list — firing may remove `once` triggers
107
+ const current = [...triggers];
108
+ for (const trigger of current) {
109
+ // Skip if removed during this cycle
110
+ if (!triggers.includes(trigger)) continue;
111
+
112
+ const result = evalCondition(trigger.condition);
113
+ const wasFalse = !trigger.lastResult;
114
+ trigger.lastResult = result;
115
+
116
+ if (result && wasFalse) {
117
+ anyFired = true;
118
+
119
+ if (trigger.options?.once) {
120
+ triggers = triggers.filter((t) => t.id !== trigger.id);
121
+ }
122
+
123
+ fireTrigger(trigger);
124
+ }
125
+ }
126
+
127
+ if (!anyFired) break;
128
+ }
129
+ } finally {
130
+ checking = false;
131
+ }
132
+ }
133
+
134
+ export function reinitTriggerState(): void {
135
+ for (const trigger of triggers) {
136
+ trigger.lastResult = evalCondition(trigger.condition);
137
+ }
138
+ }
139
+
140
+ export function resetTriggers(): void {
141
+ triggers = [];
142
+ dialogQueue = [];
143
+ nextId = 0;
144
+ }
145
+
146
+ export function subscribeTriggerDialogs(cb: () => void): () => void {
147
+ dialogNotify = cb;
148
+ // Flush any queued dialogs
149
+ if (dialogQueue.length > 0) cb();
150
+ return () => {
151
+ if (dialogNotify === cb) dialogNotify = null;
152
+ };
153
+ }
154
+
155
+ export function shiftDialogQueue(): string | undefined {
156
+ return dialogQueue.shift();
157
+ }
158
+
159
+ export function dialogQueueLength(): number {
160
+ return dialogQueue.length;
161
+ }