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:
parent
9e315bd7a9
commit
b76820b88e
193
OPUS-BUILD-BRIEF-HEAT.md
Normal file
193
OPUS-BUILD-BRIEF-HEAT.md
Normal 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: **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.
|
||||
73
src/dev.ts
73
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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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
210
src/sim/heatsource.ts
Normal 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 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';
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user