Compare commits
4 Commits
f6404586cd
...
94a5f2f293
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a5f2f293 | ||
|
|
ff1c3deafc | ||
|
|
81c0151975 | ||
|
|
e6509479d2 |
@ -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: **680–719** (open gap after P12's 630–679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181–219 for the general cast; the 680–719 sprites (flames, glows, flare, ZAP) remain.
|
||||
|
||||
187
src/dev.ts
187
src/dev.ts
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
283
src/scenes/grill.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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
197
src/sim/charcoal.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user