Files
feuerwerk/src/core/graph/nested.ts
T
adminandClaude Opus 5.5 4e6aeb5f72
game-maker / gate (push) Successful in 3m36s
game-maker / suite (push) Failing after 10m6s
Query objects in bulk and filter, count or test a list (#259)
The object queries only ever returned one object: Object by name,
Child by name and Find all stop at the first hit. Getting every coin,
every child or every item that passes a test needed a For each with
its own bookkeeping. These nodes return the whole set as a list of
Object, so For each and Find can use item straight away.

- Objects by name returns every scene object with that name in scene
  order.
- Children by name returns every match below object, nearer levels
  first, the same order as the singular node.
- Children returns the direct children, with recursive all of them
  level by level.
- Objects of asset returns every instance of an object asset or
  container by the same rule as Is asset, spawned ones included.
- All four take a snapshot. Destroyed objects are never in it,
  disabled ones are, as with the singular nodes.
- Find has a Mode setting: First, All, Any and Count. First is the
  default and hides the row, so existing graphs look the same.
- Match follows the mode of its paired Find: done with items and
  indices, done with any, or done with count. An unpaired Match shows
  First, since the pairing only comes from the wiring.
- First and Any stop at the first hit, All and Count walk the list.
- Changing the mode drops edges at outputs the Match no longer has,
  in one undo step. SetNodeParamCommand now cleans the whole graph,
  but only edges that were valid before the change.
- The palette entry "filter" adds a Find set to All with its Match.

Found on the way:

- After adding the filter entry, "find" + Enter picked Filter. The
  entry has node type Find, counted as an exact hit, and the tie went
  to the alphabet. The search now ranks by the entry id. e2e 6z-v
  caught it.

Tests:

- ./check.sh: 188 files, 2746 tests, CHECK EXIT 0.
- find-modes.test.ts with 11 tests, 11 catalog cases for the four
  nodes, the asset input guard now expects 3.
- e2e 6z-vii with 3 checks: filter from the palette, Count picked in
  the inspector drops the items edge, a run logs 2, 2, 2, 3, 4, "0, 2".
- GM_STEPS=1-6: all passed, 278 checks.
- Counter-checks fail as expected: every mode stopping early (2),
  Match ignoring the mode (6), cleanup on the own node only (1), no
  recursion (2), no inOrder (2), old ranking (1). In the browser a
  Match ignoring the mode fails both editor checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:43:03 +02:00

331 lines
13 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 { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema';
import {
type DeclaredPort,
declaredPortType,
edgesInto,
findNode,
type Graph,
type GraphEdge,
type GraphNode,
type PortType,
} from './Graph';
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
import { lerpProblem } from './nodes/data/Lerp';
import { withSplits } from './portSplits';
import { agreedType, type SharedPortGroup } from './portTypes';
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 ON_START = 'OnStart';
export const BEHAVIOR_PARAM = 'behavior';
export const VARIABLES_PARAM = 'variablesAsset';
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
export const VARIABLES_SETTING: PropertyDef = {
key: VARIABLES_PARAM,
label: 'Variables',
kind: 'asset',
default: '',
assetTypes: ['variables'],
};
export const STRUCT_TYPE_PARAM = 'structType';
/** The Struct asset whose fields a `Pack` or `Expand` takes as its ports (#253); empty is by hand. */
export const STRUCT_TYPE_SETTING: PropertyDef = {
key: STRUCT_TYPE_PARAM,
label: 'Type',
kind: 'asset',
default: '',
assetTypes: ['struct'],
};
export function variablesSourceOf(params: PropertyBag): string {
return textOf(params[VARIABLES_PARAM] ?? '');
}
/**
* 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)];
}
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);
}
/**
* Where a call into `graph` begins: its start node, or – for a behavior that has none – every
* `On start` in it, in graph order, as when the behavior starts on its own (#230).
*/
export function callEntries(graph: Graph): GraphNode[] {
const start = terminalNode(graph, BEHAVIOR_START);
return start ? [start] : graph.nodes.filter((n) => n.type === ON_START);
}
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 – except
* an object, which nothing can type in.
*/
export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
return isTaggedKind(port.kind) && port.kind !== 'object'
? { ...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 = shapedDef(node, graph, lookup, true);
return def && splitDef(def, node);
}
function splitDef(def: NodeDef, node: GraphNode): NodeDef {
return withSplits(def, node, (key, field) => ({ ...portDefOf(field), key, label: key }));
}
/**
* The definition a run works with: `resolveDef` without the field ports of split outputs. The node
* produces its wholes as always; reading a field port picks the field out of one (`splitField`).
*/
export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
return shapedDef(node, graph, lookup, true);
}
/**
* `inherit` is off while answering what feeds a shared group: a node of that kind then reports the
* `any` it was registered with, so two of them in a row cannot ask each other in circles (#207).
*/
function shapedDef(
node: GraphNode,
graph: Graph,
lookup: GraphLookup,
inherit: boolean,
): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def) return undefined;
const shared = inherit ? def.sharedType : undefined;
if (!def.declaresPorts && !def.derivePorts)
return shared ? withSharedType(def, shared, node, graph, lookup) : 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') => {
const ports = [...def[key], ...(grown[key] ?? []), ...own(key)];
return grown.inOrder ? ports : execFirst(ports);
};
const resolved = {
...def,
label: grown.label ?? def.label,
inputs: side('inputs'),
outputs: side('outputs'),
};
return shared ? withSharedType(resolved, shared, node, graph, lookup) : resolved;
}
/** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */
function withSharedType(
def: NodeDef,
group: SharedPortGroup,
node: GraphNode,
graph: Graph,
lookup: GraphLookup,
): NodeDef {
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false)));
if (type === 'any') return def;
return {
...def,
outputs: def.outputs.map((p) => (group.outputs.includes(p.key) ? { ...p, type } : p)),
};
}
/**
* What the edge into that input carries, or undefined while nothing is connected to it. `inherit`
* says whether a shared group on the *feeding* node may have its say: off while a group asks what
* feeds it, on when only the drawn colour is wanted, which is what the edge itself is drawn in.
*/
function feedingType(
node: GraphNode,
key: string,
graph: Graph,
lookup: GraphLookup,
inherit: boolean,
): PortType | undefined {
const edge = edgesInto(graph, node.id, key)[0];
if (!edge) return undefined;
const from = findNode(graph, edge.from.node);
const def = from && shapedDef(from, graph, lookup, inherit);
return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type;
}
/**
* `any` inputs painted as what flows into them: an unconnected one stays grey, a connected one takes
* the colour of its own edge, so a group feeding two different types is visible as two colours.
* Only `paintType` is set – the port's `type` stays `any`, so `canConnect` keeps taking everything.
*/
function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null {
if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null;
return def.inputs.map((port) => {
if (port.type !== 'any' || port.ghost) return port;
const fed = feedingType(node, port.key, graph, lookup, true);
return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed };
});
}
/** 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')];
}
/** One end of an edge naming a key its node no longer has. */
interface GhostEnd {
edge: GraphEdge;
key: string;
isOutput: boolean;
}
/** Read from the edge list, not from the ports: what is looked for is exactly what has no port. */
function ghostEnds(node: GraphNode, graph: Graph, def: NodeDef): GhostEnd[] {
const inputs = new Set(def.inputs.map((p) => p.key));
const outputs = new Set(def.outputs.map((p) => p.key));
const ends: GhostEnd[] = [];
for (const edge of graph.edges) {
if (edge.to.node === node.id && !inputs.has(edge.to.port))
ends.push({ edge, key: edge.to.port, isOutput: false });
if (edge.from.node === node.id && !outputs.has(edge.from.port))
ends.push({ edge, key: edge.from.port, isOutput: true });
}
return ends;
}
/** `any` because the type the key once had is exactly what is no longer known. */
function ghostPort(key: string): PortDef {
return { key, label: key, type: 'any', inline: false, ghost: true };
}
/**
* The definition as the canvas draws it: the real ports with their pin colours (`paintedInputs`),
* then the keys only edges still name. The ghosts go last and skip `execFirst`, so no real row moves
* because of one – and none of this reaches `resolveDef`, which the interpreter, the compilers and
* `ConnectCommand` read as the truth.
*/
export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): NodeDef {
const painted = paintedInputs(node, graph, def, lookup);
const ends = ghostEnds(node, graph, def);
if (ends.length === 0) return painted ? { ...def, inputs: painted } : def;
const side = (isOutput: boolean) =>
[...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort);
return {
...def,
inputs: [...(painted ?? def.inputs), ...side(false)],
outputs: [...def.outputs, ...side(true)],
};
}
/** Why a node warns although its own settings are sound: edges left hanging on keys that are gone. */
export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): string | null {
const count = ghostEnds(node, graph, def).length;
if (count === 0) return null;
return count === 1 ? '1 edge on a port that is gone' : `${count} edges on ports that are gone`;
}
/**
* 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. A
* `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too.
*/
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
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 (callEntries(program.graph).length === 0) return 'No start or On 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) ?? ''
}`;
}