diff --git a/src/game/game.ts b/src/game/game.ts index e6b0856..2643de5 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -8,7 +8,7 @@ import { OvenView } from '../scenes/oven'; import { GrillView } from '../scenes/grill'; import { PanView } from '../scenes/pan'; import { FridgeView } from '../scenes/fridge'; -import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, freshnessWord, type StoredItemSave } from '../sim/coldchain'; +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'; @@ -143,6 +143,12 @@ export class Game { this.kitchen.flash(cut.knife === 'the_best_thing' ? 'cut with The Best Thing' : 'now toast it'); }; this.judgeView.onNext = () => { + // Commit any fridge consumption atomically with the day advance: a reload + // before this point re-derives the same fetch, so the cap can't be dodged. + if (this.pendingFridge) { + this.save.fridge = this.pendingFridge; + this.pendingFridge = null; + } this.save.day++; writeSave(this.save); this.beginDay(); @@ -560,20 +566,24 @@ export class Game { /** 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. Then write the (now smaller) stock back to the save. + // 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: freshnessWord(got) }; - this.save.fridge = serializeStore(store); - writeSave(this.save); + this.panIngredient = { quality: q.quality, word: q.word }; + this.pendingFridge = serializeStore(store); } } this.pan.reset(p.food, p.fuel); diff --git a/src/game/judging.ts b/src/game/judging.ts index 22bdfa3..a0af247 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -589,11 +589,15 @@ export function judgePan(r: PanResult, order: Order, seconds: number, ingredient // row — and a genuinely off ingredient CAPS the whole dish, because no sear // saves rot. You can't cook your way out of bad storage. if (ingredient) { + // Scored on the freshness BAND, not raw quality: a genuinely fresh ingredient + // is full marks (so perfect storage still leaves S on the table — the floors + // law), use-soon is a real dent, off/dangerous is near-zero and hard-caps below. + const bandScore = ingredient.word === 'fresh' ? 1 : ingredient.word === 'use soon' ? 0.55 : ingredient.word === 'off' ? 0.2 : 0; criteria.push({ key: 'ingredient', group: 'pan', label: 'The Ingredient', - score: clamp01(ingredient.quality), + score: bandScore, weight: 1.2, detail: `off the shelf — ${ingredient.word}`, }); diff --git a/src/game/orders.ts b/src/game/orders.ts index 689cdc0..71ecc1e 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -5,6 +5,7 @@ import { CUT_BANDS, type CutClass } from '../sim/slicing'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; import type { JuicerId } from '../sim/juicing'; +import { FRIDGE_MUSHROOM_ID } from '../sim/coldchain'; /** * A stop at the prep bench before the toaster. M11 shipped the 'cut' kind; @@ -416,7 +417,7 @@ export const DAYS: Order[] = [ butterSoftness: 0.5, who: 'the lunch rush', text: 'Pan-sear the mushrooms — the ones from your own fridge. Whatever shape they\'re in now is the shape you stored them in. Butter, foam, both faces. No excuses about the delivery; that was your fridge.', - pan: { food: 'button_mushroom', fuel: 'gas', fromFridge: true }, + pan: { food: FRIDGE_MUSHROOM_ID, fuel: 'gas', fromFridge: true }, }, ]; diff --git a/src/scenes/fridge.ts b/src/scenes/fridge.ts index a0a3fe2..822768c 100644 --- a/src/scenes/fridge.ts +++ b/src/scenes/fridge.ts @@ -10,6 +10,7 @@ import { coldStep, storeHealth, ZONE_COLD, + FRIDGE_MUSHROOM_ID, } from '../sim/coldchain'; import { el, Panel } from '../ui/hud'; @@ -151,7 +152,7 @@ export class FridgeView implements View { { id: 'salad', perish: 1, rte: true, color: 0x5aa84a }, { id: 'cheese', perish: 0.7, rte: true, color: 0xe6c34e }, { id: 'berries', perish: 1.6, rte: true, age: 1, color: 0x8a3d78 }, - { id: 'button_mushroom', perish: 1.3, color: 0xd8cbb0 }, + { id: FRIDGE_MUSHROOM_ID, perish: 1.3, color: 0xd8cbb0 }, { id: 'onion', perish: 0.3, color: 0xb99a5b }, ]; spec.forEach((sp, i) => { diff --git a/src/sim/coldchain.ts b/src/sim/coldchain.ts index f706789..de2cbd2 100644 --- a/src/sim/coldchain.ts +++ b/src/sim/coldchain.ts @@ -50,6 +50,10 @@ export const FRESH = 0.7; export const USE_SOON = 0.4; export const OFF = 0.15; +/** The mushroom that ties the fridge day (delivery) to the pan day (fetch). One + * id, so a rename can't silently uncouple the two days. */ +export const FRIDGE_MUSHROOM_ID = 'button_mushroom'; + /** Base spoilage per DAY at bench temp, before the item's own perishability. * Spoilage lives on the day clock, not the second clock — food goes off across * days (the cure system's twin), and a service leaves things out for a fraction @@ -183,11 +187,15 @@ export function freshnessWord(it: StoredItem): FreshWord { * a serve you do not walk back. */ export function usableQuality(it: StoredItem): { quality: number; safe: boolean; word: FreshWord } { - const word = freshnessWord(it); let q = it.freshness; - if (it.frozen) q *= 0.82; // thawed texture + if (it.frozen) q *= 0.78; // thawed texture — steep enough that a 3-day freeze on a + // mid-perishable no longer beats the coldest fridge shelf. if (it.contaminated) q *= 0.4; - return { quality: Math.max(0, q), safe: it.freshness > OFF && !it.contaminated, word }; + q = Math.max(0, q); + // The word describes what you're ACTUALLY handed (post-thaw, post-drip), not the + // raw freshness — a frozen item can read "fresh" on the shelf and "use soon" on the plate. + const word: FreshWord = q >= FRESH ? 'fresh' : q >= USE_SOON ? 'use soon' : q >= OFF ? 'off' : 'dangerous'; + return { quality: q, safe: it.freshness > OFF && !it.contaminated, word }; } /** The inspector's storage read: hygiene, rotation, and what's gone off on your watch. */ @@ -222,10 +230,12 @@ export function serializeStore(s: ColdStore): StoredItemSave[] { return out; } -/** Rebuild a store from a save snapshot (a fresh Rng — spoilage is deterministic). */ +/** Rebuild a store from a save snapshot (a fresh Rng — spoilage is deterministic). + * Corruption-tolerant: a garbled save (unknown zone, non-array, junk item) yields + * an empty/partial store rather than an uncaught crash — the save intent of loadSave. */ export function restoreStore(items: StoredItemSave[], seed = 20260720): ColdStore { const s = newColdStore(seed); - for (const it of items) s.zones[it.zone].push({ ...it }); + if (Array.isArray(items)) for (const it of items) if (it && s.zones[it.zone]) s.zones[it.zone].push({ ...it }); return s; }