M13: the grater — one stroke, and the block shrinks toward your knuckles

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 19:06:40 +10:00
parent 32d1cced69
commit efe4d1d1a6
4 changed files with 1327 additions and 0 deletions

195
src/dev-grate.ts Normal file
View File

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

View File

@ -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();

569
src/scenes/grate.ts Normal file
View File

@ -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<typeof grateStroke>, 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<string, number>();
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();
}
}

562
src/sim/grating.ts Normal file
View File

@ -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<GraterId, Grater> = {
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.',
],
},
};