From efe4d1d1a623c3309b9ded8ba5ff4379277b3531 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 19:06:40 +1000 Subject: [PATCH] =?UTF-8?q?M13:=20the=20grater=20=E2=80=94=20one=20stroke,?= =?UTF-8?q?=20and=20the=20block=20shrinks=20toward=20your=20knuckles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prep bench's third station, built on the M12 juice lane's exact shape: pure sim (sim/grating.ts, no three.js), a mirroring scene (scenes/grate.ts), and a separate harness on window.__tg (dev-grate.ts). One additive DEV import line in main.ts. Judge integration is exported and documented in-file (grateResult / grateCriteria / GRATE_LINES / knuckleLine) — NOT wired into game/* (that lane is owned elsewhere this stage). The verb is a STROKE down the box-grater face: length × pressure → shred mass. Long confident strokes land on the pile; short scrubby ones shear off the board (mess). Two customers on one prop — cheese (parmesan) and zest (orange): - Cheese shrinks the block as you take mass off it. As it gets SMALL your knuckles near the teeth: knuckle-graze risk = pressure × stroke-length × block-proximity, so it only bites in the tense last third. Parmesan grates clean IF cold; it softens on the bench (temp clock, warmOnBench) and soft cheese SMEARS — output drops, the teeth CLOG (clearClog costs service time). The butter inversion: butter wants warm, cheese wants cold. - Zest: each of 8 orange regions has its own depth; grate one past the pith line (ingredient gratable.pithDepth 0.35) and it sheds white pith into the pile instead of zest. Rotate (scroll) to a fresh region — the skill is rotation discipline. The pith idea is re-derived from the ingredient's own pithDepth; grating deliberately does NOT import juicing (independent lanes). Judge: The Gratings/The Zest (amount band, w1.0, docked hard per knuckle), The Pith (zest, w1.1), The Grate (cheese clog+smear, w0.9). Bench scored separately via result.bench, same path prep/juice use. 8 bad + 3 good lines per criterion, plus the knuckle opener. Tuning (first principles, then measured headless on the pure sim): - RASP 0.12, gratable.yield 0.9 → a long stroke (len 0.9 × press 0.7) sheds ~0.068/stroke; ~7-8 strokes make an in-band pile. - KNUCKLE_SAFE_BLOCK 0.35: proximity is zero above it, ramps to 1 at empty. KNUCKLE_GAIN 0.6. Oranges never shrink, so zest never grazes (its danger is pith, not knuckles) — by design. - ZEST_DEPTH_GAIN 0.18 → a region hits the 0.35 pith line in ~3 hard strokes, so rotate by then. ZEST_MASS_FRAC 0.4 (zest is a whisper vs a cheese pile). - SPRAY_GAIN 0.9 on (1-length)^2 — spray climbs steeply as strokes shorten. - SMEAR_LOSS 0.8, CLOG_GAIN 0.35 × grater.clogProne (box 0.7 / microplane 1.3). Verified: tsc --noEmit clean, npm run build clean. Sim driven headless with the scene's own seed (0x6ac0f). __tg calls and expected numbers: __tg.enter({ingredient:'parmesan'}); __tg.stroke(8,0.7,0.9); __tg.stats() → gratings≈0.61 "a good pile", blockLeft≈0.38, knuckleHits 0, clog 0; The Gratings 1.0, The Grate 1.0. __tg.enter({ingredient:'parmesan'}); __tg.stroke(12,0.6,0.25); __tg.stats() → scrub: gratings≈0.10 "a dusting", stray≈0.10 off the board, block barely touched (0.81); The Gratings 0.24. __tg.enter({ingredient:'parmesan',softness:1}); __tg.stroke(8,0.8,0.9); __tg.stats() → soft: clog 1.0, smear≈0.61, gratings≈0.05; The Grate 0.0 "clogged solid". __tg.enter({ingredient:'parmesan',block:0.25}); __tg.stroke(8,0.9,0.95); __tg.stats() → danger: knuckleHits 4, blockLeft≈0.02, gratings≈0.24; The Gratings 0.008. __tg.enter({ingredient:'parmesan',block:0.25}); __tg.stroke(8,0.9,0.35); __tg.stats() → ease off: knuckleHits 0, gratings≈0.22; The Gratings 0.54. The lesson pays. __tg.enter({ingredient:'orange'}); __tg.stroke(5,0.6,0.8) with rotate every 2 → gratings≈0.12 in band, pithFrac 0; The Zest 1.0, The Pith 1.0. __tg.enter({ingredient:'orange'}); __tg.stroke(9,0.9,0.9); __tg.stats() // no rotation → pithFrac≈0.74 "74% pith"; The Pith 0.0. (Scene HUD, pile mound, red knuckle flash, and scroll-rotate are visual — the reviewer drives __tg and screenshots; the numbers above come from the pure sim the scene feeds.) Co-Authored-By: Claude Fable 5 --- src/dev-grate.ts | 195 +++++++++++++++ src/main.ts | 1 + src/scenes/grate.ts | 569 ++++++++++++++++++++++++++++++++++++++++++++ src/sim/grating.ts | 562 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1327 insertions(+) create mode 100644 src/dev-grate.ts create mode 100644 src/scenes/grate.ts create mode 100644 src/sim/grating.ts diff --git a/src/dev-grate.ts b/src/dev-grate.ts new file mode 100644 index 0000000..e4493e3 --- /dev/null +++ b/src/dev-grate.ts @@ -0,0 +1,195 @@ +import * as THREE from 'three'; +import type { App } from './core/app'; +import { GrateView } from './scenes/grate'; +import type { IngredientId } from './sim/ingredients'; +import type { GraterId } from './sim/grating'; + +/** + * Grate-station dev harness — a SEPARATE installer from dev.ts and dev-juice.ts + * so the three lanes don't collide. It owns its own GrateView (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 vertical pointer drags down the grater face + * for the strokes, `press` injected the way dev.ts injects kitchen.power. + * + * The reviewer verifies with a handful of calls, e.g. + * __tg.enter({ ingredient: 'parmesan' }); __tg.stroke(8, 0.7, 0.9); __tg.stats() + * → a good cold-parmesan pile (~0.5 shred), no knuckles, clean bench. + * __tg.enter({ ingredient: 'parmesan' }); __tg.stroke(10, 0.6, 0.25); __tg.stats() + * → scrubby short strokes: much of it strays off the board, bench dirtied. + * __tg.enter({ ingredient: 'parmesan', softness: 1 }); __tg.stroke(8, 0.8, 0.9); __tg.stats() + * → warm cheese: smear + clog climb, gratings drop. + * __tg.enter({ ingredient: 'parmesan', block: 0.25 }); __tg.stroke(8, 0.95, 0.9); __tg.stats() + * → small block + hard press: knuckleHits > 0 (the tense end). + * __tg.enter({ ingredient: 'orange' }); __tg.stroke(3, 0.7, 0.8); __tg.stats() + * → clean zest, low pith (rotate before it whitens). + * __tg.enter({ ingredient: 'orange' }); __tg.stroke(8, 0.9, 0.9); __tg.stats() + * → no rotation: pithFrac climbs, "The Pith" tanks. + * + * Dev builds only. + */ +export function installGrateHarness(app: App): void { + const view = new GrateView(app); + app.scene.add(view.root); + // Hidden until __tg.enter() makes it the active view. + view.root.visible = false; + + const sessionOf = () => + (view as unknown as { + session: { + placed: boolean; + mode: 'cheese' | 'zest'; + blockMass: number; + initialBlock: number; + shred: number; + softness: number; + clog: number; + smearTotal: number; + zestShed: number; + pithShed: number; + faces: number[]; + faceIndex: number; + strayTotal: number; + knuckleHits: number; + bloodied: boolean; + strokeCount: number; + totalTravel: 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); + }; + + // Must match the face geometry in grate.ts. + const FACE_TOP = 1.15; + const FACE_BOT = 0.28; + const FACE_H = FACE_TOP - FACE_BOT; + const FACE_Z = 0.1; + + const harness = { + app, + view, + THREE, + run, + point, + + /** Reset the station and make it the active view. */ + enter( + opts: { + ingredient?: IngredientId; + grater?: GraterId; + block?: number; + softness?: number; + } = {}, + ) { + const { ingredient = 'parmesan', grater = 'box_grater', block, softness } = opts; + view.reset(grater, ingredient, `grate the ${ingredient} (${grater})`, { + blockMass: block, + softness, + }); + app.setView(view); + run(3); // let the view take the camera before we project points + return harness.stats(); + }, + + /** + * `n` strokes DOWN the face at a fixed `pressure` (0..1) and `length` (0..1, + * fraction of the full face). The press is injected; the stroke itself is + * real pointer motion down the face, so the sim sees exactly what a hand + * would produce — length is measured from the travel, not passed in. + */ + stroke(n = 8, pressure = 0.7, length = 0.9) { + view.autoPress = false; + view.press = pressure; + const travel = Math.max(0.05, Math.min(1, length)) * FACE_H; + const startY = FACE_TOP - 0.05; + const steps = 10; + for (let k = 0; k < n; k++) { + // Drag down the face. + for (let i = 0; i <= steps; i++) { + const y = startY - (i / steps) * travel; + point(0, y, FACE_Z, true); + run(1); + } + // Lift → the stroke commits. + point(0, startY - travel, FACE_Z, false); + run(1); + } + return harness.stats(); + }, + + /** Rotate the orange to a fresh region (scroll in play). Zest only. */ + rotate(times = 1, dir: 1 | -1 = 1) { + const s = sessionOf(); + for (let i = 0; i < times; i++) { + app.input.wheel = dir; + run(1); + } + return { faceIndex: s?.faceIndex ?? 0 }; + }, + + /** Advance the bench temperature clock — warm the cheese `seconds` in place. */ + warm(seconds = 5) { + app.input.down = false; + run(Math.round(seconds * 60)); + return harness.stats(); + }, + + /** Clear the clogged teeth (costs the player service time in play). */ + clear() { + view.clearTeeth(); + 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, + mode: r.mode, + // The pile the judge scores as an amount band. + gratings: round(r.gratings), + shred: round(s?.shred ?? 0), + zest: round(s?.zestShed ?? 0), + pith: round(r.pith), + pithFrac: round(r.pithFrac), + blockLeft: round(r.blockLeft), + blockMass: round(s?.blockMass ?? 0), + softness: round(s?.softness ?? 0), + clog: round(r.clog), + smear: round(r.smear), + stray: round(s?.strayTotal ?? 0), + knuckleHits: r.knuckleHits, + bloodied: r.bloodied, + strokeCount: s?.strokeCount ?? 0, + totalTravel: round(s?.totalTravel ?? 0), + faceIndex: s?.faceIndex ?? 0, + bench: { + mass: round(r.bench?.mass ?? 0), + spreadFrac: round(r.bench?.spreadFrac ?? 0), + solids: r.bench?.solids ?? 0, + }, + benchWord: view.mess.benchWord(), + grater: r.graterName, + }; + }, + + /** 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 { __tg: unknown }).__tg = harness; +} diff --git a/src/main.ts b/src/main.ts index 80bf34f..9e0be38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,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)); + void import('./dev-grate').then((m) => m.installGrateHarness(app)); } app.start(); diff --git a/src/scenes/grate.ts b/src/scenes/grate.ts new file mode 100644 index 0000000..30a0076 --- /dev/null +++ b/src/scenes/grate.ts @@ -0,0 +1,569 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { INGREDIENTS, type IngredientId } from '../sim/ingredients'; +import { + clearClog, + grateResult, + grateStroke, + GRATERS, + newGrateSession, + placeBlock, + rotateOrange, + warmOnBench, + amountWord, + type GrateResult, + type GrateSession, + type GraterId, +} from '../sim/grating'; +import { Mess } from '../sim/mess'; +import { Rng } from '../core/rng'; +import { el, Panel } from '../ui/hud'; +import { loadProp } from './assets'; + +/** + * The grate station. One box grater, one block (or orange), one growing pile. + * + * It mirrors the juice bench on purpose: procedural stand-in silhouettes (the + * rasped face IS gameplay — the teeth are why a stroke bites), a mess field + * that remembers the shreds that sprayed off the board, a GLB prop that drops + * in over the stand-in if it renders, and a HUD that reads the same numbers the + * judge will. + * + * The one verb is a STROKE: press the block onto the face (hold to lean in — + * the dwell IS the press) and drag DOWN. The vertical travel of the pointer on + * the face is the stroke length; long confident strokes yield and land on the + * pile, short scrubby ones shear off the board. As the block shrinks your + * knuckles near the teeth — press hard on a small block and you'll find out. + */ + +const TRAY_Y = 0.06; +const TRAY_W = 3.2; +const TRAY_D = 2.4; +/** The grater face is a vertical plane in front of the camera; the stroke is + * measured as vertical travel of the pointer hit on it. */ +const FACE_Z = 0.1; +const FACE_TOP = 1.15; +const FACE_BOT = 0.28; +const FACE_H = FACE_TOP - FACE_BOT; +/** Where the pile of shreds mounds up, in front of the grater's foot. */ +const PILE_Z = 0.7; + +/** Press ramps in while you hold the block against the face. */ +const PRESS_RAMP = 1.8; + +export class GrateView implements View { + readonly root = new THREE.Group(); + readonly mess = new Mess(96); + + private session: GrateSession | null = null; + private ingId: IngredientId = 'parmesan'; + + private grater!: THREE.Group; + private block: THREE.Group | null = null; + private pile!: THREE.InstancedMesh; + private pileFlakes: { u: number; v: number; s: number; pith: boolean }[] = []; + private pileGeo = new THREE.BoxGeometry(1, 1, 1); + private pileMat: THREE.MeshStandardMaterial; + private pithMat: THREE.MeshStandardMaterial; + + private messTexData: Uint8Array; + private messTex: THREE.DataTexture; + private messVersion = -1; + private solidsMesh: THREE.InstancedMesh; + + private ray = new THREE.Raycaster(); + private facePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -FACE_Z); + private hitPt = new THREE.Vector3(); + + // Stroke state: track the pointer's vertical travel down the face. + private stroking = false; + private strokeLastY = 0; + private strokeTravel = 0; + private holdT = 0; + private flash = 0; + + /** 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(0x6ac0f); + private panel: Panel; + private flashEl!: HTMLElement; + private askEl!: HTMLElement; + private amountEl!: HTMLElement; + private amountBar!: HTMLElement; + private amountFill!: HTMLElement; + private stateEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: GrateResult) => void) | null = null; + + constructor(private app: App) { + this.pileMat = new THREE.MeshStandardMaterial({ color: 0xede0b0, roughness: 0.7 }); + this.pithMat = new THREE.MeshStandardMaterial({ color: 0xf4f1e6, roughness: 0.8 }); + 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); + + // The pile: little instanced flakes mounding in front of the grater. + this.pile = new THREE.InstancedMesh(this.pileGeo, this.pileMat, 512); + this.pile.count = 0; + this.pile.castShadow = true; + this.pile.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(512 * 3), 3); + this.root.add(this.pile); + + const seedGeo = new THREE.SphereGeometry(0.02, 6, 5); + seedGeo.scale(1.4, 0.5, 0.9); + this.solidsMesh = new THREE.InstancedMesh( + seedGeo, + new THREE.MeshStandardMaterial({ color: 0xe6d488, roughness: 0.7 }), + 256, + ); + this.solidsMesh.count = 0; + this.solidsMesh.castShadow = true; + this.root.add(this.solidsMesh); + + this.panel = new Panel('grate-panel'); + this.buildUi(); + this.panel.hide(); + } + + private buildUi(): void { + const p = this.panel.root; + // A red flash overlay for the knuckle graze — brief, full-panel. + this.flashEl = el('div', 'grate-flash', p); + this.flashEl.style.cssText = + 'position:fixed;inset:0;background:#c01818;opacity:0;pointer-events:none;transition:opacity 0.08s;'; + const card = el('div', 'slicer-card', p); + el('div', 'drawer-lbl', card, 'GRATE IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.amountEl = el('div', 'slicer-thick', card, ''); + this.amountBar = el('div', 'timer-bar', card); + this.amountFill = el('div', 'timer-fill', this.amountBar); + this.stateEl = 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.grater = this.buildGrater(); + this.root.add(this.grater); + void loadProp('/assets/models/box_grater.glb', 1.3) + .then((prop) => { + prop.position.set(0, TRAY_Y * 2, 0); + // Keep the procedural face as the stroke target; hide only the shell. + const shell = this.grater.getObjectByName('shell'); + if (shell) shell.visible = false; + const face = this.grater.getObjectByName('face'); + if (face) face.visible = false; + this.root.add(prop); + }) + .catch(() => undefined); + } + + /** The box grater, procedural: a tapered four-sided shell with a rasped face + * turned toward the camera, and a handle on top. */ + private buildGrater(): THREE.Group { + const g = new THREE.Group(); + const metal = new THREE.MeshStandardMaterial({ + color: 0xb9c0c8, + roughness: 0.35, + metalness: 0.8, + }); + // Shell: a gently tapered box, hollow-looking from the coarse-teeth face. + const shell = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.42, FACE_TOP - 0.12, 4), metal); + shell.name = 'shell'; + shell.rotation.y = Math.PI / 4; + shell.position.y = (FACE_TOP - 0.12) / 2 + TRAY_Y * 2; + shell.castShadow = true; + shell.receiveShadow = true; + g.add(shell); + + // The rasped face turned toward the camera (+z): a punched-hole grid that + // reads as teeth. This is the stroke target. + const face = new THREE.Group(); + face.name = 'face'; + const faceMat = new THREE.MeshStandardMaterial({ + color: 0xd6dbe0, + roughness: 0.4, + metalness: 0.7, + side: THREE.DoubleSide, + }); + const plate = new THREE.Mesh(new THREE.PlaneGeometry(0.5, FACE_H), faceMat); + plate.position.set(0, (FACE_TOP + FACE_BOT) / 2, 0.30); + face.add(plate); + // Teeth: rows of tiny raised nibs, purely to read as a grating surface. + const toothMat = new THREE.MeshStandardMaterial({ color: 0xf0f3f6, roughness: 0.3, metalness: 0.6 }); + const cols = 6; + const rows = 9; + const teeth = new THREE.InstancedMesh(new THREE.ConeGeometry(0.018, 0.03, 4), toothMat, cols * rows); + const m = new THREE.Matrix4(); + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, 0)); + let ti = 0; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = (c / (cols - 1) - 0.5) * 0.44; + const y = FACE_BOT + 0.05 + (r / (rows - 1)) * (FACE_H - 0.1); + m.compose(new THREE.Vector3(x, y, 0.315), q, new THREE.Vector3(1, 1, 1)); + teeth.setMatrixAt(ti++, m); + } + } + teeth.instanceMatrix.needsUpdate = true; + face.add(teeth); + g.add(face); + + // Handle across the top. + const handle = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.03, 8, 16, Math.PI), metal); + handle.position.set(0, FACE_TOP - 0.06, 0); + handle.rotation.x = Math.PI / 2; + g.add(handle); + return g; + } + + /** + * A fresh block/orange on a chosen grater. `blockMass` sets a fresh corner; + * `softness` (0 cold .. 1 soft) is where the temperature clock starts. + */ + reset( + graterId: GraterId, + ingId: IngredientId, + askText: string, + opts: { blockMass?: number; softness?: number } = {}, + ): void { + this.ingId = ingId; + this.session = newGrateSession(INGREDIENTS[ingId], GRATERS[graterId], opts); + this.mess.reset(); + this.press = 0; + this.holdT = 0; + this.stroking = false; + this.strokeTravel = 0; + this.autoPress = true; + this.flash = 0; + + this.pileFlakes = []; + this.pile.count = 0; + + if (this.block) this.root.remove(this.block); + this.block = this.buildBlock(); + this.seatBlock(); + this.root.add(this.block); + + this.askEl.textContent = askText; + this.hintEl.textContent = + this.session.mode === 'zest' + ? 'hold to press · stroke DOWN the face · scroll to rotate the orange · ENTER serves' + : 'hold to press · long strokes DOWN the face · ease off as it shrinks · ENTER serves'; + } + + private buildBlock(): THREE.Group { + const g = new THREE.Group(); + const ing = INGREDIENTS[this.ingId]; + if (this.session?.mode === 'zest') { + const r = ing.size / 2; + const skin = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace), + roughness: 0.6, + }); + const ball = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 16), skin); + ball.scale.set(1.05, 0.94, 1.05); + ball.castShadow = true; + g.add(ball); + } else { + const skin = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...ing.colors.flesh, THREE.SRGBColorSpace), + roughness: 0.75, + }); + const blk = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.7, ing.size * 0.7), skin); + blk.castShadow = true; + g.add(blk); + // A darker rind on one end, for parmesan. + const rind = new THREE.Mesh( + new THREE.BoxGeometry(ing.size * 0.12, ing.size * 0.72, ing.size * 0.72), + new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace), + roughness: 0.9, + }), + ); + rind.position.x = -ing.size * 0.5; + g.add(rind); + } + return g; + } + + /** Rest the block against the face at a height that tracks how much is left. */ + private seatBlock(): void { + if (!this.block || !this.session) return; + const left = this.session.blockMass / this.session.initialBlock; + // As it shrinks, the block sits lower and smaller — knuckles near the teeth. + const y = FACE_BOT + 0.12 + left * (FACE_H * 0.5); + this.block.position.set(0, y, FACE_Z + 0.12); + if (this.session.mode === 'cheese') { + const sc = 0.35 + left * 0.65; + this.block.scale.set(sc, 1, 1); + } + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + update(dt: number): void { + const inp = this.app.input; + this.app.camera.position.set(1.15, 1.75, 3.4); + this.app.camera.lookAt(0.2, 0.7, 0.1); + const s = this.session; + if (!s) return; + + // The bench warms the cheese in real time — the temperature clock. + warmOnBench(s, dt); + + // Scroll rotates the orange to a fresh region (zest only). + if (s.mode === 'zest' && Math.abs(inp.wheel) > 0.01) { + rotateOrange(s, inp.wheel > 0 ? 1 : -1); + } + + this.ray.setFromCamera(inp.ndc, this.app.camera); + this.handleStroke(dt, inp.down); + + // Clear the clogged teeth (costs service time in the game loop). + if (inp.justPressed('KeyC')) this.clearTeeth(); + + if (inp.justPressed('Enter') && this.onDone && s.placed) { + const cb = this.onDone; + this.onDone = null; + cb(this.result()); + return; + } + + // Fade the knuckle flash. + if (this.flash > 0) { + this.flash = Math.max(0, this.flash - dt * 3); + this.flashEl.style.opacity = `${Math.min(0.5, this.flash)}`; + } + + this.syncMess(); + this.updateHud(); + } + + /** The face is where the pointer must be to grate; measured on FACE_Z plane. */ + private overFace(): { y: number } | null { + if (!this.ray.ray.intersectPlane(this.facePlane, this.hitPt)) return null; + if (Math.abs(this.hitPt.x) > 0.34) return null; + if (this.hitPt.y < FACE_BOT - 0.1 || this.hitPt.y > FACE_TOP + 0.1) return null; + return { y: this.hitPt.y }; + } + + private handleStroke(dt: number, down: boolean): void { + const s = this.session!; + if (!s.placed) placeBlock(s); // the block rests on the face from the start + + const on = this.overFace(); + + // Press: hold on the face and lean in (the dwell is the downforce). + if (this.autoPress) { + if (down && on) { + 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); + } + } + + // Stroke tracking: pointer pressed on the face and dragged DOWN. + if (down && on) { + if (!this.stroking) { + this.stroking = true; + this.strokeLastY = on.y; + this.strokeTravel = 0; + } else { + // Downward motion accumulates travel; upward motion ends the stroke. + const dy = this.strokeLastY - on.y; // positive = moving down + if (dy > 0) this.strokeTravel += dy; + this.strokeLastY = on.y; + } + } else if (this.stroking) { + // Lifted (or left the face): commit the stroke. + this.commitStroke(); + this.stroking = false; + } + } + + private commitStroke(): void { + const s = this.session!; + const length = Math.min(1, this.strokeTravel / FACE_H); + if (length < 0.04) return; // a tap, not a stroke + const f = grateStroke(s, length, this.press, () => this.rng.next()); + this.applyStroke(f, length); + this.seatBlock(); + } + + private applyStroke(f: ReturnType, length: number): void { + const s = this.session!; + // Sound: the rasp scales with how much came off. + const yield_ = f.shred + f.zest + f.pith; + if (yield_ > 0.001) audio.scrape(Math.min(1, this.press), Math.min(6, 2 + length * 4)); + + // Good gratings mound onto the pile. + this.addToPile(f.shred + f.zest, false); + this.addToPile(f.pith, true); + + // Stray shreds shear off the board (→ mess solids + a light stain). + if (f.stray > 0.002) { + const a = this.rng.next() * Math.PI * 2; + const u = 0.5 + Math.cos(a) * (0.15 + this.rng.next() * 0.2); + const v = 0.62 + Math.sin(a) * 0.12; + this.mess.addJuice(u, v, f.stray * 0.4, Math.cos(a), Math.sin(a)); + this.mess.addSolid(u, v, s.mode === 'zest' ? 'zest' : 'crumb'); + } + // Smeared cheese is a wet mess on the board. + if (f.smear > 0.002) { + this.mess.addJuice(0.5, 0.68, f.smear * 0.6, 0, 1); + } + if (f.clogged) { + this.hintEl.textContent = 'the teeth are clogging — press C to clear, or it smears'; + } + if (f.knuckle) { + this.flash = 0.5; + this.app.shake = 0.02; + audio.clatter(0.6, 1.2); + this.mess.addSolid(0.5 + (this.rng.next() - 0.5) * 0.1, 0.66, 'pulp'); + this.hintEl.textContent = "that's your knuckle — ease off, the block is small"; + } + } + + private addToPile(mass: number, pith: boolean): void { + if (mass <= 0) return; + // One flake per ~0.02 mass, clustered in a mound in front of the grater. + const flakes = Math.max(1, Math.round(mass / 0.02)); + for (let i = 0; i < flakes && this.pileFlakes.length < 512; i++) { + const a = this.rng.next() * Math.PI * 2; + const r = Math.sqrt(this.rng.next()) * 0.26; + const u = 0.5 + Math.cos(a) * r; + const v = (PILE_Z / TRAY_D + 0.5) + Math.sin(a) * r * 0.6; + this.pileFlakes.push({ u, v, s: 0.02 + this.rng.next() * 0.02, pith }); + } + this.syncPile(); + } + + private syncPile(): void { + const m = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const eu = new THREE.Euler(); + const col = new THREE.Color(); + let n = 0; + // Count flakes per cell to mound them up a little. + const heights = new Map(); + for (const fl of this.pileFlakes) { + if (n >= 512) break; + const key = `${Math.round(fl.u * 40)},${Math.round(fl.v * 40)}`; + const h = heights.get(key) ?? 0; + heights.set(key, h + 1); + const x = fl.u * TRAY_W - TRAY_W / 2; + const z = fl.v * TRAY_D - TRAY_D / 2; + const y = TRAY_Y * 2 + 0.01 + h * fl.s * 0.5; + eu.set(this.rng.next() * 0.5, this.rng.next() * Math.PI, this.rng.next() * 0.5); + q.setFromEuler(eu); + m.compose(new THREE.Vector3(x, y, z), q, new THREE.Vector3(fl.s * 3, fl.s * 0.6, fl.s)); + this.pile.setMatrixAt(n, m); + col.copy(fl.pith ? this.pithMat.color : this.pileMat.color); + this.pile.setColorAt(n, col); + n++; + } + this.pile.count = n; + this.pile.instanceMatrix.needsUpdate = true; + if (this.pile.instanceColor) this.pile.instanceColor.needsUpdate = true; + } + + 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] = 226; + px[i * 4 + 1] = 210; + px[i * 4 + 2] = 150; + px[i * 4 + 3] = Math.round(Math.min(0.8, 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 = grateResult(s); + this.amountEl.textContent = `${amountWord(r.mode, r.gratings)} · ${Math.round(r.gratings * 100)}g`; + // The bar fills toward the top of the ideal band. + const target = r.mode === 'cheese' ? 0.7 : 0.2; + this.amountFill.style.width = `${Math.min(100, Math.round((r.gratings / target) * 100))}%`; + this.amountFill.style.background = r.gratings > target * 1.15 ? '#c88a2a' : '#e8c23a'; + + const bench = this.mess.benchWord(); + let state = ''; + if (r.mode === 'zest') { + const pith = r.pithFrac > 0.05 ? ` · ${Math.round(r.pithFrac * 100)}% pith` : ''; + state = `region ${s.faceIndex + 1}/${s.faces.length}${pith}`; + if (r.pithFrac > 0.2) state += ' — rotate the orange'; + } else { + const block = `block ${Math.round(r.blockLeft * 100)}%`; + const soft = s.softness > 0.5 ? ' · soft — it will smear' : s.softness > 0.15 ? ' · warming' : ' · cold'; + const clog = r.clog > 0.6 ? ' · CLOGGED' : r.clog > 0.2 ? ' · clogging' : ''; + state = `${block}${soft}${clog}`; + } + if (r.knuckleHits > 0) state += ` · ${r.knuckleHits}× knuckle`; + state += ` · bench: ${bench}`; + this.stateEl.textContent = state; + this.stateEl.style.color = + r.knuckleHits > 0 || r.pithFrac > 0.2 || r.clog > 0.6 || bench !== 'clean' ? '#e2603a' : ''; + } + + /** Clear the clogged teeth — a double-click in play, a call in the harness. */ + clearTeeth(): void { + if (this.session) clearClog(this.session); + } + + /** What the judge reads. Snapshot any time; final at serve. */ + result(): GrateResult { + const s = this.session!; + return { ...grateResult(s), bench: this.mess.stats() }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/grating.ts b/src/sim/grating.ts new file mode 100644 index 0000000..fa1dd53 --- /dev/null +++ b/src/sim/grating.ts @@ -0,0 +1,562 @@ +import type { Ingredient } from './ingredients'; +import type { Criterion } from '../game/judging'; + +/** + * Grating, on the same bones as everything else. + * + * The verb is a STROKE — drag the block (or the orange) down the box grater's + * rasped face and lift. One stroke is length × pressure, and the sim does the + * honest things with it: + * + * yield — a long confident stroke at good pressure sheds the most onto the + * pile. That is the feel that has to be right: length is the lever. + * spray — a short scrubby stroke shears shreds sideways off the board + * instead of down onto the pile (→ mess field). Scrub and you make + * a mess and little cheese. + * knuckle — the danger. As the block SHRINKS your knuckles get closer to the + * teeth; press hard on a small block and you roll a graze. Ease off + * near the end or you're grating protein. This is block-proximity × + * pressure, so it only bites when the block is genuinely small — + * the tense last third. + * + * Two customers on one prop: + * + * cheese (parmesan) — grates clean IF cold. It softens on the bench in real + * time (the same temperature clock as butter, run backwards: butter + * wants warm, cheese wants cold). Soft cheese SMEARS: output drops, + * the teeth CLOG (a meter; clearing it costs service time), mess + * accrues. The block shrinks as you take mass off it. + * zest (orange) — each surface region has its own depth. Grate a region past + * the pith line (`gratable.pithDepth`) and it sheds white PITH into + * the pile instead of clean zest — visible, and bitter. Rotating the + * orange (scroll) exposes a fresh region. The skill is rotation + * discipline: move on before you hit the pith. + * + * Nothing here imports three.js — it runs on numbers the scene hands it, which + * is what lets the harness grate a block headless. It deliberately does NOT + * import juicing; the pith idea is re-derived here from the ingredient's own + * pithDepth so the two stations stay independent lanes. + */ + +export type GraterId = 'box_grater' | 'microplane'; + +export interface Grater { + id: GraterId; + name: string; + /** Rasp aggression — mass shed per unit of (stroke length × press). */ + rasp: number; + /** How fast a zest stroke eats into the skin toward the pith line. Coarse + * teeth bite deeper per stroke (hit pith sooner); a fine face is forgiving. */ + pithBite: number; + /** How readily soft cheese packs the teeth. Open coarse teeth clog less. */ + clogProne: number; + blurb: string; +} + +export const GRATERS: Record = { + box_grater: { + id: 'box_grater', + // Coarse: lots of cheese per stroke, forgiving to clog, but it bites deep + // on citrus so the pith line comes up fast — rotate often. + name: 'Box Grater', + rasp: 0.12, + pithBite: 1.0, + clogProne: 0.7, + blurb: 'Four faces, coarse teeth. The workhorse.', + }, + microplane: { + id: 'microplane', + // Fine: less mass per stroke, shallow bite (kind to zest), but soft cheese + // packs it solid. The zest specialist. (Scene ships the box; this is spec.) + name: 'Microplane', + rasp: 0.08, + pithBite: 0.6, + clogProne: 1.3, + blurb: 'Razor teeth. Snow, not shreds. Clogs if you let it soften.', + }, +}; + +/** How the ingredient wants to be grated, decided by its profile. Citrus (a + * pithDepth) is zest; a hard gratable block (parmesan) is cheese. */ +export type GrateMode = 'cheese' | 'zest'; + +export function grateMode(ing: Ingredient): GrateMode { + return ing.gratable?.pithDepth != null ? 'zest' : 'cheese'; +} + +/** What one stroke did — the scene turns these into pile, mess, clog, a flinch. */ +export interface StrokeResult { + /** Good cheese shred onto the pile this stroke. */ + shred: number; + /** Clean orange zest onto the pile this stroke. */ + zest: number; + /** White pith shed into the pile this stroke (over-grating a region). */ + pith: number; + /** Shred/zest sheared off the board this stroke (→ mess field solids). */ + stray: number; + /** Cheese smeared onto the teeth/board from clogging (→ mess field mass). */ + smear: number; + /** A knuckle graze happened — flinch, red flash, judged incident. */ + knuckle: boolean; + /** Clog crossed the "the teeth are packed" line this stroke. */ + clogged: boolean; +} + +/** How many rotational regions an orange presents to the grater. Rotate (scroll) + * to bring a fresh one to the teeth; each remembers its own depth. */ +export const ZEST_REGIONS = 8; + +export interface GrateSession { + ing: Ingredient; + grater: Grater; + mode: GrateMode; + placed: boolean; + + // --- Cheese / the block --- + /** Remaining block, 1.0 = a fresh corner. Shrinks as you shred it. */ + blockMass: number; + initialBlock: number; + shred: number; + /** 0 = fridge-cold and clean, 1 = fully soft and smeary. The temp clock. */ + softness: number; + /** 0..1 teeth packed with smeared cheese; output falls as it climbs. */ + clog: number; + smearTotal: number; + + // --- Zest / the orange --- + zestShed: number; + pithShed: number; + /** Accumulated grate depth per region; past pithDepth it sheds pith. */ + faces: number[]; + faceIndex: number; + + // --- Shared --- + strayTotal: number; + knuckleHits: number; + bloodied: boolean; + + // --- Gesture state (rhythm) --- + /** Consecutive-long-stroke confidence, small multiplier on yield. */ + rhythm: number; + strokeCount: number; + totalTravel: number; +} + +// --- Tuning. First principles, then measured on the bench (see commit). --- +/** A fresh block, in the same "1 unit" mass currency the pile and bands use. */ +const INITIAL_BLOCK = 1.0; +/** Zest sheds far less mass than cheese per stroke — it's a whisper of skin. */ +const ZEST_MASS_FRAC = 0.4; +/** Depth a zest stroke adds to its region, before length×press. Sized so a + * region hits an orange's 0.35 pith line in ~3 hard strokes → rotate by then. */ +const ZEST_DEPTH_GAIN = 0.18; +/** Spray rises steeply as strokes get short: (1-length)^2. Scrub = mess. */ +const SPRAY_GAIN = 0.9; +/** Long strokes build confidence; this caps the rhythm bonus. */ +const RHYTHM_MAX = 0.15; +const RHYTHM_LONG = 0.75; // a stroke this long or longer counts as "confident" +/** Soft cheese: fraction of a stroke's output lost to smear at full softness. */ +const SMEAR_LOSS = 0.8; +/** Clog gained per stroke = softness × press × this × grater.clogProne. */ +const CLOG_GAIN = 0.35; +/** --- Knuckles --- */ +/** Above this remaining block you're safe; below it, proximity ramps in. */ +const KNUCKLE_SAFE_BLOCK = 0.35; +/** Graze chance = pressure × (0.4 + 0.6·length) × proximity × this. */ +const KNUCKLE_GAIN = 0.6; + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +export function newGrateSession( + ing: Ingredient, + grater: Grater, + opts: { blockMass?: number; softness?: number } = {}, +): GrateSession { + const mode = grateMode(ing); + const initialBlock = opts.blockMass ?? INITIAL_BLOCK; + return { + ing, + grater, + mode, + placed: false, + blockMass: initialBlock, + initialBlock, + shred: 0, + // Cheese starts as cold as you left it; zest ignores softness entirely. + softness: mode === 'cheese' ? clamp01(opts.softness ?? 0) : 0, + clog: 0, + smearTotal: 0, + zestShed: 0, + pithShed: 0, + faces: new Array(ZEST_REGIONS).fill(0), + faceIndex: 0, + strayTotal: 0, + knuckleHits: 0, + bloodied: false, + rhythm: 0, + strokeCount: 0, + totalTravel: 0, + }; +} + +/** Press the ingredient against the face. Nothing grates until it's placed. */ +export function placeBlock(s: GrateSession): void { + s.placed = true; +} + +/** + * Advance the bench temperature clock for a cheese that wants to stay cold. + * `softensInSec` from the ingredient sets the pace; call it with the frame dt + * while the block sits out. A no-op for zest and for cheeses with no temp need. + */ +export function warmOnBench(s: GrateSession, dt: number): void { + if (s.mode !== 'cheese' || dt <= 0) return; + const secs = s.ing.temp?.softensInSec; + if (!secs || secs <= 0) return; + s.softness = clamp01(s.softness + dt / secs); +} + +/** Clear the packed teeth. Costs the player service time in the scene. */ +export function clearClog(s: GrateSession): void { + s.clog = 0; +} + +/** Rotate the orange to bring a fresh region to the teeth (scroll in play). */ +export function rotateOrange(s: GrateSession, dir = 1): void { + if (s.mode !== 'zest') return; + const n = s.faces.length; + s.faceIndex = ((s.faceIndex + (dir >= 0 ? 1 : -1)) % n + n) % n; +} + +/** + * One stroke down the face. `length` 0..1 is how far the stroke travelled + * (1 = the full face), `press` 0..1 is downforce. `rand` decides only the + * knuckle-graze roll — everything else is deterministic, which is what lets + * the harness check it. + */ +export function grateStroke( + s: GrateSession, + length: number, + press: number, + rand: () => number, +): StrokeResult { + const out: StrokeResult = { + shred: 0, + zest: 0, + pith: 0, + stray: 0, + smear: 0, + knuckle: false, + clogged: false, + }; + if (!s.placed) return out; + + const len = clamp01(length); + const p = clamp01(press); + const effort = len * p; + if (effort <= 0) return out; + + s.strokeCount++; + s.totalTravel += len; + + // Rhythm: confident long strokes build a small yield bonus; a short scrub + // resets it. This is what makes a steady rhythm feel better than a flail. + if (len >= RHYTHM_LONG) s.rhythm = Math.min(RHYTHM_MAX, s.rhythm + RHYTHM_MAX / 3); + else s.rhythm = Math.max(0, s.rhythm - RHYTHM_MAX / 2); + const rhythmMult = 1 + s.rhythm; + + // --- The knuckle roll: only real when the block is small, worse under + // pressure and on a long stroke that runs off the end of the block. --- + const proximity = + s.blockMass < KNUCKLE_SAFE_BLOCK + ? clamp01((KNUCKLE_SAFE_BLOCK - s.blockMass) / KNUCKLE_SAFE_BLOCK) + : 0; + if (proximity > 0) { + const risk = p * (0.4 + 0.6 * len) * proximity; + if (rand() < risk * KNUCKLE_GAIN) { + out.knuckle = true; + s.knuckleHits++; + s.bloodied = true; + // You flinch: the stroke's yield is mostly lost and a little sprays. + const flinch = effort * s.grater.rasp * 0.3; + out.stray += flinch; + s.strayTotal += flinch; + return out; + } + } + + if (s.mode === 'cheese') gratecheese(s, len, p, effort, rhythmMult, out); + else gratezest(s, len, p, effort, rhythmMult, out); + + return out; +} + +function gratecheese( + s: GrateSession, + len: number, + p: number, + effort: number, + rhythmMult: number, + out: StrokeResult, +): void { + // Base rasp yield this stroke, gated by how much block is left (a nub gives + // less), rasped by the grater, softened output lost to smear, choked by clog. + const blockGate = clamp01(s.blockMass / 0.15); // the last crumb rasps poorly + const gross = effort * s.grater.rasp * (s.ing.gratable?.yield ?? 1) * blockGate * rhythmMult; + + // Soft cheese smears instead of shredding; cold cheese is all clean output. + const smearFrac = s.softness * SMEAR_LOSS; + const clogChoke = 1 - s.clog; + const clean = gross * (1 - smearFrac) * clogChoke; + const smeared = gross * smearFrac + gross * (1 - smearFrac) * (1 - clogChoke) * 0.5; + + // Short scrubby strokes shear shreds off the board instead of onto the pile. + const sprayFrac = clamp01((1 - len) * (1 - len) * SPRAY_GAIN); + const toPile = clean * (1 - sprayFrac); + const stray = clean * sprayFrac; + + s.shred += toPile; + out.shred += toPile; + out.stray += stray; + s.strayTotal += stray; + out.smear += smeared; + s.smearTotal += smeared; + + // Everything that left the block — pile, spray, smear — came off the block. + s.blockMass = Math.max(0, s.blockMass - (toPile + stray + smeared)); + + // Clog climbs when soft cheese packs the teeth. + if (s.softness > 0.05) { + const before = s.clog; + s.clog = clamp01(s.clog + s.softness * p * CLOG_GAIN * s.grater.clogProne); + if (before < 0.6 && s.clog >= 0.6) out.clogged = true; + } +} + +function gratezest( + s: GrateSession, + len: number, + p: number, + effort: number, + rhythmMult: number, + out: StrokeResult, +): void { + const pithDepth = s.ing.gratable?.pithDepth ?? 0.35; + const i = s.faceIndex; + const before = s.faces[i]; + const inc = len * p * ZEST_DEPTH_GAIN * s.grater.pithBite; + const after = before + inc; + s.faces[i] = after; + + // The portion of this stroke that stayed in clean skin (above the pith line) + // is zest; whatever crossed into the pith is bitter white. + const cleanDepth = Math.max(0, Math.min(after, pithDepth) - Math.min(before, pithDepth)); + const cleanFrac = inc > 1e-6 ? clamp01(cleanDepth / inc) : 1; + + const gross = effort * s.grater.rasp * (s.ing.gratable?.yield ?? 1) * ZEST_MASS_FRAC * rhythmMult; + const sprayFrac = clamp01((1 - len) * (1 - len) * SPRAY_GAIN); + + const cleanMass = gross * cleanFrac; + const pithMass = gross * (1 - cleanFrac); + + const zestToPile = cleanMass * (1 - sprayFrac); + const zestStray = cleanMass * sprayFrac; + const pithToPile = pithMass * (1 - sprayFrac); + const pithStray = pithMass * sprayFrac; + + s.zestShed += zestToPile; + out.zest += zestToPile; + s.pithShed += pithToPile; + out.pith += pithToPile; + const stray = zestStray + pithStray; + out.stray += stray; + s.strayTotal += stray; +} + +// ------------------------------------------------------------------------- +// The judge's side. Kept here so the game has ONE place to consume grating: +// import { GrateResult, grateResult, grateCriteria, GRATE_LINES } from +// '../sim/grating'; +// The scene assembles GrateResult (grateResult(session) + the bench stats it +// owns) and hands it to the game; the game calls grateCriteria(result) and +// merges GRATE_LINES into its verdict bank. Bench itself is scored by the +// existing benchCriterion via result.bench — the same path prep/juice orders +// use. knuckleLine() slots in like juicerRecognitionLine() when it fires. +// ------------------------------------------------------------------------- + +/** The number-bag the prep bench hands the judge for a grate order. */ +export interface GrateResult { + graterId: GraterId; + graterName: string; + mode: GrateMode; + /** Total good gratings on the pile (cheese shred OR clean zest). */ + gratings: number; + /** Pith on the pile (zest only) — the bitter white. */ + pith: number; + /** pith / (zest + pith) — the ratio the Pith criterion scores. */ + pithFrac: number; + /** Remaining block, 0..1 of a fresh corner (cheese). */ + blockLeft: number; + clog: number; + smear: number; + knuckleHits: number; + bloodied: boolean; + /** Filled in by the scene from its mess field, same shape prep/juice use. */ + bench?: { mass: number; spreadFrac: number; solids: number }; +} + +export function grateResult(s: GrateSession): GrateResult { + const gratings = s.mode === 'cheese' ? s.shred : s.zestShed; + const pith = s.pithShed; + const denom = s.zestShed + s.pithShed; + return { + graterId: s.grater.id, + graterName: s.grater.name, + mode: s.mode, + gratings, + pith, + pithFrac: denom > 1e-6 ? pith / denom : 0, + blockLeft: clamp01(s.blockMass / s.initialBlock), + clog: s.clog, + smear: s.smearTotal, + knuckleHits: s.knuckleHits, + bloodied: s.bloodied, + }; +} + +/** Score a value against an ideal band: 1 inside [lo,hi], falling off outside. */ +function bandScore(v: number, lo: number, hi: number, underK: number, overK: number): number { + if (v < lo) return clamp01(1 - (lo - v) / underK); + if (v > hi) return clamp01(1 - (v - hi) / overK); + return 1; +} + +/** Ideal serving bands per mode — cheese is a real pile, zest a fine scatter. */ +const BAND = { + cheese: { lo: 0.4, hi: 0.7, under: 0.4, over: 0.5 }, + zest: { lo: 0.09, hi: 0.2, under: 0.08, over: 0.12 }, +} as const; + +/** Live-readout word for the amount, in the judge's own thresholds. */ +export function amountWord(mode: GrateMode, gratings: number): string { + const b = BAND[mode]; + if (gratings < b.lo * 0.5) return mode === 'cheese' ? 'a dusting' : 'barely a fleck'; + if (gratings < b.lo) return 'a little short'; + if (gratings <= b.hi) return mode === 'cheese' ? 'a good pile' : 'a proper scatter'; + return mode === 'cheese' ? 'a mountain' : 'a snowdrift'; +} + +/** + * The Gratings, the Pith and the Clog. Amount is a band (like spread amount); + * a knuckle graze docks it and gets its own line, because he can see it. Pith + * scores the white ratio (zest only). Clog scores the smeary mess a warm block + * makes. Bench is scored separately via result.bench, same as juice. + */ +export function grateCriteria(result: GrateResult): Criterion[] { + const b = BAND[result.mode]; + const amount = bandScore(result.gratings, b.lo, b.hi, b.under, b.over); + // A knuckle in the pile is contamination; each graze docks the amount hard. + const knucklePenalty = Math.min(0.6, result.knuckleHits * 0.35); + const amountScore = clamp01(amount - knucklePenalty); + const knuckleWord = result.knuckleHits > 0 ? `, ${result.knuckleHits}× knuckle` : ''; + + const out: Criterion[] = [ + { + key: 'gratings', + group: 'prep', + label: result.mode === 'cheese' ? 'The Gratings' : 'The Zest', + score: amountScore, + weight: 1.0, + detail: `${amountWord(result.mode, result.gratings)}${knuckleWord}`, + }, + ]; + + if (result.mode === 'zest') { + // Pith is unforgiving: a tenth pith already reads, half is a headache. + const pithScore = clamp01(1 - result.pithFrac / 0.4); + out.push({ + key: 'pith', + group: 'prep', + label: 'The Pith', + score: pithScore, + weight: 1.1, + detail: + result.pithFrac < 0.05 + ? 'clean zest, no white' + : `${Math.round(result.pithFrac * 100)}% pith`, + }); + } else { + // Clog/smear only matters for cheese — a warm block ruins the yield twice. + const clogScore = clamp01(1 - result.clog * 0.8 - Math.min(0.5, result.smear * 1.5)); + out.push({ + key: 'clog', + group: 'prep', + label: 'The Grate', + score: clogScore, + weight: 0.9, + detail: + result.clog < 0.15 && result.smear < 0.05 + ? 'clean teeth, dry shred' + : result.clog > 0.6 + ? 'clogged solid' + : 'smeary — it was too soft', + }); + } + + return out; +} + +/** The knuckle line, slotted in as an opener when a graze happened (like the + * Juicernaut recognition line). */ +export function knuckleLine(result: GrateResult): string | null { + return result.knuckleHits > 0 + ? "That's your knuckle in there. I'm not scoring the protein." + : null; +} + +/** Judge lines for the grate criteria — merged into lines.ts's BANK by the + * game. Five bad, three good, in his voice: dry, specific, never cruel. */ +export const GRATE_LINES: Record<'gratings' | 'pith' | 'clog', { bad: string[]; good: string[] }> = { + gratings: { + bad: [ + 'A dusting. Were you seasoning it or introducing them?', + 'You grated half the block. It was a garnish, not a duvet.', + 'This is a mountain. I asked for cheese, not a foothill.', + 'You scrubbed at it. Half of it is on the board, not the plate.', + 'Short little strokes. Short little pile.', + ], + good: [ + 'A good pile. Long strokes, all of it on the plate. That is technique.', + 'Exactly the amount. You stopped when it was right, which nobody does.', + 'Even, generous, tidy. I have nothing to say, which is a compliment.', + ], + }, + pith: { + bad: [ + 'This zest is half pith. It will taste like a headache.', + 'You stayed on one spot until it went white. Rotate the fruit.', + 'I can see the pith from here. Bitter. All that work for bitter.', + 'White among the orange. You went past the skin and kept going.', + 'You reamed the peel. There is more pith than perfume in this.', + ], + good: [ + 'Bright zest, no white. You turned the fruit before it turned on you.', + 'All perfume, no pith. Rotation discipline. I noticed.', + 'Not a fleck of white in it. That is a zest.', + ], + }, + clog: { + bad: [ + 'It smeared. You let the parmesan sweat before you grated it.', + 'The teeth are packed solid. That is a warm-cheese problem.', + 'This is paste, not shred. It came out of the fridge too early.', + 'Clogged. You spent longer clearing the grater than grating.', + 'Soft cheese, coarse teeth, no plan. It shows.', + ], + good: [ + 'Dry, loose shred. That block was cold and you kept it that way.', + 'Clean teeth, clean pile. Cold cheese, honest work.', + 'Not a smear on it. You grated it before it could betray you.', + ], + }, +};