diff --git a/src/dev.ts b/src/dev.ts index 4c43d2d..f939f7f 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -251,6 +251,39 @@ export function installDevHarness(app: App, game: Game): void { return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f }; }, + /** + * Drive a whole `wedges(n)` pattern: ceil(n/2) diametral cuts spaced evenly + * around the half-turn, which fall out as n even radial wedges. `wobble` + * throws the spacing off to make a lottery on purpose. Returns prepStats. + */ + wedges(opts: { n?: number; dy?: number; wobble?: number } = {}) { + const { n = 6, dy = 0.05, wobble = 0 } = opts; + const k = Math.ceil(n / 2); + // aimPos reads as a fraction of the half-turn, so evenly spaced positions + // in [-0.5, 0.5) give angles 0, π/k, 2π/k … — even wedges, CV → 0. + for (let i = 0; i < k; i++) harness.chop({ pos: i / k - 0.5, dy, wobble }); + return harness.prepStats(); + }, + + /** + * Drive a whole `dice(n)` pattern: two rotated passes of n-1 evenly spaced + * parallel cuts. The sim banks the first pass and rotates the board itself, + * so this is just 2·(n-1) chops. Evenly spaced → n×n even cells, CV → 0. + */ + dice(opts: { n?: number; dy?: number; wobble?: number } = {}) { + const { n = 4, dy = 0.05, wobble = 0 } = opts; + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < n - 1; i++) harness.chop({ pos: (i + 1) / n - 0.5, dy, wobble }); + } + return harness.prepStats(); + }, + + /** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */ + handoff() { + tap('Enter'); + run(3); + }, + /** Drag the cloth across the board `passes` times. */ wipe(passes = 1, vAt = 0.5) { const bw = 4.6; diff --git a/src/game/game.ts b/src/game/game.ts index 1d07544..5d8e80a 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -13,7 +13,7 @@ import { audio } from '../core/audio'; import { coolStep } from '../sim/toasting'; import { SPREADS } from '../sim/spreads'; import { judge, type Grade } from './judging'; -import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders'; +import { DAYS, proceduralOrder, nameBrowning, prepAsk, type Order, type PrepStep } from './orders'; import { el, Panel } from '../ui/hud'; import { Title } from '../ui/title'; @@ -37,6 +37,8 @@ export class Game { private prep: PrepView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; + /** Remaining prep steps for the current order, drained as each hands off. */ + private prepQueue: PrepStep[] = []; /** The pre-toast drawer trip on loaf days: you're after a bread knife. */ private knifeTrip = false; private rng = new Rng(20260716); @@ -135,25 +137,61 @@ export class Game { } /** - * Send the player to the prep bench. Orders with prep steps route through - * here (M12+); the harness drives it directly for verification. + * Send the player to the prep bench in isolation. The harness drives this + * for verification (`t.prep`); when it hands off (ENTER) it drops straight + * into the kitchen, no toast flow around it. */ startPrep(ingredient: IngredientId, pattern: CutPattern, knife: ToolId = 'dinner_knife', ask = ''): void { - this.prep.reset(ingredient, knife, pattern, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`); + this.enterPrep(ingredient, pattern, knife, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`, (result) => { + this.prepResult = result; + this.kitchen.root.visible = true; + this.app.setView(this.kitchen); + this.kitchen.flash('prep done — the bench remembers'); + }); + } + + /** + * Put the prep bench on screen for one cut, and route wherever `onComplete` + * says once ENTER hands the board off. The single door into the prep scene — + * both the standalone harness path and the real day flow go through here. + */ + private enterPrep( + ingredient: IngredientId, + pattern: CutPattern, + knife: ToolId, + ask: string, + onComplete: (result: PrepResult) => void, + ): void { + this.prep.reset(ingredient, knife, pattern, ask); this.kitchen.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = true; this.app.setView(this.prep); + this.ticket.show(); this.prep.onDone = (result) => { - this.prepResult = result; this.prep.root.visible = false; - this.kitchen.root.visible = true; - this.app.setView(this.kitchen); - this.kitchen.flash('prep done — the bench remembers'); + onComplete(result); }; } + /** + * Drain the current order's prep queue one bench trip at a time, folding each + * result into `prepResult`, then fall through to the toaster. Orders ship with + * a single step today; the merge keeps a two-step order honest anyway. + */ + private runNextPrep(): void { + const step = this.prepQueue.shift(); + if (!step) { + this.enterToastFlow(); + return; + } + this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { + this.prepResult = mergePrep(this.prepResult, result); + this.runNextPrep(); + }); + } + /** Test hook: jump straight to a day, through the real order table. */ setDay(day: number): void { this.save.day = day; @@ -214,6 +252,7 @@ export class Game { this.order = o; this.orderSeconds = 0; this.prepResult = null; + this.prepQueue = o.prep ? [...o.prep] : []; this.timing = true; const s = this.judgeView.release(); @@ -223,7 +262,20 @@ export class Game { this.judgeView.root.visible = false; this.renderTicket(); - if (o.fromLoaf) { + // Prep comes before the toaster — dice the onion, wedge the tomato — then + // the toast flow picks up where it always did. Orders without prep skip + // straight to it. (The kitchen already reads 'ready' after applyOrder, so + // no toast cools while you're at the bench.) + if (this.prepQueue.length) { + this.runNextPrep(); + return; + } + this.enterToastFlow(); + } + + /** The toaster half of the day: the loaf knife-trip, or straight to the bench. */ + private enterToastFlow(): void { + if (this.order.fromLoaf) { // Bakery bread: first find something to slice it with. this.knifeTrip = true; this.drawer.reset('bread_knife'); @@ -238,6 +290,7 @@ export class Game { } this.drawer.root.visible = false; this.slicer.root.visible = false; + this.prep.root.visible = false; this.app.setView(this.kitchen); this.kitchen.root.visible = true; this.ticket.show(); @@ -251,6 +304,7 @@ export class Game { el('div', 'ticket-text', this.ticketEl, `“${o.text}”`); const spec = el('div', 'ticket-spec', this.ticketEl); el('span', 'chip brand', spec, o.brand); + if (o.prep) for (const step of o.prep) el('span', 'chip warn', spec, prepAsk(step)); if (o.fromLoaf) el('span', 'chip warn', spec, `${o.cutClass} — slice it yourself`); el('span', 'chip', spec, o.browningName); el('span', 'chip', spec, `${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}`); @@ -293,6 +347,30 @@ export class Game { } } +/** + * Fold a second prep step's result into the running one. A one-step order (all + * of them, today) just returns `b`. For two steps the judge sees the worst cut + * (highest CV), the summed mess, and every piece — nothing gets silently + * dropped when the day grows a second bench trip. + */ +function mergePrep(a: PrepResult | null, b: PrepResult): PrepResult { + if (!a) return b; + return { + cv: Math.max(a.cv ?? 0, b.cv ?? 0), + pieces: (a.pieces ?? 0) + (b.pieces ?? 0), + patternName: b.patternName ?? a.patternName, + slips: (a.slips ?? 0) + (b.slips ?? 0), + bench: + a.bench && b.bench + ? { + mass: a.bench.mass + b.bench.mass, + spreadFrac: Math.max(a.bench.spreadFrac, b.bench.spreadFrac), + solids: a.bench.solids + b.bench.solids, + } + : (b.bench ?? a.bench), + }; +} + function loadSave(): Save { try { const raw = localStorage.getItem(SAVE_KEY); diff --git a/src/game/orders.ts b/src/game/orders.ts index 68d0bd0..e642058 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -14,6 +14,25 @@ export interface PrepStep { kind: 'cut'; ingredient: IngredientId; pattern: CutPattern; + /** The knife the bench hands you for this step. Prep knives don't come from + * the drawer (that trip is for the spread tool); the order supplies them. */ + knife?: ToolId; +} + +/** The ticket's short line for a prep step: "dice the onion", "6 wedges of tomato". */ +export function prepAsk(step: PrepStep): string { + const name = step.ingredient.replace(/_/g, ' '); + const p = step.pattern; + switch (p.kind) { + case 'halve': + return `halve the ${name}`; + case 'slices': + return `${p.n} slices of ${name}`; + case 'wedges': + return `${p.n} wedges of ${name}`; + case 'dice': + return `dice the ${name}`; + } } export interface Order { @@ -196,6 +215,40 @@ export const DAYS: Order[] = [ who: 'the inspector himself', text: 'The bakery loaf. Slice it yourself — a DOORSTOP, and I want the faces parallel. Then golden, butter, normal.', }, + { + day: 10, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.5, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.6, + who: 'Deidre', + text: 'Dice me an onion first — small and square, mind the stem — then golden white, butter, normal.', + // The onion is the dice's showcase: layered flesh means a bad cut explodes + // the piece CV. The dinner knife bites clean if you saw instead of crush. + prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4 }, knife: 'dinner_knife' }], + }, + { + day: 11, + brand: 'Crustworthy', + bread: 'white', + spread: 'butter', + amount: 'thin', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.55, + who: 'a regular', + text: 'Six wedges of tomato — even ones — and a scrape of butter on golden white.', + // Tomato = the wet, slippery showcase for wedges: press without sawing and + // it skates and hoses the board; saw and it gives clean radial cuts. + prep: [{ kind: 'cut', ingredient: 'tomato', pattern: { kind: 'wedges', n: 6 }, knife: 'dinner_knife' }], + }, ]; /** Days beyond the script — keep going, with everything cranked. */