Files
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
2026-09-16 08:08:10 +02:00

301 lines
12 KiB
JavaScript
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.
import { edge, node } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* "Run in editor": a behavior that keeps running while the project is not playing, and whose
* work lands in the saved project.
*
* The step brings its own box and its own behavior instead of using the fixture's Mover, so that
* the drifting checks measure nothing but time. Everything it adds is removed again at the end.
*/
const DRIFT_PER_SECOND = 100;
const WALK_PER_SECOND = 200;
/** Must stay in step with `GameViewport.editorRunCaption`. */
const EDITOR_RUN_CAPTION = 'Behaviors run here · keys and clicks go to the game';
/** `On key held(ArrowRight) → Move(owner)`: moves only while that key reaches the world. */
const walkGraph = {
nodes: [
node('k-held', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }),
node('k-owner', 'Owner', 0, 120),
node('k-move', 'Move', 340, 0, { dx: 1, scale: WALK_PER_SECOND }),
],
edges: [
edge('k-e1', 'k-held', 'exec', 'k-move', 'exec'),
edge('k-e2', 'k-held', 'dt', 'k-move', 'dt'),
edge('k-e3', 'k-owner', 'object', 'k-move', 'object'),
],
};
/** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */
const driftGraph = {
nodes: [
node('d-update', 'OnUpdate', 0, 0),
node('d-owner', 'Owner', 0, 120),
node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
],
edges: [
edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'),
edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'),
edge('d-e3', 'd-owner', 'object', 'd-move', 'object'),
],
};
export async function editorRun(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
holdKey,
mutate,
undo,
object,
api,
projectId,
showTab,
} = t;
// 17a. A box with a drifting behavior below it. Written into the model directly: what is under
// test is the switch, not another way of building a graph.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'drifter',
name: 'Drifter',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(driftGraph)},
});
p.assets.push(asset);
${MAKE_OBJECT}
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
`);
const boxX = async () => (await object('er-box')).position.x;
const startX = await boxX();
await sleep(500);
const idle = await json('__gm.editorRun()');
check(
'a placed behavior does not run outside play mode until it is asked to',
idle.running === false && (await boxX()) === startX,
`${JSON.stringify(idle)} x=${startX}`,
);
// 17b. The switch itself, through the inspector. It belongs with Enabled and above the
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
await evaluate(
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
);
await sleep(200);
const enabledBox = await json(`__gm.inspectorField('Enabled')`);
const runBox = await json(`__gm.inspectorField('Run in editor')`);
const variables = await json(`__gm.locate('VARIABLES')`);
check(
'the inspector offers the switch on a behavior object, with Enabled and above the variables',
Boolean(runBox) &&
Boolean(enabledBox) &&
Boolean(variables) &&
runBox.y > enabledBox.y &&
runBox.y < variables.y,
JSON.stringify({ enabledBox, runBox, variables }),
);
await click(runBox.x, runBox.y);
const logic = await object('er-logic');
check(
'switching it on is an ordinary override on the object',
logic.overrides.runInEditor === true,
JSON.stringify(logic.overrides),
);
const running = await json('__gm.editorRun()');
const mode = await evaluate('__gm.state.mode');
check(
'the behavior runs although Play was never pressed',
running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit',
`${JSON.stringify(running)} mode=${mode}`,
);
// 17c. It really drives its owner, and it is the stored scene object that moves – no runtime
// copy is involved here, which is the whole difference to playing.
const before = await boxX();
await sleep(600);
const after = await boxX();
check(
'the owner drifts on, and the moved object is the stored one',
after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit',
`x ${before} → ${after}`,
);
await shot('36-editor-run');
// 17d. The point of this semantic: the change is a real edit, so it reaches the server.
const first = await api(`projects/${projectId}`);
await sleep(2000);
const second = await api(`projects/${projectId}`);
const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box')
.position.x;
check(
'what the behavior moved is saved, not just shown',
savedX > startX,
`stored x=${savedX} (started at ${startX})`,
);
// A behavior writing every frame must not turn into a save per frame. That it saves at all is
// the check above; this one is only about the ceiling – 60 frames a second would be ~120.
const saves = second.revision - first.revision;
check(
'saving stays debounced while it runs, instead of once per frame',
saves <= 6,
`${saves} saves in 2 s`,
);
// 17e. Taking the switch back stops it again.
await click(runBox.x, runBox.y);
const stoppedAt = await boxX();
await sleep(500);
const stopped = await json('__gm.editorRun()');
check(
'taking the switch back stops the run and the object stands still',
stopped.running === false && (await boxX()) === stoppedAt,
`${JSON.stringify(stopped)} x=${stoppedAt}`,
);
// 17f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
await undo();
await undo();
const afterUndo = await object('er-box');
check(
'undo takes back the switch, never the movement it caused',
(await object('er-logic')).overrides.runInEditor === undefined &&
afterUndo.position.x >= stoppedAt &&
afterUndo.position.x > startX,
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
);
// 17g. The case a real project has: the behavior sits inside a *placed container* – a level
// packed up once and dropped into the scene. The scene stores the container object alone,
// so this only works if the run resolves the template instead of expecting stored objects.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
p.assets.push({
id: 'er-level',
type: 'container',
name: 'Editor level',
props: { enabled: true },
objects: [make('t-box', 'crate', '', 800), make('t-logic', 'drifter', 't-box', 0)],
});
const instance = make('er-inst', 'er-level', '', 0);
instance.instanceEdits = { 't-logic': { position: {}, overrides: { runInEditor: true } } };
scene.objects.push(instance);
`);
await sleep(600);
const packedX = async () => (await object('er-inst~t-box')).position.x;
const packedStart = await packedX();
const packedRun = await json('__gm.editorRun()');
await sleep(600);
const packedNow = await packedX();
check(
'a behavior inside a placed container runs too, although the scene stores no object for it',
packedRun.behaviors.includes('er-inst~t-logic') && packedNow - packedStart > DRIFT_PER_SECOND * 0.3,
`${JSON.stringify(packedRun.behaviors)} x ${packedStart} → ${packedNow}`,
);
const stored = await json(`__gm.state.activeScene.objects.map((o) => o.id)`);
const edits = await json(`__gm.state.activeScene.objects.find((o) => o.id === 'er-inst').instanceEdits`);
const template = await json(
`__gm.state.project.assets.find((a) => a.id === 'er-level').objects.find((o) => o.id === 't-box').position`,
);
check(
'and what it moved is kept as an instance edit – the instance is not baked out, the template not touched',
stored.includes('er-inst') &&
!stored.some((id) => id.startsWith('er-inst~')) &&
edits['t-box'].position.x > packedStart &&
template.x === 0,
`stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`,
);
// 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
// arrow keys walking the hierarchy must not steer a game at the same time. The game tab is
// where no key means anything to the editor, so that is where a behavior may have them.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
p.assets.push(asset);
${MAKE_OBJECT}
scene.objects.push(
make('er-walkbox', 'crate', '', 1200),
Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
);
`);
await sleep(600);
const walkX = async () => (await object('er-walkbox')).position.x;
const onScene = await json('__gm.editorRun()');
const sceneBefore = await walkX();
await holdKey('ArrowRight', 500);
await sleep(200);
check(
'a key pressed while the scene is on screen belongs to the editor, not to the run',
onScene.running === true &&
onScene.keyboard === false &&
(await json('__gm.tab()')) === 'scene' &&
(await walkX()) === sceneBefore,
`${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`,
);
await showTab('game');
// Clicking the view first: a checkbox left focused by an earlier check would still want the keys.
const gameCentre = await json('__gm.gameCenter()');
await click(gameCentre.x, gameCentre.y);
const onGame = await json('__gm.editorRun()');
const gameBefore = await walkX();
await holdKey('ArrowRight', 500);
await sleep(200);
const gameAfter = await walkX();
check(
'in the game tab the same key reaches the behavior and the box walks',
onGame.keyboard === true && gameAfter - gameBefore > WALK_PER_SECOND * 0.3,
`${JSON.stringify(onGame)} x ${gameBefore} → ${gameAfter}`,
);
const caption = await json(`__gm.locate(${JSON.stringify(EDITOR_RUN_CAPTION)})`);
check(
'and the tab says so, instead of leaving the keyboard a guess',
caption !== null,
JSON.stringify(caption),
);
await shot('37-editor-run-keys');
await showTab('scene');
const backOnScene = await walkX();
await holdKey('ArrowRight', 400);
await sleep(200);
check(
'leaving the tab takes the keyboard back, and a key held across it does not stay held',
(await json('__gm.editorRun()')).keyboard === false && (await walkX()) === backOnScene,
`x=${backOnScene} → ${await walkX()}`,
);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-'));
p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id));
`);
check(
'the step leaves nothing of its own behind',
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 &&
(await json('__gm.editorRun()')).running === false,
);
}