Merge branch 'worktree-agent-aa2e6cce1a128c28b'
This commit is contained in:
commit
46b1d8fd72
149
src/dev.ts
149
src/dev.ts
@ -1,6 +1,8 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App } from './core/app';
|
||||
import type { Game } from './game/game';
|
||||
import { sogWord } from './sim/assembly';
|
||||
import type { IngredientId } from './sim/ingredients';
|
||||
|
||||
/**
|
||||
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
|
||||
@ -419,6 +421,153 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
frames: f,
|
||||
};
|
||||
},
|
||||
|
||||
// --- M15 bruschetta ------------------------------------------------------
|
||||
|
||||
/** Flip a perishable fridge↔bench on the temp clock (costs service time). */
|
||||
tempToggle(id: IngredientId) {
|
||||
game.toggleTemp(id);
|
||||
run(1);
|
||||
return game.tempClock?.readiness(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drive the routed oven (the one a day-14 order sends you to): dial in the
|
||||
* heat, slide the tray in, roast `seconds`, pull it out. Leaves it at 'done';
|
||||
* `t.bruschetta` advances with ENTER. ~32s@6 lands a blistered ~0.70, none
|
||||
* slumped.
|
||||
*/
|
||||
roast(seconds = 32, power = 6) {
|
||||
const oven = game.ovenView;
|
||||
oven.power = power;
|
||||
run(3);
|
||||
tap('Space'); // tray in
|
||||
run(Math.round(seconds * 60));
|
||||
tap('Space'); // tray out
|
||||
run(3);
|
||||
return oven.result();
|
||||
},
|
||||
|
||||
/**
|
||||
* Rub garlic across the toast at the assembly bench: G-mode, then a raster
|
||||
* drag. `passes` rows of strokes; 3 lands ~0.35 coverage — a light, even
|
||||
* coat, full marks.
|
||||
*/
|
||||
rub(passes = 3) {
|
||||
const a = game.assemblyView;
|
||||
run(2);
|
||||
tap('KeyG');
|
||||
for (let p = 0; p < passes; p++) {
|
||||
const v = 0.2 + p * 0.18;
|
||||
const cols = 12;
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const t = i / cols;
|
||||
const u = 0.15 + (p % 2 ? 1 - t : t) * 0.7;
|
||||
const [x, y, z] = a.worldAt(u, v);
|
||||
point(x, y, z, true);
|
||||
run(1);
|
||||
}
|
||||
}
|
||||
app.input.down = false;
|
||||
run(1);
|
||||
return { rubCoverage: +a.result().rubCoverage.toFixed(3) };
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop a set of toppings on a shared even grid — kinds interleaved onto one
|
||||
* grid so the point set stays evenly strewn (Clark–Evans R ~1.25). The digit
|
||||
* keys pick the tool; each press-edge drops one piece.
|
||||
*/
|
||||
assemble(counts: { tomato?: number; cheese?: number; basil?: number } = { tomato: 5, cheese: 4, basil: 3 }) {
|
||||
const a = game.assemblyView;
|
||||
const kinds: [string, string][] = [];
|
||||
const push = (n: number, key: string) => {
|
||||
for (let i = 0; i < n; i++) kinds.push([key, key]);
|
||||
};
|
||||
push(counts.tomato ?? 0, 'Digit1');
|
||||
push(counts.cheese ?? 0, 'Digit2');
|
||||
push(counts.basil ?? 0, 'Digit3');
|
||||
const n = kinds.length;
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(n * 1.3)));
|
||||
const rows = Math.max(1, Math.ceil(n / cols));
|
||||
let lastKey = '';
|
||||
run(2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = kinds[i][1];
|
||||
if (key !== lastKey) {
|
||||
tap(key);
|
||||
lastKey = key;
|
||||
run(1);
|
||||
}
|
||||
const c = i % cols;
|
||||
const rw = Math.floor(i / cols);
|
||||
const u = 0.14 + ((c + 0.5) / cols) * 0.72;
|
||||
const v = 0.14 + ((rw + 0.5) / rows) * 0.72;
|
||||
const [x, y, z] = a.worldAt(u, v);
|
||||
point(x, y, z, false);
|
||||
run(1);
|
||||
point(x, y, z, true); // press edge → one placement
|
||||
run(1);
|
||||
}
|
||||
app.input.down = false;
|
||||
run(1);
|
||||
const res = a.result();
|
||||
return { count: res.count, mass: +res.massPerArea.toFixed(2), rub: +res.rubCoverage.toFixed(3), kinds: res.kinds };
|
||||
},
|
||||
|
||||
/** Let the sog clock run `seconds` at the assembly bench, then read it. */
|
||||
sogWait(seconds = 8) {
|
||||
run(Math.round(seconds * 60));
|
||||
const sog = game.assemblyView.result().sog;
|
||||
return { sog: +sog.toFixed(3), word: sogWord(sog) };
|
||||
},
|
||||
|
||||
/** Read the last bruschetta verdict — the full stats, grouped by station. */
|
||||
bruschettaStats() {
|
||||
const lb = game.lastBruschettaStats;
|
||||
if (!lb) return null;
|
||||
const { result, verdict } = lb;
|
||||
const crit = (k: string) => {
|
||||
const c = verdict.criteria.find((x) => x.key === k);
|
||||
return c ? +c.score.toFixed(3) : null;
|
||||
};
|
||||
const r = (n: number) => +n.toFixed(3);
|
||||
return {
|
||||
grade: verdict.grade,
|
||||
total: +verdict.total.toFixed(2),
|
||||
roast: { mean: r(result.roast.mean), blister: r(result.roast.blisterFrac), collapse: r(result.roast.collapseFrac), score: crit('roast') },
|
||||
distribution: crit('topdist'),
|
||||
balance: { mass: r(result.assembly.massPerArea), score: crit('balance') },
|
||||
sog: { value: r(result.assembly.sog), word: sogWord(result.assembly.sog), score: crit('sog') },
|
||||
rub: { coverage: r(result.assembly.rubCoverage), score: crit('rub') },
|
||||
temp: { score: r(result.temp.score), worst: result.temp.worstName, criterion: crit('temp') },
|
||||
bench: crit('bench'),
|
||||
criteria: verdict.criteria.map((c) => ({ key: c.key, group: c.group ?? '-', score: +c.score.toFixed(2) })),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The whole bruschetta, end to end and headless: day 14, plan the brie out,
|
||||
* cut the doorstop, toast it, roast the tomatoes, rub + top + wait, serve.
|
||||
* Returns the full grouped stats.
|
||||
*/
|
||||
bruschetta() {
|
||||
game.setDay(14);
|
||||
run(5);
|
||||
game.toggleTemp('brie'); // take the brie out early — it must be soft
|
||||
harness.pick('bread_knife'); // knife trip → slicer
|
||||
harness.slice({ thickness: 0.31, dy: 0.05 }); // cut the doorstop → kitchen
|
||||
harness.toast(12, 6); // toast → lands → enterOven
|
||||
harness.roast(32, 6); // roast the tomatoes
|
||||
tap('Enter'); // oven done → assembly
|
||||
run(3);
|
||||
harness.rub(3);
|
||||
harness.assemble({ tomato: 5, cheese: 4, basil: 3 });
|
||||
harness.sogWait(8);
|
||||
tap('Enter'); // serve → the judge
|
||||
run(5);
|
||||
return harness.bruschettaStats();
|
||||
},
|
||||
};
|
||||
|
||||
(window as unknown as { __t: unknown }).__t = harness;
|
||||
|
||||
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 { 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. */
|
||||
|
||||
@ -3,6 +3,10 @@ import { CHAR_THRESHOLD } from '../sim/slice';
|
||||
import { SPREADS, amountClassOf } from '../sim/spreads';
|
||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import type { Order } from './orders';
|
||||
import type { RoastResult } from '../sim/roasting';
|
||||
import { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting';
|
||||
import type { AssemblyResult } from '../sim/assembly';
|
||||
import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly';
|
||||
|
||||
export interface Criterion {
|
||||
key: string;
|
||||
@ -12,8 +16,10 @@ export interface Criterion {
|
||||
weight: number;
|
||||
/** What the number actually was, in words. */
|
||||
detail: string;
|
||||
/** Which station earned it — the scorecard groups by this on prep orders. */
|
||||
group?: 'prep' | 'toast' | 'spread';
|
||||
/** Which station earned it — the scorecard groups by this on prep orders.
|
||||
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
|
||||
* distribution, balance, sog), bench (temperature + mess). */
|
||||
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench';
|
||||
}
|
||||
|
||||
/** What the prep bench hands the judge, when the order had prep on it. */
|
||||
@ -431,3 +437,227 @@ export function gradeOf(total: number): Grade {
|
||||
if (total >= 3.8) return 'C';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping /
|
||||
// The Bench. Every row is a number some station computed — the cut and toast
|
||||
// off the bread, the roast + Clark–Evans distribution + mass balance + sog off
|
||||
// the topping, the temperature-plan and the mess off the bench. Kept here beside
|
||||
// the toast judge so the game has one door to scoring.
|
||||
// =====================================================================
|
||||
|
||||
/** What the whole bruschetta hands the judge. */
|
||||
export interface BruschettaResult {
|
||||
roast: RoastResult;
|
||||
assembly: AssemblyResult;
|
||||
/** Temperature-plan readiness, from TempClock.overall(). */
|
||||
temp: { score: number; worstName: string; worstWord: string };
|
||||
bench: { mass: number; spreadFrac: number; solids: number };
|
||||
}
|
||||
|
||||
/** A value in [lo,hi] scores 1, falling away outside over `soft` either side. */
|
||||
function bandScore(v: number, lo: number, hi: number, soft: number): number {
|
||||
if (v >= lo && v <= hi) return 1;
|
||||
const d = v < lo ? lo - v : v - hi;
|
||||
return clamp01(1 - d / soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Roast. Blistered is the target (mean in the blister window); raw scores
|
||||
* low, and a rack that slumped to sauce is docked hard — collapsed tomatoes are
|
||||
* not a topping, they're a spill waiting to happen.
|
||||
*/
|
||||
export function roastCriterion(r: RoastResult): Criterion {
|
||||
const band = bandScore(r.mean, BLISTER_AT - 0.02, 0.82, 0.4);
|
||||
const score = clamp01(band - r.collapseFrac * 1.1);
|
||||
const word =
|
||||
r.collapseFrac > 0.35
|
||||
? 'collapsed to sauce'
|
||||
: r.mean < 0.3
|
||||
? 'still raw'
|
||||
: r.mean < BLISTER_AT
|
||||
? 'under-roasted'
|
||||
: r.mean >= COLLAPSE_AT
|
||||
? 'scorched'
|
||||
: 'blistered';
|
||||
return {
|
||||
key: 'roast',
|
||||
group: 'topping',
|
||||
label: 'The Roast',
|
||||
score,
|
||||
weight: 1.2,
|
||||
detail: `${word} (${r.mean.toFixed(2)}${r.collapseFrac > 0.05 ? `, ${Math.round(r.collapseFrac * 100)}% slumped` : ''})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribution — the same Clark–Evans nearest-neighbour index the rind is
|
||||
* scored with, fed the topping point list. Even scatter over the toast beats a
|
||||
* pile in one corner; every bite should get a bit of everything.
|
||||
*/
|
||||
export function toppingDistributionCriterion(points: { u: number; v: number }[]): Criterion {
|
||||
const n = points.length;
|
||||
if (n < 4) {
|
||||
return {
|
||||
key: 'topdist',
|
||||
group: 'topping',
|
||||
label: 'Distribution',
|
||||
score: n === 0 ? 0.05 : 0.35,
|
||||
weight: 1.2,
|
||||
detail: n === 0 ? 'nothing on it' : `only ${n} pieces`,
|
||||
};
|
||||
}
|
||||
const R = clarkEvans(points, 1);
|
||||
const score = clamp01(R / 0.95);
|
||||
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
|
||||
return {
|
||||
key: 'topdist',
|
||||
group: 'topping',
|
||||
label: 'Distribution',
|
||||
score,
|
||||
weight: 1.2,
|
||||
detail: `${n} pieces, ${word} (R ${R.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance — total topping mass per slice area, held to a band. "Thicker but
|
||||
* even and balanced" is the ask: too bare and the toast shows through, too
|
||||
* heaped and it slides off (and soaks through). Distribution is scored beside
|
||||
* this, so an even-but-thin or heaped-but-tidy build each only wins one row.
|
||||
*/
|
||||
export function toppingBalanceCriterion(a: AssemblyResult): Criterion {
|
||||
const [lo, hi] = MASS_BAND;
|
||||
const score = bandScore(a.massPerArea, lo, hi, (hi - lo) * 1.1);
|
||||
const word =
|
||||
a.massPerArea < lo * 0.85
|
||||
? 'bare in patches'
|
||||
: a.massPerArea > hi * 1.1
|
||||
? 'a heap, not a topping'
|
||||
: 'well balanced';
|
||||
const missing =
|
||||
a.kinds.tomato === 0 ? ' — no tomato' : a.kinds.cheese === 0 ? ' — no cheese' : a.kinds.basil === 0 ? ' — no basil' : '';
|
||||
return {
|
||||
key: 'balance',
|
||||
group: 'topping',
|
||||
label: 'Balance',
|
||||
score: clamp01(score - (missing ? 0.25 : 0)),
|
||||
weight: 1.1,
|
||||
detail: `${word}${missing} (mass ${a.massPerArea.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The sog clock's verdict — the finger-press. Serve before it bends. */
|
||||
export function sogCriterion(sog: number): Criterion {
|
||||
const score = clamp01(1 - (sog - 0.2) / 0.6);
|
||||
return {
|
||||
key: 'sog',
|
||||
group: 'topping',
|
||||
label: 'Sog',
|
||||
score,
|
||||
weight: 1.1,
|
||||
detail: `${sogWord(sog)} (${sog.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The garlic rub — one light pass, judged gently. None reads raw; a scrub is
|
||||
* wasteful; an even light coat is right. */
|
||||
export function rubCriterion(coverage: number): Criterion {
|
||||
const score = bandScore(coverage, RUB_TARGET * 0.6, 0.62, 0.32);
|
||||
const word =
|
||||
coverage < 0.06 ? 'no garlic at all' : coverage < RUB_TARGET * 0.6 ? 'barely rubbed' : coverage > 0.7 ? 'drenched in garlic' : 'a proper rub';
|
||||
return {
|
||||
key: 'rub',
|
||||
group: 'bread',
|
||||
label: 'The Rub',
|
||||
score,
|
||||
weight: 0.7,
|
||||
detail: `${word} (${Math.round(coverage * 100)}%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The temperature plan — brie soft, parmesan cold. The Bench's planning row. */
|
||||
export function tempCriterion(temp: { score: number; worstName: string; worstWord: string }): Criterion {
|
||||
return {
|
||||
key: 'temp',
|
||||
group: 'bench',
|
||||
label: 'Temperature',
|
||||
score: temp.score,
|
||||
weight: 1.0,
|
||||
detail: temp.score > 0.8 ? 'brie soft, parmesan cold' : `${temp.worstName} ${temp.worstWord}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bruschetta verdict. `slice` is the toasted, cut sourdough (browning + cut
|
||||
* come off it); everything else comes from the three stations.
|
||||
*/
|
||||
export function judgeBruschetta(
|
||||
slice: Slice,
|
||||
order: Order,
|
||||
usedTool: ToolId,
|
||||
seconds: number,
|
||||
r: BruschettaResult,
|
||||
): Verdict {
|
||||
const b = slice.browning.stats(slice.mask);
|
||||
const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
|
||||
|
||||
// The Bread: the cut, the toast, the rub.
|
||||
const cut = cutCriterion(slice, order);
|
||||
cut.group = 'bread';
|
||||
const criteria: Criterion[] = [
|
||||
cut,
|
||||
{
|
||||
key: 'browning',
|
||||
group: 'bread',
|
||||
label: 'Browning',
|
||||
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
|
||||
weight: 1.4,
|
||||
detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
|
||||
},
|
||||
{
|
||||
key: 'char',
|
||||
group: 'bread',
|
||||
label: 'Char',
|
||||
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
|
||||
weight: 0.6,
|
||||
detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
|
||||
},
|
||||
rubCriterion(r.assembly.rubCoverage),
|
||||
// The Topping.
|
||||
roastCriterion(r.roast),
|
||||
toppingDistributionCriterion(r.assembly.points),
|
||||
toppingBalanceCriterion(r.assembly),
|
||||
sogCriterion(r.assembly.sog),
|
||||
// The Bench.
|
||||
tempCriterion(r.temp),
|
||||
{ ...benchCriterion(r.bench), group: 'bench' as const },
|
||||
// Ungrouped.
|
||||
{
|
||||
key: 'time',
|
||||
label: 'Service',
|
||||
score: clamp01(1 - (seconds - 60) / 90),
|
||||
weight: 0.3,
|
||||
detail: `${Math.round(seconds)}s`,
|
||||
},
|
||||
];
|
||||
|
||||
let sum = 0;
|
||||
let wsum = 0;
|
||||
for (const c of criteria) {
|
||||
sum += c.score * c.weight;
|
||||
wsum += c.weight;
|
||||
}
|
||||
const total = (sum / wsum) * 10;
|
||||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||||
const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
|
||||
void usedTool;
|
||||
return {
|
||||
criteria,
|
||||
total,
|
||||
grade: gradeOf(total),
|
||||
best: sorted[sorted.length - 1],
|
||||
worst: sorted[0],
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -34,6 +34,19 @@ export type PrepStep =
|
||||
seeds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bruschetta capstone (day 14). Its presence on an order flips the day flow
|
||||
* from cut→toast→spread to cut→toast→roast→assemble→serve, and arms the
|
||||
* temperature clock for the perishables it names.
|
||||
*/
|
||||
export interface BruschettaSpec {
|
||||
/** Tomato halves on the oven tray. */
|
||||
roastHalves: number;
|
||||
/** Perishables the temp clock tracks: brie wants out early (soft), parmesan
|
||||
* wants to stay in (cold). */
|
||||
perishables: IngredientId[];
|
||||
}
|
||||
|
||||
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
|
||||
export function prepAsk(step: PrepStep): string {
|
||||
const name = step.ingredient.replace(/_/g, ' ');
|
||||
@ -78,6 +91,8 @@ export interface Order {
|
||||
who: string;
|
||||
/** Work at the prep bench, when the order has any. */
|
||||
prep?: PrepStep[];
|
||||
/** The bruschetta capstone: roast + assemble replace the spread flow. */
|
||||
bruschetta?: BruschettaSpec;
|
||||
}
|
||||
|
||||
export const BROWNING_NAMES: [number, string][] = [
|
||||
@ -303,6 +318,30 @@ export const DAYS: Order[] = [
|
||||
// therefore sting) stays low and the whole story is restraint at the root.
|
||||
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }],
|
||||
},
|
||||
{
|
||||
day: 14,
|
||||
brand: 'the bakery loaf',
|
||||
bread: 'sourdough',
|
||||
// The spread flow is replaced by roast + assemble on this order — the garlic
|
||||
// rub stands in for the spread — but the Order shape still wants these, and
|
||||
// they stay harmless defaults the bruschetta path never reads.
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.55,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.5,
|
||||
fromLoaf: true,
|
||||
cutClass: 'doorstop',
|
||||
cutBand: CUT_BANDS.doorstop,
|
||||
who: 'the inspector himself',
|
||||
text: 'Bruschetta. Cut the sourdough thick and toast it, then rub it with garlic. Roast the tomatoes till they BLISTER — not sauce. Brie soft, parmesan cold. Top it even and balanced, and serve it before it bends.',
|
||||
// The capstone: cut the loaf → toast → roast the tomatoes → assemble (rub,
|
||||
// top, watch the sog) → serve. The temp clock tracks the two cheeses from
|
||||
// order start; take the brie out early, leave the parmesan in.
|
||||
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'] },
|
||||
},
|
||||
];
|
||||
|
||||
/** Days beyond the script — keep going, with everything cranked. */
|
||||
|
||||
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);
|
||||
// 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
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,
|
||||
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
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