game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.
Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.
Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.
`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.
The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.
One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
18 KiB
TypeScript
422 lines
18 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 } 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,
|
||
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('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: [],
|
||
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']);
|
||
});
|
||
});
|