diff --git a/src/dev.ts b/src/dev.ts index f939f7f..5a7f7ea 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -212,10 +212,12 @@ export function installDevHarness(app: App, game: Game): void { /** * One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis), * bite, saw to completion. `press` seconds of leaning without sawing - * first, to invite the slip. + * first, to invite the slip. `stopAt` (0..1) is the onion stop-line: release + * the blade once saw progress reaches it, committing a cut that stops short + * of the root instead of sawing through. */ - chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number } = {}) { - const { pos = 0, dy = 0.045, wobble = 0, press = 0 } = opts; + chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number; stopAt?: number } = {}) { + const { pos = 0, dy = 0.045, wobble = 0, press = 0, stopAt } = opts; const pv = game.prepView as unknown as { session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] }; }; @@ -245,10 +247,12 @@ export function installDevHarness(app: App, game: Game): void { point(X() + (f % 2 ? wobble : -wobble), y, Z, true); run(1); f++; + // Stop-line: let the blade up short of the root — the release commits it. + if (stopAt !== undefined && s.phase === 'saw' && s.progress >= stopAt) break; } app.input.down = false; - run(2); - return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f }; + run(3); // let the release-commit (liftKnife) land before the next chop + return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f, progress: +s.progress.toFixed(3) }; }, /** @@ -278,6 +282,28 @@ export function installDevHarness(app: App, game: Game): void { return harness.prepStats(); }, + /** + * The M14 onion dice with the stop-line. The first pass (root-ward cuts) is + * released short at `stopAt` (the sim commits on release); the sim rotates + * the board and the second pass criss-crosses straight through. Pass + * `through: true` to saw the root pass all the way — the "went through the + * stem, served confetti" failure. Reads live dicePhase, so it stays in step + * with the sim's own rotation. + */ + diceOnion(opts: { n?: number; stopAt?: number; dy?: number; wobble?: number; through?: boolean } = {}) { + // stopAt 0.8 lands the committed depth ~0.85 (a frame of overshoot) — square + // in the "stopped short" band and level with the scene's stop-line marker. + const { n = 4, stopAt = 0.8, dy = 0.05, wobble = 0, through = false } = opts; + const pv = game.prepView as unknown as { session: { dicePhase: number } }; + const total = 2 * (n - 1); + for (let i = 0; i < total; i++) { + const pos = (i % (n - 1) + 1) / n - 0.5; + if (pv.session.dicePhase === 0 && !through) harness.chop({ pos, dy, wobble, stopAt }); + else harness.chop({ pos, dy, wobble }); + } + return harness.prepStats(); + }, + /** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */ handoff() { tap('Enter'); @@ -307,6 +333,51 @@ export function installDevHarness(app: App, game: Game): void { return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() }; }, + /** + * Drive the REAL routed juice station (game.juiceView, the one a day-12 + * order sends you to — not the __tj sandbox): seat the half, then press-and- + * twist for `revolutions` turns at `press` downforce. Follow with `t.handoff()` + * (ENTER) to serve the glass and drop back into the toast flow. + */ + juice(revolutions = 3, press = 0.7, dir: 1 | -1 = 1) { + const view = game.juiceView as unknown as { placeHalf(): void; autoPress: boolean; press: number }; + run(3); // let the juice view take the camera before we project points + view.placeHalf(); + run(1); + view.autoPress = false; + view.press = press; + const CONE_TOP = 0.92; + const R = 0.3; + const steps = Math.max(1, Math.round(revolutions * 48)); + for (let i = 0; i <= steps; i++) { + const a = dir * (i / 48) * Math.PI * 2; + point(Math.cos(a) * R, CONE_TOP, Math.sin(a) * R, true); + run(1); + } + app.input.down = false; + run(1); + return harness.juiceStats(); + }, + + juiceStats() { + const view = game.juiceView; + const r = view.result(); + const round = (n: number) => +n.toFixed(3); + return { + yieldOfOrange: round(r.yieldOfOrange), + yieldOfReachable: round(r.yieldOfReachable), + theoretical: round(r.theoretical), + extracted: round(r.extracted), + pips: r.pips, + seedsCaught: r.seedsCaught, + bitterness: round(r.bitterness), + pulp: round(r.pulp), + bench: view.mess.stats(), + benchWord: view.mess.benchWord(), + juicer: r.juicerName, + }; + }, + /** * Cut a slice off the loaf. `dy` is world-units per frame of saw motion * (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame. diff --git a/src/game/game.ts b/src/game/game.ts index 5d8e80a..2ea67af 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -3,8 +3,10 @@ import { Rng } from '../core/rng'; import { KitchenView } from '../scenes/kitchen'; import { SlicerView } from '../scenes/slicer'; import { PrepView } from '../scenes/prep'; +import { JuiceView } from '../scenes/juice'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; +import { juiceCriteria, type JuiceResult } from '../sim/juicing'; import type { PrepResult } from './judging'; import { JudgeView } from '../scenes/judge'; import { DrawerView } from '../scenes/drawer'; @@ -35,8 +37,11 @@ export class Game { private drawer: DrawerView; private slicer: SlicerView; private prep: PrepView; + private juice: JuiceView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; + /** What the juice station reported, if the order sent you there. */ + private juiceResult: JuiceResult | null = null; /** Remaining prep steps for the current order, drained as each hands off. */ private prepQueue: PrepStep[] = []; /** The pre-toast drawer trip on loaf days: you're after a bread knife. */ @@ -57,15 +62,18 @@ export class Game { this.drawer = new DrawerView(app, 7); this.slicer = new SlicerView(app); this.prep = new PrepView(app); + this.juice = new JuiceView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); app.scene.add(this.drawer.root); app.scene.add(this.slicer.root); app.scene.add(this.prep.root); + app.scene.add(this.juice.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; + this.juice.root.visible = false; this.ticket = new Panel('ticket'); this.ticketEl = el('div', 'ticket-body', this.ticket.root); @@ -135,6 +143,9 @@ export class Game { get prepView(): PrepView { return this.prep; } + get juiceView(): JuiceView { + return this.juice; + } /** * Send the player to the prep bench in isolation. The harness drives this @@ -166,6 +177,7 @@ export class Game { this.kitchen.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; + this.juice.root.visible = false; this.prep.root.visible = true; this.app.setView(this.prep); this.ticket.show(); @@ -175,10 +187,37 @@ export class Game { }; } + /** + * The juice station's door, same shape as enterPrep: reset the reamer for one + * orange, take the screen, and route wherever `onComplete` says once ENTER + * serves the glass. The bench it leaves behind folds into the same prepResult + * path a cut order uses, so The Bench is one row however you dirtied it. + */ + private enterJuice( + step: Extract, + onComplete: (result: JuiceResult) => void, + ): void { + const halve = step.halve ?? { offset: 0, wedge: 0 }; + this.juice.reset(step.juicer, halve, prepAsk(step), step.seeds); + this.kitchen.root.visible = false; + this.drawer.root.visible = false; + this.slicer.root.visible = false; + this.prep.root.visible = false; + this.juice.root.visible = true; + this.app.setView(this.juice); + this.ticket.show(); + this.juice.onDone = (result) => { + this.juice.root.visible = false; + onComplete(result); + }; + } + /** * Drain the current order's prep queue one bench trip at a time, folding each - * result into `prepResult`, then fall through to the toaster. Orders ship with - * a single step today; the merge keeps a two-step order honest anyway. + * result into `prepResult`, then fall through to the toaster. A step is a cut + * (the bench) or a juice (the reamer); both leave a bench, and the juice also + * leaves a scored glass. Orders ship with a single step today; the merge keeps + * a two-step order honest anyway. */ private runNextPrep(): void { const step = this.prepQueue.shift(); @@ -186,6 +225,16 @@ export class Game { this.enterToastFlow(); return; } + if (step.kind === 'juice') { + this.enterJuice(step, (result) => { + this.juiceResult = result; + // The glass is scored on its own criteria; its spray joins the bench + // through the same prepResult path a cut order uses. + this.prepResult = mergePrep(this.prepResult, { bench: result.bench }); + this.runNextPrep(); + }); + return; + } this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.prepResult = mergePrep(this.prepResult, result); this.runNextPrep(); @@ -252,6 +301,7 @@ export class Game { this.order = o; this.orderSeconds = 0; this.prepResult = null; + this.juiceResult = null; this.prepQueue = o.prep ? [...o.prep] : []; this.timing = true; @@ -291,6 +341,7 @@ export class Game { this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; + this.juice.root.visible = false; this.app.setView(this.kitchen); this.kitchen.root.visible = true; this.ticket.show(); @@ -326,7 +377,10 @@ export class Game { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; - const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined); + // The juicer scored its own glass (The Juice + Pips); fold those rows in so + // one verdict covers the toast and everything the order asked beside it. + const extra = this.juiceResult ? juiceCriteria(this.juiceResult) : []; + const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined, extra); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -356,10 +410,16 @@ export class Game { function mergePrep(a: PrepResult | null, b: PrepResult): PrepResult { if (!a) return b; return { - cv: Math.max(a.cv ?? 0, b.cv ?? 0), + // A step with no cut of its own (the juicer's bench-only fold) mustn't wipe + // out the cut a previous step already recorded — only take defined values. + cv: b.cv !== undefined ? Math.max(a.cv ?? 0, b.cv) : a.cv, pieces: (a.pieces ?? 0) + (b.pieces ?? 0), patternName: b.patternName ?? a.patternName, slips: (a.slips ?? 0) + (b.slips ?? 0), + // Onion technique carries through untouched — only one step is ever the dice. + technique: b.technique ?? a.technique, + sting: b.sting ?? a.sting, + stem: b.stem ?? a.stem, bench: a.bench && b.bench ? { diff --git a/src/game/judging.ts b/src/game/judging.ts index 429ff85..3f7e26c 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -25,6 +25,13 @@ export interface PrepResult { slips?: number; /** From sim/mess Mess.stats() at serve time. */ bench?: { mass: number; spreadFrac: number; solids: number }; + // --- M14 onion, present only on the technique-test dice -------------------- + /** Eye sting endured, 0..1 (sim/cutting). Scored when `technique` is set. */ + sting?: number; + /** True on the root-stop dice: turns on The Dice + The Eyes rows. */ + technique?: boolean; + /** Stem discipline — how the root-ward cuts stopped short (sim/cutting). */ + stem?: { through: number; stopMean: number; score: number }; } export type Grade = 'S' | 'A' | 'B' | 'C' | 'F'; @@ -54,6 +61,9 @@ export function judge( usedTool: ToolId, seconds: number, prep?: PrepResult, + /** Criteria a station scored for itself (the juicer's The Juice + Pips) — + * folded straight into the card so the toast and the prep are one verdict. */ + extra: Criterion[] = [], ): Verdict { const b = slice.browning.stats(slice.mask); const s = slice.spread.stats(slice.mask); @@ -73,7 +83,10 @@ export function judge( const covered = spreadStdevOverCovered(slice, COVER_FLOOR); const criteria: Criterion[] = [ + ...extra, ...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []), + ...(prep?.technique && prep.stem ? [stemCriterion(prep.stem)] : []), + ...(prep?.technique && prep.sting !== undefined ? [stingCriterion(prep.sting)] : []), ...(prep?.bench ? [benchCriterion(prep.bench)] : []), { key: 'browning', @@ -318,6 +331,33 @@ export function cutEvennessCriterion(cv: number, pieces: number, patternName: st }; } +/** + * Stem discipline — the onion dice's technique row. Stopping every root-ward + * cut short of the root is the whole craft: the root holds the layers together + * for the criss-cross, and cutting through it turns a dice into confetti. This + * is scored SEPARATELY from The Pieces (which reads the criss-cross spacing) so + * a player can be told exactly which half they got wrong. + */ +export function stemCriterion(stem: { through: number; stopMean: number; score: number }): Criterion { + const detail = + stem.through > 0 + ? `${stem.through} cut through the root — it scattered` + : `stopped short at ${Math.round(stem.stopMean * 100)}% — the root held`; + return { key: 'dice', group: 'prep', label: 'The Dice', score: stem.score, weight: 1.3, detail }; +} + +/** + * The eyes. A cut onion off-gasses; the longer it's open and the more it was + * crushed rather than sliced, the more it stings. Low sting means you were + * sharp and quick — which is the point. Weighted lightly (0.7): it colours the + * verdict, it doesn't decide it. + */ +export function stingCriterion(sting: number): Criterion { + const score = clamp01(1 - sting / 0.85); + const word = sting < 0.25 ? 'clear-eyed' : sting < 0.55 ? 'watering' : sting < 0.8 ? 'streaming' : 'blind'; + return { key: 'sting', group: 'prep', label: 'The Eyes', score, weight: 0.7, detail: `${word} — sting ${sting.toFixed(2)}` }; +} + /** * The state of the board at serve time. Weighted mildly (0.9): mess is a * running gag before it's a crime, but it IS on the card. diff --git a/src/game/lines.ts b/src/game/lines.ts index 368d277..44ab984 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -1,6 +1,7 @@ import type { Criterion, Grade, Verdict } from './judging'; import type { Order } from './orders'; import { SPREADS } from '../sim/spreads'; +import { JUICE_LINES, juicerRecognitionLine } from '../sim/juicing'; /** * The judge. Deadpan, specific, never cruel about anything except MITEY, about @@ -187,8 +188,39 @@ const BANK: Bank = { 'Somebody wiped as they went. Somebody was raised right.', ], }, + dice: { + bad: [ + 'You went through the stem. The onion became confetti. You served confetti.', + 'The root was the one thing holding it together. You cut the root.', + 'It fell apart into slivers. A dice has corners. This has regrets.', + 'You did not stop. The onion did — all over my board.', + 'That is not diced. That is shredded, by someone in a hurry.', + ], + good: [ + 'Square. Actually square. I am keeping one.', + 'You stopped at the root every time. That is the whole skill, and you have it.', + 'Even little cubes, and the root held. Textbook.', + ], + }, + sting: { + bad: [ + 'You were crying before the second cut. It shows in the pieces.', + 'Blunt and slow. The onion made you pay in tears.', + 'You crushed it more than you cut it. I could smell it from the pass.', + 'Take your time and the onion takes your eyes. You took your time.', + ], + good: [ + 'Sharp, quick, dry-eyed. That is how you beat an onion.', + 'Not a tear. You were faster than it was.', + 'Clean and fast. The onion never got a word in.', + ], + }, }; +// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the +// station owns its whole voice. Merge them in rather than duplicate them here. +Object.assign(BANK, JUICE_LINES); + /** MITEY gets its own vocabulary. */ const MITEY_CRIMES = [ 'This is not a scrape of MITEY. This is a hate crime.', @@ -237,6 +269,16 @@ export function verdictLines(v: Verdict, order: Order, tool: string, rand: () => const out: string[] = []; out.push(pick(GRADE_OPENERS[v.grade])); + // The Juicernaut announces itself before the verdict proper — if the order + // was juiced on one, and only then. + for (const step of order.prep ?? []) { + if (step.kind === 'juice') { + const rec = juicerRecognitionLine(step.juicer); + if (rec) out.push(rec); + break; + } + } + const mitey = order.spread === 'mitey'; const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4; if (mitey && amountBad) { diff --git a/src/game/orders.ts b/src/game/orders.ts index e642058..d31296b 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -4,24 +4,40 @@ import type { ToolId } from '../sim/cutlery'; import { CUT_BANDS, type CutClass } from '../sim/slicing'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; +import type { JuicerId } from '../sim/juicing'; /** - * A stop at the prep bench before (or instead of) the toaster. M11 ships the - * 'cut' kind; juicing, zesting, grating and roasting arrive with their - * stations (M12+) and extend this union. + * A stop at the prep bench before the toaster. M11 shipped the 'cut' kind; + * M12 adds 'juice' (the ribbed reamer). Zesting, grating and roasting arrive + * with their stations and extend this union. */ -export interface PrepStep { - kind: 'cut'; - ingredient: IngredientId; - pattern: CutPattern; - /** The knife the bench hands you for this step. Prep knives don't come from - * the drawer (that trip is for the spread tool); the order supplies them. */ - knife?: ToolId; -} +export type PrepStep = + | { + kind: 'cut'; + ingredient: IngredientId; + pattern: CutPattern; + /** The knife the bench hands you. Prep knives don't come from the drawer + * (that trip is for the spread tool); the order supplies them. */ + knife?: ToolId; + } + | { + kind: 'juice'; + ingredient: IngredientId; + /** Which reamer sits on the bench for this order (progression, like bread + * brands). Day 12 hands you the Pyramid Classic — no seed moat. */ + juicer: JuicerId; + /** The cut the halving left, capping the reachable juice. Absent = a clean + * cook's halve (dead-centre), so the whole test is the twist itself. */ + halve?: { offset: number; wedge: number }; + /** Seeds in this orange (fixed so the pip count is a skill test, not a + * dice roll). Defaults to the orange's own count via the station. */ + seeds?: number; + }; -/** The ticket's short line for a prep step: "dice the onion", "6 wedges of tomato". */ +/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */ export function prepAsk(step: PrepStep): string { const name = step.ingredient.replace(/_/g, ' '); + if (step.kind === 'juice') return `juice the ${name}`; const p = step.pattern; switch (p.kind) { case 'halve': @@ -249,6 +265,44 @@ export const DAYS: Order[] = [ // it skates and hoses the board; saw and it gives clean radial cuts. prep: [{ kind: 'cut', ingredient: 'tomato', pattern: { kind: 'wedges', n: 6 }, knife: 'dinner_knife' }], }, + { + day: 12, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.5, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.55, + who: 'Deidre', + text: 'Fresh orange juice with the toast, love — squeeze it properly, mind the pips. Then golden white, butter, normal.', + // The juice detour, first outing. The Pyramid Classic has no seed moat, so + // every pip it frees goes to the glass — the twist is the whole test. Five + // seeds fixed so Pips is a skill row, not a lottery. The halve is left clean + // (dead-centre) — the halve→juice chain is M12's own later order. + prep: [{ kind: 'juice', ingredient: 'orange', juicer: 'pyramid_classic', seeds: 5 }], + }, + { + day: 13, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.5, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the inspector himself', + text: 'Dice this onion PROPERLY — stop the cut short of the root or it falls to confetti. Sharp and quick, mind your eyes. Then golden white, butter.', + // The technique test. root: true arms the stop-line — the first pass of cuts + // must be RELEASED short of the root; saw through and the layers scatter and + // the Dice row collapses. The Best Thing is the sharpest edge, so squash (and + // therefore sting) stays low and the whole story is restraint at the root. + prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }], + }, ]; /** Days beyond the script — keep going, with everything cranked. */ diff --git a/src/scenes/prep.ts b/src/scenes/prep.ts index f74887c..e71de66 100644 --- a/src/scenes/prep.ts +++ b/src/scenes/prep.ts @@ -12,6 +12,8 @@ import { pieceCV, pieceVolumes, plannedCuts, + stemDiscipline, + stingStep, type CutPattern, type CutSession, } from '../sim/cutting'; @@ -45,6 +47,10 @@ export class PrepView implements View { private pieces: THREE.Group | null = null; private seams: THREE.Mesh[] = []; private knifeMesh!: THREE.Group; + /** The onion's stop-line: a bright band the blade must not saw past. */ + private stopLine: THREE.Mesh | null = null; + /** Full-screen sting overlay — the eyes welling up, cheap DOM. */ + private stingOverlay: HTMLDivElement; private rng = new Rng(20260717); private messTexData: Uint8Array; @@ -101,6 +107,14 @@ export class PrepView implements View { this.panel = new Panel('prep-panel'); this.buildUi(); this.panel.hide(); + + // The sting overlay lives over everything, ignores the mouse, and does + // nothing until an onion is open. backdrop-filter blur is the "screen blurs" + // and the radial vignette is the eyes welling — both scale with sting. + this.stingOverlay = document.createElement('div'); + this.stingOverlay.style.cssText = + 'position:fixed;inset:0;pointer-events:none;z-index:40;opacity:0;transition:opacity 0.25s ease'; + document.body.appendChild(this.stingOverlay); } private buildUi(): void { @@ -212,6 +226,19 @@ export class PrepView implements View { if (this.knifeMesh) this.root.remove(this.knifeMesh); this.knifeMesh = makeCutleryMesh(TOOLS[knife]); this.root.add(this.knifeMesh); + if (this.stopLine) { + this.root.remove(this.stopLine); + this.stopLine = null; + } + // The onion dice arms the stop-line: a bright band the blade must not pass. + if (pattern.kind === 'dice' && pattern.root) { + this.stopLine = new THREE.Mesh( + new THREE.BoxGeometry(this.ing.size * 1.04, 0.006, this.ing.size * 1.04), + new THREE.MeshBasicMaterial({ color: 0x3ad17a, transparent: true, opacity: 0.55 }), + ); + this.stopLine.visible = false; + this.root.add(this.stopLine); + } this.mess.reset(); this.askEl.textContent = askText; this.doneTimer = 0; @@ -225,6 +252,10 @@ export class PrepView implements View { } exit(): void { this.panel.hide(); + // Leaving the bench: the eyes clear the moment the onion is off the board. + this.stingOverlay.style.opacity = '0'; + this.stingOverlay.style.backdropFilter = 'none'; + (this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none'; } /** Board-local UV under the pointer, if the ray hits the board. */ @@ -269,7 +300,10 @@ export class PrepView implements View { } this.applyFrame(f, dy); } else if (!inp.down) { - liftKnife(s); + // Release. On the onion's root pass this COMMITS a stop-short cut, so + // apply the frame it returns (seam, clunk) exactly like a through-cut. + const f = liftKnife(s); + if (f.cutDone) this.applyFrame(f, 0); } } else if (inp.down) { // Off the ingredient with the button down: the cloth. @@ -295,22 +329,45 @@ export class PrepView implements View { } } + // The eyes keep stinging whether you're cutting or the onion's just sitting + // there open while you dither — that's the whole point of the clock. + stingStep(s, dt); + this.prev.set(this.planePt.x, this.planePt.y); this.hasPrev = !!hit; this.positionKnife(s); + this.updateStopLine(s); this.syncMess(); this.updateHud(s); } + /** Park the stop-line at the depth the blade must not saw past, on pass one. */ + private updateStopLine(s: CutSession): void { + if (!this.stopLine) return; + const show = s.dicePhase === 0 && s.phase !== 'done'; + this.stopLine.visible = show; + if (show && this.ingMesh) { + const topY = this.ingMesh.position.y + this.ing.size * 0.55; + // The knife tip at progress p sits at topY + 0.22 − p·size·0.9 (positionKnife); + // the line marks p ≈ 0.82, the middle of the "stopped short" band. + this.stopLine.position.set(0, topY + 0.22 - 0.82 * this.ing.size * 0.9, 0); + } + } + /** What the judge reads. Snapshot at any time; final after onDone. */ result(): PrepResult { const s = this.session!; + const technique = s.pattern.kind === 'dice' && s.pattern.root === true; return { cv: pieceCV(s), pieces: pieceVolumes(s).length, patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind, slips: s.slips, bench: this.mess.stats(), + // Onions carry their sting whatever the pattern; only the technique dice + // turns it (and the stop-line) into scored rows. + ...(this.ing.sting ? { sting: s.sting } : {}), + ...(technique ? { technique: true, stem: stemDiscipline(s) } : {}), }; } @@ -434,15 +491,50 @@ export class PrepView implements View { this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`; } this.progFill.style.width = `${Math.round(s.progress * 100)}%`; - this.progFill.style.background = '#e8a53a'; + // On the root pass the bar turns red past the stop-line — a live warning + // that another stroke puts the blade through the root. + const rootPass = s.pattern.kind === 'dice' && s.pattern.root && s.dicePhase === 0; + this.progFill.style.background = rootPass && s.progress > 0.9 ? '#e2603a' : '#e8a53a'; + const bench = this.mess.benchWord(); const wobble = s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : ''; - this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`; - this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a'; + const eyes = this.ing.sting ? ` · ${stingWord(s.sting)}` : ''; + this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}${eyes}`; + this.benchEl.style.color = bench === 'clean' && s.sting < 0.4 ? '' : bench === 'smeared' || s.sting < 0.75 ? '#e8a53a' : '#e2603a'; + + if (rootPass && s.phase !== 'done') { + this.hintEl.textContent = + 'saw toward the root and STOP at the green line — release to commit · through the root and it falls to confetti'; + } + + this.syncSting(s); + } + + /** The eyes welling up — vignette + blur that grow with sting, cheap DOM. */ + private syncSting(s: CutSession): void { + if (!this.ing.sting || s.sting <= 0.01) { + this.stingOverlay.style.opacity = '0'; + this.stingOverlay.style.backdropFilter = 'none'; + (this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none'; + return; + } + const st = s.sting; + const vig = Math.min(0.6, st * 0.62); + const blur = (st * 5).toFixed(1); + this.stingOverlay.style.opacity = '1'; + this.stingOverlay.style.background = `radial-gradient(ellipse at 50% 46%, transparent ${Math.round(48 - st * 22)}%, rgba(150,172,120,${vig.toFixed(2)}) 100%)`; + this.stingOverlay.style.backdropFilter = `blur(${blur}px)`; + (this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = `blur(${blur}px)`; } dispose(): void { this.panel.dispose(); + this.stingOverlay.remove(); } } + +/** The eyes readout, in the judge's own thresholds. */ +function stingWord(sting: number): string { + return sting < 0.25 ? 'eyes fine' : sting < 0.55 ? 'eyes watering' : sting < 0.8 ? 'eyes streaming' : 'can barely see'; +} diff --git a/src/sim/cutting.ts b/src/sim/cutting.ts index 9f7bf38..3a34d9b 100644 --- a/src/sim/cutting.ts +++ b/src/sim/cutting.ts @@ -22,7 +22,15 @@ export type CutPattern = | { kind: 'halve' } | { kind: 'slices'; n: number } | { kind: 'wedges'; n: number } - | { kind: 'dice'; n: number }; + /** + * dice — two rotated passes of parallel cuts. `root: true` turns on the M14 + * onion stop-line: the first pass (the horizontal, root-ward cuts) must STOP + * short of the root end, committed by releasing the blade mid-saw. Saw all the + * way through and the root lets go — the layers fall apart into confetti. + * Plain dice (day 10) leaves `root` unset and every cut runs straight through, + * exactly as M11 shipped it. + */ + | { kind: 'dice'; n: number; root?: boolean }; export interface CutFrame { /** Skated this frame: the aim jumped to `aimPos`, juice sprayed. */ @@ -65,6 +73,17 @@ export interface CutSession { aggression: number; wedgeWorst: number; squashWorst: number; + // --- M14 onion stop-line + sting ------------------------------------------- + /** Saw depth (0..1 of a full cut) at which each root-pass cut was committed. + * A stopped cut records where you let the blade up; a through-cut records 1. */ + stopDepths: number[]; + /** Root-pass cuts that sawed all the way through the root — each one is a + * handful of confetti. */ + rootThrough: number; + /** 0..1 eye sting, risen while the cut onion sat exposed. */ + sting: number; + /** Seconds the onion has been open — the readout and the tuning both use it. */ + stingTime: number; } const STROKE_GAIN = 0.11; @@ -74,6 +93,19 @@ const SQUASH_GAIN = 0.55; /** Seconds of leaning on slippery skin before it skates, at slippery = 1 * with a knife that has no saw stats worth the name. */ const SLIP_PATIENCE = 0.35; +/** Below this saw depth a release is a fumble, not a committed stop-short cut. */ +const STOP_MIN = 0.15; +/** Sting per (second × open cut-surface × ing.sting × aggression factor). Tuned + * so a sharp, quick clean dice finishes clear-eyed (~0.15) and a slow crushing + * one — or one left sitting — floods the eyes (→ 1). See the commit. */ +const STING_RATE = 0.02; + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +/** The onion's root-ward first pass: where the stop-line lives. */ +function isRootPass(s: CutSession): boolean { + return s.pattern.kind === 'dice' && s.pattern.root === true && s.dicePhase === 0; +} export function plannedCuts(pattern: CutPattern): number { switch (pattern.kind) { @@ -111,6 +143,10 @@ export function newCutSession(ing: Ingredient, knife: ToolId, pattern: CutPatter aggression: 0, wedgeWorst: 0, squashWorst: 0, + stopDepths: [], + rootThrough: 0, + sting: 0, + stingTime: 0, }; } @@ -133,9 +169,104 @@ export function beginBite(s: CutSession): void { } } -/** Mouse released mid-cut: progress keeps, the blade comes off the skin. */ -export function liftKnife(s: CutSession): void { - if (s.phase === 'bite') s.phase = 'aim'; +/** + * Mouse released. On an ordinary cut this just lifts the blade off the skin + * (progress keeps, you can come back). On the onion's root-ward pass it is the + * stop-line commit: letting the blade UP past the first bite is how you end a + * cut deliberately short of the root. That is the whole feel — restraint, one + * cut at a time, not a menu choice. Returns the frame so the scene draws the + * seam and hears the clunk exactly as a through-cut would. + */ +export function liftKnife(s: CutSession): CutFrame { + const out: CutFrame = { slipped: false, juice: 0, cutDone: false, seedsSpilled: 0, sessionDone: false }; + if (s.phase === 'bite') { + s.phase = 'aim'; + return out; + } + if (s.phase === 'saw' && isRootPass(s) && s.progress >= STOP_MIN) { + finishCut(s, out, false); + } + return out; +} + +/** + * A cut has landed — record it, bank the wobble/squash it earned, spill any + * seeds, and advance the pattern (rotate the board on the dice's first pass, + * finish on the last). `through` is true when the blade sawed clean through + * (progress hit 1); false when it was stopped short at the root. + */ +function finishCut(s: CutSession, out: CutFrame, through: boolean): void { + out.cutDone = true; + if (isRootPass(s)) { + s.stopDepths.push(Math.min(1, s.progress)); + if (through) s.rootThrough++; + } + s.cuts.push(s.aimPos); + s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge); + s.squashWorst = Math.max(s.squashWorst, s.squash); + out.seedsSpilled = seedsFor(s); + s.progress = 0; + s.wedge = 0; + s.squash = 0; + s.biteTravel = 0; + s.pressTime = 0; + if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) { + // First pass done: bank it, rotate the board, go again. + s.diceCutsA = s.cuts; + s.cuts = []; + s.dicePhase = 1; + s.phase = 'aim'; + } else if (totalCutsDone(s) >= plannedCuts(s.pattern)) { + s.phase = 'done'; + out.sessionDone = true; + } else { + s.phase = 'aim'; + } +} + +/** + * The eye clock. While a cut onion sits open — being cut, or just sitting on + * the board after — sting rises with the open cut-surface (how many cuts you've + * made) and how much you CRUSHED rather than sliced (blunt knives and squashy + * strokes aerosolise it — literally tear gas). Sharp, quick and few cuts stings + * slow; dawdling floods you. Pure and time-stepped, so the harness can read it. + */ +export function stingStep(s: CutSession, dt: number): void { + if (!s.ing.sting || dt <= 0) return; + const exposed = s.cuts.length + s.diceCutsA.length; + const cutting = s.phase === 'saw' || s.phase === 'bite'; + if (exposed <= 0 && !cutting) return; + const surface = Math.max(1, exposed); + const aggro = 1 + Math.min(2, s.aggression * 4); + s.stingTime += dt; + s.sting = clamp01(s.sting + dt * STING_RATE * s.ing.sting * surface * aggro); +} + +/** Onion stem discipline: how well the root-ward cuts stopped short of the root. */ +export interface StemState { + /** Cuts that sawed clean through the root (each = confetti). */ + through: number; + /** Mean committed depth of the root-ward cuts, 0..1 (ideal ~0.75–0.9). */ + stopMean: number; + /** 0..1 — 1 is every cut stopped clean at the line, 0 is the onion in pieces. */ + score: number; +} + +export function stemDiscipline(s: CutSession): StemState { + const depths = s.stopDepths; + const through = s.rootThrough; + if (depths.length === 0) { + return { through, stopMean: 1, score: through > 0 ? 0.1 : 1 }; + } + let penalty = 0; + for (const d of depths) { + if (d >= 0.97) penalty += 1; // through the root → the layers scatter + else if (d > 0.9) penalty += (d - 0.9) * 3; // grazed the root — risky + else if (d < 0.55) penalty += (0.55 - d) * 1.4; // undercut → chunks still joined + } + penalty /= depths.length; + const stopMean = depths.reduce((a, d) => a + d, 0) / depths.length; + return { through, stopMean, score: clamp01(1 - penalty) }; } /** @@ -204,28 +335,9 @@ export function cutFrame(s: CutSession, dz: number, dx: number, dt: number, rand } if (s.progress >= 1) { - out.cutDone = true; - s.cuts.push(s.aimPos); - s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge); - s.squashWorst = Math.max(s.squashWorst, s.squash); - out.seedsSpilled = seedsFor(s); - s.progress = 0; - s.wedge = 0; - s.squash = 0; - s.biteTravel = 0; - s.pressTime = 0; - if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) { - // First pass done: bank it, rotate the board, go again. - s.diceCutsA = s.cuts; - s.cuts = []; - s.dicePhase = 1; - s.phase = 'aim'; - } else if (totalCutsDone(s) >= plannedCuts(s.pattern)) { - s.phase = 'done'; - out.sessionDone = true; - } else { - s.phase = 'aim'; - } + // Sawed clean through. On the onion's root pass that's exactly the sin — + // you were meant to stop short. finishCut records it as a through-cut. + finishCut(s, out, true); } return out; }