Loop over a list and stop at the first match with For each (#249)

Finding the first item that matches took Count, Repeat, Item at and a
Branch, and the loop could not stop once it had found something. A graph
has no lambdas, so the predicate is an input that is read every round.

- For each runs body once per element, with item and index set, and
  goes on at done.
- Its break input is read anew before every round, after item is set.
  Once it is true the loop ends there and that round's body does not
  run.
- done sees the matching item and index with stopped true. Without a
  match it sees no item, index -1 and stopped false, the OrDefault.
- RepeatResult has optional items, stopWhen, stopped and exhausted.
  GraphInterpreter.loop reads them, Repeat runs as before.

Tests:
- ./check.sh: 180 files, 2623 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 269 checks
- 5 interpreter tests in graph.test.ts, 2 catalog cases
- e2e 6z-iv: the palette creates the node, and a search for
  Name == "Gold" in Axe, Gold, Bow, Gold logs "1true"
- Counter-checks: without the break check 2 unit tests fail, and with
  break not read over its edge 6z-iv fails
- This commit alone: tsc and the three touched unit files pass
  (349 tests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 21:06:40 +02:00
1 parent a5ae21481b
commit cb8a2d029a
8 files changed
+253 -3

No files matched your search

+92
View File
@@ -1101,6 +1101,7 @@ export async function graph(t) {
`);
await lerpNode();
await forEachNode();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1154,4 +1155,95 @@ export async function graph(t) {
p.assets = p.assets.filter((a) => a.id !== 'lerpy');
`);
}
// 6z-iv. For each (#249): typed into the palette it arrives with its ports, and a break built on
// the item finds the first loot named "Gold" – list.FirstOrDefault(x => x.Name == "Gold").
// The body logs the index of every round it runs, done logs index and stopped.
async function forEachNode() {
const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'eachy',
type: 'behavior',
name: 'Eachy',
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: 'eachy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('for each');
await press('Enter');
const each = (await asset('eachy')).graph.nodes[0];
const types = each ? await json(`__gm.graph.portTypes(${JSON.stringify(each.id)})`) : null;
check(
'typing "for each" into the palette adds For each with list and break in, item, index and stopped out',
each?.type === 'ForEach' &&
JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['exec', 'list', 'break']) &&
JSON.stringify(Object.keys(types?.outputs ?? {})) ===
JSON.stringify(['body', 'item', 'index', 'stopped', 'done']),
JSON.stringify({ each, types }),
);
if (!each) return;
const id = JSON.stringify(each.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'eachy');
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 } });
behavior.graph.nodes.push(
node('fe-start', 'OnStart', -300, 0),
node('fe-loots', 'Variables', -300, 160, {}, [{ key: 'loots', kind: 'list' }]),
node('fe-expand', 'ExpandStruct', -300, 300, {}, [{ key: 'Name', kind: 'text' }]),
node('fe-equals', 'Equals', -80, 300, { b: 'Gold' }),
node('fe-body', 'Log', 400, 0),
node('fe-done', 'Log', 400, 200),
node('fe-join', 'Join', 200, 260),
);
behavior.graph.edges.push(
edge('fe-e1', 'fe-start', 'exec', ${id}, 'exec'),
edge('fe-e2', 'fe-loots', 'loots', ${id}, 'list'),
edge('fe-e3', ${id}, 'item', 'fe-expand', 'value'),
edge('fe-e4', 'fe-expand', 'Name', 'fe-equals', 'a'),
edge('fe-e5', 'fe-equals', 'result', ${id}, 'break'),
edge('fe-e6', ${id}, 'body', 'fe-body', 'exec'),
edge('fe-e7', ${id}, 'index', 'fe-body', 'message'),
edge('fe-e8', ${id}, 'done', 'fe-done', 'exec'),
edge('fe-e9', ${id}, 'index', 'fe-join', 'a'),
edge('fe-e10', ${id}, 'stopped', 'fe-join', 'b'),
edge('fe-e11', 'fe-join', 'text', 'fe-done', 'message'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'fe-holder', assetId: 'eachy', parentId: '', name: 'Each holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
`);
await shot('23-for-each');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Each holder]'))
.map((line) => line.split(']').pop().trim());
check(
'the loop stops at the first "Gold": the body ran for Axe only, done sees index 1 and stopped',
JSON.stringify(logged) === JSON.stringify(['0', '1true']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'eachy');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'fe-holder');
`);
}
}