@rohal12/spindle 0.42.0 → 0.43.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.
@@ -29,7 +29,7 @@ export interface MacroToken {
29
29
  export interface VariableToken {
30
30
  type: 'variable';
31
31
  name: string;
32
- scope: 'variable' | 'temporary' | 'local';
32
+ scope: 'variable' | 'temporary' | 'local' | 'transient';
33
33
  className?: string;
34
34
  id?: string;
35
35
  start: number;
@@ -499,6 +499,51 @@ export function tokenize(input: string): Token[] {
499
499
  continue;
500
500
  }
501
501
 
502
+ if (charAfter === '%') {
503
+ // {.class#id %transient.field} or {.class %expr[...]}
504
+ i = afterSelectors + 1;
505
+ const nameStart = i;
506
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
507
+ const name = input.slice(nameStart, i);
508
+
509
+ if (input[i] === '}') {
510
+ i++; // skip }
511
+ const token: VariableToken = {
512
+ type: 'variable',
513
+ name,
514
+ scope: 'transient',
515
+ start,
516
+ end: i,
517
+ };
518
+ if (className) token.className = className;
519
+ if (id) token.id = id;
520
+ tokens.push(token);
521
+ textStart = i;
522
+ continue;
523
+ }
524
+ // Complex expression — scan for balanced closing }
525
+ const closeIdx_pct = scanBalancedBrace(input, nameStart);
526
+ if (closeIdx_pct !== -1) {
527
+ const expression = input.slice(afterSelectors, closeIdx_pct);
528
+ i = closeIdx_pct + 1;
529
+ const token: ExpressionToken = {
530
+ type: 'expression',
531
+ expression,
532
+ start,
533
+ end: i,
534
+ };
535
+ if (className) token.className = className;
536
+ if (id) token.id = id;
537
+ tokens.push(token);
538
+ textStart = i;
539
+ continue;
540
+ }
541
+ // Unbalanced — treat as text
542
+ i = start + 1;
543
+ textStart = start;
544
+ continue;
545
+ }
546
+
502
547
  if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
503
548
  // {.class#id macroName args}
504
549
  i = afterSelectors;
@@ -663,6 +708,46 @@ export function tokenize(input: string): Token[] {
663
708
  continue;
664
709
  }
665
710
 
711
+ // {%transient.field} or {%expr[...]}
712
+ if (nextChar === '%') {
713
+ flushText(i);
714
+ i += 2;
715
+ const nameStart = i;
716
+ while (i < input.length && /[\w.]/.test(input[i]!)) i++;
717
+ const name = input.slice(nameStart, i);
718
+
719
+ if (input[i] === '}') {
720
+ i++; // skip }
721
+ tokens.push({
722
+ type: 'variable',
723
+ name,
724
+ scope: 'transient',
725
+ start,
726
+ end: i,
727
+ });
728
+ textStart = i;
729
+ continue;
730
+ }
731
+ // Complex expression — scan for balanced closing }
732
+ const closeIdx = scanBalancedBrace(input, nameStart);
733
+ if (closeIdx !== -1) {
734
+ const expression = input.slice(start + 1, closeIdx);
735
+ i = closeIdx + 1;
736
+ tokens.push({
737
+ type: 'expression',
738
+ expression,
739
+ start,
740
+ end: i,
741
+ });
742
+ textStart = i;
743
+ continue;
744
+ }
745
+ // Unbalanced — treat as text
746
+ i = start + 1;
747
+ textStart = start;
748
+ continue;
749
+ }
750
+
666
751
  // {macro ...} or {/macro} — but not bare { that's just text
667
752
  // Must start with a letter or /
668
753
  if (
package/src/store.ts CHANGED
@@ -42,6 +42,7 @@ const SPECIAL_PASSAGES = new Set([
42
42
  'StoryInit',
43
43
  'StoryInterface',
44
44
  'StoryVariables',
45
+ 'StoryTransients',
45
46
  'StoryLoading',
46
47
  'SaveTitle',
47
48
  'PassageReady',
@@ -237,6 +238,8 @@ export interface StoryState {
237
238
  currentPassage: string;
238
239
  variables: Record<string, unknown>;
239
240
  variableDefaults: Record<string, unknown>;
241
+ transient: Record<string, unknown>;
242
+ transientDefaults: Record<string, unknown>;
240
243
  temporary: Record<string, unknown>;
241
244
  history: HistoryMoment[];
242
245
  historyIndex: number;
@@ -256,6 +259,7 @@ export interface StoryState {
256
259
  init: (
257
260
  storyData: StoryData,
258
261
  variableDefaults?: Record<string, unknown>,
262
+ transientDefaults?: Record<string, unknown>,
259
263
  ) => void;
260
264
  navigate: (passageName: string) => void;
261
265
  goBack: () => void;
@@ -264,6 +268,8 @@ export interface StoryState {
264
268
  setTemporary: (name: string, value: unknown) => void;
265
269
  deleteVariable: (name: string) => void;
266
270
  deleteTemporary: (name: string) => void;
271
+ setTransient: (name: string, value: unknown) => void;
272
+ deleteTransient: (name: string) => void;
267
273
  trackRender: (passageName: string) => void;
268
274
  restart: () => void;
269
275
  save: (slot?: string, custom?: Record<string, unknown>) => void;
@@ -291,6 +297,8 @@ export const useStoryStore = create<StoryState>()(
291
297
  currentPassage: '',
292
298
  variables: {},
293
299
  variableDefaults: {},
300
+ transient: {},
301
+ transientDefaults: {},
294
302
  temporary: {},
295
303
  history: [],
296
304
  historyIndex: -1,
@@ -315,6 +323,7 @@ export const useStoryStore = create<StoryState>()(
315
323
  init: (
316
324
  storyData: StoryData,
317
325
  variableDefaults: Record<string, unknown> = {},
326
+ transientDefaults: Record<string, unknown> = {},
318
327
  ) => {
319
328
  const startPassage = storyData.passagesById.get(storyData.startNode);
320
329
  if (!startPassage) {
@@ -331,6 +340,8 @@ export const useStoryStore = create<StoryState>()(
331
340
  state.currentPassage = startPassage.name;
332
341
  state.variables = initialVars;
333
342
  state.variableDefaults = variableDefaults;
343
+ state.transient = deepClone(transientDefaults);
344
+ state.transientDefaults = transientDefaults;
334
345
  state.temporary = {};
335
346
  state.history = [
336
347
  {
@@ -516,6 +527,18 @@ export const useStoryStore = create<StoryState>()(
516
527
  });
517
528
  },
518
529
 
530
+ setTransient: (name: string, value: unknown) => {
531
+ set((state) => {
532
+ state.transient[name] = value;
533
+ });
534
+ },
535
+
536
+ deleteTransient: (name: string) => {
537
+ set((state) => {
538
+ delete state.transient[name];
539
+ });
540
+ },
541
+
519
542
  trackRender: (passageName: string) => {
520
543
  set((state) => {
521
544
  state.renderCounts[passageName] =
@@ -524,7 +547,7 @@ export const useStoryStore = create<StoryState>()(
524
547
  },
525
548
 
526
549
  restart: () => {
527
- const { storyData, variableDefaults } = get();
550
+ const { storyData, variableDefaults, transientDefaults } = get();
528
551
  if (!storyData) return;
529
552
 
530
553
  const startPassage = storyData.passagesById.get(storyData.startNode);
@@ -551,6 +574,7 @@ export const useStoryStore = create<StoryState>()(
551
574
  set((state) => {
552
575
  state.currentPassage = startPassage.name;
553
576
  state.variables = initialVars;
577
+ state.transient = deepClone(transientDefaults);
554
578
  state.temporary = {};
555
579
  state.history = [
556
580
  {
@@ -804,6 +828,7 @@ export const useStoryStore = create<StoryState>()(
804
828
  state.visitCounts = payload.visitCounts ?? {};
805
829
  state.renderCounts = payload.renderCounts ?? {};
806
830
  state.temporary = {};
831
+ state.transient = deepClone(get().transientDefaults);
807
832
  });
808
833
 
809
834
  lastNavigationVars = get().variables;
package/src/story-api.ts CHANGED
@@ -76,20 +76,37 @@ function ensureVariableChangedSubscription(): void {
76
76
  if (variableChangedSubActive) return;
77
77
  variableChangedSubActive = true;
78
78
  let prevVars = { ...useStoryStore.getState().variables };
79
+ let prevTrans = { ...useStoryStore.getState().transient };
79
80
  useStoryStore.subscribe((state) => {
80
81
  const changed: Record<string, { from: unknown; to: unknown }> = {};
81
82
  let hasChanges = false;
82
- const allKeys = new Set([
83
+
84
+ // Check $variables
85
+ const allVarKeys = new Set([
83
86
  ...Object.keys(prevVars),
84
87
  ...Object.keys(state.variables),
85
88
  ]);
86
- for (const key of allKeys) {
89
+ for (const key of allVarKeys) {
87
90
  if (state.variables[key] !== prevVars[key]) {
88
91
  changed[key] = { from: prevVars[key], to: state.variables[key] };
89
92
  hasChanges = true;
90
93
  }
91
94
  }
95
+
96
+ // Check %transient
97
+ const allTransKeys = new Set([
98
+ ...Object.keys(prevTrans),
99
+ ...Object.keys(state.transient),
100
+ ]);
101
+ for (const key of allTransKeys) {
102
+ if (state.transient[key] !== prevTrans[key]) {
103
+ changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] };
104
+ hasChanges = true;
105
+ }
106
+ }
107
+
92
108
  prevVars = { ...state.variables };
109
+ prevTrans = { ...state.transient };
93
110
  if (hasChanges) {
94
111
  emit('variableChanged', changed);
95
112
  }
@@ -178,16 +195,27 @@ export interface StoryAPI {
178
195
  function createStoryAPI(): StoryAPI {
179
196
  return {
180
197
  get(name: string): unknown {
198
+ if (name.startsWith('%')) {
199
+ return useStoryStore.getState().transient[name.slice(1)];
200
+ }
181
201
  return useStoryStore.getState().variables[name];
182
202
  },
183
203
 
184
204
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
185
205
  const state = useStoryStore.getState();
186
206
  if (typeof nameOrVars === 'string') {
187
- state.setVariable(nameOrVars, value);
207
+ if (nameOrVars.startsWith('%')) {
208
+ state.setTransient(nameOrVars.slice(1), value);
209
+ } else {
210
+ state.setVariable(nameOrVars, value);
211
+ }
188
212
  } else {
189
213
  for (const [k, v] of Object.entries(nameOrVars)) {
190
- state.setVariable(k, v);
214
+ if (k.startsWith('%')) {
215
+ state.setTransient(k.slice(1), v);
216
+ } else {
217
+ state.setVariable(k, v);
218
+ }
191
219
  }
192
220
  }
193
221
  },
@@ -12,7 +12,10 @@ export interface VariableSchema extends FieldSchema {
12
12
  default: unknown;
13
13
  }
14
14
 
15
- const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
15
+ function declarationRegex(sigil: string): RegExp {
16
+ const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
18
+ }
16
19
  const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
17
20
  const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
18
21
 
@@ -32,20 +35,23 @@ function inferSchema(value: unknown): FieldSchema {
32
35
  const jsType = typeof value;
33
36
  if (!VALID_VAR_TYPES.has(jsType)) {
34
37
  throw new Error(
35
- `StoryVariables: Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
38
+ `Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`,
36
39
  );
37
40
  }
38
41
  return { type: jsType as VarType };
39
42
  }
40
43
 
41
44
  /**
42
- * Parse a StoryVariables passage content into a schema map.
43
- * Each line: `$varName = expression`
45
+ * Parse a StoryVariables or StoryTransients passage content into a schema map.
46
+ * Each line: `$varName = expression` (or `%varName = expression` for transients)
44
47
  */
45
48
  export function parseStoryVariables(
46
49
  content: string,
50
+ sigil: '$' | '%' = '$',
47
51
  ): Map<string, VariableSchema> {
48
52
  const schema = new Map<string, VariableSchema>();
53
+ const DECLARATION_RE = declarationRegex(sigil);
54
+ const passageName = sigil === '%' ? 'StoryTransients' : 'StoryVariables';
49
55
 
50
56
  for (const rawLine of content.split('\n')) {
51
57
  const line = rawLine.trim();
@@ -54,7 +60,7 @@ export function parseStoryVariables(
54
60
  const match = line.match(DECLARATION_RE);
55
61
  if (!match) {
56
62
  throw new Error(
57
- `StoryVariables: Invalid declaration: "${line}". Expected: $name = value`,
63
+ `${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`,
58
64
  );
59
65
  }
60
66
 
@@ -64,11 +70,18 @@ export function parseStoryVariables(
64
70
  value = new Function('return (' + expr + ')')();
65
71
  } catch (err) {
66
72
  throw new Error(
67
- `StoryVariables: Failed to evaluate "$${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
73
+ `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
68
74
  );
69
75
  }
70
76
 
71
- const fieldSchema = inferSchema(value);
77
+ let fieldSchema: FieldSchema;
78
+ try {
79
+ fieldSchema = inferSchema(value);
80
+ } catch (err) {
81
+ throw new Error(
82
+ `${passageName}: ${err instanceof Error ? err.message : err}`,
83
+ );
84
+ }
72
85
  schema.set(name, { ...fieldSchema, name, default: value });
73
86
  }
74
87
 
@@ -146,8 +159,8 @@ export function validatePassages(
146
159
  const errors: string[] = [];
147
160
 
148
161
  for (const [name, passage] of passages) {
149
- // Don't validate the StoryVariables passage itself
150
- if (name === 'StoryVariables') continue;
162
+ // Don't validate the StoryVariables/StoryTransients passages themselves
163
+ if (name === 'StoryVariables' || name === 'StoryTransients') continue;
151
164
 
152
165
  const forLocals = extractForLocals(passage.content);
153
166
 
package/src/triggers.ts CHANGED
@@ -45,7 +45,13 @@ let dialogHostCallbacks: DialogHostCallbacks | null = null;
45
45
  function evalCondition(condition: string): boolean {
46
46
  const state = useStoryStore.getState();
47
47
  try {
48
- return !!evaluate(condition, state.variables, state.temporary);
48
+ return !!evaluate(
49
+ condition,
50
+ state.variables,
51
+ state.temporary,
52
+ {},
53
+ state.transient,
54
+ );
49
55
  } catch {
50
56
  return false;
51
57
  }
package/types/index.d.ts CHANGED
@@ -294,6 +294,7 @@ export interface MacroContext {
294
294
  Record<string, unknown>,
295
295
  Record<string, unknown>,
296
296
  Record<string, unknown>,
297
+ Record<string, unknown>,
297
298
  ];
298
299
  varName?: string;
299
300
  value?: unknown;
@@ -364,12 +365,20 @@ export interface SaveInfo {
364
365
  * @see {@link ../../src/story-api.ts} for the implementation.
365
366
  */
366
367
  export interface StoryAPI {
367
- /** Get the value of a story variable. */
368
+ /**
369
+ * Get a variable value. Use '%name' prefix for transient variables.
370
+ * @example Story.get('health') // $health
371
+ * @example Story.get('%npcList') // %npcList (transient)
372
+ */
368
373
  get(name: string): unknown;
369
374
 
370
- /** Set a single story variable. */
375
+ /**
376
+ * Set one or more variables. Use '%name' prefix for transient variables.
377
+ * @example Story.set('health', 100)
378
+ * @example Story.set('%npcList', [...])
379
+ * @example Story.set({ health: 100, '%npcList': [...] })
380
+ */
371
381
  set(name: string, value: unknown): void;
372
- /** Set multiple story variables at once. */
373
382
  set(vars: Record<string, unknown>): void;
374
383
 
375
384
  /** Navigate to a passage by name. */