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:
type-two 2026-07-19 20:55:55 +10:00
parent 8b13ac68a8
commit e7ed091077
2 changed files with 287 additions and 0 deletions

View File

@ -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
View 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` 10 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,
};
}