From 7babf886361dc32e70a5d49c383cdc24549228c2 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 15 Sep 2026 06:18:20 +0200 Subject: [PATCH] A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/commands/assetCommands.ts | 35 ++- src/core/graph/GraphInterpreter.ts | 41 ++- src/core/graph/NodeContext.ts | 6 + src/core/graph/NodeRegistry.ts | 10 +- src/core/graph/lookup.ts | 2 + src/core/graph/nodes/data/CurrentState.ts | 26 ++ src/core/graph/nodes/data/IsInState.ts | 26 ++ src/core/graph/nodes/events/OnStateEntered.ts | 20 ++ src/core/graph/nodes/events/OnStateExited.ts | 16 ++ src/core/graph/nodes/flow/SetState.ts | 4 +- src/core/graph/nodes/flow/StateBranch.ts | 9 +- src/core/graph/nodes/flow/StateTransition.ts | 7 +- src/core/graph/nodes/index.ts | 8 + src/core/graph/sharedState.ts | 37 ++- src/core/graph/stateMachine.ts | 37 ++- src/core/model/Asset.ts | 6 + src/core/model/graphLookup.ts | 3 +- src/core/store/serialize.ts | 2 + src/editor/Editor.ts | 8 + src/editor/EditorRun.ts | 6 + src/editor/PlaySession.ts | 10 + src/editor/debugHook.ts | 3 + src/editor/graph/GraphCanvas.ts | 66 ++++- src/editor/graph/NodeView.ts | 17 ++ src/runtime/systems/BehaviorSystem.ts | 103 +++++++- tests/core/graph.test.ts | 8 +- tests/core/node-catalog.test.ts | 63 ++++- tests/core/state-machine.test.ts | 244 +++++++++++++++++- tests/helpers.ts | 9 +- tools/e2e/26-state-machine.mjs | 118 +++++++++ 30 files changed, 906 insertions(+), 44 deletions(-) create mode 100644 src/core/graph/nodes/data/CurrentState.ts create mode 100644 src/core/graph/nodes/data/IsInState.ts create mode 100644 src/core/graph/nodes/events/OnStateEntered.ts create mode 100644 src/core/graph/nodes/events/OnStateExited.ts diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index d06aca46..a648b87e 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -1,6 +1,6 @@ import { createEmptyGraph } from '../graph/Graph'; import { newId } from '../ids'; -import type { Asset, FontAsset, TextureAsset } from '../model/Asset'; +import type { Asset, FontAsset, StateMachineAsset, TextureAsset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { removeSubtrees } from '../model/hierarchy'; @@ -241,6 +241,39 @@ export class SetMachineScopeCommand implements Command { } } +/** + * Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the + * machine is back to starting in no state. Only one state can be the start because there is only + * one field to hold it. + */ +export class SetMachineStartCommand implements Command { + readonly label: string; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly start: string, + ) { + this.label = start ? 'Set start state' : 'Remove start state'; + } + + execute(project: Project): void { + const asset = requireStateMachine(project, this.assetId); + this.previous = asset.start ?? ''; + this.write(asset, this.start); + } + + undo(project: Project): void { + this.write(requireStateMachine(project, this.assetId), this.previous); + } + + /** Kept out of the document when there is none, the way a `local` scope is. */ + private write(asset: StateMachineAsset, start: string): void { + if (start) asset.start = start; + else delete asset.start; + } +} + abstract class AssetPropertyCommand extends SetBagValueCommand { protected constructor( private readonly assetId: string, diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 8fa0a5e6..80cda921 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -11,6 +11,7 @@ import { type PortDef, type RepeatResult, } from './NodeRegistry'; +import { machineIdOf } from './stateMachine'; import type { Value } from './values'; /** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */ @@ -72,6 +73,8 @@ export class GraphInterpreter { ctx: NodeContext, outputs: Record = {}, filter?: (node: GraphNode) => boolean, + /** Which of the event node's exec outputs the pulse leaves through – a state event names one. */ + port = 'exec', ): number { let fired = 0; for (const node of this.eventNodes(type)) { @@ -82,12 +85,28 @@ export class GraphInterpreter { this.frame.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); this.record(node.id, outputs); - this.follow(node, 'exec', ctx, 0); + this.follow(node, port, ctx, 0); fired++; } return fired; } + /** + * `On state entered` / `On state exited` for one move: only the nodes watching that machine, and + * out of the one output the state stands for. A node whose machine input carries a running value + * rather than a pick has no state outputs at all, so it is never among them. + */ + fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number { + if (!machineId || !stateId) return 0; + return this.fire( + type, + ctx, + {}, + (node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId, + stateId, + ); + } + private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { for (const edge of edgesOutOf(this.frame.graph, node.id, port)) { const target = this.node(edge.to.node); @@ -135,8 +154,22 @@ export class GraphInterpreter { this.calledBehaviors.add(call.behaviorId); try { this.trace?.onNode(start.id); - // Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the caller's. - this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults }, states: {} }, depth + 1); + // Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the + // caller's. What it does to a local machine therefore dies with the call and is not reported; + // a `global` machine is the run's, so moving that one is a move like any other. + this.follow( + start, + 'exec', + { + ...ctx, + variables: { ...program.defaults }, + states: {}, + stateChanged: (machineId, from, to) => { + if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); + }, + }, + depth + 1, + ); const end = terminalNode(program.graph, BEHAVIOR_END); return (end && this.frame.outputCache.get(end.id)) ?? {}; } finally { @@ -211,7 +244,7 @@ export class GraphInterpreter { return found ? this.inputValue(node, found, ctx) : null; }; // `read` may hand back a whole bag (the Variables node returns the object's variables, live). - const all = def.read?.(ctx, node.params, input) ?? {}; + const all = def.read?.(ctx, node.params, input, def, this.lookup) ?? {}; const own: Record = {}; for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null; return own; diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index ebd6d454..40768a7f 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -94,6 +94,12 @@ export interface NodeContext { */ states: Record; shared: SharedState; + /** + * A machine moved from one state to another – what `On state exited` / `On state entered` are + * fired from. Reported rather than fired on the spot: the events belong after the exec flow that + * caused them, never in the middle of it (see `BehaviorSystem`). + */ + stateChanged(machineId: string, from: string, to: string): void; /** Seconds since the world started and since the last tick. */ time: number; dt: number; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 65ddb185..1a7fca53 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -106,8 +106,16 @@ export interface NodeDef { * Outputs readable at any moment, even while the node's exec input has not fired in this pulse. * Never cached, so a read after a write sees the new value. `input` pulls one input on demand: * pulling all of them would loop through an edge that feeds the node's own output back in. + * `def` and `lookup` are as in `run`: the definition resolved for this very node, and the project + * around it – what a reader with one output per state needs to answer for all of them. */ - read?(ctx: NodeContext, params: PropertyBag, input: (key: string) => Value): Record; + read?( + ctx: NodeContext, + params: PropertyBag, + input: (key: string) => Value, + def: NodeDef, + lookup: GraphLookup, + ): Record; /** * Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at * hand, the only way a node with derived ports knows how many it has. `entry` is the exec input diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index ce244dd9..4fe5f0f7 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -28,6 +28,8 @@ export interface StateMachine { name: string; states: MachineState[]; transitions: MachineTransition[]; + /** Id of the state every instance begins in, empty when the machine has no start (see #55). */ + start: string; } /** diff --git a/src/core/graph/nodes/data/CurrentState.ts b/src/core/graph/nodes/data/CurrentState.ts new file mode 100644 index 00000000..78340810 --- /dev/null +++ b/src/core/graph/nodes/data/CurrentState.ts @@ -0,0 +1,26 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { currentState } from '../../sharedState'; +import { machinePort } from '../../stateMachine'; +import { asString } from '../../values'; +import { str } from '../shared'; + +/** + * The data side of `State branch`: two outputs, because the two uses pull apart. `name` is what + * `Join` and a `Text` want to show, `id` is what a comparison should hold – a rename moves the + * name, never the id. Both are empty while the machine is in no state. + */ +export const CurrentState: NodeDef = { + type: 'CurrentState', + label: 'Current state', + category: 'data', + description: 'The state the machine is in: its name to show, its id to compare. Empty for no state.', + inputs: [machinePort()], + outputs: [str('name'), str('id')], + params: [], + read: (ctx, _params, input, _def, lookup) => { + const machine = asString(input('machine')); + const id = currentState(ctx, machine); + const name = (id && lookup.machine(machine)?.states.find((s) => s.id === id)?.name) || ''; + return { name, id }; + }, +}; diff --git a/src/core/graph/nodes/data/IsInState.ts b/src/core/graph/nodes/data/IsInState.ts new file mode 100644 index 00000000..a76ab761 --- /dev/null +++ b/src/core/graph/nodes/data/IsInState.ts @@ -0,0 +1,26 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { currentState } from '../../sharedState'; +import { machineOf, machinePort, statePorts } from '../../stateMachine'; +import { asString, type Value } from '../../values'; + +/** + * One boolean per state, all false while the machine is in none – the data twin of `State branch`, + * for the places a flow cannot go: `Branch`, `And`, a property. Every output is answered, so a + * state nobody is in reads `false` rather than nothing at all. + */ +export const IsInState: NodeDef = { + type: 'IsInState', + label: 'Is in state', + category: 'data', + description: 'True on the output of the state the machine is in, false on every other.', + inputs: [machinePort()], + outputs: [], + params: [], + derivePorts: (source) => ({ outputs: statePorts(machineOf(source), 'boolean') }), + read: (ctx, _params, input, def) => { + const current = currentState(ctx, asString(input('machine'))); + const values: Record = {}; + for (const port of def.outputs) values[port.key] = port.key === current; + return values; + }, +}; diff --git a/src/core/graph/nodes/events/OnStateEntered.ts b/src/core/graph/nodes/events/OnStateEntered.ts new file mode 100644 index 00000000..61745a6c --- /dev/null +++ b/src/core/graph/nodes/events/OnStateEntered.ts @@ -0,0 +1,20 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { machineOf, machinePort, statePorts } from '../../stateMachine'; + +/** + * One exec output per state, like `State branch` – the pulse leaves through the state that was + * entered. Fired after the flow that caused the move, never inside it. A `global` machine is + * entered once for the whole run, and every instance watching it hears that one move. + */ +export const OnStateEntered: NodeDef = { + type: 'OnStateEntered', + label: 'On state entered', + category: 'event', + description: + "Runs when the machine enters a state, along that state's output. The start state counts as " + + 'entered when the instance begins; entering the state the machine is already in does not.', + inputs: [machinePort()], + outputs: [], + params: [], + derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), +}; diff --git a/src/core/graph/nodes/events/OnStateExited.ts b/src/core/graph/nodes/events/OnStateExited.ts new file mode 100644 index 00000000..d153e544 --- /dev/null +++ b/src/core/graph/nodes/events/OnStateExited.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { machineOf, machinePort, statePorts } from '../../stateMachine'; + +/** The other half of the move: the pulse leaves through the state that was left, before `entered`. */ +export const OnStateExited: NodeDef = { + type: 'OnStateExited', + label: 'On state exited', + category: 'event', + description: + "Runs when the machine leaves a state, along that state's output. A machine coming out of no " + + 'state at all has nothing to leave, so nothing runs.', + inputs: [machinePort()], + outputs: [], + params: [], + derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), +}; diff --git a/src/core/graph/nodes/flow/SetState.ts b/src/core/graph/nodes/flow/SetState.ts index 62ba7de1..dbe61ba0 100644 --- a/src/core/graph/nodes/flow/SetState.ts +++ b/src/core/graph/nodes/flow/SetState.ts @@ -1,5 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; -import { stateBag } from '../../sharedState'; +import { setState } from '../../sharedState'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -19,7 +19,7 @@ export const SetState: NodeDef = { run: (ctx, inputs, _params, def, entry) => { const machine = asString(inputs.machine); if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) - stateBag(ctx, machine)[machine] = entry; + setState(ctx, machine, entry); return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/flow/StateBranch.ts b/src/core/graph/nodes/flow/StateBranch.ts index 449576b0..ec7407c9 100644 --- a/src/core/graph/nodes/flow/StateBranch.ts +++ b/src/core/graph/nodes/flow/StateBranch.ts @@ -1,5 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; -import { stateBag } from '../../sharedState'; +import { currentState } from '../../sharedState'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -10,15 +10,14 @@ export const StateBranch: NodeDef = { label: 'State branch', category: 'flow', description: - 'Continues along the output of the state the machine is in. Before "Set state" has run, the ' + - 'machine is in no state and nothing continues.', + 'Continues along the output of the state the machine is in. A machine with no start state is ' + + 'in no state until "Set state" has run, and nothing continues.', inputs: [exec(), machinePort()], outputs: [], params: [], derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), run: (ctx, inputs, _params, def) => { - const machine = asString(inputs.machine); - const current = stateBag(ctx, machine)[machine]; + const current = currentState(ctx, asString(inputs.machine)); return def.outputs.some((p) => p.key === current) ? { next: current } : {}; }, }; diff --git a/src/core/graph/nodes/flow/StateTransition.ts b/src/core/graph/nodes/flow/StateTransition.ts index 7e819962..e005f800 100644 --- a/src/core/graph/nodes/flow/StateTransition.ts +++ b/src/core/graph/nodes/flow/StateTransition.ts @@ -1,7 +1,7 @@ import { textOf } from '../../../model/Properties'; import { flatDef } from '../../../properties/PropertySchema'; import type { NodeDef } from '../../NodeRegistry'; -import { stateBag } from '../../sharedState'; +import { currentState, setState } from '../../sharedState'; import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -19,12 +19,11 @@ export const StateTransition: NodeDef = { params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })], run: (ctx, inputs, params, _def, _entry, lookup) => { const id = asString(inputs.machine); - const states = stateBag(ctx, id); - const current = states[id] ?? ''; + const current = currentState(ctx, id); const machine = current ? lookup.machine(id) : null; const name = textOf(params[TRANSITION_PARAM] ?? ''); const target = machine ? transitionTo(machine, current, name) : ''; - if (target) states[id] = target; + if (target) setState(ctx, id, target); return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 7ec17741..b85aa5a5 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -23,6 +23,7 @@ import { GetPosition } from './data/GetPosition'; import { IsAsset } from './data/IsAsset'; import { IsEnabled } from './data/IsEnabled'; import { IsHovered } from './data/IsHovered'; +import { IsInState } from './data/IsInState'; import { IsTouching } from './data/IsTouching'; import { Join } from './data/Join'; import { KeyIsDown } from './data/KeyIsDown'; @@ -61,6 +62,7 @@ import { ToNumber } from './data/ToNumber'; import { ToText } from './data/ToText'; import { Concat } from './data/Concat'; import { Count } from './data/Count'; +import { CurrentState } from './data/CurrentState'; import { ExpandStruct } from './data/ExpandStruct'; import { Fill } from './data/Fill'; import { IndexOf } from './data/IndexOf'; @@ -79,6 +81,8 @@ import { OnKeyDown } from './events/OnKeyDown'; import { OnKeyHeld } from './events/OnKeyHeld'; import { OnKeyUp } from './events/OnKeyUp'; import { OnStart } from './events/OnStart'; +import { OnStateEntered } from './events/OnStateEntered'; +import { OnStateExited } from './events/OnStateExited'; import { OnTimer } from './events/OnTimer'; import { OnUpdate } from './events/OnUpdate'; import { BehaviorEnd } from './flow/BehaviorEnd'; @@ -114,6 +118,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnCollisionExit, OnClick, OnAnimationFinished, + OnStateEntered, + OnStateExited, BehaviorStart, Branch, Sequence, @@ -122,6 +128,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ StateBranch, SetState, StateTransition, + CurrentState, + IsInState, BehaviorEnd, Variables, Self, diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts index 040db17c..0e1a81f6 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -2,6 +2,7 @@ import type { PropertyBag } from '../model/Properties'; import type { Project } from '../model/Project'; import { isGlobal } from '../model/Scope'; import type { NodeContext, SharedState } from './NodeContext'; +import { startStateOf } from './stateMachine'; export function emptySharedState(): SharedState { return { variables: {}, states: {} }; @@ -26,10 +27,25 @@ export function declaredVariables(project: Project): Record export function buildSharedState(project: Project): SharedState { const shared: SharedState = { variables: declaredVariables(project), states: {} }; for (const asset of project.assets) - if (asset.type === 'statemachine' && isGlobal(asset)) shared.states[asset.id] = ''; + if (asset.type === 'statemachine' && isGlobal(asset)) + shared.states[asset.id] = startStateOf(asset.graph, asset.start); return shared; } +/** + * The start state of every machine that has a usable one, keyed by asset id. A machine without one + * is absent, which is what keeps "no start" and "starts in a deleted state" the same thing. + */ +export function startStates(project: Project): Record { + const starts: Record = {}; + for (const asset of project.assets) { + if (asset.type !== 'statemachine') continue; + const start = startStateOf(asset.graph, asset.start); + if (start) starts[asset.id] = start; + } + return starts; +} + /** * Moves edits made to the declarations during a run into the values it shares. A key is taken over * only where the document changed it since `seen`, which is what leaves a value a graph has moved @@ -68,3 +84,22 @@ export function variableBag(ctx: NodeContext, source: string): PropertyBag { export function stateBag(ctx: NodeContext, machineId: string): Record { return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; } + +/** The current state of a machine, `''` when it is in none. */ +export function currentState(ctx: NodeContext, machineId: string): string { + return (machineId && stateBag(ctx, machineId)[machineId]) || ''; +} + +/** + * The one way a machine moves: writes the new state and reports the move. Into the state it is + * already in is no move – nothing is written, and nothing fires, which is what keeps a `Set state` + * run every frame from firing `entered` every frame. + */ +export function setState(ctx: NodeContext, machineId: string, next: string): void { + if (!machineId) return; + const bag = stateBag(ctx, machineId); + const previous = bag[machineId] ?? ''; + if (previous === next) return; + bag[machineId] = next; + ctx.stateChanged(machineId, previous, next); +} diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index 85a1c541..f2830f7f 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -1,6 +1,6 @@ import { textOf } from '../model/Properties'; import { flatDef } from '../properties/PropertySchema'; -import { edgesInto, type Graph, type GraphNode } from './Graph'; +import { edgesInto, type Graph, type GraphNode, type PortType } from './Graph'; import type { MachineState, MachineTransition, StateMachine } from './lookup'; import type { NodeDef, PortDef, PortSource } from './NodeRegistry'; @@ -47,6 +47,15 @@ export function statesOf(graph: Graph): MachineState[] { return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) })); } +/** + * The state a machine starts in, as far as its graph agrees: the marked id when a state of that id + * is still there, else none. A start pointing at a deleted state is not an error to repair – it + * reads as "no start", which is exactly what the machine had before anyone marked one. + */ +export function startStateOf(graph: Graph, start: string | undefined): string { + return start && graph.nodes.some((n) => n.id === start && isStateNode(n)) ? start : ''; +} + /** The transitions of a machine's graph: every edge between two states, in edge order. */ export function transitionsOf(graph: Graph): MachineTransition[] { const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id)); @@ -86,22 +95,24 @@ export const machinePort = (): PortDef => ({ }); /** - * The machine a node's `machine` input names while the graph is drawn: the inline pick, or the + * The machine asset a node's `machine` input names while the graph is drawn: the inline pick, or the * `State machine` literal an edge brings in. A port is derived from the graph, not from a running - * value, so a variable or a list item feeding the input yields no state ports. + * value, so a variable or a list item feeding the input yields nothing. */ -export function machineOf({ node, graph, lookup }: PortSource): StateMachine | null { +export function machineIdOf({ node, graph }: PortSource): string { const edge = edgesInto(graph, node.id, MACHINE_PORT)[0]; const source = edge && graph.nodes.find((n) => n.id === edge.from.node); - const id = edge - ? source?.type === STATE_MACHINE - ? textOf(source.params.value ?? '') - : '' - : textOf(node.params[MACHINE_PORT] ?? ''); - return id ? lookup.machine(id) : null; + if (!edge) return textOf(node.params[MACHINE_PORT] ?? ''); + return source?.type === STATE_MACHINE ? textOf(source.params.value ?? '') : ''; } -/** One exec port per state, keyed by the state's id – a rename keeps every edge. */ -export function statePorts(machine: StateMachine | null): PortDef[] { - return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type: 'exec' })); +/** What that machine is, looked up – null where the input names none, or none that exists. */ +export function machineOf(source: PortSource): StateMachine | null { + const id = machineIdOf(source); + return id ? source.lookup.machine(id) : null; +} + +/** One port per state, keyed by the state's id – a rename keeps every edge. */ +export function statePorts(machine: StateMachine | null, type: PortType = 'exec'): PortDef[] { + return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type })); } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 6bae82fb..4f51f03c 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -97,11 +97,17 @@ export interface AnimationAsset extends AssetBase { * The graph holds one `State` node per state and an edge per transition; a behavior reads the * machine's current state through `State branch` and moves it through `Set state` or `State transition`. * A `global` machine has one current state for the whole run instead of one per behavior instance. + * + * `start` is the id of the state every instance begins in, empty when the machine has no start – + * then it is in no state until `Set state` runs. It lives on the asset rather than on the node + * because only one state may be the start, and that is structural here instead of a rule someone + * has to keep. A deleted state leaves a dead id behind, which reads as no start. */ export interface StateMachineAsset extends AssetBase { type: 'statemachine'; graph: Graph; scope?: Scope; + start?: string; } /** diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 6ba0592c..eb5054e8 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,5 +1,5 @@ import type { GraphLookup } from '../graph/lookup'; -import { statesOf, transitionsOf } from '../graph/stateMachine'; +import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine'; import { resolveAssetProps } from '../properties/resolve'; import { findAssetOf, type Project } from './Project'; @@ -21,6 +21,7 @@ export function graphLookup(project: Project): GraphLookup { name: asset.name, states: statesOf(asset.graph), transitions: transitionsOf(asset.graph), + start: startStateOf(asset.graph, asset.start), }; }, }; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index f6a0922f..33f419af 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -141,6 +141,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph, (def) => def.type === STATE), }; if (readScope(a.scope) === 'global') machine.scope = 'global'; + // Kept as written even where it names no state any more: the same as a deleted start. + if (typeof a.start === 'string' && a.start !== '') machine.start = a.start; assets.push(machine); } else if (type === 'shader') { assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index a849bdaa..2874c924 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -177,6 +177,11 @@ export class Editor implements PlayHost { this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.timeProvider = () => this.play.time; + // Only one of the two ever runs a given machine, for the reason the live variables are one call. + this.stateMachineEditor.canvas.stateProvider = (assetId) => { + const playing = this.play.machineStates(assetId); + return playing.length > 0 ? playing : this.editorRun.machineStates(assetId); + }; // Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`). this.inspector.liveVariables = (assetId) => this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); @@ -343,6 +348,9 @@ export class Editor implements PlayHost { this.gameView.animate(); this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(step); + // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session + // to do it, and its states move just the same. + if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); if (this.tab === 'particles') this.particlesEditor.tick(); if (this.movesByItself()) requestRender(); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 24d30c0f..962983e0 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -5,6 +5,7 @@ import { hasEditorRun, runsInEditor } from '../core/properties/editorRun'; import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem'; import { Input } from '../runtime/systems/Input'; import { type ModelWrite, World } from '../runtime/World'; +import type { LiveMachineState } from './graph/GraphCanvas'; import type { EditorState } from './state'; export interface EditorRunHooks { @@ -69,6 +70,11 @@ export class EditorRun { if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick(); } + /** Which state each instance of a machine holds in this run; empty when no editor run is on. */ + machineStates(machineId: string): LiveMachineState[] { + return this.behaviors?.machineStates(machineId) ?? []; + } + /** The values a Variables asset shares in this run; null when no editor run is on. */ liveVariables(assetId: string): PropertyBag | null { return this.behaviors?.liveVariables(assetId) ?? null; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 520edc24..f42eef20 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -18,6 +18,8 @@ import { World } from '../runtime/World'; import { ui } from '../ui'; import type { GameViewport } from './viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; +import type { LiveMachineState } from './graph/GraphCanvas'; +import type { StateMachineEditor } from './statemachine/StateMachineEditor'; import type { EditorState } from './state'; /** Facts about the running world for the check scripts. */ @@ -42,6 +44,7 @@ export interface WorldSnapshot { export interface PlayHost { gameView: GameViewport; graphEditor: GraphEditor; + stateMachineEditor: StateMachineEditor; showGame(): void; /** Leave the game view if it is the one on screen. */ leaveGame(): void; @@ -77,6 +80,11 @@ export class PlaySession { return this.behaviors?.traceFor(assetId) ?? null; } + /** Which state each running instance of a machine holds; empty when nothing is playing. */ + machineStates(machineId: string): LiveMachineState[] { + return this.behaviors?.machineStates(machineId) ?? []; + } + get time(): number { return this.world?.time ?? 0; } @@ -157,6 +165,8 @@ export class PlaySession { if (world.syncToModel().structure) this.state.preview(); this.state.runtimeTick(); if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw(); + // The machine tab shows the state a run is in, so it repaints for the same reason the graph does. + if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw(); } snapshot(): WorldSnapshot | null { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 77a40169..41f45425 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -303,6 +303,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(), emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(), selected: () => state.selectedNodeIds, + /** The start ring and the live marks as drawn – what no colour check could tell apart. */ + startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId), + stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(), }, particles: { current: () => editor.particlesEditor.currentAssetId, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index ab7a77c5..d5abc0c5 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -8,6 +8,7 @@ import { ConnectCommand, MoveNodeCommand, } from '../../core/commands/graphCommands'; +import { SetMachineStartCommand } from '../../core/commands/assetCommands'; import { BEHAVIOR_PARAM, callProblem, @@ -29,6 +30,7 @@ import { RUN_BEHAVIOR, STATE_IN, STATE_OUT, + startStateOf, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; @@ -64,6 +66,14 @@ import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; +/** One running instance's answer for a machine: which state it holds, and whose instance it is. */ +export interface LiveMachineState { + state: string; + /** Object id of the behavior itself, and of the object it hangs under (empty when it has none). */ + objectId: string; + ownerId: string; +} + /** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ const GRID_STEP = 24; /** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ @@ -134,6 +144,8 @@ export class GraphCanvas extends Widget { private beadAsset = ''; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; + /** Which state each running instance of this machine holds; empty when nothing runs. */ + stateProvider: (assetId: string) => readonly LiveMachineState[] = () => []; timeProvider: () => number = () => 0; /** A double-clicked call node; the tab opens the behavior and records the step. */ onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId }); @@ -269,6 +281,51 @@ export class GraphCanvas extends Widget { this.redraw(); } + /** + * The one entry that marks a state as the machine's start, or takes the mark off it again – the + * same place, so it reads as the switch it is. Only a state machine has one to give. + */ + private startItems(nodeId: string): MenuItem[] { + const asset = this.asset(); + if (asset?.type !== 'statemachine') return []; + const isStart = startStateOf(asset.graph, asset.start) === nodeId; + return [ + { + label: isStart ? 'Remove start state' : 'Set as start state', + onSelect: () => this.state.execute(new SetMachineStartCommand(asset.id, isStart ? '' : nodeId)), + }, + ]; + } + + /** + * Which states to light up while something runs: every state an instance holds, and strongly the + * one held by the selected object's instance. Without a selection nothing is strong – that keeps + * "many instances, many states" readable instead of picking one of them at random. + */ + private stateMarks(asset: GraphAsset): Map { + const marks = new Map(); + if (asset.type !== 'statemachine') return marks; + const selection = this.state.selection; + const picked = selection?.kind === 'object' ? selection.objectId : ''; + for (const live of this.stateProvider(asset.id)) { + const mine = picked !== '' && (live.objectId === picked || live.ownerId === picked); + if (mine || marks.get(live.state) !== 'current') marks.set(live.state, mine ? 'current' : 'held'); + } + return marks; + } + + /** What the last redraw marked, for the checks: the drawn state, not a recomputation of it. */ + stateMarksDrawn(): Record { + const marks: Record = {}; + for (const [id, view] of this.views) if (view.stateMark !== 'none') marks[id] = view.stateMark; + return marks; + } + + /** Whether the start ring is drawn on this node – the same rule. */ + startRingDrawn(nodeId: string): boolean { + return this.views.get(nodeId)?.isStart ?? false; + } + /** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */ private fitView(): void { if (this.w === 0 || this.h === 0) return; @@ -383,7 +440,10 @@ export class GraphCanvas extends Widget { const graphPoint = this.screenToGraph(at.x, at.y); const extras: MenuItem[] = view.def.transitionEdges === true - ? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }] + ? [ + { label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }, + ...this.startItems(id), + ] : []; openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras); } @@ -808,9 +868,13 @@ export class GraphCanvas extends Widget { const wall = performance.now(); const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; if (values) this.valuesShownAt = wall; + const marks = this.stateMarks(asset); + const start = asset.type === 'statemachine' ? startStateOf(asset.graph, asset.start) : ''; for (const view of this.views.values()) { view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); + view.isStart = view.node.id === start; + view.stateMark = marks.get(view.node.id) ?? 'none'; if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project); view.targetPort = target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 9d22a198..f2bb28a9 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -102,6 +102,13 @@ export class NodeView extends Widget { private problem: string | null = null; selected = false; hot = false; + /** The state a machine begins in wears a ring, so the entry point is visible at a glance. */ + isStart = false; + /** + * While something is running: `held` for a state some instance of the machine is in, `current` + * for the one the selected object's instance holds. Nothing outside a run. + */ + stateMark: 'none' | 'held' | 'current' = 'none'; /** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */ targetPort: { port: string; isOutput: boolean } | null = null; /** Data inputs that currently have a connection (their inline value is hidden). */ @@ -390,10 +397,20 @@ export class NodeView extends Widget { .rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius) .fill(categoryColor(this.def.category)); this.drawChevron(PAD + 4, TITLE_HEIGHT / 2); + // The ring sits where a state's ports never are: a state draws none (see `portCentres`). + if (this.isStart) + this.box + .circle(this.w - PAD - PORT_RADIUS, TITLE_HEIGHT / 2, PORT_RADIUS) + .stroke({ width: 2, color: c.text }); const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder; this.box .roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius) .stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border }); + // Inside the border, so a selected state still shows both: what is picked, and what is live. + if (this.stateMark !== 'none') + this.box + .roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius) + .stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 }); this.ports.clear(); const r = PORT_RADIUS; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 7bc0764a..28cfa4a2 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -11,6 +11,7 @@ import { isHandleEnabled, type NodeContext, type SharedState, + startStates, type TraceListener, type Value, } from '../../core/graph'; @@ -41,6 +42,21 @@ interface Produced { values: Record; } +/** + * A move one graph made, waiting for its events. `source` is the instance that caused it, and who + * hears it when the machine is local; a `global` machine is heard by every instance, so the move it + * reports has no owner (`null` for the start state entered before any instance existed). + */ +interface StateChange { + machineId: string; + from: string; + to: string; + source: Instance | null; +} + +/** How often state events may cause the next move before the run is called endless. */ +const MAX_STATE_ROUNDS = 100; + interface Instance { entity: Entity; interpreter: GraphInterpreter; @@ -89,6 +105,8 @@ export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; private sharedState: SharedState = emptySharedState(); + /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ + private pending: StateChange[] = []; /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ private declared: Record = {}; private readonly collisions: CollisionSystem | null; @@ -128,9 +146,14 @@ export class BehaviorSystem implements System { start(world: World): void { this.world = world; this.instances = []; + this.pending = []; // Built fresh for every run: a shared variable starts at its declared value each time Play does. this.sharedState = buildSharedState(this.project); this.declared = declaredVariables(this.project); + // A `global` machine is entered once for the whole run, before the first instance exists – so + // the move is queued here and every instance the first sync creates hears that one entry. + for (const [machineId, start] of Object.entries(this.sharedState.states)) + if (start) this.pending.push({ machineId, from: '', to: start, source: null }); this.syncInstances(world); } @@ -138,6 +161,7 @@ export class BehaviorSystem implements System { stop(): void { for (const instance of this.instances) this.finish(instance); this.instances = []; + this.pending = []; this.world = null; } @@ -147,6 +171,8 @@ export class BehaviorSystem implements System { */ private syncInstances(world: World): void { this.reap(world); + // Read once, and only where an instance is actually born: most frames create none at all. + let starts: Record | null = null; for (const entity of world.entities) { if ( entity.type !== 'behavior' || @@ -171,6 +197,7 @@ export class BehaviorSystem implements System { } satisfies TraceListener; const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true }; this.instances.push(instance); + this.enterStartStates(instance, (starts ??= startStates(this.project))); this.run( instance, (i, ctx) => { @@ -180,6 +207,74 @@ export class BehaviorSystem implements System { 0, ); } + this.flushStateChanges(); + } + + /** + * A fresh instance begins in each local machine's start state, and hears `On state entered` for + * it – after its `On start`, like every other move. A `global` machine is not entered here: it + * was entered once when the run began, and a later instance joins it where it already is. + */ + private enterStartStates(instance: Instance, starts: Record): void { + for (const [machineId, start] of Object.entries(starts)) { + if (machineId in this.sharedState.states) continue; + instance.states[machineId] = start; + this.pending.push({ machineId, from: '', to: start, source: instance }); + } + } + + /** + * The events every queued move owes, and those the events themselves cause, until none are left. + * Each move is delivered whole – `exited` to everyone who hears it, then `entered` – so the two + * never interleave across instances. + */ + private flushStateChanges(): void { + const world = this.world; + if (!world) { + this.pending = []; + return; + } + for (let round = 0; this.pending.length > 0; round++) { + if (round >= MAX_STATE_ROUNDS) { + this.pending = []; + world.log.push(`${ERROR_PREFIX} graphs: state changes keep causing each other (endless loop?)`); + world.stopRequested = true; + return; + } + const batch = this.pending; + this.pending = []; + for (const change of batch) this.deliver(change); + } + } + + private deliver(change: StateChange): void { + const global = change.machineId in this.sharedState.states; + const targets = (global ? this.instances : [change.source]).filter( + (i): i is Instance => i !== null && this.instances.includes(i), + ); + const fire = (type: string, state: string) => { + for (const instance of targets) + this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0); + }; + fire('OnStateExited', change.from); + fire('OnStateEntered', change.to); + } + + /** + * Which state each instance holds of one machine, for the State machine tab's live highlight. + * A `global` machine answers even with no instance running: its state belongs to the run. + */ + machineStates(machineId: string): { state: string; objectId: string; ownerId: string }[] { + const shared = this.sharedState.states[machineId]; + const held = this.instances + .map((i) => ({ + state: shared ?? i.states[machineId] ?? '', + objectId: i.entity.objectId, + ownerId: i.entity.parent?.objectId ?? '', + })) + .filter((entry) => entry.state !== ''); + if (held.length === 0 && shared) return [{ state: shared, objectId: '', ownerId: '' }]; + return held; } /** Instances whose entity left the world get their last events and are dropped. */ @@ -250,8 +345,12 @@ export class BehaviorSystem implements System { }, dt, ); + // After this instance's frame, not inside it: a state entered halfway through `On update` + // must not push its events between the nodes that are still to run. + this.flushStateChanges(); } this.reap(world); + this.flushStateChanges(); } /** @@ -360,7 +459,8 @@ export class BehaviorSystem implements System { } } - private context({ entity, states }: Instance, world: World, dt: number): NodeContext { + private context(instance: Instance, world: World, dt: number): NodeContext { + const { entity, states } = instance; // Handles arriving from the graph are the entities themselves, or test doubles with the same id. const resolve = (handle: EntityHandle | null): Entity | undefined => handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; @@ -370,6 +470,7 @@ export class BehaviorSystem implements System { variables: entity.props, states, shared: this.sharedState, + stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }), time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 96cf72be..457fcf91 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -72,13 +72,15 @@ describe('node registry', () => { const keys = [...def.inputs, ...def.outputs].map((p) => p.key); expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length); expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); - // An event is seeded by the runtime, so nothing flows into it. - if (def.category === 'event') expect(def.inputs).toHaveLength(0); + // An event is seeded by the runtime, so no flow arrives at one. What it may take is a data + // input that says which event it means – the machine `On state entered` watches. + if (def.category === 'event') expect(def.inputs.filter((p) => p.type === 'exec')).toHaveLength(0); // A state is a name in a machine; its ports only anchor transitions, so it never runs. else if (def.category === 'state') expect(def.run).toBeUndefined(); // A shader node is compiled to GLSL and a particle node to a function: neither is ever run. else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true); - else expect(def.run).toBeTypeOf('function'); + // Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`). + else expect(typeof (def.run ?? def.read)).toBe('function'); expect(keys.every((k) => k.length > 0)).toBe(true); } }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 6e849e3f..cc7cb9d9 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -45,11 +45,19 @@ interface Case { after?(ctx: FakeContext): void; } +/** A lookup holding one machine "m" with the given states – what the two state readers name. */ +const machineWithStates = (states: { id: string; name: string }[]): GraphLookup => ({ + ...NO_LOOKUP, + machine: (id) => (id === 'm' ? { name: 'M', states, start: '', transitions: [] } : null), +}); + /** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */ const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({ ...NO_LOOKUP, machine: (id) => - id === 'm' ? { name: 'M', states: [], transitions: transitions.map((t) => ({ name: '', ...t })) } : null, + id === 'm' + ? { name: 'M', states: [], start: '', transitions: transitions.map((t) => ({ name: '', ...t })) } + : null, }); const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => { @@ -63,7 +71,12 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)], }; const lookup = c.lookup ?? NO_LOOKUP; - return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup), ctx }; + // A pure reader has no `run`: its outputs are what it answers at the moment they are asked for, + // which is exactly what the interpreter pulls (see `GraphInterpreter.readAll`). + const result = def.run + ? def.run(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup) + : { outputs: def.read!(ctx, c.params ?? {}, (key) => c.inputs?.[key] ?? null, def, lookup) }; + return { result, ctx }; }; const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); @@ -151,6 +164,44 @@ const CASES: Record = { after: (ctx) => expect(ctx.states).toEqual({}), }, ], + CurrentState: [ + { + name: 'names the state the machine is in, and hands out its id as well', + inputs: { machine: 'm' }, + context: { states: { m: 's1' } }, + lookup: machineWithStates([{ id: 's1', name: 'Open' }]), + outputs: { name: 'Open', id: 's1' }, + }, + { + name: 'in no state, both outputs are empty', + inputs: { machine: 'm' }, + lookup: machineWithStates([{ id: 's1', name: 'Open' }]), + outputs: { name: '', id: '' }, + }, + { + // A state of another machine, or one that was deleted: the id stands, the name is gone. + name: 'a state the machine does not know keeps its id but has no name', + inputs: { machine: 'm' }, + context: { states: { m: 'gone' } }, + lookup: machineWithStates([{ id: 's1', name: 'Open' }]), + outputs: { name: '', id: 'gone' }, + }, + ], + IsInState: [ + { + name: 'is true on the current state and false on every other', + inputs: { machine: 'm' }, + context: { states: { m: 's2' } }, + derivedOutputs: ['s1', 's2'], + outputs: { s1: false, s2: true }, + }, + { + name: 'in no state, every output is false rather than unanswered', + inputs: { machine: 'm' }, + derivedOutputs: ['s1', 's2'], + outputs: { s1: false, s2: false }, + }, + ], StateTransition: [ { name: 'follows the transition of the asked-for name out of the current state', @@ -786,18 +837,20 @@ const EVENT_NODES: Record = { OnCollisionExit: 'collider.test.ts', OnClick: 'nodes.test.ts', OnAnimationFinished: 'animation.test.ts', + OnStateEntered: 'state-machine.test.ts', + OnStateExited: 'state-machine.test.ts', }; describe('node catalog', () => { it('covers every built-in node', () => { const uncovered = BUILT_IN_NODES.filter((def) => - def.run ? !CASES[def.type] : !EVENT_NODES[def.type], + (def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type], ).map((def) => def.type); expect(uncovered).toEqual([]); }); - it('lists exactly the run-less nodes as events', () => { - const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type); + it('lists exactly the nodes that neither run nor read as events', () => { + const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read).map((def) => def.type); expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort()); }); diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index e2719bd1..fd168f84 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -4,7 +4,11 @@ * 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 { + CreateAssetCommand, + SetMachineScopeCommand, + SetMachineStartCommand, +} from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand, @@ -24,6 +28,7 @@ import { STATE_IN, STATE_NAME_PARAM, STATE_OUT, + startStateOf, statesOf, transitionNames, transitionsOf, @@ -301,6 +306,242 @@ describe('a running state machine', () => { }); }); +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) => @@ -407,6 +648,7 @@ describe('transitions between states', () => { const machine = { name: 'Guard', states: [], + start: '', transitions: [ { from: 'patrolling', to: 'chasing', name: 'alarm' }, { from: 'searching', to: 'chasing', name: 'alarm' }, diff --git a/tests/helpers.ts b/tests/helpers.ts index 4a7ba9b0..11c3711f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -188,7 +188,12 @@ export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = nul return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; } -export type FakeContext = NodeContext & { logs: string[]; stopped: boolean }; +/** `changes` records the state moves the context was told about, in the order they were reported. */ +export type FakeContext = NodeContext & { + logs: string[]; + stopped: boolean; + changes: { machineId: string; from: string; to: string }[]; +}; /** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */ export function fakeContext(overrides: Partial = {}): FakeContext { @@ -199,6 +204,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { variables: { speed: 100 }, states: {}, shared: emptySharedState(), + changes: [], + stateChanged: (machineId, from, to) => ctx.changes.push({ machineId, from, to }), time: 0, dt: 0.5, logs, diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 681a0e45..2f6e5e0d 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -422,6 +422,124 @@ export async function stateMachine(t) { await sleep(200); await sleep(200); + // 26f3. A start state: marked from the state's own menu, worn as a ring, entered by every + // instance before anything reads the machine – and heard as `On state entered`. + await evaluate("__gm.showTab('statemachine')"); + await sleep(300); + const startOf = () => + json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`); + const ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`); + const marks = () => json('__gm.stateMachine.stateMarks()'); + const startCentre = await centreOfState(first); + await click(startCentre.x, startCentre.y, 'right'); + await sleep(300); + const stateMenu = await json('__gm.overlayTexts()'); + check( + 'a state offers to become the start state', + stateMenu.includes('Set as start state'), + JSON.stringify(stateMenu), + ); + await clickLast('Set as start state'); + await sleep(400); + check( + 'the mark is kept on the asset and drawn as a ring on that state alone', + (await startOf()) === first && (await ringOn(first)) === true && (await ringOn(second)) === false, + JSON.stringify({ start: await startOf(), first: await ringOn(first), second: await ringOn(second) }), + ); + await shot('05-start-state'); + + // Nothing sets the machine any more: what the branch follows can only be the start state. + await mutate(` + const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); + b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1'); + b.graph.nodes.push( + ${JSON.stringify(node('sm-entered', 'OnStateEntered', 900, 520, { machine: made.id }))}, + ${JSON.stringify(node('sm-entered-log', 'Log', 1200, 520, { message: 'entered the start' }))}, + ); + b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))}); + `); + await sleep(300); + await play(); + await sleep(600); + const started = (await world())?.log ?? []; + check( + 'every instance begins in the start state, with no Set state anywhere', + started.includes('first state') && !started.includes('second state'), + JSON.stringify([...new Set(started)]), + ); + check( + 'and hears On state entered for it', + started.filter((line) => line === 'entered the start').length === 1, + JSON.stringify(started.filter((line) => line === 'entered the start')), + ); + + // 26f4. The tab shows where the run is: weakly for any instance, strongly for the selected one. + await evaluate("__gm.showTab('statemachine')"); + await evaluate('__gm.state.select(null)'); + await sleep(400); + const loose = await marks(); + check( + 'while playing, the state the machine is in is marked', + loose[first] === 'held' && loose[second] === undefined, + JSON.stringify(loose), + ); + await evaluate( + "__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })", + ); + await sleep(400); + const picked2 = await marks(); + check( + "selecting the object makes its instance's state the strong one", + picked2[first] === 'current', + JSON.stringify(picked2), + ); + await shot('06-live-state'); + await stop(); + await evaluate("__gm.showTab('statemachine')"); + await sleep(400); + check( + 'nothing is marked once the run is over', + Object.keys(await marks()).length === 0, + JSON.stringify(await marks()), + ); + + // 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere. + await evaluate('__gm.state.select(null)'); + await sleep(200); + const againCentre = await centreOfState(first); + await click(againCentre.x, againCentre.y, 'right'); + await sleep(300); + const onMenu = await json('__gm.overlayTexts()'); + check( + 'the same place offers to take the mark off again', + onMenu.includes('Remove start state') && !onMenu.includes('Set as start state'), + JSON.stringify(onMenu), + ); + await clickLast('Remove start state'); + await sleep(400); + check( + 'the mark and its ring are gone', + (await startOf()) === '' && (await ringOn(first)) === false, + JSON.stringify({ start: await startOf(), ring: await ringOn(first) }), + ); + await play(); + await sleep(600); + const nowhere = (await world())?.log ?? []; + check( + 'without a start state the branch follows nothing again', + !nowhere.includes('first state') && !nowhere.includes('entered the start'), + JSON.stringify([...new Set(nowhere)]), + ); + await stop(); + // Back to the graph the later sections expect: the set on start, and no state watcher. + await mutate(` + const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); + b.graph.nodes = b.graph.nodes.filter((n) => n.id !== 'sm-entered' && n.id !== 'sm-entered-log'); + b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e6'); + b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))}); + `); + await sleep(300); + // 26g. An unconnected machine input shows a picker; picking a machine grows the state ports. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');