toastsim/src/game/game.ts
type-two 29f6b25c5b THE AIR FRYER: day 23 — air is the ingredient
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>
2026-07-21 03:17:49 +10:00

1299 lines
51 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 { 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 };