toastsim/src/game/game.ts
type-two cebff4bd69 STAGE: culinary school — one board, one spec, one clock
The drills mode from P8, and the one John asked for by name. No
customer, no ticket, no day: a spec, a board and a stopwatch. Three to
start — brunoise the onion, bâtonnets off a bell pepper, twelve
cucumber rounds — each with four medal tiers that must be earned on
BOTH axes at once. You do not get gold for being fast and ragged, and
you do not get gold for being immaculate and late, and the card names
the single thing standing between this run and the next tier: "Even
enough for gold. 6s too slow for it."

A drill is a SPEC, not a separate game — it runs the real prep bench
through the real cutting sim, and pieceCV was already sitting there
doing exactly the measurement a spec needs.

Sealed off from the campaign on purpose: no order, no streak, no
starter, no regulars, no verdict. Checked rather than assumed — after a
full drill run, save.day, save.total and save.streak were byte-identical
and the ONLY thing written was the drill's own line in the medal book,
which keeps your best (a better medal, or the same medal faster).

Two things the build surfaced:

- The prep bench only accepts ENTER when the pattern is COMPLETE, which
  is right for service (the order wants the whole cut) but made "7 of 16.
  Finish the cut before you stop the clock" a verdict nothing could ever
  reach. Drills now allow the panic-stop via an explicit
  allowEarlyServe flag; service explicitly sets it false, so an order can
  never be half-served.
- The title was built in the constructor and nowhere else, so there was
  no way back from anything. Extracted showTitle().

Also: a recorded run with no medal was labelled "not attempted", which
is a small lie about the worst runs. It says "attempted — no medal" now.

Verified: S on the cucumber rounds books S·4s on the title; a short
brunoise books no medal and says why; service prep still refuses an
early ENTER; bruschetta 9.6, grill 9.4, eggs 10.0, curry 10.0, greens
10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:45:19 +10:00

