M11 WIP: the prep bench core — one board, and it remembers

Code-complete and verified headless; NOT wired into a day yet (see brief §0.5).

The core, all reusing the M0-M10 bones:
- sim/ingredients.ts: the cast (tomato/orange/onion/garlic/parmesan/brie/
  cucumber/avocado/roast_tomato) with shape, skin slip, flesh moisture+layers,
  seeds, sting, temperature needs, gratability.
- sim/cutting.ts: CutSession = ingredient + knife + pattern (halve/slices/
  wedges/dice), generalizing the M10 slicing sim. New physics: slip (press a
  flat blade on slippery skin and it skates, the aim jumps, juice sprays) and
  juice (moisture x speed, so deliberate leaks and frantic hoses).
- sim/mess.ts: the bench is judged. Field over the board UV + a solids list;
  the wipe is the butter brush in reverse and mass-conserving, so one pass
  smears and the second cleans.
- scenes/prep.ts: aim/bite/saw, deepening seams, pieces fan out, juice stain
  texture, instanced solids, live 'bench: clean/smeared/a crime scene'.
- judging.ts: The Pieces (CV, w1.3) + The Bench (w0.9); clarkEvans extracted
  for M15's toppings; criteria grouped by station and the card grouped with it.
- lines.ts: 8 lines each, in his voice.

Measured, not asserted:
- Even deliberate slices -> CV ~0, bench mass 0.51.
- Sloppy + frantic (0.3 u/frame) -> CV 0.72, mass 18.8, 16 solids, 'a crime scene'.
- Dinner knife pressed on a tomato without sawing -> skates, aim jumps 0 -> 0.12.
  The Best Thing under the same press bites in 7 frames, 0 slips.
- Wipe two passes -> mass 0.51 -> 0.09.

Tuning found by playing: slice CV runs over piece WIDTHS not volumes (round
ingredients make end slices thin by geometry — a perfect cut scored 0.40, which
is a geometry lesson, not a skill test); juice coefficient 0.5 -> 0.1 (the first
tomato flooded the board before the second cut).

Assets: gen_3d retries once per stage, bakery seeds bumped, prep cast queued.
2D landed (bruschetta_hero, wonderslice_bag). 3D is BLOCKED: every bg_remove
fails because m4pro's disk is 100% full. Procedural stand-ins carry the bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-17 17:17:47 +10:00
parent e5be266ce6
commit faf196657e
14 changed files with 1386 additions and 20 deletions

View File

