The cold chain: spoilage, placement, rotation, contamination (sim)
src/sim/coldchain.ts — the temperature clock pointed the other way. Every perishable has a freshness 1->0 that only falls, on the DAY clock (the cure system's defensive twin); how fast is decided by the zone's cold. Plus the three disciplines on top: PLACEMENT (fridge zones door->back warmest->coldest, cram it and airflow chokes, raw-over-ready-to-eat drips and contaminates), ROTATION (FIFO pull -- use the oldest or it rots in the back = waste), and THE FREEZER (stops the clock but the thaw costs texture). Fresh -> use-soon -> off -> dangerous. Verified headless (t.coldZones/coldRotation/coldOverpack/coldContam/coldFreezer): - zones (raw fish, 3 days): bench/door dangerous, fridge off, back use-soon, freezer fresh (0.94) -- a real placement gradient. - rotation: FIFO 0 waste vs newest-first 2 tubs rotted. - overpack: crammed fridge spoils milk to 0 vs 0.29 sparse. - contamination: raw-above-salad -> unsafe (q 0.29); same-shelf -> safe (0.88). - freezer: preserves 0.93 the bench lost (0), thaw docks usable to 0.76. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8b13ac68a8
commit
e7ed091077
80
src/dev.ts
80
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<string, { fresh: number; word: string }> = {};
|
||||
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). ----
|
||||
|
||||
|
||||
207
src/sim/coldchain.ts
Normal file
207
src/sim/coldchain.ts
Normal file
@ -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<ZoneId, number> = {
|
||||
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<ZoneId, number> = {
|
||||
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<ZoneId, StoredItem[]>;
|
||||
/** 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<StoredItem> & { 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user