import * as THREE from 'three'; import type { App } from './core/app'; import { JuiceView } from './scenes/juice'; import type { JuicerId } from './sim/juicing'; /** * Juice-station dev harness — a SEPARATE installer from dev.ts so the two lanes * don't collide. It owns its own JuiceView (the game doesn't route orders here * yet — that's the game lane's job), adds it to the scene, and drives the real * input path: real pointer circles around the reamer for the twist, `press` * injected the way dev.ts injects kitchen.power. * * The reviewer verifies with a couple of calls, e.g. * __tj.enter({ seeds: 5 }); __tj.twist(3, 0.7); __tj.stats() * __tj.enter({ juicer: 'ribbed_deluxe', seeds: 5 }); __tj.twist(3, 0.95); __tj.stats() * __tj.enter({ offset: 0.2 }); __tj.twist(6, 0.7); __tj.stats() // capped yield * __tj.enter(); __tj.mash(1.0, 0.9); __tj.stats() // tearing + spray * * Dev builds only. */ export function installJuiceHarness(app: App): void { const view = new JuiceView(app); app.scene.add(view.root); // Hidden until __tj.enter() makes it the active view — otherwise it renders // on top of whatever the game is showing at startup. view.root.visible = false; const sessionOf = () => (view as unknown as { session: { placed: boolean; extracted: number; theoretical: number; pulp: number; bitterness: number; seedsTotal: number; seedsReleased: number; seedsCaught: number; seedsInGlass: number; revolutions: number; } | null; }).session; const v = new THREE.Vector3(); const point = (x: number, y: number, z: number, down: boolean) => { v.set(x, y, z).project(app.camera); app.input.ndc.set(v.x, v.y); app.input.down = down; }; const run = (frames: number, dt = 1 / 60) => { for (let i = 0; i < frames; i++) app.step(dt); }; const CONE_TOP = 0.92; const R = 0.3; const harness = { app, view, THREE, run, point, /** Reset the station and make it the active view. */ enter( opts: { juicer?: JuicerId; offset?: number; wedge?: number; seeds?: number } = {}, ) { const { juicer = 'pyramid_classic', offset = 0, wedge = 0, seeds } = opts; view.reset(juicer, { offset, wedge }, `juice the orange (${juicer})`, seeds); app.setView(view); run(3); // let the view take the camera before we project points return harness.place(); }, /** Seat the half on the cone (the drag's commit). */ place() { view.placeHalf(); run(1); return harness.stats(); }, /** * Press-and-twist for `revolutions` turns at a fixed `pressure` (0..1). * `dir` +1/-1 sets the twist direction. Press is injected; the twist itself * is real pointer motion around the cone, so the sim sees exactly what a * hand would produce. */ twist(revolutions = 3, pressure = 0.7, dir: 1 | -1 = 1) { view.autoPress = false; view.press = pressure; 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.stats(); }, /** * Work the wrist: `reversals` back-and-forth half-turns. This is what earns * the reversal bonus bites — the number to watch is extracted climbing a * touch faster than the same sweep in one direction. */ wristle(reversals = 6, pressure = 0.7) { view.autoPress = false; view.press = pressure; // One running angle so each reversal continues smoothly from the last — // no position jump, so the sim sees a clean back-and-forth wrist. let ang = 0; let dir: 1 | -1 = 1; for (let r = 0; r < reversals; r++) { for (let i = 0; i < 24; i++) { ang += dir * (Math.PI / 24); // a smooth half-turn point(Math.cos(ang) * R, CONE_TOP, Math.sin(ang) * R, true); run(1); } dir = (dir === 1 ? -1 : 1) as 1 | -1; } app.input.down = false; run(1); return harness.stats(); }, /** * Lean without twisting — hold `pressure` on the cone for `seconds` and go * nowhere. This is the mash: it should tear pulp and spray the bench, not * fill the glass. */ mash(seconds = 1, pressure = 0.9) { view.autoPress = false; view.press = pressure; const frames = Math.round(seconds * 60); for (let i = 0; i < frames; i++) { point(R, CONE_TOP, 0, true); // fixed point → zero sweep run(1); } app.input.down = false; run(1); return harness.stats(); }, /** Fish one pip out of the glass (costs the player service time in play). */ fish() { view.fishOnePip(); run(1); return harness.stats(); }, /** Everything the judge and the readouts will read. */ stats() { const s = sessionOf(); const r = view.result(); const round = (n: number) => +n.toFixed(3); return { placed: s?.placed ?? false, yieldOfOrange: round(r.yieldOfOrange), yieldOfReachable: round(r.yieldOfReachable), theoretical: round(r.theoretical), extracted: round(r.extracted), pips: r.pips, seedsCaught: r.seedsCaught, seedsReleased: s?.seedsReleased ?? 0, seedsTotal: s?.seedsTotal ?? 0, bitterness: round(r.bitterness), pulp: round(r.pulp), revolutions: round(s?.revolutions ?? 0), bench: { mass: round(r.bench?.mass ?? 0), spreadFrac: round(r.bench?.spreadFrac ?? 0), solids: r.bench?.solids ?? 0, }, benchWord: view.mess.benchWord(), juicer: r.juicerName, }; }, /** Move the camera 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); }, }; (window as unknown as { __tj: unknown }).__tj = harness; }