@ -35,7 +35,59 @@ M0M9 are shipped and verified: toasting (browning field), spreading (knife-an
5. **Silhouettes are gameplay** → procedural geometry for anything the player must identify or that carries physics. Generated GLBs for hero props and vessels. 5. **Silhouettes are gameplay** → procedural geometry for anything the player must identify or that carries physics. Generated GLBs for hero props and vessels.
6. **The judge is the reward.** Every new criterion ships with ~5 bad lines + 3 good lines in his voice: dry, specific, never cruel except about MITEY. 6. **The judge is the reward.** Every new criterion ships with ~5 bad lines + 3 good lines in his voice: dry, specific, never cruel except about MITEY.
## 2. M10 finish line — verify the bakery (do this first) ## 0.5 STATUS — updated 2026-07-17, read this before §2
**M10 is DONE, verified and pushed** (commit `e5be266`). §2 below is history; skip it.
**M11 is code-complete and verified, committed as WIP** — see §3, and the notes here.
What M11 landed (all typechecked, all played headless via the harness):
- `sim/ingredients.ts` — the nine-ingredient cast with the full profile from §3.1.
- `sim/cutting.ts``CutSession`, patterns halve/slices/wedges/dice, slip physics,
juice ejection, `pieceCV`. **Note:** CV for `slices`/`halve` runs over piece WIDTHS,
not volumes — on a round ingredient the end slices are thinner by geometry and a
perfect cut still scored CV 0.40, which is not a skill test. Wedges/dice keep volumes.
- `sim/mess.ts` — the judged mess field + solids list + mass-conserving wipe.
- `scenes/prep.ts` — the bench: aim/bite/saw, seams, pieces fan out at the end,
juice stain texture, instanced solids, live `bench: clean/smeared/a crime scene`.
Session end does NOT auto-exit — ENTER hands off, so you can wipe first.
- `judging.ts``PrepResult`, `cutEvennessCriterion` (The Pieces, w 1.3),
`benchCriterion` (The Bench, w 0.9), `clarkEvans` extracted for M15's toppings,
criteria tagged with `group` and the scorecard grouped by station.
- `lines.ts` — 8 lines each for `cutcv` and `bench`.
- Harness: `t.prep(ing, pattern, knife)`, `t.chop({pos, dy, wobble, press})`,
`t.wipe(passes)`, `t.prepStats()`, plus `t.start()`, `t.day(n)`, `t.pick(id)`, `t.slice()`.
Measured on the bench (repeat these before changing tuning):
- Even deliberate slices (dinner knife, 0.045 u/frame) → CV ~0, bench mass 0.51.
- Sloppy positions + frantic speed (0.3 u/frame) → CV 0.72, mass 18.8, 16 solids, "a crime scene".
- Press a dinner knife on a tomato without sawing → it skates: 1 slip, aim jumped 0 → 0.12.
- Same press with The Best Thing → bites in 7 frames, 0 slips (sharp edges sink on downforce).
- Wipe two passes → mass 0.51 → 0.09.
**M11 is NOT finished — remaining before it can be called done:**
1. No order actually routes through the bench yet. `Game.startPrep()` exists and the
harness drives it, but `Order.prep?: PrepStep[]` is declared and unused — no day
sends you there. Wire a day-10 prep order and play it end to end.
2. `dice` two-phase and `wedges` are implemented in the sim but never driven —
no rotate-the-board input, no verification. M14 needs the dice; do it there or here.
3. The pieces fan-out is placeholder-grade (cylinders). Pieces aren't physics bodies yet.
4. The wipe is the same drag as cutting, disambiguated only by distance from the
ingredient — it needs a real cloth tool/mode before it reads to a stranger.
5. No screenshots taken of the bench yet. Do that when the visuals are past placeholder.
**BLOCKER — the asset farm is down for 3D.** Every `bg_remove_local` job fails:
m4pro's disk is 100% full (118MB free; ~3.4TB is `~/Music` — hands off, that's John's).
Flux 2D still works — `bruschetta_hero.png` and `wonderslice_bag.png` generated fine and
are committed. The whole 3D cast (bakery loaves, cutting board, prep-bench props) is
stuck at the cutout stage. `mb settings` is owner-only (403), so re-routing the
bg_remove lane needs John. Once there's space: `bash scripts/gen-assets.sh bakery`
then `bash scripts/gen-assets.sh prep` — retry-once is now built into `gen_3d`, seeds
are bumped, and the prep cast + 2D are already queued in the script. Everything
degrades gracefully: `loadProp` catches, so the procedural stand-ins just stay.
---
## 2. M10 finish line — verify the bakery (DONE — kept for the record)
Everything compiles and day 9 reaches the drawer with the right ticket (verified: chips show `the bakery loaf / doorstop — slice it yourself`, timer reads "they are waiting", and the drawer contains `bread_knife` AND `the_best_thing`). Remaining: Everything compiles and day 9 reaches the drawer with the right ticket (verified: chips show `the bakery loaf / doorstop — slice it yourself`, timer reads "they are waiting", and the drawer contains `bread_knife` AND `the_best_thing`). Remaining:

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

View File

@ -203,6 +203,77 @@ export function installDevHarness(app: App, game: Game): void {
return !drawer.root.visible; return !drawer.root.visible;
}, },
/** Enter the prep bench with an ingredient, a knife, and a pattern. */
prep(ing = 'tomato', pattern: { kind: string; n?: number } = { kind: 'slices', n: 5 }, knife = 'dinner_knife') {
(game as unknown as { startPrep(i: string, p: unknown, k: string): void }).startPrep(ing, pattern, knife);
run(3);
},
/**
* One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis),
* bite, saw to completion. `press` seconds of leaning without sawing
* first, to invite the slip.
*/
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number } = {}) {
const { pos = 0, dy = 0.045, wobble = 0, press = 0 } = opts;
const pv = game.prepView as unknown as {
session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] };
};
const s = pv.session;
const ing = (game.prepView as unknown as { ing: { size: number } }).ing;
run(2);
const Z = -0.2;
const X = () => s.aimPos * ing.size; // slip moves the aim; follow it
point(pos * ing.size, 1.0, Z, false);
run(2);
point(pos * ing.size, 1.0, Z, true);
run(1);
if (press > 0) {
// Lean on it without sawing.
for (let i = 0; i < Math.round(press * 60); i++) {
point(X(), 1.0, Z, true);
run(1);
if (s.phase === 'aim') break; // it slipped and threw the knife off
}
}
let y = 1.0;
let dir = -1;
let f = 0;
while ((s.phase === 'bite' || s.phase === 'saw') && f < 3000) {
y += dir * dy;
if (y < 0.8 || y > 1.3) dir = -dir;
point(X() + (f % 2 ? wobble : -wobble), y, Z, true);
run(1);
f++;
}
app.input.down = false;
run(2);
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f };
},
/** Drag the cloth across the board `passes` times. */
wipe(passes = 1, vAt = 0.5) {
const bw = 4.6;
const bd = 2.4;
for (let p = 0; p < passes; p++) {
for (let i = 0; i <= 40; i++) {
const u = p % 2 ? 1 - i / 40 : i / 40;
const wx = (0.15 + u * 0.7) * bw - bw / 2;
const wz = (vAt - 0.5) * bd;
point(wx, 0.12, wz, true);
run(1);
}
}
app.input.down = false;
run(2);
return game.prepView.mess.stats();
},
prepStats() {
const r = game.prepView.result();
return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() };
},
/** /**
* Cut a slice off the loaf. `dy` is world-units per frame of saw motion * Cut a slice off the loaf. `dy` is world-units per frame of saw motion
* (0.05 deliberate, 0.4 slamming); `wobble` is sideways drift per frame. * (0.05 deliberate, 0.4 slamming); `wobble` is sideways drift per frame.

View File

@ -2,6 +2,10 @@ import type { App } from '../core/app';
import { Rng } from '../core/rng'; import { Rng } from '../core/rng';
import { KitchenView } from '../scenes/kitchen'; import { KitchenView } from '../scenes/kitchen';
import { SlicerView } from '../scenes/slicer'; import { SlicerView } from '../scenes/slicer';
import { PrepView } from '../scenes/prep';
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
import type { PrepResult } from './judging';
import { JudgeView } from '../scenes/judge'; import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -30,6 +34,9 @@ export class Game {
private judgeView: JudgeView; private judgeView: JudgeView;
private drawer: DrawerView; private drawer: DrawerView;
private slicer: SlicerView; private slicer: SlicerView;
private prep: PrepView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */ /** The pre-toast drawer trip on loaf days: you're after a bread knife. */
private knifeTrip = false; private knifeTrip = false;
private rng = new Rng(20260716); private rng = new Rng(20260716);
@ -47,13 +54,16 @@ export class Game {
this.judgeView = new JudgeView(app); this.judgeView = new JudgeView(app);
this.drawer = new DrawerView(app, 7); this.drawer = new DrawerView(app, 7);
this.slicer = new SlicerView(app); this.slicer = new SlicerView(app);
this.prep = new PrepView(app);
app.scene.add(this.kitchen.root); app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root); app.scene.add(this.judgeView.root);
app.scene.add(this.drawer.root); app.scene.add(this.drawer.root);
app.scene.add(this.slicer.root); app.scene.add(this.slicer.root);
app.scene.add(this.prep.root);
this.judgeView.root.visible = false; this.judgeView.root.visible = false;
this.drawer.root.visible = false; this.drawer.root.visible = false;
this.slicer.root.visible = false; this.slicer.root.visible = false;
this.prep.root.visible = false;
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root); this.ticketEl = el('div', 'ticket-body', this.ticket.root);
@ -120,6 +130,29 @@ export class Game {
get slicerView(): SlicerView { get slicerView(): SlicerView {
return this.slicer; return this.slicer;
} }
get prepView(): PrepView {
return this.prep;
}
/**
* Send the player to the prep bench. Orders with prep steps route through
* here (M12+); the harness drives it directly for verification.
*/
startPrep(ingredient: IngredientId, pattern: CutPattern, knife: ToolId = 'dinner_knife', ask = ''): void {
this.prep.reset(ingredient, knife, pattern, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`);
this.kitchen.root.visible = false;
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.prep.root.visible = true;
this.app.setView(this.prep);
this.prep.onDone = (result) => {
this.prepResult = result;
this.prep.root.visible = false;
this.kitchen.root.visible = true;
this.app.setView(this.kitchen);
this.kitchen.flash('prep done — the bench remembers');
};
}
/** Test hook: jump straight to a day, through the real order table. */ /** Test hook: jump straight to a day, through the real order table. */
setDay(day: number): void { setDay(day: number): void {
@ -180,6 +213,7 @@ export class Game {
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`; if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
this.order = o; this.order = o;
this.orderSeconds = 0; this.orderSeconds = 0;
this.prepResult = null;
this.timing = true; this.timing = true;
const s = this.judgeView.release(); const s = this.judgeView.release();
@ -238,7 +272,7 @@ export class Game {
this.timing = false; this.timing = false;
const seconds = this.orderSeconds; const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice; const slice = this.kitchen.currentSlice;
const v = judge(slice, this.order, this.kitchen.toolId, seconds); const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined);
this.save.total += v.total; this.save.total += v.total;
const key = `day${this.order.day}`; const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);

View File

@ -12,6 +12,19 @@ export interface Criterion {
weight: number; weight: number;
/** What the number actually was, in words. */ /** What the number actually was, in words. */
detail: string; detail: string;
/** Which station earned it — the scorecard groups by this on prep orders. */
group?: 'prep' | 'toast' | 'spread';
}
/** What the prep bench hands the judge, when the order had prep on it. */
export interface PrepResult {
/** Coefficient of variation of piece volumes, from sim/cutting pieceCV. */
cv?: number;
pieces?: number;
patternName?: string;
slips?: number;
/** From sim/mess Mess.stats() at serve time. */
bench?: { mass: number; spreadFrac: number; solids: number };
} }
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F'; export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
@ -35,7 +48,13 @@ export const COVER_FLOOR = 0.06;
* Every number here is read off the same fields the player was pushing around * Every number here is read off the same fields the player was pushing around
* with the knife, so the scorecard can always point at something real. * with the knife, so the scorecard can always point at something real.
*/ */
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict { export function judge(
slice: Slice,
order: Order,
usedTool: ToolId,
seconds: number,
prep?: PrepResult,
): Verdict {
const b = slice.browning.stats(slice.mask); const b = slice.browning.stats(slice.mask);
const s = slice.spread.stats(slice.mask); const s = slice.spread.stats(slice.mask);
const d = slice.damage.stats(slice.mask); const d = slice.damage.stats(slice.mask);
@ -54,8 +73,11 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
const covered = spreadStdevOverCovered(slice, COVER_FLOOR); const covered = spreadStdevOverCovered(slice, COVER_FLOOR);
const criteria: Criterion[] = [ const criteria: Criterion[] = [
...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []),
...(prep?.bench ? [benchCriterion(prep.bench)] : []),
{ {
key: 'browning', key: 'browning',
group: 'toast',
label: 'Browning', label: 'Browning',
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
weight: 1.5, weight: 1.5,
@ -63,6 +85,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}, },
{ {
key: 'evenness', key: 'evenness',
group: 'toast',
label: 'Evenness', label: 'Evenness',
score: clamp01(1 - b.stdev / 0.2), score: clamp01(1 - b.stdev / 0.2),
weight: 1.1, weight: 1.1,
@ -70,6 +93,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}, },
{ {
key: 'coverage', key: 'coverage',
group: 'spread',
label: 'Coverage', label: 'Coverage',
score: clamp01((coverage - 0.15) / 0.75), score: clamp01((coverage - 0.15) / 0.75),
weight: 1.3, weight: 1.3,
@ -77,6 +101,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}, },
{ {
key: 'uniformity', key: 'uniformity',
group: 'spread',
label: 'Uniformity', label: 'Uniformity',
score: clamp01(1 - covered / 0.22), score: clamp01(1 - covered / 0.22),
weight: 0.9, weight: 0.9,
@ -84,6 +109,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}, },
{ {
key: 'char', key: 'char',
group: 'toast',
label: 'Char', label: 'Char',
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
weight: order.noChar ? 1.3 : 0.5, weight: order.noChar ? 1.3 : 0.5,
@ -91,6 +117,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}, },
{ {
key: 'integrity', key: 'integrity',
group: 'spread',
label: 'Integrity', label: 'Integrity',
score: clamp01(1 - d.mean / 0.06), score: clamp01(1 - d.mean / 0.06),
weight: 1.2, weight: 1.2,
@ -101,6 +128,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
...(def.separates ? [consistencyCriterion(slice)] : []), ...(def.separates ? [consistencyCriterion(slice)] : []),
{ {
key: 'amount', key: 'amount',
group: 'spread',
label: 'The Right Amount', label: 'The Right Amount',
score: amountScore(s.mean, lo, hi, target), score: amountScore(s.mean, lo, hi, target),
weight: 1.4, weight: 1.4,
@ -154,7 +182,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
function cutCriterion(slice: Slice, order: Order): Criterion { function cutCriterion(slice: Slice, order: Order): Criterion {
const c = slice.cut; const c = slice.cut;
if (!c) { if (!c) {
return { key: 'cut', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' }; return { key: 'cut', group: 'prep', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' };
} }
const [lo, hi] = order.cutBand ?? [0.16, 0.24]; const [lo, hi] = order.cutBand ?? [0.16, 0.24];
const mid = (lo + hi) / 2; const mid = (lo + hi) / 2;
@ -172,6 +200,7 @@ function cutCriterion(slice: Slice, order: Order): Criterion {
c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean'; c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean';
return { return {
key: 'cut', key: 'cut',
group: 'prep',
label: 'The Cut', label: 'The Cut',
score, score,
weight: 1.3, weight: 1.3,
@ -200,21 +229,7 @@ function rindCriterion(slice: Slice, name: string): Criterion {
} }
// Mask area in UV units — the bread is the study region, not the unit square. // Mask area in UV units — the bread is the study region, not the unit square.
const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n); const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
let sum = 0; const R = clarkEvans(pts, area);
for (let i = 0; i < n; i++) {
let best = Infinity;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const du = pts[i].u - pts[j].u;
const dv = pts[i].v - pts[j].v;
const d2 = du * du + dv * dv;
if (d2 < best) best = d2;
}
sum += Math.sqrt(best);
}
const observed = sum / n;
const expected = 0.5 * Math.sqrt(area / n);
const R = observed / expected;
const score = clamp01(R / 0.95); const score = clamp01(R / 0.95);
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
return { return {
@ -260,6 +275,74 @@ function consistencyCriterion(slice: Slice): Criterion {
}; };
} }
/**
* ClarkEvans nearest-neighbour index over a point set in a study region of
* `area` (UV² units). R 0 one clump, 1 random, >1 deliberately even. Used
* by the rind today; topping distribution (M15) takes the same point list.
*/
export function clarkEvans(pts: { u: number; v: number }[], area: number): number {
const n = pts.length;
if (n < 2 || area <= 0) return 0;
let sum = 0;
for (let i = 0; i < n; i++) {
let best = Infinity;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const du = pts[i].u - pts[j].u;
const dv = pts[i].v - pts[j].v;
const d2 = du * du + dv * dv;
if (d2 < best) best = d2;
}
sum += Math.sqrt(best);
}
const observed = sum / n;
const expected = 0.5 * Math.sqrt(area / n);
return observed / expected;
}
/**
* Piece evenness off the prep bench: the CV of piece volumes, banded the way
* the live readout words it. Anyone can cut five pieces; the marks are for
* five pieces that could be mistaken for each other.
*/
export function cutEvennessCriterion(cv: number, pieces: number, patternName: string): Criterion {
const score = clamp01(1 - (cv - 0.05) / 0.32);
const word = cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery';
return {
key: 'cutcv',
group: 'prep',
label: 'The Pieces',
score,
weight: 1.3,
detail: `${pieces} ${patternName}, CV ${cv.toFixed(3)}${word}`,
};
}
/**
* The state of the board at serve time. Weighted mildly (0.9): mess is a
* running gag before it's a crime, but it IS on the card.
*/
export function benchCriterion(bench: { mass: number; spreadFrac: number; solids: number }): Criterion {
const badness = bench.spreadFrac * 3 + bench.solids * 0.04 + bench.mass * 0.4;
const score = clamp01(1 - badness / 1.1);
const worst =
bench.solids >= 3 && bench.solids * 0.04 > bench.spreadFrac * 3
? `${bench.solids} bits on my board`
: bench.spreadFrac > 0.08
? `juice over ${Math.round(bench.spreadFrac * 100)}% of it`
: badness < 0.25
? 'clean'
: 'smeared';
return {
key: 'bench',
group: 'prep',
label: 'The Bench',
score,
weight: 0.9,
detail: worst,
};
}
function cap(s: string): string { function cap(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }

View File

@ -159,6 +159,34 @@ const BANK: Bank = {
'Correct utensil. Small thing. Not nothing.', 'Correct utensil. Small thing. Not nothing.',
], ],
}, },
cutcv: {
bad: [
'These pieces are related, at best. Distant cousins.',
'One of these is a slice. The rest are opinions.',
'I lined them up by size. That took a while. That is the problem.',
'You cut this the way weather cuts a coastline.',
'Every piece is a surprise. Nobody ordered surprises.',
],
good: [
'Even pieces. Actually even. I measured.',
'You could deal these like cards.',
'Uniform. The knife was under instruction, and it listened.',
],
},
bench: {
bad: [
'There is juice on everything. Everything.',
"Somebody's seeds are on my floor.",
'The board looks like it lost an argument.',
'You cooked here the way storms cook.',
'I am not inspecting the food yet. I am inspecting the aftermath.',
],
good: [
"A clean board. You'd be surprised how rare.",
'The bench is spotless. I checked the corners.',
'Somebody wiped as they went. Somebody was raised right.',
],
},
}; };
/** MITEY gets its own vocabulary. */ /** MITEY gets its own vocabulary. */

View File

@ -2,6 +2,19 @@ import { BRANDS, type BreadId } from '../sim/bread';
import type { AmountClass, SpreadId } from '../sim/spreads'; import type { AmountClass, SpreadId } from '../sim/spreads';
import type { ToolId } from '../sim/cutlery'; import type { ToolId } from '../sim/cutlery';
import { CUT_BANDS, type CutClass } from '../sim/slicing'; import { CUT_BANDS, type CutClass } from '../sim/slicing';
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
/**
* A stop at the prep bench before (or instead of) the toaster. M11 ships the
* 'cut' kind; juicing, zesting, grating and roasting arrive with their
* stations (M12+) and extend this union.
*/
export interface PrepStep {
kind: 'cut';
ingredient: IngredientId;
pattern: CutPattern;
}
export interface Order { export interface Order {
day: number; day: number;
@ -28,6 +41,8 @@ export interface Order {
text: string; text: string;
/** Who's asking. */ /** Who's asking. */
who: string; who: string;
/** Work at the prep bench, when the order has any. */
prep?: PrepStep[];
} }
export const BROWNING_NAMES: [number, string][] = [ export const BROWNING_NAMES: [number, string][] = [

View File

@ -105,7 +105,20 @@ export class JudgeView implements View {
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
clear(this.cardEl); clear(this.cardEl);
for (const c of verdict.criteria) { // 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' };
let lastGroup: string | undefined;
const rank = (g?: string) => (g === 'prep' ? 0 : g === 'toast' ? 1 : g === 'spread' ? 2 : 3);
const ordered = grouped
? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group))
: verdict.criteria;
for (const c of ordered) {
if (grouped && c.group !== lastGroup) {
lastGroup = c.group;
if (c.group) el('div', 'crit-group', this.cardEl, headers[c.group]);
}
const row = el('div', 'crit', this.cardEl); const row = el('div', 'crit', this.cardEl);
el('span', 'crit-name', row, c.label); el('span', 'crit-name', row, c.label);
const bar = el('span', 'crit-bar', row); const bar = el('span', 'crit-bar', row);

429
src/scenes/prep.ts Normal file
View File

@ -0,0 +1,429 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { INGREDIENTS, type Ingredient, type IngredientId } from '../sim/ingredients';
import {
aimCut,
beginBite,
cutFrame,
cvWord,
liftKnife,
newCutSession,
pieceCV,
pieceVolumes,
plannedCuts,
type CutPattern,
type CutSession,
} from '../sim/cutting';
import { Mess } from '../sim/mess';
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
import type { PrepResult } from '../game/judging';
import { el, Panel } from '../ui/hud';
import { Rng } from '../core/rng';
import { loadProp } from './assets';
const BOARD_Y = 0.06;
const BOARD_W = 4.6;
const BOARD_D = 2.4;
/**
* The prep bench. One board, one knife, one ingredient at a time, and a mess
* field that remembers everything you did to all three.
*
* Controls mirror the slicer on purpose: horizontal mouse aims the cut, press
* bites, vertical strokes saw. The two new verbs are the ingredient's, not
* yours: slippery skin skates if you lean without sawing, and wet flesh
* juices the board. Drag on the empty board (off the ingredient) to wipe.
*/
export class PrepView implements View {
readonly root = new THREE.Group();
readonly mess = new Mess(96);
private session: CutSession | null = null;
private ing!: Ingredient;
private ingMesh: THREE.Group | null = null;
private pieces: THREE.Group | null = null;
private seams: THREE.Mesh[] = [];
private knifeMesh!: THREE.Group;
private rng = new Rng(20260717);
private messTexData: Uint8Array;
private messTex: THREE.DataTexture;
private messVersion = -1;
private solidsMesh: THREE.InstancedMesh;
private ray = new THREE.Raycaster();
private planePt = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0.2);
private boardPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2));
private prev = new THREE.Vector2();
private hasPrev = false;
private wipePrev: THREE.Vector3 | null = null;
private sawPhase = 0;
private doneTimer = 0;
private panel: Panel;
private askEl!: HTMLElement;
private cutsEl!: HTMLElement;
private progFill!: HTMLElement;
private benchEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: PrepResult) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
const n = this.mess.field.n;
this.messTexData = new Uint8Array(n * n * 4);
this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat);
this.messTex.minFilter = THREE.LinearFilter;
this.messTex.magFilter = THREE.LinearFilter;
const stain = new THREE.Mesh(
new THREE.PlaneGeometry(BOARD_W, BOARD_D),
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
);
stain.rotation.x = -Math.PI / 2;
stain.position.y = BOARD_Y * 2 + 0.004;
this.root.add(stain);
// Solids: seeds and skins, instanced like the rind bits.
const seedGeo = new THREE.SphereGeometry(0.028, 8, 6);
seedGeo.scale(1, 0.45, 0.72);
this.solidsMesh = new THREE.InstancedMesh(
seedGeo,
new THREE.MeshStandardMaterial({ color: 0xf0e6c8, roughness: 0.6 }),
256,
);
this.solidsMesh.count = 0;
this.solidsMesh.castShadow = true;
this.root.add(this.solidsMesh);
this.panel = new Panel('prep-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, 'PREP IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.cutsEl = el('div', 'slicer-thick', card, '');
const bar = el('div', 'timer-bar', card);
this.progFill = el('div', 'timer-fill', bar);
this.benchEl = 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 board = new THREE.Mesh(
new THREE.BoxGeometry(BOARD_W, BOARD_Y * 2, BOARD_D),
new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
board.castShadow = true;
this.root.add(board);
void loadProp('/assets/models/cutting_board.glb', 2.2)
.then((prop) => {
prop.position.y = BOARD_Y;
board.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
}
/** Procedural stand-in silhouettes — the shapes carry the physics. */
private buildIngredientMesh(ing: Ingredient): THREE.Group {
const g = new THREE.Group();
const skin = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
roughness: 0.55,
});
let mesh: THREE.Mesh;
const r = ing.size / 2;
switch (ing.shape) {
case 'ovoid': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
mesh.scale.set(1.25, 0.92, 0.92);
break;
}
case 'block': {
mesh = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.6, ing.size * 0.7), skin);
break;
}
case 'wedge': {
mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, ing.size * 0.5, 24, 1, false, 0, Math.PI / 2.2), skin);
mesh.rotation.y = Math.PI / 4;
break;
}
case 'bulb': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 14), skin);
mesh.scale.set(1, 0.85, 1);
break;
}
default:
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
}
mesh.castShadow = true;
mesh.receiveShadow = true;
g.add(mesh);
g.position.y = BOARD_Y * 2 + (ing.shape === 'block' ? ing.size * 0.3 : r * (ing.shape === 'bulb' ? 0.85 : 0.92));
return g;
}
/** A fresh ingredient and a fresh session. */
reset(ingredientId: IngredientId, knife: ToolId, pattern: CutPattern, askText: string): void {
this.ing = INGREDIENTS[ingredientId];
this.session = newCutSession(this.ing, knife, pattern);
if (this.ingMesh) this.root.remove(this.ingMesh);
if (this.pieces) this.root.remove(this.pieces);
this.pieces = null;
for (const s of this.seams) this.root.remove(s);
this.seams = [];
this.ingMesh = this.buildIngredientMesh(this.ing);
this.root.add(this.ingMesh);
if (this.knifeMesh) this.root.remove(this.knifeMesh);
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
this.root.add(this.knifeMesh);
this.mess.reset();
this.askEl.textContent = askText;
this.doneTimer = 0;
this.hasPrev = false;
this.wipePrev = null;
this.hintEl.textContent = 'aim · press on it and saw up-down · drag the board to wipe';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
/** Board-local UV under the pointer, if the ray hits the board. */
private boardUV(): { u: number; v: number } | null {
const pt = new THREE.Vector3();
if (!this.ray.ray.intersectPlane(this.boardPlane, pt)) return null;
const u = (pt.x + BOARD_W / 2) / BOARD_W;
const v = (pt.z + BOARD_D / 2) / BOARD_D;
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
return { u, v };
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(1.2, 2.3, 4.4);
this.app.camera.lookAt(0.6, 0.5, -0.1);
const s = this.session;
if (!s) return;
this.ray.setFromCamera(inp.ndc, this.app.camera);
this.plane.set(new THREE.Vector3(0, 0, 1), 0.2);
const hit = this.ray.ray.intersectPlane(this.plane, this.planePt);
// Cutting happens on the vertical plane near the ingredient; wiping on the
// board everywhere else. "Near" = within a size of the centre.
const nearIngredient = hit && Math.abs(this.planePt.x) < this.ing.size * 0.85 && s.phase !== 'done';
if (s.phase !== 'done' && nearIngredient && hit) {
if (s.phase === 'aim') {
aimCut(s, this.planePt.x / this.ing.size);
if (inp.down) {
beginBite(s);
audio.clatter(0.15, 1.8);
}
} else if (inp.down && this.hasPrev) {
const dy = this.planePt.y - this.prev.y;
const dx = this.planePt.x - this.prev.x;
const f = cutFrame(s, dy, dx, dt, () => this.rng.next());
if (Math.abs(dy) > 0.004) {
this.sawPhase += Math.abs(dy) * 3;
audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22);
}
this.applyFrame(f, dy);
} else if (!inp.down) {
liftKnife(s);
}
} else if (inp.down) {
// Off the ingredient with the button down: the cloth.
const uv = this.boardUV();
if (uv && this.wipePrev) {
this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v);
}
if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0);
}
if (!inp.down) this.wipePrev = null;
if (s.phase === 'done') {
this.doneTimer += dt;
// No auto-exit: the bench is judged at serve, so the moment after the
// last cut is exactly when you'd want the cloth. ENTER hands it over.
this.hintEl.textContent = 'done — wipe the board if you dare care · ENTER takes it to the kitchen';
if (inp.justPressed('Enter') && this.onDone) {
const result = this.result();
const cb = this.onDone;
this.onDone = null;
cb(result);
return;
}
}
this.prev.set(this.planePt.x, this.planePt.y);
this.hasPrev = !!hit;
this.positionKnife(s);
this.syncMess();
this.updateHud(s);
}
/** What the judge reads. Snapshot at any time; final after onDone. */
result(): PrepResult {
const s = this.session!;
return {
cv: pieceCV(s),
pieces: pieceVolumes(s).length,
patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind,
slips: s.slips,
bench: this.mess.stats(),
};
}
private applyFrame(f: ReturnType<typeof cutFrame>, dy: number): void {
const s = this.session!;
const cutWorldX = s.aimPos * this.ing.size;
const u = (cutWorldX + BOARD_W / 2) / BOARD_W;
const v = 0.5;
if (f.juice > 0) {
// Sprayed along the blade (board Z), the side picked by the stroke.
const side = dy > 0 ? 1 : -1;
this.mess.addJuice(u, v, f.juice, (this.rng.next() - 0.5) * 0.3, side * (0.6 + this.rng.next() * 0.4));
}
if (f.slipped) {
audio.clatter(0.5, 0.9);
this.app.shake = 0.015;
this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite';
}
if (f.cutDone) {
audio.clunk(false);
this.addSeam(s.cuts[s.cuts.length - 1]);
for (let i = 0; i < f.seedsSpilled; i++) {
this.mess.addSolid(u + (this.rng.next() - 0.5) * 0.12, v + (this.rng.next() - 0.5) * 0.3, 'seed');
}
}
if (f.sessionDone) {
this.showPieces();
}
}
/** Committed cuts read as dark seams on the ingredient. */
private addSeam(pos: number): void {
const seam = new THREE.Mesh(
new THREE.BoxGeometry(0.016, this.ing.size * 0.95, this.ing.size * 0.95),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(
this.ing.colors.flesh[0] * 0.45,
this.ing.colors.flesh[1] * 0.45,
this.ing.colors.flesh[2] * 0.45,
THREE.SRGBColorSpace,
),
roughness: 1,
}),
);
seam.position.set(pos * this.ing.size, this.ingMesh!.position.y, 0);
this.root.add(seam);
this.seams.push(seam);
}
/** Session done: the whole becomes pieces, fanned apart so the CV is visible. */
private showPieces(): void {
if (!this.session || this.pieces) return;
const s = this.session;
if (this.ingMesh) this.ingMesh.visible = false;
for (const seam of this.seams) this.root.remove(seam);
this.seams = [];
this.pieces = new THREE.Group();
const flesh = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...this.ing.colors.flesh, THREE.SRGBColorSpace),
roughness: 0.5,
});
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
let x = -this.ing.size * 0.55 - edges.length * 0.02;
for (let i = 0; i + 1 < edges.length; i++) {
const w = (edges[i + 1] - edges[i]) * this.ing.size;
const mid = (edges[i] + edges[i + 1]) / 2;
const rHere = Math.sqrt(Math.max(0.05, 0.25 - mid * mid)) * this.ing.size;
const piece = new THREE.Mesh(new THREE.CylinderGeometry(rHere, rHere, Math.max(0.02, w * 0.92), 20), flesh);
piece.rotation.z = Math.PI / 2;
piece.castShadow = true;
piece.position.set(x + w / 2, BOARD_Y * 2 + rHere, 0.1);
this.pieces.add(piece);
x += w + 0.05;
}
this.root.add(this.pieces);
}
private positionKnife(s: CutSession): void {
if (!this.knifeMesh) return;
const topY = this.ingMesh ? this.ingMesh.position.y + this.ing.size * 0.55 : 1;
this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2);
const sawZ = s.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.3 : 0.15;
const sink = s.phase === 'aim' || s.phase === 'done' ? 0 : s.progress * this.ing.size * 0.9;
this.knifeMesh.visible = s.phase !== 'done';
this.knifeMesh.position.set(s.aimPos * this.ing.size, topY + 0.22 - sink, sawZ + 0.35);
}
/** Push the mess field into the stain texture when it changed. */
private syncMess(): void {
if (this.mess.version === this.messVersion) return;
this.messVersion = this.mess.version;
const d = this.mess.field.data;
const px = this.messTexData;
for (let i = 0; i < d.length; i++) {
const m = Math.min(1, d[i] * 2.2);
px[i * 4] = 190;
px[i * 4 + 1] = 68;
px[i * 4 + 2] = 36;
px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255);
}
this.messTex.needsUpdate = true;
const solids = this.mess.solids.filter((s) => !s.collected);
const mat = new THREE.Matrix4();
let n = 0;
for (const s of solids) {
if (n >= 256) break;
mat.setPosition(s.u * BOARD_W - BOARD_W / 2, BOARD_Y * 2 + 0.02, s.v * BOARD_D - BOARD_D / 2);
this.solidsMesh.setMatrixAt(n++, mat);
}
this.solidsMesh.count = n;
this.solidsMesh.instanceMatrix.needsUpdate = true;
}
private updateHud(s: CutSession): void {
const total = plannedCuts(s.pattern);
const done = s.cuts.length + s.diceCutsA.length;
if (s.phase === 'done') {
const cv = pieceCV(s);
this.cutsEl.textContent = `${pieceVolumes(s).length} pieces — CV ${cv.toFixed(3)}, ${cvWord(cv)}`;
} else {
this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`;
}
this.progFill.style.width = `${Math.round(s.progress * 100)}%`;
this.progFill.style.background = '#e8a53a';
const bench = this.mess.benchWord();
const wobble =
s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : '';
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`;
this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a';
}
dispose(): void {
this.panel.dispose();
}
}

