toastsim/src/dev.ts
type-two cc39478a6e M15 bruschetta capstone: roast → assemble → the sog finger-press, day 14 end to end
The multi-station order, on the proven bones. Cut the sourdough (M10) →
toast (M1) → roast the tomatoes (toaster generalized) → assemble (garlic rub,
topping point-set, sog clock) → serve. Judge groups The Bread / The Topping /
The Bench. Planning is the mechanic: the temperature clock drifts the two
cheeses on real service time.

What I built on top of the partial work (sims + judge criteria + lines were
already in place and good — kept them):
- Temperature clock WIRED (sim/tempclock.ts): TempClock armed on bruschetta
  days, steps every tick while timing; a per-perishable fridge/bench toggle UI
  (game.showTempToggle) that reads the judge's own readiness word live; a
  toggle costs 8s service (reopening the fridge has teeth). Ticket hints:
  "get the brie out now", "leave the parmesan in".
- Roast tomatoes WIRED: OvenView routed after the toast lands on bruschetta
  orders (kitchen.onPopped branch → enterOven). Browning Field reused, blister
  past 0.6, collapse to sauce past 0.9. oven_tray.glb is absent → loadProp
  catch-fallback to the procedural tray (graceful, as designed).
- Assembly scene BUILT (scenes/assembly.ts, new): own toasted garlic-rubbed
  slice; G rubs the clove (one-swipe coverage), 1/2/3 drop tomato/cheese/basil
  as a point set; ENTER serves. Distribution scored by Clark-Evans (the rind
  code, fed the topping list); balance by mass-per-area band.
- Sog clock: rate x wet-mass x moisture / thickness — the doorstop resists,
  the thin slice surrenders; the judge presses a finger ("It bends. Toast
  should not bend.").
- Day 14 order added; enterOven/enterAssembly/serveBruschetta follow the exact
  runNextPrep/enterPrep/enterJuice machinery. Scorecard grouped
  bread/topping/bench via judge()'s existing group path.
- Harness (src/dev.ts): t.roast(s,power), t.rub(passes), t.assemble({...}),
  t.sogWait(s), t.tempToggle(id), and t.bruschetta() happy path returning the
  full grouped stats.

Fixes to the partial work: judge.ts shadowed the `order` param with a local
`const order` (rename → groupRank; would not typecheck); oven.ts material
opacity access typed as Material|Material[] (cast once).

Tuning (measured headless — the pure sims run without three.js):
- assembly SOG_RATE 0.006 → 0.004: doorstop (0.31) under a normal wet load
  (5 tomato ~2.1) served promptly (8s) stays CRISP (sog 0.21); dawdled 30s goes
  soft (0.77); a thin slice (0.12) bends inside ~15s. The bakery loaf matters.
- assembly MASS_BAND [1.1,2.1] → [2.5,4.5]: the old band scored a normal build
  (5t+4c+3b = 3.82 total mass) as an off-band 0; the band now sits around a
  proper generous topping. Sparse ~1.3 reads bare, a heap ~6.6 slides off.
- roasting RATE 0.042 kept; comment corrected to the measured window (power 6:
  stalls wet to ~26s, blisters 28-36s, collapses past ~40s).
- brie softensInSec 80 (from partial work) kept: out at order start, spreadable
  by assembly.

VERIFY (dev build; run in the console):
  t.start(); t.bruschetta()
Expected (headless sim + live path):
  roast:        {mean ~0.70, blister ~1.0, collapse 0, score ~1.0}
  distribution: ~1.0   (Clark-Evans R ~1.25, evenly strewn)
  balance:      {mass ~3.82 in band [2.5,4.5], score ~1.0}
  sog:          {value ~0.21, word "crisp", score ~0.99}
  rub:          {coverage ~0.35-0.5, score ~1.0}
  temp:         {score 1.0 (brie taken out, parmesan left in)}
  bench:        ~1.0   (clean roast, no slump)
  grade A/S, total ~9

Sub-mechanic checks (after t.bruschetta up to the oven, or standalone):
  t.roast(26,6)  → mean ~0.56, blister ~0     ("under-roasted")
  t.roast(42,6)  → collapse ~0.69             ("collapsing to sauce")
  skip t.tempToggle('brie') → Temperature 0, worst "brie fridge-hard"
  thin slice + t.sogWait(15) → sog ~1.0, "it bends"

typecheck clean (tsc --noEmit), vite build clean. node_modules is a symlink to
the main checkout and is deliberately NOT committed (dir-only .gitignore rule
misses the symlink; staged the 12 source files explicitly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:25:21 +10:00

575 lines
21 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';
/**
* 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();
},
/**
* 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;
}