toastsim/src/dev.ts
type-two 8b13ac68a8 M17 pan shipped as a playable day (16); heat brief complete
Routed the pan into a real order: day 16 'pan-sear the mushrooms on the
gas' — butter in, wait for the FOAM not the smoke, land the food, brown
both faces, baste in the noisette. judgePan scores THE SEAR / BOTH SIDES
/ THE BUTTER with a butter-snob line bank. New scorecard group THE PAN;
the cast-iron skillet GLB renders with a fuel-coloured flame.

Balance fix (the floor): food shields the butter once it lands, so a
clean cook keeps the butter at noisette instead of racing to burnt — a
full sear is now winnable. Emergent fuel character kept: induction sears
both sides dead-even (0.016), gas flares the first side so you turn it
early (0.085) — both under the 0.1 bar.

Verified live: day 16 routes to the pan, plays end to end, grades A;
judge nails it ('cooked in different postcodes... foamed not burnt, you
were watching'). Cast-iron pan + butter + searing mushroom on screen.

Heat brief M-H1..M-H6 + M-H4 all shipped and live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:43:30 +10:00

959 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as THREE from 'three';
import type { App } from './core/app';
import type { Game } from './game/game';
import { sogWord } from './sim/assembly';
import type { IngredientId } from './sim/ingredients';
import { type Fuel, newHeatSource, setKnob, heatStep, envStep, newOutdoorEnv, addSmoke, newIndoorEnv, applyToHeatMap } from './sim/heatsource';
import { newRoastSession, roastStep, roastResult } from './sim/roasting';
import { Rng } from './core/rng';
import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal';
import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
* "does it actually work" hard to answer by inspection — so this drives real
* input through the real code paths, deterministically, at a chosen dt.
*
* Also the escape hatch for headless/embedded browsers that never fire rAF:
* everything here calls app.step() directly.
*
* Dev builds only.
*/
export function installDevHarness(app: App, game: Game): void {
const kitchen = game.kitchenView;
const v = new THREE.Vector3();
const toNdc = (x: number, y: number, z: number): [number, number] => {
v.set(x, y, z).project(app.camera);
return [v.x, v.y];
};
const run = (frames: number, dt = 1 / 60) => {
for (let i = 0; i < frames; i++) app.step(dt);
};
const tap = (code: string) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code }));
app.step(1 / 60);
window.dispatchEvent(new KeyboardEvent('keyup', { code }));
};
const point = (x: number, y: number, z: number, down: boolean) => {
const n = toNdc(x, y, z);
app.input.ndc.set(n[0], n[1]);
app.input.down = down;
};
const harness = {
app,
game,
kitchen,
THREE,
run,
tap,
point,
toNdc,
/** Drop the lever, brown for `seconds`, pop, and wait for it to land. */
toast(seconds = 10, power?: number) {
if (power !== undefined) kitchen.power = power;
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(200);
return harness.stats();
},
dip() {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.29, 0.45);
hit?.getWorldPosition(wp);
point(wp.x, wp.y, wp.z, true);
run(4);
app.input.down = false;
app.step(1 / 60);
},
/**
* Raster the whole slice. `speed` is world-units/sec — the thing that
* actually decides how thick the spread goes on.
*/
spread(passes = 9, speed = 0.55, angle?: number) {
const sl = kitchen.currentSlice;
if (angle !== undefined) kitchen.knife.angle = angle;
harness.dip();
const stepDist = speed / 60;
for (let p = 0; p < passes; p++) {
const z = 0.55 - 0.44 + (p / Math.max(1, passes - 1)) * 0.88;
const steps = Math.ceil(0.96 / stepDist);
for (let s = 0; s <= steps; s++) {
if (kitchen.knife.load < 0.02) harness.dip();
const x = 1.45 + (p % 2 ? 1 : -1) * (0.48 - s * stepDist);
point(x, sl.topY, z, true);
app.step(1 / 60);
}
}
app.input.down = false;
harness.park();
return harness.stats();
},
/** Swirl the knife in the jar — remixes a separated spread. */
swirl(circles = 3) {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.56, 0.45);
hit?.getWorldPosition(wp);
const steps = Math.round(circles * 48);
for (let i = 0; i <= steps; i++) {
const a = (i / 48) * Math.PI * 2;
point(wp.x + Math.cos(a) * 0.2, wp.y, wp.z + Math.sin(a) * 0.2, true);
app.step(1 / 60);
}
app.input.down = false;
app.step(1 / 60);
},
/** Park the cursor off the toast so a screenshot isn't full of knife. */
park() {
point(2.5, kitchen.currentSlice.topY, 1.3, false);
app.step(1 / 60);
},
stats() {
const sl = kitchen.currentSlice;
const b = sl.browning.stats(sl.mask);
const s = sl.spread.stats(sl.mask);
const d = sl.damage.stats(sl.mask);
const r = (n: number) => +n.toFixed(3);
return {
phase: kitchen.phase,
warmth: r(sl.warmth),
browning: { mean: r(b.mean), stdev: r(b.stdev), max: r(b.max) },
char: r(sl.browning.fraction(sl.mask, (x) => x > 0.85)),
spread: { mean: r(s.mean), stdev: r(s.stdev), max: r(s.max) },
coverage: r(sl.spread.fraction(sl.mask, (x) => x > 0.02)),
damage: r(d.mean),
load: r(kitchen.knife.load),
angle: r(kitchen.knife.angle),
mode: kitchen.lastFx?.mode ?? 'idle',
cut: sl.cut
? {
thickness: r(sl.cut.thickness),
wedge: r(sl.cut.wedge),
squash: r(sl.cut.squash),
knife: sl.cut.knife,
}
: null,
};
},
/** Move the camera somewhere for a screenshot without disturbing the sim. */
look(pos: [number, number, number], at: [number, number, number]) {
app.camera.position.set(...pos);
app.camera.lookAt(new THREE.Vector3(...at));
app.renderer.render(app.scene, app.camera);
},
/** Click through the title screen. */
start() {
(document.querySelector('.title .btn') as HTMLButtonElement | null)?.click();
app.resize();
run(5);
},
/** Jump to a day, through the real order table. */
day(n: number) {
game.setDay(n);
run(5);
},
grade: () => game.gradeNow(),
/**
* Fish a specific tool out of the drawer the honest way: grab it, drag it
* above the rim, and hold it there until the dwell commits. Grabs whatever
* is on top of it out of the way first if it has to.
*/
pick(id: string, maxSeconds = 20): boolean {
const drawer = game.drawerView;
const d = drawer as unknown as {
pieces: { id: string; body: { translation(): { x: number; y: number; z: number } } }[];
grabbed: { id: string } | null;
};
const piece = d.pieces.find((p) => p.id === id);
if (!piece) throw new Error(`no ${id} in this drawer`);
let frames = Math.round(maxSeconds * 60);
while (frames-- > 0 && drawer.root.visible) {
const t = piece.body.translation();
if (!d.grabbed) {
point(t.x, t.y, t.z, false);
run(1);
point(t.x, t.y, t.z, true);
run(1);
frames -= 2;
continue;
}
if (d.grabbed.id !== id) {
// Grabbed a blocker lying on top — sling it toward a corner, drop it.
const dir = t.x > 0 ? -1 : 1;
for (let i = 0; i < 25 && d.grabbed; i++) {
point(dir * 1.2, 1.4, t.z > 0 ? -0.7 : 0.7, true);
run(1);
}
app.input.down = false;
run(8);
frames -= 35;
continue;
}
// Holding the right piece: haul it up past the rim and keep it there.
point(t.x * 0.7, Math.min(2.2, t.y + 0.4), t.z * 0.7, true);
run(1);
}
app.input.down = false;
run(2);
return !drawer.root.visible;
},
/** Enter the prep bench with an ingredient, a knife, and a pattern. */
prep(ing = 'tomato', pattern: { kind: string; n?: number } = { kind: 'slices', n: 5 }, knife = 'dinner_knife') {
(game as unknown as { startPrep(i: string, p: unknown, k: string): void }).startPrep(ing, pattern, knife);
run(3);
},
/**
* One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis),
* bite, saw to completion. `press` seconds of leaning without sawing
* first, to invite the slip. `stopAt` (0..1) is the onion stop-line: release
* the blade once saw progress reaches it, committing a cut that stops short
* of the root instead of sawing through.
*/
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number; stopAt?: number } = {}) {
const { pos = 0, dy = 0.045, wobble = 0, press = 0, stopAt } = opts;
const pv = game.prepView as unknown as {
session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] };
};
const s = pv.session;
const ing = (game.prepView as unknown as { ing: { size: number } }).ing;
run(2);
const Z = -0.2;
const X = () => s.aimPos * ing.size; // slip moves the aim; follow it
point(pos * ing.size, 1.0, Z, false);
run(2);
point(pos * ing.size, 1.0, Z, true);
run(1);
if (press > 0) {
// Lean on it without sawing.
for (let i = 0; i < Math.round(press * 60); i++) {
point(X(), 1.0, Z, true);
run(1);
if (s.phase === 'aim') break; // it slipped and threw the knife off
}
}
let y = 1.0;
let dir = -1;
let f = 0;
while ((s.phase === 'bite' || s.phase === 'saw') && f < 3000) {
y += dir * dy;
if (y < 0.8 || y > 1.3) dir = -dir;
point(X() + (f % 2 ? wobble : -wobble), y, Z, true);
run(1);
f++;
// Stop-line: let the blade up short of the root — the release commits it.
if (stopAt !== undefined && s.phase === 'saw' && s.progress >= stopAt) break;
}
app.input.down = false;
run(3); // let the release-commit (liftKnife) land before the next chop
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f, progress: +s.progress.toFixed(3) };
},
/**
* Drive a whole `wedges(n)` pattern: ceil(n/2) diametral cuts spaced evenly
* around the half-turn, which fall out as n even radial wedges. `wobble`
* throws the spacing off to make a lottery on purpose. Returns prepStats.
*/
wedges(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 6, dy = 0.05, wobble = 0 } = opts;
const k = Math.ceil(n / 2);
// aimPos reads as a fraction of the half-turn, so evenly spaced positions
// in [-0.5, 0.5) give angles 0, π/k, 2π/k … — even wedges, CV → 0.
for (let i = 0; i < k; i++) harness.chop({ pos: i / k - 0.5, dy, wobble });
return harness.prepStats();
},
/**
* Drive a whole `dice(n)` pattern: two rotated passes of n-1 evenly spaced
* parallel cuts. The sim banks the first pass and rotates the board itself,
* so this is just 2·(n-1) chops. Evenly spaced → n×n even cells, CV → 0.
*/
dice(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 4, dy = 0.05, wobble = 0 } = opts;
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < n - 1; i++) harness.chop({ pos: (i + 1) / n - 0.5, dy, wobble });
}
return harness.prepStats();
},
/**
* The M14 onion dice with the stop-line. The first pass (root-ward cuts) is
* released short at `stopAt` (the sim commits on release); the sim rotates
* the board and the second pass criss-crosses straight through. Pass
* `through: true` to saw the root pass all the way — the "went through the
* stem, served confetti" failure. Reads live dicePhase, so it stays in step
* with the sim's own rotation.
*/
diceOnion(opts: { n?: number; stopAt?: number; dy?: number; wobble?: number; through?: boolean } = {}) {
// stopAt 0.8 lands the committed depth ~0.85 (a frame of overshoot) — square
// in the "stopped short" band and level with the scene's stop-line marker.
const { n = 4, stopAt = 0.8, dy = 0.05, wobble = 0, through = false } = opts;
const pv = game.prepView as unknown as { session: { dicePhase: number } };
const total = 2 * (n - 1);
for (let i = 0; i < total; i++) {
const pos = (i % (n - 1) + 1) / n - 0.5;
if (pv.session.dicePhase === 0 && !through) harness.chop({ pos, dy, wobble, stopAt });
else harness.chop({ pos, dy, wobble });
}
return harness.prepStats();
},
/** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */
handoff() {
tap('Enter');
run(3);
},
/** Drag the cloth across the board `passes` times. */
wipe(passes = 1, vAt = 0.5) {
const bw = 4.6;
const bd = 2.4;
for (let p = 0; p < passes; p++) {
for (let i = 0; i <= 40; i++) {
const u = p % 2 ? 1 - i / 40 : i / 40;
const wx = (0.15 + u * 0.7) * bw - bw / 2;
const wz = (vAt - 0.5) * bd;
point(wx, 0.12, wz, true);
run(1);
}
}
app.input.down = false;
run(2);
return game.prepView.mess.stats();
},
prepStats() {
const r = game.prepView.result();
return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() };
},
/**
* Drive the REAL routed juice station (game.juiceView, the one a day-12
* order sends you to — not the __tj sandbox): seat the half, then press-and-
* twist for `revolutions` turns at `press` downforce. Follow with `t.handoff()`
* (ENTER) to serve the glass and drop back into the toast flow.
*/
juice(revolutions = 3, press = 0.7, dir: 1 | -1 = 1) {
const view = game.juiceView as unknown as { placeHalf(): void; autoPress: boolean; press: number };
run(3); // let the juice view take the camera before we project points
view.placeHalf();
run(1);
view.autoPress = false;
view.press = press;
const CONE_TOP = 0.92;
const R = 0.3;
const steps = Math.max(1, Math.round(revolutions * 48));
for (let i = 0; i <= steps; i++) {
const a = dir * (i / 48) * Math.PI * 2;
point(Math.cos(a) * R, CONE_TOP, Math.sin(a) * R, true);
run(1);
}
app.input.down = false;
run(1);
return harness.juiceStats();
},
juiceStats() {
const view = game.juiceView;
const r = view.result();
const round = (n: number) => +n.toFixed(3);
return {
yieldOfOrange: round(r.yieldOfOrange),
yieldOfReachable: round(r.yieldOfReachable),
theoretical: round(r.theoretical),
extracted: round(r.extracted),
pips: r.pips,
seedsCaught: r.seedsCaught,
bitterness: round(r.bitterness),
pulp: round(r.pulp),
bench: view.mess.stats(),
benchWord: view.mess.benchWord(),
juicer: r.juicerName,
};
},
/**
* Cut a slice off the loaf. `dy` is world-units per frame of saw motion
* (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame.
*/
slice(opts: { thickness?: number; dy?: number; wobble?: number } = {}) {
const { thickness = 0.31, dy = 0.05, wobble = 0 } = opts;
const sl = game.slicerView as unknown as {
cutting: { phase: string; thickness: number; progress: number; wedge: number; squash: number; strokes: number };
endX: number;
};
run(2); // let the slicer take the camera before we project points
const c = sl.cutting;
const X = sl.endX - thickness;
const Z = -0.2;
point(X, 1.0, Z, false);
run(2);
point(X, 1.0, Z, true);
run(1); // first bite locks thickness
let y = 1.0;
let dir = -1;
let f = 0;
while (c.phase === 'saw' && f < 4000) {
y += dir * dy;
// ~0.5 world units per sweep — the length of a deliberate human stroke.
if (y < 0.8 || y > 1.3) dir = -dir;
point(X + (f % 2 ? wobble : -wobble), y, Z, true);
run(1);
f++;
}
app.input.down = false;
run(80); // the done beat, the fall, and the handoff to the kitchen
const r = (n: number) => +n.toFixed(3);
return {
phase: c.phase,
thickness: r(c.thickness),
wedge: r(c.wedge),
squash: r(c.squash),
strokes: c.strokes,
frames: f,
};
},
// --- M15 bruschetta ------------------------------------------------------
/** Flip a perishable fridge↔bench on the temp clock (costs service time). */
tempToggle(id: IngredientId) {
game.toggleTemp(id);
run(1);
return game.tempClock?.readiness(id);
},
/**
* Drive the routed oven (the one a day-14 order sends you to): dial in the
* heat, slide the tray in, roast `seconds`, pull it out. Leaves it at 'done';
* `t.bruschetta` advances with ENTER. ~32s@6 lands a blistered ~0.70, none
* slumped.
*/
roast(seconds = 32, power = 6) {
const oven = game.ovenView;
oven.power = power;
run(3);
tap('Space'); // tray in
run(Math.round(seconds * 60));
tap('Space'); // tray out
run(3);
return oven.result();
},
// ---- Heat-source doctrine (M-H1/M-H2). Pure-sim probes: no scene, they
// measure the fuel curves the whole game will ride. ----
/** Seconds for a fuel's output to climb `from`→`to` (M-H1: gas<1, elec 5-9, induction<0.15). */
heatChase(fuel: Fuel, from = 3, to = 9, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = from;
setKnob(hs, to);
let s = 0;
for (let i = 0; i < 6000 && hs.output < to - 0.01; i++) {
heatStep(hs, dt);
s += dt;
}
return +s.toFixed(3);
},
/** Output remaining `sec` after the knob drops 9→0 — the residual (electric remembers). */
residual(fuel: Fuel, sec = 6, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = 9;
hs.target = 9;
setKnob(hs, 0);
if (fuel === 'charcoal') hs.target = 9; // no knob; measure the bed decay
for (let i = 0; i < Math.round(sec / dt); i++) heatStep(hs, dt);
return +hs.output.toFixed(3);
},
/** Roast a tray on an oven SKU to ~`toMean` roast, report evenness. Gas oven
* reads uneven (high stdev), fan oven flat (low stdev) at equal mean. */
ovenStdev(sku = 'oven_gas', toMean = 0.6, power = 8) {
const src = newHeatSource(sku);
const s = newRoastSession(4, 20260718, power, src);
let f = 0;
let res = roastResult(s);
while (res.mean < toMean && f < 6000) {
roastStep(s, 1 / 60);
res = roastResult(s);
f++;
}
const r = (n: number) => +n.toFixed(4);
return { sku, mean: r(res.mean), stdev: r(res.stdev), seconds: +(f / 60).toFixed(1), output: +s.src!.output.toFixed(2) };
},
/** M-H3: hold a flame at knob 7 in wind for 8s; return the stdev of output.
* Outdoor unsheltered gusts perturb it (>0.05); sheltered forces it flat (<0.02). */
envVar(fuel: Fuel = 'gas', wind = 0.6, sheltered = false, secs = 8, dt = 1 / 60) {
const hs = newHeatSource(fuel);
const env = newOutdoorEnv(wind);
env.sheltered = sheltered;
setKnob(hs, 7);
// settle to target first, then sample under wind
for (let i = 0; i < 120; i++) heatStep(hs, dt, env);
const samples: number[] = [];
for (let i = 0; i < Math.round(secs / dt); i++) {
envStep(env, dt);
heatStep(hs, dt, env);
samples.push(hs.output);
}
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
const stdev = Math.sqrt(samples.reduce((a, b) => a + (b - mean) ** 2, 0) / samples.length);
return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) };
},
// ---- M17 + M-H4: the pan. Butter state timeline, the flip, the baste,
// and the fuel differences (gas foams faster, induction never flares). ----
/** Butter state timeline at a given heat: seconds to foaming / noisette /
* burnt, and the foaming-window duration (shorter at higher heat). */
panButter(fuel = 'gas', heat = 7, dt = 1 / 60) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
const mark: Record<string, number> = {};
let prev = butterState(s);
for (let i = 0; i < 60 * 60; i++) {
panStep(s, dt);
const st = butterState(s);
if (st !== prev) {
if (!(st in mark)) mark[st] = +s.seconds.toFixed(2);
prev = st;
}
if (st === 'burnt') break;
}
const foamWindow = mark.noisette && mark.foaming ? +(mark.noisette - mark.foaming).toFixed(2) : null;
return { fuel, heat, toFoaming: mark.foaming ?? null, toNoisette: mark.noisette ?? null, toBurnt: mark.burnt ?? null, foamWindow };
},
/** M17: cook a mushroom, flip at `flipAt`s. A well-timed flip → both sides
* within 0.1; never flipping → one side burnt, one raw. */
panFlip(fuel = 'gas', flipAt = 9, total = 18, heat = 7) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
// let the butter come up to foaming before the mushroom goes in
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let sec = 0; sec < total; sec++) {
if (sec === flipAt) flip(s);
for (let i = 0; i < 60; i++) panStep(s, 1 / 60);
}
const r = panResult(s);
const q = (n: number) => +n.toFixed(3);
return { sideGap: q(r.sideGap), doneScore: q(r.doneScore), butter: r.butter, bitterness: q(r.bitterness) };
},
/** M17: never flip — one side should burn (>0.9), the other stay raw. */
panNoFlip(fuel = 'gas', total = 20, heat = 7) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * total; i++) panStep(s, 1 / 60);
const r = panResult(s);
return { downMean: +r.downMean.toFixed(3), upMean: +r.upMean.toFixed(3) };
},
/** M17: baste the up side during noisette — raises its mean, flags the bonus.
* Baste with burnt butter instead → bitterness written in. */
panBaste(fuel = 'gas', heat = 7) {
// noisette baste
const s = newPanSession(fuel);
setPanKnob(s, heat); addButter(s);
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * 8; i++) panStep(s, 1 / 60); // butter into noisette
const upBefore = panResult(s).upMean;
const b1 = baste(s, 4);
const upAfter = panResult(s).upMean;
// burnt baste (separate pan, run butter to burnt)
const s2 = newPanSession(fuel);
setPanKnob(s2, 9); addButter(s2);
for (let i = 0; i < 60 * 20; i++) panStep(s2, 1 / 60); // burn the butter
addFood(s2, 'button_mushroom', 'A');
const b2 = baste(s2, 4);
return {
noisette: { state: b1.state, bonus: b1.bonus, upBefore: +upBefore.toFixed(3), upAfter: +upAfter.toFixed(3) },
burnt: { state: b2.state, bitterness: +panResult(s2).bitterness.toFixed(3) },
};
},
/** Play the whole day-16 pan order: route in, butter → foam → food →
* flip → serve, and read the scorecard. A clean cook should grade well. */
panDay() {
game.setDay(16);
run(5);
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession; result(): unknown };
const s = pv.session;
if (!s) return { error: 'not at pan' };
// Only run() advances time (the scene's update() steps the pan). Set state
// through the sim, but never step it directly here.
setPanKnob(s, 7);
addButter(s);
let guard = 0;
while (butterState(s) !== 'foaming' && guard++ < 60 * 30) run(1); // wait for the foam
addFood(s, 'button_mushroom', 'A');
// Flip a touch before the halfway mark: a gas flame gives the first side a
// flare lead, so a good cook turns it early (induction wouldn't need to).
for (let i = 0; i < 60 * 8; i++) run(1); // sear side one
flip(s);
for (let i = 0; i < 60 * 9; i++) run(1); // sear side two
tap('Enter');
run(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, groups, rows };
},
/** M-H4: butter reaches noisette on gas ~faster than electric at the same
* knob; induction never flares (fat has zero effect on output). */
panFuel(heat = 7) {
const noisetteAt = (fuel: string) => {
const s = newPanSession(fuel);
setPanKnob(s, heat); addButter(s);
for (let i = 0; i < 60 * 60; i++) { panStep(s, 1 / 60); if (butterState(s) === 'noisette') return +s.seconds.toFixed(2); }
return null;
};
// induction flare check: cook a fatty piece, watch output — fat must not spike it
const ind = newPanSession('induction');
setPanKnob(ind, 8); ind.env.vesselFerrous = true; addButter(ind);
for (let i = 0; i < 60 * 3; i++) panStep(ind, 1 / 60);
addFood(ind, 'button_mushroom', 'A');
let maxOut = 0;
for (let i = 0; i < 60 * 15; i++) { panStep(ind, 1 / 60); maxOut = Math.max(maxOut, ind.src.output); }
return { noisetteGas: noisetteAt('gas'), noisetteElectric: noisetteAt('electric'), inductionMaxOut: +maxOut.toFixed(2), inductionKnob: 8 };
},
// ---- M-H6: charcoal is a Field, not a dial. Pure-sim probes for the bed
// gradient, the monotonic decay, and one-bed-two-outcomes. ----
/** Bank a two-zone bed (hot left, cool right); return the bed's heat stdev.
* A real gradient reads > 0.25 (bar). */
zoneStdev() {
const s = newGrillSession();
// Pile coals hot under the left third; leave the right cool.
bankCoals(s, 0.25, 0.5, 0.22, 1.6);
bankCoals(s, 0.25, 0.5, 0.14, 1.0);
const d = s.zone.data;
let sum = 0;
let max = 0;
for (let i = 0; i < d.length; i++) {
sum += d[i];
if (d[i] > max) max = d[i];
}
const mean = sum / d.length;
let acc = 0;
for (let i = 0; i < d.length; i++) acc += (d[i] - mean) ** 2;
return { stdev: +Math.sqrt(acc / d.length).toFixed(3), max: +max.toFixed(2), mean: +mean.toFixed(3) };
},
/** The bed only cools: sample the ceiling over a 90s service, assert monotone.
* Empty grate — food's own flares are a separate, local spike (tested below). */
zoneDecay() {
const s = newGrillSession([]);
bankCoals(s, 0.5, 0.5, 0.3, 1.7);
const samples: number[] = [];
let monotone = true;
let prev = bedCeiling(s);
for (let sec = 0; sec < 90; sec++) {
for (let i = 0; i < 60; i++) grillStep(s, 1 / 60);
const c = bedCeiling(s);
if (c > prev + 1e-6) monotone = false;
samples.push(+c.toFixed(2));
prev = c;
}
return { monotone, start: samples[0], at30: samples[29], at90: samples[89] };
},
/** One bed, two outcomes: sear a piece over the hot zone, hold one on the lee. */
grillTwoZones(secs = 20) {
const s = newGrillSession(['sear_me', 'hold_me']);
bankCoals(s, 0.25, 0.5, 0.2, 1.7); // hot left
placeFood(s, 0, 0.25, 0.5); // over the coals
placeFood(s, 1, 0.8, 0.5); // on the cool lee
for (let i = 0; i < Math.round(secs * 60); i++) grillStep(s, 1 / 60);
const r = (n: number) => +n.toFixed(3);
return {
seared: { sear: r(s.foods[0].sear), flareChar: r(s.foods[0].flareChar) },
held: { sear: r(s.foods[1].sear), flareChar: r(s.foods[1].flareChar) },
result: { mean: r(grillResult(s).mean), stdev: r(grillResult(s).stdev), flares: s.flares },
};
},
/** The flare + dodge: leave a fatty piece over the coals (chars) vs move it
* to the lee partway (survives) — the dodge must be a real defense. */
grillFlareDodge(secs = 30, dodgeAt = 12) {
const stay = newGrillSession(['left']);
bankCoals(stay, 0.3, 0.5, 0.2, 1.7);
placeFood(stay, 0, 0.3, 0.5);
for (let i = 0; i < Math.round(secs * 60); i++) grillStep(stay, 1 / 60);
const dodge = newGrillSession(['left']);
bankCoals(dodge, 0.3, 0.5, 0.2, 1.7);
placeFood(dodge, 0, 0.3, 0.5);
for (let i = 0; i < Math.round(secs * 60); i++) {
if (i === Math.round(dodgeAt * 60)) placeFood(dodge, 0, 0.8, 0.5); // to the lee
grillStep(dodge, 1 / 60);
}
const r = (n: number) => +n.toFixed(3);
return {
stayed: { done: r(stay.foods[0].sear + stay.foods[0].flareChar), flares: stay.flares },
dodged: { done: r(dodge.foods[0].sear + dodge.foods[0].flareChar), flares: dodge.flares },
};
},
/** M-H5 (noise half): a SKU's hot-spot noise = the stdev of its heat map.
* A cheap thin electric hob is blotchy (>0.12); induction is glassy-flat
* (<0.04). The "both still reach 10/10" half awaits the M17 pan. */
hobNoise(sku = 'cheap_electric') {
const hs = newHeatSource(sku);
const n = 64;
const heat = new Float32Array(n * n);
applyToHeatMap(heat, n, hs, new Rng(42));
let sum = 0;
for (let i = 0; i < heat.length; i++) sum += heat[i];
const mean = sum / heat.length;
let acc = 0;
for (let i = 0; i < heat.length; i++) acc += (heat[i] - mean) ** 2;
return { sku, stdev: +Math.sqrt(acc / heat.length).toFixed(4) };
},
/** M-H3: pour smoke into an indoor env; the ZAP must fire exactly once. */
zapTest() {
const env = newIndoorEnv();
let fires = 0;
for (let i = 0; i < 40; i++) if (addSmoke(env, 0.05)) fires++;
// outdoor never zaps
const out = newOutdoorEnv(0.5);
let outFires = 0;
for (let i = 0; i < 40; i++) if (addSmoke(out, 0.05)) outFires++;
return { indoorFires: fires, outdoorFires: outFires, finalSmoke: +env.smoke.toFixed(2) };
},
/** Play the whole day-15 grill order: route in, bank a two-zone bed, sear
* each piece then move it to the lee, serve, and read the scorecard. */
grillDay() {
game.setDay(15);
run(5);
const g = game.grillView as unknown as { session: import('./sim/charcoal').GrillSession; result(): unknown };
const s = g.session;
if (!s) return { error: 'not at grill', view: 'other' };
// Bank hot on the left, cool on the right.
for (let k = 0; k < 45; k++) s.zone.brush(0.3, 0.5, 0.24, (i, w) => { s.zone.data[i] = Math.min(1.8, s.zone.data[i] + 0.12 * w); });
// Sear each piece over the coals, staggered, then shuffle it to the lee.
const lanes = [0.4, 0.5, 0.6];
for (let i = 0; i < s.foods.length; i++) { s.foods[i].u = 0.3; s.foods[i].v = lanes[i] ?? 0.5; }
for (let step = 0; step < 30; step++) {
// pull each piece to the lee the moment it hits a good sear — before its
// fat renders and flares (the whole technique).
for (const f of s.foods) if (f.sear >= 0.56 && f.u < 0.6) f.u = 0.82;
run(30); // half a second — tighter cadence so we catch the window
}
tap('Enter');
run(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, groups, rows };
},
/** Drive the live grill scene: bank a hot-left/cool-right bed, sear a piece
* over the coals then shuffle it to the lee, hold another on the cool side.
* For a screenshot + an end-to-end check of the real scene path. */
grillDemo() {
const view = game.grillView as unknown as {
reset(ids: string[], ask?: string): void;
result(): { mean: number; stdev: number; searedFrac: number; charFrac: number; flares: number };
};
const sess = () => (game.grillView as unknown as { session: import('./sim/charcoal').GrillSession }).session;
view.reset(['steak', 'snag']);
app.setView(game.grillView);
run(3);
const s = sess();
// Bank the coals hot on the left, cool on the right.
for (let i = 0; i < 30; i++) {
s.zone.brush(0.28, 0.5, 0.2, (idx: number, w: number) => {
s.zone.data[idx] = Math.min(1.8, s.zone.data[idx] + 0.12 * w);
});
}
// Place the steak over the coals, the snag on the lee.
s.foods[0].u = 0.28; s.foods[0].v = 0.5;
s.foods[1].u = 0.8; s.foods[1].v = 0.5;
run(60 * 14); // sear
s.foods[0].u = 0.8; // shuffle the steak to the lee before it flares out
run(60 * 4);
const res = view.result();
const r = (n: number) => +n.toFixed(3);
return { mean: r(res.mean), stdev: r(res.stdev), searedFrac: r(res.searedFrac), charFrac: r(res.charFrac), flares: res.flares };
},
/** Put the ACTUAL oven scene on screen wearing a fuel, and roast it — for a
* live screenshot of the gas-oven vs fan-oven HUD. */
ovenDemo(fuel = 'oven_gas', seconds = 24, power = 8) {
const oven = game.ovenView as unknown as {
reset(h: number, p: number, ask: string, fuel?: string): void;
result(): { mean: number; stdev: number; blisterFrac: number; collapseFrac: number };
};
oven.reset(4, power, `roast on the ${fuel === 'oven_gas' ? 'gas' : 'fan'} oven`, fuel);
app.setView(game.ovenView);
run(3);
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(3);
const res = oven.result();
const r = (n: number) => +n.toFixed(4);
return { fuel, mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac) };
},
/** Golden test: roast with NO source. Must match the legacy oven numbers exactly. */
ovenGolden(seconds = 32, power = 6) {
const s = newRoastSession(4, 20260718, power);
for (let i = 0; i < Math.round(seconds * 60); i++) roastStep(s, 1 / 60);
const res = roastResult(s);
const r = (n: number) => +n.toFixed(4);
return { mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac), collapse: r(res.collapseFrac) };
},
/**
* Rub garlic across the toast at the assembly bench: G-mode, then a raster
* drag. `passes` rows of strokes; 3 lands ~0.35 coverage — a light, even
* coat, full marks.
*/
rub(passes = 3) {
const a = game.assemblyView;
run(2);
tap('KeyG');
for (let p = 0; p < passes; p++) {
const v = 0.2 + p * 0.18;
const cols = 12;
for (let i = 0; i <= cols; i++) {
const t = i / cols;
const u = 0.15 + (p % 2 ? 1 - t : t) * 0.7;
const [x, y, z] = a.worldAt(u, v);
point(x, y, z, true);
run(1);
}
}
app.input.down = false;
run(1);
return { rubCoverage: +a.result().rubCoverage.toFixed(3) };
},
/**
* Drop a set of toppings on a shared even grid — kinds interleaved onto one
* grid so the point set stays evenly strewn (ClarkEvans R ~1.25). The digit
* keys pick the tool; each press-edge drops one piece.
*/
assemble(counts: { tomato?: number; cheese?: number; basil?: number } = { tomato: 5, cheese: 4, basil: 3 }) {
const a = game.assemblyView;
const kinds: [string, string][] = [];
const push = (n: number, key: string) => {
for (let i = 0; i < n; i++) kinds.push([key, key]);
};
push(counts.tomato ?? 0, 'Digit1');
push(counts.cheese ?? 0, 'Digit2');
push(counts.basil ?? 0, 'Digit3');
const n = kinds.length;
const cols = Math.max(1, Math.ceil(Math.sqrt(n * 1.3)));
const rows = Math.max(1, Math.ceil(n / cols));
let lastKey = '';
run(2);
for (let i = 0; i < n; i++) {
const key = kinds[i][1];
if (key !== lastKey) {
tap(key);
lastKey = key;
run(1);
}
const c = i % cols;
const rw = Math.floor(i / cols);
const u = 0.14 + ((c + 0.5) / cols) * 0.72;
const v = 0.14 + ((rw + 0.5) / rows) * 0.72;
const [x, y, z] = a.worldAt(u, v);
point(x, y, z, false);
run(1);
point(x, y, z, true); // press edge → one placement
run(1);
}
app.input.down = false;
run(1);
const res = a.result();
return { count: res.count, mass: +res.massPerArea.toFixed(2), rub: +res.rubCoverage.toFixed(3), kinds: res.kinds };
},
/** Let the sog clock run `seconds` at the assembly bench, then read it. */
sogWait(seconds = 8) {
run(Math.round(seconds * 60));
const sog = game.assemblyView.result().sog;
return { sog: +sog.toFixed(3), word: sogWord(sog) };
},
/** Read the last bruschetta verdict — the full stats, grouped by station. */
bruschettaStats() {
const lb = game.lastBruschettaStats;
if (!lb) return null;
const { result, verdict } = lb;
const crit = (k: string) => {
const c = verdict.criteria.find((x) => x.key === k);
return c ? +c.score.toFixed(3) : null;
};
const r = (n: number) => +n.toFixed(3);
return {
grade: verdict.grade,
total: +verdict.total.toFixed(2),
roast: { mean: r(result.roast.mean), blister: r(result.roast.blisterFrac), collapse: r(result.roast.collapseFrac), score: crit('roast') },
distribution: crit('topdist'),
balance: { mass: r(result.assembly.massPerArea), score: crit('balance') },
sog: { value: r(result.assembly.sog), word: sogWord(result.assembly.sog), score: crit('sog') },
rub: { coverage: r(result.assembly.rubCoverage), score: crit('rub') },
temp: { score: r(result.temp.score), worst: result.temp.worstName, criterion: crit('temp') },
bench: crit('bench'),
criteria: verdict.criteria.map((c) => ({ key: c.key, group: c.group ?? '-', score: +c.score.toFixed(2) })),
};
},
/**
* The whole bruschetta, end to end and headless: day 14, plan the brie out,
* cut the doorstop, toast it, roast the tomatoes, rub + top + wait, serve.
* Returns the full grouped stats.
*/
bruschetta() {
game.setDay(14);
run(5);
game.toggleTemp('brie'); // take the brie out early — it must be soft
harness.pick('bread_knife'); // knife trip → slicer
harness.slice({ thickness: 0.31, dy: 0.05 }); // cut the doorstop → kitchen
harness.toast(12, 6); // toast → lands → enterOven
harness.roast(32, 6); // roast the tomatoes
tap('Enter'); // oven done → assembly
run(3);
harness.rub(3);
harness.assemble({ tomato: 5, cheese: 4, basil: 3 });
harness.sogWait(8);
tap('Enter'); // serve → the judge
run(5);
return harness.bruschettaStats();
},
};
(window as unknown as { __t: unknown }).__t = harness;
}