From 2085f68655e99de6cea8c90aaef0a25137b2d3cb Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 21:22:20 +1000 Subject: [PATCH] The cold chain is playable: day 17, put the delivery away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KITCHEN-ATLAS.md | 12 +- src/dev.ts | 39 ++++++ src/game/game.ts | 46 ++++++- src/game/judging.ts | 54 +++++++- src/game/lines.ts | 31 +++++ src/game/orders.ts | 18 +++ src/scenes/fridge.ts | 305 +++++++++++++++++++++++++++++++++++++++++++ src/scenes/judge.ts | 13 +- src/sim/coldchain.ts | 22 ++-- 9 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 src/scenes/fridge.ts diff --git a/KITCHEN-ATLAS.md b/KITCHEN-ATLAS.md index 05eac73..4847e87 100644 --- a/KITCHEN-ATLAS.md +++ b/KITCHEN-ATLAS.md @@ -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 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` is built and gate-verified — spoilage is the temperature clock pointed the other diff --git a/src/dev.ts b/src/dev.ts index dbcae5b..0a3b0be 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -517,6 +517,45 @@ export function installDevHarness(app: App, game: Game): void { 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. ---- /** Same fish stored in each zone for `days` — freshness must fall fast on the diff --git a/src/game/game.ts b/src/game/game.ts index 45a6a0e..d9c6392 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -7,6 +7,7 @@ import { JuiceView } from '../scenes/juice'; import { OvenView } from '../scenes/oven'; import { GrillView } from '../scenes/grill'; import { PanView } from '../scenes/pan'; +import { FridgeView } from '../scenes/fridge'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; @@ -14,7 +15,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing'; import { TempClock } from '../sim/tempclock'; import type { RoastResult } from '../sim/roasting'; 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 { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -51,6 +52,7 @@ export class Game { private oven: OvenView; private grill: GrillView; private pan: PanView; + private fridge: FridgeView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -87,6 +89,7 @@ export class Game { this.oven = new OvenView(app); this.grill = new GrillView(app); this.pan = new PanView(app); + this.fridge = new FridgeView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -97,6 +100,7 @@ export class Game { app.scene.add(this.oven.root); app.scene.add(this.grill.root); app.scene.add(this.pan.root); + app.scene.add(this.fridge.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -106,6 +110,7 @@ export class Game { this.oven.root.visible = false; this.grill.root.visible = false; this.pan.root.visible = false; + this.fridge.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -202,6 +207,9 @@ export class Game { get panView(): PanView { return this.pan; } + get fridgeView(): FridgeView { + return this.fridge; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -370,6 +378,7 @@ export class Game { this.oven.root.visible = false; this.grill.root.visible = false; this.pan.root.visible = false; + this.fridge.root.visible = false; this.assembly.root.visible = false; } @@ -503,9 +512,44 @@ export class Game { this.enterPan(); return; } + // A fridge day: put the delivery away, three days pass, the inspector looks. + if (this.order.fridge) { + this.enterFridge(); + return; + } 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): 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. */ private enterPan(): void { const p = this.order.pan!; diff --git a/src/game/judging.ts b/src/game/judging.ts index 1dba231..6a378bf 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -10,6 +10,7 @@ import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly'; import type { GrillResult } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal'; import type { PanResult } from '../sim/pan'; +import type { storeHealth } from '../sim/coldchain'; export interface Criterion { key: string; @@ -22,7 +23,7 @@ export interface Criterion { /** Which station earned it — the scorecard groups by this on prep orders. * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * 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. */ @@ -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: [] }; } +// ===================================================================== +// 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, 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 // good butter not burnt (the bitterness), and is the finished piece even. diff --git a/src/game/lines.ts b/src/game/lines.ts index c387bca..dc4dd02 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -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. Object.assign(BANK, { pansear: { diff --git a/src/game/orders.ts b/src/game/orders.ts index 4abe345..2302573 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -102,6 +102,9 @@ export interface Order { /** 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 }; + /** 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][] = [ @@ -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.', 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. */ diff --git a/src/scenes/fridge.ts b/src/scenes/fridge.ts new file mode 100644 index 0000000..ce9ed3a --- /dev/null +++ b/src/scenes/fridge.ts @@ -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 & { 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 = {}; + 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(); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 8eee8ad..f7e8643 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -109,9 +109,11 @@ export class JudgeView implements View { ? `Day ${order.day} · ${order.who} asked for the barbie` : order.pan ? `Day ${order.day} · ${order.who} asked for the pan-sear` - : 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}`; + : order.fridge + ? `Day ${order.day} · the fridge, three days on` + : 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); // 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; // a plain toast order keeps the flat card it always had. 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 = { prep: 'THE PREP', @@ -129,9 +131,10 @@ export class JudgeView implements View { topping: 'THE TOPPING', grill: 'THE GRILL', pan: 'THE PAN', + cold: 'THE FRIDGE', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped diff --git a/src/sim/coldchain.ts b/src/sim/coldchain.ts index cf725db..907fe01 100644 --- a/src/sim/coldchain.ts +++ b/src/sim/coldchain.ts @@ -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 * 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 { for (const zone of Object.keys(s.zones) as ZoneId[]) { const items = s.zones[zone]; @@ -137,14 +140,17 @@ export function coldStep(s: ColdStore, days: number): void { // 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 - } + } + // Contamination is a drip: a raw item soils any ready-to-eat item on a lower + // shelf ANYWHERE in the same fridge box (put raw meat on the BOTTOM). The + // freezer and the bench are separate — a drip doesn't cross them. + const box = FRIDGE_UNIT.flatMap((z) => s.zones[z]); + for (const raw of box) { + 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); } } }