Compare commits

...
3 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 e4ccf5f72f Keep other nodes out of a group the agent makes round new nodes
feuerwerk / gate (push) Successful in 5m18s
feuerwerk / suite (push) Successful in 19m7s
A group made in an agent call is fitted round its nodes only after
the call has arranged them, and the arrangement knew nothing of it.
With On key held, Move and Owner placed fresh, a group round key and
move and one round the owner, the Owner landed beside the key. Move
is taller than the key, so the Owner stood in the first group and the
two groups lay on each other. lint_graph counted none of it. Found
while testing copy_nodes (#434).

- arrange takes the new option blocks: sets of arranged nodes a group
  is fitted round afterwards. Once the columns are laid out, whatever
  would stand in such a group goes below it, a node or the group of
  another set, with everything arranged under it.
- The flow keeps its columns. A first try laid each set out as one
  block instead, but then the link from the Owner ran over the key
  inside the group (throughNodes 1).
- AgentEdit passes every group made in the call whose nodes are all
  new. Groups round nodes already there and the Arrange button stay
  as they were.

Tests:
- agent-edit: new test with the setup that showed the fault. No node
  stands in a group not its own, the groups lie apart, lint_graph has
  0 overlaps, throughNodes, backwards and coveredNotes.
- Counter-check: without the new step exactly that test is red.
- Agent, graph-arrange and graph-follow tests 120/120.
- e2e 71 on atx: ALL PASSED, 26 checks.
- Gate on atx: CHECK EXIT 0, 327 files, 5025 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:53:45 +02:00
adminandClaude Opus 5.5 fb981b5b0f Let the agent copy nodes, also into another graph (#434)
The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:39:37 +02:00
adminandClaude Opus 5.5 e4b0419a53 Measure e2e 68h against what the sound is held to (#418)
The check failed in CI twice in a row (tasks 740 and 741) with the
sound 61 and 52 ms ahead of the picture, against a fixed limit of
50 ms. Neither commit touched the sound timing. The limit was tighter
than what the composition promises.

- The sound follows the unrounded `player.time` and is started again
  once it lies more than `SLIP` (40 ms) off.
- The check reads the picture from `videoTime`, which counts in whole
  frames of the cut. It can lie up to one frame (1/60 s) behind.
- The first frame at 0.5 s may also come past it (0.5167 s in 740).
  That overshoot is now taken off.
- The limit is SLIP plus one frame plus 5 ms for the audio clock
  moving on between the game's frame and the test's.

Recomputed, the two red runs give 44.5 and 52.1 ms, under the new
limit of 61.7 ms.

Tests:
- e2e 68 (atx): ALL PASSED, 21 checks, 68h at 16 ms.
- Counter-probe: sound planned 100 ms early, 68h red at 116 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:39:14 +02:00
12 changed files with 510 additions and 52 deletions

No files matched your search

+42
View File
@@ -65,6 +65,48 @@ export class AddVariableCommand implements Command {
}
}
/**
* Declares variables of one behavior in another as they are declared there (#434): default, Keep
* and Private included, for copied nodes that name them. A key the target has already is left alone.
*/
export class CarryVariablesCommand implements Command {
readonly label = 'Carry variables';
private added: string[] = [];
constructor(
private readonly assetId: string,
private readonly fromId: string,
private readonly keys: readonly string[],
) {}
execute(project: Project): void {
const from = requireBehavior(project, this.fromId);
const to = requireBehavior(project, this.assetId);
this.added = [];
for (const key of this.keys) {
const def = from.variables.find((v) => v.key === key);
if (!def) throw new CommandError(`${from.name} has no variable "${key}"`);
if (to.variables.some((v) => v.key === key)) continue;
to.variables.push(structuredClone(def));
to.props[key] = structuredClone(from.props[key] ?? def.default);
if (isKeptVariable(from, key)) setKeptVariable(to, key, true);
if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true);
this.added.push(key);
}
}
undo(project: Project): void {
const to = requireBehavior(project, this.assetId);
const added = new Set(this.added);
to.variables = to.variables.filter((v) => !added.has(v.key));
for (const key of added) {
delete to.props[key];
setKeptVariable(to, key, false);
setPrivateVariable(to, key, false);
}
}
}
/** One placed object's own value for a variable, remembered so that undo can put it back. */
interface TakenOverride {
holderId: string;
+99 -2
View File
@@ -19,6 +19,7 @@ import {
ResizeNodeCommand,
SetNodeParamCommand,
} from '../../../core/commands/graphCommands';
import { PasteGraphCommand } from '../../../core/commands/pasteCommands';
import {
AddPortCommand,
MovePortCommand,
@@ -34,25 +35,33 @@ import {
SetStructTypeCommand,
SplitOutputCommand,
} from '../../../core/commands/structTypeCommands';
import { CarryVariablesCommand } from '../../../core/commands/variableCommands';
import {
type AnnotationKind,
type DeclaredPort,
declaresVariablePorts,
findAnnotation,
findNode,
type Graph,
type GraphNode,
nodeDef,
portKindOf,
variablesSourceOf,
} from '../../../core/graph';
import { copyGraph } from '../../../core/model/clipboard';
import type { PropertyBag, PropertyValue } from '../../../core/model/Properties';
import { findGraphAsset, type Project } from '../../../core/model/Project';
import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project';
import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds';
import { coerceValue } from '../../../core/properties/PropertySchema';
import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag';
import { boxesAbout } from '../../graph/annotationFollow';
import { arrangeCommand, layoutOf } from '../../graph/graphLayout';
import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { pickedPortChoices } from '../../graph/portChoices';
import {
type Args,
type BuildContext,
type Built,
type CatalogCommand,
flag,
GRAPH,
@@ -122,6 +131,32 @@ export const GRAPH_COMMANDS: readonly CatalogCommand[] = [
},
shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }),
},
{
name: 'copy_nodes',
description:
'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' +
'and the groups and comments about them alone. made names each copy by the id of its original ' +
'("$copy.<id>"; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' +
'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' +
'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' +
'a copied Variables node reads from its behavior come along into the other behavior.',
args: object(
{
from: id('Id of the asset holding the nodes.'),
nodes: ids('The nodes to copy.'),
to: id('Id of the asset to copy them into; leave out for the same one.'),
x: number('Only to place the copy by hand: its left.'),
y: number('Only to place the copy by hand: its top.'),
},
['from', 'nodes'],
),
build: copyNodes,
shows: (args, made) => ({
kind: 'nodes',
asset: (args.to as string | undefined) ?? text(args, 'from'),
ids: Object.values(made),
}),
},
{
name: 'arrange_graph',
description:
@@ -588,6 +623,68 @@ function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
return coerced;
}
/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */
function copyNodes(args: Args, context: BuildContext): Built {
const { project } = context;
const fromId = text(args, 'from');
const toId = (args.to as string | undefined) ?? fromId;
const nodeIds = listOf(args, 'nodes');
requireNodes(project, fromId, nodeIds);
const source = findGraphAsset(project, fromId)!;
const target = findGraphAsset(project, toId);
if (!target) throw new CommandError(`There is no graph asset ${toId}`);
const layout = layoutOf(project, fromId, context.measure(fromId))!;
const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!;
const { skipped } = fittingPart(project, target, clip);
if (skipped.length > 0) {
const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`);
throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`);
}
const carried = carriedVariables(source, target, clip.nodes);
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId);
const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)];
const shift = {
x: at.x - Math.min(...corners.map((c) => c.x)),
y: at.y - Math.min(...corners.map((c) => c.y)),
};
const paste = new PasteGraphCommand(toId, clip, shift);
const command =
carried.length > 0
? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste])
: paste;
const copies = paste.nodeIds;
const made: Record<string, string> = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! };
clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!));
clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!));
return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } };
}
/**
* The variables of `source` that copied Variables nodes read from it, for a copy into another
* behavior that lacks them; one declared there in another kind is refused.
*/
function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] {
if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return [];
const named = nodes.flatMap((node) =>
declaresVariablePorts(node.type) && !variablesSourceOf(node.params)
? (node.ports ?? []).map((port) => port.key)
: [],
);
const carried: string[] = [];
for (const key of new Set(named)) {
const def = source.variables.find((v) => v.key === key);
const there = target.variables.find((v) => v.key === key);
if (!def) continue;
if (!there) carried.push(key);
else if (there.kind !== def.kind)
throw new CommandError(
`"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`,
);
}
return carried;
}
function requireGraphOf(project: Project, assetId: string): Graph {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
+6 -1
View File
@@ -96,8 +96,13 @@ export class AgentEdit implements Command {
}
}
// A group made here holds its nodes, which arranging sees through; a comment sits above them.
// Round new nodes alone, it gets room: what else would stand in it goes below it.
for (const [assetId, nodes] of unplaced) {
const arranged = arrangeCommand(project, assetId, nodes, this.measure(assetId), true);
const blocks = frames
.filter((frame) => frame.asset === assetId && frame.kind === 'group')
.map((frame) => frame.nodes)
.filter((held) => held.length > 0 && held.every((node) => nodes.includes(node)));
const arranged = arrangeCommand(project, assetId, nodes, this.measure(assetId), true, blocks);
if (arranged) run(arranged);
}
for (const frame of frames) {
+2 -40
View File
@@ -3,16 +3,7 @@
* copy names assets of this project, which mean nothing anywhere else.
*/
import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands';
import {
BEHAVIOR_PARAM,
findAnnotation,
findNode,
FUNCTION_PARAM,
type GraphNode,
nodeDef,
namesBehavior,
SHADER_CALL,
} from '../core/graph';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance';
@@ -21,12 +12,11 @@ import {
findAssetOf,
findGraphAsset,
findObjectHolder,
type GraphHolder,
type ObjectHolder,
type Project,
} from '../core/model/Project';
import { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries';
import { fittingPart } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state';
import { SCENE_GRID_STEP } from './viewport/sceneViewHost';
@@ -194,34 +184,6 @@ export function pasteObjects(
return true;
}
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean {
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
/**
* Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid
* step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is
+22
View File
@@ -89,6 +89,28 @@ export function followedBoxes(
return done;
}
/**
* The groups and comments that are about these nodes alone (#434), in the graph's order: a group
* holding some of them and no other node, a comment going with such a group or with nodes all among
* them.
*/
export function boxesAbout(graph: LayoutGraph, ids: ReadonlySet<string>): string[] {
const groups = new Set(
graph.boxes
.filter((box) => {
const members = box.kind === 'group' ? graph.nodes.filter((node) => holds(box, node)) : [];
return members.length > 0 && members.every((node) => ids.has(node.id));
})
.map((box) => box.id),
);
const about = (note: LayoutBox) => {
const anchor = note.kind === 'note' ? anchorOf(graph, note) : null;
if (!anchor) return false;
return 'box' in anchor ? groups.has(anchor.box.id) : anchor.nodes.every((node) => ids.has(node.id));
};
return graph.boxes.filter((box) => groups.has(box.id) || about(box)).map((box) => box.id);
}
type Anchor = { box: LayoutBox; below: boolean } | { nodes: LayoutNode[]; below: boolean };
/** What a comment is about, by where it stands now. */
+70 -2
View File
@@ -1,4 +1,5 @@
import type { Box } from '../framing';
import { ANNOTATION_TITLE_HEIGHT, GROUP_MARGIN } from './annotationDrag';
import { curvePoint } from './beads';
/**
@@ -44,6 +45,11 @@ export interface ArrangeOptions {
open?: ReadonlySet<string>;
/** The nodes are new and stand nowhere yet: next to what they link to is tried before where they are. */
fresh?: boolean;
/**
* Arranged nodes a group is to be fitted round afterwards: what else would stand in that group goes
* below it, so that it takes in no node not its own and lies on no other such group.
*/
blocks?: readonly (readonly string[])[];
}
export const COLUMN_GAP = 72;
@@ -86,10 +92,10 @@ export function arrange(
options: ArrangeOptions = {},
): Map<string, { x: number; y: number }> {
const moving = graph.nodes.filter((node) => !movable || movable.has(node.id));
const placed = new Map<string, { x: number; y: number }>();
if (moving.length === 0) return placed;
if (moving.length === 0) return new Map();
const ids = new Set(moving.map((node) => node.id));
const edges = graph.edges.filter((edge) => ids.has(edge.from.node) && ids.has(edge.to.node));
const placed = new Map<string, { x: number; y: number }>();
let top = 0;
for (const part of parts(moving, edges)) {
const partIds = new Set(part.map((node) => node.id));
@@ -99,9 +105,71 @@ export function arrange(
for (const [id, spot] of spots) placed.set(id, { x: spot.x, y: spot.y + top });
top += height + PART_GAP;
}
clearBlocks(moving, placed, options.blocks ?? []);
return settle(graph, moving, placed, options);
}
/** How far a group reaches past its nodes: its margin, and its title bar on top. */
const BLOCK_SIDE = GROUP_MARGIN;
const BLOCK_TOP = GROUP_MARGIN + ANNOTATION_TITLE_HEIGHT;
/**
* Makes room for the groups of `blocks`: what would stand in one – a node, or the group of another
* block – goes below it, together with everything arranged under it, until nothing does. The flow
* keeps its columns; sets that share a node with an earlier one are left out.
*/
function clearBlocks(
nodes: readonly LayoutNode[],
placed: Map<string, { x: number; y: number }>,
blocks: readonly (readonly string[])[],
): void {
const sets: string[][] = [];
for (const set of blocks) {
const present = set.filter((id) => placed.has(id));
if (present.length > 0 && !present.some((id) => sets.some((other) => other.includes(id))))
sets.push(present);
}
if (sets.length === 0) return;
const units = [
...sets,
...nodes.filter((node) => !sets.some((set) => set.includes(node.id))).map((node) => [node.id]),
];
const byId = new Map(nodes.map((node) => [node.id, node]));
const frameOf = (unit: readonly string[]) => {
const boxes = unit.map((id) => ({ ...byId.get(id)!, ...placed.get(id)! }));
const minX = Math.min(...boxes.map((box) => box.x));
const minY = Math.min(...boxes.map((box) => box.y));
const maxX = Math.max(...boxes.map((box) => box.x + box.w));
const maxY = Math.max(...boxes.map((box) => box.y + box.h));
const framed = sets.includes(unit as string[]);
const side = framed ? BLOCK_SIDE : 0;
return {
x: minX - side,
y: minY - (framed ? BLOCK_TOP : 0),
w: maxX - minX + 2 * side,
h: maxY - minY + (framed ? BLOCK_TOP + GROUP_MARGIN : 0),
};
};
for (let round = 0; round < units.length * sets.length; round++) {
const frames = new Map(units.map((unit) => [unit, frameOf(unit)]));
const crowded = sets
.map((set) => ({ set, frame: frames.get(set)! }))
.sort((a, b) => a.frame.y - b.frame.y)
.find(({ set, frame }) =>
units.some((unit) => unit !== set && overlap(grown(frames.get(unit)!, ROW_GAP), frame)),
);
if (!crowded) return;
const { set, frame } = crowded;
const tops = units
.filter((unit) => unit !== set && overlap(grown(frames.get(unit)!, ROW_GAP), frame))
.map((unit) => frames.get(unit)!.y);
const from = Math.min(...tops);
const by = frame.y + frame.h + ROW_GAP - from;
for (const unit of units)
if (unit !== set && frames.get(unit)!.y >= from) for (const id of unit) placed.get(id)!.y += by;
}
}
/** Groups of nodes that links join, in the order their first node has in the graph. */
function parts(nodes: readonly LayoutNode[], edges: readonly LayoutEdge[]): LayoutNode[][] {
const root = new Map(nodes.map((node) => [node.id, node.id]));
+4 -2
View File
@@ -55,7 +55,8 @@ export function layoutOf(
/**
* The moves that arrange these nodes of a graph (all of them without a list), with the groups and
* comments going along, as one undo step; null when nothing has to move. `fresh` nodes are new ones
* the agent gave no place: put next to what they link to, they join its group.
* the agent gave no place: put next to what they link to, they join its group. `blocks` are sets of
* them a new group is fitted round afterwards: what else would stand in it goes below it.
*/
export function arrangeCommand(
project: Project,
@@ -63,12 +64,13 @@ export function arrangeCommand(
nodeIds: readonly string[] | null,
measure?: MeasureNode,
fresh = false,
blocks: readonly (readonly string[])[] = [],
): Command | null {
const layout = layoutOf(project, assetId, measure);
if (!layout) return null;
const movable = nodeIds ? new Set(nodeIds) : undefined;
const joining = fresh && movable ? adoptingGroups(layout, movable) : [];
const spots = arrange(layout, movable, { open: new Set(joining), fresh });
const spots = arrange(layout, movable, { open: new Set(joining), fresh, blocks });
const boxes = followedBoxes(layout, spots, { groups: joining, nodes: nodeIds ?? [] });
const moves = layout.nodes.flatMap((node) => {
const spot = spots.get(node.id);
+31
View File
@@ -6,6 +6,7 @@ import {
callsFunction,
createEmptyGraph,
FUNCTION_PARAM,
type GraphNode,
isTerminalType,
namesBehavior,
nextStateName,
@@ -22,6 +23,7 @@ import {
terminalNode,
} from '../../core/graph';
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import type { GraphClip } from '../../core/model/clipboard';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
@@ -188,3 +190,32 @@ export function paletteEntries(project: Project, asset: GraphHolder): PaletteEnt
}
return entries;
}
/** Whether a graph may hold `node`: its palette `entries` offer the node's kind. */
function fitsGraph(entries: readonly PaletteEntry[], node: GraphNode): boolean {
return entries.some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot (#240, #434). */
export function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const entries = paletteEntries(project, asset);
const nodes = clip.nodes.filter((n) => fitsGraph(entries, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import { isKeptVariable } from '../../src/core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../src/core/model/Project';
import { runTool, type ToolHost } from '../../src/editor/agent/agentTools';
import { makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** Copying nodes through the agent's tools (#434). */
/**
* Behavior Walk, placed by hand: a key held moving its owner, a Variables node reading `speed`
* (kept), a group round key and move, a comment above the Variables node, and a group round the
* owner alone.
*/
async function walkAndRun(fixture: SceneFixture, host: ToolHost) {
const steps = [
{ command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' },
{ command: 'create_asset', args: { type: 'behavior', name: 'Run' }, as: 'run' },
{ command: 'add_variable', args: { asset: '$walk', key: 'speed', kind: 'number' } },
{ command: 'set_variable_kept', args: { asset: '$walk', key: 'speed', kept: true } },
{ command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', x: 0, y: 0 }, as: 'key' },
{
command: 'add_node',
args: { asset: '$walk', node: 'Move', params: { dx: -200 }, x: 300, y: 0 },
as: 'move',
},
{ command: 'add_node', args: { asset: '$walk', node: 'Owner', x: 0, y: 400 }, as: 'owner' },
{ command: 'add_node', args: { asset: '$walk', node: 'Variables', x: 0, y: 700 }, as: 'vars' },
{ command: 'add_port', args: { asset: '$walk', node: '$vars', key: 'speed' } },
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } },
},
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } },
},
{
command: 'connect',
args: {
asset: '$walk',
from: { node: '$owner', port: 'object' },
to: { node: '$move', port: 'object' },
},
},
{ command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } },
{ command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } },
{ command: 'add_comment', args: { asset: '$walk', nodes: ['$vars'], text: 'How fast' } },
];
const result = await runTool('run_commands', { steps, label: 'walk' }, host);
expect(result.isError, text(result)).toBeFalsy();
const walk = findAssetOf(fixture.project, assetNamed(fixture, 'Walk'), 'behavior')!;
const run = findAssetOf(fixture.project, assetNamed(fixture, 'Run'), 'behavior')!;
const id = (type: string) => walk.graph.nodes.find((node) => node.type === type)!.id;
return { walk, run, key: id('OnKeyHeld'), move: id('Move'), owner: id('Owner'), vars: id('Variables') };
}
/** The project as JSON, an empty list of groups or comments as none – undo leaves one, as for Add group. */
function plain(project: Project): string {
return JSON.stringify(project, (key, value: unknown) =>
(key === 'groups' || key === 'notes') && Array.isArray(value) && value.length === 0 ? undefined : value,
);
}
function assetNamed(fixture: SceneFixture, name: string): string {
return fixture.project.assets.find((asset) => asset.name === name)!.id;
}
describe('copy_nodes', () => {
it('copies nodes into another behavior with their links, groups, comments and variables', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, move, vars } = await walkAndRun(fixture, host);
const before = plain(fixture.project);
const steps = [
{ command: 'copy_nodes', args: { from: walk.id, nodes: [key, move, vars], to: run.id }, as: 'copy' },
{ command: 'add_node', args: { asset: run.id, node: 'Owner' }, as: 'owner' },
{
command: 'connect',
args: {
asset: run.id,
from: { node: '$owner', port: 'object' },
to: { node: `$copy.${move}`, port: 'object' },
},
},
];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const graph = findGraphAsset(fixture.project, run.id)!.graph;
expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Variables', 'Owner']);
const [copiedKey, copiedMove, copiedVars] = graph.nodes;
expect(copiedMove!.params.dx).toBe(-200);
expect(copiedVars!.ports).toEqual([{ key: 'speed', kind: 'number' }]);
// The link from the Owner left behind stays behind; the one drawn after the copy is there.
expect(graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`)).toEqual([
`${copiedKey!.id}.exec>${copiedMove!.id}.exec`,
`${copiedKey!.id}.dt>${copiedMove!.id}.dt`,
`${graph.nodes[3]!.id}.object>${copiedMove!.id}.object`,
]);
expect((graph.groups ?? []).map((group) => group.text)).toEqual(['Walk']);
expect((graph.notes ?? []).map((note) => note.text)).toEqual(['How fast']);
const { step, ...made } = (result.structuredContent as { made: Record<string, unknown>[] }).made[0]!;
expect(step).toBe(1);
expect(made).toMatchObject({
id: copiedKey!.id,
[key]: copiedKey!.id,
[move]: copiedMove!.id,
[vars]: copiedVars!.id,
});
// The first node's copy, three copies by their originals, the group and the comment.
expect(Object.keys(made)).toHaveLength(6);
const runNow = findAssetOf(fixture.project, run.id, 'behavior')!;
expect(runNow.variables.map((v) => [v.key, v.kind])).toEqual([['speed', 'number']]);
expect(isKeptVariable(runNow, 'speed')).toBe(true);
// The originals are untouched.
expect(walk.graph.nodes).toHaveLength(4);
await runTool('undo', {}, host);
expect(plain(fixture.project)).toBe(before);
});
it('keeps the layout of a copy placed by hand, its top left where it is told', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, key, move } = await walkAndRun(fixture, host);
const [from, to] = [key, move].map((nodeId) => walk.graph.nodes.find((n) => n.id === nodeId)!);
const steps = [{ command: 'copy_nodes', args: { from: walk.id, nodes: [move, key], x: 2000, y: 900 } }];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const [copiedKey, copiedMove] = walk.graph.nodes.slice(4);
const group = walk.graph.groups!.find((g) => g.text === 'Walk' && g.x >= 2000)!;
expect(Math.min(copiedKey!.x, group.x)).toBe(2000);
expect(Math.min(copiedKey!.y, group.y)).toBe(900);
expect(copiedMove!.x - copiedKey!.x).toBe(to!.x - from!.x);
expect(copiedMove!.y - copiedKey!.y).toBe(to!.y - from!.y);
// The comment is about the Variables node left behind, the second group about the Owner.
expect(walk.graph.notes).toHaveLength(1);
expect(walk.graph.groups).toHaveLength(3);
// The copy of the first node given answers to "$copy" alone.
const made = (result.structuredContent as { made: Record<string, string>[] }).made[0]!;
expect(made.id).toBe(copiedMove!.id);
});
it('refuses nodes the other graph cannot hold, and a variable of another kind there', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, vars } = await walkAndRun(fixture, host);
const made = await runTool(
'run_commands',
{
steps: [
{ command: 'create_asset', args: { type: 'shader', name: 'Glow' } },
{ command: 'add_variable', args: { asset: run.id, key: 'speed', kind: 'text' } },
],
label: 'more',
},
host,
);
expect(made.isError, text(made)).toBeFalsy();
const before = JSON.stringify(fixture.project);
const shader = assetNamed(fixture, 'Glow');
const intoShader = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [key], to: shader } }], label: 'x' },
host,
);
expect(intoShader.isError).toBe(true);
expect(text(intoShader)).toContain(`Glow cannot hold`);
expect(text(intoShader)).toContain(`(${key}) – leave them out`);
const clash = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [vars], to: run.id } }], label: 'x' },
host,
);
expect(clash.isError).toBe(true);
expect(text(clash)).toContain('"speed" is a number in Walk but a text in Run');
expect(JSON.stringify(fixture.project)).toBe(before);
});
});
+35
View File
@@ -241,6 +241,41 @@ describe('run_commands', () => {
expect(behavior.graph.groups![0]).toEqual(before);
});
it('gives a group made round new nodes room, what else would stand in it going below', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const steps = [
...walkLeft(fixture),
{ command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } },
{ command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } },
];
const result = await runTool('run_commands', { steps, label: 'walk' }, host);
expect(result.isError, text(result)).toBeFalsy();
const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
const { graph } = findGraphAsset(fixture.project, behavior.id)!;
const [walk, who] = graph.groups!;
const centred = (box: { x: number; y: number; w: number; h: number }) =>
graph.nodes
.filter((node) => {
const cx = node.x + NODE_WIDTH / 2;
const cy = node.y + 20;
return cx >= box.x && cx <= box.x + box.w && cy >= box.y && cy <= box.y + box.h;
})
.map((node) => node.type);
// Move is taller than the key: before, the Owner beside the key came to stand in the Walk group.
expect(centred(walk!)).toEqual(['OnKeyHeld', 'Move']);
expect(centred(who!)).toEqual(['Owner']);
const apart =
who!.x + who!.w <= walk!.x ||
walk!.x + walk!.w <= who!.x ||
who!.y + who!.h <= walk!.y ||
walk!.y + walk!.h <= who!.y;
expect(apart).toBe(true);
const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
expect(lint).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0, coveredNotes: 0 });
});
it('changes nothing when a step fails, and names the step and why', async () => {
const fixture = makeSpriteScene();
const { host } = hostFor(fixture);
+8 -2
View File
@@ -432,7 +432,13 @@ export async function composition(t) {
const ran = toneRows.findIndex((row) => row.time > 0);
const reached = toneRows.find((row) => row.time >= 0.5);
const toneClip = toneRows.at(-1)?.clip;
const early = reached && toneClip ? reached.clock - toneClip.at : null;
// How far the sound runs ahead of the picture in that frame: the frame may come past 0.5, and the
// sound is held to the picture only within SLIP (40 ms, compositionSound.ts). `videoTime` counts
// in whole frames of the cut while the sound follows the unrounded time, so the picture read here
// can lie up to one frame (1/60 s) behind what the sound is held to; 5 ms more for the audio clock
// moving on between the game's frame and this one. A fixed 50 ms failed at 52 and 61 ms in CI
// (tasks 740, 741) with the sound inside all of that.
const early = reached && toneClip ? reached.clock - toneClip.at - (reached.time - 0.5) : null;
check(
'a sound item is planned only once the cut’s clock runs, for when the cut reaches it, to its end',
ran > 0 &&
@@ -442,7 +448,7 @@ export async function composition(t) {
Math.abs(toneClip.duration - 0.5) < 0.002 &&
early !== null &&
early > -0.02 &&
early < 0.05,
early < 0.04 + 1 / 60 + 0.005,
JSON.stringify({ waited: ran, early, reached, toneClip }),
);
// 68i. The sound item picked the composition's bus, the video item picked none: both go into the
+5 -3
View File
@@ -45,9 +45,11 @@ const INSTRUCTIONS = [
'over a long link. Check with lint_graph, the crossings too: when there are more than the links',
'need, arrange_graph orders the whole graph for the fewest. Put nodes that belong together into a',
'group with add_group, and say what they do with add_comment; a comment a node lies on',
'(coveredNotes) goes back above its nodes with fit_group. Music plays nothing without an',
'instrument, and an instrument sounds only the notes its Notes nodes take in, C4 to B4 in a new',
'one: get_asset lists under silent what of a pattern, song or composition is not heard.',
'(coveredNotes) goes back above its nodes with fit_group. A part already built elsewhere is',
'copied with copy_nodes, into the same graph or another, rather than built again. Music plays',
'nothing without an instrument, and an instrument sounds only the notes its Notes nodes take',
'in, C4 to B4 in a new one: get_asset lists under silent what of a pattern, song or composition',
'is not heard.',
'apply_preset gives an instrument that sounds every note in one step. Pictures, fonts, sounds',
'and videos are files: list_files lists those the server holds, add_file_asset makes an asset',
'of one. Before deleting or reworking an asset, get_asset says under usedBy what names it.',