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

+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.