336
src/sim/cutting.ts Normal file
View File

@ -0,0 +1,336 @@
import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery';
import type { Ingredient } from './ingredients';
/**
* Cutting, generalized from the loaf (sim/slicing.ts) to anything on the board.
*
* A CutSession is an ingredient, a knife, and a pattern. Each cut in the
* pattern runs the same three-act physics the loaf taught us bite, saw,
* through with two additions the loaf never needed:
*
* slip skin.slippery × pressing-without-sawing. A serrated blade in
* motion bites; a flat blade leaning on a tomato skates. When it
* skates the cut position jumps and the juice goes somewhere.
* juice flesh.moisture ejected along the stroke, superlinear in speed,
* so the frantic cut soaks the board and the deliberate one doesn't.
*
* The judged number is piece evenness: the coefficient of variation of piece
* volumes. CV < 0.08 reads as "even"; past 0.25 it's a lottery.
*/
export type CutPattern =
| { kind: 'halve' }
| { kind: 'slices'; n: number }
| { kind: 'wedges'; n: number }
| { kind: 'dice'; n: number };
export interface CutFrame {
/** Skated this frame: the aim jumped to `aimPos`, juice sprayed. */
slipped: boolean;
/** Juice mass ejected this frame (scene feeds it to the mess field). */
juice: number;
/** A cut just went through. */
cutDone: boolean;
/** Seeds shaken loose by the cut that just finished. */
seedsSpilled: number;
/** The whole pattern is complete. */
sessionDone: boolean;
}
export interface CutSession {
ing: Ingredient;
knife: ToolId;
pattern: CutPattern;
phase: 'aim' | 'bite' | 'saw' | 'done';
/** Committed cut positions, -0.5..0.5 along the axis (fraction of size).
* For wedges the same range reads as a fraction of the half-turn. */
cuts: number[];
/** Dice is two passes: 0 = first slices, 1 = rotated criss-cross. */
dicePhase: 0 | 1;
diceCutsA: number[];
aimPos: number;
/** Per-cut accumulators, reset when a cut commits. */
progress: number;
wedge: number;
squash: number;
strokes: number;
biteTravel: number;
pressTime: number;
lastDir: number;
strokeTravel: number;
/** Session totals the judge and the sting math read. */
slips: number;
juiceTotal: number;
/** Crushing over the whole session — onion sting scales with this (M14). */
aggression: number;
wedgeWorst: number;
squashWorst: number;
}
const STROKE_GAIN = 0.11;
const WOBBLE_GAIN = 0.55;
const SPEED_CAP = 3.2;
const SQUASH_GAIN = 0.55;
/** Seconds of leaning on slippery skin before it skates, at slippery = 1
* with a knife that has no saw stats worth the name. */
const SLIP_PATIENCE = 0.35;
export function plannedCuts(pattern: CutPattern): number {
switch (pattern.kind) {
case 'halve':
return 1;
case 'slices':
return pattern.n - 1;
case 'wedges':
return Math.ceil(pattern.n / 2); // diametral cuts, each makes two wedges
case 'dice':
return (pattern.n - 1) * 2; // two rotated passes of n-1
}
}
export function newCutSession(ing: Ingredient, knife: ToolId, pattern: CutPattern): CutSession {
return {
ing,
knife,
pattern,
phase: 'aim',
cuts: [],
dicePhase: 0,
diceCutsA: [],
aimPos: 0,
progress: 0,
wedge: 0,
squash: 0,
strokes: 0,
biteTravel: 0,
pressTime: 0,
lastDir: 0,
strokeTravel: 0,
slips: 0,
juiceTotal: 0,
aggression: 0,
wedgeWorst: 0,
squashWorst: 0,
};
}
export function cutSawStats(knife: ToolId): { eff: number; steady: number; gentle: number } {
return TOOLS[knife].saw ?? DEFAULT_SAW;
}
/** While aiming: where the next cut will land. */
export function aimCut(s: CutSession, pos: number): void {
if (s.phase !== 'aim') return;
s.aimPos = Math.min(0.5, Math.max(-0.5, pos));
}
/** Mouse went down on the ingredient: the blade is on the skin. */
export function beginBite(s: CutSession): void {
if (s.phase === 'aim') {
s.phase = 'bite';
s.biteTravel = 0;
s.pressTime = 0;
}
}
/** Mouse released mid-cut: progress keeps, the blade comes off the skin. */
export function liftKnife(s: CutSession): void {
if (s.phase === 'bite') s.phase = 'aim';
}
/**
* One frame of blade-on-ingredient. `dz` is saw motion along the blade, `dx`
* sideways wander. `rand` feeds the slip jump only everything else is
* deterministic, which is what makes it verifiable.
*/
export function cutFrame(s: CutSession, dz: number, dx: number, dt: number, rand: () => number): CutFrame {
const out: CutFrame = { slipped: false, juice: 0, cutDone: false, seedsSpilled: 0, sessionDone: false };
if (s.phase !== 'bite' && s.phase !== 'saw') return out;
const k = cutSawStats(s.knife);
const speed = dt > 0 ? Math.abs(dz) / dt : 0;
if (s.phase === 'bite') {
// The first bite: saw motion earns grip, leaning earns a skate. A truly
// sharp edge (The Best Thing) sinks in on downforce alone — that is the
// whole legend of it.
s.biteTravel += Math.abs(dz) + Math.max(0, k.eff - 1.05) * 0.08 * dt;
s.pressTime += dt;
const biteNeed = 0.015 + s.ing.skin.resistance * 0.05 * (1.6 - Math.min(1.2, k.eff));
if (s.biteTravel >= biteNeed) {
s.phase = 'saw';
} else {
const patience = (SLIP_PATIENCE / (s.ing.skin.slippery + 0.05)) * (0.4 + k.eff);
if (s.pressTime > patience) {
// It skates. The cut position jumps, the juice goes on the board.
s.slips++;
s.pressTime = 0;
s.biteTravel = 0;
const jump = (0.07 + rand() * 0.09) * (rand() < 0.5 ? -1 : 1);
s.aimPos = Math.min(0.5, Math.max(-0.5, s.aimPos + jump));
out.slipped = true;
out.juice = Math.max(0, s.ing.flesh.moisture - 0.3) * 0.35;
s.juiceTotal += out.juice;
}
return out;
}
}
// Sawing. Same speed-capped progress as the loaf, scaled by flesh fight.
const hardness = 0.35 / (0.12 + s.ing.flesh.resistance * 0.55);
const sizeFactor = 0.5 / (0.2 + s.ing.size * 0.6);
const useful = Math.min(Math.abs(dz), SPEED_CAP * dt);
s.progress += useful * STROKE_GAIN * k.eff * hardness * sizeFactor * 14;
const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle));
const crush = excess * SQUASH_GAIN * (1 - k.gentle) * (1.3 - s.ing.flesh.resistance) * dt;
s.squash = Math.min(1, s.squash + crush);
s.aggression += crush;
s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady));
const dir = Math.sign(dz);
if (dir !== 0 && dir !== s.lastDir && s.strokeTravel > 0.08) {
s.strokes++;
s.strokeTravel = 0;
}
if (dir !== 0) s.lastDir = dir;
s.strokeTravel += Math.abs(dz);
// Juice: moisture × speed², so the deliberate cut leaks and the frantic one
// hoses. Only flesh wetter than 0.4 juices at all.
if (s.ing.flesh.moisture > 0.4) {
out.juice = (s.ing.flesh.moisture - 0.35) * speed * Math.abs(dz) * 0.1;
s.juiceTotal += out.juice;
}
if (s.progress >= 1) {
out.cutDone = true;
s.cuts.push(s.aimPos);
s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge);
s.squashWorst = Math.max(s.squashWorst, s.squash);
out.seedsSpilled = seedsFor(s);
s.progress = 0;
s.wedge = 0;
s.squash = 0;
s.biteTravel = 0;
s.pressTime = 0;
if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) {
// First pass done: bank it, rotate the board, go again.
s.diceCutsA = s.cuts;
s.cuts = [];
s.dicePhase = 1;
s.phase = 'aim';
} else if (totalCutsDone(s) >= plannedCuts(s.pattern)) {
s.phase = 'done';
out.sessionDone = true;
} else {
s.phase = 'aim';
}
}
return out;
}
function totalCutsDone(s: CutSession): number {
return s.cuts.length + s.diceCutsA.length;
}
function seedsFor(s: CutSession): number {
const seeds = s.ing.seeds;
if (!seeds) return 0;
const justCut = s.cuts[s.cuts.length - 1] ?? 0;
if (seeds.pocket === 'core') {
return Math.abs(justCut) < 0.12 ? Math.ceil(seeds.count * 0.6) : 0;
}
// Distributed pockets shed a share per cut, more when squashed.
return Math.round((seeds.count / Math.max(1, plannedCuts(s.pattern))) * (0.35 + s.squash * 0.5));
}
/**
* Piece volumes implied by the committed cuts. For slices/halve these are
* slab volumes through the actual solid of revolution, so a cut near the end
* of a sphere honestly yields a sliver.
*/
export function pieceVolumes(s: CutSession): number[] {
const p = s.pattern;
if (p.kind === 'wedges') {
// Diametral cuts at angles (aimPos as fraction of the half-turn).
const angles = [...s.cuts].map((c) => (c + 0.5) * Math.PI).sort((a, b) => a - b);
if (angles.length === 0) return [];
const out: number[] = [];
for (let i = 0; i < angles.length; i++) {
const a = angles[i];
const b = i + 1 < angles.length ? angles[i + 1] : angles[0] + Math.PI;
out.push(b - a, b - a); // each diametral gap is two opposite wedges
}
return out;
}
if (p.kind === 'dice') {
const a = widths(s.diceCutsA);
const b = widths(s.cuts);
const cells: number[] = [];
for (const wa of a) for (const wb of b) cells.push(wa * wb);
return cells;
}
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
const out: number[] = [];
for (let i = 0; i + 1 < edges.length; i++) out.push(slabVolume(s.ing, edges[i], edges[i + 1]));
return out;
}
/** Sorted cut positions -> piece widths including both ends. */
function widths(cuts: number[]): number[] {
const c = [...cuts].sort((a, b) => a - b);
const edges = [-0.5, ...c, 0.5];
const out: number[] = [];
for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]);
return out;
}
/** Volume of the slab between fractions a..a+w of the solid, unit size. */
function slabVolume(ing: Ingredient, a: number, b: number): number {
// Numeric integral of the cross-section through a sphere/ovoid; blocks and
// wedges are uniform enough that width is volume.
if (ing.shape === 'block' || ing.shape === 'wedge') return Math.abs(b - a);
const steps = 24;
let v = 0;
for (let i = 0; i < steps; i++) {
const x = a + ((i + 0.5) / steps) * (b - a);
const r2 = Math.max(0, 0.25 - x * x); // unit sphere of radius 0.5
v += r2;
}
return (v / steps) * Math.abs(b - a) * Math.PI;
}
/**
* The judged statistic: coefficient of variation of the pieces.
*
* For straight slices the CV runs over piece WIDTHS, not volumes on a round
* ingredient the end slices are thinner by geometry, and a statistic the
* player cannot get under 0.4 with a laser-guided knife isn't a skill test.
* Widths are exactly the thing the hand controls. Wedges and dice keep the
* volume/cell statistic: there the spread really is all yours.
*/
export function pieceCV(s: CutSession): number {
const vals = s.pattern.kind === 'slices' || s.pattern.kind === 'halve' ? sliceWidths(s) : pieceVolumes(s);
if (vals.length < 2) return 0;
const mean = vals.reduce((a, v) => a + v, 0) / vals.length;
if (mean <= 0) return 1;
const acc = vals.reduce((a, v) => a + (v - mean) * (v - mean), 0) / vals.length;
return Math.sqrt(acc) / mean;
}
function sliceWidths(s: CutSession): number[] {
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
const out: number[] = [];
for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]);
return out;
}
/** Halve quality: centre offset and the worst wedge — M12's yield cap reads this. */
export function halveQuality(s: CutSession): { offset: number; wedge: number } {
return { offset: Math.abs(s.cuts[0] ?? 0.5), wedge: s.wedgeWorst };
}
export function cvWord(cv: number): string {
return cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery';
}

