diff --git a/src/dev.ts b/src/dev.ts index 9062c6c..dbcae5b 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -8,6 +8,7 @@ import { newRoastSession, roastStep, roastResult } from './sim/roasting'; import { Rng } from './core/rng'; import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal'; import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan'; +import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -516,6 +517,85 @@ export function installDevHarness(app: App, game: Game): void { return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) }; }, + // ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ---- + + /** Same fish stored in each zone for `days` — freshness must fall fast on the + * bench, slow in the fridge, barely at all in the freezer. */ + coldZones(days = 3) { + const zones = ['bench', 'door', 'fridge', 'back', 'freezer'] as const; + const out: Record = {}; + for (const z of zones) { + const s = newColdStore(); + const fish = store(s, { id: 'fish', perish: 2, raw: true }, z); + coldStep(s, days); + out[z] = { fresh: +fish.freshness.toFixed(3), word: freshnessWord(fish) }; + } + return out; + }, + + /** Rotation over a week: each day age the stock, use one tub, restock fresh. + * FIFO (use the oldest) wastes nothing; always-newest lets old stock rot. */ + coldRotation(daysRun = 7) { + const run = (fifo: boolean) => { + const s = newColdStore(); + for (let k = 0; k < 3; k++) { + const it = store(s, { id: 'mince', perish: 1, raw: true }, 'fridge'); + it.age = 2 - k; // one is already 2 days old (closest to expiry) + it.freshness = 1 - (2 - k) * 0.11; // and correspondingly less fresh + } + for (let d = 0; d < daysRun; d++) { + coldStep(s, 1); // a day passes + pull(s, 'fridge', fifo); // use one tub + store(s, { id: 'mince', perish: 1, raw: true }, 'fridge'); // restock fresh + } + return storeHealth(s).waste; + }; + return { fifoWaste: run(true), newestFirstWaste: run(false) }; + }, + + /** Overpack: cram the fridge and airflow chokes — the same milk spoils faster + * than in a sparse fridge. */ + coldOverpack(days = 6) { + const sparse = newColdStore(); + const a = store(sparse, { id: 'milk', perish: 1 }, 'fridge'); + const packed = newColdStore(); + const b = store(packed, { id: 'milk', perish: 1 }, 'fridge'); + for (let k = 0; k < 10; k++) store(packed, { id: 'filler', perish: 0.2 }, 'fridge'); + coldStep(sparse, days); + coldStep(packed, days); + return { sparse: +a.freshness.toFixed(3), packed: +b.freshness.toFixed(3) }; + }, + + /** Contamination: raw chicken on a shelf ABOVE the salad drips onto it; beside + * it (same shelf) it doesn't. */ + coldContam(days = 1) { + const bad = newColdStore(); + store(bad, { id: 'chicken', perish: 2, raw: true, shelf: 2 }, 'fridge'); + const saladBad = store(bad, { id: 'salad', perish: 1, readyToEat: true, shelf: 0 }, 'fridge'); + const good = newColdStore(); + store(good, { id: 'chicken', perish: 2, raw: true, shelf: 0 }, 'fridge'); + const saladGood = store(good, { id: 'salad', perish: 1, readyToEat: true, shelf: 0 }, 'fridge'); + coldStep(bad, days); + coldStep(good, days); + return { + rawAbove: { contaminated: saladBad.contaminated, quality: +usableQuality(saladBad).quality.toFixed(3), safe: usableQuality(saladBad).safe }, + sameShelf: { contaminated: saladGood.contaminated, quality: +usableQuality(saladGood).quality.toFixed(3), safe: usableQuality(saladGood).safe }, + }; + }, + + /** The freezer trade-off: it holds freshness a bench-item would lose, but the + * thaw costs texture (usable quality docked below the raw freshness). */ + coldFreezer(days = 5) { + const s = newColdStore(); + const frozen = store(s, { id: 'steak', perish: 1.5 }, 'freezer'); + const bench = store(s, { id: 'steak', perish: 1.5 }, 'bench'); + coldStep(s, days); + return { + frozen: { fresh: +frozen.freshness.toFixed(3), usable: +usableQuality(frozen).quality.toFixed(3), word: freshnessWord(frozen) }, + bench: { fresh: +bench.freshness.toFixed(3), word: freshnessWord(bench) }, + }; + }, + // ---- M17 + M-H4: the pan. Butter state timeline, the flip, the baste, // and the fuel differences (gas foams faster, induction never flares). ---- diff --git a/src/sim/coldchain.ts b/src/sim/coldchain.ts new file mode 100644 index 0000000..cf725db --- /dev/null +++ b/src/sim/coldchain.ts @@ -0,0 +1,207 @@ +import { Rng } from '../core/rng'; + +/** + * THE COLD CHAIN — where you put it, and how long it's been there. + * + * The temperature clock (tempclock.ts) warms a cheese toward spreadable. Point + * the same clock the other way and it's SPOILAGE: every perishable has a + * `freshness` 1→0 that only falls, and how fast it falls is decided by the cold. + * Bench races, the fridge crawls, the freezer nearly stops. That's the whole + * cold chain — three things you manage on top of it: + * + * PLACEMENT — the fridge has zones. The door is warmest, the back-bottom + * coldest. Cram it and airflow chokes and the whole box warms. + * Raw meat over ready-to-eat drips down and CONTAMINATES it. + * ROTATION — first in, first out. Use the OLDEST stock or it goes off in the + * back while you keep opening fresh. Expired stock is waste (money) + * and, unnoticed, a hazard you serve. + * THE FREEZER — stops the clock, but freezing-then-thawing costs texture. It + * buys time you pay for later. + * + * Fresh → use-soon → off → DANGEROUS. Cook an off one and the judge tastes it; + * serve a dangerous one and it's the rotten-egg beat with the safety off. + * + * Pure — no three.js — so the harness ages a fridge headless and reads the exact + * numbers the inspector will. + */ + +export type ZoneId = 'bench' | 'door' | 'fridge' | 'back' | 'freezer'; + +/** How cold each zone runs, 0 (room) .. 1 (deep freeze). Spoilage scales off this. */ +export const ZONE_COLD: Record = { + bench: 0, + door: 0.55, + fridge: 0.78, + back: 0.9, + freezer: 1.0, +}; + +/** How many items a zone holds before airflow chokes and it warms up. */ +export const ZONE_CAP: Record = { + bench: 99, + door: 4, + fridge: 6, + back: 4, + freezer: 8, +}; + +/** Freshness thresholds — the inspector's own words. */ +export const FRESH = 0.7; +export const USE_SOON = 0.4; +export const OFF = 0.15; + +/** 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 + * of a day. Bench fish (perish 2) → off in ~1 day; fridge milk (perish 1) → ~a + * week; freezer → nearly frozen in time. */ +const BASE_SPOIL = 0.5; + +export interface StoredItem { + id: string; + /** 1 = just bought, 0 = biohazard. Only ever falls. */ + freshness: number; + /** Seconds this item has existed — rotation reads this (oldest first). */ + age: number; + /** How fast this thing turns: fish/mince ~2, dairy ~1, onion/potato ~0.3. */ + perish: number; + /** Raw proteins drip and contaminate what sits below them. */ + raw: boolean; + /** Ready-to-eat things are the ones a drip ruins. */ + readyToEat: boolean; + /** Vertical slot in a zone (0 = bottom): a raw item above a RTE item contaminates it. */ + shelf: number; + /** Was ever frozen → thawed texture penalty when used. */ + frozen: boolean; + /** A drip landed on it — a hygiene fail the inspector catches. */ + contaminated: boolean; + zone: ZoneId; +} + +export interface ColdStore { + zones: Record; + /** Fraction of stored stock that spoiled to DANGEROUS unused — waste (money + risk). */ + wasteCount: number; + daysElapsed: number; + rng: Rng; +} + +export function newColdStore(seed = 20260720): ColdStore { + return { + zones: { bench: [], door: [], fridge: [], back: [], freezer: [] }, + wasteCount: 0, + daysElapsed: 0, + rng: new Rng(seed), + }; +} + +export function store(s: ColdStore, item: Partial & { id: string; perish: number }, zone: ZoneId, shelf = 0): StoredItem { + const it: StoredItem = { + freshness: 1, + age: 0, + raw: false, + readyToEat: false, + shelf, + frozen: false, + contaminated: false, + zone, + ...item, + }; + it.zone = zone; + s.zones[zone].push(it); + return it; +} + +/** How overpacked a zone is → how much its cold is degraded (crammed = warmer). */ +function airflowPenalty(s: ColdStore, zone: ZoneId): number { + const over = s.zones[zone].length - ZONE_CAP[zone]; + return over > 0 ? Math.min(0.5, over * 0.12) : 0; +} + +/** + * Age everything by `days` (fractional — a whole service leaves things out for + * ~0.08 of a day). Each item spoils at its perishability × the warmth of its zone + * (degraded if the zone is crammed). The freezer all but stops it. Raw items drip + * onto ready-to-eat items below them in the same zone. + */ +export function coldStep(s: ColdStore, days: number): void { + for (const zone of Object.keys(s.zones) as ZoneId[]) { + const items = s.zones[zone]; + const cold = Math.max(0, ZONE_COLD[zone] - airflowPenalty(s, zone)); + const warmth = Math.max(0.02, 1 - cold * 0.98); // freezer ~0.04, bench 1.0 + for (const it of items) { + it.age += days; + const before = it.freshness; + it.freshness = Math.max(0, it.freshness - BASE_SPOIL * it.perish * warmth * days); + if (zone === 'freezer' && it.freshness > 0.2) it.frozen = true; + // Crossed into DANGEROUS, unused → waste. + if (before > OFF && it.freshness <= OFF) s.wasteCount++; + } + // Contamination: a raw item drips onto any ready-to-eat item on a lower shelf. + for (const raw of items) { + if (!raw.raw) continue; + for (const rte of items) { + if (rte.readyToEat && rte.shelf < raw.shelf && !rte.contaminated) { + rte.contaminated = true; + rte.freshness = Math.max(0, rte.freshness - 0.15); // the drip itself sets it back + } + } + } + } + s.daysElapsed += days; +} + +/** Pull stock. FIFO (the default) hands you the OLDEST — proper rotation. */ +export function pull(s: ColdStore, zone: ZoneId, fifo = true): StoredItem | null { + const items = s.zones[zone]; + if (!items.length) return null; + let idx = 0; + for (let i = 1; i < items.length; i++) { + if (fifo ? items[i].age > items[idx].age : items[i].age < items[idx].age) idx = i; + } + return items.splice(idx, 1)[0]; +} + +export type FreshWord = 'fresh' | 'use soon' | 'off' | 'dangerous'; + +export function freshnessWord(it: StoredItem): FreshWord { + if (it.freshness >= FRESH) return 'fresh'; + if (it.freshness >= USE_SOON) return 'use soon'; + if (it.freshness >= OFF) return 'off'; + return 'dangerous'; +} + +/** + * What a used item is worth: its freshness, docked hard if it's contaminated or + * was frozen (thaw softens texture). Below OFF it's a quality crime; dangerous is + * 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.contaminated) q *= 0.4; + return { quality: Math.max(0, q), safe: it.freshness > OFF && !it.contaminated, word }; +} + +/** The inspector's storage read: hygiene, rotation, and what's gone off on your watch. */ +export function storeHealth(s: ColdStore): { fresh: number; off: number; contaminated: number; waste: number } { + let fresh = 0; + let off = 0; + let contaminated = 0; + let total = 0; + for (const zone of Object.keys(s.zones) as ZoneId[]) { + for (const it of s.zones[zone]) { + total++; + if (it.freshness >= FRESH) fresh++; + if (it.freshness < OFF) off++; + if (it.contaminated) contaminated++; + } + } + return { + fresh: total ? fresh / total : 1, + off: total ? off / total : 0, + contaminated: total ? contaminated / total : 0, + waste: s.wasteCount, + }; +}