Go to file
type-two e1e5ce823a M12 routed + M14 onions: the juice detour and the stop-line
Stage 2 Lane B. Two jobs, both riding the existing prep-bench machinery
(prepQueue drained through enterPrep/runNextPrep before enterToastFlow, results
folded into the judged scorecard) — extended, not rebuilt.

JOB 1 — the juice station is a real day now.
- orders.ts: PrepStep is a discriminated union; new 'juice' kind carries the
  juicer tier + optional halve + fixed seed count. Day 12 (Deidre): "fresh
  orange juice with the toast" — juicer comes from the ORDER (Pyramid Classic,
  the starter, no seed moat), the halve is left clean (dead-centre) so the twist
  is the whole test; the halve->juice chain stays M12's own later order. seeds
  fixed at 5 so Pips is a skill row, not a lottery.
- game.ts: the Game owns a JuiceView (like PrepView). runNextPrep branches on
  step kind; enterJuice mirrors enterPrep. The glass is scored on its own
  criteria (juiceCriteria) folded into judge() via a new `extra` param; its
  spray joins The Bench through the same prepResult path a cut order uses.
- lines.ts: JUICE_LINES merged into the verdict BANK; juicerRecognitionLine
  wired off order.prep (the Juicernaut announces itself before the verdict).
- dev.ts: t.juice(revs, press) drives the REAL routed game.juiceView (not the
  __tj sandbox); follow with t.handoff() to serve the glass.

JOB 2 — M14 onions, the technique test (brief section 6).
- The stop-line rides the existing cut sim with one additive hook: on the
  onion dice's root-ward first pass, RELEASING the blade mid-saw commits a cut
  that stops short of the root (liftKnife now commits + returns its frame).
  finishCut is factored so a stopped cut and a through cut share the path.
  Saw all the way (progress hits 1) and the root severs -> the layers scatter.
  Gated behind pattern { kind:'dice', root:true } so day 10's plain dice
  (M11-verified) is untouched. That is the "ending a cut deliberately short"
  feel — restraint one cut at a time, not a menu choice.
- Sting clock: stingStep raises sting with open cut-surface x time x how much
  you crushed rather than sliced (blunt/squashy = tear gas). Rendered as a
  cheap full-screen DOM overlay (radial vignette + backdrop blur) that grows
  with sting, plus an "eyes fine/watering/streaming" readout and a green
  stop-line marker on the onion.
- Scored as two new rows (judging.ts): The Dice (stem discipline, w1.3) and
  The Eyes (sting endured, w0.7). The Pieces (CV) stays the criss-cross
  spacing, so the two skills are told apart. Lines for 'dice' and 'sting'.
- Day 13 (the inspector): dice the onion PROPERLY with The Best Thing.
- dev.ts: t.chop gains stopAt; t.diceOnion({stopAt|through}) drives both.

Constants and why:
- STOP_MIN 0.15 — a release shallower than this is a fumble, not a stop.
- STING_RATE 0.02 — a sharp, quick clean dice finishes ~0.05 (clear-eyed);
  the same onion left open 10s floods to 1.0. Sharp+fast wins, dawdling blinds.
- stemDiscipline band: full marks 0.55..0.9 depth, graze 0.9..0.97, through
  >=0.97 = confetti; undercut <0.55 = joined chunks.
- t.diceOnion default stopAt 0.8 -> commits ~0.85, level with the 0.82 marker.

Verified headless (deterministic; sim is pure):

Day 12 juice, t.day(12); t.juice(3, 0.7):
  extracted 0.728, theoretical 1.0, yieldOfReachable 0.728, pips 4,
  seedsCaught 0 (Classic has no moat -> serve the pips or fish them).
  Criteria: The Juice score 0.631 (w1.3), Pips 0.200 (w1.1). t.handoff()
  serves the glass; the toast half then runs as any non-loaf day, and the
  scorecard shows THE PREP (The Juice, Pips, The Bench) beside THE TOAST/SPREAD.

