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:
parent
3f91bedc7f
commit
afe2bf174b
@ -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);
|
||||
|
||||
@ -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}`,
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user