Make a call node say why it does nothing, and easier to place

A behavior node that cannot run looked exactly like a healthy one: no
start node, no behavior picked, a deleted behavior or a self-call were
all silently skipped. The call still had to be forgiving at runtime -- a
dangling reference must not abort a game -- so the fix is diagnosis, not
an exception: describeProblem() names the one reason, the node draws it
in red under its title, and the inspector repeats it where you pick the
behavior. A missing end node is deliberately not a problem: a call is
always synchronous, and the end node only decides which values come back.

Two ways in: a behavior dragged from the asset panel lands in the graph
under the pointer, and double-clicking a call node opens the graph it
runs. Both reuse machinery that already existed -- the asset panel
already starts the drag, and list views already report double clicks.

The e2e module needed its assets at the head of the asset list: after 18
modules the list is long enough that a newly appended row scrolls out of
view, and the press then lands on the panel behind it, starting no drag
at all. That looked exactly like a rejected drop and cost two full runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 20:40:56 +02:00
1 parent 1d8e6ebea0
commit c6f907b4e0
12 files changed
+427 -24

No files matched your search

+87 -3
View File
@@ -74,13 +74,16 @@ export async function nestedBehavior(t) {
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
// At the front of the list, not the end: these get dragged out of the asset panel below, and
// by this point in the suite the list is long enough to scroll a row at the end out of reach.
const behavior = (id, name, graph) => {
const a = structuredClone(mover);
Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
p.assets.push(a);
p.assets.unshift(a);
};
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
@@ -204,12 +207,93 @@ export async function nestedBehavior(t) {
await undo();
await sleep(200);
// 19g. Clean up: the two behaviors and the box are gone again.
// 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the
// pointer let go of it.
const newNodeIn = async (before) =>
(await asset('nb-caller')).graph.nodes.find((n) => !before.includes(n.id));
const beforeDrag = await nodeIds();
// Points taken from the drawing area itself: an offset from the centre can fall outside it,
// and a drop that never arrives looks exactly like one that was refused.
const rect = await json('__gm.graph.canvasRect()');
const spot = (fx, fy) => ({
x: Math.round(rect.x + rect.w * fx),
y: Math.round(rect.y + rect.h * fy),
});
const dropAt = spot(0.25, 0.75);
await t.dragAsset('nb-double', dropAt.x, dropAt.y);
await sleep(300);
const dropped = await newNodeIn(beforeDrag);
check(
'a behavior dragged from the asset panel becomes a call node',
dropped?.type === 'RunBehavior' && dropped?.params.behavior === 'nb-double',
JSON.stringify(dropped && { type: dropped.type, params: dropped.params }),
);
const droppedAt = dropped && (await json(`__gm.graph.nodeAt(${JSON.stringify(dropped.id)})`));
check(
'and it lands under the pointer, not at some default spot',
Boolean(droppedAt) && Math.hypot(droppedAt.x - dropAt.x, droppedAt.y - dropAt.y) < 20,
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
);
// 19h. The question from the note: a behavior with no start node can still be added – and now it
// says why nothing happens instead of sitting there looking healthy.
const beforeEmpty = await nodeIds();
const emptyRow = await json(`__gm.assetRow('nb-empty')`);
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
await sleep(300);
const emptyCall = await newNodeIn(beforeEmpty);
check(
'a behavior without a start node can be added just the same',
emptyCall?.params.behavior === 'nb-empty',
`${JSON.stringify(emptyCall && { type: emptyCall.type, params: emptyCall.params })} row=${JSON.stringify(emptyRow)}`,
);
const problem = await evaluate(`__gm.graph.nodeProblem(${JSON.stringify(emptyCall?.id ?? '')})`);
check(
'and the node says why it does nothing, instead of failing silently',
problem === 'No start node – does nothing',
`problem=${JSON.stringify(problem)}`,
);
check(
'a healthy call carries no such warning',
(await evaluate(`__gm.graph.nodeProblem('nb-call')`)) === null,
);
await shot('39-call-problem');
// 19i. Dropping a behavior into its own graph is refused – and says so, rather than quietly
// adding a node that would throw the moment it ran.
const beforeSelf = await nodeIds();
const selfRow = await json(`__gm.assetRow('nb-caller')`);
await t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y);
await sleep(300);
const afterSelf = await nodeIds();
const { notice } = await t.state();
// `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone.
const dragging = await json('__gm.dragPayload()');
check(
'dropping a behavior into its own graph is refused, out loud',
afterSelf.length === beforeSelf.length && notice === 'A behavior cannot run itself',
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
);
// 19j. Double-clicking a call opens the behavior it runs – the other half of the note.
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
await t.doubleClick(callNode.x, callNode.y);
await sleep(400);
const opened = await evaluate('__gm.graph.current()');
check(
'double-clicking a call node opens the behavior it runs',
opened === 'nb-double',
`current=${JSON.stringify(opened)}`,
);
// 19k. Clean up: the three behaviors and the box are gone again.
await evaluate(`__gm.state.select(null)`);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets = p.assets.filter((a) => a.id !== 'nb-double' && a.id !== 'nb-caller');
const mine = ['nb-double', 'nb-caller', 'nb-empty'];
p.assets = p.assets.filter((a) => !mine.includes(a.id));
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
`);
await sleep(300);