Find the first matching item with a Find … Match block (#255)

Searching a list with For each needs the test result wired back into
break on the left of the loop node. The wires then run across the node
and the graph reads badly. Find … Match keeps the whole search flowing
left to right, while the test in between stays a free graph.

- Find takes exec and list and offers item and index. It sets them to
  each element in turn and has no exec output of its own.
- Match reads its bool input match once per element. The first true
  value continues at found with item and index of the hit. No hit or an
  empty list continues at not found with an empty item and index -1.
- The pair is derived from the wiring and stored nowhere. A Match
  belongs to the Find whose item or index its test is built from. Copy,
  paste and undo therefore need no extra code (findBlock.ts).
- An unpaired Find or Match, two Matches on one Find and a Match fed
  by two Finds show a warning on the node. At run time the flow ends at
  an unpaired Find and the console says so.
- Adding Find from the palette places its Match 480 px to the right in
  the same undo step.
- The graph draws a quiet frame behind Find, Match and the nodes of the
  test. Literals feeding the test from outside stay outside the frame.
- The test consists of data nodes only, like the break of For each.

Tests:
- ./check.sh: 184 files, 2694 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 272 checks
- New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v
  with 3 checks using a real mouse.
- Counter-checks: ignoring the test fails 4 unit tests, no ambiguity
  check fails 2, framing everything fails 1. In the browser, no frame
  and no Match from the palette each fail their check.
- Check total in README: 1517.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 11:33:25 +02:00
1 parent e1d3c0cadb
commit 2aaca01929
16 files changed
+608 -5

No files matched your search

+124
View File
@@ -1102,6 +1102,7 @@ export async function graph(t) {
await lerpNode();
await forEachNode();
await findMatchNodes();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1246,4 +1247,127 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => o.id !== 'fe-holder');
`);
}
// 6z-v. Find … Match: typed into the palette, Find arrives with its Match beside it. The test in
// between is dragged into the Match with the mouse, which closes the block and frames it.
// Run, it finds the first loot named "Gold" and goes on at found with that one.
async function findMatchNodes() {
const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'findy',
type: 'behavior',
name: 'Findy',
props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} },
variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'findy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('find');
await press('Enter');
const [find, match] = (await asset('findy')).graph.nodes;
const findTypes = find ? await json(`__gm.graph.portTypes(${JSON.stringify(find.id)})`) : null;
const matchTypes = match ? await json(`__gm.graph.portTypes(${JSON.stringify(match.id)})`) : null;
const lonely = find ? await json(`__gm.graph.nodeProblem(${JSON.stringify(find.id)})`) : null;
check(
'typing "find" into the palette adds Find with its Match beside it, both ports as planned',
find?.type === 'Find' &&
match?.type === 'Match' &&
match.x > find.x &&
match.y === find.y &&
JSON.stringify(Object.keys(findTypes?.outputs ?? {})) === JSON.stringify(['item', 'index']) &&
JSON.stringify(Object.keys(matchTypes?.inputs ?? {})) === JSON.stringify(['match']) &&
JSON.stringify(Object.keys(matchTypes?.outputs ?? {})) ===
JSON.stringify(['found', 'item', 'index', 'notFound']) &&
lonely === 'No Match tests its item',
JSON.stringify({ find, match, findTypes, matchTypes, lonely }),
);
if (!find || !match) return;
const f = JSON.stringify(find.id);
const m = JSON.stringify(match.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'findy');
const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
const at = behavior.graph.nodes.find((n) => n.id === ${f});
behavior.graph.nodes.push(
node('fm-start', 'OnStart', at.x - 240, at.y - 60),
node('fm-loots', 'Variables', at.x - 240, at.y + 80, {}, [{ key: 'loots', kind: 'list' }]),
node('fm-expand', 'ExpandStruct', at.x + 200, at.y + 90, {}, [{ key: 'Name', kind: 'text' }]),
node('fm-equals', 'Equals', at.x + 200, at.y + 200, { b: 'Gold' }),
node('fm-found', 'Log', at.x + 700, at.y - 60),
node('fm-name', 'ExpandStruct', at.x + 700, at.y + 120, {}, [{ key: 'Name', kind: 'text' }]),
node('fm-join', 'Join', at.x + 900, at.y + 120),
);
behavior.graph.edges.push(
edge('fm-e1', 'fm-start', 'exec', ${f}, 'exec'),
edge('fm-e2', 'fm-loots', 'loots', ${f}, 'list'),
edge('fm-e3', ${f}, 'item', 'fm-expand', 'value'),
edge('fm-e4', 'fm-expand', 'Name', 'fm-equals', 'a'),
edge('fm-e5', ${m}, 'found', 'fm-found', 'exec'),
edge('fm-e6', ${m}, 'item', 'fm-name', 'value'),
edge('fm-e9', 'fm-name', 'Name', 'fm-join', 'a'),
edge('fm-e7', ${m}, 'index', 'fm-join', 'b'),
edge('fm-e8', 'fm-join', 'text', 'fm-found', 'message'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'fm-holder', assetId: 'findy', parentId: '', name: 'Find holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
`);
// With the console open the graph is short: fit it, or the ports lie under the console.
await clickText('Fit');
const framesBefore = await json('__gm.graph.blockFrames()');
const from = await json(`__gm.graph.portAt('fm-equals', 'result', true)`);
const to = await json(`__gm.graph.portAt(${m}, 'match', false)`);
if (from && to) await drag(from.x, from.y, to.x, to.y, 16);
const wired = (await asset('findy')).graph;
const frames = await json('__gm.graph.blockFrames()');
const inside = (id) => {
const n = wired.nodes.find((node) => node.id === id);
const b = frames[0]?.bounds;
return !!n && !!b && n.x > b.minX && n.x < b.maxX && n.y > b.minY && n.y < b.maxY;
};
const problems = [await json(`__gm.graph.nodeProblem(${f})`), await json(`__gm.graph.nodeProblem(${m})`)];
check(
'dragging the test into match closes the block: one frame around Find, Expand, Equals and Match only',
framesBefore.length === 0 &&
wired.edges.some(
(e) => e.from.node === 'fm-equals' && e.to.node === match.id && e.to.port === 'match',
) &&
frames.length === 1 &&
[find.id, 'fm-expand', 'fm-equals', match.id].every(inside) &&
!['fm-start', 'fm-loots', 'fm-found', 'fm-name', 'fm-join'].some(inside) &&
problems.every((problem) => problem === null),
JSON.stringify({ from, to, framesBefore, frames, problems }),
);
await shot('24-find-match');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Find holder]'))
.map((line) => line.split(']').pop().trim());
check(
'run, the search goes on at found with the first "Gold", index 1',
JSON.stringify(logged) === JSON.stringify(['Gold1']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'findy');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder');
`);
}
}