Heat M-H3 (environment) + M-H5 noise: wind, the windbreak floor, the ZAP

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 15:55:34 +10:00
parent f6404586cd
commit e6509479d2
3 changed files with 78 additions and 3 deletions

View File

@ -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: **680719** (open gap after P12's 630679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181219 for the general cast; the 680719 sprites (flames, glows, flare, ZAP) remain.

View File

@ -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) {

View File

@ -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<HeatSource, 'fuel' | 'target' | 'output' | 'zone'>;
/**
@ -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();