The cold chain is playable: day 17, put the delivery away

Wired coldchain.ts into a live day. scenes/fridge.ts — a front-view fridge
with zones (freezer drawer / cold back / mid shelves / warm door + glass
shelves), a delivery you drag in, items that recolour as they spoil. Day
17 'put it away': stock it right, ENTER shuts the door (3 days pass),
ENTER again and the inspector opens it. judgeFridge scores THE FRIDGE
(The Cold / Hygiene) + The Waste; hygiene-inspector line bank ('Raw
chicken dripping onto the salad. That is not a fridge, that is a crime
scene with a light in it.').

Sim fix: contamination drips across the whole fridge interior by shelf
height (raw-on-bottom is the real lesson), not just within one zone.

Verified live: day 17 routes to the fridge; 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 21:22:20 +10:00
parent a2ecfdda8f
commit 2085f68655
9 changed files with 524 additions and 16 deletions

View File

@ -241,7 +241,17 @@ The pot is the pan's sibling: a heat-under-vessel with a LIQUID field.
bucket, preserving jars ×4 (empty/full family), gravlax slab, curing bucket, preserving jars ×4 (empty/full family), gravlax slab, curing
shelf, ice cream scoop, coupe glass. shelf, ice cream scoop, coupe glass.
### P10.5 — THE COLD CHAIN (cure's defensive twin — SIM BUILT & VERIFIED 2026-07-20) ### P10.5 — THE COLD CHAIN (cure's defensive twin — PLAYABLE & LIVE 2026-07-20)
**SHIPPED**: `src/scenes/fridge.ts` + **day 17 "put the delivery away"** — drag a
delivery into the fridge's zones (freezer drawer / cold back / mid shelves / warm
door), shut the door, three days compress-pass (items recolour as they spoil), and
`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.
The cure is time you *want*; the cold chain is time you *fight*. `src/sim/coldchain.ts` The cure is time you *want*; the cold chain is time you *fight*. `src/sim/coldchain.ts`
is built and gate-verified — spoilage is the temperature clock pointed the other is built and gate-verified — spoilage is the temperature clock pointed the other

View File

@ -517,6 +517,45 @@ export function installDevHarness(app: App, game: Game): void {
return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) }; return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) };
}, },
/** 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) {
game.setDay(17);
run(5);
const fv = game.fridgeView as unknown as {
items: { it: import('./sim/coldchain').StoredItem; mesh: unknown }[];
storeState: import('./sim/coldchain').ColdStore;
};
const s = fv.storeState;
if (!s) return { error: 'not at fridge' };
// Place every delivery item: good = raw low/cold, perishables cold, RTE high
// and safe; bad = everything crammed in the warm door with raw on top.
const move = (it: import('./sim/coldchain').StoredItem, zone: import('./sim/coldchain').ZoneId, shelf: number) => {
const from = s.zones[it.zone];
const i = from.indexOf(it);
if (i >= 0) from.splice(i, 1);
it.zone = zone; it.shelf = shelf;
s.zones[zone].push(it);
};
for (const { it } of fv.items) {
if (good) {
if (it.raw) move(it, 'back', 0); // raw meat low + coldest
else if (it.readyToEat) move(it, 'fridge', 2); // ready-to-eat up high, safe
else move(it, 'fridge', 1); // dairy/veg mid-cold
} else {
move(it, it.raw ? 'fridge' : 'door', it.raw ? 2 : 1); // raw HIGH, everything warm
}
}
tap('Enter'); // shut the door — 3 days pass
run(10);
tap('Enter'); // the inspector opens it → serve
run(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, groups, rows };
},
// ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ---- // ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ----
/** Same fish stored in each zone for `days` freshness must fall fast on the /** Same fish stored in each zone for `days` freshness must fall fast on the

View File

@ -7,6 +7,7 @@ import { JuiceView } from '../scenes/juice';
import { OvenView } from '../scenes/oven'; import { OvenView } from '../scenes/oven';
import { GrillView } from '../scenes/grill'; import { GrillView } from '../scenes/grill';
import { PanView } from '../scenes/pan'; import { PanView } from '../scenes/pan';
import { FridgeView } from '../scenes/fridge';
import { AssemblyView } from '../scenes/assembly'; import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting'; import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients'; import type { IngredientId } from '../sim/ingredients';
@ -14,7 +15,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock'; import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting'; import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, judgeGrill, judgePan, type BruschettaResult, type PrepResult, type Verdict } from './judging'; import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge'; import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -51,6 +52,7 @@ export class Game {
private oven: OvenView; private oven: OvenView;
private grill: GrillView; private grill: GrillView;
private pan: PanView; private pan: PanView;
private fridge: FridgeView;
private assembly: AssemblyView; private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */ /** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null; private prepResult: PrepResult | null = null;
@ -87,6 +89,7 @@ export class Game {
this.oven = new OvenView(app); this.oven = new OvenView(app);
this.grill = new GrillView(app); this.grill = new GrillView(app);
this.pan = new PanView(app); this.pan = new PanView(app);
this.fridge = new FridgeView(app);
this.assembly = new AssemblyView(app); this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root); app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root); app.scene.add(this.judgeView.root);
@ -97,6 +100,7 @@ export class Game {
app.scene.add(this.oven.root); app.scene.add(this.oven.root);
app.scene.add(this.grill.root); app.scene.add(this.grill.root);
app.scene.add(this.pan.root); app.scene.add(this.pan.root);
app.scene.add(this.fridge.root);
app.scene.add(this.assembly.root); app.scene.add(this.assembly.root);
this.judgeView.root.visible = false; this.judgeView.root.visible = false;
this.drawer.root.visible = false; this.drawer.root.visible = false;
@ -106,6 +110,7 @@ export class Game {
this.oven.root.visible = false; this.oven.root.visible = false;
this.grill.root.visible = false; this.grill.root.visible = false;
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
@ -202,6 +207,9 @@ export class Game {
get panView(): PanView { get panView(): PanView {
return this.pan; return this.pan;
} }
get fridgeView(): FridgeView {
return this.fridge;
}
get assemblyView(): AssemblyView { get assemblyView(): AssemblyView {
return this.assembly; return this.assembly;
} }
@ -370,6 +378,7 @@ export class Game {
this.oven.root.visible = false; this.oven.root.visible = false;
this.grill.root.visible = false; this.grill.root.visible = false;
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
} }
@ -503,9 +512,44 @@ export class Game {
this.enterPan(); this.enterPan();
return; return;
} }
// A fridge day: put the delivery away, three days pass, the inspector looks.
if (this.order.fridge) {
this.enterFridge();
return;
}
this.enterToastFlow(); this.enterToastFlow();
} }
/** The cold-chain day: stock the fridge right, age it, let the inspector look. */
private enterFridge(): void {
this.fridge.reset();
this.hideAllScenes();
this.fridge.root.visible = true;
this.app.setView(this.fridge);
this.ticket.show();
this.fridge.onDone = (result) => {
this.fridge.root.visible = false;
this.serveFridge(result);
};
}
private serveFridge(result: ReturnType<FridgeView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeFridge(result, this.order, seconds);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
this.hideAllScenes();
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** The pan day (M17): a fuel-aware hob — butter it, foam it, flip it, serve. */ /** The pan day (M17): a fuel-aware hob — butter it, foam it, flip it, serve. */
private enterPan(): void { private enterPan(): void {
const p = this.order.pan!; const p = this.order.pan!;

View File

@ -10,6 +10,7 @@ import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly';
import type { GrillResult } from '../sim/charcoal'; import type { GrillResult } from '../sim/charcoal';
import { grillWord } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal';
import type { PanResult } from '../sim/pan'; import type { PanResult } from '../sim/pan';
import type { storeHealth } from '../sim/coldchain';
export interface Criterion { export interface Criterion {
key: string; key: string;
@ -22,7 +23,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders. /** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */ * distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan'; group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold';
} }
/** What the prep bench hands the judge, when the order had prep on it. */ /** What the prep bench hands the judge, when the order had prep on it. */
@ -494,6 +495,57 @@ export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdi
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
} }
// =====================================================================
// The cold chain — the fridge day. After three days, how much of your delivery
// survived (placement/cold), did anything drip (hygiene), and what did you waste.
export function judgeFridge(h: ReturnType<typeof storeHealth>, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'coldkept',
group: 'cold',
label: 'The Cold',
score: clamp01(1 - h.off * 1.6),
weight: 1.4,
detail: h.off > 0.01 ? `${Math.round(h.off * 100)}% went off` : 'nothing spoiled',
},
{
key: 'hygiene',
group: 'cold',
label: 'Hygiene',
score: clamp01(1 - h.contaminated * 2.2),
weight: 1.2,
detail: h.contaminated > 0.01 ? `${Math.round(h.contaminated * 100)}% dripped on` : 'no cross-contamination',
},
{
key: 'waste',
group: 'bench',
label: 'The Waste',
score: clamp01(1 - h.waste * 0.25),
weight: 0.7,
detail: h.waste > 0 ? `${h.waste} thrown out` : 'nothing wasted',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 40) / 90),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
void order;
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// ===================================================================== // =====================================================================
// M17 — The pan. Did both faces land a proper sear (the flip), did you cook in // 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. // good butter not burnt (the bitterness), and is the finished piece even.

View File

@ -336,6 +336,37 @@ Object.assign(BANK, {
}, },
}); });
// The fridge — the hygiene inspector, who is happiest saying nothing.
Object.assign(BANK, {
coldkept: {
bad: [
'Half this delivery is a science experiment now. Cold things go COLD, love.',
'You put the fish in the door. The door. The warmest shelf in the box.',
'This has gone off. Not adventurous. Off. There is a difference and you crossed it.',
'Perishables belong in the back where it is coldest. You knew that. The bin knows it now.',
],
good: [
'Everything still fresh in three days. You know where the cold lives. Good.',
'Stored like someone who has been paged about a bad prawn. Correct.',
],
},
hygiene: {
bad: [
'Raw chicken dripping onto the salad. That is not a fridge, that is a crime scene with a light in it.',
'Something raw sat above something ready-to-eat. I can hear the health board from here.',
'Cross-contamination. The one rule. Raw goes on the BOTTOM. Always.',
],
good: [
'Raw on the bottom, nothing dripped. That is the whole discipline and you have it.',
'No cross-contamination. Boring. Boring is the goal. Well done.',
],
},
waste: {
bad: ['You threw good money in the bin because it rotted in the back. Rotate your stock.', 'Waste. That was profit, once.'],
good: ['Nothing wasted. You used the old before the new.'],
},
});
// The pan — the butter snob. // The pan — the butter snob.
Object.assign(BANK, { Object.assign(BANK, {
pansear: { pansear: {

View File

@ -102,6 +102,9 @@ export interface Order {
/** The pan (M17): the food to sear, and the fuel the hob runs on. Presence /** 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. */ * sends the day to the pan butter it, foam it, flip it, don't burn it. */
pan?: { food: string; fuel: string }; pan?: { food: string; fuel: string };
/** 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;
} }
export const BROWNING_NAMES: [number, string][] = [ export const BROWNING_NAMES: [number, string][] = [
@ -383,6 +386,21 @@ export const DAYS: Order[] = [
text: 'Pan-sear the mushrooms on the gas. Butter in first — wait for the FOAM, not the smoke. Land them in the foam, brown both faces, baste them in the noisette. Burn the butter and I will know.', text: 'Pan-sear the mushrooms on the gas. Butter in first — wait for the FOAM, not the smoke. Land them in the foam, brown both faces, baste them in the noisette. Burn the butter and I will know.',
pan: { food: 'button_mushroom', fuel: 'gas' }, pan: { food: 'button_mushroom', fuel: 'gas' },
}, },
{
day: 17,
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 delivery, at 6am',
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,
},
]; ];
/** Days beyond the script — keep going, with everything cranked. */ /** Days beyond the script — keep going, with everything cranked. */

305
src/scenes/fridge.ts Normal file
View File

@ -0,0 +1,305 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import {
type ColdStore,
type StoredItem,
type ZoneId,
newColdStore,
store,
coldStep,
storeHealth,
ZONE_COLD,
} from '../sim/coldchain';
import { el, Panel } from '../ui/hud';
/** A drop target on the fridge front: a rectangle mapped to (zone, shelf). */
interface Bay {
key: string;
zone: ZoneId;
shelf: number;
x0: number;
x1: number;
y0: number;
y1: number;
label: string;
}
const PLANE_Z = 0.25;
// The fridge front, laid out in world XY. Colder toward the bottom/back; the
// door rack (right) is the warmest; the freezer is its own drawer.
const BAYS: Bay[] = [
{ key: 'freezer', zone: 'freezer', shelf: 0, x0: -1.4, x1: 1.4, y0: 0.25, y1: 0.85, label: 'FREEZER' },
{ key: 'back', zone: 'back', shelf: 0, x0: -1.4, x1: 0.75, y0: 0.95, y1: 1.55, label: 'bottom shelf — coldest' },
{ key: 'fridgeMid', zone: 'fridge', shelf: 1, x0: -1.4, x1: 0.75, y0: 1.6, y1: 2.15, label: 'middle shelf' },
{ key: 'fridgeTop', zone: 'fridge', shelf: 2, x0: -1.4, x1: 0.75, y0: 2.2, y1: 2.8, label: 'top shelf' },
{ key: 'door', zone: 'door', shelf: 1, x0: 0.85, x1: 1.45, y0: 0.95, y1: 2.8, label: 'door — warmest' },
];
/**
* THE FRIDGE put the delivery away, and put it away RIGHT.
*
* Raw meat goes LOW (a drip falls on anything below it). The perishable stuff
* goes COLD (the back and bottom; the door is warm). Don't cram one shelf a
* packed shelf can't breathe and warms up. Then you shut the door, a few days
* pass, and the inspector opens it. What you stored badly has gone off.
*/
export class FridgeView implements View {
readonly root = new THREE.Group();
private storeState: ColdStore | null = null;
private items: { it: StoredItem; mesh: THREE.Mesh }[] = [];
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -PLANE_Z);
private grabbed = -1;
private wasDown = false;
private aged = false;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof storeHealth> & { store: ColdStore }) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('fridge-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'PUT IT AWAY');
this.askEl = el('div', 'slicer-ask', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const wall = new THREE.Mesh(
new THREE.PlaneGeometry(9, 6),
new THREE.MeshStandardMaterial({ color: 0x24262b, roughness: 1 }),
);
wall.position.set(0, 1.5, -0.6);
this.root.add(wall);
// The fridge body: a light shell with a dark interior the bays sit in.
const shell = new THREE.Mesh(
new THREE.BoxGeometry(3.4, 3.1, 0.7),
new THREE.MeshStandardMaterial({ color: 0xcfd3d8, roughness: 0.5, metalness: 0.3 }),
);
shell.position.set(0, 1.55, -0.15);
this.root.add(shell);
const cavity = new THREE.Mesh(
new THREE.BoxGeometry(3.05, 2.75, 0.4),
new THREE.MeshStandardMaterial({ color: 0x0d1013, roughness: 0.9 }),
);
cavity.position.set(0, 1.55, 0.05);
this.root.add(cavity);
// Each bay gets a tinted panel — bluer = colder — and a glass shelf under it.
const shelfMat = new THREE.MeshStandardMaterial({ color: 0x9fb3c0, roughness: 0.3, metalness: 0.4, transparent: true, opacity: 0.5 });
for (const b of BAYS) {
const cold = ZONE_COLD[b.zone];
const panel = new THREE.Mesh(
new THREE.PlaneGeometry(b.x1 - b.x0, b.y1 - b.y0),
new THREE.MeshBasicMaterial({
color: new THREE.Color().setRGB(0.28 - cold * 0.22, 0.42 + cold * 0.12, 0.55 + cold * 0.4),
transparent: true,
opacity: 0.32,
}),
);
panel.position.set((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, PLANE_Z - 0.02);
this.root.add(panel);
// A glass shelf lip at the bottom of each bay (not the door — that's a rack).
if (b.zone !== 'door') {
const shelf = new THREE.Mesh(new THREE.BoxGeometry(b.x1 - b.x0, 0.03, 0.34), shelfMat);
shelf.position.set((b.x0 + b.x1) / 2, b.y0 - 0.01, 0.08);
this.root.add(shelf);
}
}
// A vertical divider between the main compartment and the door rack.
const divider = new THREE.Mesh(
new THREE.BoxGeometry(0.04, 1.9, 0.34),
new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.6 }),
);
divider.position.set(0.8, 1.85, 0.08);
this.root.add(divider);
}
/** A delivery of `spec` items, all landed on the bench (below the fridge). */
reset(askText = 'put the delivery away — raw low, perishables cold, don\'t cram it'): void {
for (const { mesh } of this.items) this.root.remove(mesh);
this.items = [];
this.aged = false;
this.grabbed = -1;
const s = newColdStore();
this.storeState = s;
// The delivery: raw proteins, dairy, a ready-to-eat salad, hardy veg. A couple
// arrive already a day or two old — those want the coldest spots.
const spec: { id: string; perish: number; raw?: boolean; rte?: boolean; age?: number; color: number }[] = [
{ id: 'chicken', perish: 2, raw: true, color: 0xe8cdb0 },
{ id: 'fish', perish: 2, raw: true, age: 1, color: 0xcdd7dd },
{ id: 'mince', perish: 1.8, raw: true, color: 0xb5453a },
{ id: 'milk', perish: 1, color: 0xf2f2f2 },
{ 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: 'onion', perish: 0.3, color: 0xb99a5b },
];
spec.forEach((sp, i) => {
const it = store(s, { id: sp.id, perish: sp.perish, raw: !!sp.raw, readyToEat: !!sp.rte }, 'bench');
if (sp.age) {
it.age = sp.age;
it.freshness = Math.max(0.2, 1 - sp.age * 0.12);
}
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.3, 0.22),
new THREE.MeshStandardMaterial({ color: sp.color, roughness: 0.6 }),
);
mesh.castShadow = true;
mesh.userData = { baseColor: sp.color };
// Lay the delivery out in a row along the bench.
mesh.position.set(-1.75 + i * 0.5, -0.15, PLANE_Z);
this.root.add(mesh);
this.items.push({ it, mesh });
});
this.askEl.textContent = askText;
this.hintEl.textContent = 'DRAG each item into the fridge · raw meat LOW · perishables COLD · ENTER shuts the door';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
private bayAt(x: number, y: number): Bay | null {
for (const b of BAYS) if (x >= b.x0 && x <= b.x1 && y >= b.y0 && y <= b.y1) return b;
return null;
}
update(_dt: number): void {
this.app.camera.position.set(0, 1.5, 4.2);
this.app.camera.lookAt(0, 1.4, 0);
const s = this.storeState;
if (!s) return;
const inp = this.app.input;
if (this.aged) {
// The reveal is up; the next ENTER opens the door for the inspector.
if (inp.justPressed('Enter')) {
const cb = this.onDone;
this.onDone = null;
cb?.({ ...storeHealth(s), store: s });
}
return;
}
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
if (inp.down && on) {
if (!this.wasDown) this.grabbed = this.nearest(this.hit.x, this.hit.y, 0.24);
if (this.grabbed >= 0) {
this.items[this.grabbed].mesh.position.set(this.hit.x, this.hit.y, PLANE_Z + 0.05);
}
}
if (!inp.down && this.wasDown && this.grabbed >= 0) {
// Dropped: which bay caught it?
const { it, mesh } = this.items[this.grabbed];
const bay = this.bayAt(mesh.position.x, mesh.position.y);
const from = it.zone;
it.zone = bay ? bay.zone : 'bench';
it.shelf = bay ? bay.shelf : 0;
this.moveItem(it, from);
audio.clatter(0.3, 1.3);
this.grabbed = -1;
}
this.wasDown = inp.down;
this.syncItems();
this.updateHud();
if (inp.justPressed('Enter')) this.ageThreeDays();
}
private moveItem(it: StoredItem, from: ZoneId): void {
const arr = this.storeState!.zones[from];
const i = arr.indexOf(it);
if (i >= 0) arr.splice(i, 1);
this.storeState!.zones[it.zone].push(it);
}
private nearest(x: number, y: number, r: number): number {
let best = -1;
let bd = r;
for (let i = 0; i < this.items.length; i++) {
const p = this.items[i].mesh.position;
const d = Math.hypot(p.x - x, p.y - y);
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}
/** Lay each stored item out inside its bay; recolour by freshness. */
private syncItems(): void {
const perBay: Record<string, number> = {};
for (const { it, mesh } of this.items) {
if (this.grabbed >= 0 && this.items[this.grabbed].it === it) continue; // held: follow the cursor
if (it.zone === 'bench') continue; // still on the bench, keep its spot
const b = BAYS.find((bb) => bb.zone === it.zone && bb.shelf === it.shelf) ?? BAYS.find((bb) => bb.zone === it.zone)!;
const n = perBay[b.key] ?? 0;
perBay[b.key] = n + 1;
const cols = Math.max(1, Math.floor((b.x1 - b.x0) / 0.36));
const cx = b.x0 + 0.2 + (n % cols) * 0.36;
const cy = (b.y0 + b.y1) / 2 + (Math.floor(n / cols) - 0.5) * 0.3;
mesh.position.set(Math.min(b.x1 - 0.15, cx), cy, PLANE_Z);
this.tint(it, mesh);
}
}
private tint(it: StoredItem, mesh: THREE.Mesh): void {
const base = new THREE.Color(mesh.userData.baseColor as number);
const mat = mesh.material as THREE.MeshStandardMaterial;
// Fresh = its own colour; going off = drift toward sickly grey-green-brown.
const off = 1 - it.freshness;
const spoiled = new THREE.Color(0.32, 0.3, 0.2);
mat.color.copy(base).lerp(spoiled, off * 0.85);
if (it.contaminated) mat.color.lerp(new THREE.Color(0.35, 0.4, 0.2), 0.4);
}
private ageThreeDays(): void {
const s = this.storeState!;
// Shut the door; three days pass.
coldStep(s, 3);
this.aged = true;
for (const { it, mesh } of this.items) this.tint(it, mesh);
const health = storeHealth(s);
this.stateEl.textContent = `three days on: ${Math.round(health.fresh * 100)}% fresh · ${health.waste} spoiled`;
this.hintEl.textContent = 'the inspector opens the door… ENTER for the verdict';
audio.clunk(true);
}
private updateHud(): void {
const s = this.storeState!;
const onBench = s.zones.bench.length;
this.stateEl.textContent = onBench > 0 ? `${onBench} still on the bench` : 'all away — ENTER shuts the door';
}
result() {
return this.storeState ? { ...storeHealth(this.storeState), store: this.storeState } : { fresh: 1, off: 0, contaminated: 0, waste: 0, store: newColdStore() };
}
dispose(): void {
this.panel.dispose();
}
}

View File

@ -109,9 +109,11 @@ export class JudgeView implements View {
? `Day ${order.day} · ${order.who} asked for the barbie` ? `Day ${order.day} · ${order.who} asked for the barbie`
: order.pan : order.pan
? `Day ${order.day} · ${order.who} asked for the pan-sear` ? `Day ${order.day} · ${order.who} asked for the pan-sear`
: order.bruschetta : order.fridge
? `Day ${order.day} · ${order.who} asked for the bruschetta` ? `Day ${order.day} · the fridge, three days on`
: `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; : order.bruschetta
? `Day ${order.day} · ${order.who} asked for the bruschetta`
: `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
clear(this.cardEl); clear(this.cardEl);
// On prep orders the card is long enough to need signposts: group rows by // On prep orders the card is long enough to need signposts: group rows by
@ -119,7 +121,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card; // Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had. // a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some( const grouped = verdict.criteria.some(
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan', (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold',
); );
const headers: Record<string, string> = { const headers: Record<string, string> = {
prep: 'THE PREP', prep: 'THE PREP',
@ -129,9 +131,10 @@ export class JudgeView implements View {
topping: 'THE TOPPING', topping: 'THE TOPPING',
grill: 'THE GRILL', grill: 'THE GRILL',
pan: 'THE PAN', pan: 'THE PAN',
cold: 'THE FRIDGE',
bench: 'THE BENCH', bench: 'THE BENCH',
}; };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, spread: 4, bench: 5 }; const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined; let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped const ordered = grouped

View File

@ -124,6 +124,9 @@ function airflowPenalty(s: ColdStore, zone: ZoneId): number {
* (degraded if the zone is crammed). The freezer all but stops it. Raw items drip * (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. * onto ready-to-eat items below them in the same zone.
*/ */
/** The zones that share one physical box — a drip crosses shelves within it. */
const FRIDGE_UNIT: ZoneId[] = ['door', 'fridge', 'back'];
export function coldStep(s: ColdStore, days: number): void { export function coldStep(s: ColdStore, days: number): void {
for (const zone of Object.keys(s.zones) as ZoneId[]) { for (const zone of Object.keys(s.zones) as ZoneId[]) {
const items = s.zones[zone]; const items = s.zones[zone];
@ -137,14 +140,17 @@ export function coldStep(s: ColdStore, days: number): void {
// Crossed into DANGEROUS, unused → waste. // Crossed into DANGEROUS, unused → waste.
if (before > OFF && it.freshness <= OFF) s.wasteCount++; 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) { // Contamination is a drip: a raw item soils any ready-to-eat item on a lower
if (!raw.raw) continue; // shelf ANYWHERE in the same fridge box (put raw meat on the BOTTOM). The
for (const rte of items) { // freezer and the bench are separate — a drip doesn't cross them.
if (rte.readyToEat && rte.shelf < raw.shelf && !rte.contaminated) { const box = FRIDGE_UNIT.flatMap((z) => s.zones[z]);
rte.contaminated = true; for (const raw of box) {
rte.freshness = Math.max(0, rte.freshness - 0.15); // the drip itself sets it back if (!raw.raw) continue;
} for (const rte of box) {
if (rte.readyToEat && rte.shelf < raw.shelf && !rte.contaminated) {
rte.contaminated = true;
rte.freshness = Math.max(0, rte.freshness - 0.15);
} }
} }
} }