toastsim/src/dev.ts
type-two bffee40c6f THE POACH: day 21 — the shiver, the vortex, the drop, the wobble test
The near-game, treated with respect (masterplan P3). sim/poach.ts (pure,
rides the heat-source bone): the pot chases the knob on the fuel's lag
and the poach wants the SHIVER (5.8-7.4) — cooler rags the white on the
way in, a rolling boil (>8.2) tears it the whole time. The VORTEX is the
reamer-twist at pot scale: swirl builds it, it DECAYS (swirl then drop,
not swirl then admire), and the strength at the drop is the judged form.
Two-layer set: white first, the yolk only firms once the white is mostly
there — lift in the window (white >=0.85, yolk <=0.35) or you've boiled
an egg the long way. Then DRAIN on the slotted spoon. Stirring a dropped
egg is vandalism (rags it directly).

scenes/poach.ts: WHEEL flame, circular-drag swirl (real angular sweep),
SPACE drop, L lift, hold-to-drain, ENTER serves; vortex streaks that spin
and fade, shiver-vs-boil bubbles, the white gathering tighter as it sets,
a hard yolk going pale. judgePoach: THE POACH (White/Wobble/Form) + The
Drain; the wobble line is the dossier's: 'It should tremble. Like you,
right now.' Day 21 authored + poach in the endless rotation (electric
pot past day 27).

Verified with REAL input: shiver + 91% vortex + lift-at-tremble + drain
= 9.9/10 'it TREMBLES — perfect', 'one neat comet (vortex 91%)'; still
water into a rolling boil, overstayed, undrained = 3.6/10, 'a pot of
rags', 'it does not move at all'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:34:50 +10:00

