Let the agent hold the game at a given game time (#435)
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:
1 parent
e4ccf5f72f
commit
70402aa19a
12 files changed
+351
-26
No files matched your search
+14
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
|
||||
Reference in new issue
Block a user