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>
This commit is contained in:
type-two 2026-07-19 16:27:54 +10:00
parent ff1c3deafc
commit 94a5f2f293
8 changed files with 195 additions and 11 deletions

View File

@ -7,7 +7,8 @@
- **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-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-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-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.
- **NEXT: M-H6 (charcoal — a heat-bed you arrange: the showpiece outdoor grill scene). M-H4 (pan/butter/flare) waits on M17 (Brief 3).** Everything below §2 not checked off here is unbuilt. - **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. - 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. - **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. - 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

@ -621,6 +621,33 @@ export function installDevHarness(app: App, game: Game): void {
return { indoorFires: fires, outdoorFires: outFires, finalSmoke: +env.smoke.toFixed(2) }; 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 /** 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. * 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. */ * For a screenshot + an end-to-end check of the real scene path. */

View File

@ -13,7 +13,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock'; import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting'; import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; 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 { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -484,9 +484,45 @@ export class Game {
this.runNextPrep(); this.runNextPrep();
return; return;
} }
// A grill day goes straight outdoors to the coals — no toaster at all.
if (this.order.grill) {
this.enterGrill();
return;
}
this.enterToastFlow(); 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. */ /** The toaster half of the day: the loaf knife-trip, or straight to the bench. */
private enterToastFlow(): void { private enterToastFlow(): void {
if (this.order.fromLoaf) { 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 { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; import type { AssemblyResult } from '../sim/assembly';
import { MASS_BAND, RUB_TARGET, sogWord } 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 { export interface Criterion {
key: string; key: string;
@ -19,7 +21,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders. /** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */ * 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. */ /** 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'; 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 / // M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping /
// The Bench. Every row is a number some station computed — the cut and toast // 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 // 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. // station owns its whole voice. Merge them in rather than duplicate them here.
Object.assign(BANK, JUICE_LINES); Object.assign(BANK, JUICE_LINES);

View File

@ -96,6 +96,9 @@ export interface Order {
prep?: PrepStep[]; prep?: PrepStep[];
/** The bruschetta capstone: roast + assemble replace the spread flow. */ /** The bruschetta capstone: roast + assemble replace the spread flow. */
bruschetta?: BruschettaSpec; 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][] = [ 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. // order start; take the brie out early, leave the parmesan in.
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'], oven: 'oven_gas' }, 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. */ /** Days beyond the script — keep going, with everything cranked. */

View File

@ -103,7 +103,13 @@ export class JudgeView implements View {
slice.mesh.rotation.set(0, 0, 0); slice.mesh.rotation.set(0, 0, 0);
slice.mesh.scale.setScalar(1.55); 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); clear(this.cardEl);
// On prep orders the card is long enough to need signposts: group rows by // 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; // Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had. // a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some( 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> = { const headers: Record<string, string> = {
prep: 'THE PREP', prep: 'THE PREP',
@ -119,9 +125,10 @@ export class JudgeView implements View {
spread: 'THE SPREAD', spread: 'THE SPREAD',
bread: 'THE BREAD', bread: 'THE BREAD',
topping: 'THE TOPPING', topping: 'THE TOPPING',
grill: 'THE GRILL',
bench: 'THE BENCH', 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; let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped const ordered = grouped

View File

@ -22,12 +22,14 @@ const N = 48;
/** Where the coals top out when freshly banked. */ /** Where the coals top out when freshly banked. */
export const BED_MAX = 1.8; export const BED_MAX = 1.8;
/** Sear past here renders fat that can flare. */
export const RENDER_AT = 0.45;
/** A good sear lives in this window; past it is char, then carbon. */ /** A good sear lives in this window; past it is char, then carbon. */
export const SEAR_LO = 0.55; export const SEAR_LO = 0.5;
export const SEAR_HI = 0.8; export const SEAR_HI = 0.78;
export const CHAR_AT = 0.9; 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 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. */ * sear over a fresh (~1.7) bed takes ~18s and chars by ~28s the oven's timescale. */
@ -37,7 +39,7 @@ const DECAY = 0.012;
/** Coals hotter than this can ignite dripping fat. */ /** Coals hotter than this can ignite dripping fat. */
const FLARE_HEAT = 0.8; const FLARE_HEAT = 0.8;
/** How fast a live flare scorches the piece above it (past honest searing). */ /** How fast a live flare scorches the piece above it (past honest searing). */
const FLARE_CHAR_RATE = 0.6; const FLARE_CHAR_RATE = 0.45;
export interface GrillFood { export interface GrillFood {
id: string; id: string;