139
src/sim/ingredients.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* The prep-bench cast. Everything a knife can be taken to, described by the
* numbers the cutting sim actually consumes shape for the silhouette and the
* piece math, skin for the slip check, flesh for juice and layering, and the
* station-specific extras (seeds, sting, temperature needs, gratability).
*
* 1 world unit 11cm, same convention as the loaf.
*/
export type IngredientShape = 'sphere' | 'ovoid' | 'block' | 'wedge' | 'bulb';
export interface Ingredient {
id: string;
name: string;
shape: IngredientShape;
/** Characteristic diameter/length in world units. */
size: number;
/** resistance: how hard the skin fights the first bite. slippery: how keenly
* it skates when pressed without sawing the tomato number. */
skin: { resistance: number; slippery: number };
/** moisture > 0.4 juices when cut; layered flesh falls apart when mistreated. */
flesh: { resistance: number; moisture: number; layered?: boolean };
seeds?: { count: number; pocket: 'core' | 'distributed' };
/** Onions. 0..1, scales how fast cutting stings the eyes. */
sting?: number;
temp?: { state: 'fridge' | 'bench'; softensInSec?: number; needsSoft?: boolean; needsCold?: boolean };
gratable?: { yield: number; pithDepth?: number };
roastable?: boolean;
/** Authored sRGB, same convention as bread crumb/crust. */
colors: { skin: [number, number, number]; flesh: [number, number, number] };
}
export type IngredientId =
| 'tomato'
| 'roast_tomato'
| 'orange'
| 'onion'
| 'garlic'
| 'parmesan'
| 'brie'
| 'cucumber'
| 'avocado';
export const INGREDIENTS: Record<IngredientId, Ingredient> = {
tomato: {
id: 'tomato',
name: 'tomato',
shape: 'sphere',
size: 0.6,
skin: { resistance: 0.35, slippery: 0.9 },
flesh: { resistance: 0.2, moisture: 0.75 },
seeds: { count: 20, pocket: 'distributed' },
roastable: true,
colors: { skin: [0.82, 0.18, 0.12], flesh: [0.9, 0.35, 0.22] },
},
roast_tomato: {
id: 'roast_tomato',
name: 'roast tomato',
shape: 'sphere',
size: 0.52,
// The skin blisters off in the oven; what's left is a water balloon.
skin: { resistance: 0.1, slippery: 0.55 },
flesh: { resistance: 0.08, moisture: 0.95 },
colors: { skin: [0.62, 0.16, 0.1], flesh: [0.75, 0.28, 0.16] },
},
orange: {
id: 'orange',
name: 'orange',
shape: 'ovoid',
size: 0.68,
skin: { resistance: 0.55, slippery: 0.45 },
flesh: { resistance: 0.3, moisture: 0.85 },
seeds: { count: 5, pocket: 'core' },
gratable: { yield: 1.0, pithDepth: 0.35 },
colors: { skin: [0.95, 0.55, 0.1], flesh: [0.98, 0.68, 0.2] },
},
onion: {
id: 'onion',
name: 'brown onion',
shape: 'sphere',
size: 0.62,
skin: { resistance: 0.4, slippery: 0.6 },
flesh: { resistance: 0.45, moisture: 0.55, layered: true },
sting: 1.0,
colors: { skin: [0.72, 0.5, 0.28], flesh: [0.93, 0.9, 0.82] },
},
garlic: {
id: 'garlic',
name: 'garlic',
shape: 'bulb',
size: 0.28,
skin: { resistance: 0.25, slippery: 0.35 },
flesh: { resistance: 0.5, moisture: 0.3, layered: true },
sting: 0.15,
colors: { skin: [0.9, 0.86, 0.8], flesh: [0.95, 0.92, 0.85] },
},
parmesan: {
id: 'parmesan',
name: 'parmesan',
shape: 'block',
size: 0.5,
skin: { resistance: 0.8, slippery: 0.15 },
flesh: { resistance: 0.85, moisture: 0.1 },
temp: { state: 'fridge', softensInSec: 90, needsCold: true },
gratable: { yield: 0.9 },
colors: { skin: [0.85, 0.75, 0.5], flesh: [0.93, 0.87, 0.68] },
},
brie: {
id: 'brie',
name: 'brie',
shape: 'wedge',
size: 0.45,
skin: { resistance: 0.3, slippery: 0.2 },
flesh: { resistance: 0.15, moisture: 0.35 },
temp: { state: 'fridge', softensInSec: 120, needsSoft: true },
colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] },
},
cucumber: {
id: 'cucumber',
name: 'cucumber',
shape: 'ovoid',
size: 1.4,
skin: { resistance: 0.5, slippery: 0.5 },
flesh: { resistance: 0.25, moisture: 0.8 },
seeds: { count: 14, pocket: 'distributed' },
colors: { skin: [0.2, 0.45, 0.16], flesh: [0.85, 0.94, 0.75] },
},
avocado: {
id: 'avocado',
name: 'avocado',
shape: 'ovoid',
size: 0.62,
skin: { resistance: 0.7, slippery: 0.3 },
// Ripeness is a lottery: roll flesh resistance per-avocado at spawn.
flesh: { resistance: 0.5, moisture: 0.45 },
seeds: { count: 1, pocket: 'core' },
colors: { skin: [0.16, 0.2, 0.1], flesh: [0.78, 0.85, 0.5] },
},
};

