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:
parent
a2ecfdda8f
commit
2085f68655
@ -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
|
||||||
|
|||||||
39
src/dev.ts
39
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) };
|
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
|
||||||
|
|||||||
@ -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!;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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
305
src/scenes/fridge.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user