The agent could not look at a short moment of a running game. Its
calls took longer than the moment lasted, so an intro window from
0.5 to 2 s slipped past twice in the 5d test. Now the editor itself
stops the clock at the time the agent names.
- play takes until: play, resume or pause let the game run on to
that game time and hold it there. The call answers once the game
is paused, so get_runtime and screenshot show that moment.
- The frame that would step past the time is cut short
(frameWithin in src/core/time.ts, used in PlaySession). The game
stands at 0.5 s, not one frame later.
- play_input takes a last step {"pause": true}. After a wait it
holds the game at the time of the wait, after a key or click at
once. Its answer names the game time and whether it is paused.
- A pause that comes first (toolbar or a Break node), a Load scene
or a Restart game call the until off, and the answer says so.
- When the clock stands still, the call gives up after 25 s with
the time it is at. A tab in the background is refused at once.
- A recording that does not run in real time is not cut. There the
game stops in the first frame that reaches the time.
- get_runtime and play_input give times to the millisecond, not to
the hundredth, so 0.505 no longer reads as 0.51.
- The relay's instructions name both ways.
Tests:
- Unit agent-play (6 new) and time (2 new): 19/19, related editor
and relay tests 1158/1158.
- 6 counter-checks, all compile, all red. One stayed green at
first: 0.3 s is exactly 18 frames. The test now takes 0.31 s.
- e2e 71 (atx): ALL PASSED, 27 checks. New 71h3 holds at 0.505 s
and 0.8 s and after a wait of 0.31 s. Without the cut in
PlaySession it went red at 0.5166 s.
- Gate (atx): CHECK EXIT 0, 327 files, 5033 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
16 KiB
TypeScript
383 lines
16 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 { frameWithin } from '../../src/core/time';
|
||
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.
|
||
* A pause asked for at a time cuts the frame that reaches it, as `PlaySession` does.
|
||
*/
|
||
function driverFor(started: StartedWorld) {
|
||
const state = {
|
||
running: true,
|
||
paused: false,
|
||
hidden: false,
|
||
frozen: false,
|
||
wall: 0,
|
||
pausing: null as number | null,
|
||
};
|
||
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 || state.paused) return Promise.resolve();
|
||
const left = state.pausing === null ? Infinity : state.pausing - started.world.time;
|
||
started.frame(frameWithin(1 / 60, 1, left));
|
||
if (state.pausing !== null && started.world.time >= state.pausing - 1e-9) {
|
||
state.paused = true;
|
||
state.pausing = null;
|
||
}
|
||
return Promise.resolve();
|
||
},
|
||
now: () => state.wall,
|
||
pause: () => {
|
||
state.paused = true;
|
||
},
|
||
pauseAt: (time) => {
|
||
state.pausing = time;
|
||
},
|
||
};
|
||
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, wait or pause');
|
||
expect(text(await run([{ pause: true }, { wait: 1 }]))).toBe(
|
||
'Step 1: {"pause": true} ends the steps, it comes last',
|
||
);
|
||
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('pausing at a game time (#435)', () => {
|
||
it('play with until holds the game at that time exactly, not at the frame after', async () => {
|
||
const { f, started, driver, state } = game();
|
||
const host = hostFor(f, started, driver);
|
||
|
||
const result = await runTool('play', { action: 'play', until: 0.505 }, host);
|
||
expect(result.isError).toBeUndefined();
|
||
expect(result.structuredContent).toEqual({ time: 0.505, paused: true });
|
||
expect(text(result)).toBe(`Paused at 0.505 s of game time. ${NO_CAMERA_HINT}`);
|
||
expect(started.world.time).toBeCloseTo(0.505, 9);
|
||
expect(state).toMatchObject({ paused: true, pausing: null });
|
||
|
||
// Paused, it holds: a frame later the time is the same.
|
||
await driver.frame();
|
||
expect(started.world.time).toBeCloseTo(0.505, 9);
|
||
});
|
||
|
||
it('resume and pause go on from where the game is', async () => {
|
||
const { f, started, driver, state } = game();
|
||
const host = hostFor(f, started, driver);
|
||
await runTool('play', { action: 'pause', until: 0.25 }, host);
|
||
expect(started.world.time).toBeCloseTo(0.25, 9);
|
||
|
||
// The host's play is a stand-in here: resuming is the test's.
|
||
const resuming = { ...host, play: () => ((state.paused = false), null) };
|
||
const resumed = await runTool('play', { action: 'resume', until: 1 }, resuming);
|
||
expect(resumed.structuredContent).toEqual({ time: 1, paused: true });
|
||
expect(started.world.time).toBeCloseTo(1, 9);
|
||
});
|
||
|
||
it('turns down what cannot be: stop, a time gone by or too far ahead, paused or running already', async () => {
|
||
const { f, started, driver, state } = game();
|
||
const host = hostFor(f, started, driver);
|
||
const play = async (args: Record<string, unknown>) => text(await runTool('play', args, host));
|
||
|
||
expect(await play({ action: 'stop', until: 1 })).toBe('until goes with play, resume or pause');
|
||
expect(await play({ action: 'pause', until: 31 })).toMatch(/31 s ahead; at most 30 s/);
|
||
expect(await play({ action: 'resume', until: 1 })).toMatch(/running already: pause it with until/);
|
||
await runTool('play', { action: 'pause', until: 0.5 }, host);
|
||
expect(await play({ action: 'pause', until: 1 })).toMatch(/paused already: resume it with until/);
|
||
expect(await play({ action: 'resume', until: 0.5 })).toBe(
|
||
'The game is at 0.5 s already: until has to lie ahead',
|
||
);
|
||
state.paused = false;
|
||
state.hidden = true;
|
||
expect(await play({ action: 'pause', until: 1 })).toMatch(/in the background/);
|
||
expect(started.world.time).toBeCloseTo(0.5, 9);
|
||
});
|
||
|
||
it('gives up when the time stands still, and calls the pause off', async () => {
|
||
const { f, started, driver, state } = game();
|
||
state.frozen = true;
|
||
const result = await runTool('play', { action: 'pause', until: 1 }, hostFor(f, started, driver));
|
||
|
||
expect(result.isError).toBe(true);
|
||
expect(text(result)).toMatch(/^The game time stood still at 0 s/);
|
||
expect(state.pausing).toBeNull();
|
||
});
|
||
|
||
it('says so when something paused the game before the time', async () => {
|
||
const { f, started, driver, state } = game();
|
||
const host = hostFor(f, started, driver);
|
||
const frame = driver.frame;
|
||
driver.frame = async () => {
|
||
await frame();
|
||
if (started.world.time > 0.2) state.paused = true;
|
||
};
|
||
|
||
const result = await runTool('play', { action: 'pause', until: 1 }, host);
|
||
expect(text(result)).toMatch(/^Paused at 0\.21\d* s of game time, before 1 s: the user or a Break node/);
|
||
expect(state.pausing).toBeNull();
|
||
});
|
||
|
||
it('a last pause step holds the game after a wait to its time exactly, and after a tap at once', async () => {
|
||
const { started, driver, state } = game();
|
||
const waited = await sendInput([{ wait: 0.31 }, { pause: true }], driver, 25_000);
|
||
|
||
expect(waited).toMatchObject({ time: 0.31, paused: true });
|
||
expect(started.world.time).toBeCloseTo(0.31, 9);
|
||
expect(state.pausing).toBeNull();
|
||
|
||
state.paused = false;
|
||
const tapped = await sendInput([{ key: 'Space' }, { pause: true }], driver, 25_000);
|
||
expect(tapped).toMatchObject({ paused: true, frames: 2 });
|
||
expect(started.world.time).toBeCloseTo(0.31 + 2 / 60, 9);
|
||
|
||
state.paused = false;
|
||
const plain = await sendInput([{ wait: 0.1 }], driver, 25_000);
|
||
expect(plain.paused).toBe(false);
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|