Query objects in bulk and filter, count or test a list (#259)
game-maker / gate (push) Successful in 3m36s
game-maker / suite (push) Failing after 10m6s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 15:43:03 +02:00
1 parent f84defb9b6
commit 4e6aeb5f72
22 files changed
+671 -35

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1532 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1535 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+18 -8
View File
@@ -2,10 +2,12 @@ import {
edgesInto, edgesInto,
edgesOfNode, edgesOfNode,
findNode, findNode,
type Graph,
type GraphEdge, type GraphEdge,
type GraphNode, type GraphNode,
portsCompatible, portsCompatible,
} from '../graph/Graph'; } from '../graph/Graph';
import type { GraphLookup } from '../graph/lookup';
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine'; import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
import { nodeDef } from '../graph/NodeRegistry'; import { nodeDef } from '../graph/NodeRegistry';
@@ -148,18 +150,16 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
} }
override execute(project: Project): void { override execute(project: Project): void {
super.execute(project);
const graph = requireGraph(project, this.assetId); const graph = requireGraph(project, this.assetId);
const node = requireNode(project, this.assetId, this.nodeId); const lookup = graphLookup(project);
const def = resolveDef(node, graph, graphLookup(project)); // A param can reshape another node too – a Find's mode decides its Match's outputs (#259).
if (!def) return; const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup)));
const outputs = new Set(def.outputs.map((p) => p.key)); super.execute(project);
const inputs = new Set(def.inputs.map((p) => p.key));
this.orphans = removeWhere( this.orphans = removeWhere(
graph.edges, graph.edges,
(e) => (e) =>
(e.from.node === this.nodeId && !outputs.has(e.from.port)) || (held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) &&
(e.to.node === this.nodeId && !inputs.has(e.to.port)), !edgeHolds(graph, e, lookup),
); );
} }
@@ -183,6 +183,16 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
} }
} }
/** Whether both ends of an edge name a port their node has; a node of unknown type is not judged. */
function edgeHolds(graph: Graph, edge: GraphEdge, lookup: GraphLookup): boolean {
const has = (end: GraphEdge['from'], side: 'inputs' | 'outputs') => {
const node = findNode(graph, end.node);
const def = node && resolveDef(node, graph, lookup);
return !def || def[side].some((p) => p.key === end.port);
};
return has(edge.from, 'outputs') && has(edge.to, 'inputs');
}
/** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */ /** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */
export class ConnectCommand implements Command { export class ConnectCommand implements Command {
readonly label = 'Connect'; readonly label = 'Connect';
+13 -12
View File
@@ -1,4 +1,4 @@
import { blockProblem, MATCH_INPUT, matchOf } from './findBlock'; import { blockProblem, findMode, MATCH_INPUT, matchOf, searchOutcome, stopsAtHit } from './findBlock';
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type GraphLookup, NO_LOOKUP } from './lookup'; import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested'; import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested';
@@ -252,8 +252,9 @@ export class GraphInterpreter {
/** /**
* A Find's rounds: its `item` and `index` are set to one element after the other, and the Match * A Find's rounds: its `item` and `index` are set to one element after the other, and the Match
* closing the block reads its test anew each time, the way a loop's stop input is read. The first * closing the block reads its test anew each time, the way a loop's stop input is read. Its mode
* true one ends the search. Both nodes then hold the hit, and the flow goes on from the Match. * says whether the first true one ends the search; the Match then holds the outcome, and the flow
* goes on from there.
*/ */
private search(find: GraphNode, values: readonly Value[], ctx: NodeContext, depth: number): void { private search(find: GraphNode, values: readonly Value[], ctx: NodeContext, depth: number): void {
const match = matchOf(this.frame.graph, find.id); const match = matchOf(this.frame.graph, find.id);
@@ -262,7 +263,8 @@ export class GraphInterpreter {
ctx.log(`Find: ${blockProblem(this.frame.graph, find) ?? 'no Match closes it'}`); ctx.log(`Find: ${blockProblem(this.frame.graph, find) ?? 'no Match closes it'}`);
return; return;
} }
let hit = -1; const mode = findMode(find);
const hits: number[] = [];
for (let round = 0; round < values.length; round++) { for (let round = 0; round < values.length; round++) {
if (++this.steps > MAX_STEPS_PER_FIRE) if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (endless loop?)'); throw new Error('Behavior graph exceeded the step limit (endless loop?)');
@@ -271,18 +273,17 @@ export class GraphInterpreter {
this.frame.outputCache.set(find.id, roundOutputs); this.frame.outputCache.set(find.id, roundOutputs);
this.record(find.id, roundOutputs); this.record(find.id, roundOutputs);
if (asBoolean(this.inputValue(match, test, ctx))) { if (asBoolean(this.inputValue(match, test, ctx))) {
hit = round; hits.push(round);
break; if (stopsAtHit(mode)) break;
} }
} }
this.frame.dataCache.clear(); this.frame.dataCache.clear();
const found = hit >= 0; const outcome = searchOutcome(mode, values, hits);
const outputs = found ? { item: values[hit] ?? null, index: hit } : { item: null, index: -1 }; this.frame.outputCache.set(find.id, outcome.find);
this.frame.outputCache.set(find.id, outputs); this.frame.outputCache.set(match.id, outcome.match);
this.frame.outputCache.set(match.id, outputs);
this.trace?.onNode(match.id); this.trace?.onNode(match.id);
this.record(match.id, outputs); this.record(match.id, outcome.match);
this.follow(match, found ? 'found' : 'notFound', ctx, depth); this.follow(match, outcome.next, ctx, depth);
} }
/** Resolves every data input: connected → pulled from the source, else the inline param. */ /** Resolves every data input: connected → pulled from the source, else the inline param. */
+2
View File
@@ -148,6 +148,8 @@ export interface NodeContext {
findReferenced(from: EntityHandle, ref: string): EntityHandle | null; findReferenced(from: EntityHandle, ref: string): EntityHandle | null;
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */ /** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
childByName(parent: EntityHandle, name: string): EntityHandle | null; childByName(parent: EntityHandle, name: string): EntityHandle | null;
/** Every object in the game right now, in scene order – what the plural lookups filter (#259). */
objects(): readonly EntityHandle[];
isKeyDown(key: string): boolean; isKeyDown(key: string): boolean;
pointer: PointerState; pointer: PointerState;
/** /**
+2
View File
@@ -89,6 +89,8 @@ export interface DerivedShape {
label?: string; label?: string;
inputs?: PortDef[]; inputs?: PortDef[];
outputs?: PortDef[]; outputs?: PortDef[];
/** The ports stand in the order given instead of exec pins first – a node laying out a whole side. */
inOrder?: true;
} }
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */ /** Palette, port rendering, connection validation and inspector fields are all derived from this. */
+49
View File
@@ -1,5 +1,6 @@
import { findNode, type Graph, type GraphEdge, type GraphNode } from './Graph'; import { findNode, type Graph, type GraphEdge, type GraphNode } from './Graph';
import { nodeDef } from './NodeRegistry'; import { nodeDef } from './NodeRegistry';
import { asValueItem, type Value } from './values';
/* /*
* A `Find` … `Match` block is not stored anywhere: it is the wiring. A Match closes the one Find its * A `Find` … `Match` block is not stored anywhere: it is the wiring. A Match closes the one Find its
@@ -12,6 +13,54 @@ export const MATCH = 'Match';
/** The boolean input a `Match` reads its test from. */ /** The boolean input a `Match` reads its test from. */
export const MATCH_INPUT = 'match'; export const MATCH_INPUT = 'match';
/** The Find's setting saying what its block answers (#259); a Find saved without one is `first`. */
export const FIND_MODE = 'mode';
export type FindMode = 'first' | 'all' | 'any' | 'count';
const MODES: readonly FindMode[] = ['first', 'all', 'any', 'count'];
export function findMode(node: GraphNode): FindMode {
const mode = node.params[FIND_MODE];
return MODES.find((m) => m === mode) ?? 'first';
}
/** The mode of the Find a Match's test is built from; `first` while it is built from none or several. */
export function matchMode(graph: Graph, match: GraphNode): FindMode {
const finds = findsOf(graph, match);
return finds.length === 1 ? findMode(finds[0]!) : 'first';
}
/**
* Whether the search goes on after a hit. `first` and `any` have their answer with the first one;
* `all` and `count` look at every element.
*/
export function stopsAtHit(mode: FindMode): boolean {
return mode === 'first' || mode === 'any';
}
/**
* What a finished search hands on: the exec output of the Match to continue along, what the Match
* holds, and what the Find is left holding. `hits` are the indices the test was true for.
*/
export function searchOutcome(
mode: FindMode,
values: readonly Value[],
hits: readonly number[],
): { next: string; match: Record<string, Value>; find: Record<string, Value> } {
const first = hits[0];
const none = { item: null, index: -1 };
if (mode === 'first') {
const held = first === undefined ? none : { item: values[first] ?? null, index: first };
return { next: first === undefined ? 'notFound' : 'found', match: held, find: held };
}
const match: Record<string, Value> =
mode === 'all'
? { items: hits.map((i) => asValueItem(values[i] ?? null)), indices: [...hits] }
: mode === 'any'
? { any: first !== undefined }
: { count: hits.length };
return { next: 'done', match, find: none };
}
function isExecPort(type: string | undefined, key: string, side: 'inputs' | 'outputs'): boolean { function isExecPort(type: string | undefined, key: string, side: 'inputs' | 'outputs'): boolean {
return (type && nodeDef(type)?.[side].find((p) => p.key === key)?.type) === 'exec'; return (type && nodeDef(type)?.[side].find((p) => p.key === key)?.type) === 'exec';
} }
+4 -1
View File
@@ -181,7 +181,10 @@ function shapedDef(
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p), def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
); );
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {}; const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]); const side = (key: 'inputs' | 'outputs') => {
const ports = [...def[key], ...(grown[key] ?? []), ...own(key)];
return grown.inOrder ? ports : execFirst(ports);
};
const resolved = { const resolved = {
...def, ...def,
label: grown.label ?? def.label, label: grown.label ?? def.label,
+21
View File
@@ -0,0 +1,21 @@
import type { NodeDef } from '../../NodeRegistry';
import { childrenOf } from '../../objectQueries';
import { asBoolean, asEntity } from '../../values';
import { bool, list, obj } from '../shared';
export const Children: NodeDef = {
type: 'Children',
label: 'Children',
category: 'data',
description:
'The children of an object as a list, in scene order. With recursive, every object below it: ' +
'the children first, then their children and so on.',
inputs: [obj('object'), bool('recursive')],
outputs: [list('children')],
params: [],
run: (ctx, inputs) => {
const parent = asEntity(inputs.object);
const children = parent ? childrenOf(ctx.objects(), parent, asBoolean(inputs.recursive)) : [];
return { outputs: { children } };
},
};
@@ -0,0 +1,21 @@
import type { NodeDef } from '../../NodeRegistry';
import { childrenOf, objectsNamed } from '../../objectQueries';
import { asEntity, asString } from '../../values';
import { list, obj, str } from '../shared';
export const ChildrenByName: NodeDef = {
type: 'ChildrenByName',
label: 'Children by name',
category: 'data',
description:
'Every object with that name below the given one – its children, their children and so on. ' +
'Nearer levels come first, within a level scene order, as Child by name searches.',
inputs: [obj('object'), str('name')],
outputs: [list('children')],
params: [],
run: (ctx, inputs) => {
const parent = asEntity(inputs.object);
const below = parent ? childrenOf(ctx.objects(), parent, true) : [];
return { outputs: { children: objectsNamed(below, asString(inputs.name)) } };
},
};
@@ -0,0 +1,17 @@
import type { NodeDef } from '../../NodeRegistry';
import { objectsNamed } from '../../objectQueries';
import { asString } from '../../values';
import { list, str } from '../shared';
export const ObjectsByName: NodeDef = {
type: 'ObjectsByName',
label: 'Objects by name',
category: 'data',
description:
'Every scene object with that name, as a list in scene order – empty when none is called so. ' +
'The list is taken when it is read and does not follow later changes.',
inputs: [str('name')],
outputs: [list('objects')],
params: [],
run: (ctx, inputs) => ({ outputs: { objects: objectsNamed(ctx.objects(), asString(inputs.name)) } }),
};
@@ -0,0 +1,17 @@
import type { NodeDef } from '../../NodeRegistry';
import { objectsOfAsset } from '../../objectQueries';
import { asString } from '../../values';
import { assetPort, list } from '../shared';
export const ObjectsOfAsset: NodeDef = {
type: 'ObjectsOfAsset',
label: 'Objects of asset',
category: 'data',
description:
'Every object in the game that is an instance of the chosen asset – the ones Is asset says ' +
'yes to –, as a list in scene order. Spawned ones count, destroyed ones do not.',
inputs: [assetPort()],
outputs: [list('objects')],
params: [],
run: (ctx, inputs) => ({ outputs: { objects: objectsOfAsset(ctx.objects(), asString(inputs.asset)) } }),
};
+19 -4
View File
@@ -1,4 +1,4 @@
import { FIND, MATCH } from '../../findBlock'; import { FIND, FIND_MODE, MATCH } from '../../findBlock';
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values'; import { asList } from '../../values';
import { any, exec, list, num } from '../shared'; import { any, exec, list, num } from '../shared';
@@ -9,11 +9,26 @@ export const Find: NodeDef = {
category: 'flow', category: 'flow',
description: description:
'Opens a search block, closed by a Match. "item" and "index" go through one element after the ' + 'Opens a search block, closed by a Match. "item" and "index" go through one element after the ' +
'other; whatever is built from them into the Match\'s "match" is the test. The first element it ' + 'other; whatever is built from them into the Match\'s "match" is the test. Mode says what the ' +
'is true for continues at "found" of the Match, none at "not found".', 'Match answers: the first element the test is true for, all of them, whether there is any, or ' +
'how many.',
inputs: [exec(), list('list')], inputs: [exec(), list('list')],
outputs: [any('item'), num('index')], outputs: [any('item'), num('index')],
params: [], params: [
{
key: FIND_MODE,
label: 'Mode',
kind: 'enum',
default: 'first',
options: [
{ value: 'first', label: 'First' },
{ value: 'all', label: 'All' },
{ value: 'any', label: 'Any' },
{ value: 'count', label: 'Count' },
],
},
],
hiddenParams: (params) => ((params[FIND_MODE] ?? 'first') === 'first' ? [FIND_MODE] : []),
opens: { type: MATCH, dx: 480 }, opens: { type: MATCH, dx: 480 },
run: (_ctx, inputs) => ({ outputs: { item: null, index: -1 }, find: asList(inputs.list) }), run: (_ctx, inputs) => ({ outputs: { item: null, index: -1 }, find: asList(inputs.list) }),
}; };
+17 -7
View File
@@ -1,16 +1,26 @@
import { MATCH, MATCH_INPUT } from '../../findBlock'; import { type FindMode, MATCH, MATCH_INPUT, matchMode } from '../../findBlock';
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef, PortDef } from '../../NodeRegistry';
import { any, bool, exec, num } from '../shared'; import { any, bool, exec, list, num } from '../shared';
/** Never runs itself: the Find its test is built from hands it the hit and continues from here. */ /** The Match's outputs per mode of its Find; each mode but `first` goes on along one `done`. */
const OUTPUTS: Record<FindMode, PortDef[]> = {
first: [exec('found', 'found'), any('item'), num('index'), exec('notFound', 'not found')],
all: [exec('done', 'done'), list('items'), list('indices')],
any: [exec('done', 'done'), bool('any')],
count: [exec('done', 'done'), num('count')],
};
/** Never runs itself: the Find its test is built from hands it the outcome and continues from here. */
export const Match: NodeDef = { export const Match: NodeDef = {
type: MATCH, type: MATCH,
label: 'Match', label: 'Match',
category: 'flow', category: 'flow',
description: description:
'Closes the search block of the Find its "match" is built from. Continues at "found" with the ' + 'Closes the search block of the Find its "match" is built from. What it answers follows the ' +
'first element "match" was true for, or at "not found" with no item and index -1.', 'Find\'s mode: First continues at "found" with the first element "match" was true for, or at ' +
'"not found"; All hands on every such item and index, Any whether there was one, Count how many.',
inputs: [{ ...bool(MATCH_INPUT), inline: false }], inputs: [{ ...bool(MATCH_INPUT), inline: false }],
outputs: [exec('found', 'found'), any('item'), num('index'), exec('notFound', 'not found')], outputs: [],
params: [], params: [],
derivePorts: ({ node, graph }) => ({ outputs: OUTPUTS[matchMode(graph, node)], inOrder: true }),
}; };
+8
View File
@@ -39,6 +39,8 @@ import { FirstNotEmpty } from './data/FirstNotEmpty';
import { Floor } from './data/Floor'; import { Floor } from './data/Floor';
import { GetPosition } from './data/GetPosition'; import { GetPosition } from './data/GetPosition';
import { ChildByName } from './data/ChildByName'; import { ChildByName } from './data/ChildByName';
import { Children } from './data/Children';
import { ChildrenByName } from './data/ChildrenByName';
import { IsAsset } from './data/IsAsset'; import { IsAsset } from './data/IsAsset';
import { IsEnabled } from './data/IsEnabled'; import { IsEnabled } from './data/IsEnabled';
import { IsHovered } from './data/IsHovered'; import { IsHovered } from './data/IsHovered';
@@ -70,6 +72,8 @@ import { NameOf } from './data/NameOf';
import { Normalize } from './data/Normalize'; import { Normalize } from './data/Normalize';
import { Not } from './data/Not'; import { Not } from './data/Not';
import { ObjectByName } from './data/ObjectByName'; import { ObjectByName } from './data/ObjectByName';
import { ObjectsByName } from './data/ObjectsByName';
import { ObjectsOfAsset } from './data/ObjectsOfAsset';
import { Or } from './data/Or'; import { Or } from './data/Or';
import { Owner } from './data/Owner'; import { Owner } from './data/Owner';
import { PackStruct } from './data/PackStruct'; import { PackStruct } from './data/PackStruct';
@@ -170,9 +174,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Owner, Owner,
Parent, Parent,
ObjectByName, ObjectByName,
ObjectsByName,
ChildByName, ChildByName,
ChildrenByName,
Children,
NameOf, NameOf,
IsAsset, IsAsset,
ObjectsOfAsset,
IsEnabled, IsEnabled,
Properties, Properties,
GetPosition, GetPosition,
+39
View File
@@ -0,0 +1,39 @@
import type { EntityHandle } from './NodeContext';
/*
* The plural lookups (#259) all filter the one list `NodeContext.objects()` hands out: every living
* object of the scene, in scene order. A list is a snapshot of that moment and does not follow.
*/
function isBelow(child: EntityHandle, parent: EntityHandle): boolean {
return child.parent !== null && child.parent.id === parent.id;
}
/**
* The objects below `parent`, level by level: its children in scene order, then theirs – the order
* `Child by name` searches in. Without `recursive`, the children only.
*/
export function childrenOf(
objects: readonly EntityHandle[],
parent: EntityHandle,
recursive: boolean,
): EntityHandle[] {
const found: EntityHandle[] = [];
let level = objects.filter((e) => isBelow(e, parent));
while (level.length > 0) {
found.push(...level);
if (!recursive) break;
const above = level;
level = objects.filter((e) => above.some((p) => isBelow(e, p)));
}
return found;
}
export function objectsNamed(objects: readonly EntityHandle[], name: string): EntityHandle[] {
return objects.filter((e) => e.name === name);
}
/** The instances of an asset, by the rule `Is asset` answers with; no asset, no objects. */
export function objectsOfAsset(objects: readonly EntityHandle[], assetId: string): EntityHandle[] {
return assetId ? objects.filter((e) => e.assetId === assetId) : [];
}
+16 -1
View File
@@ -14,6 +14,7 @@ import {
terminalNode, terminalNode,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project'; import type { Project } from '../../core/model/Project';
@@ -53,7 +54,7 @@ export function rankedEntries(entries: PaletteEntry[], query: string): PaletteEn
return entries return entries
.map((entry) => ({ .map((entry) => ({
entry, entry,
rank: searchRank(needle, [entry.def.label, entry.def.type, entry.def.description]), rank: searchRank(needle, [entry.def.label, entry.id, entry.def.description]),
})) }))
.filter(({ rank }) => rank >= 0) .filter(({ rank }) => rank >= 0)
.sort( .sort(
@@ -65,6 +66,19 @@ export function rankedEntries(entries: PaletteEntry[], query: string): PaletteEn
.map(({ entry }) => entry); .map(({ entry }) => entry);
} }
/**
* A Find set to All under a name of its own (#259). Its id is what the search reads besides the
* label, so "find" still lands on the plain Find rather than on this one.
*/
function filterEntry(find: NodeDef): PaletteEntry {
const def = {
...find,
label: 'Filter',
description: 'A Find set to All: its Match hands on every item the test is true for, and their indices.',
};
return { id: 'Filter', hint: find.category, def, params: { [FIND_MODE]: 'all' } };
}
/** /**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each * The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes, its one Output dropping out once * new one named by its number; a shader holds shader nodes, its one Output dropping out once
@@ -99,6 +113,7 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
if (def.category === 'shader' || def.category === 'particles') continue; if (def.category === 'shader' || def.category === 'particles') continue;
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
entries.push({ id: def.type, hint: def.category, def, params: {} }); entries.push({ id: def.type, hint: def.category, def, params: {} });
if (def.type === FIND) entries.push(filterEntry(def));
} }
const lookup = graphLookup(project); const lookup = graphLookup(project);
for (const other of project.assets) { for (const other of project.assets) {
+1
View File
@@ -672,6 +672,7 @@ export class BehaviorSystem implements System {
const found = resolve(parent); const found = resolve(parent);
return (found && world.childByName(found, name)) ?? null; return (found && world.childByName(found, name)) ?? null;
}, },
objects: () => world.entities,
isKeyDown: (key) => this.input.isDown(key), isKeyDown: (key) => this.input.isDown(key),
pointer: world.pointer, pointer: world.pointer,
hitTest: (target, x, y) => { hitTest: (target, x, y) => {
+1 -1
View File
@@ -126,7 +126,7 @@ describe('node asset inputs', () => {
const params = BUILT_IN_NODES.flatMap((def) => def.inputs) const params = BUILT_IN_NODES.flatMap((def) => def.inputs)
.filter((p) => p.key === 'asset') .filter((p) => p.key === 'asset')
.map((p) => p.field!); .map((p) => p.field!);
expect(params.map((p) => p.key)).toEqual(['asset', 'asset']); expect(params.map((p) => p.key)).toEqual(['asset', 'asset', 'asset']);
for (const param of params) { for (const param of params) {
expect(param.kind).toBe('asset'); expect(param.kind).toBe('asset');
expect(param.kind === 'asset' && param.assetTypes).toBeUndefined(); expect(param.kind === 'asset' && param.assetTypes).toBeUndefined();
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { AddNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { requireNodeDef } from '../../src/core/commands/require';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
FIND,
FIND_MODE,
type FindMode,
GraphInterpreter,
resolveDef,
searchOutcome,
} from '../../src/core/graph';
import { graphLookup } from '../../src/core/model/graphLookup';
import type { PropertyValue } from '../../src/core/model/Properties';
import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
/** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */
function block(wanted: PropertyValue = 'b', mode?: FindMode) {
const fixture = makeProject();
const behavior = makeBehavior(fixture);
fixture.stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
const edit = graphEditor(fixture, behavior);
const add = new AddNodeCommand(behavior.id, FIND, 0, 0, mode ? { [FIND_MODE]: mode } : {});
fixture.stack.execute(add);
const find = add.node;
const match = add.closing!;
const start = edit.addNode('OnStart');
const get = edit.variables(['names']);
const equals = edit.addNode('Equals', { b: wanted });
edit.wire([
[start, 'exec', find, 'exec'],
[get, 'names', find, 'list'],
[find, 'item', equals, 'a'],
[equals, 'result', match, 'match'],
]);
const outputs = () =>
resolveDef(match, behavior.graph, graphLookup(fixture.project))!.outputs.map((p) => p.key);
const setMode = (value: FindMode) =>
fixture.stack.execute(new SetNodeParamCommand(behavior.id, find.id, FIND_MODE, value));
return { ...fixture, behavior, edit, find, match, outputs, setMode };
}
/** Runs a block in `mode` over `names`, logging each of the Match's `ports` after `done`. */
function answer(mode: FindMode, names: readonly PropertyValue[], ports: string[]): string[] {
const { behavior, edit, match } = block('b', mode);
let from = { node: match, port: 'done' };
for (const port of ports) {
const log = edit.addNode('Log');
edit.wire([
[from.node, from.port, log, 'exec'],
[match, port, log, 'message'],
]);
from = { node: log, port: 'exec' };
}
const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
return ctx.logs;
}
describe('Find modes at run time (#259)', () => {
it('All hands on every item the test is true for, and their indices', () => {
expect(answer('all', ['a', 'b', 'c', 'b'], ['items', 'indices'])).toEqual(['b, b', '1, 3']);
});
it('All with no hit goes on at done with two empty lists', () => {
expect(answer('all', ['a', 'c'], ['items', 'indices'])).toEqual(['', '']);
});
it('Any says whether there is one', () => {
expect(answer('any', ['a', 'b'], ['any'])).toEqual(['true']);
expect(answer('any', ['a', 'c'], ['any'])).toEqual(['false']);
expect(answer('any', [], ['any'])).toEqual(['false']);
});
it('Count counts every hit', () => {
expect(answer('count', ['b', 'a', 'b', 'b'], ['count'])).toEqual(['3']);
expect(answer('count', [], ['count'])).toEqual(['0']);
});
it('First and Any stop at the first hit, All and Count look at every element', () => {
const values = ['x', 'y', 'z'];
expect(searchOutcome('first', values, [1])).toEqual({
next: 'found',
match: { item: 'y', index: 1 },
find: { item: 'y', index: 1 },
});
expect(searchOutcome('first', values, []).next).toBe('notFound');
expect(searchOutcome('all', values, [0, 2])).toEqual({
next: 'done',
match: { items: ['x', 'z'], indices: [0, 2] },
find: { item: null, index: -1 },
});
expect(searchOutcome('any', values, [2]).match).toEqual({ any: true });
expect(searchOutcome('count', values, [0, 1]).match).toEqual({ count: 2 });
});
});
describe('the Match follows its Find', () => {
it('has the outputs of its Find’s mode, in the order they are drawn', () => {
const { outputs, setMode } = block();
expect(outputs()).toEqual(['found', 'item', 'index', 'notFound']);
setMode('all');
expect(outputs()).toEqual(['done', 'items', 'indices']);
setMode('any');
expect(outputs()).toEqual(['done', 'any']);
setMode('count');
expect(outputs()).toEqual(['done', 'count']);
});
it('a Find saved without a mode is First, and First leaves the setting off the node', () => {
const { find, outputs } = block();
delete find.params[FIND_MODE];
expect(outputs()).toEqual(['found', 'item', 'index', 'notFound']);
const hidden = requireNodeDef(FIND).hiddenParams!;
expect(hidden(find.params)).toEqual([FIND_MODE]);
expect(hidden({ [FIND_MODE]: 'all' })).toEqual([]);
});
it('switching the mode drops the Match’s edges on outputs that are gone; undo brings them back', () => {
const { behavior, edit, match, setMode, stack } = block();
const hit = edit.addNode('Log');
const other = edit.addNode('Log');
edit.wire([
[match, 'found', hit, 'exec'],
[match, 'item', hit, 'message'],
]);
const before = behavior.graph.edges.length;
setMode('all');
expect(behavior.graph.edges.length).toBe(before - 2);
edit.wire([[match, 'done', other, 'exec']]);
setMode('count');
// `done` is there in both modes, so its edge stays.
expect(behavior.graph.edges.some((e) => e.from.node === match.id && e.from.port === 'done')).toBe(true);
stack.undo();
stack.undo();
stack.undo();
expect(behavior.graph.edges.length).toBe(before);
expect(behavior.graph.edges.some((e) => e.from.port === 'found')).toBe(true);
});
it('an edge that already hung on a gone port elsewhere is left to show as a ghost', () => {
const { behavior, edit, find, stack } = block();
const log = edit.addNode('Log');
const ghost = { id: 'ghost', from: { node: log.id, port: 'gone' }, to: { node: log.id, port: 'exec' } };
behavior.graph.edges.push(ghost);
stack.execute(new SetNodeParamCommand(behavior.id, find.id, FIND_MODE, 'all'));
expect(behavior.graph.edges).toContain(ghost);
});
});
describe('the Filter entry', () => {
it('is a Find set to All under a name of its own', () => {
const { project, behavior } = block();
const filter = paletteEntries(project, behavior).find((e) => e.def.label === 'Filter')!;
expect(filter.def.type).toBe(FIND);
expect(filter.params).toEqual({ [FIND_MODE]: 'all' });
const add = new AddNodeCommand(behavior.id, filter.def.type, 0, 0, filter.params);
expect(add.node.params[FIND_MODE]).toBe('all');
expect(add.closing).not.toBeNull();
});
it('"filter" finds it first, "find" still the plain Find', () => {
const { project, behavior } = block();
const first = (query: string) => rankedEntries(paletteEntries(project, behavior), query)[0]!;
expect([first('filter').def.label, first('filter').params]).toEqual(['Filter', { [FIND_MODE]: 'all' }]);
expect([first('find').def.label, first('find').params]).toEqual(['Find', {}]);
});
});
+77
View File
@@ -129,6 +129,14 @@ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContex
pointer: { ...NO_POINTER, ...overrides }, pointer: { ...NO_POINTER, ...overrides },
}); });
/** Player › (Arm › Hitbox #2), Hitbox #1 – the arm comes first in scene order – and a lone Coin. */
const hero = fakeEntity('Player');
const arm = { ...fakeEntity('Arm'), parent: hero };
const deepBox = { ...fakeEntity('Hitbox'), id: 'hitbox-2', parent: arm };
const nearBox = { ...fakeEntity('Hitbox'), id: 'hitbox-1', parent: hero };
const coins = [fakeEntity('Coin', 0, 'coin'), { ...fakeEntity('Coin', 0, 'coin'), id: 'coin-2' }];
const scene: Partial<NodeContext> = { objects: () => [hero, arm, deepBox, nearBox, ...coins] };
const CASES: Record<string, Case[]> = { const CASES: Record<string, Case[]> = {
Branch: [ Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' }, { name: 'true takes then', inputs: { condition: true }, next: 'then' },
@@ -500,6 +508,75 @@ const CASES: Record<string, Case[]> = {
}, },
{ name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } }, { name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } },
], ],
ObjectsByName: [
{
name: 'every object so named, in scene order',
inputs: { name: 'Hitbox' },
context: scene,
outputs: { objects: [deepBox, nearBox] },
},
{
name: 'none so named is the empty list',
inputs: { name: 'Ghost' },
context: scene,
outputs: { objects: [] },
},
],
ChildrenByName: [
{
name: 'every one below, the nearer level first',
inputs: { object: hero, name: 'Hitbox' },
context: scene,
outputs: { children: [nearBox, deepBox] },
},
{
name: 'nothing above or beside the object',
inputs: { object: arm, name: 'Hitbox' },
context: scene,
outputs: { children: [deepBox] },
},
{
name: 'no object is the empty list',
inputs: { object: null, name: 'Hitbox' },
context: scene,
outputs: { children: [] },
},
],
Children: [
{
name: 'the direct children only',
inputs: { object: hero, recursive: false },
context: scene,
outputs: { children: [arm, nearBox] },
},
{
name: 'recursive: level by level',
inputs: { object: hero, recursive: true },
context: scene,
outputs: { children: [arm, nearBox, deepBox] },
},
{
name: 'a leaf has none',
inputs: { object: deepBox, recursive: true },
context: scene,
outputs: { children: [] },
},
{ name: 'no object has none', inputs: { object: null }, context: scene, outputs: { children: [] } },
],
ObjectsOfAsset: [
{
name: 'every instance of the asset',
inputs: { asset: 'coin' },
context: scene,
outputs: { objects: coins },
},
{
name: 'no asset chosen is the empty list',
inputs: { asset: '' },
context: scene,
outputs: { objects: [] },
},
],
NameOf: [ NameOf: [
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
+1
View File
@@ -231,6 +231,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
isAlive: () => true, isAlive: () => true,
findReferenced: () => null, findReferenced: () => null,
childByName: () => null, childByName: () => null,
objects: () => [],
isKeyDown: () => false, isKeyDown: () => false,
pointer: { ...NO_POINTER }, pointer: { ...NO_POINTER },
hitTest: () => false, hitTest: () => false,
+158
View File
@@ -1104,6 +1104,7 @@ export async function graph(t) {
await forEachNode(); await forEachNode();
await findMatchNodes(); await findMatchNodes();
await splitOutputs(); await splitOutputs();
await pluralLookups();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1497,4 +1498,161 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder'); scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder');
`); `);
} }
// 6z-vii. Plural lookups and Find modes (#259): "filter" in the palette adds a Find set to All,
// whose Match hands on lists. Count picked in the inspector reshapes the Match and drops
// the edge on a list output it no longer has. A run over a small tree answers every new
// node: Objects by name, Children by name, Children (direct and recursive), Objects of
// asset, and a Filter over the children.
async function pluralLookups() {
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'plury',
type: 'behavior',
name: 'Plury',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'plury' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('filter');
await press('Enter');
const [find, match] = (await asset('plury')).graph.nodes;
const outputsOf = async (id) =>
Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {});
const settings = find ? await json(`__gm.graph.nodeSettings(${JSON.stringify(find.id)})`) : null;
if (!find || !match) {
check(
'typing "filter" into the palette adds a Find and its Match',
false,
JSON.stringify({ find, match }),
);
return;
}
const f = JSON.stringify(find.id);
const m = JSON.stringify(match.id);
// Only a Match whose test is built from the Find follows its mode: the test is wired first.
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'plury');
const at = behavior.graph.nodes.find((n) => n.id === ${m});
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
{ id: 'pl-test', type: 'Not', x: at.x - 240, y: at.y + 120, params: {} },
{ id: 'pl-count', type: 'Count', x: at.x + 260, y: at.y + 60, params: {} },
);
behavior.graph.edges.push(
edge('pl-in', ${f}, 'item', 'pl-test', 'value'),
edge('pl-out', 'pl-test', 'result', ${m}, 'match'),
edge('pl-items', ${m}, 'items', 'pl-count', 'list'),
);
`);
const listOutputs = await outputsOf(match.id);
check(
'typing "filter" into the palette adds a Find set to All; its Match hands on items and indices',
find.type === 'Find' &&
find.params.mode === 'all' &&
match.type === 'Match' &&
JSON.stringify(settings) === JSON.stringify(['Mode: All']) &&
JSON.stringify(listOutputs) === JSON.stringify(['done', 'items', 'indices']),
JSON.stringify({ find, match, settings, listOutputs }),
);
await select({ kind: 'node', assetId: 'plury', nodeId: find.id });
const modeField = await json(`__gm.inspectorField('Mode')`);
if (modeField) {
await click(modeField.x, modeField.y);
await clickText('Count');
}
const reshaped = await asset('plury');
check(
'picking Count in the inspector gives the Match done and count, and drops the edge on items',
reshaped.graph.nodes.find((n) => n.id === find.id)?.params.mode === 'count' &&
JSON.stringify(await outputsOf(match.id)) === JSON.stringify(['done', 'count']) &&
!reshaped.graph.edges.some((e) => e.id === 'pl-items'),
JSON.stringify({ modeField, edges: reshaped.graph.edges }),
);
await clickText('Fit');
await shot('26-find-modes');
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'plury');
const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite'));
Object.assign(sprite, { id: 'pl-sprite', name: 'Plural sprite' });
p.assets.push(sprite);
const node = (id, type, params = {}) => ({ id, type, x: 0, y: 0, params });
const edge = (from, fromPort, to, toPort) =>
({ id: from + '>' + to + '.' + toPort, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph = { nodes: [
node('pl-start', 'OnStart'),
node('pl-root', 'ObjectByName', { name: 'Crowd 7' }),
node('pl-named', 'ObjectsByName', { name: 'Bee' }),
node('pl-below', 'ChildrenByName', { name: 'Bee' }),
node('pl-direct', 'Children', { recursive: false }),
node('pl-all', 'Children', { recursive: true }),
node('pl-kind', 'ObjectsOfAsset', { asset: 'pl-sprite' }),
node('pl-find', 'Find', { mode: 'all' }),
node('pl-name', 'NameOf'),
node('pl-bee', 'Equals', { b: 'Bee' }),
node('pl-match', 'Match'),
...[1, 2, 3, 4, 5].map((i) => node('pl-n' + i, 'Count')),
...[1, 2, 3, 4, 5, 6].map((i) => node('pl-log' + i, 'Log')),
], edges: [
edge('pl-start', 'exec', 'pl-log1', 'exec'),
edge('pl-root', 'object', 'pl-below', 'object'),
edge('pl-root', 'object', 'pl-direct', 'object'),
edge('pl-root', 'object', 'pl-all', 'object'),
edge('pl-named', 'objects', 'pl-n1', 'list'),
edge('pl-below', 'children', 'pl-n2', 'list'),
edge('pl-direct', 'children', 'pl-n3', 'list'),
edge('pl-all', 'children', 'pl-n4', 'list'),
edge('pl-kind', 'objects', 'pl-n5', 'list'),
...[1, 2, 3, 4, 5].map((i) => edge('pl-n' + i, 'count', 'pl-log' + i, 'message')),
...[1, 2, 3, 4].map((i) => edge('pl-log' + i, 'exec', 'pl-log' + (i + 1), 'exec')),
edge('pl-log5', 'exec', 'pl-find', 'exec'),
edge('pl-all', 'children', 'pl-find', 'list'),
edge('pl-find', 'item', 'pl-name', 'object'),
edge('pl-name', 'name', 'pl-bee', 'a'),
edge('pl-bee', 'result', 'pl-match', 'match'),
edge('pl-match', 'done', 'pl-log6', 'exec'),
edge('pl-match', 'indices', 'pl-log6', 'message'),
] };
const scene = __gm.state.activeScene;
const place = (id, assetId, parentId, name) => {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
};
place('pl-holder', 'plury', '', 'Plural holder');
place('pl-crowd', 'pl-sprite', '', 'Crowd 7');
place('pl-a', 'pl-sprite', 'pl-crowd', 'Bee');
place('pl-b', 'pl-sprite', 'pl-a', 'Bee');
place('pl-c', 'pl-sprite', 'pl-crowd', 'Ant');
`);
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Plural holder]'))
.map((line) => line.split(']').pop().trim());
// By name 2 · below Crowd 2 · direct children 2 · all below 3 · of the asset 4 · Bees at 0 and 2.
check(
'run over Crowd 7 › (Bee › Bee), Ant: every plural lookup and the Filter answer as planned',
JSON.stringify(logged) === JSON.stringify(['2', '2', '2', '3', '4', '0, 2']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'plury' && a.id !== 'pl-sprite');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-'));
`);
}
} }