toastsim/src/sim/charcoal.ts
type-two 94a5f2f293 Heat M-H6 shipped: the charcoal grill is a playable day (day 15)
Routed the grill into a real order: day 15 'chuck these on the barbie'
sends the whole day outdoors — bank the coals hot one side / cool the
other, sear each piece then move it to the lee before the fat flares,
serve. judgeGrill scores THE SEAR / THE CHAR / THE GRILL with a char-snob
line bank ('There is no char on this. There is a memory of char. A
rumour.'). New scorecard group THE GRILL + grill/bruschetta order headers.

Balance retune (the floor): RENDER_AT 0.62 lifted above the good-sear
window so a clean sear is reachable with NO flare — sear-then-pull is a
reliable win, only overcooking invites the flames.

Verified end-to-end in-browser: day 15 routes to the grill; careful
sear-and-pull grades S (9.4/10), 100% well-seared, no carbon, no flares;
leaving pieces over the coals chars to carbon (0.9/10, 31 flares). All
M-H6 sim bars still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:27:54 +10:00

198 lines
7.8 KiB
TypeScript

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;
/** A good sear lives in this window; past it is char, then carbon. */
export const SEAR_LO = 0.5;
export const SEAR_HI = 0.78;
export const CHAR_AT = 0.9;
/** Sear past here renders fat that can flare — set ABOVE the good window's floor
* so a clean sear is reachable with no flare (the floor: sear, then pull). Only
* pushing a piece toward the top of the window and past it invites the flames. */
export const RENDER_AT = 0.62;
/** 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.45;
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;
}