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

+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, 1477 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1479 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
+16 -2
View File
@@ -13,7 +13,7 @@ import {
type RepeatResult, type RepeatResult,
} from './NodeRegistry'; } from './NodeRegistry';
import { machineIdOf } from './stateMachine'; import { machineIdOf } from './stateMachine';
import { isObjectPlace, type Value } from './values'; import { asBoolean, isObjectPlace, type Value } from './values';
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */ /** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
const MAX_STEPS_PER_FIRE = 100_000; const MAX_STEPS_PER_FIRE = 100_000;
@@ -217,20 +217,34 @@ export class GraphInterpreter {
/** /**
* One round per count, the loop node's `index` output carrying the round number. Data nodes are * One round per count, the loop node's `index` output carrying the round number. Data nodes are
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round * cached for the whole pulse, so the round has to drop that cache first – otherwise every round
* would read the values the first one computed. * would read the values the first one computed. The stop input is read the same way, after the
* round's outputs are set, so a condition built on `item` asks about the element at hand.
*/ */
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void { private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
const outputs = this.frame.outputCache.get(node.id) ?? {}; const outputs = this.frame.outputCache.get(node.id) ?? {};
const stopPort = repeat.stopWhen
? this.def(node)?.inputs.find((p) => p.key === repeat.stopWhen)
: undefined;
let stopped = false;
for (let round = 0; round < repeat.times; round++) { for (let round = 0; round < repeat.times; 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?)');
this.frame.dataCache.clear(); this.frame.dataCache.clear();
const roundOutputs = { ...outputs, [repeat.index]: round }; const roundOutputs = { ...outputs, [repeat.index]: round };
if (repeat.items) roundOutputs[repeat.items.port] = repeat.items.values[round] ?? null;
this.frame.outputCache.set(node.id, roundOutputs); this.frame.outputCache.set(node.id, roundOutputs);
this.record(node.id, roundOutputs); this.record(node.id, roundOutputs);
if (stopPort && asBoolean(this.inputValue(node, stopPort, ctx))) {
stopped = true;
break;
}
this.follow(node, repeat.port, ctx, depth); this.follow(node, repeat.port, ctx, depth);
} }
this.frame.dataCache.clear(); this.frame.dataCache.clear();
if (!repeat.stopped) return;
const last = this.frame.outputCache.get(node.id) ?? outputs;
const after = stopped ? last : { ...last, ...repeat.exhausted };
this.frame.outputCache.set(node.id, { ...after, [repeat.stopped]: stopped });
} }
/** 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. */
+11
View File
@@ -42,6 +42,17 @@ export interface RepeatResult {
port: string; port: string;
index: string; index: string;
times: number; times: number;
/** Round i also sets output `port` to `values[i]` – the element a `For each` is at. */
items?: { port: string; values: readonly Value[] };
/**
* Boolean input read anew before every round, after the round's outputs are set. True ends the
* loop there: that round's body does not run, and its outputs stay for what follows.
*/
stopWhen?: string;
/** Boolean output telling whether `stopWhen` ended the loop. */
stopped?: string;
/** Outputs standing after a loop that ran through all rounds without being stopped. */
exhausted?: Record<string, Value>;
} }
/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */ /** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */
+36
View File
@@ -0,0 +1,36 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { any, bool, exec, list, num, withDefault } from '../shared';
/** What `item`, `index` and `stopped` say when no round stopped the loop – or there was none. */
const NOTHING = { item: null, index: -1, stopped: false };
export const ForEach: NodeDef = {
type: 'ForEach',
label: 'For each',
category: 'flow',
description:
'Runs "body" once per element of the list, "item" and "index" telling which one, and continues at ' +
'"done" afterwards. "break" is read before every round: once it is true, the loop ends there – ' +
'that round\'s body does not run, and "done" sees its item with "stopped" set. Without a break, ' +
'"done" sees no item, index -1. "break" built on "item" finds the first element that matches.',
inputs: [exec(), list('list'), withDefault(bool('break'), false)],
outputs: [exec('body', 'body'), any('item'), num('index'), bool('stopped'), exec('done', 'done')],
params: [],
run: (_ctx, inputs) => {
const values = asList(inputs.list);
return {
outputs: { ...NOTHING },
repeat: {
port: 'body',
index: 'index',
times: values.length,
items: { port: 'item', values },
stopWhen: 'break',
stopped: 'stopped',
exhausted: NOTHING,
},
next: 'done',
};
},
};
+2
View File
@@ -113,6 +113,7 @@ import { OnUpdate } from './events/OnUpdate';
import { BehaviorEnd } from './flow/BehaviorEnd'; import { BehaviorEnd } from './flow/BehaviorEnd';
import { BehaviorStart } from './flow/BehaviorStart'; import { BehaviorStart } from './flow/BehaviorStart';
import { Branch } from './flow/Branch'; import { Branch } from './flow/Branch';
import { ForEach } from './flow/ForEach';
import { Repeat } from './flow/Repeat'; import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior'; import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence'; import { Sequence } from './flow/Sequence';
@@ -150,6 +151,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Branch, Branch,
Sequence, Sequence,
Repeat, Repeat,
ForEach,
RunBehavior, RunBehavior,
StateBranch, StateBranch,
SetState, SetState,
+63
View File
@@ -24,6 +24,7 @@ import {
portsCompatible, portsCompatible,
} from '../../src/core/graph'; } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import type { PropertyValue } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project'; import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { import {
@@ -390,6 +391,68 @@ describe('Repeat', () => {
}); });
}); });
/**
* `For each` over the list variable `names`, "break" = (item == wanted). The body logs the item it
* runs for, done logs item, index and stopped run together, so the logs show every round and where the loop ended.
*/
describe('For each', () => {
function search(names: readonly PropertyValue[], wanted: string | null) {
const fixture = emptyBehavior();
const { edit, behavior, stack } = fixture;
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
const start = edit.addNode('OnStart');
const each = edit.addNode('ForEach');
const get = edit.variables(['names']);
const body = edit.addNode('Log');
const done = edit.addNode('Log');
const line = edit.addNode('Join');
const tail = edit.addNode('Join');
edit.wire([
[start, 'exec', each, 'exec'],
[get, 'names', each, 'list'],
[each, 'body', body, 'exec'],
[each, 'item', body, 'message'],
[each, 'done', done, 'exec'],
[each, 'item', line, 'a'],
[each, 'index', tail, 'a'],
[each, 'stopped', tail, 'b'],
[tail, 'text', line, 'b'],
[line, 'text', done, 'message'],
]);
if (wanted !== null) {
const equals = edit.addNode('Equals', { b: wanted });
edit.wire([
[each, 'item', equals, 'a'],
[equals, 'result', each, 'break'],
]);
}
const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
return ctx.logs;
}
it('runs the body once per element, each round seeing its own item', () => {
expect(search(['a', 'b', 'c'], null)).toEqual(['a', 'b', 'c', '-1false']);
});
it('stops at the first element the break is true for, and done sees that one', () => {
// The body of the matching round does not run: "b" is logged by done only.
expect(search(['a', 'b', 'c', 'b'], 'b')).toEqual(['a', 'b1true']);
});
it('reads the break anew every round: it is true for the element at hand, not for the first one', () => {
expect(search(['a', 'b', 'c'], 'c')).toEqual(['a', 'b', 'c2true']);
});
it('without a match runs every round and done sees no item, index -1', () => {
expect(search(['a', 'b'], 'x')).toEqual(['a', 'b', '-1false']);
});
it('runs nothing for an empty list and goes straight to done', () => {
expect(search([], 'a')).toEqual(['-1false']);
});
});
describe('graph commands', () => { describe('graph commands', () => {
it('rejects incompatible, self and duplicate connections', () => { it('rejects incompatible, self and duplicate connections', () => {
const { const {
+32
View File
@@ -377,6 +377,38 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// The rounds themselves and the break are the interpreter's: see "For each" in graph.test.ts.
ForEach: [
{
name: 'asks for one body round per element, handing out the elements as item',
inputs: { list: ['a', 'b'] },
outputs: { item: null, index: -1, stopped: false },
repeat: {
port: 'body',
index: 'index',
times: 2,
items: { port: 'item', values: ['a', 'b'] },
stopWhen: 'break',
stopped: 'stopped',
exhausted: { item: null, index: -1, stopped: false },
},
next: 'done',
},
{
name: 'no list runs nothing',
inputs: { list: null },
repeat: {
port: 'body',
index: 'index',
times: 0,
items: { port: 'item', values: [] },
stopWhen: 'break',
stopped: 'stopped',
exhausted: { item: null, index: -1, stopped: false },
},
},
],
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts. // Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
Variables: [ Variables: [
{ {
+92
View File
@@ -1101,6 +1101,7 @@ export async function graph(t) {
`); `);
await lerpNode(); await lerpNode();
await forEachNode();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); 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'); 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');
`);
}
} }