- `Texture` and `Color` can be picked as a variable kind, a struct field kind and a declared port kind; a new `Texture` node hands a picture id into the graph. Declared, a texture is its bare id – the `$texture` tag stays a list-only thing. - The `String` node is labelled "Text" like its kind; the type id stays. - Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`, `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts). - All server projects are reseeded, so `legacyNodes.ts`, `migrateDeclaredLists` and the `ownerId` fallback go, with their tests. - Note `reseed-because-of-list` moved to done/; new note `refactor-types`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* The Variables node: its ports are the behavior's variables, each read at any time and written
|
||
* where it is switched to Set. The step checks the run, picking a port from the variable list,
|
||
* switching Set, and pointing an existing port at another variable without losing its edge.
|
||
*
|
||
* The step brings its own box and behavior; everything it adds is removed at the end.
|
||
*/
|
||
|
||
const SCALE = 100;
|
||
|
||
/**
|
||
* `On update → Variables{set vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two
|
||
* numbers reach Move through the *variables* – one node writes both, one node reads both back.
|
||
* Neither speed is on the Move node, so a node that dropped a port would leave the box still.
|
||
*/
|
||
const logicGraph = {
|
||
nodes: [
|
||
node('vp-update', 'OnUpdate', 0, 0),
|
||
node('vp-owner', 'Owner', 0, 300),
|
||
node('vp-set', 'Variables', 260, 0, { vx: 2, vy: 3 }, [written('vx', 'number'), written('vy', 'number')]),
|
||
node('vp-get', 'Variables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]),
|
||
node('vp-move', 'Move', 560, 0, { scale: SCALE }),
|
||
],
|
||
edges: [
|
||
edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'),
|
||
edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'),
|
||
edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'),
|
||
edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'),
|
||
edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'),
|
||
edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'),
|
||
],
|
||
};
|
||
|
||
export async function variableNodes(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
locate,
|
||
undo,
|
||
entity,
|
||
asset,
|
||
mutate,
|
||
stop,
|
||
typeText,
|
||
press,
|
||
has,
|
||
} = t;
|
||
const portAt = (nodeId, key, isOutput) =>
|
||
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
|
||
const portsOf = async (nodeId) =>
|
||
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
|
||
const select = async (nodeId) => {
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
|
||
);
|
||
await sleep(300);
|
||
};
|
||
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
await sleep(200);
|
||
};
|
||
/** The Set checkbox of the newest variable row: the control right of the last "Set" label. */
|
||
const clickLastSet = async () => {
|
||
const box = await json(`__gm.inspectorField('Set', true)`);
|
||
await click(box.x, box.y);
|
||
await sleep(300);
|
||
return box;
|
||
};
|
||
|
||
// 23a. A box wearing a behavior whose two speeds travel through its variables.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const mover = p.assets.find((a) => a.id === 'mover');
|
||
const a = structuredClone(mover);
|
||
Object.assign(a, {
|
||
id: 'vp-logic',
|
||
name: 'Speeds',
|
||
props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 },
|
||
variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])},
|
||
graph: ${JSON.stringify(logicGraph)},
|
||
});
|
||
p.assets.unshift(a);
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
|
||
`);
|
||
await sleep(400);
|
||
|
||
// 23b. The run: both variables are written by one node and read back by another, and the two
|
||
// speeds stay apart – 2 to the right for every 3 down.
|
||
await t.play();
|
||
await sleep(300);
|
||
const before = await entity('vp-box');
|
||
await sleep(1000);
|
||
const after = await entity('vp-box');
|
||
const dx = after.x - before.x;
|
||
const dy = after.y - before.y;
|
||
check(
|
||
'one node writes both variables, another reads both back, and the run uses them',
|
||
dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6,
|
||
`moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
|
||
);
|
||
await stop();
|
||
await sleep(300);
|
||
|
||
// 23c. Every variable is an output; a written one is an input too, and only a writing node has
|
||
// flow pins.
|
||
await select('vp-set');
|
||
const pins = {
|
||
setIn: Boolean(await portAt('vp-set', 'vx', false)),
|
||
setOut: Boolean(await portAt('vp-set', 'vx', true)),
|
||
getOut: Boolean(await portAt('vp-get', 'vx', true)),
|
||
getIn: Boolean(await portAt('vp-get', 'vx', false)),
|
||
getFlow: Boolean(await portAt('vp-get', 'exec', false)),
|
||
};
|
||
check(
|
||
'a written variable is an input and an output, a read one only an output, without flow pins',
|
||
pins.setIn && pins.setOut && pins.getOut && !pins.getIn && !pins.getFlow,
|
||
JSON.stringify(pins),
|
||
);
|
||
|
||
// 23d. The inspector picks a port from the behavior's variables instead of having it typed, and
|
||
// a picked one is read until it is switched to Set.
|
||
const add = await locate('+ Variable');
|
||
check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
|
||
await shot('01-variable-ports');
|
||
await click(add.x, add.y);
|
||
await sleep(200);
|
||
await clickLast('vz');
|
||
await sleep(300);
|
||
const grown = await portsOf('vp-set');
|
||
check(
|
||
'picking one adds it as a read port, typed from the variable',
|
||
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number' && !grown[2].write,
|
||
JSON.stringify(grown),
|
||
);
|
||
check(
|
||
'and the node grew that output only',
|
||
Boolean(await portAt('vp-set', 'vz', true)) && !(await portAt('vp-set', 'vz', false)),
|
||
);
|
||
await clickLastSet();
|
||
check(
|
||
'switching it to Set gives it an input as well',
|
||
(await portsOf('vp-set'))[2]?.write === true && Boolean(await portAt('vp-set', 'vz', false)),
|
||
JSON.stringify(await portsOf('vp-set')),
|
||
);
|
||
const drawn = await json(`__gm.graph.nodeInputs('vp-set')`);
|
||
check(
|
||
'with nothing typed the node shows the value it writes beside the name, as the inspector does',
|
||
drawn?.includes('vz 0') && drawn.includes('vx 2'),
|
||
JSON.stringify(drawn),
|
||
);
|
||
await shot('02-variable-set');
|
||
await undo();
|
||
await sleep(300);
|
||
await undo();
|
||
await sleep(300);
|
||
check('undo takes both back', (await portsOf('vp-set')).length === 2);
|
||
|
||
// 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a
|
||
// rewiring. `vp-e3` runs from this port to Move's dx.
|
||
await select('vp-get');
|
||
await clickLast('vx');
|
||
await sleep(200);
|
||
await clickLast('vz');
|
||
await sleep(300);
|
||
const swapped = await portsOf('vp-get');
|
||
const edgeStill = await json(
|
||
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`,
|
||
);
|
||
check(
|
||
'a port pointed at another variable keeps its edge',
|
||
swapped[0].key === 'vz' && edgeStill.port === 'vz',
|
||
JSON.stringify({ swapped, edgeStill }),
|
||
);
|
||
await undo();
|
||
await sleep(300);
|
||
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
|
||
|
||
// 23f. Collapsed, the ports are out of sight, so the title says what the node does – and only then.
|
||
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
|
||
const toggle = async (id) => {
|
||
const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`);
|
||
await click(chevron.x, chevron.y);
|
||
await sleep(250);
|
||
};
|
||
/** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */
|
||
const drawnWhole = async (text) => {
|
||
const canvas = await json('__gm.graph.canvasRect()');
|
||
const hits = await json(`__gm.locateAll(${JSON.stringify(text)})`);
|
||
return hits.some((p) => p.x > canvas.x && p.x < canvas.x + canvas.w);
|
||
};
|
||
await toggle('vp-set');
|
||
await toggle('vp-get');
|
||
const shut = { set: await titleOf('vp-set'), get: await titleOf('vp-get') };
|
||
check(
|
||
'collapsed, a node writing two variables says so, and one only reading two says that',
|
||
shut.set === 'Set 2 variables' &&
|
||
shut.get === 'Get 2 variables' &&
|
||
(await drawnWhole('Set 2 variables')) &&
|
||
(await drawnWhole('Get 2 variables')),
|
||
JSON.stringify(shut),
|
||
);
|
||
await shot('02b-collapsed-titles');
|
||
const narrow = (await json(`__gm.graph.nodeRect('vp-get')`)).w;
|
||
await clickLastSet();
|
||
const switched = await titleOf('vp-get');
|
||
check(
|
||
'switching one to Set while collapsed retitles it and resizes it to the new title',
|
||
switched === 'Set vy' &&
|
||
(await drawnWhole('Set vy')) &&
|
||
(await json(`__gm.graph.nodeRect('vp-get')`)).w < narrow,
|
||
JSON.stringify({ switched, narrow }),
|
||
);
|
||
await undo();
|
||
await sleep(300);
|
||
await toggle('vp-set');
|
||
await toggle('vp-get');
|
||
const open = [await titleOf('vp-set'), await titleOf('vp-get')];
|
||
check(
|
||
'expanded again, both are plain Variables nodes',
|
||
open.join() === 'Variables,Variables',
|
||
JSON.stringify(open),
|
||
);
|
||
|
||
// 23g. A Pack keeps declaring its own fields, but a variable that already carries the shape can
|
||
// hand it over. A color field arrives as a number port – the graph has no colors.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic');
|
||
a.variables.push(
|
||
${JSON.stringify(
|
||
variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, [
|
||
{ key: 'shape', kind: 'number' },
|
||
{ key: 'tint', kind: 'color' },
|
||
{ key: 'cells', kind: 'list' },
|
||
]),
|
||
)},
|
||
${JSON.stringify(variable('shapes', 'list', [{ x: 0, y: 0 }]))},
|
||
);
|
||
a.props.piece = { shape: 0, tint: 0, cells: [] };
|
||
a.props.shapes = [{ x: 0, y: 0 }];
|
||
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
|
||
`);
|
||
await sleep(300);
|
||
|
||
await select('vp-pack');
|
||
const from = await locate('From variable');
|
||
check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from));
|
||
await click(from.x, from.y);
|
||
await sleep(200);
|
||
await shot('03-from-variable');
|
||
await clickLast('piece');
|
||
await sleep(300);
|
||
const seeded = await portsOf('vp-pack');
|
||
check(
|
||
'picking a struct variable declares every field at once, each of its own kind',
|
||
JSON.stringify(seeded) ===
|
||
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"color"},{"key":"cells","kind":"list"}]',
|
||
JSON.stringify(seeded),
|
||
);
|
||
check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false)));
|
||
await shot('04-fields-seeded');
|
||
await undo();
|
||
await sleep(300);
|
||
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
|
||
|
||
// 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack
|
||
// builds for such a list.
|
||
await click(from.x, from.y);
|
||
await sleep(200);
|
||
await clickLast('shapes (item)');
|
||
await sleep(300);
|
||
const fromItem = await portsOf('vp-pack');
|
||
check(
|
||
'a list offers the shape of the struct items it holds',
|
||
fromItem.map((p) => p.key).join(',') === 'x,y',
|
||
JSON.stringify(fromItem),
|
||
);
|
||
await undo();
|
||
await sleep(300);
|
||
|
||
// 23i. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
|
||
await mutate(`
|
||
__gm.state.project.assets.find((x) => x.id === 'vp-logic').graph.nodes.push(
|
||
${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))},
|
||
);
|
||
`);
|
||
await sleep(200);
|
||
await select('vp-start');
|
||
// `locate` throws when the text is absent, which is the wrong shape for "is it there?".
|
||
const helper = await json(`__gm.locate('From variable', false)`);
|
||
check(
|
||
'a behavior start declares inputs, not fields, and is not offered a shape',
|
||
Boolean(await locate('+ Input')) && helper === null,
|
||
JSON.stringify(helper),
|
||
);
|
||
|
||
// 23j. From the palette the node comes empty; picking a variable keeps its title, and switched to Set
|
||
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
|
||
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
|
||
const idsBefore = await nodeIds();
|
||
const centre = await json('__gm.graph.center()');
|
||
// Below the Owner node; centre + (250, 250) would land on the Pack.
|
||
await click(centre.x - 250, centre.y + 250, 'right');
|
||
await sleep(150);
|
||
// Enter takes the first hit in category order, so search by a phrase only its description has.
|
||
await typeText('switched to Set');
|
||
await sleep(200);
|
||
await press('Enter');
|
||
await sleep(300);
|
||
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id));
|
||
check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added));
|
||
const variableId = added?.id ?? '';
|
||
const untitled = await titleOf(variableId);
|
||
const bare = !(await portAt(variableId, 'exec', false));
|
||
await clickLast('+ Variable');
|
||
await clickLast('vz');
|
||
await sleep(300);
|
||
await clickLastSet();
|
||
check(
|
||
'it starts without pins, and picking its variable leaves the title Variables',
|
||
untitled === 'Variables' && bare && (await titleOf(variableId)) === 'Variables',
|
||
JSON.stringify({ untitled, bare, titled: await titleOf(variableId) }),
|
||
);
|
||
check('switched to Set it grows its flow pins', Boolean(await portAt(variableId, 'exec', false)));
|
||
await shot('05-variable-node');
|
||
|
||
await mutate(`
|
||
const g = __gm.state.project.assets.find((x) => x.id === 'vp-logic').graph;
|
||
g.nodes.push(
|
||
${JSON.stringify(node('vp-add', 'Add', 800, 200, { b: 1 }))},
|
||
${JSON.stringify(node('vp-gone', 'Variables', 800, 500, {}, [port('gone', 'number')]))},
|
||
);
|
||
g.edges.push(
|
||
${JSON.stringify(edge('vp-e7', 'vp-move', 'exec', variableId, 'exec'))},
|
||
${JSON.stringify(edge('vp-e8', variableId, 'vz', 'vp-add', 'a'))},
|
||
${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))},
|
||
);
|
||
`);
|
||
await sleep(300);
|
||
await select('vp-gone');
|
||
check(
|
||
'a port naming a variable the behavior does not have is marked in the inspector',
|
||
await has('"gone" is not a variable of this behavior.'),
|
||
);
|
||
|
||
await t.play();
|
||
await sleep(1000);
|
||
const counted = (await entity('vp-brain'))?.props.vz;
|
||
await stop();
|
||
await sleep(300);
|
||
check(
|
||
'in the run it reads through its own output and writes the sum back, so vz climbs',
|
||
typeof counted === 'number' && counted > 20,
|
||
`vz = ${counted} after ~1 s`,
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(300);
|
||
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length');
|
||
check('the step leaves the fixture as it found it', left === 0, String(left));
|
||
}
|