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:
parent
e6509479d2
commit
81c0151975
79
src/dev.ts
79
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. */
|
||||
|
||||
195
src/sim/charcoal.ts
Normal file
195
src/sim/charcoal.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user