Merge branch 'worktree-agent-aa2e6cce1a128c28b'

This commit is contained in:
type-two 2026-07-18 15:26:40 +10:00
commit 46b1d8fd72
12 changed files with 1728 additions and 8 deletions

View File

@ -1,6 +1,8 @@
import * as THREE from 'three';
import type { App } from './core/app';
import type { Game } from './game/game';
import { sogWord } from './sim/assembly';
import type { IngredientId } from './sim/ingredients';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
@ -419,6 +421,153 @@ export function installDevHarness(app: App, game: Game): void {
frames: f,
};
},
// --- M15 bruschetta ------------------------------------------------------
/** Flip a perishable fridge↔bench on the temp clock (costs service time). */
tempToggle(id: IngredientId) {
game.toggleTemp(id);
run(1);
return game.tempClock?.readiness(id);
},
/**
* Drive the routed oven (the one a day-14 order sends you to): dial in the
* heat, slide the tray in, roast `seconds`, pull it out. Leaves it at 'done';
* `t.bruschetta` advances with ENTER. ~32s@6 lands a blistered ~0.70, none
* slumped.
*/
roast(seconds = 32, power = 6) {
const oven = game.ovenView;
oven.power = power;
run(3);
tap('Space'); // tray in
run(Math.round(seconds * 60));
tap('Space'); // tray out
run(3);
return oven.result();
},
/**
* Rub garlic across the toast at the assembly bench: G-mode, then a raster
* drag. `passes` rows of strokes; 3 lands ~0.35 coverage a light, even
* coat, full marks.
*/
rub(passes = 3) {
const a = game.assemblyView;
run(2);
tap('KeyG');
for (let p = 0; p < passes; p++) {
const v = 0.2 + p * 0.18;
const cols = 12;
for (let i = 0; i <= cols; i++) {
const t = i / cols;
const u = 0.15 + (p % 2 ? 1 - t : t) * 0.7;
const [x, y, z] = a.worldAt(u, v);
point(x, y, z, true);
run(1);
}
}
app.input.down = false;
run(1);
return { rubCoverage: +a.result().rubCoverage.toFixed(3) };
},
/**
* Drop a set of toppings on a shared even grid kinds interleaved onto one
* grid so the point set stays evenly strewn (ClarkEvans 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;

View File

@ -4,10 +4,15 @@ import { KitchenView } from '../scenes/kitchen';
import { SlicerView } from '../scenes/slicer';
import { PrepView } from '../scenes/prep';
import { JuiceView } from '../scenes/juice';
import { OvenView } from '../scenes/oven';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import type { PrepResult } from './judging';
import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
@ -21,6 +26,9 @@ import { Title } from '../ui/title';
const SAVE_KEY = 'toastsim.save';
/** Reopening the fridge mid-service costs you — the planning has teeth. */
const TEMP_TOGGLE_COST = 8;
interface Save {
day: number;
total: number;
@ -38,10 +46,16 @@ export class Game {
private slicer: SlicerView;
private prep: PrepView;
private juice: JuiceView;
private oven: OvenView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
/** What the juice station reported, if the order sent you there. */
private juiceResult: JuiceResult | null = null;
/** The temperature clock for the current bruschetta order (brie/parmesan). */
private temp: TempClock | null = null;
/** The last bruschetta verdict, for the harness to read. */
private lastBruschetta: { result: BruschettaResult; verdict: Verdict } | null = null;
/** Remaining prep steps for the current order, drained as each hands off. */
private prepQueue: PrepStep[] = [];
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
@ -54,6 +68,9 @@ export class Game {
private save: Save;
private ticket: Panel;
private ticketEl!: HTMLElement;
/** The fridge/bench toggle, one row per perishable, shown on bruschetta days. */
private tempPanel: Panel;
private tempRows: { id: IngredientId; btn: HTMLButtonElement }[] = [];
constructor(private app: App) {
this.save = loadSave();
@ -63,25 +80,39 @@ export class Game {
this.slicer = new SlicerView(app);
this.prep = new PrepView(app);
this.juice = new JuiceView(app);
this.oven = new OvenView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
app.scene.add(this.drawer.root);
app.scene.add(this.slicer.root);
app.scene.add(this.prep.root);
app.scene.add(this.juice.root);
app.scene.add(this.oven.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.prep.root.visible = false;
this.juice.root.visible = false;
this.oven.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
this.tempPanel = new Panel('temp-toggle');
this.tempPanel.hide();
// toast -> drawer -> spread. The drawer sits AFTER the toaster on purpose:
// its cost is your toast going cold, and cold toast is what makes butter
// impossible. Dawdling here is the punishment.
this.kitchen.onPopped = () => this.openDrawer();
// After the toast lands: a bruschetta order goes to the oven instead of the
// spread drawer (the garlic rub replaces the spread), everything else opens
// the drawer as it always did.
this.kitchen.onPopped = () => {
if (this.order.bruschetta) this.enterOven();
else this.openDrawer();
};
this.kitchen.onServed = () => this.serve();
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
this.slicer.onSliced = (cut) => {
@ -103,6 +134,12 @@ export class Game {
// while you rummage" is the entire reason the drawer exists.
app.onTick((dt) => {
if (this.timing) this.orderSeconds += dt;
// The temperature clock drifts on real service time, whatever screen you're
// on: the brie warms toward spreadable, the parmesan warms toward smearing.
if (this.temp && this.timing) {
this.temp.step(dt);
this.refreshTempToggle();
}
// On the knife trip there's no toast to cool yet — the timer is the
// customer's patience instead.
if (this.knifeTrip) {
@ -146,6 +183,28 @@ export class Game {
get juiceView(): JuiceView {
return this.juice;
}
get ovenView(): OvenView {
return this.oven;
}
get assemblyView(): AssemblyView {
return this.assembly;
}
get tempClock(): TempClock | null {
return this.temp;
}
/** The last bruschetta verdict + inputs, for the harness. */
get lastBruschettaStats() {
return this.lastBruschetta;
}
/** Flip a perishable fridge↔bench. Revisiting the fridge costs service time. */
toggleTemp(id: IngredientId): void {
if (!this.temp) return;
this.temp.toggle(id);
// Opening the fridge again mid-service is not free.
this.orderSeconds += TEMP_TOGGLE_COST;
this.refreshTempToggle();
}
/**
* Send the player to the prep bench in isolation. The harness drives this
@ -212,6 +271,90 @@ export class Game {
};
}
/**
* The oven's door, same shape as enterPrep/enterJuice: a tray of tomato halves,
* the roast Field, and a hand-off to the assembly bench once ENTER pulls them.
*/
private enterOven(): void {
const b = this.order.bruschetta!;
this.oven.reset(b.roastHalves, 6, 'roast the tomatoes — blistered, not sauce');
this.hideAllScenes();
this.oven.root.visible = true;
this.app.setView(this.oven);
this.ticket.show();
this.oven.onDone = (roast) => {
this.oven.root.visible = false;
this.enterAssembly(roast);
};
}
/**
* The assembly bench, primed with what the upstream stations left: the cut's
* thickness (the doorstop's sog defence), how wet the tomatoes came off the tray
* (a collapsed rack soaks faster), and how ready the brie is off the temp clock.
*/
private enterAssembly(roast: RoastResult): void {
const slice = this.kitchen.currentSlice;
const thickness = slice.cut?.thickness ?? 0.24;
// Collapsed halves went on wetter — the moisture they were holding soaks in.
const tomatoMoisture = 0.95 + roast.collapseFrac * 0.6;
const cheeseReady = this.temp ? this.temp.readiness('brie').score : 1;
this.assembly.reset({ sliceThickness: thickness, tomatoMoisture, cheeseReady, askText: 'top it even, mind the sog' });
this.hideAllScenes();
this.assembly.root.visible = true;
this.app.setView(this.assembly);
this.ticket.show();
this.assembly.onDone = (a) => {
this.assembly.root.visible = false;
this.serveBruschetta(roast, a);
};
}
/** Everything the three bruschetta stations measured, in one verdict. */
private serveBruschetta(roast: RoastResult, assembly: AssemblyResult): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const ov = this.temp ? this.temp.overall() : null;
const result: BruschettaResult = {
roast,
assembly,
temp: {
score: ov ? ov.score : 1,
worstName: ov ? ov.worst.name : '',
worstWord: ov ? ov.word : 'fine',
},
// The only mess this order makes: tomatoes you let slump to sauce are a
// spill waiting to happen. A clean roast leaves a clean bench.
bench: { mass: roast.collapseFrac * 3, spreadFrac: roast.collapseFrac * 0.5, solids: 0 },
};
const v = judgeBruschetta(slice, this.order, this.kitchen.toolId, seconds, result);
this.lastBruschetta = { result, verdict: v };
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
this.hideTempToggle();
this.hideAllScenes();
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** Hide every station root — the one place the bruschetta hand-offs go through. */
private hideAllScenes(): void {
this.kitchen.root.visible = false;
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.prep.root.visible = false;
this.juice.root.visible = false;
this.oven.root.visible = false;
this.assembly.root.visible = false;
}
/**
* Drain the current order's prep queue one bench trip at a time, folding each
* result into `prepResult`, then fall through to the toaster. A step is a cut
@ -302,9 +445,21 @@ export class Game {
this.orderSeconds = 0;
this.prepResult = null;
this.juiceResult = null;
this.lastBruschetta = null;
this.prepQueue = o.prep ? [...o.prep] : [];
this.timing = true;
// The temperature clock: armed only on bruschetta days, tracking the two
// cheeses from order start so planning (brie out early, parmesan in late) is
// a real choice with a real deadline.
if (o.bruschetta) {
this.temp = new TempClock(o.bruschetta.perishables);
this.showTempToggle();
} else {
this.temp = null;
this.hideTempToggle();
}
const s = this.judgeView.release();
if (s) this.kitchen.root.add(s.mesh);
@ -370,6 +525,42 @@ export class Game {
if (SPREADS[o.spread].separates) {
el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip');
}
if (o.bruschetta) {
el('div', 'ticket-hint', this.ticketEl, 'get the brie out now — it must be soft to spread');
el('div', 'ticket-hint', this.ticketEl, 'leave the parmesan in the fridge until you shave it');
}
}
/** Build the fridge/bench toggle for this order's perishables and show it. */
private showTempToggle(): void {
if (!this.temp) return;
const p = this.tempPanel.root;
p.innerHTML = '';
el('div', 'drawer-lbl', p, 'THE FRIDGE');
this.tempRows = [];
for (const per of this.temp.items) {
const btn = el('button', 'btn ghost', p) as HTMLButtonElement;
btn.addEventListener('click', () => this.toggleTemp(per.id));
this.tempRows.push({ id: per.id, btn });
}
this.tempPanel.show();
this.refreshTempToggle();
}
private refreshTempToggle(): void {
if (!this.temp) return;
for (const row of this.tempRows) {
const per = this.temp.get(row.id);
if (!per) continue;
const r = this.temp.readiness(row.id);
row.btn.textContent = `${per.name}: ${per.state === 'bench' ? 'on the bench' : 'in the fridge'}${r.word}`;
row.btn.style.color = r.score > 0.8 ? '#8fce8f' : r.score > 0.45 ? '#e8a53a' : '#e2603a';
}
}
private hideTempToggle(): void {
this.tempRows = [];
this.tempPanel.hide();
}
/** Called when the player hands the plate over. */

View File

@ -3,6 +3,10 @@ import { CHAR_THRESHOLD } from '../sim/slice';
import { SPREADS, amountClassOf } from '../sim/spreads';
import { TOOLS, type ToolId } from '../sim/cutlery';
import type { Order } from './orders';
import type { RoastResult } from '../sim/roasting';
import { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly';
export interface Criterion {
key: string;
@ -12,8 +16,10 @@ export interface Criterion {
weight: number;
/** What the number actually was, in words. */
detail: string;
/** Which station earned it — the scorecard groups by this on prep orders. */
group?: 'prep' | 'toast' | 'spread';
/** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -431,3 +437,227 @@ export function gradeOf(total: number): Grade {
if (total >= 3.8) return 'C';
return 'F';
}
// =====================================================================
// M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping /
// The Bench. Every row is a number some station computed — the cut and toast
// off the bread, the roast + ClarkEvans 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 ClarkEvans 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: [],
};
}

View File

@ -215,6 +215,89 @@ const BANK: Bank = {
'Clean and fast. The onion never got a word in.',
],
},
// --- M15 bruschetta ---
roast: {
bad: [
'These tomatoes are raw. You showed them the oven. That is not the same as using it.',
'You roasted them into sauce. This is not a topping, it is a spill with ambitions.',
'Half blistered, half collapsed. You opened the oven at exactly the wrong two moments.',
'A tomato should blister, not surrender. These surrendered.',
'You cooked the water out and then kept going. Now there is nothing but skin and regret.',
],
good: [
'Blistered, not burst. That is the whole window and you were in it.',
'The tomatoes caught just enough. Sweet, not sludge.',
'Properly roasted. They hold their shape and give up their sugar. Good.',
],
},
topdist: {
bad: [
'All the topping, one corner. The rest of the toast is a rumour.',
'You built a little hill. Bruschetta is not a hill.',
'One bite has everything. The next has toast and disappointment.',
'It is clumped. Spread it OUT — every bite earns its share.',
'This is a garnish that gave up and sat down together.',
],
good: [
'Evenly strewn, corner to corner. Every bite is the same bite. Good.',
'You placed each piece like it mattered. It did.',
'Textbook scatter. I did not have to hunt for the tomato.',
],
},
balance: {
bad: [
'There is barely anything on this. The toast is doing all the work.',
'You heaped it. It is sliding off as I look at it.',
'Thin in the middle of a thick idea. Commit to the topping.',
'A tomato, a thought, and a lot of bare bread.',
'This is either a very sad pizza or a very confused piece of toast.',
],
good: [
'Thick, even, and it stays put. That is the balance, exactly.',
'Enough of everything, too much of nothing. Well judged.',
'A generous, level topping. Nobody gets a bare bite.',
],
},
sog: {
bad: [
'It bends. Toast should not bend.',
'I pressed it and it folded like a wet letter. You were too slow.',
'The juice won. It always wins if you dawdle, and you dawdled.',
'Soggy. The doorstop was your one defence and you let it soak anyway.',
'This has the structural integrity of an apology.',
],
good: [
'It snapped. That is the sound a bruschetta is meant to make.',
'Crisp under the finger. You served it in time. Good.',
'Still crunching. The thick cut earned its keep.',
],
},
rub: {
bad: [
'No garlic. It is the name of the dish, more or less.',
'You forgot the clove. I can taste the absence of it.',
'You have not rubbed this, you have varnished it. My eyes are watering.',
'A whole clove, in one streak. The rest is plain toast.',
],
good: [
'A light, even rub. You can smell it before you taste it. Correct.',
'The garlic is there and it knows its place. Good.',
'One pass, all over. That is all it ever needed.',
],
},
temp: {
bad: [
'The brie is fridge-hard. You had all service to take it out. You took none of it.',
'The parmesan is sweating. You left it on the bench like it owed you money.',
'Cold cheese does not spread and warm cheese does not shave. You managed both, wrongly.',
'You did not plan. The clock planned for you, and the clock is cruel.',
],
good: [
'Brie soft, parmesan cold. You worked the fridge like someone who has done this.',
'Both cheeses exactly as they should be. That is planning, and I noticed.',
'You took the brie out early and left the parmesan in. Textbook.',
],
},
};
// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the

View File

@ -34,6 +34,19 @@ export type PrepStep =
seeds?: number;
};
/**
* The bruschetta capstone (day 14). Its presence on an order flips the day flow
* from cuttoastspread to cuttoastroastassembleserve, and arms the
* temperature clock for the perishables it names.
*/
export interface BruschettaSpec {
/** Tomato halves on the oven tray. */
roastHalves: number;
/** Perishables the temp clock tracks: brie wants out early (soft), parmesan
* wants to stay in (cold). */
perishables: IngredientId[];
}
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
export function prepAsk(step: PrepStep): string {
const name = step.ingredient.replace(/_/g, ' ');
@ -78,6 +91,8 @@ export interface Order {
who: string;
/** Work at the prep bench, when the order has any. */
prep?: PrepStep[];
/** The bruschetta capstone: roast + assemble replace the spread flow. */
bruschetta?: BruschettaSpec;
}
export const BROWNING_NAMES: [number, string][] = [
@ -303,6 +318,30 @@ export const DAYS: Order[] = [
// therefore sting) stays low and the whole story is restraint at the root.
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }],
},
{
day: 14,
brand: 'the bakery loaf',
bread: 'sourdough',
// The spread flow is replaced by roast + assemble on this order — the garlic
// rub stands in for the spread — but the Order shape still wants these, and
// they stay harmless defaults the bruschetta path never reads.
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
fromLoaf: true,
cutClass: 'doorstop',
cutBand: CUT_BANDS.doorstop,
who: 'the inspector himself',
text: 'Bruschetta. Cut the sourdough thick and toast it, then rub it with garlic. Roast the tomatoes till they BLISTER — not sauce. Brie soft, parmesan cold. Top it even and balanced, and serve it before it bends.',
// The capstone: cut the loaf → toast → roast the tomatoes → assemble (rub,
// top, watch the sog) → serve. The temp clock tracks the two cheeses from
// order start; take the brie out early, leave the parmesan in.
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'] },
},
];
/** Days beyond the script — keep going, with everything cranked. */

284
src/scenes/assembly.ts Normal file
View 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();
}
}

View File

@ -107,10 +107,22 @@ export class JudgeView implements View {
clear(this.cardEl);
// On prep orders the card is long enough to need signposts: group rows by
// station. Ordinary toast orders keep the flat card they always had.
const grouped = verdict.criteria.some((c) => c.group === 'prep');
const headers: Record<string, string> = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD' };
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some(
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench',
);
const headers: Record<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;
const rank = (g?: string) => (g === 'prep' ? 0 : g === 'toast' ? 1 : g === 'spread' ? 2 : 3);
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped
? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group))
: verdict.criteria;

260
src/scenes/oven.ts Normal file
View 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
View 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 ClarkEvans 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 ClarkEvans (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';
}

View File

@ -116,7 +116,9 @@ export const INGREDIENTS: Record<IngredientId, Ingredient> = {
size: 0.45,
skin: { resistance: 0.3, slippery: 0.2 },
flesh: { resistance: 0.15, moisture: 0.35 },
temp: { state: 'fridge', softensInSec: 120, needsSoft: true },
// softensInSec tuned for M15: out on the bench at order start, a brie is
// spreadable by the time the roast + toast are done (~45s of service).
temp: { state: 'fridge', softensInSec: 80, needsSoft: true },
colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] },
},
cucumber: {

150
src/sim/roasting.ts Normal file
View 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 ~2836s (mean ~0.610.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
View 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' };
}