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:
parent
94a5f2f293
commit
c528ba49ab
98
src/dev.ts
98
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<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
|
||||
// gradient, the monotonic decay, and one-bed-two-outcomes. ----
|
||||
|
||||
|
||||
272
src/sim/pan.ts
Normal file
272
src/sim/pan.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user