From 47f6c39e2bcf351f23d5f1b1e0e8d8f1ead03c51 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 26 Jul 2026 21:46:47 +1000 Subject: [PATCH] =?UTF-8?q?THE=20MANDOLINE:=20day=2028=20=E2=80=94=20yield?= =?UTF-8?q?=20against=20skin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P8's machine, and the one station where evenness is FREE. A knife makes you earn every slice; the mandoline hands you a perfect one every stroke. So the game is not the cut. The game is the last third of the vegetable, where your knuckles get close to the blade and the only thing between them is a guard that wastes what it protects. The trade, and it is the whole station: bare-handed shaves further down for a better yield and is COMPLETELY SAFE while the thing is long — the risk is zero above a comfortable stub and climbs steeply below it, so careful play never bleeds and greed does. The guard is not a probability, it is a fact: with it on you cannot be cut. It also cannot grip a stub, so it stops you early and leaves waste in the bin. Bleeding is not a bad row, it is a health inspector: the plate caps at 3.5 and the detail reads 'that food does not leave my kitchen'. Two fixes the verification forced: - The first tuning asked for 14 slices, and a guarded cook tops out at about 13 — so the safe route could not satisfy the ticket and the player was choosing between failing the ask and bleeding. That breaks the house rule that careful play is reliably safe. The ask is 12 now (10..13 procedurally), reachable guarded, with bare-handed greed worth a couple of extra slices and nothing you could not otherwise earn. - The waste scale punished the guard so hard that safe play was mediocre by construction. Re-centred on the guard floor: the guard costs a little yield, it does not sink the run. And a bug the screenshot caught, which turned out to be three bugs: the nick message is sticky by design (updateHud refuses to overwrite it), and NOTHING cleared it on reset — so a fresh cook was told 'THE BLADE FOUND YOU' in red before touching the machine. The fryer's 'it CAUGHT you' and the air fryer's 'jumped the rack' had the identical trap. All three cleared on reset. Verified: careful 9.6 (13 slices, guarded, hands intact); greedy 15 slices — MORE yield — and 3.5 for bleeding on them; word clean on a fresh board; curry 10.0, chips 10.0. Co-Authored-By: Claude Fable 5 --- src/dev.ts | 36 ++++++ src/game/game.ts | 47 ++++++- src/game/judging.ts | 70 ++++++++++- src/game/orders.ts | 35 +++++- src/scenes/airfryer.ts | 5 + src/scenes/fryerpot.ts | 5 + src/scenes/judge.ts | 11 +- src/scenes/mandoline.ts | 264 ++++++++++++++++++++++++++++++++++++++++ src/sim/mandoline.ts | 119 ++++++++++++++++++ 9 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 src/scenes/mandoline.ts create mode 100644 src/sim/mandoline.ts diff --git a/src/dev.ts b/src/dev.ts index 022d168..1092263 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -678,6 +678,41 @@ 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-28 mandoline with REAL input: shave bare-handed while the + * vegetable is long, then put the GUARD on before the stub. Never bleeds. */ + async mandolineDay(greedy = false) { + game.setDay(28); + await F(6); + const mv = game.mandolineView as unknown as { session: import('./sim/mandoline').MandolineSession }; + const s = mv.session; + if (!s) return { error: 'not at mandoline' }; + const DANGER = (await import('./sim/mandoline')).DANGER_FROM; + const pass = async () => { + point(0, 0.75, 1.0, false); await F(2); + point(0, 0.75, 1.0, true); await F(2); + point(0, 0.75, -1.0, true); await F(2); // across the blade + point(0, 0.75, -1.0, false); await F(2); + }; + let guard = false; + for (let i = 0; i < 40; i++) { + // The whole decision, in one line: the careful cook arms the guard + // BEFORE the stub; the greedy one never does. + if (!greedy && !guard && s.length < DANGER + 0.03) { + tap('KeyG'); + guard = true; + await F(3); + } + const before = s.slices; + await pass(); + if (s.slices === before && s.length < 0.34) break; // refused: nothing left to hold + if (s.length <= 0.04) break; + } + tap('Enter'); + await F(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + return { slices: s.slices, waste: s.length.toFixed(2), nicks: s.nicks, bled: s.bled, guard: s.guard, rows }; + }, + /** Play the day-27 curry with REAL input: pour each spice into its band, * taste, WAIT for the chili to bloom, taste again, serve. */ async spiceDay() { @@ -1591,6 +1626,7 @@ export function installDevHarness(app: App, game: Game): void { 25: () => harness.potDay(), 26: () => harness.steamDay(), 27: () => harness.spiceDay(), + 28: () => harness.mandolineDay(), }; (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 7b3959e..13bf48f 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -16,6 +16,7 @@ import { FryerPotView } from '../scenes/fryerpot'; import { PotView } from '../scenes/pot'; import { SteamerView } from '../scenes/steamer'; import { SpiceView } from '../scenes/spice'; +import { MandolineView } from '../scenes/mandoline'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -24,7 +25,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, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, 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'; @@ -93,6 +94,7 @@ export class Game { private pot: PotView; private steamer: SteamerView; private spice: SpiceView; + private mandoline: MandolineView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -150,6 +152,7 @@ export class Game { this.pot = new PotView(app); this.steamer = new SteamerView(app); this.spice = new SpiceView(app); + this.mandoline = new MandolineView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -169,6 +172,7 @@ export class Game { app.scene.add(this.pot.root); app.scene.add(this.steamer.root); app.scene.add(this.spice.root); + app.scene.add(this.mandoline.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -187,6 +191,7 @@ export class Game { this.pot.root.visible = false; this.steamer.root.visible = false; this.spice.root.visible = false; + this.mandoline.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -362,6 +367,9 @@ export class Game { get eggBenchView(): EggBenchView { return this.eggBench; } + get mandolineView(): MandolineView { + return this.mandoline; + } get spiceView(): SpiceView { return this.spice; } @@ -554,6 +562,7 @@ export class Game { this.pot.root.visible = false; this.steamer.root.visible = false; this.spice.root.visible = false; + this.mandoline.root.visible = false; this.assembly.root.visible = false; } @@ -779,13 +788,14 @@ export class Game { if (o.pot) return this.enterPot(); if (o.steamer) return this.enterSteamer(); if (o.spice) return this.enterSpice(); + if (o.mandoline) return this.enterMandoline(); 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.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.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`; } @@ -892,6 +902,11 @@ export class Game { this.enterSpice(); return; } + // The mandoline: yield against skin. + if (this.order.mandoline) { + this.enterMandoline(); + return; + } // A poach day: the shiver, the vortex, the drop, the wobble. if (this.order.poach) { this.enterPoach(); @@ -1100,6 +1115,34 @@ export class Game { } /** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ + private enterMandoline(): void { + const m = this.order.mandoline!; + this.mandoline.reset(`${m.slices} slices \u2014 and your hands intact`); + this.hideAllScenes(); + this.mandoline.root.visible = true; + this.app.setView(this.mandoline); + this.ticket.show(); + this.mandoline.onDone = (result) => { + this.mandoline.root.visible = false; + this.serveMandoline(result); + }; + } + + private serveMandoline(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgeMandoline(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 enterSpice(): void { const sp = this.order.spice!; this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base); diff --git a/src/game/judging.ts b/src/game/judging.ts index b659ba5..0835b19 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -17,6 +17,7 @@ import type { PoachResult } from '../sim/poach'; import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot'; import type { SteamerResult } from '../sim/steamer'; import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor'; +import type { MandolineResult } from '../sim/mandoline'; export interface Criterion { key: string; @@ -29,7 +30,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'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -619,6 +620,73 @@ 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 mandoline — yield against skin. Evenness is free here; what costs is +// how far down the vegetable you were willing to go, and what it cost you. + +export function judgeMandoline(r: MandolineResult, order: Order, seconds: number): Verdict { + const asked = order.mandoline?.slices ?? 12; + const criteria: Criterion[] = [ + { + key: 'bladecount', + group: 'blade', + label: 'The Yield', + score: clamp01(r.slices / asked), + weight: 1.2, + detail: r.slices >= asked + ? `${r.slices} of ${asked} — the whole ask` + : `${r.slices} of ${asked}. The rest is still on the board`, + }, + { + key: 'bladewaste', + group: 'blade', + label: 'The Waste', + // Centred so a guarded finish (the guard floor) still scores well: the + // guard should COST a little yield, not sink the run of a careful cook. + score: clamp01(1 - (r.waste - 0.17) / 0.65), + weight: 0.9, + detail: r.waste > 0.3 + ? 'a fat stub in the bin. the guard is not free' + : r.waste > 0.16 + ? 'a little left on the board' + : 'shaved down to almost nothing. nothing wasted', + }, + { + key: 'bladehands', + group: 'bench', + label: 'The Hands', + // The floor: a clean pair of hands is a full mark, always available, + // and no amount of yield buys its way past a bleeding one. + score: r.nicks === 0 ? 1 : Math.max(0, 0.35 - (r.nicks - 1) * 0.18), + weight: 1.4, + detail: r.nicks === 0 + ? r.guard ? 'guarded the whole way. hands intact' : 'bare handed and unmarked — you knew when to stop' + : r.nicks === 1 + ? 'you nicked yourself. that food does not leave my kitchen' + : `${r.nicks} times. put the guard on or put the machine away`, + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 70) / 110), + 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; + } + // Blood on the board is not a row, it is a health inspector. Nothing that + // came off this machine after you bled on it is servable. + const total = Math.min((sum / wsum) * 10, r.bled ? 3.5 : 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 spice rack — a row per axis the ticket actually asked about, because // "it needs something" is not a note, and he has never once given one. diff --git a/src/game/orders.ts b/src/game/orders.ts index 830c29a..9fc3f78 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -130,6 +130,8 @@ export interface Order { steamer?: { veg: string }; /** THE SPICE RACK: six axes, no subtract, and a chili that lies. */ spice?: { dish: string; target: import('../sim/flavor').Target; base?: Partial> }; + /** THE MANDOLINE: yield against skin. The guard wastes what it protects. */ + mandoline?: { slices: number }; } export const BROWNING_NAMES: [number, string][] = [ @@ -580,6 +582,21 @@ export const DAYS: Order[] = [ base: { aroma: 0.06, bitter: 0.04 }, }, }, + { + day: 28, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the chef, on her day off', + text: 'Twelve slices on the mandoline, paper thin. It will give you every one of them evenly \u2014 that is what the machine is FOR. What it will not do is tell you when to stop. The guard wastes the end; your knuckles are the alternative. Choose, and live with it.', + mandoline: { slices: 12 }, + }, ]; /** Days beyond the script — keep going, with everything cranked. */ @@ -603,7 +620,8 @@ export function proceduralOrder(day: number, rand: () => number): Order { if (roll < 0.925) return proceduralFryerPot(day, rand); if (roll < 0.935) return proceduralPot(day, rand); if (roll < 0.945) return proceduralSteamer(day, rand); - if (roll < 0.96) return proceduralSpice(day, rand); + if (roll < 0.955) return proceduralSpice(day, rand); + if (roll < 0.97) return proceduralMandoline(day, rand); if (roll < 0.945) return proceduralPoach(day, rand); if (roll < 0.97) return proceduralBenedict(day, rand); return proceduralFridge(day, rand); @@ -662,6 +680,7 @@ export function stationOf(o: Order): string | null { if (o.pot) return 'pot'; if (o.steamer) return 'steamer'; if (o.spice) return 'spice'; + if (o.mandoline) return 'mandoline'; return null; } @@ -698,6 +717,20 @@ function proceduralSpice(day: number, rand: () => number): Order { return o; } +function proceduralMandoline(day: number, rand: () => number): Order { + // 10..13: reachable with the guard on, so a safe cook can always make the + // ask. Going bare-handed past it buys a couple of extra slices, never a + // pass mark you could not otherwise get. + const slices = 10 + Math.floor(rand() * 4); + const o = baseOrder( + day, + pickOf(rand, ['the chef, on her day off', 'the lunch rush', 'a regular', 'the man at table nine']), + `${slices} slices on the mandoline. Even is free; stopping in time is not.`, + ); + o.mandoline = { 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/airfryer.ts b/src/scenes/airfryer.ts index 186e6b2..cd67f61 100644 --- a/src/scenes/airfryer.ts +++ b/src/scenes/airfryer.ts @@ -140,6 +140,11 @@ export class AirFryerView implements View { this.root.add(wing); this.wingMeshes.push(wing); } + // Same sticky-word trap as the mandoline: updateHud deliberately refuses + // to overwrite the accident line, so a fresh session must clear it or the + // next cook is told about an injury that has not happened. + this.wordEl.textContent = ''; + this.wordEl.style.color = ''; this.askEl.textContent = askText; this.cardEl.textContent = cardText; this.hintEl.textContent = 'DRAG — give them AIR · WHEEL — knob · SPACE — drawer in/out · hold F, release — SHAKE · ENTER serves'; diff --git a/src/scenes/fryerpot.ts b/src/scenes/fryerpot.ts index a461a70..efd2e8a 100644 --- a/src/scenes/fryerpot.ts +++ b/src/scenes/fryerpot.ts @@ -184,6 +184,11 @@ export class FryerPotView implements View { this.chipMeshes.push(chip); this.root.add(chip); } + // Same sticky-word trap as the mandoline: updateHud deliberately refuses + // to overwrite the accident line, so a fresh session must clear it or the + // next cook is told about an injury that has not happened. + this.wordEl.textContent = ''; + this.wordEl.style.color = ''; this.askEl.textContent = askText; this.cardEl.textContent = cardText; this.hintEl.textContent = 'WHEEL — flame · SPACE — basket in / out to the rack · keep your HAND off the pot when it spits · ENTER serves'; diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 3831a91..5c30613 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' : 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' : 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.spice + this.orderEl.textContent = order.mandoline + ? `Day ${order.day} · ${order.who} asked for the mandoline` + : order.spice ? `Day ${order.day} · ${order.who} asked for it SEASONED` : order.steamer ? `Day ${order.day} · ${order.who} asked for the greens` @@ -237,7 +239,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) => 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', ); const headers: Record = { prep: 'THE PREP', @@ -255,9 +257,10 @@ export class JudgeView implements View { pot: 'THE POT', steam: 'THE STEAMER', spice: 'THE SEASONING', + blade: 'THE BLADE', 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, 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, 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/scenes/mandoline.ts b/src/scenes/mandoline.ts new file mode 100644 index 0000000..7a73bde --- /dev/null +++ b/src/scenes/mandoline.ts @@ -0,0 +1,264 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; +import { + type MandolineSession, + newMandolineSession, + setGuard, + stroke, + mandolineStep, + mandolineResult, + mandolineWord, + riskOf, + DANGER_FROM, + GUARD_FLOOR, +} from '../sim/mandoline'; +import { el, Panel } from '../ui/hud'; +import { loadBackdrop } from './props'; + +const BOARD_Y = 0.55; +const BOARD_L = 3.0; + +/** + * THE MANDOLINE — drag DOWN across the blade to shave a slice, and decide, + * every stroke, whether your knuckles or your yield matter more. + * + * G puts the guard on: perfectly safe, slower, and it will refuse to hold a + * stub — so it leaves waste on the board. Bare-handed goes further down the + * vegetable for a better yield, and is completely safe until the stub gets + * short, at which point it is not. ENTER serves. + */ +export class MandolineView implements View { + readonly root = new THREE.Group(); + + private session: MandolineSession | null = null; + private body!: THREE.Mesh; + private blade!: THREE.Mesh; + private veg!: THREE.Mesh; + private guardMesh!: THREE.Group; + private sliceMeshes: THREE.Mesh[] = []; + private bloodMeshes: THREE.Mesh[] = []; + private strokeAnim = 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.2)); + private dragStartZ: number | 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('mandoline-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, 'THE MANDOLINE'); + 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 ramp: a tilted plate with a blade set across the middle of it. + this.body = new THREE.Mesh( + new THREE.BoxGeometry(1.9, 0.12, BOARD_L), + new THREE.MeshStandardMaterial({ color: 0xd8d4cc, roughness: 0.35, metalness: 0.3 }), + ); + this.body.position.set(0, BOARD_Y, 0); + this.body.rotation.x = -0.13; + this.body.receiveShadow = true; + this.root.add(this.body); + + this.blade = new THREE.Mesh( + new THREE.BoxGeometry(1.86, 0.03, 0.1), + new THREE.MeshStandardMaterial({ color: 0xf0f4f8, roughness: 0.08, metalness: 0.95 }), + ); + this.blade.position.set(0, BOARD_Y + 0.09, 0); + this.blade.rotation.x = -0.13; + this.root.add(this.blade); + + // The vegetable: shortens visibly, which is the only warning you need. + this.veg = new THREE.Mesh( + new THREE.CylinderGeometry(0.34, 0.34, 1.1, 20), + new THREE.MeshStandardMaterial({ color: 0xe8d9a8, roughness: 0.8 }), + ); + this.veg.rotation.x = Math.PI / 2 - 0.13; + this.veg.position.set(0, BOARD_Y + 0.42, 0.9); + this.root.add(this.veg); + + // The guard: a little toothed cap that sits on the end when armed. + this.guardMesh = new THREE.Group(); + const cap = new THREE.Mesh( + new THREE.CylinderGeometry(0.4, 0.4, 0.3, 18, 1, true), + new THREE.MeshStandardMaterial({ color: 0xc85a2a, roughness: 0.6, side: THREE.DoubleSide }), + ); + const knob = new THREE.Mesh( + new THREE.SphereGeometry(0.16, 12, 10), + new THREE.MeshStandardMaterial({ color: 0xc85a2a, roughness: 0.6 }), + ); + knob.position.y = 0.2; + this.guardMesh.add(cap, knob); + this.guardMesh.rotation.x = Math.PI / 2 - 0.13; + this.guardMesh.visible = false; + this.root.add(this.guardMesh); + } + + reset(askText = 'slices — as many as you can get, with your hands intact'): void { + this.session = newMandolineSession(); + this.dragStartZ = null; + this.strokeAnim = 0; + for (const m of this.sliceMeshes) this.root.remove(m); + for (const m of this.bloodMeshes) this.root.remove(m); + this.sliceMeshes = []; + this.bloodMeshes = []; + this.guardMesh.visible = false; + // The nick message is sticky by design — updateHud refuses to overwrite + // it — so a fresh vegetable has to clear it explicitly, or the next cook + // is told they are bleeding before they have touched the machine. + this.wordEl.textContent = ''; + this.wordEl.style.color = ''; + this.askEl.textContent = askText; + this.hintEl.textContent = 'DRAG DOWN across the blade — one stroke, one slice · G — the guard (safe, slower, wastes the stub) · ENTER serves'; + } + + enter(): void { + this.prevBg = this.app.scene.background; + this.app.scene.background = this.bgTex; + this.panel.show(); + } + exit(): void { + this.app.scene.background = this.prevBg as never; + this.panel.hide(); + } + + update(dt: number): void { + this.app.camera.position.set(0, 3.5, 3.2); + this.app.camera.lookAt(0, BOARD_Y, 0); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + if (inp.justPressed('KeyG')) { + setGuard(s, !s.guard); + this.guardMesh.visible = s.guard; + audio.clatter(0.3, 1.3); + } + + // A stroke is a DRAG across the blade, front to back. Starting behind the + // blade and finishing in front of it is one pass of the vegetable. + this.ray.setFromCamera(inp.ndc, this.app.camera); + const on = this.ray.ray.intersectPlane(this.plane, this.hit); + if (inp.down && on) { + if (this.dragStartZ === null) this.dragStartZ = this.hit.z; + else if (this.dragStartZ > 0.25 && this.hit.z < -0.25) { + this.doStroke(); + this.dragStartZ = null; // one pass per drag + } + } else { + this.dragStartZ = null; + } + + mandolineStep(s, dt); + this.syncVisuals(dt); + this.updateHud(); + + if (inp.justPressed('Enter')) { + const cb = this.onDone; + this.onDone = null; + cb?.(mandolineResult(s)); + } + } + + private doStroke(): void { + const s = this.session!; + const r = stroke(s); + this.strokeAnim = 1; + if (r.nicked) { + audio.clatter(1, 2.6); + audio.fart(s.nicks * 5 + 2); // the yelp, in the house voice + eye.mood('horrified', 3.5); + this.wordEl.textContent = 'THE BLADE FOUND YOU. that is a stub, not a handle.'; + this.wordEl.style.color = '#e2603a'; + // A red bead on the board. It does not wipe off. + const drop = new THREE.Mesh( + new THREE.SphereGeometry(0.07, 8, 6), + new THREE.MeshStandardMaterial({ color: 0x8c1a12, roughness: 0.5 }), + ); + drop.scale.y = 0.35; + drop.position.set((Math.random() - 0.5) * 1.2, BOARD_Y + 0.08, -0.4 - Math.random() * 0.6); + this.bloodMeshes.push(drop); + this.root.add(drop); + return; + } + if (r.refused) { + audio.clatter(0.25, 0.9); + return; + } + audio.scrape(0.5, 1.4); + // The slice lands on the pile in front of the blade. + const sl = new THREE.Mesh( + new THREE.CylinderGeometry(0.32, 0.32, 0.02, 18), + new THREE.MeshStandardMaterial({ color: 0xf0e4be, roughness: 0.75 }), + ); + const n = this.sliceMeshes.length; + sl.position.set((Math.random() - 0.5) * 0.5, BOARD_Y + 0.06 + n * 0.012, -1.0 - Math.random() * 0.4); + sl.rotation.set(-0.13, Math.random() * Math.PI, 0); + this.sliceMeshes.push(sl); + this.root.add(sl); + } + + private syncVisuals(dt: number): void { + const s = this.session!; + this.strokeAnim = Math.max(0, this.strokeAnim - dt * 4); + // The vegetable shortens — the only warning the station gives, and the + // honest one: you can SEE the stub getting close to the blade. + const len = Math.max(0.06, s.length * 1.1); + this.veg.scale.set(1, len / 1.1, 1); + this.veg.position.z = 0.42 + len * 0.44 - this.strokeAnim * 0.28; + this.veg.position.y = BOARD_Y + 0.42; + const danger = s.guard ? 0 : Math.min(1, riskOf(s) / 0.4); + (this.veg.material as THREE.MeshStandardMaterial).color + .setHex(0xe8d9a8) + .lerp(new THREE.Color(0xe07a5a), danger * 0.5); + this.guardMesh.position.copy(this.veg.position); + this.guardMesh.position.z += len * 0.5; + } + + private updateHud(): void { + const s = this.session!; + const stub = s.length <= 0.04 ? 'gone' : s.length < GUARD_FLOOR ? 'a stub' : s.length < DANGER_FROM ? 'short' : 'long'; + this.stateEl.textContent = `${s.slices} slices · ${stub} left · guard ${s.guard ? 'ON' : 'off'}${s.nicks ? ` · ${s.nicks} nick${s.nicks > 1 ? 's' : ''}` : ''} · ${Math.round(s.seconds)}s`; + if (!this.wordEl.textContent?.includes('FOUND YOU')) { + this.wordEl.textContent = mandolineWord(s); + this.wordEl.style.color = ''; + } + } + + result(): ReturnType { + return mandolineResult(this.session!); + } +} diff --git a/src/sim/mandoline.ts b/src/sim/mandoline.ts new file mode 100644 index 0000000..65a947b --- /dev/null +++ b/src/sim/mandoline.ts @@ -0,0 +1,119 @@ +import { Rng } from '../core/rng'; + +/** + * THE MANDOLINE — the machine that makes evenness free and charges you for it + * somewhere else. + * + * A knife makes you earn every slice; the mandoline hands you a perfect one + * every stroke. So evenness is not the game here. The game is the LAST THIRD + * of the vegetable, where your knuckles get close to the blade and the only + * thing between them is a guard that wastes what it protects. + * + * Bare-handed you can shave the thing down to almost nothing — best yield, + * and perfectly safe while it is still long. The danger is not a dice roll + * you were always going to lose: it is zero above a comfortable stub and + * climbs steeply below it. Careful play NEVER bleeds. Greed bleeds. + * + * The guard cannot grip a stub, so it stops you early and leaves waste on the + * board. That is the trade, and it is the whole station: yield against skin. + */ + +/** Remaining length below which bare knuckles start meeting the blade. */ +export const DANGER_FROM = 0.34; +/** The guard cannot hold anything shorter than this. */ +export const GUARD_FLOOR = 0.3; +/** How much length one stroke shaves off. */ +export const STROKE_TAKE = 0.055; +/** Handling overhead the guard adds to every stroke, in seconds. */ +export const GUARD_COST = 0.55; + +export interface MandolineSession { + /** 1 = a whole vegetable, 0 = nothing left. */ + length: number; + slices: number; + guard: boolean; + /** Times the blade found a knuckle. He counts these. */ + nicks: number; + /** True once you have bled — the board is not clean any more. */ + bled: boolean; + /** Strokes refused because the guard had nothing to hold. */ + refused: number; + seconds: number; + rng: Rng; +} + +export function newMandolineSession(seed = 20260726): MandolineSession { + return { length: 1, slices: 0, guard: false, nicks: 0, bled: false, refused: 0, seconds: 0, rng: new Rng(seed) }; +} + +export function setGuard(s: MandolineSession, on: boolean): void { + s.guard = on; +} + +/** Chance this stroke finds a knuckle. Zero until the stub gets short. */ +export function riskOf(s: MandolineSession): number { + if (s.guard) return 0; // the guard is not a probability, it is a fact + if (s.length >= DANGER_FROM) return 0; + const into = (DANGER_FROM - s.length) / DANGER_FROM; + return Math.min(0.55, into * into * 0.85); +} + +export interface StrokeResult { + sliced: boolean; + nicked: boolean; + refused: boolean; +} + +/** One pass across the blade. */ +export function stroke(s: MandolineSession): StrokeResult { + if (s.length <= 0.04) return { sliced: false, nicked: false, refused: true }; + if (s.guard && s.length < GUARD_FLOOR) { + // Not a failure, a limit: the guard's teeth have nothing left to bite. + s.refused++; + return { sliced: false, nicked: false, refused: true }; + } + const risk = riskOf(s); + s.seconds += s.guard ? GUARD_COST : 0; + if (risk > 0 && s.rng.next() < risk) { + s.nicks++; + s.bled = true; + // A nick costs you the stroke and a great deal of time. It does not end + // the station — plenty of cooks keep going. He notices that too. + s.seconds += 6; + return { sliced: false, nicked: true, refused: false }; + } + s.length = Math.max(0, s.length - STROKE_TAKE); + s.slices++; + return { sliced: true, nicked: false, refused: false }; +} + +export function mandolineStep(s: MandolineSession, dt: number): void { + s.seconds += dt; +} + +export interface MandolineResult { + slices: number; + /** What is left on the board and going in the bin. */ + waste: number; + guard: boolean; + nicks: number; + bled: boolean; + seconds: number; +} + +export function mandolineResult(s: MandolineSession): MandolineResult { + return { slices: s.slices, waste: s.length, guard: s.guard, nicks: s.nicks, bled: s.bled, seconds: s.seconds }; +} + +/** The board, out loud. It warns you — once the stub is short, every time. */ +export function mandolineWord(s: MandolineSession): string { + if (s.bled && s.nicks > 0 && s.length < DANGER_FROM) return 'you are bleeding on a stub. put the guard on'; + if (s.length <= 0.04) return 'nothing left to slice'; + if (s.guard) { + if (s.length < GUARD_FLOOR) return 'the guard has nothing left to hold — take it off, or stop'; + return 'guarded. slower, safer, and it will make you stop early'; + } + if (s.length >= DANGER_FROM) return 'long and steady. your hand is nowhere near it'; + if (s.length > DANGER_FROM * 0.6) return 'getting short — the blade is closer than it was'; + return 'that is a STUB and your knuckles are on it'; +}