John's spec from the atlas, honored verbatim: the basket is a small 2D
tray and ARRANGEMENT is the skill. Six wings arrive DUMPED in a pile;
every piece wants a clear gap to breathe (Clark–Evans inverted — the
same spacing statistics that reward an even topping spread here reward
margins). Smothered wings brown slow and keep a PALE face the judge can
see; steam is a LOCAL sin — a corner pile in an empty tray still steams
(the first cut used global fill fraction, which six wings could never
trip; the adversarial pass caught the dead mechanic and the smother
statistic replaced it). The drawer pauses the cook and bleeds the heat —
hovering costs. The SHAKE is the egg-crack grammar: hold F, release; a
sane charge turns the pale faces to the wind, a full-send puts five
wings on his floor and the corner eye goes horrified.
New station, full rails: sim/airfryer.ts (pure, seeded), the top-down
scene with the sliding basket (snap-out on reset — a stale docked tray
was eating the demo's opening drags), judgeAirfryer (THE CRISP golden
band / THE TURN pale faces / THE AIR steam / THE COUNT floor wings),
day 23 on the card with the cursed recipe card, a procedural endless
arm, fan-whir foley that dies when the drawer opens, ghost demo slot 23,
and the wing man's portrait (high-five not coming).
Verified: honest lattice play 9.5 twice back-to-back; the lazy pile
3.6 ('a steamer wearing a fryer badge', raw, never shaken); full-charge
shake loses 5 of 6 with the horrified eye.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1299 lines
51 KiB
TypeScript
1299 lines
51 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 { AirFryerView } from '../scenes/airfryer';
|
||
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, 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, type Order, type PrepStep } from './orders';
|
||
import { el, Panel } from '../ui/hud';
|
||
import { Title } from '../ui/title';
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* 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 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;
|
||
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.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.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.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 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.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 (day === 'daily') {
|
||
this.startDaily();
|
||
return;
|
||
}
|
||
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();
|
||
}, () => {
|
||
// 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 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.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.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));
|
||
}
|
||
|
||
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();
|
||
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.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 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 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 = !this.order.grill && !this.order.pan && !this.order.fridge && !this.order.steak && !this.order.eggs && !this.order.poach;
|
||
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 {
|
||
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 };
|
||
}
|
||
}
|
||
} 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 };
|