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>
228 lines
9.2 KiB
TypeScript
228 lines
9.2 KiB
TypeScript
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);
|
||
});
|
||
});
|