diff --git a/src/dev.ts b/src/dev.ts index 07fe2a0..f82f9f7 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -7,6 +7,7 @@ import { type Fuel, newHeatSource, setKnob, heatStep, envStep, newOutdoorEnv, ad 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 @@ -515,6 +516,103 @@ export function installDevHarness(app: App, game: Game): void { 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) }, + }; + }, + + /** 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. ---- diff --git a/src/sim/pan.ts b/src/sim/pan.ts new file mode 100644 index 0000000..d2391ee --- /dev/null +++ b/src/sim/pan.ts @@ -0,0 +1,272 @@ +import { Field } from '../core/field'; +import { Rng } from '../core/rng'; +import { INGREDIENTS, type IngredientId } from './ingredients'; +import { + type HeatSource, + type Environment, + newHeatSource, + newIndoorEnv, + setKnob, + heatStep, + applyToHeatMap, + addSmoke, +} from './heatsource'; + +/** + * THE PAN — the toaster generalized to a continuous, modulated heat source with + * two things the toaster never had: BUTTER, and a second side. + * + * The pan reads a HeatSource: the knob commands a target and the pan temp + * (`src.output`) chases it on the fuel's lag. Gas is instant, an electric coil + * drags AND remembers, induction is exact. You don't cook the knob — you cook + * the lag, and then you cook the BUTTER: + * + * cold → melting → FOAMING → noisette → burnt + * + * Foaming is the moment to land your food; noisette is the nutty peak (a flavour + * bonus); burnt is bitterness + smoke. The food has two sides, each its own sear + * Field — the side on the pan browns fast, the up side barely, until you FLIP it + * or BASTE hot butter over it. Baste with burnt butter and you paint bitterness. + * + * On a flame fuel, rendered fat can flare (spike the output); induction never + * flares. Pure — Field + Rng — so the harness cooks headless and reads the + * judge's exact numbers. + */ + +export type ButterState = 'none' | 'cold' | 'melting' | 'foaming' | 'noisette' | 'burnt'; + +const N = 32; +/** Butter browning per second at full pan temp. */ +const BUTTER_RATE = 0.06; +/** Pan temp (0..10) below which butter just sits cold. */ +const MELT_TEMP = 1.5; +/** Down-side sear per second per unit of (heat × dryness × butter). */ +const SEAR_RATE = 0.085; +/** The up side gets this fraction of the pan's heat (radiant off the pan walls). */ +const UP_FRACTION = 0.15; + +export interface PanFood { + id: string; + mask: Field; + searA: Field; + searB: Field; + dryA: number; + dryB: number; + down: 'A' | 'B'; + /** From cooking IN or basting WITH burnt butter — the judge tastes it. */ + bitterness: number; + /** Rendered fat, feeds the flame's flare. */ + fat: number; + moisture: number; + /** The fuel-shaped heat map under the food: gas hot-spots, induction flat. */ + panHeat: Float32Array; +} + +export interface PanSession { + src: HeatSource; + env: Environment; + /** 0 = fresh butter .. 1 = burnt. Only meaningful once `hasButter`. */ + butter: number; + hasButter: boolean; + food: PanFood | null; + seconds: number; + /** Fired the frame the smoke alarm trips (burnt butter, indoors). */ + zapped: boolean; + rng: Rng; +} + +export function newPanSession(fuel: string = 'gas', env?: Environment): PanSession { + return { + src: newHeatSource(fuel), + env: env ?? newIndoorEnv(), + butter: 0, + hasButter: false, + food: null, + seconds: 0, + zapped: false, + rng: new Rng(20260719), + }; +} + +export function setPanKnob(s: PanSession, k: number): void { + setKnob(s.src, k); +} + +export function addButter(s: PanSession): void { + s.hasButter = true; + s.butter = 0; +} + +export function butterState(s: PanSession): ButterState { + if (!s.hasButter) return 'none'; + const b = s.butter; + if (b < 0.02) return s.src.output > MELT_TEMP ? 'melting' : 'cold'; + if (b < 0.2) return 'melting'; + if (b < 0.45) return 'foaming'; + if (b < 0.7) return 'noisette'; + return 'burnt'; +} + +function stampDisc(mask: Field): void { + const n = mask.n; + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const u = x / (n - 1); + const v = y / (n - 1); + if (Math.hypot(u - 0.5, v - 0.5) <= 0.42) mask.data[y * n + x] = 1; + } + } +} + +/** Lay a piece of food in the pan, `down` side against the heat. */ +export function addFood(s: PanSession, id: IngredientId | string, down: 'A' | 'B' = 'A'): void { + const mask = new Field(N); + stampDisc(mask); + const panHeat = new Float32Array(N * N); + applyToHeatMap(panHeat, N, s.src, new Rng(7), mask); + const ing = (INGREDIENTS as Record)[id]; + s.food = { + id, + mask, + searA: new Field(N), + searB: new Field(N), + dryA: 0, + dryB: 0, + down, + bitterness: 0, + fat: 0, + moisture: ing?.flesh?.moisture ?? 0.4, + panHeat, + }; +} + +/** Turn the piece over — the up side comes down onto the heat. */ +export function flip(s: PanSession): void { + if (s.food) s.food.down = s.food.down === 'A' ? 'B' : 'A'; +} + +/** + * Baste: spoon hot butter over the UP face. Raises its sear toward the down + * side, and carries the butter's character — noisette butter is a flavour bonus, + * burnt butter paints bitterness. Returns the state you basted with. + */ +export function baste(s: PanSession, strokes = 3): { state: ButterState; bonus: boolean } { + const st = butterState(s); + const f = s.food; + if (!f || !s.hasButter) return { state: st, bonus: false }; + const up = f.down === 'A' ? f.searB : f.searA; + const gain = st === 'burnt' ? 0.04 : st === 'noisette' || st === 'foaming' ? 0.09 : 0.05; + for (let stroke = 0; stroke < strokes; stroke++) { + for (let i = 0; i < up.data.length; i++) if (f.mask.data[i] >= 0.5) up.data[i] = Math.min(1.2, up.data[i] + gain); + } + if (st === 'burnt') f.bitterness = Math.min(1, f.bitterness + 0.12 * strokes); + return { state: st, bonus: st === 'noisette' }; +} + +export function panStep(s: PanSession, dt: number): void { + s.zapped = false; + const f = s.food; + const fatLoad = f ? f.fat : 0; + // The pan temp chases the knob on the fuel's lag; rendered fat can flare a flame. + heatStep(s.src, dt, s.env, fatLoad); + const temp = s.src.output; + const p = temp / 10; + + // Butter browns with pan temp — the hotter the pan, the shorter the window. + if (s.hasButter && temp > MELT_TEMP) { + s.butter = Math.min(1.2, s.butter + BUTTER_RATE * p * dt); + } + const st = butterState(s); + // Foaming/noisette butter conducts and flavours; burnt butter drags and taints. + const butterFactor = st === 'foaming' || st === 'noisette' ? 1.15 : st === 'burnt' ? 0.9 : st === 'melting' ? 1.0 : 0.85; + + if (f) { + const downSear = f.down === 'A' ? f.searA : f.searB; + const upSear = f.down === 'A' ? f.searB : f.searA; + // The down side steams its water off, then browns fast. + const dryCap = 0.5 + f.moisture * 2; + if (f.down === 'A') f.dryA = Math.min(1, f.dryA + (p * dt) / dryCap); + else f.dryB = Math.min(1, f.dryB + (p * dt) / dryCap); + const dryDown = f.down === 'A' ? f.dryA : f.dryB; + const md = f.mask.data; + const dd = downSear.data; + for (let i = 0; i < dd.length; i++) { + if (md[i] < 0.5) continue; + const h = f.panHeat[i] * p; + dd[i] = Math.min(1.2, dd[i] + SEAR_RATE * h * (0.3 + 0.7 * dryDown) * butterFactor * dt); + } + // The up side gets only the pan's radiant halo — slow, even. + const ud = upSear.data; + for (let i = 0; i < ud.length; i++) { + if (md[i] < 0.5) continue; + ud[i] = Math.min(1.2, ud[i] + SEAR_RATE * p * UP_FRACTION * dt); + } + // Fat renders off the browning down side and feeds the flare. + const dmean = downSear.stats(f.mask).mean; + if (dmean > 0.4) f.fat = Math.min(1, f.fat + 0.35 * dt); + else f.fat = Math.max(0, f.fat - 0.2 * dt); + // Cooking in burnt butter taints the food. + if (st === 'burnt') f.bitterness = Math.min(1, f.bitterness + 0.05 * dt); + } + + // Burnt butter smokes — indoors it trips the alarm once. + if (st === 'burnt' && addSmoke(s.env, 0.5 * dt)) s.zapped = true; + s.seconds += dt; +} + +export interface PanResult { + /** Per-side browning. */ + downMean: number; + upMean: number; + downStdev: number; + /** |downMean - upMean| — evenness across the two sides (the flip test). */ + sideGap: number; + /** Best-side / worst-side doneness in the good window (0.5..0.85). */ + doneScore: number; + butter: ButterState; + bitterness: number; + flares: number; +} + +/** A good sear per side lives here; past it is over, then burnt. */ +export const SEAR_LO = 0.5; +export const SEAR_HI = 0.85; +export const BURN_AT = 0.95; + +export function panResult(s: PanSession): PanResult { + const f = s.food; + if (!f) return { downMean: 0, upMean: 0, downStdev: 0, sideGap: 0, doneScore: 0, butter: butterState(s), bitterness: 0, flares: 0 }; + const a = f.searA.stats(f.mask); + const b = f.searB.stats(f.mask); + // A and B, not down/up — the judge sees the finished piece, both faces. + const inWindow = (m: number) => (m >= SEAR_LO && m < BURN_AT ? 1 - Math.abs(m - 0.68) / 0.4 : m >= BURN_AT ? 0 : m / SEAR_LO * 0.5); + const doneScore = Math.max(0, Math.min(1, (inWindow(a.mean) + inWindow(b.mean)) / 2)); + return { + downMean: (f.down === 'A' ? a : b).mean, + upMean: (f.down === 'A' ? b : a).mean, + downStdev: (f.down === 'A' ? a : b).stdev, + sideGap: Math.abs(a.mean - b.mean), + doneScore, + butter: butterState(s), + bitterness: f.bitterness, + flares: 0, + }; +} + +/** The live diegetic tell for the butter — never a meter. */ +export function butterWord(s: PanSession): string { + switch (butterState(s)) { + case 'none': + return 'dry pan'; + case 'cold': + return 'cold butter, sitting there'; + case 'melting': + return 'melting…'; + case 'foaming': + return 'FOAMING — in it goes'; + case 'noisette': + return 'nutty brown — noisette'; + case 'burnt': + return 'burnt. that is a smell you chose'; + } +}