diff --git a/README.md b/README.md index 2b15f97a..071ccfb4 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 169a404c..dc94f8a8 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -2,10 +2,12 @@ import { edgesInto, edgesOfNode, findNode, + type Graph, type GraphEdge, type GraphNode, portsCompatible, } from '../graph/Graph'; +import type { GraphLookup } from '../graph/lookup'; import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { nextTransitionName, parallelTransitions } from '../graph/stateMachine'; import { nodeDef } from '../graph/NodeRegistry'; @@ -148,18 +150,16 @@ export class SetNodeParamCommand extends SetBagValueCommand { } override execute(project: Project): void { - super.execute(project); const graph = requireGraph(project, this.assetId); - const node = requireNode(project, this.assetId, this.nodeId); - const def = resolveDef(node, graph, graphLookup(project)); - if (!def) return; - const outputs = new Set(def.outputs.map((p) => p.key)); - const inputs = new Set(def.inputs.map((p) => p.key)); + const lookup = graphLookup(project); + // A param can reshape another node too – a Find's mode decides its Match's outputs (#259). + const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup))); + super.execute(project); this.orphans = removeWhere( graph.edges, (e) => - (e.from.node === this.nodeId && !outputs.has(e.from.port)) || - (e.to.node === this.nodeId && !inputs.has(e.to.port)), + (held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) && + !edgeHolds(graph, e, lookup), ); } @@ -183,6 +183,16 @@ export class SetNodeParamCommand extends SetBagValueCommand { } } +/** 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). */ export class ConnectCommand implements Command { readonly label = 'Connect'; diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 11e2a2fc..65ba8843 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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 { type GraphLookup, NO_LOOKUP } from './lookup'; 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 - * closing the block reads its test anew each time, the way a loop's stop input is read. The first - * true one ends the search. Both nodes then hold the hit, and the flow goes on from the Match. + * closing the block reads its test anew each time, the way a loop's stop input is read. Its mode + * 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 { 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'}`); return; } - let hit = -1; + const mode = findMode(find); + const hits: number[] = []; for (let round = 0; round < values.length; round++) { if (++this.steps > MAX_STEPS_PER_FIRE) 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.record(find.id, roundOutputs); if (asBoolean(this.inputValue(match, test, ctx))) { - hit = round; - break; + hits.push(round); + if (stopsAtHit(mode)) break; } } this.frame.dataCache.clear(); - const found = hit >= 0; - const outputs = found ? { item: values[hit] ?? null, index: hit } : { item: null, index: -1 }; - this.frame.outputCache.set(find.id, outputs); - this.frame.outputCache.set(match.id, outputs); + const outcome = searchOutcome(mode, values, hits); + this.frame.outputCache.set(find.id, outcome.find); + this.frame.outputCache.set(match.id, outcome.match); this.trace?.onNode(match.id); - this.record(match.id, outputs); - this.follow(match, found ? 'found' : 'notFound', ctx, depth); + this.record(match.id, outcome.match); + this.follow(match, outcome.next, ctx, depth); } /** Resolves every data input: connected → pulled from the source, else the inline param. */ diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 6e35ea61..a493a533 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -148,6 +148,8 @@ export interface NodeContext { findReferenced(from: EntityHandle, ref: string): EntityHandle | null; /** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */ 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; pointer: PointerState; /** diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 80228d21..07723c24 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -89,6 +89,8 @@ export interface DerivedShape { label?: string; inputs?: 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. */ diff --git a/src/core/graph/findBlock.ts b/src/core/graph/findBlock.ts index 60d06a04..b0b95730 100644 --- a/src/core/graph/findBlock.ts +++ b/src/core/graph/findBlock.ts @@ -1,5 +1,6 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from './Graph'; 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 @@ -12,6 +13,54 @@ export const MATCH = 'Match'; /** The boolean input a `Match` reads its test from. */ 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; find: Record } { + 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 = + 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 { return (type && nodeDef(type)?.[side].find((p) => p.key === key)?.type) === 'exec'; } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 57f6b1de..1e16f253 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -181,7 +181,10 @@ function shapedDef( 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)]); + 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, diff --git a/src/core/graph/nodes/data/Children.ts b/src/core/graph/nodes/data/Children.ts new file mode 100644 index 00000000..59be2c1c --- /dev/null +++ b/src/core/graph/nodes/data/Children.ts @@ -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 } }; + }, +}; diff --git a/src/core/graph/nodes/data/ChildrenByName.ts b/src/core/graph/nodes/data/ChildrenByName.ts new file mode 100644 index 00000000..19594d94 --- /dev/null +++ b/src/core/graph/nodes/data/ChildrenByName.ts @@ -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)) } }; + }, +}; diff --git a/src/core/graph/nodes/data/ObjectsByName.ts b/src/core/graph/nodes/data/ObjectsByName.ts new file mode 100644 index 00000000..e1b83bfc --- /dev/null +++ b/src/core/graph/nodes/data/ObjectsByName.ts @@ -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)) } }), +}; diff --git a/src/core/graph/nodes/data/ObjectsOfAsset.ts b/src/core/graph/nodes/data/ObjectsOfAsset.ts new file mode 100644 index 00000000..547e3171 --- /dev/null +++ b/src/core/graph/nodes/data/ObjectsOfAsset.ts @@ -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)) } }), +}; diff --git a/src/core/graph/nodes/flow/Find.ts b/src/core/graph/nodes/flow/Find.ts index 075def36..2221feef 100644 --- a/src/core/graph/nodes/flow/Find.ts +++ b/src/core/graph/nodes/flow/Find.ts @@ -1,4 +1,4 @@ -import { FIND, MATCH } from '../../findBlock'; +import { FIND, FIND_MODE, MATCH } from '../../findBlock'; import type { NodeDef } from '../../NodeRegistry'; import { asList } from '../../values'; import { any, exec, list, num } from '../shared'; @@ -9,11 +9,26 @@ export const Find: NodeDef = { category: 'flow', description: '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 ' + - 'is true for continues at "found" of the Match, none at "not found".', + 'other; whatever is built from them into the Match\'s "match" is the test. Mode says what the ' + + 'Match answers: the first element the test is true for, all of them, whether there is any, or ' + + 'how many.', inputs: [exec(), list('list')], 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 }, run: (_ctx, inputs) => ({ outputs: { item: null, index: -1 }, find: asList(inputs.list) }), }; diff --git a/src/core/graph/nodes/flow/Match.ts b/src/core/graph/nodes/flow/Match.ts index 86c42b01..49385da2 100644 --- a/src/core/graph/nodes/flow/Match.ts +++ b/src/core/graph/nodes/flow/Match.ts @@ -1,16 +1,26 @@ -import { MATCH, MATCH_INPUT } from '../../findBlock'; -import type { NodeDef } from '../../NodeRegistry'; -import { any, bool, exec, num } from '../shared'; +import { type FindMode, MATCH, MATCH_INPUT, matchMode } from '../../findBlock'; +import type { NodeDef, PortDef } from '../../NodeRegistry'; +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 = { + 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 = { type: MATCH, label: 'Match', category: 'flow', description: - 'Closes the search block of the Find its "match" is built from. Continues at "found" with the ' + - 'first element "match" was true for, or at "not found" with no item and index -1.', + 'Closes the search block of the Find its "match" is built from. What it answers follows the ' + + '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 }], - outputs: [exec('found', 'found'), any('item'), num('index'), exec('notFound', 'not found')], + outputs: [], params: [], + derivePorts: ({ node, graph }) => ({ outputs: OUTPUTS[matchMode(graph, node)], inOrder: true }), }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 09922b41..d807e5b7 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -39,6 +39,8 @@ import { FirstNotEmpty } from './data/FirstNotEmpty'; import { Floor } from './data/Floor'; import { GetPosition } from './data/GetPosition'; import { ChildByName } from './data/ChildByName'; +import { Children } from './data/Children'; +import { ChildrenByName } from './data/ChildrenByName'; import { IsAsset } from './data/IsAsset'; import { IsEnabled } from './data/IsEnabled'; import { IsHovered } from './data/IsHovered'; @@ -70,6 +72,8 @@ import { NameOf } from './data/NameOf'; import { Normalize } from './data/Normalize'; import { Not } from './data/Not'; import { ObjectByName } from './data/ObjectByName'; +import { ObjectsByName } from './data/ObjectsByName'; +import { ObjectsOfAsset } from './data/ObjectsOfAsset'; import { Or } from './data/Or'; import { Owner } from './data/Owner'; import { PackStruct } from './data/PackStruct'; @@ -170,9 +174,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Owner, Parent, ObjectByName, + ObjectsByName, ChildByName, + ChildrenByName, + Children, NameOf, IsAsset, + ObjectsOfAsset, IsEnabled, Properties, GetPosition, diff --git a/src/core/graph/objectQueries.ts b/src/core/graph/objectQueries.ts new file mode 100644 index 00000000..b4bf0042 --- /dev/null +++ b/src/core/graph/objectQueries.ts @@ -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) : []; +} diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index bc1323d1..09b6c780 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -14,6 +14,7 @@ import { terminalNode, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; +import { FIND, FIND_MODE } from '../../core/graph/findBlock'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; @@ -53,7 +54,7 @@ export function rankedEntries(entries: PaletteEntry[], query: string): PaletteEn return entries .map((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) .sort( @@ -65,6 +66,19 @@ export function rankedEntries(entries: PaletteEntry[], query: string): PaletteEn .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 * 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 (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; entries.push({ id: def.type, hint: def.category, def, params: {} }); + if (def.type === FIND) entries.push(filterEntry(def)); } const lookup = graphLookup(project); for (const other of project.assets) { diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index dd48349a..2cfc67b0 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -672,6 +672,7 @@ export class BehaviorSystem implements System { const found = resolve(parent); return (found && world.childByName(found, name)) ?? null; }, + objects: () => world.entities, isKeyDown: (key) => this.input.isDown(key), pointer: world.pointer, hitTest: (target, x, y) => { diff --git a/tests/core/asset-ref.test.ts b/tests/core/asset-ref.test.ts index e0364a6e..5749557a 100644 --- a/tests/core/asset-ref.test.ts +++ b/tests/core/asset-ref.test.ts @@ -126,7 +126,7 @@ describe('node asset inputs', () => { const params = BUILT_IN_NODES.flatMap((def) => def.inputs) .filter((p) => p.key === 'asset') .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) { expect(param.kind).toBe('asset'); expect(param.kind === 'asset' && param.assetTypes).toBeUndefined(); diff --git a/tests/core/find-modes.test.ts b/tests/core/find-modes.test.ts new file mode 100644 index 00000000..bc26a592 --- /dev/null +++ b/tests/core/find-modes.test.ts @@ -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', {}]); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index b37b5aa9..3a5ac8a7 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -129,6 +129,14 @@ const pointer = (overrides: Partial): Partial = { objects: () => [hero, arm, deepBox, nearBox, ...coins] }; + const CASES: Record = { Branch: [ { name: 'true takes then', inputs: { condition: true }, next: 'then' }, @@ -500,6 +508,75 @@ const CASES: Record = { }, { 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: [ { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, diff --git a/tests/helpers.ts b/tests/helpers.ts index 6bbee598..281852fd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -231,6 +231,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { isAlive: () => true, findReferenced: () => null, childByName: () => null, + objects: () => [], isKeyDown: () => false, pointer: { ...NO_POINTER }, hitTest: () => false, diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index bae4a55b..9e2665af 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1104,6 +1104,7 @@ export async function graph(t) { await forEachNode(); await findMatchNodes(); await splitOutputs(); + await pluralLookups(); 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'); `); } + // 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-')); + `); + } }