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>
This commit is contained in:
type-two 2026-07-19 14:16:54 +10:00
parent 9e315bd7a9
commit b76820b88e
7 changed files with 515 additions and 9 deletions

193
OPUS-BUILD-BRIEF-HEAT.md Normal file
View File

@ -0,0 +1,193 @@
# TOASTSIM — Build Brief: THE HEAT SOURCE & ENVIRONMENT DOCTRINE
*Read this and execute it. It works cold for an Opus 4.8 session with no other context. It is a CROSS-CUTTING doctrine, not a single phase — it is the layer the pan (P2/M17), the pot & fryer (P6), and the torch & grill (P9) all ride. Read MASTERPLAN §1§3 (design law + bones), then KITCHEN-ATLAS §2/§5/§7.5, then OPUS-BUILD-BRIEF-3 §4 (the pan — committed, not yet built), then this file top to bottom. Build the doctrine's core (§2, §6 M-H1/H2) BEFORE or ALONGSIDE M17 so the pan is born fuel-aware; retrofit oven/toaster in the same pass; ship the rest as the phases that need it arrive.*
## 0. STATUS (update this section as you go — a cold session reads here first)
- **M-H1 + M-H2 DONE & LIVE (2026-07-19).** `src/sim/heatsource.ts` (the bone) + the golden-preserving oven retrofit + the gas-oven day are shipped. Verified in-browser: **golden** — no-source oven reads mean 0.698 / blister 1.0 / collapse 0, byte-identical to HEAD. **M-H1 chase 3→9** — gas 0.433s, electric 6.0s, induction 0.133s (distinct, no overlap). **Residual after 9→0** — gas 0 @1s, electric 6.6 @6s, charcoal 9 @30s. **M-H2** — gas oven stdev 0.1207 vs fan oven 0.0016 at equal mean 0.6001 (75× ratio; bar was 2×). Day 14 bruschetta now roasts on `oven_gas` and still grades **S (9.62)**, HUD shows "GAS OVEN · blue flame". Harness: `t.heatChase`, `t.residual`, `t.ovenStdev`, `t.ovenGolden`, `t.ovenDemo`. **NEXT: M-H3 (Environment/wind), M-H4 (pan/butter/flare — needs M17), M-H5 (economy SKUs), M-H6 (charcoal zone Field — the showpiece scene).** Everything below §2 not checked off here is unbuilt.
- Presupposes the M16 salt doctrine and the M17 pan skeleton from Brief 3; those aren't in yet, so §2 ships as a standalone `heatsource.ts` (zero dependency on the pan) and M17 will read it from birth.
- **LIVE 🌐** https://partly.party/toastsim/ — `bash scripts/deploy.sh` (build `--base=/toastsim/` → rsync staging → docker cp into `forum-nginx`). Curl cache-busted after every deploy.
- Seed lane for this brief's assets: **680719** (open gap after P12's 630679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181219 for the general cast; the 680719 sprites (flames, glows, flare, ZAP) remain.
## 0b. THE PITCH (one paragraph)
Today every heat surface in TOASTSIM maps its dial *straight* into the browning integrator — `roastStep` reads `s.power`, `toastStep` reads `power`, and the number the knob commands is the number the food gets. That is a lie no real cook believes. This doctrine interposes ONE new bone, a `HeatSource`, between the knob and the integrator: the knob sets a **target**, an **output** chases it on a fuel-specific lag, and every station reads `output` where it read `power`. Gas is glued to your hand, an electric coil cooks its own future, induction is eerily exact and rejects the wrong pan, charcoal has no knob at all and is a heat-zone Field you *arrange*. Wrap that in an `Environment` — indoor is a promise, outdoor is weather that steals heat off the windward edge — and the four axes SOURCE ⟂ MEDIUM ⟂ VESSEL ⟂ ENVIRONMENT become pure data multiplied into the *same* `power → heat → dry → roast` pipeline. No new integrator. New primitives are two small structs and a first-order ease; everything else is content.
## 1. THE HEAT SOURCE & ENVIRONMENT DOCTRINE — the cross-cutting rule
State it as crisply as the salt doctrine ("salt is two objects and a timing question") and the butter doctrine ("cold → foaming → noisette → burnt"):
> **Every heat surface has a FUEL and lives in an ENVIRONMENT. The knob commands a target; the fuel decides how fast the heat obeys; the environment decides how much of it survives the trip to the food. You never cook the knob — you cook the lag.**
Four laws follow, and nothing in any phase may break them:
1. **The knob is not the heat.** No station reads its dial into the integrator anymore. It reads `hs.output`. `output` chases `target` on the fuel's `riseRate`/`fallRate`. The gap between what you asked for and what you're getting IS the skill (the pan's "the lag IS the skill", generalized to every burner).
2. **No meter. The state reads diegetically.** Blue gas cone, orange coil glow, silent induction, coal-bed glow — never a temperature bar. The butter's foam and the food's sear ARE the readout. The dial can lie (electric residual, induction rejection); the food never does.
3. **Floors, not lotteries (WA-4, MASTERPLAN).** Hot-spots, wind, flare-ups, empty bottles, altitude — all add NOISE and dodgeable EVENTS to the Field. None may randomly burn a correct cook. Every hazard has a diegetic warning and a physical dodge (turn the pan, raise the windbreak, move the food across the gradient). Careful, lag-aware play reaches 10/10 on the worst gear in the game.
4. **SOURCE ⟂ MEDIUM ⟂ VESSEL ⟂ ENVIRONMENT are orthogonal data axes.** A fuel is a profile. A pan is a profile. A wind is a profile. Combinations (gas + wok + windy beach) are *content the day table composes*, never new code. Adding the fifth fuel or the tenth environment must touch zero integrator lines.
## 2. THE DATA MODEL — `src/sim/heatsource.ts` (pure: Field + Rng only)
The SOURCE sits between the knob and the existing integrator. It owns a scalar that chases the setpoint on the SAME first-order shape `TempClock.step` already uses (climb toward target by rate·dt; if above, fall), retargeted from fridge/bench(0/1) to the knob's setpoint. This is the only new math, and it is not new — it is `TempClock`'s drift and `coolStep`'s decay, generalized.
```ts
export type Fuel = 'gas' | 'electric' | 'induction' | 'charcoal' | 'radiant';
export interface HeatSource {
fuel: Fuel;
target: number; // 0..10 — what the knob commands (charcoal: the bed level)
output: number; // 0..10 — what actually reaches the vessel; every station reads THIS
riseRate: number; // units/sec climbing toward target
fallRate: number; // units/sec falling toward target (LOW = residual heat that won't let go)
maxOut: number; // powerMax clamp (cheap hobs top out below 10)
evenNoise: number; // amplitude blended into the station's heat-map bias (hot-spots)
hotspot: number; // radial concentration term (a burner ring, a coal pile)
flame: boolean; // has a flame → wind steals from output, fat can flare it
needsFerrous: boolean; // induction rejects a non-ferrous vessel → output pinned at ambient
flareGain: number; // how hard rendered fat spikes output (gas high, induction 0)
zone?: Field; // charcoal only: a 2D heat-zone gradient you arrange (itself a Field)
}
```
`setKnob(hs, k)``hs.target = clamp(k, 0, hs.maxOut)`. Charcoal ignores it: `target` is the fuel-bed ceiling and `stoke(hs)` bumps it. The step:
```
export function heatStep(hs, env, dt, fatLoad = 0) {
const t = hs.flame ? hs.target * (1 - 0.35 * env.gust) : hs.target; // wind steals flame
if (hs.needsFerrous && !env.vesselFerrous) { hs.output = env.ambient; return; } // dead pan
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); // residual coast
hs.output = Math.min(hs.maxOut, hs.output + hs.flareGain * fatLoad); // flare on fat
if (hs.zone) zoneDecayStep(hs.zone, dt); // coal-bed cools
}
```
**`FUELS` profiles** (tune headless against §6 bars; shipping targets, not gospel):
| Fuel | rise | fall | maxOut | evenNoise | flame | needsFerrous | flareGain | Feel |
|---|---|---|---|---|---|---|---|---|
| `gas` | 12 | 14 | 10 | 0.10 | ✓ | | 3.5 | settles <1s, no memory |
| `electric` | 1.1 | 0.8 | 9 | 0.28 | | | 0 | 610s lag, long residual |
| `induction` | 20 | 20 | 10 | 0.03 | | ✓ | 0 | instant, flattest, dead to wrong pan |
| `charcoal` | — | 0.02 | (zone) | 0.35 | ✓ | | 4.0 | no knob; a 2D bed, decays |
| `radiant` | 0.9 | 0.5 | 10 | 0.14 | | | 0.5 | top-hot; what toaster & electric oven ARE |
`applyToHeatMap(heat, hs, rng)` is the only spatial hook: it scales the station's existing `Float32Array` and re-blends the bias amplitude to `hs.evenNoise` plus a `hotspot` radial term — the `amp = 0.12 + 0.42·(1 quality)` slot in `buildHeatMap`, now driven by fuel instead of bread brand. Charcoal writes its `zone` Field in directly (§3).
**`Environment` — same file:**
```ts
export interface Environment {
place: 'indoor' | 'outdoor';
ambient: number; // the temp TempClock/coolStep pull toward
wind: number; // 0..1 base
gust: number; // 0..1 live, walks toward wind on value-noise
sheltered: boolean; // lid / windbreak / lee side → gust forced to 0 (dodgeable floor)
vesselFerrous: boolean; // does the mounted pan take induction
smoke: number; // indoor only: accumulates → fires the ZAP once
rng: Rng;
}
```
`envStep(env, dt)` walks `gust` via value-noise toward `wind` (0 if `sheltered`/`indoor`). Wind does exactly two things, both existing knobs: perturbs flame `output` in `heatStep`; accelerates surface cooling (`coolStep` `tau /= (1 + 1.4·gust)`). `sheltered` zeroes gust → wind is NEVER a lottery; it is a floor you dodge. Indoors `wind=0` but `smoke` climbs from burnt butter/food and fires the **ZAP** once (comedy, no damage).
**Exports (documented in-file, the `juiceResult()` pattern):** `Fuel, HeatSource, Environment, FUELS, HEATSOURCE_SKUS, newHeatSource(fuelOrSku, seed), setKnob, stoke, heatStep, envStep, applyToHeatMap, bankZone` (charcoal).
**Retrofit is opt-in and golden-preserving.** `roasting.ts`/`toasting.ts` gain an optional `src?: HeatSource`; the step reads `src?.output ?? power`. With no source passed, every existing oven and toaster judged number is byte-for-byte unchanged (§6 M-H2 golden test). Nothing already shipped moves.
## 3. THE FEEL per fuel — the must-feel moments
**GAS — the live wire.** `output` glued to the knob (`rise 12`); blue cone visible, `maxOut` to the clamp. **Must-feel — the gas scorch:** one nudge to chase a stalling first side and `output` is instantly there — butter clears foaming → noisette → burnt in one breath. The recovery is the lesson: yank the pan off, heat dies on contact (`fall 14`, no residual). Punishes and forgives at the same speed.
**ELECTRIC COIL — the ring remembers.** `rise 1.1 / fall 0.8`. Turn the dial *down* and `output` keeps coasting for ten-plus seconds while the sear climbs past target. No flame: the dial lies, the food is truth. **Must-feel — the coil that won't let go:** spin the dial to 3, nothing changes for ten seconds; your only save is lifting the pan and riding carryover. Make the turn-down BEFORE you need it.
**INDUCTION — eerie precision, dead pans.** `rise 20 / fall 20`, `evenNoise 0.03` — the flattest sear in the game. Gated by `needsFerrous`: wrong pan → `output` never leaves `ambient`, readout stone-cold, dial at 8, silence. **Must-feel — the induction ZAP:** wrong pan, flat sear, *beep beep beep*; swap and it snaps to setpoint in a heartbeat. Comedy by absence.
**CHARCOAL — you build it.** No knob. Banking coals paints the 2D `zone` Field — a screaming-hot side, a cool side to park on. The ceiling decays on `zoneDecayStep` ("sear early, hold late"). Fat drip spikes a local texel — a **flare** you dodge by moving the food. The fan (`stoke`) pumps it back up; the lid raises `ambient` and traps it.
**RADIANT — the element, honest and top-biased.** `rise 0.9 / fall 0.5`, top-hot bias, no flame. This is what the toaster and electric oven ALREADY are — the retrofit names them. Patience: climbs slow, holds.
## 4. THE ENVIRONMENTS — ambient modifiers + comedy
**INDOOR is a promise.** `ambient` constant, `wind=0`, sources behave as specced — the clean bench to learn each curve. The one hazard is `smoke`: burnt butter/food accumulates it, past threshold the **ZAP** fires once (a `Mess`-style event → `result.bench` + a judge line; no temp damage).
**OUTDOOR is weather.** `envStep` walks `gust` toward `wind`; wind steals from flame `output` on the windward half and accelerates `coolStep`. All dodgeable via `sheltered`. Three flagship stages, each ONE new pressure:
- **Backyard BBQ** (flagship). Gas bottle with a hidden `fuel` reserve that reads only diegetically — flame shrinks blue → sad yellow, then floors mid-service. **New pressure: fuel is finite.** Flare-ups when fat hits flame (local `output` spike, chars in ~3s, dodge by moving). Wind subtracts from the windward half. The neighbour, a char snob, leaning on the fence.
- **Camp stove.** Tiny tippy burner — narrow `heatDisc`. Altitude lowers `maxOut` (water never boils; `dry` gate stalls). One gust and the flame *goes out*. **New pressure: no dial you can trust.**
- **Beach / park.** Sand tilts the pan so fat pools (`heat × dry` asymmetry). Constant low breeze. A seagull. **New pressure: the tilt.**
Charcoal's outdoor gusts blow **ash** into the sear — a `Mess` spatter, not a temp hit (WA-4 kept).
## 5. UTENSIL EXPANSION that pairs with heat (procedural vs generated)
The VESSEL is the third orthogonal axis. Each vessel is a small data profile (`heatDisc` radius, `ferrous` flag, `tilt` susceptibility, `wallHeight` for boil-over) multiplied into the same map — a heavier pan literally smooths the `evenNoise` Field (P12), never adds a stat.
- **Pans** (cast-iron — M17 hero; carbon-steel; thin cheap). GENERATED props; `heatDisc`/`ferrous`/`tilt` PROCEDURAL.
- **Woks** — bowl `heatDisc`, hot centre / cool rim, the wok-hei window. GENERATED prop; toss + peak-window PROCEDURAL.
- **Spatulas / tongs / basting spoon** — GENERATED (tongs seed 217); flip/baste/grip PROCEDURAL (they write to the Field).
- **BBQ tools** — long tongs, grill fork, coal fan, chimney, lid. GENERATED; `stoke`/`sheltered`/light-time PROCEDURAL.
- **Pots / camp burner / trivet** — GENERATED; `wallHeight` boil-over + coin `heatDisc` PROCEDURAL (seed P6).
Rule, unchanged: **the prop is art, the Field is sim.** Judged numbers stay procedural; chrome/marbling/lids are generated.
## 6. MILESTONES with harness-measurable exit bars (these PROVE the fuels differ)
Add `src/dev.ts` helpers driving REAL input (`tap('Space')`, `run`, `point`; study `t.chop`/`t.juice`/`t.roast`): `t.heatChase(fuel, from, to) → sec`, `t.residual(fuel) → sec`, `t.hobNoise(sku) → stdev`, `t.zoneStdev()`, `t.envVar(fuel, wind)`. Verify each fuel first in a `dev-stove.ts` sandbox like grate. Typecheck + build stay clean; one commit per milestone, message = measured numbers.
- **M-H1 — the core ease.** `t.heatChase('gas',3,9) < 1.0s`; `('electric',3,9)` 59s; `('induction',3,9) < 0.15s`. Residual after 9→0: gas `output<1` within 1s, electric still `>6` at 6s, charcoal still `>5` at t=30s. **Bar:** three rise + three residual curves numerically distinct, no overlap.
- **M-H2 — retrofit, golden-preserving.** Gas-oven roast `stdev > 2×` electric-fan roast at equal `mean`. **Golden test:** running the existing oven and toaster with `src` omitted leaves every shipped judged number identical to HEAD — zero drift.
- **M-H3 — environment.** Outdoor gas at `wind=0.6`: `output` variance `>0.05`; `sheltered:true` drops it `<0.02` (dodgeable floor proven). Indoor high-`smoke` fires the ZAP exactly once. Charcoal ash registers as `result.bench`, not a `roast` delta.
- **M-H4 — pan / butter / flare.** Butter → noisette on gas ~3s faster than electric at the same knob. Electric's post-zero `∫output dt` tips butter cold→burnt on coast alone. Induction `flareGain·fatLoad == 0`. Gas flare spikes one zone, chars in ~3s, DODGEABLE.
- **M-H5 — economy is noise, never tax.** Cheap-electric SKU `t.hobNoise > 0.12` vs induction `< 0.04`; BOTH reach 10/10 under lag-aware play. The WA-4 / P12 promise, measured.
- **M-H6 — charcoal is a Field, not a dial.** `t.zoneStdev()` after banking a two-zone bed `> 0.25`; ceiling monotonically decays; food on the cool zone holds below sear while the hot zone chars — one bed, two outcomes by placement.
## 7. How it composes with the already-planned systems
- **The pan (P2 / M17).** M17 reads `hs.output` not its knob. Fuel-aware from birth: `PanSession` holds a `HeatSource` + the shared `Environment`. The butter state machine keys on `∫output dt`, so gas overshoots to noisette fast and electric's residual keeps butter cooking after the knob is off — the fuel drives the "moment" for free. The `~8s` committed lag IS the electric profile.
- **The oven (M14 `roasting.ts`).** Two profiles over the SAME `roastStep`: gas = `radiant` + top-weighted gradient (`v>0.7`) + `flame`; electric fan = flat, low `evenNoise`. Pure data on `heat[]`; golden test proves default untouched.
- **The toaster (`toasting.ts`).** Retrofit as `fuel:'radiant'`: feed `hs.evenNoise` into `buildHeatMap`'s `amp` slot, pass `hs.output` to `toastStep`. Stripes stay; a cheap toaster is higher `evenNoise`.
- **Salt & butter (M16).** Rendered fat is the `fatLoad` into `heatStep`: flame flares, induction never does. Salt's moisture-draw on `dry` is unchanged.
- **P12 career (buy better hobs).** `HEATSOURCE_SKUS` maps SKU → profile override: cheap thin electric = `{maxOut:8.2, evenNoise:0.42, hotspot:0.5, riseRate:0.9}` — hot-spot NOISE + slow chase, never a flat tax. Induction is aspirational (flat, silent) with the vessel-match catch. Every SKU: careful play still reaches 10/10.
## 8. JUDGE LINES (house voice, deadpan — bank 14, expand per day)
1. "You cooked this on an electric coil. I can taste the patience it required of you."
2. "There's no char on this. There's a *memory* of char. A rumour."
3. "Induction. Even. Correct. Joyless. Like a well-run dentist's office."
4. "The wind took the left three. You didn't. Notice."
5. "This has wok-hei. I did not expect wok-hei from a man with a domestic extractor fan."
6. "Gas. You panicked at the whoomp and never recovered. It's fine. We all hear the whoomp."
7. "One side is Sunday. The other side is a house fire. Commit to a Tuesday."
8. "The bottle ran out at minute four. I know because minute five tastes like giving up."
9. "You seared this over charcoal you arranged with genuine care. I'm almost moved."
10. "Blistered, not black. You waited through the boring part. That's the whole job."
11. "Flare-up. You let the fat conduct the orchestra. It chose *inferno*."
12. "This never got hot. Altitude, or nerve — either way, it steamed."
13. "You put cast iron on induction and stood there. It beeped at you. It was right."
14. "Perfectly even, corner to corner. On a *grill*. You've smuggled a laboratory outdoors."
(Wire through `Criterion{key,label,score,weight,group}`; ZAP/empty-bottle events read into `result.bench` like `Mess`. Each shipped fuel/environment day deserves 8+ lines in its own group.)
## 9. ASSET LANES — MODELBEAST (seeds 680719; the 181219 cast is already fired)
The general heat cast (gas/electric/induction hobs, camp stove, cast iron, wok, saucepan, kettle & gas BBQ, spatula, wooden spoon, tongs, ladle) was fired at **seeds 181219**. The 680719 lane is the remaining SPRITES + specialised props:
- 3D: kettle grill lid-closed pair (691), chimney starter (693), coal fan/bellows (695), gas bottle full+empty tell pair (697/698), windbreak screen (708), neighbour's fence rail (711).
- 2D: blue-cone flame frames (713), sad-yellow low-fuel flame (714), coil orange-glow ramp (715), flare-up flash (716), ash-gust spatter (717), smoke-alarm ZAP flash (718), wind streak overlay (719).
**Stays PROCEDURAL:** the charcoal `zone` Field, every sear/doneness Field, the flare's local spike, hot-spot noise. Prop is art, Field is sim.
## 10. RECOMMENDED FIRST BUILDABLE SLICE (being built now)
**Build `src/sim/heatsource.ts` + the OVEN retrofit first — NOT the pan-with-fuel from scratch. Ship a "gas oven vs electric fan oven" day.**
Justification: the pan (M17) isn't on disk and is P2's biggest integrative build; coupling a new bone to a new station doubles the bug surface. The oven ALREADY ships (`roasting.ts`, M14) with a `power`-scaled `heat` Field and a centre-hot gradient — interposing `HeatSource` there is a `src?.output ?? power` change with a golden test proving byte-identical default. "Gas hot-at-top vs electric fan even" is pure data on `heat[]`, proving the doctrine (two fuels, distinct `stdev` at equal `mean`) with zero new scene and one order flag. It de-risks M17: the pan then reads a measured, gate-verified `heatsource.ts`.
Concretely: (1) `heatsource.ts` with `FUELS` for `radiant`/gas-oven, `heatStep`, `applyToHeatMap`, no `Environment` yet. (2) `roasting.ts`: optional `src?`; `src?.output ?? power`; golden test green. (3) `dev.ts`: `t.heatChase`, `t.residual`, gas-vs-fan `stdev`; hit M-H1 (partial) + M-H2. (4) One day-table entry handing the same tray to gas vs fan oven; judge lines. (5) Deploy, curl, screenshot both scorecards.
Then M17 is born fuel-aware, `Environment` bolts on for BBQ/camp, and charcoal (M-H6) is the one genuinely new scene — the showpiece, built after the bone is proven.
---
**The bet:** the knob was always lying. This doctrine makes the lie a mechanic — gas obeys your hand, electric cooks its own future, induction is exact and unforgiving of the wrong pan, charcoal has no hand to obey. Four fuels, two environments, one first-order ease on a scalar the game already integrates. If the coil that won't let go, the gas scorch, and the induction ZAP all *feel* like the same lesson taught three ways — you never cook the knob, you cook the lag — then every burner TOASTSIM will ever add is already content.

View File

@ -3,6 +3,8 @@ import type { App } from './core/app';
import type { Game } from './game/game';
import { sogWord } from './sim/assembly';
import type { IngredientId } from './sim/ingredients';
import { type Fuel, newHeatSource, setKnob, heatStep } from './sim/heatsource';
import { newRoastSession, roastStep, roastResult } from './sim/roasting';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
@ -448,6 +450,77 @@ export function installDevHarness(app: App, game: Game): void {
return oven.result();
},
// ---- Heat-source doctrine (M-H1/M-H2). Pure-sim probes: no scene, they
// measure the fuel curves the whole game will ride. ----
/** Seconds for a fuel's output to climb `from`→`to` (M-H1: gas<1, elec 5-9, induction<0.15). */
heatChase(fuel: Fuel, from = 3, to = 9, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = from;
setKnob(hs, to);
let s = 0;
for (let i = 0; i < 6000 && hs.output < to - 0.01; i++) {
heatStep(hs, dt);
s += dt;
}
return +s.toFixed(3);
},
/** Output remaining `sec` after the knob drops 9→0 — the residual (electric remembers). */
residual(fuel: Fuel, sec = 6, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = 9;
hs.target = 9;
setKnob(hs, 0);
if (fuel === 'charcoal') hs.target = 9; // no knob; measure the bed decay
for (let i = 0; i < Math.round(sec / dt); i++) heatStep(hs, dt);
return +hs.output.toFixed(3);
},
/** Roast a tray on an oven SKU to ~`toMean` roast, report evenness. Gas oven
* reads uneven (high stdev), fan oven flat (low stdev) at equal mean. */
ovenStdev(sku = 'oven_gas', toMean = 0.6, power = 8) {
const src = newHeatSource(sku);
const s = newRoastSession(4, 20260718, power, src);
let f = 0;
let res = roastResult(s);
while (res.mean < toMean && f < 6000) {
roastStep(s, 1 / 60);
res = roastResult(s);
f++;
}
const r = (n: number) => +n.toFixed(4);
return { sku, mean: r(res.mean), stdev: r(res.stdev), seconds: +(f / 60).toFixed(1), output: +s.src!.output.toFixed(2) };
},
/** Put the ACTUAL oven scene on screen wearing a fuel, and roast it for a
* live screenshot of the gas-oven vs fan-oven HUD. */
ovenDemo(fuel = 'oven_gas', seconds = 24, power = 8) {
const oven = game.ovenView as unknown as {
reset(h: number, p: number, ask: string, fuel?: string): void;
result(): { mean: number; stdev: number; blisterFrac: number; collapseFrac: number };
};
oven.reset(4, power, `roast on the ${fuel === 'oven_gas' ? 'gas' : 'fan'} oven`, fuel);
app.setView(game.ovenView);
run(3);
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(3);
const res = oven.result();
const r = (n: number) => +n.toFixed(4);
return { fuel, mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac) };
},
/** Golden test: roast with NO source. Must match the legacy oven numbers exactly. */
ovenGolden(seconds = 32, power = 6) {
const s = newRoastSession(4, 20260718, power);
for (let i = 0; i < Math.round(seconds * 60); i++) roastStep(s, 1 / 60);
const res = roastResult(s);
const r = (n: number) => +n.toFixed(4);
return { mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac), collapse: r(res.collapseFrac) };
},
/**
* Rub garlic across the toast at the assembly bench: G-mode, then a raster
* drag. `passes` rows of strokes; 3 lands ~0.35 coverage a light, even

View File

@ -277,7 +277,7 @@ export class Game {
*/
private enterOven(): void {
const b = this.order.bruschetta!;
this.oven.reset(b.roastHalves, 6, 'roast the tomatoes — blistered, not sauce');
this.oven.reset(b.roastHalves, 6, 'roast the tomatoes — blistered, not sauce', b.oven);
this.hideAllScenes();
this.oven.root.visible = true;
this.app.setView(this.oven);

View File

@ -45,6 +45,9 @@ export interface BruschettaSpec {
/** Perishables the temp clock tracks: brie wants out early (soft), parmesan
* wants to stay in (cold). */
perishables: IngredientId[];
/** The oven's fuel character (heatsource SKU): 'oven_gas' roasts hot-at-centre
* and uneven, 'oven_fan' laboratory-flat. Omit for the legacy dial-is-heat oven. */
oven?: string;
}
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
@ -340,7 +343,7 @@ export const DAYS: Order[] = [
// The capstone: cut the loaf → toast → roast the tomatoes → assemble (rub,
// top, watch the sog) → serve. The temp clock tracks the two cheeses from
// order start; take the brie out early, leave the parmesan in.
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'] },
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'], oven: 'oven_gas' },
},
];

