Cold-chain review fixes: reload-dodge, crash-safety, fair ceiling (6 findings)

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 22:15:39 +10:00
parent 3f91bedc7f
commit afe2bf174b
5 changed files with 40 additions and 14 deletions

View File

@ -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);

View File

@ -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}`,
});

View File

@ -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 },
},
];

View File

@ -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) => {

View File

@ -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;
}