Fix the landed-toast softlock: the drawer no longer opens before Rapier is up

The toast could beat the wasm: main.ts boots the game without awaiting
game.init(), and drawer.reset() dereferences the Rapier World. Pop early
enough on a slow device and onPopped -> openDrawer threw mid-update,
leaving the round on phase 'landed' with an empty hint and a dead ENTER
(field report: toast apparently resting against the toaster, game stuck).

- Game.init() is memoised with a drawerReady flag; a second call joins
  the first instead of building a second World.
- openDrawer() and the fromLoaf knife-trip both gate on it: wait for
  physics if it's still compiling, and if it never arrives, degrade to
  handing over the ordered tool instead of softlocking.
- Belt and braces in the kitchen: a 3s flying timeout force-lands the
  slice, so 'flying' can never hang mid-air whatever the cause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-09-01 11:49:14 +10:00
parent 317c78be71
commit e851900cc1
3 changed files with 52 additions and 5 deletions

View File

@ -659,12 +659,36 @@ export class Game {
this.beginDay();
}
async init(): Promise<void> {
await this.drawer.init();
private drawerInit?: Promise<void>;
private drawerReady = false;
/**
* Rapier's wasm compile. Memoised: the drawer gates on this promise when the
* toast lands before physics is up (main.ts boots without waiting), and a
* second call must join the first, not build a second World.
*/
init(): Promise<void> {
this.drawerInit ??= this.drawer.init().then(() => {
this.drawerReady = true;
});
return this.drawerInit;
}
/** Fresh out of the toaster, and now you need something to spread with. */
private openDrawer(): void {
if (!this.drawerReady) {
// The toast beat the wasm: on a slow device Rapier can still be
// compiling when the slice lands. Opening anyway dereferenced an
// undefined World mid-update — the round softlocked on a landed slice
// with no hint and a dead ENTER. Hold on the plate until physics is in;
// if it never arrives, skip the fishing and hand over the ordered tool.
this.kitchen.flash('rummaging for the drawer…');
void this.init().then(
() => this.openDrawer(),
() => this.closeDrawer(this.order.tool),
);
return;
}
this.drawer.reset(this.order.tool);
this.drawer.setTimerLabel('your toast is going cold');
this.drawer.warmth = this.kitchen.currentSlice.warmth;
@ -1523,6 +1547,18 @@ export class Game {
/** The toaster half of the day: the loaf knife-trip, or straight to the bench. */
private enterToastFlow(): void {
if (this.order.fromLoaf) {
if (!this.drawerReady) {
// Same wasm race as openDrawer, at the day's front door. Wait it out;
// a physics that never arrives skips the trip and hands them the knife.
void this.init().then(
() => this.enterToastFlow(),
() => {
this.knifeTrip = true;
this.closeDrawer('bread_knife');
},
);
return;
}
// Bakery bread: first find something to slice it with.
this.knifeTrip = true;
this.drawer.reset('bread_knife');

View File

@ -13,8 +13,10 @@ app.scene.background = new THREE.Color(0x241a15);
const game = new Game(app);
// Rapier's wasm has to be up before the drawer can be opened, but the toaster
// works without it — so boot the game now and let physics catch up.
void game.init();
// works without it — so boot the game now and let physics catch up. A compile
// failure is handled at the drawer's door (it degrades to handing over the
// ordered tool); swallow it here so it isn't also an unhandled rejection.
void game.init().catch(() => {});
// The main harness ships in production too: it powers the in-game
// demonstrations ("watch him do it"), and console cooks are welcome guests.

View File

@ -62,6 +62,7 @@ export class KitchenView implements View {
private vel = new THREE.Vector3();
private spin = 0;
private toastSeconds = 0;
private flyingSeconds = 0;
knife: Knife = newKnife();
/** Brand quality for the current order's bread. */
@ -386,6 +387,7 @@ export class KitchenView implements View {
const T = (vy + Math.sqrt(disc)) / g;
this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T);
this.spin = Math.PI / 2 / T;
this.flyingSeconds = 0;
this.app.shake = 0.045;
this.hintEl.textContent = '';
}
@ -423,11 +425,18 @@ export class KitchenView implements View {
this.placeInSlot(this.leverT);
} else if (this.phase === 'flying') {
this.vel.y -= 9.8 * dt;
this.flyingSeconds += dt;
this.slice.mesh.position.addScaledVector(this.vel, dt);
this.slice.mesh.rotation.x = Math.max(0, this.slice.mesh.rotation.x - this.spin * dt);
this.slice.mesh.rotation.z += dt * 1.1;
this.slice.setHeatGlow(0);
if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) {
// The arc is deterministic and takes ~1s, so the timeout is pure
// insurance: whatever leaves a slice hanging mid-air (a hitch, a bug,
// a hostile embed), the round must never be able to hang with it.
if (
(this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) ||
this.flyingSeconds > 3
) {
this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z);
this.slice.mesh.rotation.set(0, 0, 0);
this.app.shake = 0.02;