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>
189 lines
6.2 KiB
TypeScript
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;
|
|
}
|