/** * When event nodes run within a frame (#264): event kind by event kind across every behavior, within a * kind by the nodes' `order`, equal orders by the hierarchy and then by the order the nodes were made. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetMachineScopeCommand, SetMachineStartCommand, } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import { ReorderObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { BEHAVIOR_START, ON_BEHAVIOR, EVENT_ORDER, eventOrder, type GraphNode, hiddenOrder, hierarchyRanks, inEventOrder, MACHINE_PORT, STATE, STATE_NAME_PARAM, } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { findAssetOf } from '../../src/core/model/Project'; import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, type SceneFixture, startWorld, } from '../helpers'; const node = (id: string, order?: unknown): GraphNode => ({ id, type: 'OnUpdate', x: 0, y: 0, params: order === undefined ? {} : { [EVENT_ORDER]: order }, }) as GraphNode; /** Wires ` → Log(message)` into the behavior and returns the event node. */ function logOn( f: SceneFixture, behavior: BehaviorAsset, event: string, message: string, params: Record = {}, ): GraphNode { const edit = graphEditor(f, behavior); const fired = edit.addNode(event, params); edit.connect(fired, 'exec', edit.addNode('Log', { message }), 'exec'); return fired; } /** Two behaviors placed at the top level, `A` above `B`. */ function twoBehaviors(): SceneFixture & { a: BehaviorAsset; b: BehaviorAsset; placedA: string; placedB: string; } { const f = makeSpriteScene('Box'); const a = makeBehavior(f, 'A'); const b = makeBehavior(f, 'B'); const placedA = place(f, a.id, { x: 0, y: 0 }); const placedB = place(f, b.id, { x: 0, y: 0 }); return { ...f, a, b, placedA, placedB }; } describe('the order rule', () => { it('reads a missing, broken or fractional order as a whole number, 0 when there is none', () => { expect(eventOrder(node('n'))).toBe(0); expect(eventOrder(node('n', 'soon'))).toBe(0); expect(eventOrder(node('n', -3))).toBe(-3); expect(eventOrder(node('n', 2.4))).toBe(2); }); it('sorts by order and keeps the arrival order among equals', () => { const pulses = [ { node: node('a', 5) }, { node: node('b') }, { node: node('c', -1) }, { node: node('d') }, { node: node('e', 5) }, ]; expect(inEventOrder(pulses).map((p) => p.node.id)).toEqual(['c', 'b', 'd', 'a', 'e']); }); it('ranks by the hierarchy and puts what it does not list after it, in the order given', () => { const [x, y, z] = [{ objectId: 'x' }, { objectId: 'y' }, { objectId: 'z' }]; const ranks = hierarchyRanks([x, y, z], ['z', 'x']); expect([x, y, z].sort((p, q) => ranks.get(p)! - ranks.get(q)!)).toEqual([z, x, y]); }); it('gives every event node the setting, and leaves the row off the node at 0', () => { // `On behavior` fires inside a pulse of the behavior it hears, whose event nodes carry the order. const events = BUILT_IN_NODES.filter( (def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR, ); expect(events).toHaveLength(18); for (const def of events) { expect( def.params.map((p) => p.key), def.type, ).toContain(EVENT_ORDER); expect(def.hiddenParams?.({ [EVENT_ORDER]: 0 }), def.type).toContain(EVENT_ORDER); expect(def.hiddenParams?.({ [EVENT_ORDER]: 4 }), def.type).not.toContain(EVENT_ORDER); } expect(hiddenOrder({})).toEqual([EVENT_ORDER]); }); }); describe('the frame', () => { it('runs a lower order first across behaviors, and the hierarchy among equals', () => { const f = twoBehaviors(); logOn(f, f.a, 'OnUpdate', 'A'); const late = logOn(f, f.b, 'OnUpdate', 'B'); const { world, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['A', 'B']); f.stack.execute(new SetNodeParamCommand(f.b.id, late.id, EVENT_ORDER, -1)); world.log.length = 0; frame(); expect(world.log).toEqual(['B', 'A']); }); it('goes event kind by event kind: a key event of any behavior before every On update', () => { const f = twoBehaviors(); logOn(f, f.a, 'OnUpdate', 'A update'); logOn(f, f.b, 'OnKeyDown', 'B key', { key: 'x' }); const { world, input, frame } = startWorld(f, { behaviors: true }); input.press('x'); frame(); expect(world.log).toEqual(['B key', 'A update']); }); it('runs two nodes of one graph in the order they were made, unless their order says otherwise', () => { const f = twoBehaviors(); logOn(f, f.a, 'OnUpdate', 'first'); const second = logOn(f, f.a, 'OnUpdate', 'second'); const { world, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['first', 'second']); f.stack.execute(new SetNodeParamCommand(f.a.id, second.id, EVENT_ORDER, -5)); world.log.length = 0; frame(); expect(world.log).toEqual(['second', 'first']); }); it('orders On start the same way', () => { const f = twoBehaviors(); logOn(f, f.a, 'OnStart', 'A'); logOn(f, f.b, 'OnStart', 'B', { [EVENT_ORDER]: -2 }); const { world, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['B', 'A']); }); it('follows the hierarchy as it is now, also for a behavior switched on later', () => { const f = twoBehaviors(); logOn(f, f.a, 'OnUpdate', 'A'); logOn(f, f.b, 'OnUpdate', 'B'); f.stack.execute(new SetOverrideCommand(f.sceneId, f.placedA, 'enabled', false)); const { world, frame, behaviors } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['B']); // Switched on after B started: it still runs first, since it stands above B. world.entities.find((e) => e.objectId === f.placedA)!.props.enabled = true; world.log.length = 0; frame(); expect(world.log).toEqual(['A', 'B']); // Moved below B while running: the next frame after the edit reaches the run has it second. f.stack.execute(new ReorderObjectCommand(f.sceneId, f.placedA, '')); behaviors!.syncFromModel(); world.log.length = 0; frame(); expect(world.log).toEqual(['B', 'A']); }); it('delivers a state event of a shared machine to its listeners by order', () => { const f = twoBehaviors(); const machine = new CreateAssetCommand('statemachine', 'Doors'); f.stack.execute(machine); const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); f.stack.execute(state); f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); f.stack.execute(new SetMachineStartCommand(machine.asset.id, state.node.id)); for (const [behavior, message, order] of [ [f.a, 'A', 0], [f.b, 'B', -1], ] as const) { const edit = graphEditor(f, behavior); const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machine.asset.id, [EVENT_ORDER]: order, }); edit.connect(entered, state.node.id, edit.addNode('Log', { message }), 'exec'); } const { world, frame } = startWorld(f, { behaviors: true }); frame(); expect(world.log).toEqual(['B', 'A']); }); }); it('keeps the order through saving and loading', () => { const f = twoBehaviors(); const fired = logOn(f, f.a, 'OnUpdate', 'A', { [EVENT_ORDER]: 7 }); const loaded = findAssetOf(roundTrip(f.project), f.a.id, 'behavior')!; expect(eventOrder(loaded.graph.nodes.find((n) => n.id === fired.id)!)).toBe(7); });