toastsim/src/sim/roasting.ts
type-two b76820b88e Heat sources M-H1/M-H2: the knob is not the heat
New bone src/sim/heatsource.ts: a HeatSource sits between the knob and
the browning integrator — the dial sets target, output chases it on a
fuel-specific lag, every station reads output. Five fuels (gas/electric/
induction/charcoal/radiant) + appliance SKUs (oven_gas, oven_fan,
cheap_electric). roasting.ts retrofit is golden-preserving: optional
src?, reads src.output ?? power, byte-identical when absent.

Verified in-browser:
- GOLDEN: no-src oven = mean 0.698 / blister 1.0 / collapse 0 (== HEAD).
- M-H1 chase 3->9: gas 0.433s, electric 6.0s, induction 0.133s (distinct).
- residual after 9->0: gas 0@1s, electric 6.6@6s, charcoal 9@30s.
- M-H2: gas oven stdev 0.1207 vs fan 0.0016 at equal mean 0.6001 (75x).
- day 14 bruschetta now roasts on oven_gas, still grades S (9.62); oven
  HUD reads 'GAS OVEN 8 · blue flame', tomatoes visibly hot-at-centre.

Doctrine: OPUS-BUILD-BRIEF-HEAT.md (the cross-cutting heat & environment
doctrine — 'you never cook the knob, you cook the lag'). Harness:
t.heatChase/residual/ovenStdev/ovenGolden/ovenDemo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:16:54 +10:00

