Next move held four clusters of Set variable in a row, fourteen single nodes
in all; clear-lines read three variables one after the other. They are now
one plural node each, which takes Next move 40 -> 30 nodes and clear-lines
25 -> 23. Every remaining single node was checked and really is single: ok/no
are the same variable in opposite branches, take/keep the same one across a
write, and the three key setters hang on three different events.
Two contracts carry those merges, both pinned as tests. Set variables writes
per port with writeTyped exactly as the singular node does, so a Pack{rot}
still merges instead of overwriting - without it, turning would have erased
the other three fields. And the interpreter collects every input before the
node runs, so no input may come from a variable the same node writes; a data
node is evaluated once per pulse and keeps its answer. That second rule is
invisible in the game, because OnUpdate re-reads each frame and heals it, so
it lives in a unit test rather than in the smoke.
/opt/node22 and /opt/npm are gone from the tree. build.sh ran into the empty
path once they were moved away; the other seven mentions were documentation.
e2e.sh and check.sh replace the bare npm calls. A long run does not survive
the end of its shell - four full e2e runs died mid-suite with oom_kill 0 -
so e2e.sh start hands the run to a transient game-maker-e2e.service, which
does. e2e.sh status reports the running job or the last result, and calls a
log without a closing line ABORTED rather than reporting the passes it got
to. Both scripts end on an explicit exit line, because set -o pipefail inside
a script does not protect the caller: measured, ./check.sh | tail still
reports 0. Logs carry a timestamp and land in logs/, which is ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
9.7 KiB
TypeScript
207 lines
9.7 KiB
TypeScript
/**
|
||
* Get/Set variables: the two nodes whose declared ports are variables of the behavior around them,
|
||
* rather than names of their own. What that costs is the wiring – a port has to follow the variable
|
||
* it names when that is renamed or swapped, and take its edges along.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
AddStructFieldCommand,
|
||
AddVariableCommand,
|
||
RemoveVariableCommand,
|
||
RenameVariableCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import {
|
||
GET_VARIABLES,
|
||
type GraphNode,
|
||
GraphInterpreter,
|
||
resolveDef,
|
||
SET_VARIABLES,
|
||
} from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||
|
||
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
|
||
function bothWays() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 });
|
||
const get = edit.addNode(GET_VARIABLES);
|
||
const add = edit.addNode('Add');
|
||
const store = edit.addNode('SetVariable', { variable: 'sum' });
|
||
for (const node of [set, get])
|
||
for (const key of ['px', 'py'])
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[set, 'exec', store, 'exec'],
|
||
[get, 'px', add, 'a'],
|
||
[get, 'py', add, 'b'],
|
||
[add, 'result', store, 'value'],
|
||
]);
|
||
return { fixture, behavior, set, get, edit };
|
||
}
|
||
|
||
function run(
|
||
behavior: BehaviorAsset,
|
||
fixture: ReturnType<typeof makeProject>,
|
||
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
|
||
) {
|
||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||
const ctx = fakeContext({ variables });
|
||
interpreter.fire('OnStart', ctx);
|
||
return ctx.variables;
|
||
}
|
||
|
||
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
||
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
|
||
|
||
describe('a node that reads or writes several variables at once', () => {
|
||
it('writes each variable it names and reads each one back', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('takes the port type from the variable, so a number output stays a number', () => {
|
||
const { fixture, behavior, get, set } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
|
||
// The exec port is the node's own; only the variables come from the declaration.
|
||
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
|
||
});
|
||
|
||
it('follows a renamed variable, edge and all', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
|
||
expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 });
|
||
|
||
fixture.stack.undo();
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('keeps the edge when a port is pointed at another variable', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(
|
||
new SetVariablePortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
|
||
);
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
|
||
|
||
fixture.stack.undo();
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
|
||
});
|
||
|
||
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
|
||
// Left standing rather than dropped: taking the port would have to take its edge too, and
|
||
// undo could not put either back. The inspector marks it as naming no variable.
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
|
||
fixture.stack.undo();
|
||
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
/**
|
||
* The rule that decides how far a `Get variables` may reach: a data node answers once per pulse
|
||
* and that answer is kept, so a single node cannot straddle a write of one of its variables – the
|
||
* later reader would be served the value from before it. A second node is the way across.
|
||
*/
|
||
it('answers once per pulse, so a reader past a write needs a node of its own', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const early = edit.addNode(GET_VARIABLES);
|
||
const late = edit.addNode(GET_VARIABLES);
|
||
const write = edit.addNode(SET_VARIABLES, { px: 7 });
|
||
const before = edit.addNode('SetVariable', { variable: 'sum' });
|
||
const after = edit.addNode('SetVariable', { variable: 'py' });
|
||
const again = edit.addNode('SetVariable', { variable: 'fresh' });
|
||
for (const node of [early, late, write])
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', before, 'exec'],
|
||
[before, 'exec', write, 'exec'],
|
||
[write, 'exec', after, 'exec'],
|
||
[after, 'exec', again, 'exec'],
|
||
[early, 'px', before, 'value'],
|
||
[early, 'px', after, 'value'],
|
||
[late, 'px', again, 'value'],
|
||
]);
|
||
// `py` is the point: the same node read past the write still answers 1, while `late` sees 7.
|
||
expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({
|
||
px: 7,
|
||
py: 1,
|
||
sum: 1,
|
||
fresh: 7,
|
||
});
|
||
});
|
||
|
||
it('survives a save and load, which is what makes the pick more than a session', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
const loaded = roundTrip(fixture.project);
|
||
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!;
|
||
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
|
||
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
/**
|
||
* A struct port writes the way the singular node does: the bundle it is handed is merged in, so a
|
||
* `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move`
|
||
* packs only the field it changes and lets the plural node keep the other three.
|
||
*/
|
||
it('merges a partial bundle into a struct variable instead of replacing it', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||
for (const key of ['shape', 'rot'])
|
||
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode(SET_VARIABLES);
|
||
const pack = edit.addNode('PackStruct', { rot: 2 });
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' }));
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[pack, 'value', set, 'piece'],
|
||
]);
|
||
|
||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||
});
|
||
|
||
/**
|
||
* The other half of how far a plural node may reach: it collects every input before it writes any
|
||
* of them, so an input fed from a variable the same node writes sees the value from before.
|
||
*/
|
||
it('reads every input before it writes any, so a plural set cannot feed itself', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['px', 'py']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const read = edit.addNode('GetVariable', { variable: 'px' });
|
||
const set = edit.addNode(SET_VARIABLES, { px: 7 });
|
||
for (const key of ['px', 'py'])
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[read, 'value', set, 'py'],
|
||
]);
|
||
expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 });
|
||
});
|
||
});
|