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) => 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 }[] }; 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(); }); });