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 ~28–36s (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'; }