156
src/sim/mess.ts Normal file
View File

@ -0,0 +1,156 @@
import { Field } from '../core/field';
/**
* The bench is judged now.
*
* Juice, pulp and stray solids live on the cutting board's own UV square
* the same Field machinery as the toast, pointed at the crime scene around
* it. Two kinds of mess:
*
* the field juice and smear, a mass per texel
* solids seeds, skins, zest: discrete bits with positions, rendered
* instanced, counted by the judge individually ("three pips.
* I counted.")
*
* The wipe is spreading in reverse, and it keeps the spread's honesty: a
* cloth pass picks SOME mass up and pushes the rest ahead of itself, so one
* pass smears and the second one cleans. That's the feel; it's also why
* wiping late beats wiping never but costs you twice the service clock.
*/
export type SolidKind = 'seed' | 'skin' | 'zest' | 'pith' | 'pulp' | 'crumb';
export interface Solid {
u: number;
v: number;
kind: SolidKind;
/** Set when a wipe collects it; the scene fades it out. */
collected?: boolean;
}
/** "There is visibly juice on this texel." Shared with the live readout. */
export const MESS_FLOOR = 0.03;
export class Mess {
readonly field: Field;
readonly mask: Field;
readonly solids: Solid[] = [];
/** What the cloth is currently carrying. */
clothLoad = 0;
/** Bumped whenever anything changes, so the scene knows to re-texture. */
version = 0;
constructor(n = 96) {
this.field = new Field(n);
this.mask = new Field(n, 1);
}
/**
* Juice lands as a streak: a few blobs along the stroke direction from the
* cut point, weaker with distance. `du,dv` is the (unnormalized) stroke
* direction; more speed = longer throw.
*/
addJuice(u: number, v: number, amount: number, du: number, dv: number): void {
if (amount <= 0) return;
const len = Math.hypot(du, dv);
const nu = len > 1e-6 ? du / len : 0;
const nv = len > 1e-6 ? dv / len : 1;
const throwLen = Math.min(0.28, 0.06 + amount * 0.5);
const blobs = 3;
for (let i = 0; i < blobs; i++) {
const t = i / (blobs - 1);
const bu = u + nu * throwLen * t;
const bv = v + nv * throwLen * t;
const share = (amount * (1 - t * 0.55)) / blobs;
const r = 0.03 + amount * 0.04;
const total = this.field.brush(bu, bv, r, () => undefined);
if (total <= 0) continue;
this.field.brush(bu, bv, r, (idx, w) => {
this.field.data[idx] += (share * w) / total;
});
}
this.version++;
}
addSolid(u: number, v: number, kind: SolidKind): void {
this.solids.push({ u: clamp01(u), v: clamp01(v), kind });
this.version++;
}
/**
* One cloth step from (pu,pv) to (u,v). Picks up ~half of what it crosses,
* shoves the rest ahead of the leading edge mass conserving until the
* cloth finally absorbs it. Collects solids it passes over. Returns mass
* picked up this step (the scene can wring the cloth on a threshold).
*/
wipe(pu: number, pv: number, u: number, v: number, radius = 0.07): number {
const du = u - pu;
const dv = v - pv;
const len = Math.hypot(du, dv);
if (len < 1e-5) return 0;
const nu = du / len;
const nv = dv / len;
let lifted = 0;
let pushed = 0;
this.field.brush(u, v, radius, (idx, w) => {
const take = this.field.data[idx] * Math.min(1, w * 0.9);
const absorb = take * 0.55;
lifted += absorb;
pushed += take - absorb;
this.field.data[idx] -= take;
});
// The un-absorbed mass lands just past the leading edge: the smear.
if (pushed > 0) {
const fu = u + nu * radius * 1.35;
const fv = v + nv * radius * 1.35;
const total = this.field.brush(fu, fv, radius * 0.8, () => undefined);
if (total > 0) {
this.field.brush(fu, fv, radius * 0.8, (idx, w) => {
this.field.data[idx] += (pushed * w) / total;
});
}
}
for (const s of this.solids) {
if (s.collected) continue;
const d = Math.hypot(s.u - u, s.v - v);
if (d < radius) {
s.collected = true;
}
}
this.clothLoad += lifted;
this.version++;
return lifted;
}
/** The judge's numbers: total juice mass, how far it's spread, loose bits. */
stats(): { mass: number; spreadFrac: number; solids: number } {
const d = this.field.data;
let mass = 0;
let dirty = 0;
for (let i = 0; i < d.length; i++) {
mass += d[i];
if (d[i] > MESS_FLOOR) dirty++;
}
return {
mass,
spreadFrac: dirty / d.length,
solids: this.solids.filter((s) => !s.collected).length,
};
}
/** The live readout, in the judge's own thresholds. */
benchWord(): 'clean' | 'smeared' | 'a crime scene' {
const s = this.stats();
const badness = s.spreadFrac * 3 + s.solids * 0.04 + s.mass * 0.4;
return badness < 0.25 ? 'clean' : badness < 0.9 ? 'smeared' : 'a crime scene';
}
reset(): void {
this.field.clear();
this.solids.length = 0;
this.clothLoad = 0;
this.version++;
}
}
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

View File

@ -325,6 +325,16 @@ canvas#c {
padding: 4px 0; padding: 4px 0;
} }
/* Station headers on prep-order scorecards. */
.crit-group {
font-size: 9px;
letter-spacing: 0.22em;
opacity: 0.55;
margin: 8px 0 2px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
padding-bottom: 3px;
}
.crit-name { .crit-name {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;