Files
feuerwerk/tests/core/event-order.test.ts
T
adminandClaude Opus 5.5 5c0d52da66
feuerwerk / gate (push) Successful in 8m24s
feuerwerk / suite (push) Successful in 20m19s
Let a behavior judge a key press against the beat of the music (#438)
A rhythm game has to know how far a press lay from the beat. Input only
kept that a key went down during a frame, so a press was known to a
frame (16.7 ms) at best. Hit windows are ±20 to 50 ms.

- Input and the game pointer keep the time stamp of each real press.
  Auto repeat of a held key does not count.
- The new event node On key hit hears a key or a click. It gives the
  offset to the nearest point of the grid (beat, 1/8 or 1/16), with
  bar, beat and the heard second. A calibration input moves the press.
- The SoundLibrary maps the press onto the heard song position through
  getOutputTimestamp, minus the limiter. Safari lacks it, there the
  audio clock minus the latency is wound back to the press.
- Playbacks.times takes the heard second as a parameter, so the song
  position can be asked for a moment in the past.
- A pattern with swing puts its grid points where the swing moves the
  notes. The end of a pattern without loop is no grid point.
- A press older than 0.25 s when its frame comes is dropped, which
  covers presses during the toolbar pause. Exactly half way between two
  points counts as late for the first one.
- Without music playing no event comes, as decided in the issue.
- The 400 step test in tilemap-patch.test.ts gets a 30 s limit. It took
  7.45 s in gate 761 while the suite shared the cores of stein2.

Tests: 23 new unit tests (key-hit 19, sound-library 1, audio-switch 1,
catalog and event count). 23 mutations, all red. e2e 65 ALL PASSED,
79 checks. In the new 65k7 three presses of H in one frame lie 4.4 and
4.1 ms apart, and so do their offsets. Without the time stamps all
three get the same offset and only 65k7 goes red. Gate 5251 of 5252
green, the one red count in event-order.test.ts is fixed and checked
on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:54:55 +01:00

222 lines
7.8 KiB
TypeScript

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