Day 13 onion, t.day(13); t.diceOnion():           // proper dice, stop short
  16 pieces, CV 0, rootThrough 0, stopDepths [0.85,0.85,0.85],
  The Dice score 1.000, sting 0.052 -> The Eyes 0.939 ("clear-eyed").

t.day(13); t.diceOnion({ through: true }):          // cut through the root
  rootThrough 3, stopDepths [1,1,1], The Dice score 0.000 (confetti line
  fires), CV still 0 -> the disaster reads on The Dice, not a lying CV.

Sting accumulation: after a proper dice, t.run(600) (10s idle) ->
  sting 0.052 -> 1.000 ("blind"), The Eyes 0.939 -> 0.000. The cut onion
  stings while it sits, exactly as it should.

Plain dice unaffected: onion { kind:'dice', n:4 } (no root) -> 16 pieces,
  CV 0, rootThrough 0, phase done — M11's day 10 is byte-for-byte the same test.

typecheck clean, npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:19:49 +10:00
.claude Drop accidentally-committed agent worktree pointer; ignore .claude/worktrees 2026-07-17 18:51:53 +10:00
public/assets M12 core merged + brief STATUS updated to the Stage 1 finish line 2026-07-17 18:51:37 +10:00
scripts Mushrooms: three varieties for the prep bench, plus gen-assets machine fixes 2026-07-17 18:20:10 +10:00
src M12 routed + M14 onions: the juice detour and the stop-line 2026-07-17 19:19:49 +10:00
.gitignore Drop accidentally-committed agent worktree pointer; ignore .claude/worktrees 2026-07-17 18:51:53 +10:00
index.html M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline 2026-07-16 21:16:18 +10:00
MASTERPLAN.md MASTERPLAN: toast to full service — six phases on the same bones 2026-07-17 18:30:38 +10:00
OPUS-BUILD-BRIEF-2.md M12 core merged + brief STATUS updated to the Stage 1 finish line 2026-07-17 18:51:37 +10:00
OPUS-BUILD-BRIEF.md M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline 2026-07-16 21:16:18 +10:00
package-lock.json Mushrooms: three varieties for the prep bench, plus gen-assets machine fixes 2026-07-17 18:20:10 +10:00
package.json M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline 2026-07-16 21:16:18 +10:00
README.md M8: crunchy vs smooth, and the jar that separates 2026-07-16 23:03:57 +10:00
tsconfig.json M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline 2026-07-16 21:16:18 +10:00
vite.config.ts M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline 2026-07-16 21:16:18 +10:00

TOASTSIM

Bread goes in. You are judged.

A browser game about making toast, where the inputs are analog and the output is scored by a man who has been doing this for thirty-one years.

npm install
npm run dev      # http://localhost:5173

The loop

Order → toast → drawer → spread → verdict → next day.

  1. The ticket tells you what someone wants: how dark, which spread, how much, which utensil, and whether they'll tolerate burnt bits.
  2. The toaster has a dial and a lever and no timer. You go by smell.
  3. The drawer makes you find the right piece of cutlery in a tangle of similar ones — while your toast goes cold.
  4. The bench is where the game actually lives. See below.
  5. The judge scores nine criteria and has something to say about the worst one.

The mechanic

One control — the knife's angle (mouse wheel) — and three behaviours fall out of it:

angle contact what happens
flat wide, low pressure spreads — or tears the bread, if the spread is stiffer than the pressure you're allowed to make
steep narrow, high pressure scrapes spread back off, or lifts char off burnt toast
steep + fast, on bread with nothing left to take gouges

The trap: pressure comes from steepness, but past the scrape threshold a steep knife stops spreading. So a spread can demand more pressure than spreading mode can physically give:

situation yield angle needed scrape cliff at 0.62
fridge butter / cold toast 0.72 0.75 impossible
bench butter / cold toast 0.54 0.57 one click from disaster
fridge butter / fresh toast 0.36 0.38 fine
soft butter / fresh toast 0.16 0.12 dream mode

Cold toast with hard butter cannot be spread at any angle. The way out isn't technique — it's not dawdling in the drawer. That's why the drawer is there. The pressure gauge draws both marks so you can watch the gold sit past the red and understand exactly why you're losing.

