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>
575 lines
21 KiB
TypeScript
575 lines
21 KiB
TypeScript
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;
|
||
}
|