The rib eye sim: rest clock (it bleeds), grain-aware cut — verified

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 22:54:28 +10:00
parent afe2bf174b
commit 848f4b3fcb
2 changed files with 165 additions and 0 deletions

View File

@ -9,6 +9,7 @@ import { Rng } from './core/rng';
import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal'; import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal';
import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan'; 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 { 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 * 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 }; 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. ---- // ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ----
/** Same fish stored in each zone for `days` freshness must fall fast on the /** Same fish stored in each zone for `days` freshness must fall fast on the

135
src/sim/steak.ts Normal file
View File

@ -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<Doneness, number> = { 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';
}