Compare commits
2 Commits
2085f68655
...
afe2bf174b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe2bf174b | ||
|
|
3f91bedc7f |
@ -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`
|
||||
|
||||
29
src/dev.ts
29
src/dev.ts
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user