Files
feuerwerk/src/editor/PlaySession.ts
T
adminandClaude Opus 5.5 70402aa19a
feuerwerk / gate (push) Successful in 5m31s
feuerwerk / suite (push) Successful in 19m9s
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>
2026-10-18 21:26:53 +02:00

572 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Owns everything that only exists while the project is playing: the world, its systems, the
* keyboard and the per-frame step. Play never touches the saved model – the world is built
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
*/
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
import { namedValue } from '../core/graph/values';
import { AudioSwitch } from '../runtime/audioSwitch';
import { GameRun, type RunServices } from '../runtime/GameRun';
import { AnimationSystem } from '../runtime/systems/AnimationSystem';
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { VIDEO_WAIT_MS } from '../core/model/video';
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';
import { onFrame, ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import { GameRecorder } from '../viewport/GameRecorder';
import { consoleLine, type MadeRecording } from '../viewport/RecordingTake';
import type { RecordingUploadOptions } from '../core/recording/recording';
import { videoLibrary } from '../viewport/VideoLibrary';
import { FrameMeter, type FrameTimes } from './frameMeter';
import { saveArea } from './saves';
import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle';
import type { GameViewport } from './viewport/GameViewport';
import type { GraphEditor } from './graph/GraphEditor';
import type { LiveMachineState } from './graph/GraphCanvas';
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
import type { EditorState } from './state';
import { stopPreviews } from './music/transport';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: {
objectId: string;
assetId: string;
name: string;
x: number;
y: number;
worldX: number;
worldY: number;
parent: string;
props: Record<string, unknown>;
animations: AnimationPlayer[];
}[];
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
shared: SharedState;
log: string[];
}
export interface PlayHost {
gameView: GameViewport;
/** The scene view: while a run holds the panels back, it still draws every frame it is on screen. */
viewport: { visible: boolean; redraw(): void };
graphEditor: GraphEditor;
stateMachineEditor: StateMachineEditor;
showGame(): void;
/** Leave the game view if it is the one on screen. */
leaveGame(): void;
/** A recording of the game view is made (#405): it goes up, and into the project as the node asked. */
recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void;
}
export class PlaySession {
private run: GameRun | null = null;
private readonly input = new Input();
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
private waiting = false;
/** When the panels catch up with the run (#265). */
private readonly panels = new PanelClock();
/** How often the panels were brought up to date this run (test hook). */
private updates = { structure: 0, values: 0 };
/** Where the frames of this run go (#275). */
private readonly meter = new FrameMeter();
/**
* Until when the run waits at its start for the Game tab's driven videos (#404), by
* `performance.now()`; null once it goes. Its players are opened only at Play, and the first
* frames of a cut showed white without them.
*/
private videoWait: number | null = null;
/** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */
private readonly recorder: GameRecorder;
/** The sound of the run, diverted while a recording not in real time goes (#423). */
private readonly audio = new AudioSwitch(soundLibrary);
/** What a recording not in real time asked the run to move by this frame, see `recordingStep`. */
private recordStep: number | null = null;
/** When the notice last told how far such a recording has got, by `performance.now()`, and whether it did. */
private toldPace = 0;
private toldSteady = false;
/** `step` asked for a frame; `stepping` is set for the frame that takes it. */
private stepAsked = false;
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,
private readonly host: PlayHost,
) {
this.recorder = new GameRecorder({
view: () => host.gameView,
log: (line, level) => {
this.world?.log.push(line);
consoleLine(line, level);
},
saved: (recording, options) => host.recordingSaved(recording, options),
audio: this.audio,
});
// An object picked during a run is looked at now: the hierarchy has to list it to show it picked.
state.on('selection', () => {
if (this.world && this.panels.holdsStructure) this.follow(this.panels.flush());
});
}
private get world(): World | null {
return this.run?.world ?? null;
}
private get behaviors(): BehaviorSystem | null {
return this.run?.behaviors ?? null;
}
get isPlaying(): boolean {
return this.world !== null;
}
get isPaused(): boolean {
return this.world !== null && this.state.paused;
}
/** Paused by a behavior (Pause game) rather than by the toolbar – the game's own pause. */
get isGamePaused(): boolean {
return this.world?.paused ?? false;
}
/**
* Held at its start until the Game tab has a picture of its driven videos (#404), and while a
* recording sets up its encoders.
*/
get isWaiting(): boolean {
return this.videoWait !== null || this.recorder.isStarting || this.recordStep === 0;
}
/**
* Called before the clock moves: the step a recording not in real time (#423) moves the run by
* this frame, 0 while it holds the run; null when the run keeps the frame's own time.
*/
recordingStep(): number | null {
this.recordStep =
this.world && !this.state.paused && this.videoWait === null ? this.recorder.step() : null;
return this.recordStep;
}
/** Pause or resume; before Play it switches whether Play starts paused. */
togglePause(): void {
if (!this.world) {
if (this.state.mode === 'edit') this.state.startPaused = !this.state.startPaused;
return;
}
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;
}
/**
* Called before the clock moves: true when this frame is the one `step` asked for. It then moves
* the run by one fixed step, whatever the frame took, and stays paused afterwards.
*/
beginFrame(): boolean {
this.stepping = this.stepAsked && this.isPaused;
this.stepAsked = false;
if (this.stepping) this.breakAt = null;
return this.stepping;
}
/** Puts sounds, videos, the recording and the panels in line with the toolbar's pause. */
private applyPause(flush: boolean): void {
const { paused } = this.state;
// What you inspect while paused is exact, whatever the panels were holding back.
if (paused && flush) this.follow(this.panels.flush());
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
// on mid-note, rather than playing to their end over a still image.
soundLibrary.setSuspended(paused);
videoLibrary.setPaused(paused);
this.recorder.setPaused(paused);
this.state.setNotice(this.playNotice(), 'quiet');
}
traceFor(assetId: string): Trace | null {
return this.behaviors?.traceFor(assetId) ?? null;
}
/** Which state each running instance of a machine holds; empty when nothing is playing. */
machineStates(machineId: string): LiveMachineState[] {
return this.behaviors?.machineStates(machineId) ?? [];
}
get time(): number {
return this.world?.time ?? 0;
}
/** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */
get sceneId(): string {
return this.world?.sceneId ?? '';
}
syncFromModel(): void {
this.world?.syncFromModel();
this.behaviors?.syncFromModel();
}
/** The run as the agent drives and reads it (#429); null when nothing is playing. */
agentRun(): { world: World; input: Input; shared: SharedState } | null {
const world = this.world;
return world ? { world, input: this.input, shared: this.behaviors?.shared ?? emptySharedState() } : null;
}
/** The values a Variables asset shares in this run; null when nothing is playing. */
liveVariables(assetId: string): PropertyBag | null {
return this.behaviors?.liveVariables(assetId) ?? null;
}
/** `paused`: whether the run starts held; a `Restart game` keeps what the run had instead. */
play(paused = this.state.startPaused): void {
const scene = this.state.sceneToPlay();
if (!scene) return;
// The editor loads the engine right after opening, so it is almost always there already; a game
// without it would collide differently, so Play waits for it rather than running without (#146).
const physics = readyPhysics();
if (!physics) {
this.waitForPhysics();
return;
}
ui().focus.blur();
stopPreviews();
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
const runtime = this.state.enterPlay();
const services: RunServices = {
audio: this.audio,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
recorder: this.recorder,
pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) },
breakpoint: (where) => (this.breakAt ??= where),
};
this.run = GameRun.start(runtime, scene.id, this.input, services);
videoLibrary.startRun();
this.updates = { structure: 0, values: 0 };
this.meter.reset();
this.timeSystems(this.run);
this.input.attach();
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
this.waitForVideos();
this.recorder.prepare();
if (paused) {
this.state.setPaused(true);
this.applyPause(false);
} else this.state.setNotice(this.playNotice(), 'quiet');
if (this.run.world.stopRequested) this.stop();
}
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
private sceneLoaded(run: GameRun): void {
this.timeSystems(run);
this.host.gameView.setWorld(run.world);
this.waitForVideos();
this.state.setNotice(this.playNotice(), 'quiet');
if (run.world.stopRequested) this.stop();
}
private waitForPhysics(): void {
if (this.waiting) return;
this.waiting = true;
this.state.setNotice('Loading the physics engine…', 'quiet');
loadPhysics().then(
() => {
this.waiting = false;
if (this.state.mode === 'edit') this.play();
},
(error: unknown) => {
this.waiting = false;
const reason = error instanceof Error ? error.message : String(error);
console.error('[physics] Play needs the physics engine, which could not be loaded:', error);
this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error');
},
);
}
/** The Game tab has drawn the new world once by now, which opened the players it shows. */
private waitForVideos(): void {
this.videoWait = this.host.gameView.videosLoading() ? performance.now() + VIDEO_WAIT_MS : null;
}
/** Whether the run still waits for its videos; the frame it stops waiting, the notice says so. */
private waitsForVideos(): boolean {
if (this.videoWait === null) return false;
if (performance.now() < this.videoWait && this.host.gameView.videosLoading()) return true;
this.videoWait = null;
this.state.setNotice(this.playNotice(), 'quiet');
return false;
}
/** While a recording not in real time goes (#423), the notice tells how far it has got, twice a second. */
private tellPace(): void {
const now = performance.now();
const steady = this.recordStep !== null;
if (steady === this.toldSteady && (!steady || now - this.toldPace < 500)) return;
this.toldSteady = steady;
this.toldPace = now;
this.state.setNotice(this.playNotice(), 'quiet');
}
private playNotice(): string {
if (this.videoWait !== null) return 'Loading the videos… · Esc stops';
const steady = this.recorder.steadyPace();
if (steady && !this.state.paused)
return (
`Recording "${steady.name}" not in real time: ${steady.seconds.toFixed(1)} s of video, ` +
`${steady.speed.toFixed(2)}x real time · Esc stops`
);
if (this.state.paused) {
const why = this.breakAt === null ? 'Paused' : `Break in ${this.breakAt}`;
return `${why} · Ctrl+Enter resumes · Ctrl+Shift+Enter steps · Esc stops`;
}
if (this.world?.paused) return 'The game paused itself · Esc stops';
return this.world?.activeCamera()
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
: 'No camera is switched on – nothing is rendered · Esc stops';
}
/** `restarting`: a `Restart game` plays on at once, and a recording goes on with it. */
stop(restarting = false): void {
if (!restarting) {
this.recorder.end();
this.recorder.setPaused(false);
}
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
gameClock.reset();
// And its sounds end with it, looping music included – nothing may outlive the world.
soundLibrary.setSuspended(false);
this.audio.stop('');
this.recordStep = null;
videoLibrary.endRun();
this.videoWait = null;
this.stepAsked = false;
this.breakAt = null;
this.pausing = null;
const world = this.world;
this.run = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
this.host.gameView.setWorld(null);
this.state.exitPlay();
this.host.leaveGame();
const lastError = world && behaviorError(world);
this.state.setNotice(lastError ?? '', 'error');
}
/**
* `step` is what the global clock moved this frame – already scaled and capped. `awake` is the
* step it would have moved without the game's own pause, which stops the clock but not the
* behaviors that run while paused.
*/
tick(step: number, awake: number): void {
const run = this.run;
if (!run) return;
const world = run.world;
this.tellPace();
if (
(this.state.paused && !this.stepping) ||
this.waitsForVideos() ||
this.recorder.isStarting ||
this.recordStep === 0
) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
this.input.endFrame();
world.dropPointerEdges();
return;
}
const wasPaused = world.paused;
world.held = this.state.heldObjectIds;
const request = run.tick(step, awake);
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
if (request === 'stop') {
this.stop();
return;
}
if (request === 'restart') {
const held = this.state.paused || this.breakAt !== null;
this.stop(true);
this.play(held);
return;
}
if (request === 'scene') {
this.sceneLoaded(run);
this.pauseAtBreak();
return;
}
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
// edit made during the run lands on the model, and it must not land on an old picture of it.
const structure = world.syncToModel().structure;
const update = this.panels.frame(
panelPolicy(this.state.project.whilePlaying),
performance.now(),
structure,
);
// 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);
}
/**
* A `Break` node ran this frame: the run holds as if the pause button had been pressed. A step
* stays held anyway, and the notice says whether it ran into a break.
*/
private pauseAtBreak(): void {
if (this.state.paused) {
if (this.stepping) this.state.setNotice(this.playNotice(), 'quiet');
} else if (this.breakAt !== null) {
this.state.setPaused(true);
this.applyPause(true);
}
}
/**
* Called once everything that draws the game view this frame is queued: a recording takes the
* frame after all of it, behind another tab too.
*/
recordFrame(): void {
if (!this.recorder.isRecording || !this.world) return;
onFrame('recording', () => {
if (!this.host.gameView.visible) this.host.gameView.redrawHidden();
this.recorder.capture();
});
}
/** What became of the recordings (#405, test hook). */
recordingInfo(): ReturnType<GameRecorder['info']> {
return this.recorder.info();
}
/** Brings the panels up to date as far as `update` says; the game's own picture never waits. */
private follow(update: PanelUpdate): void {
if (update.structure) {
this.updates.structure++;
this.state.preview();
}
if (update.values) {
this.updates.values++;
this.state.runtimeTick();
} else {
// The views draw on `runtime`, and what is on screen of the game has to move every frame.
if (this.host.gameView.visible) this.host.gameView.redraw();
if (this.host.viewport.visible) this.host.viewport.redraw();
}
if (!update.graphs) return;
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
}
/** The world tells the meter what each system took; a `Load scene` brings a world of its own. */
private timeSystems(run: GameRun): void {
run.world.timeSystem = (system, ms) => {
const area =
system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics';
this.meter.addSystem(area, ms);
};
}
/**
* Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is
* ready. Nothing is measured while nothing plays.
*/
measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean {
return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered);
}
/** How many entities run and where the frames went lately; null while nothing plays. */
performance(): { entities: number; times: FrameTimes | null } | null {
const world = this.world;
return world ? { entities: world.entities.length, times: this.meter.average } : null;
}
/** How often the panels caught up with this run, by kind (test hook). */
panelUpdates(): { structure: number; values: number } {
return { ...this.updates };
}
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
snapshot(): WorldSnapshot | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return {
objectId: e.objectId,
assetId: e.assetId,
name: e.name,
x: e.x,
y: e.y,
worldX: w.x,
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: namedBag(e.props),
animations: structuredClone(e.animations ?? []),
};
}),
shared: namedShared(this.behaviors?.shared ?? emptySharedState()),
log: [...world.log],
};
}
}
function namedBag(bag: PropertyBag): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value);
return out;
}
function namedShared(shared: SharedState): SharedState {
const variables: Record<string, PropertyBag> = {};
for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag;
return { variables, states: shared.states };
}