From cc39478a6edef4f230fdc8f0980f2a09f1855bff Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 15:25:21 +1000 Subject: [PATCH] =?UTF-8?q?M15=20bruschetta=20capstone:=20roast=20?= =?UTF-8?q?=E2=86=92=20assemble=20=E2=86=92=20the=20sog=20finger-press,=20?= =?UTF-8?q?day=2014=20end=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-station order, on the proven bones. Cut the sourdough (M10) → toast (M1) → roast the tomatoes (toaster generalized) → assemble (garlic rub, topping point-set, sog clock) → serve. Judge groups The Bread / The Topping / The Bench. Planning is the mechanic: the temperature clock drifts the two cheeses on real service time. What I built on top of the partial work (sims + judge criteria + lines were already in place and good — kept them): - Temperature clock WIRED (sim/tempclock.ts): TempClock armed on bruschetta days, steps every tick while timing; a per-perishable fridge/bench toggle UI (game.showTempToggle) that reads the judge's own readiness word live; a toggle costs 8s service (reopening the fridge has teeth). Ticket hints: "get the brie out now", "leave the parmesan in". - Roast tomatoes WIRED: OvenView routed after the toast lands on bruschetta orders (kitchen.onPopped branch → enterOven). Browning Field reused, blister past 0.6, collapse to sauce past 0.9. oven_tray.glb is absent → loadProp catch-fallback to the procedural tray (graceful, as designed). - Assembly scene BUILT (scenes/assembly.ts, new): own toasted garlic-rubbed slice; G rubs the clove (one-swipe coverage), 1/2/3 drop tomato/cheese/basil as a point set; ENTER serves. Distribution scored by Clark-Evans (the rind code, fed the topping list); balance by mass-per-area band. - Sog clock: rate x wet-mass x moisture / thickness — the doorstop resists, the thin slice surrenders; the judge presses a finger ("It bends. Toast should not bend."). - Day 14 order added; enterOven/enterAssembly/serveBruschetta follow the exact runNextPrep/enterPrep/enterJuice machinery. Scorecard grouped bread/topping/bench via judge()'s existing group path. - Harness (src/dev.ts): t.roast(s,power), t.rub(passes), t.assemble({...}), t.sogWait(s), t.tempToggle(id), and t.bruschetta() happy path returning the full grouped stats. Fixes to the partial work: judge.ts shadowed the `order` param with a local `const order` (rename → groupRank; would not typecheck); oven.ts material opacity access typed as Material|Material[] (cast once). Tuning (measured headless — the pure sims run without three.js): - assembly SOG_RATE 0.006 → 0.004: doorstop (0.31) under a normal wet load (5 tomato ~2.1) served promptly (8s) stays CRISP (sog 0.21); dawdled 30s goes soft (0.77); a thin slice (0.12) bends inside ~15s. The bakery loaf matters. - assembly MASS_BAND [1.1,2.1] → [2.5,4.5]: the old band scored a normal build (5t+4c+3b = 3.82 total mass) as an off-band 0; the band now sits around a proper generous topping. Sparse ~1.3 reads bare, a heap ~6.6 slides off. - roasting RATE 0.042 kept; comment corrected to the measured window (power 6: stalls wet to ~26s, blisters 28-36s, collapses past ~40s). - brie softensInSec 80 (from partial work) kept: out at order start, spreadable by assembly. VERIFY (dev build; run in the console): t.start(); t.bruschetta() Expected (headless sim + live path): roast: {mean ~0.70, blister ~1.0, collapse 0, score ~1.0} distribution: ~1.0 (Clark-Evans R ~1.25, evenly strewn) balance: {mass ~3.82 in band [2.5,4.5], score ~1.0} sog: {value ~0.21, word "crisp", score ~0.99} rub: {coverage ~0.35-0.5, score ~1.0} temp: {score 1.0 (brie taken out, parmesan left in)} bench: ~1.0 (clean roast, no slump) grade A/S, total ~9 Sub-mechanic checks (after t.bruschetta up to the oven, or standalone): t.roast(26,6) → mean ~0.56, blister ~0 ("under-roasted") t.roast(42,6) → collapse ~0.69 ("collapsing to sauce") skip t.tempToggle('brie') → Temperature 0, worst "brie fridge-hard" thin slice + t.sogWait(15) → sog ~1.0, "it bends" typecheck clean (tsc --noEmit), vite build clean. node_modules is a symlink to the main checkout and is deliberately NOT committed (dir-only .gitignore rule misses the symlink; staged the 12 source files explicitly). Co-Authored-By: Claude Fable 5 --- src/dev.ts | 149 +++++++++++++++++++++ src/game/game.ts | 195 +++++++++++++++++++++++++++- src/game/judging.ts | 234 ++++++++++++++++++++++++++++++++- src/game/lines.ts | 83 ++++++++++++ src/game/orders.ts | 39 ++++++ src/scenes/assembly.ts | 284 +++++++++++++++++++++++++++++++++++++++++ src/scenes/judge.ts | 18 ++- src/scenes/oven.ts | 260 +++++++++++++++++++++++++++++++++++++ src/sim/assembly.ts | 179 ++++++++++++++++++++++++++ src/sim/ingredients.ts | 4 +- src/sim/roasting.ts | 150 ++++++++++++++++++++++ src/sim/tempclock.ts | 141 ++++++++++++++++++++ 12 files changed, 1728 insertions(+), 8 deletions(-) create mode 100644 src/scenes/assembly.ts create mode 100644 src/scenes/oven.ts create mode 100644 src/sim/assembly.ts create mode 100644 src/sim/roasting.ts create mode 100644 src/sim/tempclock.ts diff --git a/src/dev.ts b/src/dev.ts index 5a7f7ea..77a0f97 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -1,6 +1,8 @@ import * as THREE from 'three'; import type { App } from './core/app'; import type { Game } from './game/game'; +import { sogWord } from './sim/assembly'; +import type { IngredientId } from './sim/ingredients'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -419,6 +421,153 @@ export function installDevHarness(app: App, game: Game): void { frames: f, }; }, + + // --- M15 bruschetta ------------------------------------------------------ + + /** Flip a perishable fridge↔bench on the temp clock (costs service time). */ + tempToggle(id: IngredientId) { + game.toggleTemp(id); + run(1); + return game.tempClock?.readiness(id); + }, + + /** + * Drive the routed oven (the one a day-14 order sends you to): dial in the + * heat, slide the tray in, roast `seconds`, pull it out. Leaves it at 'done'; + * `t.bruschetta` advances with ENTER. ~32s@6 lands a blistered ~0.70, none + * slumped. + */ + roast(seconds = 32, power = 6) { + const oven = game.ovenView; + oven.power = power; + run(3); + tap('Space'); // tray in + run(Math.round(seconds * 60)); + tap('Space'); // tray out + run(3); + return oven.result(); + }, + + /** + * Rub garlic across the toast at the assembly bench: G-mode, then a raster + * drag. `passes` rows of strokes; 3 lands ~0.35 coverage — a light, even + * coat, full marks. + */ + rub(passes = 3) { + const a = game.assemblyView; + run(2); + tap('KeyG'); + for (let p = 0; p < passes; p++) { + const v = 0.2 + p * 0.18; + const cols = 12; + for (let i = 0; i <= cols; i++) { + const t = i / cols; + const u = 0.15 + (p % 2 ? 1 - t : t) * 0.7; + const [x, y, z] = a.worldAt(u, v); + point(x, y, z, true); + run(1); + } + } + app.input.down = false; + run(1); + return { rubCoverage: +a.result().rubCoverage.toFixed(3) }; + }, + + /** + * Drop a set of toppings on a shared even grid — kinds interleaved onto one + * grid so the point set stays evenly strewn (Clark–Evans R ~1.25). The digit + * keys pick the tool; each press-edge drops one piece. + */ + assemble(counts: { tomato?: number; cheese?: number; basil?: number } = { tomato: 5, cheese: 4, basil: 3 }) { + const a = game.assemblyView; + const kinds: [string, string][] = []; + const push = (n: number, key: string) => { + for (let i = 0; i < n; i++) kinds.push([key, key]); + }; + push(counts.tomato ?? 0, 'Digit1'); + push(counts.cheese ?? 0, 'Digit2'); + push(counts.basil ?? 0, 'Digit3'); + const n = kinds.length; + const cols = Math.max(1, Math.ceil(Math.sqrt(n * 1.3))); + const rows = Math.max(1, Math.ceil(n / cols)); + let lastKey = ''; + run(2); + for (let i = 0; i < n; i++) { + const key = kinds[i][1]; + if (key !== lastKey) { + tap(key); + lastKey = key; + run(1); + } + const c = i % cols; + const rw = Math.floor(i / cols); + const u = 0.14 + ((c + 0.5) / cols) * 0.72; + const v = 0.14 + ((rw + 0.5) / rows) * 0.72; + const [x, y, z] = a.worldAt(u, v); + point(x, y, z, false); + run(1); + point(x, y, z, true); // press edge → one placement + run(1); + } + app.input.down = false; + run(1); + const res = a.result(); + return { count: res.count, mass: +res.massPerArea.toFixed(2), rub: +res.rubCoverage.toFixed(3), kinds: res.kinds }; + }, + + /** Let the sog clock run `seconds` at the assembly bench, then read it. */ + sogWait(seconds = 8) { + run(Math.round(seconds * 60)); + const sog = game.assemblyView.result().sog; + return { sog: +sog.toFixed(3), word: sogWord(sog) }; + }, + + /** Read the last bruschetta verdict — the full stats, grouped by station. */ + bruschettaStats() { + const lb = game.lastBruschettaStats; + if (!lb) return null; + const { result, verdict } = lb; + const crit = (k: string) => { + const c = verdict.criteria.find((x) => x.key === k); + return c ? +c.score.toFixed(3) : null; + }; + const r = (n: number) => +n.toFixed(3); + return { + grade: verdict.grade, + total: +verdict.total.toFixed(2), + roast: { mean: r(result.roast.mean), blister: r(result.roast.blisterFrac), collapse: r(result.roast.collapseFrac), score: crit('roast') }, + distribution: crit('topdist'), + balance: { mass: r(result.assembly.massPerArea), score: crit('balance') }, + sog: { value: r(result.assembly.sog), word: sogWord(result.assembly.sog), score: crit('sog') }, + rub: { coverage: r(result.assembly.rubCoverage), score: crit('rub') }, + temp: { score: r(result.temp.score), worst: result.temp.worstName, criterion: crit('temp') }, + bench: crit('bench'), + criteria: verdict.criteria.map((c) => ({ key: c.key, group: c.group ?? '-', score: +c.score.toFixed(2) })), + }; + }, + + /** + * The whole bruschetta, end to end and headless: day 14, plan the brie out, + * cut the doorstop, toast it, roast the tomatoes, rub + top + wait, serve. + * Returns the full grouped stats. + */ + bruschetta() { + game.setDay(14); + run(5); + game.toggleTemp('brie'); // take the brie out early — it must be soft + harness.pick('bread_knife'); // knife trip → slicer + harness.slice({ thickness: 0.31, dy: 0.05 }); // cut the doorstop → kitchen + harness.toast(12, 6); // toast → lands → enterOven + harness.roast(32, 6); // roast the tomatoes + tap('Enter'); // oven done → assembly + run(3); + harness.rub(3); + harness.assemble({ tomato: 5, cheese: 4, basil: 3 }); + harness.sogWait(8); + tap('Enter'); // serve → the judge + run(5); + return harness.bruschettaStats(); + }, }; (window as unknown as { __t: unknown }).__t = harness; diff --git a/src/game/game.ts b/src/game/game.ts index 2ea67af..a5eb9f2 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -4,10 +4,15 @@ import { KitchenView } from '../scenes/kitchen'; import { SlicerView } from '../scenes/slicer'; import { PrepView } from '../scenes/prep'; import { JuiceView } from '../scenes/juice'; +import { OvenView } from '../scenes/oven'; +import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; import { juiceCriteria, type JuiceResult } from '../sim/juicing'; -import type { PrepResult } from './judging'; +import { TempClock } from '../sim/tempclock'; +import type { RoastResult } from '../sim/roasting'; +import type { AssemblyResult } from '../sim/assembly'; +import { judgeBruschetta, 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'; @@ -21,6 +26,9 @@ import { Title } from '../ui/title'; const SAVE_KEY = 'toastsim.save'; +/** Reopening the fridge mid-service costs you — the planning has teeth. */ +const TEMP_TOGGLE_COST = 8; + interface Save { day: number; total: number; @@ -38,10 +46,16 @@ export class Game { private slicer: SlicerView; private prep: PrepView; private juice: JuiceView; + private oven: OvenView; + private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; /** What the juice station reported, if the order sent you there. */ private juiceResult: JuiceResult | null = null; + /** The temperature clock for the current bruschetta order (brie/parmesan). */ + private temp: TempClock | null = null; + /** The last bruschetta verdict, for the harness to read. */ + private lastBruschetta: { result: BruschettaResult; verdict: Verdict } | null = null; /** Remaining prep steps for the current order, drained as each hands off. */ private prepQueue: PrepStep[] = []; /** The pre-toast drawer trip on loaf days: you're after a bread knife. */ @@ -54,6 +68,9 @@ export class Game { private save: Save; private ticket: Panel; private ticketEl!: HTMLElement; + /** The fridge/bench toggle, one row per perishable, shown on bruschetta days. */ + private tempPanel: Panel; + private tempRows: { id: IngredientId; btn: HTMLButtonElement }[] = []; constructor(private app: App) { this.save = loadSave(); @@ -63,25 +80,39 @@ export class Game { this.slicer = new SlicerView(app); this.prep = new PrepView(app); this.juice = new JuiceView(app); + this.oven = new OvenView(app); + this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); app.scene.add(this.drawer.root); app.scene.add(this.slicer.root); app.scene.add(this.prep.root); app.scene.add(this.juice.root); + app.scene.add(this.oven.root); + app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; this.prep.root.visible = false; this.juice.root.visible = false; + this.oven.root.visible = false; + this.assembly.root.visible = false; this.ticket = new Panel('ticket'); this.ticketEl = el('div', 'ticket-body', this.ticket.root); + this.tempPanel = new Panel('temp-toggle'); + this.tempPanel.hide(); // toast -> drawer -> spread. The drawer sits AFTER the toaster on purpose: // its cost is your toast going cold, and cold toast is what makes butter // impossible. Dawdling here is the punishment. - this.kitchen.onPopped = () => this.openDrawer(); + // After the toast lands: a bruschetta order goes to the oven instead of the + // spread drawer (the garlic rub replaces the spread), everything else opens + // the drawer as it always did. + this.kitchen.onPopped = () => { + if (this.order.bruschetta) this.enterOven(); + else this.openDrawer(); + }; this.kitchen.onServed = () => this.serve(); this.drawer.onPicked = (tool) => this.closeDrawer(tool); this.slicer.onSliced = (cut) => { @@ -103,6 +134,12 @@ export class Game { // while you rummage" is the entire reason the drawer exists. app.onTick((dt) => { if (this.timing) this.orderSeconds += dt; + // The temperature clock drifts on real service time, whatever screen you're + // on: the brie warms toward spreadable, the parmesan warms toward smearing. + if (this.temp && this.timing) { + this.temp.step(dt); + this.refreshTempToggle(); + } // On the knife trip there's no toast to cool yet — the timer is the // customer's patience instead. if (this.knifeTrip) { @@ -146,6 +183,28 @@ export class Game { get juiceView(): JuiceView { return this.juice; } + get ovenView(): OvenView { + return this.oven; + } + get assemblyView(): AssemblyView { + return this.assembly; + } + get tempClock(): TempClock | null { + return this.temp; + } + /** The last bruschetta verdict + inputs, for the harness. */ + get lastBruschettaStats() { + return this.lastBruschetta; + } + + /** Flip a perishable fridge↔bench. Revisiting the fridge costs service time. */ + toggleTemp(id: IngredientId): void { + if (!this.temp) return; + this.temp.toggle(id); + // Opening the fridge again mid-service is not free. + this.orderSeconds += TEMP_TOGGLE_COST; + this.refreshTempToggle(); + } /** * Send the player to the prep bench in isolation. The harness drives this @@ -212,6 +271,90 @@ export class Game { }; } + /** + * The oven's door, same shape as enterPrep/enterJuice: a tray of tomato halves, + * the roast Field, and a hand-off to the assembly bench once ENTER pulls them. + */ + private enterOven(): void { + const b = this.order.bruschetta!; + this.oven.reset(b.roastHalves, 6, 'roast the tomatoes — blistered, not sauce'); + this.hideAllScenes(); + this.oven.root.visible = true; + this.app.setView(this.oven); + this.ticket.show(); + this.oven.onDone = (roast) => { + this.oven.root.visible = false; + this.enterAssembly(roast); + }; + } + + /** + * The assembly bench, primed with what the upstream stations left: the cut's + * thickness (the doorstop's sog defence), how wet the tomatoes came off the tray + * (a collapsed rack soaks faster), and how ready the brie is off the temp clock. + */ + private enterAssembly(roast: RoastResult): void { + const slice = this.kitchen.currentSlice; + const thickness = slice.cut?.thickness ?? 0.24; + // Collapsed halves went on wetter — the moisture they were holding soaks in. + const tomatoMoisture = 0.95 + roast.collapseFrac * 0.6; + const cheeseReady = this.temp ? this.temp.readiness('brie').score : 1; + this.assembly.reset({ sliceThickness: thickness, tomatoMoisture, cheeseReady, askText: 'top it even, mind the sog' }); + this.hideAllScenes(); + this.assembly.root.visible = true; + this.app.setView(this.assembly); + this.ticket.show(); + this.assembly.onDone = (a) => { + this.assembly.root.visible = false; + this.serveBruschetta(roast, a); + }; + } + + /** Everything the three bruschetta stations measured, in one verdict. */ + private serveBruschetta(roast: RoastResult, assembly: AssemblyResult): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const ov = this.temp ? this.temp.overall() : null; + const result: BruschettaResult = { + roast, + assembly, + temp: { + score: ov ? ov.score : 1, + worstName: ov ? ov.worst.name : '', + worstWord: ov ? ov.word : 'fine', + }, + // The only mess this order makes: tomatoes you let slump to sauce are a + // spill waiting to happen. A clean roast leaves a clean bench. + bench: { mass: roast.collapseFrac * 3, spreadFrac: roast.collapseFrac * 0.5, solids: 0 }, + }; + const v = judgeBruschetta(slice, this.order, this.kitchen.toolId, seconds, result); + this.lastBruschetta = { result, verdict: v }; + 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.hideTempToggle(); + 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(); + } + + /** Hide every station root — the one place the bruschetta hand-offs go through. */ + private hideAllScenes(): void { + this.kitchen.root.visible = false; + this.drawer.root.visible = false; + this.slicer.root.visible = false; + this.prep.root.visible = false; + this.juice.root.visible = false; + this.oven.root.visible = false; + this.assembly.root.visible = false; + } + /** * Drain the current order's prep queue one bench trip at a time, folding each * result into `prepResult`, then fall through to the toaster. A step is a cut @@ -302,9 +445,21 @@ export class Game { this.orderSeconds = 0; this.prepResult = null; this.juiceResult = null; + this.lastBruschetta = null; this.prepQueue = o.prep ? [...o.prep] : []; this.timing = true; + // The temperature clock: armed only on bruschetta days, tracking the two + // cheeses from order start so planning (brie out early, parmesan in late) is + // a real choice with a real deadline. + if (o.bruschetta) { + this.temp = new TempClock(o.bruschetta.perishables); + this.showTempToggle(); + } else { + this.temp = null; + this.hideTempToggle(); + } + const s = this.judgeView.release(); if (s) this.kitchen.root.add(s.mesh); @@ -370,6 +525,42 @@ export class Game { if (SPREADS[o.spread].separates) { el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip'); } + if (o.bruschetta) { + el('div', 'ticket-hint', this.ticketEl, 'get the brie out now — it must be soft to spread'); + el('div', 'ticket-hint', this.ticketEl, 'leave the parmesan in the fridge until you shave it'); + } + } + + /** Build the fridge/bench toggle for this order's perishables and show it. */ + private showTempToggle(): void { + if (!this.temp) return; + const p = this.tempPanel.root; + p.innerHTML = ''; + el('div', 'drawer-lbl', p, 'THE FRIDGE'); + this.tempRows = []; + for (const per of this.temp.items) { + const btn = el('button', 'btn ghost', p) as HTMLButtonElement; + btn.addEventListener('click', () => this.toggleTemp(per.id)); + this.tempRows.push({ id: per.id, btn }); + } + this.tempPanel.show(); + this.refreshTempToggle(); + } + + private refreshTempToggle(): void { + if (!this.temp) return; + for (const row of this.tempRows) { + const per = this.temp.get(row.id); + if (!per) continue; + const r = this.temp.readiness(row.id); + row.btn.textContent = `${per.name}: ${per.state === 'bench' ? 'on the bench' : 'in the fridge'} — ${r.word}`; + row.btn.style.color = r.score > 0.8 ? '#8fce8f' : r.score > 0.45 ? '#e8a53a' : '#e2603a'; + } + } + + private hideTempToggle(): void { + this.tempRows = []; + this.tempPanel.hide(); } /** Called when the player hands the plate over. */ diff --git a/src/game/judging.ts b/src/game/judging.ts index 3f7e26c..c99128f 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -3,6 +3,10 @@ import { CHAR_THRESHOLD } from '../sim/slice'; import { SPREADS, amountClassOf } from '../sim/spreads'; import { TOOLS, type ToolId } from '../sim/cutlery'; import type { Order } from './orders'; +import type { RoastResult } from '../sim/roasting'; +import { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting'; +import type { AssemblyResult } from '../sim/assembly'; +import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly'; export interface Criterion { key: string; @@ -12,8 +16,10 @@ export interface Criterion { weight: number; /** What the number actually was, in words. */ detail: string; - /** Which station earned it — the scorecard groups by this on prep orders. */ - group?: 'prep' | 'toast' | 'spread'; + /** 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'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -431,3 +437,227 @@ export function gradeOf(total: number): Grade { if (total >= 3.8) return 'C'; return 'F'; } + +// ===================================================================== +// M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping / +// The Bench. Every row is a number some station computed — the cut and toast +// off the bread, the roast + Clark–Evans distribution + mass balance + sog off +// the topping, the temperature-plan and the mess off the bench. Kept here beside +// the toast judge so the game has one door to scoring. +// ===================================================================== + +/** What the whole bruschetta hands the judge. */ +export interface BruschettaResult { + roast: RoastResult; + assembly: AssemblyResult; + /** Temperature-plan readiness, from TempClock.overall(). */ + temp: { score: number; worstName: string; worstWord: string }; + bench: { mass: number; spreadFrac: number; solids: number }; +} + +/** A value in [lo,hi] scores 1, falling away outside over `soft` either side. */ +function bandScore(v: number, lo: number, hi: number, soft: number): number { + if (v >= lo && v <= hi) return 1; + const d = v < lo ? lo - v : v - hi; + return clamp01(1 - d / soft); +} + +/** + * The Roast. Blistered is the target (mean in the blister window); raw scores + * low, and a rack that slumped to sauce is docked hard — collapsed tomatoes are + * not a topping, they're a spill waiting to happen. + */ +export function roastCriterion(r: RoastResult): Criterion { + const band = bandScore(r.mean, BLISTER_AT - 0.02, 0.82, 0.4); + const score = clamp01(band - r.collapseFrac * 1.1); + const word = + r.collapseFrac > 0.35 + ? 'collapsed to sauce' + : r.mean < 0.3 + ? 'still raw' + : r.mean < BLISTER_AT + ? 'under-roasted' + : r.mean >= COLLAPSE_AT + ? 'scorched' + : 'blistered'; + return { + key: 'roast', + group: 'topping', + label: 'The Roast', + score, + weight: 1.2, + detail: `${word} (${r.mean.toFixed(2)}${r.collapseFrac > 0.05 ? `, ${Math.round(r.collapseFrac * 100)}% slumped` : ''})`, + }; +} + +/** + * Distribution — the same Clark–Evans nearest-neighbour index the rind is + * scored with, fed the topping point list. Even scatter over the toast beats a + * pile in one corner; every bite should get a bit of everything. + */ +export function toppingDistributionCriterion(points: { u: number; v: number }[]): Criterion { + const n = points.length; + if (n < 4) { + return { + key: 'topdist', + group: 'topping', + label: 'Distribution', + score: n === 0 ? 0.05 : 0.35, + weight: 1.2, + detail: n === 0 ? 'nothing on it' : `only ${n} pieces`, + }; + } + const R = clarkEvans(points, 1); + const score = clamp01(R / 0.95); + const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; + return { + key: 'topdist', + group: 'topping', + label: 'Distribution', + score, + weight: 1.2, + detail: `${n} pieces, ${word} (R ${R.toFixed(2)})`, + }; +} + +/** + * Balance — total topping mass per slice area, held to a band. "Thicker but + * even and balanced" is the ask: too bare and the toast shows through, too + * heaped and it slides off (and soaks through). Distribution is scored beside + * this, so an even-but-thin or heaped-but-tidy build each only wins one row. + */ +export function toppingBalanceCriterion(a: AssemblyResult): Criterion { + const [lo, hi] = MASS_BAND; + const score = bandScore(a.massPerArea, lo, hi, (hi - lo) * 1.1); + const word = + a.massPerArea < lo * 0.85 + ? 'bare in patches' + : a.massPerArea > hi * 1.1 + ? 'a heap, not a topping' + : 'well balanced'; + const missing = + a.kinds.tomato === 0 ? ' — no tomato' : a.kinds.cheese === 0 ? ' — no cheese' : a.kinds.basil === 0 ? ' — no basil' : ''; + return { + key: 'balance', + group: 'topping', + label: 'Balance', + score: clamp01(score - (missing ? 0.25 : 0)), + weight: 1.1, + detail: `${word}${missing} (mass ${a.massPerArea.toFixed(2)})`, + }; +} + +/** The sog clock's verdict — the finger-press. Serve before it bends. */ +export function sogCriterion(sog: number): Criterion { + const score = clamp01(1 - (sog - 0.2) / 0.6); + return { + key: 'sog', + group: 'topping', + label: 'Sog', + score, + weight: 1.1, + detail: `${sogWord(sog)} (${sog.toFixed(2)})`, + }; +} + +/** The garlic rub — one light pass, judged gently. None reads raw; a scrub is + * wasteful; an even light coat is right. */ +export function rubCriterion(coverage: number): Criterion { + const score = bandScore(coverage, RUB_TARGET * 0.6, 0.62, 0.32); + const word = + coverage < 0.06 ? 'no garlic at all' : coverage < RUB_TARGET * 0.6 ? 'barely rubbed' : coverage > 0.7 ? 'drenched in garlic' : 'a proper rub'; + return { + key: 'rub', + group: 'bread', + label: 'The Rub', + score, + weight: 0.7, + detail: `${word} (${Math.round(coverage * 100)}%)`, + }; +} + +/** The temperature plan — brie soft, parmesan cold. The Bench's planning row. */ +export function tempCriterion(temp: { score: number; worstName: string; worstWord: string }): Criterion { + return { + key: 'temp', + group: 'bench', + label: 'Temperature', + score: temp.score, + weight: 1.0, + detail: temp.score > 0.8 ? 'brie soft, parmesan cold' : `${temp.worstName} ${temp.worstWord}`, + }; +} + +/** + * The bruschetta verdict. `slice` is the toasted, cut sourdough (browning + cut + * come off it); everything else comes from the three stations. + */ +export function judgeBruschetta( + slice: Slice, + order: Order, + usedTool: ToolId, + seconds: number, + r: BruschettaResult, +): Verdict { + const b = slice.browning.stats(slice.mask); + const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); + + // The Bread: the cut, the toast, the rub. + const cut = cutCriterion(slice, order); + cut.group = 'bread'; + const criteria: Criterion[] = [ + cut, + { + key: 'browning', + group: 'bread', + label: 'Browning', + score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), + weight: 1.4, + detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`, + }, + { + key: 'char', + group: 'bread', + label: 'Char', + score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), + weight: 0.6, + detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`, + }, + rubCriterion(r.assembly.rubCoverage), + // The Topping. + roastCriterion(r.roast), + toppingDistributionCriterion(r.assembly.points), + toppingBalanceCriterion(r.assembly), + sogCriterion(r.assembly.sog), + // The Bench. + tempCriterion(r.temp), + { ...benchCriterion(r.bench), group: 'bench' as const }, + // Ungrouped. + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 60) / 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, b2) => a.score - b2.score); + void usedTool; + return { + criteria, + total, + grade: gradeOf(total), + best: sorted[sorted.length - 1], + worst: sorted[0], + lines: [], + }; +} diff --git a/src/game/lines.ts b/src/game/lines.ts index 44ab984..d7cbf54 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -215,6 +215,89 @@ const BANK: Bank = { 'Clean and fast. The onion never got a word in.', ], }, + // --- M15 bruschetta --- + roast: { + bad: [ + 'These tomatoes are raw. You showed them the oven. That is not the same as using it.', + 'You roasted them into sauce. This is not a topping, it is a spill with ambitions.', + 'Half blistered, half collapsed. You opened the oven at exactly the wrong two moments.', + 'A tomato should blister, not surrender. These surrendered.', + 'You cooked the water out and then kept going. Now there is nothing but skin and regret.', + ], + good: [ + 'Blistered, not burst. That is the whole window and you were in it.', + 'The tomatoes caught just enough. Sweet, not sludge.', + 'Properly roasted. They hold their shape and give up their sugar. Good.', + ], + }, + topdist: { + bad: [ + 'All the topping, one corner. The rest of the toast is a rumour.', + 'You built a little hill. Bruschetta is not a hill.', + 'One bite has everything. The next has toast and disappointment.', + 'It is clumped. Spread it OUT — every bite earns its share.', + 'This is a garnish that gave up and sat down together.', + ], + good: [ + 'Evenly strewn, corner to corner. Every bite is the same bite. Good.', + 'You placed each piece like it mattered. It did.', + 'Textbook scatter. I did not have to hunt for the tomato.', + ], + }, + balance: { + bad: [ + 'There is barely anything on this. The toast is doing all the work.', + 'You heaped it. It is sliding off as I look at it.', + 'Thin in the middle of a thick idea. Commit to the topping.', + 'A tomato, a thought, and a lot of bare bread.', + 'This is either a very sad pizza or a very confused piece of toast.', + ], + good: [ + 'Thick, even, and it stays put. That is the balance, exactly.', + 'Enough of everything, too much of nothing. Well judged.', + 'A generous, level topping. Nobody gets a bare bite.', + ], + }, + sog: { + bad: [ + 'It bends. Toast should not bend.', + 'I pressed it and it folded like a wet letter. You were too slow.', + 'The juice won. It always wins if you dawdle, and you dawdled.', + 'Soggy. The doorstop was your one defence and you let it soak anyway.', + 'This has the structural integrity of an apology.', + ], + good: [ + 'It snapped. That is the sound a bruschetta is meant to make.', + 'Crisp under the finger. You served it in time. Good.', + 'Still crunching. The thick cut earned its keep.', + ], + }, + rub: { + bad: [ + 'No garlic. It is the name of the dish, more or less.', + 'You forgot the clove. I can taste the absence of it.', + 'You have not rubbed this, you have varnished it. My eyes are watering.', + 'A whole clove, in one streak. The rest is plain toast.', + ], + good: [ + 'A light, even rub. You can smell it before you taste it. Correct.', + 'The garlic is there and it knows its place. Good.', + 'One pass, all over. That is all it ever needed.', + ], + }, + temp: { + bad: [ + 'The brie is fridge-hard. You had all service to take it out. You took none of it.', + 'The parmesan is sweating. You left it on the bench like it owed you money.', + 'Cold cheese does not spread and warm cheese does not shave. You managed both, wrongly.', + 'You did not plan. The clock planned for you, and the clock is cruel.', + ], + good: [ + 'Brie soft, parmesan cold. You worked the fridge like someone who has done this.', + 'Both cheeses exactly as they should be. That is planning, and I noticed.', + 'You took the brie out early and left the parmesan in. Textbook.', + ], + }, }; // The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the diff --git a/src/game/orders.ts b/src/game/orders.ts index d31296b..fdd66ff 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -34,6 +34,19 @@ export type PrepStep = seeds?: number; }; +/** + * The bruschetta capstone (day 14). Its presence on an order flips the day flow + * from cut→toast→spread to cut→toast→roast→assemble→serve, and arms the + * temperature clock for the perishables it names. + */ +export interface BruschettaSpec { + /** Tomato halves on the oven tray. */ + roastHalves: number; + /** Perishables the temp clock tracks: brie wants out early (soft), parmesan + * wants to stay in (cold). */ + perishables: IngredientId[]; +} + /** The ticket's short line for a prep step: "dice the onion", "juice the orange". */ export function prepAsk(step: PrepStep): string { const name = step.ingredient.replace(/_/g, ' '); @@ -78,6 +91,8 @@ export interface Order { who: string; /** Work at the prep bench, when the order has any. */ prep?: PrepStep[]; + /** The bruschetta capstone: roast + assemble replace the spread flow. */ + bruschetta?: BruschettaSpec; } export const BROWNING_NAMES: [number, string][] = [ @@ -303,6 +318,30 @@ export const DAYS: Order[] = [ // therefore sting) stays low and the whole story is restraint at the root. prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }], }, + { + day: 14, + brand: 'the bakery loaf', + bread: 'sourdough', + // The spread flow is replaced by roast + assemble on this order — the garlic + // rub stands in for the spread — but the Order shape still wants these, and + // they stay harmless defaults the bruschetta path never reads. + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + fromLoaf: true, + cutClass: 'doorstop', + cutBand: CUT_BANDS.doorstop, + who: 'the inspector himself', + text: 'Bruschetta. Cut the sourdough thick and toast it, then rub it with garlic. Roast the tomatoes till they BLISTER — not sauce. Brie soft, parmesan cold. Top it even and balanced, and serve it before it bends.', + // The capstone: cut the loaf → toast → roast the tomatoes → assemble (rub, + // top, watch the sog) → serve. The temp clock tracks the two cheeses from + // order start; take the brie out early, leave the parmesan in. + bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'] }, + }, ]; /** Days beyond the script — keep going, with everything cranked. */ diff --git a/src/scenes/assembly.ts b/src/scenes/assembly.ts new file mode 100644 index 0000000..f307998 --- /dev/null +++ b/src/scenes/assembly.ts @@ -0,0 +1,284 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { INGREDIENTS } from '../sim/ingredients'; +import { + newAssembly, + placeTopping, + rubStroke, + sogStep, + assemblyResult, + rubCoverage, + sogWord, + type AssemblySession, + type AssemblyResult, + type ToppingKind, +} from '../sim/assembly'; +import { el, Panel } from '../ui/hud'; + +const TOAST_W = 1.7; +const TOAST_D = 1.5; +const TOP_Y = 0.42; + +type Mode = 'rub' | ToppingKind; + +/** + * The assembly bench — where every station's number comes home. The toasted, + * garlic-rubbed sourdough is the plate now: rub a clove across it (a spread in + * reverse), drop tomato / cheese / basil as a point set, and race the sog clock. + * + * Controls, in the house style (keys pick the tool, the pointer places it): + * G the garlic clove — hold and drag to rub. + * 1/2/3 tomato / cheese / basil — click to drop one. + * ENTER serve it — and the judge presses a finger on it. + * + * Deliberately cheap visuals (a toast slab, coloured discs, a garlic smear) — + * the gameplay is the distribution, the balance and the clock, and all three + * are read straight off assembly.ts, so the harness builds one headless and + * reads the exact numbers the judge scores. + */ +export class AssemblyView implements View { + readonly root = new THREE.Group(); + + /** Public for the harness — it drives real input, same as prep/juice. */ + session: AssemblySession | null = null; + readonly toastW = TOAST_W; + readonly toastD = TOAST_D; + readonly topY = TOP_Y; + + private mode: Mode = 'rub'; + private toast!: THREE.Mesh; + private rubTex: THREE.DataTexture; + private rubTexData: Uint8Array; + private rubVersion = -1; + private clove!: THREE.Mesh; + private toppingGroup = new THREE.Group(); + private toppingMeshes: THREE.Mesh[] = []; + + private ray = new THREE.Raycaster(); + private topPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -TOP_Y); + private hit = new THREE.Vector3(); + private wasDown = false; + + private panel: Panel; + private askEl!: HTMLElement; + private modeEl!: HTMLElement; + private stateEl!: HTMLElement; + private progFill!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: AssemblyResult) => void) | null = null; + + private tomatoCol = new THREE.Color().setRGB(...INGREDIENTS.roast_tomato.colors.flesh, THREE.SRGBColorSpace); + private cheeseCol = new THREE.Color().setRGB(...INGREDIENTS.brie.colors.flesh, THREE.SRGBColorSpace); + private basilCol = new THREE.Color().setRGB(0.16, 0.4, 0.14, THREE.SRGBColorSpace); + + constructor(private app: App) { + this.buildScenery(); + // The garlic rub stain over the toast — its own little field, 48². + const n = 48; + this.rubTexData = new Uint8Array(n * n * 4); + this.rubTex = new THREE.DataTexture(this.rubTexData, n, n, THREE.RGBAFormat); + this.rubTex.minFilter = THREE.LinearFilter; + this.rubTex.magFilter = THREE.LinearFilter; + const stain = new THREE.Mesh( + new THREE.PlaneGeometry(TOAST_W, TOAST_D), + new THREE.MeshBasicMaterial({ map: this.rubTex, transparent: true, depthWrite: false }), + ); + stain.rotation.x = -Math.PI / 2; + stain.position.set(0, TOP_Y + 0.006, 0); + this.root.add(stain); + this.root.add(this.toppingGroup); + + this.panel = new Panel('assembly-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, 'ASSEMBLE IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.modeEl = el('div', 'slicer-thick', card, ''); + this.stateEl = el('div', 'slicer-thick', card, ''); + const bar = el('div', 'timer-bar', card); + this.progFill = el('div', 'timer-fill', bar); + this.wordEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + const plate = new THREE.Mesh( + new THREE.CylinderGeometry(1.5, 1.5, 0.08, 40), + new THREE.MeshStandardMaterial({ color: 0xece7de, roughness: 0.5 }), + ); + plate.position.y = 0.08; + plate.receiveShadow = true; + this.root.add(plate); + + // The toast slab — thickness is set per order in reset(); a toasty box. + this.toast = new THREE.Mesh( + new THREE.BoxGeometry(TOAST_W, TOP_Y - 0.14, TOAST_D), + new THREE.MeshStandardMaterial({ color: new THREE.Color().setRGB(0.72, 0.5, 0.28, THREE.SRGBColorSpace), roughness: 0.8 }), + ); + this.toast.position.y = (TOP_Y - 0.14) / 2 + 0.12; + this.toast.castShadow = true; + this.toast.receiveShadow = true; + this.root.add(this.toast); + + // The garlic clove that rides the cursor while rubbing. + this.clove = new THREE.Mesh( + new THREE.SphereGeometry(0.09, 16, 12), + new THREE.MeshStandardMaterial({ color: 0xf3efe6, roughness: 0.6 }), + ); + this.clove.scale.set(0.8, 1.3, 0.8); + this.clove.visible = false; + this.root.add(this.clove); + } + + /** A fresh toast and a fresh assembly, primed with what the upstream stations left. */ + reset(opts: { sliceThickness?: number; tomatoMoisture?: number; cheeseReady?: number; askText?: string }): void { + this.session = newAssembly({ + sliceThickness: opts.sliceThickness, + tomatoMoisture: opts.tomatoMoisture, + cheeseReady: opts.cheeseReady, + }); + this.mode = 'rub'; + this.wasDown = false; + this.rubVersion = -1; + this.rubTexData.fill(0); + this.rubTex.needsUpdate = true; + for (const m of this.toppingMeshes) this.toppingGroup.remove(m); + this.toppingMeshes = []; + this.askEl.textContent = opts.askText ?? 'rub it, top it, serve it before it bends'; + this.hintEl.textContent = 'G rubs garlic · 1 tomato · 2 cheese · 3 basil · ENTER serves it'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + this.clove.visible = false; + } + + /** World position of a toast-top UV — the harness projects this to drive input. */ + worldAt(u: number, v: number): [number, number, number] { + return [(u - 0.5) * TOAST_W, TOP_Y, (v - 0.5) * TOAST_D]; + } + + update(dt: number): void { + this.app.camera.position.set(0, 2.7, 2.6); + this.app.camera.lookAt(0, 0.3, 0.1); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + if (inp.justPressed('KeyG')) this.mode = 'rub'; + if (inp.justPressed('Digit1')) this.mode = 'tomato'; + if (inp.justPressed('Digit2')) this.mode = 'cheese'; + if (inp.justPressed('Digit3')) this.mode = 'basil'; + + this.ray.setFromCamera(inp.ndc, this.app.camera); + const onToast = this.ray.ray.intersectPlane(this.topPlane, this.hit); + let uv: { u: number; v: number } | null = null; + if (onToast) { + const u = this.hit.x / TOAST_W + 0.5; + const v = this.hit.z / TOAST_D + 0.5; + if (u >= 0 && u <= 1 && v >= 0 && v <= 1) uv = { u, v }; + } + + this.clove.visible = this.mode === 'rub' && !!uv; + if (uv && this.mode === 'rub') this.clove.position.set(this.hit.x, TOP_Y + 0.06, this.hit.z); + + if (uv) { + if (this.mode === 'rub') { + // Hold and drag: the clove leaves scent, not mass. + if (inp.down) rubStroke(s, uv.u, uv.v, 0.11); + } else if (inp.down && !this.wasDown) { + // Press edge drops exactly one piece — a click, not a smear. + placeTopping(s, uv.u, uv.v, this.mode); + this.addToppingMesh(uv.u, uv.v, this.mode); + audio.clatter(0.12, 2.2); + } + } + this.wasDown = inp.down; + + // The sog clock ticks the moment something wet is on the bread. + sogStep(s, dt); + + if (inp.justPressed('Enter') && this.onDone) { + const cb = this.onDone; + this.onDone = null; + cb(assemblyResult(s)); + return; + } + + this.syncRub(s); + this.updateHud(s); + } + + private addToppingMesh(u: number, v: number, kind: ToppingKind): void { + const col = kind === 'tomato' ? this.tomatoCol : kind === 'cheese' ? this.cheeseCol : this.basilCol; + const r = kind === 'basil' ? 0.07 : kind === 'cheese' ? 0.1 : 0.12; + const geo = kind === 'basil' + ? new THREE.SphereGeometry(r, 12, 8) + : new THREE.CylinderGeometry(r, r, 0.06, 16); + if (kind === 'basil') geo.scale(1.4, 0.4, 0.8); + const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: col.clone(), roughness: 0.5 })); + const [x, , z] = this.worldAt(u, v); + mesh.position.set(x, TOP_Y + 0.03, z); + mesh.castShadow = true; + this.toppingGroup.add(mesh); + this.toppingMeshes.push(mesh); + } + + private syncRub(s: AssemblySession): void { + // rub Field version-free — re-upload while the coverage is still changing. + const cov = Math.round(rubCoverage(s) * 1000); + if (cov === this.rubVersion) return; + this.rubVersion = cov; + const d = s.rub.data; + const px = this.rubTexData; + for (let i = 0; i < d.length; i++) { + px[i * 4] = 220; + px[i * 4 + 1] = 214; + px[i * 4 + 2] = 180; + px[i * 4 + 3] = Math.round(Math.min(0.5, d[i] * 0.55) * 255); + } + this.rubTex.needsUpdate = true; + } + + private updateHud(s: AssemblySession): void { + const r = assemblyResult(s); + const names: Record = { rub: 'garlic clove', tomato: 'tomato', cheese: 'cheese', basil: 'basil' }; + this.modeEl.textContent = `holding: ${names[this.mode]}`; + this.stateEl.textContent = + `${r.count} on · mass ${r.massPerArea.toFixed(1)} · garlic ${Math.round(r.rubCoverage * 100)}%` + + ` · ${r.kinds.tomato}t ${r.kinds.cheese}c ${r.kinds.basil}b`; + this.progFill.style.width = `${Math.round(s.sog * 100)}%`; + this.progFill.style.background = s.sog > 0.8 ? '#c0392b' : s.sog > 0.55 ? '#e8a53a' : '#6cbf6c'; + this.wordEl.textContent = `the toast: ${sogWord(s.sog)}`; + this.wordEl.style.color = s.sog > 0.8 ? '#e2603a' : s.sog < 0.28 ? '#8fce8f' : ''; + } + + /** What the judge reads. Snapshot any time; final at hand-off. */ + result(): AssemblyResult { + return this.session + ? assemblyResult(this.session) + : { points: [], count: 0, kinds: { tomato: 0, cheese: 0, basil: 0 }, massPerArea: 0, rubCoverage: 0, sog: 0, cheeseReady: 1 }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 4411850..7b057a0 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -107,10 +107,22 @@ export class JudgeView implements View { clear(this.cardEl); // On prep orders the card is long enough to need signposts: group rows by // station. Ordinary toast orders keep the flat card they always had. - const grouped = verdict.criteria.some((c) => c.group === 'prep'); - const headers: Record = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD' }; + // 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', + ); + const headers: Record = { + prep: 'THE PREP', + toast: 'THE TOAST', + spread: 'THE SPREAD', + bread: 'THE BREAD', + topping: 'THE TOPPING', + bench: 'THE BENCH', + }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; - const rank = (g?: string) => (g === 'prep' ? 0 : g === 'toast' ? 1 : g === 'spread' ? 2 : 3); + const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped ? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group)) : verdict.criteria; diff --git a/src/scenes/oven.ts b/src/scenes/oven.ts new file mode 100644 index 0000000..98bc88d --- /dev/null +++ b/src/scenes/oven.ts @@ -0,0 +1,260 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { INGREDIENTS } from '../sim/ingredients'; +import { + newRoastSession, + roastStep, + roastResult, + roastWord, + BLISTER_AT, + COLLAPSE_AT, + type RoastResult, + type RoastSession, +} from '../sim/roasting'; +import { el, Panel } from '../ui/hud'; +import { loadProp } from './assets'; + +const TRAY_Y = 0.06; +const TRAY_W = 3.4; +const TRAY_D = 1.6; + +/** + * The oven — the toaster, generalized. A tray of tomato halves, a heat dial, and + * a door that is really the toaster's lever wearing a different hat: SPACE slides + * the tray in, SPACE pulls it out. While it's in, the same browning Field the + * toast uses climbs on the tomatoes (roasting.ts). Past 0.6 they blister; hold + * too long and they slump to sauce. + * + * The visual is deliberately cheap (a tray, red domes that darken and sag) — the + * gameplay is the clock and the dial, and both are read straight off the sim, so + * the harness roasts headless and reads the exact numbers the judge scores. + */ +export class OvenView implements View { + readonly root = new THREE.Group(); + + private session: RoastSession | null = null; + private halves: THREE.Mesh[] = []; + private tray!: THREE.Group; + private door!: THREE.Mesh; + private glow!: THREE.Mesh; + + /** 'ready' → tray out, 'roasting' → tray in and cooking, 'done' → pulled out. */ + private phase: 'ready' | 'roasting' | 'done' = 'ready'; + /** The oven dial, 1..10. The harness writes it before the first SPACE. */ + power = 6; + private halfCount = 4; + private doneTimer = 0; + + private fleshColor = new THREE.Color().setRGB(...INGREDIENTS.tomato.colors.flesh, THREE.SRGBColorSpace); + + private panel: Panel; + private askEl!: HTMLElement; + private stateEl!: HTMLElement; + private progFill!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: RoastResult) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + this.panel = new Panel('oven-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, 'ROAST IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.stateEl = el('div', 'slicer-thick', card, ''); + const bar = el('div', 'timer-bar', card); + this.progFill = el('div', 'timer-fill', bar); + this.wordEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x3a2c20, roughness: 0.85 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + + // The oven box behind the tray, with a glowing element inside. + const oven = new THREE.Mesh( + new THREE.BoxGeometry(TRAY_W + 0.6, 1.4, TRAY_D + 0.5), + new THREE.MeshStandardMaterial({ color: 0x22201e, roughness: 0.6, metalness: 0.3 }), + ); + oven.position.set(0, 0.7, -1.4); + oven.castShadow = true; + oven.receiveShadow = true; + this.root.add(oven); + this.glow = new THREE.Mesh( + new THREE.PlaneGeometry(TRAY_W, TRAY_D), + new THREE.MeshBasicMaterial({ color: 0xff5a1e, transparent: true, opacity: 0 }), + ); + this.glow.rotation.x = -Math.PI / 2; + this.glow.position.set(0, 1.28, -1.4); + this.root.add(this.glow); + + // The tray. + this.tray = new THREE.Group(); + const trayMesh = new THREE.Mesh( + new THREE.BoxGeometry(TRAY_W, TRAY_Y * 2, TRAY_D), + new THREE.MeshStandardMaterial({ color: 0x555049, roughness: 0.5, metalness: 0.6 }), + ); + trayMesh.position.y = TRAY_Y; + trayMesh.receiveShadow = true; + trayMesh.castShadow = true; + this.tray.add(trayMesh); + this.root.add(this.tray); + void loadProp('/assets/models/oven_tray.glb', TRAY_W) + .then((prop) => { + prop.position.y = 0; + trayMesh.visible = false; + this.tray.add(prop); + }) + .catch(() => undefined); + + // A little door flap that lifts as the tray slides in. + this.door = new THREE.Mesh( + new THREE.BoxGeometry(TRAY_W + 0.6, 0.7, 0.08), + new THREE.MeshStandardMaterial({ color: 0x2c2825, roughness: 0.5, metalness: 0.4 }), + ); + this.door.position.set(0, 0.35, -0.62); + this.root.add(this.door); + } + + /** A tray of `halves` tomato halves and a fresh roast. */ + reset(halves = 4, power = 6, askText = 'roast the tomatoes'): void { + this.halfCount = halves; + this.power = power; + this.session = newRoastSession(halves, 20260718, power); + this.phase = 'ready'; + this.doneTimer = 0; + + for (const h of this.halves) this.tray.remove(h); + this.halves = []; + const r = INGREDIENTS.roast_tomato.size / 2; + for (let i = 0; i < halves; i++) { + // A cut half: a squashed dome, flat side down on the tray. + const geo = new THREE.SphereGeometry(r, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2); + geo.scale(1, 0.7, 1); + const mesh = new THREE.Mesh( + geo, + new THREE.MeshStandardMaterial({ color: this.fleshColor.clone(), roughness: 0.5 }), + ); + const x = ((i + 0.5) / halves - 0.5) * (TRAY_W - 0.5); + mesh.position.set(x, TRAY_Y * 2, 0); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.tray.add(mesh); + this.halves.push(mesh); + } + this.askEl.textContent = askText; + this.hintEl.textContent = 'DIAL sets the heat · SPACE slides the tray in · SPACE again pulls it out'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + update(dt: number): void { + this.app.camera.position.set(0, 2.5, 3.9); + this.app.camera.lookAt(0, 0.35, -0.5); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + if (inp.justPressed('Space')) { + if (this.phase === 'ready') { + this.phase = 'roasting'; + audio.clunk(); + this.hintEl.textContent = 'roasting… SPACE pulls the tray back out. blistered, not black.'; + } else if (this.phase === 'roasting') { + this.phase = 'done'; + audio.clunk(true); + this.hintEl.textContent = 'out. ENTER takes them to the bench — or SPACE back in if they need more.'; + } else if (this.phase === 'done') { + // Changed your mind: back in for another blast. + this.phase = 'roasting'; + } + } + + s.power = this.power; + const inOven = this.phase === 'roasting'; + // Slide the tray in/out and lift the door. + const targetZ = inOven ? -1.4 : 0; + this.tray.position.z += (targetZ - this.tray.position.z) * Math.min(1, dt * 6); + this.door.rotation.x = inOven ? -0.9 : 0; + const glowMat = this.glow.material as THREE.MeshBasicMaterial; + glowMat.opacity = inOven ? 0.16 + 0.1 * Math.sin(s.seconds * 8) : Math.max(0, glowMat.opacity - dt); + + if (inOven) { + roastStep(s, dt); + this.paintHalves(s); + } + + if (this.phase === 'done') { + this.doneTimer += dt; + if (inp.justPressed('Enter') && this.onDone) { + const cb = this.onDone; + this.onDone = null; + cb(roastResult(s)); + return; + } + } + + this.updateHud(s); + } + + /** Darken and sag each half by the roast level under it. */ + private paintHalves(s: RoastSession): void { + for (let i = 0; i < this.halves.length; i++) { + const cu = (i + 0.5) / this.halfCount; + const roast = s.roast.sample(cu, 0.5); + const mesh = this.halves[i]; + const mat = mesh.material as THREE.MeshStandardMaterial; + // Flesh reddens then darkens; past collapse it goes to a dull sauce brown. + const t = Math.min(1, roast); + const c = this.fleshColor.clone().multiplyScalar(1 - t * 0.55); + if (roast > BLISTER_AT) c.offsetHSL(-0.02 * (roast - BLISTER_AT) * 4, 0, -0.05); + mat.color.copy(c); + // Slump: past collapse the half flattens into the tray. + const slump = roast >= COLLAPSE_AT ? Math.min(0.6, (roast - COLLAPSE_AT) * 6) : 0; + mesh.scale.y = 1 - slump; + mesh.position.y = TRAY_Y * 2; + } + } + + private updateHud(s: RoastSession): void { + const r = roastResult(s); + this.stateEl.textContent = + this.phase === 'ready' + ? `oven ${this.power} · tray out` + : `oven ${this.power} · ${Math.round(s.seconds)}s · ${Math.round(r.mean * 100)}% roasted`; + // Progress toward the blister window; red once it starts collapsing. + this.progFill.style.width = `${Math.min(100, Math.round((r.mean / 0.82) * 100))}%`; + this.progFill.style.background = r.collapseFrac > 0.05 ? '#c0392b' : r.mean >= BLISTER_AT ? '#6cbf6c' : '#e8a53a'; + const word = roastWord(r); + this.wordEl.textContent = `the tomatoes: ${word}${r.blisterFrac > 0.2 ? ` · ${Math.round(r.blisterFrac * 100)}% blistered` : ''}`; + this.wordEl.style.color = r.collapseFrac > 0.2 ? '#e2603a' : r.blisterFrac > 0.4 ? '#8fce8f' : ''; + } + + /** What the judge reads. Snapshot any time; final at hand-off. */ + result(): RoastResult { + return this.session ? roastResult(this.session) : { mean: 0, stdev: 0, blisterFrac: 0, collapseFrac: 0 }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/assembly.ts b/src/sim/assembly.ts new file mode 100644 index 0000000..8ea9f26 --- /dev/null +++ b/src/sim/assembly.ts @@ -0,0 +1,179 @@ +import { Field } from '../core/field'; + +/** + * Assembling the bruschetta — where every station's number comes home to roost. + * + * The toasted, garlic-rubbed slice is the plate now, and three separate skills + * land on it at once: + * + * the rub — one light pass of a cut garlic clove. A "spread" in reverse: + * coverage over the toast, judged gently (nobody wants a raw + * clove, nobody wants none). Reuses the Field brush. + * placement — tomato, cheese and basil dropped as a point set. Scored with + * the same Clark–Evans index the marmalade rind uses (the judge + * builds it; this sim just holds the points), PLUS a mass-per-area + * band: thicker is better, but only if it stays even and balanced. + * the sog — wet toppings sitting on toast raise `sog` in real time, at a + * rate set by how wet they are and how thin the toast is. A thin + * slice surrenders; a doorstop off the bakery loaf holds the line. + * Serve before it bends. The judge presses it with a finger. + * + * Pure — one Field for the rub, plain arrays for the points — so the harness can + * build a whole bruschetta headless and read the exact distribution the judge + * scores. + */ + +export type ToppingKind = 'tomato' | 'cheese' | 'basil'; + +export interface Topping { + /** Position on the toast top, UV 0..1. */ + u: number; + v: number; + kind: ToppingKind; + /** How much sits here — cheese is heavier than a basil leaf. */ + mass: number; +} + +export interface AssemblySession { + toppings: Topping[]; + /** Garlic coverage over the toast. */ + rub: Field; + /** 0 crisp .. 1 bending. The finger-press verdict. */ + sog: number; + /** Slice thickness in world units — the doorstop's whole reason to exist. */ + sliceThickness: number; + /** How wet the tomatoes went on: 0.95 for a clean roast, higher if collapsed. */ + tomatoMoisture: number; + /** Brie readiness from the temp clock, 0..1 — soft cheese places, cold doesn't. */ + cheeseReady: number; + seconds: number; +} + +// --- Tuning. First principles, then measured in the scratch harness. --- +/** Default per-piece masses, so a "normal" build lands mid-band. */ +export const TOPPING_MASS: Record = { + tomato: 0.42, + cheese: 0.34, + basil: 0.12, +}; +/** Sog per second, per unit wet-mass, per unit of (1/thickness). Tuned (measured + * headless) so a doorstop (~0.31) under a normal wet load (5 tomato ≈ 2.1) served + * promptly (~8s) is still crisp (sog ≈ 0.21), a doorstop left to dawdle (~30s) + * goes soft (~0.77), and a thin slice (~0.12) bends inside ~15s — the doorstop is + * a real defence, which is the whole reason the bakery loaf matters. */ +const SOG_RATE = 0.004; +/** Only the wet toppings soak the toast; cheese and basil are along for the ride. */ +const WET_KINDS: ToppingKind[] = ['tomato']; +/** The mass-per-area sweet spot. Below it the toast reads bare; above it the + * pile slides off and soaks through. "Thicker but even" lives inside this band — + * a normal build (5 tomato + 4 cheese + 3 basil ≈ 3.8 total mass) sits mid-band. */ +export const MASS_BAND: [number, number] = [2.5, 4.5]; +/** Coverage the rub is judged against — a light, even pass, not a scrub. */ +export const RUB_TARGET = 0.32; +/** "There is visibly garlic on this texel." */ +const RUB_FLOOR = 0.05; + +const RUB_N = 48; + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +export function newAssembly(opts: { + sliceThickness?: number; + tomatoMoisture?: number; + cheeseReady?: number; +}): AssemblySession { + return { + toppings: [], + rub: new Field(RUB_N), + sog: 0, + sliceThickness: opts.sliceThickness ?? 0.24, + tomatoMoisture: opts.tomatoMoisture ?? 0.95, + cheeseReady: opts.cheeseReady ?? 1, + seconds: 0, + }; +} + +/** Drop one topping at (u,v). Mass defaults to the kind's own weight. */ +export function placeTopping(s: AssemblySession, u: number, v: number, kind: ToppingKind, mass?: number): void { + s.toppings.push({ u: clamp01(u), v: clamp01(v), kind, mass: mass ?? TOPPING_MASS[kind] }); +} + +/** + * One pass of the garlic clove across the toast — the rub. Brushes coverage into + * the rub Field, mass-free (a clove leaves a scent, not a layer), just marking + * where it went. Call it along a stroke the way the scene drags it. + */ +export function rubStroke(s: AssemblySession, u: number, v: number, radius = 0.1): void { + s.rub.brush(u, v, radius, (idx, w) => { + s.rub.data[idx] = Math.min(1, s.rub.data[idx] + w * 0.5); + }); +} + +/** + * The sog clock. Wet mass on the toast raises sog at rate ÷ thickness — thick + * slices resist. Collapsed tomatoes (moisture past 1) soak faster still. Only + * ticks once something wet is actually on the bread. + */ +export function sogStep(s: AssemblySession, dt: number): void { + if (dt <= 0) return; + s.seconds += dt; + const wet = wetMass(s); + if (wet <= 0) return; + const thick = Math.max(0.08, s.sliceThickness); + s.sog = clamp01(s.sog + dt * SOG_RATE * wet * s.tomatoMoisture / thick); +} + +function wetMass(s: AssemblySession): number { + let m = 0; + for (const t of s.toppings) if (WET_KINDS.includes(t.kind)) m += t.mass; + return m; +} + +export interface AssemblyResult { + /** Every topping position — the point set for Clark–Evans (judge builds it). */ + points: { u: number; v: number }[]; + count: number; + /** Per-kind counts, for the readout and a "you forgot the basil" check. */ + kinds: Record; + /** Total topping mass over the plate area (area is 1 — the toast top UV square). */ + massPerArea: number; + /** Garlic coverage fraction, 0..1. */ + rubCoverage: number; + /** 0 crisp .. 1 bending. */ + sog: number; + /** Brie readiness carried through for the topping-quality note. */ + cheeseReady: number; +} + +export function assemblyResult(s: AssemblySession): AssemblyResult { + const kinds: Record = { tomato: 0, cheese: 0, basil: 0 }; + let mass = 0; + const points: { u: number; v: number }[] = []; + for (const t of s.toppings) { + kinds[t.kind]++; + mass += t.mass; + points.push({ u: t.u, v: t.v }); + } + return { + points, + count: s.toppings.length, + kinds, + massPerArea: mass, + rubCoverage: rubCoverage(s), + sog: s.sog, + cheeseReady: s.cheeseReady, + }; +} + +/** Fraction of the toast the garlic actually touched. */ +export function rubCoverage(s: AssemblySession): number { + const d = s.rub.data; + let hit = 0; + for (let i = 0; i < d.length; i++) if (d[i] >= RUB_FLOOR) hit++; + return hit / d.length; +} + +/** The finger-press readout, in the judge's own thresholds. */ +export function sogWord(sog: number): string { + return sog < 0.28 ? 'crisp' : sog < 0.55 ? 'holding' : sog < 0.8 ? 'going soft' : 'it bends'; +} diff --git a/src/sim/ingredients.ts b/src/sim/ingredients.ts index 5564c0e..5afdce5 100644 --- a/src/sim/ingredients.ts +++ b/src/sim/ingredients.ts @@ -116,7 +116,9 @@ export const INGREDIENTS: Record = { size: 0.45, skin: { resistance: 0.3, slippery: 0.2 }, flesh: { resistance: 0.15, moisture: 0.35 }, - temp: { state: 'fridge', softensInSec: 120, needsSoft: true }, + // softensInSec tuned for M15: out on the bench at order start, a brie is + // spreadable by the time the roast + toast are done (~45s of service). + temp: { state: 'fridge', softensInSec: 80, needsSoft: true }, colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] }, }, cucumber: { diff --git a/src/sim/roasting.ts b/src/sim/roasting.ts new file mode 100644 index 0000000..30aa0ef --- /dev/null +++ b/src/sim/roasting.ts @@ -0,0 +1,150 @@ +import { Field } from '../core/field'; +import { Rng, valueNoise2D } from '../core/rng'; + +/** + * Roasting tomatoes — the oven is the toaster, generalized. + * + * It is the same browning Field the toast has always used, pointed at tomato + * halves on a tray instead of a slice in a slot. The math is toastStep's twin: + * moisture has to boil off before the surface can colour, and tomatoes are + * basically water balloons, so they stall a long time and then go fast — the + * "leave them in, nothing's happening, leave them in, they're gone" of a real + * oven. + * + * Two things are the tomato's and not the toast's: + * blister — past 0.6 the skin blisters and speckles (a different colour ramp + * and a speckle mask in the scene; here it's just a fraction the + * judge rewards — blistered is the target, not black). + * collapse — past ~0.9 the half slumps into sauce. The moisture it was holding + * becomes mess the moment you handle it, and it soaks the toast + * faster (the assembly reads collapseFrac into the sog clock). + * + * Pure — Field + Rng, no three.js — so the harness roasts headless and reads the + * exact same numbers the judge will. + */ + +/** Blistered skin begins here — the target window's floor. */ +export const BLISTER_AT = 0.6; +/** Past this the half has collapsed into sauce. */ +export const COLLAPSE_AT = 0.9; + +/** Roasts a touch slower than toast browns: the oven is gentler than the coils, + * and the wet flesh drinks the first stretch of heat. Measured headless at power + * 6: the halves stall wet until ~26s (mean ~0.56, barely blistered), the blister + * window opens ~28–36s (mean ~0.61→0.79, fully blistered, none slumped), and past + * ~40s they collapse to sauce — "nothing's happening, nothing's happening, gone". */ +const RATE = 0.042; + +export interface RoastSession { + /** 0 raw .. 1 blackened. Blister past 0.6, collapse past 0.9. */ + roast: Field; + /** Moisture that must cook off first — tomatoes carry a lot of it. */ + dry: Field; + /** 1 where a tomato half sits on the tray; every stat is masked by this. */ + mask: Field; + /** Where the oven puts its heat — hotter in the middle, like a real element. */ + heat: Float32Array; + seconds: number; + power: number; +} + +export interface RoastResult { + /** Mean roast over the tomatoes. */ + mean: number; + /** Evenness — a rack that roasted unevenly reads here. */ + stdev: number; + /** Fraction blistered (past 0.6) — the good bit. */ + blisterFrac: number; + /** Fraction collapsed into sauce (past 0.9) — the overdone bit. */ + collapseFrac: number; +} + +const N = 96; + +/** + * A tray of `halves` tomato halves. They sit in a row; the mask is a set of + * discs so the stats are over the tomatoes, not the empty tray between them. + */ +export function newRoastSession(halves = 4, seed = 20260718, power = 6): RoastSession { + const roast = new Field(N); + const dry = new Field(N); + const mask = new Field(N); + const rng = new Rng(seed); + + // Lay the halves out across the tray as discs. + const r = 0.5 / halves; // disc radius in UV, so they tile the width + for (let h = 0; h < halves; h++) { + const cu = (h + 0.5) / halves; + const cv = 0.5; + stampDisc(mask, cu, cv, r * 0.82); + } + + // The oven's heat: hotter toward the centre/back, gently blotched, so an even + // roast is a real (if mild) skill — the tuning keeps it forgiving. + const bias = valueNoise2D(rng, N, N, 3); + const heat = new Float32Array(N * N); + for (let y = 0; y < N; y++) { + for (let x = 0; x < N; x++) { + const u = x / (N - 1); + const v = y / (N - 1); + // Radial: the middle of the tray runs hotter than the corners. + const dc = Math.hypot(u - 0.5, v - 0.5); + const centre = 1 - 0.18 * Math.min(1, dc / 0.6); + const blot = 0.9 + 0.2 * bias[y * N + x]; + heat[y * N + x] = centre * blot; + } + } + return { roast, dry, mask, heat, seconds: 0, power }; +} + +function stampDisc(mask: Field, cu: number, cv: number, r: number): void { + const n = mask.n; + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const u = x / (n - 1); + const v = y / (n - 1); + if (Math.hypot(u - cu, v - cv) <= r) mask.data[y * n + x] = 1; + } + } +} + +/** + * Advance the roast one tick. The twin of toastStep: dryness climbs first, and + * only a dried surface colours at full rate. `power` 1..10 is the oven dial. + */ +export function roastStep(s: RoastSession, dt: number): void { + const p = Math.max(0, Math.min(10, s.power)) / 10; + const r = s.roast.data; + const d = s.dry.data; + const m = s.mask.data; + // Tomatoes hold a lot of water — a high cap means a long stall then a rush. + const moistureCap = 2.4; + for (let i = 0; i < r.length; i++) { + if (m[i] < 0.5) continue; + const h = s.heat[i] * p; + if (h <= 0) continue; + d[i] = Math.min(1, d[i] + (h * dt) / moistureCap); + const rate = RATE * h * (0.2 + 0.8 * d[i]); + r[i] = Math.min(1.1, r[i] + rate * dt); + } + s.seconds += dt; +} + +export function roastResult(s: RoastSession): RoastResult { + const st = s.roast.stats(s.mask); + return { + mean: st.mean, + stdev: st.stdev, + blisterFrac: s.roast.fraction(s.mask, (v) => v >= BLISTER_AT && v < COLLAPSE_AT), + collapseFrac: s.roast.fraction(s.mask, (v) => v >= COLLAPSE_AT), + }; +} + +/** The live-readout word for the oven, in the judge's own thresholds. */ +export function roastWord(r: RoastResult): string { + if (r.collapseFrac > 0.4) return 'collapsing to sauce'; + if (r.mean < 0.28) return 'still raw'; + if (r.mean < BLISTER_AT) return 'softening'; + if (r.blisterFrac > 0.25) return 'blistered'; + return 'coming along'; +} diff --git a/src/sim/tempclock.ts b/src/sim/tempclock.ts new file mode 100644 index 0000000..19f5db8 --- /dev/null +++ b/src/sim/tempclock.ts @@ -0,0 +1,141 @@ +import { INGREDIENTS, type IngredientId } from './ingredients'; + +/** + * The temperature clock, generalized from butter softness (M2) to any perishable + * that drifts on the bench. + * + * Butter softness was always a static dial the order handed you. This is that + * dial made real: a cheese sat in the fridge is cold and firm; left on the bench + * it warms toward soft in real time, on its own `softensInSec`. Two perishables, + * two opposite needs, and that is the whole planning game of the bruschetta: + * + * brie needsSoft — spreadable by assembly time. Get it OUT early or it + * places like a stone. + * parmesan needsCold — shaves clean only cold (the M13 grater rule: warm + * cheese smears). Leave it IN until you need it. + * + * You set each state at order start and can revisit it — for a price in service + * time, because opening the fridge again mid-service costs you (the game charges + * the seconds; this sim just flips the state). The pressure is felt through the + * ticket hint and the clock, never a tutorial: by the time you assemble, the + * cheese is however warm your planning left it, and the judge tastes it. + * + * Pure and time-stepped — no three.js — so the harness can warm a cheese headless + * and read exactly how ready it is. + */ + +export type TempState = 'fridge' | 'bench'; + +export interface Perishable { + id: IngredientId; + name: string; + state: TempState; + /** 0 = fridge-cold and firm, 1 = out-all-morning warm and soft. */ + temp: number; + /** Wants to be warm/soft by assembly (brie). */ + needsSoft: boolean; + /** Wants to be cold/firm at assembly (parmesan). */ + needsCold: boolean; + /** Seconds on the bench to drift a full 0→1. Fridge cools at the same rate. */ + softensInSec: number; +} + +export interface TempReadiness { + /** The perishable's current 0..1 warmth. */ + temp: number; + /** 0..1 — how well it meets its need right now. */ + score: number; + /** The readout word, in the judge's own thresholds. */ + word: string; +} + +/** A perishable starts cold in the fridge — you decide what comes out. */ +function makePerishable(id: IngredientId): Perishable { + const ing = INGREDIENTS[id]; + return { + id, + name: ing.name, + state: 'fridge', + temp: 0, + needsSoft: !!ing.temp?.needsSoft, + needsCold: !!ing.temp?.needsCold, + softensInSec: ing.temp?.softensInSec ?? 90, + }; +} + +export class TempClock { + readonly items: Perishable[]; + + constructor(ids: IngredientId[]) { + this.items = ids.map(makePerishable); + } + + get(id: IngredientId): Perishable | undefined { + return this.items.find((p) => p.id === id); + } + + /** Flip fridge↔bench. The game charges the service-time cost; this just flips. */ + toggle(id: IngredientId): void { + const p = this.get(id); + if (p) p.state = p.state === 'fridge' ? 'bench' : 'fridge'; + } + + setState(id: IngredientId, state: TempState): void { + const p = this.get(id); + if (p) p.state = state; + } + + /** Drift every perishable one tick toward its state's target temperature. */ + step(dt: number): void { + if (dt <= 0) return; + for (const p of this.items) { + const target = p.state === 'bench' ? 1 : 0; + const rate = dt / p.softensInSec; + if (p.temp < target) p.temp = Math.min(target, p.temp + rate); + else if (p.temp > target) p.temp = Math.max(target, p.temp - rate); + } + } + + /** How ready one perishable is for assembly, scored against its need. */ + readiness(id: IngredientId): TempReadiness { + const p = this.get(id); + if (!p) return { temp: 0, score: 1, word: 'n/a' }; + return readinessOf(p); + } + + /** + * The one number The Bench temperature row reads: the whole plan's readiness, + * dragged down to its worst perishable — one cold-hard brie or one sweaty + * parmesan is enough to hear about it. + */ + overall(): { score: number; worst: Perishable; word: string } { + let worst = this.items[0]; + let worstScore = 1; + for (const p of this.items) { + const s = readinessOf(p).score; + if (s < worstScore) { + worstScore = s; + worst = p; + } + } + return { score: worstScore, worst, word: readinessOf(worst).word }; + } +} + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +function readinessOf(p: Perishable): TempReadiness { + if (p.needsSoft) { + // Soft is the whole point: cold-hard scores nothing, spreadable scores full. + const score = clamp01((p.temp - 0.12) / 0.45); + const word = p.temp < 0.2 ? 'fridge-hard' : p.temp < 0.5 ? 'softening' : 'spreadable'; + return { temp: p.temp, score, word }; + } + if (p.needsCold) { + // Cold shaves clean; warm smears (the grater's lesson, one clock over). + const score = clamp01(1 - (p.temp - 0.2) / 0.45); + const word = p.temp < 0.25 ? 'cold' : p.temp < 0.55 ? 'sweating' : 'smearing'; + return { temp: p.temp, score, word }; + } + return { temp: p.temp, score: 1, word: 'fine' }; +}