From 848f4b3fcb14f44b5318a22381b6ec0b4dcbf2dc Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 22:54:28 +1000 Subject: [PATCH] =?UTF-8?q?The=20rib=20eye=20sim:=20rest=20clock=20(it=20b?= =?UTF-8?q?leeds),=20grain-aware=20cut=20=E2=80=94=20verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/sim/steak.ts — the crown jewel's three acts as pure sim. REST: a steak comes off the pan at moisture 0.95 and relaxes toward 0.6 over ~20s; cut it hot and the juice FLOODS the board, rest it and it's a whisper (no UI timer — the meat tells you). CUT: a grain axis; across = |sin(cut-grain)| tender, along = a rope; knife wobble tears instead of slices. COOK: interior doneness vs the rare/medium/well target. Verified via t.steakMarquee: cut hot -> flood 10.5 (rest 0); rested -> flood 0 (rest 1); across grain cut 1.0 vs along grain 0; wobble -> 6 tears (cut 0.2). The 'it bleeds' feel John wanted, measured. Co-Authored-By: Claude Fable 5 --- src/dev.ts | 30 +++++++++++ src/sim/steak.ts | 135 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/sim/steak.ts diff --git a/src/dev.ts b/src/dev.ts index 8e243ac..cc9afcb 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -9,6 +9,7 @@ import { Rng } from './core/rng'; import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal'; import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan'; import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain'; +import { newSteakSession, restStep, cut as steakCut, finishSteak, steakResult, type Doneness } from './sim/steak'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -585,6 +586,35 @@ export function installDevHarness(app: App, game: Game): void { return { grade: gradeEl ? gradeEl.textContent : null, groups, rows }; }, + // ---- THE RIB EYE: rest clock (the flood) + the grain-aware cut. ---- + + /** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain + * (0 = along, PI/2 = across). Cut hot (restS 0) and it FLOODS; rest and it + * whispers. Returns the flood + cut scores. */ + steak(opts: { restS?: number; n?: number; angle?: number; wobble?: number; grain?: number; cook?: number; target?: Doneness } = {}) { + const { restS = 20, n = 6, angle = Math.PI / 2, wobble = 0, grain = 0, cook = 0.55, target = 'medium' } = opts; + const s = newSteakSession(cook, target, grain); + for (let i = 0; i < Math.round(restS * 60); i++) restStep(s, 1 / 60); + const moistureAtCut = s.moisture; + for (let i = 0; i < n; i++) steakCut(s, angle, wobble); + finishSteak(s); + const r = steakResult(s); + const q = (v: number) => +v.toFixed(3); + return { moistureAtCut: q(moistureAtCut), flood: q(r.flood), rest: q(r.restScore), cut: q(r.cutScore), across: q(r.meanAcross), tears: r.tears, cook: q(r.cookScore) }; + }, + + /** The marquee, side by side: cut it straight off the heat vs after a rest; + * and across the grain vs along it. */ + steakMarquee() { + return { + cutHot: harness.steak({ restS: 0, angle: Math.PI / 2 }), + rested: harness.steak({ restS: 20, angle: Math.PI / 2 }), + acrossGrain: harness.steak({ restS: 20, angle: Math.PI / 2, grain: 0 }), + alongGrain: harness.steak({ restS: 20, angle: 0, grain: 0 }), + wobbled: harness.steak({ restS: 20, angle: Math.PI / 2, wobble: 0.6 }), + }; + }, + // ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ---- /** Same fish stored in each zone for `days` — freshness must fall fast on the diff --git a/src/sim/steak.ts b/src/sim/steak.ts new file mode 100644 index 0000000..0b59aeb --- /dev/null +++ b/src/sim/steak.ts @@ -0,0 +1,135 @@ +/** + * THE RIB EYE — cook it, REST it, cut it right. + * + * A steak comes off the pan full of juice (moisture ~0.95) and every fibre + * clenched. Cut it now and it bleeds out onto the board — a flood the judge + * watches happen. Let it REST and the juice redistributes (moisture relaxes + * toward ~0.6); cut it then and it's a whisper. There is no timer on screen — + * the steak tells you, if you know how to look, and the flood tells the judge. + * + * Then the cut itself: a steak has GRAIN, long muscle fibres running one way. + * Cut ACROSS them (perpendicular) and each slice is short-fibred and tender; + * cut ALONG them and it's a rope. Wobble the knife and you tear rather than + * slice. + * + * Three things judged: THE COOK (did the interior hit the doneness asked), THE + * REST (did you hold your nerve — measured by the flood), THE CUT (across the + * grain, evenly, no tearing). + * + * Pure — no three.js — so the harness rests and cuts headless. + */ + +/** A rested steak settles here; the gap above it is the juice waiting to flood. */ +export const RESTED_MOISTURE = 0.6; +/** Off the heat, this full. */ +export const HOT_MOISTURE = 0.95; +/** Seconds to fully rest (moisture HOT → RESTED). */ +const REST_SECONDS = 20; +/** How hard a cut through juicy meat bleeds (per slice, per unit over rested). */ +const BLEED_GAIN = 5.0; + +export type Doneness = 'rare' | 'medium' | 'well'; +/** Interior doneness target windows (0 raw .. 1 grey). */ +export const DONENESS_TARGET: Record = { rare: 0.35, medium: 0.55, well: 0.78 }; + +export interface SteakCut { + /** |sin(cut − grain)| — 1 is dead across the grain, 0 is along it. */ + across: number; + /** Tore instead of sliced (knife wobble through clenched fibre). */ + torn: boolean; + /** Moisture at the moment of this cut — how juicy it still was. */ + moisture: number; +} + +export interface SteakSession { + /** Interior doneness the sear left (fed from the pan). */ + cookDoneness: number; + target: Doneness; + /** Fibre direction, radians. Cutting is scored against it. */ + grainAxis: number; + /** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */ + moisture: number; + restedSeconds: number; + cuts: SteakCut[]; + /** Juice that hit the board — the nerve test, made visible. */ + flood: number; + phase: 'resting' | 'cutting' | 'done'; +} + +export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4): SteakSession { + return { + cookDoneness, + target, + grainAxis, + moisture: HOT_MOISTURE, + restedSeconds: 0, + cuts: [], + flood: 0, + phase: 'resting', + }; +} + +/** Rest one tick: the juice settles back in, moisture relaxes toward rested. */ +export function restStep(s: SteakSession, dt: number): void { + if (s.phase !== 'resting') return; + s.restedSeconds += dt; + const t = Math.min(1, s.restedSeconds / REST_SECONDS); + s.moisture = HOT_MOISTURE - (HOT_MOISTURE - RESTED_MOISTURE) * t; +} + +/** + * Make one cut at `angle` (radians) with `wobble` (0 steady .. 1 shaky). The + * juice it releases depends on how rested the meat is — cut it hot and it FLOODS. + * Cutting commits the steak to the cutting phase (resting is over once you start). + */ +export function cut(s: SteakSession, angle: number, wobble = 0): SteakCut { + if (s.phase === 'done') return { across: 0, torn: false, moisture: s.moisture }; + s.phase = 'cutting'; + const across = Math.abs(Math.sin(angle - s.grainAxis)); + const torn = wobble > 0.45; + const bleed = Math.max(0, s.moisture - RESTED_MOISTURE) * BLEED_GAIN; + s.flood += bleed; + const c: SteakCut = { across, torn, moisture: s.moisture }; + s.cuts.push(c); + return c; +} + +export function finishSteak(s: SteakSession): void { + s.phase = 'done'; +} + +export interface SteakResult { + /** How close the interior came to the doneness asked. */ + cookScore: number; + /** The nerve: 1 if you rested it, dropping with the flood. */ + restScore: number; + /** Across the grain and clean, or a torn rope. */ + cutScore: number; + doneness: number; + target: Doneness; + flood: number; + meanAcross: number; + tears: number; +} + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +export function steakResult(s: SteakSession): SteakResult { + const tgt = DONENESS_TARGET[s.target]; + const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22); + // The rest is the flood: a whisper of juice is a well-rested steak; a puddle + // is a steak you couldn't wait for. + const restScore = clamp01(1 - s.flood / 1.1); + const meanAcross = s.cuts.length ? s.cuts.reduce((a, c) => a + c.across, 0) / s.cuts.length : 0; + const tears = s.cuts.filter((c) => c.torn).length; + const cutScore = clamp01(meanAcross - (s.cuts.length ? tears / s.cuts.length : 0) * 0.8); + return { cookScore, restScore, cutScore, doneness: s.cookDoneness, target: s.target, flood: s.flood, meanAcross, tears }; +} + +/** The live tell for the resting steak — no timer, just the meat. */ +export function restWord(s: SteakSession): string { + if (s.moisture > 0.85) return 'straight off the heat — let it REST'; + if (s.moisture > 0.72) return 'still tight, still bleeding'; + if (s.moisture > 0.66) return 'nearly there…'; + return 'rested — cut it now'; +}