Scraping is the other tension: it rescues burnt toast and wrecks evenness doing it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both.

And MITEY is its own skill: spread it on, then take almost all of it back off. He wants to see the toast through it.

Peanut butter comes smooth or crunchy, and both are natural — the jar separates while it sits. The oil rises; the paste underneath dries out. An unstirred first dip is a slick (flows at almost no pressure, goes on thin and shiny), the dips after it are grout (fights like cold butter). Swirl the knife in the jar — about two and a half circles — to mix it back, and yes, stirring costs time, and time costs toast warmth. The judge reads the mass-weighted mean and spread of the consistency that actually landed: oil 0.50 ±0.00 — just right versus oil 0.54 ±0.23 — slick here, grout there, because a slick strip and a grout strip average to "correct" and nobody eats the average. Crunchy additionally has chunks, and the chunks are judged like rind (below).

Marmalade adds a second axis: the rind. Bits drop off the blade per unit of stroke distance, so a dabbing knife makes a pile and a moving knife lays a trail — and because the shed rate scales with what's left on the blade, one dip can't cover the slice. The judge scores the scatter with the ClarkEvans nearest-neighbour index (R ≈ 0 one clump, 1 random, above 1 deliberately even) and quotes it on the card: a corner dab reads "one clump (R 0.24)", three well-placed dips read "evenly strewn (R 1.17)". Scraping picks rind back up, so a clump can be argued with rather than started over.

The bread

Same dial, same 18 seconds, four breads:

bread 4s 9s 18s char
white 0.22 0.54 1.08 93%
multigrain 0.20 0.52 1.06 92%
raisin 0.30 0.76 1.14 99.8%
sourdough 0.11 0.33 0.74 14%

Sourdough's water has to boil off before the crust can brown, so it stalls and then accelerates hardest. Raisin bread is sugar: it browns early and burns early.

How it's built

  • Vite + TypeScript + three.js, Rapier for the drawer. No framework; the UI is a DOM overlay over one WebGL canvas.
  • The slice is the whole game's canvas: an extruded loaf silhouette with four 128×128 scalar fields — browning, dryness, spread, damage — packed into one RGBA8 texture per frame and composited by a custom shader. Everything the player does writes a field; everything the judge reads is a statistic over one.
  • Cutlery is procedural on purpose. The drawer asks you to tell a dessert fork from a dinner fork, and that's only fair if the silhouettes are authored.
  • Physics: compound boxes, never trimeshes. Grabbing is a capped PD spring, so a snagged piece fights what's lying on it instead of tunnelling through.

Layout

src/
  core/     app + loop, input, seeded rng, 2D field, synthesised audio
  sim/      bread, slice (geometry/fields/shader), toasting, spreads,
            spreading (the star mechanic), cutlery
  scenes/   kitchen (toast + spread), drawer (rapier), judge, props
  game/     game loop, orders, judging rubric, the judge's lines
  ui/       hud, title, cutlery silhouettes
  dev.ts    dev-only harness — drives real gestures deterministically

Dev harness

The game is driven by mouse gestures over a 3D scene, which makes "does it work" hard to answer by inspection. In a dev build, window.__t drives the real code paths at a chosen dt:

__t.toast(10, 6)      // lever down, 10s at power 6, pop, land
__t.spread(9, 0.55)   // dip and raster the slice at 0.55 units/sec
__t.stats()           // every field statistic the judge will read

It's also the escape hatch for headless browsers that never fire rAF — everything calls app.step(dt) directly.

Assets

Every asset was generated on-device and free via MODELBEAST, and is reproducible:

./scripts/gen-assets.sh        # fixed seeds; skips anything already present

flux_localbg_remove_localhunyuan3d_mlx. The judge's five expressions are one fixed seed and one fixed description with only the expression phrase varying, which is why he stays the same man while his face falls.

The toaster is procedural rather than the generated GLB — it needs a lever we can actually drive, and a dial that turns.