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>
751 lines
33 KiB
TypeScript
751 lines
33 KiB
TypeScript
/**
|
||
* A state machine is an asset whose graph holds one `State` node per state. A behavior reads it
|
||
* through `State branch` (one exec output per state) and moves it through `Set state` (one exec
|
||
* input per state); the current state lives with the behavior instance, like its variables.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
SetMachineScopeCommand,
|
||
SetMachineStartCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddNodeCommand,
|
||
ConnectCommand,
|
||
NameTransitionCommand,
|
||
RemoveNodeCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../src/core/commands/graphCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import {
|
||
BEHAVIOR_PARAM,
|
||
BEHAVIOR_START,
|
||
GraphInterpreter,
|
||
MACHINE_PORT,
|
||
nextStateName,
|
||
resolveDef,
|
||
STATE,
|
||
STATE_IN,
|
||
STATE_NAME_PARAM,
|
||
STATE_OUT,
|
||
startStateOf,
|
||
statesOf,
|
||
transitionNames,
|
||
transitionsOf,
|
||
transitionTo,
|
||
} from '../../src/core/graph';
|
||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||
import type { StateMachineAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||
import {
|
||
fakeContext,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
type ProjectFixture,
|
||
roundTrip,
|
||
startWorld,
|
||
fire,
|
||
} from '../helpers';
|
||
|
||
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
|
||
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
|
||
const create = new CreateAssetCommand('statemachine', 'Doors');
|
||
fixture.stack.execute(create);
|
||
const ids = names.map((name) => {
|
||
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
||
fixture.stack.execute(add);
|
||
return add.node.id;
|
||
});
|
||
return {
|
||
id: create.asset.id,
|
||
asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!,
|
||
ids,
|
||
};
|
||
}
|
||
|
||
describe('a state machine asset', () => {
|
||
it('is created empty, is not placeable, and takes states as nodes', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
expect(isPlaceableType('statemachine')).toBe(false);
|
||
expect(statesOf(machine.asset().graph)).toEqual([
|
||
{ id: machine.ids[0], name: 'Open' },
|
||
{ id: machine.ids[1], name: 'Closed' },
|
||
]);
|
||
f.stack.undo();
|
||
expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']);
|
||
});
|
||
|
||
it('names a state by its setting, and falls back to "State" for an empty name', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f, ['Open']);
|
||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, ''));
|
||
expect(statesOf(machine.asset().graph)[0]?.name).toBe('State');
|
||
const node = machine.asset().graph.nodes[0]!;
|
||
expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State');
|
||
});
|
||
|
||
it('survives a save and load, keeping only its states', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine');
|
||
expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph));
|
||
|
||
// A node of any other type has no business in a machine and is dropped on load.
|
||
const smuggled = deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'm',
|
||
type: 'statemachine',
|
||
name: 'M',
|
||
graph: {
|
||
nodes: [
|
||
{ id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } },
|
||
{ id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } },
|
||
],
|
||
edges: [],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
const asset = findAsset(smuggled, 'm') as StateMachineAsset;
|
||
expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]);
|
||
});
|
||
|
||
it('keeps its State node out of every behavior palette: it is registered, not built in', () => {
|
||
expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false);
|
||
expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false);
|
||
});
|
||
|
||
it('offers one palette entry, a State named by its number', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const entries = paletteEntries(f.project, machine.asset());
|
||
expect(entries.map((e) => e.def.type)).toEqual([STATE]);
|
||
expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' });
|
||
expect(nextStateName(machine.asset().graph)).toBe('State 3');
|
||
f.stack.undo();
|
||
expect(nextStateName(machine.asset().graph)).toBe('State 2');
|
||
});
|
||
|
||
it('is a kind of variable holding the asset id', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior');
|
||
expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']);
|
||
expect(back?.props.doors).toBe(machine.id);
|
||
});
|
||
});
|
||
|
||
describe('the state ports of State branch and Set state', () => {
|
||
const setup = () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const ports = (node: { id: string }, side: 'inputs' | 'outputs') =>
|
||
resolveDef(
|
||
behavior.graph.nodes.find((n) => n.id === node.id)!,
|
||
behavior.graph,
|
||
graphLookup(f.project),
|
||
)!
|
||
[side].filter((p) => p.type === 'exec')
|
||
.map((p) => `${p.key}:${p.label}`);
|
||
return { f, machine, behavior, edit, ports };
|
||
};
|
||
|
||
it('are one exec port per state, keyed by the state id and labelled by its name', () => {
|
||
const { machine, edit, ports } = setup();
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`];
|
||
// The branch carries one more: the way out for everything no state covers.
|
||
expect(ports(branch, 'outputs')).toEqual([...expected, 'else:else']);
|
||
expect(ports(set, 'inputs')).toEqual(expected);
|
||
});
|
||
|
||
it('follow a State machine literal wired into the machine input', () => {
|
||
const { machine, edit, ports } = setup();
|
||
const literal = edit.addNode('StateMachine', { value: machine.id });
|
||
const branch = edit.addNode('StateBranch');
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
edit.connect(literal, 'value', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).toEqual([
|
||
`${machine.ids[0]}:Open`,
|
||
`${machine.ids[1]}:Closed`,
|
||
'else:else',
|
||
]);
|
||
});
|
||
|
||
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
|
||
const { f, machine, behavior, edit, ports } = setup();
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const variables = edit.variables(['doors']);
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.connect(variables, 'doors', branch, MACHINE_PORT);
|
||
edit.connect(variables, 'doors', set, MACHINE_PORT);
|
||
// `else` is all that is left of the branch, and Set state has nothing at all – which is why
|
||
// "Set state by name" exists: it names the state instead of wiring it.
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
expect(ports(set, 'inputs')).toEqual([]);
|
||
// Only the State machine literal is followed – a text holding the very same id is not.
|
||
const text = edit.addNode('String', { value: machine.id });
|
||
edit.connect(text, 'value', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
});
|
||
|
||
it('follow a rename, and lose the edges of a state that is deleted', () => {
|
||
const { f, machine, behavior, edit, ports } = setup();
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.connect(start, 'exec', set, machine.ids[0]!);
|
||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar'));
|
||
expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`);
|
||
expect(behavior.graph.edges).toHaveLength(1);
|
||
// Picking another machine orphans the edge, as any param that decides the ports does.
|
||
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
|
||
expect(behavior.graph.edges).toHaveLength(0);
|
||
f.stack.undo();
|
||
expect(behavior.graph.edges).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('a running state machine', () => {
|
||
/** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */
|
||
function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) {
|
||
const behavior = makeBehavior(f, 'Door');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId });
|
||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||
const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||
edit.wire([
|
||
[start, 'exec', setOpen, open!],
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, open!, logOpen, 'exec'],
|
||
[logOpen, 'exec', setClosed, closed!],
|
||
[branch, closed!, logClosed, 'exec'],
|
||
]);
|
||
return behavior;
|
||
}
|
||
|
||
it('is in no state until Set state ran, then follows the state it was put in', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = build(f, machine.id, machine.ids);
|
||
const ctx = fakeContext();
|
||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual([]);
|
||
fire(interpreter, 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual(['open', 'closed']);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||
});
|
||
|
||
it('keeps one state per behavior instance', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const behavior = build(f, machine.id, machine.ids);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Shared state would let the second instance find the door the first one just closed.
|
||
expect(started.world.log).toEqual(['open', 'open']);
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']);
|
||
});
|
||
|
||
it('lets a called behavior see the state its caller set', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const inner = makeBehavior(f, 'Inner');
|
||
const innerEdit = graphEditor(f, inner);
|
||
const innerStart = innerEdit.addNode(BEHAVIOR_START);
|
||
const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const log = innerEdit.addNode('Log', { message: 'inner saw open' });
|
||
innerEdit.wire([
|
||
[innerStart, 'exec', branch, 'exec'],
|
||
[branch, machine.ids[0]!, log, 'exec'],
|
||
]);
|
||
const outer = makeBehavior(f, 'Outer');
|
||
const edit = graphEditor(f, outer);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id });
|
||
edit.wire([
|
||
[start, 'exec', set, machine.ids[0]!],
|
||
[set, 'exec', call, 'exec'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
// A local machine belongs to the instance, not to the graph that happens to run.
|
||
expect(ctx.logs).toEqual(['inner saw open']);
|
||
});
|
||
|
||
it('is driven by name where the machine arrives as a value, and branches through else', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'Door');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const edit = graphEditor(f, behavior);
|
||
const variables = edit.variables(['doors']);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetStateByName', { state: 'Closed' });
|
||
const missing = edit.addNode('SetStateByName', { state: 'Ajar' });
|
||
const logMissing = edit.addNode('Log', { message: 'no such state' });
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch');
|
||
const logElse = edit.addNode('Log', { message: 'unknown to the graph' });
|
||
edit.wire([
|
||
[variables, 'doors', set, MACHINE_PORT],
|
||
[variables, 'doors', missing, MACHINE_PORT],
|
||
[variables, 'doors', branch, MACHINE_PORT],
|
||
[start, 'exec', set, 'exec'],
|
||
[set, 'exec', missing, 'exec'],
|
||
[missing, 'notFound', logMissing, 'exec'],
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, 'else', logElse, 'exec'],
|
||
]);
|
||
const ctx = fakeContext({ variables: { doors: machine.id } });
|
||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||
fire(interpreter, 'OnStart', ctx);
|
||
// The name moved the machine; the one nothing answers to took "not found" instead of ending.
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||
expect(ctx.logs).toEqual(['no such state']);
|
||
// The branch has no state outputs at all here, so even a state that is set leaves through else.
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
|
||
});
|
||
|
||
it('refuses a connection to a state port the machine does not have', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
expect(() =>
|
||
f.stack.execute(
|
||
new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }),
|
||
),
|
||
).toThrow('Port not found');
|
||
});
|
||
});
|
||
|
||
describe('the state a machine starts in', () => {
|
||
it('is kept on the asset, can be taken off again, and survives a save', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
expect(machine.asset().start).toBe(open);
|
||
expect(findAssetOf(roundTrip(f.project), machine.id, 'statemachine')?.start).toBe(open);
|
||
|
||
// The same entry takes the mark off: only one state can be the start, so there is one field.
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, ''));
|
||
expect(machine.asset().start).toBeUndefined();
|
||
f.stack.undo();
|
||
expect(machine.asset().start).toBe(open);
|
||
});
|
||
|
||
it('reads as no start once that state is gone', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
f.stack.execute(new RemoveNodeCommand(machine.id, open));
|
||
// The dead id stays in the document; what the graph agrees to is what counts.
|
||
expect(machine.asset().start).toBe(open);
|
||
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe('');
|
||
expect(graphLookup(f.project).machine(machine.id)?.start).toBe('');
|
||
f.stack.undo();
|
||
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe(open);
|
||
});
|
||
|
||
it('puts every instance in it before anything reads the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'Door');
|
||
const edit = graphEditor(f, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||
edit.wire([
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, open, logOpen, 'exec'],
|
||
[branch, closed, logClosed, 'exec'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Without a start state this branch would follow nothing at all (that is the old behaviour).
|
||
expect(started.world.log).toEqual(['open']);
|
||
});
|
||
});
|
||
|
||
describe('On state entered and On state exited', () => {
|
||
/** A behavior that logs every entry and exit of the machine, one Log per state and direction. */
|
||
function watcher(f: ProjectFixture, machineId: string, ids: readonly string[], name = 'Watcher') {
|
||
const behavior = makeBehavior(f, name);
|
||
const edit = graphEditor(f, behavior);
|
||
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machineId });
|
||
const exited = edit.addNode('OnStateExited', { [MACHINE_PORT]: machineId });
|
||
for (const [index, state] of ids.entries()) {
|
||
const label = index === 0 ? 'open' : 'closed';
|
||
const inLog = edit.addNode('Log', { message: `${name}: entered ${label}` });
|
||
const outLog = edit.addNode('Log', { message: `${name}: exited ${label}` });
|
||
edit.wire([
|
||
[entered, state, inLog, 'exec'],
|
||
[exited, state, outLog, 'exec'],
|
||
]);
|
||
}
|
||
return { behavior, edit };
|
||
}
|
||
|
||
it('fires for the start state too, with nothing to exit before it', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, machine.ids[0]!));
|
||
const { behavior } = watcher(f, machine.id, machine.ids);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
// Once, not once per frame: the instance is already in that state the second time round.
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
});
|
||
|
||
it('fires exited before entered, after the flow that moved the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
// The pulse that moves the machine logs after the move: its own line comes first, the events after.
|
||
const key = edit.addNode('OnUpdate');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const done = edit.addNode('Log', { message: 'flow finished' });
|
||
edit.wire([
|
||
[key, 'exec', set, closed],
|
||
[set, 'exec', done, 'exec'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.log).toEqual([
|
||
'Watcher: entered open',
|
||
'flow finished',
|
||
'Watcher: exited open',
|
||
'Watcher: entered closed',
|
||
]);
|
||
});
|
||
|
||
it('says nothing when a set puts the machine into the state it is already in', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
const update = edit.addNode('OnUpdate');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([[update, 'exec', set, open]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
});
|
||
|
||
it('reaches only the instance that moved while the machine is local', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
// Both instances move themselves; what is asserted is that neither hears the other's move.
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([[start, 'exec', set, closed]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Each instance hears its own move and nobody else's: two entries, no exits (no state before).
|
||
expect(started.world.log).toEqual(['Watcher: entered closed', 'Watcher: entered closed']);
|
||
expect(started.world.log.filter((line) => line.includes('exited'))).toEqual([]);
|
||
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
|
||
expect(open).not.toBe('');
|
||
});
|
||
|
||
it('hears a move made inside a called behavior, each object in its own state', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
// Called every frame: closes the door it finds open, says so once it finds it closed.
|
||
const step = makeBehavior(f, 'Step');
|
||
const stepEdit = graphEditor(f, step);
|
||
const branch = stepEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const set = stepEdit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const said = stepEdit.addNode('Log', { message: 'Step: found closed' });
|
||
stepEdit.wire([
|
||
[stepEdit.addNode(BEHAVIOR_START), 'exec', branch, 'exec'],
|
||
[branch, open, set, closed],
|
||
[branch, closed, said, 'exec'],
|
||
]);
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: step.id });
|
||
edit.connect(edit.addNode('OnUpdate'), 'exec', call, 'exec');
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// The call starts in the instance's start state and its move reaches that instance's events.
|
||
expect([...started.world.log].sort()).toEqual([
|
||
'Watcher: entered closed',
|
||
'Watcher: entered closed',
|
||
'Watcher: entered open',
|
||
'Watcher: entered open',
|
||
'Watcher: exited open',
|
||
'Watcher: exited open',
|
||
]);
|
||
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
|
||
started.world.log.length = 0;
|
||
started.frame();
|
||
// The next call finds what the last one set, once per object.
|
||
expect(started.world.log).toEqual(['Step: found closed', 'Step: found closed']);
|
||
});
|
||
|
||
it('reaches every instance when the machine is global, and enters its start once', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineScopeCommand(machine.id, 'global'));
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior } = watcher(f, machine.id, machine.ids);
|
||
const mover = watcher(f, machine.id, machine.ids, 'Mover');
|
||
const start = mover.edit.addNode('OnStart');
|
||
const set = mover.edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
mover.edit.wire([[start, 'exec', set, closed]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, mover.behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// The start is entered once for the whole run and heard by both; so is the move one of them made.
|
||
expect(started.world.log).toEqual([
|
||
'Watcher: entered open',
|
||
'Mover: entered open',
|
||
'Watcher: exited open',
|
||
'Mover: exited open',
|
||
'Watcher: entered closed',
|
||
'Mover: entered closed',
|
||
]);
|
||
});
|
||
|
||
it('stops a run whose state events keep moving the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'Flapper');
|
||
const edit = graphEditor(f, behavior);
|
||
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machine.id });
|
||
const toClosed = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const toOpen = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([
|
||
[entered, open, toClosed, closed],
|
||
[entered, closed, toOpen, open],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.stopRequested).toBe(true);
|
||
expect(started.world.log.some((line) => line.includes('endless loop'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('reading the current state', () => {
|
||
it('hands out the name to show and the id to compare', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const current = edit.addNode('CurrentState', { [MACHINE_PORT]: machine.id });
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[start, 'exec', set, open],
|
||
[set, 'exec', log, 'exec'],
|
||
[current, 'name', log, 'message'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.logs).toEqual(['Open']);
|
||
});
|
||
|
||
it('is one boolean per state, false everywhere while the machine is in none', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const isIn = edit.addNode('IsInState', { [MACHINE_PORT]: machine.id });
|
||
const node = behavior.graph.nodes.find((n) => n.id === isIn.id)!;
|
||
const def = resolveDef(node, behavior.graph, graphLookup(f.project))!;
|
||
expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([`${open}:boolean`, `${closed}:boolean`]);
|
||
const ctx = fakeContext({ states: { [machine.id]: closed } });
|
||
expect(def.read!(ctx, {}, () => machine.id, def, graphLookup(f.project))).toEqual({
|
||
[open]: false,
|
||
[closed]: true,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('transitions between states', () => {
|
||
/** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */
|
||
const connect = (f: ProjectFixture, machineId: string, from: string, to: string) =>
|
||
f.stack.execute(
|
||
new ConnectCommand(machineId, { node: from, port: STATE_OUT }, { node: to, port: STATE_IN }),
|
||
);
|
||
|
||
it('hangs on the two exec ports every state carries', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const node = machine.asset().graph.nodes[0]!;
|
||
const def = resolveDef(node, machine.asset().graph, graphLookup(f.project))!;
|
||
expect(def.inputs.map((p) => p.key)).toEqual([STATE_IN]);
|
||
expect(def.outputs.map((p) => p.key)).toEqual([STATE_OUT]);
|
||
// Without a port there is no anchor to draw, drag or right-click an edge on.
|
||
expect(def.inputs[0]?.type).toBe('exec');
|
||
expect(def.outputs[0]?.type).toBe('exec');
|
||
});
|
||
|
||
it('is an ordinary edge, so drawing, undo and a save round trip come for free', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
|
||
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([]);
|
||
});
|
||
|
||
it('goes away with the state it hangs on, because both live in the one graph', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
f.stack.execute(new RemoveNodeCommand(machine.id, closed));
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([]);
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('is what the lookup hands a running graph', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
expect(graphLookup(f.project).machine(machine.id)?.transitions).toEqual([
|
||
{ from: open, to: closed, name: '' },
|
||
]);
|
||
});
|
||
|
||
it('moves the machine one step when a behavior runs State transition', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const next = edit.addNode('StateTransition', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([
|
||
[start, 'exec', set, open],
|
||
[set, 'exec', next, 'exec'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: closed });
|
||
});
|
||
|
||
it('carries the name it was given through a save, and undo takes it back', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const edge = machine.asset().graph.edges[0]!;
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' alarm '));
|
||
// Trimmed on the way in, so a stray space never makes a second name that looks the same.
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
|
||
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
|
||
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
|
||
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('clears a name back to unnamed rather than keeping an empty one', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const edge = machine.asset().graph.edges[0]!;
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, 'alarm'));
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' '));
|
||
expect(edge.label).toBeUndefined();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('reads the same name out of whichever state the machine is in', () => {
|
||
const machine = {
|
||
name: 'Guard',
|
||
states: [],
|
||
start: '',
|
||
transitions: [
|
||
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
|
||
{ from: 'searching', to: 'chasing', name: 'alarm' },
|
||
{ from: 'chasing', to: 'searching', name: 'lost' },
|
||
],
|
||
};
|
||
expect(transitionTo(machine, 'patrolling', 'alarm')).toBe('chasing');
|
||
expect(transitionTo(machine, 'searching', 'alarm')).toBe('chasing');
|
||
expect(transitionTo(machine, 'chasing', 'alarm')).toBe('');
|
||
expect(transitionNames(machine)).toEqual(['alarm', 'lost']);
|
||
});
|
||
});
|