168 lines
6.5 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 { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
import { type HeatSource, setKnob, heatStep, applyToHeatMap } from './heatsource';
/**
* Roasting tomatoes — the oven is the toaster, generalized.
*
* It is the same browning Field the toast has always used, pointed at tomato
* halves on a tray instead of a slice in a slot. The math is toastStep's twin:
* moisture has to boil off before the surface can colour, and tomatoes are
* basically water balloons, so they stall a long time and then go fast — the
* "leave them in, nothing's happening, leave them in, they're gone" of a real
* oven.
*
* Two things are the tomato's and not the toast's:
* blister — past 0.6 the skin blisters and speckles (a different colour ramp
* and a speckle mask in the scene; here it's just a fraction the
* judge rewards — blistered is the target, not black).
* collapse — past ~0.9 the half slumps into sauce. The moisture it was holding
* becomes mess the moment you handle it, and it soaks the toast
* faster (the assembly reads collapseFrac into the sog clock).
*
* Pure — Field + Rng, no three.js — so the harness roasts headless and reads the
* exact same numbers the judge will.
*/
/** Blistered skin begins here — the target window's floor. */
export const BLISTER_AT = 0.6;
/** Past this the half has collapsed into sauce. */
export const COLLAPSE_AT = 0.9;
/** Roasts a touch slower than toast browns: the oven is gentler than the coils,
* and the wet flesh drinks the first stretch of heat. Measured headless at power
* 6: the halves stall wet until ~26s (mean ~0.56, barely blistered), the blister
* window opens ~2836s (mean ~0.61→0.79, fully blistered, none slumped), and past
* ~40s they collapse to sauce — "nothing's happening, nothing's happening, gone". */
const RATE = 0.042;
export interface RoastSession {
/** 0 raw .. 1 blackened. Blister past 0.6, collapse past 0.9. */
roast: Field;
/** Moisture that must cook off first — tomatoes carry a lot of it. */
dry: Field;
/** 1 where a tomato half sits on the tray; every stat is masked by this. */
mask: Field;
/** Where the oven puts its heat — hotter in the middle, like a real element. */
heat: Float32Array;
seconds: number;
power: number;
/** Optional fuel character (gas oven vs electric fan). When absent, the oven
* behaves EXACTLY as it always has — the knob maps straight to power. */
src?: HeatSource;
}
export interface RoastResult {
/** Mean roast over the tomatoes. */
mean: number;
/** Evenness — a rack that roasted unevenly reads here. */
stdev: number;
/** Fraction blistered (past 0.6) — the good bit. */
blisterFrac: number;
/** Fraction collapsed into sauce (past 0.9) — the overdone bit. */
collapseFrac: number;
}
const N = 96;
/**
* A tray of `halves` tomato halves. They sit in a row; the mask is a set of
* discs so the stats are over the tomatoes, not the empty tray between them.
*/
export function newRoastSession(halves = 4, seed = 20260718, power = 6, src?: HeatSource): RoastSession {
const roast = new Field(N);
const dry = new Field(N);
const mask = new Field(N);
const rng = new Rng(seed);
// Lay the halves out across the tray as discs.
const r = 0.5 / halves; // disc radius in UV, so they tile the width
for (let h = 0; h < halves; h++) {
const cu = (h + 0.5) / halves;
const cv = 0.5;
stampDisc(mask, cu, cv, r * 0.82);
}
// The oven's heat: hotter toward the centre/back, gently blotched, so an even
// roast is a real (if mild) skill — the tuning keeps it forgiving.
const bias = valueNoise2D(rng, N, N, 3);
const heat = new Float32Array(N * 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);
// Radial: the middle of the tray runs hotter than the corners.
const dc = Math.hypot(u - 0.5, v - 0.5);
const centre = 1 - 0.18 * Math.min(1, dc / 0.6);
const blot = 0.9 + 0.2 * bias[y * N + x];
heat[y * N + x] = centre * blot;
}
}
// A fuel character reshapes the heat map (gas oven top-hot & blotchy vs fan
// even) — normalized over the mask so equal cook time gives equal MEAN, and
// the fuel shows up as evenness (stdev), not as a head start. Omit src and the
// heat map above is untouched: the oven is byte-for-byte its old self.
if (src) applyToHeatMap(heat, N, src, new Rng(seed ^ 0x5f), mask);
return { roast, dry, mask, heat, seconds: 0, power, src };
}
function stampDisc(mask: Field, cu: number, cv: number, r: number): 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 - cu, v - cv) <= r) mask.data[y * n + x] = 1;
}
}
}
/**
* Advance the roast one tick. The twin of toastStep: dryness climbs first, and
* only a dried surface colours at full rate. `power` 1..10 is the oven dial.
*/
export function roastStep(s: RoastSession, dt: number): void {
// The knob commands a target; the fuel decides how fast the heat obeys. With
// no source this is `s.power` unchanged — the whole retrofit is invisible.
let effective = s.power;
if (s.src) {
setKnob(s.src, s.power);
heatStep(s.src, dt);
effective = s.src.output;
}
const p = Math.max(0, Math.min(10, effective)) / 10;
const r = s.roast.data;
const d = s.dry.data;
const m = s.mask.data;
// Tomatoes hold a lot of water — a high cap means a long stall then a rush.
const moistureCap = 2.4;
for (let i = 0; i < r.length; i++) {
if (m[i] < 0.5) continue;
const h = s.heat[i] * p;
if (h <= 0) continue;
d[i] = Math.min(1, d[i] + (h * dt) / moistureCap);
const rate = RATE * h * (0.2 + 0.8 * d[i]);
r[i] = Math.min(1.1, r[i] + rate * dt);
}
s.seconds += dt;
}
export function roastResult(s: RoastSession): RoastResult {
const st = s.roast.stats(s.mask);
return {
mean: st.mean,
stdev: st.stdev,
blisterFrac: s.roast.fraction(s.mask, (v) => v >= BLISTER_AT && v < COLLAPSE_AT),
collapseFrac: s.roast.fraction(s.mask, (v) => v >= COLLAPSE_AT),
};
}
/** The live-readout word for the oven, in the judge's own thresholds. */
export function roastWord(r: RoastResult): string {
if (r.collapseFrac > 0.4) return 'collapsing to sauce';
if (r.mean < 0.28) return 'still raw';
if (r.mean < BLISTER_AT) return 'softening';
if (r.blisterFrac > 0.25) return 'blistered';
return 'coming along';
}