An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s

Four ways erase a derived port and take no edge with them: deleting a state,
deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting
the asset. The common cause is `RemoveNodeCommand` clearing only its own graph,
so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site.

A right-click on an edge goes through `view.portAt` only — there is no curve
hit-test. Without a port row such an edge is not merely invisible, it is
unreachable, so the ghost row is the handle Disconnect hangs on.

`resolveDef` stays untouched: it is the truth for interpreter, compiler,
ConnectCommand and dropTarget, so nothing runs or connects through a ghost
without a second guard.
This commit is contained in:
root committed 2026-09-14 11:50:36 +02:00
1 parent d5e67cd80c
commit a041322a0e
8 files changed
+372 -16

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 837 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 844 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+2
View File
@@ -16,6 +16,8 @@ export interface PortDef {
field?: PropertyDef;
/** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */
inline?: boolean;
/** Not a port of the definition: a key only an edge still names, drawn so that edge can be seen. */
ghost?: true;
}
/**
+51 -1
View File
@@ -1,7 +1,7 @@
import { textOf } from '../model/Properties';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph';
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -109,6 +109,56 @@ function execFirst(ports: PortDef[]): PortDef[] {
return [...ports.filter((p) => p.type === 'exec'), ...ports.filter((p) => p.type !== 'exec')];
}
/** One end of an edge naming a key its node no longer has. */
interface GhostEnd {
edge: GraphEdge;
key: string;
isOutput: boolean;
}
/** Read from the edge list, not from the ports: what is looked for is exactly what has no port. */
function ghostEnds(node: GraphNode, graph: Graph, def: NodeDef): GhostEnd[] {
const inputs = new Set(def.inputs.map((p) => p.key));
const outputs = new Set(def.outputs.map((p) => p.key));
const ends: GhostEnd[] = [];
for (const edge of graph.edges) {
if (edge.to.node === node.id && !inputs.has(edge.to.port))
ends.push({ edge, key: edge.to.port, isOutput: false });
if (edge.from.node === node.id && !outputs.has(edge.from.port))
ends.push({ edge, key: edge.from.port, isOutput: true });
}
return ends;
}
/** `any` because the type the key once had is exactly what is no longer known. */
function ghostPort(key: string): PortDef {
return { key, label: key, type: 'any', inline: false, ghost: true };
}
/**
* The definition as the canvas draws it: the real ports, then the keys only edges still name. The
* ghosts go last and skip `execFirst`, so no real row moves because of one – and they stay out of
* `resolveDef`, which the interpreter, the compilers and `ConnectCommand` read as the truth.
*/
export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef): NodeDef {
const ends = ghostEnds(node, graph, def);
if (ends.length === 0) return def;
const side = (isOutput: boolean) =>
[...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort);
return {
...def,
inputs: [...def.inputs, ...side(false)],
outputs: [...def.outputs, ...side(true)],
};
}
/** Why a node warns although its own settings are sound: edges left hanging on keys that are gone. */
export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): string | null {
const count = ghostEnds(node, graph, def).length;
if (count === 0) return null;
return count === 1 ? '1 edge on a port that is gone' : `${count} edges on ports that are gone`;
}
/**
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
+44 -4
View File
@@ -18,6 +18,10 @@ const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
const BEAD_RADIUS = 4.5;
/** Dash pattern and sampling of an edge hanging on a port that is gone. */
const GHOST_DASH = 7;
const GHOST_GAP = 5;
const DASH_STEPS = 80;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
@@ -99,9 +103,13 @@ export class EdgeLayer extends Container {
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
const out = from.def.outputs.find((p) => p.key === edge.from.port);
const inp = to.def.inputs.find((p) => p.key === edge.to.port);
const type = out?.type ?? 'any';
const inType = inp?.type ?? 'any';
// One end on a port that is gone: the edge is drawn, but never as a working link.
const ghost = out?.ghost === true || inp?.ghost === true;
const valid = !ghost && portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
@@ -110,7 +118,8 @@ export class EdgeLayer extends Container {
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.curves, start.x, start.y, end.x, end.y);
if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
@@ -191,3 +200,34 @@ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: numb
const dx = curveReach(x1, x2);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
/**
* The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be
* stepped by length, so it is sampled and the walked distance decides which steps are drawn.
*/
export function dashedBezier(
g: Graphics,
x1: number,
y1: number,
x2: number,
y2: number,
dash: number,
gap: number,
): void {
const dx = curveReach(x1, x2);
const at = (t: number) => {
const u = 1 - t;
return {
x: u * u * u * x1 + 3 * u * u * t * (x1 + dx) + 3 * u * t * t * (x2 - dx) + t * t * t * x2,
y: u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2,
};
};
let prev = at(0);
let walked = 0;
for (let i = 1; i <= DASH_STEPS; i++) {
const point = at(i / DASH_STEPS);
if (walked % (dash + gap) < dash) g.moveTo(prev.x, prev.y).lineTo(point.x, point.y);
walked += Math.hypot(point.x - prev.x, point.y - prev.y);
prev = point;
}
}
+13 -6
View File
@@ -13,10 +13,12 @@ import {
callProblem,
canConnect,
defAcceptsFrom,
drawnDef,
dropTargetPort,
edgesInto,
edgesOutOf,
firstPortFor,
ghostProblem,
type Graph,
type GraphNode,
type GraphLookup,
@@ -229,8 +231,9 @@ export class GraphCanvas extends Widget {
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) {
const def = resolveDef(node, asset.graph, lookup);
if (!def) continue;
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
const def = drawnDef(node, asset.graph, resolved);
seen.add(node.id);
let view = this.views.get(node.id);
if (!view || view.node !== node) {
@@ -240,9 +243,9 @@ export class GraphCanvas extends Widget {
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
const problem = compilerProblems
? (compilerProblems[node.id] ?? null)
: callProblem(node, lookup, asset.id);
const problem =
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
ghostProblem(node, asset.graph, resolved);
view.sync(node, asset.graph, this.state.project, def, problem);
}
for (const [id, view] of this.views) {
@@ -358,6 +361,9 @@ export class GraphCanvas extends Widget {
return;
}
if (e.button !== 0) return;
// A ghost pin is a handle for the right-click menu only: nothing to drag from, and dragging the
// node instead would make a press on the very edge of it move the whole node.
if (hit?.port.ghost) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = {
@@ -595,7 +601,8 @@ export class GraphCanvas extends Widget {
for (const view of this.topDownViews()) {
const local = view.toLocal({ x: globalX, y: globalY });
const hit = view.portAt(local.x, local.y);
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
// A ghost is no target, and it must not shadow the node body underneath it either.
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
+12 -4
View File
@@ -82,6 +82,11 @@ export function portColor(type: PortType): number {
}
}
function rowStyle(port: PortDef): TextStyleToken {
if (port.ghost) return 'warning';
return port.type === 'exec' ? 'body' : 'muted';
}
export interface PortHit {
port: PortDef;
isOutput: boolean;
@@ -336,7 +341,7 @@ export class NodeView extends Widget {
if (input) {
const text = add(
this.inputText(input),
input.type === 'exec' ? 'body' : 'muted',
rowStyle(input),
PAD + PORT_RADIUS + 4,
rowTop,
'left',
@@ -347,13 +352,13 @@ export class NodeView extends Widget {
if (output) {
const text = add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
rowStyle(output),
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
);
if (output.type !== 'exec')
if (output.type !== 'exec' && !output.ghost)
this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 });
}
}
@@ -394,7 +399,10 @@ export class NodeView extends Widget {
this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node });
this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget });
}
if (port.type === 'exec') {
if (port.ghost) {
// Hollow: a pin is drawn where a port used to be, and nothing stands behind it any more.
this.ports.circle(x, y, r).stroke({ width: 2, color: c.danger });
} else if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports
.moveTo(x - r, y - r)
+189
View File
@@ -0,0 +1,189 @@
/**
* A derived port list can lose a key without anything touching the edges: a state deleted in a
* machine, the `machine` edge cut, a terminal node or a whole asset removed. Such an edge draws as
* nothing yet survives every save. `drawnDef` brings the key back as a ghost port so the canvas can
* draw the edge and offer its right-click menu; `ghostProblem` marks the node. `resolveDef` stays
* the truth – the interpreter, the compilers and ConnectCommand read it, and it never sees a ghost.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import {
AddNodeCommand,
ConnectCommand,
RemoveNodeCommand,
SetNodeParamCommand,
} from '../../src/core/commands/graphCommands';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import { CommandError } from '../../src/core/commands/Command';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
drawnDef,
ghostProblem,
MACHINE_PORT,
resolveDef,
RUN_BEHAVIOR,
STATE,
STATE_NAME_PARAM,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
const create = new CreateAssetCommand('statemachine', 'Doors');
fixture.stack.execute(create);
const ids = names.map((name) => {
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
fixture.stack.execute(add);
return add.node.id;
});
return { id: create.asset.id, ids };
}
function setup() {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
const edit = graphEditor(f, behavior);
const seen = (asset: BehaviorAsset, id: string) => {
const node = asset.graph.nodes.find((n) => n.id === id)!;
const real = resolveDef(node, asset.graph, graphLookup(f.project))!;
return {
real,
drawn: drawnDef(node, asset.graph, real),
problem: ghostProblem(node, asset.graph, real),
};
};
const ghosts = (asset: BehaviorAsset, id: string, side: 'inputs' | 'outputs') =>
seen(asset, id)
.drawn[side].filter((p) => p.ghost)
.map((p) => p.key);
return { f, behavior, edit, seen, ghosts };
}
describe('an edge on a port that is gone', () => {
it('is no ghost while every edge names a port that exists', () => {
const { f, behavior, edit, seen, ghosts } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
expect(ghosts(behavior, set.id, 'inputs')).toEqual([]);
expect(seen(behavior, set.id).problem).toBeNull();
});
it('comes back as a ghost when the state it names is deleted in the machine', () => {
const { f, behavior, edit, seen, ghosts } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
// Deleting the state reaches only the machine's own graph – the behavior's edge stays behind.
f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!));
expect(behavior.graph.edges).toHaveLength(1);
expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]);
expect(seen(behavior, set.id).problem).toBe('1 edge on a port that is gone');
f.stack.undo();
expect(ghosts(behavior, set.id, 'inputs')).toEqual([]);
});
it('appears once per state when the State machine literal feeding the input is deleted', () => {
const { f, behavior, edit, seen, ghosts } = setup();
const machine = makeMachine(f);
const literal = edit.addNode('StateMachine', { value: machine.id });
const branch = edit.addNode('StateBranch');
edit.connect(literal, 'value', branch, MACHINE_PORT);
const log = edit.addNode('Log', { message: 'x' });
const other = edit.addNode('Log', { message: 'y' });
edit.connect(branch, machine.ids[0]!, log, 'exec');
edit.connect(branch, machine.ids[1]!, other, 'exec');
// The literal takes its own edge with it; the two exec edges out of the branch it cannot see.
f.stack.execute(new RemoveNodeCommand(behavior.id, literal.id));
expect(ghosts(behavior, branch.id, 'outputs')).toEqual(machine.ids);
expect(seen(behavior, branch.id).problem).toBe('2 edges on ports that are gone');
});
it('appears on every caller when a behavior loses its start node', () => {
const f = makeProject();
const callee = makeBehavior(f, 'Double');
const calleeEdit = graphEditor(f, callee);
const start = calleeEdit.addNode(BEHAVIOR_START);
const end = calleeEdit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' }));
f.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
const caller = makeBehavior(f, 'Caller');
const edit = graphEditor(f, caller);
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const number = edit.addNode('Number', { value: 3 });
edit.connect(number, 'value', call, 'in1');
const node = () => caller.graph.nodes.find((n) => n.id === call.id)!;
const real = () => resolveDef(node(), caller.graph, graphLookup(f.project))!;
expect(drawnDef(node(), caller.graph, real()).inputs.filter((p) => p.ghost)).toHaveLength(0);
f.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(
drawnDef(node(), caller.graph, real())
.inputs.filter((p) => p.ghost)
.map((p) => p.key),
).toEqual(['in1']);
});
it('appears when the machine asset itself is deleted', () => {
const { f, behavior, edit, ghosts } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
f.stack.execute(new DeleteAssetCommand(machine.id));
expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]);
});
it('goes last, so no real port row moves because of it', () => {
const { f, behavior, edit, seen } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!));
const { real, drawn } = seen(behavior, set.id);
const keys = drawn.inputs.map((p) => p.key);
expect(keys.slice(0, real.inputs.length)).toEqual(real.inputs.map((p) => p.key));
expect(keys.at(-1)).toBe(machine.ids[0]!);
});
it('is a handle only: resolveDef has no ghost, so nothing can be connected to one', () => {
const { f, behavior, edit, seen } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!));
const { real } = seen(behavior, set.id);
expect(real.inputs.some((p) => p.key === machine.ids[0]!)).toBe(false);
const second = edit.addNode('OnUpdate');
expect(() =>
f.stack.execute(
new ConnectCommand(
behavior.id,
{ node: second.id, port: 'exec' },
{ node: set.id, port: machine.ids[0]! },
),
),
).toThrow(CommandError);
});
it('survives a param change that orphans the edge, until that command takes it', () => {
const { f, behavior, edit, seen, ghosts } = setup();
const machine = makeMachine(f);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
// SetNodeParamCommand is the one path that already cleans up: no edge left, so no ghost.
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
expect(behavior.graph.edges).toHaveLength(0);
expect(ghosts(behavior, set.id, 'inputs')).toEqual([]);
f.stack.undo();
expect(seen(behavior, set.id).problem).toBeNull();
});
});
+60
View File
@@ -22,6 +22,7 @@ export async function stateMachine(t) {
has,
doubleClick,
drag,
mouse,
} = t;
const { typeText, undo, redo, world, play, stop } = t;
const machines = () =>
@@ -273,6 +274,65 @@ export async function stateMachine(t) {
);
await shot('03-machine-picked');
// 26i. A deleted state leaves its edge hanging; the port comes back as a ghost so it can be seen.
const named = await states();
const nameOf = (id) => named.find((s) => s.id === id)?.name;
check(
'before the deletion the edge to the second state is drawn',
(await json("__gm.graph.edgeDrawn('sm-e4')")) === true,
);
await mutate(`
const m = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)});
`);
await sleep(400);
const ghosted = await outputsOf('sm-branch');
check(
'the deleted state leaves a row named after its key, and takes its name away',
names(ghosted).includes(second) && !names(ghosted).includes(nameOf(second)),
JSON.stringify({ ghosted, was: nameOf(second) }),
);
check(
'the edge on it is drawn again instead of vanishing',
(await json("__gm.graph.edgeDrawn('sm-e4')")) === true,
);
check(
'the node warns about it',
(await json("__gm.graph.nodeProblem('sm-branch')")) === '1 edge on a port that is gone',
JSON.stringify(await json("__gm.graph.nodeProblem('sm-branch')")),
);
check(
'the ghost pin can be reached, so the edge has a right-click handle',
(await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`)) !== null,
);
const pin = await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`);
const before = await json("__gm.graph.nodeRect('sm-branch')");
const links = () => json('__gm.state.project.assets.find((a) => a.id === "sm-logic").graph.edges.length');
const linksBefore = await links();
// Measured mid-drag: after the release nothing would show either way, because a ghost has no type
// and `openPaletteForDrop` gives up on that. The rubber band is the only visible difference.
const empty = await json('__gm.graph.emptySpot()');
await mouse('mouseMoved', pin.x, pin.y);
await mouse('mousePressed', pin.x, pin.y, 'left', {});
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
await sleep(150);
const dangling = await json('__gm.graph.danglingEdge()');
await mouse('mouseReleased', empty.x, empty.y, 'left', {});
await sleep(250);
const after = await json("__gm.graph.nodeRect('sm-branch')");
check(
'pressing a ghost pin starts no rubber band, adds no link, and leaves the node where it was',
dangling === false && (await links()) === linksBefore && after.x === before.x && after.y === before.y,
JSON.stringify({ dangling, linksBefore, links: await links(), moved: after.x - before.x }),
);
check(
'the state that is left keeps its edge and its name',
(await json("__gm.graph.edgeDrawn('sm-e3')")) === true &&
names(await outputsOf('sm-branch')).includes(nameOf(first)),
JSON.stringify({ lines: await outputsOf('sm-branch'), kept: nameOf(first) }),
);
await shot('04-ghost-port');
// 26h. Everything the step made goes away again.
const left = await rig.remove();
await mutate(`