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

+16 -2
View File
@@ -13,7 +13,7 @@ import {
type RepeatResult,
} from './NodeRegistry';
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. */
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
* 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 {
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++) {
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
this.frame.dataCache.clear();
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.record(node.id, roundOutputs);
if (stopPort && asBoolean(this.inputValue(node, stopPort, ctx))) {
stopped = true;
break;
}
this.follow(node, repeat.port, ctx, depth);
}
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. */
+11
View File
@@ -42,6 +42,17 @@ export interface RepeatResult {
port: string;
index: string;
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. */
+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 { BehaviorStart } from './flow/BehaviorStart';
import { Branch } from './flow/Branch';
import { ForEach } from './flow/ForEach';
import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
@@ -150,6 +151,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Branch,
Sequence,
Repeat,
ForEach,
RunBehavior,
StateBranch,
SetState,