View File

@ -14,6 +14,7 @@ import {
} from '../sim/roasting';
import { el, Panel } from '../ui/hud';
import { loadProp } from './assets';
import { newHeatSource, fuelWord } from '../sim/heatsource';
const TRAY_Y = 0.06;
const TRAY_W = 3.4;
@ -45,6 +46,9 @@ export class OvenView implements View {
power = 6;
private halfCount = 4;
private doneTimer = 0;
/** The oven's fuel character, if any 'oven_gas' (hot-at-top, uneven) or
* 'oven_fan' (laboratory-flat). Undefined = the legacy dial-is-heat oven. */
private fuel: string | undefined;
private fleshColor = new THREE.Color().setRGB(...INGREDIENTS.tomato.colors.flesh, THREE.SRGBColorSpace);
@ -131,10 +135,11 @@ export class OvenView implements View {
}
/** A tray of `halves` tomato halves and a fresh roast. */
reset(halves = 4, power = 6, askText = 'roast the tomatoes'): void {
reset(halves = 4, power = 6, askText = 'roast the tomatoes', fuel?: string): void {
this.halfCount = halves;
this.power = power;
this.session = newRoastSession(halves, 20260718, power);
this.fuel = fuel;
this.session = newRoastSession(halves, 20260718, power, fuel ? newHeatSource(fuel) : undefined);
this.phase = 'ready';
this.doneTimer = 0;
@ -237,10 +242,15 @@ export class OvenView implements View {
private updateHud(s: RoastSession): void {
const r = roastResult(s);
// Diegetic fuel tell — a gas oven roars, a fan oven hums, and an electric
// element glows and *remembers*. Never a temperature bar; the fuel word and
// the tomatoes are the readout.
const ovenName = this.fuel === 'oven_gas' ? 'GAS OVEN' : this.fuel === 'oven_fan' ? 'FAN OVEN' : 'oven';
const tell = s.src ? ` · ${fuelWord(s.src)}` : '';
this.stateEl.textContent =
this.phase === 'ready'
? `oven ${this.power} · tray out`
: `oven ${this.power} · ${Math.round(s.seconds)}s · ${Math.round(r.mean * 100)}% roasted`;
? `${ovenName} ${this.power} · tray out${tell}`
: `${ovenName} ${this.power} · ${Math.round(s.seconds)}s · ${Math.round(r.mean * 100)}% roasted${tell}`;
// Progress toward the blister window; red once it starts collapsing.
this.progFill.style.width = `${Math.min(100, Math.round((r.mean / 0.82) * 100))}%`;
this.progFill.style.background = r.collapseFrac > 0.05 ? '#c0392b' : r.mean >= BLISTER_AT ? '#6cbf6c' : '#e8a53a';

210
src/sim/heatsource.ts Normal file
View File

@ -0,0 +1,210 @@
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 39 is gas <1s,
* electric 5-9s, induction <0.15s; residual after 90 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';
}
}

View File

@ -1,5 +1,6 @@
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.
@ -46,6 +47,9 @@ export interface RoastSession {
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 {
@ -65,7 +69,7 @@ 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): RoastSession {
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);
@ -94,7 +98,12 @@ export function newRoastSession(halves = 4, seed = 20260718, power = 6): RoastSe
heat[y * N + x] = centre * blot;
}
}
return { roast, dry, mask, heat, seconds: 0, power };
// 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 {
@ -113,7 +122,15 @@ function stampDisc(mask: Field, cu: number, cv: number, r: number): void {
* only a dried surface colours at full rate. `power` 1..10 is the oven dial.
*/
export function roastStep(s: RoastSession, dt: number): void {
const p = Math.max(0, Math.min(10, s.power)) / 10;
// 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;