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>
141 lines
4.5 KiB
TypeScript
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';
|
|
}
|