From 0d8fd6ff521f342da76bd271b09a1fe3ad6d4659 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 18 Oct 2026 16:01:15 +0200 Subject: [PATCH] Let the agent use the graph, scene and project commands (#429) The agent knew 23 of the editor's commands. It could not point a Variables node at a variable, group nodes, edit ports, transitions, containers, colliders or the project settings. Stage 5a brings the catalog to 85 commands in five areas and keeps every answer short. - The catalog lists names and descriptions only. With a query (an area or command names) it adds the arguments. All schemas at once would pass the 60 000 character limit of one answer. - add_port points a Variables node at a variable. The port takes the variable's kind, and an unknown name is refused with the list of the ones that exist. - add_group and add_comment take nodes instead of coordinates. They follow the nodes after the arrange step, in the same undo step. - The rule for which list a port picks from moved into portChoices.ts. Inspector and agent now share it. - The frame of a group around nodes moved into annotationDrag.ts, so the button and the agent get the same frame. - list_assets returns the project settings. get_asset shows the content of placed containers with the ids overrides need. get_graph shows declared ports and frame colors. - A new node is placed at the height of its port, in the free spot whose links cross the fewest others. A source goes left of the node it feeds. Before, new nodes always went right of the rest. - Arrange breaks a tie in crossings by putting the flow on top, and exec links pull harder when a column is sorted. - A comment over a group keeps clear of the group's title bar. - The agent's instructions and lint_graph ask to look at crossings too and to call arrange_graph when there are more than needed. Left out on purpose: file assets (no upload), paste from the clipboard, internal wrapper commands and switching plugins on. Found on the way: - Variable commands on a Struct asset did not run through WithStructTypes. New fields did not reach variables of that type. - A query "variables" also matched set_variables_scope. A word that names an area now means only that area. Tests: - agent-catalog (new, 10), agent-edit, graph-arrange (3 new) and relay green. 11 bends of the code, all red. - e2e 71 on atx: 22/22, the new check sees port speed and a group around all 4 drawn nodes. - Live on a test project: a speed variable of 300 moved the player to x 150.03 in 0.5 s. A re-placed Variables node landed below Axis input with 4 crossings instead of 7. Co-Authored-By: Claude Opus 5.5 --- src/core/commands/structTypeCommands.ts | 10 + src/editor/agent/agentTools.ts | 7 +- src/editor/agent/catalog/assets.ts | 172 ++++++ src/editor/agent/catalog/graphs.ts | 675 ++++++++++++++++++++++++ src/editor/agent/catalog/objects.ts | 373 +++++++++++++ src/editor/agent/catalog/project.ts | 100 ++++ src/editor/agent/catalog/shared.ts | 114 ++++ src/editor/agent/catalog/variables.ts | 306 +++++++++++ src/editor/agent/commandCatalog.ts | 532 +------------------ src/editor/agent/editPlan.ts | 56 +- src/editor/agent/readTools.ts | 72 ++- src/editor/agent/tools.json | 12 +- src/editor/graph/AnnotationView.ts | 3 +- src/editor/graph/GraphCanvas.ts | 29 +- src/editor/graph/annotationDrag.ts | 37 +- src/editor/graph/arrange.ts | 194 +++++-- src/editor/graph/portChoices.ts | 67 +++ src/editor/inspector/nodeSection.ts | 2 +- src/editor/inspector/portSection.ts | 27 +- tests/editor/agent-catalog.test.ts | 215 ++++++++ tests/editor/agent-edit.test.ts | 79 +-- tests/editor/agentTestHost.ts | 48 ++ tests/editor/graph-arrange.test.ts | 74 ++- tests/editor/variable-choices.test.ts | 2 +- tools/e2e/71-mcp.mjs | 39 ++ tools/mcp/relay.mjs | 12 +- 26 files changed, 2585 insertions(+), 672 deletions(-) create mode 100644 src/editor/agent/catalog/assets.ts create mode 100644 src/editor/agent/catalog/graphs.ts create mode 100644 src/editor/agent/catalog/objects.ts create mode 100644 src/editor/agent/catalog/project.ts create mode 100644 src/editor/agent/catalog/shared.ts create mode 100644 src/editor/agent/catalog/variables.ts create mode 100644 src/editor/graph/portChoices.ts create mode 100644 tests/editor/agent-catalog.test.ts create mode 100644 tests/editor/agentTestHost.ts diff --git a/src/core/commands/structTypeCommands.ts b/src/core/commands/structTypeCommands.ts index df0489fd..9017c260 100644 --- a/src/core/commands/structTypeCommands.ts +++ b/src/core/commands/structTypeCommands.ts @@ -227,6 +227,11 @@ export class MakeStructTypeCommand implements Command { private readonly path: readonly string[], ) {} + /** The Struct asset it made, once it has run. */ + get madeId(): string | undefined { + return this.asset?.id; + } + execute(project: Project): void { const owner = requireVariableOwner(project, this.ownerId); const def = requireStructVariable(project, this.ownerId, this.key); @@ -372,6 +377,11 @@ export class MakeListItemTypeCommand implements Command { constructor(private readonly target: ListTarget) {} + /** The Struct asset it made, once it has run. */ + get madeId(): string | undefined { + return this.asset?.id; + } + execute(project: Project): void { const { def, decl } = requireListDecl(project, this.target); if (decl.itemType && structAssetOf(project, decl.itemType)) diff --git a/src/editor/agent/agentTools.ts b/src/editor/agent/agentTools.ts index 23f887ae..d771de21 100644 --- a/src/editor/agent/agentTools.ts +++ b/src/editor/agent/agentTools.ts @@ -18,7 +18,7 @@ import { sendInput, totalSeconds, } from './playInput'; -import { catalog, type CatalogTopic, getAsset, getGraph, listAssets } from './readTools'; +import { catalog, type CatalogTopic, getAsset, getGraph, listAssets, projectSettings } from './readTools'; import { NO_CAMERA_HINT, type RuntimeFacts, runtimeView } from './runtimeView'; import TOOLS from './tools.json'; @@ -128,7 +128,10 @@ async function run(name: string, args: Record, host: ToolHost): }; } case 'list_assets': - return answer({ assets: listAssets(host.project(), args.type as string, args.query as string) }); + return answer({ + project: projectSettings(host.project()), + assets: listAssets(host.project(), args.type as string, args.query as string), + }); case 'get_asset': return answer(getAsset(host.project(), args.asset as string) ?? missing(args.asset)); case 'get_graph': diff --git a/src/editor/agent/catalog/assets.ts b/src/editor/agent/catalog/assets.ts new file mode 100644 index 00000000..aa62b321 --- /dev/null +++ b/src/editor/agent/catalog/assets.ts @@ -0,0 +1,172 @@ +import { + CreateAssetCommand, + DeleteAssetCommand, + DuplicateAssetCommand, + MakeProjectAssetCommand, + RenameAssetCommand, + ResetAssetPropertyCommand, + SetAssetPropertyCommand, + SetMachineScopeCommand, + SetMachineStartCommand, + SetStartSceneCommand, + SetVariablesPersistentCommand, + SetVariablesScopeCommand, +} from '../../../core/commands/assetCommands'; +import { ASSET_TYPES, type AssetType } from '../../../core/model/AssetTypes'; +import type { PropertyBag, PropertyValue } from '../../../core/model/Properties'; +import type { Scope, SharedScope } from '../../../core/model/Scope'; +import { + ASSET, + anyValue, + type CatalogCommand, + flag, + id, + madeAsset, + object, + oneOf, + text, + theAsset, +} from './shared'; + +/** Assets the agent cannot make: each needs a file, and there is no upload for agents. */ +const FILE_TYPES: readonly AssetType[] = ['texture', 'font', 'sound', 'video']; + +export const ASSET_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'create_asset', + description: + 'Makes a new asset. A scene is where objects are placed; a sprite, text or camera is placed into one; ' + + "a behavior is a graph of nodes that runs on the object it is placed under. Textures, fonts, sounds and videos need a file and can't be made here.", + args: object( + { + type: { + type: 'string', + enum: ASSET_TYPES.filter((type) => !FILE_TYPES.includes(type)), + description: 'The kind of asset.', + }, + name: { type: 'string', minLength: 1, description: 'Its name in the asset list.' }, + props: { + type: 'object', + description: "Starting values of its properties by key (see catalog topic 'asset_types').", + }, + }, + ['type', 'name'], + ), + build: (args) => { + const command = new CreateAssetCommand( + text(args, 'type') as AssetType, + text(args, 'name'), + (args.props ?? {}) as PropertyBag, + ); + return { command, made: { id: command.asset.id } }; + }, + shows: madeAsset, + }, + { + name: 'rename_asset', + description: 'Renames an asset.', + args: object({ asset: ASSET, name: { type: 'string', minLength: 1 } }, ['asset', 'name']), + build: (args) => ({ command: new RenameAssetCommand(text(args, 'asset'), text(args, 'name')) }), + shows: theAsset, + }, + { + name: 'duplicate_asset', + description: 'Copies an asset; the copy comes right behind it in the list.', + args: object({ asset: ASSET }, ['asset']), + build: (args) => { + const command = new DuplicateAssetCommand(text(args, 'asset')); + return { command, made: { id: command.copyId } }; + }, + shows: madeAsset, + }, + { + name: 'delete_asset', + description: + 'Deletes an asset and what depends on it (placed copies of it, for one). The user is asked first and may say no.', + args: object({ asset: ASSET }, ['asset']), + confirms: true, + build: (args) => ({ command: new DeleteAssetCommand(text(args, 'asset')) }), + }, + { + name: 'set_asset_property', + description: "Sets a property of an asset by its key (see get_asset, or catalog topic 'asset_types').", + args: object({ asset: ASSET, key: id('Key of the property.'), value: anyValue }, [ + 'asset', + 'key', + 'value', + ]), + build: (args) => ({ + command: new SetAssetPropertyCommand( + text(args, 'asset'), + text(args, 'key'), + args.value as PropertyValue, + ), + }), + shows: theAsset, + }, + { + name: 'reset_asset_property', + description: 'Puts a property of an asset back to what a new asset starts with.', + args: object({ asset: ASSET, key: id('Key of the property.') }, ['asset', 'key']), + build: (args) => ({ command: new ResetAssetPropertyCommand(text(args, 'asset'), text(args, 'key')) }), + shows: theAsset, + }, + { + name: 'set_start_scene', + description: 'Makes a scene the one the game starts in; an empty scene id takes that back.', + args: object({ scene: { type: 'string', description: 'Id of the scene, or "".' } }, ['scene']), + build: (args) => ({ command: new SetStartSceneCommand(text(args, 'scene')) }), + }, + { + name: 'set_machine_scope', + description: + 'How many current states a state machine keeps: local, one per behavior running it; global, one for the ' + + 'whole run; scene, one that starts over with every scene.', + args: object({ asset: ASSET, scope: oneOf(['local', 'global', 'scene'], 'The scope.') }, [ + 'asset', + 'scope', + ]), + build: (args) => ({ + command: new SetMachineScopeCommand(text(args, 'asset'), text(args, 'scope') as Scope), + }), + shows: theAsset, + }, + { + name: 'set_machine_start', + description: 'Makes a state node the state a machine starts in; an empty node id takes the mark off.', + args: object({ asset: ASSET, node: { type: 'string', description: 'Id of the state node, or "".' } }, [ + 'asset', + 'node', + ]), + build: (args) => ({ command: new SetMachineStartCommand(text(args, 'asset'), text(args, 'node')) }), + shows: theAsset, + }, + { + name: 'set_variables_scope', + description: + 'How long the values of a Variables asset live: global, the whole run; scene, built again with every scene ' + + '(which also switches persistent off).', + args: object({ asset: ASSET, scope: oneOf(['global', 'scene'], 'The scope.') }, ['asset', 'scope']), + build: (args) => ({ + command: new SetVariablesScopeCommand(text(args, 'asset'), text(args, 'scope') as SharedScope), + }), + shows: theAsset, + }, + { + name: 'set_variables_persistent', + description: 'Whether a Variables asset keeps its values past the end of the run, saved in the browser.', + args: object({ asset: ASSET, persistent: flag('Keep them.') }, ['asset', 'persistent']), + build: (args) => ({ + command: new SetVariablesPersistentCommand(text(args, 'asset'), args.persistent as boolean), + }), + shows: theAsset, + }, + { + name: 'make_project_asset', + description: + "Makes an asset a song keeps to itself (it has an owner in list_assets) one of the project's own, shown in the lists.", + args: object({ asset: ASSET }, ['asset']), + build: (args) => ({ command: new MakeProjectAssetCommand(text(args, 'asset')) }), + shows: theAsset, + }, +]; diff --git a/src/editor/agent/catalog/graphs.ts b/src/editor/agent/catalog/graphs.ts new file mode 100644 index 00000000..9ea13c76 --- /dev/null +++ b/src/editor/agent/catalog/graphs.ts @@ -0,0 +1,675 @@ +import { + AddAnnotationCommand, + RemoveAnnotationCommand, + SetAnnotationBoxCommand, + SetAnnotationColorCommand, + SetAnnotationTextCommand, +} from '../../../core/commands/annotationCommands'; +import { CommandError } from '../../../core/commands/Command'; +import { CompoundCommand } from '../../../core/commands/CompoundCommand'; +import { + AddNodeCommand, + CollapseNodesCommand, + ConnectCommand, + DisconnectCommand, + FoldEdgeCommand, + MoveNodeCommand, + NameTransitionCommand, + RemoveNodeCommand, + ResizeNodeCommand, + SetNodeParamCommand, +} from '../../../core/commands/graphCommands'; +import { + AddPortCommand, + MovePortCommand, + RemovePortCommand, + RenamePortCommand, + RepointPortCommand, + SetPortDefaultCommand, + SetPortMachineCommand, + SetPortWriteCommand, +} from '../../../core/commands/portCommands'; +import { + SetListNodeTypeCommand, + SetStructTypeCommand, + SplitOutputCommand, +} from '../../../core/commands/structTypeCommands'; +import { + type AnnotationKind, + type DeclaredPort, + findAnnotation, + findNode, + type Graph, + portKindOf, +} from '../../../core/graph'; +import type { PropertyBag, PropertyValue } from '../../../core/model/Properties'; +import { findGraphAsset, type Project } from '../../../core/model/Project'; +import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds'; +import { coerceValue } from '../../../core/properties/PropertySchema'; +import { parseHex } from '../../../ui/color'; +import { groupBoxAround, noteBoxAbove } from '../../graph/annotationDrag'; +import { arrangeCommand, layoutOf } from '../../graph/graphLayout'; +import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries'; +import { pickedPortChoices } from '../../graph/portChoices'; +import { + type Args, + type BuildContext, + type CatalogCommand, + flag, + GRAPH, + id, + ids, + listOf, + NODE, + number, + numberOf, + object, + oneOf, + text, + theGraph, + theNode, +} from './shared'; + +const PORT_END = object({ node: NODE, port: id('Key of the port, as get_graph lists it.') }, [ + 'node', + 'port', +]); +const ANNOTATION = id('Id of the group or comment, as get_graph lists it.'); +const PORT = id('Key of the declared port.'); +const COLOR = { + type: 'string' as const, + description: 'A color as "#rrggbb"; "" for the default.', +}; +const PLACE = (description: string) => ({ type: 'integer' as const, minimum: 0, description }); + +const theNodes = (args: Args) => ({ + kind: 'nodes' as const, + asset: text(args, 'asset'), + ids: listOf(args, 'nodes'), +}); + +export const GRAPH_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_node', + description: + "Adds a node to a graph. `node` is an entry of catalog topic 'nodes' for that graph (its id, e.g. " + + '"OnKeyHeld", "Move", or "RunBehavior:"). Leave x and y out: when the call has run, the editor ' + + 'arranges the nodes added without a place, flow left to right, and leaves the others where they are.', + args: object( + { + asset: GRAPH, + node: id('The entry id from the catalog.'), + params: { type: 'object', description: 'Settings and typed-in input values by key.' }, + x: number('Only to place it by hand.'), + y: number('Only to place it by hand.'), + }, + ['asset', 'node'], + ), + build: (args, context) => { + const assetId = text(args, 'asset'); + const entry = paletteEntry(context.project, assetId, text(args, 'node')); + const placed = typeof args.x === 'number' && typeof args.y === 'number'; + const spot = placed ? { x: args.x as number, y: args.y as number } : context.spot(assetId); + const params = { ...entry.params, ...coercedParams(entry, (args.params ?? {}) as PropertyBag) }; + const command = new AddNodeCommand(assetId, entry.def.type, spot.x, spot.y, params); + const made: Record = { id: command.node.id }; + if (command.closing) made.closing = command.closing.id; + return placed + ? { command, made } + : { command, made, unplaced: { asset: assetId, nodes: Object.values(made) } }; + }, + shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }), + }, + { + name: 'arrange_graph', + description: + 'Arranges nodes of a graph as the Arrange button does: flow left to right, the order that crosses the ' + + 'fewest links, no overlaps. Without nodes it arranges the whole graph – moving what the user placed, so ' + + 'only do that when asked.', + args: object({ asset: GRAPH, nodes: ids('The nodes to arrange; the rest stay.') }, ['asset']), + build: (args, context) => { + const assetId = text(args, 'asset'); + const nodes = (args.nodes as string[] | undefined) ?? null; + requireNodes(context.project, assetId, nodes ?? []); + const command = arrangeCommand(context.project, assetId, nodes, context.measure(assetId)); + return { command: command ?? new CompoundCommand('Arrange nodes', []) }; + }, + shows: (args) => ({ + kind: 'nodes', + asset: text(args, 'asset'), + ids: (args.nodes as string[] | undefined) ?? [], + }), + }, + { + name: 'remove_node', + description: 'Removes a node and every edge on it.', + args: object({ asset: GRAPH, node: NODE }, ['asset', 'node']), + build: (args) => ({ command: new RemoveNodeCommand(text(args, 'asset'), text(args, 'node')) }), + shows: theGraph, + }, + { + name: 'move_node', + description: 'Moves a node in its graph.', + args: object({ asset: GRAPH, node: NODE, x: number('x'), y: number('y') }, ['asset', 'node', 'x', 'y']), + build: (args) => ({ + command: new MoveNodeCommand( + text(args, 'asset'), + text(args, 'node'), + numberOf(args, 'x'), + numberOf(args, 'y'), + ), + }), + shows: theNode, + }, + { + name: 'set_node_param', + description: 'Sets a setting of a node, or the value typed into one of its inputs that has no edge.', + args: object( + { + asset: GRAPH, + node: NODE, + key: id('Key of the setting or input.'), + value: { description: 'Any JSON value.' }, + }, + ['asset', 'node', 'key', 'value'], + ), + build: (args) => ({ + command: new SetNodeParamCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'key'), + args.value as PropertyValue, + ), + }), + shows: theNode, + }, + { + name: 'collapse_nodes', + description: 'Draws nodes as their title bar alone, or in full again; it changes nothing they do.', + args: object({ asset: GRAPH, nodes: ids('Ids of the nodes.'), collapsed: flag('Collapse them.') }, [ + 'asset', + 'nodes', + 'collapsed', + ]), + build: (args) => ({ + command: new CollapseNodesCommand( + text(args, 'asset'), + listOf(args, 'nodes'), + args.collapsed as boolean, + ), + }), + shows: theNodes, + }, + { + name: 'resize_node', + description: 'Sets the size of a node whose face can be dragged bigger (a preview, for one).', + args: object({ asset: GRAPH, node: NODE, w: number('Width.'), h: number('Height.') }, [ + 'asset', + 'node', + 'w', + 'h', + ]), + build: (args) => ({ + command: new ResizeNodeCommand(text(args, 'asset'), text(args, 'node'), { + w: numberOf(args, 'w'), + h: numberOf(args, 'h'), + }), + }), + shows: theNode, + }, + { + name: 'connect', + description: + 'Draws an edge from an output to an input. Exec ports carry the flow, the others values; an input ' + + 'that holds one value loses the edge it had.', + args: object({ asset: GRAPH, from: PORT_END, to: PORT_END }, ['asset', 'from', 'to']), + build: (args) => { + const end = (key: string) => args[key] as { node: string; port: string }; + const command = new ConnectCommand(text(args, 'asset'), end('from'), end('to')); + return { command, made: { id: command.edge.id } }; + }, + shows: (args) => ({ + kind: 'nodes', + asset: text(args, 'asset'), + ids: (['from', 'to'] as const).map((key) => (args[key] as { node: string }).node), + }), + }, + { + name: 'disconnect', + description: 'Removes an edge.', + args: object({ asset: GRAPH, edge: id('Id of the edge.') }, ['asset', 'edge']), + build: (args) => ({ command: new DisconnectCommand(text(args, 'asset'), text(args, 'edge')) }), + shows: theGraph, + }, + { + name: 'fold_edge', + description: + 'Hides an edge as a short stub at both ends, or shows it again – for a link that would cross the whole ' + + 'graph. It keeps carrying its value either way.', + args: object({ asset: GRAPH, edge: id('Id of the edge.'), folded: flag('Hide it.') }, [ + 'asset', + 'edge', + 'folded', + ]), + build: (args) => ({ + command: new FoldEdgeCommand(text(args, 'asset'), text(args, 'edge'), args.folded as boolean), + }), + shows: theGraph, + }, + { + name: 'name_transition', + description: + 'Names a transition of a state machine (an edge between states), which a Go to node can then take; "" clears it.', + args: object({ asset: GRAPH, edge: id('Id of the edge.'), name: { type: 'string' } }, [ + 'asset', + 'edge', + 'name', + ]), + build: (args) => ({ + command: new NameTransitionCommand(text(args, 'asset'), text(args, 'edge'), text(args, 'name')), + }), + shows: theGraph, + }, + { + name: 'add_group', + description: + 'Draws a titled group around nodes that belong together; it fits them once the call has arranged its ' + + 'nodes. Moving the group moves what is in it.', + args: object( + { asset: GRAPH, nodes: ids('The nodes it holds.'), title: { type: 'string' }, color: COLOR }, + ['asset', 'nodes', 'title'], + ), + build: (args, context) => annotationAt('group', args, context), + shows: theNodes, + }, + { + name: 'add_comment', + description: + 'Puts a comment just above nodes, saying what they do; it follows them as the call arranges them.', + args: object( + { asset: GRAPH, nodes: ids('The nodes it is about.'), text: { type: 'string' }, color: COLOR }, + ['asset', 'nodes', 'text'], + ), + build: (args, context) => annotationAt('note', args, context), + shows: theNodes, + }, + { + name: 'fit_group', + description: 'Fits a group (or puts a comment back above) to the nodes given.', + args: object({ asset: GRAPH, id: ANNOTATION, nodes: ids('The nodes.') }, ['asset', 'id', 'nodes']), + build: (args, context) => { + const assetId = text(args, 'asset'); + const { kind } = requireAnnotationOf(context.project, assetId, text(args, 'id')); + const box = boxAt(kind, context, assetId, listOf(args, 'nodes')); + return { command: new SetAnnotationBoxCommand(assetId, kind, text(args, 'id'), box) }; + }, + shows: theNodes, + }, + { + name: 'edit_annotation', + description: "Changes a group's title or a comment's text, its color, or its box.", + args: object( + { + asset: GRAPH, + id: ANNOTATION, + text: { type: 'string', description: 'The title of a group, the text of a comment.' }, + color: COLOR, + x: number('Box left.'), + y: number('Box top.'), + w: number('Box width.'), + h: number('Box height.'), + }, + ['asset', 'id'], + ), + build: (args, { project }) => { + const assetId = text(args, 'asset'); + const annotationId = text(args, 'id'); + const { kind, annotation } = requireAnnotationOf(project, assetId, annotationId); + const steps = []; + if (typeof args.text === 'string') + steps.push(new SetAnnotationTextCommand(assetId, kind, annotationId, args.text)); + if (typeof args.color === 'string') + steps.push(new SetAnnotationColorCommand(assetId, kind, annotationId, colorOf(args.color))); + const box = (['x', 'y', 'w', 'h'] as const).filter((key) => typeof args[key] === 'number'); + if (box.length > 0) { + const next = { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h }; + for (const key of box) next[key] = numberOf(args, key); + steps.push(new SetAnnotationBoxCommand(assetId, kind, annotationId, next)); + } + if (steps.length === 0) throw new CommandError('Name what to change: text, color or the box'); + return { command: new CompoundCommand('Edit annotation', steps) }; + }, + shows: theGraph, + }, + { + name: 'remove_annotation', + description: 'Removes a group or comment; the nodes it framed stay.', + args: object({ asset: GRAPH, id: ANNOTATION }, ['asset', 'id']), + build: (args, { project }) => { + const assetId = text(args, 'asset'); + const { kind } = requireAnnotationOf(project, assetId, text(args, 'id')); + return { command: new RemoveAnnotationCommand(assetId, kind, text(args, 'id')) }; + }, + shows: theGraph, + }, + { + name: 'add_port', + description: + 'Adds a declared port to a node that has them: a Variables node (key = the variable it reads or writes), a ' + + 'Properties node (key = the property), Sound FX parameters (key = the parameter) – their kind comes from ' + + 'what they name; a start or end node of a behavior or function, a struct Pack/Expand, a sound FX Input – ' + + 'named freely with a kind. get_graph lists the ports a node has under ports.', + args: object( + { + asset: GRAPH, + node: NODE, + key: id('Name of the port.'), + kind: oneOf(DECLARED_PORT_KINDS, 'Its kind; leave out where it comes from what the port names.'), + }, + ['asset', 'node', 'key'], + ), + build: (args, { project }) => { + const assetId = text(args, 'asset'); + const port = declaredPort( + project, + assetId, + text(args, 'node'), + text(args, 'key'), + args.kind as DeclaredPortKind, + ); + return { command: new AddPortCommand(assetId, text(args, 'node'), port) }; + }, + shows: theNode, + }, + { + name: 'repoint_port', + description: + 'Points a declared port of a Variables, Properties or Sound FX parameters node at another variable, property ' + + 'or parameter; its edges come along.', + args: object({ asset: GRAPH, node: NODE, from: PORT, to: id('What it names now.') }, [ + 'asset', + 'node', + 'from', + 'to', + ]), + build: (args, { project }) => { + const assetId = text(args, 'asset'); + const port = declaredPort(project, assetId, text(args, 'node'), text(args, 'to')); + return { command: new RepointPortCommand(assetId, text(args, 'node'), text(args, 'from'), port) }; + }, + shows: theNode, + }, + { + name: 'remove_port', + description: + 'Removes a declared port of a node, with the edges on it (also on the nodes calling the behavior).', + args: object({ asset: GRAPH, node: NODE, key: PORT }, ['asset', 'node', 'key']), + build: (args) => ({ + command: new RemovePortCommand(text(args, 'asset'), text(args, 'node'), text(args, 'key')), + }), + shows: theNode, + }, + { + name: 'rename_port', + description: 'Renames a declared port named freely (start, end, struct field); edges and callers follow.', + args: object({ asset: GRAPH, node: NODE, from: PORT, to: id('Its new name.') }, [ + 'asset', + 'node', + 'from', + 'to', + ]), + build: (args) => ({ + command: new RenamePortCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'from'), + text(args, 'to'), + ), + }), + shows: theNode, + }, + { + name: 'move_port', + description: "Moves a declared port to another place in the node's list; edges stay on it.", + args: object( + { asset: GRAPH, node: NODE, from: PLACE('Its place now, from 0.'), to: PLACE('Its new place.') }, + ['asset', 'node', 'from', 'to'], + ), + build: (args) => ({ + command: new MovePortCommand( + text(args, 'asset'), + text(args, 'node'), + numberOf(args, 'from'), + numberOf(args, 'to'), + ), + }), + shows: theNode, + }, + { + name: 'set_port_write', + description: + 'On a Variables or Properties node: whether it writes this port too (it then gets an input and flow ports), or only reads it.', + args: object({ asset: GRAPH, node: NODE, key: PORT, write: flag('Write it.') }, [ + 'asset', + 'node', + 'key', + 'write', + ]), + build: (args) => ({ + command: new SetPortWriteCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'key'), + args.write as boolean, + ), + }), + shows: theNode, + }, + { + name: 'set_port_machine', + description: + 'Which state machine a port of kind statemachine carries (on a start node or a Variables node), so the state nodes behind it offer its states; "" for none.', + args: object( + { + asset: GRAPH, + node: NODE, + key: PORT, + machine: { type: 'string', description: 'Id of the state machine, or "".' }, + }, + ['asset', 'node', 'key', 'machine'], + ), + build: (args) => ({ + command: new SetPortMachineCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'key'), + text(args, 'machine'), + ), + }), + shows: theNode, + }, + { + name: 'set_port_default', + description: 'The default of a parameter of a sound FX Input or an instrument Parameters node.', + args: object({ asset: GRAPH, node: NODE, key: PORT, value: number('The default.') }, [ + 'asset', + 'node', + 'key', + 'value', + ]), + build: (args) => ({ + command: new SetPortDefaultCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'key'), + numberOf(args, 'value'), + ), + }), + shows: theNode, + }, + { + name: 'set_node_struct_type', + description: + 'Gives a struct Pack or Expand node the fields of a Struct asset as its ports; "" gives it fields of its own again.', + args: object( + { asset: GRAPH, node: NODE, type: { type: 'string', description: 'Id of the Struct asset, or "".' } }, + ['asset', 'node', 'type'], + ), + build: (args) => ({ + command: new SetStructTypeCommand( + { kind: 'node', assetId: text(args, 'asset'), nodeId: text(args, 'node') }, + text(args, 'type'), + ), + }), + shows: theNode, + }, + { + name: 'split_output', + description: + 'Takes a struct output apart into one output per field of a Struct asset (ports "."); "" puts it together again.', + args: object( + { + asset: GRAPH, + node: NODE, + port: id('Key of the output.'), + type: { type: 'string', description: 'Id of the Struct asset, or "".' }, + }, + ['asset', 'node', 'port', 'type'], + ), + build: (args) => ({ + command: new SplitOutputCommand( + text(args, 'asset'), + text(args, 'node'), + text(args, 'port'), + text(args, 'type'), + ), + }), + shows: theNode, + }, + { + name: 'set_list_node_type', + description: 'The item type of a List node, a Struct asset; "" makes it a mixed list.', + args: object( + { asset: GRAPH, node: NODE, type: { type: 'string', description: 'Id of the Struct asset, or "".' } }, + ['asset', 'node', 'type'], + ), + build: (args) => ({ + command: new SetListNodeTypeCommand(text(args, 'asset'), text(args, 'node'), text(args, 'type')), + }), + shows: theNode, + }, +]; + +/** The palette entry a node is added from: by its id, else by its label when only one has it. */ +export function paletteEntry(project: Project, assetId: string, wanted: string): PaletteEntry { + const holder = findGraphAsset(project, assetId); + if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); + const entries = paletteEntries(project, holder); + const byId = entries.find((entry) => entry.id === wanted); + if (byId) return byId; + const byLabel = entries.filter((entry) => entry.def.label.toLowerCase() === wanted.toLowerCase()); + if (byLabel.length === 1) return byLabel[0]!; + throw new CommandError( + `A ${holder.type} graph has no node "${wanted}" – look it up with catalog topic "nodes" and this asset`, + ); +} + +/** Settings are held to their kind, as the inspector holds them; a typed-in input value stays as given. */ +function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag { + const coerced: PropertyBag = {}; + for (const [key, value] of Object.entries(params)) { + const param = entry.def.params.find((p) => p.key === key); + coerced[key] = param && param.kind !== 'enum' ? coerceValue(param, value) : value; + } + return coerced; +} + +function requireGraphOf(project: Project, assetId: string): Graph { + const holder = findGraphAsset(project, assetId); + if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); + return holder.graph; +} + +function requireNodes(project: Project, assetId: string, nodeIds: readonly string[]): void { + const holder = findGraphAsset(project, assetId); + if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); + const unknown = nodeIds.find((nodeId) => !findNode(holder.graph, nodeId)); + if (unknown !== undefined) throw new CommandError(`${holder.name} has no node ${unknown}`); +} + +function requireAnnotationOf(project: Project, assetId: string, annotationId: string) { + const found = findAnnotation(requireGraphOf(project, assetId), annotationId); + if (!found) throw new CommandError(`The graph has no group or comment ${annotationId}`); + return found; +} + +function colorOf(value: string): number | null { + if (value === '') return null; + const color = parseHex(value); + if (color === null) throw new CommandError(`"${value}" is no color – write "#rrggbb"`); + return color; +} + +/** Where a group or comment on these nodes goes, by the nodes as they stand now. */ +export function boxAt( + kind: AnnotationKind, + context: BuildContext, + assetId: string, + nodeIds: readonly string[], +) { + requireNodes(context.project, assetId, nodeIds); + const wanted = new Set(nodeIds); + const boxes = (layoutOf(context.project, assetId, context.measure(assetId))?.nodes ?? []).filter((node) => + wanted.has(node.id), + ); + const box = kind === 'group' ? groupBoxAround(boxes) : noteBoxAbove(boxes); + if (!box) throw new CommandError('None of those nodes is drawn'); + return box; +} + +function annotationAt(kind: AnnotationKind, args: Args, context: BuildContext) { + const assetId = text(args, 'asset'); + const nodes = listOf(args, 'nodes'); + const words = text(args, kind === 'group' ? 'title' : 'text'); + const add = new AddAnnotationCommand(assetId, kind, boxAt(kind, context, assetId, nodes), words); + const color = typeof args.color === 'string' ? colorOf(args.color) : null; + const command = + color === null + ? add + : new CompoundCommand(add.label, [ + add, + new SetAnnotationColorCommand(assetId, kind, add.annotation.id, color), + ]); + return { + command, + made: { id: add.annotation.id }, + frames: { asset: assetId, kind, id: add.annotation.id, nodes }, + }; +} + +/** A port as a node takes it: picked ones in the kind of what they name, the others in the kind given. */ +function declaredPort( + project: Project, + assetId: string, + nodeId: string, + key: string, + kind?: DeclaredPortKind, +): DeclaredPort { + const node = findNode(requireGraphOf(project, assetId), nodeId); + if (!node) throw new CommandError(`The graph has no node ${nodeId}`); + const choices = pickedPortChoices(project, assetId, node); + if (choices === null) { + if (!kind) throw new CommandError('Give the port a kind'); + return { key, kind }; + } + const def = choices.find((choice) => choice.key === key); + if (!def) { + const offered = choices.map((choice) => choice.key); + throw new CommandError( + offered.length > 0 + ? `"${key}" is none of what this node can name: ${offered.join(', ')}` + : 'This node has nothing to name yet: give its owner variables, or pick its source in its settings first', + ); + } + return { key, kind: portKindOf(def) }; +} diff --git a/src/editor/agent/catalog/objects.ts b/src/editor/agent/catalog/objects.ts new file mode 100644 index 00000000..3927a486 --- /dev/null +++ b/src/editor/agent/catalog/objects.ts @@ -0,0 +1,373 @@ +import { + ApplyOverrideToAssetCommand, + ApplyOverrideToContainerCommand, + ApplyPositionToContainerCommand, + RevertOverrideToTemplateCommand, + RevertPositionToTemplateCommand, +} from '../../../core/commands/applyCommands'; +import type { Command } from '../../../core/commands/Command'; +import { + AddColliderCommand, + AddJointCommand, + AddRigidbodyCommand, + FitColliderCommand, +} from '../../../core/commands/colliderCommands'; +import { CreateContainerCommand, UnpackContainerCommand } from '../../../core/commands/containerCommands'; +import { + GroupObjectsCommand, + RenameGroupCommand, + SetObjectGroupCommand, + UngroupCommand, +} from '../../../core/commands/groupCommands'; +import { + ClearOverrideCommand, + MoveSceneObjectCommand, + PlaceAssetCommand, + RemoveSceneObjectsCommand, + RenameSceneObjectCommand, + ReorderObjectCommand, + SetEditorOffsetCommand, + SetOverrideCommand, + SetParentCommand, +} from '../../../core/commands/sceneObjectCommands'; +import type { Axis, PropertyValue } from '../../../core/model/Properties'; +import { findObjectHolder, type Project } from '../../../core/model/Project'; +import { + anyValue, + type Args, + type CatalogCommand, + HOLDER, + id, + ids, + listOf, + number, + numberOf, + OBJECT, + object, + oneOf, + text, + theObject, + theObjects, +} from './shared'; + +const KEY = id('Key of the property.'); +const AXIS = oneOf(['x', 'y', 'z'], 'Only this axis; leave out for all three.'); +const GROUP = id('Id of the group, as get_asset lists it under groups.'); + +const inHolder = (args: Args, objectIds: string[]) => ({ + kind: 'objects' as const, + in: text(args, 'in'), + ids: objectIds, +}); + +/** A part placed under an object: a new asset, or one already there to share. */ +const part = ( + name: string, + noun: string, + make: (holder: string, target: string, existing: string) => Command & { assetId: string; objectId: string }, + more: string, +): CatalogCommand => ({ + name, + description: `Gives a placed object a ${noun} as a child, at its centre: a new ${noun} asset, or the one named by asset. ${more}`, + args: { + type: 'object', + properties: { + in: HOLDER, + object: OBJECT, + asset: { type: 'string', description: `Id of a ${noun} asset to share; leave out for a new one.` }, + }, + required: ['in', 'object'], + additionalProperties: false, + }, + build: (args) => { + const command = make(text(args, 'in'), text(args, 'object'), (args.asset as string | undefined) ?? ''); + return { command, made: { id: command.objectId, asset: command.assetId } }; + }, + shows: (args, made) => inHolder(args, [made.id!]), +}); + +export const OBJECT_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'place_asset', + description: + 'Places an asset into a scene or container as a new object, at a position in the world. ' + + 'A behavior, collider or rigidbody is placed under the object it belongs to (parent).', + args: object( + { + in: HOLDER, + asset: id('Id of the asset.'), + x: number('World x, pixels.'), + y: number('World y, pixels, down is positive.'), + parent: { + type: 'string', + description: 'Id of the object to place it under, or "" for the top level.', + }, + }, + ['in', 'asset', 'x', 'y'], + ), + build: (args) => { + const command = new PlaceAssetCommand( + text(args, 'in'), + text(args, 'asset'), + { x: numberOf(args, 'x'), y: numberOf(args, 'y') }, + (args.parent as string | undefined) ?? '', + ); + return { command, made: { id: command.objectId } }; + }, + shows: (args, made) => inHolder(args, [made.id!]), + }, + { + name: 'remove_objects', + description: 'Removes placed objects together with everything placed under them.', + args: object({ in: HOLDER, objects: ids('Ids of the objects.') }, ['in', 'objects']), + build: (args) => ({ command: new RemoveSceneObjectsCommand(text(args, 'in'), listOf(args, 'objects')) }), + shows: (args) => inHolder(args, []), + }, + { + name: 'move_object', + description: 'Moves a placed object; x and y are relative to its parent (the world at the top level).', + args: object({ in: HOLDER, object: OBJECT, x: number('x'), y: number('y'), z: number('Draw order.') }, [ + 'in', + 'object', + 'x', + 'y', + ]), + build: (args, { project }) => { + const z = + typeof args.z === 'number' ? args.z : currentZ(project, text(args, 'in'), text(args, 'object')); + return { + command: new MoveSceneObjectCommand(text(args, 'in'), text(args, 'object'), { + x: numberOf(args, 'x'), + y: numberOf(args, 'y'), + z, + }), + }; + }, + shows: theObject, + }, + { + name: 'reorder_object', + description: + 'Moves an object among its siblings, right before another one ("" for the end). Objects with the same z ' + + 'draw in this order, the later in front.', + args: object( + { in: HOLDER, object: OBJECT, before: { type: 'string', description: 'Id of the sibling, or "".' } }, + ['in', 'object', 'before'], + ), + build: (args) => ({ + command: new ReorderObjectCommand(text(args, 'in'), text(args, 'object'), text(args, 'before')), + }), + shows: theObject, + }, + { + name: 'set_editor_offset', + description: + 'Shifts where an object is drawn in the editor only, to get it out of the way; the game does not see it.', + args: object({ in: HOLDER, object: OBJECT, x: number('x'), y: number('y') }, ['in', 'object', 'x', 'y']), + build: (args) => ({ + command: new SetEditorOffsetCommand(text(args, 'in'), text(args, 'object'), { + x: numberOf(args, 'x'), + y: numberOf(args, 'y'), + }), + }), + shows: theObject, + }, + { + name: 'rename_object', + description: 'Names a placed object; "" goes back to the name of its asset.', + args: object({ in: HOLDER, object: OBJECT, name: { type: 'string' } }, ['in', 'object', 'name']), + build: (args) => ({ + command: new RenameSceneObjectCommand(text(args, 'in'), text(args, 'object'), text(args, 'name')), + }), + shows: theObject, + }, + { + name: 'set_override', + description: + 'Gives one placed object its own value for a property of its asset. Content of a placed container has ids ' + + 'like "~", as get_asset lists them.', + args: object({ in: HOLDER, object: OBJECT, key: KEY, value: anyValue }, ['in', 'object', 'key', 'value']), + build: (args) => ({ + command: new SetOverrideCommand( + text(args, 'in'), + text(args, 'object'), + text(args, 'key'), + args.value as PropertyValue, + ), + }), + shows: theObject, + }, + { + name: 'clear_override', + description: "Lets a placed object follow its asset's value for a property again.", + args: object({ in: HOLDER, object: OBJECT, key: KEY }, ['in', 'object', 'key']), + build: (args) => ({ + command: new ClearOverrideCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')), + }), + shows: theObject, + }, + { + name: 'apply_override_to_asset', + description: + "Writes a placed object's own value for a property into its asset, so every placement has it, and drops the override.", + args: object({ in: HOLDER, object: OBJECT, key: KEY }, ['in', 'object', 'key']), + build: (args) => ({ + command: new ApplyOverrideToAssetCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')), + }), + shows: theObject, + }, + { + name: 'apply_override_to_container', + description: + 'For content of a placed container: writes its own value for a property into the container, so every placement of the container has it.', + args: object({ in: HOLDER, object: OBJECT, key: KEY }, ['in', 'object', 'key']), + build: (args) => ({ + command: new ApplyOverrideToContainerCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')), + }), + shows: theObject, + }, + { + name: 'revert_override_to_container', + description: + 'For content of a placed container: takes its own value for a property back to the container’s.', + args: object({ in: HOLDER, object: OBJECT, key: KEY }, ['in', 'object', 'key']), + build: (args) => ({ + command: new RevertOverrideToTemplateCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')), + }), + shows: theObject, + }, + { + name: 'apply_position_to_container', + description: + 'For content of a placed container: writes its position (one axis or all) into the container.', + args: object({ in: HOLDER, object: OBJECT, axis: AXIS }, ['in', 'object']), + build: (args) => ({ + command: new ApplyPositionToContainerCommand( + text(args, 'in'), + text(args, 'object'), + args.axis as Axis | undefined, + ), + }), + shows: theObject, + }, + { + name: 'revert_position_to_container', + description: + 'For content of a placed container: takes its position (one axis or all) back to the container’s.', + args: object({ in: HOLDER, object: OBJECT, axis: AXIS }, ['in', 'object']), + build: (args) => ({ + command: new RevertPositionToTemplateCommand( + text(args, 'in'), + text(args, 'object'), + args.axis as Axis | undefined, + ), + }), + shows: theObject, + }, + { + name: 'set_parent', + description: + 'Puts a placed object under another one ("" for the top level); it keeps its place in the world.', + args: object({ in: HOLDER, object: OBJECT, parent: { type: 'string' } }, ['in', 'object', 'parent']), + build: (args) => ({ + command: new SetParentCommand(text(args, 'in'), text(args, 'object'), text(args, 'parent')), + }), + shows: theObject, + }, + { + name: 'group_objects', + description: + 'Puts top-level objects into a new named group of the hierarchy (a folder; it does not move them).', + args: object({ in: HOLDER, objects: ids('Ids of the objects.'), name: id('Name of the group.') }, [ + 'in', + 'objects', + 'name', + ]), + build: (args) => { + const command = new GroupObjectsCommand(text(args, 'in'), listOf(args, 'objects'), text(args, 'name')); + return { command, made: { id: command.groupId } }; + }, + shows: theObjects, + }, + { + name: 'set_object_group', + description: 'Moves top-level objects into a group of the hierarchy, or out of every group with "".', + args: object( + { + in: HOLDER, + objects: ids('Ids of the objects.'), + group: { type: 'string', description: 'Id, or "".' }, + }, + ['in', 'objects', 'group'], + ), + build: (args) => ({ + command: new SetObjectGroupCommand(text(args, 'in'), listOf(args, 'objects'), text(args, 'group')), + }), + shows: theObjects, + }, + { + name: 'rename_object_group', + description: 'Renames a group of the hierarchy.', + args: object({ in: HOLDER, group: GROUP, name: id('Its new name.') }, ['in', 'group', 'name']), + build: (args) => ({ + command: new RenameGroupCommand(text(args, 'in'), text(args, 'group'), text(args, 'name')), + }), + shows: (args) => inHolder(args, []), + }, + { + name: 'ungroup_objects', + description: 'Dissolves a group of the hierarchy; its objects stay where they are.', + args: object({ in: HOLDER, group: GROUP }, ['in', 'group']), + build: (args) => ({ command: new UngroupCommand(text(args, 'in'), text(args, 'group')) }), + shows: (args) => inHolder(args, []), + }, + { + name: 'create_container', + description: + 'Turns placed objects (with what is under them) into a new container asset, placed where they were. Every ' + + 'placement of a container shows the same content.', + args: object({ in: HOLDER, objects: ids('Ids of the objects.') }, ['in', 'objects']), + build: (args) => { + const command = new CreateContainerCommand(text(args, 'in'), listOf(args, 'objects')); + return { command, made: { id: command.objectId, asset: command.assetId } }; + }, + shows: (args, made) => inHolder(args, [made.id!]), + }, + { + name: 'unpack_container', + description: + "Writes a placed container's content into the scene as ordinary objects; it stops following the container.", + args: object({ in: HOLDER, object: OBJECT }, ['in', 'object']), + build: (args) => ({ command: new UnpackContainerCommand(text(args, 'in'), text(args, 'object')) }), + shows: (args) => inHolder(args, []), + }, + part( + 'add_collider', + 'collider', + (holder, target, existing) => new AddColliderCommand(holder, target, existing), + 'A new one is sized like the object.', + ), + part( + 'add_rigidbody', + 'rigidbody', + (holder, target, existing) => new AddRigidbodyCommand(holder, target, existing), + 'It makes the object move by physics; one per object.', + ), + part( + 'add_joint', + 'joint', + (holder, target, existing) => new AddJointCommand(holder, target, existing), + 'A new one is a hinge at the centre, held to the scene.', + ), + { + name: 'fit_collider', + description: 'Sizes a placed collider to cover the sprite it is under, and centres it there.', + args: object({ in: HOLDER, object: id('Id of the placed collider.') }, ['in', 'object']), + build: (args) => ({ command: new FitColliderCommand(text(args, 'in'), text(args, 'object')) }), + shows: theObject, + }, +]; + +function currentZ(project: Project, holderId: string, objectId: string): number { + return findObjectHolder(project, holderId)?.objects.find((o) => o.id === objectId)?.position.z ?? 0; +} diff --git a/src/editor/agent/catalog/project.ts b/src/editor/agent/catalog/project.ts new file mode 100644 index 00000000..725ec666 --- /dev/null +++ b/src/editor/agent/catalog/project.ts @@ -0,0 +1,100 @@ +import { + type LayerList, + RenameProjectCommand, + SetGameLinkZoomCommand, + SetLayerNamesCommand, + SetMasterFxCommand, + SetMasterVolumeCommand, + SetPlayFromCommand, + SetSymbolScaleCommand, + SetWhilePlayingCommand, +} from '../../../core/commands/projectCommands'; +import { + GAME_LINK_ZOOMS, + type GameLinkZoom, + type PlayFrom, + WHILE_PLAYING, + type WhilePlaying, +} from '../../../core/model/playSettings'; +import { SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../../../core/model/symbolScale'; +import { type CatalogCommand, numberOf, object, oneOf, text } from './shared'; + +/** Project settings, as get_view names the project and list_assets gives its settings. */ +export const PROJECT_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'rename_project', + description: 'Renames the project.', + args: object({ name: { type: 'string', minLength: 1 } }, ['name']), + build: (args) => ({ command: new RenameProjectCommand(text(args, 'name')) }), + }, + { + name: 'set_layer_names', + description: + 'Names the collision layers (what colliders sit on and collide with) or the render layers (what cameras ' + + 'see), the first being layer 0. Objects name a layer by number: removing one from the middle shifts the ' + + 'ones after it.', + args: object( + { + list: oneOf(['collisionLayers', 'renderLayers'], 'Which list.'), + names: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + description: 'All of them.', + }, + }, + ['list', 'names'], + ), + build: (args) => ({ + command: new SetLayerNamesCommand(text(args, 'list') as LayerList, args.names as string[]), + }), + }, + { + name: 'set_play_from', + description: 'Which scene Play starts: the start scene, or the scene open in the editor.', + args: object({ from: oneOf(['start', 'active'], 'start or active.') }, ['from']), + build: (args) => ({ command: new SetPlayFromCommand(text(args, 'from') as PlayFrom) }), + }, + { + name: 'set_while_playing', + description: 'How closely the panels follow a running game: live, balanced or minimal.', + args: object({ mode: oneOf(WHILE_PLAYING, 'The mode.') }, ['mode']), + build: (args) => ({ command: new SetWhilePlayingCommand(text(args, 'mode') as WhilePlaying) }), + }, + { + name: 'set_game_link_zoom', + description: 'How the page of the released game sizes the camera frame: auto (whole steps) or fit.', + args: object({ zoom: oneOf(GAME_LINK_ZOOMS, 'The zoom.') }, ['zoom']), + build: (args) => ({ command: new SetGameLinkZoomCommand(text(args, 'zoom') as GameLinkZoom) }), + }, + { + name: 'set_symbol_scale', + description: "Size of the scene view's symbol icons, as a factor.", + args: object( + { + scale: { + type: 'number', + minimum: SYMBOL_SCALE_MIN, + maximum: SYMBOL_SCALE_MAX, + description: 'The factor.', + }, + }, + ['scale'], + ), + build: (args) => ({ command: new SetSymbolScaleCommand(numberOf(args, 'scale')) }), + }, + { + name: 'set_master_volume', + description: 'The level of the master bus every sound ends in, 0 to 1.', + args: object({ volume: { type: 'number', minimum: 0, maximum: 1, description: 'The level.' } }, [ + 'volume', + ]), + build: (args) => ({ command: new SetMasterVolumeCommand(numberOf(args, 'volume')) }), + }, + { + name: 'set_master_fx', + description: 'The sound FX on the master bus, by id; "" for none.', + args: object({ fx: { type: 'string', description: 'Id of the sound FX, or "".' } }, ['fx']), + build: (args) => ({ command: new SetMasterFxCommand(text(args, 'fx')) }), + }, +]; diff --git a/src/editor/agent/catalog/shared.ts b/src/editor/agent/catalog/shared.ts new file mode 100644 index 00000000..6d4a7dde --- /dev/null +++ b/src/editor/agent/catalog/shared.ts @@ -0,0 +1,114 @@ +import type { Command } from '../../../core/commands/Command'; +import type { AnnotationKind } from '../../../core/graph'; +import type { Project } from '../../../core/model/Project'; +import type { MeasureNode } from '../../graph/graphLayout'; +import type { ArgSchema } from '../argCheck'; + +/** What a command is built from: the project as it stands before the edit, and where new nodes go. */ +export interface BuildContext { + project: Project; + /** A free place for a node the agent gave none, right of what the graph and this edit hold. */ + spot(assetId: string): { x: number; y: number }; + /** How the nodes of a graph are measured for arranging. */ + measure(assetId: string): MeasureNode; +} + +/** The command, and the ids of what it makes – what later steps of the same edit can name. */ +export interface Built { + command: Command; + /** A function for what is only known once the command has run. */ + made?: Record | (() => Record); + /** Nodes it added without a place: they are arranged when the edit's steps have run. */ + unplaced?: { asset: string; nodes: string[] }; + /** A group or comment it put at nodes: fitted to them again once the edit has arranged its nodes. */ + frames?: Frame; +} + +export interface Frame { + asset: string; + kind: AnnotationKind; + id: string; + nodes: readonly string[]; +} + +export type Args = Record; + +/** What a step worked on, for the editor to show the user: an asset, objects of a holder, nodes of a graph. */ +export type StepTarget = + | { kind: 'asset'; asset: string } + | { kind: 'objects'; in: string; ids: string[] } + | { kind: 'nodes'; asset: string; ids: string[] }; + +/** One command the agent can run (#429): what it does, its arguments, and how it is made. */ +export interface CatalogCommand { + name: string; + description: string; + args: ArgSchema; + /** Deletes something the user has to agree to first – decision 4 of #429. */ + confirms?: true; + build(args: Args, context: BuildContext): Built; + /** What it worked on, from its arguments and what it made; none for a step there is nothing to show of. */ + shows?(args: Args, made: Record): StepTarget; +} + +export const id = (description: string): ArgSchema => ({ type: 'string', minLength: 1, description }); +export const number = (description: string): ArgSchema => ({ type: 'number', description }); +export const flag = (description: string): ArgSchema => ({ type: 'boolean', description }); +export const ids = (description: string): ArgSchema => ({ + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + description, +}); +export const oneOf = (values: readonly string[], description: string): ArgSchema => ({ + type: 'string', + enum: values, + description, +}); +export const anyValue: ArgSchema = { + description: 'Any JSON value; it is checked against the kind of the property.', +}; +export const object = (properties: Record, required: string[]): ArgSchema => ({ + type: 'object', + properties, + required, + additionalProperties: false, +}); + +export const ASSET = id('Id of the asset.'); +export const HOLDER = id('Id of the scene or container the object is in.'); +export const OBJECT = id('Id of the placed object.'); +export const GRAPH = id('Id of the asset holding the graph (behavior, state machine, shader, ...).'); +export const NODE = id('Id of the node.'); +export const PATH: ArgSchema = { + type: 'array', + items: { type: 'string', minLength: 1 }, + description: + 'Field keys leading into a struct variable, outermost first; leave out for the variable itself.', +}; + +export const text = (args: Args, key: string): string => args[key] as string; +export const numberOf = (args: Args, key: string): number => args[key] as number; +export const listOf = (args: Args, key: string): string[] => args[key] as string[]; + +export const theAsset = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset') }); +export const madeAsset = (_args: Args, made: Record): StepTarget => ({ + kind: 'asset', + asset: made.id!, +}); +export const theObject = (args: Args): StepTarget => ({ + kind: 'objects', + in: text(args, 'in'), + ids: [text(args, 'object')], +}); +export const theObjects = (args: Args): StepTarget => ({ + kind: 'objects', + in: text(args, 'in'), + ids: listOf(args, 'objects'), +}); +export const theNode = (args: Args): StepTarget => ({ + kind: 'nodes', + asset: text(args, 'asset'), + ids: [text(args, 'node')], +}); +export const theGraph = (args: Args): StepTarget => ({ kind: 'nodes', asset: text(args, 'asset'), ids: [] }); diff --git a/src/editor/agent/catalog/variables.ts b/src/editor/agent/catalog/variables.ts new file mode 100644 index 00000000..c6495a77 --- /dev/null +++ b/src/editor/agent/catalog/variables.ts @@ -0,0 +1,306 @@ +import type { Command } from '../../../core/commands/Command'; +import { + MakeListItemTypeCommand, + MakeStructTypeCommand, + SetListItemTypeCommand, + SetStructTypeCommand, + WithStructTypes, +} from '../../../core/commands/structTypeCommands'; +import { + AddStructFieldCommand, + AddVariableCommand, + MoveStructFieldCommand, + MoveVariableCommand, + RemoveStructFieldCommand, + RemoveVariableCommand, + RenameStructFieldCommand, + RenameVariableCommand, + SetVariableKeptCommand, + SetVariablePrivateCommand, +} from '../../../core/commands/variableCommands'; +import { findAsset, type Project } from '../../../core/model/Project'; +import { + NESTED_KINDS, + type NestedKind, + VARIABLE_KINDS, + type VariableKind, +} from '../../../core/properties/kinds'; +import type { StructFieldDef } from '../../../core/properties/PropertySchema'; +import type { FieldRename } from '../../../core/model/structTypes'; +import { + ASSET, + type Args, + type BuildContext, + type Built, + type CatalogCommand, + flag, + id, + numberOf, + object, + oneOf, + PATH, + text, + theAsset, +} from './shared'; + +const KEY = id('Name of the variable.'); +const PLACE = (description: string) => ({ type: 'integer' as const, minimum: 0, description }); + +const pathOf = (args: Args): string[] => (args.path as string[] | undefined) ?? []; + +/** + * A Struct asset's fields are its variables; every change to them is carried on to the places of + * that type, as the inspector does it. A rename names the field, so its values come along. + */ +function onOwner(project: Project, assetId: string, command: Command, rename?: FieldRename): Built { + const isType = findAsset(project, assetId)?.type === 'struct'; + return { command: isType ? new WithStructTypes(command, rename) : command }; +} + +const ownerEdit = + (make: (args: Args) => Command, rename?: (args: Args) => Omit) => + (args: Args, { project }: BuildContext): Built => { + const assetId = text(args, 'asset'); + const renamed = rename ? { structType: assetId, ...rename(args) } : undefined; + return onOwner(project, assetId, make(args), renamed); + }; + +const newField = (key: string, kind: NestedKind): StructFieldDef => + kind === 'struct' ? { key, kind, fields: [] } : { key, kind }; + +export const VARIABLE_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_variable', + description: + 'Adds a variable to a behavior or a Variables asset, or a field to a Struct asset, starting at the default of its kind.', + args: object( + { + asset: ASSET, + key: id('Name of the variable: letters, digits and _, not starting with a digit.'), + kind: { type: 'string', enum: VARIABLE_KINDS, description: 'What it holds.' }, + }, + ['asset', 'key', 'kind'], + ), + build: ownerEdit( + (args) => + new AddVariableCommand(text(args, 'asset'), text(args, 'key'), text(args, 'kind') as VariableKind), + ), + shows: theAsset, + }, + { + name: 'remove_variable', + description: 'Removes a variable, its value and every override of it on placed objects.', + args: object({ asset: ASSET, key: KEY }, ['asset', 'key']), + build: ownerEdit((args) => new RemoveVariableCommand(text(args, 'asset'), text(args, 'key'))), + shows: theAsset, + }, + { + name: 'rename_variable', + description: 'Renames a variable; nodes and overrides that use it follow.', + args: object({ asset: ASSET, from: id('Its name now.'), to: id('Its new name.') }, [ + 'asset', + 'from', + 'to', + ]), + build: ownerEdit( + (args) => new RenameVariableCommand(text(args, 'asset'), text(args, 'from'), text(args, 'to')), + (args) => ({ path: [], from: text(args, 'from'), to: text(args, 'to') }), + ), + shows: theAsset, + }, + { + name: 'move_variable', + description: 'Moves a variable to another place in the list; only the order changes.', + args: object({ asset: ASSET, from: PLACE('Its place now, from 0.'), to: PLACE('Its new place.') }, [ + 'asset', + 'from', + 'to', + ]), + build: ownerEdit( + (args) => new MoveVariableCommand(text(args, 'asset'), numberOf(args, 'from'), numberOf(args, 'to')), + ), + shows: theAsset, + }, + { + name: 'set_variable_kept', + description: + "Whether a behavior's variable carries on from one run of the behavior to the next instead of starting at its default.", + args: object({ asset: ASSET, key: KEY, kept: flag('Keep it.') }, ['asset', 'key', 'kept']), + build: (args) => ({ + command: new SetVariableKeptCommand(text(args, 'asset'), text(args, 'key'), args.kept as boolean), + }), + shows: theAsset, + }, + { + name: 'set_variable_private', + description: + "Whether a behavior's variable is hidden outside its own graph. Making it private drops its overrides on placed objects.", + args: object({ asset: ASSET, key: KEY, private: flag('Hide it.') }, ['asset', 'key', 'private']), + build: (args) => ({ + command: new SetVariablePrivateCommand(text(args, 'asset'), text(args, 'key'), args.private as boolean), + }), + shows: theAsset, + }, + { + name: 'add_struct_field', + description: 'Adds a field to a struct variable (or to a struct field inside it, by path).', + args: object( + { + asset: ASSET, + key: KEY, + field: id('Name of the new field.'), + kind: oneOf(NESTED_KINDS, 'What the field holds.'), + path: PATH, + }, + ['asset', 'key', 'field', 'kind'], + ), + build: ownerEdit( + (args) => + new AddStructFieldCommand( + text(args, 'asset'), + text(args, 'key'), + newField(text(args, 'field'), text(args, 'kind') as NestedKind), + pathOf(args), + ), + ), + shows: theAsset, + }, + { + name: 'remove_struct_field', + description: 'Removes a field of a struct variable, and its value from every struct carrying it.', + args: object({ asset: ASSET, key: KEY, field: id('Name of the field.'), path: PATH }, [ + 'asset', + 'key', + 'field', + ]), + build: ownerEdit( + (args) => + new RemoveStructFieldCommand( + text(args, 'asset'), + text(args, 'key'), + text(args, 'field'), + pathOf(args), + ), + ), + shows: theAsset, + }, + { + name: 'rename_struct_field', + description: 'Renames a field of a struct variable; its values follow.', + args: object({ asset: ASSET, key: KEY, from: id('Its name now.'), to: id('Its new name.'), path: PATH }, [ + 'asset', + 'key', + 'from', + 'to', + ]), + build: ownerEdit( + (args) => + new RenameStructFieldCommand( + text(args, 'asset'), + text(args, 'key'), + text(args, 'from'), + text(args, 'to'), + pathOf(args), + ), + (args) => ({ + path: [text(args, 'key'), ...pathOf(args)], + from: text(args, 'from'), + to: text(args, 'to'), + }), + ), + shows: theAsset, + }, + { + name: 'move_struct_field', + description: 'Moves a field of a struct variable to another place; only the order changes.', + args: object( + { + asset: ASSET, + key: KEY, + from: PLACE('Its place now, from 0.'), + to: PLACE('Its new place.'), + path: PATH, + }, + ['asset', 'key', 'from', 'to'], + ), + build: ownerEdit( + (args) => + new MoveStructFieldCommand( + text(args, 'asset'), + text(args, 'key'), + numberOf(args, 'from'), + numberOf(args, 'to'), + pathOf(args), + ), + ), + shows: theAsset, + }, + { + name: 'set_struct_type', + description: + 'Gives a struct variable (or a struct field inside it, by path) the fields of a Struct asset; an empty type ' + + 'gives it fields of its own again.', + args: object( + { + asset: ASSET, + key: KEY, + type: { type: 'string', description: 'Id of the Struct asset, or "".' }, + path: PATH, + }, + ['asset', 'key', 'type'], + ), + build: (args) => ({ + command: new SetStructTypeCommand( + { kind: 'variable', ownerId: text(args, 'asset'), key: text(args, 'key'), path: pathOf(args) }, + text(args, 'type'), + ), + }), + shows: theAsset, + }, + { + name: 'make_struct_type', + description: + 'Turns the fields a struct variable holds into a new Struct asset and gives the variable that type.', + args: object({ asset: ASSET, key: KEY, path: PATH }, ['asset', 'key']), + build: (args) => { + const command = new MakeStructTypeCommand(text(args, 'asset'), text(args, 'key'), pathOf(args)); + return { command, made: () => ({ id: command.madeId! }) }; + }, + shows: theAsset, + }, + { + name: 'set_list_item_type', + description: + 'Makes every item of a list variable (or list field, by path) one Struct asset; an empty type makes it a mixed list again.', + args: object( + { + asset: ASSET, + key: KEY, + type: { type: 'string', description: 'Id of the Struct asset, or "".' }, + path: PATH, + }, + ['asset', 'key', 'type'], + ), + build: (args) => ({ + command: new SetListItemTypeCommand( + { ownerId: text(args, 'asset'), key: text(args, 'key'), path: pathOf(args) }, + text(args, 'type'), + ), + }), + shows: theAsset, + }, + { + name: 'make_list_item_type', + description: "Turns the shape of a list's items into a new Struct asset and types the list with it.", + args: object({ asset: ASSET, key: KEY, path: PATH }, ['asset', 'key']), + build: (args) => { + const command = new MakeListItemTypeCommand({ + ownerId: text(args, 'asset'), + key: text(args, 'key'), + path: pathOf(args), + }); + return { command, made: () => ({ id: command.madeId! }) }; + }, + shows: theAsset, + }, +]; diff --git a/src/editor/agent/commandCatalog.ts b/src/editor/agent/commandCatalog.ts index aa111819..e5fb4eb0 100644 --- a/src/editor/agent/commandCatalog.ts +++ b/src/editor/agent/commandCatalog.ts @@ -1,517 +1,43 @@ -import { - CreateAssetCommand, - DeleteAssetCommand, - DuplicateAssetCommand, - RenameAssetCommand, - ResetAssetPropertyCommand, - SetAssetPropertyCommand, - SetStartSceneCommand, -} from '../../core/commands/assetCommands'; -import { type Command, CommandError } from '../../core/commands/Command'; -import { CompoundCommand } from '../../core/commands/CompoundCommand'; -import { - AddNodeCommand, - ConnectCommand, - DisconnectCommand, - MoveNodeCommand, - RemoveNodeCommand, - SetNodeParamCommand, -} from '../../core/commands/graphCommands'; -import { - ClearOverrideCommand, - MoveSceneObjectCommand, - PlaceAssetCommand, - RemoveSceneObjectsCommand, - RenameSceneObjectCommand, - SetOverrideCommand, - SetParentCommand, -} from '../../core/commands/sceneObjectCommands'; -import { - AddVariableCommand, - RemoveVariableCommand, - RenameVariableCommand, -} from '../../core/commands/variableCommands'; -import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes'; -import type { PropertyBag, PropertyValue } from '../../core/model/Properties'; -import { findGraphAsset, findObjectHolder, type Project } from '../../core/model/Project'; -import { VARIABLE_KINDS, type VariableKind } from '../../core/properties/kinds'; -import { coerceValue } from '../../core/properties/PropertySchema'; -import { arrangeCommand, type MeasureNode } from '../graph/graphLayout'; -import { type PaletteEntry, paletteEntries } from '../graph/paletteEntries'; -import type { ArgSchema } from './argCheck'; +import { ASSET_COMMANDS } from './catalog/assets'; +import { GRAPH_COMMANDS } from './catalog/graphs'; +import { OBJECT_COMMANDS } from './catalog/objects'; +import { PROJECT_COMMANDS } from './catalog/project'; +import type { CatalogCommand } from './catalog/shared'; +import { VARIABLE_COMMANDS } from './catalog/variables'; -/** What a command is built from: the project as it stands before the edit, and where new nodes go. */ -export interface BuildContext { - project: Project; - /** A free place for a node the agent gave none, right of what the graph and this edit hold. */ - spot(assetId: string): { x: number; y: number }; - /** How the nodes of a graph are measured for arranging. */ - measure(assetId: string): MeasureNode; +export type { BuildContext, Built, CatalogCommand, Frame, StepTarget } from './catalog/shared'; + +/** The commands of one part of the editor, as `catalog` lists them. */ +export interface CommandArea { + area: string; + about: string; + commands: readonly CatalogCommand[]; } -/** The command, and the ids of what it makes – what later steps of the same edit can name. */ -export interface Built { - command: Command; - made?: Record; - /** Nodes it added without a place: they are arranged when the edit's steps have run. */ - unplaced?: { asset: string; nodes: string[] }; -} - -type Args = Record; - -/** What a step worked on, for the editor to show the user: an asset, objects of a holder, nodes of a graph. */ -export type StepTarget = - | { kind: 'asset'; asset: string } - | { kind: 'objects'; in: string; ids: string[] } - | { kind: 'nodes'; asset: string; ids: string[] }; - -/** One command the agent can run (#429): what it does, its arguments, and how it is made. */ -export interface CatalogCommand { - name: string; - description: string; - args: ArgSchema; - /** Deletes something the user has to agree to first – decision 4 of #429. */ - confirms?: true; - build(args: Args, context: BuildContext): Built; - /** What it worked on, from its arguments and what it made; none for a step there is nothing to show of. */ - shows?(args: Args, made: Record): StepTarget; -} - -/** Assets the agent cannot make: each needs a file, and there is no upload for agents. */ -const FILE_TYPES: readonly AssetType[] = ['texture', 'font', 'sound', 'video']; - -const id = (description: string): ArgSchema => ({ type: 'string', minLength: 1, description }); -const number = (description: string): ArgSchema => ({ type: 'number', description }); -const anyValue: ArgSchema = { - description: 'Any JSON value; it is checked against the kind of the property.', -}; -const object = (properties: Record, required: string[]): ArgSchema => ({ - type: 'object', - properties, - required, - additionalProperties: false, -}); - -const ASSET = id('Id of the asset.'); -const HOLDER = id('Id of the scene or container the object is in.'); -const OBJECT = id('Id of the placed object.'); -const GRAPH = id('Id of the asset holding the graph (behavior, state machine, shader, ...).'); -const NODE = id('Id of the node.'); -const PORT_END = object({ node: NODE, port: id('Key of the port, as get_graph lists it.') }, [ - 'node', - 'port', -]); - -const text = (args: Args, key: string): string => args[key] as string; -const numberOf = (args: Args, key: string): number => args[key] as number; - -const theAsset = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset') }); -const madeAsset = (_args: Args, made: Record): StepTarget => ({ - kind: 'asset', - asset: made.id!, -}); -const theObject = (args: Args): StepTarget => ({ - kind: 'objects', - in: text(args, 'in'), - ids: [text(args, 'object')], -}); -const theNode = (args: Args): StepTarget => ({ - kind: 'nodes', - asset: text(args, 'asset'), - ids: [text(args, 'node')], -}); -const theGraph = (args: Args): StepTarget => ({ kind: 'nodes', asset: text(args, 'asset'), ids: [] }); - -export const COMMANDS: readonly CatalogCommand[] = [ +/** Every command the agent can run (#429), by the part of the editor it works on. */ +export const AREAS: readonly CommandArea[] = [ + { area: 'assets', about: 'Making, naming and setting assets.', commands: ASSET_COMMANDS }, { - name: 'create_asset', - description: - 'Makes a new asset. A scene is where objects are placed; a sprite, text or camera is placed into one; ' + - "a behavior is a graph of nodes that runs on the object it is placed under. Textures, fonts, sounds and videos need a file and can't be made here.", - args: object( - { - type: { - type: 'string', - enum: ASSET_TYPES.filter((type) => !FILE_TYPES.includes(type)), - description: 'The kind of asset.', - }, - name: { type: 'string', minLength: 1, description: 'Its name in the asset list.' }, - props: { - type: 'object', - description: "Starting values of its properties by key (see catalog topic 'asset_types').", - }, - }, - ['type', 'name'], - ), - build: (args) => { - const command = new CreateAssetCommand( - text(args, 'type') as AssetType, - text(args, 'name'), - (args.props ?? {}) as PropertyBag, - ); - return { command, made: { id: command.asset.id } }; - }, - shows: madeAsset, + area: 'variables', + about: 'Variables of behaviors and Variables assets, fields of Struct assets.', + commands: VARIABLE_COMMANDS, }, { - name: 'rename_asset', - description: 'Renames an asset.', - args: object({ asset: ASSET, name: { type: 'string', minLength: 1 } }, ['asset', 'name']), - build: (args) => ({ command: new RenameAssetCommand(text(args, 'asset'), text(args, 'name')) }), - shows: theAsset, + area: 'objects', + about: 'Objects placed in scenes and containers: place, move, parent, override, group, colliders.', + commands: OBJECT_COMMANDS, }, { - name: 'duplicate_asset', - description: 'Copies an asset; the copy comes right behind it in the list.', - args: object({ asset: ASSET }, ['asset']), - build: (args) => { - const command = new DuplicateAssetCommand(text(args, 'asset')); - return { command, made: { id: command.copyId } }; - }, - shows: madeAsset, - }, - { - name: 'delete_asset', - description: - 'Deletes an asset and what depends on it (placed copies of it, for one). The user is asked first and may say no.', - args: object({ asset: ASSET }, ['asset']), - confirms: true, - build: (args) => ({ command: new DeleteAssetCommand(text(args, 'asset')) }), - }, - { - name: 'set_asset_property', - description: "Sets a property of an asset by its key (see get_asset, or catalog topic 'asset_types').", - args: object({ asset: ASSET, key: id('Key of the property.'), value: anyValue }, [ - 'asset', - 'key', - 'value', - ]), - build: (args) => ({ - command: new SetAssetPropertyCommand( - text(args, 'asset'), - text(args, 'key'), - args.value as PropertyValue, - ), - }), - shows: theAsset, - }, - { - name: 'reset_asset_property', - description: 'Puts a property of an asset back to what a new asset starts with.', - args: object({ asset: ASSET, key: id('Key of the property.') }, ['asset', 'key']), - build: (args) => ({ command: new ResetAssetPropertyCommand(text(args, 'asset'), text(args, 'key')) }), - shows: theAsset, - }, - { - name: 'set_start_scene', - description: 'Makes a scene the one the game starts in; an empty scene id takes that back.', - args: object({ scene: { type: 'string', description: 'Id of the scene, or "".' } }, ['scene']), - build: (args) => ({ command: new SetStartSceneCommand(text(args, 'scene')) }), - }, - { - name: 'add_variable', - description: 'Adds a variable to a behavior or a variables asset, starting at the default of its kind.', - args: object( - { - asset: ASSET, - key: id('Name of the variable: letters, digits and _, not starting with a digit.'), - kind: { type: 'string', enum: VARIABLE_KINDS, description: 'What it holds.' }, - }, - ['asset', 'key', 'kind'], - ), - build: (args) => ({ - command: new AddVariableCommand( - text(args, 'asset'), - text(args, 'key'), - text(args, 'kind') as VariableKind, - ), - }), - shows: theAsset, - }, - { - name: 'remove_variable', - description: 'Removes a variable, its value and every override of it on placed objects.', - args: object({ asset: ASSET, key: id('Name of the variable.') }, ['asset', 'key']), - build: (args) => ({ command: new RemoveVariableCommand(text(args, 'asset'), text(args, 'key')) }), - shows: theAsset, - }, - { - name: 'rename_variable', - description: 'Renames a variable; nodes and overrides that use it follow.', - args: object({ asset: ASSET, from: id('Its name now.'), to: id('Its new name.') }, [ - 'asset', - 'from', - 'to', - ]), - build: (args) => ({ - command: new RenameVariableCommand(text(args, 'asset'), text(args, 'from'), text(args, 'to')), - }), - shows: theAsset, - }, - { - name: 'place_asset', - description: - 'Places an asset into a scene or container as a new object, at a position in the world. ' + - 'A behavior, collider or rigidbody is placed under the object it belongs to (parent).', - args: object( - { - in: HOLDER, - asset: ASSET, - x: number('World x, pixels.'), - y: number('World y, pixels, down is positive.'), - parent: { - type: 'string', - description: 'Id of the object to place it under, or "" for the top level.', - }, - }, - ['in', 'asset', 'x', 'y'], - ), - build: (args) => { - const command = new PlaceAssetCommand( - text(args, 'in'), - text(args, 'asset'), - { x: numberOf(args, 'x'), y: numberOf(args, 'y') }, - (args.parent as string | undefined) ?? '', - ); - return { command, made: { id: command.objectId } }; - }, - shows: (args, made) => ({ kind: 'objects', in: text(args, 'in'), ids: [made.id!] }), - }, - { - name: 'remove_objects', - description: 'Removes placed objects together with everything placed under them.', - args: object({ in: HOLDER, objects: { type: 'array', items: OBJECT, minItems: 1 } }, ['in', 'objects']), - build: (args) => ({ command: new RemoveSceneObjectsCommand(text(args, 'in'), args.objects as string[]) }), - shows: (args) => ({ kind: 'objects', in: text(args, 'in'), ids: [] }), - }, - { - name: 'move_object', - description: 'Moves a placed object; x and y are relative to its parent (the world at the top level).', - args: object({ in: HOLDER, object: OBJECT, x: number('x'), y: number('y'), z: number('Draw order.') }, [ - 'in', - 'object', - 'x', - 'y', - ]), - build: (args, { project }) => { - const z = - typeof args.z === 'number' ? args.z : currentZ(project, text(args, 'in'), text(args, 'object')); - return { - command: new MoveSceneObjectCommand(text(args, 'in'), text(args, 'object'), { - x: numberOf(args, 'x'), - y: numberOf(args, 'y'), - z, - }), - }; - }, - shows: theObject, - }, - { - name: 'rename_object', - description: 'Names a placed object; "" goes back to the name of its asset.', - args: object({ in: HOLDER, object: OBJECT, name: { type: 'string' } }, ['in', 'object', 'name']), - build: (args) => ({ - command: new RenameSceneObjectCommand(text(args, 'in'), text(args, 'object'), text(args, 'name')), - }), - shows: theObject, - }, - { - name: 'set_override', - description: 'Gives one placed object its own value for a property of its asset.', - args: object({ in: HOLDER, object: OBJECT, key: id('Key of the property.'), value: anyValue }, [ - 'in', - 'object', - 'key', - 'value', - ]), - build: (args) => ({ - command: new SetOverrideCommand( - text(args, 'in'), - text(args, 'object'), - text(args, 'key'), - args.value as PropertyValue, - ), - }), - shows: theObject, - }, - { - name: 'clear_override', - description: "Lets a placed object follow its asset's value for a property again.", - args: object({ in: HOLDER, object: OBJECT, key: id('Key of the property.') }, ['in', 'object', 'key']), - build: (args) => ({ - command: new ClearOverrideCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')), - }), - shows: theObject, - }, - { - name: 'set_parent', - description: - 'Puts a placed object under another one ("" for the top level); it keeps its place in the world.', - args: object({ in: HOLDER, object: OBJECT, parent: { type: 'string' } }, ['in', 'object', 'parent']), - build: (args) => ({ - command: new SetParentCommand(text(args, 'in'), text(args, 'object'), text(args, 'parent')), - }), - shows: theObject, - }, - { - name: 'add_node', - description: - "Adds a node to a graph. `node` is an entry of catalog topic 'nodes' for that graph (its id, e.g. " + - '"OnKeyHeld", "Move", or "RunBehavior:"). Leave x and y out: when the call has run, the editor ' + - 'arranges the nodes added without a place, flow left to right, and leaves the others where they are.', - args: object( - { - asset: GRAPH, - node: id('The entry id from the catalog.'), - params: { type: 'object', description: 'Settings and typed-in input values by key.' }, - x: number('Only to place it by hand.'), - y: number('Only to place it by hand.'), - }, - ['asset', 'node'], - ), - build: (args, context) => { - const assetId = text(args, 'asset'); - const entry = paletteEntry(context.project, assetId, text(args, 'node')); - const placed = typeof args.x === 'number' && typeof args.y === 'number'; - const spot = placed ? { x: args.x as number, y: args.y as number } : context.spot(assetId); - const params = { ...entry.params, ...coercedParams(entry, (args.params ?? {}) as PropertyBag) }; - const command = new AddNodeCommand(assetId, entry.def.type, spot.x, spot.y, params); - const made: Record = { id: command.node.id }; - if (command.closing) made.closing = command.closing.id; - return placed - ? { command, made } - : { command, made, unplaced: { asset: assetId, nodes: Object.values(made) } }; - }, - shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }), - }, - { - name: 'arrange_graph', - description: - 'Arranges nodes of a graph as the Arrange button does: flow left to right, the order that crosses the ' + - 'fewest links, no overlaps. Without nodes it arranges the whole graph – moving what the user placed, so ' + - 'only do that when asked.', - args: object( - { - asset: GRAPH, - nodes: { - type: 'array', - items: NODE, - minItems: 1, - description: 'The nodes to arrange; the rest stay.', - }, - }, - ['asset'], - ), - build: (args, context) => { - const assetId = text(args, 'asset'); - const holder = findGraphAsset(context.project, assetId); - if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); - const nodes = (args.nodes as string[] | undefined) ?? null; - const unknown = nodes?.find((nodeId) => !holder.graph.nodes.some((node) => node.id === nodeId)); - if (unknown !== undefined) throw new CommandError(`${holder.name} has no node ${unknown}`); - const command = arrangeCommand(context.project, assetId, nodes, context.measure(assetId)); - return { command: command ?? new CompoundCommand('Arrange nodes', []) }; - }, - shows: (args) => ({ - kind: 'nodes', - asset: text(args, 'asset'), - ids: (args.nodes as string[] | undefined) ?? [], - }), - }, - { - name: 'remove_node', - description: 'Removes a node and every edge on it.', - args: object({ asset: GRAPH, node: NODE }, ['asset', 'node']), - build: (args) => ({ command: new RemoveNodeCommand(text(args, 'asset'), text(args, 'node')) }), - shows: theGraph, - }, - { - name: 'move_node', - description: 'Moves a node in its graph.', - args: object({ asset: GRAPH, node: NODE, x: number('x'), y: number('y') }, ['asset', 'node', 'x', 'y']), - build: (args) => ({ - command: new MoveNodeCommand( - text(args, 'asset'), - text(args, 'node'), - numberOf(args, 'x'), - numberOf(args, 'y'), - ), - }), - shows: theNode, - }, - { - name: 'set_node_param', - description: 'Sets a setting of a node, or the value typed into one of its inputs that has no edge.', - args: object({ asset: GRAPH, node: NODE, key: id('Key of the setting or input.'), value: anyValue }, [ - 'asset', - 'node', - 'key', - 'value', - ]), - build: (args) => ({ - command: new SetNodeParamCommand( - text(args, 'asset'), - text(args, 'node'), - text(args, 'key'), - args.value as PropertyValue, - ), - }), - shows: theNode, - }, - { - name: 'connect', - description: - 'Draws an edge from an output to an input. Exec ports carry the flow, the others values; an input ' + - 'that holds one value loses the edge it had.', - args: object({ asset: GRAPH, from: PORT_END, to: PORT_END }, ['asset', 'from', 'to']), - build: (args) => { - const end = (key: string) => args[key] as { node: string; port: string }; - const command = new ConnectCommand(text(args, 'asset'), end('from'), end('to')); - return { command, made: { id: command.edge.id } }; - }, - shows: (args) => ({ - kind: 'nodes', - asset: text(args, 'asset'), - ids: (['from', 'to'] as const).map((key) => (args[key] as { node: string }).node), - }), - }, - { - name: 'disconnect', - description: 'Removes an edge.', - args: object({ asset: GRAPH, edge: id('Id of the edge.') }, ['asset', 'edge']), - build: (args) => ({ command: new DisconnectCommand(text(args, 'asset'), text(args, 'edge')) }), - shows: theGraph, + area: 'graphs', + about: + 'Nodes, edges, groups, comments and ports of behaviors, state machines, shaders and the other graphs.', + commands: GRAPH_COMMANDS, }, + { area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS }, ]; +export const COMMANDS: readonly CatalogCommand[] = AREAS.flatMap((area) => area.commands); + export function catalogCommand(name: string): CatalogCommand | undefined { return COMMANDS.find((command) => command.name === name); } - -/** The palette entry a node is added from: by its id, else by its label when only one has it. */ -export function paletteEntry(project: Project, assetId: string, wanted: string): PaletteEntry { - const holder = findGraphAsset(project, assetId); - if (!holder) throw new CommandError(`There is no graph asset ${assetId}`); - const entries = paletteEntries(project, holder); - const byId = entries.find((entry) => entry.id === wanted); - if (byId) return byId; - const byLabel = entries.filter((entry) => entry.def.label.toLowerCase() === wanted.toLowerCase()); - if (byLabel.length === 1) return byLabel[0]!; - throw new CommandError( - `A ${holder.type} graph has no node "${wanted}" – look it up with catalog topic "nodes" and this asset`, - ); -} - -/** Settings are held to their kind, as the inspector holds them; a typed-in input value stays as given. */ -function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag { - const coerced: PropertyBag = {}; - for (const [key, value] of Object.entries(params)) { - const param = entry.def.params.find((p) => p.key === key); - coerced[key] = param && param.kind !== 'enum' ? coerceValue(param, value) : value; - } - return coerced; -} - -function currentZ(project: Project, holderId: string, objectId: string): number { - return findObjectHolder(project, holderId)?.objects.find((o) => o.id === objectId)?.position.z ?? 0; -} diff --git a/src/editor/agent/editPlan.ts b/src/editor/agent/editPlan.ts index 21b02194..1701c721 100644 --- a/src/editor/agent/editPlan.ts +++ b/src/editor/agent/editPlan.ts @@ -1,10 +1,13 @@ +import { normalizeBox, SetAnnotationBoxCommand } from '../../core/commands/annotationCommands'; import { SetActiveSceneCommand } from '../../core/commands/assetCommands'; import { type Command, CommandError } from '../../core/commands/Command'; +import { findAnnotation } from '../../core/graph'; import { findAsset, findGraphAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { arrangeCommand, type MeasureNode } from '../graph/graphLayout'; import { estimatedShape } from '../graph/nodeBox'; import { argProblem } from './argCheck'; -import { type BuildContext, type Built, catalogCommand, type StepTarget } from './commandCatalog'; +import { boxAt } from './catalog/graphs'; +import { type BuildContext, catalogCommand, type Frame, type StepTarget } from './commandCatalog'; /** One step of an edit as the agent sends it. */ export interface EditStep { @@ -53,39 +56,50 @@ export class AgentEdit implements Command { const targets: StepTarget[] = []; const names = new Map>(); const unplaced = new Map(); + const frames: Frame[] = []; const context: BuildContext = { project, spot: (assetId) => freeSpot(project, assetId), measure: this.measure, }; + const run = (command: Command) => { + command.execute(project); + done.push(command); + }; try { for (const [index, step] of this.steps.entries()) { const where = `Step ${index + 1} (${step.command})`; try { - const { built, target } = buildStep(step, names, context); - built.command.execute(project); - done.push(built.command); - this.made.push(built.made ?? null); + const { entry, args } = stepEntry(step, names); + const built = entry.build(args, context); + run(built.command); + const made = typeof built.made === 'function' ? built.made() : built.made; + this.made.push(made ?? null); + const target = entry.shows?.(args, made ?? {}); if (target) targets.push(target); if (built.unplaced) { const { asset, nodes } = built.unplaced; unplaced.set(asset, [...(unplaced.get(asset) ?? []), ...nodes]); } + if (built.frames) frames.push(built.frames); if (step.as !== undefined) { - if (!built.made) throw new CommandError(`makes nothing a later step could call "${step.as}"`); + if (!made) throw new CommandError(`makes nothing a later step could call "${step.as}"`); if (names.has(step.as)) throw new CommandError(`"${step.as}" names an earlier step already`); - names.set(step.as, built.made); + names.set(step.as, made); } } catch (error) { if (error instanceof CommandError) throw new CommandError(`${where}: ${error.message}`); throw error; } } + // A group made here holds its nodes, which arranging sees through; a comment sits above them. for (const [assetId, nodes] of unplaced) { const arranged = arrangeCommand(project, assetId, nodes, this.measure(assetId)); - if (!arranged) continue; - arranged.execute(project); - done.push(arranged); + if (arranged) run(arranged); + } + for (const frame of frames) { + const fitted = refit(frame, context); + if (fitted) run(fitted); } this.focus = focusOf(project, targets); const scene = this.focus?.kind === 'objects' ? findAsset(project, this.focus.in) : undefined; @@ -117,18 +131,26 @@ export function deletedAssets(steps: readonly EditStep[]): string[] { }); } -function buildStep( - step: EditStep, - names: ReadonlyMap>, - context: BuildContext, -): { built: Built; target: StepTarget | null } { +/** The catalog entry of a step and its arguments, the names of earlier steps put in and checked. */ +function stepEntry(step: EditStep, names: ReadonlyMap>) { const entry = catalogCommand(step.command); if (!entry) throw new CommandError('no such command – catalog topic "commands" lists them'); const args = resolve(step.args ?? {}, names) as Record; const problem = argProblem(entry.args, args, 'args'); if (problem) throw new CommandError(problem); - const built = entry.build(args, context); - return { built, target: entry.shows?.(args, built.made ?? {}) ?? null }; + return { entry, args }; +} + +/** A group or comment put at nodes, set to them again where they stand once the edit has arranged them. */ +function refit(frame: Frame, context: BuildContext): Command | null { + const holder = findGraphAsset(context.project, frame.asset); + const found = holder ? findAnnotation(holder.graph, frame.id) : null; + const present = frame.nodes.filter((nodeId) => holder?.graph.nodes.some((node) => node.id === nodeId)); + if (!found || present.length === 0) return null; + const box = normalizeBox(boxAt(frame.kind, context, frame.asset, present)); + const { x, y, w, h } = found.annotation; + if (box.x === x && box.y === y && box.w === w && box.h === h) return null; + return new SetAnnotationBoxCommand(frame.asset, frame.kind, frame.id, box); } /** diff --git a/src/editor/agent/readTools.ts b/src/editor/agent/readTools.ts index ea39d080..e602d194 100644 --- a/src/editor/agent/readTools.ts +++ b/src/editor/agent/readTools.ts @@ -1,14 +1,16 @@ -import { type GraphNode, resolveDef } from '../../core/graph'; +import { type GraphAnnotation, type GraphNode, resolveDef } from '../../core/graph'; import type { PortDef } from '../../core/graph/NodeRegistry'; import type { Asset } from '../../core/model/Asset'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import { graphLookup } from '../../core/model/graphLookup'; +import { isTemplateContent, resolveObjects } from '../../core/model/instance'; import { findAsset, findGraphAsset, type Project } from '../../core/model/Project'; import { objectName } from '../../core/model/SceneObject'; import { VARIABLE_KINDS } from '../../core/properties/kinds'; import { type PropertyDef, schemaFor, schemaForAsset } from '../../core/properties/PropertySchema'; import { type PaletteEntry, paletteEntries, rankedEntries } from '../graph/paletteEntries'; -import { COMMANDS } from './commandCatalog'; +import { toHex } from '../../ui/color'; +import { AREAS, type CatalogCommand } from './commandCatalog'; /** * What the agent reads of the project (#429): plain data, as JSON, built from the model alone. Big @@ -16,6 +18,22 @@ import { COMMANDS } from './commandCatalog'; * counted, so one answer stays readable. */ +/** The project's own settings, which commands of area "project" change. */ +export function projectSettings(project: Project): object { + return { + name: project.name, + startScene: project.startSceneId, + playFrom: project.playFrom, + whilePlaying: project.whilePlaying, + gameLinkZoom: project.gameLinkZoom, + symbolScale: project.symbolScale, + collisionLayers: project.collisionLayers, + renderLayers: project.renderLayers, + masterVolume: project.masterVolume, + masterFx: project.masterFx, + }; +} + export function listAssets(project: Project, type?: string, query?: string): object[] { const needle = query?.trim().toLowerCase() ?? ''; return project.assets @@ -38,10 +56,12 @@ export function getAsset(project: Project, assetId: string): object | null { if (!asset) return null; const plain = JSON.parse(JSON.stringify(asset, trimmed)) as Record; if ('objects' in asset) { - plain.objects = asset.objects.map((object) => ({ + // The content of a placed container is listed too, by the ids its overrides go by. + plain.objects = resolveObjects(project, asset).map((object) => ({ ...object, name: objectName(object, findAsset(project, object.assetId)), asset: assetLabel(project, object.assetId), + ...(isTemplateContent(asset, object.id) ? { fromContainer: true } : {}), })); } plain.properties = schemaForAsset(project, asset).map(propertyRow); @@ -75,11 +95,15 @@ export function getGraph(project: Project, assetId: string): object | null { to: edge.to, ...(edge.label ? { label: edge.label } : {}), })), - groups: (graph.groups ?? []).map(({ id, text, x, y, w, h }) => ({ id, title: text, x, y, w, h })), - notes: (graph.notes ?? []).map(({ id, text, x, y }) => ({ id, text, x, y })), + groups: (graph.groups ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), title: text })), + notes: (graph.notes ?? []).map(({ text, ...box }) => ({ ...annotationRow(box), text })), }; } +function annotationRow({ id, x, y, w, h, color }: Omit): object { + return { id, x, y, w, h, ...(color === undefined ? {} : { color: toHex(color) }) }; +} + function nodeRow(node: GraphNode, def: ReturnType): object { return { id: node.id, @@ -88,6 +112,8 @@ function nodeRow(node: GraphNode, def: ReturnType): object { x: node.x, y: node.y, params: node.params, + ...(node.ports ? { ports: node.ports } : {}), + ...(node.collapsed ? { collapsed: true } : {}), inputs: (def?.inputs ?? []).map(portRow), outputs: (def?.outputs ?? []).map(portRow), }; @@ -105,7 +131,7 @@ export function catalog( ): object { switch (topic) { case 'commands': - return COMMANDS.map(({ name, description, args }) => ({ name, description, args })); + return commandsFor(options.query ?? ''); case 'nodes': { const holder = options.asset ? findGraphAsset(project, options.asset) : undefined; if (!holder) throw new Error('Name the graph asset (asset) whose nodes you want'); @@ -140,6 +166,40 @@ export function catalog( const MAX_NODES_IN_FULL = 25; +/** + * Without a query the areas with what each command does – there are too many to give all their + * arguments at once. A query of area names or command names gives those commands in full. + */ +function commandsFor(query: string): object { + const words = query + .toLowerCase() + .split(/[\s,]+/) + .filter(Boolean); + if (words.length === 0) { + return { + areas: AREAS.map(({ area, about, commands }) => ({ + area, + about, + commands: commands.map(({ name, description }) => ({ name, description })), + })), + next: 'Ask again with query: an area or command names, for their arguments.', + }; + } + // A word naming an area means that area, not every command with the word in its name. + const areas = new Set(words.filter((word) => AREAS.some((entry) => entry.area === word))); + const names = words.filter((word) => !areas.has(word)); + const wanted = (area: string, command: CatalogCommand) => + areas.has(area) || names.some((word) => command.name.includes(word)); + const found = AREAS.flatMap(({ area, commands }) => + commands + .filter((command) => wanted(area, command)) + .map(({ name, description, args }) => ({ area, name, description, args })), + ); + if (found.length === 0) + throw new Error(`No area or command matches "${query}" – ask without a query for the list`); + return found; +} + function nodeEntry({ id, def, params }: PaletteEntry): object { return { id, diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index 557a4509..8229d0b0 100644 --- a/src/editor/agent/tools.json +++ b/src/editor/agent/tools.json @@ -27,7 +27,7 @@ { "name": "list_assets", "title": "List the assets", - "description": "The project's assets with their ids, names and types, and how many objects or nodes each holds. Filter by type and by a part of the name.", + "description": "The project's settings (start scene, layers, master bus, ...) and its assets with their ids, names and types, and how many objects or nodes each holds. Filter the assets by type and by a part of the name.", "inputSchema": { "type": "object", "properties": { @@ -44,7 +44,7 @@ { "name": "get_asset", "title": "Read an asset", - "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions. A graph is only counted here; read it with get_graph.", + "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer). A graph is only counted here; read it with get_graph.", "inputSchema": { "type": "object", "properties": { "asset": { "type": "string", "description": "Id of the asset." } }, @@ -56,7 +56,7 @@ { "name": "get_graph", "title": "Read a graph", - "description": "The graph of a behavior, state machine, shader, particles, sound FX or instrument: each node with its settings and its input and output ports as they are now, the edges between ports, and the groups and notes.", + "description": "The graph of a behavior, state machine, shader, particles, sound FX or instrument: each node with its settings and its input and output ports as they are now, the edges between ports, and the groups and comments (notes). A node that declares its own ports lists them under ports.", "inputSchema": { "type": "object", "properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } }, @@ -68,7 +68,7 @@ { "name": "lint_graph", "title": "Check how a graph reads", - "description": "Counts what makes a graph hard to read, as the user sees it: nodes on top of each other (overlaps), links that cross (crossings), links running over a node that is not theirs (throughNodes), links running right to left (backwards), and the longest links, with a few ids of each. Aim for 0 overlaps and 0 throughNodes. To mend: the command arrange_graph, or a second Get or Owner node next to a far-off user instead of one long link.", + "description": "Counts what makes a graph hard to read, as the user sees it: nodes on top of each other (overlaps), links that cross (crossings), links running over a node that is not theirs (throughNodes), links running right to left (backwards), and the longest links, with a few ids of each. Aim for 0 overlaps, 0 throughNodes and 0 backwards, and for no more crossings than the links need: two links leaving one node far apart, like exec and dt, cross what comes in between them. To mend: the command arrange_graph, or a second Get or Owner node next to a far-off user instead of one long link.", "inputSchema": { "type": "object", "properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } }, @@ -80,7 +80,7 @@ { "name": "catalog", "title": "What can be made", - "description": "Looks things up before an edit. topic \"commands\": every command run_commands knows, with its arguments. topic \"nodes\" with asset: the nodes that graph can hold; with query, the best matches in full (ports, settings). topic \"asset_types\": the kinds of asset; with type, that kind's properties. topic \"variable_kinds\": what a variable can hold.", + "description": "Looks things up before an edit. topic \"commands\": the commands run_commands knows by area, each with what it does; with query (an area, or command names), those commands with their arguments. topic \"nodes\" with asset: the nodes that graph can hold; with query, the best matches in full (ports, settings). topic \"asset_types\": the kinds of asset; with type, that kind's properties. topic \"variable_kinds\": what a variable can hold.", "inputSchema": { "type": "object", "properties": { @@ -88,7 +88,7 @@ "asset": { "type": "string", "description": "For nodes: id of the graph asset." }, "query": { "type": "string", - "description": "For nodes: words to search for, like \"key held\" or \"move\"." + "description": "For nodes: words to search for, like \"key held\" or \"move\". For commands: an area or command names, like \"graphs\" or \"add_port add_group\"." }, "type": { "type": "string", "description": "For asset_types: one type to describe in full." } }, diff --git a/src/editor/graph/AnnotationView.ts b/src/editor/graph/AnnotationView.ts index acface5b..3cafeaa6 100644 --- a/src/editor/graph/AnnotationView.ts +++ b/src/editor/graph/AnnotationView.ts @@ -1,9 +1,8 @@ import { Graphics, Rectangle, type Text, TextStyle } from 'pixi.js'; import type { AnnotationKind, GraphAnnotation } from '../../core/graph'; +import { ANNOTATION_TITLE_HEIGHT } from './annotationDrag'; import { readableTextColor, Theme, truncate, Widget, crispText } from '../../ui'; -/** Height of a group's title bar – the same as a node's, so the two line up in the eye. */ -export const ANNOTATION_TITLE_HEIGHT = 24; /** Side of the square at the bottom right corner that resizes the box. */ export const RESIZE_HANDLE = 14; const PAD = 8; diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 604b668f..c6a04f4b 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -67,10 +67,16 @@ import { nodeSelection, } from '../state'; import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; -import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes, revealBoxes } from '../framing'; -import { annotationBoxes, boxOf } from './annotationDrag'; +import { type Box, boxesInBand, boxesWithinBand, frameBoxes, revealBoxes } from '../framing'; +import { + ANNOTATION_TITLE_HEIGHT, + annotationBoxes, + boxOf, + groupBoxAround, + NEW_NOTE_SIZE, +} from './annotationDrag'; import { AnnotationLayer } from './annotationLayer'; -import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView'; +import { type AnnotationView, RESIZE_HANDLE } from './AnnotationView'; import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames'; import { EdgeLayer } from './EdgeLayer'; import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; @@ -101,9 +107,6 @@ const NODE_HEIGHT_ESTIMATE = 80; const LIVE_VALUE_INTERVAL_MS = 100; /** A group or comment created out of nothing – large enough to drop a few nodes into. */ const NEW_GROUP_SIZE = { w: 320, h: 200 }; -const NEW_NOTE_SIZE = { w: 220, h: 96 }; -/** Air a group keeps around the nodes it was drawn around; the top also holds the title bar. */ -const GROUP_MARGIN = 20; /** How far from a link, in screen pixels, a press still means that link. */ const EDGE_TOLERANCE = 6; @@ -466,18 +469,8 @@ export class GraphCanvas extends Widget { /** A group drawn around the selected nodes, with room for the title bar above them. */ groupSelectedNodes(): void { const ids = new Set(this.state.selectedNodeIds); - const bounds = boundsOf(this.nodeBoxes().filter((b) => ids.has(b.id))); - if (!bounds) return; - this.addAnnotation( - 'group', - { - x: bounds.minX - GROUP_MARGIN, - y: bounds.minY - GROUP_MARGIN - ANNOTATION_TITLE_HEIGHT, - w: bounds.maxX - bounds.minX + GROUP_MARGIN * 2, - h: bounds.maxY - bounds.minY + GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT, - }, - 'Group', - ); + const box = groupBoxAround(this.nodeBoxes().filter((b) => ids.has(b.id))); + if (box) this.addAnnotation('group', box, 'Group'); } /** diff --git a/src/editor/graph/annotationDrag.ts b/src/editor/graph/annotationDrag.ts index 4dcfd381..c97c8772 100644 --- a/src/editor/graph/annotationDrag.ts +++ b/src/editor/graph/annotationDrag.ts @@ -15,7 +15,42 @@ import { type Graph, type GraphAnnotation, } from '../../core/graph'; -import { type Box, boxesCentredIn, type Point } from '../framing'; +import { type Box, boundsOf, boxesCentredIn, type Point } from '../framing'; + +/** Height of a group's title bar – the same as a node's, so the two line up in the eye. */ +export const ANNOTATION_TITLE_HEIGHT = 24; +/** Room a group made around nodes leaves between them and its edge. */ +const GROUP_MARGIN = 20; +/** A comment as it starts: wide enough for a sentence, tall enough for three lines. */ +export const NEW_NOTE_SIZE = { w: 220, h: 96 }; + +/** The group a set of node boxes is gathered into: around them, with room and a title bar above. */ +export function groupBoxAround(boxes: readonly Box[]): AnnotationBox | null { + const bounds = boundsOf(boxes); + if (!bounds) return null; + return { + x: bounds.minX - GROUP_MARGIN, + y: bounds.minY - GROUP_MARGIN - ANNOTATION_TITLE_HEIGHT, + w: bounds.maxX - bounds.minX + GROUP_MARGIN * 2, + h: bounds.maxY - bounds.minY + GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT, + }; +} + +/** Between a comment and the nodes below it: clear of the title bar of a group around them, with air. */ +const NOTE_GAP = GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT; + +/** A comment on a set of node boxes: just above them, as wide as they are or a new comment is. */ +export function noteBoxAbove(boxes: readonly Box[]): AnnotationBox | null { + const bounds = boundsOf(boxes); + if (!bounds) return null; + const h = NEW_NOTE_SIZE.h; + return { + x: bounds.minX, + y: bounds.minY - NOTE_GAP - h, + w: Math.max(NEW_NOTE_SIZE.w, bounds.maxX - bounds.minX), + h, + }; +} /** A box as the drag found it. */ export interface BoxSeed { diff --git a/src/editor/graph/arrange.ts b/src/editor/graph/arrange.ts index 53e77c9c..e02d3fb7 100644 --- a/src/editor/graph/arrange.ts +++ b/src/editor/graph/arrange.ts @@ -237,6 +237,7 @@ function orderColumns(slots: readonly Slot[], hops: readonly Hop[]): Slot[][] { } let best = columns.map((column) => [...column]); let fewest = crossingsOf(hops); + let highest = flowHeight(hops); for (let sweep = 0; sweep < ORDER_SWEEPS && fewest > 0; sweep++) { const down = sweep % 2 === 0; const range = down ? columns.keys() : [...columns.keys()].reverse(); @@ -246,8 +247,12 @@ function orderColumns(slots: readonly Slot[], hops: readonly Hop[]): Slot[][] { for (const slot of column) { const links = down ? (into.get(slot) ?? []) : (outOf.get(slot) ?? []); if (links.length === 0) continue; - const at = links.map((hop) => (down ? key(hop.from, hop.fromY) : key(hop.to, hop.toY))); - centre.set(slot, at.reduce((a, b) => a + b, 0) / at.length); + // The flow pulls harder here too: a node giving it and a value goes where its flow goes. + const sum = links.reduce( + (total, hop) => total + (down ? key(hop.from, hop.fromY) : key(hop.to, hop.toY)) * hop.weight, + 0, + ); + centre.set(slot, sum / links.reduce((total, hop) => total + hop.weight, 0)); } // A slot with nothing on that side keeps its place among the others. const sorted = [...column].sort((a, b) => (centre.get(a) ?? a.order) - (centre.get(b) ?? b.order)); @@ -255,8 +260,10 @@ function orderColumns(slots: readonly Slot[], hops: readonly Hop[]): Slot[][] { columns[l] = sorted; } const crossings = crossingsOf(hops); - if (crossings < fewest) { + const height = flowHeight(hops); + if (crossings < fewest || (crossings === fewest && height < highest)) { fewest = crossings; + highest = height; best = columns.map((column) => [...column]); } } @@ -264,6 +271,14 @@ function orderColumns(slots: readonly Slot[], hops: readonly Hop[]): Slot[][] { return best; } +/** How far down their columns the links carrying the flow run: of two orders crossing as often, the flow on top reads first. */ +function flowHeight(hops: readonly Hop[]): number { + let sum = 0; + for (const hop of hops) + if (hop.weight === EXEC_PULL) sum += key(hop.from, hop.fromY) + key(hop.to, hop.toY); + return sum; +} + /** Where along its column a port sits: its slot's place, and a fraction for how far down the slot. */ function key(slot: Slot, portY: number): number { return slot.order + (slot.h > 0 ? portY / slot.h : 0.5) * 0.9; @@ -358,8 +373,11 @@ function keepApart(wanted: readonly { y: number; weight: number }[], heights: re } /** - * Moves the arrangement as one to where the nodes were, or right of everything else when it would - * land on a node that stays or on a group or note. + * Moves the arrangement as one to where the nodes were. Where that would land on a node that stays, + * on a group or note, or turn a link to what stays backwards, it goes next to the node it links to – + * a value it hands on left of the node taking it, one it takes right of the node giving it, where its + * links cross the fewest others and else as near the port's height as it fits – and else right of + * everything. */ function settle( graph: LayoutGraph, @@ -383,22 +401,52 @@ function settle( const spot = placed.get(node.id)!; return { x: spot.x + dx, y: spot.y + dy, w: node.w, h: node.h }; }); + const hits = (boxes: readonly Omit[], room = 0) => + boxes.some((box) => obstacles.some((o) => overlap(grown(box, room), o))); + const across = graph.edges.filter((edge) => ids.has(edge.from.node) !== ids.has(edge.to.node)); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const boxAt = (id: string, dx: number, dy: number) => { + const node = byId.get(id)!; + const spot = placed.get(id); + return spot ? { x: spot.x + dx, y: spot.y + dy, w: node.w } : node; + }; + const backwards = (dx: number, dy: number) => + across.some((edge) => { + const from = boxAt(edge.from.node, dx, dy); + return from.x + from.w > boxAt(edge.to.node, dx, dy).x; + }); + const linked = graph.edges.filter((edge) => ids.has(edge.from.node) || ids.has(edge.to.node)); + const still = curvesOf( + graph.edges.filter((edge) => !linked.includes(edge)), + byId, + ); + const crossingsAt = (dx: number, dy: number) => { + const moved = new Map(byId); + for (const node of moving) { + const spot = placed.get(node.id)!; + moved.set(node.id, { ...node, x: spot.x + dx, y: spot.y + dy }); + } + const mine = curvesOf(linked, moved); + let count = 0; + mine.forEach((a, i) => { + for (const b of [...still, ...mine.slice(i + 1)]) if (curvesCross(a, b)) count += 1; + }); + return count; + }; const left = Math.min(...moving.map((node) => node.x)); const top = Math.min(...moving.map((node) => node.y)); - const hits = (boxes: readonly Omit[]) => - boxes.some((box) => obstacles.some((o) => overlap(box, o))); let shift = { x: left, y: top }; - if (hits(at(left, top))) { - const linked = staying.filter((node) => - graph.edges.some( - (edge) => - (edge.from.node === node.id && ids.has(edge.to.node)) || - (edge.to.node === node.id && ids.has(edge.from.node)), - ), - ); - shift = { + if (hits(at(left, top)) || backwards(left, top)) { + shift = besideLink( + across, + ids, + byId, + placed, + (dx, dy) => !hits(at(dx, dy), ROW_GAP) && !backwards(dx, dy), + crossingsAt, + ) ?? { x: Math.max(...obstacles.map((o) => o.x + o.w)) + BESIDE_GAP, - y: Math.min(...(linked.length > 0 ? linked : obstacles).map((o) => o.y)), + y: Math.min(...(across.length > 0 ? linkedStaying(across, ids, byId) : obstacles).map((o) => o.y)), }; } for (const spot of placed.values()) { @@ -408,6 +456,59 @@ function settle( return placed; } +/** How far up or down from the linked node's height a place next to it is looked for, in rows. */ +const BESIDE_ROWS = 40; + +/** + * The shift that puts the arrangement next to the node its first link to what stays goes to: of the + * places where `fits`, the one whose links cross the fewest others, and of those the nearest to the + * two ports of that link lying level; null when none fits. + */ +function besideLink( + across: readonly LayoutEdge[], + ids: ReadonlySet, + byId: ReadonlyMap, + placed: ReadonlyMap, + fits: (dx: number, dy: number) => boolean, + crossingsAt: (dx: number, dy: number) => number, +): { x: number; y: number } | null { + const edge = across[0]; + if (!edge) return null; + const handsOn = ids.has(edge.from.node); + const mineId = handsOn ? edge.from.node : edge.to.node; + const mine = placed.get(mineId)!; + const mineNode = byId.get(mineId)!; + const other = byId.get(handsOn ? edge.to.node : edge.from.node)!; + const minePort = handsOn ? mineNode.outputs[edge.from.port] : mineNode.inputs[edge.to.port]; + const otherPort = handsOn ? other.inputs[edge.to.port] : other.outputs[edge.from.port]; + const spots = [...placed].map(([id, spot]) => ({ ...spot, w: byId.get(id)!.w })); + const x = handsOn + ? other.x - COLUMN_GAP - Math.max(...spots.map((spot) => spot.x + spot.w)) + : other.x + other.w + COLUMN_GAP - Math.min(...spots.map((spot) => spot.x)); + const y = other.y + (otherPort ?? other.h / 2) - mine.y - (minePort ?? mineNode.h / 2); + let best: { x: number; y: number; crossings: number } | null = null; + for (let row = 0; row <= BESIDE_ROWS && best?.crossings !== 0; row++) { + for (const dy of row === 0 ? [0] : [-row * ROW_GAP, row * ROW_GAP]) { + if (!fits(x, y + dy)) continue; + const crossings = crossingsAt(x, y + dy); + if (!best || crossings < best.crossings) best = { x, y: y + dy, crossings }; + } + } + return best && { x: best.x, y: best.y }; +} + +function linkedStaying( + across: readonly LayoutEdge[], + ids: ReadonlySet, + byId: ReadonlyMap, +): LayoutNode[] { + return across.map((edge) => byId.get(ids.has(edge.from.node) ? edge.to.node : edge.from.node)!); +} + +function grown(box: Omit, by: number): Omit { + return { x: box.x - by, y: box.y - by, w: box.w + 2 * by, h: box.h + 2 * by }; +} + function overlap(a: Omit, b: Omit): boolean { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; } @@ -451,26 +552,13 @@ export function lintLayout(graph: LayoutGraph): GraphLint { example('overlap', [a.id, b.id]); } } - const curves = graph.edges.flatMap((edge) => { - const from = byId.get(edge.from.node); - const to = byId.get(edge.to.node); - if (!from || !to) return []; - const start = { x: from.x + from.w, y: from.y + (from.outputs[edge.from.port] ?? from.h / 2) }; - const end = { x: to.x, y: to.y + (to.inputs[edge.to.port] ?? to.h / 2) }; - const points = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => - curvePoint(start.x, start.y, end.x, end.y, i / CURVE_STEPS), - ); - return [{ edge, start, end, points }]; - }); + const curves = curvesOf(graph.edges, byId); let crossings = 0; for (let i = 0; i < curves.length; i++) { for (let j = i + 1; j < curves.length; j++) { const a = curves[i]!; const b = curves[j]!; - const sharesEnd = - (a.edge.from.node === b.edge.from.node && a.edge.from.port === b.edge.from.port) || - (a.edge.to.node === b.edge.to.node && a.edge.to.port === b.edge.to.port); - if (sharesEnd || !polylinesCross(a.points, b.points)) continue; + if (!curvesCross(a, b)) continue; crossings += 1; example('crossing', [a.edge.id, b.edge.id]); } @@ -516,6 +604,48 @@ export function lintLayout(graph: LayoutGraph): GraphLint { type P = { x: number; y: number }; +/** A link as drawn: from the right of its first node to the left of its second, sampled. */ +interface Curve { + edge: LayoutEdge; + start: P; + end: P; + points: P[]; + bounds: Omit; +} + +function curvesOf(edges: readonly LayoutEdge[], byId: ReadonlyMap>): Curve[] { + return edges.flatMap((edge) => { + const from = byId.get(edge.from.node); + const to = byId.get(edge.to.node); + if (!from || !to) return []; + const start = { x: from.x + from.w, y: from.y + (from.outputs[edge.from.port] ?? from.h / 2) }; + const end = { x: to.x, y: to.y + (to.inputs[edge.to.port] ?? to.h / 2) }; + const points = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => + curvePoint(start.x, start.y, end.x, end.y, i / CURVE_STEPS), + ); + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + const bounds = { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; + return [{ edge, start, end, points, bounds }]; + }); +} + +/** Links leaving or reaching one port together do not cross. */ +function curvesCross(a: Curve, b: Curve): boolean { + const sharesEnd = + (a.edge.from.node === b.edge.from.node && a.edge.from.port === b.edge.from.port) || + (a.edge.to.node === b.edge.to.node && a.edge.to.port === b.edge.to.port); + if (sharesEnd) return false; + const touch = + a.bounds.x <= b.bounds.x + b.bounds.w && + b.bounds.x <= a.bounds.x + a.bounds.w && + a.bounds.y <= b.bounds.y + b.bounds.h && + b.bounds.y <= a.bounds.y + a.bounds.h; + return touch && polylinesCross(a.points, b.points); +} + /** * Two links that swap rows in a perfect mirror meet exactly on a point both curves are sampled at * (t = 1/2), where no segment crosses another properly – so meeting on a point counts as well. diff --git a/src/editor/graph/portChoices.ts b/src/editor/graph/portChoices.ts new file mode 100644 index 00000000..1cac8673 --- /dev/null +++ b/src/editor/graph/portChoices.ts @@ -0,0 +1,67 @@ +import { + declaresPropertyPorts, + declaresSoundFxPorts, + declaresVariablePorts, + type GraphNode, + OBJECT_BEHAVIOR_PARAM, + propertyTargetOf, + soundFxSourceOf, + targetProperties, + variablesSourceOf, +} from '../../core/graph'; +import { graphLookup } from '../../core/model/graphLookup'; +import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; +import { textOf } from '../../core/model/Properties'; +import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; + +/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */ +export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] { + if (declaresSoundFxPorts(node.type)) return soundFxChoices(project, node); + const source = variablesSourceOf(node.params); + if (source) return findAssetOf(project, source, 'variables')?.variables ?? []; + const asset = findGraphAsset(project, assetId); + // A state machine declares no variables; its one node type has none to name. + return asset?.type === 'behavior' ? asset.variables : []; +} + +/** + * The parameters the sound FX a `Sound FX parameters` node names declares at its Input (#311), or + * the instrument an `Instrument parameters` node names at its Parameters node (#331). + */ +export function soundFxChoices(project: Project, node: GraphNode): PropertyDef[] { + const ports = graphLookup(project).soundFx(soundFxSourceOf(node.params))?.params ?? []; + return ports.map((p) => ({ key: p.key, label: p.key, kind: 'number', default: p.default ?? 0 })); +} + +/** + * What a Properties node picks its ports from: the properties of its object type – for a sprite, text + * or camera with the parameters of any shader it may wear – or of the behavior it names. + */ +export function propertyChoices(project: Project, node: GraphNode): readonly PropertyDef[] { + const target = propertyTargetOf(node.params); + if (target === 'behavior') { + const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? ''); + const behavior = id ? findAssetOf(project, id, 'behavior') : undefined; + return behavior ? schemaForAsset(project, behavior) : []; + } + const wears = target === 'sprite' || target === 'text' || target === 'camera'; + const shaderRows = wears ? allShaderParamRows(project) : []; + return [...targetProperties(target), ...shaderRows.map((row) => row.def)]; +} + +/** + * The declarations a node's ports are picked from, each port being one of them with its kind; null + * for a node whose ports are named by hand with a kind of their own (a start or end node, a struct, + * a Properties node of type Any). + */ +export function pickedPortChoices( + project: Project, + assetId: string, + node: GraphNode, +): readonly PropertyDef[] | null { + if (declaresSoundFxPorts(node.type) || declaresVariablePorts(node.type)) + return variableChoices(project, assetId, node); + if (declaresPropertyPorts(node.type)) + return propertyTargetOf(node.params) === 'any' ? null : propertyChoices(project, node); + return null; +} diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 414df59a..1a02d380 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -46,8 +46,8 @@ import { buildPropertyPortSection, buildSoundFxPortSection, buildVariablePortSection, - variableChoices, } from './portSection'; +import { variableChoices } from '../graph/portChoices'; import { listSource, previewingHandlers } from './previewHandlers'; import { buildTransitionPicker, buildTransitionSection } from './transitionSection'; import { currentNode, nodeParamValue } from './valueSpecs'; diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 25b75842..3622a62a 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -14,7 +14,6 @@ import { type DeclaredPort, type GraphNode, declaresParameters, - declaresSoundFxPorts, INSTRUMENT_PARAMETER_VALUES, INSTRUMENT_SETTING, isEntryTerminal, @@ -40,11 +39,11 @@ import { } from '../../core/graph'; import { isPrivateVariable } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; -import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; +import { findAssetOf } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; import { assetRefChoices, NO_ASSET_LABEL } from '../../core/properties/assetRef'; import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds'; -import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; +import { allShaderParamRows, type PropertyDef } from '../../core/properties/PropertySchema'; import { Checkbox, Dropdown, @@ -65,16 +64,7 @@ import { import { NAME_WIDTH, removeButton } from './fieldFactory'; import { addedRows, addNote, type InspectorHost, sectionHeader } from './host'; import { kindMenu, markLabel } from './variableRows'; - -/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */ -export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] { - if (declaresSoundFxPorts(node.type)) return soundFxChoices(project, node); - const source = variablesSourceOf(node.params); - if (source) return findAssetOf(project, source, 'variables')?.variables ?? []; - const asset = findGraphAsset(project, assetId); - // A state machine declares no variables; its one node type has none to name. - return asset?.type === 'behavior' ? asset.variables : []; -} +import { propertyChoices, soundFxChoices, variableChoices } from '../graph/portChoices'; /** * The ports of a Variables node: each one *is* a declared variable, so it is picked from a list @@ -101,15 +91,6 @@ export function buildVariablePortSection( }); } -/** - * The parameters the sound FX a `Sound FX parameters` node names declares at its Input (#311), or - * the instrument an `Instrument parameters` node names at its Parameters node (#331). - */ -function soundFxChoices(project: Project, node: GraphNode): PropertyDef[] { - const ports = graphLookup(project).soundFx(soundFxSourceOf(node.params))?.params ?? []; - return ports.map((p) => ({ key: p.key, label: p.key, kind: 'number', default: p.default ?? 0 })); -} - /** The ports of a `Sound FX parameters` node: picked from the parameters of the FX it names. */ export function buildSoundFxPortSection( host: InspectorHost, @@ -168,7 +149,7 @@ export function buildPropertyPortSection( const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined; buildPickedPortSection(host, assetId, node, { noun: 'Property', - choices: behavior ? schemaForAsset(host.state.project, behavior) : [], + choices: propertyChoices(host.state.project, node), owner: behavior ? `"${behavior.name}"` : 'a behavior', empty: id ? 'That behavior is gone.' : 'Pick a behavior first.', // Not offered, yet a port naming one by hand is one of its properties all the same (#257). diff --git a/tests/editor/agent-catalog.test.ts b/tests/editor/agent-catalog.test.ts new file mode 100644 index 00000000..1ba6993c --- /dev/null +++ b/tests/editor/agent-catalog.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { findAnnotation, VARIABLES } from '../../src/core/graph'; +import { findAsset, findGraphAsset } from '../../src/core/model/Project'; +import { runTool } from '../../src/editor/agent/agentTools'; +import { normalizeBox } from '../../src/core/commands/annotationCommands'; +import { groupBoxAround, noteBoxAbove } from '../../src/editor/graph/annotationDrag'; +import { lintLayout } from '../../src/editor/graph/arrange'; +import { layoutOf } from '../../src/editor/graph/graphLayout'; +import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; + +/** The commands of #429 stage 5a: what an agent needs past nodes and edges to build a game. */ + +const run = (fixture: SceneFixture, steps: object[]) => + runTool('run_commands', { steps }, hostFor(fixture).host); + +const graphOf = (fixture: SceneFixture, assetId: string) => findGraphAsset(fixture.project, assetId)!.graph; + +describe('variables in a graph', () => { + it('a Variables node reads a variable once add_port names it, in the kind of the variable', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk', ['speed']); + const result = await run(fixture, [ + { command: 'add_node', args: { asset: behavior.id, node: 'Variables' }, as: 'vars' }, + { command: 'add_port', args: { asset: behavior.id, node: '$vars', key: 'speed' } }, + { command: 'set_port_write', args: { asset: behavior.id, node: '$vars', key: 'speed', write: true } }, + ]); + expect(result.isError).toBeUndefined(); + const node = graphOf(fixture, behavior.id).nodes.find((n) => n.type === VARIABLES)!; + expect(node.ports).toEqual([{ key: 'speed', kind: 'number', write: true }]); + + const read = (await runTool('get_graph', { asset: behavior.id }, hostFor(fixture).host)) + .structuredContent as { nodes: { ports?: unknown; outputs: { key: string }[] }[] }; + expect(read.nodes[0]!.ports).toEqual([{ key: 'speed', kind: 'number', write: true }]); + expect(read.nodes[0]!.outputs.map((port) => port.key)).toContain('speed'); + }); + + it('refuses a port naming no variable, and lists the ones there are', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk', ['speed', 'jump']); + const result = await run(fixture, [ + { command: 'add_node', args: { asset: behavior.id, node: 'Variables' }, as: 'vars' }, + { command: 'add_port', args: { asset: behavior.id, node: '$vars', key: 'sped' } }, + ]); + expect(result.isError).toBe(true); + expect(text(result)).toContain( + 'Step 2 (add_port): "sped" is none of what this node can name: speed, jump', + ); + expect(graphOf(fixture, behavior.id).nodes).toHaveLength(0); + }); + + it('a port named freely needs a kind', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Point'); + const steps = (kind?: string) => [ + { command: 'add_node', args: { asset: behavior.id, node: 'PackStruct' }, as: 'pack' }, + { + command: 'add_port', + args: { asset: behavior.id, node: '$pack', key: 'x', ...(kind ? { kind } : {}) }, + }, + ]; + expect(text(await run(fixture, steps()))).toContain('Step 2 (add_port): Give the port a kind'); + expect((await run(fixture, steps('number'))).isError).toBeUndefined(); + expect(graphOf(fixture, behavior.id).nodes[0]!.ports).toEqual([{ key: 'x', kind: 'number' }]); + }); +}); + +describe('groups and comments', () => { + it('a group made in the call that adds its nodes fits them where the call arranged them', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk'); + const link = (from: string, port: string, to: string, into = port) => ({ + command: 'connect', + args: { asset: behavior.id, from: { node: from, port }, to: { node: to, port: into } }, + }); + // An Owner waits right of the others until arranging puts it before the Move: the group has to follow. + const result = await run(fixture, [ + { command: 'add_node', args: { asset: behavior.id, node: 'OnKeyHeld' }, as: 'key' }, + { command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' }, + { command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' }, + link('$key', 'exec', '$move'), + link('$owner', 'object', '$move'), + { + command: 'add_group', + args: { asset: behavior.id, nodes: ['$key', '$move', '$owner'], title: 'Walk' }, + }, + { command: 'add_comment', args: { asset: behavior.id, nodes: ['$key'], text: 'Held key moves it.' } }, + ]); + expect(result.isError).toBeUndefined(); + const graph = graphOf(fixture, behavior.id); + const layout = layoutOf(fixture.project, behavior.id)!; + const owner = graph.nodes.find((node) => node.type === 'Owner')!; + const move = graph.nodes.find((node) => node.type === 'Move')!; + expect(owner.x).toBeLessThan(move.x); + + expect(graph.groups!.map(({ x, y, w, h, text: title }) => ({ x, y, w, h, title }))).toEqual([ + { ...normalizeBox(groupBoxAround(layout.nodes)!), title: 'Walk' }, + ]); + const key = layout.nodes.find((node) => node.id === graph.nodes[0]!.id)!; + const { x, y, w, h } = graph.notes![0]!; + expect({ x, y, w, h }).toEqual(normalizeBox(noteBoxAbove([key])!)); + expect(lintLayout(layout).overlaps).toBe(0); + + await runTool('undo', {}, hostFor(fixture).host); + expect(graphOf(fixture, behavior.id).groups ?? []).toHaveLength(0); + expect(graphOf(fixture, behavior.id).nodes).toHaveLength(0); + }); + + it('edit_annotation changes title, color and box; remove_annotation leaves the nodes', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk'); + await run(fixture, [ + { command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' }, + { command: 'add_group', args: { asset: behavior.id, nodes: ['$move'], title: 'A', color: '#ff8800' } }, + ]); + const id = graphOf(fixture, behavior.id).groups![0]!.id; + expect(graphOf(fixture, behavior.id).groups![0]!.color).toBe(0xff8800); + await run(fixture, [ + { command: 'edit_annotation', args: { asset: behavior.id, id, text: 'B', color: '', w: 400 } }, + ]); + const group = findAnnotation(graphOf(fixture, behavior.id), id)!.annotation; + expect([group.text, group.color, group.w]).toEqual(['B', undefined, 400]); + await run(fixture, [{ command: 'remove_annotation', args: { asset: behavior.id, id } }]); + expect(graphOf(fixture, behavior.id).groups).toEqual([]); + expect(graphOf(fixture, behavior.id).nodes).toHaveLength(1); + }); +}); + +describe('struct types', () => { + it('a field added to a Struct asset reaches the variables of that type', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk'); + const result = await run(fixture, [ + { command: 'create_asset', args: { type: 'struct', name: 'Point' }, as: 'point' }, + { command: 'add_variable', args: { asset: '$point', key: 'x', kind: 'number' } }, + { command: 'add_variable', args: { asset: behavior.id, key: 'at', kind: 'struct' } }, + { command: 'set_struct_type', args: { asset: behavior.id, key: 'at', type: '$point' } }, + { command: 'add_variable', args: { asset: '$point', key: 'y', kind: 'number' } }, + ]); + expect(result.isError).toBeUndefined(); + const at = findAsset(fixture.project, behavior.id)!; + const def = 'variables' in at ? at.variables.find((v) => v.key === 'at') : undefined; + expect(def && 'fields' in def ? def.fields.map((field) => field.key) : []).toEqual(['x', 'y']); + }); + + it('make_struct_type hands later steps the Struct asset it made', async () => { + const fixture = makeSpriteScene(); + const behavior = makeBehavior(fixture, 'Walk'); + const result = await run(fixture, [ + { command: 'add_variable', args: { asset: behavior.id, key: 'at', kind: 'struct' } }, + { command: 'add_struct_field', args: { asset: behavior.id, key: 'at', field: 'x', kind: 'number' } }, + { command: 'make_struct_type', args: { asset: behavior.id, key: 'at' }, as: 'type' }, + { command: 'rename_asset', args: { asset: '$type', name: 'Spot' } }, + ]); + expect(result.isError).toBeUndefined(); + expect(fixture.project.assets.find((asset) => asset.type === 'struct')?.name).toBe('Spot'); + }); +}); + +describe('objects', () => { + it('a container made of objects lists its content by the ids an override takes', async () => { + const fixture = makeSpriteScene(); + const made = await run(fixture, [ + { command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 }, as: 'a' }, + { + command: 'place_asset', + args: { in: fixture.sceneId, asset: fixture.spriteId, x: 64, y: 0 }, + as: 'b', + }, + { command: 'add_collider', args: { in: fixture.sceneId, object: '$a' } }, + { command: 'create_container', args: { in: fixture.sceneId, objects: ['$a', '$b'] } }, + ]); + expect(made.isError).toBeUndefined(); + const read = (await runTool('get_asset', { asset: fixture.sceneId }, hostFor(fixture).host)) + .structuredContent as { objects: { id: string; fromContainer?: true }[] }; + const content = read.objects.filter((object) => object.fromContainer); + // Both sprites, and the collider under the first. + expect(content).toHaveLength(3); + expect(content.every((object) => object.id.includes('~'))).toBe(true); + + const set = await run(fixture, [ + { + command: 'set_override', + args: { in: fixture.sceneId, object: content[0]!.id, key: 'width', value: 10 }, + }, + ]); + expect(set.isError).toBeUndefined(); + expect(fixture.object(content[0]!.id).overrides.width).toBe(10); + }); + + it('groups top-level objects and names the group it made', async () => { + const fixture = makeSpriteScene(); + const result = await run(fixture, [ + { command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 }, as: 'a' }, + { command: 'group_objects', args: { in: fixture.sceneId, objects: ['$a'], name: 'Enemies' }, as: 'g' }, + { command: 'rename_object_group', args: { in: fixture.sceneId, group: '$g', name: 'Foes' } }, + ]); + expect(result.isError).toBeUndefined(); + expect(fixture.sceneOf().groups?.map((group) => group.name)).toEqual(['Foes']); + }); +}); + +describe('the project', () => { + it('list_assets gives the settings that the project commands change', async () => { + const fixture = makeSpriteScene(); + await run(fixture, [ + { command: 'set_layer_names', args: { list: 'collisionLayers', names: ['Default', 'Player'] } }, + { command: 'set_play_from', args: { from: 'active' } }, + ]); + const listed = (await runTool('list_assets', {}, hostFor(fixture).host)).structuredContent as { + project: { collisionLayers: string[]; playFrom: string }; + }; + expect(listed.project).toMatchObject({ collisionLayers: ['Default', 'Player'], playFrom: 'active' }); + }); +}); diff --git a/tests/editor/agent-edit.test.ts b/tests/editor/agent-edit.test.ts index 3ca113ed..ca80e141 100644 --- a/tests/editor/agent-edit.test.ts +++ b/tests/editor/agent-edit.test.ts @@ -1,60 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { type Command, CommandError } from '../../src/core/commands/Command'; import { findGraphAsset } from '../../src/core/model/Project'; import { argProblem } from '../../src/editor/agent/argCheck'; -import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools'; -import type { AgentView } from '../../src/editor/agent/agentView'; -import { COMMANDS, type StepTarget } from '../../src/editor/agent/commandCatalog'; +import { runTool } from '../../src/editor/agent/agentTools'; +import { COMMANDS } from '../../src/editor/agent/commandCatalog'; import { NODE_STEP } from '../../src/editor/agent/editPlan'; import { COLUMN_GAP, type GraphLint } from '../../src/editor/graph/arrange'; import { NODE_WIDTH } from '../../src/editor/graph/nodeBox'; import TOOLS from '../../src/editor/agent/tools.json'; import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; /** What an agent changes and reads through the tools (#429, stage 2). */ -function hostFor(fixture: SceneFixture, deleteAnswer: string | null = null, follows = false) { - const asked: string[][] = []; - const shown: StepTarget[] = []; - const host: ToolHost = { - view: () => ({ play: 'editing' }) as AgentView, - screenshot: () => Promise.resolve({ data: '', width: 0, height: 0 }), - project: () => fixture.project, - execute: (command: Command) => { - try { - fixture.stack.execute(command); - return null; - } catch (error) { - if (error instanceof CommandError) return error.message; - throw error; - } - }, - confirmDeletes: (ids) => { - asked.push([...ids]); - return Promise.resolve(deleteAnswer); - }, - open: () => null, - select: () => null, - play: () => null, - undo: () => { - const label = fixture.stack.undoLabel; - if (label !== null) fixture.stack.undo(); - return label; - }, - redo: () => { - const label = fixture.stack.redoLabel; - if (label !== null) fixture.stack.redo(); - return label; - }, - follows: () => follows, - show: (focus) => shown.push(focus), - }; - return { host, asked, shown }; -} - -const text = (result: ToolResult) => - result.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n'); - /** An object that walks left while the left arrow is held: behavior, placement, three nodes, three edges. */ function walkLeft(fixture: SceneFixture) { return [ @@ -470,12 +427,32 @@ describe('the catalog', () => { expect(command.args).toMatchObject({ type: 'object', additionalProperties: false }); expect(argProblem(command.args, {})).toMatch(/is missing|^null$/); } - const fixture = makeSpriteScene(); - const listed = (await runTool('catalog', { topic: 'commands' }, hostFor(fixture).host)) - .structuredContent as { - commands: { name: string }[]; + const { host } = hostFor(makeSpriteScene()); + const listed = (await runTool('catalog', { topic: 'commands' }, host)).structuredContent as { + commands: { areas: { area: string; commands: { name: string; args?: unknown }[] }[] }; }; - expect(listed.commands.map((command) => command.name)).toEqual(names); + const areas = listed.commands.areas; + expect(areas.flatMap((area) => area.commands.map((command) => command.name))).toEqual(names); + expect(areas.flatMap((area) => area.commands).some((command) => 'args' in command)).toBe(false); + // Each area in full fits one answer, which is cut beyond 60 000 characters. + for (const { area, commands } of areas) { + const full = await runTool('catalog', { topic: 'commands', query: area }, host); + expect(full.isError).toBeUndefined(); + expect(text(full).length).toBeLessThan(60_000); + const rows = (full.structuredContent as { commands: { name: string; args: unknown }[] }).commands; + expect(rows.map((row) => row.name)).toEqual(commands.map((command) => command.name)); + expect(rows.every((row) => row.args !== undefined)).toBe(true); + } + }); + + it('a query of command names answers just those in full, and an unknown one says so', async () => { + const { host } = hostFor(makeSpriteScene()); + const found = await runTool('catalog', { topic: 'commands', query: 'add_port, add_group' }, host); + const rows = (found.structuredContent as { commands: { area: string; name: string }[] }).commands; + expect(rows.map((row) => `${row.area}/${row.name}`)).toEqual(['graphs/add_group', 'graphs/add_port']); + const none = await runTool('catalog', { topic: 'commands', query: 'teleport' }, host); + expect(none.isError).toBe(true); + expect(text(none)).toContain('No area or command matches "teleport"'); }); it('the tools are the ones the relay lists, each with a schema', () => { diff --git a/tests/editor/agentTestHost.ts b/tests/editor/agentTestHost.ts new file mode 100644 index 00000000..7ebde178 --- /dev/null +++ b/tests/editor/agentTestHost.ts @@ -0,0 +1,48 @@ +import { type Command, CommandError } from '../../src/core/commands/Command'; +import type { ToolHost, ToolResult } from '../../src/editor/agent/agentTools'; +import type { AgentView } from '../../src/editor/agent/agentView'; +import type { StepTarget } from '../../src/editor/agent/commandCatalog'; +import type { SceneFixture } from '../helpers'; + +/** The editor behind the agent's tools, as the tests drive it: the fixture's project and undo stack. */ +export function hostFor(fixture: SceneFixture, deleteAnswer: string | null = null, follows = false) { + const asked: string[][] = []; + const shown: StepTarget[] = []; + const host: ToolHost = { + view: () => ({ play: 'editing' }) as AgentView, + screenshot: () => Promise.resolve({ data: '', width: 0, height: 0 }), + project: () => fixture.project, + execute: (command: Command) => { + try { + fixture.stack.execute(command); + return null; + } catch (error) { + if (error instanceof CommandError) return error.message; + throw error; + } + }, + confirmDeletes: (ids) => { + asked.push([...ids]); + return Promise.resolve(deleteAnswer); + }, + open: () => null, + select: () => null, + play: () => null, + undo: () => { + const label = fixture.stack.undoLabel; + if (label !== null) fixture.stack.undo(); + return label; + }, + redo: () => { + const label = fixture.stack.redoLabel; + if (label !== null) fixture.stack.redo(); + return label; + }, + follows: () => follows, + show: (focus) => shown.push(focus), + }; + return { host, asked, shown }; +} + +export const text = (result: ToolResult) => + result.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n'); diff --git a/tests/editor/graph-arrange.test.ts b/tests/editor/graph-arrange.test.ts index acfd3e51..e8cf8cc1 100644 --- a/tests/editor/graph-arrange.test.ts +++ b/tests/editor/graph-arrange.test.ts @@ -11,6 +11,7 @@ import { type LayoutGraph, type LayoutNode, lintLayout, + ROW_GAP, } from '../../src/editor/graph/arrange'; import { layoutOf } from '../../src/editor/graph/graphLayout'; import { makeBehavior, makeProject } from '../helpers'; @@ -151,11 +152,74 @@ describe('arrange', () => { const clear = n.x >= box.x + box.w || n.x + n.w <= box.x || n.y >= box.y + box.h || n.y + n.h <= box.y; expect(clear).toBe(true); } - // Right of everything, level with the node they hang on. - expect(Math.min(...result.nodes.filter((n) => n.id !== 'kept').map((n) => n.x))).toBeGreaterThan( - box.x + box.w, - ); - expect(Math.min(...result.nodes.filter((n) => n.id !== 'kept').map((n) => n.y))).toBe(0); + // Right of the node they hang on, a column away, out of the group's way above or below it. + const newA = result.nodes.find((n) => n.id === 'new-a')!; + expect(newA.x).toBe(kept.x + kept.w + COLUMN_GAP); + expect(lintLayout(result).backwards).toBe(0); + }); + + /** The walking graph of #429: an update and the arrow-key axis moving the owner. */ + function walking(): LayoutGraph { + return { + nodes: [ + node('owner', [], ['object'], 0, 0), + node('update', [], ['exec', 'dt'], 0, 80), + node('axis', [], ['x', 'y', 'active'], 0, 180), + node('move', ['exec', 'object', 'dx', 'dy', 'scale', 'dt'], ['exec'], 248, 67), + ], + edges: [ + edge('update', 'exec', 'move', 'exec'), + edge('update', 'dt', 'move', 'dt'), + edge('owner', 'object', 'move', 'object'), + edge('axis', 'x', 'move', 'dx'), + edge('axis', 'y', 'move', 'dy'), + ], + boxes: [], + }; + } + + it('puts a new node handing a value to one that stays left of it, where its link crosses least', () => { + // The live case of #429: a Variables node added to the walking graph, feeding the Move's scale. + // Above the Owner is nearer, with the axis this tall, but its link would cross the flow, the + // object and dx and dy there. + const graph = walking(); + graph.nodes[2] = node('axis', [], ['x', 'y', 'active', 'a', 'b', 'c', 'd', 'e'], 0, 180); + graph.nodes.push(node('vars', [], ['speed'], 508, 0)); + graph.edges.push(edge('vars', 'speed', 'move', 'scale')); + const result = placed(graph, new Set(['vars'])); + const vars = result.nodes.find((n) => n.id === 'vars')!; + const axis = result.nodes.find((n) => n.id === 'axis')!; + expect(vars.x + vars.w + COLUMN_GAP).toBe(248); + const lint = lintLayout(result); + expect(lint).toMatchObject({ overlaps: 0, backwards: 0, crossings: 4 }); + // Under the axis, as near the Move's scale as the column leaves room. + expect(vars.y).toBeGreaterThanOrEqual(axis.y + axis.h + ROW_GAP); + expect(vars.y).toBeLessThan(axis.y + axis.h + 2 * ROW_GAP); + }); + + it('lines the new node’s port up with the one it feeds where there is room', () => { + const graph: LayoutGraph = { + nodes: [ + node('move', ['exec', 'object', 'dx', 'dy', 'scale'], ['exec'], 248, 0), + node('vars', [], ['speed'], 508, 0), + ], + edges: [edge('vars', 'speed', 'move', 'scale')], + boxes: [], + }; + const result = placed(graph, new Set(['vars'])); + const [move, vars] = result.nodes; + expect(vars!.y + vars!.outputs.speed!).toBe(move!.y + move!.inputs.scale!); + }); + + it('puts the node giving the flow on top when two orders cross as often', () => { + // The Owner stood first; giving it the top or the update crosses three links either way. + const result = placed(walking()); + const ids = result.nodes + .filter((n) => n.id !== 'move') + .sort((a, b) => a.y - b.y) + .map((n) => n.id); + expect(ids[0]).toBe('update'); + expect(lintLayout(result).crossings).toBe(3); }); it('arranges nodes inside the group that holds them where they are, rather than outside it', () => { diff --git a/tests/editor/variable-choices.test.ts b/tests/editor/variable-choices.test.ts index f6ca6b0d..8f0dcfb9 100644 --- a/tests/editor/variable-choices.test.ts +++ b/tests/editor/variable-choices.test.ts @@ -8,7 +8,7 @@ import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { VARIABLES, VARIABLES_PARAM } from '../../src/core/graph'; -import { variableChoices } from '../../src/editor/inspector/portSection'; +import { variableChoices } from '../../src/editor/graph/portChoices'; import { makeBehavior, makeProject } from '../helpers'; describe('the variables a node offers', () => { diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index 190c92d7..7c82e159 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -346,6 +346,45 @@ export async function mcp(t) { JSON.stringify({ zoomBefore, ...farView }), ); + // 71g5. Past nodes and edges (#429 stage 5a): a Variables node reads a variable once add_port names it, + // and a group made in the same call frames every node as drawn, the one the call arranged among them. + const gathered = await callTool('run_commands', { + label: 'speed in a group', + steps: [ + { command: 'add_variable', args: { asset: walkId, key: 'speed', kind: 'number' } }, + { command: 'add_node', args: { asset: walkId, node: 'Variables' }, as: 'vars' }, + { command: 'add_port', args: { asset: walkId, node: '$vars', key: 'speed' } }, + { + command: 'add_group', + args: { asset: walkId, nodes: [...graph.nodes.map((node) => node.id), '$vars'], title: 'Walk' }, + }, + ], + }); + await settle(); + const groupId = gathered?.structuredContent?.made?.find((entry) => entry.step === 4)?.id; + const varsId = gathered?.structuredContent?.made?.find((entry) => entry.step === 2)?.id; + const framing = await json(`(() => { + const box = __gm.graph.annotationRect(${JSON.stringify(groupId)}); + const ids = ${JSON.stringify([...graph.nodes.map((node) => node.id), varsId])}; + const nodes = ids.map((id) => __gm.graph.nodeRect(id)); + const inside = Boolean(box) && nodes.every((n) => n && n.x > box.x && n.y > box.y && + n.x + n.w < box.x + box.w && n.y + n.h < box.y + box.h); + return { box, nodes, inside }; + })()`); + const readVars = (await callTool('get_graph', { asset: walkId }))?.structuredContent?.nodes?.find( + (node) => node.id === varsId, + ); + const gatheredLint = (await callTool('lint_graph', { asset: walkId }))?.structuredContent; + await callTool('undo'); + check( + 'add_port names a variable on a Variables node, and add_group frames every drawn node of the call', + gathered?.isError === false && + framing.inside && + readVars?.outputs?.some((port) => port.key === 'speed' && port.type === 'number') && + gatheredLint?.overlaps === 0, + JSON.stringify({ text: gathered?.content?.[0]?.text?.slice(0, 300), framing, readVars, gatheredLint }), + ); + // 71h. Played by the agent, the object walks left while the user holds the arrow. const started = await callTool('play', { action: 'play' }); await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' }); diff --git a/tools/mcp/relay.mjs b/tools/mcp/relay.mjs index 8e82e506..a8a25522 100644 --- a/tools/mcp/relay.mjs +++ b/tools/mcp/relay.mjs @@ -35,12 +35,16 @@ const INSTRUCTIONS = [ 'live: the user sees every change you make and can take it back with Ctrl+Z.', 'Start with get_view to learn what the user is looking at; screenshot shows it as a picture.', 'Read the project with list_assets, get_asset and get_graph. Change it with run_commands: its steps', - 'are commands from catalog topic "commands", run as one undo step, and a step can name what an', - 'earlier one made ("as": "x", then "$x"). Nodes for a graph come from catalog topic "nodes".', - 'A behavior acts on the object it is placed under: place it with parent set to that object.', + 'are commands from catalog topic "commands" (by area; ask with query for their arguments), run as', + 'one undo step, and a step can name what an earlier one made ("as": "x", then "$x"). Nodes for a', + 'graph come from catalog topic "nodes". A Variables node reads or writes a variable once add_port', + 'names it. A behavior acts on the object it is placed under: place it with parent set to that object.', 'Give new nodes no x and y: the editor arranges them when the call ends, and leaves the nodes', 'already there where they are. Keep a graph readable: a value needed far away comes from a second', - 'Get or Owner node next to its user, not over a long link; check with lint_graph.', + 'Get or Owner node next to its user, not 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.', 'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked', 'on, with what it touched selected. Use open and select to show anything else.', 'Test what you built yourself: play, then play_input sends keys and mouse clicks into the game,',