@rohal12/spindle 0.41.0 → 0.43.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.41.0",
3
+ "version": "0.43.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -61,11 +61,12 @@ function computeAndApply(
61
61
  variables: Record<string, unknown>,
62
62
  temporary: Record<string, unknown>,
63
63
  locals: Record<string, unknown>,
64
+ transient: Record<string, unknown>,
64
65
  rawArgs: string,
65
66
  ): void {
66
67
  let newValue: unknown;
67
68
  try {
68
- newValue = evaluate(expr, variables, temporary, locals);
69
+ newValue = evaluate(expr, variables, temporary, locals, transient);
69
70
  } catch (err) {
70
71
  console.error(
71
72
  `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
@@ -86,7 +87,7 @@ defineMacro({
86
87
  name: 'computed',
87
88
  merged: true,
88
89
  render({ rawArgs }, ctx) {
89
- const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!;
90
+ const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
90
91
 
91
92
  let target: string;
92
93
  let expr: string;
@@ -113,6 +114,7 @@ defineMacro({
113
114
  mergedVars,
114
115
  mergedTemps,
115
116
  mergedLocals,
117
+ mergedTrans,
116
118
  rawArgs,
117
119
  );
118
120
  }
@@ -125,9 +127,10 @@ defineMacro({
125
127
  mergedVars,
126
128
  mergedTemps,
127
129
  mergedLocals,
130
+ mergedTrans,
128
131
  rawArgs,
129
132
  );
130
- }, [mergedVars, mergedTemps, mergedLocals]);
133
+ }, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);
131
134
 
132
135
  return null;
133
136
  },
@@ -17,10 +17,17 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
17
17
  const localsValues = useContext(LocalsValuesContext);
18
18
  const variables = useStoryStore((s) => s.variables);
19
19
  const temporary = useStoryStore((s) => s.temporary);
20
+ const transient = useStoryStore((s) => s.transient);
20
21
 
21
22
  let display: string;
22
23
  try {
23
- const value = evaluate(expression, variables, temporary, localsValues);
24
+ const value = evaluate(
25
+ expression,
26
+ variables,
27
+ temporary,
28
+ localsValues,
29
+ transient,
30
+ );
24
31
  display = value == null ? '' : String(value);
25
32
  } catch {
26
33
  display = `{error: ${expression}}`;
@@ -15,11 +15,13 @@ defineMacro({
15
15
  state.deleteVariable(name.slice(1));
16
16
  } else if (name.startsWith('_')) {
17
17
  state.deleteTemporary(name.slice(1));
18
+ } else if (name.startsWith('%')) {
19
+ state.deleteTransient(name.slice(1));
18
20
  } else if (name.startsWith('@')) {
19
21
  ctx.update(name.slice(1), undefined);
20
22
  } else {
21
23
  console.error(
22
- `spindle: {unset} expects a variable ($name, _name, or @name), got "${name}"`,
24
+ `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
23
25
  );
24
26
  }
25
27
  }
@@ -5,7 +5,7 @@ import { useInterpolate } from '../../hooks/use-interpolate';
5
5
 
6
6
  interface VarDisplayProps {
7
7
  name: string;
8
- scope: 'variable' | 'temporary' | 'local';
8
+ scope: 'variable' | 'temporary' | 'local' | 'transient';
9
9
  className?: string;
10
10
  id?: string;
11
11
  }
@@ -22,7 +22,9 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
22
22
  ? s.variables[root]
23
23
  : scope === 'temporary'
24
24
  ? s.temporary[root]
25
- : undefined,
25
+ : scope === 'transient'
26
+ ? s.transient[root]
27
+ : undefined,
26
28
  );
27
29
 
28
30
  let value: unknown;
