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
3 changes: 3 additions & 0 deletions changes/sim-advance-yield.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions packages/shell/src/devtools/agentBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down
30 changes: 19 additions & 11 deletions packages/shell/src/devtools/simStepper.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Expand Down
22 changes: 18 additions & 4 deletions packages/shell/src/devtools/simStepper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> = defaultYield): Promise<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);
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<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
Loading