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:
1 parent
1d8e6ebea0
commit
c6f907b4e0
12 files changed
+427
-24
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user