A local state machine started over in every Run behavior call, so a state set in a sub-behavior was gone by the next call. A character controller split into sub-behaviors could therefore never remember that it was attacking. Scene or global scope would keep the state, but then every character shares it. - A call now runs on the states of the instance that runs it, however deep. A state one call sets is still set in the next one, and the call begins in the start state like the instance does. - A move made inside a call is reported like one the caller made. The caller's On state entered and On state exited hear it, the event nodes of the called graph stay silent as before. - Two objects running the same behavior still hold a state each. - This reverses the rule from #229 that local machines of a called behavior are not kept. Keep stays a switch for variables only. Tests: - Two unit tests that pinned the old rule now expect the shared state. - New world test: a sub-behavior closes a door per object, the caller's events hear it, and the next call finds it closed. - Counter-check: with the old call context the 3 tests fail. - Gate on melektaus-atx: 239 files, 3600 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
13 KiB
TypeScript
310 lines
13 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 {
|
||
AddVariableCommand,
|
||
RemoveVariableCommand,
|
||
RenameVariableCommand,
|
||
SetVariableKeptCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import {
|
||
BEHAVIOR_END,
|
||
BEHAVIOR_PARAM,
|
||
BEHAVIOR_START,
|
||
GraphInterpreter,
|
||
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 { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
fakeContext,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
type ProjectFixture,
|
||
roundTrip,
|
||
startWorld,
|
||
fire,
|
||
} from '../helpers';
|
||
|
||
/**
|
||
* A behavior that counts its calls in `count` and hands the new count out as `out1`. `steps`
|
||
* counts the same way but is left off, so it shows what an unkept variable does meanwhile.
|
||
*/
|
||
function counter(fixture: ProjectFixture): BehaviorAsset {
|
||
const callee = makeBehavior(fixture, 'Counter', ['count', 'steps']);
|
||
fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'count', true));
|
||
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' }));
|
||
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out2', kind: 'number' }));
|
||
const get = edit.variables(['count', 'steps']);
|
||
const plus = edit.addNode('Add', { b: 1 });
|
||
const plusSteps = edit.addNode('Add', { b: 1 });
|
||
const set = edit.variables(['count', 'steps'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[get, 'count', plus, 'a'],
|
||
[get, 'steps', plusSteps, 'a'],
|
||
[plus, 'result', set, 'count'],
|
||
[plusSteps, 'result', set, 'steps'],
|
||
[set, 'exec', end, 'exec'],
|
||
[plus, 'result', end, 'out1'],
|
||
[plusSteps, 'result', end, 'out2'],
|
||
]);
|
||
return callee;
|
||
}
|
||
|
||
/** A caller that runs `callee` once per `event` through each of `sites` nodes, storing `out1`. */
|
||
function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, 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 });
|
||
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;
|
||
}
|
||
|
||
/** `out1` and `out2` of three calls from one node. */
|
||
function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][] {
|
||
const caller = makeBehavior(fixture, 'Caller', ['r0', 'r1']);
|
||
const edit = graphEditor(fixture, caller);
|
||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||
const store = edit.variables(['r0', 'r1'], { write: true });
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', call, 'exec'],
|
||
[call, 'exec', store, 'exec'],
|
||
[call, 'out1', store, 'r0'],
|
||
[call, 'out2', store, 'r1'],
|
||
]);
|
||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
|
||
const seen: unknown[][] = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
fire(interpreter, 'OnStart', ctx);
|
||
seen.push([ctx.variables.r0, ctx.variables.r1]);
|
||
}
|
||
return seen;
|
||
}
|
||
|
||
describe('Keep on a behavior variable (#229)', () => {
|
||
it('starts off for a variable added to a behavior, and on a Variables asset', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['count']);
|
||
expect('keptVariables' in behavior).toBe(false);
|
||
const shared = new CreateAssetCommand('variables', 'Shared');
|
||
fixture.stack.execute(shared);
|
||
fixture.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number'));
|
||
expect('keptVariables' in shared.asset).toBe(false);
|
||
});
|
||
|
||
it('keeps none in a project saved before the switch existed', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'Old', ['count']);
|
||
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
|
||
delete raw.assets.find((a) => a.id === behavior.id)!.keptVariables;
|
||
const loaded = deserializeProject(raw).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
expect(loaded.keptVariables).toBeUndefined();
|
||
});
|
||
|
||
it('resumes a kept variable from the last call of this node, and restarts an unkept one', () => {
|
||
const fixture = makeProject();
|
||
// count is kept and counts 1, 2, 3; steps is not and reads 1 every time.
|
||
expect(threeCalls(fixture, counter(fixture))).toEqual([
|
||
[1, 1],
|
||
[2, 1],
|
||
[3, 1],
|
||
]);
|
||
});
|
||
|
||
it('switches off and on as one undo step each', () => {
|
||
const fixture = makeProject();
|
||
const callee = counter(fixture);
|
||
fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'count', false));
|
||
expect(callee.keptVariables).toBeUndefined();
|
||
fixture.stack.undo();
|
||
expect(callee.keptVariables).toEqual(['count']);
|
||
fixture.stack.redo();
|
||
expect(callee.keptVariables).toBeUndefined();
|
||
expect(threeCalls(fixture, callee).map(([count]) => count)).toEqual([1, 1, 1]);
|
||
});
|
||
|
||
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), 2);
|
||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
|
||
fire(interpreter, 'OnStart', ctx);
|
||
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]);
|
||
fire(interpreter, 'OnStart', ctx);
|
||
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
|
||
// 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 object in a running world', () => {
|
||
const fixture = makeSpriteScene();
|
||
const callee = counter(fixture);
|
||
const caller = callerOf(fixture, callee, 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 call inside a call its own state per outer node', () => {
|
||
const fixture = makeProject();
|
||
const inner = counter(fixture);
|
||
// The middle behavior just passes the counter through.
|
||
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 });
|
||
edit.wire([
|
||
[start, 'exec', call, 'exec'],
|
||
[call, 'exec', end, 'exec'],
|
||
[call, 'out1', end, 'out1'],
|
||
]);
|
||
const caller = callerOf(fixture, middle, 2);
|
||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
|
||
fire(interpreter, 'OnStart', ctx);
|
||
fire(interpreter, '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('leaves a local state machine with the caller, so a call finds what the last one set', () => {
|
||
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". The variable is not kept, which
|
||
// shows that the machine does not hang on the Keep switch.
|
||
const callee = makeBehavior(fixture, 'Door', ['count']);
|
||
fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'count', false));
|
||
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'],
|
||
]);
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const cEdit = graphEditor(fixture, caller);
|
||
cEdit.connect(
|
||
cEdit.addNode('OnStart'),
|
||
'exec',
|
||
cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }),
|
||
'exec',
|
||
);
|
||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||
const ctx = fakeContext();
|
||
fire(interpreter, 'OnStart', ctx);
|
||
fire(interpreter, 'OnStart', ctx);
|
||
expect(ctx.logs).toEqual(['first', 'open']);
|
||
// The state sits in the caller's bag, where the running instance keeps its local machines.
|
||
expect(ctx.states).toEqual({ [machine]: open.node.id });
|
||
});
|
||
|
||
it('survives saving and loading, and a name that is not declared is dropped', () => {
|
||
const fixture = makeProject();
|
||
const callee = counter(fixture);
|
||
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
|
||
raw.assets.find((a) => a.id === callee.id)!.keptVariables = ['count', 'gone', 'count', 7];
|
||
const loaded = deserializeProject(raw).assets.find((a) => a.id === callee.id) as BehaviorAsset;
|
||
expect(loaded.keptVariables).toEqual(['count']);
|
||
const again = roundTrip(fixture.project).assets.find((a) => a.id === callee.id) as BehaviorAsset;
|
||
expect(again.keptVariables).toEqual(['count']);
|
||
});
|
||
|
||
it('follows its variable through a rename, and a removal gives it back on undo', () => {
|
||
const fixture = makeProject();
|
||
const callee = counter(fixture);
|
||
fixture.stack.execute(new RenameVariableCommand(callee.id, 'count', 'hits'));
|
||
expect(callee.keptVariables).toEqual(['hits']);
|
||
fixture.stack.execute(new RemoveVariableCommand(callee.id, 'hits'));
|
||
expect(callee.keptVariables).toBeUndefined();
|
||
fixture.stack.undo();
|
||
expect(callee.keptVariables).toEqual(['hits']);
|
||
fixture.stack.undo();
|
||
expect(callee.keptVariables).toEqual(['count']);
|
||
});
|
||
|
||
it('is gone from Run behavior, which has no switch of its own any more', () => {
|
||
const fixture = makeProject();
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const added = new AddNodeCommand(caller.id, RUN_BEHAVIOR, 0, 0);
|
||
fixture.stack.execute(added);
|
||
expect(Object.keys(added.node.params)).toEqual([BEHAVIOR_PARAM]);
|
||
});
|
||
});
|
||
|
||
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 }, ['count', 'fresh'])).toEqual({
|
||
count: 7,
|
||
fresh: 5,
|
||
});
|
||
});
|
||
|
||
it('restarts a variable that is not kept, whatever it was left at', () => {
|
||
expect(resumedVariables({ count: 0, steps: 0 }, { count: 7, steps: 4 }, ['count'])).toEqual({
|
||
count: 7,
|
||
steps: 0,
|
||
});
|
||
});
|
||
|
||
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' }, ['count', 'name']),
|
||
).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, ['count']);
|
||
expect(variables).toEqual(defaults);
|
||
expect(variables).not.toBe(defaults);
|
||
});
|
||
});
|