Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
afe2bf174b 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>
2026-07-19 22:15:39 +10:00
type-two
3f91bedc7f The cold chain closes the loop: stock day 17, cook it day 18
The fridge persists in the save (serializeStore/restoreStore/fetch in
coldchain.ts). Day 18 'the lunch rush' pan-sears the very mushrooms you
stored on day 17 — fetched FIFO at whatever freshness your storage left
them. judgePan gains 'The Ingredient' row; a dangerous/off one HARD-CAPS
the total (no sear saves rot). Judge line bank for the fetched ingredient.

Verified live: identical cook (72%/57% sear), well-stored mushroom serves
7.4/10, badly-stored (warm door) serves the SAME sear but 3.0/10 (F) with
'The Ingredient: off the shelf — dangerous' capping it. Plain pan day 16
unaffected (8.0) — backward compatible. Day-17 delivery gains mushrooms;
Order.pan.fromFridge flag; save carries the stock.

Harness: t.coldChainArc(good?).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:52:27 +10:00
8 changed files with 180 additions and 11 deletions

View File

@ -249,8 +249,18 @@ door), shut the door, three days compress-pass (items recolour as they spoil), a
`judgeFridge` scores THE FRIDGE (The Cold / Hygiene) + The Waste with the hygiene-
inspector line bank. Verified live: good storage (raw low, perishables cold, RTE
high) grades **S (10.0)**; bad storage (raw high in the warm door, crammed) grades
**C (3.5)** — 38% off, 38% dripped on, 3 wasted. Cross-day carryover (fridge ages
in the save between days) and the FIFO pull-for-a-cook are the remaining polish.
**C (3.5)** — 38% off, 38% dripped on, 3 wasted.
**CROSS-DAY LOOP SHIPPED**: the fridge persists in the save (`serializeStore`/
`restoreStore`) — what you stock on day 17 is still there on day 18. Day 18 "the
lunch rush" pan-sears **the very mushrooms from your own fridge** (`fetch` FIFO);
the fetched `usableQuality` is a judged row ("The Ingredient") and a genuinely off
one HARD-CAPS the dish — no sear saves rot. Verified: identical cook (72%/57% sear),
well-stored mushroom → **7.4/10**, badly-stored (warm door) → same sear, off
ingredient → **3.0/10 (F)**; plain pan day unaffected (8.0). Judge: "No sear saves a
bad mushroom. The fridge did this, not the flame." Remaining polish: per-day-transition
ageing (currently the fridge day's own 3-day compress does the ageing) and the fetch
wired into the grill/other cooks too.
The cure is time you *want*; the cold chain is time you *fight*. `src/sim/coldchain.ts`

View File

@ -517,6 +517,35 @@ export function installDevHarness(app: App, game: Game): void {
return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) };
},
/** The cross-day arc: stock the fridge on day 17 (well or badly), then cook
* those very mushrooms on day 18. Bad storage an off mushroom caps the sear. */
coldChainArc(goodStorage = true) {
harness.fridgeDay(goodStorage); // day 17: stock + 3 days + serve
run(5);
// Advance to day 18 (the judge's NEXT button bumps the day + beginDay).
const g = game as unknown as { save: { day: number; fridge?: unknown[] } };
const storedCount = g.save.fridge?.length ?? 0;
game.setDay(18);
run(6);
const atPan = app.currentView === game.panView ? 'pan' : 'other';
const fetched = (game as unknown as { panIngredient: { quality: number; word: string } | null }).panIngredient;
// Cook it well and serve — only the fetched ingredient's quality varies.
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession };
const s = pv.session;
if (s) {
setPanKnob(s, 7); addButter(s);
let guard = 0; while (butterState(s) !== 'foaming' && guard++ < 60 * 30) run(1);
addFood(s, 'button_mushroom', 'A');
for (let i = 0; i < 60 * 8; i++) run(1);
flip(s);
for (let i = 0; i < 60 * 9; i++) run(1);
tap('Enter'); run(5);
}
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { storedCount, atPan, fetched, grade: gradeEl ? gradeEl.textContent : null, rows };
},
/** Play the whole day-17 fridge order: route in, put the delivery away
* (well or badly), shut the door (3 days), serve, read the scorecard. */
fridgeDay(good = true) {

View File

@ -8,6 +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, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
@ -36,6 +37,9 @@ interface Save {
day: 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[];
}
/**
@ -139,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();
@ -537,6 +547,9 @@ export class Game {
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.save.total += v.total;
const key = `day${this.order.day}`;
@ -551,8 +564,28 @@ 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. 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;
@ -568,7 +601,7 @@ export class Game {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgePan(result, this.order, seconds);
const v = judgePan(result, this.order, seconds, this.panIngredient ?? undefined);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
@ -762,7 +795,7 @@ function loadSave(): Save {
if (raw) {
const s = JSON.parse(raw) as Partial<Save>;
if (typeof s.day === 'number' && s.day >= 1) {
return { day: s.day, total: s.total ?? 0, best: s.best ?? {} };
return { day: s.day, total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge };
}
}
} catch {

View File

@ -550,7 +550,7 @@ export function judgeFridge(h: ReturnType<typeof storeHealth>, order: Order, sec
// M17 — The pan. Did both faces land a proper sear (the flip), did you cook in
// good butter not burnt (the bitterness), and is the finished piece even.
export function judgePan(r: PanResult, order: Order, seconds: number): Verdict {
export function judgePan(r: PanResult, order: Order, seconds: number, ingredient?: { quality: number; word: string }): Verdict {
const seared = clamp01(r.doneScore);
const criteria: Criterion[] = [
{
@ -585,13 +585,32 @@ export function judgePan(r: PanResult, order: Order, seconds: number): Verdict {
detail: `${Math.round(seconds)}s`,
},
];
// If the mushroom came out of the fridge, its freshness is judged on its own
// 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: bandScore,
weight: 1.2,
detail: `off the shelf — ${ingredient.word}`,
});
}
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
let total = (sum / wsum) * 10;
// A dangerous/off ingredient hard-caps the total — you served it, after all.
if (ingredient && ingredient.quality < 0.4) total = Math.min(total, 3 + ingredient.quality * 5);
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
void order;

View File

@ -367,6 +367,22 @@ Object.assign(BANK, {
},
});
// The fetched ingredient — the cook who reads a use-by date.
Object.assign(BANK, {
ingredient: {
bad: [
'This was off before it hit the pan. You stored it in the door, didn\'t you. Three days ago.',
'No sear saves a bad mushroom. The fridge did this, not the flame.',
'You cooked something that had already given up. I can taste the fridge door.',
'Fresh is a choice you make days early. You made the other one.',
],
good: [
'The mushroom was still perfect. Cold, back of the fridge, used in time. That is the whole craft before the pan even lights.',
'Fresh off the shelf. You store like someone who respects the ingredient.',
],
},
});
// The pan — the butter snob.
Object.assign(BANK, {
pansear: {

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;
@ -100,8 +101,10 @@ export interface Order {
* the whole day to the outdoor grill instead of the toaster. */
grill?: string[];
/** The pan (M17): the food to sear, and the fuel the hob runs on. Presence
* sends the day to the pan butter it, foam it, flip it, don't burn it. */
pan?: { food: string; fuel: string };
* sends the day to the pan butter it, foam it, flip it, don't burn it.
* `fromFridge` pulls the food out of your stored stock how you stocked it
* days ago decides how fresh it is now. */
pan?: { food: string; fuel: string; fromFridge?: boolean };
/** The cold chain: a delivery to put away. Presence sends the day to the
* fridge raw low, perishables cold, don't cram it, then three days pass. */
fridge?: boolean;
@ -401,6 +404,21 @@ export const DAYS: Order[] = [
text: 'The order came in. Put it away before the inspector does his rounds — raw meat down low where it can\'t drip, the perishables in the cold at the back, and don\'t cram one shelf or nothing breathes. Three days till he looks.',
fridge: true,
},
{
day: 18,
brand: 'the bakery loaf',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
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: FRIDGE_MUSHROOM_ID, fuel: 'gas', fromFridge: true },
},
];
/** Days beyond the script — keep going, with everything cranked. */

