toastsim/src/dev-juice.ts
type-two d2ab84d549 M12 WIP: oranges and the ribbed pyramid — the reamer twist is causal
The juice station, on the same bones. Press-and-twist reuses the jar's swirl
detector: signed angular sweep of the pointer around the reamer becomes juice
one frame at a time, so twist rate IS juice rate (the feel that had to be right,
brief §9). Dwell is the downforce — holding the half down ramps press, and the
twist while you hold is what juices.

NEW src/sim/juicing.ts (pure, no three — mirrors cutting.ts):
- theoreticalYield(halve) = base·(1−2|offset|)·(1−0.5·wedge): the halve sets the
  reachable ceiling, so a butchered 60/40 caps you at ~70% and the bar can't lie.
- juiceTwist(s, dTheta, press, dt, rand): pulse = sweep·press·ream, capped by
  what's reachable; twist REVERSALS bite a fresh face (a small bonus, real reamer
  physics); pressing WITHOUT twisting mashes → pulp + bench spray + no juice;
  pressing too HARD shoves seeds past the moat; grinding a DRY half reams the pith
  → bitterness that no yield buys back.
- Three tiers as tools (like knives differ): Pyramid Classic (no moat, ream 0.92),
  Ribbed Deluxe (catches ~80%, +4%), The Juicernaut (catches all, +10%, ream 1.32).
- Single game integration point, documented in-file: juiceResult(session) → the
  number-bag; juiceCriteria(result) → The Juice (yield band − bitterness − murk,
  w1.3) + Pips (seed count, w1.1); JUICE_LINES (5 bad / 3 good each) +
  juicerRecognitionLine(). Bench rides the existing benchCriterion via result.bench.

NEW src/scenes/juice.ts: JuiceView mirroring prep.ts — procedural ribbed cone in
a moat dish, glass with a rising juice column, orange half, mess field for spray,
loadProp GLB fallbacks (juicer_pyramid/juice_glass). Live HUD reads the judge's
own numbers: "62% of what this orange had", a ceiling marker the fill can't pass,
pips (click to fish out), bench word, pith warning.

NEW src/dev-juice.ts: SEPARATE installer (no collision with dev.ts) — owns its own
JuiceView, drives the REAL input path (pointer circles for the twist, press
injected like dev.ts injects kitchen.power). Exposes window.__tj. main.ts: one
additive DEV-only import line.

VERIFY (reviewer, ~3 console calls after the dev server loads):
  __tj.enter({ seeds: 5 }); __tj.twist(3, 0.7); __tj.stats()
    → yieldOfOrange ~0.73, pips 4 (Pyramid lets seeds through), bench dirtied
  __tj.enter({ juicer: 'juicernaut', seeds: 5 }); __tj.twist(4, 0.8); __tj.stats()
    → yieldOfReachable ~1.0, pips 0 (catches all), bitterness rises if you grind on
  __tj.enter({ offset: 0.2 }); __tj.twist(6, 0.8); __tj.stats()
    → theoretical ~0.60, yieldOfOrange caps at ~0.60 (the butchered halve)
  __tj.enter(); __tj.mash(1.0, 0.9); __tj.stats()
    → extracted 0, pulp/spray > 0, pips forced out (lean-without-twist = a mash)
  __tj.enter(); __tj.wristle(6, 0.7); __tj.stats()  vs  __tj.twist(3, 0.7)
    → wristle ~0.77 vs twist ~0.73 at equal sweep: reversals pay off

Measured (standalone port of the sim, same constants):
  pyramid 3rev@0.7 → 0.728 | @0.9 → 0.936 | 5rev@0.85 → 1.0 (bitter 0.38, over-reamed)
  deluxe 4rev@0.8 → caught 3/5, 2 pips | juicernaut → caught 5/5, 0 pips
  butcher off .2 → capped 0.60 | mash 1s@0.9 → ext 0, pulp 0.135, 3 pips

tsc --noEmit clean; npm run build clean. Not routed into game.ts yet (game lane's
job) — the harness owns the view so the station is fully drivable now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:48:37 +10:00

189 lines
6.2 KiB
TypeScript

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;
}