diff --git a/README.md b/README.md index 1ed91f8e..95967475 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, 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 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/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 6787dc5d..86b07238 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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. */ diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index a76d9ce1..9e6c82f2 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; } /** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */ diff --git a/src/core/graph/nodes/flow/ForEach.ts b/src/core/graph/nodes/flow/ForEach.ts new file mode 100644 index 00000000..6c7f6df9 --- /dev/null +++ b/src/core/graph/nodes/flow/ForEach.ts @@ -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', + }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index d32a135d..6c455aff 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index b4c8de94..9b9a32cc 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -24,6 +24,7 @@ import { portsCompatible, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; +import type { PropertyValue } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; 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', () => { it('rejects incompatible, self and duplicate connections', () => { const { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index c3807f1c..405f0725 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -377,6 +377,38 @@ const CASES: Record = { }, ], + // 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. Variables: [ { diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 17bbbd53..f30e79d7 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -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'); + `); + } }