toastsim/src/sim/slicing.ts
monster e5be266ce6 M10: the bakery, verified — the wobble follows you into the toaster
Played end-to-end via the extended harness (t.start/day/pick/slice):
- Day 9 knife trip: bread_knife dragged above the rim, 0.45s dwell commits.
- Clean doorstop (bread knife, 0.05 u/frame): 5 strokes, wedge 0, squash 0.
  STROKE_GAIN 0.16 -> 0.10 to hit the 4-6 deliberate-stroke feel.
- The Best Thing: 3 strokes, and the same wobble input that wedges the bread
  knife to 0.299 only reaches 0.044 — nearly wobble-proof, as specced.
- Coupling: wobbled cut toasts at browning stdev 0.088 vs 0.071 clean, same
  mean — 'a bit wedged' band (>0.18) confirmed in the Cut criterion.
- Slam cut (0.4 u/frame): squash 1.0, arrives pre-damaged (damage 0.02 pre-toast).
- Soup spoon siege: 17 strokes / 166 frames, miserable but possible.
- Brands: quality now drives heat-noise amp (0.12+0.42*(1-q)) AND blotch
  frequency (3+3*(1-q)); WONDERSLICE visibly patchier than Crustworthy at 10s.
- Slicer visuals: cut seam deepens with progress; freed slice tips off the
  loaf and lands crumb-up (doneTimer 0.7 -> 1.05 for the fall).
- gen-assets.sh: retry-once per stage (rsync flakes), bakery seeds bumped,
  prep-bench cast queued ($1 prep).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:58:13 +10:00

141 lines
4.5 KiB
TypeScript

import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery';
/**
* Cutting a slice off a loaf.
*
* Three numbers come out, and each one feeds forward honestly:
* thickness — you chose it when the knife first bit; the judge holds it
* against the ticket, and the toasting sim uses it as thermal mass.
* wedge — sideways wander while sawing. A wedge-shaped slice doesn't just
* lose points here: its thin edge browns faster in the toaster,
* so a bad cut is STILL punishing you two phases later.
* squash — downforce the knife didn't earn. Fresh crumb compresses and
* tears; squash arrives on the slice as pre-existing damage.
*
* The knife decides how those accumulate. A bread knife saws clean. The Best
* Thing barely needs you. A butter knife against a bakery crust is a siege.
*/
export interface SliceCut {
/** World units; 1 unit ≈ 11cm, so 0.22 ≈ a 24mm doorstop. */
thickness: number;
/** 0 = parallel faces, 1 = a full wedge. */
wedge: number;
/** 0 = unbruised, 1 = flattened. */
squash: number;
knife: ToolId;
strokes: number;
}
export type CutClass = 'thin cut' | 'regular cut' | 'doorstop';
export const CUT_BANDS: Record<CutClass, [number, number]> = {
'thin cut': [0.09, 0.15],
'regular cut': [0.16, 0.24],
doorstop: [0.26, 0.36],
};
/** Progress a full stroke contributes, before knife efficiency. Tuned so a
* clean bread-knife doorstop is ~5 deliberate strokes, The Best Thing ~3. */
const STROKE_GAIN = 0.1;
/** Sideways wander → wedge, tempered by the knife's steadiness. */
const WOBBLE_GAIN = 0.55;
/** Sawing faster than the knife can bite leans on the loaf instead. */
const SPEED_CAP = 3.2;
const SQUASH_GAIN = 0.5;
export interface Slicing {
phase: 'aim' | 'saw' | 'done';
thickness: number;
progress: number;
wedge: number;
squash: number;
strokes: number;
knife: ToolId;
/** Direction of the current stroke, for counting reversals as strokes. */
lastDir: number;
strokeTravel: number;
}
export function newSlicing(knife: ToolId): Slicing {
return {
phase: 'aim',
thickness: 0.2,
progress: 0,
wedge: 0,
squash: 0,
strokes: 0,
knife,
lastDir: 0,
strokeTravel: 0,
};
}
export function sawStats(knife: ToolId): { eff: number; steady: number; gentle: number } {
return TOOLS[knife].saw ?? DEFAULT_SAW;
}
/** While aiming: the cursor picks how thick the slice will be. */
export function aim(s: Slicing, thickness: number): void {
if (s.phase !== 'aim') return;
s.thickness = Math.min(0.4, Math.max(0.06, thickness));
}
/** First bite locks the thickness — no re-aiming mid-cut. */
export function beginCut(s: Slicing): void {
if (s.phase === 'aim') s.phase = 'saw';
}
/**
* One frame of sawing. `dz` is the saw motion (along the blade), `dx` the
* sideways wander, both in world units this frame.
*/
export function saw(s: Slicing, dz: number, dx: number, dt: number): void {
if (s.phase !== 'saw') return;
const k = sawStats(s.knife);
const speed = dt > 0 ? Math.abs(dz) / dt : 0;
// Progress only accrues up to the speed the blade can actually bite at.
const useful = Math.min(Math.abs(dz), SPEED_CAP * dt);
s.progress += useful * STROKE_GAIN * k.eff * (14 / (1 + s.thickness * 8));
// Frantic sawing past the bite speed is just downforce.
const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle));
s.squash = Math.min(1, s.squash + excess * SQUASH_GAIN * (1 - k.gentle) * dt);
// Wander: judged against how far you've sawed, so one twitch isn't fatal.
s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady));
// Count strokes on direction reversals — it's the rhythm that reads as sawing.
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);
if (s.progress >= 1) {
s.progress = 1;
s.phase = 'done';
}
}
export function finishCut(s: Slicing): SliceCut {
return {
thickness: s.thickness,
wedge: s.wedge,
squash: s.squash,
knife: s.knife,
strokes: s.strokes,
};
}
export function classOfCut(thickness: number): CutClass | 'wafer' | 'slab' {
if (thickness < CUT_BANDS['thin cut'][0]) return 'wafer';
if (thickness <= CUT_BANDS['thin cut'][1]) return 'thin cut';
if (thickness <= CUT_BANDS['regular cut'][1]) return 'regular cut';
if (thickness <= CUT_BANDS.doorstop[1]) return 'doorstop';
return 'slab';
}