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:
parent
afe2bf174b
commit
848f4b3fcb
30
src/dev.ts
30
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
|
||||
|
||||
135
src/sim/steak.ts
Normal file
135
src/sim/steak.ts
Normal 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';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user