@@ -32,7 +32,8 @@ function isStandaloneValue(token: string): boolean {
32
32
  // Quoted string
33
33
  if (first === '"' || first === "'" || first === '`') return true;
34
34
  // Variable ($var, _var, @var)
35
- if (first === '$' || first === '_' || first === '@') return true;
35
+ if (first === '$' || first === '_' || first === '@' || first === '%')
36
+ return true;
36
37
  // Number literal
37
38
  if (/\d/.test(first)) return true;
38
39
  // Signed number (-1, +2)
@@ -218,7 +219,8 @@ export function WidgetInvocation({
218
219
  }: WidgetInvocationProps) {
219
220
  const parentValues = useContext(LocalsValuesContext);
220
221
  const nobr = useContext(NobrContext);
221
- const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
222
+ const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
223
+ useMergedLocals();
222
224
 
223
225
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
224
226
 
@@ -239,7 +241,13 @@ export function WidgetInvocation({
239
241
  let value: unknown;
240
242
  if (expr !== undefined) {
241
243
  try {
242
- value = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
244
+ value = evaluate(
245
+ expr,
246
+ mergedVars,
247
+ mergedTemps,
248
+ mergedLocals,
249
+ mergedTrans,
250
+ );
243
251
  } catch {
244
252
  value = undefined;
245
253
  }
@@ -52,6 +52,7 @@ export interface MacroContext {
52
52
  Record<string, unknown>,
53
53
  Record<string, unknown>,
54
54
  Record<string, unknown>,
55
+ Record<string, unknown>,
55
56
  ];
56
57
  varName?: string;
57
58
  value?: unknown;
@@ -178,12 +179,21 @@ export function defineMacro(
178
179
  ctx.merged = useMergedLocals();
179
180
  const merged = ctx.merged;
180
181
  ctx.evaluate = (expr: string) =>
181
- evaluate(expr, merged[0], merged[1], merged[2]);
182
+ evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
182
183
  }
183
184
 
184
185
  if (config.storeVar) {
185
186
  const firstToken =
186
187
  props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? '';
188
+
189
+ if (firstToken.startsWith('%')) {
190
+ return h(
191
+ 'span',
192
+ { class: 'error' },
193
+ `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
194
+ );
195
+ }
196
+
187
197
  const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
188
198
  const segments = varExpr.split('.');
189
199
  ctx.varName = varExpr;
@@ -10,9 +10,10 @@ export function executeMutation(
10
10
  const state = useStoryStore.getState();
11
11
  const vars = deepClone(state.variables);
12
12
  const temps = deepClone(state.temporary);
13
+ const trans = deepClone(state.transient);
13
14
  const localsClone = { ...mergedLocals };
14
15
 
15
- execute(code, vars, temps, localsClone);
16
+ execute(code, vars, temps, localsClone, trans);
16
17
 
17
18
  for (const key of Object.keys(vars)) {
18
19
  if (vars[key] !== state.variables[key]) {
@@ -24,6 +25,11 @@ export function executeMutation(
24
25
  state.setTemporary(key, temps[key]);
25
26
  }
26
27
  }
28
+ for (const key of Object.keys(trans)) {
29
+ if (trans[key] !== state.transient[key]) {
30
+ state.setTransient(key, trans[key]);
31
+ }
32
+ }
27
33
  for (const key of Object.keys(localsClone)) {
28
34
  if (localsClone[key] !== mergedLocals[key]) {
29
35
  scopeUpdate(key, localsClone[key]);
@@ -41,6 +47,11 @@ export function executeMutation(
41
47
  state.deleteTemporary(key);
42
48
  }
43
49
  }
50
+ for (const key of Object.keys(state.transient)) {
51
+ if (!(key in trans)) {
52
+ state.deleteTransient(key);
53
+ }
54
+ }
44
55
  for (const key of Object.keys(mergedLocals)) {
45
56
  if (!(key in localsClone)) {
46
57
  scopeUpdate(key, undefined);
package/src/expression.ts CHANGED
@@ -23,30 +23,34 @@ type CompiledExpression = (
23
23
  temporary: Record<string, unknown>,
24
24
  locals: Record<string, unknown>,
25
25
  __fns: ExpressionFns,
26
+ transient: Record<string, unknown>,
26
27
  ) => unknown;
27
28
 
28
29
  const FN_CACHE_MAX = 500;
29
30
  const fnCache = new Map<string, CompiledExpression>();
30
31
 
31
32
  /**
32
- * Transform expression: $var → variables["var"], _var → temporary["var"]
33
- * Only transforms when $ or _ appears as a word boundary (not inside strings naively,
33
+ * Transform expression: $var → variables["var"], _var → temporary["var"],
34
+ * @var locals["var"], %var transient["var"]
35
+ * Only transforms when sigils appear as a word boundary (not inside strings naively,
34
36
  * but authors already have full JS access so this is acceptable).
35
37
  */
36
38
  const VAR_RE = /\$(\w+)/g;
37
39
  const TEMP_RE = /(?<![.\w])_(\w+)/g;
38
40
  const LOCAL_RE = /@(\w+)/g;
41
+ const TRANS_RE = /(?<!\w)%(\w+)/g;
39
42
 
40
43
  function transformSegment(segment: string): string {
41
44
  return segment
42
45
  .replace(VAR_RE, 'variables["$1"]')
43
46
  .replace(TEMP_RE, 'temporary["$1"]')
44
- .replace(LOCAL_RE, 'locals["$1"]');
47
+ .replace(LOCAL_RE, 'locals["$1"]')
48
+ .replace(TRANS_RE, 'transient["$1"]');
45
49
  }
46
50
 
47
51
  /**
48
52
  * String-aware expression transformer. Walks the expression character by
49
- * character so that variable sigils ($, _, @) inside string literals are
53
+ * character so that variable sigils ($, _, @, %) inside string literals are
50
54
  * left untouched while code — including expressions inside template-literal
51
55
  * `${…}` interpolations — is transformed.
52
56
  */
@@ -215,6 +219,7 @@ function getOrCompile(key: string, body: string): CompiledExpression {
215
219
  'temporary',
216
220
  'locals',
217
221
  '__fns',
222
+ 'transient',
218
223
  preamble + body,
219
224
  ) as CompiledExpression;
220
225
  fnCache.set(key, fn);
@@ -298,11 +303,12 @@ export function evaluate(
298
303
  variables: Record<string, unknown>,
299
304
  temporary: Record<string, unknown>,
300
305
  locals: Record<string, unknown> = {},
306
+ transient: Record<string, unknown> = {},
301
307
  ): unknown {
302
308
  const transformed = transform(expr);
303
309
  const body = `return (${transformed});`;
304
310
  const fn = getOrCompile(body, body);
305
- return fn(variables, temporary, locals, buildExpressionFns());
311
+ return fn(variables, temporary, locals, buildExpressionFns(), transient);
306
312
  }
307
313
 
308
314
  /**
@@ -314,10 +320,11 @@ export function execute(
314
320
  variables: Record<string, unknown>,
315
321
  temporary: Record<string, unknown>,
316
322
  locals: Record<string, unknown> = {},
323
+ transient: Record<string, unknown> = {},
317
324
  ): void {
318
325
  const transformed = transform(code);
319
326
  const fn = getOrCompile('exec:' + transformed, transformed);
320
- fn(variables, temporary, locals, buildExpressionFns());
327
+ fn(variables, temporary, locals, buildExpressionFns(), transient);
321
328
  }
322
329
 
323
330
  /**
@@ -332,5 +339,5 @@ export function clearExpressionCache(): void {
332
339
  }
333
340
 
334
341
  export function evaluateWithState(expr: string, state: StoryState): unknown {
335
- return evaluate(expr, state.variables, state.temporary, {});
342
+ return evaluate(expr, state.variables, state.temporary, {}, state.transient);
336
343
  }
@@ -5,13 +5,13 @@ import { hasInterpolation, interpolate } from '../interpolation';
5
5
  export function useInterpolate(): (
6
6
  s: string | undefined,
7
7
  ) => string | undefined {
8
- const [variables, temporary, locals] = useMergedLocals();
8
+ const [variables, temporary, locals, transient] = useMergedLocals();
9
9
 
10
10
  return useCallback(
11
11
  (s: string | undefined): string | undefined => {
12
12
  if (s === undefined || !hasInterpolation(s)) return s;
13
- return interpolate(s, variables, temporary, locals);
13
+ return interpolate(s, variables, temporary, locals, transient);
14
14
  },
15
- [variables, temporary, locals],
15
+ [variables, temporary, locals, transient],
16
16
  );
17
17
  }
@@ -3,19 +3,21 @@ import { useStoryStore } from '../store';
3
3
  import { LocalsValuesContext } from '../markup/render';
4
4
 
5
5
  /**
6
- * Return store variables, temporary, and locals from context.
7
- * All three dicts use unprefixed keys suitable for evaluate/execute.
6
+ * Return store variables, temporary, locals from context, and transient.
7
+ * All four dicts use unprefixed keys suitable for evaluate/execute.
8
8
  */
9
9
  export function useMergedLocals(): readonly [
10
10
  Record<string, unknown>,
11
11
  Record<string, unknown>,
12
12
  Record<string, unknown>,
13
+ Record<string, unknown>,
13
14
  ] {
14
15
  const variables = useStoryStore((s) => s.variables);
15
16
  const temporary = useStoryStore((s) => s.temporary);
17
+ const transient = useStoryStore((s) => s.transient);
16
18
  const localsValues = useContext(LocalsValuesContext);
17
19
 
18
20
  return useMemo(() => {
19
- return [variables, temporary, localsValues] as const;
20
- }, [variables, temporary, localsValues]);
21
+ return [variables, temporary, localsValues, transient] as const;
22
+ }, [variables, temporary, localsValues, transient]);
21
23
  }
package/src/index.tsx CHANGED
@@ -88,6 +88,27 @@ function boot() {
88
88
  const schema = parseStoryVariables(storyVarsPassage.content);
89
89
  const errors = validatePassages(storyData.passages, schema);
90
90
 
91
+ // Parse StoryTransients (optional — no error if missing)
92
+ let transientDefaults: Record<string, unknown> = {};
93
+ const storyTransientsPassage = storyData.passages.get('StoryTransients');
94
+ if (storyTransientsPassage) {
95
+ const transientSchema = parseStoryVariables(
96
+ storyTransientsPassage.content,
97
+ '%',
98
+ );
99
+
100
+ // Check for cross-scope name collisions
101
+ for (const name of transientSchema.keys()) {
102
+ if (schema.has(name)) {
103
+ errors.push(
104
+ `StoryTransients: Variable "${name}" is already declared in StoryVariables. Names must be unique across scopes.`,
105
+ );
106
+ }
107
+ }
108
+
109
+ transientDefaults = extractDefaults(transientSchema);
110
+ }
111
+
91
112
  if (errors.length > 0) {
92
113
  const root = document.getElementById('root');
93
114
  if (root) renderErrors(root, errors);
@@ -98,7 +119,7 @@ function boot() {
98
119
 
99
120
  defaults = extractDefaults(schema);
100
121
 
101
- useStoryStore.getState().init(storyData, defaults);
122
+ useStoryStore.getState().init(storyData, defaults, transientDefaults);
102
123
 
103
124
  // Enter runtime phase — handlers registered from here on are cleaned on restart
104
125
  enterRuntimePhase();
@@ -1,7 +1,7 @@
1
1
  import { evaluate } from './expression';
2
2
 
3
- /** Detects any {…} block that starts with a sigil ($, _, @). */
4
- const INTERP_TEST = /\{[\$_@]\w/;
3
+ /** Detects any {…} block that starts with a sigil ($, _, @, %). */
4
+ const INTERP_TEST = /\{[\$_@%]\w/;
5
5
 
6
6
  export function hasInterpolation(s: string): boolean {
7
7
  return INTERP_TEST.test(s);
@@ -25,8 +25,9 @@ export function interpolateExpression(
25
25
  variables: Record<string, unknown>,
26
26
  temporary: Record<string, unknown>,
27
27
  locals: Record<string, unknown>,
28
+ transient: Record<string, unknown> = {},
28
29
  ): string {
29
- const value = evaluate(expr, variables, temporary, locals);
30
+ const value = evaluate(expr, variables, temporary, locals, transient);
30
31
  return value == null ? '' : String(value);
31
32
  }
32
33
 
@@ -35,6 +36,7 @@ function resolveSimple(
35
36
  variables: Record<string, unknown>,
36
37
  temporary: Record<string, unknown>,
37
38
  locals: Record<string, unknown>,
39
+ transient: Record<string, unknown>,
38
40
  ): string {
39
41
  const prefix = ref[0]!;
40
42
  const path = ref.slice(1);
@@ -46,6 +48,8 @@ function resolveSimple(
46
48
  value = variables[root];
47
49
  } else if (prefix === '_') {
48
50
  value = temporary[root];
51
+ } else if (prefix === '%') {
52
+ value = transient[root];
49
53
  } else {
50
54
  value = locals[root];
51
55
  }
@@ -62,6 +66,7 @@ export function interpolate(
62
66
  variables: Record<string, unknown>,
63
67
  temporary: Record<string, unknown>,
64
68
  locals: Record<string, unknown>,
69
+ transient: Record<string, unknown> = {},
65
70
  ): string {
66
71
  // Manual scan: process {…} blocks containing sigils.
67
72
  // Simple dot-path refs use the fast resolver; everything else falls back
@@ -86,7 +91,7 @@ export function interpolate(
86
91
  i++; // skip {
87
92
 
88
93
  const sigil = template[i];
89
- if (sigil !== '$' && sigil !== '_' && sigil !== '@') {
94
+ if (sigil !== '$' && sigil !== '_' && sigil !== '@' && sigil !== '%') {
90
95
  // Not an interpolation — emit the { as text
91
96
  result += '{';
92
97
  continue;
@@ -111,10 +116,16 @@ export function interpolate(
111
116
  i = j + 1; // skip past closing }
112
117
 
113
118
  // Try simple dot-path match first
114
- if (/^[\$_@][\w.]+$/.test(inner)) {
115
- result += resolveSimple(inner, variables, temporary, locals);
119
+ if (/^[\$_@%][\w.]+$/.test(inner)) {
120
+ result += resolveSimple(inner, variables, temporary, locals, transient);
116
121
  } else {
117
- result += interpolateExpression(inner, variables, temporary, locals);
122
+ result += interpolateExpression(
123
+ inner,
124
+ variables,
125
+ temporary,
126
+ locals,
127
+ transient,
128
+ );
118
129
  }
119
130
  }
120
131
 
package/src/markup/ast.ts CHANGED
@@ -8,7 +8,7 @@ export interface TextNode {
8
8
  export interface VariableNode {
9
9
  type: 'variable';
10
10
  name: string;
11
- scope: 'variable' | 'temporary' | 'local';
11
+ scope: 'variable' | 'temporary' | 'local' | 'transient';
12
12
  className?: string;
13
13
  id?: string;
14
14
  }
@@ -243,6 +243,7 @@ function getVariableTextValue(
243
243
  let value: unknown;
244
244
  if (node.scope === 'variable') value = state.variables[root];
245
245
  else if (node.scope === 'temporary') value = state.temporary[root];
246
+ else if (node.scope === 'transient') value = state.transient[root];
246
247
  else value = locals[root];
247
248
 
248
249
  for (let i = 1; i < parts.length; i++) {
@@ -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;