Files
feuerwerk/tests/core/behavior-events.test.ts
adminandClaude Opus 5.5 5fbc6eb088
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Failing after 11m7s
Set the order in which event nodes run across behaviors (#264)
Which of two events ran first depended on hidden lists: the order of
the behavior instances and the order in which nodes were created. A
graph had no way to say that one On update must run before another,
and an On update of one behavior could run before a key event of
another.

- A frame now runs event kind by event kind over all behaviors: On
  disable and On enable, key down, key up, key held, timer, collision
  enter, stay and exit, animation finished, click, mouse wheel and at
  last On update.
- All 17 event nodes have a field Order, a whole number, default 0,
  negative allowed. Within one kind a lower Order runs earlier, no
  matter which behavior holds the node.
- On a tie the current hierarchy decides from top to bottom, then the
  order in which the nodes were created. The old rule that a behavior
  enabled later goes to the end is gone.
- The node shows Order: 5 only when the value is not 0, so existing
  graphs look the same.
- The rule lives as pure functions in core/graph/eventOrder.ts. The
  interpreter can now fire a single event node.

Changes in existing projects:

- An object destroyed in a frame gets no further event in that frame.
  On disable and On destroy still come at the end of the frame.
- New instances get all their On start first, then all On enable.
  Teardown runs all On disable first, then all On destroy.
- State events follow right after the event kind that caused them.
- Four unit tests had fixed the old order and are adjusted:
  two in behavior-events, one in physics-system, one in key-repeat.

Tests:

- ./check.sh: 191 files, 2784 tests, CHECK EXIT 0.
- GM_STEPS=1-6: ALL PASSED, 280 checks.
- Full e2e run (local): ALL PASSED, 1551 checks, measured.
- tests/core/event-order.test.ts: 11 tests for the rule, the field on
  all 17 nodes, order across behaviors, key before update, two nodes
  in one graph, On start, hierarchy after late enable and reorder at
  runtime, state events, save and load.
- e2e 6z-viii, 2 checks with real mouse and keyboard: Order -1 set in
  the inspector shows on the node, and a run logs B, A although B is
  lower in the hierarchy.
- Counter-checks: 5 in unit tests and 1 in the browser, all fail as
  expected. The originals were restored and checked with cmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:21:31 +02:00

505 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
* difference between a collision that starts and one that continues.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { loadPhysics } from '../../src/runtime/physics/engine';
import {
COLLISION_SYSTEMS,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
startWorld,
type SceneFixture,
} from '../helpers';
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
const f = makeSpriteScene('Box');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
return { ...f, behavior, box };
}
/** Wires `<event> → Log(message)` so a fired event shows up as one entry in `world.log`. */
function logOn(
f: SceneFixture & { behavior: BehaviorAsset },
event: string,
params: Record<string, string | number> = {},
message = event,
): void {
const edit = graphEditor(f, f.behavior);
const node = edit.addNode(event, params);
const log = edit.addNode('Log', { message });
edit.connect(node, 'exec', log, 'exec');
}
describe('key events', () => {
it('On key down fires once per press, not while the key stays held', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.log).toEqual([]);
input.press('a');
frame();
expect(world.log).toEqual(['down']);
input.press('a'); // auto-repeat while held: no new edge
frame();
frame();
expect(world.log).toEqual(['down']);
input.release('a');
input.press('a');
frame();
expect(world.log).toEqual(['down', 'down']);
});
it('On key up fires once when the key is released', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
frame();
expect(world.log).toEqual([]);
input.release('a');
frame();
expect(world.log).toEqual(['up']);
frame(); // the edge is consumed, not sticky
expect(world.log).toEqual(['up']);
});
it('On key held fires every frame the key is down', () => {
const f = scene();
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
frame();
frame();
expect(world.log).toEqual(['held', 'held', 'held']);
input.release('a');
frame();
expect(world.log).toHaveLength(3);
});
it('only the node whose key matches fires', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'a');
logOn(f, 'OnKeyDown', { key: 'b' }, 'b');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('b');
frame();
expect(world.log).toEqual(['b']);
});
it('losing the window releases every held key, so nothing stays down behind the user’s back', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
expect(world.log).toEqual(['held']);
input.releaseAll();
frame();
expect(world.log).toEqual(['held', 'up']);
frame();
expect(world.log).toHaveLength(2);
});
it('a key pressed as "a" and let go as "A" under Shift is released (#227)', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a', 'KeyA');
frame();
input.press('Shift', 'ShiftLeft');
input.press('A', 'KeyA'); // auto-repeat, now with Shift: still the same key
frame();
input.release('A', 'KeyA');
frame();
input.release('Shift', 'ShiftLeft');
frame();
expect(world.log).toEqual(['held', 'held', 'up']);
expect(input.isDown('a')).toBe(false);
});
it('a key keeps the name it went down as, even when Shift gives its release another one', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: '1' }, 'up');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('1', 'Digit1');
frame();
input.release('!', 'Digit1');
frame();
expect(world.log).toEqual(['up']);
expect(input.heldKeys.size).toBe(0);
});
it('a key stored upper-case in the graph still hears the key', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'A' }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a', 'KeyA');
frame();
expect(world.log).toEqual(['down']);
expect(input.isDown('A')).toBe(true);
});
});
// The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in
// binary (0.125, 0.25, 0.5), so a repeat due at a round hold time is never lost to a rounding error.
describe('On key down repeat', () => {
it('fires again after the delay, then on its own interval', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 250 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
expect(world.log).toEqual(['down']);
for (let n = 0; n < 3; n++) frame(0.125); // held 0.375 s: still short of the delay
expect(world.log).toEqual(['down']);
frame(0.125); // held 0.5 s
expect(world.log).toEqual(['down', 'down']);
frame(0.125);
expect(world.log).toHaveLength(2); // 0.625 s: half an interval on
frame(0.125);
expect(world.log).toHaveLength(3); // 0.75 s
frame(0.25);
expect(world.log).toHaveLength(4);
});
it('leaves a key alone while the delay is zero', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 0, repeatInterval: 50 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
for (let n = 0; n < 30; n++) frame(0.125);
expect(world.log).toEqual(['down']);
});
it('fires every frame when the interval is zero', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 0 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
frame(0.125); // held 0.125 s
expect(world.log).toEqual(['down']);
frame(0.125); // held 0.25 s: the delay is up
expect(world.log).toHaveLength(2);
frame(0.0625);
frame(0.0625);
expect(world.log).toHaveLength(4); // a short frame counts as much as a long one
});
it('starts the delay over on the next press', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
input.release('a');
frame(0.5); // well past the delay, but the key is gone: no repeat
expect(world.log).toEqual(['down']);
input.press('a');
frame(0.125);
expect(world.log).toEqual(['down', 'down']); // the press itself, not a repeat
frame(0.125); // held 0.125 s: the clock restarted, so nothing is due yet
expect(world.log).toHaveLength(2);
frame(0.125); // held 0.25 s
expect(world.log).toHaveLength(3);
});
it('catches up when one frame is longer than the interval', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 500 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
frame(2); // repeats due at 0.5, 1.0, 1.5 and 2.0
expect(world.log).toHaveLength(5);
});
it('gives two nodes on the same key their own clocks', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'fast');
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 750, repeatInterval: 750 }, 'slow');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
expect(world.log).toEqual(['fast', 'slow']); // the press reaches both
frame(0.125);
frame(0.125); // held 0.25 s
expect(world.log).toEqual(['fast', 'slow', 'fast']);
frame(0.125);
frame(0.125); // held 0.5 s
expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast']);
frame(0.25); // held 0.75 s: the fast one a third time, the slow one its first
expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast', 'fast', 'slow']);
});
});
describe('On timer', () => {
it('fires on its own clock, independent of the frame rate', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 1 }, 'tick');
const { world, frame } = startWorld(f, { behaviors: true });
for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s
expect(world.log).toEqual([]);
frame(0.2); // 1.0 s
expect(world.log).toEqual(['tick']);
for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s
expect(world.log).toEqual(['tick', 'tick']);
});
it('catches up when one frame is longer than the interval', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
const { world, frame } = startWorld(f, { behaviors: true });
frame(2); // a hitch worth four intervals
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
});
it('late is how far the firing frame is past the due time, so time minus late is exact', () => {
const f = scene();
const edit = graphEditor(f, f.behavior);
const timer = edit.addNode('OnTimer', { interval: 1 });
const log = edit.addNode('Log', {});
edit.connect(timer, 'exec', log, 'exec');
edit.connect(timer, 'late', log, 'message');
const { world, frame } = startWorld(f, { behaviors: true });
frame(0.75);
frame(0.375); // 1.125 s: due at 1.0
expect(world.log).toEqual(['0.125']);
frame(0.875); // 2.0 s: due exactly now
expect(world.log).toEqual(['0.125', '0']);
frame(2.5); // 4.5 s: catches up 3.0 and 4.0, the first one longer ago
expect(world.log).toEqual(['0.125', '0', '1.5', '0.5']);
});
it('two timers keep separate clocks', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 1 }, 'slow');
logOn(f, 'OnTimer', { interval: 0.5 }, 'fast');
const { world, frame } = startWorld(f, { behaviors: true });
frame(0.5);
expect(world.log).toEqual(['fast']);
frame(0.5);
expect(world.log).toEqual(['fast', 'slow', 'fast']);
});
});
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
describe('On collision stay', () => {
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, a));
f.stack.execute(new AddColliderCommand(f.sceneId, b));
place(f, behavior.id, { x: 0, y: 0 }, a);
const fixture = { ...f, behavior };
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
logOn(fixture, 'OnCollisionStay', {}, 'stay');
logOn(fixture, 'OnCollisionExit', {}, 'exit');
const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true });
const crateB = world.findByObjectId(b)!;
frame(0.1);
expect(world.log).toEqual([]);
crateB.x = 50; // now overlapping
frame(0.1);
expect(world.log).toEqual(['enter']);
frame(0.1);
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay']);
crateB.x = 500;
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
frame(0.1);
expect(world.log).toHaveLength(4);
});
});
});
describe('a switched-off behavior receives no events', () => {
it('ignores keys and timers until it is switched back on', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
const { world, input, frame } = startWorld(f, { behaviors: true });
const logic = world.entities.find((e) => e.name === 'Logic')!;
logic.props.enabled = false;
input.press('a');
frame(1);
expect(world.log).toEqual([]);
logic.props.enabled = true;
input.release('a');
input.press('a');
frame(1);
// The timer starts counting when the behavior runs again – no backlog from the off period.
expect(world.log).toEqual(['down', 'tick', 'tick']);
});
});
/** Every lifecycle event logs its own name, so `world.log` reads as the order they fired in. */
function lifecycle(): ReturnType<typeof scene> {
const f = scene();
for (const event of ['OnStart', 'OnEnable', 'OnUpdate', 'OnDisable', 'OnDestroy'])
logOn(f, event, {}, event.slice(2).toLowerCase());
return f;
}
describe('lifecycle events', () => {
it('fires On start, then On enable, then the first On update', () => {
const { world, frame } = startWorld(lifecycle(), { behaviors: true });
frame();
expect(world.log).toEqual(['start', 'enable', 'update']);
});
it('fires On disable when switched off and On enable – not On start – when switched back on', () => {
const f = lifecycle();
const { world, frame } = startWorld(f, { behaviors: true });
const logic = world.entities.find((e) => e.name === 'Logic')!;
frame();
logic.props.enabled = false;
frame();
frame();
expect(world.log).toEqual(['start', 'enable', 'update', 'disable']);
logic.props.enabled = true;
frame();
expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'enable', 'update']);
});
it('treats a parent being switched off the same way', () => {
const f = lifecycle();
const { world, frame } = startWorld(f, { behaviors: true });
const box = world.findByObjectId(f.box)!;
frame();
box.props.enabled = false;
frame();
expect(world.log).toEqual(['start', 'enable', 'update', 'disable']);
box.props.enabled = true;
frame();
expect(world.log.slice(4)).toEqual(['enable', 'update']);
});
it('fires On disable then On destroy when the owner is destroyed, in the same frame', () => {
const f = lifecycle();
// `On key down → Destroy(Owner)`: the behavior destroys the box it hangs below, itself included.
const edit = graphEditor(f, f.behavior);
const key = edit.addNode('OnKeyDown', { key: 'x' });
const owner = edit.addNode('Owner');
const destroy = edit.addNode('Destroy');
edit.wire([
[key, 'exec', destroy, 'exec'],
[owner, 'object', destroy, 'object'],
]);
const { world, input, frame, behaviors } = startWorld(f, { behaviors: true });
frame();
expect(behaviors!.runningIds()).toHaveLength(1);
input.press('x');
frame();
// Gone for the rest of the frame: no On update after the key event that destroyed it (#264).
expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'destroy']);
expect(behaviors!.runningIds()).toEqual([]);
expect(world.entities.some((e) => e.name === 'Logic')).toBe(false);
frame();
expect(world.log).toHaveLength(5);
});
it('skips On disable for a behavior destroyed while it was off', () => {
const f = lifecycle();
const { world, frame } = startWorld(f, { behaviors: true });
const logic = world.entities.find((e) => e.name === 'Logic')!;
logic.props.enabled = false;
frame();
world.remove(logic);
frame();
expect(world.log).toEqual(['start', 'enable', 'disable', 'destroy']);
});
it('runs On disable and On destroy for every instance when the world is torn down', () => {
const f = lifecycle();
place(f, f.behavior.id, { x: 0, y: 0 });
const { world, frame } = startWorld(f, { behaviors: true });
frame();
world.destroy();
// Event kind by event kind (#264): every start before the first enable, every disable before a destroy.
expect(world.log).toEqual([
'start',
'start',
'enable',
'enable',
'update',
'update',
'disable',
'disable',
'destroy',
'destroy',
]);
});
});