1569 lines
61 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 { AirFryerView } from '../scenes/airfryer';
import { FryerPotView } from '../scenes/fryerpot';
import { PotView } from '../scenes/pot';
import { SteamerView } from '../scenes/steamer';
import { SpiceView } from '../scenes/spice';
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, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, 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, gradeOf, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, type PrepStep } from './orders';
import { el, Panel } from '../ui/hud';
import { Title } from '../ui/title';
import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills';
import { eye } from '../ui/eye';
import { assetUrl } from '../scenes/assets';
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[];
/** Consecutive S grades. The judge notices. Breaking it, he notices more. */
streak?: number;
/** Best score per Daily Deal date (YYYY-MM-DD) — one shared deal per day. */
daily?: Record<string, number>;
/** The regulars remember: per-customer visit count and how last time went. */
customers?: Record<string, { visits: number; last: Grade; best: number }>;
/** Days S-graded under the Inspector's Shadow — no words, face and sound only. */
shadowed?: Record<string, boolean>;
/** THE MOTHER: the sourdough starter. Appears after day 5, wants feeding
* every new day you reach, and remembers exactly how long you forgot her.
* `fed`/`born` are maxDay stamps so replaying old days never starves her. */
starter?: { born: number; fed: number; deaths: number };
/** STAGE: the best medal and time booked for each drill. */
drills?: Record<string, { medal: Medal; cv: number; seconds: 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 pan: PanView;
private fridge: FridgeView;
private steakBoard: SteakBoardView;
private eggBench: EggBenchView;
private poach: PoachView;
private airFryer: AirFryerView;
private fryerPot: FryerPotView;
private pot: PotView;
private steamer: SteamerView;
private spice: SpiceView;
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;
/** The Daily Deal: everyone on Earth gets the same order today. */
private dailyDate: string | null = null;
/** A demonstration is playing — the judge performs, nothing counts. */
private demoMode = false;
/** The Inspector's Shadow: the kitchen's words go dark; you cook by eye and ear. */
private shadowMode = false;
/** Rush hour: ticket one's total, parked on the pass while you cook two. */
private rushFirst: number | null = null;
/** STAGE: the drill being practised. No customer, no day, no verdict. */
private drill: Drill | null = null;
private drillPanel: Panel | null = null;
private demoSnapshot: string | null = null;
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.airFryer = new AirFryerView(app);
this.fryerPot = new FryerPotView(app);
this.pot = new PotView(app);
this.steamer = new SteamerView(app);
this.spice = new SpiceView(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.airFryer.root);
app.scene.add(this.fryerPot.root);
app.scene.add(this.pot.root);
app.scene.add(this.steamer.root);
app.scene.add(this.spice.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.airFryer.root.visible = false;
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.spice.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
// On a phone the ticket covers half the bench — and you cannot drag a
// spice jar you cannot reach. Tap it to fold it down to the day and the
// customer; tap again to read the whole ask. Desktop ignores this.
this.ticket.root.addEventListener('click', (e) => {
if ((e.target as HTMLElement).tagName === 'BUTTON') return; // the demo button still works
if (window.matchMedia('(max-width: 600px)').matches) this.ticket.root.classList.toggle('folded');
});
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 if (this.order.benedict) this.enterBenedictPoach();
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 = () => {
if (this.demoMode) {
// The demonstration is over. Put everything back and deal the day for real.
if (this.demoSnapshot) this.save = JSON.parse(this.demoSnapshot) as Save;
this.demoSnapshot = null;
this.demoMode = false;
demoWriteGuard = false;
this.beginDay();
return;
}
// 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;
}
if (this.dailyDate) {
// A daily doesn't advance the campaign — back to your regular day.
this.dailyDate = null;
writeSave(this.save);
this.beginDay();
return;
}
if (this.rushFirst !== null) {
// Ticket two of the rush: same day, different deal, the clock restarts
// but the FIRST ticket's decay is measured by ticket two's service.
this.beginDay();
return;
}
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.showTitle();
}
/** The front door. Built once at boot, and again whenever something sends
* you back to it — STAGE has a way out, so this had to stop being a
* constructor-only affair. */
private showTitle(): void {
this.kitchen.root.visible = false;
this.ticket.hide();
eye.hide(); // he doesn't watch you read the menu
// The jar arrives once the kitchen has survived five days.
if (!this.save.starter && this.save.maxDay >= 5) {
this.save.starter = { born: this.save.maxDay, fed: this.save.maxDay, deaths: 0 };
writeSave(this.save);
}
new Title(this.save, (day, shadow) => {
audio.resume();
this.shadowMode = !!shadow;
if (typeof day === 'string' && day.startsWith('drill:')) {
this.startDrill(day.slice(6));
return;
}
if (day === 'daily') {
this.startDaily();
return;
}
if (typeof day === 'number' && 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();
}, () => {
// Feeding day: the jar mutated in place; make it stick and let her sing.
writeSave(this.save);
audio.resume();
audio.blip(0.3);
});
}
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 spiceView(): SpiceView {
return this.spice;
}
get steamerView(): SteamerView {
return this.steamer;
}
get potView(): PotView {
return this.pot;
}
get fryerPotView(): FryerPotView {
return this.fryerPot;
}
get airFryerView(): AirFryerView {
return this.airFryer;
}
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.recordVerdict(v);
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.airFryer.root.visible = false;
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.spice.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.prep.allowEarlyServe = false; // service wants the whole cut
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.rushFirst = null;
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()}`);
}
}
/**
* THE DAILY DEAL: one shared order per real date — the seed IS the date, so
* everyone gets the same deal. Scored into save.daily[date]; the campaign's
* day counter and streaks are left alone. Deterministic: replaying today
* re-deals today.
*/
/**
* "Watch him do it": replay this authored day as a live demonstration, driven
* by the same harness script that verifies it. The save is snapshotted and
* write-guarded — a demo can neither farm best scores nor advance anything.
*/
startDemo(day: number): void {
const t = (window as unknown as { __t?: { demos?: Record<number, () => Promise<unknown>>; setDemoPlayback?: (on: boolean) => void } }).__t;
const demo = t?.demos?.[day];
if (!demo || !t?.setDemoPlayback || this.demoMode) return;
this.demoSnapshot = JSON.stringify(this.save);
this.demoMode = true;
demoWriteGuard = true;
t.setDemoPlayback(true);
void demo().finally(() => t.setDemoPlayback!(false));
}
/**
* STAGE. The prep bench, a spec and a clock — and none of the service
* machinery: no order, no customer, no streak, no starter, no verdict. The
* only thing a drill can write is its own line in the medal book.
*/
startDrill(id: string): void {
const d = DRILLS.find((x) => x.id === id);
if (!d) return;
this.drill = d;
this.rushFirst = null;
this.dailyDate = null;
this.shadowMode = false;
eye.show();
this.orderSeconds = 0;
this.timing = true;
this.ticket.hide();
this.hideTempToggle();
const sl = this.judgeView.release();
if (sl) this.kitchen.root.add(sl.mesh);
this.judgeView.root.visible = false;
this.hideAllScenes();
this.prep.reset(d.ingredient, d.knife, d.pattern, d.brief);
this.prep.allowEarlyServe = true;
this.prep.root.visible = true;
this.app.setView(this.prep);
this.prep.onDone = (result) => {
// The board STAYS on screen behind the medal card — you should be able
// to look at the pieces while he tells you what they were.
this.finishDrill(result);
};
}
private finishDrill(result: PrepResult): void {
const d = this.drill!;
this.timing = false;
const sc = scoreDrill(d, result, this.orderSeconds);
const book = (this.save.drills ??= {});
const prev = book[d.id];
// The book keeps your BEST: a better medal, or the same medal faster.
if (!prev || medalRank(sc.medal) > medalRank(prev.medal) || (medalRank(sc.medal) === medalRank(prev.medal) && sc.seconds < prev.seconds)) {
book[d.id] = { medal: sc.medal, cv: sc.cv, seconds: sc.seconds };
writeSave(this.save);
}
eye.mood(sc.medal === 'S' || sc.medal === 'gold' ? 'impressed' : sc.medal ? 'intrigued' : 'disappointed', 4);
this.showDrillCard(d, sc, !!prev && book[d.id] !== prev);
// Freeze the bench rather than hiding it: setView(null) stops it updating,
// but the scene still renders, so the cut board is right there under the card.
this.app.setView(null);
}
private showDrillCard(d: Drill, sc: ReturnType<typeof scoreDrill>, beat: boolean): void {
this.drillPanel?.dispose();
const p = new Panel('drill-card');
this.drillPanel = p;
el('div', 'drawer-lbl', p.root, 'STAGE');
el('div', 'drill-title', p.root, d.label);
el('div', `drill-medal medal-${sc.medal ?? 'none'}`, p.root, medalWord(sc.medal));
el('div', 'drill-stat', p.root, `${sc.pieces} pieces \u00b7 evenness ${sc.cv.toFixed(3)} \u00b7 ${Math.round(sc.seconds)}s`);
el('div', 'drill-note', p.root, sc.note);
if (beat) el('div', 'drill-beat', p.root, 'a better run than the one in the book');
const btns = el('div', 'judge-btns', p.root);
const again = el('button', 'btn', btns, 'AGAIN');
again.addEventListener('click', () => { p.dispose(); this.drillPanel = null; this.startDrill(d.id); });
const back = el('button', 'btn ghost', btns, 'BACK TO THE PASS');
back.addEventListener('click', () => {
p.dispose();
this.drillPanel = null;
this.drill = null;
this.prep.root.visible = false;
eye.hide();
this.showTitle();
});
}
startDaily(): void {
this.rushFirst = null;
const date = new Date().toISOString().slice(0, 10);
this.dailyDate = date;
let h = 2166136261;
for (const ch of date) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 16777619);
}
const rng = new Rng(h >>> 0);
// A virtual day number varies the ramps date-to-date without touching the save.
const virtualDay = 23 + (Math.abs(h) % 14);
const o = proceduralOrder(virtualDay, () => rng.next());
o.who = `the daily — ${date}`;
this.order = o;
this.orderSeconds = 0;
this.prepResult = null;
this.juiceResult = null;
this.panIngredient = null;
this.lastBruschetta = null;
this.prepQueue = o.prep ? [...o.prep] : [];
this.timing = true;
this.temp = null;
this.hideTempToggle();
const sl = this.judgeView.release();
if (sl) this.kitchen.root.add(sl.mesh);
this.kitchen.applyOrder(o);
this.judgeView.root.visible = false;
this.renderTicket();
if (this.prepQueue.length) {
this.runNextPrep();
return;
}
if (o.grill) return this.enterGrill();
if (o.pan) return this.enterPan();
if (o.fridge) return this.enterFridge();
if (o.steak) return this.enterSteak();
if (o.eggs) return this.enterEggs();
if (o.poach) return this.enterPoach();
if (o.airfryer) return this.enterAirFryer();
if (o.fryerpot) return this.enterFryerPot();
if (o.pot) return this.enterPot();
if (o.steamer) return this.enterSteamer();
if (o.spice) return this.enterSpice();
this.enterToastFlow();
}
/** The one-line shareable result for the current order's verdict. */
private shareLine(v: Verdict): string {
const o = this.order;
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.spice ? '🌶️' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
}
beginDay(): void {
eye.critic = !this.dailyDate && !this.demoMode && this.save.day % 10 === 0;
eye.show(); // he watches you cook — or on the tenth day, HE does
document.body.classList.toggle('shadow', this.shadowMode);
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);
// Ticket two of a rush re-deals the same day from a twisted seed — a
// different order, deterministically, so reloads still can't scum it.
const rng2 = this.rushFirst !== null ? new Rng(((day * 2654435761) ^ 0x9e3779b9) >>> 0) : dayRng;
const o =
day <= DAYS.length
? DAYS[day - 1]
: proceduralOrder(day, () => rng2.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 wing day: the basket, the air, the shake, the count.
if (this.order.airfryer) {
this.enterAirFryer();
return;
}
// A chip day: the pot, the blanch, the rest, the hot fry, the spit.
if (this.order.fryerpot) {
this.enterFryerPot();
return;
}
// Pasta night: the salt you cannot take back, the roll, the bite.
if (this.order.pot) {
this.enterPot();
return;
}
// Greens: the lid is opaque, and that is the entire game.
if (this.order.steamer) {
this.enterSteamer();
return;
}
// The spice rack: six axes, no subtract, and a chili that lies.
if (this.order.spice) {
this.enterSpice();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
return;
}
this.enterToastFlow();
}
/** The benedict's second act: the toast is made — now poach the egg for it. */
private enterBenedictPoach(): void {
this.poach.reset(this.order.benedict!.fuel, 'now the egg — poach it and land it on the toast');
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.serveBenedict(result);
};
}
private serveBenedict(poach: ReturnType<PoachView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeBenedict(slice, this.order, poach, seconds);
this.recordVerdict(v);
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 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.recordVerdict(v);
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.recordVerdict(v);
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.recordVerdict(v);
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.recordVerdict(v);
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.recordVerdict(v);
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 enterSpice(): void {
const sp = this.order.spice!;
this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base);
this.hideAllScenes();
this.spice.root.visible = true;
this.app.setView(this.spice);
this.ticket.show();
this.spice.onDone = (result) => {
this.spice.root.visible = false;
this.serveSpice(result);
};
}
private serveSpice(result: ReturnType<SpiceView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSpice(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterSteamer(): void {
this.steamer.reset('greens \u2014 tender, and still GREEN');
this.hideAllScenes();
this.steamer.root.visible = true;
this.app.setView(this.steamer);
this.ticket.show();
this.steamer.onDone = (result) => {
this.steamer.root.visible = false;
this.serveSteamer(result);
};
}
private serveSteamer(result: ReturnType<SteamerView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSteamer(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterPot(): void {
this.pot.reset('pasta \u2014 salted like the sea, rolling boil, al dente');
this.hideAllScenes();
this.pot.root.visible = true;
this.app.setView(this.pot);
this.ticket.show();
this.pot.onDone = (result) => {
this.pot.root.visible = false;
this.servePot(result);
};
}
private servePot(result: ReturnType<PotView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgePot(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterFryerPot(): void {
const f = this.order.fryerpot!;
this.fryerPot.reset(f.count, 'THE CARD: blanch in the shimmer \u00b7 REST till dry \u00b7 back in HOT \u00b7 stand back when it spits', `${f.count} chips \u2014 golden out, fluffy in, dry`);
this.hideAllScenes();
this.fryerPot.root.visible = true;
this.app.setView(this.fryerPot);
this.ticket.show();
this.fryerPot.onDone = (result) => {
this.fryerPot.root.visible = false;
this.serveFryerPot(result);
};
}
private serveFryerPot(result: ReturnType<FryerPotView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeFryerPot(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterAirFryer(): void {
const f = this.order.airfryer!;
this.airFryer.reset(f.count, f.card, `${f.count} wings — golden, breathing, all present`);
this.hideAllScenes();
this.airFryer.root.visible = true;
this.app.setView(this.airFryer);
this.ticket.show();
this.airFryer.onDone = (result) => {
this.airFryer.root.visible = false;
this.serveAirFryer(result);
};
}
private serveAirFryer(result: ReturnType<AirFryerView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeAirfryer(result, this.order, seconds);
this.recordVerdict(v);
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();
}
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.recordVerdict(v);
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();
}
/**
* The kitchen's memory: streaks the judge acknowledges, and regulars who
* remember how last time went. Runs before the verdict shows, so its lines
* ride the judge screen via extraLines.
*/
private recordVerdict(v: Verdict): void {
if (this.demoMode) {
this.judgeView.extraLines = ['A demonstration. None of it counts. Now you.'];
this.judgeView.shareText = '';
this.judgeView.critic = false; // he doesn't review his own cooking
return;
}
const extras: string[] = [];
// THE CRITIC: every 10th day he seats himself. He finds the flaw and
// leans on it — the worst row counts DOUBLE, and he says so in print.
const criticDay = !this.dailyDate && this.order.day % 10 === 0;
this.judgeView.critic = criticDay;
if (criticDay) {
const ws = v.criteria.reduce((a, c) => a + c.weight, 0);
v.total = Math.round(((v.total / 10) * ws + v.worst.score * v.worst.weight) / (ws + v.worst.weight) * 100) / 10;
v.grade = gradeOf(v.total);
if (v.grade === 'S') extras.push('The Critic: I came to write a takedown. I cannot. This is the worse outcome — for me.');
else if (v.grade === 'A') extras.push('The Critic: competent. The word has never once appeared in a good review.');
else if (v.grade === 'F') extras.push('The Critic: I will be gentle in print. This kitchen has suffered enough today.');
else extras.push(`The Critic: the ${v.worst.label.toLowerCase().replace(/^the /, '')} betrayed you. It will be the headline.`);
}
// THE MOTHER: a lively starter firms the crumb on toaster days. A dead
// one is on the shelf where he can see it, and he can smell a coward.
const st = this.save.starter;
if (st) {
const gap = this.save.maxDay - st.fed;
const toastDay = isToastDay(this.order);
if (gap >= 5) extras.push('The jar on the shelf has gone still. You know what you did.');
else if (gap <= 1 && toastDay) {
v.total = Math.min(10, Math.round((v.total + 0.2) * 10) / 10);
v.grade = gradeOf(v.total);
extras.push('The starter is lively — the crumb held. (+0.2. She did that, not you.)');
} else if (gap >= 3 && toastDay) extras.push('The jar says nothing today. It is sulking, and the crumb knows.');
}
// RUSH HOUR (endless only): the day dealt TWO tickets. The first verdict
// goes on the pass — no streak, no book, no star, nothing written — and
// the second closes the pair. The first decays for every second it sat
// while you cooked. Only the PAIR reaches the streak, the regulars'
// book, the Shadow star and the ledger: one service, one line.
if (this.rushDay(this.order.day) && !this.dailyDate) {
if (this.rushFirst === null) {
this.rushFirst = v.total;
extras.push('Ticket one, on the pass. The second is already up — NEXT. Cook.');
this.judgeView.extraLines = extras;
this.judgeView.shareText = '';
return; // nothing is written until the pair closes
}
const passSec = this.orderSeconds;
const decay = Math.min(1.5, (passSec / 60) * 0.5);
const aAdj = Math.max(0, Math.round((this.rushFirst - decay) * 10) / 10);
extras.push(`Ticket one sat ${Math.round(passSec)}s on the pass: ${this.rushFirst.toFixed(1)} fell to ${aAdj.toFixed(1)}.`);
v.total = Math.round(((aAdj + v.total) / 2) * 10) / 10;
v.grade = gradeOf(v.total);
extras.push('Two tickets, one verdict — the rush is scored as a pair.');
this.rushFirst = null;
}
// The streak. He notices. He hates that he notices.
const prev = this.save.streak ?? 0;
if (v.grade === 'S') {
this.save.streak = prev + 1;
if (this.save.streak === 3) extras.push('Three in a row. I am beginning to suspect competence.');
else if (this.save.streak === 5) extras.push('Five. I have started a file.');
else if (this.save.streak > 5 && this.save.streak % 5 === 0) extras.push(`${this.save.streak} straight. This is no longer luck and we both know it.`);
} else {
if (prev >= 3) extras.push(`The streak is dead. It was ${prev}. We do not speak of it.`);
this.save.streak = 0;
}
// The regulars remember.
const who = this.order.who;
const book = (this.save.customers ??= {});
const c = book[who] ?? { visits: 0, last: v.grade, best: 0 };
if (c.visits > 0) {
if (c.last === 'S' && v.grade !== 'S') extras.push(`${who} remembers last time being better. So do I.`);
else if ((c.last === 'C' || c.last === 'F') && (v.grade === 'S' || v.grade === 'A')) extras.push(`An improvement on ${who}'s last visit. It was noted. It was needed.`);
}
c.visits++;
c.last = v.grade;
c.best = Math.max(c.best, v.total);
book[who] = c;
if (this.shadowMode && v.grade === 'S') {
(this.save.shadowed ??= {})[`day${this.order.day}`] = true;
extras.push('In the silence, too. I have nothing left to teach you. \u2605');
}
if (this.dailyDate) {
const d = (this.save.daily ??= {});
d[this.dailyDate] = Math.max(d[this.dailyDate] ?? 0, v.total);
extras.push('The Daily. Same deal for everyone today — come back tomorrow.');
}
this.judgeView.extraLines = extras;
this.judgeView.shareText = this.shareLine(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);
}
/** Endless-only, deterministic by day number: ~1 in 4 days past the card
* are a rush, never on a Critic day (one cruelty at a time). */
private rushDay(day: number): boolean {
return day > DAYS.length && day % 10 !== 0 && ((day * 2654435761) >>> 0) % 4 === 1;
}
private renderTicket(): void {
this.ticket.root.classList.remove('folded');
const o = this.order;
this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
el('div', 'ticket-who', this.ticketEl, o.who);
// The regular's face, clipped to the ticket. The inspector uses his own.
const faceImg = el('img', 'ticket-face', this.ticketEl) as HTMLImageElement;
const slug = o.who.replace(', again', '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
faceImg.alt = '';
faceImg.src = assetUrl(o.who.includes('inspector') ? '/assets/img/judge_neutral.png' : `/assets/img/who_${slug}.png`);
faceImg.onerror = () => faceImg.remove(); // a face we never made — no frame, no fuss
if (o.day % 10 === 0 && !this.dailyDate) el('div', 'ticket-critic', this.ticketEl, 'THE CRITIC is in today. The flaw will be the headline.');
if (this.rushDay(o.day) && !this.dailyDate) el('div', 'ticket-critic', this.ticketEl, this.rushFirst !== null ? 'TICKET TWO — the first is dying on the pass.' : 'RUSH HOUR — two tickets on the rail. The first will wait for the second.');
const demos = (window as unknown as { __t?: { demos?: Record<number, unknown> } }).__t?.demos;
if (!this.demoMode && demos && demos[o.day]) {
const watch = el('button', 'btn ghost watch-btn', this.ticketEl, '▶ watch him do it');
watch.addEventListener('click', () => this.startDemo(o.day));
}
const knownAs = this.save.customers?.[o.who];
if (knownAs && knownAs.visits > 0) {
el('div', 'ticket-hint', this.ticketEl, `visit №${knownAs.visits + 1} — last time: ${knownAs.last}${knownAs.last === 'F' ? '. they came BACK.' : ''}`);
}
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.recordVerdict(v);
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>;
// NaN/Infinity stringify to null and null arithmetic breeds more NaN —
// one bad number would otherwise rot the whole save. Refuse them here.
const num = (x: unknown, d: number): number => (typeof x === 'number' && Number.isFinite(x) ? x : d);
if (typeof s.day === 'number' && s.day >= 1) {
const day = num(s.day, 1);
const maxDay = Math.max(day, num(s.maxDay, day));
const st = s.starter;
const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined;
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {} };
}
}
} catch {
/* a corrupt save is not worth a crash */
}
return { day: 1, maxDay: 1, total: 0, best: {}, streak: 0, customers: {}, daily: {} };
}
/** While a demonstration runs, nothing it does may touch the disk. */
let demoWriteGuard = false;
function writeSave(s: Save): void {
if (demoWriteGuard) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
} catch {
/* private mode; the day counter just won't persist */
}
}
export { nameBrowning };