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>
This commit is contained in:
type-two 2026-07-19 21:52:27 +10:00
parent 2085f68655
commit 3f91bedc7f
8 changed files with 151 additions and 8 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, freshnessWord, 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[];
}
/**
@ -537,6 +541,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 +558,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;
private enterPan(): void {
const p = this.order.pan!;
this.panIngredient = 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.
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.pan.reset(p.food, p.fuel);
this.hideAllScenes();
this.pan.root.visible = true;
@ -568,7 +591,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 +785,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,28 @@ 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) {
criteria.push({
key: 'ingredient',
group: 'pan',
label: 'The Ingredient',
score: clamp01(ingredient.quality),
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

@ -100,8 +100,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 +403,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: 'button_mushroom', fuel: 'gas', fromFridge: true },
},
];
/** Days beyond the script — keep going, with everything cranked. */

View File

@ -151,6 +151,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: 'onion', perish: 0.3, color: 0xb99a5b },
];
spec.forEach((sp, i) => {

View File

@ -211,3 +211,35 @@ 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). */
export function restoreStore(items: StoredItemSave[], seed = 20260720): ColdStore {
const s = newColdStore(seed);
for (const it of items) 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;
}