/** * `On behavior` (#294): another behavior used as an event. It lives at the node for as long as the * behavior around it runs, its own events fire, and each of its pulses that reaches its end node * goes on at the node hearing it. Driven through a real world, since the running game keeps it. */ import { describe, expect, it } from 'vitest'; import { AddNodeCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands'; import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, callProblem, type GraphNode, ON_BEHAVIOR, resolveDef, RUN_BEHAVIOR, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { graphEditor, makeBehavior, makeSpriteScene, place, type ProjectFixture, type SceneFixture, startWorld, } from '../helpers'; /** "Confirm": Space or Enter going down reaches the end node by its flow port `pressed`. */ function confirm(f: ProjectFixture, name = 'Confirm'): { asset: BehaviorAsset; end: GraphNode } { const asset = makeBehavior(f, name); const edit = graphEditor(f, asset); const end = edit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'pressed', kind: 'flow' })); for (const key of [' ', 'Enter']) edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', end, 'pressed'); return { asset, end }; } /** A behavior on a box hearing `heard`, with a Log on each named output of the node. */ function hearing( f: SceneFixture, heard: BehaviorAsset, outputs: readonly string[], name = 'Player', ): { asset: BehaviorAsset; node: GraphNode; objectId: string } { const asset = makeBehavior(f, name); const edit = graphEditor(f, asset); const node = edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: heard.id }); for (const port of outputs) edit.connect(node, port, edit.addNode('Log', { message: `${name} ${port}` }), 'exec'); const box = place(f, f.spriteId, { x: 0, y: 0 }); return { asset, node, objectId: place(f, asset.id, { x: 0, y: 0 }, box) }; } describe('On behavior', () => { it('fires when an event of the behavior it hears reaches that behavior’s end node', () => { const f = makeSpriteScene(); hearing(f, confirm(f).asset, ['pressed', 'exec']); const { world, input, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual([]); input.press('Enter'); frame(); expect(world.log).toEqual(['Player pressed']); input.release('Enter'); input.press(' '); frame(); expect(world.log).toEqual(['Player pressed', 'Player pressed']); }); it('stays silent for a pulse that does not reach the end node', () => { const f = makeSpriteScene(); const { asset } = confirm(f); const edit = graphEditor(f, asset); edit.connect(edit.addNode('OnUpdate'), 'exec', edit.addNode('Log', { message: 'inner update' }), 'exec'); hearing(f, asset, ['pressed', 'exec']); const { world, frame } = startWorld(f, { behaviors: true }); frame(); frame(); expect(world.log).toEqual(['inner update', 'inner update']); }); it('fires `exec` only when the end node was reached by its plain input', () => { const f = makeSpriteScene(); const { asset, end } = confirm(f); const edit = graphEditor(f, asset); edit.connect(edit.addNode('OnKeyDown', { key: 'x' }), 'exec', end, 'exec'); hearing(f, asset, ['pressed', 'exec']); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('x'); frame(); expect(world.log).toEqual(['Player exec']); input.press('Enter'); frame(); expect(world.log).toEqual(['Player exec', 'Player pressed']); }); it('fires once, by the way reached last, when one pulse reaches the end node twice', () => { const f = makeSpriteScene(); const asset = makeBehavior(f, 'Twice'); const edit = graphEditor(f, asset); const end = edit.addNode(BEHAVIOR_END); for (const key of ['first', 'second']) f.stack.execute(new AddPortCommand(asset.id, end.id, { key, kind: 'flow' })); const both = edit.addNode('Sequence'); edit.wire([ [edit.addNode('OnKeyDown', { key: 'a' }), 'exec', both, 'exec'], [both, 'a', end, 'first'], [both, 'b', end, 'second'], ]); hearing(f, asset, ['first', 'second', 'exec']); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(); expect(world.log).toEqual(['Player second']); }); it('has the outputs of the end node and the value inputs of the start node, under the behavior’s name', () => { const f = makeSpriteScene(); const { asset, end } = confirm(f); const start = graphEditor(f, asset).addNode(BEHAVIOR_START); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'way', kind: 'flow' })); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' })); f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'score', kind: 'number' })); const { asset: outer, node } = hearing(f, asset, []); const def = resolveDef(node, outer.graph, graphLookup(f.project))!; expect(def.label).toBe('On Confirm'); expect(def.category).toBe('event'); expect(def.inputs.map((p) => `${p.key}:${p.type}`)).toEqual(['amount:number']); expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([ 'exec:exec', 'pressed:exec', 'score:number', ]); }); it('hands its inputs to the start node, read anew for every pulse, and the end node’s values out', () => { const f = makeSpriteScene(); const asset = makeBehavior(f, 'Doubled'); const edit = graphEditor(f, asset); const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' })); f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'twice', kind: 'number' })); const times = edit.addNode('Multiply', { b: 2 }); edit.wire([ [edit.addNode('OnKeyDown', { key: 'a' }), 'exec', end, 'exec'], [start, 'amount', times, 'a'], [times, 'result', end, 'twice'], ]); const outer = makeBehavior(f, 'Player', ['count']); const oEdit = graphEditor(f, outer); const node = oEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); const log = oEdit.addNode('Log'); const plus = oEdit.addNode('Add', { b: 1 }); const set = oEdit.variables(['count'], { write: true }); const get = oEdit.variables(['count']); oEdit.wire([ [get, 'count', node, 'amount'], [node, 'exec', log, 'exec'], [node, 'twice', log, 'message'], [log, 'exec', set, 'exec'], [get, 'count', plus, 'a'], [plus, 'result', set, 'count'], ]); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, outer.id, { x: 0, y: 0 }, box); const { world, input, frame } = startWorld(f, { behaviors: true }); for (let i = 0; i < 3; i++) { input.press('a'); frame(); input.release('a'); frame(); } expect(world.log).toEqual(['0', '2', '4']); }); it('keeps the variables of the behavior it hears alive, one set per node', () => { const f = makeSpriteScene(); const asset = makeBehavior(f, 'Counter', ['count']); const edit = graphEditor(f, asset); const end = edit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'count', kind: 'number' })); const plus = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); const get = edit.variables(['count']); edit.wire([ [edit.addNode('OnKeyDown', { key: 'a' }), 'exec', set, 'exec'], [get, 'count', plus, 'a'], [plus, 'result', set, 'count'], [set, 'exec', end, 'exec'], [get, 'count', end, 'count'], ]); // Not kept from call to call: what holds the count here is the node it lives at. asset.keptVariables = []; const outer = makeBehavior(f, 'Player', ['count']); const oEdit = graphEditor(f, outer); for (const name of ['one', 'two']) { const node = oEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); const say = oEdit.addNode('Join', { a: `${name} ` }); const log = oEdit.addNode('Log'); oEdit.wire([ [node, 'exec', log, 'exec'], [node, 'count', say, 'b'], [say, 'text', log, 'message'], ]); } const box = place(f, f.spriteId, { x: 0, y: 0 }); const objectId = place(f, outer.id, { x: 0, y: 0 }, box); const { world, input, frame } = startWorld(f, { behaviors: true }); for (let i = 0; i < 2; i++) { input.press('a'); frame(); input.release('a'); frame(); } expect(world.log).toEqual(['one 1', 'two 1', 'one 2', 'two 2']); // The outer behavior's own variable of the same name is another one. expect(world.entities.find((e) => e.objectId === objectId)?.props.count).toBe(0); }); it('runs the lifecycle events of the behavior it hears in step with the one around it', () => { const f = makeSpriteScene(); const asset = makeBehavior(f, 'Life'); const edit = graphEditor(f, asset); const end = edit.addNode(BEHAVIOR_END); for (const type of ['OnStart', 'OnEnable', 'OnDisable', 'OnDestroy']) { f.stack.execute(new AddPortCommand(asset.id, end.id, { key: type, kind: 'flow' })); edit.connect(edit.addNode(type), 'exec', end, type); } const { asset: outer } = hearing(f, asset, ['OnStart', 'OnEnable', 'OnDisable', 'OnDestroy']); const oEdit = graphEditor(f, outer); oEdit.connect(oEdit.addNode('OnStart'), 'exec', oEdit.addNode('Log', { message: 'own start' }), 'exec'); const { world, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['own start', 'Player OnStart', 'Player OnEnable']); world.destroy(); expect(world.log.slice(3)).toEqual(['Player OnDisable', 'Player OnDestroy']); }); it('hears a timer of that behavior', () => { const f = makeSpriteScene(); const asset = makeBehavior(f, 'Tick'); const edit = graphEditor(f, asset); edit.connect(edit.addNode('OnTimer', { interval: 0.5 }), 'exec', edit.addNode(BEHAVIOR_END), 'exec'); hearing(f, asset, ['exec']); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.3); expect(world.log).toEqual([]); frame(0.3); expect(world.log).toEqual(['Player exec']); }); it('goes as deep as behaviors hear each other', () => { const f = makeSpriteScene(); const inner = confirm(f).asset; const middle = makeBehavior(f, 'Menu'); const mEdit = graphEditor(f, middle); const end = mEdit.addNode(BEHAVIOR_END); f.stack.execute(new AddPortCommand(middle.id, end.id, { key: 'chosen', kind: 'flow' })); mEdit.connect(mEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }), 'pressed', end, 'chosen'); hearing(f, middle, ['chosen', 'exec']); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('Enter'); frame(); expect(world.log).toEqual(['Player chosen']); }); it('stays silent in a behavior that is run as a node, like every event there', () => { const f = makeSpriteScene(); const inner = confirm(f).asset; const called = makeBehavior(f, 'Called'); const cEdit = graphEditor(f, called); cEdit.connect( cEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }), 'pressed', cEdit.addNode('Log', { message: 'heard in a call' }), 'exec', ); cEdit.addNode(BEHAVIOR_START); const outer = makeBehavior(f, 'Player'); const oEdit = graphEditor(f, outer); oEdit.connect( oEdit.addNode('OnUpdate'), 'exec', oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: called.id }), 'exec', ); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, outer.id, { x: 0, y: 0 }, box); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('Enter'); frame(); expect(world.log).toEqual([]); }); it('does not count as a running behavior of its own', () => { const f = makeSpriteScene(); const { objectId } = hearing(f, confirm(f).asset, ['pressed']); const { behaviors, frame } = startWorld(f, { behaviors: true }); frame(); expect(behaviors?.runningIds()).toEqual([objectId]); }); it('lights up the graph of the behavior it hears', () => { const f = makeSpriteScene(); const { asset, end } = confirm(f); hearing(f, asset, ['pressed']); const { behaviors, input, frame } = startWorld(f, { behaviors: true }); frame(); expect(behaviors?.traceFor(asset.id).nodes.has(end.id)).toBe(false); input.press('Enter'); frame(); expect(behaviors?.traceFor(asset.id).nodes.has(end.id)).toBe(true); }); }); describe('On behavior while the graph is edited during a run', () => { it('starts hearing a node added to the running graph, and stops hearing a removed one', () => { const f = makeSpriteScene(); const heard = confirm(f).asset; const outer = makeBehavior(f, 'Player'); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, outer.id, { x: 0, y: 0 }, box); const { world, behaviors, input, frame } = startWorld(f, { behaviors: true }); const tap = () => { input.press('Enter'); frame(); input.release('Enter'); frame(); }; tap(); expect(world.log).toEqual([]); const add = new AddNodeCommand(outer.id, ON_BEHAVIOR, 0, 0, { [BEHAVIOR_PARAM]: heard.id }); f.stack.execute(add); const edit = graphEditor(f, outer); edit.connect(add.node, 'pressed', edit.addNode('Log', { message: 'heard' }), 'exec'); behaviors?.syncFromModel(); tap(); expect(world.log).toEqual(['heard']); f.stack.execute(new RemoveNodeCommand(outer.id, add.node.id)); behaviors?.syncFromModel(); tap(); expect(world.log).toEqual(['heard']); }); it('ends the behavior a removed node was hearing: it says goodbye and runs no more', () => { const f = makeSpriteScene(); const heard = makeBehavior(f, 'Chatty'); const hEdit = graphEditor(f, heard); hEdit.addNode(BEHAVIOR_END); hEdit.connect( hEdit.addNode('OnUpdate'), 'exec', hEdit.addNode('Log', { message: 'inner update' }), 'exec', ); hEdit.connect(hEdit.addNode('OnDestroy'), 'exec', hEdit.addNode('Log', { message: 'inner bye' }), 'exec'); const { asset: outer, node } = hearing(f, heard, []); const { world, behaviors, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['inner update']); f.stack.execute(new RemoveNodeCommand(outer.id, node.id)); behaviors?.syncFromModel(); frame(); frame(); expect(world.log).toEqual(['inner update', 'inner bye']); }); }); describe('an On behavior node that cannot fire', () => { const problemOf = (f: ProjectFixture, outer: BehaviorAsset, node: GraphNode) => callProblem(node, graphLookup(f.project), outer.id); it('says so: nothing picked, the behavior gone, or no end node to be reached', () => { const f = makeSpriteScene(); const outer = makeBehavior(f, 'Player'); const edit = graphEditor(f, outer); expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR))).toBe('No behavior picked'); expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: 'nope' }))).toBe( 'That behavior is gone', ); const endless = makeBehavior(f, 'Endless'); expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: endless.id }))).toBe( 'No End node – never fires', ); expect( problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: confirm(f).asset.id })), ).toBeNull(); }); it('is reported for a behavior hearing itself, directly or through others, and such a circle does not run', () => { const f = makeSpriteScene(); const a = makeBehavior(f, 'A'); const b = makeBehavior(f, 'B'); const aEdit = graphEditor(f, a); const bEdit = graphEditor(f, b); const aEnd = aEdit.addNode(BEHAVIOR_END); const bEnd = bEdit.addNode(BEHAVIOR_END); const hearsB = aEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: b.id }); const hearsA = bEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: a.id }); const hearsSelf = aEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: a.id }); aEdit.connect(hearsB, 'exec', aEnd, 'exec'); bEdit.connect(hearsA, 'exec', bEnd, 'exec'); bEdit.connect(bEdit.addNode('OnKeyDown', { key: 'a' }), 'exec', bEnd, 'exec'); aEdit.connect(hearsB, 'exec', aEdit.addNode('Log', { message: 'A heard B' }), 'exec'); expect(problemOf(f, a, hearsB)).toBe('A behavior cannot listen to itself'); expect(problemOf(f, b, hearsA)).toBe('A behavior cannot listen to itself'); expect(problemOf(f, a, hearsSelf)).toBe('A behavior cannot listen to itself'); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, a.id, { x: 0, y: 0 }, box); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('a'); frame(); // B runs once below A; the A it names in turn is the one already running, so it is not built again. expect(world.log).toEqual(['A heard B']); expect(world.stopRequested).toBe(false); }); }); describe('the ports of an On behavior node follow the behavior it hears', () => { it('keeps its edges through a rename at the end node and loses them with the port', () => { const f = makeSpriteScene(); const { asset, end } = confirm(f); const { asset: outer } = hearing(f, asset, ['pressed']); const ports = () => outer.graph.edges.map((e) => e.from.port); expect(ports()).toEqual(['pressed']); f.stack.execute(new RenamePortCommand(asset.id, end.id, 'pressed', 'confirmed')); expect(ports()).toEqual(['confirmed']); f.stack.execute(new RemovePortCommand(asset.id, end.id, 'confirmed')); expect(ports()).toEqual([]); f.stack.undo(); expect(ports()).toEqual(['confirmed']); }); it('keeps the typed-in value of an input through a rename at the start node', () => { const f = makeSpriteScene(); const { asset } = confirm(f); const start = graphEditor(f, asset).addNode(BEHAVIOR_START); f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' })); const outer = makeBehavior(f, 'Player'); const node = graphEditor(f, outer).addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id, amount: 5 }); f.stack.execute(new RenamePortCommand(asset.id, start.id, 'amount', 'size')); expect(node.params.size).toBe(5); expect(node.params.amount).toBeUndefined(); }); });