M17 + M-H4: the pan, born fuel-aware — butter, the flip, the baste

src/sim/pan.ts (pure) — the toaster generalized with two new things:
BUTTER as a state machine (cold->melting->foaming->noisette->burnt, keyed
on pan temp x time) and a second SIDE. The pan reads a HeatSource, so the
knob commands a target and the pan temp chases it on the fuel's lag; gas
foams fast, electric drags and remembers, induction is exact and never
flares. Per-side sear Fields (down side browns fast, up side radiant-slow
until you flip or baste); baste paints hot butter onto the up face (noisette
= bonus, burnt = bitterness); rendered fat flares a flame, never induction.

Verified headless (all M17 + M-H4 bars):
- butter foaming window shrinks with heat: 10.4s @4, 6.0s @7, 4.6s @9.
- well-timed flip -> both sides within 0.008 (bar <0.1); never flip ->
  one side 1.2 (burnt), other 0.23 (raw).
- baste in noisette flags the bonus, up-side 0.073->0.433; burnt-butter
  baste writes bitterness 0.48.
- M-H4: noisette on gas 11.0s vs electric 14.4s (~3.4s faster); induction
  output pinned at knob 8 with fat searing (never flares).

Harness: t.panButter/panFlip/panNoFlip/panBaste/panFuel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 20:30:34 +10:00
parent 94a5f2f293
commit c528ba49ab
2 changed files with 370 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import { type Fuel, newHeatSource, setKnob, heatStep, envStep, newOutdoorEnv, ad
import { newRoastSession, roastStep, roastResult } from './sim/roasting'; import { newRoastSession, roastStep, roastResult } from './sim/roasting';
import { Rng } from './core/rng'; import { Rng } from './core/rng';
import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal'; 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 * 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) }; return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) };
}, },
// ---- M17 + M-H4: the pan. Butter state timeline, the flip, the baste,
// and the fuel differences (gas foams faster, induction never flares). ----
/** Butter state timeline at a given heat: seconds to foaming / noisette /
* burnt, and the foaming-window duration (shorter at higher heat). */
panButter(fuel = 'gas', heat = 7, dt = 1 / 60) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
const mark: Record<string, number> = {};
let prev = butterState(s);
for (let i = 0; i < 60 * 60; i++) {
panStep(s, dt);
const st = butterState(s);
if (st !== prev) {
if (!(st in mark)) mark[st] = +s.seconds.toFixed(2);
prev = st;
}
if (st === 'burnt') break;
}
const foamWindow = mark.noisette && mark.foaming ? +(mark.noisette - mark.foaming).toFixed(2) : null;
return { fuel, heat, toFoaming: mark.foaming ?? null, toNoisette: mark.noisette ?? null, toBurnt: mark.burnt ?? null, foamWindow };
},
/** M17: cook a mushroom, flip at `flipAt`s. A well-timed flip both sides
* within 0.1; never flipping one side burnt, one raw. */
panFlip(fuel = 'gas', flipAt = 9, total = 18, heat = 7) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
// let the butter come up to foaming before the mushroom goes in
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let sec = 0; sec < total; sec++) {
if (sec === flipAt) flip(s);
for (let i = 0; i < 60; i++) panStep(s, 1 / 60);
}
const r = panResult(s);
const q = (n: number) => +n.toFixed(3);
return { sideGap: q(r.sideGap), doneScore: q(r.doneScore), butter: r.butter, bitterness: q(r.bitterness) };
},
/** M17: never flip — one side should burn (>0.9), the other stay raw. */
panNoFlip(fuel = 'gas', total = 20, heat = 7) {
const s = newPanSession(fuel);
setPanKnob(s, heat);
addButter(s);
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * total; i++) panStep(s, 1 / 60);
const r = panResult(s);
return { downMean: +r.downMean.toFixed(3), upMean: +r.upMean.toFixed(3) };
},
/** M17: baste the up side during noisette raises its mean, flags the bonus.
* Baste with burnt butter instead bitterness written in. */
panBaste(fuel = 'gas', heat = 7) {
// noisette baste
const s = newPanSession(fuel);
setPanKnob(s, heat); addButter(s);
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * 8; i++) panStep(s, 1 / 60); // butter into noisette
const upBefore = panResult(s).upMean;
const b1 = baste(s, 4);
const upAfter = panResult(s).upMean;
// burnt baste (separate pan, run butter to burnt)
const s2 = newPanSession(fuel);
setPanKnob(s2, 9); addButter(s2);
for (let i = 0; i < 60 * 20; i++) panStep(s2, 1 / 60); // burn the butter
addFood(s2, 'button_mushroom', 'A');
const b2 = baste(s2, 4);
return {
noisette: { state: b1.state, bonus: b1.bonus, upBefore: +upBefore.toFixed(3), upAfter: +upAfter.toFixed(3) },
burnt: { state: b2.state, bitterness: +panResult(s2).bitterness.toFixed(3) },
};
},
/** 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 // ---- M-H6: charcoal is a Field, not a dial. Pure-sim probes for the bed
// gradient, the monotonic decay, and one-bed-two-outcomes. ---- // gradient, the monotonic decay, and one-bed-two-outcomes. ----

272
src/sim/pan.ts Normal file
View File

@ -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<string, { flesh?: { moisture: number } }>)[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';
}
}