toastsim/src/game/game.ts
type-two bffee40c6f THE POACH: day 21 — the shiver, the vortex, the drop, the wobble test
The near-game, treated with respect (masterplan P3). sim/poach.ts (pure,
rides the heat-source bone): the pot chases the knob on the fuel's lag
and the poach wants the SHIVER (5.8-7.4) — cooler rags the white on the
way in, a rolling boil (>8.2) tears it the whole time. The VORTEX is the
reamer-twist at pot scale: swirl builds it, it DECAYS (swirl then drop,
not swirl then admire), and the strength at the drop is the judged form.
Two-layer set: white first, the yolk only firms once the white is mostly
there — lift in the window (white >=0.85, yolk <=0.35) or you've boiled
an egg the long way. Then DRAIN on the slotted spoon. Stirring a dropped
egg is vandalism (rags it directly).

scenes/poach.ts: WHEEL flame, circular-drag swirl (real angular sweep),
SPACE drop, L lift, hold-to-drain, ENTER serves; vortex streaks that spin
and fade, shiver-vs-boil bubbles, the white gathering tighter as it sets,
a hard yolk going pale. judgePoach: THE POACH (White/Wobble/Form) + The
Drain; the wobble line is the dossier's: 'It should tremble. Like you,
right now.' Day 21 authored + poach in the endless rotation (electric
pot past day 27).

Verified with REAL input: shiver + 91% vortex + lift-at-tremble + drain
= 9.9/10 'it TREMBLES — perfect', 'one neat comet (vortex 91%)'; still
water into a rolling boil, overstayed, undrained = 3.6/10, 'a pot of
rags', 'it does not move at all'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:34:50 +10:00

977 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number>;
/** 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<PrepStep, { kind: 'juice' }>,
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<void> {
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<PoachView['result']>): 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<EggBenchView['result']>): 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<SteakBoardView['result']>): 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<FridgeView['result']>): 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<PanView['result']>): 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<GrillView['result']>): 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<Save>;
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 };