From e6509479d2410dbab2d23aad889fca2ccd67b1d8 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 15:55:34 +1000 Subject: [PATCH] Heat M-H3 (environment) + M-H5 noise: wind, the windbreak floor, the ZAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-H3 environment sim: newOutdoorEnv/addSmoke/SMOKE_ZAP. Wind perturbs a flame's output AND steals its heat; a windbreak (sheltered) forces it flat; induction shrugs wind off; indoor smoke trips the ZAP exactly once. Verified: windy gas stdev 0.2635 mean 5.66, sheltered stdev 0.0065 mean 6.99, induction stdev 0, zap fires 1x indoors / 0x outdoors. M-H5 noise half: cheap_electric hob stdev 0.1244 (>0.12) vs induction 0.0198 (<0.04) vs fan 0.0064 — cheap gear is Field noise, not a tax. The 'both reach 10/10' half waits on the M17 pan. Harness: t.envVar, t.zapTest, t.hobNoise. Co-Authored-By: Claude Fable 5 --- OPUS-BUILD-BRIEF-HEAT.md | 5 +++- src/dev.ts | 51 +++++++++++++++++++++++++++++++++++++++- src/sim/heatsource.ts | 25 +++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/OPUS-BUILD-BRIEF-HEAT.md b/OPUS-BUILD-BRIEF-HEAT.md index 044f8cb..59d5f32 100644 --- a/OPUS-BUILD-BRIEF-HEAT.md +++ b/OPUS-BUILD-BRIEF-HEAT.md @@ -4,7 +4,10 @@ ## 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. +- **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`. +- **M-H3 DONE (2026-07-19).** Environment sim (gust walk, flame perturbation, ferrous rejection, indoor smoke→ZAP). Verified: windy gas flame stdev 0.2635 (bar >0.05) and mean drops to 5.66 (wind steals heat); sheltered stdev 0.0065 (bar <0.02, the dodgeable floor); induction-in-wind stdev 0 (no flame); the ZAP fires exactly once indoors, never outdoors. `newOutdoorEnv`, `addSmoke`, `SMOKE_ZAP`. Harness: `t.envVar`, `t.zapTest`. +- **M-H5 (noise half) DONE.** cheap_electric hob stdev 0.1244 (bar >0.12) vs induction 0.0198 (bar <0.04) vs fan 0.0064. Harness: `t.hobNoise`. The "both reach 10/10" half needs the M17 pan to cook on — deferred with it. +- **NEXT: M-H6 (charcoal — a heat-bed you arrange: the showpiece outdoor grill scene). M-H4 (pan/butter/flare) waits on M17 (Brief 3).** 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. diff --git a/src/dev.ts b/src/dev.ts index 62fe105..9d71110 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -3,8 +3,9 @@ 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 { type Fuel, newHeatSource, setKnob, heatStep, envStep, newOutdoorEnv, addSmoke, newIndoorEnv, applyToHeatMap } from './sim/heatsource'; import { newRoastSession, roastStep, roastResult } from './sim/roasting'; +import { Rng } from './core/rng'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -493,6 +494,54 @@ export function installDevHarness(app: App, game: Game): void { return { sku, mean: r(res.mean), stdev: r(res.stdev), seconds: +(f / 60).toFixed(1), output: +s.src!.output.toFixed(2) }; }, + /** M-H3: hold a flame at knob 7 in wind for 8s; return the stdev of output. + * Outdoor unsheltered gusts perturb it (>0.05); sheltered forces it flat (<0.02). */ + envVar(fuel: Fuel = 'gas', wind = 0.6, sheltered = false, secs = 8, dt = 1 / 60) { + const hs = newHeatSource(fuel); + const env = newOutdoorEnv(wind); + env.sheltered = sheltered; + setKnob(hs, 7); + // settle to target first, then sample under wind + for (let i = 0; i < 120; i++) heatStep(hs, dt, env); + const samples: number[] = []; + for (let i = 0; i < Math.round(secs / dt); i++) { + envStep(env, dt); + heatStep(hs, dt, env); + samples.push(hs.output); + } + const mean = samples.reduce((a, b) => a + b, 0) / samples.length; + const stdev = Math.sqrt(samples.reduce((a, b) => a + (b - mean) ** 2, 0) / samples.length); + return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) }; + }, + + /** M-H5 (noise half): a SKU's hot-spot noise = the stdev of its heat map. + * A cheap thin electric hob is blotchy (>0.12); induction is glassy-flat + * (<0.04). The "both still reach 10/10" half awaits the M17 pan. */ + hobNoise(sku = 'cheap_electric') { + const hs = newHeatSource(sku); + const n = 64; + const heat = new Float32Array(n * n); + applyToHeatMap(heat, n, hs, new Rng(42)); + let sum = 0; + for (let i = 0; i < heat.length; i++) sum += heat[i]; + const mean = sum / heat.length; + let acc = 0; + for (let i = 0; i < heat.length; i++) acc += (heat[i] - mean) ** 2; + return { sku, stdev: +Math.sqrt(acc / heat.length).toFixed(4) }; + }, + + /** M-H3: pour smoke into an indoor env; the ZAP must fire exactly once. */ + zapTest() { + const env = newIndoorEnv(); + let fires = 0; + for (let i = 0; i < 40; i++) if (addSmoke(env, 0.05)) fires++; + // outdoor never zaps + const out = newOutdoorEnv(0.5); + let outFires = 0; + for (let i = 0; i < 40; i++) if (addSmoke(out, 0.05)) outFires++; + return { indoorFires: fires, outdoorFires: outFires, finalSmoke: +env.smoke.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) { diff --git a/src/sim/heatsource.ts b/src/sim/heatsource.ts index 8ab35a2..88bf9bb 100644 --- a/src/sim/heatsource.ts +++ b/src/sim/heatsource.ts @@ -63,9 +63,14 @@ export interface Environment { vesselFerrous: boolean; /** indoor only: burnt food accumulates it → fires the ZAP once. */ smoke: number; + /** latches true when the smoke alarm has already gone off (fires exactly once). */ + zapped: boolean; rng: Rng; } +/** Smoke past this trips the alarm. */ +export const SMOKE_ZAP = 1.0; + type Profile = Omit; /** @@ -115,7 +120,25 @@ export function newHeatSource(fuelOrSku: Fuel | string): HeatSource { } 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) }; + return { place: 'indoor', ambient: 0, wind: 0, gust: 0, sheltered: true, vesselFerrous: true, smoke: 0, zapped: false, rng: new Rng(seed) }; +} + +/** An outdoor bench: wind blows, nothing shelters it until you raise a break or + * drop a lid (`sheltered = true`). A cold day lowers `ambient`. */ +export function newOutdoorEnv(wind = 0.4, seed = 11): Environment { + return { place: 'outdoor', ambient: 0, wind, gust: 0, sheltered: false, vesselFerrous: true, smoke: 0, zapped: false, rng: new Rng(seed) }; +} + +/** Burnt butter and charred food make smoke — indoors only (outdoors it drifts). + * Returns true the ONE frame the alarm trips, so the caller fires the ZAP once. */ +export function addSmoke(env: Environment, amt: number): boolean { + if (env.place !== 'indoor' || amt <= 0) return false; + env.smoke += amt; + if (env.smoke >= SMOKE_ZAP && !env.zapped) { + env.zapped = true; + return true; + } + return false; } const INDOOR = newIndoorEnv();