Split a struct output into one pin per field (#256)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Successful in 10m45s

Reading one field of a struct needed its own Expand node, even where
the graph already knows the struct type. A split output shows the
fields directly on the node, so a Find … Match test becomes a single
Equals and the wires keep running left to right.

- Right-clicking an output pin offers "Split into <Type> fields" when
  the type is known and "Split as …" for every other struct asset.
  The output is replaced by one pin per field with the field's kind.
- The type is known for typed struct variables, typed Pack/Expand,
  fields of another split and item of For each, Find, Match and Item
  at over a typed list (portSplits.ts).
- Right-clicking a field pin offers "Recombine". Splitting drops the
  edges of the whole output, recombining drops the field edges. One
  undo step brings them back.
- The split is stored as GraphNode.splits and saved with the graph.
  Copy and paste carry it along.
- Field pins follow the struct asset like a typed Expand. A new field
  gets a pin, a removed field loses pin and edge, a renamed field keeps
  its edge. Deleting the asset keeps the last fields. The delete dialog
  counts splits, so it now says "node(s)" instead of "Pack/Expand
  node(s)".
- Renaming a variable carries its split and the field edges along.
- At run time the node still produces the whole value. A field pin
  reads its field like Expand, a missing field reads as empty.
- Limits: outputs only, one level, behavior graphs only.

Tests:
- ./check.sh: 185 files, 2706 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks
- New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks
  using a real mouse: split Find.item, wire item.Name into the test
  and Match.item.Amount into the log (logs 7), recombine.
- Counter-checks: without field reads in the interpreter, without
  rename in the split, without moving edges on rename and without
  cleanup on field removal one unit test fails each. In the browser,
  without field reads nothing is logged and without type inference
  the split entry is missing.
- Check total in README: 1520.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 11:33:38 +02:00
1 parent 2aaca01929
commit 38e0f656be
17 files changed
+705 -22

No files matched your search

+127
View File
@@ -1103,6 +1103,7 @@ export async function graph(t) {
await lerpNode();
await forEachNode();
await findMatchNodes();
await splitOutputs();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1370,4 +1371,130 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder');
`);
}
// 6z-vi. Split outputs: the item of a Find over a typed list is split with a right-click on its
// pin, straight into the fields of the list's type. A field port is dragged into the test,
// the Match's item is split as well, and a run logs the amount of the loot found. A right-
// click on a field port puts the output together again.
async function splitOutputs() {
const loots = [
{ Name: 'Axe', Amount: 1 },
{ Name: 'Gold', Amount: 7 },
];
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'sp-loot',
type: 'struct',
name: 'Loot 6',
props: { Name: '', Amount: 0 },
variables: [
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
{ key: 'Amount', label: 'Amount', kind: 'number', default: 0 },
],
});
p.assets.push({
id: 'splity',
type: 'behavior',
name: 'Splity',
props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} },
variables: [
{ key: 'loots', label: 'loots', kind: 'list', itemType: 'sp-loot', default: ${JSON.stringify(loots)} },
],
graph: { nodes: [], edges: [] },
});
const behavior = p.assets.find((a) => a.id === 'splity');
const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('sp-start', 'OnStart', -240, -60),
node('sp-loots', 'Variables', -240, 80, {}, [{ key: 'loots', kind: 'list' }]),
node('sp-find', 'Find', 0, 0),
node('sp-equals', 'Equals', 240, 120, { b: 'Gold' }),
node('sp-match', 'Match', 480, 0),
node('sp-log', 'Log', 760, 0),
);
behavior.graph.edges.push(
edge('sp-e1', 'sp-start', 'exec', 'sp-find', 'exec'),
edge('sp-e2', 'sp-loots', 'loots', 'sp-find', 'list'),
edge('sp-e3', 'sp-equals', 'result', 'sp-match', 'match'),
edge('sp-e4', 'sp-match', 'found', 'sp-log', 'exec'),
);
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'sp-holder', assetId: 'splity', parentId: '', name: 'Split holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
__gm.state.select({ kind: 'asset', assetId: 'splity' });
`);
await showTab('graph');
await clickText('Fit');
const outputsOf = async (id) =>
Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {});
const splitPin = async (id, port, entry) => {
const pin = await json(`__gm.graph.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, true)`);
if (!pin) return false;
await click(pin.x, pin.y, 'right');
await sleep(150);
await clickText(entry);
await sleep(150);
return true;
};
const opened = await splitPin('sp-find', 'item', 'Split into Loot 6 fields');
const types = await json(`__gm.graph.portTypes('sp-find')`);
check(
'right-click on the item of a Find over a typed list splits it into the fields of that type',
opened &&
JSON.stringify(Object.keys(types?.outputs ?? {})) ===
JSON.stringify(['item.Name', 'item.Amount', 'index']) &&
types.outputs['item.Name'] === 'string' &&
types.outputs['item.Amount'] === 'number',
JSON.stringify({ opened, types }),
);
await clickText('Fit');
const from = await json(`__gm.graph.portAt('sp-find', 'item.Name', true)`);
const to = await json(`__gm.graph.portAt('sp-equals', 'a', false)`);
if (from && to) await drag(from.x, from.y, to.x, to.y, 16);
await splitPin('sp-match', 'item', 'Split into Loot 6 fields');
await clickText('Fit');
const amount = await json(`__gm.graph.portAt('sp-match', 'item.Amount', true)`);
const message = await json(`__gm.graph.portAt('sp-log', 'message', false)`);
if (amount && message) await drag(amount.x, amount.y, message.x, message.y, 16);
const wired = (await asset('splity')).graph.edges;
await shot('25-split-outputs');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Split holder]'))
.map((line) => line.split(']').pop().trim());
check(
'field ports dragged into the test and the log: a run logs the Amount of the loot named "Gold"',
wired.some((e) => e.from.port === 'item.Name' && e.to.node === 'sp-equals') &&
wired.some((e) => e.from.port === 'item.Amount' && e.to.node === 'sp-log') &&
JSON.stringify(logged) === JSON.stringify(['7']),
JSON.stringify({ from, to, amount, message, logged }),
);
// Play shows the Game tab; the pin is only there to be clicked with the graph in front again.
await showTab('graph');
await clickText('Fit');
await splitPin('sp-find', 'item.Name', 'Recombine item');
const back = await outputsOf('sp-find');
const left = (await asset('splity')).graph.edges;
check(
'right-click on a field port recombines the output, the edge on the field goes with it',
JSON.stringify(back) === JSON.stringify(['item', 'index']) &&
!left.some((e) => e.from.node === 'sp-find' && e.from.port.startsWith('item.')),
JSON.stringify({ back, left }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'splity' && a.id !== 'sp-loot');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder');
`);
}
}