scenes/grill.ts (GrillView) — the coal bed rendered as an additive glow texture from the zone Field, food pieces you drag between the sear zone and the cool lee, flares that lick up when fat renders over hot coals. Registered in game.ts (hidden, in hideAllScenes, grillView getter). Harness t.grillDemo drives the real scene end-to-end. Verified in-browser: banked a bright hot-left bed, placed food across the gradient, cooked through the real View — bed glows, food browns, flares fire. Screenshot confirms the two-zone gradient reads at a glance. Framing fix: glow above the grate, camera overhead, low open bowl (the kettle GLB is a closed dome that occludes a top-down cook). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
658 lines
24 KiB
TypeScript
658 lines
24 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 { 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, 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;
|
||
total: number;
|
||
best: Record<string, number>;
|
||
}
|
||
|
||
/**
|
||
* 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 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.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.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.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 = () => {
|
||
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();
|
||
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 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.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;
|
||
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;
|
||
const o =
|
||
day <= DAYS.length
|
||
? DAYS[day - 1]
|
||
: proceduralOrder(day, () => this.rng.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.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;
|
||
}
|
||
this.enterToastFlow();
|
||
}
|
||
|
||
/** The toaster half of the day: the loaf knife-trip, or straight to the bench. */
|
||
private enterToastFlow(): void {
|
||
if (this.order.fromLoaf) {
|
||
// Bakery bread: first find something to slice it with.
|
||
this.knifeTrip = true;
|
||
this.drawer.reset('bread_knife');
|
||
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, total: s.total ?? 0, best: s.best ?? {} };
|
||
}
|
||
}
|
||
} catch {
|
||
/* a corrupt save is not worth a crash */
|
||
}
|
||
return { day: 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 };
|