Files
feuerwerk/tests/core/behavior-events.test.ts
T
adminandClaude Opus 5.5 6fecb2e841
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Failing after 9m22s
Tell On timer how late its frame is, so time minus late is exact (#224)
A timer can only fire on a frame. It fires on the first frame past each
due time, so Time.time at that moment is the due time plus up to one
frame. On a display that is not exactly 60 Hz this delay creeps up by
a millisecond every few seconds and falls back to zero once it reaches
a whole frame. The issue saw 82.009 after 82 ticks. The timer itself
does not drift, it keeps the remainder when it fires.

- On timer gets a number output late. It is what is left of the
  timer's clock after taking one interval, so time - late is exactly
  the due time.
- When one long frame catches up several ticks, each gets its own
  late. The first one was due longest ago and is the latest.

Tests:
- New unit test in behavior-events: late 0.125 after a frame past
  1.0 s, 0 on the dot, 1.5 and 0.5 for two ticks caught up in one
  frame.
- Counter-check: late taken before the interval is subtracted fails
  exactly that test. Original restored with cmp.
- ./check.sh: 156 files, 2300 tests, CHECK EXIT 0 (was 2299).
- No e2e change. The e2e modules only wire the exec of On timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:44:53 +02:00

461 lines
15 KiB
TypeScript
Raw 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);
});
});
// 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();
// The frame that destroys it still runs to its end – the farewell comes after this frame's update.
expect(world.log).toEqual(['start', 'enable', 'update', 'update', 'disable', 'destroy']);
expect(behaviors!.runningIds()).toEqual([]);
expect(world.entities.some((e) => e.name === 'Logic')).toBe(false);
frame();
expect(world.log).toHaveLength(6);
});
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();
expect(world.log).toEqual([
'start',
'enable',
'start',
'enable',
'update',
'update',
'disable',
'destroy',
'disable',
'destroy',
]);
});
});