An agent could build a game object but not see whether it works. It had to ask the user to press the keys. Now it can play the game in the editor itself, send keys and mouse input and read what happened. - play_input sends steps into the running game: hold or tap a key, keep it down or let it go, click, press, release or move the mouse, turn the wheel, wait. Holding and waiting count game seconds. - A mouse target is a point in world units or an object by id or name. A click on an object reports whether it landed on it. - The answer names the game time used, the keys still held, the mouse and the new console lines. - One tool instead of press_keys and pointer from the plan. A step list can then mix both, like holding Shift during a click. - get_runtime reads the running game: objects with id, type and position, the own variables of their behaviors, the Variables assets, the camera, keys, mouse and the console. Named objects come with all their properties. - A hidden editor tab gets no frames. play_input says so at once. If game time stands still for 25 s it stops, names the step and lets go of every key it held. - get_view and play say when the scene has no active camera. The first agent test missed that and only saw it on a screenshot. - The relay instructions tell the agent to test what it built. Found in the live test on a real project: - A click on an object by its name reported hit false. The hit test looked the target up by id only. - get_runtime listed the built-in switches of a behavior (enabled, runInEditor) as its variables. Tests: - agent-play (new), agent-edit, agent-view, relay green. - 5 counter-checks red: hold counts one frame, key never released, click ignores the object, hidden tab not refused, no variable filter. - e2e 71: 21/21. The agent holds ArrowLeft 0.5 s (x -120 to -270) and clicks the object (hit true). - Gate: CHECK EXIT 0, 319 files, 4944 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import { emptySharedState } from '../../src/core/graph';
|
||
import { findScene } from '../../src/core/model/Project';
|
||
import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools';
|
||
import type { AgentView } from '../../src/editor/agent/agentView';
|
||
import { type PlayDriver, pointOf, sendInput } from '../../src/editor/agent/playInput';
|
||
import { cameraHint, NO_CAMERA_HINT } from '../../src/editor/agent/runtimeView';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
type SceneFixture,
|
||
startWorld,
|
||
type StartedWorld,
|
||
} from '../helpers';
|
||
|
||
/** The agent plays its own game (#429, stage 4): keys and clicks in, positions and console out. */
|
||
|
||
/** A box at (200, 100) that walks left while ← is held, logs "jump" on Space and "clicked" when clicked. */
|
||
function game(withCamera = false) {
|
||
const f = makeSpriteScene('Box');
|
||
const behavior = makeBehavior(f, 'Logic', ['score']);
|
||
const box = place(f, f.spriteId, { x: 200, y: 100 });
|
||
place(f, behavior.id, { x: 200, y: 100 }, box);
|
||
if (withCamera) {
|
||
const camera = new CreateAssetCommand('camera', 'Cam');
|
||
f.stack.execute(camera);
|
||
place(f, camera.asset.id, { x: 0, y: 0 });
|
||
}
|
||
const edit = graphEditor(f, behavior);
|
||
const held = edit.addNode('OnKeyHeld', { key: 'ArrowLeft' });
|
||
const move = edit.addNode('Move', { dx: -100 });
|
||
const owner = edit.addNode('Owner');
|
||
edit.connect(held, 'exec', move, 'exec');
|
||
edit.connect(held, 'dt', move, 'dt');
|
||
edit.connect(owner, 'object', move, 'object');
|
||
const jump = edit.addNode('OnKeyDown', { key: ' ' });
|
||
edit.connect(jump, 'exec', edit.addNode('Log', { message: 'jump' }), 'exec');
|
||
const click = edit.addNode('OnClick');
|
||
edit.connect(click, 'exec', edit.addNode('Log', { message: 'clicked' }), 'exec');
|
||
const started = startWorld(f, { behaviors: true });
|
||
return { f, box, started, ...driverFor(started) };
|
||
}
|
||
|
||
/** Each frame is a 60th of a second of game time and 16 ms of wall time; `frozen` holds the game. */
|
||
function driverFor(started: StartedWorld) {
|
||
const state = { running: true, paused: false, hidden: false, frozen: false, wall: 0 };
|
||
const driver: PlayDriver = {
|
||
run: () => (state.running ? { world: started.world, input: started.input } : null),
|
||
paused: () => state.paused,
|
||
hidden: () => state.hidden,
|
||
frame: () => {
|
||
state.wall += 16;
|
||
if (!state.frozen) started.frame(1 / 60);
|
||
return Promise.resolve();
|
||
},
|
||
now: () => state.wall,
|
||
};
|
||
return { driver, state };
|
||
}
|
||
|
||
function hostFor(f: SceneFixture, started: StartedWorld, driver: PlayDriver, running = true): ToolHost {
|
||
return {
|
||
view: () => ({ play: 'playing' }) as AgentView,
|
||
screenshot: () => Promise.resolve({ data: '', width: 0, height: 0 }),
|
||
project: () => f.project,
|
||
execute: () => null,
|
||
confirmDeletes: () => Promise.resolve(null),
|
||
open: () => null,
|
||
select: () => null,
|
||
play: () => null,
|
||
undo: () => null,
|
||
redo: () => null,
|
||
game: driver,
|
||
runtime: () =>
|
||
running
|
||
? {
|
||
world: started.world,
|
||
project: f.project,
|
||
shared: emptySharedState(),
|
||
heldKeys: started.input.heldKeys,
|
||
paused: false,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
const text = (result: ToolResult) =>
|
||
result.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n');
|
||
|
||
const boxX = (started: StartedWorld, id: string) => started.world.findByObjectId(id)!.x;
|
||
|
||
describe('play_input', () => {
|
||
it('holds a key for game seconds, and the object walks that far', async () => {
|
||
const { box, started, driver } = game();
|
||
const outcome = await sendInput([{ key: 'ArrowLeft', hold: 0.5 }], driver, 25_000);
|
||
|
||
expect(outcome.seconds).toBeGreaterThanOrEqual(0.5);
|
||
expect(outcome.seconds).toBeLessThan(0.5 + 2 / 60);
|
||
// −100 per second for the frames the key was down; the frame that lets go moves no more.
|
||
expect(boxX(started, box)).toBeCloseTo(200 - 100 * outcome.seconds + 100 / 60, 0);
|
||
expect(outcome.held).toEqual([]);
|
||
});
|
||
|
||
it('taps a key for one frame: a key-down event runs once, and "Space" is the space bar', async () => {
|
||
const { started, driver } = game();
|
||
const outcome = await sendInput([{ key: 'Space' }], driver, 25_000);
|
||
|
||
expect(started.world.log).toEqual(['jump']);
|
||
expect(outcome.log).toEqual(['jump']);
|
||
expect(outcome.frames).toBe(2);
|
||
expect(outcome.held).toEqual([]);
|
||
});
|
||
|
||
it('keeps a key down across calls until it is let go', async () => {
|
||
const { box, started, driver } = game();
|
||
const first = await sendInput([{ key: 'ArrowLeft', keep: 'down' }, { wait: 0.25 }], driver, 25_000);
|
||
expect(first.held).toEqual(['ArrowLeft']);
|
||
const walked = boxX(started, box);
|
||
expect(walked).toBeLessThan(180);
|
||
|
||
const second = await sendInput(
|
||
[{ wait: 0.25 }, { key: 'ArrowLeft', keep: 'up' }, { wait: 0.25 }],
|
||
driver,
|
||
25_000,
|
||
);
|
||
expect(second.held).toEqual([]);
|
||
expect(boxX(started, box)).toBeLessThan(walked - 20);
|
||
expect(boxX(started, box)).toBeGreaterThan(walked - 30);
|
||
});
|
||
|
||
it('clicks an object by its id and tells whether the click landed on it', async () => {
|
||
const { box, started, driver } = game();
|
||
const outcome = await sendInput([{ mouse: 'click', at: { object: box } }], driver, 25_000);
|
||
|
||
expect(outcome.hits).toEqual([{ step: 1, object: box, hit: true }]);
|
||
expect(started.world.log).toEqual(['clicked']);
|
||
expect(outcome.mouse).toMatchObject({ x: 200, y: 100, down: false });
|
||
});
|
||
|
||
it('clicks an object by its name as well', async () => {
|
||
const { started, driver } = game();
|
||
const outcome = await sendInput([{ mouse: 'click', at: { object: 'Box' } }], driver, 25_000);
|
||
|
||
expect(outcome.hits).toEqual([{ step: 1, object: 'Box', hit: true }]);
|
||
expect(started.world.log).toEqual(['clicked']);
|
||
});
|
||
|
||
it('a click beside the object does not reach it', async () => {
|
||
const { started, driver } = game();
|
||
await sendInput([{ mouse: 'click', at: { x: 0, y: 0 } }], driver, 25_000);
|
||
|
||
expect(started.world.log).toEqual([]);
|
||
});
|
||
|
||
it('gives up when the game time stands still, and lets go of the key it held', async () => {
|
||
const { started, driver, state } = game();
|
||
state.frozen = true;
|
||
await expect(sendInput([{ key: 'ArrowLeft', hold: 1 }], driver, 1000)).rejects.toThrow(
|
||
/Step 1: the game time stood still/,
|
||
);
|
||
expect([...started.input.heldKeys]).toEqual([]);
|
||
});
|
||
|
||
it('turns the steps down before anything runs: not playing, paused, a tab in the background, a step doing two things', async () => {
|
||
const { f, started, driver, state } = game();
|
||
const host = hostFor(f, started, driver);
|
||
const run = (steps: object[]) => runTool('play_input', { steps }, host);
|
||
|
||
const result = await run([{ key: 'a', mouse: 'click' }]);
|
||
expect(result.isError).toBe(true);
|
||
expect(text(result)).toBe('Step 1 has to do one thing: key, mouse, wheel or wait');
|
||
expect(text(await run([{ wait: 10 }, { wait: 10 }, { wait: 10 }, { wait: 1 }]))).toMatch(/at most 30 s/);
|
||
|
||
state.hidden = true;
|
||
expect(text(await run([{ key: 'a' }]))).toMatch(/in the background/);
|
||
state.hidden = false;
|
||
state.paused = true;
|
||
expect(text(await run([{ key: 'a' }]))).toMatch(/paused/);
|
||
state.running = false;
|
||
expect(text(await run([{ key: 'a' }]))).toMatch(/not running/);
|
||
expect(started.world.time).toBe(0);
|
||
});
|
||
|
||
it('tells the relay when a call runs long', async () => {
|
||
const { f, started, driver } = game();
|
||
let told = 0;
|
||
const host = { ...hostFor(f, started, driver), takesLong: () => told++ };
|
||
|
||
await runTool('play_input', { steps: [{ wait: 1 }] }, host);
|
||
expect(told).toBe(0);
|
||
await runTool('play_input', { steps: [{ wait: 6 }] }, host);
|
||
expect(told).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('get_runtime', () => {
|
||
it('lists every object with its position, a behavior with its variables, and the console', async () => {
|
||
const { f, box, started, driver } = game();
|
||
const host = hostFor(f, started, driver);
|
||
await runTool('play_input', { steps: [{ key: 'ArrowLeft', hold: 0.2 }, { key: 'Space' }] }, host);
|
||
|
||
const result = await runTool('get_runtime', {}, host);
|
||
const view = result.structuredContent as {
|
||
objects: { id: string; name: string; type: string; x: number; variables?: object; parent?: string }[];
|
||
console: string[];
|
||
hint?: string;
|
||
};
|
||
const row = view.objects.find((o) => o.id === box)!;
|
||
expect(row).toMatchObject({ name: 'Box', type: 'sprite' });
|
||
expect(row.x).toBeLessThan(185);
|
||
expect(row).not.toHaveProperty('props');
|
||
const behavior = view.objects.find((o) => o.type === 'behavior');
|
||
expect(behavior).toMatchObject({ parent: box });
|
||
// Its own variables only, not the switches every behavior has (enabled, runInEditor, ...).
|
||
expect(behavior?.variables).toEqual({ score: 0 });
|
||
expect(view.console).toEqual(['jump']);
|
||
expect(view.hint).toBe(NO_CAMERA_HINT);
|
||
});
|
||
|
||
it('gives all properties of the objects named, and says which name is none', async () => {
|
||
const { f, started, driver } = game();
|
||
const host = hostFor(f, started, driver);
|
||
|
||
const result = await runTool('get_runtime', { objects: ['Box'] }, host);
|
||
const view = result.structuredContent as { objects: { props: Record<string, unknown> }[] };
|
||
expect(view.objects).toHaveLength(1);
|
||
expect(view.objects[0]!.props).toHaveProperty('width');
|
||
|
||
expect(text(await runTool('get_runtime', { objects: ['Nobody'] }, host))).toBe(
|
||
'There is no object Nobody in the running game',
|
||
);
|
||
expect(text(await runTool('get_runtime', {}, hostFor(f, started, driver, false)))).toMatch(/not running/);
|
||
});
|
||
});
|
||
|
||
describe('the camera', () => {
|
||
it('puts the mouse on its picture: inside its frame, outside beyond it', () => {
|
||
const { started } = game(true);
|
||
expect(pointOf(started.world, { x: 100, y: -50 })).toMatchObject({ inside: true });
|
||
expect(pointOf(started.world, { x: 5000, y: 0 })).toMatchObject({ inside: false });
|
||
expect(pointOf(game(false).started.world, { x: 0, y: 0 })).toMatchObject({ inside: false });
|
||
});
|
||
|
||
it('is missed before Play and while playing, and play says so', async () => {
|
||
const without = game(false);
|
||
const scene = findScene(without.f.project, without.f.sceneId);
|
||
expect(cameraHint(without.f.project, scene, null)).toMatch(/\("Level"\) has no camera/);
|
||
expect(cameraHint(without.f.project, scene, without.started.world)).toBe(NO_CAMERA_HINT);
|
||
const played = await runTool(
|
||
'play',
|
||
{ action: 'play' },
|
||
hostFor(without.f, without.started, without.driver),
|
||
);
|
||
expect(text(played)).toBe(`play: done. ${NO_CAMERA_HINT}`);
|
||
|
||
const withCamera = game(true);
|
||
const sceneWith = findScene(withCamera.f.project, withCamera.f.sceneId);
|
||
expect(cameraHint(withCamera.f.project, sceneWith, null)).toBeNull();
|
||
expect(cameraHint(withCamera.f.project, sceneWith, withCamera.started.world)).toBeNull();
|
||
});
|
||
});
|