diff --git a/src/core/time.ts b/src/core/time.ts index 47e927ae..c30bf208 100644 --- a/src/core/time.ts +++ b/src/core/time.ts @@ -41,6 +41,12 @@ export function timeStep(dt: number, scale: number, paused: boolean): number { return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused); } +/** The frame that moves the clock by `left` seconds at most, at this scale (#435). */ +export function frameWithin(dt: number, scale: number, left: number): number { + const rate = effectiveScale(scale, false); + return rate > 0 ? Math.min(dt, Math.max(0, left) / rate) : dt; +} + export class GameClock { private seconds = 0; /** The global time scale: 1 is real time, 2 twice as fast, 0 frozen. */ diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 248acb66..7ff62364 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1668,9 +1668,10 @@ export class Editor implements PlayHost { // and so does a run that waits for its videos at the start (#404). // A single step out of the pause moves by one fixed step, however long the frame took. // A recording not in real time (#423) moves it by one frame of the recording, or holds it. + // A pause the agent asked for at a time (#435) cuts the frame that would go past it. const stepping = this.play.beginFrame(); const recorded = stepping ? null : this.play.recordingStep(); - const frame = stepping ? FIXED_STEP : (recorded ?? dt); + const frame = stepping ? FIXED_STEP : (recorded ?? this.play.frameBeforePause(dt, gameClock.scale)); const held = this.state.paused && !stepping; const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting); this.play.tick(step, timeStep(frame, gameClock.scale, held)); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index cd3b214f..fbefd24e 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -12,7 +12,7 @@ import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/syste import type { AnimationPlayer } from '../core/model/animation'; import type { PropertyBag } from '../core/model/Properties'; import { VIDEO_WAIT_MS } from '../core/model/video'; -import { gameClock } from '../core/time'; +import { frameWithin, gameClock } from '../core/time'; import { loadPhysics, readyPhysics } from '../runtime/physics/engine'; import { Input } from '../runtime/systems/Input'; import type { World } from '../runtime/World'; @@ -95,6 +95,8 @@ export class PlaySession { private stepping = false; /** The object whose `Break` node paused the run, until it is resumed or stepped on. */ private breakAt: string | null = null; + /** The time the agent wants the run held at (#435), on the world it named; a `Load scene` calls it off. */ + private pausing: { world: World; time: number } | null = null; constructor( private readonly state: EditorState, @@ -162,10 +164,24 @@ export class PlaySession { } this.state.setPaused(!this.state.paused); this.breakAt = null; + this.pausing = null; this.applyPause(true); ui().focus.blur(); } + /** Holds the run once its time reaches `time` (#435), as if the pause button had been pressed; null calls it off. */ + pauseAt(time: number | null): void { + this.pausing = time === null || !this.world ? null : { world: this.world, time }; + } + + /** Called before the clock moves: the frame cut short so the run does not go past the time `pauseAt` named. */ + frameBeforePause(dt: number, scale: number): number { + const pausing = this.pausing; + if (!pausing || pausing.world !== this.world || this.state.paused || this.isGamePaused || this.isWaiting) + return dt; + return frameWithin(dt, scale, pausing.time - pausing.world.time); + } + /** Lets a paused run on by exactly one frame, at the next tick; does nothing unless paused. */ step(): void { if (this.isPaused) this.stepAsked = true; @@ -356,6 +372,7 @@ export class PlaySession { this.videoWait = null; this.stepAsked = false; this.breakAt = null; + this.pausing = null; const world = this.world; this.run = null; this.input.detach(); @@ -420,6 +437,21 @@ export class PlaySession { // A game that pauses itself is looked at like one paused from the toolbar, and so is a step. this.follow((world.paused && !wasPaused) || this.stepping ? this.panels.flush() : update); this.pauseAtBreak(); + this.pauseWhenDue(); + } + + /** The run reached the time `pauseAt` named: it holds there. A pause that came first calls it off. */ + private pauseWhenDue(): void { + const pausing = this.pausing; + if (!pausing) return; + if (pausing.world !== this.world || this.state.paused) { + this.pausing = null; + return; + } + if (pausing.world.time < pausing.time - 1e-9) return; + this.pausing = null; + this.state.setPaused(true); + this.applyPause(true); } /** diff --git a/src/editor/agent/agentTools.ts b/src/editor/agent/agentTools.ts index a9d4a6b6..adb73f47 100644 --- a/src/editor/agent/agentTools.ts +++ b/src/editor/agent/agentTools.ts @@ -11,12 +11,16 @@ import type { StepTarget } from './commandCatalog'; import { cameraOfWorld } from '../../viewport/activeCamera'; import { pixelCels } from '../../viewport/PixelCels'; import { + exact, + IN_BACKGROUND, type InputStep, inputProblem, inputRefusal, LONG_DEADLINE_MS, LONG_INPUT_SECONDS, + MAX_INPUT_SECONDS, type PlayDriver, + runUntil, SHORT_DEADLINE_MS, sendInput, totalSeconds, @@ -164,6 +168,7 @@ async function run(name: string, args: Record, host: ToolHost): case 'select': return outcome(host.select(args as unknown as AgentSelection), 'Selected.'); case 'play': { + if (args.until !== undefined) return playUntil(args.action as PlayAction, args.until as number, host); const refusal = host.play(args.action as PlayAction); if (refusal !== null) return failure(refusal); const facts = args.action === 'play' ? host.runtime?.() : null; @@ -215,6 +220,41 @@ async function playInput(steps: InputStep[], host: ToolHost): Promise { + const game = host.game; + if (!game) return failure('This editor plays no game'); + if (action === 'stop') return failure('until goes with play, resume or pause'); + if (game.hidden()) return failure(IN_BACKGROUND); + const run = action === 'play' ? null : game.run(); + if (action !== 'play') { + if (!run) return failure('The game is not running: start it with play first'); + if (action === 'pause' && game.paused()) + return failure('The game is paused already: resume it with until'); + if (action === 'resume' && !game.paused()) + return failure('The game is running already: pause it with until'); + } + const now = run?.world.time ?? 0; + if (until <= now + 1e-6) return failure(`The game is at ${exact(now)} s already: until has to lie ahead`); + const ahead = until - now; + if (ahead > MAX_INPUT_SECONDS) + return failure(`until is ${exact(ahead)} s ahead; at most ${MAX_INPUT_SECONDS} s go in one call`); + if (action !== 'pause') { + const refusal = host.play(action); + if (refusal !== null) return failure(refusal); + } + const long = ahead > LONG_INPUT_SECONDS; + if (long) host.takesLong?.(); + const time = await runUntil(game, until, long ? LONG_DEADLINE_MS : SHORT_DEADLINE_MS); + const facts = action === 'play' ? host.runtime?.() : null; + const blind = facts && !cameraOfWorld(facts.world) ? ` ${NO_CAMERA_HINT}` : ''; + const text = + time < until - 1e-6 + ? `Paused at ${time} s of game time, before ${until} s: the user or a Break node paused it first.` + : `Paused at ${time} s of game time.${blind}`; + return done(text, { time, paused: true }); +} + async function runCommands(args: Record, host: ToolHost): Promise { const steps = args.steps as EditStep[]; const deletes = deletedAssets(steps); diff --git a/src/editor/agent/editorToolHost.ts b/src/editor/agent/editorToolHost.ts index 5528b07d..70210b75 100644 --- a/src/editor/agent/editorToolHost.ts +++ b/src/editor/agent/editorToolHost.ts @@ -25,6 +25,7 @@ export interface EditorParts { play(paused: boolean): void; stop(): void; togglePause(): void; + pauseAt(time: number | null): void; agentRun(): { world: World; input: Input; shared: SharedState } | null; }; /** The user's "Follow agent" switch. */ @@ -82,6 +83,10 @@ export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost { hidden: () => document.visibilityState === 'hidden', frame: () => nextFrame(parts.app), now: () => performance.now(), + pause: () => { + if (parts.play.isPlaying && !state.paused) parts.play.togglePause(); + }, + pauseAt: (time) => parts.play.pauseAt(time), }, runtime: () => { const run = parts.play.agentRun(); diff --git a/src/editor/agent/playInput.ts b/src/editor/agent/playInput.ts index 1d3c41d1..cc1f9149 100644 --- a/src/editor/agent/playInput.ts +++ b/src/editor/agent/playInput.ts @@ -14,6 +14,8 @@ export interface InputStep { at?: { x?: number; y?: number; object?: string }; wheel?: number; wait?: number; + /** Holds the game after the step before (#435); it comes last. */ + pause?: boolean; } /** What `sendInput` needs of the editor. */ @@ -28,6 +30,10 @@ export interface PlayDriver { frame(): Promise; /** Wall-clock milliseconds. */ now(): number; + /** Pauses the run, as the toolbar does. */ + pause(): void; + /** Pauses the run once its time reaches `time`, with the frame before cut short to land on it; null calls it off. */ + pauseAt(time: number | null): void; } export interface InputOutcome { @@ -38,6 +44,9 @@ export interface InputOutcome { held: string[]; /** Where the mouse is, in world units, and whether it is over the game's picture. */ mouse: { x: number; y: number; inside: boolean; down: boolean }; + /** The game's time when the steps were done, and whether a `pause` step holds it there. */ + time: number; + paused: boolean; /** What the console got meanwhile. */ log: string[]; /** Per click on an object: whether it landed on it. */ @@ -63,8 +72,12 @@ export function keyOf(name: string): string { export function inputProblem(steps: readonly InputStep[]): string | null { for (const [index, step] of steps.entries()) { const at = `Step ${index + 1}`; - const kinds = (['key', 'mouse', 'wheel', 'wait'] as const).filter((kind) => step[kind] !== undefined); - if (kinds.length !== 1) return `${at} has to do one thing: key, mouse, wheel or wait`; + const kinds = (['key', 'mouse', 'wheel', 'wait', 'pause'] as const).filter( + (kind) => step[kind] !== undefined, + ); + if (kinds.length !== 1) return `${at} has to do one thing: key, mouse, wheel, wait or pause`; + if (step.pause !== undefined && (step.pause !== true || index !== steps.length - 1)) + return `${at}: {"pause": true} ends the steps, it comes last`; if (step.key === undefined && (step.hold !== undefined || step.keep !== undefined)) return `${at}: hold and keep go with a key`; if (step.hold !== undefined && step.keep !== undefined) return `${at}: a key is held or kept, not both`; @@ -89,11 +102,13 @@ export function totalSeconds(steps: readonly InputStep[]): number { export function inputRefusal(driver: PlayDriver): string | null { if (!driver.run()) return 'The game is not running: start it with play first'; if (driver.paused()) return 'The game is paused: resume it first'; - if (driver.hidden()) - return 'The editor tab is in the background, where the browser runs no frames: ask the user to bring it to the front'; + if (driver.hidden()) return IN_BACKGROUND; return null; } +export const IN_BACKGROUND = + 'The editor tab is in the background, where the browser runs no frames: ask the user to bring it to the front'; + /** * Plays the steps into the running game, each one taking at least one frame, so an edge a step * makes is seen before the next one. A tapped or held key and a clicked button are let go again @@ -109,28 +124,70 @@ export async function sendInput( const logStart = first.world.log.length; const startWorld = first.world; const hits: InputOutcome['hits'] = []; - for (const [index, step] of steps.entries()) { - try { - await playStep(step, clock, (object, hit) => hits.push({ step: index + 1, object, hit })); - } catch (error) { - const why = error instanceof Error ? error.message : String(error); - throw new Error(`Step ${index + 1}: ${why} (after ${round(clock.seconds)} s of game time)`, { - cause: error, - }); + const pauses = steps.at(-1)?.pause === true; + try { + for (const [index, step] of steps.entries()) { + try { + if (step.pause) { + if (!driver.paused()) driver.pause(); + continue; + } + // A wait the pause comes after ends on its time exactly, not a frame beyond. + if (pauses && index === steps.length - 2 && (step.wait ?? 0) > 0) + driver.pauseAt(clock.run().world.time + step.wait!); + await playStep(step, clock, (object, hit) => hits.push({ step: index + 1, object, hit })); + } catch (error) { + const why = error instanceof Error ? error.message : String(error); + throw new Error(`Step ${index + 1}: ${why} (after ${round(clock.seconds)} s of game time)`, { + cause: error, + }); + } } + } finally { + if (pauses) driver.pauseAt(null); } const { world, input } = clock.run(); const { x, y, inside, down } = world.pointer; return { - seconds: round(clock.seconds), + seconds: exact(clock.seconds), frames: clock.frames, held: [...input.heldKeys], mouse: { x: round(x), y: round(y), inside, down }, + time: exact(world.time), + paused: driver.paused(), log: world === startWorld ? world.log.slice(logStart) : [...world.log], hits, }; } +/** + * Lets the run go on until its time reaches `until` and holds it there (#435): the time it holds at, + * earlier when the user or a `Break` node paused it first. + */ +export async function runUntil(driver: PlayDriver, until: number, deadlineMs: number): Promise { + const deadline = driver.now() + deadlineMs; + const world = driver.run()?.world; + driver.pauseAt(until); + try { + for (;;) { + const run = driver.run(); + if (!run) throw new Error('The game stopped before it got there'); + if (run.world !== world) + throw new Error( + 'A Load scene or Restart game started the time over before it got there; the game runs on', + ); + if (driver.paused()) return exact(run.world.time); + if (driver.now() > deadline) + throw new Error( + `The game time stood still at ${exact(run.world.time)} s: paused by a behavior, or the editor tab went to the background`, + ); + await driver.frame(); + } + } finally { + driver.pauseAt(null); + } +} + async function playStep( step: InputStep, clock: GameClock, @@ -255,3 +312,8 @@ class GameClock { function round(value: number): number { return Math.round(value * 100) / 100; } + +/** A game time to the millisecond: a pause lands on it, and a hundredth would hide a frame. */ +export function exact(value: number): number { + return Math.round(value * 1000) / 1000; +} diff --git a/src/editor/agent/runtimeView.ts b/src/editor/agent/runtimeView.ts index 04e713cf..df459519 100644 --- a/src/editor/agent/runtimeView.ts +++ b/src/editor/agent/runtimeView.ts @@ -8,6 +8,7 @@ import type { Entity } from '../../runtime/Entity'; import type { World } from '../../runtime/World'; import { cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { collectHolderItems } from '../../viewport/items'; +import { exact } from './playInput'; /** The running game as `get_runtime` reads it (#429). */ export interface RuntimeFacts { @@ -55,7 +56,7 @@ export function runtimeView( const lines = options.console ?? CONSOLE_LINES; return { scene: { id: world.sceneId, name: findAsset(project, world.sceneId)?.name ?? '' }, - time: round(world.time), + time: exact(world.time), paused: facts.paused ? 'toolbar' : world.paused ? 'by the game' : false, camera: camera ? { id: camera.id, x: round(camera.x), y: round(camera.y), ...cameraFrame(camera.props) } diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index 5b261982..850b2731 100644 --- a/src/editor/agent/tools.json +++ b/src/editor/agent/tools.json @@ -187,10 +187,17 @@ { "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.", + "description": "Plays the game in the editor, pauses, resumes or stops it. With until, play, resume or pause let the game run on to that game time, as get_runtime gives it, and hold it there, exactly: the call answers once it is paused, and get_runtime and screenshot then show that moment. 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"] } }, + "properties": { + "action": { "type": "string", "enum": ["play", "pause", "resume", "stop"] }, + "until": { + "type": "number", + "minimum": 0, + "description": "Game seconds to pause at, at most 30 ahead. A Load scene or Restart game starts the time over and calls it off." + } + }, "required": ["action"], "additionalProperties": false }, @@ -199,7 +206,7 @@ { "name": "play_input", "title": "Keys and mouse in the running game", - "description": "Plays keys and the mouse into the running game like a player, step by step; each step takes at least one frame, and hold and wait count game seconds. A step does one thing: {\"key\": \"ArrowLeft\", \"hold\": 0.5} holds a key; {\"key\": \"Space\"} taps it for one frame; {\"key\": \"Shift\", \"keep\": \"down\"} presses it and leaves it held after the call, \"keep\": \"up\" lets it go; {\"mouse\": \"click\", \"at\": {\"object\": \"\"}} or {\"mouse\": \"click\", \"at\": {\"x\": 10, \"y\": -20}} clicks the left button there (world units, as get_runtime gives them); \"down\", \"up\" and \"move\" do just that; {\"wheel\": -1} turns the wheel; {\"wait\": 1} lets the game run on. Keys go by their KeyboardEvent.key name: \"a\", \"ArrowUp\", \"Enter\", \"Space\". Answers the game time it took, the keys still held, where the mouse is, whether a click on an object landed on it, and the new console lines; read positions and variables with get_runtime. Needs the game playing, not paused, and the editor tab in front.", + "description": "Plays keys and the mouse into the running game like a player, step by step; each step takes at least one frame, and hold and wait count game seconds. A step does one thing: {\"key\": \"ArrowLeft\", \"hold\": 0.5} holds a key; {\"key\": \"Space\"} taps it for one frame; {\"key\": \"Shift\", \"keep\": \"down\"} presses it and leaves it held after the call, \"keep\": \"up\" lets it go; {\"mouse\": \"click\", \"at\": {\"object\": \"\"}} or {\"mouse\": \"click\", \"at\": {\"x\": 10, \"y\": -20}} clicks the left button there (world units, as get_runtime gives them); \"down\", \"up\" and \"move\" do just that; {\"wheel\": -1} turns the wheel; {\"wait\": 1} lets the game run on; {\"pause\": true}, the last step, holds the game right after the step before, a wait to its time exactly, so get_runtime and screenshot show that moment. Keys go by their KeyboardEvent.key name: \"a\", \"ArrowUp\", \"Enter\", \"Space\". Answers the game time it took and the game time it is at, whether it is paused, the keys still held, where the mouse is, whether a click on an object landed on it, and the new console lines; read positions and variables with get_runtime. Needs the game playing, not paused, and the editor tab in front.", "inputSchema": { "type": "object", "properties": { @@ -228,7 +235,8 @@ "additionalProperties": false }, "wheel": { "type": "integer", "minimum": -10, "maximum": 10 }, - "wait": { "type": "number", "minimum": 0, "maximum": 10, "description": "Game seconds." } + "wait": { "type": "number", "minimum": 0, "maximum": 10, "description": "Game seconds." }, + "pause": { "type": "boolean", "description": "true: holds the game here; the last step." } }, "additionalProperties": false } diff --git a/tests/core/time.test.ts b/tests/core/time.test.ts index 3c56cdaa..ae3c3811 100644 --- a/tests/core/time.test.ts +++ b/tests/core/time.test.ts @@ -1,6 +1,6 @@ /** The global clock: one scaled step a frame, which is what the world, the particles and the shaders share. */ import { beforeEach, describe, expect, it } from 'vitest'; -import { effectiveScale, GameClock, MAX_FRAME, timeStep } from '../../src/core/time'; +import { effectiveScale, frameWithin, GameClock, MAX_FRAME, timeStep } from '../../src/core/time'; describe('effectiveScale', () => { it('is the scale itself while running', () => { @@ -38,6 +38,19 @@ describe('timeStep', () => { }); }); +describe('frameWithin (#435)', () => { + it('cuts the frame that would go past the time left, at the clock scale', () => { + expect(frameWithin(1 / 60, 1, 0.005)).toBeCloseTo(0.005, 12); + expect(timeStep(frameWithin(1 / 60, 2, 0.005), 2, false)).toBeCloseTo(0.005, 12); + }); + + it('leaves a frame that stays short of it, and one the clock does not move by', () => { + expect(frameWithin(1 / 60, 1, 1)).toBe(1 / 60); + expect(frameWithin(1 / 60, 0, 0.005)).toBe(1 / 60); + expect(frameWithin(1 / 60, 1, -1)).toBe(0); + }); +}); + describe('GameClock', () => { let clock: GameClock; diff --git a/tests/editor/agent-play.test.ts b/tests/editor/agent-play.test.ts index a6415c91..ad31c47b 100644 --- a/tests/editor/agent-play.test.ts +++ b/tests/editor/agent-play.test.ts @@ -4,6 +4,7 @@ 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 { @@ -44,19 +45,41 @@ function game(withCamera = false) { 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. */ +/** + * 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 }; + 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) started.frame(1 / 60); + 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 }; } @@ -171,7 +194,10 @@ describe('play_input', () => { 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(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; @@ -196,6 +222,98 @@ describe('play_input', () => { }); }); +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(); diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index 825c7858..a6872c59 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -600,6 +600,43 @@ export async function mcp(t) { JSON.stringify({ started: started?.content, x0, x1 }), ); + // 71h3. A moment of the game (#435): play with until holds it at that game time exactly, resume + // with until goes on to the next, and a last pause step holds it at the end of a wait, not a + // frame beyond (the game runs between two calls, so the wait is what is exact there). + const atHalf = await callTool('play', { action: 'play', until: 0.505 }); + const halfInfo = await json('__gm.playInfo()'); + const halfRead = (await callTool('get_runtime', { console: 0 }))?.structuredContent; + const onward = await callTool('play', { action: 'resume', until: 0.8 }); + const onwardInfo = await json('__gm.playInfo()'); + await callTool('play', { action: 'resume' }); + const waited = await callTool('play_input', { steps: [{ wait: 0.31 }, { pause: true }] }); + const waitedInfo = await json('__gm.playInfo()'); + await callTool('play', { action: 'stop' }); + await waitUntil('__gm.state.mode === "edit"', { label: 'the game stopped again' }); + check( + 'play with until and a last pause step hold the game at the game time asked for, not a frame beyond', + atHalf?.structuredContent?.time === 0.505 && + halfInfo.paused && + Math.abs(halfInfo.time - 0.505) < 1e-6 && + halfRead?.time === 0.505 && + onward?.structuredContent?.time === 0.8 && + onwardInfo.paused && + Math.abs(onwardInfo.time - 0.8) < 1e-6 && + waited?.structuredContent?.paused === true && + waited.structuredContent.seconds === 0.31 && + waitedInfo.paused && + Math.abs(waitedInfo.time - waited.structuredContent.time) < 1e-3, + JSON.stringify({ + atHalf: atHalf?.structuredContent ?? atHalf?.content, + halfInfo, + halfTime: halfRead?.time, + onward: onward?.structuredContent ?? onward?.content, + onwardInfo, + waited: waited?.structuredContent ?? waited?.content, + waitedInfo, + }), + ); + // 71i. One Ctrl+Z of the user takes the agent's whole step back. await press('z', { ctrl: true }); await settle(); diff --git a/tools/mcp/relay.mjs b/tools/mcp/relay.mjs index 49652313..955f9cff 100644 --- a/tools/mcp/relay.mjs +++ b/tools/mcp/relay.mjs @@ -56,7 +56,9 @@ const INSTRUCTIONS = [ 'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked', 'on, with what it touched selected. Use open and select to show anything else.', 'Test what you built yourself: play, then play_input sends keys and mouse clicks into the game,', - 'and get_runtime reads where the objects are, their variables and the console. A game needs a', + 'and get_runtime reads where the objects are, their variables and the console. To look at one', + 'moment, play with until holds the game at that game time, as does a last play_input step', + '{"pause": true}; a call that only reads comes too late for it, the game runs on meanwhile. A game needs a', 'camera in its scene to show anything; get_view says when it has none.', 'When a tool answers that no editor is connected, ask the user to open the editor and switch on', '"Agent" in its account menu.',