/** * One table covering every built-in node that has a `run`: adding a node without a case here * fails `covers every built-in node`. The run-less event nodes are listed in `EVENT_NODES`. */ import { describe, expect, it } from 'vitest'; import { requireNodeDef } from '../../src/core/commands/require'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { createEmptyGraph, type DeclaredPort, type GraphLookup, NO_LOOKUP, type NodeContext, type PhysicsControl, resolveDef, type RunResult, type Value, worldPositionOf, } from '../../src/core/graph'; import type { PropertyBag } from '../../src/core/model/Properties'; import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers'; interface Case { /** What the case demonstrates – becomes the failure message. */ name: string; inputs?: Record; params?: PropertyBag; context?: Partial; /** Ports the node declares for itself – for the types whose ports are variables it names. */ ports?: DeclaredPort[]; /** Expected data outputs, compared exactly. */ outputs?: Record; /** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */ derivedOutputs?: string[]; /** Exec inputs grown the same way, and the one the pulse arrives through (`exec` unless said). */ derivedInputs?: string[]; entry?: string; /** Expected exec continuation; `null` asserts the flow ends here. */ next?: string | string[] | null; /** Expected loop request; `null` asserts the node does not loop. */ repeat?: RunResult['repeat'] | null; /** Expected nested-behavior call; `null` asserts the node calls nothing. */ call?: RunResult['call'] | null; /** Expected elements to test against a Match; `null` asserts the node searches nothing. */ find?: RunResult['find'] | null; /** The project around the graph – for a node whose step is written in another asset. */ lookup?: GraphLookup; /** Assertions on what the node did to the context or to the handles it was given. */ after?(ctx: FakeContext): void; } /** A lookup holding one machine "m" with the given states – what the two state readers name. */ const machineWithStates = (states: { id: string; name: string }[]): GraphLookup => ({ ...NO_LOOKUP, machine: (id) => (id === 'm' ? { name: 'M', states, start: '', transitions: [] } : null), }); /** A lookup holding one curve "c" (#247): straight up to 1.5 at the middle, back down to 1 at the end. */ const overshootingCurve: GraphLookup = { ...NO_LOOKUP, curve: (id) => id === 'c' ? [ { x: 0, y: 0 }, { x: 0.5, y: 1.5 }, { x: 1, y: 1 }, ] : null, }; /** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */ const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({ ...NO_LOOKUP, machine: (id) => id === 'm' ? { name: 'M', states: [], start: '', transitions: transitions.map((t) => ({ name: '', ...t })) } : null, }); const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => { const ctx = fakeContext(c.context); const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports }; const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_LOOKUP)! : requireNodeDef(type); const execPorts = (keys: string[] = []) => keys.map((key) => ({ key, label: key, type: 'exec' as const })); const def = { ...registered, inputs: [...execPorts(c.derivedInputs), ...registered.inputs], outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)], }; const lookup = c.lookup ?? NO_LOOKUP; // A pure reader has no `run`: its outputs are what it answers at the moment they are asked for, // which is exactly what the interpreter pulls (see `GraphInterpreter.readAll`). const result = def.run ? def.run(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup) : { outputs: def.read!(ctx, c.params ?? {}, (key) => c.inputs?.[key] ?? null, def, lookup) }; return { result, ctx }; }; const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); /** At (100, 0) in the world, below a parent that is moved, turned a quarter and doubled. */ const nestedMover = { ...at('mover', 0, 0), parent: { ...at('frame', 100, 0), props: { rotation: 90, scale: 2 } }, }; /** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */ const emitTarget = fakeEntity('Sparks'); /** Their own entity each: the animation nodes leave players on the object they are handed. */ const dancer = fakeEntity('Dancer'); /** An object whose once-reset "swing" has run out and blends back over half a second (#233). */ const swinging = { ...fakeEntity('Fighter'), animations: [ { animationId: 'swing', time: 1, playing: true, order: 1, fade: { seconds: 0.5, elapsed: 0, ended: true }, }, ], }; const swingLookup: GraphLookup = { ...NO_LOOKUP, animation: (id) => (id === 'swing' ? { frames: 10, fps: 10, playback: 'once-reset' } : null), }; const packed = fakeEntity('Player'); const painted = fakeHandle({ width: 100, text: '' }); type PhysicsDouble = PhysicsControl & { calls: unknown[][] }; /** Records what the physics nodes ask for; "crate" is the one object with a dynamic body. */ const physicsDouble = (): PhysicsDouble => { const calls: unknown[][] = []; const has = (target: { id: string }) => target.id === 'crate'; return { calls, velocityOf: (target) => (has(target) ? { x: 12, y: -3, angular: 45 } : null), setVelocity: (target, x, y) => (calls.push(['setVelocity', target.id, x, y]), has(target)), setAngularVelocity: (target, degrees) => ( calls.push(['setAngularVelocity', target.id, degrees]), has(target) ), applyImpulse: (target, x, y) => (calls.push(['applyImpulse', target.id, x, y]), has(target)), setGravity: (x, y) => void calls.push(['setGravity', x, y]), jointStateOf: (target) => (target.id === 'hinge' ? { value: 30, speed: -12 } : null), }; }; const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls; const crate = fakeEntity('crate'); /** A tilemap of 4 x 2 cells of 10 units, placed at (100, 50). */ const map = { ...fakeEntity('map', 100), y: 50, props: { columns: 4, rows: 2, tileSize: 10 } }; const pointer = (overrides: Partial): Partial => ({ pointer: { ...NO_POINTER, ...overrides }, }); /** Player › (Arm › Hitbox #2), Hitbox #1 – the arm comes first in scene order – and a lone Coin. */ const hero = fakeEntity('Player'); const arm = { ...fakeEntity('Arm'), parent: hero }; const deepBox = { ...fakeEntity('Hitbox'), id: 'hitbox-2', parent: arm }; const nearBox = { ...fakeEntity('Hitbox'), id: 'hitbox-1', parent: hero }; const coins = [fakeEntity('Coin', 0, 'coin'), { ...fakeEntity('Coin', 0, 'coin'), id: 'coin-2' }]; const scene: Partial = { objects: () => [hero, arm, deepBox, nearBox, ...coins] }; const CASES: Record = { Branch: [ { name: 'true takes then', inputs: { condition: true }, next: 'then' }, { name: 'false takes else', inputs: { condition: false }, next: 'else' }, { name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' }, { name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' }, ], Sequence: [ { name: 'on its own it has the one output, and follows it', next: ['a'] }, { name: 'runs every output it has grown, in order', derivedOutputs: ['b', 'c'], next: ['a', 'b', 'c'] }, ], BehaviorEnd: [ { name: 'hands its inputs back out, which is where the caller reads the result', inputs: { hit: true, damage: 7 }, outputs: { hit: true, damage: 7 }, next: null, }, ], RunBehavior: [ { name: 'asks for the named behavior to run, passing its inputs on', inputs: { power: 3 }, params: { behavior: 'b1' }, call: { behaviorId: 'b1', inputs: { power: 3 }, entry: 'exec' }, next: 'exec', }, { name: 'names the flow input the call came in by, so the start node leaves through that one', params: { behavior: 'b1' }, entry: 'open', call: { behaviorId: 'b1', inputs: {}, entry: 'open' }, next: 'exec', }, { name: 'without a behavior chosen it still continues, calling nothing that exists', call: { behaviorId: '', inputs: {}, entry: 'exec' }, next: 'exec', }, ], StateBranch: [ { name: 'in no state yet, the flow leaves along else', inputs: { machine: 'm' }, derivedOutputs: ['s1'], next: 'else', }, { name: 'follows the output of the current state', inputs: { machine: 'm' }, context: { states: { m: 's1' } }, derivedOutputs: ['s1', 's2'], next: 's1', }, { name: 'a state that is not among its outputs (another machine, a deleted state) takes else', inputs: { machine: 'm' }, context: { states: { m: 'gone' } }, derivedOutputs: ['s1'], next: 'else', }, { // The point of #58: a machine arriving as a value has no state outputs at all. name: 'a machine the graph cannot name has no state outputs, so else is the whole node', inputs: { machine: 'm' }, context: { states: { m: 's1' } }, next: 'else', }, ], SetStateByName: [ { name: 'moves the machine to the state of that name', inputs: { machine: 'm', state: 'Open' }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }), }, { name: 'a state id names the same state, so a rename cannot break a graph that used the id', inputs: { machine: 'm', state: 's1' }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }), }, { name: 'a name no state answers to moves nothing and takes "not found"', inputs: { machine: 'm', state: 'Ajar' }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), next: 'notFound', after: (ctx) => expect(ctx.states).toEqual({}), }, { name: 'a name two states share is no answer: the graph never picked between them', inputs: { machine: 'm', state: 'Open' }, lookup: machineWithStates([ { id: 's1', name: 'Open' }, { id: 's2', name: 'Open' }, ]), next: 'notFound', after: (ctx) => expect(ctx.states).toEqual({}), }, { name: 'without a machine there is nothing to set', inputs: { machine: '', state: 'Open' }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), next: 'notFound', after: (ctx) => expect(ctx.states).toEqual({}), }, ], SetState: [ { name: 'puts the machine into the state whose input the flow arrived through', inputs: { machine: 'm' }, derivedInputs: ['s1', 's2'], entry: 's2', next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }), }, { name: 'arriving through no state input changes nothing', inputs: { machine: 'm' }, derivedInputs: ['s1'], entry: 'exec', next: 'exec', after: (ctx) => expect(ctx.states).toEqual({}), }, { name: 'without a machine there is nothing to set', inputs: { machine: '' }, derivedInputs: ['s1'], entry: 's1', next: 'exec', after: (ctx) => expect(ctx.states).toEqual({}), }, ], TextureSize: [ { name: "hands out the picture's own pixel size", inputs: { texture: 'coin' }, lookup: { ...NO_LOOKUP, textureSize: (id) => (id === 'coin' ? { w: 8, h: 6 } : null) }, outputs: { width: 8, height: 6 }, }, { name: 'no picture is 0 × 0', inputs: { texture: '' }, outputs: { width: 0, height: 0 }, }, ], AnimationPlaying: [ { name: 'a once-reset pass blending out still runs, for as long as its blend out lasts (#233)', inputs: { object: swinging, animation: 'swing' }, lookup: swingLookup, outputs: { playing: true, progress: 1, timeLeft: 0.5 }, }, { name: 'with no animation named answers for what runs on the object', inputs: { object: swinging, animation: '' }, lookup: swingLookup, outputs: { playing: true, progress: 1, timeLeft: 0.5 }, }, { name: 'another animation is not playing', inputs: { object: swinging, animation: 'walk' }, lookup: swingLookup, outputs: { playing: false, progress: 0, timeLeft: 0 }, }, { name: 'no object plays nothing', inputs: { object: null, animation: '' }, lookup: swingLookup, outputs: { playing: false, progress: 0, timeLeft: 0 }, }, ], AnimationLength: [ { name: 'one pass in seconds: ten frames at 10 fps', inputs: { animation: 'swing' }, lookup: swingLookup, outputs: { seconds: 1 }, }, { name: 'no animation is 0', inputs: { animation: '' }, outputs: { seconds: 0 } }, ], CurrentState: [ { name: 'names the state the machine is in, and hands out its id as well', inputs: { machine: 'm' }, context: { states: { m: 's1' } }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), outputs: { name: 'Open', id: 's1' }, }, { name: 'in no state, both outputs are empty', inputs: { machine: 'm' }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), outputs: { name: '', id: '' }, }, { // A state of another machine, or one that was deleted: the id stands, the name is gone. name: 'a state the machine does not know keeps its id but has no name', inputs: { machine: 'm' }, context: { states: { m: 'gone' } }, lookup: machineWithStates([{ id: 's1', name: 'Open' }]), outputs: { name: '', id: 'gone' }, }, ], IsInState: [ { name: 'is true on the current state and false on every other', inputs: { machine: 'm' }, context: { states: { m: 's2' } }, derivedOutputs: ['s1', 's2'], outputs: { s1: false, s2: true }, }, { name: 'in no state, every output is false rather than unanswered', inputs: { machine: 'm' }, derivedOutputs: ['s1', 's2'], outputs: { s1: false, s2: false }, }, ], StateTransition: [ { name: 'follows the transition of the asked-for name out of the current state', inputs: { machine: 'm' }, params: { transition: 'alarm' }, context: { states: { m: 's1' } }, lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }), }, { // The point of naming transitions: one node works from every state that has that name. name: 'picks the same name out of whichever state the machine is in', inputs: { machine: 'm' }, params: { transition: 'alarm' }, context: { states: { m: 'searching' } }, lookup: machineLookup([ { from: 'patrolling', to: 'chasing', name: 'alarm' }, { from: 'chasing', to: 'searching', name: 'lost' }, { from: 'searching', to: 'chasing', name: 'alarm' }, ]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 'chasing' }), }, { name: 'a name the current state does not carry moves nothing', inputs: { machine: 'm' }, params: { transition: 'lost' }, context: { states: { m: 's1' } }, lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }), }, { name: 'two of the name out of one state is no move: the graph never picked between them', inputs: { machine: 'm' }, params: { transition: 'alarm' }, context: { states: { m: 's1' } }, lookup: machineLookup([ { from: 's1', to: 's2', name: 'alarm' }, { from: 's1', to: 's3', name: 'alarm' }, ]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }), }, { // An unnamed transition is a name too, so a machine nobody named still steps. name: 'with no name asked for, follows the one unnamed transition', inputs: { machine: 'm' }, context: { states: { m: 's1' } }, lookup: machineLookup([{ from: 's1', to: 's2' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }), }, { name: 'a state with nothing drawn out of it stays put', inputs: { machine: 'm' }, context: { states: { m: 's2' } }, lookup: machineLookup([{ from: 's1', to: 's2' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }), }, { name: 'in no state yet there is nothing to move from', inputs: { machine: 'm' }, lookup: machineLookup([{ from: 's1', to: 's2' }]), next: 'exec', after: (ctx) => expect(ctx.states).toEqual({}), }, ], Repeat: [ { name: 'asks for as many body rounds as it was given, then continues at done', inputs: { times: 3 }, repeat: { port: 'body', index: 'index', times: 3 }, next: 'done', }, { name: 'a fractional count is cut and a negative one runs nothing', inputs: { times: -2.7 }, repeat: { port: 'body', index: 'index', times: 0 }, }, ], // The rounds, the test and where the flow goes on are the interpreter's: see find-match.test.ts. Find: [ { name: 'hands the elements to test and goes on from its Match, not from itself', inputs: { list: ['a', 'b'] }, outputs: { item: null, index: -1 }, next: null, find: ['a', 'b'], }, { name: 'no list tests nothing', inputs: { list: null }, find: [] }, ], // 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: [ { name: 'writes the variables switched to Set, each keeping its declared type', ports: [ { key: 'speed', kind: 'number', write: true }, { key: 'lives', kind: 'number' }, ], inputs: { speed: '250', lives: 9 }, context: { variables: { speed: 100, lives: 3 } }, next: 'exec', after: (ctx) => expect(ctx.variables).toEqual({ speed: 250, lives: 3 }), }, { name: 'without a variable it writes nothing', next: 'exec', after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }), }, ], Properties: [ { name: 'writes the properties switched to Set, each keeping the type it already had', ports: [ { key: 'width', kind: 'number', write: true }, { key: 'text', kind: 'text', write: true }, { key: 'height', kind: 'number' }, ], inputs: { object: painted, width: '250', text: 7 }, next: 'exec', after: () => expect(painted.props).toEqual({ width: 250, text: '7' }), }, { name: 'no object is a no-op', ports: [{ key: 'width', kind: 'number', write: true }], inputs: { object: null, width: 5 }, next: 'exec', }, ], Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }], Owner: [ { name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } }, ], Parent: [ { name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } }, { name: 'a top-level object has no parent', inputs: { object: fakeHandle({}) }, outputs: { parent: null }, }, ], ObjectByName: [ { name: 'asks the context for the name', inputs: { name: 'Player' }, context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) }, outputs: { object: fakeEntity('Player') }, }, { name: 'an unknown name is null', inputs: { name: 'Ghost' }, outputs: { object: null } }, ], ChildByName: [ { name: 'asks the context below the object it is given', inputs: { object: fakeEntity('Player'), name: 'Hitbox' }, context: { childByName: (parent, name) => parent.name === 'Player' && name === 'Hitbox' ? fakeEntity('Hitbox') : null, }, outputs: { child: fakeEntity('Hitbox') }, }, { name: 'an unknown name is null', inputs: { object: fakeEntity('Player'), name: 'Ghost' }, outputs: { child: null }, }, { name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } }, ], LayerOf: [ { name: 'names the layer the context says the object is on (#211)', inputs: { object: fakeEntity('Player') }, context: { collisionLayerOf: (target) => (target.name === 'Player' ? 1 : null) }, lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground', 'Ghost'] }, outputs: { name: 'Ghost', number: 1 }, }, { name: 'a layer the project has not named goes by its number', inputs: { object: fakeEntity('Player') }, context: { collisionLayerOf: () => 4 }, lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground'] }, outputs: { name: 'Layer 4', number: 4 }, }, { name: 'an object without a collider is on no layer', inputs: { object: fakeEntity('Player') }, outputs: { name: '', number: -1 }, }, { name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } }, ], RenderLayerOf: [ { name: 'names the render layer the context says the object is drawn on (#305)', inputs: { object: fakeEntity('Coin') }, context: { renderLayerOf: (target) => (target.name === 'Coin' ? 1 : null) }, lookup: { ...NO_LOOKUP, renderLayers: () => ['Default', 'UI'] }, outputs: { name: 'UI', number: 1 }, }, { name: 'a render layer the project has not named goes by its number', inputs: { object: fakeEntity('Coin') }, context: { renderLayerOf: () => 3 }, lookup: { ...NO_LOOKUP, renderLayers: () => ['Default'] }, outputs: { name: 'Layer 3', number: 3 }, }, { name: 'an object that is not drawn is on no render layer', inputs: { object: fakeEntity('Cam') }, outputs: { name: '', number: -1 }, }, { name: 'no object is on no render layer', inputs: { object: null }, outputs: { name: '', number: -1 } }, ], ObjectsByName: [ { name: 'every object so named, in scene order', inputs: { name: 'Hitbox' }, context: scene, outputs: { objects: [deepBox, nearBox] }, }, { name: 'none so named is the empty list', inputs: { name: 'Ghost' }, context: scene, outputs: { objects: [] }, }, ], ChildrenByName: [ { name: 'every one below, the nearer level first', inputs: { object: hero, name: 'Hitbox' }, context: scene, outputs: { children: [nearBox, deepBox] }, }, { name: 'nothing above or beside the object', inputs: { object: arm, name: 'Hitbox' }, context: scene, outputs: { children: [deepBox] }, }, { name: 'no object is the empty list', inputs: { object: null, name: 'Hitbox' }, context: scene, outputs: { children: [] }, }, ], Children: [ { name: 'the direct children only', inputs: { object: hero, recursive: false }, context: scene, outputs: { children: [arm, nearBox] }, }, { name: 'recursive: level by level', inputs: { object: hero, recursive: true }, context: scene, outputs: { children: [arm, nearBox, deepBox] }, }, { name: 'a leaf has none', inputs: { object: deepBox, recursive: true }, context: scene, outputs: { children: [] }, }, { name: 'no object has none', inputs: { object: null }, context: scene, outputs: { children: [] } }, ], ObjectsOfAsset: [ { name: 'every instance of the asset', inputs: { asset: 'coin' }, context: scene, outputs: { objects: coins }, }, { name: 'no asset chosen is the empty list', inputs: { asset: '' }, context: scene, outputs: { objects: [] }, }, ], NameOf: [ { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, ], IsAsset: [ { name: 'matches the asset id', inputs: { object: fakeEntity('Coin', 0, 'coin'), asset: 'coin' }, outputs: { result: true }, }, { name: 'another asset does not match', inputs: { object: fakeEntity('Coin', 0, 'coin'), asset: 'gem' }, outputs: { result: false }, }, { name: 'no asset chosen never matches', inputs: { object: fakeEntity('Coin', 0, 'coin') }, outputs: { result: false }, }, ], IsEnabled: [ { name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } }, { name: 'own switch off', inputs: { object: fakeHandle({ enabled: false }) }, outputs: { result: false, own: false }, }, { name: 'own switch on but a parent is off – "own" still reports the own switch', inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) }, outputs: { result: false, own: true }, }, { name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } }, ], GetPosition: [ { name: 'a top-level object is at its own position', inputs: { object: at('A', 10, 20) }, outputs: { x: 10, y: 20 }, }, { name: 'a child reports its position local to the parent', inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } }, outputs: { x: 10, y: 20 }, }, { name: 'no object is the origin', inputs: { object: null }, outputs: { x: 0, y: 0 } }, ], GetWorldPosition: [ { name: 'a top-level object is at its own position', inputs: { object: at('A', 10, 20) }, outputs: { x: 10, y: 20 }, }, { name: 'a child is at the summed position of its parents', inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } }, outputs: { x: 110, y: 220 }, }, { name: 'no object is the origin', inputs: { object: null }, outputs: { x: 0, y: 0 } }, ], GetZ: [ { name: 'a child reports its own z and the z summed up with its parents', inputs: { object: { ...at('child', 0, 0), z: 2, parent: { ...at('parent', 0, 0), z: 5 } } }, outputs: { z: 2, worldZ: 7 }, }, { name: 'no object is 0', inputs: { object: null }, outputs: { z: 0, worldZ: 0 } }, ], Number: [ { name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } }, { name: 'unset is 0', outputs: { value: 0 } }, ], Boolean: [ { name: 'outputs its setting', params: { value: true }, outputs: { value: true } }, { name: 'unset is false', outputs: { value: false } }, ], String: [ { name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } }, { name: 'unset is empty', outputs: { value: '' } }, ], Texture: [ { name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } }, { name: 'unset is no picture', outputs: { value: '' } }, ], StateMachine: [ { name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } }, { name: 'unset is no machine', outputs: { value: '' } }, ], Sound: [ { name: 'outputs the id it was set to', params: { value: 'snd-1' }, outputs: { value: 'snd-1' } }, { name: 'unset is no sound', outputs: { value: '' } }, ], Scene: [ { name: 'outputs the id it was set to', params: { value: 'sc-1' }, outputs: { value: 'sc-1' } }, { name: 'unset is no scene', outputs: { value: '' } }, ], Animation: [ { name: 'outputs the id it was set to', params: { value: 'anim-1' }, outputs: { value: 'anim-1' } }, { name: 'unset is no animation', outputs: { value: '' } }, ], Asset: [ { name: 'outputs the id it was set to', params: { value: 'crate' }, outputs: { value: 'crate' } }, { name: 'unset is no asset', outputs: { value: '' } }, ], Add: [ { name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } }, { name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } }, ], Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }], Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }], Divide: [ { name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } }, { name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } }, ], Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }], Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }], Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }], Sqrt: [ { name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } }, { name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } }, ], Clamp: [ { name: 'inside the range passes through', inputs: { value: 0.4, min: 0, max: 1 }, outputs: { result: 0.4 }, }, { name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } }, { name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } }, ], Lerp: [ { name: 'linear by default', inputs: { a: 10, b: 20, t: 0.25 }, outputs: { result: 12.5 } }, { name: 'the easings of an animation key: cubic in', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'cubic', direction: 'in' }, outputs: { result: 1 }, }, { name: 'quad out', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'quad', direction: 'out' }, outputs: { result: 6 }, }, { name: 'a curve without a direction set eases both ways', inputs: { a: 0, b: 8, t: 0.25 }, params: { ease: 'cubic' }, outputs: { result: 0.5 }, }, { name: 'step stays at a until t reaches 1', inputs: { a: 2, b: 8, t: 0.99 }, params: { ease: 'step' }, outputs: { result: 2 }, }, { name: 'step at t 1 is b', inputs: { a: 2, b: 8, t: 1 }, params: { ease: 'step' }, outputs: { result: 8 }, }, { name: 'a direction says nothing for linear', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'linear', direction: 'in' }, outputs: { result: 4 }, }, { name: 'quad in-out, slow at both ends', inputs: { a: 0, b: 8, t: 0.25 }, params: { ease: 'quad', direction: 'in-out' }, outputs: { result: 1 }, }, { name: 'quad in-out, half way at the middle', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'quad', direction: 'in-out' }, outputs: { result: 4 }, }, { name: 'an unknown easing runs linear', inputs: { a: 0, b: 8, t: 0.25 }, params: { ease: 'in' }, outputs: { result: 2 }, }, { name: 't past 1 stops at b', inputs: { a: 3, b: -5, t: 1.2 }, outputs: { result: -5 } }, { name: 't below 0 stops at a', inputs: { a: 3, b: -5, t: -0.5 }, params: { ease: 'quad', direction: 'out' }, outputs: { result: 3 }, }, { name: 'along a Curve asset, overshooting b', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'curve', curve: 'c' }, lookup: overshootingCurve, outputs: { result: 12 }, }, { name: 'along a Curve asset that is gone: linear', inputs: { a: 0, b: 8, t: 0.25 }, params: { ease: 'curve', curve: 'gone' }, lookup: overshootingCurve, outputs: { result: 2 }, }, ], Floor: [ { name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } }, { name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } }, ], Ceil: [ { name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } }, { name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } }, ], Round: [ { name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } }, { name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } }, { name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } }, ], Modulo: [ { name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } }, { name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } }, ], Random: [ // The spread is checked in "Random stays inside [min, max)" below. { name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } }, ], Time: [ { name: 'reports the clock and the frame length from the context', context: { time: 12.5, dt: 0.016 }, outputs: { time: 12.5, dt: 0.016 }, }, ], ToText: [ { name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } }, { name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } }, { name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } }, ], ToNumber: [ { name: 'reads a text as a number', inputs: { value: '42' }, outputs: { result: 42 } }, { name: 'a word is 0', inputs: { value: 'twelve' }, outputs: { result: 0 } }, ], Substring: [ { name: 'takes count characters from start', inputs: { text: 'abcdef', start: 2, count: 3 }, outputs: { text: 'cde' }, }, { name: 'a negative count reaches to the end', inputs: { text: 'abcdef', start: 4, count: -1 }, outputs: { text: 'ef' }, }, { name: 'asking past the end gives what is there', inputs: { text: 'ab', start: 1, count: 10 }, outputs: { text: 'b' }, }, ], List: [ { name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } }, { name: 'an unset list is empty, not missing', outputs: { value: [] } }, { name: 'with a type, hands out the items without the mark they carry in the node (#271)', params: { structType: 'loot', value: [{ Name: 'Gem', $struct: 'loot' }] }, outputs: { value: [{ Name: 'Gem' }] }, }, ], Fill: [ { name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } }, { name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } }, ], Count: [ { name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } }, { name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } }, ], ItemAt: [ { name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } }, { name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } }, ], SetItem: [ { name: 'replaces one item', inputs: { list: [1, 2, 3], index: 1, value: 9 }, outputs: { result: [1, 9, 3] }, }, { name: 'leaves the list it was given untouched – a graph may hold it elsewhere', inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 }, outputs: { result: [9, 2, 3] }, }, { name: 'an index outside the list changes nothing', inputs: { list: [1, 2], index: 5, value: 9 }, outputs: { result: [1, 2] }, }, ], AddItem: [ { name: 'appends by default', inputs: { list: [1, 2], value: 3 }, outputs: { result: [1, 2, 3] } }, { name: 'inserts before index', inputs: { list: [1, 2, 3], value: 9, index: 1 }, outputs: { result: [1, 9, 2, 3] }, }, { name: 'index equal to the length appends', inputs: { list: [1, 2], value: 9, index: 2 }, outputs: { result: [1, 2, 9] }, }, { name: 'an index outside the list appends instead of dropping the value', inputs: { list: [1, 2], value: 9, index: 7 }, outputs: { result: [1, 2, 9] }, }, { name: 'leaves the list it was given untouched – a graph may hold it elsewhere', inputs: { list: Object.freeze([1, 2]), value: 3 }, outputs: { result: [1, 2, 3] }, }, { name: 'a list goes in as one item, not joined', inputs: { list: [[1]], value: [2, 3] }, outputs: { result: [[1], [2, 3]] }, }, { name: 'an unset list starts empty', inputs: { value: 5 }, outputs: { result: [5] } }, ], SubList: [ { name: 'takes count items from start', inputs: { list: [1, 2, 3, 4], start: 1, count: 2 }, outputs: { result: [2, 3] }, }, { name: 'asking past the end gives what is there', inputs: { list: [1, 2], start: 1, count: 9 }, outputs: { result: [2] }, }, ], Concat: [ { name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } }, { name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } }, ], PackStruct: [ { name: 'bundles its declared inputs into one value', inputs: { x: 2, tags: ['a', 'b'] }, outputs: { value: { x: 2, tags: ['a', 'b'] } }, }, { name: 'a node with no fields yet packs nothing', outputs: { value: {} } }, { name: 'an entity packs as the object it is, so a struct can hold one', inputs: { who: packed }, outputs: { value: { who: packed } as never }, }, ], ExpandStruct: [ { name: 'reads the fields back out, one output each', inputs: { value: { x: 2, tags: ['a'] } }, outputs: { x: 2, tags: ['a'] }, }, { name: 'a value that is not a bundle has no fields', inputs: { value: 7 }, outputs: {} }, ], IndexOf: [ { name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } }, { name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } }, ], FindText: [ { name: 'finds where the search first appears', inputs: { text: '..#.#', search: '#' }, outputs: { index: 2 }, }, { name: 'text that does not appear is -1', inputs: { text: '....', search: '#' }, outputs: { index: -1 }, }, ], Join: [ { name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } }, { name: 'an object joins as its name', inputs: { a: '', b: fakeEntity('Coin') }, outputs: { text: 'Coin' }, }, ], Equals: [ { name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } }, { name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } }, { name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } }, { name: 'two numbers ignore float noise', inputs: { a: 5.999999999999972, b: 6 }, outputs: { result: true }, }, { name: 'two numbers still tell 6.001 from 6', inputs: { a: 6.001, b: 6 }, outputs: { result: false } }, // The inline field of an `any` pin is text: a typed 130 against time - late at 130 s. { name: 'a noisy number equals the typed text 130', inputs: { a: 129.99999999999997, b: '130' }, outputs: { result: true }, }, { name: 'empty text is not 0', inputs: { a: 0, b: '' }, outputs: { result: false } }, { name: 'two texts still compare as text', inputs: { a: '1.0', b: '1' }, outputs: { result: false } }, ], Compare: [ { name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } }, { name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } }, { name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } }, // time - late after 6 s of summed dt (#224): the noise counts as equal under every operator. { name: '== ignores float noise', inputs: { a: 5.999999999999972, b: 6 }, params: { op: '==' }, outputs: { result: true }, }, { name: '!= ignores float noise', inputs: { a: 5.999999999999972, b: 6 }, params: { op: '!=' }, outputs: { result: false }, }, { name: '< ignores float noise', inputs: { a: 5.999999999999972, b: 6 }, params: { op: '<' }, outputs: { result: false }, }, { name: '>= ignores float noise', inputs: { a: 5.999999999999972, b: 6 }, params: { op: '>=' }, outputs: { result: true }, }, { name: '> ignores float noise', inputs: { a: 6.000000000000028, b: 6 }, params: { op: '>' }, outputs: { result: false }, }, { name: '<= ignores float noise', inputs: { a: 6.000000000000028, b: 6 }, params: { op: '<=' }, outputs: { result: true }, }, { name: 'the tolerance grows with the values', inputs: { a: 1e6, b: 1e6 + 1e-4 }, params: { op: '==' }, outputs: { result: true }, }, { name: 'a real difference stays one', inputs: { a: 6.001, b: 6 }, params: { op: '==' }, outputs: { result: false }, }, { name: 'a tiny value still differs from 0', inputs: { a: 1e-6, b: 0 }, params: { op: '>' }, outputs: { result: true }, }, { name: 'no operator chosen falls back to the default "<"', inputs: { a: 1, b: 2 }, outputs: { result: true }, }, ], FirstNotEmpty: [ { name: 'a is taken when it has a value', inputs: { a: 'Hero', b: 'Nobody' }, outputs: { value: 'Hero' }, }, { name: 'an unconnected a gives b', inputs: { b: 5 }, outputs: { value: 5 } }, { name: '0 counts as empty', inputs: { a: 0, b: 5 }, outputs: { value: 5 } }, { name: 'empty text counts as empty', inputs: { a: '', b: 'x' }, outputs: { value: 'x' } }, { name: 'false counts as empty', inputs: { a: false, b: true }, outputs: { value: true } }, { name: 'an empty list counts as empty', inputs: { a: [], b: [1] }, outputs: { value: [1] } }, { name: 'a position (0, 0) counts as empty', inputs: { a: { x: 0, y: 0 }, b: 3 }, outputs: { value: 3 } }, { name: 'a struct with one value set is kept', inputs: { a: { x: 0, y: 2 }, b: 3 }, outputs: { value: { x: 0, y: 2 } }, }, { name: 'an object is never empty', inputs: { a: fakeEntity('Coin'), b: 1 }, outputs: { value: fakeEntity('Coin') }, }, { name: 'both empty gives b', inputs: { a: '', b: 0 }, outputs: { value: 0 } }, { name: '"nothing there" keeps a 0', inputs: { a: 0, b: 5 }, params: { empty: 'nothing' }, outputs: { value: 0 }, }, { name: '"nothing there" keeps a false', inputs: { a: false, b: true }, params: { empty: 'nothing' }, outputs: { value: false }, }, { name: '"nothing there" still falls back on empty text', inputs: { a: '', b: 'x' }, params: { empty: 'nothing' }, outputs: { value: 'x' }, }, { name: '"nothing there" still falls back on an empty list', inputs: { a: [], b: [1] }, params: { empty: 'nothing' }, outputs: { value: [1] }, }, { name: '"nothing there" keeps a position (0, 0)', inputs: { a: { x: 0, y: 0 }, b: 3 }, params: { empty: 'nothing' }, outputs: { value: { x: 0, y: 0 } }, }, { name: '"only a missing value" keeps an empty text', inputs: { a: '', b: 'x' }, params: { empty: 'null' }, outputs: { value: '' }, }, { name: '"only a missing value" keeps an empty list', inputs: { a: [], b: [1] }, params: { empty: 'null' }, outputs: { value: [] }, }, { name: '"only a missing value" still falls back on an unconnected a', inputs: { b: 5 }, params: { empty: 'null' }, outputs: { value: 5 }, }, { name: 'an unknown setting behaves like the default', inputs: { a: 0, b: 5 }, params: { empty: 'nonsense' }, outputs: { value: 5 }, }, ], Select: [ { name: 'true takes the true input', inputs: { condition: true, false: 'Idle', true: 'Walk' }, outputs: { value: 'Walk' }, }, { name: 'false takes the false input', inputs: { condition: false, false: 'Idle', true: 'Walk' }, outputs: { value: 'Idle' }, }, { name: 'an unconnected condition is false', inputs: { false: 1, true: 2 }, outputs: { value: 1 } }, { name: 'an empty picked input gives null', inputs: { condition: true, false: 1 }, outputs: { value: null }, }, { name: 'a value is passed on as it is, even an empty one', inputs: { condition: false, false: 0, true: 5 }, outputs: { value: 0 }, }, ], And: [ { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } }, { name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } }, { name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } }, ], Or: [ { name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } }, { name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } }, ], Not: [ { name: 'inverts', inputs: { value: true }, outputs: { result: false } }, { name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } }, { name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } }, ], Length: [ { name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } }, { name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } }, ], Normalize: [ { name: 'scales to length 1 and reports the old length', inputs: { x: 0, y: -4 }, outputs: { x: 0, y: -1, length: 4 }, }, { name: 'the zero vector stays zero instead of becoming NaN', inputs: { x: 0, y: 0 }, outputs: { x: 0, y: 0, length: 0 }, }, ], Distance: [ { name: 'distance between two objects', inputs: { a: at('a', 0, 0), b: at('b', 3, 4) }, outputs: { result: 5 }, }, { name: 'measures in world units, also below a parent', inputs: { a: { ...at('a', 3, 4), parent: at('p', 100, 0) }, b: at('b', 100, 0) }, outputs: { result: 5 }, }, { name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } }, ], AxisInput: [ { name: 'one key gives a unit vector', context: { isKeyDown: (key) => key === 'ArrowRight' }, outputs: { x: 1, y: 0, active: true }, }, { name: 'diagonals are normalized, so they are not faster', context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' }, // Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower. outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true }, }, { name: 'opposite keys cancel out', context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' }, outputs: { x: 0, y: 0, active: false }, }, { name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } }, ], KeyIsDown: [ { name: 'asks the context for the chosen key', params: { key: 'a' }, context: { isKeyDown: (k) => k === 'a' }, outputs: { down: true }, }, { name: 'another key is not down', params: { key: 'b' }, context: { isKeyDown: (k) => k === 'a' }, outputs: { down: false }, }, { name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } }, ], KeyWasPressed: [ { name: 'asks the context for the press of the chosen key', params: { key: 'a' }, context: { wasKeyPressed: (k) => k === 'a', wasKeyReleased: () => true }, outputs: { pressed: true }, }, { name: 'another key was not pressed, and a held one is not a press', params: { key: 'b' }, context: { wasKeyPressed: (k) => k === 'a', isKeyDown: () => true }, outputs: { pressed: false }, }, ], KeyWasReleased: [ { name: 'asks the context for the release of the chosen key', params: { key: 'a' }, context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => false }, outputs: { released: true }, }, { name: 'another key was not released', params: { key: 'b' }, context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => true }, outputs: { released: false }, }, ], MousePosition: [ { name: 'reports the pointer in world units', context: pointer({ x: 40, y: -20, inside: true }), outputs: { x: 40, y: -20, inside: true }, }, { name: 'outside the camera frame the position is still reported, but "in view" is false', context: pointer({ x: 5, y: 5, inside: false }), outputs: { x: 5, y: 5, inside: false }, }, { name: 'through the camera given, where that camera sees the mouse', inputs: { camera: fakeEntity('Ui camera') }, context: { ...pointer({ x: 100, y: 50, inside: true }), pointerThrough: (camera) => camera.id === 'Ui camera' ? { x: 1050, y: 25, inside: false } : { x: 0, y: 0, inside: true }, }, outputs: { x: 1050, y: 25, inside: false }, }, ], MouseButton: [ { name: 'the frame the button goes down', context: pointer({ down: true, pressed: true }), outputs: { down: true, pressed: true, released: false }, }, { name: 'a later frame while it is still held', context: pointer({ down: true }), outputs: { down: true, pressed: false, released: false }, }, { name: 'the frame it goes up', context: pointer({ released: true }), outputs: { down: false, pressed: false, released: true }, }, ], IsHovered: [ { name: 'the pointer is over the object', inputs: { object: fakeEntity('Button') }, context: { ...pointer({ inside: true }), hitTest: () => true }, outputs: { result: true }, }, { name: 'a hit outside the camera frame does not count', inputs: { object: fakeEntity('Button') }, context: { ...pointer({ inside: false }), hitTest: () => true }, outputs: { result: false }, }, { name: 'no object is never hovered', inputs: { object: null }, context: { ...pointer({ inside: true }), hitTest: () => true }, outputs: { result: false }, }, { name: 'through the camera given, the test is where that camera sees the mouse', inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') }, context: { ...pointer({ x: 100, y: 50, inside: true }), pointerThrough: () => ({ x: 1050, y: 25, inside: true }), hitTest: (_, x, y) => x === 1050 && y === 25, }, outputs: { result: true }, }, { name: 'off the picture of the camera given, nothing is hovered', inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') }, context: { ...pointer({ x: 1050, y: 25, inside: true }), pointerThrough: () => ({ x: 1050, y: 25, inside: false }), hitTest: () => true, }, outputs: { result: false }, }, ], IsTouching: [ { name: 'asks the context', inputs: { a: fakeEntity('a'), b: fakeEntity('b') }, context: { isTouching: () => true }, outputs: { result: true }, }, { name: 'a missing object never touches', inputs: { a: fakeEntity('a'), b: null }, context: { isTouching: () => true }, outputs: { result: false }, }, ], Move: [ { name: 'moves by (dx, dy) × scale × dt', inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 }, next: 'exec', outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }, }, { name: 'with collision it hands on what the move met', inputs: { object: at('m', 0, 0), dx: 4, dy: 2, scale: 1, dt: 1 }, params: { collide: true }, context: { moveAndSlide: (_target: unknown, dx: number, dy: number) => ({ blockedX: dx === 4, blockedY: dy === 3, hit: true, normal: { x: -1, y: 0 }, }), }, next: 'exec', outputs: { blockedX: true, blockedY: false, hit: true, normalX: -1, normalY: 0 }, }, { name: 'no object is a no-op that still continues the flow', inputs: { object: null, dx: 1, dy: 1 }, next: 'exec', outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }, }, ], MoveToward: [ { name: 'steps speed × dt toward the point', inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 4, dt: 0.5 }, next: 'exec', outputs: { arrived: false, distance: 8, hit: false }, }, { name: 'stops on the point instead of running past it', inputs: { object: at('m', 0, 0), x: 3, y: 4, speed: 100, dt: 1 }, next: 'exec', outputs: { arrived: true, distance: 0, hit: false }, }, { name: 'counts the point in world units under a moved, turned parent', inputs: { object: nestedMover, x: 100, y: 10, speed: 100, dt: 1 }, next: 'exec', after: () => { const world = worldPositionOf(nestedMover); expect(world.x).toBeCloseTo(100, 9); expect(world.y).toBeCloseTo(10, 9); }, }, { name: 'with collision a solid in the way holds it back and says so', inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 100, dt: 1 }, params: { collide: true }, context: { moveAndSlide: () => ({ blockedX: true, blockedY: false, hit: true, normal: { x: -1, y: 0 } }), }, next: 'exec', outputs: { arrived: false, distance: 10, hit: true }, }, { name: 'no object is a no-op that still continues the flow', inputs: { object: null, x: 1, y: 1 }, next: 'exec', outputs: { arrived: false, distance: 0, hit: false }, }, ], SetPosition: [ { name: 'places the object and continues', inputs: { object: at('p', 0, 0), x: 30, y: -40 }, next: 'exec', }, { name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' }, ], SetZ: [ { name: 'sets the z and continues', inputs: { object: at('z', 0, 0), z: 3 }, next: 'exec' }, { name: 'no object is a no-op', inputs: { object: null, z: 3 }, next: 'exec' }, ], ApplyForce: [ { name: 'pushes for the length of the frame: force × dt, handed on as an impulse', inputs: { object: crate, x: 10, y: -4 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 5, -2]]), }, { name: 'no object asks nothing and continues', inputs: { object: null, x: 10, y: 0 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([]), }, { name: 'without physics (the editor run) it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec', }, ], ApplyImpulse: [ { name: 'kicks the object with the impulse as given', inputs: { object: crate, x: 3, y: -4 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 3, -4]]), }, { name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' }, ], SetVelocity: [ { name: 'sets the velocity as given', inputs: { object: crate, x: 120, y: -30 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setVelocity', 'crate', 120, -30]]), }, { name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' }, ], SetAngularVelocity: [ { name: 'sets the turning speed in degrees per second', inputs: { object: crate, speed: 90 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setAngularVelocity', 'crate', 90]]), }, { name: 'without physics it continues', inputs: { object: crate, speed: 90 }, next: 'exec' }, ], GetVelocity: [ { name: 'reads velocity and turning speed', inputs: { object: crate }, context: { physics: physicsDouble() }, outputs: { x: 12, y: -3, angular: 45 }, }, { name: 'an object without a dynamic body reads 0', inputs: { object: fakeEntity('wall') }, context: { physics: physicsDouble() }, outputs: { x: 0, y: 0, angular: 0 }, }, { name: 'without physics it reads 0', inputs: { object: crate }, outputs: { x: 0, y: 0, angular: 0 } }, ], JointPosition: [ { name: 'reads how far the joint stands from where it was placed, and how fast', inputs: { joint: fakeEntity('hinge') }, context: { physics: physicsDouble() }, outputs: { value: 30, speed: -12 }, }, { name: 'a joint the engine does not read reads 0', inputs: { joint: fakeEntity('rope') }, context: { physics: physicsDouble() }, outputs: { value: 0, speed: 0 }, }, { name: 'without physics it reads 0', inputs: { joint: fakeEntity('hinge') }, outputs: { value: 0, speed: 0 }, }, ], SetGravity: [ { name: 'sets the scene gravity', inputs: { x: 0, y: -500 }, context: { physics: physicsDouble() }, next: 'exec', after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setGravity', 0, -500]]), }, { name: 'without physics it continues', inputs: { x: 0, y: 1 }, next: 'exec' }, ], Raycast: [ { name: 'hands the ray to the context, ignoring the owner, and puts out what it met', inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300 }, context: { raycast: (from, direction, length, options) => { expect([from, direction, length, options.ignore?.id, options.triggers]).toEqual([ { x: 10, y: 20 }, { x: 0, y: 1 }, 300, 'owner', false, ]); return { object: crate, collider: painted, point: { x: 10, y: 120 }, normal: { x: 0, y: -1 }, distance: 100, }; }, }, next: 'exec', outputs: { hit: true, object: crate, collider: painted, pointX: 10, pointY: 120, normalX: 0, normalY: -1, distance: 100, }, }, { name: 'without "Ignore owner" and with "Hit triggers" it says so; a miss puts out nothing', inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50 }, params: { ignoreOwner: false, triggers: true }, context: { raycast: (_from, _direction, _length, options) => { expect([options.ignore, options.triggers]).toEqual([null, true]); return null; }, }, next: 'exec', outputs: { hit: false, object: null, collider: null, pointX: 0, pointY: 0, normalX: 0, normalY: 0, distance: 0, }, }, ], RaycastAll: [ { name: 'puts the hits out as parallel lists, nearest first, and counts them (#157)', inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300, maxHits: 2 }, context: { raycastAll: (from, direction, length, options) => { expect([from, direction, length, options.ignore?.id, options.triggers, options.maxHits]).toEqual([ { x: 10, y: 20 }, { x: 0, y: 1 }, 300, 'owner', false, 2, ]); return [ { object: crate, collider: painted, point: { x: 10, y: 120 }, normal: { x: 0, y: -1 }, distance: 100, }, { object: dancer, collider: dancer, point: { x: 10, y: 220 }, normal: { x: 0, y: 1 }, distance: 200, }, ]; }, }, next: 'exec', outputs: { count: 2, // The objects travel as objects, not as their names: that is what a list may carry now. objects: [crate, dancer], colliders: [painted, dancer], pointsX: [10, 10], pointsY: [120, 220], normalsX: [0, 0], normalsY: [-1, 1], distances: [100, 200], }, }, { name: 'a ray that meets nothing puts out empty lists and a count of 0', inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50, maxHits: -3 }, params: { ignoreOwner: false, triggers: true }, context: { raycastAll: (_from, _direction, _length, options) => { // A negative "max hits" is no limit at all, the same as 0. expect([options.ignore, options.triggers, options.maxHits]).toEqual([null, true, 0]); return []; }, }, next: 'exec', outputs: { count: 0, objects: [], colliders: [], pointsX: [], pointsY: [], normalsX: [], normalsY: [], distances: [], }, }, ], IgnoreOneWay: [ { name: 'asks the context to let the object through for the seconds given', inputs: { object: crate, seconds: 0.4 }, context: { ignoreOneWay: (target, seconds) => expect([target.id, seconds]).toEqual(['crate', 0.4]) }, next: 'exec', }, { name: 'no object is a no-op that continues', inputs: { object: null, seconds: 1 }, next: 'exec' }, ], TileAt: [ { name: 'asks the context for the cell, floored to whole cells, and says what lies there (#135)', inputs: { tilemap: map, layer: 'Ground', cellX: 2.7, cellY: 1 }, context: { tileAt: (target, layer, cell) => { expect([target.id, layer, cell]).toEqual(['map', 'Ground', { x: 2, y: 1 }]); return 'grass'; }, }, outputs: { tile: 'grass', empty: false }, }, { name: 'an empty cell, and one the context cannot answer, both read as empty', inputs: { tilemap: map, layer: '', cellX: 0, cellY: 0 }, context: { tileAt: () => null }, outputs: { tile: '', empty: true }, }, { name: 'no tilemap is an empty cell', inputs: { tilemap: null }, outputs: { tile: '', empty: true } }, ], SetTile: [ { name: 'hands the cell and the tile to the context and says whether it took them', inputs: { tilemap: map, layer: 'Ground', cellX: 3, cellY: 4.5, tile: 'door' }, context: { setTile: (target, layer, cell, tile) => { expect([target.id, layer, cell, tile]).toEqual(['map', 'Ground', { x: 3, y: 4 }, 'door']); return true; }, }, next: 'exec', outputs: { done: true }, }, { name: 'no tilemap sets nothing and continues', inputs: { tilemap: null }, next: 'exec', outputs: { done: false }, }, ], WorldToCell: [ { name: 'finds the cell under a world point of a moved map, and whether it is on the grid', // 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60). inputs: { tilemap: map, x: 95, y: 55 }, context: { isTilemap: () => true }, outputs: { cellX: 1, cellY: 1, inside: true }, }, { name: 'a point off the grid still names a cell, but not an inside one', inputs: { tilemap: map, x: 75, y: 45 }, context: { isTilemap: () => true }, outputs: { cellX: -1, cellY: 0, inside: false }, }, { name: 'something that is no tilemap has no cells', inputs: { tilemap: map, x: 95, y: 55 }, outputs: { cellX: 0, cellY: 0, inside: false }, }, ], CellToWorld: [ { name: 'puts out the middle of the cell in world units', inputs: { tilemap: map, cellX: 1, cellY: 1 }, context: { isTilemap: () => true }, outputs: { x: 95, y: 55 }, }, { name: 'no tilemap answers the origin', inputs: { tilemap: null, cellX: 1 }, outputs: { x: 0, y: 0 } }, ], Tile: [{ name: 'hands out the tile chosen', params: { value: 'door' }, outputs: { value: 'door' } }], Spawn: [ { name: 'places the asset that arrives on its input, local to the parent', inputs: { asset: 'crate', x: 1, y: 2, z: 3, parent: fakeEntity('Box') }, context: { spawn: (assetId, position, parent) => assetId === 'crate' && position.x === 1 && position.y === 2 && position.z === 3 && parent?.name === 'Box' ? fakeEntity('Crate') : null, }, next: 'exec', outputs: { object: fakeEntity('Crate') }, }, { name: 'no asset chosen logs a hint and continues with no object', next: 'exec', outputs: { object: null }, after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']), }, ], AddJoint: [ { name: 'places the picked joint on the object at (x, y), connected to the other', inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: fakeEntity('Hook'), x: 4, y: -6 }, context: { addJoint: (assetId, owner, connected, at) => assetId === 'rope' && owner.name === 'Box' && connected?.name === 'Hook' && at.x === 4 && at.y === -6 ? fakeEntity('Rope') : null, }, next: 'exec', outputs: { joint: fakeEntity('Rope') }, }, { name: 'hands the scene on as null when nothing is connected', inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: null }, context: { addJoint: (_asset, _owner, connected) => (connected === null ? fakeEntity('Rope') : null) }, next: 'exec', outputs: { joint: fakeEntity('Rope') }, }, { name: 'without a picked joint places nothing and says so', inputs: { jointAsset: '', object: fakeEntity('Box') }, next: 'exec', outputs: { joint: null }, after: (ctx) => expect(ctx.logs).toEqual(['Add joint: no joint selected']), }, { name: 'an asset that is no joint places nothing and says so', inputs: { jointAsset: 'crate', object: fakeEntity('Box') }, next: 'exec', outputs: { joint: null }, after: (ctx) => expect(ctx.logs).toEqual(['Add joint: asset crate is no joint']), }, ], Destroy: [ { name: 'no object is a no-op that still continues the flow', inputs: { object: null }, next: 'exec', }, ], Emit: [ { name: 'no source is a no-op that still continues the flow', inputs: { source: null }, next: 'exec', }, { name: 'counts a pulse on the source it is given', inputs: { source: emitTarget }, next: 'exec', after: () => expect(emitTarget.emits).toBe(1), }, ], PlayAnimation: [ { name: 'no object is a no-op that still continues the flow', inputs: { object: null, animation: 'anim-1' }, next: 'exec', }, { name: 'starts a player on the object, and a second play leaves it running', inputs: { object: dancer, animation: 'anim-1' }, next: 'exec', after: () => { expect(dancer.animations).toMatchObject([{ animationId: 'anim-1', time: 0, playing: true }]); }, }, ], PauseAnimation: [ { name: 'holds the player where it stands', inputs: { object: dancer, animation: 'anim-1' }, next: 'exec', after: () => expect(dancer.animations?.map((p) => p.playing)).toEqual([false]), }, ], StopAnimation: [ { name: 'with no animation named ends every player on the object', inputs: { object: dancer, animation: '' }, next: 'exec', after: () => expect(dancer.animations).toEqual([]), }, ], PlaySound: [ { name: 'no sound is a no-op that still continues the flow', inputs: { sound: '', volume: 1, loop: '' }, next: 'exec', outputs: { voice: 0, length: 0, tail: 0 }, after: (ctx) => expect(ctx.played).toEqual([]), }, { name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315), its length and its tail (#324)', inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' }, next: 'exec', // The fake context rings a voice on for its number in tenths of a second. outputs: { voice: 1, length: 0.25, tail: 0.35 }, // What the two mean – a factor on the asset's volume, an answer that overrules its loop // flag – is the BehaviorSystem's to resolve, since only it holds the project (see // `sounds.test.ts`). after: (ctx) => expect(ctx.played).toEqual([ { assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {}, tempo: 120 }, ]), }, { name: 'hands on its tempo for a Delay synced to beats, held to 20 to 400 BPM (#332)', inputs: { sound: 'snd-1', volume: 1, loop: '', tempo: 1000 }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.tempo).toBe(400), }, { name: 'detached says the voice is nobody’s, so destroying this object leaves it playing', inputs: { sound: 'snd-1', volume: 1, loop: '', detached: true }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.detached).toBe(true), }, { name: 'saying nothing about looping leaves the answer to the asset', inputs: { sound: 'snd-1', volume: 1, loop: '' }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.loop).toBeUndefined(), }, { name: 'off is an answer of its own, not the absence of one', inputs: { sound: 'snd-1', volume: 1, loop: 'false' }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.loop).toBe(false), }, { name: 'hands on the sound FX it names (#311)', inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1' }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.fx).toBe('fx-1'), }, { name: 'hands on the numbers of its fx params, and nothing for what is no bundle', inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1', fxParams: { depth: -12, name: 'x' } }, next: 'exec', after: (ctx) => expect(ctx.played[0]?.fxParams).toEqual({ depth: -12 }), }, ], PlayNote: [ { name: 'no instrument is a no-op that still continues the flow', inputs: { instrument: '', note: 60 }, next: 'exec', outputs: { voice: 0 }, after: (ctx) => expect(ctx.notes).toEqual([]), }, { name: 'hands the instrument on with its note, velocity, volume and duration, and gives out the voice (#331)', inputs: { instrument: 'ins-1', note: 64, velocity: 0.5, volume: 0.8, duration: 0.25, fxParams: { cut: 2 }, }, next: 'exec', outputs: { voice: 1 }, after: (ctx) => expect(ctx.notes).toEqual([ { instrumentId: 'ins-1', note: 64, velocity: 0.5, volume: 0.8, duration: 0.25, detached: false, instrumentParams: { cut: 2 }, tempo: 120, fx: '', fxParams: {}, }, ]), }, { name: 'hands on a sound FX of its own with its values, apart from the instrument parameters (#346)', inputs: { instrument: 'ins-1', fxParams: { cut: 2 }, soundFx: 'fx-1', soundFxParams: { depth: -6 } }, next: 'exec', after: (ctx) => expect(ctx.notes[0]).toMatchObject({ instrumentParams: { cut: 2 }, fx: 'fx-1', fxParams: { depth: -6 }, }), }, { name: 'hands on its tempo for a Delay synced to beats (#332)', inputs: { instrument: 'ins-1', note: 60, tempo: 90 }, next: 'exec', after: (ctx) => expect(ctx.notes[0]?.tempo).toBe(90), }, ], InstrumentParameterValues: [ { name: 'bundles its picked parameters as numbers, as for a sound FX', ports: [{ key: 'cut', kind: 'number' }], inputs: { cut: '0.5' }, outputs: { params: { cut: 0.5 } }, }, { name: 'with none picked the bundle is empty', outputs: { params: {} } }, ], PlayPattern: [ { name: 'no pattern is a no-op that still continues the flow', inputs: { pattern: '' }, next: 'exec', outputs: { playback: 0 }, after: (ctx) => expect(ctx.patterns).toEqual([]), }, { name: 'hands the pattern on with its volume, looping and owned unless asked otherwise', inputs: { pattern: 'pat-1', volume: 0.5, loop: true }, next: 'exec', outputs: { playback: 1 }, after: (ctx) => expect(ctx.patterns).toEqual([ { patternId: 'pat-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {} }, ]), }, { name: 'hands on a sound FX for all of it with its values (#346)', inputs: { pattern: 'pat-1', fx: 'fx-1', fxParams: { depth: -6 } }, next: 'exec', after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ fx: 'fx-1', fxParams: { depth: -6 } }), }, { name: 'plays once and detached when asked to', inputs: { pattern: 'pat-1', loop: false, detached: true }, next: 'exec', after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ loop: false, detached: true }), }, ], StopPattern: [ { name: 'stops the pattern it names, letting its notes ring when asked to', inputs: { pattern: 'pat-1', letRing: true }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['pattern pat-1 (let ring)']), }, { name: 'naming none stops every pattern', inputs: { pattern: '' }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['pattern ']), }, { name: 'with a playback connected stops only that one, whatever pattern is named', inputs: { pattern: 'pat-1', playback: 4 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['voice 4']), }, { name: 'a connected playback of 0 – a Play pattern that played nothing – stops nothing', inputs: { pattern: 'pat-1', playback: 0 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual([]), }, ], PlaySong: [ { name: 'no song is a no-op that still continues the flow', inputs: { song: '' }, next: 'exec', outputs: { playback: 0 }, after: (ctx) => expect(ctx.songs).toEqual([]), }, { name: 'hands the song on with its volume and fade in, looping and owned unless asked otherwise', inputs: { song: 'song-1', volume: 0.5, fadeIn: 2, loop: true }, next: 'exec', outputs: { playback: 1 }, after: (ctx) => expect(ctx.songs).toEqual([ { songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false, fx: '', fxParams: {} }, ]), }, { name: 'hands on a sound FX for the whole of it with its values (#346)', inputs: { song: 'song-1', fx: 'fx-1', fxParams: { depth: -6 } }, next: 'exec', after: (ctx) => expect(ctx.songs[0]).toMatchObject({ fx: 'fx-1', fxParams: { depth: -6 } }), }, { name: 'plays once and detached when asked to', inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true }, next: 'exec', after: (ctx) => expect(ctx.songs[0]).toMatchObject({ loop: false, detached: true }), }, ], StopSong: [ { name: 'stops the song it names over its fade out', inputs: { song: 'song-1', fadeOut: 1.5 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['song song-1 (fade 1.5)']), }, { name: 'naming none stops every song', inputs: { song: '', fadeOut: 0 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['song (fade 0)']), }, { name: 'with a playback connected stops only that one, whatever song is named', inputs: { song: 'song-1', playback: 4, fadeOut: 2 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['playback 4 (fade 2)']), }, { name: 'a connected playback of 0 – a Play song that played nothing – stops nothing', inputs: { song: 'song-1', playback: 0 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual([]), }, ], SoundFxParameters: [ { name: 'bundles its picked parameters as numbers', ports: [ { key: 'depth', kind: 'number' }, { key: 'loud', kind: 'number' }, ], inputs: { depth: '-12', loud: 3 }, outputs: { params: { depth: -12, loud: 3 } }, }, { name: 'with none picked the bundle is empty', outputs: { params: {} } }, ], LoadScene: [ { name: 'asks the context for the scene it names', inputs: { scene: 'sc-1' }, next: 'exec', after: (ctx) => expect(ctx.loaded).toEqual(['sc-1']), }, { name: 'naming no scene asks for nothing, and the flow carries on', inputs: { scene: '' }, next: 'exec', after: (ctx) => expect(ctx.loaded).toEqual([]), }, ], StopSound: [ { name: 'silences the sound it names', inputs: { sound: 'snd-1' }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['snd-1']), }, { name: 'naming none asks for everything', inputs: { sound: '' }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['']), }, { name: 'lets the echoes of the sound ring when asked to (#324)', inputs: { sound: 'snd-1', letRing: true }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['snd-1 (let ring)']), }, { name: 'with a voice connected silences only that one, whatever sound is named', inputs: { sound: 'snd-1', voice: 3, letRing: true }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual(['voice 3 (let ring)']), }, { name: 'a connected voice of 0 – a Play sound that played nothing – silences nothing, not everything', inputs: { sound: '', voice: 0 }, next: 'exec', after: (ctx) => expect(ctx.silenced).toEqual([]), }, ], SetSoundFxParameters: [ { name: 'hands the numbers of the bundle to the voice it is given', inputs: { voice: 4, fxParams: { depth: 3, label: 'x' } }, next: 'exec', after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0, ease: 'linear' }]), }, { name: 'hands on the time the values take to get there', inputs: { voice: 4, fxParams: { depth: -64 }, time: 1.28 }, next: 'exec', after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: -64 }, time: 1.28, ease: 'linear' }]), }, { name: 'a time below 0 is a quick glide', inputs: { voice: 4, fxParams: { depth: 3 }, time: -2 }, next: 'exec', after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0, ease: 'linear' }]), }, { name: 'hands on the easing the values take: a curve with its direction as a share of the way', inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 }, params: { ease: 'cubic', direction: 'in' }, next: 'exec', after: (ctx) => { const ease = ctx.tuned[0]!.ease; expect(typeof ease === 'function' && [ease(0), ease(0.5), ease(1)]).toEqual([0, 0.125, 1]); }, }, { name: 'step is handed on by name, the clock jumps at the end', inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 }, params: { ease: 'step' }, next: 'exec', after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('step'), }, { name: 'along a Curve asset, as a share that may overshoot', inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 }, params: { ease: 'curve', curve: 'c' }, lookup: overshootingCurve, next: 'exec', after: (ctx) => { const ease = ctx.tuned[0]!.ease; expect(typeof ease === 'function' && ease(0.5)).toBeCloseTo(1.5, 6); }, }, { name: 'a Curve asset that is gone is linear', inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 }, params: { ease: 'curve', curve: 'gone' }, lookup: overshootingCurve, next: 'exec', after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('linear'), }, { name: 'voice 0 – nothing was played – reaches nothing, and the flow carries on', inputs: { voice: 0, fxParams: { depth: 3 } }, next: 'exec', after: (ctx) => expect(ctx.tuned).toEqual([]), }, ], Log: [ { name: 'logs its message and continues', inputs: { message: 'hi' }, next: 'exec', after: (ctx) => expect(ctx.logs).toEqual(['hi']), }, { name: 'an object logs as its name', inputs: { message: fakeEntity('Coin') }, next: 'exec', after: (ctx) => expect(ctx.logs).toEqual(['Coin']), }, ], StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }], RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }], PauseGame: [ { name: 'pauses the game and flows on', next: 'exec', after: (ctx) => expect(ctx.paused).toBe(true) }, ], ResumeGame: [ { name: 'lets a paused game run on and flows on', context: { paused: true } as Partial, next: 'exec', after: (ctx) => expect(ctx.paused).toBe(false), }, ], IsGamePaused: [ { name: 'false while the game runs', outputs: { paused: false } }, { name: 'true while it is paused', context: { paused: true } as Partial, outputs: { paused: true }, }, ], SetTimeScale: [ { name: 'sets the scale and flows on', inputs: { scale: 0.5 }, next: 'exec', after: (ctx) => expect(ctx.scale).toBe(0.5), }, { name: '0 freezes the game', inputs: { scale: 0 }, next: 'exec', after: (ctx) => expect(ctx.scale).toBe(0), }, { name: 'a negative scale is 0 – time never runs backwards', inputs: { scale: -2 }, after: (ctx) => expect(ctx.scale).toBe(0), }, ], TimeScale: [ { name: 'answers the scale the run goes at', context: { scale: 0.25 } as Partial, outputs: { scale: 0.25 }, }, ], }; /** Two distinct handles that share a name – `Equals` must still tell them apart. */ function eqNamesakes(): Record { return { a: fakeEntity('Coin'), b: fakeEntity('Coin') }; } /** * Nodes without a `run`: only observable through a running world, since the BehaviorSystem * seeds their outputs. The value names the test that drives each one. */ const EVENT_NODES: Record = { BehaviorStart: 'nested-behavior.test.ts', OnBehavior: 'on-behavior.test.ts', OnStart: 'graph.test.ts', OnEnable: 'behavior-events.test.ts', OnUpdate: 'graph.test.ts', OnFixedUpdate: 'fixed-update.test.ts', OnDisable: 'behavior-events.test.ts', OnDestroy: 'behavior-events.test.ts', OnKeyDown: 'behavior-events.test.ts', OnKeyUp: 'behavior-events.test.ts', OnKeyHeld: 'behavior-events.test.ts', OnTimer: 'behavior-events.test.ts', OnCollisionEnter: 'collider.test.ts', OnCollisionStay: 'behavior-events.test.ts', OnCollisionExit: 'collider.test.ts', OnClick: 'nodes.test.ts', OnMouseWheel: 'clip-areas.test.ts', OnAnimationFinished: 'animation.test.ts', OnStateEntered: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts', }; /** A node that ends the block another one opens never runs itself – the opener runs it (`Match`). */ const closesBlock = (type: string) => BUILT_IN_NODES.some((def) => def.opens?.type === type); describe('node catalog', () => { it('covers every built-in node', () => { const uncovered = BUILT_IN_NODES.filter((def) => (def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type] && !closesBlock(def.type), ).map((def) => def.type); expect(uncovered).toEqual([]); }); it('lists exactly the nodes that neither run nor read as events', () => { const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read && !closesBlock(def.type)).map( (def) => def.type, ); expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort()); }); it('declares well-formed ports and settings', () => { for (const def of BUILT_IN_NODES) { const keys = [...def.inputs, ...def.outputs].map((p) => p.key); expect(def.label, `${def.type} has a label`).not.toBe(''); expect(def.description, `${def.type} has a description`).not.toBe(''); // Keys are unique per side, but may repeat across sides (Normalize reads and writes x/y). expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe( def.inputs.length, ); expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe( def.outputs.length, ); expect( keys.every((k) => k !== ''), `${def.type} has no empty port key`, ).toBe(true); for (const param of def.params) { expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined(); } } }); }); for (const [type, cases] of Object.entries(CASES)) { describe(type, () => { for (const c of cases) { it(c.name, () => { const { result, ctx } = run(type, c); if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs); if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat); if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call); if (c.find !== undefined) expect(result.find ?? null).toEqual(c.find); c.after?.(ctx); }); } }); } describe('action nodes write through to the object they are given', () => { it('Move applies dx/dy × scale × dt', () => { const target = at('m', 0, 0); runDef('Move', fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {}); expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 }); }); it('SetPosition places the object absolutely', () => { const target = at('p', 10, 10); runDef('SetPosition', fakeContext(), { object: target, x: 30, y: -40 }, {}); expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 }); }); it('SetZ sets only the z', () => { const target = { ...at('z', 10, 20), z: 1 }; runDef('SetZ', fakeContext(), { object: target, z: -4 }, {}); expect({ x: target.x, y: target.y, z: target.z }).toEqual({ x: 10, y: 20, z: -4 }); }); it('Destroy hands the object to the context, children and all unless told to keep them', () => { const removed: string[] = []; const moved = [fakeEntity('Lid'), fakeEntity('Nail')]; const ctx = fakeContext({ destroy: (target, keepChildren) => { removed.push(`${target.name} ${keepChildren}`); return keepChildren ? moved : []; }, }); expect(runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {}).outputs).toEqual({ children: [] }); const kept = runDef('Destroy', ctx, { object: fakeEntity('Crate'), keepChildren: true }, {}); expect(kept.outputs).toEqual({ children: moved }); expect(removed).toEqual(['Coin false', 'Crate true']); }); it('RestartGame asks the context for a restart', () => { let restarted = false; const ctx = fakeContext({ restart: () => { restarted = true; }, }); runDef('RestartGame', ctx, {}, {}); expect(restarted).toBe(true); }); it('Random stays inside [min, max) over many draws', () => { const ctx = fakeContext(); const draws = Array.from( { length: 200 }, () => runDef('Random', ctx, { min: 5, max: 10 }, {}).outputs!.result as number, ); expect(draws.every((n) => n >= 5 && n < 10)).toBe(true); // Not a constant: a generator stuck on one value would pass the range check above. expect(new Set(draws).size).toBeGreaterThan(1); }); });