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
30 changes: 30 additions & 0 deletions packages/physics/__tests__/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ describe("rapier plugin", () => {
plugin.dispose?.(ctx);
});

it("resolves a setup whose game was disposed first, installing nothing and reporting it", async () => {
await RAPIER.init();
const plugin = rapier();
const ctx = { physics: undefined } as unknown as ICtx<Record<string, unknown>, IPhysicsContext>;
const gates: Promise<void>[] = [];
const runtime = {
fixedStep: () => 0,
holdStart: (gate: Promise<void>) => {
gates.push(gate);
return gate;
},
observations: { contribute: () => () => undefined, contributions: () => [] },
rapier: null,
seed: 1,
step: 1 / 60,
tick: () => 0,
} satisfies IGamePluginRuntime;
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);

plugin.setup?.(ctx, runtime);
plugin.dispose?.(ctx);

// A reload during the Wasm load must not become a boot failure on the page.
await expect(Promise.all(gates)).resolves.toBeDefined();
expect(ctx.physics).toBeUndefined();
expect(runtime.rapier).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/TN_PHYSICS_SETUP_SUPERSEDED/u));
warn.mockRestore();
});

it("should register the actual Rapier runtime version", async () => {
await RAPIER.init();
const plugin = rapier();
Expand Down
5 changes: 4 additions & 1 deletion packages/physics/src/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,16 @@ export function recast(): NavigationPlugin {
// Like rapier(), Recast initializes while the scene loads and holds `enter()` until it is up.
setup: (ctx: ICtx<Record<string, unknown>, IPhysicsContext>, runtime) => {
// rapier() leaves its readiness here (`plugin.ts` PHYSICS_READY); this bundle has no copy of it.
const physics: Promise<IPhysicsContext> | undefined = Reflect.get(
const physics: Promise<IPhysicsContext | undefined> | undefined = Reflect.get(
ctx,
Symbol.for("threenative.physics.ready"),
);
if (physics === undefined)
throw new Error("recast() requires rapier() earlier in the plugins array.");
const ready = Promise.all([physics, initialize()]).then(([context]) => {
// The page that asked for navigation is gone; installing into it would leak, and rejecting
// would report a boot failure the disposed game never had.
if (context === undefined) return;
const empty = emptyNavigation();
navigation = {
agents: new Set(),
Expand Down
14 changes: 11 additions & 3 deletions packages/physics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,17 @@ export function rapier(options: IPhysicsOptions = {}): PhysicsPlugin {
setup: (ctx: ICtx<Record<string, unknown>, IPhysicsContext>, runtime) => {
gameContext = ctx;
const generation = ++generations;
const ready = backend.initialize().then(() => {
if (generation !== generations)
throw new Error("rapier() was disposed before it initialized.");
const ready = backend.initialize().then((): IPhysicsContext | undefined => {
// A reload or a stop raced Rapier's Wasm. The generation that asked is gone, so nothing is
// installed — and nothing rejects: with the start gate, nothing awaits `ready` any more, so
// a rejection here reached the page as a boot failure it never had (the 0.3.13 release proof
// stopped on exactly that, PRD-860).
if (generation !== generations) {
console.warn(
"TN_PHYSICS_SETUP_SUPERSEDED: rapier initialized after its game was disposed; nothing was installed.",
);
return undefined;
}
const selected = backend.createSimulation(options);
simulation = selected;
if (runtime !== undefined) runtime.rapier = selected.version;
Expand Down
Loading