Heat M-H6 sim: charcoal is a Field you arrange, not a dial

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 16:04:05 +10:00
parent e6509479d2
commit 81c0151975
2 changed files with 274 additions and 0 deletions

View File

@ -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. */

195
src/sim/charcoal.ts Normal file
View File

@ -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;
}