Merge branch 'worktree-agent-a994a2ac1eff4f7dc'

This commit is contained in:
type-two 2026-07-17 18:49:26 +10:00
commit 8e249b4dba
4 changed files with 1165 additions and 0 deletions

188
src/dev-juice.ts Normal file
View File

@ -0,0 +1,188 @@
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;
}

View File

@ -16,6 +16,7 @@ void game.init();
if (import.meta.env.DEV) {
void import('./dev').then((m) => m.installDevHarness(app, game));
void import('./dev-juice').then((m) => m.installJuiceHarness(app));
}
app.start();

533
src/scenes/juice.ts Normal file
View File

@ -0,0 +1,533 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { INGREDIENTS } from '../sim/ingredients';
import {
fishPip,
juiceResult,
juiceTwist,
JUICERS,
newJuiceSession,
placeHalf,
theoreticalYield,
yieldWord,
type JuiceResult,
type JuiceSession,
type JuicerId,
} from '../sim/juicing';
import { Mess } from '../sim/mess';
import { Rng } from '../core/rng';
import { el, Panel } from '../ui/hud';
import { loadProp } from './assets';
/**
* The juice station. One reamer, one orange half, one glass.
*
* It mirrors the prep bench on purpose: procedural stand-in silhouettes (the
* ribbed pyramid IS gameplay the ribs are why the twist bites), a mess field
* that remembers the spray, a GLB prop that drops in over the stand-in if it
* ever renders, and a HUD that reads the same numbers the judge will.
*
* The one verb is the jar's swirl, pointed down: press the half onto the cone
* (hold to lean in the dwell IS the press) and twist. Angular sweep of the
* pointer around the cone becomes juice, one frame at a time. Twist rate is the
* juice rate; that is the whole feel.
*/
const TRAY_Y = 0.06;
const TRAY_W = 3.2;
const TRAY_D = 2.4;
/** Reamer tip height above the tray — the plane the twist angle is measured on. */
const CONE_TOP = 0.92;
const GLASS_X = 1.9;
/** Press ramps in while you hold the half down against the cone. */
const PRESS_RAMP = 1.6;
export class JuiceView implements View {
readonly root = new THREE.Group();
readonly mess = new Mess(96);
private session: JuiceSession | null = null;
private orangeColor = new THREE.Color().setRGB(...INGREDIENTS.orange.colors.flesh, THREE.SRGBColorSpace);
private reamer!: THREE.Group;
private half: THREE.Group | null = null;
private glass!: THREE.Group;
private juiceCol!: THREE.Mesh;
private pipMeshes: THREE.Mesh[] = [];
private pipGeo = new THREE.SphereGeometry(0.02, 8, 6);
private pipMat = new THREE.MeshStandardMaterial({ color: 0xe8d9a6, roughness: 0.5 });
private messTexData: Uint8Array;
private messTex: THREE.DataTexture;
private messVersion = -1;
private solidsMesh: THREE.InstancedMesh;
private ray = new THREE.Raycaster();
private twistPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -CONE_TOP);
private trayPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(TRAY_Y * 2));
private hitPt = new THREE.Vector3();
private prevAngle: number | null = null;
private wipePrev: THREE.Vector3 | null = null;
private holdT = 0;
private grabbingHalf = false;
private wasDown = false;
/** Downforce 0..1. Set by the pointer hold in play; the harness writes it. */
press = 0;
/** When false, the harness owns `press` and the hold-ramp is skipped. */
autoPress = true;
private rng = new Rng(0x0ea9e);
private panel: Panel;
private askEl!: HTMLElement;
private yieldEl!: HTMLElement;
private yieldBar!: HTMLElement;
private yieldFill!: HTMLElement;
private yieldCeil!: HTMLElement;
private pipsEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: JuiceResult) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
const n = this.mess.field.n;
this.messTexData = new Uint8Array(n * n * 4);
this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat);
this.messTex.minFilter = THREE.LinearFilter;
this.messTex.magFilter = THREE.LinearFilter;
const stain = new THREE.Mesh(
new THREE.PlaneGeometry(TRAY_W, TRAY_D),
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
);
stain.rotation.x = -Math.PI / 2;
stain.position.set(0, TRAY_Y * 2 + 0.004, 0);
this.root.add(stain);
const seedGeo = new THREE.SphereGeometry(0.024, 8, 6);
seedGeo.scale(1, 0.5, 0.7);
this.solidsMesh = new THREE.InstancedMesh(
seedGeo,
new THREE.MeshStandardMaterial({ color: 0xe8d9a6, roughness: 0.6 }),
256,
);
this.solidsMesh.count = 0;
this.solidsMesh.castShadow = true;
this.root.add(this.solidsMesh);
this.panel = new Panel('juice-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'JUICE IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.yieldEl = el('div', 'slicer-thick', card, '');
this.yieldBar = el('div', 'timer-bar', card);
this.yieldFill = el('div', 'timer-fill', this.yieldBar);
// The ceiling the halve left — the bar physically can't fill past it.
this.yieldCeil = el('div', 'juice-ceil', this.yieldBar);
this.yieldCeil.style.cssText =
'position:absolute;top:0;bottom:0;width:2px;background:#e2603a;pointer-events:none;';
this.yieldBar.style.position = 'relative';
this.pipsEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(24, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
this.reamer = this.buildReamer();
this.root.add(this.reamer);
void loadProp('/assets/models/juicer_pyramid.glb', 1.1)
.then((prop) => {
prop.position.set(0, TRAY_Y * 2, 0);
// Keep the procedural cone as the twist target; hide only the visible dish.
const dish = this.reamer.getObjectByName('dish');
if (dish) dish.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
this.glass = this.buildGlass();
this.glass.position.set(GLASS_X, TRAY_Y * 2, 0.2);
this.root.add(this.glass);
void loadProp('/assets/models/juice_glass.glb', 0.7)
.then((prop) => {
prop.position.set(GLASS_X, TRAY_Y * 2, 0.2);
this.root.add(prop);
})
.catch(() => undefined);
}
/** The ribbed pyramid, procedural: a fluted cone in a shallow moat dish. */
private buildReamer(): THREE.Group {
const g = new THREE.Group();
const glassMat = new THREE.MeshStandardMaterial({
color: 0x9fb7c4,
roughness: 0.25,
metalness: 0.1,
transparent: true,
opacity: 0.55,
});
// Dish / moat.
const dish = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.5, 0.16, 32), glassMat);
dish.name = 'dish';
dish.position.y = TRAY_Y * 2 + 0.08;
dish.castShadow = true;
dish.receiveShadow = true;
g.add(dish);
// The fluted cone: a low-segment cone reads faceted, plus raised ribs.
const coneH = CONE_TOP - (TRAY_Y * 2 + 0.16);
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.42, coneH, 10), glassMat);
cone.position.y = TRAY_Y * 2 + 0.16 + coneH / 2;
cone.castShadow = true;
g.add(cone);
const ribMat = new THREE.MeshStandardMaterial({
color: 0xb9cdd6,
roughness: 0.3,
transparent: true,
opacity: 0.7,
});
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const rib = new THREE.Mesh(new THREE.BoxGeometry(0.03, coneH * 0.9, 0.09), ribMat);
rib.position.set(Math.cos(a) * 0.22, cone.position.y, Math.sin(a) * 0.22);
rib.rotation.y = -a;
rib.rotation.z = 0.12;
g.add(rib);
}
return g;
}
private buildGlass(): THREE.Group {
const g = new THREE.Group();
const glassMat = new THREE.MeshStandardMaterial({
color: 0xcfe0e8,
roughness: 0.12,
metalness: 0.05,
transparent: true,
opacity: 0.32,
});
const wall = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.2, 0.62, 28, 1, true), glassMat);
wall.position.y = 0.31;
g.add(wall);
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.02, 28), glassMat);
base.position.y = 0.01;
g.add(base);
// The juice column: scaled up as the glass fills.
this.juiceCol = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.185, 1, 28),
new THREE.MeshStandardMaterial({ color: this.orangeColor, roughness: 0.35 }),
);
this.juiceCol.position.y = 0.02;
this.juiceCol.scale.y = 0.0001;
g.add(this.juiceCol);
return g;
}
/**
* A fresh half on a chosen reamer. `halve` is the cut quality from the prep
* bench (offset/wedge) a butchered halve caps the reachable juice.
*/
reset(
juicerId: JuicerId,
halve: { offset: number; wedge: number },
askText: string,
seeds?: number,
): void {
const reachable = theoreticalYield(halve);
const seedCount = seeds ?? this.rng.int(3, 7);
this.session = newJuiceSession(INGREDIENTS.orange, JUICERS[juicerId], reachable, seedCount);
this.mess.reset();
this.press = 0;
this.holdT = 0;
this.prevAngle = null;
this.wipePrev = null;
this.grabbingHalf = false;
this.autoPress = true;
if (this.half) this.root.remove(this.half);
this.half = this.buildHalf();
// Start it beside the reamer, waiting to be seated.
this.half.position.set(-1.1, TRAY_Y * 2 + 0.18, 0.4);
this.root.add(this.half);
for (const m of this.pipMeshes) this.glass.remove(m);
this.pipMeshes = [];
this.juiceCol.scale.y = 0.0001;
this.askEl.textContent = askText;
this.yieldCeil.style.left = `${Math.round(this.session.theoretical * 100)}%`;
this.hintEl.textContent = 'drag the half onto the cone · hold to press, twist to juice · ENTER serves';
}
private buildHalf(): THREE.Group {
const g = new THREE.Group();
const r = INGREDIENTS.orange.size / 2;
const skin = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...INGREDIENTS.orange.colors.skin, THREE.SRGBColorSpace),
roughness: 0.6,
});
// Top hemisphere = the skin dome.
const dome = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), skin);
dome.scale.set(1.05, 0.92, 1.05);
dome.castShadow = true;
g.add(dome);
// The cut face, segments radiating — flesh side, faces down onto the cone.
const face = new THREE.Mesh(
new THREE.CircleGeometry(r * 0.98, 20),
new THREE.MeshStandardMaterial({ color: this.orangeColor, roughness: 0.5 }),
);
face.rotation.x = Math.PI / 2;
face.position.y = -0.002;
g.add(face);
return g;
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
/** Seat the half on the cone — the drag's commit, also the harness hook. */
placeHalf(): void {
const s = this.session;
if (!s || s.placed) return;
placeHalf(s);
if (this.half) this.half.position.set(0, CONE_TOP - 0.02, 0);
this.grabbingHalf = false;
this.hintEl.textContent = 'hold to press · twist round the cone · reverse for a bite · ENTER serves';
}
private trayUV(): { u: number; v: number } | null {
if (!this.ray.ray.intersectPlane(this.trayPlane, this.hitPt)) return null;
const u = (this.hitPt.x + TRAY_W / 2) / TRAY_W;
const v = (this.hitPt.z + TRAY_D / 2) / TRAY_D;
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
return { u, v };
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(1.05, 1.85, 3.5);
this.app.camera.lookAt(0.35, 0.55, 0.05);
const s = this.session;
if (!s) return;
this.ray.setFromCamera(inp.ndc, this.app.camera);
const justDown = inp.down && !this.wasDown;
if (!s.placed) {
this.handlePlacement(inp.down);
} else if (justDown && this.clickPip()) {
// Clicked a pip in the glass — fished it out, no juicing this frame.
} else {
this.handleJuicing(dt, inp.down);
}
this.wasDown = inp.down;
// Serve.
if (inp.justPressed('Enter') && this.onDone && s.placed) {
const cb = this.onDone;
this.onDone = null;
cb(this.result());
return;
}
this.syncGlass();
this.syncMess();
this.updateHud();
}
private handlePlacement(down: boolean): void {
if (!this.half) return;
if (down && this.ray.ray.intersectPlane(this.trayPlane, this.hitPt)) {
// Grab if near the half, then it follows the pointer on the tray.
const near = this.hitPt.distanceTo(this.half.position) < 0.6;
if (this.grabbingHalf || near) {
this.grabbingHalf = true;
this.half.position.set(this.hitPt.x, TRAY_Y * 2 + 0.18, this.hitPt.z);
}
} else if (this.grabbingHalf) {
// Released: seat it if it's over the cone, else it stays where dropped.
const overCone = Math.hypot(this.half.position.x, this.half.position.z) < 0.55;
if (overCone) this.placeHalf();
else this.grabbingHalf = false;
}
}
private handleJuicing(dt: number, down: boolean): void {
const s = this.session!;
// Press: hold the half down and it leans in (the dwell is the downforce).
if (this.autoPress) {
const onCone = down && this.overCone();
if (onCone) {
this.holdT += dt;
this.press = Math.min(1, this.holdT * PRESS_RAMP);
} else {
this.holdT = 0;
this.press = Math.max(0, this.press - dt * 3);
}
}
// Twist: angular sweep of the pointer around the cone, on the tip plane.
let dTheta = 0;
if (down && this.ray.ray.intersectPlane(this.twistPlane, this.hitPt)) {
const ang = Math.atan2(this.hitPt.z, this.hitPt.x);
if (this.prevAngle !== null) {
let d = ang - this.prevAngle;
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
dTheta = d;
}
this.prevAngle = ang;
} else {
this.prevAngle = null;
}
// Off the cone with the button down and no twist target: wipe the tray.
if (down && !this.overCone() && this.press < 0.05) {
const uv = this.trayUV();
if (uv && this.wipePrev) this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v);
if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0);
} else {
this.wipePrev = null;
}
if (this.press > 0.01 || Math.abs(dTheta) > 0) {
const f = juiceTwist(s, dTheta, this.press, dt, () => this.rng.next());
this.applyFrame(f, dt);
}
}
/** A fresh click on a pip in the glass fishes it out. Returns true if it hit. */
private clickPip(): boolean {
if (this.pipMeshes.length === 0) return false;
const hits = this.ray.intersectObjects(this.pipMeshes, false);
if (hits.length === 0) return false;
return this.fishOnePip();
}
private overCone(): boolean {
if (!this.ray.ray.intersectPlane(this.twistPlane, this.hitPt)) return false;
return Math.hypot(this.hitPt.x, this.hitPt.z) < 0.6;
}
private applyFrame(f: ReturnType<typeof juiceTwist>, dt: number): void {
if (f.juice > 0 && dt > 0) {
const rate = f.juice / dt;
if (rate > 0.02) audio.scrape(Math.min(1, this.press), Math.min(6, rate * 12));
}
if (f.reversalBonus) audio.clunk(true);
if (f.spray > 0) {
// Sprayed off the cone toward a random rib gap onto the tray.
const a = this.rng.next() * Math.PI * 2;
const u = 0.5 + Math.cos(a) * 0.14;
const v = 0.5 + Math.sin(a) * 0.14;
this.mess.addJuice(u, v, f.spray, Math.cos(a), Math.sin(a));
if (f.spray > 0.01) this.mess.addSolid(u, v, 'pulp');
}
if (f.tearing) {
this.app.shake = 0.014;
audio.clatter(0.4, 0.8);
this.hintEl.textContent = 'that is a mash, not a juice — twist, don\'t just lean';
}
for (let i = 0; i < f.seedsEscaped; i++) this.addPip();
}
private addPip(): void {
const pip = new THREE.Mesh(this.pipGeo, this.pipMat);
const a = this.rng.next() * Math.PI * 2;
const r = this.rng.next() * 0.14;
pip.position.set(Math.cos(a) * r, 0.05 + this.rng.next() * 0.03, Math.sin(a) * r);
this.glass.add(pip);
this.pipMeshes.push(pip);
}
private syncGlass(): void {
const s = this.session;
if (!s) return;
// Column height maps 0..1 orange to a near-full glass at wrung-dry.
const h = Math.max(0.0001, Math.min(0.58, s.extracted * 0.62));
this.juiceCol.scale.y = h;
this.juiceCol.position.y = 0.02 + h / 2;
// Murk from mashed pulp darkens the pour.
const mat = this.juiceCol.material as THREE.MeshStandardMaterial;
const murk = Math.min(0.4, s.pulp * 1.5);
mat.color.copy(this.orangeColor).multiplyScalar(1 - murk);
}
private syncMess(): void {
if (this.mess.version === this.messVersion) return;
this.messVersion = this.mess.version;
const d = this.mess.field.data;
const px = this.messTexData;
for (let i = 0; i < d.length; i++) {
const m = Math.min(1, d[i] * 2.2);
px[i * 4] = 214;
px[i * 4 + 1] = 150;
px[i * 4 + 2] = 44;
px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255);
}
this.messTex.needsUpdate = true;
const solids = this.mess.solids.filter((sd) => !sd.collected);
const mat = new THREE.Matrix4();
let n = 0;
for (const sd of solids) {
if (n >= 256) break;
mat.setPosition(sd.u * TRAY_W - TRAY_W / 2, TRAY_Y * 2 + 0.02, sd.v * TRAY_D - TRAY_D / 2);
this.solidsMesh.setMatrixAt(n++, mat);
}
this.solidsMesh.count = n;
this.solidsMesh.instanceMatrix.needsUpdate = true;
}
private updateHud(): void {
const s = this.session;
if (!s) return;
const r = juiceResult(s);
const pct = Math.round(r.yieldOfOrange * 100);
this.yieldEl.textContent = `${pct}% of what this orange had — ${yieldWord(r.yieldOfReachable)}`;
// The fill can't pass the ceiling the halve left; show both.
this.yieldFill.style.width = `${Math.min(100, Math.round(r.yieldOfOrange * 100))}%`;
this.yieldFill.style.background = r.bitterness > 0.3 ? '#8a6a2a' : '#e8a53a';
const pips = s.seedsInGlass;
const bench = this.mess.benchWord();
const bit = s.bitterness > 0.3 ? ' · bitter — you reamed the pith' : s.bitterness > 0.1 ? ' · getting pithy' : '';
this.pipsEl.textContent = `pips: ${pips}${pips ? ' (click to fish out)' : ''} · bench: ${bench}${bit}`;
this.pipsEl.style.color = pips > 0 || bench !== 'clean' ? '#e2603a' : '';
}
/** What the judge reads. Snapshot any time; final at serve. */
result(): JuiceResult {
const s = this.session!;
return { ...juiceResult(s), bench: this.mess.stats() };
}
/** Fish one pip out of the glass (a click in play, a call in the harness). */
fishOnePip(): boolean {
const s = this.session;
if (!s || !fishPip(s)) return false;
const m = this.pipMeshes.pop();
if (m) this.glass.remove(m);
return true;
}
dispose(): void {
this.panel.dispose();
}
}

