import { Field } from '../core/field'; import { Rng, valueNoise2D } from '../core/rng'; /** * THE HEAT SOURCE — the bone between the knob and the browning integrator. * * Every heat surface in the game used to map its dial STRAIGHT into the cooker: * roastStep read s.power, toastStep read power, and the number the knob asked for * was the number the food got. No real cook believes that. A HeatSource sits in * between: the knob sets a `target`, an `output` chases it on a fuel-specific lag, * and every station reads `output` where it read `power`. * * gas — output glued to the knob (fast rise AND fall). No memory. * electric — slow rise, slower fall: the coil remembers, you cook its future. * induction — instant both ways, flattest heat, DEAD to a non-ferrous pan. * charcoal — no knob; a heat you bank and it decays over a service (see zone). * radiant — the element: slow, top-biased — what the toaster & fan oven ARE. * * You never cook the knob — you cook the lag. * * Pure: Field + Rng only, so the harness measures the same numbers the judge does. * Retrofit is golden-preserving: a station passing no source reads `power` exactly * as before (see roasting.ts / the M-H2 golden test). */ export type Fuel = 'gas' | 'electric' | 'induction' | 'charcoal' | 'radiant'; export interface HeatSource { fuel: Fuel; /** 0..10 — what the knob commands (charcoal: the banked bed level). */ target: number; /** 0..10 — what actually reaches the vessel. EVERY station reads this. */ output: number; /** units/sec climbing toward target. */ riseRate: number; /** units/sec falling toward target. LOW = residual heat that won't let go. */ fallRate: number; /** powerMax clamp — cheap hobs top out below 10. */ maxOut: number; /** blotch amplitude blended into the station's heat-map (hot-spots). */ evenNoise: number; /** radial concentration of the heat map — a burner ring, a coal pile, a centre-hot oven. */ hotspot: number; /** has a flame → wind steals from output, fat can flare it. */ flame: boolean; /** induction rejects a non-ferrous vessel → output pinned at ambient. */ needsFerrous: boolean; /** how hard rendered fat spikes output (gas high, induction zero). */ flareGain: number; /** charcoal only: a 2D heat-zone gradient you arrange. */ zone?: Field; } export interface Environment { place: 'indoor' | 'outdoor'; /** the temp cooling pulls toward (a cold night lowers it). */ ambient: number; wind: number; // 0..1 base gust: number; // 0..1 live, walks toward wind /** lid / windbreak / lee side → gust forced to 0 (the dodgeable floor). */ sheltered: boolean; /** does the mounted pan take induction. */ vesselFerrous: boolean; /** indoor only: burnt food accumulates it → fires the ZAP once. */ smoke: number; rng: Rng; } type Profile = Omit; /** * Base fuel profiles. Tuned to the M-H1 exit bars: chase 3→9 is gas <1s, * electric 5-9s, induction <0.15s; residual after 9→0 is gas <1 within 1s, * electric still >6 at 6s, charcoal still >5 at 30s. */ export const FUELS: Record = { gas: { riseRate: 14, fallRate: 14, maxOut: 10, evenNoise: 0.1, hotspot: 0.35, flame: true, needsFerrous: false, flareGain: 3.5 }, electric: { riseRate: 1.0, fallRate: 0.4, maxOut: 9, evenNoise: 0.28, hotspot: 0.2, flame: false, needsFerrous: false, flareGain: 0 }, induction: { riseRate: 45, fallRate: 45, maxOut: 10, evenNoise: 0.03, hotspot: 0.1, flame: false, needsFerrous: true, flareGain: 0 }, charcoal: { riseRate: 6, fallRate: 0.02, maxOut: 10, evenNoise: 0.35, hotspot: 0.6, flame: true, needsFerrous: false, flareGain: 4.0 }, radiant: { riseRate: 0.9, fallRate: 0.5, maxOut: 10, evenNoise: 0.14, hotspot: 0.05, flame: false, needsFerrous: false, flareGain: 0.5 }, }; /** * Appliance SKUs — a base fuel with overrides. This is both the two oven * variants (gas hot-at-top vs electric fan even) and the P12 buy mechanism * (a cheap hob is noise + a slow chase, never a flat score tax). */ export const HEATSOURCE_SKUS: Record & { fuel: Fuel }> = { // Gas oven: preheats fast-ish, top/centre-hot, blotchy → uneven roast (high stdev). oven_gas: { fuel: 'gas', riseRate: 2.2, fallRate: 1.2, evenNoise: 0.24, hotspot: 0.62, maxOut: 10 }, // Electric fan oven: slow to preheat, laboratory-flat → even roast (low stdev). oven_fan: { fuel: 'radiant', riseRate: 0.8, fallRate: 0.5, evenNoise: 0.03, hotspot: 0.0, maxOut: 10 }, // P12: a cheap thin electric hob — hot-spot noise + a slow chase, tops out low. cheap_electric: { fuel: 'electric', maxOut: 8.2, evenNoise: 0.42, hotspot: 0.5, riseRate: 0.9 }, }; export function newHeatSource(fuelOrSku: Fuel | string): HeatSource { const sku = HEATSOURCE_SKUS[fuelOrSku]; const fuel: Fuel = sku ? sku.fuel : (fuelOrSku as Fuel); const p: Profile = { ...FUELS[fuel], ...(sku ?? {}) }; return { fuel, target: 0, output: 0, riseRate: p.riseRate, fallRate: p.fallRate, maxOut: p.maxOut, evenNoise: p.evenNoise, hotspot: p.hotspot, flame: p.flame, needsFerrous: p.needsFerrous, flareGain: p.flareGain, }; } export function newIndoorEnv(seed = 7): Environment { return { place: 'indoor', ambient: 0, wind: 0, gust: 0, sheltered: true, vesselFerrous: true, smoke: 0, rng: new Rng(seed) }; } const INDOOR = newIndoorEnv(); /** Set the knob. Charcoal ignores this (target is the bed level; use stoke). */ export function setKnob(hs: HeatSource, k: number): void { if (hs.fuel === 'charcoal') return; hs.target = Math.max(0, Math.min(hs.maxOut, k)); } /** Charcoal: fan the coals — bump the bed ceiling. */ export function stoke(hs: HeatSource, amt = 1.5): void { hs.target = Math.min(hs.maxOut, hs.target + amt); } /** * Advance the source one tick. output chases target on the fuel's lag; a flame * loses ground to wind and gains it from fat; a dead (non-ferrous) induction pan * sits at ambient. This is TempClock's linear drift and coolStep's decay, married. */ export function heatStep(hs: HeatSource, dt: number, env: Environment = INDOOR, fatLoad = 0): void { if (hs.needsFerrous && !env.vesselFerrous) { hs.output = env.ambient; return; } const t = hs.flame ? hs.target * (1 - 0.35 * env.gust) : hs.target; if (hs.output < t) hs.output = Math.min(t, hs.output + hs.riseRate * dt); else hs.output = Math.max(t, hs.output - hs.fallRate * dt); if (fatLoad > 0 && hs.flareGain > 0) { hs.output = Math.min(hs.maxOut, hs.output + hs.flareGain * fatLoad); } } /** Walk the live gust toward the base wind (0 indoors or when sheltered). */ export function envStep(env: Environment, dt: number): void { const target = env.place === 'outdoor' && !env.sheltered ? env.wind : 0; // A lazy first-order walk with a little value-noise jitter so gusts feel alive. const jitter = (env.rng.next() - 0.5) * 0.4 * env.wind; env.gust += (target + jitter - env.gust) * Math.min(1, dt * 1.5); env.gust = Math.max(0, Math.min(1, env.gust)); } /** * Reshape a station's heat map for this fuel — the only spatial hook. A flat fan * (hotspot 0, evenNoise ~0) leaves a near-uniform disc; a gas/coal source (high * hotspot + blotch) concentrates heat toward the centre and speckles it, so an * even cook becomes a real skill. Normalized over `mask` (or the whole grid) so * two fuels compared at equal cook time have equal MEAN heat — the difference is * pure evenness (the M-H2 bar). */ export function applyToHeatMap(heat: Float32Array, n: number, hs: HeatSource, rng: Rng, mask?: Field): void { const bias = valueNoise2D(rng, n, n, 3); let sum = 0; let count = 0; for (let y = 0; y < n; y++) { for (let x = 0; x < n; x++) { const i = y * n + x; const u = x / (n - 1); const v = y / (n - 1); const dc = Math.hypot(u - 0.5, v - 0.5); const centre = 1 - hs.hotspot * Math.min(1, dc / 0.6); const blot = 1 + hs.evenNoise * (bias[i] * 2 - 1); const val = Math.max(0.05, centre * blot); heat[i] = val; if (!mask || mask.data[i] >= 0.5) { sum += val; count++; } } } // Normalize so the masked average matches the legacy heat map's ~0.9. const avg = count > 0 ? sum / count : 1; const k = avg > 0 ? 0.9 / avg : 1; for (let i = 0; i < heat.length; i++) heat[i] *= k; } /** A human tell for the current source state — for a diegetic HUD, never a bar. */ export function fuelWord(hs: HeatSource): string { const o = hs.output; switch (hs.fuel) { case 'gas': return o < 0.5 ? 'flame out' : o > 8 ? 'roaring blue' : 'blue flame'; case 'electric': return o < 0.5 ? 'cold coil' : o > hs.target + 0.5 ? 'still glowing (residual)' : o > 6 ? 'glowing orange' : 'warming'; case 'induction': return o < 0.3 ? 'dead — wrong pan?' : 'silent, exact'; case 'charcoal': return o > 6 ? 'coals screaming' : o > 3 ? 'good bed' : 'coals fading'; default: return o > 6 ? 'element hot' : o > 2 ? 'warming' : 'cold'; } }