From d8a947f8dc87250453d6808a0c52584d6e44ab45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:20:24 +0000 Subject: [PATCH 1/2] Yield between sim_advance batches so mid-advance spawns get models A 30 s sim_advance through Beacon Bastion's first wave spawned 18 raiders in one synchronous burst; their model groups stayed empty (bars drew, bodies never did) while the same wave reached with time_scale and normal frames mounted every model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- changes/sim-advance-yield.md | 3 ++ packages/shell/src/devtools/agentBridge.ts | 9 +++--- .../shell/src/devtools/simStepper.test.ts | 30 ++++++++++++------- packages/shell/src/devtools/simStepper.ts | 22 +++++++++++--- 4 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 changes/sim-advance-yield.md diff --git a/changes/sim-advance-yield.md b/changes/sim-advance-yield.md new file mode 100644 index 000000000..0dc5a47b0 --- /dev/null +++ b/changes/sim-advance-yield.md @@ -0,0 +1,3 @@ +### Fixed + +- `sim_advance` (`@jgengine/shell` agent bridge) yields to the browser every simulated second, so models for entities spawned mid-advance mount as in play; a 30 s advance through Beacon Bastion's first wave previously left every raider with health bars but no model. diff --git a/packages/shell/src/devtools/agentBridge.ts b/packages/shell/src/devtools/agentBridge.ts index 477fe7d33..0d76be311 100644 --- a/packages/shell/src/devtools/agentBridge.ts +++ b/packages/shell/src/devtools/agentBridge.ts @@ -212,10 +212,11 @@ export function installAgentBridge(options: { 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 }; + return advanceSimulation(seconds).then((frames): AgentBridgeResponse => { + 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) }; diff --git a/packages/shell/src/devtools/simStepper.test.ts b/packages/shell/src/devtools/simStepper.test.ts index d6824c659..fdd16ac32 100644 --- a/packages/shell/src/devtools/simStepper.test.ts +++ b/packages/shell/src/devtools/simStepper.test.ts @@ -1,36 +1,44 @@ import { describe, expect, test } from "bun:test"; -import { advanceSimulation, registerSimStepper } from "./simStepper"; +import { advanceSimulation, FRAMES_PER_YIELD, registerSimStepper } from "./simStepper"; describe("advanceSimulation", () => { - test("is null with no driver mounted", () => { - expect(advanceSimulation(1)).toBeNull(); + test("is null with no driver mounted", async () => { + expect(await advanceSimulation(1)).toBeNull(); }); - test("runs the registered step in 0.05 s frames covering the requested time", () => { + test("runs the registered step in 0.05 s frames covering the requested time", async () => { const steps: number[] = []; const unregister = registerSimStepper((dt) => steps.push(dt)); - expect(advanceSimulation(1)).toBe(20); + expect(await advanceSimulation(1)).toBe(20); expect(steps.length).toBe(20); expect(steps.every((dt) => dt === 0.05)).toBe(true); unregister(); - expect(advanceSimulation(1)).toBeNull(); + expect(await advanceSimulation(1)).toBeNull(); }); - test("caps a single call at 2400 frames and clamps the frame size", () => { + test("yields to the host between batches so the UI can commit mid-advance", async () => { + const unregister = registerSimStepper(() => {}); + let yields = 0; + expect(await advanceSimulation(3, 0.05, async () => { yields += 1; })).toBe(60); + expect(yields).toBe(60 / FRAMES_PER_YIELD - 1); + unregister(); + }); + + test("caps a single call at 2400 frames and clamps the frame size", async () => { let count = 0; const unregister = registerSimStepper(() => { count += 1; }); - expect(advanceSimulation(1000)).toBe(2400); + expect(await advanceSimulation(1000, 0.05, async () => {})).toBe(2400); expect(count).toBe(2400); - expect(advanceSimulation(0.1, 0.5)).toBe(2); + expect(await advanceSimulation(0.1, 0.5)).toBe(2); unregister(); }); - test("an older driver's unregister does not remove a newer registration", () => { + test("an older driver's unregister does not remove a newer registration", async () => { const first = registerSimStepper(() => {}); let ran = 0; const second = registerSimStepper(() => { ran += 1; }); first(); - expect(advanceSimulation(0.05)).toBe(1); + expect(await 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 index c74d0b7c4..0414845c9 100644 --- a/packages/shell/src/devtools/simStepper.ts +++ b/packages/shell/src/devtools/simStepper.ts @@ -18,14 +18,28 @@ export function registerSimStepper(step: SimStep): () => void { /** * 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. + * the full driver step: input, movement, `onTick`, behaviors, bound actions). Frames run in + * batches of {@link FRAMES_PER_YIELD} with a macrotask between batches, so React commits what the + * sim spawned along the way — models for entities created mid-advance mount as they would in play, + * instead of one burst the renderer never catches up with. Capped at 2400 frames per call. + * Resolves to the frames run, or `null` when no driver is mounted. * @internal */ -export function advanceSimulation(seconds: number, step = 0.05): number | null { +export async function advanceSimulation(seconds: number, step = 0.05, yieldToHost: () => Promise = defaultYield): Promise { 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); + for (let index = 0; index < frames; index += 1) { + if (current === null) return index; + current(frame); + if ((index + 1) % FRAMES_PER_YIELD === 0 && index + 1 < frames) await yieldToHost(); + } return frames; } + +/** Simulated frames between yields to the host (one game second at 0.05 s frames). @internal */ +export const FRAMES_PER_YIELD = 20; + +function defaultYield(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} From 0f9a2bef735415e4f7f97d60aad7b9e60a1ec5dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:24:23 +0000 Subject: [PATCH 2/2] Yield every ten simulated seconds, not every second Each yield can cost a full render frame (~3 s under software GL), so a 30 s advance with per-second yields outran drive's 30 s RPC timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- changes/sim-advance-yield.md | 2 +- packages/shell/src/devtools/simStepper.test.ts | 4 ++-- packages/shell/src/devtools/simStepper.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/changes/sim-advance-yield.md b/changes/sim-advance-yield.md index 0dc5a47b0..d99614d2c 100644 --- a/changes/sim-advance-yield.md +++ b/changes/sim-advance-yield.md @@ -1,3 +1,3 @@ ### Fixed -- `sim_advance` (`@jgengine/shell` agent bridge) yields to the browser every simulated second, so models for entities spawned mid-advance mount as in play; a 30 s advance through Beacon Bastion's first wave previously left every raider with health bars but no model. +- `sim_advance` (`@jgengine/shell` agent bridge) yields to the browser every ten simulated seconds, so models for entities spawned mid-advance mount as in play; a 30 s advance through Beacon Bastion's first wave previously left every raider with health bars but no model. diff --git a/packages/shell/src/devtools/simStepper.test.ts b/packages/shell/src/devtools/simStepper.test.ts index fdd16ac32..85cb3dfc7 100644 --- a/packages/shell/src/devtools/simStepper.test.ts +++ b/packages/shell/src/devtools/simStepper.test.ts @@ -19,8 +19,8 @@ describe("advanceSimulation", () => { test("yields to the host between batches so the UI can commit mid-advance", async () => { const unregister = registerSimStepper(() => {}); let yields = 0; - expect(await advanceSimulation(3, 0.05, async () => { yields += 1; })).toBe(60); - expect(yields).toBe(60 / FRAMES_PER_YIELD - 1); + expect(await advanceSimulation(30, 0.05, async () => { yields += 1; })).toBe(600); + expect(yields).toBe(600 / FRAMES_PER_YIELD - 1); unregister(); }); diff --git a/packages/shell/src/devtools/simStepper.ts b/packages/shell/src/devtools/simStepper.ts index 0414845c9..d4031fee0 100644 --- a/packages/shell/src/devtools/simStepper.ts +++ b/packages/shell/src/devtools/simStepper.ts @@ -37,8 +37,8 @@ export async function advanceSimulation(seconds: number, step = 0.05, yieldToHos return frames; } -/** Simulated frames between yields to the host (one game second at 0.05 s frames). @internal */ -export const FRAMES_PER_YIELD = 20; +/** Simulated frames between yields to the host (ten game seconds at 0.05 s frames): each yield can cost a full render frame, which headless software rendering spends seconds on. @internal */ +export const FRAMES_PER_YIELD = 200; function defaultYield(): Promise { return new Promise((resolve) => setTimeout(resolve, 0));