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>
648 lines
24 KiB
TypeScript
648 lines
24 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';
|
||
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 (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;
|
||
}
|