toastsim/src/sim/toasting.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

110 lines
4.3 KiB
TypeScript

import type { Slice } from './slice';
import { Rng } from '../core/rng';
/**
* Browning rate. Measured, not guessed: the heat map's mean is ~0.77 (coils,
* edge falloff and the cool top all bite), so this is set so power 6 reaches a
* golden 0.5 in ~10s, power 10 is nearly black by then, and power 3 takes ~20s.
*/
const RATE = 0.11;
/** Where the elements actually put their heat. */
export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
const n = slice.mask.n;
const out = new Float32Array(n * n);
// Real toasters have vertical element wires spaced across the slot, so the
// hot spots are stripes, not a wash. Jitter the phase per run.
const coils = 4 + rng.int(0, 2);
const phase = rng.range(0, Math.PI * 2);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const u = x / (n - 1);
const v = y / (n - 1);
// Subtle: at much more than this the slice reads as a grill plate, not toast.
const coil = 0.92 + 0.08 * Math.sin(u * Math.PI * 2 * coils + phase);
// The top of the slice sticks up out of the slot and stays pale — the
// single most recognisable thing about real toast.
const vert = v > 0.72 ? 1 - 0.5 * Math.pow((v - 0.72) / 0.28, 1.5) : 1;
// and the very bottom sits on the rack, slightly shaded
const base = v < 0.06 ? 0.82 + (v / 0.06) * 0.18 : 1;
// heat escapes at the left/right edges
const edge = 0.82 + 0.18 * Math.sin(Math.min(1, Math.max(0, u)) * Math.PI);
// Cheap bread takes heat like a raffle: the noise amplitude scales with
// how bad the brand is. Good bread flattens it out.
const amp = 0.12 + 0.42 * (1 - slice.quality);
const bias = 1 - amp / 2 + slice.heatBias[y * n + x] * amp;
// A wedge-shaped slice has a thin edge, and thin edges brown first: the
// bad cut follows you into the toaster.
const wedge = slice.cut ? 1 + slice.cut.wedge * 0.55 * (u - 0.5) * 2 : 1;
out[y * n + x] = coil * vert * base * edge * bias * Math.max(0.3, wedge);
}
}
return out;
}
export interface ToastTick {
/** Mean browning over the slice — drives the smell cues. */
mean: number;
/** How much of the slice is past char. */
charFrac: number;
/** 0..1, how smoky it is right now. */
smoke: number;
}
/**
* Advance the browning one tick.
*
* Two things make this more than a timer:
* - moisture has to boil off before the surface can brown at full rate, so a wet
* crumb stalls and then accelerates once it's dry (sourdough's whole trick);
* - sugar browns early and burns early (raisin bread's whole trick).
*/
export function toastStep(slice: Slice, heat: Float32Array, power: number, dt: number): void {
const b = slice.bread;
const p = Math.max(0, Math.min(10, power)) / 10;
const moistureCap = 0.4 + b.moisture * 2.2;
const thick = 0.6 + b.thickness * 3;
const sugar = 1 + b.sugar * 0.8;
const br = slice.browning.data;
const dry = slice.dryness.data;
const mask = slice.mask.data;
for (let i = 0; i < br.length; i++) {
if (mask[i] < 0.5) continue;
const h = heat[i] * p;
if (h <= 0) continue;
dry[i] = Math.min(1, dry[i] + (h * dt) / moistureCap);
const rate = (RATE * h * (0.25 + 0.75 * dry[i]) * sugar) / thick;
br[i] = Math.min(1.15, br[i] + rate * dt);
}
slice.touch();
}
/** Warmth decays in real time. Thick slices hold heat longer. */
export function coolStep(slice: Slice, dt: number): void {
const tau = 18 + slice.bread.thickness * 90;
slice.warmth *= Math.exp(-dt / tau);
slice.material.uniforms.uWarmth.value = slice.warmth;
}
export function readToast(slice: Slice): ToastTick {
const s = slice.browning.stats(slice.mask);
const charFrac = slice.browning.fraction(slice.mask, (v) => v > 0.85);
const smoke = Math.max(0, Math.min(1, (s.max - 0.78) / 0.3));
return { mean: s.mean, charFrac, smoke };
}
/**
* No timer, no numbers — you go by smell. Deliberately vague until it isn't.
*/
export function smellCue(t: ToastTick): string {
if (t.smoke > 0.75) return 'SMOKE. ACTUAL SMOKE.';
if (t.smoke > 0.45) return 'something is burning';
if (t.mean > 0.62) return 'dark, and getting darker';
if (t.mean > 0.42) return 'smells like toast';
if (t.mean > 0.22) return 'smells toasty';
if (t.mean > 0.08) return 'smells warm';
if (t.mean > 0.01) return 'a faint warmth';
return 'smells like bread';
}