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>
977 lines
35 KiB
TypeScript
977 lines
35 KiB
TypeScript
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 };
|