toastsim/src/dev.ts
type-two b76820b88e Heat sources M-H1/M-H2: the knob is not the heat
New bone src/sim/heatsource.ts: a HeatSource sits between the knob and
the browning integrator — the dial sets target, output chases it on a
fuel-specific lag, every station reads output. Five fuels (gas/electric/
induction/charcoal/radiant) + appliance SKUs (oven_gas, oven_fan,
cheap_electric). roasting.ts retrofit is golden-preserving: optional
src?, reads src.output ?? power, byte-identical when absent.

Verified in-browser:
- GOLDEN: no-src oven = mean 0.698 / blister 1.0 / collapse 0 (== HEAD).
- M-H1 chase 3->9: gas 0.433s, electric 6.0s, induction 0.133s (distinct).
- residual after 9->0: gas 0@1s, electric 6.6@6s, charcoal 9@30s.
- M-H2: gas oven stdev 0.1207 vs fan 0.0016 at equal mean 0.6001 (75x).
- day 14 bruschetta now roasts on oven_gas, still grades S (9.62); oven
  HUD reads 'GAS OVEN 8 · blue flame', tomatoes visibly hot-at-centre.

Doctrine: OPUS-BUILD-BRIEF-HEAT.md (the cross-cutting heat & environment
doctrine — 'you never cook the knob, you cook the lag'). Harness:
t.heatChase/residual/ovenStdev/ovenGolden/ovenDemo.

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

