game-maker / suite (push) Successful in 10m58s
Four issues that all reach for the same line: how the current state is read and written. Splitting them into four commits would mean four states of the tree that do not compile, so they arrive together. #55 start state — the machine asset carries an optional `start` holding a State node's id. Right-click a state to set or remove it, a ring in the node's title says which one it is. A dead id (the state was deleted) reads as "no start", so nothing has to clean up after a deletion. #53 entered/exited — `On state entered` and `On state exited`, one exec output per state. Every change now goes through a single `setState` that reports it; BehaviorSystem delivers it after the exec flow that caused it, `exited` of the old before `entered` of the new, round after round until the queue is empty, with a cap that stops a ping-pong graph with an endless-loop error. A local machine tells the instance that switched, a global one tells every instance holding a matching event node. The start state fires `entered` too: per instance for a local machine, once per run for a global one. #57 reading — `Current state` with `name` for showing and `id` for comparing, `Is in state` with one boolean per state. Neither has a `run`, so `read` learned `def` and `lookup`, and the catalog test knows this third kind of node. #54 seeing it — during a run the state machine tab marks every state some instance is holding faintly, and the one held by the selected scene object's instance strongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
28 KiB
TypeScript
664 lines
28 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,
|
||
} 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`];
|
||
expect(ports(branch, 'outputs')).toEqual(expected);
|
||
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([]);
|
||
edit.connect(literal, 'value', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]);
|
||
});
|
||
|
||
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 });
|
||
edit.connect(variables, 'doors', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).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([]);
|
||
});
|
||
|
||
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));
|
||
interpreter.fire('OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual([]);
|
||
interpreter.fire('OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
interpreter.fire('OnUpdate', ctx);
|
||
interpreter.fire('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('starts a called behavior in no state, whatever the 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();
|
||
new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
expect(ctx.logs).toEqual([]);
|
||
});
|
||
|
||
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('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();
|
||
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('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();
|
||
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('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']);
|
||
});
|
||
});
|