import type { App } from '../core/app'; import { Rng } from '../core/rng'; import { KitchenView } from '../scenes/kitchen'; import { SlicerView } from '../scenes/slicer'; import { PrepView } from '../scenes/prep'; import { JuiceView } from '../scenes/juice'; import { OvenView } from '../scenes/oven'; import { GrillView } from '../scenes/grill'; import { PanView } from '../scenes/pan'; import { FridgeView } from '../scenes/fridge'; import { SteakBoardView } from '../scenes/steakboard'; import { EggBenchView } from '../scenes/eggbench'; import { PoachView } from '../scenes/poach'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; 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, 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'; 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, prepAsk, type Order, type PrepStep } from './orders'; import { el, Panel } from '../ui/hud'; import { Title } from '../ui/title'; const SAVE_KEY = 'toastsim.save'; /** Reopening the fridge mid-service costs you — the planning has teeth. */ const TEMP_TOGGLE_COST = 8; interface Save { day: number; /** The furthest day ever reached — the day-select grid unlocks up to here, * so replaying an old day for the S never locks the future away. */ maxDay: number; total: number; best: Record; /** The cold store, persisted: what you stocked on a fridge day is still there * (and older) when a later cook reaches in for it. */ fridge?: StoredItemSave[]; } /** * The arcade loop: an order, a slice, a verdict, the next order. The day counter * is the whole progression — everything else is the orders getting nastier. */ export class Game { private kitchen: KitchenView; private judgeView: JudgeView; private drawer: DrawerView; private slicer: SlicerView; private prep: PrepView; private juice: JuiceView; private oven: OvenView; private grill: GrillView; private pan: PanView; private fridge: FridgeView; private steakBoard: SteakBoardView; private eggBench: EggBenchView; private poach: PoachView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; /** What the juice station reported, if the order sent you there. */ private juiceResult: JuiceResult | null = null; /** The temperature clock for the current bruschetta order (brie/parmesan). */ private temp: TempClock | null = null; /** The last bruschetta verdict, for the harness to read. */ private lastBruschetta: { result: BruschettaResult; verdict: Verdict } | 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); private order!: Order; /** Sim-time service clock — wall clock lies whenever the tab throttles. */ private orderSeconds = 0; private timing = false; private save: Save; private ticket: Panel; private ticketEl!: HTMLElement; /** The fridge/bench toggle, one row per perishable, shown on bruschetta days. */ private tempPanel: Panel; private tempRows: { id: IngredientId; btn: HTMLButtonElement }[] = []; constructor(private app: App) { this.save = loadSave(); this.kitchen = new KitchenView(app, 3); this.judgeView = new JudgeView(app); this.drawer = new DrawerView(app, 7); this.slicer = new SlicerView(app); this.prep = new PrepView(app); this.juice = new JuiceView(app); this.oven = new OvenView(app); this.grill = new GrillView(app); this.pan = new PanView(app); this.fridge = new FridgeView(app); this.steakBoard = new SteakBoardView(app); this.eggBench = new EggBenchView(app); this.poach = new PoachView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); app.scene.add(this.drawer.root); app.scene.add(this.slicer.root); app.scene.add(this.prep.root); app.scene.add(this.juice.root); app.scene.add(this.oven.root); app.scene.add(this.grill.root); app.scene.add(this.pan.root); app.scene.add(this.fridge.root); app.scene.add(this.steakBoard.root); app.scene.add(this.eggBench.root); app.scene.add(this.poach.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; this.juice.root.visible = false; this.oven.root.visible = false; this.grill.root.visible = false; this.pan.root.visible = false; this.fridge.root.visible = false; this.steakBoard.root.visible = false; this.eggBench.root.visible = false; this.poach.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); this.ticketEl = el('div', 'ticket-body', this.ticket.root); this.tempPanel = new Panel('temp-toggle'); this.tempPanel.hide(); // toast -> drawer -> spread. The drawer sits AFTER the toaster on purpose: // its cost is your toast going cold, and cold toast is what makes butter // impossible. Dawdling here is the punishment. // After the toast lands: a bruschetta order goes to the oven instead of the // spread drawer (the garlic rub replaces the spread), everything else opens // the drawer as it always did. this.kitchen.onPopped = () => { if (this.order.bruschetta) this.enterOven(); else this.openDrawer(); }; this.kitchen.onServed = () => this.serve(); this.drawer.onPicked = (tool) => this.closeDrawer(tool); this.slicer.onSliced = (cut) => { this.kitchen.applyCut(cut); this.slicer.root.visible = false; this.kitchen.root.visible = true; this.app.setView(this.kitchen); this.ticket.show(); this.kitchen.flash(cut.knife === 'the_best_thing' ? 'cut with The Best Thing' : 'now toast it'); }; this.judgeView.onNext = () => { // Commit any fridge consumption atomically with the day advance: a reload // before this point re-derives the same fetch, so the cap can't be dodged. if (this.pendingFridge) { this.save.fridge = this.pendingFridge; this.pendingFridge = null; } this.save.day++; this.save.maxDay = Math.max(this.save.maxDay, this.save.day); writeSave(this.save); this.beginDay(); }; // Cooling runs at the app level, not in the kitchen's update: the kitchen // stops updating the moment the drawer opens, and "your toast goes cold // while you rummage" is the entire reason the drawer exists. app.onTick((dt) => { if (this.timing) this.orderSeconds += dt; // The temperature clock drifts on real service time, whatever screen you're // on: the brie warms toward spreadable, the parmesan warms toward smearing. if (this.temp && this.timing) { this.temp.step(dt); this.refreshTempToggle(); } // On the knife trip there's no toast to cool yet — the timer is the // customer's patience instead. if (this.knifeTrip) { this.drawer.warmth = Math.max(0, 1 - this.orderSeconds / 150); return; } const k = this.kitchen; if (k.phase === 'ready' || k.phase === 'toasting') return; coolStep(k.currentSlice, dt); this.drawer.warmth = k.currentSlice.warmth; }); // Sit on the title until the player clicks — which also gives us the user // gesture the browser wants before any audio is allowed to make a sound. this.kitchen.root.visible = false; this.ticket.hide(); new Title(this.save, (day) => { audio.resume(); if (day !== undefined && day !== this.save.day) { // Replaying an old day: move the cursor there, but maxDay holds the // real progress so the ledger never shrinks. this.save.day = day; writeSave(this.save); } this.beginDay(); }); } get currentOrder(): Order { return this.order; } get kitchenView(): KitchenView { return this.kitchen; } get judgeScreen(): JudgeView { return this.judgeView; } get drawerView(): DrawerView { return this.drawer; } get slicerView(): SlicerView { return this.slicer; } get prepView(): PrepView { return this.prep; } get juiceView(): JuiceView { return this.juice; } get ovenView(): OvenView { return this.oven; } get grillView(): GrillView { return this.grill; } get panView(): PanView { return this.pan; } get fridgeView(): FridgeView { return this.fridge; } get steakBoardView(): SteakBoardView { return this.steakBoard; } get eggBenchView(): EggBenchView { return this.eggBench; } get poachView(): PoachView { return this.poach; } get assemblyView(): AssemblyView { return this.assembly; } get tempClock(): TempClock | null { return this.temp; } /** The last bruschetta verdict + inputs, for the harness. */ get lastBruschettaStats() { return this.lastBruschetta; } /** Flip a perishable fridge↔bench. Revisiting the fridge costs service time. */ toggleTemp(id: IngredientId): void { if (!this.temp) return; this.temp.toggle(id); // Opening the fridge again mid-service is not free. this.orderSeconds += TEMP_TOGGLE_COST; this.refreshTempToggle(); } /** * 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.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.juice.root.visible = false; this.prep.root.visible = true; this.app.setView(this.prep); this.ticket.show(); this.prep.onDone = (result) => { this.prep.root.visible = false; onComplete(result); }; } /** * The juice station's door, same shape as enterPrep: reset the reamer for one * orange, take the screen, and route wherever `onComplete` says once ENTER * serves the glass. The bench it leaves behind folds into the same prepResult * path a cut order uses, so The Bench is one row however you dirtied it. */ private enterJuice( step: Extract, onComplete: (result: JuiceResult) => void, ): void { const halve = step.halve ?? { offset: 0, wedge: 0 }; this.juice.reset(step.juicer, halve, prepAsk(step), step.seeds); this.kitchen.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; this.juice.root.visible = true; this.app.setView(this.juice); this.ticket.show(); this.juice.onDone = (result) => { this.juice.root.visible = false; onComplete(result); }; } /** * The oven's door, same shape as enterPrep/enterJuice: a tray of tomato halves, * the roast Field, and a hand-off to the assembly bench once ENTER pulls them. */ private enterOven(): void { const b = this.order.bruschetta!; this.oven.reset(b.roastHalves, 6, 'roast the tomatoes — blistered, not sauce', b.oven); this.hideAllScenes(); this.oven.root.visible = true; this.app.setView(this.oven); this.ticket.show(); this.oven.onDone = (roast) => { this.oven.root.visible = false; this.enterAssembly(roast); }; } /** * The assembly bench, primed with what the upstream stations left: the cut's * thickness (the doorstop's sog defence), how wet the tomatoes came off the tray * (a collapsed rack soaks faster), and how ready the brie is off the temp clock. */ private enterAssembly(roast: RoastResult): void { const slice = this.kitchen.currentSlice; const thickness = slice.cut?.thickness ?? 0.24; // Collapsed halves went on wetter — the moisture they were holding soaks in. const tomatoMoisture = 0.95 + roast.collapseFrac * 0.6; const cheeseReady = this.temp ? this.temp.readiness('brie').score : 1; this.assembly.reset({ sliceThickness: thickness, tomatoMoisture, cheeseReady, askText: 'top it even, mind the sog' }); this.hideAllScenes(); this.assembly.root.visible = true; this.app.setView(this.assembly); this.ticket.show(); this.assembly.onDone = (a) => { this.assembly.root.visible = false; this.serveBruschetta(roast, a); }; } /** Everything the three bruschetta stations measured, in one verdict. */ private serveBruschetta(roast: RoastResult, assembly: AssemblyResult): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const ov = this.temp ? this.temp.overall() : null; const result: BruschettaResult = { roast, assembly, temp: { score: ov ? ov.score : 1, worstName: ov ? ov.worst.name : '', worstWord: ov ? ov.word : 'fine', }, // The only mess this order makes: tomatoes you let slump to sauce are a // spill waiting to happen. A clean roast leaves a clean bench. bench: { mass: roast.collapseFrac * 3, spreadFrac: roast.collapseFrac * 0.5, solids: 0 }, }; const v = judgeBruschetta(slice, this.order, this.kitchen.toolId, seconds, result); this.lastBruschetta = { result, verdict: v }; this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); this.hideTempToggle(); 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(); } /** Hide every station root — the one place the bruschetta hand-offs go through. */ private hideAllScenes(): void { this.kitchen.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; this.juice.root.visible = false; this.oven.root.visible = false; this.grill.root.visible = false; this.pan.root.visible = false; this.fridge.root.visible = false; this.steakBoard.root.visible = false; this.eggBench.root.visible = false; this.poach.root.visible = false; this.assembly.root.visible = false; } /** * Drain the current order's prep queue one bench trip at a time, folding each * result into `prepResult`, then fall through to the toaster. A step is a cut * (the bench) or a juice (the reamer); both leave a bench, and the juice also * leaves a scored glass. Orders ship with a single step today; the merge keeps * a two-step order honest anyway. */ private runNextPrep(): void { const step = this.prepQueue.shift(); if (!step) { this.enterToastFlow(); return; } if (step.kind === 'juice') { this.enterJuice(step, (result) => { this.juiceResult = result; // The glass is scored on its own criteria; its spray joins the bench // through the same prepResult path a cut order uses. this.prepResult = mergePrep(this.prepResult, { bench: result.bench }); this.runNextPrep(); }); return; } this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.prepResult = mergePrep(this.prepResult, result); this.runNextPrep(); }); } /** Test hook: jump straight to a day, through the real order table. */ setDay(day: number): void { this.save.day = day; this.save.maxDay = Math.max(this.save.maxDay ?? day, day); writeSave(this.save); this.beginDay(); } async init(): Promise { await this.drawer.init(); } /** Fresh out of the toaster, and now you need something to spread with. */ private openDrawer(): void { this.drawer.reset(this.order.tool); this.drawer.setTimerLabel('your toast is going cold'); this.drawer.warmth = this.kitchen.currentSlice.warmth; this.kitchen.root.visible = false; this.drawer.root.visible = true; this.app.setView(this.drawer); this.ticket.hide(); } private closeDrawer(tool: ToolId): void { if (this.knifeTrip) { // Whatever you fished out is what you slice with. Good luck with the spoon. this.knifeTrip = false; this.drawer.root.visible = false; this.slicer.root.visible = true; const o = this.order; const [lo, hi] = o.cutBand ?? [0.16, 0.24]; this.slicer.reset( o.bread, tool, `${o.cutClass ?? 'regular cut'} — ${Math.round(lo * 110)}–${Math.round(hi * 110)}mm`, ); this.app.setView(this.slicer); this.ticket.show(); return; } this.kitchen.setTool(tool); this.drawer.root.visible = false; this.kitchen.root.visible = true; this.app.setView(this.kitchen); this.ticket.show(); this.kitchen.beginSpreading(); if (tool !== this.order.tool) { this.kitchen.flash(`you brought a ${TOOLS[tool].name.toLowerCase()}`); } } beginDay(): void { const day = this.save.day; // Procedural days are seeded BY the day, not by a running stream: a reload // re-deals the identical order (no reroll-scumming), and the harness can // reproduce any day by number. const dayRng = new Rng((day * 2654435761) >>> 0); const o = day <= DAYS.length ? DAYS[day - 1] : proceduralOrder(day, () => dayRng.next()); if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`; this.order = o; this.orderSeconds = 0; this.prepResult = null; this.juiceResult = null; this.panIngredient = null; // a fetched-mushroom day's ingredient never leaks forward this.lastBruschetta = null; this.prepQueue = o.prep ? [...o.prep] : []; this.timing = true; // The temperature clock: armed only on bruschetta days, tracking the two // cheeses from order start so planning (brie out early, parmesan in late) is // a real choice with a real deadline. if (o.bruschetta) { this.temp = new TempClock(o.bruschetta.perishables); this.showTempToggle(); } else { this.temp = null; this.hideTempToggle(); } const s = this.judgeView.release(); if (s) this.kitchen.root.add(s.mesh); this.kitchen.applyOrder(o); this.judgeView.root.visible = false; this.renderTicket(); // 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; } // A grill day goes straight outdoors to the coals — no toaster at all. if (this.order.grill) { this.enterGrill(); return; } // A pan day goes to the hob — butter, foam, flip, serve. if (this.order.pan) { this.enterPan(); return; } // A fridge day: put the delivery away, three days pass, the inspector looks. if (this.order.fridge) { this.enterFridge(); return; } // A steak day: sear on the pan, then rest + cut on the board. if (this.order.steak) { this.enterSteak(); return; } // An egg day: the carton, the glass, the bowl, and your nerve. if (this.order.eggs) { this.enterEggs(); return; } // A poach day: the shiver, the vortex, the drop, the wobble. if (this.order.poach) { this.enterPoach(); return; } this.enterToastFlow(); } /** The poach pot: spin it, drop it, lift it, drain it, serve it. */ private enterPoach(): void { this.poach.reset(this.order.poach!.fuel); this.hideAllScenes(); this.poach.root.visible = true; this.app.setView(this.poach); this.ticket.show(); this.poach.onDone = (result) => { this.poach.root.visible = false; this.servePoach(result); }; } private servePoach(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgePoach(result, this.order, seconds); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** The egg bench: float-test the suspects, crack, separate, serve. */ private enterEggs(): void { const e = this.order.eggs!; this.eggBench.reset(e.need, e.cartonAge); this.hideAllScenes(); this.eggBench.root.visible = true; this.app.setView(this.eggBench); this.ticket.show(); this.eggBench.onDone = (result) => { this.eggBench.root.visible = false; this.serveEggs(result); }; } private serveEggs(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeEggs(result, this.order, seconds); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** The rib eye: sear it on the pan, carry the sear's doneness to the resting * board, rest + cut, serve. Pan → board → judge, like the bruschetta chain. */ private enterSteak(): void { const st = this.order.steak!; this.pan.reset('rib_eye', st.fuel, 'sear the rib eye — both faces, foam the butter'); this.hideAllScenes(); this.pan.root.visible = true; this.app.setView(this.pan); this.ticket.show(); this.pan.onDone = (panResult) => { this.pan.root.visible = false; // The two seared faces stand in for the interior doneness carried to the board. const cookDoneness = (panResult.downMean + panResult.upMean) / 2; this.steakBoard.reset(cookDoneness, st.target, st.grain, panResult.sideGap); this.hideAllScenes(); this.steakBoard.root.visible = true; this.app.setView(this.steakBoard); this.ticket.show(); this.steakBoard.onDone = (r) => { this.steakBoard.root.visible = false; this.serveSteak(r); }; }; } private serveSteak(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeSteak(result, this.order, seconds); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** The cold-chain day: stock the fridge right, age it, let the inspector look. */ private enterFridge(): void { this.fridge.reset(); this.hideAllScenes(); this.fridge.root.visible = true; this.app.setView(this.fridge); this.ticket.show(); this.fridge.onDone = (result) => { this.fridge.root.visible = false; this.serveFridge(result); }; } private serveFridge(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; // Persist the stock exactly as you left it — a later cook reaches into THIS // fridge, at whatever freshness your storage (and the days) left each item. this.save.fridge = serializeStore(result.store); const v = judgeFridge(result, this.order, seconds); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** The pan day (M17): a fuel-aware hob — butter it, foam it, flip it, serve. */ /** When a pan day fetches its food from the fridge, what came out. */ private panIngredient: { quality: number; word: string } | null = null; /** The fridge stock AFTER this day's fetch, held until the day actually advances * (committed in onNext) — so a mid-service reload re-fetches the same item and * can't dodge the rot-cap or silently lose the ingredient. */ private pendingFridge: StoredItemSave[] | null = null; private enterPan(): void { const p = this.order.pan!; this.panIngredient = null; this.pendingFridge = null; if (p.fromFridge && this.save.fridge?.length) { // Reach into the fridge you stocked: FIFO, at whatever freshness your storage // left it. The consumed stock is HELD (pendingFridge), not written yet. const store = restoreStore(this.save.fridge); const got = fetchStock(store, p.food); if (got) { const q = usableQuality(got); this.panIngredient = { quality: q.quality, word: q.word }; this.pendingFridge = serializeStore(store); } } this.pan.reset(p.food, p.fuel); this.hideAllScenes(); this.pan.root.visible = true; this.app.setView(this.pan); this.ticket.show(); this.pan.onDone = (result) => { this.pan.root.visible = false; this.servePan(result); }; } private servePan(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgePan(result, this.order, seconds, this.panIngredient ?? undefined); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ private enterGrill(): void { this.grill.reset(this.order.grill!); this.hideAllScenes(); this.grill.root.visible = true; this.app.setView(this.grill); this.ticket.show(); this.grill.onDone = (result) => { this.grill.root.visible = false; this.serveGrill(result); }; } private serveGrill(result: ReturnType): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeGrill(result, this.order, seconds); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); 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(); } /** 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'); this.drawer.setTimerLabel('they are waiting'); this.drawer.warmth = 1; this.kitchen.root.visible = false; this.slicer.root.visible = false; this.drawer.root.visible = true; this.app.setView(this.drawer); this.ticket.show(); return; } this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; this.juice.root.visible = false; this.app.setView(this.kitchen); this.kitchen.root.visible = true; this.ticket.show(); } private renderTicket(): void { const o = this.order; this.ticketEl.innerHTML = ''; el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`); el('div', 'ticket-who', this.ticketEl, o.who); 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}`); if (o.noChar) el('span', 'chip warn', spec, 'no burnt bits'); el('span', 'chip tool', spec, `use the ${o.tool.replace(/_/g, ' ')}`); el( 'div', 'ticket-hint', this.ticketEl, `butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`, ); if (SPREADS[o.spread].separates) { el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip'); } if (o.bruschetta) { el('div', 'ticket-hint', this.ticketEl, 'get the brie out now — it must be soft to spread'); el('div', 'ticket-hint', this.ticketEl, 'leave the parmesan in the fridge until you shave it'); } } /** Build the fridge/bench toggle for this order's perishables and show it. */ private showTempToggle(): void { if (!this.temp) return; const p = this.tempPanel.root; p.innerHTML = ''; el('div', 'drawer-lbl', p, 'THE FRIDGE'); this.tempRows = []; for (const per of this.temp.items) { const btn = el('button', 'btn ghost', p) as HTMLButtonElement; btn.addEventListener('click', () => this.toggleTemp(per.id)); this.tempRows.push({ id: per.id, btn }); } this.tempPanel.show(); this.refreshTempToggle(); } private refreshTempToggle(): void { if (!this.temp) return; for (const row of this.tempRows) { const per = this.temp.get(row.id); if (!per) continue; const r = this.temp.readiness(row.id); row.btn.textContent = `${per.name}: ${per.state === 'bench' ? 'on the bench' : 'in the fridge'} — ${r.word}`; row.btn.style.color = r.score > 0.8 ? '#8fce8f' : r.score > 0.45 ? '#e8a53a' : '#e2603a'; } } private hideTempToggle(): void { this.tempRows = []; this.tempPanel.hide(); } /** Called when the player hands the plate over. */ serve(): void { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; // The juicer scored its own glass (The Juice + Pips); fold those rows in so // one verdict covers the toast and everything the order asked beside it. const extra = this.juiceResult ? juiceCriteria(this.juiceResult) : []; const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined, extra); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); writeSave(this.save); this.kitchen.root.visible = false; 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(); } /** Test hook. */ gradeNow(): { total: number; grade: Grade } { const v = judge(this.kitchen.currentSlice, this.order, this.kitchen.toolId, 60); return { total: v.total, grade: v.grade }; } } /** * 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 { // A step with no cut of its own (the juicer's bench-only fold) mustn't wipe // out the cut a previous step already recorded — only take defined values. cv: b.cv !== undefined ? Math.max(a.cv ?? 0, b.cv) : a.cv, pieces: (a.pieces ?? 0) + (b.pieces ?? 0), patternName: b.patternName ?? a.patternName, slips: (a.slips ?? 0) + (b.slips ?? 0), // Onion technique carries through untouched — only one step is ever the dice. technique: b.technique ?? a.technique, sting: b.sting ?? a.sting, stem: b.stem ?? a.stem, bench: a.bench && b.bench ? { 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); if (raw) { const s = JSON.parse(raw) as Partial; if (typeof s.day === 'number' && s.day >= 1) { return { day: s.day, maxDay: Math.max(s.day, s.maxDay ?? s.day), total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge }; } } } catch { /* a corrupt save is not worth a crash */ } return { day: 1, maxDay: 1, total: 0, best: {} }; } function writeSave(s: Save): void { try { localStorage.setItem(SAVE_KEY, JSON.stringify(s)); } catch { /* private mode; the day counter just won't persist */ } } export { nameBrowning };