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 (Clark–Evans 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; }