Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/jgengine-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions changes/agent-time-scale.md
Original file line number Diff line number Diff line change
@@ -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 }`.
21 changes: 20 additions & 1 deletion packages/shell/src/devtools/agentBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<GameContext, "time">;
};

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);
});
});
32 changes: 32 additions & 0 deletions packages/shell/src/devtools/agentBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -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<GameContext, "time">, 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 };

/**
Expand Down Expand Up @@ -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":
Expand Down
37 changes: 37 additions & 0 deletions packages/shell/src/devtools/simStepper.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
31 changes: 31 additions & 0 deletions packages/shell/src/devtools/simStepper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 25 additions & 13 deletions packages/shell/src/drivers/FrameDriver.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 15 additions & 5 deletions packages/shell/src/drivers/HudOnlyDriver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() };
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@
"./devtools/panelAtoms",
"./devtools/perfDiagnose",
"./devtools/playtestProbe",
"./devtools/simStepper",
"./diagnostics/FpsCounter",
"./diagnostics/RuntimeDiagnostics",
"./drivers/FrameDriver",
Expand Down
Loading