Files
feuerwerk/src/core/graph/nested.ts
T
root 2cf788eedd Let a value be a bundle of named fields
PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.

An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.

String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.

normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
2026-09-10 08:14:18 +02:00

98 lines
4.0 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 { type PropertyBag, textOf } from '../model/Properties';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
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 BEHAVIOR_PARAM = 'behavior';
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
export interface BehaviorProgram {
name: string;
graph: Graph;
defaults: PropertyBag;
}
export type BehaviorLookup = (assetId: string) => BehaviorProgram | null;
export const NO_BEHAVIORS: BehaviorLookup = () => null;
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
}
/** 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);
}
export function portsOf(port: DeclaredPort): PortDef {
return { key: port.key, label: port.key, type: declaredPortType(port.kind) };
}
function declaredOf(graph: Graph | undefined, type: string): PortDef[] {
const node = graph && terminalNode(graph, type);
return (node?.ports ?? []).map(portsOf);
}
/**
* The node definition as it applies to *this* node. Identical to the registered one for every fixed
* type; the three nested-behavior types grow ports from a declaration instead – the start and end
* node from their own, the call node from those of the behavior it names.
*/
export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def) return undefined;
const own = (node.ports ?? []).map(portsOf);
if (node.type === BEHAVIOR_START) return { ...def, outputs: [...def.outputs, ...own] };
if (node.type === BEHAVIOR_END) return { ...def, inputs: [...def.inputs, ...own] };
if (node.type !== RUN_BEHAVIOR) return def;
const program = lookup(String(node.params[BEHAVIOR_PARAM] ?? ''));
return {
...def,
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
label: program ? program.name : def.label,
inputs: [...def.inputs, ...declaredOf(program?.graph, BEHAVIOR_START)],
outputs: [...def.outputs, ...declaredOf(program?.graph, BEHAVIOR_END)],
};
}
/**
* 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: BehaviorLookup, 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(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, lookup: BehaviorLookup, ownerId = ''): string {
const def = resolveDef(node, 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) ?? ''
}`;
}