From 9eec932d67d0fa531225019dac813f846d90ae3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:51:14 +0000 Subject: [PATCH 1/2] Fast-forward the simulation from drive RPCs Headless software rendering runs Beacon Bastion at 0.2-0.4 fps (render ~2.9 s/frame, sim 0.3 ms), so its first wave, Odd Orbit's 20 s shift and other timers never arrived inside a drive capture. The frame and HUD-only drivers now share their per-frame step with a registered stepper: sim_advance runs N seconds of it in 0.05 s frames without rendering, and time_scale sets the clock's timescale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- .claude/skills/jgengine-verify/SKILL.md | 2 +- changes/agent-time-scale.md | 4 ++ .../shell/src/devtools/agentBridge.test.ts | 21 +++++++++- packages/shell/src/devtools/agentBridge.ts | 32 ++++++++++++++++ .../shell/src/devtools/simStepper.test.ts | 37 ++++++++++++++++++ packages/shell/src/devtools/simStepper.ts | 31 +++++++++++++++ packages/shell/src/drivers/FrameDriver.tsx | 38 ++++++++++++------- packages/shell/src/drivers/HudOnlyDriver.tsx | 20 +++++++--- 8 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 changes/agent-time-scale.md create mode 100644 packages/shell/src/devtools/simStepper.test.ts create mode 100644 packages/shell/src/devtools/simStepper.ts diff --git a/.claude/skills/jgengine-verify/SKILL.md b/.claude/skills/jgengine-verify/SKILL.md index 43f7b2d02..274d8c063 100644 --- a/.claude/skills/jgengine-verify/SKILL.md +++ b/.claude/skills/jgengine-verify/SKILL.md @@ -37,7 +37,7 @@ Classify each acceptance claim before scheduling proof. A plan that uses screens - Gameplay tests prove the observable acceptance scenario, including save/restore or multi-client behavior when changed. - Time-based headless tests: `HeadlessRunner.step(dt)` clamps every step to `maxStepSeconds` (default 0.05 s) no matter what dt you pass — `step(5)` advances the sim by 0.05 s, not 5 s, so a timer that "never fires" is usually this clamp, not a bug. Advance game time with many small steps (~20 per second of game time, e.g. `for (…) runner.step(1 / 60)`), or raise `maxStepSeconds` in the runner options when coarse fixed steps are intended. - `drive` boots from a **clean storage state by default**: it clears the target origin's `localStorage`/IndexedDB/origin storage before navigating, so a game with `persist: true` (whole-world save) starts fresh and `capture.probe` reflects THIS run rather than a restored prior session. On a warm/persistent Chrome (`shoot daemon`, `--keep`/`--connect`) that save would otherwise carry between drives and silently corrupt probe/recording evidence. Pass `--reuse-storage` to intentionally keep the warm profile's save (e.g. to capture a Continue/restore flow). -- For interactive softlock/progress proof, drive input and interrogate `window.__jgengineAgent.handle({ method: ... })` — `agent_status`, `debug_snapshot`, `player_pose` (local player position + heading; read it before and after a `--key` hold to prove movement) and the editor verbs work headlessly on any running game page. In a created standalone game the one command for all of this is `bun run drive` (`scripts/drive.mjs`, shipped in the scaffold): ordered `--click`/`--click-at x,y`/`--double-click`/`--fill`/`--key`/`--wait`/`--shot` steps, `--rpc '{"method":"agent_status"}'` for the bridge, and `--playtest --strict` for the softlock rung — never hand-roll a Playwright/Puppeteer/CDP script or a bespoke Vite consumer for this. A browser tool on the `bun dev` page is the fallback, not the default. +- For interactive softlock/progress proof, drive input and interrogate `window.__jgengineAgent.handle({ method: ... })` — `agent_status`, `debug_snapshot`, `player_pose` (local player position + heading; read it before and after a `--key` hold to prove movement), `sim_advance` (`{"method":"sim_advance","seconds":30}` runs 30 s of simulation without rendering — headless software rendering is often under 1 fps, so waves, shifts and timers otherwise never arrive), `time_scale` (`{"method":"time_scale","scale":20}` sets the game clock timescale) and the editor verbs work headlessly on any running game page. In a created standalone game the one command for all of this is `bun run drive` (`scripts/drive.mjs`, shipped in the scaffold): ordered `--click`/`--click-at x,y`/`--double-click`/`--fill`/`--key`/`--wait`/`--shot` steps, `--rpc '{"method":"agent_status"}'` for the bridge, and `--playtest --strict` for the softlock rung — never hand-roll a Playwright/Puppeteer/CDP script or a bespoke Vite consumer for this. A browser tool on the `bun dev` page is the fallback, not the default. - `GameHost` publishes declared `capture.probe` as `window.__jgProbe` after game initialization and staging. Reads use the live context; replacement/unmount retires the hook. An absent or throwing probe supplies no progress evidence. - Placeholder-vs-authored: `debug_snapshot().probes.fallbacks` reports which render seams resolved to fallbacks (green ground, primitive actors, proxy scatter) and why — a non-empty count proves content is unauthored/misconfigured rather than an intended placeholder. `probes.textureErrors` (`{ url, count }[]`) is its twin for the failure `fallbacks` cannot see: a model that resolves but whose GLB textures 404 — a non-empty list is a visibly-broken (untextured) scene, treat it exactly like a model fallback. - Portable `drive --click-at x,y` sends trusted native left-click input to a canvas or control in CSS viewport pixels. Steps preserve argument order; coordinates must be finite, nonnegative and strictly inside the selected device or overridden viewport (mobile DPR does not multiply them). Use the game’s visible world target; coordinate clicking does not project an entity id, run a game command directly, or advance fixed simulation time. Older project-owned `scripts/drive.mjs` keep their installed behavior; update the scaffold harness to use this option. diff --git a/changes/agent-time-scale.md b/changes/agent-time-scale.md new file mode 100644 index 000000000..27c164b6f --- /dev/null +++ b/changes/agent-time-scale.md @@ -0,0 +1,4 @@ +### Added + +- `sim_advance` agent-bridge verb (`@jgengine/shell`, `drive --rpc '{"method":"sim_advance","seconds":30}'`) steps the mounted sim driver — input, movement, `onTick`, behaviors, bound actions — in 0.05 s frames without rendering, so a headless drive can reach waves, shifts and timers that never arrive at software-rendered frame rates. Capped at 120 s per call. +- `time_scale` agent-bridge verb sets the game clock's timescale and reports `{ timescale, now }`. diff --git a/packages/shell/src/devtools/agentBridge.test.ts b/packages/shell/src/devtools/agentBridge.test.ts index 2198ddc37..02188fd13 100644 --- a/packages/shell/src/devtools/agentBridge.test.ts +++ b/packages/shell/src/devtools/agentBridge.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { GameContext } from "@jgengine/core/runtime/gameContext"; -import { playerPoseReport, waitForEditorHost } from "./agentBridge"; +import { applyTimeScale, playerPoseReport, waitForEditorHost } from "./agentBridge"; describe("waitForEditorHost", () => { test("resolves true as soon as the host goes live", async () => { @@ -39,3 +39,22 @@ describe("playerPoseReport", () => { expect(playerPoseReport(ctxWith(null))).toBeNull(); }); }); + +describe("applyTimeScale", () => { + const clock = () => { + let scale = 1; + return { time: { setTimescale: (value: number) => { scale = value; }, timescale: () => scale, now: () => 12.34567 } } as unknown as Pick; + }; + + test("sets and reports the timescale with the current game time", () => { + const ctx = clock(); + expect(applyTimeScale(ctx, 20)).toEqual({ ok: true, timescale: 20, now: 12.346 }); + expect(applyTimeScale(ctx, undefined)).toEqual({ ok: true, timescale: 20, now: 12.346 }); + }); + + test("rejects non-numeric, negative or extreme scales without touching the clock", () => { + const ctx = clock(); + for (const bad of ["2", -1, Number.NaN, 500]) expect(applyTimeScale(ctx, bad).ok).toBe(false); + expect(ctx.time.timescale()).toBe(1); + }); +}); diff --git a/packages/shell/src/devtools/agentBridge.ts b/packages/shell/src/devtools/agentBridge.ts index 55f2ee55d..477fe7d33 100644 --- a/packages/shell/src/devtools/agentBridge.ts +++ b/packages/shell/src/devtools/agentBridge.ts @@ -14,6 +14,7 @@ import { type EditorUiPanelLayout, } from "@jgengine/core/ui/hudDocument"; import { buildFullReport, buildLeanReport } from "./devtoolsReports"; +import { advanceSimulation } from "./simStepper"; /** One RPC call into the agent bridge: a verb name plus its verb-specific fields. */ export type AgentBridgeRequest = { method: string } & Record; @@ -38,6 +39,22 @@ export function playerPoseReport(ctx: GameContext | null): { userId: string; pos return { userId: ctx.player.userId, position: [round(x), round(y), round(z)], rotationY: round(entity.rotationY ?? 0) }; } +/** + * @internal + * `time_scale` verb: sets the game clock's global timescale (`ctx.time.setTimescale`) so a headless + * drive can fast-forward timers, economies and day cycles that never finish at capture frame rates. + * Large scales take large steps — use it for clocks, not to judge movement. Omit `scale` to read. + */ +export function applyTimeScale(ctx: Pick, scale: unknown): AgentBridgeResponse { + if (scale !== undefined) { + if (typeof scale !== "number" || !Number.isFinite(scale) || scale < 0 || scale > 120) { + return { ok: false, error: "time_scale needs { scale } between 0 and 120 (1 = normal)" }; + } + ctx.time.setTimescale(scale); + } + return { ok: true, timescale: ctx.time.timescale(), now: Math.round(ctx.time.now() * 1000) / 1000 }; +} + type EditorHost = { gameId: string; handle(request: AgentBridgeRequest): unknown }; /** @@ -185,6 +202,21 @@ export function installAgentBridge(options: { const pose = playerPoseReport(getContext()); return pose === null ? { ok: false, error: "no local player entity yet" } : { ok: true, ...pose }; } + case "time_scale": { + const ctx = getContext(); + if (ctx === null) return { ok: false, error: "no game context yet" }; + return applyTimeScale(ctx, request.scale); + } + case "sim_advance": { + const seconds = request.seconds; + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0 || seconds > 120) { + return { ok: false, error: "sim_advance needs { seconds } in (0, 120]" }; + } + const frames = advanceSimulation(seconds); + if (frames === null) return { ok: false, error: "no sim driver mounted" }; + const ctx = getContext(); + return { ok: true, frames, now: ctx === null ? null : Math.round(ctx.time.now() * 1000) / 1000 }; + } case "debug_report": return { ok: true, report: buildFullReport(playable) }; case "debug_perf_reset": diff --git a/packages/shell/src/devtools/simStepper.test.ts b/packages/shell/src/devtools/simStepper.test.ts new file mode 100644 index 000000000..d6824c659 --- /dev/null +++ b/packages/shell/src/devtools/simStepper.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { advanceSimulation, registerSimStepper } from "./simStepper"; + +describe("advanceSimulation", () => { + test("is null with no driver mounted", () => { + expect(advanceSimulation(1)).toBeNull(); + }); + + test("runs the registered step in 0.05 s frames covering the requested time", () => { + const steps: number[] = []; + const unregister = registerSimStepper((dt) => steps.push(dt)); + expect(advanceSimulation(1)).toBe(20); + expect(steps.length).toBe(20); + expect(steps.every((dt) => dt === 0.05)).toBe(true); + unregister(); + expect(advanceSimulation(1)).toBeNull(); + }); + + test("caps a single call at 2400 frames and clamps the frame size", () => { + let count = 0; + const unregister = registerSimStepper(() => { count += 1; }); + expect(advanceSimulation(1000)).toBe(2400); + expect(count).toBe(2400); + expect(advanceSimulation(0.1, 0.5)).toBe(2); + unregister(); + }); + + test("an older driver's unregister does not remove a newer registration", () => { + const first = registerSimStepper(() => {}); + let ran = 0; + const second = registerSimStepper(() => { ran += 1; }); + first(); + expect(advanceSimulation(0.05)).toBe(1); + expect(ran).toBe(1); + second(); + }); +}); diff --git a/packages/shell/src/devtools/simStepper.ts b/packages/shell/src/devtools/simStepper.ts new file mode 100644 index 000000000..c74d0b7c4 --- /dev/null +++ b/packages/shell/src/devtools/simStepper.ts @@ -0,0 +1,31 @@ +/** + * The mounted sim driver's per-frame step, registered so the agent bridge can fast-forward the + * simulation without rendering (`sim_advance`). Headless software rendering runs at well under + * 1 fps, so timers, waves and economies otherwise never complete inside a drive capture. + * @internal + */ +export type SimStep = (rawDt: number) => void; + +let current: SimStep | null = null; + +/** Register the active driver's step; returns the unregister. Last registration wins. @internal */ +export function registerSimStepper(step: SimStep): () => void { + current = step; + return () => { + if (current === step) current = null; + }; +} + +/** + * Step the registered simulation `seconds` of real time in `step`-second frames (each frame runs + * the full driver step: input, movement, `onTick`, behaviors, bound actions). Capped at 2400 + * frames per call. Returns the frames run, or `null` when no driver is mounted. + * @internal + */ +export function advanceSimulation(seconds: number, step = 0.05): number | null { + if (current === null) return null; + const frame = Math.min(0.05, Math.max(0.001, step)); + const frames = Math.min(2400, Math.max(0, Math.ceil(seconds / frame))); + for (let index = 0; index < frames; index += 1) current(frame); + return frames; +} diff --git a/packages/shell/src/drivers/FrameDriver.tsx b/packages/shell/src/drivers/FrameDriver.tsx index 28d08000f..16eeb79aa 100644 --- a/packages/shell/src/drivers/FrameDriver.tsx +++ b/packages/shell/src/drivers/FrameDriver.tsx @@ -1,5 +1,5 @@ import { useFrame } from "@react-three/fiber"; -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { RESERVED_INPUT_ACTIONS, dispatchBoundAction, heldActionsFor, shouldFireBoundAction } from "../boundActionDispatch"; import { executeHotbarSlot, findHotbarSlotActions, hotbarIdFor } from "../hotbarActions"; @@ -24,6 +24,7 @@ import { devtools } from "@jgengine/core/devtools/devtools"; import type { ActionStateTracker } from "@jgengine/core/input/actionBindings"; import { collisionDebug } from "../devtools/collisionDebug"; +import { registerSimStepper } from "../devtools/simStepper"; import { GAME_SIM_FRAME_PRIORITY } from "../camera"; import { NO_ACTIONS } from "../shellConstants"; import type { PointerService } from "../pointer/pointerService"; @@ -144,16 +145,10 @@ export function FrameDriver({ }, [ctx]); authoritativeFrameRef.current = serverAuthoritative ? predictionHandler : null; - useFrame((_state, rawDt) => { - if (poster) { - if (posterDoneRef.current) return; - posterElapsedRef.current += Math.min(rawDt, 0.05); - if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) { - posterDoneRef.current = true; - onPosterSettled(); - return; - } - } + // One frame of simulation — input, movement, onTick, behaviors, bound actions — shared by the + // render loop and the agent bridge's render-free `sim_advance`. + /** Returns false when play is gated (menu, pause) and nothing advanced. */ + const simulate = (rawDt: number): boolean => { const sendInput = () => { if (!serverAuthoritative) return; const frame: InputFrame = { @@ -171,9 +166,8 @@ export function FrameDriver({ ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true }); ctx.input.publishAnalog(null); sendInput(); - return; + return false; } - const simStart = performance.now(); try { let endPhase = devtools.profile.begin("time+input"); const dt = Math.min(rawDt, 0.05); @@ -350,6 +344,24 @@ export function FrameDriver({ onRuntimeError(error, "tick"); } } + return true; + }; + const simulateRef = useRef(simulate); + simulateRef.current = simulate; + useEffect(() => (poster ? undefined : registerSimStepper((rawDt) => simulateRef.current(rawDt))), [poster]); + + useFrame((_state, rawDt) => { + if (poster) { + if (posterDoneRef.current) return; + posterElapsedRef.current += Math.min(rawDt, 0.05); + if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) { + posterDoneRef.current = true; + onPosterSettled(); + return; + } + } + const simStart = performance.now(); + if (!simulate(rawDt)) return; devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart }); }, GAME_SIM_FRAME_PRIORITY); return null; diff --git a/packages/shell/src/drivers/HudOnlyDriver.tsx b/packages/shell/src/drivers/HudOnlyDriver.tsx index 1f7e8bf05..304b62446 100644 --- a/packages/shell/src/drivers/HudOnlyDriver.tsx +++ b/packages/shell/src/drivers/HudOnlyDriver.tsx @@ -15,6 +15,7 @@ import type { ShellMultiplayer } from "../multiplayer"; import { EMPTY_RESERVED, NO_ACTIONS } from "../shellConstants"; import type { PlayableGame } from "../registry"; +import { registerSimStepper } from "../devtools/simStepper"; export function HudOnlyDriver({ ctx, @@ -49,6 +50,13 @@ export function HudOnlyDriver({ const last = lastFrameRef.current; lastFrameRef.current = now; if (last === null) return; + const rawDt = (now - last) / 1000; + const simStart = performance.now(); + if (!simulate(rawDt)) return; + devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart }); + }; + /** One frame of simulation; false when play is gated and nothing advanced. Shared with `sim_advance`. */ + const simulate = (rawDt: number): boolean => { const sendInput = () => { if (!serverAuthoritative || serverIdRef.current === null) return; const frame: InputFrame = { held: ctx.input.held(), pointer: ctx.input.pointer(), analog: ctx.input.analog() }; @@ -60,10 +68,8 @@ export function HudOnlyDriver({ ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true }); ctx.input.publishAnalog(null); sendInput(); - return; + return false; } - const rawDt = (now - last) / 1000; - const simStart = performance.now(); try { let endPhase = devtools.profile.begin("time+input"); const dt = Math.min(rawDt, 0.05); @@ -97,10 +103,14 @@ export function HudOnlyDriver({ onRuntimeError(error, "tick"); } } - devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart }); + return true; }; frameId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(frameId); + const unregister = registerSimStepper((rawDt) => { simulate(rawDt); }); + return () => { + cancelAnimationFrame(frameId); + unregister(); + }; }, [ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, inputActions, multiplayer, serverIdRef, serverAuthoritative]); return null; From 10eac2bef1d1f0f7a49f66c88b0c3934169a60a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:52:06 +0000 Subject: [PATCH 2/2] Regenerate export manifest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- scripts/export-manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 28992ea28..71f765b7a 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -828,6 +828,7 @@ "./devtools/panelAtoms", "./devtools/perfDiagnose", "./devtools/playtestProbe", + "./devtools/simStepper", "./diagnostics/FpsCounter", "./diagnostics/RuntimeDiagnostics", "./drivers/FrameDriver",