THE MANDOLINE: day 28 — yield against skin

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 21:46:47 +10:00
parent cebff4bd69
commit 47f6c39e2b
9 changed files with 584 additions and 8 deletions

View File

@ -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 }; 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, /** Play the day-27 curry with REAL input: pour each spice into its band,
* taste, WAIT for the chili to bloom, taste again, serve. */ * taste, WAIT for the chili to bloom, taste again, serve. */
async spiceDay() { async spiceDay() {
@ -1591,6 +1626,7 @@ export function installDevHarness(app: App, game: Game): void {
25: () => harness.potDay(), 25: () => harness.potDay(),
26: () => harness.steamDay(), 26: () => harness.steamDay(),
27: () => harness.spiceDay(), 27: () => harness.spiceDay(),
28: () => harness.mandolineDay(),
}; };
(harness as unknown as { demos: typeof demos }).demos = demos; (harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => { (harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -16,6 +16,7 @@ import { FryerPotView } from '../scenes/fryerpot';
import { PotView } from '../scenes/pot'; import { PotView } from '../scenes/pot';
import { SteamerView } from '../scenes/steamer'; import { SteamerView } from '../scenes/steamer';
import { SpiceView } from '../scenes/spice'; import { SpiceView } from '../scenes/spice';
import { MandolineView } from '../scenes/mandoline';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly'; import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting'; import type { CutPattern } from '../sim/cutting';
@ -24,7 +25,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock'; import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting'; import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; 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 { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -93,6 +94,7 @@ export class Game {
private pot: PotView; private pot: PotView;
private steamer: SteamerView; private steamer: SteamerView;
private spice: SpiceView; private spice: SpiceView;
private mandoline: MandolineView;
private assembly: AssemblyView; private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */ /** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null; private prepResult: PrepResult | null = null;
@ -150,6 +152,7 @@ export class Game {
this.pot = new PotView(app); this.pot = new PotView(app);
this.steamer = new SteamerView(app); this.steamer = new SteamerView(app);
this.spice = new SpiceView(app); this.spice = new SpiceView(app);
this.mandoline = new MandolineView(app);
this.assembly = new AssemblyView(app); this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root); app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root); app.scene.add(this.judgeView.root);
@ -169,6 +172,7 @@ export class Game {
app.scene.add(this.pot.root); app.scene.add(this.pot.root);
app.scene.add(this.steamer.root); app.scene.add(this.steamer.root);
app.scene.add(this.spice.root); app.scene.add(this.spice.root);
app.scene.add(this.mandoline.root);
app.scene.add(this.assembly.root); app.scene.add(this.assembly.root);
this.judgeView.root.visible = false; this.judgeView.root.visible = false;
this.drawer.root.visible = false; this.drawer.root.visible = false;
@ -187,6 +191,7 @@ export class Game {
this.pot.root.visible = false; this.pot.root.visible = false;
this.steamer.root.visible = false; this.steamer.root.visible = false;
this.spice.root.visible = false; this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
@ -362,6 +367,9 @@ export class Game {
get eggBenchView(): EggBenchView { get eggBenchView(): EggBenchView {
return this.eggBench; return this.eggBench;
} }
get mandolineView(): MandolineView {
return this.mandoline;
}
get spiceView(): SpiceView { get spiceView(): SpiceView {
return this.spice; return this.spice;
} }
@ -554,6 +562,7 @@ export class Game {
this.pot.root.visible = false; this.pot.root.visible = false;
this.steamer.root.visible = false; this.steamer.root.visible = false;
this.spice.root.visible = false; this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
} }
@ -779,13 +788,14 @@ export class Game {
if (o.pot) return this.enterPot(); if (o.pot) return this.enterPot();
if (o.steamer) return this.enterSteamer(); if (o.steamer) return this.enterSteamer();
if (o.spice) return this.enterSpice(); if (o.spice) return this.enterSpice();
if (o.mandoline) return this.enterMandoline();
this.enterToastFlow(); this.enterToastFlow();
} }
/** The one-line shareable result for the current order's verdict. */ /** The one-line shareable result for the current order's verdict. */
private shareLine(v: Verdict): string { private shareLine(v: Verdict): string {
const o = this.order; 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}`; const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`; return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
} }
@ -892,6 +902,11 @@ export class Game {
this.enterSpice(); this.enterSpice();
return; return;
} }
// The mandoline: yield against skin.
if (this.order.mandoline) {
this.enterMandoline();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble. // A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) { if (this.order.poach) {
this.enterPoach(); this.enterPoach();
@ -1100,6 +1115,34 @@ export class Game {
} }
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ /** 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<MandolineView['result']>): 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 { private enterSpice(): void {
const sp = this.order.spice!; const sp = this.order.spice!;
this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base); this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base);

View File

@ -17,6 +17,7 @@ import type { PoachResult } from '../sim/poach';
import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot'; import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot';
import type { SteamerResult } from '../sim/steamer'; import type { SteamerResult } from '../sim/steamer';
import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor'; import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor';
import type { MandolineResult } from '../sim/mandoline';
export interface Criterion { export interface Criterion {
key: string; key: string;
@ -29,7 +30,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders. /** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */ * 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. */ /** 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: [] }; 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 // 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. // "it needs something" is not a note, and he has never once given one.

View File

@ -130,6 +130,8 @@ export interface Order {
steamer?: { veg: string }; steamer?: { veg: string };
/** THE SPICE RACK: six axes, no subtract, and a chili that lies. */ /** THE SPICE RACK: six axes, no subtract, and a chili that lies. */
spice?: { dish: string; target: import('../sim/flavor').Target; base?: Partial<Record<import('../sim/flavor').Axis, number>> }; spice?: { dish: string; target: import('../sim/flavor').Target; base?: Partial<Record<import('../sim/flavor').Axis, number>> };
/** THE MANDOLINE: yield against skin. The guard wastes what it protects. */
mandoline?: { slices: number };
} }
export const BROWNING_NAMES: [number, string][] = [ export const BROWNING_NAMES: [number, string][] = [
@ -580,6 +582,21 @@ export const DAYS: Order[] = [
base: { aroma: 0.06, bitter: 0.04 }, 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. */ /** 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.925) return proceduralFryerPot(day, rand);
if (roll < 0.935) return proceduralPot(day, rand); if (roll < 0.935) return proceduralPot(day, rand);
if (roll < 0.945) return proceduralSteamer(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.945) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand); if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand); return proceduralFridge(day, rand);
@ -662,6 +680,7 @@ export function stationOf(o: Order): string | null {
if (o.pot) return 'pot'; if (o.pot) return 'pot';
if (o.steamer) return 'steamer'; if (o.steamer) return 'steamer';
if (o.spice) return 'spice'; if (o.spice) return 'spice';
if (o.mandoline) return 'mandoline';
return null; return null;
} }
@ -698,6 +717,20 @@ function proceduralSpice(day: number, rand: () => number): Order {
return o; 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 = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)]; const pickOf = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)];
/** The toast fields every Order carries; station days never read most of them. */ /** The toast fields every Order carries; station days never read most of them. */

View File

@ -140,6 +140,11 @@ export class AirFryerView implements View {
this.root.add(wing); this.root.add(wing);
this.wingMeshes.push(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.askEl.textContent = askText;
this.cardEl.textContent = cardText; this.cardEl.textContent = cardText;
this.hintEl.textContent = 'DRAG — give them AIR · WHEEL — knob · SPACE — drawer in/out · hold F, release — SHAKE · ENTER serves'; this.hintEl.textContent = 'DRAG — give them AIR · WHEEL — knob · SPACE — drawer in/out · hold F, release — SHAKE · ENTER serves';

View File

@ -184,6 +184,11 @@ export class FryerPotView implements View {
this.chipMeshes.push(chip); this.chipMeshes.push(chip);
this.root.add(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.askEl.textContent = askText;
this.cardEl.textContent = cardText; 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'; this.hintEl.textContent = 'WHEEL — flame · SPACE — basket in / out to the rack · keep your HAND off the pot when it spits · ENTER serves';

View File

@ -195,7 +195,7 @@ export class JudgeView implements View {
// Grill and bruschetta days don't have a browning/spread to name — show who // Grill and bruschetta days don't have a browning/spread to name — show who
// asked and what for, not the unread toast defaults. // asked and what for, not the unread toast defaults.
// The dish itself, in miniature, beside the ask. // 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(); this.orderEl.parentElement?.querySelector('.judge-hero')?.remove();
if (heroKey) { if (heroKey) {
const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement; 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`); hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`);
this.orderEl.parentElement?.insertBefore(hero, this.orderEl); 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` ? `Day ${order.day} · ${order.who} asked for it SEASONED`
: order.steamer : order.steamer
? `Day ${order.day} · ${order.who} asked for the greens` ? `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; // Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had. // a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some( 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<string, string> = { const headers: Record<string, string> = {
prep: 'THE PREP', prep: 'THE PREP',
@ -255,9 +257,10 @@ export class JudgeView implements View {
pot: 'THE POT', pot: 'THE POT',
steam: 'THE STEAMER', steam: 'THE STEAMER',
spice: 'THE SEASONING', spice: 'THE SEASONING',
blade: 'THE BLADE',
bench: 'THE BENCH', bench: 'THE BENCH',
}; };
const groupRank: Record<string, number> = { 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<string, number> = { 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; let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped const ordered = grouped

264
src/scenes/mandoline.ts Normal file
View File

@ -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<typeof mandolineResult>) => 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<typeof mandolineResult> {
return mandolineResult(this.session!);
}
}

119
src/sim/mandoline.ts Normal file
View File

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