diff --git a/changes/sim-advance-yield.md b/changes/sim-advance-yield.md new file mode 100644 index 000000000..d99614d2c --- /dev/null +++ b/changes/sim-advance-yield.md @@ -0,0 +1,3 @@ +### Fixed + +- `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/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..85cb3dfc7 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(30, 0.05, async () => { yields += 1; })).toBe(600); + expect(yields).toBe(600 / 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..d4031fee0 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 (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)); +}