import { CreateAssetCommand, DeleteAssetCommand, DuplicateAssetCommand, RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand, SetStartSceneCommand, } from '../../core/commands/assetCommands'; import { type Command, CommandError } from '../../core/commands/Command'; 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 { type PaletteEntry, paletteEntries } from '../graph/paletteEntries'; 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 }; } /** 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; } type Args = Record; /** 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; } /** 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; export const 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 } }; }, }, { 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')) }), }, { 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 } }; }, }, { 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, ), }), }, { 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')) }), }, { 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, ), }), }, { 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')) }), }, { 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')), }), }, { 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 } }; }, }, { 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[]) }), }, { 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, }), }; }, }, { 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')), }), }, { 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, ), }), }, { 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')), }), }, { 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')), }), }, { 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: the editor finds the node a place.', 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 spot = typeof args.x === 'number' && typeof args.y === 'number' ? { x: args.x, y: args.y } : 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 { command, made }; }, }, { 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')) }), }, { 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'), ), }), }, { 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, ), }), }, { 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 } }; }, }, { 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')) }), }, ]; 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; }