From 81c0151975772cdcb41bb1250106211e0572db9f Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 16:04:05 +1000 Subject: [PATCH] Heat M-H6 sim: charcoal is a Field you arrange, not a dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/sim/charcoal.ts — the fuel with no knob. bankCoals paints a 2D heat bed (a screaming zone + a cool lee); zoneDecayStep only cools it; food sears by the coal heat beneath it; a rendering piece over hot coals flares and chars until you move it. One bed, two outcomes by placement. Verified (t.zoneStdev/zoneDecay/grillTwoZones/grillFlareDodge): - two-zone bed stdev 0.303 (bar >0.25) — a real gradient. - bed ceiling monotone-decays 1.68->0.61 over 90s (sear early, hold late). - hot-zone piece sears 0.72 while cool-lee piece holds at 0. - FLARE DODGE: left over coals = done 2.12 / 46 flares (carbon); moved to the lee in time = 0.66 clean sear / 0 flares. Active play is the floor. Co-Authored-By: Claude Fable 5 --- src/dev.ts | 79 ++++++++++++++++++ src/sim/charcoal.ts | 195 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 src/sim/charcoal.ts diff --git a/src/dev.ts b/src/dev.ts index 9d71110..e2f08ce 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -6,6 +6,7 @@ import type { IngredientId } from './sim/ingredients'; 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'; +import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -514,6 +515,84 @@ export function installDevHarness(app: App, game: Game): void { return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) }; }, + // ---- M-H6: charcoal is a Field, not a dial. Pure-sim probes for the bed + // gradient, the monotonic decay, and one-bed-two-outcomes. ---- + + /** Bank a two-zone bed (hot left, cool right); return the bed's heat stdev. + * A real gradient reads > 0.25 (bar). */ + zoneStdev() { + const s = newGrillSession(); + // Pile coals hot under the left third; leave the right cool. + bankCoals(s, 0.25, 0.5, 0.22, 1.6); + bankCoals(s, 0.25, 0.5, 0.14, 1.0); + const d = s.zone.data; + let sum = 0; + let max = 0; + for (let i = 0; i < d.length; i++) { + sum += d[i]; + if (d[i] > max) max = d[i]; + } + const mean = sum / d.length; + let acc = 0; + for (let i = 0; i < d.length; i++) acc += (d[i] - mean) ** 2; + return { stdev: +Math.sqrt(acc / d.length).toFixed(3), max: +max.toFixed(2), mean: +mean.toFixed(3) }; + }, + + /** The bed only cools: sample the ceiling over a 90s service, assert monotone. + * Empty grate — food's own flares are a separate, local spike (tested below). */ + zoneDecay() { + const s = newGrillSession([]); + bankCoals(s, 0.5, 0.5, 0.3, 1.7); + const samples: number[] = []; + let monotone = true; + let prev = bedCeiling(s); + for (let sec = 0; sec < 90; sec++) { + for (let i = 0; i < 60; i++) grillStep(s, 1 / 60); + const c = bedCeiling(s); + if (c > prev + 1e-6) monotone = false; + samples.push(+c.toFixed(2)); + prev = c; + } + return { monotone, start: samples[0], at30: samples[29], at90: samples[89] }; + }, + + /** One bed, two outcomes: sear a piece over the hot zone, hold one on the lee. */ + grillTwoZones(secs = 20) { + const s = newGrillSession(['sear_me', 'hold_me']); + bankCoals(s, 0.25, 0.5, 0.2, 1.7); // hot left + placeFood(s, 0, 0.25, 0.5); // over the coals + placeFood(s, 1, 0.8, 0.5); // on the cool lee + for (let i = 0; i < Math.round(secs * 60); i++) grillStep(s, 1 / 60); + const r = (n: number) => +n.toFixed(3); + return { + seared: { sear: r(s.foods[0].sear), flareChar: r(s.foods[0].flareChar) }, + held: { sear: r(s.foods[1].sear), flareChar: r(s.foods[1].flareChar) }, + result: { mean: r(grillResult(s).mean), stdev: r(grillResult(s).stdev), flares: s.flares }, + }; + }, + + /** The flare + dodge: leave a fatty piece over the coals (chars) vs move it + * to the lee partway (survives) — the dodge must be a real defense. */ + grillFlareDodge(secs = 30, dodgeAt = 12) { + const stay = newGrillSession(['left']); + bankCoals(stay, 0.3, 0.5, 0.2, 1.7); + placeFood(stay, 0, 0.3, 0.5); + for (let i = 0; i < Math.round(secs * 60); i++) grillStep(stay, 1 / 60); + + const dodge = newGrillSession(['left']); + bankCoals(dodge, 0.3, 0.5, 0.2, 1.7); + placeFood(dodge, 0, 0.3, 0.5); + for (let i = 0; i < Math.round(secs * 60); i++) { + if (i === Math.round(dodgeAt * 60)) placeFood(dodge, 0, 0.8, 0.5); // to the lee + grillStep(dodge, 1 / 60); + } + const r = (n: number) => +n.toFixed(3); + return { + stayed: { done: r(stay.foods[0].sear + stay.foods[0].flareChar), flares: stay.flares }, + dodged: { done: r(dodge.foods[0].sear + dodge.foods[0].flareChar), flares: dodge.flares }, + }; + }, + /** 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. */ diff --git a/src/sim/charcoal.ts b/src/sim/charcoal.ts new file mode 100644 index 0000000..a17765d --- /dev/null +++ b/src/sim/charcoal.ts @@ -0,0 +1,195 @@ +import { Field } from '../core/field'; +import { Rng } from '../core/rng'; +import { type Environment, newOutdoorEnv, envStep } from './heatsource'; + +/** + * THE CHARCOAL GRILL — the fuel with no knob. + * + * Gas, electric and induction all chase a dial. Charcoal doesn't. You don't SET + * a charcoal grill's temperature — you ARRANGE it. Bank the coals hot under one + * half and leave the other cool, and now the grate is a gradient: a screaming + * zone to sear over and a lee to park on and hold. That gradient is a 2D Field + * you paint (`bankCoals`), and it only cools from there (`zoneDecayStep`, the bed + * "sear early, hold late"). Fat renders off a searing piece, drips into the coals, + * and FLARES — a local spike that chars whatever sits above it until you move it. + * The lid traps the heat (shelters the bed); the fan (`stoke`) fights the decline. + * + * One bed, two outcomes, chosen by placement. Pure — Field + Rng — so the harness + * grills headless and reads the judge's exact numbers. + */ + +const N = 48; + +/** Where the coals top out when freshly banked. */ +export const BED_MAX = 1.8; +/** Sear past here renders fat that can flare. */ +export const RENDER_AT = 0.45; +/** A good sear lives in this window; past it is char, then carbon. */ +export const SEAR_LO = 0.55; +export const SEAR_HI = 0.8; +export const CHAR_AT = 0.9; + +/** Sear climbs this fast per unit of coal heat beneath the piece. Tuned so a good + * sear over a fresh (~1.7) bed takes ~18s and chars by ~28s — the oven's timescale. */ +const SEAR_RATE = 0.022; +/** The whole bed cools this fast (units/sec of the ceiling) — slow: sear early. */ +const DECAY = 0.012; +/** Coals hotter than this can ignite dripping fat. */ +const FLARE_HEAT = 0.8; +/** How fast a live flare scorches the piece above it (past honest searing). */ +const FLARE_CHAR_RATE = 0.6; + +export interface GrillFood { + id: string; + /** Position on the grate, 0..1 UV. */ + u: number; + v: number; + /** Top-side doneness, 0 raw .. 1 carbon. */ + sear: number; + /** How much a flare has scorched this piece beyond honest searing. */ + flareChar: number; + /** Rendered fat pooled under it — feeds flares. */ + fat: number; +} + +export interface GrillSession { + /** The coal bed: heat at every texel of the grate. You paint this. */ + zone: Field; + foods: GrillFood[]; + env: Environment; + seconds: number; + /** Wind-blown ash that has landed on the food — a mess, never a temp hit. */ + ash: number; + /** Flare incidents this session (for the judge and the tell). */ + flares: number; + rng: Rng; +} + +export interface GrillResult { + /** Mean sear over the pieces. */ + mean: number; + /** Evenness ACROSS pieces — a bed cooked without care reads high here. */ + stdev: number; + /** Fraction of pieces landed in the good sear window. */ + searedFrac: number; + /** Fraction charred past the window (flare or forgotten). */ + charFrac: number; + flares: number; + ash: number; +} + +export function newGrillSession(foodIds: string[] = ['steak', 'sausage', 'corn'], seed = 20260719, env?: Environment): GrillSession { + const zone = new Field(N); // starts cold — you bank the coals yourself + const rng = new Rng(seed); + const foods: GrillFood[] = foodIds.map((id, i) => ({ + id, + u: 0.2 + (i / Math.max(1, foodIds.length - 1)) * 0.6, + v: 0.5, + sear: 0, + flareChar: 0, + fat: 0, + })); + return { zone, foods, env: env ?? newOutdoorEnv(0.35), seconds: 0, ash: 0, flares: 0, rng }; +} + +/** + * Bank coals hotter under (u,v): pile the bed up in a region, clamped to BED_MAX. + * Paint the left half hot and the right stays cool and you've built a gradient — + * a searing zone and a holding zone. This is the whole "arrange it" verb. + */ +export function bankCoals(s: GrillSession, u: number, v: number, radius: number, amount: number): void { + s.zone.brush(u, v, radius, (i, w) => { + s.zone.data[i] = Math.min(BED_MAX, s.zone.data[i] + amount * w); + }); +} + +/** Fan the whole bed — fight the decline, at the cost of blowing ash about. */ +export function stoke(s: GrillSession, amount = 0.25): void { + const d = s.zone.data; + for (let i = 0; i < d.length; i++) if (d[i] > 0.05) d[i] = Math.min(BED_MAX, d[i] + amount); +} + +/** Move a piece across the grate — from the sear zone to the lee, or into trouble. */ +export function placeFood(s: GrillSession, index: number, u: number, v: number): void { + const f = s.foods[index]; + if (!f) return; + f.u = Math.max(0, Math.min(1, u)); + f.v = Math.max(0, Math.min(1, v)); +} + +/** The bed only cools. The ceiling falls on DECAY; the lid (sheltered) slows it. */ +export function zoneDecayStep(s: GrillSession, dt: number): void { + const rate = DECAY * (s.env.sheltered ? 0.4 : 1) * dt; + const d = s.zone.data; + for (let i = 0; i < d.length; i++) if (d[i] > 0) d[i] = Math.max(0, d[i] - rate); +} + +/** + * Advance the grill one tick: the bed cools, each piece sears by the coal heat + * beneath it, a rendering piece drips fat that FLARES the coals under it (a spike + * that chars it fast — move it or lose it), and outdoor wind lands ash. + */ +export function grillStep(s: GrillSession, dt: number): void { + zoneDecayStep(s, dt); + envStep(s.env, dt); + + for (const f of s.foods) { + const heat = s.zone.sample(f.u, f.v); + // Sear climbs with the coal heat under the piece. Cool lee = barely moves. + f.sear = Math.min(1.2, f.sear + SEAR_RATE * heat * dt); + + // A rendering piece over genuinely hot coals drips fat that IGNITES. The + // flames scorch the piece directly (not via the bed, which would just + // saturate) — a runaway you only stop by moving off the hot coals. + if (f.sear > RENDER_AT && heat > FLARE_HEAT) { + f.fat = Math.min(1, f.fat + 0.5 * dt); + const flareInt = f.fat * (heat - FLARE_HEAT); // real only over hot coals with pooled fat + if (flareInt > 0.12) { + f.flareChar = Math.min(1, f.flareChar + FLARE_CHAR_RATE * flareInt * dt); + // a small visible lick in the coals under it (for the scene glow) + s.zone.brush(f.u, f.v, 0.05, (i, w) => { + s.zone.data[i] = Math.min(BED_MAX, s.zone.data[i] + 0.25 * flareInt * dt * w); + }); + if (f.fat > 0.6 && s.rng.next() < 3 * dt) s.flares++; + } + } else { + f.fat = Math.max(0, f.fat - 0.4 * dt); // in the lee: fat drains, flames die + } + } + + // Outdoor gusts blow ash onto the food — a mess, not a temperature hit. + if (s.env.place === 'outdoor') s.ash += s.env.gust * 0.04 * dt; + s.seconds += dt; +} + +/** The effective doneness of a piece: honest sear plus flare scorch. */ +function doneness(f: GrillFood): number { + return Math.min(1.2, f.sear + f.flareChar); +} + +export function grillResult(s: GrillSession): GrillResult { + const vals = s.foods.map(doneness); + const mean = vals.reduce((a, b) => a + b, 0) / Math.max(1, vals.length); + const stdev = Math.sqrt(vals.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, vals.length)); + const searedFrac = vals.filter((v) => v >= SEAR_LO && v < CHAR_AT).length / Math.max(1, vals.length); + const charFrac = vals.filter((v) => v >= CHAR_AT).length / Math.max(1, vals.length); + return { mean, stdev, searedFrac, charFrac, flares: s.flares, ash: s.ash }; +} + +/** The live-readout word, in the judge's thresholds. */ +export function grillWord(r: GrillResult): string { + if (r.charFrac > 0.34) return 'carbon'; + if (r.flares > 2) return 'flaring up'; + if (r.mean < 0.3) return 'still raw'; + if (r.mean < SEAR_LO) return 'coming up'; + if (r.searedFrac > 0.6) return 'a proper sear'; + return 'unevenly done'; +} + +/** The single hottest coal on the bed — the ceiling that only falls. */ +export function bedCeiling(s: GrillSession): number { + let max = 0; + const d = s.zone.data; + for (let i = 0; i < d.length; i++) if (d[i] > max) max = d[i]; + return max; +}