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>
959 lines
38 KiB
TypeScript
959 lines
38 KiB
TypeScript
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 (Clark–Evans 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;
|
||
}
|