Files
feuerwerk/src/core/graph/nested.ts
T
rootandClaude Fable 5.1 e7d82bd1a9 State machines: asset, tab, State branch and Set state nodes (#51)
A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).

Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).

The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:00:23 +02:00

139 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { textOf } from '../model/Properties';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export const BEHAVIOR_START = 'BehaviorStart';
export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties';
export const BEHAVIOR_PARAM = 'behavior';
/**
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
* Renaming a variable therefore has to reach them, and the inspector picks their ports from a list
* instead of letting them be typed.
*/
export function declaresVariablePorts(type: string): boolean {
return type === VARIABLES;
}
/** Nodes whose declared ports are properties of the object they are handed. */
export function declaresPropertyPorts(type: string): boolean {
return type === PROPERTIES;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean {
return isValidFieldName(name) && name !== 'exec';
}
/**
* Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`), and
* its settings, which share the params bag with the typed-in values of its inputs.
*/
export function fixedPortKeys(def: NodeDef): string[] {
const side = def.declaresPorts;
const fixed =
side === 'inputs' ? def.inputs : side === 'outputs' ? def.outputs : [...def.inputs, ...def.outputs];
return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)];
}
export function declaredOn(
def: NodeDef,
ports: readonly DeclaredPort[],
side: 'inputs' | 'outputs',
): DeclaredPort[] {
if (def.declaresPorts === 'both') return side === 'outputs' ? [...ports] : ports.filter((p) => p.write);
return def.declaresPorts === side ? [...ports] : [];
}
/** What a collapsed Variables or Properties node does, since its ports are out of sight. */
export function collapsedTitle(ports: readonly DeclaredPort[], noun: string): string {
const set = ports.filter((p) => p.write);
const get = ports.filter((p) => !p.write);
if (set.length > 1) return `Set ${set.length} ${noun}`;
if (get.length > 1) return `Get ${get.length} ${noun}`;
if (set[0]) return `Set ${set[0].key}`;
if (get[0]) return `Get ${get[0].key}`;
return `No ${noun}`;
}
/** The single start (or end) node of a graph; a second one is ignored. */
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
return graph.nodes.find((n) => n.type === type);
}
export function isTerminalType(type: string): boolean {
return type === BEHAVIOR_START || type === BEHAVIOR_END;
}
export function isTerminalNode(node: GraphNode): boolean {
return isTerminalType(node.type);
}
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
}
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] {
const node = graph && terminalNode(graph, type);
return (node?.ports ?? []).map(portDefOf);
}
/**
* The node definition as it applies to *this* node: identical to the registered one, unless the
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
*/
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
const own = (side: 'inputs' | 'outputs') =>
declaredOn(def, node.ports ?? [], side).map((p) =>
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
);
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
}
/** A derived exec pin belongs at the top, above the fixed data ports too. */
function execFirst(ports: PortDef[]): PortDef[] {
return [...ports.filter((p) => p.type === 'exec'), ...ports.filter((p) => p.type !== 'exec')];
}
/**
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
*/
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return null;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
if (id === ownerId) return 'A behavior cannot run itself';
const program = lookup.behavior(id);
if (!program) return 'That behavior is gone';
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
return null;
}
/**
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
* changes – a port list that is derived rather than fixed has no other way of being noticed.
*/
export function portSignature(node: GraphNode, graph: Graph, lookup: GraphLookup, ownerId = ''): string {
const def = resolveDef(node, graph, lookup);
if (!def) return 'missing';
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}|${
callProblem(node, lookup, ownerId) ?? ''
}`;
}