diff --git a/src/dev.ts b/src/dev.ts index c18620b..6cd0ea9 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -678,6 +678,39 @@ export function installDevHarness(app: App, game: Game): void { return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows }; }, + /** Play the day-31 carve with REAL input: fork DOWN for every stroke, + * each one started the same distance along the joint. `slip` lets go. */ + async carveDay(slip = false) { + game.setDay(31); + await F(6); + const cv = game.carveView as unknown as { session: import('./sim/carve').CarveSession }; + const s = cv.session; + if (!s) return { error: 'not at the board' }; + const stroke = async (holdFork: boolean, startX: number) => { + if (holdFork) window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF' })); + await F(2); + point(startX, 0.88, 1.0, false); await F(2); + point(startX, 0.88, 1.0, true); await F(2); + point(startX, 0.88, -1.0, true); await F(2); + point(startX, 0.88, -1.0, false); await F(2); + if (holdFork) window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyF' })); + await F(2); + }; + for (let i = 0; i < 12; i++) { + // The face travels as you carve, so the start point travels with it โ€” + // a fixed offset is what makes every slice the same thickness. + const face = -1.5 + (1 - s.remaining) * 3; + // A slip run lets the fork go on the fourth stroke, once, on purpose. + await stroke(!(slip && i === 3), face + 0.2); + if (s.remaining <= 0.07) break; + } + tap('Enter'); + await F(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + const r = (cv as unknown as { result: () => import('./sim/carve').CarveResult }).result(); + return { count: r.count, cv: r.cv.toFixed(3), mean: r.meanThickness.toFixed(3), tears: r.tears, rows }; + }, + /** Play the day-29 brรปlรฉe with REAL input: hold the torch well back and * sweep the whole top evenly until it is glass. Dwell nowhere. */ async bruleeDay(sloppy = false) { @@ -1680,6 +1713,7 @@ export function installDevHarness(app: App, game: Game): void { 27: () => harness.spiceDay(), 28: () => harness.mandolineDay(), 29: () => harness.bruleeDay(), + 31: () => harness.carveDay(), }; (harness as unknown as { demos: typeof demos }).demos = demos; (harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => { diff --git a/src/game/game.ts b/src/game/game.ts index 3fe978e..32f2264 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -19,6 +19,7 @@ import { SpiceView } from '../scenes/spice'; import { MandolineView } from '../scenes/mandoline'; import { BruleeView } from '../scenes/brulee'; import { CureView } from '../scenes/cure'; +import { CarveView } from '../scenes/carve'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -27,7 +28,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing'; import { TempClock } from '../sim/tempclock'; import type { RoastResult } from '../sim/roasting'; import type { AssemblyResult } from '../sim/assembly'; -import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, judgeCurePack, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, judgeCurePack, judgeCarve, type BruschettaResult, type PrepResult, type Verdict } from './judging'; import { JudgeView } from '../scenes/judge'; import { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -111,6 +112,7 @@ export class Game { private mandoline: MandolineView; private brulee: BruleeView; private cure: CureView; + private carve: CarveView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -171,6 +173,7 @@ export class Game { this.mandoline = new MandolineView(app); this.brulee = new BruleeView(app); this.cure = new CureView(app); + this.carve = new CarveView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -193,6 +196,7 @@ export class Game { app.scene.add(this.mandoline.root); app.scene.add(this.brulee.root); app.scene.add(this.cure.root); + app.scene.add(this.carve.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -214,6 +218,7 @@ export class Game { this.mandoline.root.visible = false; this.brulee.root.visible = false; this.cure.root.visible = false; + this.carve.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -396,6 +401,9 @@ export class Game { get eggBenchView(): EggBenchView { return this.eggBench; } + get carveView(): CarveView { + return this.carve; + } get cureView(): CureView { return this.cure; } @@ -600,6 +608,7 @@ export class Game { this.mandoline.root.visible = false; this.brulee.root.visible = false; this.cure.root.visible = false; + this.carve.root.visible = false; this.assembly.root.visible = false; } @@ -830,13 +839,14 @@ export class Game { if (o.mandoline) return this.enterMandoline(); if (o.brulee) return this.enterBrulee(); if (o.cure) return this.enterCure(); + if (o.carve) return this.enterCarve(); this.enterToastFlow(); } /** The one-line shareable result for the current order's verdict. */ private shareLine(v: Verdict): string { const o = this.order; - const emoji = o.steak ? '๐Ÿฅฉ' : o.grill ? '๐Ÿ”ฅ' : o.pan ? '๐Ÿณ' : o.benedict ? '๐Ÿž๐Ÿณ' : o.poach ? '๐Ÿฅš' : o.eggs ? '๐Ÿฅš' : o.airfryer ? '๐Ÿ—' : o.fryerpot ? '๐ŸŸ' : o.pot ? '๐Ÿ' : o.steamer ? '๐Ÿฅฆ' : o.spice ? '๐ŸŒถ๏ธ' : o.mandoline ? '๐Ÿ”ช' : o.brulee ? '๐Ÿฎ' : o.cure ? '๐Ÿง‚' : o.fridge ? '๐ŸงŠ' : o.prep ? '๐Ÿ”ช' : '๐Ÿž'; + const emoji = o.steak ? '๐Ÿฅฉ' : o.grill ? '๐Ÿ”ฅ' : o.pan ? '๐Ÿณ' : o.benedict ? '๐Ÿž๐Ÿณ' : o.poach ? '๐Ÿฅš' : o.eggs ? '๐Ÿฅš' : o.airfryer ? '๐Ÿ—' : o.fryerpot ? '๐ŸŸ' : o.pot ? '๐Ÿ' : o.steamer ? '๐Ÿฅฆ' : o.spice ? '๐ŸŒถ๏ธ' : o.mandoline ? '๐Ÿ”ช' : o.brulee ? '๐Ÿฎ' : o.cure ? '๐Ÿง‚' : o.carve ? '๐Ÿ–' : o.fridge ? '๐ŸงŠ' : o.prep ? '๐Ÿ”ช' : '๐Ÿž'; const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`; return `TOASTSIM ${tag} โ€” ${emoji} ${v.grade} ${v.total.toFixed(1)}/10 ยท partly.party/toastsim`; } @@ -958,6 +968,11 @@ export class Game { this.enterCure(); return; } + // The carving board: the fork is a hold, and the joint knows it. + if (this.order.carve) { + this.enterCarve(); + return; + } // A poach day: the shiver, the vortex, the drop, the wobble. if (this.order.poach) { this.enterPoach(); @@ -1166,6 +1181,34 @@ export class Game { } /** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ + private enterCarve(): void { + const c = this.order.carve!; + this.carve.reset(`${c.slices} slices \u2014 even, and hold the fork`); + this.hideAllScenes(); + this.carve.root.visible = true; + this.app.setView(this.carve); + this.ticket.show(); + this.carve.onDone = (result) => { + this.carve.root.visible = false; + this.serveCarve(result); + }; + } + + private serveCarve(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgeCarve(result, this.order, seconds); + this.recordVerdict(v); + + this.hideAllScenes(); + this.judgeView.root.visible = true; + this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next()); + audio.stamp(); + this.app.setView(this.judgeView); + this.ticket.hide(); + } + private enterCure(): void { this.cure.reset(); this.hideAllScenes(); diff --git a/src/game/judging.ts b/src/game/judging.ts index d200e8e..8516d17 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -20,6 +20,7 @@ import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim import type { MandolineResult } from '../sim/mandoline'; import { type BruleeResult, crackWord } from '../sim/brulee'; import { type PackResult, packScore } from '../sim/cure'; +import { type CarveResult, thicknessScore } from '../sim/carve'; export interface Criterion { key: string; @@ -32,7 +33,7 @@ export interface Criterion { /** Which station earned it โ€” the scorecard groups by this on prep orders. * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * distribution, balance, sog), bench (temperature + mess). */ - group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch' | 'cure'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch' | 'cure' | 'carve'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -624,6 +625,81 @@ export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdi return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } +// ===================================================================== +// The carve โ€” the ultimate pieces test, and the only row in the game that is +// really about your OTHER hand. + +export function judgeCarve(r: CarveResult, order: Order, seconds: number): Verdict { + const asked = order.carve?.slices ?? 10; + const criteria: Criterion[] = [ + { + key: 'carveeven', + group: 'carve', + label: 'The Slices', + score: clamp01(1 - r.cv / 0.4), + weight: 1.3, + detail: r.count === 0 + ? 'there are no slices' + : r.cv > 0.35 + ? 'no two the same. that is a pile, not a plate' + : r.cv > 0.18 + ? 'close, but they do not match' + : 'every one the same. that is carving', + }, + { + key: 'carvethick', + group: 'carve', + label: 'The Thickness', + score: clamp01(thicknessScore(r.meanThickness)), + weight: 1.0, + detail: r.meanThickness < 0.055 + ? 'shaved. this is not deli meat' + : r.meanThickness > 0.115 + ? 'doorstops' + : 'the thickness it was asked for', + }, + { + key: 'carveanchor', + group: 'carve', + label: 'The Fork', + // The floor: hold the fork and this row is free. Every tear is a + // decision to carve against nothing. + score: r.tears === 0 ? 1 : Math.max(0, 0.4 - (r.tears - 1) * 0.15), + weight: 1.2, + detail: r.tears === 0 + ? 'anchored the whole way. nothing moved' + : r.tears === 1 + ? 'it got away from you once, and the torn face shows' + : `${r.tears} tears. put the fork IN it`, + }, + { + key: 'carvecount', + group: 'bench', + label: 'The Plate', + score: clamp01(r.count / asked), + weight: 0.8, + detail: r.count >= asked ? `${r.count} of ${asked} \u2014 the table is fed` : `${r.count} of ${asked}. Someone is going without`, + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 90) / 120), + weight: 0.3, + detail: `${Math.round(seconds)}s`, + }, + ]; + let sum = 0; + let wsum = 0; + for (const c of criteria) { + sum += c.score * c.weight; + wsum += c.weight; + } + const total = (sum / wsum) * 10; + const rankable = criteria.filter((c) => c.key !== 'time'); + const sorted = [...rankable].sort((a, b) => a.score - b.score); + return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; +} + // ===================================================================== // The cure, part one. Today he can only judge the PACK โ€” the verdict on the // cure itself is days away, and he is quite clear that he has not forgotten. diff --git a/src/game/orders.ts b/src/game/orders.ts index 8c1fa9c..32083cb 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -136,6 +136,8 @@ export interface Order { brulee?: true; /** THE CURE: pack it today, open it days from now. */ cure?: { kind: string }; + /** THE CARVING BOARD: the fork is a hold, not a decoration. */ + carve?: { slices: number }; } export const BROWNING_NAMES: [number, string][] = [ @@ -631,6 +633,21 @@ export const DAYS: Order[] = [ text: 'Gravlax. Pack the whole slab in salt and sugar \u2014 an EVEN blanket, and every bare patch is somewhere it will spoil instead of cure. Then it goes on the shelf and you leave it alone for three days. I will ask you for it. I will remember what day it was.', cure: { kind: 'gravlax' }, }, + { + day: 31, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the lunch rush', + text: 'Ten slices off the joint, all the same. And use the FORK \u2014 hold it down the whole way through the cut. A roast that moves under the knife does not give you a slice, it gives you a tear, and I will see the face where it happened.', + carve: { slices: 10 }, + }, ]; /** Days beyond the script โ€” keep going, with everything cranked. */ @@ -656,7 +673,8 @@ export function proceduralOrder(day: number, rand: () => number): Order { if (roll < 0.945) return proceduralSteamer(day, rand); if (roll < 0.955) return proceduralSpice(day, rand); if (roll < 0.965) return proceduralMandoline(day, rand); - if (roll < 0.98) return proceduralBrulee(day, rand); + if (roll < 0.975) return proceduralBrulee(day, rand); + if (roll < 0.99) return proceduralCarve(day, rand); if (roll < 0.945) return proceduralPoach(day, rand); if (roll < 0.97) return proceduralBenedict(day, rand); return proceduralFridge(day, rand); @@ -718,6 +736,7 @@ export function stationOf(o: Order): string | null { if (o.mandoline) return 'mandoline'; if (o.brulee) return 'brulee'; if (o.cure) return 'cure'; + if (o.carve) return 'carve'; return null; } @@ -778,6 +797,17 @@ function proceduralBrulee(day: number, rand: () => number): Order { return o; } +function proceduralCarve(day: number, rand: () => number): Order { + const slices = 8 + Math.floor(rand() * 5); + const o = baseOrder( + day, + pickOf(rand, ['the lunch rush', 'the pavlova order', 'a regular', 'the man at table nine']), + `${slices} slices off the joint, even, and hold the fork through every one.`, + ); + o.carve = { slices }; + return o; +} + const pickOf = (rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)]; /** The toast fields every Order carries; station days never read most of them. */ diff --git a/src/scenes/carve.ts b/src/scenes/carve.ts new file mode 100644 index 0000000..11be12a --- /dev/null +++ b/src/scenes/carve.ts @@ -0,0 +1,263 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; +import { + type CarveSession, + newCarveSession, + setAnchor, + carve, + carveStep, + carveResult, + carveWord, + THIN_AT, + THICK_AT, +} from '../sim/carve'; +import { el, Panel } from '../ui/hud'; +import { loadBackdrop } from './props'; + +const BOARD_Y = 0.58; +/** The joint runs along X, from the face at -ROAST_HALF to the far end. */ +const ROAST_HALF = 1.5; + +/** + * THE CARVING BOARD โ€” the only station that wants both hands. + * + * HOLD F to plant the carving fork. While it is down the joint cannot move + * and the knife cuts against it; the moment you let go it slides, and a + * stroke against a sliding roast is a tear, not a slice. DRAG across the + * joint to carve โ€” where you START the drag along its length is how thick + * the slice is. ENTER sends the plate out. + */ +export class CarveView implements View { + readonly root = new THREE.Group(); + + private session: CarveSession | null = null; + private roast!: THREE.Mesh; + private fork!: THREE.Group; + private knife!: THREE.Mesh; + private sliceMeshes: THREE.Mesh[] = []; + private slideAnim = 0; + + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y + 0.3)); + private dragStart: THREE.Vector3 | null = null; + + private bgTex = loadBackdrop('/assets/img/bg_butcher.png'); + private prevBg: unknown = null; + + private panel: Panel; + private askEl!: HTMLElement; + private stateEl!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: ReturnType) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + this.panel = new Panel('carve-panel'); + this.buildUi(); + this.panel.hide(); + } + + private buildUi(): void { + const p = this.panel.root; + const card = el('div', 'slicer-card', p); + el('div', 'drawer-lbl', card, 'CARVE IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.stateEl = el('div', 'slicer-thick', card, ''); + this.wordEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(9, 0.4, 6), + new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + + // The board, with the groove that catches what the joint gives up. + const board = new THREE.Mesh( + new THREE.BoxGeometry(4.6, 0.22, 3.0), + new THREE.MeshStandardMaterial({ color: 0x8a6234, roughness: 0.75 }), + ); + board.position.y = BOARD_Y - 0.11; + board.receiveShadow = true; + this.root.add(board); + const groove = new THREE.Mesh( + new THREE.TorusGeometry(1.9, 0.045, 8, 40), + new THREE.MeshStandardMaterial({ color: 0x6d4c26, roughness: 0.8 }), + ); + groove.rotation.x = Math.PI / 2; + groove.scale.set(1, 0.62, 1); + groove.position.y = BOARD_Y; + this.root.add(groove); + + // The joint. It shortens from the face end as you carve. + this.roast = new THREE.Mesh( + new THREE.CylinderGeometry(0.52, 0.56, ROAST_HALF * 2, 22), + new THREE.MeshStandardMaterial({ color: 0x9a5a3a, roughness: 0.72 }), + ); + this.roast.rotation.z = Math.PI / 2; + this.roast.position.set(0, BOARD_Y + 0.5, 0); + this.roast.castShadow = true; + this.root.add(this.roast); + + // The fork: two tines and a handle, planted or held clear. + this.fork = new THREE.Group(); + for (let i = 0; i < 2; i++) { + const tine = new THREE.Mesh( + new THREE.CylinderGeometry(0.035, 0.02, 0.75, 8), + new THREE.MeshStandardMaterial({ color: 0xc8ccd0, roughness: 0.3, metalness: 0.85 }), + ); + tine.position.set(0, -0.35, (i - 0.5) * 0.16); + this.fork.add(tine); + } + const handle = new THREE.Mesh( + new THREE.BoxGeometry(0.14, 0.6, 0.14), + new THREE.MeshStandardMaterial({ color: 0x2c2018, roughness: 0.6 }), + ); + handle.position.y = 0.32; + this.fork.add(handle); + this.fork.position.set(0.75, BOARD_Y + 1.2, 0); + this.root.add(this.fork); + + // The knife rides with the pointer. + this.knife = new THREE.Mesh( + new THREE.BoxGeometry(0.05, 0.16, 1.7), + new THREE.MeshStandardMaterial({ color: 0xe8ecf0, roughness: 0.15, metalness: 0.9 }), + ); + this.knife.position.set(0, BOARD_Y + 1.1, 0); + this.root.add(this.knife); + } + + reset(askText = 'carve it โ€” even slices, and hold the fork'): void { + this.session = newCarveSession(); + this.dragStart = null; + this.slideAnim = 0; + for (const m of this.sliceMeshes) this.root.remove(m); + this.sliceMeshes = []; + this.wordEl.textContent = ''; + this.wordEl.style.color = ''; + this.askEl.textContent = askText; + this.hintEl.textContent = 'HOLD F โ€” plant the fork ยท DRAG across the joint to carve (start further along = thicker) ยท ENTER serves'; + } + + enter(): void { + this.prevBg = this.app.scene.background; + this.app.scene.background = this.bgTex; + this.panel.show(); + } + exit(): void { + this.panel.hide(); + this.app.scene.background = this.prevBg as never; + } + + update(dt: number): void { + this.app.camera.position.set(0, 3.5, 3.4); + this.app.camera.lookAt(0, BOARD_Y + 0.3, 0); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + // The off hand. This is a HOLD, not a toggle, because that is the whole + // point: it has to stay down for the length of the cut. + setAnchor(s, inp.held('KeyF')); + + this.ray.setFromCamera(inp.ndc, this.app.camera); + const on = this.ray.ray.intersectPlane(this.plane, this.hit); + if (on) this.knife.position.set(this.hit.x, BOARD_Y + 1.1, 0); + + if (inp.down && on) { + if (!this.dragStart) this.dragStart = this.hit.clone(); + else if (this.dragStart.z > 0.3 && this.hit.z < -0.3) { + this.doCarve(this.dragStart.x); + this.dragStart = null; + } + } else { + this.dragStart = null; + } + + carveStep(s, dt); + this.syncVisuals(dt); + this.updateHud(); + + if (inp.justPressed('Enter')) { + const cb = this.onDone; + this.onDone = null; + cb?.(carveResult(s)); + } + } + + /** Where you started the drag along the joint decides the thickness. */ + private doCarve(startX: number): void { + const s = this.session!; + const faceX = this.faceX(); + const want = Math.max(0.02, (startX - faceX) * 0.42); + const r = carve(s, want); + if (r.torn) { + this.slideAnim = 1; + audio.scrape(0.9, 2.2); + audio.clatter(0.5, 0.6); + eye.mood('disappointed', 2.5); + this.wordEl.textContent = 'IT SLID. that is a tear, not a slice.'; + this.wordEl.style.color = '#e2603a'; + return; + } + if (!r.sliced) return; + audio.scrape(0.45, 1.1); + // The slice lands in front of the board, stacking as it goes. + const n = this.sliceMeshes.length; + const sl = new THREE.Mesh( + new THREE.CylinderGeometry(0.5, 0.5, Math.max(0.02, r.thickness * 1.6), 20), + new THREE.MeshStandardMaterial({ color: 0xb06a48, roughness: 0.7 }), + ); + sl.rotation.x = Math.PI / 2 - 0.25; + sl.position.set(-1.5 + (n % 6) * 0.42, BOARD_Y + 0.06 + Math.floor(n / 6) * 0.07, 1.35); + this.sliceMeshes.push(sl); + this.root.add(sl); + } + + /** The cut face travels as the joint is carved down. */ + private faceX(): number { + const s = this.session!; + return -ROAST_HALF + (1 - s.remaining) * ROAST_HALF * 2; + } + + private syncVisuals(dt: number): void { + const s = this.session!; + this.slideAnim = Math.max(0, this.slideAnim - dt * 3); + const len = Math.max(0.05, s.remaining) * ROAST_HALF * 2; + this.roast.scale.set(1, len / (ROAST_HALF * 2), 1); + // The remaining joint sits from the face to the far end. + this.roast.position.x = this.faceX() + len / 2 + Math.sin(this.slideAnim * 26) * this.slideAnim * 0.14; + + // The fork: planted IN the joint, or held up and useless. + const fx = Math.min(ROAST_HALF - 0.2, this.faceX() + len * 0.62); + this.fork.position.x += (fx - this.fork.position.x) * (1 - Math.exp(-dt / 0.1)); + const fy = s.anchored ? BOARD_Y + 0.72 : BOARD_Y + 1.35; + this.fork.position.y += (fy - this.fork.position.y) * (1 - Math.exp(-dt / 0.07)); + this.fork.rotation.z = s.anchored ? 0 : 0.35; + } + + private updateHud(): void { + const s = this.session!; + const r = carveResult(s); + this.stateEl.textContent = `${r.count} slice${r.count === 1 ? '' : 's'} ยท fork ${s.anchored ? 'DOWN' : 'up'}${r.tears ? ` ยท ${r.tears} torn` : ''} ยท ${Math.round(r.waste * 100)}% of the joint left`; + if (!this.wordEl.textContent?.includes('SLID')) { + this.wordEl.textContent = carveWord(s); + this.wordEl.style.color = ''; + } + void THIN_AT; + void THICK_AT; + } + + result(): ReturnType { + return carveResult(this.session!); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 955cc05..539f71b 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -195,7 +195,7 @@ export class JudgeView implements View { // Grill and bruschetta days don't have a browning/spread to name โ€” show who // asked and what for, not the unread toast defaults. // The dish itself, in miniature, beside the ask. - const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : order.cure ? 'cure' : null; + const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : order.cure ? 'cure' : order.carve ? 'roast' : null; this.orderEl.parentElement?.querySelector('.judge-hero')?.remove(); if (heroKey) { const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement; @@ -203,7 +203,9 @@ export class JudgeView implements View { hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`); this.orderEl.parentElement?.insertBefore(hero, this.orderEl); } - this.orderEl.textContent = order.cure + this.orderEl.textContent = order.carve + ? `Day ${order.day} ยท ${order.who} asked for the joint carved` + : order.cure ? `Day ${order.day} ยท ${order.who} started the cure` : order.brulee ? `Day ${order.day} ยท ${order.who} asked for the br\u00fbl\u00e9e` @@ -243,7 +245,7 @@ export class JudgeView implements View { // Station orders (prep, and M15's bruschetta trio) get the grouped card; // a plain toast order keeps the flat card it always had. const grouped = verdict.criteria.some( - (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch' || c.group === 'cure', + (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch' || c.group === 'cure' || c.group === 'carve', ); const headers: Record = { prep: 'THE PREP', @@ -264,9 +266,10 @@ export class JudgeView implements View { blade: 'THE BLADE', torch: 'THE TORCH', cure: 'THE PACK', + carve: 'THE CARVE', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, cure: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, cure: 3, carve: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped diff --git a/src/sim/carve.ts b/src/sim/carve.ts new file mode 100644 index 0000000..2e8a264 --- /dev/null +++ b/src/sim/carve.ts @@ -0,0 +1,120 @@ +import { Rng } from '../core/rng'; + +/** + * CARVING โ€” the first job the off hand has ever had. + * + * A roast is not a vegetable: it is heavy, it is round, and it is greased in + * its own fat, so it will slide out from under a blade given the smallest + * excuse. The fork is not decoration. Planted, it is a pin constraint and the + * knife cuts against it; lifted, the joint travels with the stroke and what + * you get is not a slice, it is a tear. + * + * So the whole station is a hold: one hand keeps the fork down for the entire + * length of a cut while the other does the carving. Let go halfway and you + * will see exactly where you let go. + */ + +/** The band a carved slice wants to be in. */ +export const THIN_AT = 0.055; +export const THICK_AT = 0.115; + +/** How much roast is left when there is nothing worth carving. */ +const SPENT_AT = 0.06; + +export interface CarveSession { + /** 1 = a whole joint, 0 = bones. */ + remaining: number; + /** Every slice that made it to the plate, by thickness. */ + slices: number[]; + /** Slices ruined by carving against nothing. */ + tears: number; + /** True while the fork is down. */ + anchored: boolean; + seconds: number; + rng: Rng; +} + +export function newCarveSession(seed = 20260728): CarveSession { + return { remaining: 1, slices: [], tears: 0, anchored: false, seconds: 0, rng: new Rng(seed) }; +} + +export function setAnchor(s: CarveSession, on: boolean): void { + s.anchored = on; +} + +export interface CarveStroke { + sliced: boolean; + torn: boolean; + thickness: number; +} + +/** + * One stroke of the knife. `want` is the thickness the stroke asked for โ€” + * how far down the joint you started the cut. Unanchored, the joint slides: + * the blade skates, and what comes off is a ragged fraction of the ask. + */ +export function carve(s: CarveSession, want: number): CarveStroke { + if (s.remaining <= SPENT_AT) return { sliced: false, torn: false, thickness: 0 }; + const asked = Math.max(0.02, Math.min(0.2, want)); + if (!s.anchored) { + // The joint travels with the stroke. Some meat comes off; none of it is + // a slice, and the judge can see the torn face for the rest of the carve. + s.tears++; + s.remaining = Math.max(0, s.remaining - asked * 0.55); + s.seconds += 1.2; + return { sliced: false, torn: true, thickness: asked * 0.55 }; + } + const took = Math.min(asked, s.remaining); + s.remaining -= took; + s.slices.push(took); + s.seconds += 0.8; + return { sliced: true, torn: false, thickness: took }; +} + +export function carveStep(s: CarveSession, dt: number): void { + s.seconds += dt; +} + +export interface CarveResult { + count: number; + meanThickness: number; + /** Coefficient of variation across the slices โ€” the ultimate pieces test. */ + cv: number; + tears: number; + /** Joint left uncarved. */ + waste: number; + seconds: number; +} + +export function carveResult(s: CarveSession): CarveResult { + const n = s.slices.length; + const mean = n ? s.slices.reduce((a, b) => a + b, 0) / n : 0; + const sd = n > 1 ? Math.sqrt(s.slices.reduce((a, b) => a + (b - mean) ** 2, 0) / n) : 0; + return { + count: n, + meanThickness: mean, + cv: mean > 0 ? sd / mean : 0, + tears: s.tears, + waste: s.remaining, + seconds: s.seconds, + }; +} + +/** 1 inside the thickness band, falling off outside it. */ +export function thicknessScore(mean: number): number { + if (mean < THIN_AT) return Math.max(0, 1 - (THIN_AT - mean) / 0.045); + if (mean > THICK_AT) return Math.max(0, 1 - (mean - THICK_AT) / 0.07); + return 1; +} + +/** The joint, out loud. It tells you about the fork, because that is the game. */ +export function carveWord(s: CarveSession): string { + if (s.remaining <= SPENT_AT) return 'carved down to the bone'; + if (!s.anchored) return s.tears > 0 ? 'it SLID. hold the fork and cut against it' : 'nothing is holding that joint down'; + const n = s.slices.length; + if (n === 0) return 'forked and steady. carve'; + const last = s.slices[n - 1]; + if (last < THIN_AT) return `${n} on the plate โ€” that one was shaved, not sliced`; + if (last > THICK_AT) return `${n} on the plate โ€” that one is a doorstop`; + return `${n} on the plate, and even`; +}