443
src/sim/juicing.ts Normal file
View File

@ -0,0 +1,443 @@
import type { Ingredient } from './ingredients';
import type { Criterion } from '../game/judging';
/**
* Juicing, on the same bones as everything else.
*
* The orange half sits cut-side-down on a ribbed reamer, and the only verb is
* the one the jar taught us: press-and-twist. The swirl detector in kitchen.ts
* turns pointer motion around a centre into angular sweep; this consumes that
* same signed sweep, one frame at a time, and does three honest things with it:
*
* extract the juice pulse this frame is sweep × press. Twist faster and
* more comes out THIS FRAME; stop twisting and it stops. That is
* the feel that has to be right causal, not a fill-bar on a timer.
* reverse flip the twist direction and the ribs bite a fresh face: a small
* bonus bite, exactly the way a real reamer rewards the back-and-
* forth wrist.
* punish press without twisting and you don't juice, you MASH: pulp tears
* into the glass and juice sprays the bench. Press too hard and the
* seeds jump the moat. Grind on after the well runs dry and you're
* reaming pith bitterness, which no amount of yield buys back.
*
* The cut you made upstream (the halve) sets the ceiling: a butchered 60/40
* half leaves juice you physically cannot reach, and the yield bar can't lie
* about it. Nothing here imports three.js it's driven by numbers the scene
* hands it, which is what lets the harness juice an orange headless.
*/
export type JuicerId = 'pyramid_classic' | 'ribbed_deluxe' | 'juicernaut';
export interface Juicer {
id: JuicerId;
name: string;
/** Extraction efficiency — how well the ribs grip and tear the flesh. */
ream: number;
/** 0..1 fraction of loosened seeds the moat catches before the glass. */
seedCatch: number;
/** Extra reachable juice this reamer squeezes past a bare hand-half (0..). */
yieldBonus: number;
blurb: string;
}
export const JUICERS: Record<JuicerId, Juicer> = {
pyramid_classic: {
id: 'pyramid_classic',
name: 'Pyramid Classic',
ream: 0.92,
// No moat. Every pip it frees goes straight to the glass, and it knows.
seedCatch: 0,
yieldBonus: 0,
blurb: 'Ribbed glass, no strainer. Honest about the pips.',
},
ribbed_deluxe: {
id: 'ribbed_deluxe',
name: 'Ribbed Deluxe',
ream: 1.06,
// A moat around the cone catches most of what comes loose.
seedCatch: 0.8,
yieldBonus: 0.04,
blurb: 'A moat for the pips and taller ribs. A real upgrade.',
},
juicernaut: {
id: 'juicernaut',
name: 'The Juicernaut',
ream: 1.32,
// Catches everything, squeezes more, and the judge sits up.
seedCatch: 1,
yieldBonus: 0.1,
blurb: 'Legendary. It catches everything and asks for more.',
},
};
/** What one frame of press-and-twist did the scene turns these into juice
* in the glass, pips, spray on the bench, and audio. */
export interface TwistFrame {
/** Juice extracted this frame: the pulse. Feeds the glass and the yield bar. */
juice: number;
/** Seeds the moat caught this frame (never reach the glass). */
seedsCaught: number;
/** Seeds that made it into the glass this frame — the pips. */
seedsEscaped: number;
/** Juice/pulp thrown onto the bench this frame (→ mess field). */
spray: number;
/** Pressed without twisting: pulp tore instead of juicing. */
tearing: boolean;
/** A twist reversal bit a fresh face — a bonus pulse. */
reversalBonus: boolean;
/** Pith bitterness added this frame (grinding a dry, over-reamed half). */
bitterness: number;
}
export interface JuiceSession {
ing: Ingredient;
juicer: Juicer;
/** Reachable juice, 0..1 of the whole orange the ceiling the halve left,
* already lifted by this reamer's yieldBonus. */
theoretical: number;
placed: boolean;
/** Absolute juice in the glass, 0..theoretical. */
extracted: number;
/** Murk: pulp mashed into the juice by pressing without twisting. */
pulp: number;
/** Pith bitterness, 0..1 — over-reaming past the dry point. */
bitterness: number;
/** Total juice sprayed off the reamer onto the bench. */
sprayTotal: number;
seedsTotal: number;
seedsReleased: number;
seedsCaught: number;
seedsInGlass: number;
// Gesture state — mirrors the cut sim's per-stroke accumulators.
lastDir: number;
sinceReversal: number;
/** Seconds of pressing with (almost) no twist — the mash timer. */
mashTime: number;
/** Total angular travel, for the readout and audio rhythm. */
revolutions: number;
}
// --- Tuning. First principles, then measured on the bench (see commit). ---
/** Juice per unit of (sweep × press × ream). Measured so a clean half reaches
* ~73% at 3 revolutions/press 0.7 and wrings dry by ~5 firm revolutions on the
* Pyramid Classic the twist rate is the juice rate, the count is the effort. */
const EXTRACT_GAIN = 0.06;
/** A reversal's bonus bite, scaled by press×ream. Small — flavour, not a cheat. */
const BONUS_BITE = 0.015;
/** Minimum sweep between reversals before another one counts (anti-jitter). */
const REVERSAL_MIN = 0.25;
/** Remaining-juice below this reads as "the well is dry" → grinding = pith. */
const DRY = 0.02;
/** Bitterness per unit of dry-grinding (sweep × press), before pith scaling. */
const PITH_GAIN = 0.05;
/** Fraction of each juice pulse that sprays the bench. Reamers are messy. */
const SPRAY_FRAC = 0.06;
/** Press above this with no twist is a mash, not a juice. */
const TEAR_PRESS = 0.55;
const TEAR_SWEEP = 0.02;
/** Seconds of mashing before the pulp tears. */
const TEAR_PATIENCE = 0.28;
const TEAR_PULP = 0.05;
const TEAR_SPRAY = 0.05;
/** Press above this crushes the pith and shoves seeds past the moat. */
const CRUSH_KNEE = 0.85;
/** How much crush-pressure bypasses the moat (per unit of press over the knee). */
const CRUSH_BYPASS = 2;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* The halve's gift to the juicer: how much of the orange is reachable at all.
* `yield = base × (1 2|offset|) × (1 0.5·wedge)` a dead-centre cut leaves
* everything, a cut at the edge (offset 0.5) leaves nothing, and a wedged face
* bleeds a little more of it out of reach. Reads halveQuality() from cutting.ts.
*/
export function theoreticalYield(halve: { offset: number; wedge: number }, base = 1): number {
return clamp01(base * (1 - 2 * Math.abs(halve.offset)) * (1 - 0.5 * halve.wedge));
}
export function newJuiceSession(
ing: Ingredient,
juicer: Juicer,
reachable: number,
seedCount?: number,
): JuiceSession {
return {
ing,
juicer,
// The good reamer squeezes a touch past a bare half; cap at the whole orange.
theoretical: clamp01(reachable * (1 + juicer.yieldBonus)),
placed: false,
extracted: 0,
pulp: 0,
bitterness: 0,
sprayTotal: 0,
seedsTotal: seedCount ?? ing.seeds?.count ?? 0,
seedsReleased: 0,
seedsCaught: 0,
seedsInGlass: 0,
lastDir: 0,
sinceReversal: 0,
mashTime: 0,
revolutions: 0,
};
}
/** Drag the half onto the cone. Nothing juices until it's seated. */
export function placeHalf(s: JuiceSession): void {
s.placed = true;
}
/**
* One frame of press-and-twist. `dTheta` is the signed angular sweep of the
* pointer around the reamer this frame (radians); `press` is 0..1 downforce.
* `rand` decides only whether a freed seed clears the moat everything else is
* deterministic, which is what makes the harness able to check it.
*/
export function juiceTwist(
s: JuiceSession,
dTheta: number,
press: number,
dt: number,
rand: () => number,
): TwistFrame {
const out: TwistFrame = {
juice: 0,
seedsCaught: 0,
seedsEscaped: 0,
spray: 0,
tearing: false,
reversalBonus: false,
bitterness: 0,
};
if (!s.placed || dt <= 0) return out;
const p = clamp01(press);
const sweep = Math.abs(dTheta);
const dir = Math.sign(dTheta);
s.revolutions += sweep;
const crush = Math.max(0, p - CRUSH_KNEE);
const remaining = Math.max(0, s.theoretical - s.extracted);
// --- Extraction: the pulse is sweep × press, capped by what's reachable. ---
if (remaining > 0 && sweep > 0 && p > 0) {
const pulse = Math.min(remaining, sweep * p * s.juicer.ream * EXTRACT_GAIN);
s.extracted += pulse;
out.juice += pulse;
}
// --- Reversal bonus: flip direction on a fresh face, get a bite. ---
if (dir !== 0) {
if (s.lastDir !== 0 && dir !== s.lastDir && s.sinceReversal > REVERSAL_MIN) {
const rem = Math.max(0, s.theoretical - s.extracted);
const bonus = Math.min(rem, BONUS_BITE * p * s.juicer.ream);
if (bonus > 0) {
s.extracted += bonus;
out.juice += bonus;
out.reversalBonus = true;
}
s.sinceReversal = 0;
}
s.sinceReversal += sweep;
s.lastDir = dir;
}
// --- Mash: press without twist tears pulp and sprays, doesn't juice. ---
let forceSeed = false;
if (p > TEAR_PRESS && sweep < TEAR_SWEEP) {
s.mashTime += dt;
if (s.mashTime > TEAR_PATIENCE) {
out.tearing = true;
s.pulp += TEAR_PULP * p;
out.spray += TEAR_SPRAY * p;
forceSeed = true;
s.mashTime = 0;
}
} else {
s.mashTime = Math.max(0, s.mashTime - dt * 2);
}
// --- Spray: a little of every honest pulse lands on the bench too. ---
if (out.juice > 0) {
const sp = out.juice * SPRAY_FRAC;
out.spray += sp;
}
s.sprayTotal += out.spray;
// --- Pith: grinding a dry half reams the peel. Bitterness, no yield. ---
if (remaining < DRY && p > 0.2 && sweep > 0) {
const pith = 0.5 + (s.ing.gratable?.pithDepth ?? 0.3);
const db = sweep * p * PITH_GAIN * pith;
s.bitterness = clamp01(s.bitterness + db);
out.bitterness = db;
}
// --- Seeds: freed by extraction progress (and by every tear), then the
// moat gets a chance to catch them — unless you crushed them past it. ---
if (s.theoretical > 1e-4) {
const frac = s.extracted / s.theoretical;
while (
s.seedsReleased < s.seedsTotal &&
frac >= (s.seedsReleased + 1) / (s.seedsTotal + 1)
) {
releaseSeed(s, crush, rand, out);
}
}
if (forceSeed && s.seedsReleased < s.seedsTotal) releaseSeed(s, crush, rand, out);
return out;
}
function releaseSeed(s: JuiceSession, crush: number, rand: () => number, out: TwistFrame): void {
s.seedsReleased++;
const effCatch = clamp01(s.juicer.seedCatch - crush * CRUSH_BYPASS);
if (rand() < effCatch) {
s.seedsCaught++;
out.seedsCaught++;
} else {
s.seedsInGlass++;
out.seedsEscaped++;
}
}
/** Fish a pip out of the glass — the scene charges service time for it. */
export function fishPip(s: JuiceSession): boolean {
if (s.seedsInGlass <= 0) return false;
s.seedsInGlass--;
return true;
}
// -------------------------------------------------------------------------
// The judge's side. Kept here so the game has ONE place to consume juicing:
// import { JuiceResult, juiceResult, juiceCriteria, JUICE_LINES } from
// '../sim/juicing';
// The scene assembles JuiceResult (juiceResult(session) + the bench stats it
// owns) and hands it to the game; the game calls juiceCriteria(result) and
// merges JUICE_LINES into its verdict bank. Bench itself is scored by the
// existing benchCriterion via result.bench — same path prep orders use.
// -------------------------------------------------------------------------
/** The number-bag the prep bench hands the judge for a juice order. */
export interface JuiceResult {
juicerId: JuicerId;
juicerName: string;
/** Absolute juice in the glass, 0..1 of the whole orange. */
extracted: number;
/** The reachable ceiling the halve (and reamer) left, 0..1. */
theoretical: number;
/** extracted / theoretical — how much of the reachable you actually got. */
yieldOfReachable: number;
/** extracted — the live readout's "% of what this orange had". */
yieldOfOrange: number;
pips: number;
seedsCaught: number;
bitterness: number;
pulp: number;
/** Filled in by the scene from its mess field, same shape prep uses. */
bench?: { mass: number; spreadFrac: number; solids: number };
}
export function juiceResult(s: JuiceSession): JuiceResult {
const yieldOfReachable = s.theoretical > 1e-4 ? s.extracted / s.theoretical : 0;
return {
juicerId: s.juicer.id,
juicerName: s.juicer.name,
extracted: s.extracted,
theoretical: s.theoretical,
yieldOfReachable: clamp01(yieldOfReachable),
yieldOfOrange: s.extracted,
pips: s.seedsInGlass,
seedsCaught: s.seedsCaught,
bitterness: s.bitterness,
pulp: s.pulp,
};
}
/** Live-readout word for the yield bar, in the judge's own thresholds. */
export function yieldWord(yieldOfReachable: number): string {
return yieldOfReachable < 0.4
? 'barely wet'
: yieldOfReachable < 0.7
? 'half a glass'
: yieldOfReachable < 0.9
? 'a good pour'
: 'wrung dry';
}
/**
* The Juice and the Pips. The Juice bands yield against the reachable max, then
* docks the score for bitterness (over-reaming) and murk (mashing) a bitter
* pulpy glass is not a good glass however full it is. The Pips criterion counts
* seeds in the glass, and it is unforgiving, because he can see them.
*/
export function juiceCriteria(result: JuiceResult): Criterion[] {
const yieldScore = clamp01((result.yieldOfReachable - 0.35) / 0.6);
const penalty = result.bitterness * 0.7 + Math.min(0.35, result.pulp * 1.2);
const juiceScore = clamp01(yieldScore - penalty);
const pct = Math.round(result.yieldOfOrange * 100);
const bitWord =
result.bitterness > 0.35 ? ', gone bitter' : result.bitterness > 0.12 ? ', a little pithy' : '';
const murkWord = result.pulp > 0.12 ? ', pulpy' : '';
const pips = result.pips;
const pipScore = pips === 0 ? 1 : clamp01(1 - pips / 5);
return [
{
key: 'yield',
group: 'prep',
label: 'The Juice',
score: juiceScore,
weight: 1.3,
detail: `${pct}% of the orange${bitWord}${murkWord} (${Math.round(result.theoretical * 100)}% reachable)`,
},
{
key: 'pips',
group: 'prep',
label: 'Pips',
score: pipScore,
weight: 1.1,
detail: pips === 0 ? 'not one pip' : `${pips} in the glass`,
},
];
}
/** The Juicernaut announces itself. Slotted in as an opener when recognised. */
export function juicerRecognitionLine(id: JuicerId): string | null {
return id === 'juicernaut' ? 'Is that a Juicernaut. Sit down.' : null;
}
/** Judge lines for the two juice criteria merged into lines.ts's BANK by the
* game. Five bad, three good, in his voice: dry, specific, never cruel. */
export const JUICE_LINES: Record<'yield' | 'pips', { bad: string[]; good: string[] }> = {
yield: {
bad: [
'There is more juice in the reamer than the glass. You lost the argument.',
'You reamed the pith. I can taste the peel. It tastes like a headache.',
'Half the orange is still in the orange.',
'You mashed it. This is not juice, it is a suspension.',
'A cut like that left juice you were never going to reach. It shows.',
],
good: [
'Wrung dry. There is nothing left in that half and everything in the glass.',
'Full pour, no pith, no pulp. That is the whole orange, respectfully.',
'You worked the wrist, both directions. I could hear it was right.',
],
},
pips: {
bad: [
'There are three pips in this. I counted. I shouldn\'t have been able to.',
'A pip. In the glass. Where I can see it.',
'You pressed so hard the seeds jumped the moat. Ambitious.',
'I found the seeds. They found me first.',
'Fish them out or serve them proudly. You did neither, then both.',
],
good: [
'Not a pip in it. Clear all the way down.',
'Seedless. Somebody caught every one.',
'I looked for a pip. I am almost disappointed.',
],
},
};