Edit the graph during play and watch values and flow run through it

The graph was only locked by five edit-mode guards in GraphCanvas. The
running game reads the graph by reference each frame, so a new node, edge,
changed value, disconnect or undo takes effect from the next frame. Stop
discards these edits like everything else changed in play. A new On start
node does not fire for a behavior that has already started.

During play every data output shows the value it last produced, in the
highlight colour and refreshed ten times a second. Values are kept per
graph, because node ids are only unique within one; with several
instances the most recent value wins. The Variables node reads the live
variable bag, so only its own outputs are kept, as a snapshot.

Each time an exec edge fires, a bead runs along it in 0.3 s, at most one
every 0.15 s per edge, following the same curve the edge is drawn with.
Beads run on game time and freeze while paused; the game logic is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 21:19:07 +02:00
1 parent 495756b500
commit 656d61f187
17 files changed
+542 -37

No files matched your search

+101 -3
View File
@@ -31,9 +31,24 @@ const pauseGraph = {
};
export async function pause(t) {
const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t;
const {
check,
sleep,
json,
has,
shot,
click,
clickText,
press,
entity,
world,
stop,
select,
evaluate,
asset,
} = t;
const playInfo = () => json('__gm.playInfo()');
const boxX = async () => (await entity('p-box')).x;
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
// 19a. A box with a drifting behavior below it, written into the model directly: what is under
@@ -143,10 +158,93 @@ export async function pause(t) {
(await boxX()) > restartedX,
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
);
// 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
// selects the Move node, Delete removes it, and the running world stops drifting the box.
await select({ kind: 'asset', assetId: 'pauser' });
await evaluate(`__gm.showTab('graph')`);
await sleep(150);
const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`);
const live = {
owner: await outputs('p-owner'),
update: await outputs('p-update'),
move: await outputs('p-move'),
};
check(
'while playing, each output shows the value it last carried in front of its name',
live.owner[0] === 'Pause box object' &&
/^0\.\d{3} {2}dt$/.test(live.update[1]) &&
live.move[1] === 'false blocked x',
JSON.stringify(live),
);
await shot('36b-graph-live-values');
const beadsOn = async (edgeId) =>
(await json('__gm.graph.beads()')).filter((b) => b.edgeId === edgeId).map((b) => b.progress);
const chain = await beadsOn('p-e1');
const gaps = chain.slice(1).map((p, i) => chain[i] - p);
check(
'an exec edge firing every frame carries an evenly spaced chain of beads',
chain.length >= 1 && chain.length <= 3 && gaps.every((g) => Math.abs(g - 0.5) < 0.1),
JSON.stringify(chain),
);
check(
'data edges carry no beads',
(await beadsOn('p-e2')).length === 0 && (await beadsOn('p-e3')).length === 0,
);
const beforeKey = await beadsOn('p-e4');
await press('ArrowRight');
await sleep(80);
const onKey = await beadsOn('p-e4');
await sleep(500);
const afterKey = await beadsOn('p-e4');
check(
'a single key press sends exactly one bead, and it arrives',
beforeKey.length === 0 && onKey.length === 1 && afterKey.length === 0,
JSON.stringify({ beforeKey, onKey, afterKey }),
);
const movePos = await json(`__gm.graph.nodeAt('p-move')`);
await click(movePos.x, movePos.y);
await sleep(100);
check(
'a click selects a node while playing',
JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]',
JSON.stringify(await json('__gm.graph.selected()')),
);
const area = await json('__gm.graph.canvasRect()');
await click(area.x + area.w - 15, area.y + area.h - 15, 'right');
await sleep(100);
check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…'));
await press('Escape');
await sleep(100);
check(
'Escape closes the palette and leaves the game running',
!(await has('Search nodes…')) && (await playInfo()).playing === true,
);
await click(movePos.x, movePos.y);
await press('Delete');
await sleep(150);
const stoppedAt = await boxX();
await sleep(500);
check(
'deleting the Move node while playing stops the drift on the next frames',
!(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move') &&
Math.abs((await boxX()) - stoppedAt) < 0.001,
`x ${stoppedAt} → ${await boxX()}`,
);
await stop();
await sleep(200);
check(
'stopping takes the values off the nodes',
JSON.stringify(await outputs('p-owner')) === '["object"]',
JSON.stringify(await outputs('p-owner')),
);
check(
'stopping discards the graph edit with the rest of the run',
(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move'),
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
);
// 19f. Play discarded its copy, so the box is back at the position the model holds.
// 19g. Play discarded its copy, so the box is back at the position the model holds.
const left = await rig.remove();
check(
'the step leaves nothing of its own behind',