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>
211 lines
9.0 KiB
TypeScript
211 lines
9.0 KiB
TypeScript
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<HeatSource, 'fuel' | 'target' | 'output' | 'zone'>;
|
|
|
|
/**
|
|
* 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<Fuel, Profile> = {
|
|
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<string, Partial<Profile> & { 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';
|
|
}
|
|
}
|