Let a nested behavior keep its variables between calls (#229)
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Failing after 8m39s

A Run behavior call started with fresh variables every time, so a
nested behavior could not count, remember or keep a local state
machine across calls. A new "Keep state" switch on the node lets the
callee resume where its last call left off. It is off by default, so
existing projects run unchanged.

- RunBehavior gets the boolean parameter keepState and its node
  description explains it.
- GraphInterpreter keeps the state in keptCalls, keyed by the call
  path, the chain of Run behavior nodes. Two nodes calling the same
  behavior each keep their own state.
- One interpreter exists per behavior instance, so every object gets
  its own state without further code.
- Local state machines of the callee are kept like its variables.
- resumedVariables() matches kept values against today's
  declarations. A new variable starts at its default, a removed one
  drops out, and one whose kind changed starts over.
- The kept state lives as long as the object in the run. It is not
  written to save games.

Limit: a callee input named keepState collides with the switch. The
same already holds for the name behavior, so it is not caught.

Tests:
- ./check.sh: 163 files, 2388 tests, CHECK EXIT 0 (before 2377)
- 10 new unit tests in tests/core/keep-state.test.ts and 1 new
  node catalog case.
- e2e 19: ALL PASSED, 35 checks, with the new check 19c-ii for the
  checkbox, its click and undo.
- Counter probes: ignoring keepState fails 5 tests, state per
  behavior instead of per node fails 1, dropping the outer path
  fails 1, skipping the kind check fails 1, and without the node
  parameter the e2e check fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 14:41:17 +02:00
1 parent 03e7a78c3d
commit 7c70245c79
9 files changed
+314 -17

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1384 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1385 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+40 -8
View File
@@ -1,6 +1,7 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type GraphLookup, NO_LOOKUP } from './lookup'; import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested'; import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested';
import type { PropertyBag } from '../model/Properties';
import type { NodeContext } from './NodeContext'; import type { NodeContext } from './NodeContext';
import { import {
type CallResult, type CallResult,
@@ -42,6 +43,25 @@ function emptyFrame(graph: Graph): Frame {
return { graph, outputCache: new Map(), dataCache: new Map() }; return { graph, outputCache: new Map(), dataCache: new Map() };
} }
/** What a `Run behavior` with Keep state left behind in its callee (#229). */
interface KeptState {
variables: PropertyBag;
states: Record<string, string>;
}
/**
* The variables a kept call resumes with: today's declarations, each at the value it was left at
* unless that value is gone or no longer of the declared sort – then at its default.
*/
export function resumedVariables(defaults: PropertyBag, kept: PropertyBag | undefined): PropertyBag {
const variables: PropertyBag = {};
for (const [key, value] of Object.entries(defaults)) {
const left = kept?.[key];
variables[key] = left !== undefined && typeof left === typeof value ? left : value;
}
return variables;
}
/** /**
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows * Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and * exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
@@ -52,8 +72,11 @@ export class GraphInterpreter {
readonly nodeState = new Map<string, Record<string, number>>(); readonly nodeState = new Map<string, Record<string, number>>();
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */ /** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
readonly calledBehaviors = new Set<string>(); readonly calledBehaviors = new Set<string>();
/** Kept calls by call site: the chain of `Run behavior` node ids that led there. */
readonly keptCalls = new Map<string, KeptState>();
private frame: Frame; private frame: Frame;
private callStack: string[] = []; private callStack: string[] = [];
private callSites: string[] = [];
private steps = 0; private steps = 0;
trace: TraceListener | null = null; trace: TraceListener | null = null;
@@ -81,6 +104,7 @@ export class GraphInterpreter {
if (filter && !filter(node)) continue; if (filter && !filter(node)) continue;
this.frame = emptyFrame(this.graph); this.frame = emptyFrame(this.graph);
this.callStack = []; this.callStack = [];
this.callSites = [];
this.steps = 0; this.steps = 0;
this.frame.outputCache.set(node.id, { ...outputs }); this.frame.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id); this.trace?.onNode(node.id);
@@ -125,7 +149,7 @@ export class GraphInterpreter {
if (!def?.run) return; if (!def?.run) return;
const inputs = this.collectInputs(node, ctx); const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params, def, entry, this.lookup); const result = def.run(ctx, inputs, node.params, def, entry, this.lookup);
const returned = result.call ? this.call(result.call, ctx, depth) : {}; const returned = result.call ? this.call(result.call, node.id, ctx, depth) : {};
const produced = { ...result.outputs, ...returned }; const produced = { ...result.outputs, ...returned };
if (!def.read) this.frame.outputCache.set(node.id, produced); if (!def.read) this.frame.outputCache.set(node.id, produced);
this.trace?.onNode(node.id); this.trace?.onNode(node.id);
@@ -138,32 +162,39 @@ export class GraphInterpreter {
/** /**
* Runs another behavior from its start node to its end node and returns what the end node was * Runs another behavior from its start node to its end node and returns what the end node was
* handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is * handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is
* a call and not an inlining: nothing of it survives the return but the values it hands back. * a call and not an inlining: nothing of it survives the return but the values it hands back –
* unless the call keeps its state, which then waits for the next call from the same site.
*/ */
private call(call: CallResult, ctx: NodeContext, depth: number): Record<string, Value> { private call(call: CallResult, site: string, ctx: NodeContext, depth: number): Record<string, Value> {
const program = this.lookup.behavior(call.behaviorId); const program = this.lookup.behavior(call.behaviorId);
if (!program) return {}; if (!program) return {};
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`); if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
const start = terminalNode(program.graph, BEHAVIOR_START); const start = terminalNode(program.graph, BEHAVIOR_START);
if (!start) return {}; if (!start) return {};
const sites = [...this.callSites, site].join('/');
const kept = call.keep ? this.keptCalls.get(sites) : undefined;
const variables = resumedVariables(program.defaults, kept?.variables);
const states = kept?.states ?? {};
if (call.keep) this.keptCalls.set(sites, { variables, states });
const outer = this.frame; const outer = this.frame;
this.frame = emptyFrame(program.graph); this.frame = emptyFrame(program.graph);
this.frame.outputCache.set(start.id, { ...call.inputs }); this.frame.outputCache.set(start.id, { ...call.inputs });
this.record(start.id, call.inputs); this.record(start.id, call.inputs);
this.callStack.push(call.behaviorId); this.callStack.push(call.behaviorId);
this.callSites.push(site);
this.calledBehaviors.add(call.behaviorId); this.calledBehaviors.add(call.behaviorId);
try { try {
this.trace?.onNode(start.id); this.trace?.onNode(start.id);
// Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the // A call sees nothing of the caller's variables or states. What it does to a local machine is
// caller's. What it does to a local machine therefore dies with the call and is not reported; // therefore not reported; a `global` machine is the run's, so moving that one is a move like
// a `global` machine is the run's, so moving that one is a move like any other. // any other.
this.follow( this.follow(
start, start,
'exec', 'exec',
{ {
...ctx, ...ctx,
variables: { ...program.defaults }, variables,
states: {}, states,
stateChanged: (machineId, from, to) => { stateChanged: (machineId, from, to) => {
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
}, },
@@ -174,6 +205,7 @@ export class GraphInterpreter {
return (end && this.frame.outputCache.get(end.id)) ?? {}; return (end && this.frame.outputCache.get(end.id)) ?? {};
} finally { } finally {
this.callStack.pop(); this.callStack.pop();
this.callSites.pop();
this.frame = outer; this.frame = outer;
} }
} }
+2
View File
@@ -48,6 +48,8 @@ export interface RepeatResult {
export interface CallResult { export interface CallResult {
behaviorId: string; behaviorId: string;
inputs: Record<string, Value>; inputs: Record<string, Value>;
/** The called behavior picks up the variables and states this call site left behind (#229). */
keep?: boolean;
} }
export interface RunResult { export interface RunResult {
+1 -1
View File
@@ -14,4 +14,4 @@ export * from './sharedState';
export * from './stateMachine'; export * from './stateMachine';
export * from './values'; export * from './values';
export * from './variablePorts'; export * from './variablePorts';
export { GraphInterpreter, type TraceListener } from './GraphInterpreter'; export { GraphInterpreter, resumedVariables, type TraceListener } from './GraphInterpreter';
+1
View File
@@ -22,6 +22,7 @@ export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties'; export const PROPERTIES = 'Properties';
export const BEHAVIOR_PARAM = 'behavior'; export const BEHAVIOR_PARAM = 'behavior';
export const KEEP_STATE_PARAM = 'keepState';
export const VARIABLES_PARAM = 'variablesAsset'; export const VARIABLES_PARAM = 'variablesAsset';
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */ /** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
+20 -5
View File
@@ -1,6 +1,13 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { textOf } from '../../../model/Properties'; import { textOf } from '../../../model/Properties';
import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, declaredPorts, RUN_BEHAVIOR } from '../../nested'; import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
declaredPorts,
KEEP_STATE_PARAM,
RUN_BEHAVIOR,
} from '../../nested';
import { exec } from '../shared'; import { exec } from '../shared';
/** /**
@@ -13,11 +20,15 @@ export const RunBehavior: NodeDef = {
label: 'Run behavior', label: 'Run behavior',
category: 'flow', category: 'flow',
description: description:
'Runs another behavior as a step of this one, passing values in and taking results out. The ' + 'Runs another behavior as a step of this one, passing values in and taking results out. Its ' +
'behavior keeps no state between calls: its variables start at their defaults every time.', 'variables start at their defaults every time, unless Keep state is on: then each object keeps ' +
'them for this node from one call to the next.',
inputs: [exec()], inputs: [exec()],
outputs: [exec()], outputs: [exec()],
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }], params: [
{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] },
{ key: KEEP_STATE_PARAM, label: 'Keep state', kind: 'boolean', default: false },
],
derivePorts: ({ node, lookup }) => { derivePorts: ({ node, lookup }) => {
const program = lookup.behavior(textOf(node.params[BEHAVIOR_PARAM] ?? '')); const program = lookup.behavior(textOf(node.params[BEHAVIOR_PARAM] ?? ''));
return { return {
@@ -28,7 +39,11 @@ export const RunBehavior: NodeDef = {
}; };
}, },
run: (_ctx, inputs, params) => ({ run: (_ctx, inputs, params) => ({
call: { behaviorId: textOf(params[BEHAVIOR_PARAM] ?? ''), inputs }, call: {
behaviorId: textOf(params[BEHAVIOR_PARAM] ?? ''),
inputs,
keep: params[KEEP_STATE_PARAM] === true,
},
next: 'exec', next: 'exec',
}), }),
}; };
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
GraphInterpreter,
KEEP_STATE_PARAM,
MACHINE_PORT,
RUN_BEHAVIOR,
resumedVariables,
STATE,
STATE_NAME_PARAM,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import {
fakeContext,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
type ProjectFixture,
roundTrip,
startWorld,
} from '../helpers';
/** A behavior that counts its calls in its own `count` and hands the new count out as `out1`. */
function counter(fixture: ProjectFixture): BehaviorAsset {
const callee = makeBehavior(fixture, 'Counter', ['count']);
const edit = graphEditor(fixture, callee);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
const get = edit.variables(['count']);
const plus = edit.addNode('Add', { b: 1 });
const set = edit.variables(['count'], { write: true });
edit.wire([
[start, 'exec', set, 'exec'],
[get, 'count', plus, 'a'],
[plus, 'result', set, 'count'],
[set, 'exec', end, 'exec'],
[plus, 'result', end, 'out1'],
]);
return callee;
}
/** A caller that runs `callee` once per `event` through each of `sites` nodes, storing the results. */
function callerOf(
fixture: ProjectFixture,
callee: BehaviorAsset,
keep: boolean,
sites = 1,
event = 'OnStart',
) {
const keys = Array.from({ length: sites }, (_, i) => `r${i}`);
const caller = makeBehavior(fixture, 'Caller', keys);
const edit = graphEditor(fixture, caller);
let previous = edit.addNode(event);
for (const key of keys) {
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, [KEEP_STATE_PARAM]: keep });
const store = edit.variables([key], { write: true });
edit.wire([
[previous, 'exec', call, 'exec'],
[call, 'exec', store, 'exec'],
[call, 'out1', store, key],
]);
previous = store;
}
return caller;
}
describe('Run behavior with Keep state (#229)', () => {
it('is off by default, so a call still starts from the defaults every time', () => {
const fixture = makeProject();
const caller = callerOf(fixture, counter(fixture), false);
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
const added = new AddNodeCommand(caller.id, RUN_BEHAVIOR, 0, 0);
fixture.stack.execute(added);
expect(added.node.params[KEEP_STATE_PARAM]).toBe(false);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0 } });
for (let i = 0; i < 3; i++) interpreter.fire('OnStart', ctx);
expect(ctx.variables.r0).toBe(1);
expect(call.params[KEEP_STATE_PARAM]).toBe(false);
});
it('resumes the callee’s variables where the last call from this node left them', () => {
const fixture = makeProject();
const caller = callerOf(fixture, counter(fixture), true);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0 } });
const seen: unknown[] = [];
for (let i = 0; i < 3; i++) {
interpreter.fire('OnStart', ctx);
seen.push(ctx.variables.r0);
}
expect(seen).toEqual([1, 2, 3]);
// The caller's own bag never learns of `count`: the kept values stay the callee's.
expect('count' in ctx.variables).toBe(false);
});
it('keeps one state per node: two nodes calling the same behavior count on their own', () => {
const fixture = makeProject();
const caller = callerOf(fixture, counter(fixture), true, 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]);
interpreter.fire('OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
});
it('keeps one state per object in a running world', () => {
const fixture = makeSpriteScene();
const callee = counter(fixture);
const caller = callerOf(fixture, callee, true, 1, 'OnUpdate');
const log = graphEditor(fixture, caller).addNode('Log');
const store = caller.graph.nodes.filter((n) => n.type === 'Variables').at(-1)!;
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
graphEditor(fixture, caller).wire([
[store, 'exec', log, 'exec'],
[call, 'out1', log, 'message'],
]);
place(fixture, caller.id, { x: 0, y: 0 });
place(fixture, caller.id, { x: 10, y: 0 });
const started = startWorld(fixture, { behaviors: true });
for (let i = 0; i < 3; i++) started.frame();
// Shared across objects it would read 1…6; per object each counts 1, 2, 3.
expect(started.world.log).toEqual(['1', '1', '2', '2', '3', '3']);
});
it('gives a kept call inside a kept call its own state per outer node', () => {
const fixture = makeProject();
const inner = counter(fixture);
// The middle behavior just passes the counter through, keeping its call to it.
const middle = makeBehavior(fixture, 'Middle');
const edit = graphEditor(fixture, middle);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(middle.id, end.id, { key: 'out1', kind: 'number' }));
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id, [KEEP_STATE_PARAM]: true });
edit.wire([
[start, 'exec', call, 'exec'],
[call, 'exec', end, 'exec'],
[call, 'out1', end, 'out1'],
]);
const caller = callerOf(fixture, middle, true, 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
// One shared inner counter would have handed out 1, 2, 3, 4.
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
});
it('keeps a local state machine of the callee along with its variables', () => {
const fixture = makeProject();
const create = new CreateAssetCommand('statemachine', 'Doors');
fixture.stack.execute(create);
const open = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' });
fixture.stack.execute(open);
const machine = create.asset.id;
// No state yet → "first" and put it in Open; in Open → "open".
const callee = makeBehavior(fixture, 'Door');
const edit = graphEditor(fixture, callee);
const start = edit.addNode(BEHAVIOR_START);
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine });
const first = edit.addNode('Log', { message: 'first' });
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine });
const again = edit.addNode('Log', { message: 'open' });
edit.wire([
[start, 'exec', branch, 'exec'],
[branch, 'else', first, 'exec'],
[first, 'exec', set, open.node.id],
[branch, open.node.id, again, 'exec'],
]);
for (const keep of [false, true]) {
const caller = makeBehavior(fixture, `Caller ${keep}`);
const cEdit = graphEditor(fixture, caller);
cEdit.connect(
cEdit.addNode('OnStart'),
'exec',
cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, [KEEP_STATE_PARAM]: keep }),
'exec',
);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext();
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
expect(ctx.logs).toEqual(keep ? ['first', 'open'] : ['first', 'first']);
// A local machine of the callee is none of the caller's business either way.
expect(ctx.states).toEqual({});
}
});
it('survives saving and loading', () => {
const fixture = makeProject();
const caller = callerOf(fixture, counter(fixture), true);
const loaded = roundTrip(fixture.project).assets.find((a) => a.id === caller.id) as BehaviorAsset;
expect(loaded.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params[KEEP_STATE_PARAM]).toBe(true);
});
});
describe('resumedVariables', () => {
it('follows today’s declarations: new ones at their default, dropped ones gone', () => {
expect(resumedVariables({ count: 0, fresh: 5 }, { count: 7, gone: 1 })).toEqual({ count: 7, fresh: 5 });
});
it('falls back to the default where the kept value is of another sort now', () => {
expect(resumedVariables({ count: 0, name: 'a' }, { count: 'seven', name: 'b' })).toEqual({
count: 0,
name: 'b',
});
});
it('is a fresh copy of the defaults when nothing was kept yet', () => {
const defaults = { count: 0 };
const variables = resumedVariables(defaults, undefined);
expect(variables).toEqual(defaults);
expect(variables).not.toBe(defaults);
});
});
+8 -2
View File
@@ -137,12 +137,18 @@ const CASES: Record<string, Case[]> = {
name: 'asks for the named behavior to run, passing its inputs on', name: 'asks for the named behavior to run, passing its inputs on',
inputs: { power: 3 }, inputs: { power: 3 },
params: { behavior: 'b1' }, params: { behavior: 'b1' },
call: { behaviorId: 'b1', inputs: { power: 3 } }, call: { behaviorId: 'b1', inputs: { power: 3 }, keep: false },
next: 'exec',
},
{
name: 'asks for the call to keep its state when Keep state is on',
params: { behavior: 'b1', keepState: true },
call: { behaviorId: 'b1', inputs: {}, keep: true },
next: 'exec', next: 'exec',
}, },
{ {
name: 'without a behavior chosen it still continues, calling nothing that exists', name: 'without a behavior chosen it still continues, calling nothing that exists',
call: { behaviorId: '', inputs: {} }, call: { behaviorId: '', inputs: {}, keep: false },
next: 'exec', next: 'exec',
}, },
], ],
+14
View File
@@ -128,6 +128,20 @@ export async function nestedBehavior(t) {
); );
await shot('37-nested-behavior'); await shot('37-nested-behavior');
// 19c-ii. Keep state (#229) is a checkbox on the node, off until clicked, and one undo step.
const keepState = async () =>
(await asset('nb-caller')).graph.nodes.find((n) => n.id === 'nb-call').params.keepState;
const keepField = await json(`__gm.inspectorField('Keep state')`);
const keepBefore = await keepState();
if (keepField) await click(keepField.x, keepField.y);
const keepClicked = await keepState();
await undo();
check(
'the call has a Keep state checkbox: off, on by a click, off again after undo',
Boolean(keepField) && keepBefore !== true && keepClicked === true && (await keepState()) !== true,
`field=${JSON.stringify(keepField)} before=${keepBefore} clicked=${keepClicked} undone=${await keepState()}`,
);
// 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the
// check that a port list derived from another graph is really derived and not copied. // check that a port list derived from another graph is really derived and not copied.
check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false))); check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false)));