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