diff --git a/OPUS-BUILD-BRIEF-HEAT.md b/OPUS-BUILD-BRIEF-HEAT.md new file mode 100644 index 0000000..044f8cb --- /dev/null +++ b/OPUS-BUILD-BRIEF-HEAT.md @@ -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: **680–719** (open gap after P12's 630–679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181–219 for the general cast; the 680–719 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 | 6–10s 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)` 5–9s; `('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 680–719; the 181–219 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 181–219**. The 680–719 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. diff --git a/src/dev.ts b/src/dev.ts index 77a0f97..62fe105 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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 diff --git a/src/game/game.ts b/src/game/game.ts index a5eb9f2..50531fb 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -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); diff --git a/src/game/orders.ts b/src/game/orders.ts index fdd66ff..fc2e1f0 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -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' }, }, ]; diff --git a/src/scenes/oven.ts b/src/scenes/oven.ts index 98bc88d..706c51e 100644 --- a/src/scenes/oven.ts +++ b/src/scenes/oven.ts @@ -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'; diff --git a/src/sim/heatsource.ts b/src/sim/heatsource.ts new file mode 100644 index 0000000..8ab35a2 --- /dev/null +++ b/src/sim/heatsource.ts @@ -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; + +/** + * 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'; + } +} diff --git a/src/sim/roasting.ts b/src/sim/roasting.ts index 30aa0ef..26e1d82 100644 --- a/src/sim/roasting.ts +++ b/src/sim/roasting.ts @@ -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;