Compare commits

...

4 Commits

Author SHA1 Message Date
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
type-two
ff1c3deafc Heat M-H6 scene: the charcoal grill, top-down and glowing
scenes/grill.ts (GrillView) — the coal bed rendered as an additive glow
texture from the zone Field, food pieces you drag between the sear zone
and the cool lee, flares that lick up when fat renders over hot coals.
Registered in game.ts (hidden, in hideAllScenes, grillView getter).
Harness t.grillDemo drives the real scene end-to-end.

Verified in-browser: banked a bright hot-left bed, placed food across the
gradient, cooked through the real View — bed glows, food browns, flares
fire. Screenshot confirms the two-zone gradient reads at a glance.
Framing fix: glow above the grate, camera overhead, low open bowl (the
kettle GLB is a closed dome that occludes a top-down cook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:14:04 +10:00
type-two
81c0151975 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>
2026-07-19 16:04:05 +10:00
type-two
e6509479d2 Heat M-H3 (environment) + M-H5 noise: wind, the windbreak floor, the ZAP
M-H3 environment sim: newOutdoorEnv/addSmoke/SMOKE_ZAP. Wind perturbs a
flame's output AND steals its heat; a windbreak (sheltered) forces it
flat; induction shrugs wind off; indoor smoke trips the ZAP exactly once.
Verified: windy gas stdev 0.2635 mean 5.66, sheltered stdev 0.0065 mean
6.99, induction stdev 0, zap fires 1x indoors / 0x outdoors.

M-H5 noise half: cheap_electric hob stdev 0.1244 (>0.12) vs induction
0.0198 (<0.04) vs fan 0.0064 — cheap gear is Field noise, not a tax. The
'both reach 10/10' half waits on the M17 pan.

Harness: t.envVar, t.zapTest, t.hobNoise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:55:34 +10:00
10 changed files with 863 additions and 8 deletions

View File

@ -4,7 +4,11 @@
## 0. STATUS (update this section as you go — a cold session reads here first)
- **M-H1 + M-H2 DONE & LIVE (2026-07-19).** `src/sim/heatsource.ts` (the bone) + the golden-preserving oven retrofit + the gas-oven day are shipped. Verified in-browser: **golden** — no-source oven reads mean 0.698 / blister 1.0 / collapse 0, byte-identical to HEAD. **M-H1 chase 3→9** — gas 0.433s, electric 6.0s, induction 0.133s (distinct, no overlap). **Residual after 9→0** — gas 0 @1s, electric 6.6 @6s, charcoal 9 @30s. **M-H2** — gas oven stdev 0.1207 vs fan oven 0.0016 at equal mean 0.6001 (75× ratio; bar was 2×). Day 14 bruschetta now roasts on `oven_gas` and still grades **S (9.62)**, HUD shows "GAS OVEN · blue flame". Harness: `t.heatChase`, `t.residual`, `t.ovenStdev`, `t.ovenGolden`, `t.ovenDemo`. **NEXT: M-H3 (Environment/wind), M-H4 (pan/butter/flare — needs M17), M-H5 (economy SKUs), M-H6 (charcoal zone Field — the showpiece scene).** Everything below §2 not checked off here is unbuilt.
- **M-H1 + M-H2 DONE & LIVE (2026-07-19).** `src/sim/heatsource.ts` (the bone) + the golden-preserving oven retrofit + the gas-oven day are shipped. Verified in-browser: **golden** — no-source oven reads mean 0.698 / blister 1.0 / collapse 0, byte-identical to HEAD. **M-H1 chase 3→9** — gas 0.433s, electric 6.0s, induction 0.133s (distinct, no overlap). **Residual after 9→0** — gas 0 @1s, electric 6.6 @6s, charcoal 9 @30s. **M-H2** — gas oven stdev 0.1207 vs fan oven 0.0016 at equal mean 0.6001 (75× ratio; bar was 2×). Day 14 bruschetta now roasts on `oven_gas` and still grades **S (9.62)**, HUD shows "GAS OVEN · blue flame". Harness: `t.heatChase`, `t.residual`, `t.ovenStdev`, `t.ovenGolden`, `t.ovenDemo`.
- **M-H3 DONE (2026-07-19).** Environment sim (gust walk, flame perturbation, ferrous rejection, indoor smoke→ZAP). Verified: windy gas flame stdev 0.2635 (bar >0.05) and mean drops to 5.66 (wind steals heat); sheltered stdev 0.0065 (bar <0.02, the dodgeable floor); induction-in-wind stdev 0 (no flame); the ZAP fires exactly once indoors, never outdoors. `newOutdoorEnv`, `addSmoke`, `SMOKE_ZAP`. Harness: `t.envVar`, `t.zapTest`.
- **M-H5 (noise half) DONE.** cheap_electric hob stdev 0.1244 (bar >0.12) vs induction 0.0198 (bar <0.04) vs fan 0.0064. Harness: `t.hobNoise`. The "both reach 10/10" half needs the M17 pan to cook on deferred with it.
- **M-H6 DONE & LIVE (2026-07-19) — the charcoal grill, a heat-bed you arrange.** `src/sim/charcoal.ts` (bankCoals paints a 2D heat bed, zoneDecayStep only cools it, food sears by the coals beneath it, a rendering piece over hot coals flares and chars until you move it) + `src/scenes/grill.ts` (top-down open grill, the bed rendered as an additive glow, food dragged between the sear zone and the cool lee, flames lick up on flare) + the routed **day 15 BBQ order** with `judgeGrill` (THE SEAR / THE CHAR / THE GRILL) and the char-snob line bank. Verified: two-zone bed stdev 0.303; bed ceiling monotone-decays 1.68→0.61/90s; flare dodge — left over coals = done 2.08/31 flares (carbon), pulled to the lee = 0/0 clean; **the full day plays and grades S (9.4/10)** with careful sear-then-pull, F if you let it flare. Retune: RENDER_AT 0.62 (above the good-sear window's floor) so a clean sear is reachable with no flare — the floor holds (active play wins, neglect chars). Harness: `t.zoneStdev`, `t.zoneDecay`, `t.grillTwoZones`, `t.grillFlareDodge`, `t.grillDemo`, `t.grillDay`.
- **NEXT: M-H4 (pan/butter/flare) waits on M17 (Brief 3 — the pan itself). Environment bolts onto the grill next (wind/windbreak already in `heatsource.ts`, not yet wired to the grill's env).** Everything below §2 not checked off here is unbuilt.
- Presupposes the M16 salt doctrine and the M17 pan skeleton from Brief 3; those aren't in yet, so §2 ships as a standalone `heatsource.ts` (zero dependency on the pan) and M17 will read it from birth.
- **LIVE 🌐** https://partly.party/toastsim/ — `bash scripts/deploy.sh` (build `--base=/toastsim/` → rsync staging → docker cp into `forum-nginx`). Curl cache-busted after every deploy.
- Seed lane for this brief's assets: **680719** (open gap after P12's 630679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181219 for the general cast; the 680719 sprites (flames, glows, flare, ZAP) remain.

View File

@ -3,8 +3,10 @@ import type { App } from './core/app';
import type { Game } from './game/game';
import { sogWord } from './sim/assembly';
import type { IngredientId } from './sim/ingredients';
import { type Fuel, newHeatSource, setKnob, heatStep } from './sim/heatsource';
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
@ -493,6 +495,189 @@ export function installDevHarness(app: App, game: Game): void {
return { sku, mean: r(res.mean), stdev: r(res.stdev), seconds: +(f / 60).toFixed(1), output: +s.src!.output.toFixed(2) };
},
/** M-H3: hold a flame at knob 7 in wind for 8s; return the stdev of output.
* Outdoor unsheltered gusts perturb it (>0.05); sheltered forces it flat (<0.02). */
envVar(fuel: Fuel = 'gas', wind = 0.6, sheltered = false, secs = 8, dt = 1 / 60) {
const hs = newHeatSource(fuel);
const env = newOutdoorEnv(wind);
env.sheltered = sheltered;
setKnob(hs, 7);
// settle to target first, then sample under wind
for (let i = 0; i < 120; i++) heatStep(hs, dt, env);
const samples: number[] = [];
for (let i = 0; i < Math.round(secs / dt); i++) {
envStep(env, dt);
heatStep(hs, dt, env);
samples.push(hs.output);
}
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
const stdev = Math.sqrt(samples.reduce((a, b) => a + (b - mean) ** 2, 0) / samples.length);
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. */
hobNoise(sku = 'cheap_electric') {
const hs = newHeatSource(sku);
const n = 64;
const heat = new Float32Array(n * n);
applyToHeatMap(heat, n, hs, new Rng(42));
let sum = 0;
for (let i = 0; i < heat.length; i++) sum += heat[i];
const mean = sum / heat.length;
let acc = 0;
for (let i = 0; i < heat.length; i++) acc += (heat[i] - mean) ** 2;
return { sku, stdev: +Math.sqrt(acc / heat.length).toFixed(4) };
},
/** M-H3: pour smoke into an indoor env; the ZAP must fire exactly once. */
zapTest() {
const env = newIndoorEnv();
let fires = 0;
for (let i = 0; i < 40; i++) if (addSmoke(env, 0.05)) fires++;
// outdoor never zaps
const out = newOutdoorEnv(0.5);
let outFires = 0;
for (let i = 0; i < 40; i++) if (addSmoke(out, 0.05)) outFires++;
return { indoorFires: fires, outdoorFires: outFires, finalSmoke: +env.smoke.toFixed(2) };
},
/** Play the whole day-15 grill order: route in, bank a two-zone bed, sear
* each piece then move it to the lee, serve, and read the scorecard. */
grillDay() {
game.setDay(15);
run(5);
const g = game.grillView as unknown as { session: import('./sim/charcoal').GrillSession; result(): unknown };
const s = g.session;
if (!s) return { error: 'not at grill', view: 'other' };
// Bank hot on the left, cool on the right.
for (let k = 0; k < 45; k++) s.zone.brush(0.3, 0.5, 0.24, (i, w) => { s.zone.data[i] = Math.min(1.8, s.zone.data[i] + 0.12 * w); });
// Sear each piece over the coals, staggered, then shuffle it to the lee.
const lanes = [0.4, 0.5, 0.6];
for (let i = 0; i < s.foods.length; i++) { s.foods[i].u = 0.3; s.foods[i].v = lanes[i] ?? 0.5; }
for (let step = 0; step < 30; step++) {
// pull each piece to the lee the moment it hits a good sear — before its
// fat renders and flares (the whole technique).
for (const f of s.foods) if (f.sear >= 0.56 && f.u < 0.6) f.u = 0.82;
run(30); // half a second — tighter cadence so we catch the window
}
tap('Enter');
run(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, groups, rows };
},
/** Drive the live grill scene: bank a hot-left/cool-right bed, sear a piece
* over the coals then shuffle it to the lee, hold another on the cool side.
* For a screenshot + an end-to-end check of the real scene path. */
grillDemo() {
const view = game.grillView as unknown as {
reset(ids: string[], ask?: string): void;
result(): { mean: number; stdev: number; searedFrac: number; charFrac: number; flares: number };
};
const sess = () => (game.grillView as unknown as { session: import('./sim/charcoal').GrillSession }).session;
view.reset(['steak', 'snag']);
app.setView(game.grillView);
run(3);
const s = sess();
// Bank the coals hot on the left, cool on the right.
for (let i = 0; i < 30; i++) {
s.zone.brush(0.28, 0.5, 0.2, (idx: number, w: number) => {
s.zone.data[idx] = Math.min(1.8, s.zone.data[idx] + 0.12 * w);
});
}
// Place the steak over the coals, the snag on the lee.
s.foods[0].u = 0.28; s.foods[0].v = 0.5;
s.foods[1].u = 0.8; s.foods[1].v = 0.5;
run(60 * 14); // sear
s.foods[0].u = 0.8; // shuffle the steak to the lee before it flares out
run(60 * 4);
const res = view.result();
const r = (n: number) => +n.toFixed(3);
return { mean: r(res.mean), stdev: r(res.stdev), searedFrac: r(res.searedFrac), charFrac: r(res.charFrac), flares: res.flares };
},
/** Put the ACTUAL oven scene on screen wearing a fuel, and roast it for a
* live screenshot of the gas-oven vs fan-oven HUD. */
ovenDemo(fuel = 'oven_gas', seconds = 24, power = 8) {

View File

@ -5,6 +5,7 @@ import { SlicerView } from '../scenes/slicer';
import { PrepView } from '../scenes/prep';
import { JuiceView } from '../scenes/juice';
import { OvenView } from '../scenes/oven';
import { GrillView } from '../scenes/grill';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
@ -12,7 +13,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
@ -47,6 +48,7 @@ export class Game {
private prep: PrepView;
private juice: JuiceView;
private oven: OvenView;
private grill: GrillView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -81,6 +83,7 @@ export class Game {
this.prep = new PrepView(app);
this.juice = new JuiceView(app);
this.oven = new OvenView(app);
this.grill = new GrillView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -89,6 +92,7 @@ export class Game {
app.scene.add(this.prep.root);
app.scene.add(this.juice.root);
app.scene.add(this.oven.root);
app.scene.add(this.grill.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -96,6 +100,7 @@ export class Game {
this.prep.root.visible = false;
this.juice.root.visible = false;
this.oven.root.visible = false;
this.grill.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -186,6 +191,9 @@ export class Game {
get ovenView(): OvenView {
return this.oven;
}
get grillView(): GrillView {
return this.grill;
}
get assemblyView(): AssemblyView {
return this.assembly;
}
@ -352,6 +360,7 @@ export class Game {
this.prep.root.visible = false;
this.juice.root.visible = false;
this.oven.root.visible = false;
this.grill.root.visible = false;
this.assembly.root.visible = false;
}
@ -475,9 +484,45 @@ export class Game {
this.runNextPrep();
return;
}
// A grill day goes straight outdoors to the coals — no toaster at all.
if (this.order.grill) {
this.enterGrill();
return;
}
this.enterToastFlow();
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterGrill(): void {
this.grill.reset(this.order.grill!);
this.hideAllScenes();
this.grill.root.visible = true;
this.app.setView(this.grill);
this.ticket.show();
this.grill.onDone = (result) => {
this.grill.root.visible = false;
this.serveGrill(result);
};
}
private serveGrill(result: ReturnType<GrillView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeGrill(result, this.order, seconds);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
this.hideAllScenes();
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** The toaster half of the day: the loaf knife-trip, or straight to the bench. */
private enterToastFlow(): void {
if (this.order.fromLoaf) {

View File

@ -7,6 +7,8 @@ import type { RoastResult } from '../sim/roasting';
import { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly';
import type { GrillResult } from '../sim/charcoal';
import { grillWord } from '../sim/charcoal';
export interface Criterion {
key: string;
@ -19,7 +21,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -438,6 +440,59 @@ export function gradeOf(total: number): Grade {
return 'F';
}
// =====================================================================
// M-H6 — The charcoal grill. One bed you arranged, judged: did the pieces land
// a proper sear (not raw, not carbon), and did you keep the flares off them.
export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'sear',
group: 'grill',
label: 'The Sear',
// How many pieces landed the good window — an under-seared piece reads here,
// and an uneven bed (one great, one raw) reads in the spread.
score: clamp01(r.searedFrac * (1 - r.stdev * 0.35)),
weight: 1.4,
detail: `${grillWord(r)} (${Math.round(r.searedFrac * 100)}% well-seared)`,
},
{
key: 'grillchar',
group: 'grill',
label: 'The Char',
score: clamp01(1 - r.charFrac * 1.5),
weight: 1.0,
detail: r.charFrac > 0.02 ? `${Math.round(r.charFrac * 100)}% carbon` : 'no carbon',
},
{
key: 'grillbench',
group: 'bench',
label: 'The Grill',
score: clamp01(1 - r.flares * 0.1 - r.ash * 1.5),
weight: 0.7,
detail: r.flares > 0 ? `${r.flares} flare-up${r.flares > 1 ? 's' : ''}` : 'no flare-ups',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 55) / 90),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
void order;
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping /
// The Bench. Every row is a number some station computed — the cut and toast

View File

@ -300,6 +300,42 @@ const BANK: Bank = {
},
};
// The charcoal grill's rows — the deadpan char snob.
Object.assign(BANK, {
sear: {
bad: [
'There is no char on this. There is a memory of char. A rumour.',
'This never met the coals. It was in the same postcode as the coals.',
'One side is Sunday. The other side is a house fire. Commit to a Tuesday.',
'You had a screaming bed and a cool lee and you used neither. Bold. Wrong.',
'Raw. Not rare. Raw. There is a word for it and it is that one.',
],
good: [
'A proper sear. You arranged the coals and you moved with intent. I saw it.',
'That is a sear. Crust where crust belongs. I am almost moved.',
'You seared this over charcoal you built with genuine care. Almost.',
],
},
grillchar: {
bad: [
'Flare-up. You let the fat conduct the orchestra. It chose inferno.',
'This is carbon. Delicious, structural carbon. Inedible.',
'You watched it flare and you did nothing. We both watched. Only one of us learned.',
],
good: [
'No carbon. You dodged the flares. That is the whole discipline, right there.',
'Charred where it should be, nowhere it should not. Correct.',
],
},
grillbench: {
bad: [
'The flare-ups. I counted them. So did the smoke detector, spiritually.',
'Ash in the sear. The wind had opinions and you had no windbreak.',
],
good: ['A clean bed, no flares. You cooked, you did not fight a fire.', 'Tidy coals. Tidy cook.'],
},
});
// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the
// station owns its whole voice. Merge them in rather than duplicate them here.
Object.assign(BANK, JUICE_LINES);

View File

@ -96,6 +96,9 @@ export interface Order {
prep?: PrepStep[];
/** The bruschetta capstone: roast + assemble replace the spread flow. */
bruschetta?: BruschettaSpec;
/** The charcoal grill (M-H6): the food to cook over the coals. Presence sends
* the whole day to the outdoor grill instead of the toaster. */
grill?: string[];
}
export const BROWNING_NAMES: [number, string][] = [
@ -345,6 +348,23 @@ export const DAYS: Order[] = [
// order start; take the brie out early, leave the parmesan in.
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'], oven: 'oven_gas' },
},
{
day: 15,
brand: 'the bakery loaf',
bread: 'white',
// The grill day routes entirely to the outdoor coals — the toast defaults are
// never read, same as the bruschetta order.
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the bloke from two doors down',
text: "Chuck these on the barbie, love. Bank the coals hot one side, cool the other — sear 'em then move 'em off before they flare. A proper char, not a cremation.",
grill: ['steak', 'snag', 'corn'],
},
];
/** Days beyond the script — keep going, with everything cranked. */

283
src/scenes/grill.ts Normal file
View File

@ -0,0 +1,283 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import {
type GrillSession,
newGrillSession,
bankCoals,
placeFood,
stoke,
grillStep,
grillResult,
grillWord,
bedCeiling,
RENDER_AT,
CHAR_AT,
BED_MAX,
} from '../sim/charcoal';
import { newOutdoorEnv } from '../sim/heatsource';
import { el, Panel } from '../ui/hud';
const GRATE_Y = 0.5;
const GRATE_W = 3.6;
const GRATE_D = 2.2;
/**
* THE CHARCOAL GRILL the fuel with no knob, made playable.
*
* Drag on the grate to BANK coals (pile heat where you drag build a screaming
* zone and a cool lee). Drag a piece of food to move it across that gradient:
* sear it over the coals, then shuffle it to the lee before the dripping fat
* flares and chars it. The bed only cools. Sear early, hold late. Move it or
* lose it. ENTER serves.
*/
export class GrillView implements View {
readonly root = new THREE.Group();
private session: GrillSession | null = null;
private grate!: THREE.Mesh;
private bedTexData: Uint8Array;
private bedTex: THREE.DataTexture;
private foodMeshes: THREE.Mesh[] = [];
private flameMeshes: THREE.Mesh[] = [];
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private gratePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(GRATE_Y + 0.02));
private grabbed = -1;
private wasDown = false;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof grillResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
const n = 48;
this.bedTexData = new Uint8Array(n * n * 4);
this.bedTex = new THREE.DataTexture(this.bedTexData, n, n, THREE.RGBAFormat);
this.bedTex.minFilter = THREE.LinearFilter;
this.bedTex.magFilter = THREE.LinearFilter;
const glow = new THREE.Mesh(
new THREE.PlaneGeometry(GRATE_W, GRATE_D),
new THREE.MeshBasicMaterial({ map: this.bedTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }),
);
glow.rotation.x = -Math.PI / 2;
// Above the grate so the top-down camera sees the coals glowing through it.
glow.position.y = GRATE_Y + 0.03;
this.root.add(glow);
this.panel = new Panel('grill-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'GRILL IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
this.wordEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const ground = new THREE.Mesh(
new THREE.BoxGeometry(30, 0.4, 20),
new THREE.MeshStandardMaterial({ color: 0x5a6b3e, roughness: 0.95 }),
);
ground.position.y = -0.2;
ground.receiveShadow = true;
this.root.add(ground);
// The grate: dark bars the food sits on, over the coal bed.
this.grate = new THREE.Mesh(
new THREE.BoxGeometry(GRATE_W, 0.03, GRATE_D),
new THREE.MeshStandardMaterial({ color: 0x181818, roughness: 0.6, metalness: 0.5 }),
);
this.grate.position.y = GRATE_Y;
this.grate.receiveShadow = true;
this.root.add(this.grate);
// A low open bowl to hold the coals — we cook top-down with the lid off, so
// a shallow rim reads better than the full kettle GLB (which is a dome that
// occludes the grate from above; kept for a future side-on shot).
const bowl = new THREE.Mesh(
new THREE.CylinderGeometry(GRATE_W * 0.6, GRATE_W * 0.42, 0.36, 28, 1, true),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.8, metalness: 0.3, side: THREE.DoubleSide }),
);
bowl.position.y = GRATE_Y - 0.2;
bowl.receiveShadow = true;
this.root.add(bowl);
const legs = new THREE.Mesh(
new THREE.CylinderGeometry(GRATE_W * 0.5, GRATE_W * 0.5, 0.5, 3, 1, true),
new THREE.MeshStandardMaterial({ color: 0x141414, roughness: 0.9 }),
);
legs.position.y = 0.0;
this.root.add(legs);
}
/** A grill of `foodIds` and a fresh cold bed you build yourself. */
reset(foodIds = ['steak', 'sausage', 'corn'], askText = 'grill it — sear then move it to the lee'): void {
this.session = newGrillSession(foodIds, 20260719, newOutdoorEnv(0.35));
this.grabbed = -1;
for (const m of this.foodMeshes) this.root.remove(m);
for (const m of this.flameMeshes) this.root.remove(m);
this.foodMeshes = [];
this.flameMeshes = [];
for (const f of this.session.foods) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(0.34, 0.14, 0.24),
new THREE.MeshStandardMaterial({ color: 0xc98a6a, roughness: 0.7 }),
);
mesh.castShadow = true;
// Start off to the side, on the cool rail — you drag them onto the coals.
f.u = 0.5;
f.v = 1.15; // off the grate (v>1) until placed
this.root.add(mesh);
this.foodMeshes.push(mesh);
const flame = new THREE.Mesh(
new THREE.ConeGeometry(0.12, 0.4, 8),
new THREE.MeshBasicMaterial({ color: 0xff7a1a, transparent: true, opacity: 0 }),
);
this.root.add(flame);
this.flameMeshes.push(flame);
}
this.askEl.textContent = askText;
this.hintEl.textContent = 'DRAG the grate to bank coals · DRAG food across the heat · ENTER serves';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
private foodWorld(u: number, v: number): THREE.Vector3 {
return new THREE.Vector3(u * GRATE_W - GRATE_W / 2, GRATE_Y + 0.09, v * GRATE_D - GRATE_D / 2);
}
private uvFromWorld(p: THREE.Vector3): { u: number; v: number } {
return { u: (p.x + GRATE_W / 2) / GRATE_W, v: (p.z + GRATE_D / 2) / GRATE_D };
}
update(dt: number): void {
// High and a touch forward — you look down INTO the open grill at the coals.
this.app.camera.position.set(0, 4.3, 1.9);
this.app.camera.lookAt(0, GRATE_Y, 0.05);
const s = this.session;
if (!s) return;
const inp = this.app.input;
this.ray.setFromCamera(inp.ndc, this.app.camera);
const onPlane = this.ray.ray.intersectPlane(this.gratePlane, this.hit);
if (inp.down && onPlane) {
const { u, v } = this.uvFromWorld(this.hit);
if (!this.wasDown) {
// Start of a drag: grab the nearest food if we're on one, else bank coals.
this.grabbed = this.nearestFood(u, v, 0.12);
}
if (this.grabbed >= 0) {
placeFood(s, this.grabbed, u, v);
} else {
// Bank coals: pile heat where you drag (clamped by the sim).
bankCoals(s, u, v, 0.16, 2.4 * dt * 6);
}
}
if (!inp.down) this.grabbed = -1;
this.wasDown = inp.down;
if (inp.justPressed('KeyF')) {
stoke(s);
audio.clatter(0.4, 1.4);
}
if (inp.justPressed('KeyL')) {
s.env.sheltered = !s.env.sheltered;
}
grillStep(s, dt);
this.syncBed();
this.syncFood();
this.updateHud();
if (inp.justPressed('Enter')) {
const cb = this.onDone;
this.onDone = null;
cb?.(grillResult(s));
}
}
private nearestFood(u: number, v: number, r: number): number {
const s = this.session!;
let best = -1;
let bd = r;
for (let i = 0; i < s.foods.length; i++) {
const d = Math.hypot(s.foods[i].u - u, s.foods[i].v - v);
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}
/** Paint the coal bed into the glow texture — dark ember to white-hot. */
private syncBed(): void {
const d = this.session!.zone.data;
const px = this.bedTexData;
for (let i = 0; i < d.length; i++) {
const h = Math.min(1, d[i] / BED_MAX);
// ember → orange → yellow-white
px[i * 4] = Math.round(60 + 195 * h);
px[i * 4 + 1] = Math.round(10 + 150 * h * h);
px[i * 4 + 2] = Math.round(10 + 120 * h * h * h);
px[i * 4 + 3] = Math.round(Math.min(1, h * 1.3) * 235);
}
this.bedTex.needsUpdate = true;
}
private syncFood(): void {
const s = this.session!;
for (let i = 0; i < s.foods.length; i++) {
const f = s.foods[i];
const mesh = this.foodMeshes[i];
mesh.position.copy(this.foodWorld(f.u, f.v));
// Colour by doneness: raw pink → seared brown → black char.
const done = Math.min(1, f.sear + f.flareChar);
const c = new THREE.Color();
if (done < RENDER_AT) c.setRGB(0.79, 0.54, 0.42);
else if (done < CHAR_AT) c.lerpColors(new THREE.Color(0.5, 0.32, 0.2), new THREE.Color(0.25, 0.15, 0.09), (done - RENDER_AT) / (CHAR_AT - RENDER_AT));
else c.setRGB(0.08, 0.07, 0.06);
(mesh.material as THREE.MeshStandardMaterial).color.copy(c);
// A flame licks up when the fat's flaring.
const flame = this.flameMeshes[i];
flame.position.copy(mesh.position).setY(GRATE_Y + 0.28);
(flame.material as THREE.MeshBasicMaterial).opacity = f.fat > 0.4 && this.session!.zone.sample(f.u, f.v) > 0.8 ? Math.min(0.85, f.fat) : 0;
flame.scale.setScalar(0.7 + f.fat * 0.6);
}
}
private updateHud(): void {
const s = this.session!;
const r = grillResult(s);
this.stateEl.textContent = `coals ${bedCeiling(s).toFixed(1)}${s.env.sheltered ? ' · lid on' : ''} · ${Math.round(s.seconds)}s${s.flares > 0 ? ` · ${s.flares} flare${s.flares > 1 ? 's' : ''}` : ''}`;
const word = grillWord(r);
this.wordEl.textContent = `the grill: ${word}`;
this.wordEl.style.color = r.charFrac > 0.2 ? '#e2603a' : r.searedFrac > 0.6 ? '#8fce8f' : '';
}
result() {
return this.session ? grillResult(this.session) : { mean: 0, stdev: 0, searedFrac: 0, charFrac: 0, flares: 0, ash: 0 };
}
dispose(): void {
this.panel.dispose();
}
}

View File

@ -103,7 +103,13 @@ export class JudgeView implements View {
slice.mesh.rotation.set(0, 0, 0);
slice.mesh.scale.setScalar(1.55);
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
// Grill and bruschetta days don't have a browning/spread to name — show who
// asked and what for, not the unread toast defaults.
this.orderEl.textContent = order.grill
? `Day ${order.day} · ${order.who} asked for the barbie`
: order.bruschetta
? `Day ${order.day} · ${order.who} asked for the bruschetta`
: `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
clear(this.cardEl);
// On prep orders the card is long enough to need signposts: group rows by
@ -111,7 +117,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some(
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench',
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -119,9 +125,10 @@ export class JudgeView implements View {
spread: 'THE SPREAD',
bread: 'THE BREAD',
topping: 'THE TOPPING',
grill: 'THE GRILL',
bench: 'THE BENCH',
};
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, spread: 4, bench: 5 };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

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

@ -0,0 +1,197 @@
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;
}

View File

@ -63,9 +63,14 @@ export interface Environment {
vesselFerrous: boolean;
/** indoor only: burnt food accumulates it → fires the ZAP once. */
smoke: number;
/** latches true when the smoke alarm has already gone off (fires exactly once). */
zapped: boolean;
rng: Rng;
}
/** Smoke past this trips the alarm. */
export const SMOKE_ZAP = 1.0;
type Profile = Omit<HeatSource, 'fuel' | 'target' | 'output' | 'zone'>;
/**
@ -115,7 +120,25 @@ export function newHeatSource(fuelOrSku: Fuel | string): HeatSource {
}
export function newIndoorEnv(seed = 7): Environment {
return { place: 'indoor', ambient: 0, wind: 0, gust: 0, sheltered: true, vesselFerrous: true, smoke: 0, rng: new Rng(seed) };
return { place: 'indoor', ambient: 0, wind: 0, gust: 0, sheltered: true, vesselFerrous: true, smoke: 0, zapped: false, rng: new Rng(seed) };
}
/** An outdoor bench: wind blows, nothing shelters it until you raise a break or
* drop a lid (`sheltered = true`). A cold day lowers `ambient`. */
export function newOutdoorEnv(wind = 0.4, seed = 11): Environment {
return { place: 'outdoor', ambient: 0, wind, gust: 0, sheltered: false, vesselFerrous: true, smoke: 0, zapped: false, rng: new Rng(seed) };
}
/** Burnt butter and charred food make smoke indoors only (outdoors it drifts).
* Returns true the ONE frame the alarm trips, so the caller fires the ZAP once. */
export function addSmoke(env: Environment, amt: number): boolean {
if (env.place !== 'indoor' || amt <= 0) return false;
env.smoke += amt;
if (env.smoke >= SMOKE_ZAP && !env.zapped) {
env.zapped = true;
return true;
}
return false;
}
const INDOOR = newIndoorEnv();