View File

@ -10,6 +10,7 @@ import {
coldStep,
storeHealth,
ZONE_COLD,
FRIDGE_MUSHROOM_ID,
} from '../sim/coldchain';
import { el, Panel } from '../ui/hud';
@ -151,6 +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: 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. */
@ -211,3 +219,37 @@ export function storeHealth(s: ColdStore): { fresh: number; off: number; contami
waste: s.wasteCount,
};
}
/** A plain snapshot of the stock for the save file (no Rng, no functions). */
export type StoredItemSave = Omit<StoredItem, never>;
/** Flatten every zone's items to a plain array — each item already knows its zone. */
export function serializeStore(s: ColdStore): StoredItemSave[] {
const out: StoredItemSave[] = [];
for (const zone of Object.keys(s.zones) as ZoneId[]) for (const it of s.zones[zone]) out.push({ ...it });
return out;
}
/** 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);
if (Array.isArray(items)) for (const it of items) if (it && s.zones[it.zone]) s.zones[it.zone].push({ ...it });
return s;
}
/** Take one item of `id` out of the store, oldest-first (FIFO) across the whole
* fridge the cook reaches in for stock. Returns null if you've none. */
export function fetch(s: ColdStore, id: string): StoredItem | null {
let found: { zone: ZoneId; idx: number; it: StoredItem } | null = null;
for (const zone of Object.keys(s.zones) as ZoneId[]) {
const arr = s.zones[zone];
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id && (!found || arr[i].age > found.it.age)) found = { zone, idx: i, it: arr[i] };
}
}
if (!found) return null;
s.zones[found.zone].splice(found.idx, 1);
return found.it;
}