648 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as THREE from 'three';
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 { newRoastSession, roastStep, roastResult } from './sim/roasting';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
* "does it actually work" hard to answer by inspection — so this drives real
* input through the real code paths, deterministically, at a chosen dt.
*
* Also the escape hatch for headless/embedded browsers that never fire rAF:
* everything here calls app.step() directly.
*
* Dev builds only.
*/
export function installDevHarness(app: App, game: Game): void {
const kitchen = game.kitchenView;
const v = new THREE.Vector3();
const toNdc = (x: number, y: number, z: number): [number, number] => {
v.set(x, y, z).project(app.camera);
return [v.x, v.y];
};
const run = (frames: number, dt = 1 / 60) => {
for (let i = 0; i < frames; i++) app.step(dt);
};
const tap = (code: string) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code }));
app.step(1 / 60);
window.dispatchEvent(new KeyboardEvent('keyup', { code }));
};
const point = (x: number, y: number, z: number, down: boolean) => {
const n = toNdc(x, y, z);
app.input.ndc.set(n[0], n[1]);
app.input.down = down;
};
const harness = {
app,
game,
kitchen,
THREE,
run,
tap,
point,
toNdc,
/** Drop the lever, brown for `seconds`, pop, and wait for it to land. */
toast(seconds = 10, power?: number) {
if (power !== undefined) kitchen.power = power;
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(200);
return harness.stats();
},
dip() {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.29, 0.45);
hit?.getWorldPosition(wp);
point(wp.x, wp.y, wp.z, true);
run(4);
app.input.down = false;
app.step(1 / 60);
},
/**
* Raster the whole slice. `speed` is world-units/sec — the thing that
* actually decides how thick the spread goes on.
*/
spread(passes = 9, speed = 0.55, angle?: number) {
const sl = kitchen.currentSlice;
if (angle !== undefined) kitchen.knife.angle = angle;
harness.dip();
const stepDist = speed / 60;
for (let p = 0; p < passes; p++) {
const z = 0.55 - 0.44 + (p / Math.max(1, passes - 1)) * 0.88;
const steps = Math.ceil(0.96 / stepDist);
for (let s = 0; s <= steps; s++) {
if (kitchen.knife.load < 0.02) harness.dip();
const x = 1.45 + (p % 2 ? 1 : -1) * (0.48 - s * stepDist);
point(x, sl.topY, z, true);
app.step(1 / 60);
}
}
app.input.down = false;
harness.park();
return harness.stats();
},
/** Swirl the knife in the jar — remixes a separated spread. */
swirl(circles = 3) {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.56, 0.45);
hit?.getWorldPosition(wp);
const steps = Math.round(circles * 48);
for (let i = 0; i <= steps; i++) {
const a = (i / 48) * Math.PI * 2;
point(wp.x + Math.cos(a) * 0.2, wp.y, wp.z + Math.sin(a) * 0.2, true);
app.step(1 / 60);
}
app.input.down = false;
app.step(1 / 60);
},
/** Park the cursor off the toast so a screenshot isn't full of knife. */
park() {
point(2.5, kitchen.currentSlice.topY, 1.3, false);
app.step(1 / 60);
},
stats() {
const sl = kitchen.currentSlice;
const b = sl.browning.stats(sl.mask);
const s = sl.spread.stats(sl.mask);
const d = sl.damage.stats(sl.mask);
const r = (n: number) => +n.toFixed(3);
return {
phase: kitchen.phase,
warmth: r(sl.warmth),
browning: { mean: r(b.mean), stdev: r(b.stdev), max: r(b.max) },
char: r(sl.browning.fraction(sl.mask, (x) => x > 0.85)),
spread: { mean: r(s.mean), stdev: r(s.stdev), max: r(s.max) },
coverage: r(sl.spread.fraction(sl.mask, (x) => x > 0.02)),
damage: r(d.mean),
load: r(kitchen.knife.load),
angle: r(kitchen.knife.angle),
mode: kitchen.lastFx?.mode ?? 'idle',
cut: sl.cut
? {
thickness: r(sl.cut.thickness),
wedge: r(sl.cut.wedge),
squash: r(sl.cut.squash),
knife: sl.cut.knife,
}
: null,
};
},
/** Move the camera somewhere for a screenshot without disturbing the sim. */
look(pos: [number, number, number], at: [number, number, number]) {
app.camera.position.set(...pos);
app.camera.lookAt(new THREE.Vector3(...at));
app.renderer.render(app.scene, app.camera);
},
/** Click through the title screen. */
start() {
(document.querySelector('.title .btn') as HTMLButtonElement | null)?.click();
app.resize();
run(5);
},
/** Jump to a day, through the real order table. */
day(n: number) {
game.setDay(n);
run(5);
},
grade: () => game.gradeNow(),
/**
* Fish a specific tool out of the drawer the honest way: grab it, drag it
* above the rim, and hold it there until the dwell commits. Grabs whatever
* is on top of it out of the way first if it has to.
*/
pick(id: string, maxSeconds = 20): boolean {
const drawer = game.drawerView;
const d = drawer as unknown as {
pieces: { id: string; body: { translation(): { x: number; y: number; z: number } } }[];
grabbed: { id: string } | null;
};
const piece = d.pieces.find((p) => p.id === id);
if (!piece) throw new Error(`no ${id} in this drawer`);
let frames = Math.round(maxSeconds * 60);
while (frames-- > 0 && drawer.root.visible) {
const t = piece.body.translation();
if (!d.grabbed) {
point(t.x, t.y, t.z, false);
run(1);
point(t.x, t.y, t.z, true);
run(1);
frames -= 2;
continue;
}
if (d.grabbed.id !== id) {
// Grabbed a blocker lying on top — sling it toward a corner, drop it.
const dir = t.x > 0 ? -1 : 1;
for (let i = 0; i < 25 && d.grabbed; i++) {
point(dir * 1.2, 1.4, t.z > 0 ? -0.7 : 0.7, true);
run(1);
}
app.input.down = false;
run(8);
frames -= 35;
continue;
}
// Holding the right piece: haul it up past the rim and keep it there.
point(t.x * 0.7, Math.min(2.2, t.y + 0.4), t.z * 0.7, true);
run(1);
}
app.input.down = false;
run(2);
return !drawer.root.visible;
},
/** Enter the prep bench with an ingredient, a knife, and a pattern. */
prep(ing = 'tomato', pattern: { kind: string; n?: number } = { kind: 'slices', n: 5 }, knife = 'dinner_knife') {
(game as unknown as { startPrep(i: string, p: unknown, k: string): void }).startPrep(ing, pattern, knife);
run(3);
},
/**
* One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis),
* bite, saw to completion. `press` seconds of leaning without sawing
* first, to invite the slip. `stopAt` (0..1) is the onion stop-line: release
* the blade once saw progress reaches it, committing a cut that stops short
* of the root instead of sawing through.
*/
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number; stopAt?: number } = {}) {
const { pos = 0, dy = 0.045, wobble = 0, press = 0, stopAt } = opts;
const pv = game.prepView as unknown as {
session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] };
};
const s = pv.session;
const ing = (game.prepView as unknown as { ing: { size: number } }).ing;
run(2);
const Z = -0.2;
const X = () => s.aimPos * ing.size; // slip moves the aim; follow it
point(pos * ing.size, 1.0, Z, false);
run(2);
point(pos * ing.size, 1.0, Z, true);
run(1);
if (press > 0) {
// Lean on it without sawing.
for (let i = 0; i < Math.round(press * 60); i++) {
point(X(), 1.0, Z, true);
run(1);
if (s.phase === 'aim') break; // it slipped and threw the knife off
}
}
let y = 1.0;
let dir = -1;
let f = 0;
while ((s.phase === 'bite' || s.phase === 'saw') && f < 3000) {
y += dir * dy;
if (y < 0.8 || y > 1.3) dir = -dir;
point(X() + (f % 2 ? wobble : -wobble), y, Z, true);
run(1);
f++;
// Stop-line: let the blade up short of the root — the release commits it.
if (stopAt !== undefined && s.phase === 'saw' && s.progress >= stopAt) break;
}
app.input.down = false;
run(3); // let the release-commit (liftKnife) land before the next chop
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f, progress: +s.progress.toFixed(3) };
},
/**
* Drive a whole `wedges(n)` pattern: ceil(n/2) diametral cuts spaced evenly
* around the half-turn, which fall out as n even radial wedges. `wobble`
* throws the spacing off to make a lottery on purpose. Returns prepStats.
*/
wedges(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 6, dy = 0.05, wobble = 0 } = opts;
const k = Math.ceil(n / 2);
// aimPos reads as a fraction of the half-turn, so evenly spaced positions
// in [-0.5, 0.5) give angles 0, π/k, 2π/k … — even wedges, CV → 0.
for (let i = 0; i < k; i++) harness.chop({ pos: i / k - 0.5, dy, wobble });
return harness.prepStats();
},
/**
* Drive a whole `dice(n)` pattern: two rotated passes of n-1 evenly spaced
* parallel cuts. The sim banks the first pass and rotates the board itself,
* so this is just 2·(n-1) chops. Evenly spaced → n×n even cells, CV → 0.
*/
dice(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 4, dy = 0.05, wobble = 0 } = opts;
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < n - 1; i++) harness.chop({ pos: (i + 1) / n - 0.5, dy, wobble });
}
return harness.prepStats();
},
/**
* The M14 onion dice with the stop-line. The first pass (root-ward cuts) is
* released short at `stopAt` (the sim commits on release); the sim rotates
* the board and the second pass criss-crosses straight through. Pass
* `through: true` to saw the root pass all the way — the "went through the
* stem, served confetti" failure. Reads live dicePhase, so it stays in step
* with the sim's own rotation.
*/
diceOnion(opts: { n?: number; stopAt?: number; dy?: number; wobble?: number; through?: boolean } = {}) {
// stopAt 0.8 lands the committed depth ~0.85 (a frame of overshoot) — square
// in the "stopped short" band and level with the scene's stop-line marker.
const { n = 4, stopAt = 0.8, dy = 0.05, wobble = 0, through = false } = opts;
const pv = game.prepView as unknown as { session: { dicePhase: number } };
const total = 2 * (n - 1);
for (let i = 0; i < total; i++) {
const pos = (i % (n - 1) + 1) / n - 0.5;
if (pv.session.dicePhase === 0 && !through) harness.chop({ pos, dy, wobble, stopAt });
else harness.chop({ pos, dy, wobble });
}
return harness.prepStats();
},
/** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */
handoff() {
tap('Enter');
run(3);
},
/** Drag the cloth across the board `passes` times. */
wipe(passes = 1, vAt = 0.5) {
const bw = 4.6;
const bd = 2.4;
for (let p = 0; p < passes; p++) {
for (let i = 0; i <= 40; i++) {
const u = p % 2 ? 1 - i / 40 : i / 40;
const wx = (0.15 + u * 0.7) * bw - bw / 2;
const wz = (vAt - 0.5) * bd;
point(wx, 0.12, wz, true);
run(1);
}
}
app.input.down = false;
run(2);
return game.prepView.mess.stats();
},
prepStats() {
const r = game.prepView.result();
return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() };
},
/**
* Drive the REAL routed juice station (game.juiceView, the one a day-12
* order sends you to — not the __tj sandbox): seat the half, then press-and-
* twist for `revolutions` turns at `press` downforce. Follow with `t.handoff()`
* (ENTER) to serve the glass and drop back into the toast flow.
*/
juice(revolutions = 3, press = 0.7, dir: 1 | -1 = 1) {
const view = game.juiceView as unknown as { placeHalf(): void; autoPress: boolean; press: number };
run(3); // let the juice view take the camera before we project points
view.placeHalf();
run(1);
view.autoPress = false;
view.press = press;
const CONE_TOP = 0.92;
const R = 0.3;
const steps = Math.max(1, Math.round(revolutions * 48));
for (let i = 0; i <= steps; i++) {
const a = dir * (i / 48) * Math.PI * 2;
point(Math.cos(a) * R, CONE_TOP, Math.sin(a) * R, true);
run(1);
}
app.input.down = false;
run(1);
return harness.juiceStats();
},
juiceStats() {
const view = game.juiceView;
const r = view.result();
const round = (n: number) => +n.toFixed(3);
return {
yieldOfOrange: round(r.yieldOfOrange),
yieldOfReachable: round(r.yieldOfReachable),
theoretical: round(r.theoretical),
extracted: round(r.extracted),
pips: r.pips,
seedsCaught: r.seedsCaught,
bitterness: round(r.bitterness),
pulp: round(r.pulp),
bench: view.mess.stats(),
benchWord: view.mess.benchWord(),
juicer: r.juicerName,
};
},
/**
* Cut a slice off the loaf. `dy` is world-units per frame of saw motion
* (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame.
*/
slice(opts: { thickness?: number; dy?: number; wobble?: number } = {}) {
const { thickness = 0.31, dy = 0.05, wobble = 0 } = opts;
const sl = game.slicerView as unknown as {
cutting: { phase: string; thickness: number; progress: number; wedge: number; squash: number; strokes: number };
endX: number;
};
run(2); // let the slicer take the camera before we project points
const c = sl.cutting;
const X = sl.endX - thickness;
const Z = -0.2;
point(X, 1.0, Z, false);
run(2);
point(X, 1.0, Z, true);
run(1); // first bite locks thickness
let y = 1.0;
let dir = -1;
let f = 0;
while (c.phase === 'saw' && f < 4000) {
y += dir * dy;
// ~0.5 world units per sweep — the length of a deliberate human stroke.
if (y < 0.8 || y > 1.3) dir = -dir;
point(X + (f % 2 ? wobble : -wobble), y, Z, true);
run(1);
f++;
}
app.input.down = false;
run(80); // the done beat, the fall, and the handoff to the kitchen
const r = (n: number) => +n.toFixed(3);
return {
phase: c.phase,
thickness: r(c.thickness),
wedge: r(c.wedge),
squash: r(c.squash),
strokes: c.strokes,
frames: f,
};
},
// --- M15 bruschetta ------------------------------------------------------
/** Flip a perishable fridge↔bench on the temp clock (costs service time). */
tempToggle(id: IngredientId) {
game.toggleTemp(id);
run(1);
return game.tempClock?.readiness(id);
},
/**
* Drive the routed oven (the one a day-14 order sends you to): dial in the
* heat, slide the tray in, roast `seconds`, pull it out. Leaves it at 'done';
* `t.bruschetta` advances with ENTER. ~32s@6 lands a blistered ~0.70, none
* slumped.
*/
roast(seconds = 32, power = 6) {
const oven = game.ovenView;
oven.power = power;
run(3);
tap('Space'); // tray in
run(Math.round(seconds * 60));
tap('Space'); // tray out
run(3);
return oven.result();
},
// ---- Heat-source doctrine (M-H1/M-H2). Pure-sim probes: no scene, they
// measure the fuel curves the whole game will ride. ----
/** Seconds for a fuel's output to climb `from`→`to` (M-H1: gas<1, elec 5-9, induction<0.15). */
heatChase(fuel: Fuel, from = 3, to = 9, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = from;
setKnob(hs, to);
let s = 0;
for (let i = 0; i < 6000 && hs.output < to - 0.01; i++) {
heatStep(hs, dt);
s += dt;
}
return +s.toFixed(3);
},
/** Output remaining `sec` after the knob drops 9→0 — the residual (electric remembers). */
residual(fuel: Fuel, sec = 6, dt = 1 / 60) {
const hs = newHeatSource(fuel);
hs.output = 9;
hs.target = 9;
setKnob(hs, 0);
if (fuel === 'charcoal') hs.target = 9; // no knob; measure the bed decay
for (let i = 0; i < Math.round(sec / dt); i++) heatStep(hs, dt);
return +hs.output.toFixed(3);
},
/** Roast a tray on an oven SKU to ~`toMean` roast, report evenness. Gas oven
* reads uneven (high stdev), fan oven flat (low stdev) at equal mean. */
ovenStdev(sku = 'oven_gas', toMean = 0.6, power = 8) {
const src = newHeatSource(sku);
const s = newRoastSession(4, 20260718, power, src);
let f = 0;
let res = roastResult(s);
while (res.mean < toMean && f < 6000) {
roastStep(s, 1 / 60);
res = roastResult(s);
f++;
}
const r = (n: number) => +n.toFixed(4);
return { sku, mean: r(res.mean), stdev: r(res.stdev), seconds: +(f / 60).toFixed(1), output: +s.src!.output.toFixed(2) };
},
/** 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) {
const oven = game.ovenView as unknown as {
reset(h: number, p: number, ask: string, fuel?: string): void;
result(): { mean: number; stdev: number; blisterFrac: number; collapseFrac: number };
};
oven.reset(4, power, `roast on the ${fuel === 'oven_gas' ? 'gas' : 'fan'} oven`, fuel);
app.setView(game.ovenView);
run(3);
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(3);
const res = oven.result();
const r = (n: number) => +n.toFixed(4);
return { fuel, mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac) };
},
/** Golden test: roast with NO source. Must match the legacy oven numbers exactly. */
ovenGolden(seconds = 32, power = 6) {
const s = newRoastSession(4, 20260718, power);
for (let i = 0; i < Math.round(seconds * 60); i++) roastStep(s, 1 / 60);
const res = roastResult(s);
const r = (n: number) => +n.toFixed(4);
return { mean: r(res.mean), stdev: r(res.stdev), blister: r(res.blisterFrac), collapse: r(res.collapseFrac) };
},
/**
* Rub garlic across the toast at the assembly bench: G-mode, then a raster
* drag. `passes` rows of strokes; 3 lands ~0.35 coverage — a light, even
* coat, full marks.
*/
rub(passes = 3) {
const a = game.assemblyView;
run(2);
tap('KeyG');
for (let p = 0; p < passes; p++) {
const v = 0.2 + p * 0.18;
const cols = 12;
for (let i = 0; i <= cols; i++) {
const t = i / cols;
const u = 0.15 + (p % 2 ? 1 - t : t) * 0.7;
const [x, y, z] = a.worldAt(u, v);
point(x, y, z, true);
run(1);
}
}
app.input.down = false;
run(1);
return { rubCoverage: +a.result().rubCoverage.toFixed(3) };
},
/**
* Drop a set of toppings on a shared even grid — kinds interleaved onto one
* grid so the point set stays evenly strewn (ClarkEvans R ~1.25). The digit
* keys pick the tool; each press-edge drops one piece.
*/
assemble(counts: { tomato?: number; cheese?: number; basil?: number } = { tomato: 5, cheese: 4, basil: 3 }) {
const a = game.assemblyView;
const kinds: [string, string][] = [];
const push = (n: number, key: string) => {
for (let i = 0; i < n; i++) kinds.push([key, key]);
};
push(counts.tomato ?? 0, 'Digit1');
push(counts.cheese ?? 0, 'Digit2');
push(counts.basil ?? 0, 'Digit3');
const n = kinds.length;
const cols = Math.max(1, Math.ceil(Math.sqrt(n * 1.3)));
const rows = Math.max(1, Math.ceil(n / cols));
let lastKey = '';
run(2);
for (let i = 0; i < n; i++) {
const key = kinds[i][1];
if (key !== lastKey) {
tap(key);
lastKey = key;
run(1);
}
const c = i % cols;
const rw = Math.floor(i / cols);
const u = 0.14 + ((c + 0.5) / cols) * 0.72;
const v = 0.14 + ((rw + 0.5) / rows) * 0.72;
const [x, y, z] = a.worldAt(u, v);
point(x, y, z, false);
run(1);
point(x, y, z, true); // press edge → one placement
run(1);
}
app.input.down = false;
run(1);
const res = a.result();
return { count: res.count, mass: +res.massPerArea.toFixed(2), rub: +res.rubCoverage.toFixed(3), kinds: res.kinds };
},
/** Let the sog clock run `seconds` at the assembly bench, then read it. */
sogWait(seconds = 8) {
run(Math.round(seconds * 60));
const sog = game.assemblyView.result().sog;
return { sog: +sog.toFixed(3), word: sogWord(sog) };
},
/** Read the last bruschetta verdict — the full stats, grouped by station. */
bruschettaStats() {
const lb = game.lastBruschettaStats;
if (!lb) return null;
const { result, verdict } = lb;
const crit = (k: string) => {
const c = verdict.criteria.find((x) => x.key === k);
return c ? +c.score.toFixed(3) : null;
};
const r = (n: number) => +n.toFixed(3);
return {
grade: verdict.grade,
total: +verdict.total.toFixed(2),
roast: { mean: r(result.roast.mean), blister: r(result.roast.blisterFrac), collapse: r(result.roast.collapseFrac), score: crit('roast') },
distribution: crit('topdist'),
balance: { mass: r(result.assembly.massPerArea), score: crit('balance') },
sog: { value: r(result.assembly.sog), word: sogWord(result.assembly.sog), score: crit('sog') },
rub: { coverage: r(result.assembly.rubCoverage), score: crit('rub') },
temp: { score: r(result.temp.score), worst: result.temp.worstName, criterion: crit('temp') },
bench: crit('bench'),
criteria: verdict.criteria.map((c) => ({ key: c.key, group: c.group ?? '-', score: +c.score.toFixed(2) })),
};
},
/**
* The whole bruschetta, end to end and headless: day 14, plan the brie out,
* cut the doorstop, toast it, roast the tomatoes, rub + top + wait, serve.
* Returns the full grouped stats.
*/
bruschetta() {
game.setDay(14);
run(5);
game.toggleTemp('brie'); // take the brie out early — it must be soft
harness.pick('bread_knife'); // knife trip → slicer
harness.slice({ thickness: 0.31, dy: 0.05 }); // cut the doorstop → kitchen
harness.toast(12, 6); // toast → lands → enterOven
harness.roast(32, 6); // roast the tomatoes
tap('Enter'); // oven done → assembly
run(3);
harness.rub(3);
harness.assemble({ tomato: 5, cheese: 4, basil: 3 });
harness.sogWait(8);
tap('Enter'); // serve → the judge
run(5);
return harness.bruschettaStats();
},
};
(window as unknown as { __t: unknown }).__t = harness;
}