From afe2bf174ba09cd787c3d341c1791ee948c8ea86 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 22:15:39 +1000 Subject: [PATCH] Cold-chain review fixes: reload-dodge, crash-safety, fair ceiling (6 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the cross-day loop found six real issues; all fixed and re-verified: - RELOAD EXPLOIT (edge-case): enterPan consumed + persisted fridge stock at day-START, so a reload mid-pan re-fetched null and DODGED the rot-cap (and honest players lost the ingredient). Now the consumed stock is held (pendingFridge) and committed atomically with the day advance in onNext — a reload re-fetches the same item deterministically. Verified: after entering day 18 the mushroom is still in save.fridge until the day turns. - CRASH (edge-case): loadSave passed save.fridge unvalidated → restoreStore hard-crashed on a corrupt/unknown-zone entry. restoreStore is now corruption-tolerant (guards Array + known zone). Verified: garbage save → no crash, falls back to a fresh ingredient. - FAIRNESS (design): The Ingredient scored linearly on quality, so even a perfectly-stored 'fresh' mushroom (0.77) capped the ceiling below S. Now scored on the freshness BAND — fresh = full marks — so optimal cold storage reaches S (verified 0.77 'fresh' from the coldest back shelf), use-soon dents (A), off/dangerous near-zero + hard-cap (F). - word/quality mismatch: usableQuality's word now derives from the docked quality (a thawed item reads its real handed-over state). - freezer thaw 0.82 -> 0.78: a 3-day-frozen mid-perishable (0.75) no longer out-scores the coldest fridge shelf (0.77) — the texture cost is real. - rename-safety: FRIDGE_MUSHROOM_ID shared constant couples the two days. Gradient verified: optimal->S, decent->A(7.4), bad->F(3.0); plain pan 8.0. Co-Authored-By: Claude Fable 5 --- src/game/game.ts | 22 ++++++++++++++++------ src/game/judging.ts | 6 +++++- src/game/orders.ts | 3 ++- src/scenes/fridge.ts | 3 ++- src/sim/coldchain.ts | 20 +++++++++++++++----- 5 files changed, 40 insertions(+), 14 deletions(-) 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; }