diff --git a/README.md b/README.md index 7d0f1811..44fb2b60 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, 1384 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1385 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 56836aea..6b2edb6e 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,6 +1,7 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested'; +import type { PropertyBag } from '../model/Properties'; import type { NodeContext } from './NodeContext'; import { type CallResult, @@ -42,6 +43,25 @@ function emptyFrame(graph: Graph): Frame { return { graph, outputCache: new Map(), dataCache: new Map() }; } +/** What a `Run behavior` with Keep state left behind in its callee (#229). */ +interface KeptState { + variables: PropertyBag; + states: Record; +} + +/** + * The variables a kept call resumes with: today's declarations, each at the value it was left at + * unless that value is gone or no longer of the declared sort – then at its default. + */ +export function resumedVariables(defaults: PropertyBag, kept: PropertyBag | undefined): PropertyBag { + const variables: PropertyBag = {}; + for (const [key, value] of Object.entries(defaults)) { + const left = kept?.[key]; + variables[key] = left !== undefined && typeof left === typeof value ? left : value; + } + return variables; +} + /** * Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows * exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and @@ -52,8 +72,11 @@ export class GraphInterpreter { readonly nodeState = new Map>(); /** Assets run as a node so far – the graph editor highlights them from this instance's trace. */ readonly calledBehaviors = new Set(); + /** Kept calls by call site: the chain of `Run behavior` node ids that led there. */ + readonly keptCalls = new Map(); private frame: Frame; private callStack: string[] = []; + private callSites: string[] = []; private steps = 0; trace: TraceListener | null = null; @@ -81,6 +104,7 @@ export class GraphInterpreter { if (filter && !filter(node)) continue; this.frame = emptyFrame(this.graph); this.callStack = []; + this.callSites = []; this.steps = 0; this.frame.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); @@ -125,7 +149,7 @@ export class GraphInterpreter { if (!def?.run) return; const inputs = this.collectInputs(node, ctx); const result = def.run(ctx, inputs, node.params, def, entry, this.lookup); - const returned = result.call ? this.call(result.call, ctx, depth) : {}; + const returned = result.call ? this.call(result.call, node.id, ctx, depth) : {}; const produced = { ...result.outputs, ...returned }; if (!def.read) this.frame.outputCache.set(node.id, produced); this.trace?.onNode(node.id); @@ -138,32 +162,39 @@ export class GraphInterpreter { /** * Runs another behavior from its start node to its end node and returns what the end node was * handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is - * a call and not an inlining: nothing of it survives the return but the values it hands back. + * a call and not an inlining: nothing of it survives the return but the values it hands back – + * unless the call keeps its state, which then waits for the next call from the same site. */ - private call(call: CallResult, ctx: NodeContext, depth: number): Record { + private call(call: CallResult, site: string, ctx: NodeContext, depth: number): Record { const program = this.lookup.behavior(call.behaviorId); if (!program) return {}; if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`); const start = terminalNode(program.graph, BEHAVIOR_START); if (!start) return {}; + const sites = [...this.callSites, site].join('/'); + const kept = call.keep ? this.keptCalls.get(sites) : undefined; + const variables = resumedVariables(program.defaults, kept?.variables); + const states = kept?.states ?? {}; + if (call.keep) this.keptCalls.set(sites, { variables, states }); const outer = this.frame; this.frame = emptyFrame(program.graph); this.frame.outputCache.set(start.id, { ...call.inputs }); this.record(start.id, call.inputs); this.callStack.push(call.behaviorId); + this.callSites.push(site); this.calledBehaviors.add(call.behaviorId); try { this.trace?.onNode(start.id); - // Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the - // caller's. What it does to a local machine therefore dies with the call and is not reported; - // a `global` machine is the run's, so moving that one is a move like any other. + // A call sees nothing of the caller's variables or states. What it does to a local machine is + // therefore not reported; a `global` machine is the run's, so moving that one is a move like + // any other. this.follow( start, 'exec', { ...ctx, - variables: { ...program.defaults }, - states: {}, + variables, + states, stateChanged: (machineId, from, to) => { if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); }, @@ -174,6 +205,7 @@ export class GraphInterpreter { return (end && this.frame.outputCache.get(end.id)) ?? {}; } finally { this.callStack.pop(); + this.callSites.pop(); this.frame = outer; } } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 40809b8c..e7e2e1b8 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -48,6 +48,8 @@ export interface RepeatResult { export interface CallResult { behaviorId: string; inputs: Record; + /** The called behavior picks up the variables and states this call site left behind (#229). */ + keep?: boolean; } export interface RunResult { diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 3bade121..305546ec 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -14,4 +14,4 @@ export * from './sharedState'; export * from './stateMachine'; export * from './values'; export * from './variablePorts'; -export { GraphInterpreter, type TraceListener } from './GraphInterpreter'; +export { GraphInterpreter, resumedVariables, type TraceListener } from './GraphInterpreter'; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 6c32d38a..9bf6a322 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -22,6 +22,7 @@ export const VARIABLES = 'Variables'; export const PROPERTIES = 'Properties'; export const BEHAVIOR_PARAM = 'behavior'; +export const KEEP_STATE_PARAM = 'keepState'; export const VARIABLES_PARAM = 'variablesAsset'; /** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */ diff --git a/src/core/graph/nodes/flow/RunBehavior.ts b/src/core/graph/nodes/flow/RunBehavior.ts index 9f3bc1f8..0affcb18 100644 --- a/src/core/graph/nodes/flow/RunBehavior.ts +++ b/src/core/graph/nodes/flow/RunBehavior.ts @@ -1,6 +1,13 @@ import type { NodeDef } from '../../NodeRegistry'; import { textOf } from '../../../model/Properties'; -import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, declaredPorts, RUN_BEHAVIOR } from '../../nested'; +import { + BEHAVIOR_END, + BEHAVIOR_PARAM, + BEHAVIOR_START, + declaredPorts, + KEEP_STATE_PARAM, + RUN_BEHAVIOR, +} from '../../nested'; import { exec } from '../shared'; /** @@ -13,11 +20,15 @@ export const RunBehavior: NodeDef = { label: 'Run behavior', category: 'flow', description: - 'Runs another behavior as a step of this one, passing values in and taking results out. The ' + - 'behavior keeps no state between calls: its variables start at their defaults every time.', + 'Runs another behavior as a step of this one, passing values in and taking results out. Its ' + + 'variables start at their defaults every time, unless Keep state is on: then each object keeps ' + + 'them for this node from one call to the next.', inputs: [exec()], outputs: [exec()], - params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }], + params: [ + { key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }, + { key: KEEP_STATE_PARAM, label: 'Keep state', kind: 'boolean', default: false }, + ], derivePorts: ({ node, lookup }) => { const program = lookup.behavior(textOf(node.params[BEHAVIOR_PARAM] ?? '')); return { @@ -28,7 +39,11 @@ export const RunBehavior: NodeDef = { }; }, run: (_ctx, inputs, params) => ({ - call: { behaviorId: textOf(params[BEHAVIOR_PARAM] ?? ''), inputs }, + call: { + behaviorId: textOf(params[BEHAVIOR_PARAM] ?? ''), + inputs, + keep: params[KEEP_STATE_PARAM] === true, + }, next: 'exec', }), }; diff --git a/tests/core/keep-state.test.ts b/tests/core/keep-state.test.ts new file mode 100644 index 00000000..24cb9fa7 --- /dev/null +++ b/tests/core/keep-state.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { AddPortCommand } from '../../src/core/commands/portCommands'; +import { + BEHAVIOR_END, + BEHAVIOR_PARAM, + BEHAVIOR_START, + GraphInterpreter, + KEEP_STATE_PARAM, + MACHINE_PORT, + RUN_BEHAVIOR, + resumedVariables, + STATE, + STATE_NAME_PARAM, +} from '../../src/core/graph'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { + fakeContext, + graphEditor, + makeBehavior, + makeProject, + makeSpriteScene, + place, + type ProjectFixture, + roundTrip, + startWorld, +} from '../helpers'; + +/** A behavior that counts its calls in its own `count` and hands the new count out as `out1`. */ +function counter(fixture: ProjectFixture): BehaviorAsset { + const callee = makeBehavior(fixture, 'Counter', ['count']); + const edit = graphEditor(fixture, callee); + const start = edit.addNode(BEHAVIOR_START); + const end = edit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); + const get = edit.variables(['count']); + const plus = edit.addNode('Add', { b: 1 }); + const set = edit.variables(['count'], { write: true }); + edit.wire([ + [start, 'exec', set, 'exec'], + [get, 'count', plus, 'a'], + [plus, 'result', set, 'count'], + [set, 'exec', end, 'exec'], + [plus, 'result', end, 'out1'], + ]); + return callee; +} + +/** A caller that runs `callee` once per `event` through each of `sites` nodes, storing the results. */ +function callerOf( + fixture: ProjectFixture, + callee: BehaviorAsset, + keep: boolean, + sites = 1, + event = 'OnStart', +) { + const keys = Array.from({ length: sites }, (_, i) => `r${i}`); + const caller = makeBehavior(fixture, 'Caller', keys); + const edit = graphEditor(fixture, caller); + let previous = edit.addNode(event); + for (const key of keys) { + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, [KEEP_STATE_PARAM]: keep }); + const store = edit.variables([key], { write: true }); + edit.wire([ + [previous, 'exec', call, 'exec'], + [call, 'exec', store, 'exec'], + [call, 'out1', store, key], + ]); + previous = store; + } + return caller; +} + +describe('Run behavior with Keep state (#229)', () => { + it('is off by default, so a call still starts from the defaults every time', () => { + const fixture = makeProject(); + const caller = callerOf(fixture, counter(fixture), false); + const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; + const added = new AddNodeCommand(caller.id, RUN_BEHAVIOR, 0, 0); + fixture.stack.execute(added); + expect(added.node.params[KEEP_STATE_PARAM]).toBe(false); + + const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); + const ctx = fakeContext({ variables: { r0: 0 } }); + for (let i = 0; i < 3; i++) interpreter.fire('OnStart', ctx); + expect(ctx.variables.r0).toBe(1); + expect(call.params[KEEP_STATE_PARAM]).toBe(false); + }); + + it('resumes the callee’s variables where the last call from this node left them', () => { + const fixture = makeProject(); + const caller = callerOf(fixture, counter(fixture), true); + const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); + const ctx = fakeContext({ variables: { r0: 0 } }); + const seen: unknown[] = []; + for (let i = 0; i < 3; i++) { + interpreter.fire('OnStart', ctx); + seen.push(ctx.variables.r0); + } + expect(seen).toEqual([1, 2, 3]); + // The caller's own bag never learns of `count`: the kept values stay the callee's. + expect('count' in ctx.variables).toBe(false); + }); + + it('keeps one state per node: two nodes calling the same behavior count on their own', () => { + const fixture = makeProject(); + const caller = callerOf(fixture, counter(fixture), true, 2); + const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); + const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); + interpreter.fire('OnStart', ctx); + expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]); + interpreter.fire('OnStart', ctx); + expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); + }); + + it('keeps one state per object in a running world', () => { + const fixture = makeSpriteScene(); + const callee = counter(fixture); + const caller = callerOf(fixture, callee, true, 1, 'OnUpdate'); + const log = graphEditor(fixture, caller).addNode('Log'); + const store = caller.graph.nodes.filter((n) => n.type === 'Variables').at(-1)!; + const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; + graphEditor(fixture, caller).wire([ + [store, 'exec', log, 'exec'], + [call, 'out1', log, 'message'], + ]); + place(fixture, caller.id, { x: 0, y: 0 }); + place(fixture, caller.id, { x: 10, y: 0 }); + const started = startWorld(fixture, { behaviors: true }); + for (let i = 0; i < 3; i++) started.frame(); + // Shared across objects it would read 1…6; per object each counts 1, 2, 3. + expect(started.world.log).toEqual(['1', '1', '2', '2', '3', '3']); + }); + + it('gives a kept call inside a kept call its own state per outer node', () => { + const fixture = makeProject(); + const inner = counter(fixture); + // The middle behavior just passes the counter through, keeping its call to it. + const middle = makeBehavior(fixture, 'Middle'); + const edit = graphEditor(fixture, middle); + const start = edit.addNode(BEHAVIOR_START); + const end = edit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(middle.id, end.id, { key: 'out1', kind: 'number' })); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id, [KEEP_STATE_PARAM]: true }); + edit.wire([ + [start, 'exec', call, 'exec'], + [call, 'exec', end, 'exec'], + [call, 'out1', end, 'out1'], + ]); + const caller = callerOf(fixture, middle, true, 2); + const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); + const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); + interpreter.fire('OnStart', ctx); + interpreter.fire('OnStart', ctx); + // One shared inner counter would have handed out 1, 2, 3, 4. + expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); + }); + + it('keeps a local state machine of the callee along with its variables', () => { + const fixture = makeProject(); + const create = new CreateAssetCommand('statemachine', 'Doors'); + fixture.stack.execute(create); + const open = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); + fixture.stack.execute(open); + const machine = create.asset.id; + // No state yet → "first" and put it in Open; in Open → "open". + const callee = makeBehavior(fixture, 'Door'); + const edit = graphEditor(fixture, callee); + const start = edit.addNode(BEHAVIOR_START); + const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine }); + const first = edit.addNode('Log', { message: 'first' }); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine }); + const again = edit.addNode('Log', { message: 'open' }); + edit.wire([ + [start, 'exec', branch, 'exec'], + [branch, 'else', first, 'exec'], + [first, 'exec', set, open.node.id], + [branch, open.node.id, again, 'exec'], + ]); + for (const keep of [false, true]) { + const caller = makeBehavior(fixture, `Caller ${keep}`); + const cEdit = graphEditor(fixture, caller); + cEdit.connect( + cEdit.addNode('OnStart'), + 'exec', + cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, [KEEP_STATE_PARAM]: keep }), + 'exec', + ); + const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); + const ctx = fakeContext(); + interpreter.fire('OnStart', ctx); + interpreter.fire('OnStart', ctx); + expect(ctx.logs).toEqual(keep ? ['first', 'open'] : ['first', 'first']); + // A local machine of the callee is none of the caller's business either way. + expect(ctx.states).toEqual({}); + } + }); + + it('survives saving and loading', () => { + const fixture = makeProject(); + const caller = callerOf(fixture, counter(fixture), true); + const loaded = roundTrip(fixture.project).assets.find((a) => a.id === caller.id) as BehaviorAsset; + expect(loaded.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params[KEEP_STATE_PARAM]).toBe(true); + }); +}); + +describe('resumedVariables', () => { + it('follows today’s declarations: new ones at their default, dropped ones gone', () => { + expect(resumedVariables({ count: 0, fresh: 5 }, { count: 7, gone: 1 })).toEqual({ count: 7, fresh: 5 }); + }); + + it('falls back to the default where the kept value is of another sort now', () => { + expect(resumedVariables({ count: 0, name: 'a' }, { count: 'seven', name: 'b' })).toEqual({ + count: 0, + name: 'b', + }); + }); + + it('is a fresh copy of the defaults when nothing was kept yet', () => { + const defaults = { count: 0 }; + const variables = resumedVariables(defaults, undefined); + expect(variables).toEqual(defaults); + expect(variables).not.toBe(defaults); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index a7b7e4de..faaf9a17 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -137,12 +137,18 @@ const CASES: Record = { name: 'asks for the named behavior to run, passing its inputs on', inputs: { power: 3 }, params: { behavior: 'b1' }, - call: { behaviorId: 'b1', inputs: { power: 3 } }, + call: { behaviorId: 'b1', inputs: { power: 3 }, keep: false }, + next: 'exec', + }, + { + name: 'asks for the call to keep its state when Keep state is on', + params: { behavior: 'b1', keepState: true }, + call: { behaviorId: 'b1', inputs: {}, keep: true }, next: 'exec', }, { name: 'without a behavior chosen it still continues, calling nothing that exists', - call: { behaviorId: '', inputs: {} }, + call: { behaviorId: '', inputs: {}, keep: false }, next: 'exec', }, ], diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index b0defca2..6f4fb263 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -128,6 +128,20 @@ export async function nestedBehavior(t) { ); await shot('37-nested-behavior'); + // 19c-ii. Keep state (#229) is a checkbox on the node, off until clicked, and one undo step. + const keepState = async () => + (await asset('nb-caller')).graph.nodes.find((n) => n.id === 'nb-call').params.keepState; + const keepField = await json(`__gm.inspectorField('Keep state')`); + const keepBefore = await keepState(); + if (keepField) await click(keepField.x, keepField.y); + const keepClicked = await keepState(); + await undo(); + check( + 'the call has a Keep state checkbox: off, on by a click, off again after undo', + Boolean(keepField) && keepBefore !== true && keepClicked === true && (await keepState()) !== true, + `field=${JSON.stringify(keepField)} before=${keepBefore} clicked=${keepClicked} undone=${await keepState()}`, + ); + // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the // check that a port list derived from another graph is really derived and not copied. check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false)));