Let an AI agent read and edit the open project through MCP (#429)
Stage 2 of #429. Until now an agent could only look at the editor. Now it reads the project and changes it through the same commands the user's own edits run, so every change shows up live and one Ctrl+Z takes back a whole agent call. - Read tools: list_assets, get_asset, get_graph and catalog. The catalog lists the commands with their arguments, the nodes of a graph from the same palette the user sees, asset types and variable kinds. - run_commands runs its steps as one undo step named "Agent: ...". A failing step rolls back the steps before it. The answer names the step and the reason the editor gave. - A step can name what it made ("as") and later steps use "$name". Each step is built after the one before has run, so one call can make a behavior and fill it. - 23 commands cover assets, variables, scene objects and graphs. Textures, fonts, sounds and videos need a file and are left out. - open, select, play and undo/redo drive the editor. - Deleting asks the user with the same text as their own delete. The tab sends "wait", and the relay then gives the call 2 minutes instead of 30 seconds. - tools.json is the only source of the argument schemas. argCheck checks every call against them before anything runs. - An open menu now names its first entries in get_view, instead of an empty title. - The new host functions live in editorToolHost.ts, so Editor.ts does not grow further. Found while testing: e2e 71 hung for 30 s in Page.navigate after a failure. The beforeunload guard of an unsaved project held the page and hid the real error. The module now reports the error first and waits for the save before it navigates. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 317 files, 4903 tests. - agent-edit 14/14, agent-view 10/10, relay 24/24. - e2e 71 16/16 on atx and against the live site. One call builds a sprite with an arrow-key behavior, play moves it from x 0 to -125, and one undo restores the assets exactly. - Counter-checks turned red: no rollback, a partial undo, no delete question, a relay that ignores "wait". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48ec722660
commit
25af064249
19 files changed
+1913
-60
No files matched your search
+21
-7
@@ -120,7 +120,7 @@ import { AgentLink } from './agent/AgentLink';
|
||||
import { runTool } from './agent/agentTools';
|
||||
import { AgentTokensDialog } from './agent/AgentTokensDialog';
|
||||
import { type AgentView, agentView, viewSummary } from './agent/agentView';
|
||||
import { screenshot } from './agent/screenshot';
|
||||
import { editorToolHost } from './agent/editorToolHost';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -951,11 +951,21 @@ export class Editor implements PlayHost {
|
||||
return { view, summary: viewSummary(view) };
|
||||
},
|
||||
summary: () => viewSummary(this.agentView(false)),
|
||||
run: (tool, args) =>
|
||||
runTool(tool, args, {
|
||||
view: () => this.agentView(),
|
||||
screenshot: (maxWidth) => screenshot(app, maxWidth),
|
||||
}),
|
||||
run: (tool, args, wait) =>
|
||||
runTool(
|
||||
tool,
|
||||
args,
|
||||
editorToolHost(
|
||||
{
|
||||
state: this.state,
|
||||
app,
|
||||
view: () => this.agentView(),
|
||||
showTab: (tab) => this.showTab(tab),
|
||||
play: this.play,
|
||||
},
|
||||
wait,
|
||||
),
|
||||
),
|
||||
replaced: () => {
|
||||
sessionStorage.removeItem(AGENT_KEY);
|
||||
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
||||
@@ -1067,7 +1077,11 @@ export class Editor implements PlayHost {
|
||||
selection: state.selection,
|
||||
popups: ui()
|
||||
.overlay.children.filter((child): child is Popup => child instanceof Popup && child.visible)
|
||||
.map((popup) => ({ kind: popup instanceof Menu ? 'menu' : 'dialog', title: popupTitle(popup) })),
|
||||
.map((popup) =>
|
||||
popup instanceof Menu
|
||||
? { kind: 'menu', title: popupTitle(popup), entries: popup.entries }
|
||||
: { kind: 'dialog', title: popupTitle(popup) },
|
||||
),
|
||||
});
|
||||
const camera = withCamera ? this.camera() : undefined;
|
||||
return camera ? { ...view, camera } : view;
|
||||
|
||||
+33
-18
@@ -261,16 +261,34 @@ export function removeSelection(state: EditorState): boolean {
|
||||
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const owner = ownerOf(state.project, asset);
|
||||
if (owner) {
|
||||
state.setNotice(
|
||||
`"${asset.name}" lives in the ${owner.type} "${owner.name}" and goes with what uses it there`,
|
||||
);
|
||||
const question = deleteQuestion(state.project, assetId);
|
||||
if (!question) return;
|
||||
if ('refusal' in question) {
|
||||
state.setNotice(question.refusal);
|
||||
return;
|
||||
}
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
new Dialog(question.title, question.detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
]).openCentered();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is asked before an asset is deleted – by them or by an agent (#429) –, or why it
|
||||
* cannot be; null for an asset that is not there.
|
||||
*/
|
||||
export function deleteQuestion(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
): { title: string; detail: string } | { refusal: string } | null {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset) return null;
|
||||
const owner = ownerOf(project, asset);
|
||||
if (owner)
|
||||
return {
|
||||
refusal: `"${asset.name}" lives in the ${owner.type} "${owner.name}" and goes with what uses it there`,
|
||||
};
|
||||
const usages = findUsages(project, assetId).length;
|
||||
// A texture is never placed but worn through a property, so counting placed instances would
|
||||
// report "unused". Its frames count as the texture itself.
|
||||
const ids =
|
||||
@@ -281,16 +299,16 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
: asset.type === 'video'
|
||||
? videoRefIds(asset)
|
||||
: [assetId];
|
||||
const references = findPropertyUsages(state.project, ids).length;
|
||||
const references = findPropertyUsages(project, ids).length;
|
||||
// An asset that draws its own picture (#185) is worn the same way, only without frames.
|
||||
const picture = asset.type === 'texture' || asset.type === 'video' || generatesPictures(asset.type);
|
||||
const animations = picture ? animationsUsing(state.project, ids).length : 0;
|
||||
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
|
||||
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
|
||||
const animations = picture ? animationsUsing(project, ids).length : 0;
|
||||
const tiles = picture ? tilesUsing(project, ids).length : 0;
|
||||
const eased = asset.type === 'curve' ? curveUsages(project, assetId) : { keys: 0, nodes: 0 };
|
||||
const typed =
|
||||
asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 };
|
||||
asset.type === 'struct' ? structTypeUsages(project, assetId) : { places: 0, lists: 0, nodes: 0 };
|
||||
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
|
||||
const painted = asset.type === 'tile' ? cellsPainted(state.project, assetId) : { tilemaps: 0, cells: 0 };
|
||||
const painted = asset.type === 'tile' ? cellsPainted(project, assetId) : { tilemaps: 0, cells: 0 };
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
@@ -307,10 +325,7 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
]).openCentered();
|
||||
return { title: `Delete "${asset.name}"?`, detail };
|
||||
}
|
||||
|
||||
function wornDetail(sprites: number, animations: number, tiles: number, type: AssetType): string {
|
||||
|
||||
@@ -11,7 +11,8 @@ export interface AgentLinkHost {
|
||||
view(): { view: object; summary: string };
|
||||
/** The one line alone, cheap enough to ask twice a second. */
|
||||
summary(): string;
|
||||
run(tool: string, args: Record<string, unknown>): Promise<ToolResult>;
|
||||
/** `wait` tells the relay the answer takes longer: the user is being asked something. */
|
||||
run(tool: string, args: Record<string, unknown>, wait: () => void): Promise<ToolResult>;
|
||||
/** Another tab of the account switched the agent on; this one is off now. */
|
||||
replaced(): void;
|
||||
}
|
||||
@@ -111,8 +112,12 @@ export class AgentLink {
|
||||
return;
|
||||
case 'call': {
|
||||
let result: ToolResult;
|
||||
const wait = () => {
|
||||
if (socket.readyState === WebSocket.OPEN)
|
||||
socket.send(JSON.stringify({ type: 'wait', id: message.id }));
|
||||
};
|
||||
try {
|
||||
result = await this.host.run(message.tool ?? '', message.args ?? {});
|
||||
result = await this.host.run(message.tool ?? '', message.args ?? {}, wait);
|
||||
} catch (error) {
|
||||
result = failure(`The editor failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { type ArgSchema, argProblem } from './argCheck';
|
||||
import type { AgentView } from './agentView';
|
||||
import { AgentEdit, deletedAssets, type EditStep } from './editPlan';
|
||||
import { catalog, type CatalogTopic, getAsset, getGraph, listAssets } from './readTools';
|
||||
import TOOLS from './tools.json';
|
||||
|
||||
/** What a tool answers, as the relay hands it on to the agent (MCP's `CallToolResult`). */
|
||||
export interface ToolResult {
|
||||
@@ -7,19 +13,57 @@ export interface ToolResult {
|
||||
structuredContent?: object;
|
||||
}
|
||||
|
||||
/** What the agent can pick for the user to see; the schema in `tools.json` leaves every field but `kind` open. */
|
||||
export interface AgentSelection {
|
||||
kind: 'none' | 'asset' | 'objects' | 'nodes';
|
||||
asset?: string;
|
||||
in?: string;
|
||||
objects?: string[];
|
||||
nodes?: string[];
|
||||
}
|
||||
|
||||
export type PlayAction = 'play' | 'pause' | 'resume' | 'stop';
|
||||
|
||||
/** What the tools need of the editor. */
|
||||
export interface ToolHost {
|
||||
view(): AgentView;
|
||||
/** The editor as drawn, as a base64 PNG at most `maxWidth` pixels wide. */
|
||||
screenshot(maxWidth: number): Promise<{ data: string; width: number; height: number }>;
|
||||
project(): Project;
|
||||
/** Runs a command as one undo step: null once done, else why it was turned down. */
|
||||
execute(command: Command): string | null;
|
||||
/** Asks the user whether these assets may go; null once they agreed, else why not. */
|
||||
confirmDeletes(assetIds: readonly string[]): Promise<string | null>;
|
||||
/** Brings up an asset's tab, or a fixed tab by its id; null once done, else why not. */
|
||||
open(target: { asset?: string; tab?: string }): string | null;
|
||||
select(selection: AgentSelection): string | null;
|
||||
play(action: PlayAction): string | null;
|
||||
/** Takes back (or does again) the last edit: its label, null with nothing to take. */
|
||||
undo(): string | null;
|
||||
redo(): string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SCREENSHOT_WIDTH = 1280;
|
||||
/** Longer than this, an answer is cut: an agent reads it whole, every time. */
|
||||
const MAX_ANSWER_CHARS = 60_000;
|
||||
|
||||
export function failure(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
|
||||
function answer(data: unknown): ToolResult {
|
||||
let text = JSON.stringify(data);
|
||||
if (text.length > MAX_ANSWER_CHARS) {
|
||||
text = `${text.slice(0, MAX_ANSWER_CHARS)}… [cut at ${MAX_ANSWER_CHARS} characters – ask for less]`;
|
||||
}
|
||||
const structured = data !== null && typeof data === 'object' && !Array.isArray(data);
|
||||
return { content: [{ type: 'text', text }], ...(structured ? { structuredContent: data } : {}) };
|
||||
}
|
||||
|
||||
function done(text: string, structuredContent?: object): ToolResult {
|
||||
return { content: [{ type: 'text', text }], ...(structuredContent ? { structuredContent } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the tools of `tools.json` (#429). The relay has checked the name against the same
|
||||
* list; the arguments are checked here, where their meaning is known.
|
||||
@@ -29,17 +73,25 @@ export async function runTool(
|
||||
args: Record<string, unknown>,
|
||||
host: ToolHost,
|
||||
): Promise<ToolResult> {
|
||||
const schema = ARGS[name];
|
||||
if (!schema) return failure(`This editor has no tool ${name}; reload it to get the newest.`);
|
||||
const problem = argProblem(schema, args);
|
||||
if (problem) return failure(problem);
|
||||
try {
|
||||
return await run(name, args, host);
|
||||
} catch (error) {
|
||||
return failure(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function run(name: string, args: Record<string, unknown>, host: ToolHost): Promise<ToolResult> {
|
||||
switch (name) {
|
||||
case 'get_view': {
|
||||
const view = host.view();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(view) }], structuredContent: view };
|
||||
}
|
||||
case 'screenshot': {
|
||||
const maxWidth = args.maxWidth ?? DEFAULT_SCREENSHOT_WIDTH;
|
||||
if (typeof maxWidth !== 'number' || !Number.isInteger(maxWidth) || maxWidth < 320 || maxWidth > 3840) {
|
||||
return failure('maxWidth has to be a whole number from 320 to 3840');
|
||||
}
|
||||
const shot = await host.screenshot(maxWidth);
|
||||
const shot = await host.screenshot((args.maxWidth as number | undefined) ?? DEFAULT_SCREENSHOT_WIDTH);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'image', data: shot.data, mimeType: 'image/png' },
|
||||
@@ -47,7 +99,74 @@ export async function runTool(
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'list_assets':
|
||||
return answer({ 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':
|
||||
return answer(getGraph(host.project(), args.asset as string) ?? missing(args.asset, ' with a graph'));
|
||||
case 'catalog':
|
||||
return answer({ [args.topic as string]: catalog(host.project(), args.topic as CatalogTopic, args) });
|
||||
case 'run_commands':
|
||||
return runCommands(args, host);
|
||||
case 'open':
|
||||
return outcome(host.open(args), `Opened; the user sees it now.`);
|
||||
case 'select':
|
||||
return outcome(host.select(args as unknown as AgentSelection), 'Selected.');
|
||||
case 'play':
|
||||
return outcome(host.play(args.action as PlayAction), `${args.action as string}: done.`);
|
||||
case 'undo':
|
||||
case 'redo': {
|
||||
const step = name === 'undo' ? () => host.undo() : () => host.redo();
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < ((args.steps as number | undefined) ?? 1); i++) {
|
||||
const label = step();
|
||||
if (label === null) break;
|
||||
labels.push(label);
|
||||
}
|
||||
if (labels.length === 0) return failure(`There is nothing to ${name}.`);
|
||||
return done(`${name === 'undo' ? 'Undone' : 'Redone'}: ${labels.join('; ')}.`, { [name]: labels });
|
||||
}
|
||||
default:
|
||||
return failure(`This editor has no tool ${name}; reload it to get the newest.`);
|
||||
}
|
||||
}
|
||||
|
||||
function missing(id: unknown, what = ''): never {
|
||||
throw new Error(`There is no asset ${String(id)}${what} – list_assets has the ids`);
|
||||
}
|
||||
|
||||
function outcome(refusal: string | null, text: string): ToolResult {
|
||||
return refusal === null ? done(text) : failure(refusal);
|
||||
}
|
||||
|
||||
async function runCommands(args: Record<string, unknown>, host: ToolHost): Promise<ToolResult> {
|
||||
const steps = args.steps as EditStep[];
|
||||
const deletes = deletedAssets(steps);
|
||||
if (deletes.length > 0) {
|
||||
const refusal = await host.confirmDeletes(deletes);
|
||||
if (refusal !== null) return failure(refusal);
|
||||
}
|
||||
const label = `Agent: ${(args.label as string | undefined) ?? steps.map((step) => step.command).join(', ')}`;
|
||||
const edit = new AgentEdit(label, steps);
|
||||
const refusal = host.execute(edit);
|
||||
if (refusal !== null) return failure(`Nothing was changed. ${refusal}`);
|
||||
const made = edit.made.flatMap((ids, index) => (ids ? [{ step: index + 1, ...ids }] : []));
|
||||
const playing = host.view().play !== 'editing';
|
||||
const lines = [
|
||||
`Done as one undo step ("${label}").`,
|
||||
...made.map(({ step, ...ids }) => `Step ${step} made ${JSON.stringify(ids)}.`),
|
||||
...(playing
|
||||
? ['The game is running: this changed the running copy only, and it is gone when the game stops.']
|
||||
: []),
|
||||
];
|
||||
return done(lines.join('\n'), { label, made });
|
||||
}
|
||||
|
||||
/** The arguments of each tool, as `tools.json` gives them to the agent and the relay. */
|
||||
const ARGS: Record<string, ArgSchema> = Object.fromEntries(
|
||||
(TOOLS as unknown as { name: string; inputSchema: ArgSchema }[]).map((tool) => [
|
||||
tool.name,
|
||||
tool.inputSchema,
|
||||
]),
|
||||
);
|
||||
@@ -21,7 +21,14 @@ export interface ViewFacts {
|
||||
windows: { kind: string; document: string }[];
|
||||
selection: Selection;
|
||||
/** Menus and dialogs open over the editor, topmost last. */
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
popups: PopupView[];
|
||||
}
|
||||
|
||||
/** An open menu or dialog; a menu has no title of its own, so its entries say which one it is. */
|
||||
export interface PopupView {
|
||||
kind: 'menu' | 'dialog';
|
||||
title: string;
|
||||
entries?: readonly string[];
|
||||
}
|
||||
|
||||
export interface AssetRef {
|
||||
@@ -49,7 +56,7 @@ export interface AgentView {
|
||||
selection: SelectionView | null;
|
||||
play: 'editing' | 'playing' | 'paused';
|
||||
saved: SaveStatus;
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
popups: PopupView[];
|
||||
/** The camera of the tab showing, where it has one. */
|
||||
camera?: Record<string, number>;
|
||||
}
|
||||
@@ -80,7 +87,8 @@ export function viewSummary(view: AgentView): string {
|
||||
parts.push(`selection: ${selectionText(view.selection)}`);
|
||||
parts.push(view.play);
|
||||
const top = view.popups.at(-1);
|
||||
if (top) parts.push(top.kind === 'menu' ? 'a menu is open' : `dialog "${top.title}" is open`);
|
||||
if (top)
|
||||
parts.push(top.kind === 'menu' ? `menu ${menuText(top)} is open` : `dialog "${top.title}" is open`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
@@ -99,6 +107,16 @@ function selectionText(selection: SelectionView | null): string {
|
||||
return `${selection.kind}${where}`;
|
||||
}
|
||||
|
||||
/** A menu by its title, else by its first entries. */
|
||||
function menuText(menu: PopupView): string {
|
||||
if (menu.title) return `"${menu.title}"`;
|
||||
const entries = menu.entries ?? [];
|
||||
const shown = entries.slice(0, MENU_ENTRIES_TOLD).join(', ');
|
||||
return `(${shown}${entries.length > MENU_ENTRIES_TOLD ? ', …' : ''})`;
|
||||
}
|
||||
|
||||
const MENU_ENTRIES_TOLD = 4;
|
||||
|
||||
function countOf(n: number, noun: string): string {
|
||||
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The part of JSON Schema the agent's tools describe their arguments in (#429). An empty schema
|
||||
* takes anything – a property value, whose kind the command itself checks against the asset.
|
||||
*/
|
||||
export interface ArgSchema {
|
||||
type?: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean';
|
||||
description?: string;
|
||||
properties?: Record<string, ArgSchema>;
|
||||
required?: readonly string[];
|
||||
additionalProperties?: false;
|
||||
items?: ArgSchema;
|
||||
enum?: readonly string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
minItems?: number;
|
||||
}
|
||||
|
||||
/** What is wrong with `value` by `schema`, one line naming the place; null when nothing is. */
|
||||
export function argProblem(schema: ArgSchema, value: unknown, at = 'arguments'): string | null {
|
||||
switch (schema.type) {
|
||||
case 'object': {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
||||
return `${at} has to be an object`;
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of schema.required ?? []) {
|
||||
if (record[key] === undefined) return `${at}.${key} is missing`;
|
||||
}
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
const inner = schema.properties?.[key];
|
||||
if (!inner) {
|
||||
if (schema.additionalProperties === false) return `${at} has no ${key}`;
|
||||
continue;
|
||||
}
|
||||
const problem = argProblem(inner, item, `${at}.${key}`);
|
||||
if (problem) return problem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return `${at} has to be a list`;
|
||||
if (schema.minItems !== undefined && value.length < schema.minItems)
|
||||
return `${at} needs at least ${schema.minItems} item(s)`;
|
||||
for (const [index, item] of value.entries()) {
|
||||
const problem = schema.items ? argProblem(schema.items, item, `${at}[${index}]`) : null;
|
||||
if (problem) return problem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'string':
|
||||
if (typeof value !== 'string') return `${at} has to be a string`;
|
||||
if (schema.enum && !schema.enum.includes(value))
|
||||
return `${at} has to be one of ${schema.enum.join(', ')}`;
|
||||
if (schema.minLength !== undefined && value.length < schema.minLength) return `${at} may not be empty`;
|
||||
return null;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return `${at} has to be a number`;
|
||||
if (schema.type === 'integer' && !Number.isInteger(value)) return `${at} has to be a whole number`;
|
||||
if (schema.minimum !== undefined && value < schema.minimum)
|
||||
return `${at} has to be ${schema.minimum} or more`;
|
||||
if (schema.maximum !== undefined && value > schema.maximum)
|
||||
return `${at} has to be ${schema.maximum} or less`;
|
||||
return null;
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean' ? null : `${at} has to be true or false`;
|
||||
case undefined:
|
||||
return value === undefined ? `${at} is missing` : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** 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<string, ArgSchema>, 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:<id>"). 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<string, string> = { 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;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type Command, CommandError } from '../../core/commands/Command';
|
||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { argProblem } from './argCheck';
|
||||
import { type BuildContext, type Built, catalogCommand } from './commandCatalog';
|
||||
|
||||
/** One step of an edit as the agent sends it. */
|
||||
export interface EditStep {
|
||||
command: string;
|
||||
args?: Record<string, unknown>;
|
||||
/** A name later steps use for what this one makes: `"$name"` is its id, `"$name.closing"` another. */
|
||||
as?: string;
|
||||
}
|
||||
|
||||
/** How far apart nodes the agent gave no place stand, left to right. */
|
||||
export const NODE_STEP = 260;
|
||||
|
||||
const REF = /^\$([A-Za-z_][\w-]*)(?:\.(\w+))?$/;
|
||||
|
||||
/**
|
||||
* An agent's edit (#429): its steps as one undo step. Each step is built when the one before it has
|
||||
* run, so a step can work on what an earlier one made – the nodes of a behavior made in the same
|
||||
* edit. A step that fails takes the done ones back and throws a `CommandError` naming it; a redo
|
||||
* runs the commands built the first time.
|
||||
*/
|
||||
export class AgentEdit implements Command {
|
||||
/** What each step made, by the names of `Built.made`; filled by the first run. */
|
||||
readonly made: (Record<string, string> | null)[] = [];
|
||||
private built: Command[] | null = null;
|
||||
|
||||
constructor(
|
||||
readonly label: string,
|
||||
private readonly steps: readonly EditStep[],
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (this.built) {
|
||||
runAll(project, this.built);
|
||||
return;
|
||||
}
|
||||
const done: Command[] = [];
|
||||
const names = new Map<string, Record<string, string>>();
|
||||
const context: BuildContext = { project, spot: (assetId) => freeSpot(project, assetId) };
|
||||
try {
|
||||
for (const [index, step] of this.steps.entries()) {
|
||||
const where = `Step ${index + 1} (${step.command})`;
|
||||
try {
|
||||
const built = buildStep(step, names, context);
|
||||
built.command.execute(project);
|
||||
done.push(built.command);
|
||||
this.made.push(built.made ?? null);
|
||||
if (step.as !== undefined) {
|
||||
if (!built.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);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) throw new CommandError(`${where}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const command of done.reverse()) command.undo(project);
|
||||
this.made.length = 0;
|
||||
throw error;
|
||||
}
|
||||
this.built = done;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
for (const command of [...(this.built ?? [])].reverse()) command.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
/** The steps deleting an asset that was there before the edit: the user is asked about those first. */
|
||||
export function deletedAssets(steps: readonly EditStep[]): string[] {
|
||||
return steps.flatMap((step) => {
|
||||
const asset = step.args?.asset;
|
||||
const confirms = catalogCommand(step.command)?.confirms === true;
|
||||
return confirms && typeof asset === 'string' && !REF.test(asset) ? [asset] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildStep(
|
||||
step: EditStep,
|
||||
names: ReadonlyMap<string, Record<string, string>>,
|
||||
context: BuildContext,
|
||||
): Built {
|
||||
const entry = catalogCommand(step.command);
|
||||
if (!entry) throw new CommandError('no such command – catalog topic "commands" lists them');
|
||||
const args = resolve(step.args ?? {}, names);
|
||||
const problem = argProblem(entry.args, args, 'args');
|
||||
if (problem) throw new CommandError(problem);
|
||||
return entry.build(args as Record<string, unknown>, context);
|
||||
}
|
||||
|
||||
function runAll(project: Project, commands: readonly Command[]): void {
|
||||
const done: Command[] = [];
|
||||
try {
|
||||
for (const command of commands) {
|
||||
command.execute(project);
|
||||
done.push(command);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const command of done.reverse()) command.undo(project);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the ids of earlier steps in for `$name` and `$name.key`; other strings stay as they are. */
|
||||
function resolve(value: unknown, names: ReadonlyMap<string, Record<string, string>>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const match = REF.exec(value);
|
||||
const named = match ? names.get(match[1]!) : undefined;
|
||||
if (!match || !named) return value;
|
||||
const id = named[match[2] ?? 'id'];
|
||||
if (id === undefined) throw new CommandError(`"${value}": that step made no ${match[2]}`);
|
||||
return id;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => resolve(item, names));
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolve(item, names)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Right of every node the graph has, level with the topmost; the origin in an empty graph. */
|
||||
function freeSpot(project: Project, assetId: string): { x: number; y: number } {
|
||||
const nodes = findGraphAsset(project, assetId)?.graph.nodes ?? [];
|
||||
if (nodes.length === 0) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: Math.max(...nodes.map((node) => node.x)) + NODE_STEP,
|
||||
y: Math.min(...nodes.map((node) => node.y)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { findAsset, findGraphAsset, findObjectHolder } from '../../core/model/Project';
|
||||
import { Dialog } from '../../ui';
|
||||
import { activateAsset, deleteQuestion } from '../actions';
|
||||
import { type EditorState, nodeSelection, objectSelection } from '../state';
|
||||
import type { AgentSelection, PlayAction, ToolHost } from './agentTools';
|
||||
import type { AgentView } from './agentView';
|
||||
import { screenshot } from './screenshot';
|
||||
|
||||
/** What the agent's tools reach of the editor beyond its state. */
|
||||
export interface EditorParts {
|
||||
state: EditorState;
|
||||
app: Application;
|
||||
view(): AgentView;
|
||||
showTab(tab: 'scene' | 'game'): void;
|
||||
play: { readonly isPlaying: boolean; play(paused: boolean): void; stop(): void; togglePause(): void };
|
||||
}
|
||||
|
||||
/** How long the user has to answer an agent's question before it counts as a no. */
|
||||
export const CONFIRM_MS = 110_000;
|
||||
|
||||
/**
|
||||
* The editor as the agent's tools see it (#429). `wait` tells the relay that an answer will take a
|
||||
* while – the user is being asked something.
|
||||
*/
|
||||
export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost {
|
||||
const { state } = parts;
|
||||
return {
|
||||
view: () => parts.view(),
|
||||
screenshot: (maxWidth) => screenshot(parts.app, maxWidth),
|
||||
project: () => state.project,
|
||||
execute: (command) => state.attempt(command),
|
||||
confirmDeletes: (assetIds) => confirmDeletes(state, assetIds, wait),
|
||||
open: ({ asset, tab }) => {
|
||||
if (asset !== undefined) {
|
||||
if (!findAsset(state.project, asset)) return `There is no asset ${asset}`;
|
||||
activateAsset(state, asset);
|
||||
return null;
|
||||
}
|
||||
if (tab === 'scene' || tab === 'game') {
|
||||
parts.showTab(tab);
|
||||
return null;
|
||||
}
|
||||
return 'Name an asset or the tab (scene or game)';
|
||||
},
|
||||
select: (selection) => select(state, selection),
|
||||
play: (action) => play(parts, action),
|
||||
undo: () => {
|
||||
const label = state.commands.undoLabel;
|
||||
if (label !== null) state.undo();
|
||||
return label;
|
||||
},
|
||||
redo: () => {
|
||||
const label = state.commands.redoLabel;
|
||||
if (label !== null) state.redo();
|
||||
return label;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function select(state: EditorState, selection: AgentSelection): string | null {
|
||||
switch (selection.kind) {
|
||||
case 'none':
|
||||
state.select(null);
|
||||
return null;
|
||||
case 'asset':
|
||||
if (!selection.asset || !findAsset(state.project, selection.asset))
|
||||
return `There is no asset ${selection.asset}`;
|
||||
state.select({ kind: 'asset', assetId: selection.asset });
|
||||
return null;
|
||||
case 'objects': {
|
||||
const holder = selection.in ? findObjectHolder(state.project, selection.in) : undefined;
|
||||
if (!holder) return `There is no scene or container ${selection.in}`;
|
||||
const ids = selection.objects ?? [];
|
||||
const unknown = ids.find((id) => !holder.objects.some((object) => object.id === id));
|
||||
if (ids.length === 0 || unknown !== undefined)
|
||||
return `Name objects of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`;
|
||||
state.select(objectSelection(holder.id, ids));
|
||||
return null;
|
||||
}
|
||||
case 'nodes': {
|
||||
const holder = selection.asset ? findGraphAsset(state.project, selection.asset) : undefined;
|
||||
if (!holder) return `There is no graph asset ${selection.asset}`;
|
||||
const ids = selection.nodes ?? [];
|
||||
const unknown = ids.find((id) => !holder.graph.nodes.some((node) => node.id === id));
|
||||
if (ids.length === 0 || unknown !== undefined)
|
||||
return `Name nodes of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`;
|
||||
state.select(nodeSelection(holder.id, ids, ids[0], [], []));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function play(parts: EditorParts, action: PlayAction): string | null {
|
||||
const { state, play: session } = parts;
|
||||
switch (action) {
|
||||
case 'play':
|
||||
if (session.isPlaying) return 'The game is running already';
|
||||
if (!state.sceneToPlay()) return 'There is no scene to play';
|
||||
session.play(false);
|
||||
// `play` waits for the physics engine when it is not loaded yet, and starts nothing then.
|
||||
return isRunning(session)
|
||||
? null
|
||||
: 'The game could not start yet (the physics engine is still loading); try again';
|
||||
case 'stop':
|
||||
if (!session.isPlaying) return 'The game is not running';
|
||||
session.stop();
|
||||
return null;
|
||||
case 'pause':
|
||||
case 'resume':
|
||||
if (!session.isPlaying) return 'The game is not running';
|
||||
if (state.paused === (action === 'pause'))
|
||||
return `The game is ${state.paused ? 'paused' : 'running'} already`;
|
||||
session.togglePause();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(session: EditorParts['play']): boolean {
|
||||
return session.isPlaying;
|
||||
}
|
||||
|
||||
/** One question for all the assets an edit would delete; the user's no, a refusal or the time running out is the answer. */
|
||||
function confirmDeletes(
|
||||
state: EditorState,
|
||||
assetIds: readonly string[],
|
||||
wait: () => void,
|
||||
): Promise<string | null> {
|
||||
const questions = [];
|
||||
for (const id of assetIds) {
|
||||
const question = deleteQuestion(state.project, id);
|
||||
if (!question) return Promise.resolve(`Nothing was changed. There is no asset ${id}`);
|
||||
if ('refusal' in question) return Promise.resolve(`Nothing was changed. ${question.refusal}`);
|
||||
questions.push(question);
|
||||
}
|
||||
const title =
|
||||
questions.length === 1
|
||||
? `The agent asks: ${questions[0]!.title}`
|
||||
: `The agent asks: delete ${questions.length} assets?`;
|
||||
const detail = questions
|
||||
.map((q) => (questions.length === 1 ? q.detail : `${q.title} ${q.detail}`))
|
||||
.join('\n');
|
||||
wait();
|
||||
return new Promise((resolve) => {
|
||||
let answered = false;
|
||||
const answer = (refusal: string | null) => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
clearTimeout(timer);
|
||||
resolve(refusal);
|
||||
};
|
||||
const dialog = new Dialog(title, detail, [
|
||||
{ label: 'Cancel', onClick: () => answer('The user said no; nothing was changed.') },
|
||||
{ label: 'Delete', danger: true, onClick: () => answer(null) },
|
||||
]);
|
||||
const timer = setTimeout(() => {
|
||||
answer(`The user did not answer within ${CONFIRM_MS / 1000} s; nothing was changed.`);
|
||||
dialog.close();
|
||||
}, CONFIRM_MS);
|
||||
dialog.openCentered();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { 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 { 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';
|
||||
|
||||
/**
|
||||
* What the agent reads of the project (#429): plain data, as JSON, built from the model alone. Big
|
||||
* parts – a graph, the pixels of a drawing, the cells of a map – are left for their own tool or
|
||||
* counted, so one answer stays readable.
|
||||
*/
|
||||
|
||||
export function listAssets(project: Project, type?: string, query?: string): object[] {
|
||||
const needle = query?.trim().toLowerCase() ?? '';
|
||||
return project.assets
|
||||
.filter(
|
||||
(asset) => (!type || asset.type === type) && (!needle || asset.name.toLowerCase().includes(needle)),
|
||||
)
|
||||
.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
...(asset.owner ? { owner: asset.owner } : {}),
|
||||
...('objects' in asset ? { objects: asset.objects.length } : {}),
|
||||
...('graph' in asset ? { nodes: asset.graph.nodes.length } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** An asset as stored, with the names of what a scene or container holds and the rows of its properties. */
|
||||
export function getAsset(project: Project, assetId: string): object | null {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset) return null;
|
||||
const plain = JSON.parse(JSON.stringify(asset, trimmed)) as Record<string, unknown>;
|
||||
if ('objects' in asset) {
|
||||
plain.objects = asset.objects.map((object) => ({
|
||||
...object,
|
||||
name: objectName(object, findAsset(project, object.assetId)),
|
||||
asset: assetLabel(project, object.assetId),
|
||||
}));
|
||||
}
|
||||
plain.properties = schemaForAsset(project, asset).map(propertyRow);
|
||||
return plain;
|
||||
}
|
||||
|
||||
/** Leaves out what is too big to read and has a tool of its own, or tells only how much there is. */
|
||||
function trimmed(key: string, value: unknown): unknown {
|
||||
if (key === 'graph' && value !== null && typeof value === 'object' && 'nodes' in value) {
|
||||
const graph = value as { nodes: unknown[]; edges: unknown[] };
|
||||
return { nodes: graph.nodes.length, edges: graph.edges.length, read: 'get_graph' };
|
||||
}
|
||||
if ((key === 'cels' || key === 'cells') && value !== null && typeof value === 'object') {
|
||||
return { count: Object.keys(value).length };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A graph with every node's ports as they are now, which edges sit on them, and its groups and notes. */
|
||||
export function getGraph(project: Project, assetId: string): object | null {
|
||||
const holder = findGraphAsset(project, assetId);
|
||||
if (!holder) return null;
|
||||
const { graph } = holder;
|
||||
const lookup = graphLookup(project);
|
||||
return {
|
||||
asset: { id: holder.id, name: holder.name, type: holder.type },
|
||||
nodes: graph.nodes.map((node) => nodeRow(node, resolveDef(node, graph, lookup))),
|
||||
edges: graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
from: edge.from,
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRow(node: GraphNode, def: ReturnType<typeof resolveDef>): object {
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
label: def?.label ?? node.type,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
params: node.params,
|
||||
inputs: (def?.inputs ?? []).map(portRow),
|
||||
outputs: (def?.outputs ?? []).map(portRow),
|
||||
};
|
||||
}
|
||||
|
||||
const portRow = (port: PortDef) => ({ key: port.key, type: port.type });
|
||||
|
||||
export type CatalogTopic = 'commands' | 'nodes' | 'asset_types' | 'variable_kinds';
|
||||
|
||||
/** What the agent can make and run, by topic; `asset` names the graph for `nodes`. */
|
||||
export function catalog(
|
||||
project: Project,
|
||||
topic: CatalogTopic,
|
||||
options: { asset?: string; query?: string; type?: string } = {},
|
||||
): object {
|
||||
switch (topic) {
|
||||
case 'commands':
|
||||
return COMMANDS.map(({ name, description, args }) => ({ name, description, args }));
|
||||
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');
|
||||
const entries = rankedEntries(paletteEntries(project, holder), options.query ?? '');
|
||||
// A search answers in full; the whole list is only a table of contents.
|
||||
return options.query
|
||||
? entries.slice(0, MAX_NODES_IN_FULL).map(nodeEntry)
|
||||
: entries.map(({ id, def }) => ({ id, label: def.label, category: def.category }));
|
||||
}
|
||||
case 'asset_types': {
|
||||
if (options.type) {
|
||||
if (!(ASSET_TYPES as readonly string[]).includes(options.type))
|
||||
throw new Error(`No asset type ${options.type}`);
|
||||
const type = options.type as AssetType;
|
||||
return {
|
||||
type,
|
||||
label: ASSET_TYPE_LABELS[type],
|
||||
placeable: isPlaceableType(type),
|
||||
properties: schemaFor(type).map(propertyRow),
|
||||
};
|
||||
}
|
||||
return ASSET_TYPES.map((type) => ({
|
||||
type,
|
||||
label: ASSET_TYPE_LABELS[type],
|
||||
placeable: isPlaceableType(type),
|
||||
}));
|
||||
}
|
||||
case 'variable_kinds':
|
||||
return VARIABLE_KINDS;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_NODES_IN_FULL = 25;
|
||||
|
||||
function nodeEntry({ id, def, params }: PaletteEntry): object {
|
||||
return {
|
||||
id,
|
||||
label: def.label,
|
||||
category: def.category,
|
||||
description: def.description,
|
||||
inputs: def.inputs.map(portRow),
|
||||
outputs: def.outputs.map(portRow),
|
||||
settings: def.params.map(propertyRow),
|
||||
...(Object.keys(params).length > 0 ? { presets: params } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A property's row without what only the inspector's drawing needs. */
|
||||
function propertyRow(def: PropertyDef): object {
|
||||
const { key, label, kind } = def;
|
||||
const row: Record<string, unknown> = { key, label, kind, default: def.default };
|
||||
for (const extra of ['options', 'min', 'max', 'assetTypes'] as const) {
|
||||
if (extra in def) row[extra] = (def as Record<string, unknown>)[extra];
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function assetLabel(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
): { id: string; name: string; type: Asset['type'] | 'missing' } {
|
||||
const asset = findAsset(project, assetId);
|
||||
return { id: assetId, name: asset?.name ?? '', type: asset?.type ?? 'missing' };
|
||||
}
|
||||
+168
-1
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"name": "get_view",
|
||||
"title": "What the user sees",
|
||||
"description": "What the user is looking at in the editor right now: the project, the open tab and document, the selection (asset, scene objects, graph nodes, keys, ...), the camera of the view showing, whether the game is playing or paused, and an open dialog. Call it first, and again whenever you were told the view changed.",
|
||||
"description": "What the user is looking at in the editor right now: the project, the open tab and document, the selection (asset, scene objects, graph nodes, keys, ...), the camera of the view showing, whether the game is playing or paused, and an open menu or dialog. Call it first, and again whenever you were told the view changed.",
|
||||
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
@@ -23,5 +23,172 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Only assets of this type (scene, sprite, behavior, ...)."
|
||||
},
|
||||
"query": { "type": "string", "description": "Only assets whose name contains this." }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "asset": { "type": "string", "description": "Id of the asset." } },
|
||||
"required": ["asset"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } },
|
||||
"required": ["asset"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": { "type": "string", "enum": ["commands", "nodes", "asset_types", "variable_kinds"] },
|
||||
"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\"."
|
||||
},
|
||||
"type": { "type": "string", "description": "For asset_types: one type to describe in full." }
|
||||
},
|
||||
"required": ["topic"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "run_commands",
|
||||
"title": "Change the project",
|
||||
"description": "Runs commands (catalog topic \"commands\") in order as ONE undo step: if one fails, none of them stays, and the answer names the step. A step with \"as\": \"name\" lets later steps use what it made: \"$name\" is its id, \"$name.closing\" another id it names. Each step is built after the one before has run, so a step can add nodes to a behavior made in the same call. Deleting an asset asks the user first.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": { "type": "string", "minLength": 1, "description": "Name of the command." },
|
||||
"args": { "type": "object", "description": "Its arguments, as the catalog lists them." },
|
||||
"as": { "type": "string", "minLength": 1, "description": "A name for what this step makes." }
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "What the edit does, in a few words: the user sees it as the undo step."
|
||||
}
|
||||
},
|
||||
"required": ["steps"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": {
|
||||
"readOnlyHint": false,
|
||||
"destructiveHint": true,
|
||||
"idempotentHint": false,
|
||||
"openWorldHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "open",
|
||||
"title": "Show something",
|
||||
"description": "Brings up an asset's tab (a scene becomes the scene being edited), or the scene or game tab.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"asset": { "type": "string", "description": "Id of the asset to open." },
|
||||
"tab": { "type": "string", "enum": ["scene", "game"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "select",
|
||||
"title": "Select",
|
||||
"description": "Selects an asset, placed objects of a scene or container, or nodes of a graph – what the inspector then shows. kind \"none\" clears the selection.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["none", "asset", "objects", "nodes"] },
|
||||
"asset": {
|
||||
"type": "string",
|
||||
"description": "The asset (kind asset), or the graph asset (kind nodes)."
|
||||
},
|
||||
"in": { "type": "string", "description": "Kind objects: the scene or container." },
|
||||
"objects": { "type": "array", "items": { "type": "string" } },
|
||||
"nodes": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "play",
|
||||
"title": "Play the game",
|
||||
"description": "Plays the game in the editor, pauses, resumes or stops it. Edits made while it runs change the running copy only and are gone when it stops.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "action": { "type": "string", "enum": ["play", "pause", "resume", "stop"] } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "undo",
|
||||
"title": "Undo",
|
||||
"description": "Takes back the last edits, the user's as well as yours, like Ctrl+Z; one run_commands call is one step.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "steps": { "type": "integer", "minimum": 1, "maximum": 20 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "redo",
|
||||
"title": "Redo",
|
||||
"description": "Does the edits again that undo took back, like Ctrl+Y.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "steps": { "type": "integer", "minimum": 1, "maximum": 20 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }
|
||||
}
|
||||
]
|
||||
+11
-1
@@ -306,6 +306,8 @@ export class EditorState {
|
||||
pickEnd: PickEnd | null = null;
|
||||
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
|
||||
private edit: { project: Project; commands: CommandStack } | null = null;
|
||||
/** Why the last `attempt` was turned down. */
|
||||
private rejection = '';
|
||||
|
||||
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
|
||||
|
||||
@@ -477,12 +479,19 @@ export class EditorState {
|
||||
* In play mode the command changes the runtime copy only (see `enterPlay`).
|
||||
*/
|
||||
execute(command: Command): boolean {
|
||||
return this.runEdit(() => {
|
||||
return this.attempt(command) === null;
|
||||
}
|
||||
|
||||
/** `execute` for a caller that has to say why an edit was turned down: the message, or null once done (#429). */
|
||||
attempt(command: Command): string | null {
|
||||
this.rejection = 'The edit was turned down';
|
||||
const done = this.runEdit(() => {
|
||||
this.commands.execute(
|
||||
new KeepRoutingCommand(new FitMediaCommand(new KeepCompositionSpritesCommand(command))),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
return done ? null : this.rejection;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
@@ -504,6 +513,7 @@ export class EditorState {
|
||||
if (!operation()) return false;
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
this.rejection = error.message;
|
||||
this.setNotice(error.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ interface Row {
|
||||
|
||||
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
|
||||
export class Menu extends Popup {
|
||||
/** The entries' labels as written, separators left out: what an agent is told is open (#429). */
|
||||
readonly entries: readonly string[];
|
||||
private readonly rows: Row[] = [];
|
||||
private readonly list = new VStack({ gap: 2 });
|
||||
private readonly scroll = new ScrollContainer();
|
||||
@@ -89,6 +91,7 @@ export class Menu extends Popup {
|
||||
|
||||
constructor(items: MenuEntry[], options: MenuOptions = {}) {
|
||||
super({ padding: PADDING, gap: 4, backdrop: !options.under });
|
||||
this.entries = items.flatMap((item) => ('separator' in item ? [] : [item.label]));
|
||||
this.under = options.under ?? null;
|
||||
this.minWidth = 160;
|
||||
this.title = options.title ? new Label(options.title, { style: 'muted', padding: Theme.padding }) : null;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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 } from '../../src/editor/agent/commandCatalog';
|
||||
import { NODE_STEP } from '../../src/editor/agent/editPlan';
|
||||
import TOOLS from '../../src/editor/agent/tools.json';
|
||||
import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers';
|
||||
|
||||
/** What an agent changes and reads through the tools (#429, stage 2). */
|
||||
|
||||
function hostFor(fixture: SceneFixture, deleteAnswer: string | null = null) {
|
||||
const asked: string[][] = [];
|
||||
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;
|
||||
},
|
||||
};
|
||||
return { host, asked };
|
||||
}
|
||||
|
||||
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 [
|
||||
{ command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' },
|
||||
{
|
||||
command: 'place_asset',
|
||||
args: { in: fixture.sceneId, asset: fixture.spriteId, x: 100, y: 50 },
|
||||
as: 'player',
|
||||
},
|
||||
{
|
||||
command: 'place_asset',
|
||||
args: { in: fixture.sceneId, asset: '$walk', x: 100, y: 50, parent: '$player' },
|
||||
},
|
||||
{
|
||||
command: 'add_node',
|
||||
args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } },
|
||||
as: 'key',
|
||||
},
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -200 } }, as: 'move' },
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' },
|
||||
{
|
||||
command: 'connect',
|
||||
args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } },
|
||||
},
|
||||
{
|
||||
command: 'connect',
|
||||
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } },
|
||||
},
|
||||
{
|
||||
command: 'connect',
|
||||
args: {
|
||||
asset: '$walk',
|
||||
from: { node: '$owner', port: 'object' },
|
||||
to: { node: '$move', port: 'object' },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('run_commands', () => {
|
||||
it('builds an object with a behavior in one call, as one undo step that takes all of it back', async () => {
|
||||
const fixture = makeSpriteScene('Hero');
|
||||
const { host } = hostFor(fixture);
|
||||
const before = JSON.stringify(fixture.project);
|
||||
const result = await runTool('run_commands', { steps: walkLeft(fixture), label: 'walk left' }, host);
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fixture.stack.undoLabel).toBe('Agent: walk left');
|
||||
|
||||
const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||||
const graph = findGraphAsset(fixture.project, behavior.id)!.graph;
|
||||
expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Owner']);
|
||||
expect(graph.nodes[0]!.params.key).toBe('ArrowLeft');
|
||||
expect(graph.nodes[1]!.params.dx).toBe(-200);
|
||||
expect(graph.edges.map((edge) => `${edge.from.port}>${edge.to.port}`)).toEqual([
|
||||
'exec>exec',
|
||||
'dt>dt',
|
||||
'object>object',
|
||||
]);
|
||||
const scene = fixture.sceneOf();
|
||||
const [player, walk] = scene.objects;
|
||||
expect(walk).toMatchObject({ assetId: behavior.id, parentId: player!.id });
|
||||
expect(text(result)).toContain(`Step 1 made {"id":"${behavior.id}"}`);
|
||||
|
||||
const after = JSON.stringify(fixture.project);
|
||||
expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: walk left.');
|
||||
expect(JSON.stringify(fixture.project)).toBe(before);
|
||||
await runTool('redo', {}, host);
|
||||
expect(JSON.stringify(fixture.project)).toBe(after);
|
||||
});
|
||||
|
||||
it('places nodes given no place side by side, right of those the graph has', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture, 'Jump');
|
||||
const { host } = hostFor(fixture);
|
||||
await runTool(
|
||||
'run_commands',
|
||||
{ steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 40, y: 80 } }] },
|
||||
host,
|
||||
);
|
||||
await runTool(
|
||||
'run_commands',
|
||||
{
|
||||
steps: [
|
||||
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' } },
|
||||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' } },
|
||||
],
|
||||
},
|
||||
host,
|
||||
);
|
||||
expect(behavior.graph.nodes.map(({ x, y }) => [x, y])).toEqual([
|
||||
[40, 80],
|
||||
[40 + NODE_STEP, 80],
|
||||
[40 + 2 * NODE_STEP, 80],
|
||||
]);
|
||||
});
|
||||
|
||||
it('changes nothing when a step fails, and names the step and why', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { host } = hostFor(fixture);
|
||||
const before = JSON.stringify(fixture.project);
|
||||
const steps = walkLeft(fixture);
|
||||
steps[7] = {
|
||||
command: 'connect',
|
||||
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'exec' } },
|
||||
};
|
||||
const result = await runTool('run_commands', { steps }, host);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(text(result)).toBe('Nothing was changed. Step 8 (connect): Cannot connect number to exec');
|
||||
expect(JSON.stringify(fixture.project)).toBe(before);
|
||||
expect(fixture.stack.canUndo).toBe(true); // the fixture's own two assets, nothing of the agent's
|
||||
expect(fixture.stack.undoLabel).not.toMatch(/^Agent/);
|
||||
});
|
||||
|
||||
it('checks every step’s arguments against its command, and refuses an unknown command or node', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture);
|
||||
const { host } = hostFor(fixture);
|
||||
const one = async (step: object) => text(await runTool('run_commands', { steps: [step] }, host));
|
||||
expect(await one({ command: 'rename_asset', args: { asset: fixture.spriteId } })).toBe(
|
||||
'Nothing was changed. Step 1 (rename_asset): args.name is missing',
|
||||
);
|
||||
expect(await one({ command: 'create_asset', args: { type: 'texture', name: 'T' } })).toMatch(
|
||||
/Step 1 \(create_asset\): args\.type has to be one of scene, /,
|
||||
);
|
||||
expect(await one({ command: 'teleport', args: {} })).toMatch(/Step 1 \(teleport\): no such command/);
|
||||
expect(await one({ command: 'add_node', args: { asset: behavior.id, node: 'Fly' } })).toMatch(
|
||||
/Step 1 \(add_node\): A behavior graph has no node "Fly" – look it up with catalog/,
|
||||
);
|
||||
expect(
|
||||
await one({ command: 'rename_asset', args: { asset: fixture.spriteId, name: 'X', colour: 1 } }),
|
||||
).toMatch(/args has no colour/);
|
||||
expect(text(await runTool('run_commands', { steps: [] }, host))).toBe(
|
||||
'arguments.steps needs at least 1 item(s)',
|
||||
);
|
||||
});
|
||||
|
||||
it('finds a node by its label too, and keeps a typed-in input value as given', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture);
|
||||
const { host } = hostFor(fixture);
|
||||
await runTool(
|
||||
'run_commands',
|
||||
{ steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'on key held' } }] },
|
||||
host,
|
||||
);
|
||||
expect(behavior.graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld']);
|
||||
});
|
||||
|
||||
it('asks the user before deleting an asset that was there, and does nothing on a no', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const no = hostFor(fixture, 'The user said no; nothing was changed.');
|
||||
const deleting = { steps: [{ command: 'delete_asset', args: { asset: fixture.spriteId } }] };
|
||||
const refused = await runTool('run_commands', deleting, no.host);
|
||||
expect(no.asked).toEqual([[fixture.spriteId]]);
|
||||
expect(text(refused)).toBe('The user said no; nothing was changed.');
|
||||
expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(true);
|
||||
|
||||
const yes = hostFor(fixture);
|
||||
await runTool('run_commands', deleting, yes.host);
|
||||
expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(false);
|
||||
|
||||
// What the agent made in the same call is its own to take away again.
|
||||
const own = hostFor(fixture);
|
||||
await runTool(
|
||||
'run_commands',
|
||||
{
|
||||
steps: [
|
||||
{ command: 'create_asset', args: { type: 'sprite', name: 'Tmp' }, as: 'tmp' },
|
||||
{ command: 'delete_asset', args: { asset: '$tmp' } },
|
||||
],
|
||||
},
|
||||
own.host,
|
||||
);
|
||||
expect(own.asked).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a name for a step that makes nothing, or a part a step did not make', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { host } = hostFor(fixture);
|
||||
const rename = { command: 'rename_asset', args: { asset: fixture.spriteId, name: 'A' }, as: 'r' };
|
||||
expect(text(await runTool('run_commands', { steps: [rename] }, host))).toMatch(
|
||||
/Step 1 \(rename_asset\): makes nothing a later step could call "r"/,
|
||||
);
|
||||
const steps = [
|
||||
{ command: 'create_asset', args: { type: 'sprite', name: 'S' }, as: 's' },
|
||||
{ command: 'rename_asset', args: { asset: '$s.closing', name: 'T' } },
|
||||
];
|
||||
expect(text(await runTool('run_commands', { steps }, host))).toMatch(
|
||||
/Step 2 \(rename_asset\): "\$s.closing": that step made no closing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading', () => {
|
||||
it('get_graph lists each node’s ports as they are, and the edges', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const { host } = hostFor(fixture);
|
||||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||||
const graph = (await runTool('get_graph', { asset: walk.id }, host)).structuredContent as {
|
||||
nodes: { type: string; inputs: { key: string; type: string }[] }[];
|
||||
edges: unknown[];
|
||||
};
|
||||
expect(graph.edges).toHaveLength(3);
|
||||
expect(graph.nodes.find((node) => node.type === 'Move')!.inputs.map((port) => port.key)).toEqual([
|
||||
'exec',
|
||||
'object',
|
||||
'dx',
|
||||
'dy',
|
||||
'scale',
|
||||
'dt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('get_asset counts a graph instead of spelling it out, and names a scene’s objects', async () => {
|
||||
const fixture = makeSpriteScene('Hero');
|
||||
const { host } = hostFor(fixture);
|
||||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||||
const asset = (await runTool('get_asset', { asset: walk.id }, host)).structuredContent as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(asset.graph).toEqual({ nodes: 3, edges: 3, read: 'get_graph' });
|
||||
const scene = (await runTool('get_asset', { asset: fixture.sceneId }, host)).structuredContent as {
|
||||
objects: { name: string }[];
|
||||
};
|
||||
expect(scene.objects.map((object) => object.name)).toEqual(['Hero', 'Walk']);
|
||||
expect(text(await runTool('get_asset', { asset: 'nope' }, host))).toMatch(/There is no asset nope/);
|
||||
});
|
||||
|
||||
it('list_assets filters by type and name', async () => {
|
||||
const fixture = makeSpriteScene('Hero');
|
||||
makeBehavior(fixture, 'Jump');
|
||||
const { host } = hostFor(fixture);
|
||||
const listed = (query: Record<string, unknown>) =>
|
||||
runTool('list_assets', query, host).then(
|
||||
(r) => (r.structuredContent as { assets: { name: string }[] }).assets,
|
||||
);
|
||||
expect((await listed({})).map((asset) => asset.name)).toEqual(['Level', 'Hero', 'Jump']);
|
||||
expect((await listed({ type: 'behavior' })).map((asset) => asset.name)).toEqual(['Jump']);
|
||||
expect((await listed({ query: 'he' })).map((asset) => asset.name)).toEqual(['Hero']);
|
||||
});
|
||||
|
||||
it('the node catalog of a graph answers a search in full, and lists every node without one', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture);
|
||||
const { host } = hostFor(fixture);
|
||||
const nodes = async (query?: string) =>
|
||||
(
|
||||
(await runTool('catalog', { topic: 'nodes', asset: behavior.id, ...(query ? { query } : {}) }, host))
|
||||
.structuredContent as { nodes: { id: string; inputs?: unknown[]; settings?: { key: string }[] }[] }
|
||||
).nodes;
|
||||
const found = await nodes('key held');
|
||||
expect(found[0]).toMatchObject({ id: 'OnKeyHeld', inputs: [] });
|
||||
expect(found[0]!.settings!.map((setting) => setting.key)).toContain('key');
|
||||
const all = await nodes();
|
||||
expect(all.some((entry) => entry.id === 'Move')).toBe(true);
|
||||
expect(all[0]!.inputs).toBeUndefined();
|
||||
expect(text(await runTool('catalog', { topic: 'nodes' }, host))).toMatch(/Name the graph asset/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the catalog', () => {
|
||||
it('has every command once, each with arguments the check understands', async () => {
|
||||
const names = COMMANDS.map((command) => command.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
for (const command of COMMANDS) {
|
||||
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 }[];
|
||||
};
|
||||
expect(listed.commands.map((command) => command.name)).toEqual(names);
|
||||
});
|
||||
|
||||
it('the tools are the ones the relay lists, each with a schema', () => {
|
||||
expect(TOOLS.map((tool) => tool.name)).toEqual([
|
||||
'get_view',
|
||||
'screenshot',
|
||||
'list_assets',
|
||||
'get_asset',
|
||||
'get_graph',
|
||||
'catalog',
|
||||
'run_commands',
|
||||
'open',
|
||||
'select',
|
||||
'play',
|
||||
'undo',
|
||||
'redo',
|
||||
]);
|
||||
for (const tool of TOOLS)
|
||||
expect(tool.inputSchema).toMatchObject({ type: 'object', additionalProperties: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('argProblem', () => {
|
||||
it('names the place of the first problem', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
at: { type: 'array' as const, items: { type: 'integer' as const, minimum: 0 } },
|
||||
kind: { type: 'string' as const, enum: ['a', 'b'] },
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false as const,
|
||||
};
|
||||
expect(argProblem(schema, { kind: 'a', at: [1, 2] })).toBeNull();
|
||||
expect(argProblem(schema, { kind: 'c' })).toBe('arguments.kind has to be one of a, b');
|
||||
expect(argProblem(schema, { kind: 'a', at: [1, -1] })).toBe('arguments.at[1] has to be 0 or more');
|
||||
expect(argProblem(schema, { kind: 'a', at: [1.5] })).toBe('arguments.at[0] has to be a whole number');
|
||||
expect(argProblem(schema, [])).toBe('arguments has to be an object');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
|
||||
import { runTool } from '../../src/editor/agent/agentTools';
|
||||
import { runTool, type ToolHost } from '../../src/editor/agent/agentTools';
|
||||
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
|
||||
import TOOLS from '../../src/editor/agent/tools.json';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
|
||||
@@ -109,6 +108,14 @@ describe('get_view', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('says which menu is open by its first entries when it has no title', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const menu = (entries: string[]) =>
|
||||
viewSummary(agentView(facts(fixture, { popups: [{ kind: 'menu', title: '', entries }] })));
|
||||
expect(menu(['Change password…', 'Sign out'])).toMatch(/; menu \(Change password…, Sign out\) is open$/);
|
||||
expect(menu(['a', 'b', 'c', 'd', 'e'])).toMatch(/; menu \(a, b, c, d, …\) is open$/);
|
||||
});
|
||||
|
||||
it('has a line that does not move with the camera', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const view = agentView(facts(fixture));
|
||||
@@ -117,17 +124,23 @@ describe('get_view', () => {
|
||||
});
|
||||
|
||||
describe('the tools', () => {
|
||||
const host = {
|
||||
const unused = () => {
|
||||
throw new Error('not in this test');
|
||||
};
|
||||
const host: ToolHost = {
|
||||
view: () => agentView(facts(makeSpriteScene())),
|
||||
screenshot: (maxWidth: number) =>
|
||||
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
|
||||
project: unused,
|
||||
execute: unused,
|
||||
confirmDeletes: unused,
|
||||
open: unused,
|
||||
select: unused,
|
||||
play: unused,
|
||||
undo: unused,
|
||||
redo: unused,
|
||||
};
|
||||
|
||||
it('are the ones the relay lists, each with a schema', () => {
|
||||
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
|
||||
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
|
||||
});
|
||||
|
||||
it('get_view answers the view as text and as structured content', async () => {
|
||||
const result = await runTool('get_view', {}, host);
|
||||
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
|
||||
@@ -146,7 +159,7 @@ describe('the tools', () => {
|
||||
});
|
||||
|
||||
it('an unknown tool says to reload', async () => {
|
||||
const answer = (await runTool('run_command', {}, host)).content[0];
|
||||
const answer = (await runTool('teleport', {}, host)).content[0];
|
||||
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -371,6 +371,31 @@ describe('tabs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('that ask the user something get the longer time, once', async () => {
|
||||
await start({ callTimeoutMs: 100, waitTimeoutMs: 400 });
|
||||
const tab = await onTab('alice');
|
||||
tab.answer = (message) => {
|
||||
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
|
||||
if (message.tool === 'get_view') {
|
||||
setTimeout(() => {
|
||||
tab.socket.send(
|
||||
JSON.stringify({ type: 'result', id: message.id, content: [{ type: 'text', text: 'yes' }] }),
|
||||
);
|
||||
}, 250);
|
||||
} else {
|
||||
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const session = await initialize('fwa_alice');
|
||||
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('yes');
|
||||
const started = Date.now();
|
||||
const unanswered = await call('fwa_alice', session, 'screenshot');
|
||||
const took = Date.now() - started;
|
||||
expect(toolResult(unanswered.body).isError).toBe(true);
|
||||
expect([took >= 350, took < 1500]).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const data = Buffer.alloc(300_000, 7).toString('base64');
|
||||
|
||||
+118
-6
@@ -2,19 +2,34 @@ import { authHeaders, SESSION_COOKIE, sessionToken } from '../lib/auth.mjs';
|
||||
|
||||
/** The tokens this step makes; a run that died halfway leaves them for the next to revoke. */
|
||||
const TOKEN_NAME = 'e2e 71';
|
||||
const TOOLS = [
|
||||
'get_view',
|
||||
'screenshot',
|
||||
'list_assets',
|
||||
'get_asset',
|
||||
'get_graph',
|
||||
'catalog',
|
||||
'run_commands',
|
||||
'open',
|
||||
'select',
|
||||
'play',
|
||||
'undo',
|
||||
'redo',
|
||||
];
|
||||
|
||||
/**
|
||||
* An agent in the editor over the MCP relay (#429): a token from the account menu's dialog, the
|
||||
* agent switched on there, and an MCP client that reads what the user sees – the tab, the
|
||||
* selection, a screenshot – and hears when it changes. Switched off at the badge, the agent gets
|
||||
* no further.
|
||||
* selection, a screenshot – and hears when it changes. It builds a walking object in one call,
|
||||
* plays it, and one Ctrl+Z of the user takes it all back; deleting waits for the user's answer.
|
||||
* Switched off at the badge, the agent gets no further.
|
||||
*
|
||||
* In CI the relay stands in front of the PHP server on a port of its own (`FEUERWERK_MCP_BASE_URL`,
|
||||
* see tools/ci/serve.sh); on stein2 nginx serves it beside the editor.
|
||||
*/
|
||||
export async function mcp(t) {
|
||||
const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t;
|
||||
const { clickText, menuTicked, exceptions, navigate, shot } = t;
|
||||
const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity } = t;
|
||||
const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl;
|
||||
const endpoint = `${site}mcp`;
|
||||
|
||||
@@ -117,10 +132,10 @@ export async function mcp(t) {
|
||||
const tools = (await rpc('tools/list')).body?.result?.tools.map((tool) => tool.name);
|
||||
const lonely = await callTool('get_view');
|
||||
check(
|
||||
'an agent with the token starts a session, lists get_view and screenshot, and hears no editor is on',
|
||||
'an agent with the token starts a session, lists its tools, and hears no editor is on',
|
||||
init.status === 200 &&
|
||||
init.body?.result?.serverInfo?.name === 'feuerwerk' &&
|
||||
JSON.stringify(tools) === '["get_view","screenshot"]' &&
|
||||
JSON.stringify(tools) === JSON.stringify(TOOLS) &&
|
||||
lonely?.isError === true &&
|
||||
/No Feuerwerk editor is connected/.test(lonely.content[0].text),
|
||||
JSON.stringify({ status: init.status, tools, lonely }),
|
||||
@@ -204,7 +219,98 @@ export async function mcp(t) {
|
||||
JSON.stringify({ shownTab, width, height, canvas, bytes: png.length, told }),
|
||||
);
|
||||
|
||||
// 71g. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||||
// 71g. One call builds an object that walks left while the left arrow is held: a sprite, a behavior
|
||||
// placed under it, three nodes and three edges, as one undo step.
|
||||
const sceneId = await json('__gm.state.sceneToPlay()?.id');
|
||||
const assetsBefore = await json('JSON.stringify(__gm.state.project.assets)');
|
||||
const built = await callTool('run_commands', {
|
||||
label: 'walk left',
|
||||
steps: [
|
||||
{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} hero` }, as: 'hero' },
|
||||
{ command: 'create_asset', args: { type: 'behavior', name: `${TOKEN_NAME} walk` }, as: 'walk' },
|
||||
{ command: 'place_asset', args: { in: sceneId, asset: '$hero', x: 0, y: 0 }, as: 'player' },
|
||||
{ command: 'place_asset', args: { in: sceneId, asset: '$walk', x: 0, y: 0, parent: '$player' } },
|
||||
{
|
||||
command: 'add_node',
|
||||
args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } },
|
||||
as: 'key',
|
||||
},
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -300 } }, as: 'move' },
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' },
|
||||
...[
|
||||
['$key', 'exec', 'exec'],
|
||||
['$key', 'dt', 'dt'],
|
||||
['$owner', 'object', 'object'],
|
||||
].map(([node, from, to]) => ({
|
||||
command: 'connect',
|
||||
args: { asset: '$walk', from: { node, port: from }, to: { node: '$move', port: to } },
|
||||
})),
|
||||
],
|
||||
});
|
||||
const made = built?.structuredContent?.made ?? [];
|
||||
const walkId = made.find((entry) => entry.step === 2)?.id;
|
||||
const playerId = made.find((entry) => entry.step === 3)?.id;
|
||||
const graph = walkId ? (await callTool('get_graph', { asset: walkId }))?.structuredContent : null;
|
||||
check(
|
||||
'run_commands builds a sprite with a walking behavior in one call, one undo step named by the agent',
|
||||
built?.isError === false &&
|
||||
(await json('__gm.state.commands.undoLabel')) === 'Agent: walk left' &&
|
||||
graph?.nodes?.map((node) => node.type).join() === 'OnKeyHeld,Move,Owner' &&
|
||||
graph.edges.length === 3,
|
||||
JSON.stringify({ built: built?.content?.[0]?.text?.slice(0, 300), graph: graph?.edges?.length }),
|
||||
);
|
||||
|
||||
// 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' });
|
||||
await sleep(300);
|
||||
const x0 = (await entity(playerId))?.x;
|
||||
await holdKey('ArrowLeft', 400);
|
||||
const x1 = (await entity(playerId))?.x;
|
||||
const stopped = await callTool('play', { action: 'stop' });
|
||||
await waitUntil('__gm.state.mode === "edit"', { label: 'the game stopped' });
|
||||
check(
|
||||
'play started by the agent runs its behavior: the object walks left while ArrowLeft is held',
|
||||
started?.isError === false && stopped?.isError === false && x0 === 0 && x1 < -40,
|
||||
JSON.stringify({ started: started?.content, x0, x1 }),
|
||||
);
|
||||
|
||||
// 71i. One Ctrl+Z of the user takes the agent's whole step back.
|
||||
await press('z', { ctrl: true });
|
||||
await settle();
|
||||
check(
|
||||
'one Ctrl+Z takes back everything the call made',
|
||||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||||
JSON.stringify({ undo: await json('__gm.state.commands.undoLabel') }),
|
||||
);
|
||||
|
||||
// 71j. Deleting asks the user: the call waits for the answer, and a no leaves the asset.
|
||||
const temp = await callTool('run_commands', {
|
||||
steps: [{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} temp` } }],
|
||||
});
|
||||
const tempId = temp?.structuredContent?.made?.[0]?.id;
|
||||
const deleting = callTool('run_commands', {
|
||||
steps: [{ command: 'delete_asset', args: { asset: tempId } }],
|
||||
});
|
||||
const asks = `The agent asks: Delete "${TOKEN_NAME} temp"?`;
|
||||
await waitUntil(`Boolean(__gm.locate(${JSON.stringify(asks)}, false))`, {
|
||||
label: 'the question is open',
|
||||
});
|
||||
await shot('71-agent-delete');
|
||||
await clickText('Cancel');
|
||||
const refused = await deleting;
|
||||
const kept = await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(tempId)})`);
|
||||
await callTool('undo');
|
||||
check(
|
||||
'deleting asks the user first; the call waits, and on Cancel the asset stays',
|
||||
refused?.isError === true &&
|
||||
/said no/.test(refused.content[0].text) &&
|
||||
kept === true &&
|
||||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||||
JSON.stringify({ refused: refused?.content, kept }),
|
||||
);
|
||||
|
||||
// 71k. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||||
const badge = await locate('Agent · 1');
|
||||
await click(badge.x, badge.y);
|
||||
await waitUntil('__gm.agent().status === "off"', { label: 'the agent is off' });
|
||||
@@ -217,10 +323,16 @@ export async function mcp(t) {
|
||||
JSON.stringify({ after, revoked: revoked.status }),
|
||||
);
|
||||
check('the agent session threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||||
} catch (error) {
|
||||
// The clean-up below can fail on a page that hangs, and its error would hide this one.
|
||||
console.error('71 stopped:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await stream?.cancel().catch(() => {});
|
||||
await t.evaluate('__gm.switchAgent?.(false)').catch(() => {});
|
||||
await sweep();
|
||||
// An unsaved project holds the page with "Leave site?", which no one here answers.
|
||||
await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {});
|
||||
if (site !== baseUrl) await navigate();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface RelayOptions {
|
||||
backend?: string;
|
||||
log?: (line: string) => void;
|
||||
callTimeoutMs?: number;
|
||||
/** How long a call may take once the tab said it is asking the user. */
|
||||
waitTimeoutMs?: number;
|
||||
helloTimeoutMs?: number;
|
||||
pingMs?: number;
|
||||
maxMessageBytes?: number;
|
||||
|
||||
+25
-8
@@ -34,6 +34,10 @@ const INSTRUCTIONS = [
|
||||
"Feuerwerk is a 2D game editor in the browser. You work inside the user's open editor tab,",
|
||||
'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.',
|
||||
'When a tool answers that no editor is connected, ask the user to open the editor and switch on',
|
||||
'"Agent" in its account menu.',
|
||||
].join(' ');
|
||||
@@ -66,6 +70,7 @@ export async function startRelay(options) {
|
||||
backend,
|
||||
log = () => {},
|
||||
callTimeoutMs = 30_000,
|
||||
waitTimeoutMs = 120_000,
|
||||
helloTimeoutMs = 10_000,
|
||||
pingMs = 25_000,
|
||||
maxMessageBytes = 32 * 1024 * 1024,
|
||||
@@ -421,7 +426,7 @@ export async function startRelay(options) {
|
||||
detach(old, 'Another editor tab took over');
|
||||
old.conn.close(CLOSE.replaced, 'replaced');
|
||||
}
|
||||
const tab = new Tab(user, conn, callTimeoutMs);
|
||||
const tab = new Tab(user, conn, callTimeoutMs, waitTimeoutMs);
|
||||
tab.onView = () => viewChanged(key);
|
||||
tabs.set(key, tab);
|
||||
conn.send(JSON.stringify({ type: 'welcome', user }));
|
||||
@@ -472,27 +477,39 @@ class Tab {
|
||||
#pending = new Map();
|
||||
#next = 1;
|
||||
|
||||
constructor(user, conn, timeoutMs) {
|
||||
constructor(user, conn, timeoutMs, waitTimeoutMs) {
|
||||
this.user = user;
|
||||
this.conn = conn;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.waitTimeoutMs = waitTimeoutMs;
|
||||
}
|
||||
|
||||
/** @returns {Promise<{content: object[], isError?: boolean, structuredContent?: object}>} */
|
||||
call(tool, args) {
|
||||
const id = this.#next++;
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.#pending.delete(id);
|
||||
resolve(failure(`The editor did not answer within ${Math.round(this.timeoutMs / 1000)} s`));
|
||||
}, this.timeoutMs);
|
||||
this.#pending.set(id, { resolve, timer });
|
||||
this.#pending.set(id, { resolve, timer: this.#timer(id, this.timeoutMs) });
|
||||
this.conn.send(JSON.stringify({ type: 'call', id, tool, args }));
|
||||
});
|
||||
}
|
||||
|
||||
#timer(id, ms) {
|
||||
return setTimeout(() => {
|
||||
const waiting = this.#pending.get(id);
|
||||
this.#pending.delete(id);
|
||||
waiting?.resolve(failure(`The editor did not answer within ${Math.round(ms / 1000)} s`));
|
||||
}, ms);
|
||||
}
|
||||
|
||||
receive(message) {
|
||||
if (message?.type === 'result') {
|
||||
if (message?.type === 'wait') {
|
||||
// The tab is asking the user something: the call gets the longer time, once.
|
||||
const waiting = this.#pending.get(message.id);
|
||||
if (!waiting || waiting.extended) return;
|
||||
clearTimeout(waiting.timer);
|
||||
waiting.timer = this.#timer(message.id, this.waitTimeoutMs);
|
||||
waiting.extended = true;
|
||||
} else if (message?.type === 'result') {
|
||||
const waiting = this.#pending.get(message.id);
|
||||
if (!waiting) return;
|
||||
this.#pending.delete(message.id);
|
||||
|
||||
Reference in new issue
Block a user