1333 lines
55 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';
import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain';
import { newSteakSession, restStep, cut as steakCut, finishSteak, steakResult, type Doneness } from './sim/steak';
import { newEggSession, floatTest, crack, separatePass, fishShell, dumpBowl } from './sim/eggs';
/**
* 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) };
},
/** The cross-day arc: stock the fridge on day 17 (well or badly), then cook
* those very mushrooms on day 18. Bad storage → an off mushroom caps the sear. */
coldChainArc(goodStorage = true) {
harness.fridgeDay(goodStorage); // day 17: stock + 3 days + serve
run(5);
// Advance to day 18 (the judge's NEXT button bumps the day + beginDay).
const g = game as unknown as { save: { day: number; fridge?: unknown[] } };
const storedCount = g.save.fridge?.length ?? 0;
game.setDay(18);
run(6);
const atPan = app.currentView === game.panView ? 'pan' : 'other';
const fetched = (game as unknown as { panIngredient: { quality: number; word: string } | null }).panIngredient;
// Cook it well and serve — only the fetched ingredient's quality varies.
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession };
const s = pv.session;
if (s) {
setPanKnob(s, 7); addButter(s);
let guard = 0; while (butterState(s) !== 'foaming' && guard++ < 60 * 30) run(1);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * 8; i++) run(1);
flip(s);
for (let i = 0; i < 60 * 9; i++) run(1);
tap('Enter'); run(5);
}
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { storedCount, atPan, fetched, grade: gradeEl ? gradeEl.textContent : null, rows };
},
/** Play the whole day-17 fridge order: route in, put the delivery away
* (well or badly), shut the door (3 days), serve, read the scorecard. */
fridgeDay(good = true) {
game.setDay(17);
run(5);
const fv = game.fridgeView as unknown as {
items: { it: import('./sim/coldchain').StoredItem; mesh: unknown }[];
storeState: import('./sim/coldchain').ColdStore;
};
const s = fv.storeState;
if (!s) return { error: 'not at fridge' };
// Place every delivery item: good = raw low/cold, perishables cold, RTE high
// and safe; bad = everything crammed in the warm door with raw on top.
const move = (it: import('./sim/coldchain').StoredItem, zone: import('./sim/coldchain').ZoneId, shelf: number) => {
const from = s.zones[it.zone];
const i = from.indexOf(it);
if (i >= 0) from.splice(i, 1);
it.zone = zone; it.shelf = shelf;
s.zones[zone].push(it);
};
for (const { it } of fv.items) {
if (good) {
if (it.raw) move(it, 'back', 0); // raw meat low + coldest
else if (it.readyToEat) move(it, 'fridge', 2); // ready-to-eat up high, safe
else move(it, 'fridge', 1); // dairy/veg mid-cold
} else {
move(it, it.raw ? 'fridge' : 'door', it.raw ? 2 : 1); // raw HIGH, everything warm
}
}
tap('Enter'); // shut the door — 3 days pass
run(10);
tap('Enter'); // the inspector opens it → serve
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 };
},
/** Play the day-20 egg order with REAL input. good=true floats everything
* first and only cracks the safe ones; false cracks blind (the beat). */
eggDay(good = true) {
game.setDay(20);
run(6);
const ev = game.eggBenchView as unknown as { session: import('./sim/eggs').EggSession };
const s = ev.session;
if (!s) return { error: 'not at eggs' };
const GLASS = { x: 2.1, z: -0.4 };
const BOWL = { x: 0, z: 0.55 };
const slot = (i: number) => ({ x: -2.25 + (i % 3) * 0.55, z: -1.2 + Math.floor(i / 3) * 0.55 });
const drag = (from: { x: number; z: number }, to: { x: number; z: number }) => {
point(from.x, 0.4, from.z, false); run(2);
point(from.x, 0.4, from.z, true); run(2);
point((from.x + to.x) / 2, 0.4, (from.z + to.z) / 2, true); run(2);
point(to.x, 0.4, to.z, true); run(2);
point(to.x, 0.4, to.z, false); run(2);
};
const holdCrack = (frames: number) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' }));
run(frames);
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }));
run(3);
};
if (good) {
for (let i = 0; i < 6; i++) drag(slot(i), GLASS); // float the lot
const safe = s.eggs.filter((e) => e.floatWord !== 'FLOATS').map((e) => e.index);
for (const i of safe.slice(0, s.need)) {
drag(slot(i), BOWL);
holdCrack(30); // ~0.55 — the clean window
// separate with patient alternation (~0.9s gaps)
for (let p = 0; p < 6 && s.eggs[i].state === 'cracked' && !s.eggs[i].yolkBroken; p++) {
run(54);
tap(p % 2 === 0 ? 'ArrowRight' : 'ArrowLeft');
}
}
} else {
// Blind: crack the first ROTTEN egg straight into the bowl. The beat.
const rotten = s.eggs.find((e) => e.freshness < 0.25) ?? s.eggs[0];
drag(slot(rotten.index), BOWL);
holdCrack(30);
tap('KeyD'); // dump the crime
run(4);
}
tap('Enter');
run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows };
},
/** Play the day-21 poach with REAL input. good=true: shiver + vortex + drop
* + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */
poachDay(good = true) {
game.setDay(21);
run(6);
const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session;
if (!s) return { error: 'not at poach' };
const swirlCircles = (turns: number) => {
// real circular drags over the pot
for (let k = 0; k < turns * 24; k++) {
const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true);
run(1);
}
point(1.6, 0.7, 1.6, false);
run(2);
};
if (good) {
s.src.target = 7; // the shiver band
let g = 0;
while (s.src.output < 6 && g++ < 1200) run(1);
swirlCircles(3);
tap('Space'); // the drop, into the spin
g = 0;
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1);
tap('KeyL');
for (let i = 0; i < 60 * 4; i++) run(1); // drain
} else {
s.src.target = 9; // straight to a rolling boil
let g = 0;
while (s.src.output < 8.5 && g++ < 1200) run(1);
tap('Space'); // no vortex, into the boil
for (let i = 0; i < 60 * 30; i++) run(1); // overstay — yolk hardens, rags tear
tap('KeyL');
// no drain
}
tap('Enter');
run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const q = (n: number) => +n.toFixed(3);
return { white: q(s.whiteSet), yolk: q(s.yolkSet), rags: q(s.rags), vortexAtDrop: q(s.vortexAtDrop), rows };
},
// ---- THE EGGS: the crack window, the float test, the rotten beat. ----
/** The crack window: soft tap does nothing, the window opens clean, past it
* shell goes in the bowl. Run on a guaranteed-fresh egg. */
eggCrack() {
const mk = () => {
const s = newEggSession(3, 7, 0);
s.eggs[0].freshness = 0.9;
return s;
};
const soft = crack(mk(), 0, 0.2);
const clean = crack(mk(), 0, 0.55);
const hard = crack(mk(), 0, 0.95);
return { soft: soft.result, clean: clean.result, hard: hard.result, hardBits: hard.shellBits };
},
/** Float words track freshness; a rotten egg FLOATS — the free lie detector. */
eggFloat() {
const s = newEggSession(3, 7, 0);
s.eggs[0].freshness = 0.9;
s.eggs[1].freshness = 0.4;
s.eggs[2].freshness = 0.1;
return { fresh: floatTest(s, 0), old: floatTest(s, 1), rotten: floatTest(s, 2) };
},
/** The beat: crack an untested rotten egg → the bowl is ruined; dump it and
* the sin clears (at a time cost). */
eggRotten() {
const s = newEggSession(3, 7, 0);
s.eggs[0].freshness = 0.1;
const out = crack(s, 0, 0.55);
const ruined = s.bowlRuined;
dumpBowl(s);
return { result: out.result, ruined, afterDump: s.bowlRuined, dumpCostS: 6 };
},
/** Separation: slow passes always survive; a fast pass breaks the yolk, and
* an OLD egg's yolk breaks at speeds a fresh one shrugs off. */
eggSeparate() {
const run = (freshness: number, speed: number) => {
const s = newEggSession(3, 7, 0);
s.eggs[0].freshness = freshness;
crack(s, 0, 0.55);
for (let p = 0; p < 3; p++) {
const r = separatePass(s, 0, speed);
if (r.broke) return 'broke';
if (r.done) return 'separated';
}
return 'incomplete';
};
return {
freshSlow: run(0.9, 0.3),
freshBrisk: run(0.9, 0.7),
oldSlow: run(0.45, 0.3),
oldBrisk: run(0.45, 0.7),
};
},
/** Shrapnel: fish the bits back out and the bowl forgives you. */
eggFish() {
const s = newEggSession(3, 7, 0);
s.eggs[0].freshness = 0.9;
const c = crack(s, 0, 0.95);
const before = s.bowlShell;
while (fishShell(s)) {
/* fish every bit */
}
return { bits: c.shellBits, before, after: s.bowlShell, fished: s.fished };
},
// ---- THE RIB EYE: rest clock (the flood) + the grain-aware cut. ----
/** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain
* (0 = along, PI/2 = across). Cut hot (restS 0) and it FLOODS; rest and it
* whispers. Returns the flood + cut scores. */
steak(opts: { restS?: number; n?: number; angle?: number; wobble?: number; grain?: number; cook?: number; target?: Doneness } = {}) {
const { restS = 20, n = 6, angle = Math.PI / 2, wobble = 0, grain = 0, cook = 0.55, target = 'medium' } = opts;
const s = newSteakSession(cook, target, grain);
for (let i = 0; i < Math.round(restS * 60); i++) restStep(s, 1 / 60);
const moistureAtCut = s.moisture;
for (let i = 0; i < n; i++) steakCut(s, angle, wobble);
finishSteak(s);
const r = steakResult(s);
const q = (v: number) => +v.toFixed(3);
return { moistureAtCut: q(moistureAtCut), flood: q(r.flood), rest: q(r.restScore), cut: q(r.cutScore), across: q(r.meanAcross), tears: r.tears, cook: q(r.cookScore) };
},
/** Play the whole day-19 rib-eye order: sear on the pan, then rest (or don't)
* and cut across (or along) the grain on the board, serve. */
steakDay(opts: { rest?: boolean; alongGrain?: boolean } = {}) {
const { rest = true, alongGrain = false } = opts;
game.setDay(19);
run(6);
// Sear both faces on the pan (reuse the verified pan cook).
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession };
const ps = pv.session;
if (ps) {
setPanKnob(ps, 7); addButter(ps);
let guard = 0; while (butterState(ps) !== 'foaming' && guard++ < 1800) run(1);
addFood(ps, 'rib_eye', 'A');
for (let i = 0; i < 60 * 8; i++) run(1);
flip(ps);
for (let i = 0; i < 60 * 8; i++) run(1);
tap('Enter'); run(6); // pan done → resting board
}
const sb = game.steakBoardView as unknown as { session: import('./sim/steak').SteakSession };
const ss = sb.session;
if (!ss) return { error: 'not at board', view: app.currentView === game.steakBoardView ? 'board' : 'other' };
// Rest (or cut hot), then cut 6 slices across (or along) the grain.
if (rest) for (let i = 0; i < 60 * 20; i++) run(1);
const angle = alongGrain ? ss.grainAxis : ss.grainAxis + Math.PI / 2;
for (let i = 0; i < 6; i++) steakCut(ss, angle, 0);
tap('Enter'); run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, rows };
},
/** The marquee, side by side: cut it straight off the heat vs after a rest;
* and across the grain vs along it. */
steakMarquee() {
return {
cutHot: harness.steak({ restS: 0, angle: Math.PI / 2 }),
rested: harness.steak({ restS: 20, angle: Math.PI / 2 }),
acrossGrain: harness.steak({ restS: 20, angle: Math.PI / 2, grain: 0 }),
alongGrain: harness.steak({ restS: 20, angle: 0, grain: 0 }),
wobbled: harness.steak({ restS: 20, angle: Math.PI / 2, wobble: 0.6 }),
};
},
// ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ----
/** Same fish stored in each zone for `days` — freshness must fall fast on the
* bench, slow in the fridge, barely at all in the freezer. */
coldZones(days = 3) {
const zones = ['bench', 'door', 'fridge', 'back', 'freezer'] as const;
const out: Record<string, { fresh: number; word: string }> = {};
for (const z of zones) {
const s = newColdStore();
const fish = store(s, { id: 'fish', perish: 2, raw: true }, z);
coldStep(s, days);
out[z] = { fresh: +fish.freshness.toFixed(3), word: freshnessWord(fish) };
}
return out;
},
/** Rotation over a week: each day age the stock, use one tub, restock fresh.
* FIFO (use the oldest) wastes nothing; always-newest lets old stock rot. */
coldRotation(daysRun = 7) {
const run = (fifo: boolean) => {
const s = newColdStore();
for (let k = 0; k < 3; k++) {
const it = store(s, { id: 'mince', perish: 1, raw: true }, 'fridge');
it.age = 2 - k; // one is already 2 days old (closest to expiry)
it.freshness = 1 - (2 - k) * 0.11; // and correspondingly less fresh
}
for (let d = 0; d < daysRun; d++) {
coldStep(s, 1); // a day passes
pull(s, 'fridge', fifo); // use one tub
store(s, { id: 'mince', perish: 1, raw: true }, 'fridge'); // restock fresh
}
return storeHealth(s).waste;
};
return { fifoWaste: run(true), newestFirstWaste: run(false) };
},
/** Overpack: cram the fridge and airflow chokes — the same milk spoils faster
* than in a sparse fridge. */
coldOverpack(days = 6) {
const sparse = newColdStore();
const a = store(sparse, { id: 'milk', perish: 1 }, 'fridge');
const packed = newColdStore();
const b = store(packed, { id: 'milk', perish: 1 }, 'fridge');
for (let k = 0; k < 10; k++) store(packed, { id: 'filler', perish: 0.2 }, 'fridge');
coldStep(sparse, days);
coldStep(packed, days);
return { sparse: +a.freshness.toFixed(3), packed: +b.freshness.toFixed(3) };
},
/** Contamination: raw chicken on a shelf ABOVE the salad drips onto it; beside
* it (same shelf) it doesn't. */
coldContam(days = 1) {
const bad = newColdStore();
store(bad, { id: 'chicken', perish: 2, raw: true, shelf: 2 }, 'fridge');
const saladBad = store(bad, { id: 'salad', perish: 1, readyToEat: true, shelf: 0 }, 'fridge');
const good = newColdStore();
store(good, { id: 'chicken', perish: 2, raw: true, shelf: 0 }, 'fridge');
const saladGood = store(good, { id: 'salad', perish: 1, readyToEat: true, shelf: 0 }, 'fridge');
coldStep(bad, days);
coldStep(good, days);
return {
rawAbove: { contaminated: saladBad.contaminated, quality: +usableQuality(saladBad).quality.toFixed(3), safe: usableQuality(saladBad).safe },
sameShelf: { contaminated: saladGood.contaminated, quality: +usableQuality(saladGood).quality.toFixed(3), safe: usableQuality(saladGood).safe },
};
},
/** The freezer trade-off: it holds freshness a bench-item would lose, but the
* thaw costs texture (usable quality docked below the raw freshness). */
coldFreezer(days = 5) {
const s = newColdStore();
const frozen = store(s, { id: 'steak', perish: 1.5 }, 'freezer');
const bench = store(s, { id: 'steak', perish: 1.5 }, 'bench');
coldStep(s, days);
return {
frozen: { fresh: +frozen.freshness.toFixed(3), usable: +usableQuality(frozen).quality.toFixed(3), word: freshnessWord(frozen) },
bench: { fresh: +bench.freshness.toFixed(3), word: freshnessWord(bench) },
};
},
// ---- 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;
}