M15 bruschetta capstone: roast → assemble → the sog finger-press, day 14 end to end
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 <noreply@anthropic.com>
This commit is contained in:
parent
0e38cadc64
commit
cc39478a6e
149
src/dev.ts
149
src/dev.ts
@ -1,6 +1,8 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { App } from './core/app';
|
import type { App } from './core/app';
|
||||||
import type { Game } from './game/game';
|
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
|
* 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,
|
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;
|
(window as unknown as { __t: unknown }).__t = harness;
|
||||||
|
|||||||
195
src/game/game.ts
195
src/game/game.ts
@ -4,10 +4,15 @@ import { KitchenView } from '../scenes/kitchen';
|
|||||||
import { SlicerView } from '../scenes/slicer';
|
import { SlicerView } from '../scenes/slicer';
|
||||||
import { PrepView } from '../scenes/prep';
|
import { PrepView } from '../scenes/prep';
|
||||||
import { JuiceView } from '../scenes/juice';
|
import { JuiceView } from '../scenes/juice';
|
||||||
|
import { OvenView } from '../scenes/oven';
|
||||||
|
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';
|
||||||
import { juiceCriteria, type JuiceResult } from '../sim/juicing';
|
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 { 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';
|
||||||
@ -21,6 +26,9 @@ import { Title } from '../ui/title';
|
|||||||
|
|
||||||
const SAVE_KEY = 'toastsim.save';
|
const SAVE_KEY = 'toastsim.save';
|
||||||
|
|
||||||
|
/** Reopening the fridge mid-service costs you — the planning has teeth. */
|
||||||
|
const TEMP_TOGGLE_COST = 8;
|
||||||
|
|
||||||
interface Save {
|
interface Save {
|
||||||
day: number;
|
day: number;
|
||||||
total: number;
|
total: number;
|
||||||
@ -38,10 +46,16 @@ export class Game {
|
|||||||
private slicer: SlicerView;
|
private slicer: SlicerView;
|
||||||
private prep: PrepView;
|
private prep: PrepView;
|
||||||
private juice: JuiceView;
|
private juice: JuiceView;
|
||||||
|
private oven: OvenView;
|
||||||
|
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;
|
||||||
/** What the juice station reported, if the order sent you there. */
|
/** What the juice station reported, if the order sent you there. */
|
||||||
private juiceResult: JuiceResult | null = null;
|
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. */
|
/** Remaining prep steps for the current order, drained as each hands off. */
|
||||||
private prepQueue: PrepStep[] = [];
|
private prepQueue: PrepStep[] = [];
|
||||||
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
|
/** 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 save: Save;
|
||||||
private ticket: Panel;
|
private ticket: Panel;
|
||||||
private ticketEl!: HTMLElement;
|
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) {
|
constructor(private app: App) {
|
||||||
this.save = loadSave();
|
this.save = loadSave();
|
||||||
@ -63,25 +80,39 @@ export class Game {
|
|||||||
this.slicer = new SlicerView(app);
|
this.slicer = new SlicerView(app);
|
||||||
this.prep = new PrepView(app);
|
this.prep = new PrepView(app);
|
||||||
this.juice = new JuiceView(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.kitchen.root);
|
||||||
app.scene.add(this.judgeView.root);
|
app.scene.add(this.judgeView.root);
|
||||||
app.scene.add(this.drawer.root);
|
app.scene.add(this.drawer.root);
|
||||||
app.scene.add(this.slicer.root);
|
app.scene.add(this.slicer.root);
|
||||||
app.scene.add(this.prep.root);
|
app.scene.add(this.prep.root);
|
||||||
app.scene.add(this.juice.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.judgeView.root.visible = false;
|
||||||
this.drawer.root.visible = false;
|
this.drawer.root.visible = false;
|
||||||
this.slicer.root.visible = false;
|
this.slicer.root.visible = false;
|
||||||
this.prep.root.visible = false;
|
this.prep.root.visible = false;
|
||||||
this.juice.root.visible = false;
|
this.juice.root.visible = false;
|
||||||
|
this.oven.root.visible = false;
|
||||||
|
this.assembly.root.visible = false;
|
||||||
|
|
||||||
this.ticket = new Panel('ticket');
|
this.ticket = new Panel('ticket');
|
||||||
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
|
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:
|
// 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
|
// its cost is your toast going cold, and cold toast is what makes butter
|
||||||
// impossible. Dawdling here is the punishment.
|
// 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.kitchen.onServed = () => this.serve();
|
||||||
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
|
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
|
||||||
this.slicer.onSliced = (cut) => {
|
this.slicer.onSliced = (cut) => {
|
||||||
@ -103,6 +134,12 @@ export class Game {
|
|||||||
// while you rummage" is the entire reason the drawer exists.
|
// while you rummage" is the entire reason the drawer exists.
|
||||||
app.onTick((dt) => {
|
app.onTick((dt) => {
|
||||||
if (this.timing) this.orderSeconds += 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
|
// On the knife trip there's no toast to cool yet — the timer is the
|
||||||
// customer's patience instead.
|
// customer's patience instead.
|
||||||
if (this.knifeTrip) {
|
if (this.knifeTrip) {
|
||||||
@ -146,6 +183,28 @@ export class Game {
|
|||||||
get juiceView(): JuiceView {
|
get juiceView(): JuiceView {
|
||||||
return this.juice;
|
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
|
* 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
|
* 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
|
* 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.orderSeconds = 0;
|
||||||
this.prepResult = null;
|
this.prepResult = null;
|
||||||
this.juiceResult = null;
|
this.juiceResult = null;
|
||||||
|
this.lastBruschetta = null;
|
||||||
this.prepQueue = o.prep ? [...o.prep] : [];
|
this.prepQueue = o.prep ? [...o.prep] : [];
|
||||||
this.timing = true;
|
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();
|
const s = this.judgeView.release();
|
||||||
if (s) this.kitchen.root.add(s.mesh);
|
if (s) this.kitchen.root.add(s.mesh);
|
||||||
|
|
||||||
@ -370,6 +525,42 @@ export class Game {
|
|||||||
if (SPREADS[o.spread].separates) {
|
if (SPREADS[o.spread].separates) {
|
||||||
el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip');
|
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. */
|
/** Called when the player hands the plate over. */
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { CHAR_THRESHOLD } from '../sim/slice';
|
|||||||
import { SPREADS, amountClassOf } from '../sim/spreads';
|
import { SPREADS, amountClassOf } from '../sim/spreads';
|
||||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||||
import type { Order } from './orders';
|
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 {
|
export interface Criterion {
|
||||||
key: string;
|
key: string;
|
||||||
@ -12,8 +16,10 @@ export interface Criterion {
|
|||||||
weight: number;
|
weight: number;
|
||||||
/** What the number actually was, in words. */
|
/** What the number actually was, in words. */
|
||||||
detail: string;
|
detail: string;
|
||||||
/** Which station earned it — the scorecard groups by this on prep orders. */
|
/** Which station earned it — the scorecard groups by this on prep orders.
|
||||||
group?: 'prep' | 'toast' | 'spread';
|
* 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. */
|
/** 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';
|
if (total >= 3.8) return 'C';
|
||||||
return 'F';
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -215,6 +215,89 @@ const BANK: Bank = {
|
|||||||
'Clean and fast. The onion never got a word in.',
|
'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
|
// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the
|
||||||
|
|||||||
@ -34,6 +34,19 @@ export type PrepStep =
|
|||||||
seeds?: number;
|
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". */
|
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
|
||||||
export function prepAsk(step: PrepStep): string {
|
export function prepAsk(step: PrepStep): string {
|
||||||
const name = step.ingredient.replace(/_/g, ' ');
|
const name = step.ingredient.replace(/_/g, ' ');
|
||||||
@ -78,6 +91,8 @@ export interface Order {
|
|||||||
who: string;
|
who: string;
|
||||||
/** Work at the prep bench, when the order has any. */
|
/** Work at the prep bench, when the order has any. */
|
||||||
prep?: PrepStep[];
|
prep?: PrepStep[];
|
||||||
|
/** The bruschetta capstone: roast + assemble replace the spread flow. */
|
||||||
|
bruschetta?: BruschettaSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BROWNING_NAMES: [number, string][] = [
|
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.
|
// 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' }],
|
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. */
|
/** Days beyond the script — keep going, with everything cranked. */
|
||||||
|
|||||||
284
src/scenes/assembly.ts
Normal file
284
src/scenes/assembly.ts
Normal file
@ -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<Mode, string> = { 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -107,10 +107,22 @@ export class JudgeView implements View {
|
|||||||
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
|
||||||
// station. Ordinary toast orders keep the flat card they always had.
|
// station. Ordinary toast orders keep the flat card they always had.
|
||||||
const grouped = verdict.criteria.some((c) => c.group === 'prep');
|
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
|
||||||
const headers: Record<string, string> = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD' };
|
// 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<string, string> = {
|
||||||
|
prep: 'THE PREP',
|
||||||
|
toast: 'THE TOAST',
|
||||||
|
spread: 'THE SPREAD',
|
||||||
|
bread: 'THE BREAD',
|
||||||
|
topping: 'THE TOPPING',
|
||||||
|
bench: 'THE BENCH',
|
||||||
|
};
|
||||||
|
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, spread: 4, bench: 5 };
|
||||||
let lastGroup: string | undefined;
|
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
|
const ordered = grouped
|
||||||
? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group))
|
? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group))
|
||||||
: verdict.criteria;
|
: verdict.criteria;
|
||||||
|
|||||||
260
src/scenes/oven.ts
Normal file
260
src/scenes/oven.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/sim/assembly.ts
Normal file
179
src/sim/assembly.ts
Normal file
@ -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<ToppingKind, number> = {
|
||||||
|
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<ToppingKind, number>;
|
||||||
|
/** 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<ToppingKind, number> = { 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';
|
||||||
|
}
|
||||||
@ -116,7 +116,9 @@ export const INGREDIENTS: Record<IngredientId, Ingredient> = {
|
|||||||
size: 0.45,
|
size: 0.45,
|
||||||
skin: { resistance: 0.3, slippery: 0.2 },
|
skin: { resistance: 0.3, slippery: 0.2 },
|
||||||
flesh: { resistance: 0.15, moisture: 0.35 },
|
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] },
|
colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] },
|
||||||
},
|
},
|
||||||
cucumber: {
|
cucumber: {
|
||||||
|
|||||||
150
src/sim/roasting.ts
Normal file
150
src/sim/roasting.ts
Normal file
@ -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';
|
||||||
|
}
|
||||||
141
src/sim/tempclock.ts
Normal file
141
src/sim/tempclock.ts
Normal file
@ -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' };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user