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 = {}; 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; }