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.
98 lines
4.0 KiB
TypeScript
98 lines
4.0 KiB
TypeScript
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) ?? ''
|
||
}`;
|
||
}
|