Let the agent hold the game at a given game time (#435)
feuerwerk / gate (push) Successful in 5m31s
feuerwerk / suite (push) Successful in 19m9s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 21:26:53 +02:00
1 parent e4ccf5f72f
commit 70402aa19a
12 files changed
+351 -26

No files matched your search

+6
View File
@@ -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); 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 { export class GameClock {
private seconds = 0; private seconds = 0;
/** The global time scale: 1 is real time, 2 twice as fast, 0 frozen. */ /** The global time scale: 1 is real time, 2 twice as fast, 0 frozen. */
+2 -1
View File
@@ -1668,9 +1668,10 @@ export class Editor implements PlayHost {
// and so does a run that waits for its videos at the start (#404). // 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 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 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 stepping = this.play.beginFrame();
const recorded = stepping ? null : this.play.recordingStep(); 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 held = this.state.paused && !stepping;
const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting); const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting);
this.play.tick(step, timeStep(frame, gameClock.scale, held)); this.play.tick(step, timeStep(frame, gameClock.scale, held));
+33 -1
View File
@@ -12,7 +12,7 @@ import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/syste
import type { AnimationPlayer } from '../core/model/animation'; import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties'; import type { PropertyBag } from '../core/model/Properties';
import { VIDEO_WAIT_MS } from '../core/model/video'; 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 { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input'; import { Input } from '../runtime/systems/Input';
import type { World } from '../runtime/World'; import type { World } from '../runtime/World';
@@ -95,6 +95,8 @@ export class PlaySession {
private stepping = false; private stepping = false;
/** The object whose `Break` node paused the run, until it is resumed or stepped on. */ /** The object whose `Break` node paused the run, until it is resumed or stepped on. */
private breakAt: string | null = null; 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( constructor(
private readonly state: EditorState, private readonly state: EditorState,
@@ -162,10 +164,24 @@ export class PlaySession {
} }
this.state.setPaused(!this.state.paused); this.state.setPaused(!this.state.paused);
this.breakAt = null; this.breakAt = null;
this.pausing = null;
this.applyPause(true); this.applyPause(true);
ui().focus.blur(); 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. */ /** Lets a paused run on by exactly one frame, at the next tick; does nothing unless paused. */
step(): void { step(): void {
if (this.isPaused) this.stepAsked = true; if (this.isPaused) this.stepAsked = true;
@@ -356,6 +372,7 @@ export class PlaySession {
this.videoWait = null; this.videoWait = null;
this.stepAsked = false; this.stepAsked = false;
this.breakAt = null; this.breakAt = null;
this.pausing = null;
const world = this.world; const world = this.world;
this.run = null; this.run = null;
this.input.detach(); 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. // 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.follow((world.paused && !wasPaused) || this.stepping ? this.panels.flush() : update);
this.pauseAtBreak(); 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);
} }
/** /**
+40
View File
@@ -11,12 +11,16 @@ import type { StepTarget } from './commandCatalog';
import { cameraOfWorld } from '../../viewport/activeCamera'; import { cameraOfWorld } from '../../viewport/activeCamera';
import { pixelCels } from '../../viewport/PixelCels'; import { pixelCels } from '../../viewport/PixelCels';
import { import {
exact,
IN_BACKGROUND,
type InputStep, type InputStep,
inputProblem, inputProblem,
inputRefusal, inputRefusal,
LONG_DEADLINE_MS, LONG_DEADLINE_MS,
LONG_INPUT_SECONDS, LONG_INPUT_SECONDS,
MAX_INPUT_SECONDS,
type PlayDriver, type PlayDriver,
runUntil,
SHORT_DEADLINE_MS, SHORT_DEADLINE_MS,
sendInput, sendInput,
totalSeconds, totalSeconds,
@@ -164,6 +168,7 @@ async function run(name: string, args: Record<string, unknown>, host: ToolHost):
case 'select': case 'select':
return outcome(host.select(args as unknown as AgentSelection), 'Selected.'); return outcome(host.select(args as unknown as AgentSelection), 'Selected.');
case 'play': { 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); const refusal = host.play(args.action as PlayAction);
if (refusal !== null) return failure(refusal); if (refusal !== null) return failure(refusal);
const facts = args.action === 'play' ? host.runtime?.() : null; const facts = args.action === 'play' ? host.runtime?.() : null;
@@ -215,6 +220,41 @@ async function playInput(steps: InputStep[], host: ToolHost): Promise<ToolResult
return answer(await sendInput(steps, host.game, long ? LONG_DEADLINE_MS : SHORT_DEADLINE_MS)); return answer(await sendInput(steps, host.game, long ? LONG_DEADLINE_MS : SHORT_DEADLINE_MS));
} }
/** `play`, `resume` or `pause` with `until` (#435): the game runs on to that time and holds there. */
async function playUntil(action: PlayAction, until: number, host: ToolHost): Promise<ToolResult> {
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<string, unknown>, host: ToolHost): Promise<ToolResult> { async function runCommands(args: Record<string, unknown>, host: ToolHost): Promise<ToolResult> {
const steps = args.steps as EditStep[]; const steps = args.steps as EditStep[];
const deletes = deletedAssets(steps); const deletes = deletedAssets(steps);
+5
View File
@@ -25,6 +25,7 @@ export interface EditorParts {
play(paused: boolean): void; play(paused: boolean): void;
stop(): void; stop(): void;
togglePause(): void; togglePause(): void;
pauseAt(time: number | null): void;
agentRun(): { world: World; input: Input; shared: SharedState } | null; agentRun(): { world: World; input: Input; shared: SharedState } | null;
}; };
/** The user's "Follow agent" switch. */ /** The user's "Follow agent" switch. */
@@ -82,6 +83,10 @@ export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost {
hidden: () => document.visibilityState === 'hidden', hidden: () => document.visibilityState === 'hidden',
frame: () => nextFrame(parts.app), frame: () => nextFrame(parts.app),
now: () => performance.now(), now: () => performance.now(),
pause: () => {
if (parts.play.isPlaying && !state.paused) parts.play.togglePause();
},
pauseAt: (time) => parts.play.pauseAt(time),
}, },
runtime: () => { runtime: () => {
const run = parts.play.agentRun(); const run = parts.play.agentRun();
+75 -13
View File
@@ -14,6 +14,8 @@ export interface InputStep {
at?: { x?: number; y?: number; object?: string }; at?: { x?: number; y?: number; object?: string };
wheel?: number; wheel?: number;
wait?: number; wait?: number;
/** Holds the game after the step before (#435); it comes last. */
pause?: boolean;
} }
/** What `sendInput` needs of the editor. */ /** What `sendInput` needs of the editor. */
@@ -28,6 +30,10 @@ export interface PlayDriver {
frame(): Promise<void>; frame(): Promise<void>;
/** Wall-clock milliseconds. */ /** Wall-clock milliseconds. */
now(): number; 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 { export interface InputOutcome {
@@ -38,6 +44,9 @@ export interface InputOutcome {
held: string[]; held: string[];
/** Where the mouse is, in world units, and whether it is over the game's picture. */ /** 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 }; 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. */ /** What the console got meanwhile. */
log: string[]; log: string[];
/** Per click on an object: whether it landed on it. */ /** 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 { export function inputProblem(steps: readonly InputStep[]): string | null {
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
const at = `Step ${index + 1}`; const at = `Step ${index + 1}`;
const kinds = (['key', 'mouse', 'wheel', 'wait'] as const).filter((kind) => step[kind] !== undefined); const kinds = (['key', 'mouse', 'wheel', 'wait', 'pause'] as const).filter(
if (kinds.length !== 1) return `${at} has to do one thing: key, mouse, wheel or wait`; (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)) if (step.key === undefined && (step.hold !== undefined || step.keep !== undefined))
return `${at}: hold and keep go with a key`; 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`; 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 { export function inputRefusal(driver: PlayDriver): string | null {
if (!driver.run()) return 'The game is not running: start it with play first'; 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.paused()) return 'The game is paused: resume it first';
if (driver.hidden()) if (driver.hidden()) return IN_BACKGROUND;
return 'The editor tab is in the background, where the browser runs no frames: ask the user to bring it to the front';
return null; 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 * 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 * 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 logStart = first.world.log.length;
const startWorld = first.world; const startWorld = first.world;
const hits: InputOutcome['hits'] = []; const hits: InputOutcome['hits'] = [];
for (const [index, step] of steps.entries()) { const pauses = steps.at(-1)?.pause === true;
try { try {
await playStep(step, clock, (object, hit) => hits.push({ step: index + 1, object, hit })); for (const [index, step] of steps.entries()) {
} catch (error) { try {
const why = error instanceof Error ? error.message : String(error); if (step.pause) {
throw new Error(`Step ${index + 1}: ${why} (after ${round(clock.seconds)} s of game time)`, { if (!driver.paused()) driver.pause();
cause: error, 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 { world, input } = clock.run();
const { x, y, inside, down } = world.pointer; const { x, y, inside, down } = world.pointer;
return { return {
seconds: round(clock.seconds), seconds: exact(clock.seconds),
frames: clock.frames, frames: clock.frames,
held: [...input.heldKeys], held: [...input.heldKeys],
mouse: { x: round(x), y: round(y), inside, down }, 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], log: world === startWorld ? world.log.slice(logStart) : [...world.log],
hits, 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<number> {
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( async function playStep(
step: InputStep, step: InputStep,
clock: GameClock, clock: GameClock,
@@ -255,3 +312,8 @@ class GameClock {
function round(value: number): number { function round(value: number): number {
return Math.round(value * 100) / 100; 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;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Entity } from '../../runtime/Entity';
import type { World } from '../../runtime/World'; import type { World } from '../../runtime/World';
import { cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import { collectHolderItems } from '../../viewport/items'; import { collectHolderItems } from '../../viewport/items';
import { exact } from './playInput';
/** The running game as `get_runtime` reads it (#429). */ /** The running game as `get_runtime` reads it (#429). */
export interface RuntimeFacts { export interface RuntimeFacts {
@@ -55,7 +56,7 @@ export function runtimeView(
const lines = options.console ?? CONSOLE_LINES; const lines = options.console ?? CONSOLE_LINES;
return { return {
scene: { id: world.sceneId, name: findAsset(project, world.sceneId)?.name ?? '' }, 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, paused: facts.paused ? 'toolbar' : world.paused ? 'by the game' : false,
camera: camera camera: camera
? { id: camera.id, x: round(camera.x), y: round(camera.y), ...cameraFrame(camera.props) } ? { id: camera.id, x: round(camera.x), y: round(camera.y), ...cameraFrame(camera.props) }
+12 -4
View File
@@ -187,10 +187,17 @@
{ {
"name": "play", "name": "play",
"title": "Play the game", "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": { "inputSchema": {
"type": "object", "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"], "required": ["action"],
"additionalProperties": false "additionalProperties": false
}, },
@@ -199,7 +206,7 @@
{ {
"name": "play_input", "name": "play_input",
"title": "Keys and mouse in the running game", "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\": \"<object id>\"}} 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\": \"<object id>\"}} 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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -228,7 +235,8 @@
"additionalProperties": false "additionalProperties": false
}, },
"wheel": { "type": "integer", "minimum": -10, "maximum": 10 }, "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 "additionalProperties": false
} }
+14 -1
View File
@@ -1,6 +1,6 @@
/** The global clock: one scaled step a frame, which is what the world, the particles and the shaders share. */ /** 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 { 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', () => { describe('effectiveScale', () => {
it('is the scale itself while running', () => { 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', () => { describe('GameClock', () => {
let clock: GameClock; let clock: GameClock;
+122 -4
View File
@@ -4,6 +4,7 @@ import { emptySharedState } from '../../src/core/graph';
import { findScene } from '../../src/core/model/Project'; import { findScene } from '../../src/core/model/Project';
import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools'; import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools';
import type { AgentView } from '../../src/editor/agent/agentView'; 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 { type PlayDriver, pointOf, sendInput } from '../../src/editor/agent/playInput';
import { cameraHint, NO_CAMERA_HINT } from '../../src/editor/agent/runtimeView'; import { cameraHint, NO_CAMERA_HINT } from '../../src/editor/agent/runtimeView';
import { import {
@@ -44,19 +45,41 @@ function game(withCamera = false) {
return { f, box, started, ...driverFor(started) }; 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) { 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 = { const driver: PlayDriver = {
run: () => (state.running ? { world: started.world, input: started.input } : null), run: () => (state.running ? { world: started.world, input: started.input } : null),
paused: () => state.paused, paused: () => state.paused,
hidden: () => state.hidden, hidden: () => state.hidden,
frame: () => { frame: () => {
state.wall += 16; 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(); return Promise.resolve();
}, },
now: () => state.wall, now: () => state.wall,
pause: () => {
state.paused = true;
},
pauseAt: (time) => {
state.pausing = time;
},
}; };
return { driver, state }; return { driver, state };
} }
@@ -171,7 +194,10 @@ describe('play_input', () => {
const result = await run([{ key: 'a', mouse: 'click' }]); const result = await run([{ key: 'a', mouse: 'click' }]);
expect(result.isError).toBe(true); 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/); expect(text(await run([{ wait: 10 }, { wait: 10 }, { wait: 10 }, { wait: 1 }]))).toMatch(/at most 30 s/);
state.hidden = true; 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<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', () => { describe('get_runtime', () => {
it('lists every object with its position, a behavior with its variables, and the console', async () => { it('lists every object with its position, a behavior with its variables, and the console', async () => {
const { f, box, started, driver } = game(); const { f, box, started, driver } = game();
+37
View File
@@ -600,6 +600,43 @@ export async function mcp(t) {
JSON.stringify({ started: started?.content, x0, x1 }), 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. // 71i. One Ctrl+Z of the user takes the agent's whole step back.
await press('z', { ctrl: true }); await press('z', { ctrl: true });
await settle(); await settle();
+3 -1
View File
@@ -56,7 +56,9 @@ const INSTRUCTIONS = [
'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked', '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.', '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,', '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.', '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', 'When a tool answers that no editor is connected, ask the user to open the editor and switch on',
'"Agent" in its account menu.', '"Agent" in its account menu.',