Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
f81d1b5c82 The rib eye is playable: day 19, sear -> REST -> cut (it bleeds)
Wired steak.ts into a live day. scenes/steakboard.ts — the resting board:
the steak lands off the pan full of juice, DRAG across the grain (pale
fibre lines shown) to slice; cut it before it rests and a dark juice
puddle FLOODS the board. Day 19 routes pan-sear -> resting board -> judge
(pan's two seared faces carry the interior doneness). judgeSteak scores
THE STEAK (The Cook / The Rest / The Cut) + The Board, with a steakhouse-
grandfather line bank ('You cut it the second it left the pan. It bled out
on the board. I watched the whole thing.').

Verified live end-to-end: rested + across grain -> S (9.5); cut hot -> 'it
bled' -> 5.4; rested but along the grain -> 'chewy' -> 6.9. The three acts
each move the grade. Real-drag cutting confirmed (4 slices, flood 6.9).

Also fixed a latent crash: verdictLines could call fill() with an
undefined line when the best-scoring row (e.g. The Board) had no BANK
entry — now guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:05:45 +10:00
type-two
848f4b3fcb 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>
2026-07-19 22:54:28 +10:00
8 changed files with 629 additions and 6 deletions

View File

@ -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,66 @@ 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) };
},
/** Play the whole day-19 rib-eye order: sear on the pan, then rest (or don't)
* and cut across (or along) the grain on the board, serve. */
steakDay(opts: { rest?: boolean; alongGrain?: boolean } = {}) {
const { rest = true, alongGrain = false } = opts;
game.setDay(19);
run(6);
// Sear both faces on the pan (reuse the verified pan cook).
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession };
const ps = pv.session;
if (ps) {
setPanKnob(ps, 7); addButter(ps);
let guard = 0; while (butterState(ps) !== 'foaming' && guard++ < 1800) run(1);
addFood(ps, 'rib_eye', 'A');
for (let i = 0; i < 60 * 8; i++) run(1);
flip(ps);
for (let i = 0; i < 60 * 8; i++) run(1);
tap('Enter'); run(6); // pan done → resting board
}
const sb = game.steakBoardView as unknown as { session: import('./sim/steak').SteakSession };
const ss = sb.session;
if (!ss) return { error: 'not at board', view: app.currentView === game.steakBoardView ? 'board' : 'other' };
// Rest (or cut hot), then cut 6 slices across (or along) the grain.
if (rest) for (let i = 0; i < 60 * 20; i++) run(1);
const angle = alongGrain ? ss.grainAxis : ss.grainAxis + Math.PI / 2;
for (let i = 0; i < 6; i++) steakCut(ss, angle, 0);
tap('Enter'); run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, rows };
},
/** 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

View File

@ -8,6 +8,7 @@ import { OvenView } from '../scenes/oven';
import { GrillView } from '../scenes/grill';
import { PanView } from '../scenes/pan';
import { FridgeView } from '../scenes/fridge';
import { SteakBoardView } from '../scenes/steakboard';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -16,7 +17,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
@ -57,6 +58,7 @@ export class Game {
private grill: GrillView;
private pan: PanView;
private fridge: FridgeView;
private steakBoard: SteakBoardView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -94,6 +96,7 @@ export class Game {
this.grill = new GrillView(app);
this.pan = new PanView(app);
this.fridge = new FridgeView(app);
this.steakBoard = new SteakBoardView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -105,6 +108,7 @@ export class Game {
app.scene.add(this.grill.root);
app.scene.add(this.pan.root);
app.scene.add(this.fridge.root);
app.scene.add(this.steakBoard.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -115,6 +119,7 @@ export class Game {
this.grill.root.visible = false;
this.pan.root.visible = false;
this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -220,6 +225,9 @@ export class Game {
get fridgeView(): FridgeView {
return this.fridge;
}
get steakBoardView(): SteakBoardView {
return this.steakBoard;
}
get assemblyView(): AssemblyView {
return this.assembly;
}
@ -389,6 +397,7 @@ export class Game {
this.grill.root.visible = false;
this.pan.root.visible = false;
this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.assembly.root.visible = false;
}
@ -527,9 +536,56 @@ export class Game {
this.enterFridge();
return;
}
// A steak day: sear on the pan, then rest + cut on the board.
if (this.order.steak) {
this.enterSteak();
return;
}
this.enterToastFlow();
}
/** The rib eye: sear it on the pan, carry the sear's doneness to the resting
* board, rest + cut, serve. Pan board judge, like the bruschetta chain. */
private enterSteak(): void {
const st = this.order.steak!;
this.pan.reset('rib_eye', st.fuel, 'sear the rib eye — both faces, foam the butter');
this.hideAllScenes();
this.pan.root.visible = true;
this.app.setView(this.pan);
this.ticket.show();
this.pan.onDone = (panResult) => {
this.pan.root.visible = false;
// The two seared faces stand in for the interior doneness carried to the board.
const cookDoneness = (panResult.downMean + panResult.upMean) / 2;
this.steakBoard.reset(cookDoneness, st.target, st.grain);
this.hideAllScenes();
this.steakBoard.root.visible = true;
this.app.setView(this.steakBoard);
this.ticket.show();
this.steakBoard.onDone = (r) => {
this.steakBoard.root.visible = false;
this.serveSteak(r);
};
};
}
private serveSteak(result: ReturnType<SteakBoardView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSteak(result, this.order, seconds);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
this.hideAllScenes();
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** The cold-chain day: stock the fridge right, age it, let the inspector look. */
private enterFridge(): void {
this.fridge.reset();

View File

@ -11,6 +11,7 @@ import type { GrillResult } from '../sim/charcoal';
import { grillWord } from '../sim/charcoal';
import type { PanResult } from '../sim/pan';
import type { storeHealth } from '../sim/coldchain';
import type { SteakResult } from '../sim/steak';
export interface Criterion {
key: string;
@ -23,7 +24,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -495,6 +496,65 @@ export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdi
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The rib eye — cook it, rest it, cut it. The Rest is the nerve test: it reads
// the flood, so cutting a hot steak bleeds points onto the board.
export function judgeSteak(r: SteakResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'steakcook',
group: 'steak',
label: 'The Cook',
score: clamp01(r.cookScore),
weight: 1.2,
detail: `${r.target} — interior ${Math.round(r.doneness * 100)}%`,
},
{
key: 'steakrest',
group: 'steak',
label: 'The Rest',
score: clamp01(r.restScore),
weight: 1.3,
detail: r.flood > 1.1 ? 'it bled — you cut it early' : r.flood > 0.2 ? 'a little weep' : 'rested, held its juice',
},
{
key: 'steakcut',
group: 'steak',
label: 'The Cut',
score: clamp01(r.cutScore),
weight: 1.2,
detail: r.meanAcross < 0.4 ? 'along the grain — chewy' : r.tears > 0 ? `${r.tears} torn, not sliced` : 'across the grain, clean',
},
{
key: 'steakboard',
group: 'bench',
label: 'The Board',
score: clamp01(1 - r.flood / 8),
weight: 0.6,
detail: r.flood > 0.5 ? 'juice all over the board' : 'a clean board',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 55) / 90),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
void order;
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The cold chain — the fridge day. After three days, how much of your delivery
// survived (placement/cold), did anything drip (hygiene), and what did you waste.

View File

@ -367,6 +367,40 @@ Object.assign(BANK, {
},
});
// The rib eye — the steakhouse grandfather who has watched a thousand cooks panic.
Object.assign(BANK, {
steakcook: {
bad: [
'You asked it a question and pulled it out before it answered. Grey in the middle, or raw — pick a lane.',
'The interior is a guess. Cooking a steak is not a guess.',
'Over. You cooked the life out of it and then kept going.',
],
good: ['Edge to edge, the doneness they asked for. That is a cook.', 'The interior is exactly right. You read it.'],
},
steakrest: {
bad: [
'You cut it the second it left the pan. It bled out on the board. I watched the whole thing.',
'No rest. All that juice is on the wood, not in the meat. That was the dinner, weeping.',
'Patience is the last ingredient and you left it out. It bled.',
],
good: [
'You let it rest. You held your nerve while it did nothing, and it kept every drop. THAT is the job.',
'Rested. Cut it and it held. A cook who can wait is a cook.',
],
},
steakcut: {
bad: [
'You cut along the grain. Every slice is a rope. Turn the knife ninety degrees, always.',
'Torn, not sliced. Steady the hand — a steak deserves one clean pass.',
'Chewy. You cut WITH the fibres. Across. Across the grain.',
],
good: [
'Across the grain, one clean pass each. Short fibres, tender. You know where the muscle runs.',
'Every slice across the grain, none torn. That is knife work.',
],
},
});
// The fetched ingredient — the cook who reads a use-by date.
Object.assign(BANK, {
ingredient: {
@ -488,7 +522,10 @@ export function verdictLines(v: Verdict, order: Order, tool: string, rand: () =>
}
if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool));
// Guard: a criterion with no BANK entry (or an empty good[]) must not push an
// undefined line into fill() — some rows (The Board, Service) carry no praise.
const good = BANK[v.best.key]?.good;
if (good && good.length) out.push(fill(pick(good), order, v, tool));
}
out.push(pick(GRADE_CLOSERS[v.grade]));

View File

@ -108,6 +108,9 @@ export interface Order {
/** The cold chain: a delivery to put away. Presence sends the day to the
* fridge raw low, perishables cold, don't cram it, then three days pass. */
fridge?: boolean;
/** The rib eye: sear it on the pan, then REST it and cut across the grain.
* `target` is the doneness asked; `grain` is the fibre direction to cut across. */
steak?: { fuel: string; target: 'rare' | 'medium' | 'well'; grain: number };
}
export const BROWNING_NAMES: [number, string][] = [
@ -419,6 +422,21 @@ export const DAYS: Order[] = [
text: 'Pan-sear the mushrooms — the ones from your own fridge. Whatever shape they\'re in now is the shape you stored them in. Butter, foam, both faces. No excuses about the delivery; that was your fridge.',
pan: { food: FRIDGE_MUSHROOM_ID, fuel: 'gas', fromFridge: true },
},
{
day: 19,
brand: 'the bakery loaf',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the man at table nine',
text: 'Rib eye, medium. Sear it in the pan, both faces, foam the butter. Then REST it — do not touch it, do not cut it, let it settle. And when you slice, go ACROSS the grain, clean. Cut it early and I will see it bleed.',
steak: { fuel: 'gas', target: 'medium', grain: 0.35 },
},
];
/** Days beyond the script — keep going, with everything cranked. */

View File

@ -107,7 +107,9 @@ export class JudgeView implements View {
// asked and what for, not the unread toast defaults.
this.orderEl.textContent = order.grill
? `Day ${order.day} · ${order.who} asked for the barbie`
: order.pan
: order.steak
? `Day ${order.day} · ${order.who} asked for the rib eye`
: order.pan
? `Day ${order.day} · ${order.who} asked for the pan-sear`
: order.fridge
? `Day ${order.day} · the fridge, three days on`
@ -121,7 +123,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some(
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold',
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -132,9 +134,10 @@ export class JudgeView implements View {
grill: 'THE GRILL',
pan: 'THE PAN',
cold: 'THE FRIDGE',
steak: 'THE STEAK',
bench: 'THE BENCH',
};
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, spread: 4, bench: 5 };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

253
src/scenes/steakboard.ts Normal file
View File

@ -0,0 +1,253 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import {
type SteakSession,
newSteakSession,
restStep,
cut as steakCut,
finishSteak,
steakResult,
restWord,
type Doneness,
} from '../sim/steak';
import { el, Panel } from '../ui/hud';
const BOARD_Y = 0.06;
const STEAK_W = 1.9;
const STEAK_D = 1.3;
/**
* THE RESTING BOARD the nerve test, then the cut.
*
* The steak lands here straight off the pan, full of juice. DRAG across it to
* slice but cut it now and it BLEEDS all over the board. Let it REST first
* (the meat relaxes; there's no timer, just the steak) and it barely weeps.
* And cut ACROSS the grain the pale fibre lines show which way they run
* not along it, and keep the knife steady or you'll tear it. ENTER serves.
*/
export class SteakBoardView implements View {
readonly root = new THREE.Group();
private session: SteakSession | null = null;
private steak!: THREE.Mesh;
private grainLines: THREE.Line[] = [];
private seams: THREE.Mesh[] = [];
private puddle!: THREE.Mesh;
private knife!: THREE.Mesh;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2 + 0.08));
private strokeStart: THREE.Vector3 | null = null;
private strokePath: THREE.Vector3[] = [];
private wasDown = false;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof steakResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('steak-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'REST IT, THEN CUT');
this.askEl = el('div', 'slicer-ask', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
this.wordEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(24, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const board = new THREE.Mesh(
new THREE.BoxGeometry(STEAK_W + 1, BOARD_Y * 2, STEAK_D + 0.8),
new THREE.MeshStandardMaterial({ color: 0x8a5a34, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
this.root.add(board);
// The juice puddle: a dark-red disc that grows with the flood, under the steak.
this.puddle = new THREE.Mesh(
new THREE.CircleGeometry(1, 32),
new THREE.MeshBasicMaterial({ color: 0x5a0f0a, transparent: true, opacity: 0.0, depthWrite: false }),
);
this.puddle.rotation.x = -Math.PI / 2;
this.puddle.position.y = BOARD_Y * 2 + 0.005;
this.puddle.scale.setScalar(0.01);
this.root.add(this.puddle);
// The steak slab.
this.steak = new THREE.Mesh(
new THREE.BoxGeometry(STEAK_W, 0.22, STEAK_D),
new THREE.MeshStandardMaterial({ color: 0x6e2b22, roughness: 0.55 }),
);
this.steak.position.y = BOARD_Y * 2 + 0.11;
this.steak.castShadow = true;
this.root.add(this.steak);
// The knife, following the cursor.
this.knife = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 0.04, 0.12),
new THREE.MeshStandardMaterial({ color: 0xcdd2d6, roughness: 0.3, metalness: 0.8 }),
);
this.knife.position.y = BOARD_Y * 2 + 0.4;
this.root.add(this.knife);
}
/** A steak fresh off the pan: its interior doneness, the ordered target, and
* a fibre direction to cut across. */
reset(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.35, askText = 'let it rest — then cut across the grain'): void {
this.session = newSteakSession(cookDoneness, target, grainAxis);
for (const l of this.grainLines) this.root.remove(l);
for (const s of this.seams) this.root.remove(s);
this.grainLines = [];
this.seams = [];
this.strokeStart = null;
(this.puddle.material as THREE.MeshBasicMaterial).opacity = 0;
this.puddle.scale.setScalar(0.01);
// Draw the grain: pale parallel fibre lines at grainAxis across the top face.
const y = BOARD_Y * 2 + 0.23;
const mat = new THREE.LineBasicMaterial({ color: 0x9a6a5a, transparent: true, opacity: 0.7 });
const dir = new THREE.Vector2(Math.cos(grainAxis), Math.sin(grainAxis));
const perp = new THREE.Vector2(-dir.y, dir.x);
for (let i = -6; i <= 6; i++) {
const off = perp.clone().multiplyScalar(i * 0.13);
const a = new THREE.Vector3(off.x - dir.x * 1.2, y, off.y - dir.y * 1.2);
const b = new THREE.Vector3(off.x + dir.x * 1.2, y, off.y + dir.y * 1.2);
const g = new THREE.BufferGeometry().setFromPoints([a, b]);
const line = new THREE.Line(g, mat);
// Clip to the steak footprint by hiding lines whose midpoint is off-slab.
if (Math.abs(off.x) < STEAK_W / 2 && Math.abs(off.y) < STEAK_D / 2) {
this.grainLines.push(line);
this.root.add(line);
}
}
this.askEl.textContent = askText;
this.hintEl.textContent = 'wait for it to REST · DRAG across the grain to slice · steady hands · ENTER serves';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.0, 2.6);
this.app.camera.lookAt(0, BOARD_Y, 0.05);
const s = this.session;
if (!s) return;
const inp = this.app.input;
restStep(s, dt);
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
if (on) {
this.knife.position.set(this.hit.x, BOARD_Y * 2 + 0.4, this.hit.z);
}
const overSteak = on && Math.abs(this.hit.x) < STEAK_W / 2 + 0.2 && Math.abs(this.hit.z) < STEAK_D / 2 + 0.2;
if (inp.down && overSteak) {
if (!this.wasDown) {
this.strokeStart = this.hit.clone();
this.strokePath = [this.hit.clone()];
} else if (this.strokeStart) {
this.strokePath.push(this.hit.clone());
}
}
if (!inp.down && this.wasDown && this.strokeStart) {
this.commitStroke();
this.strokeStart = null;
}
this.wasDown = inp.down;
// The puddle grows with the flood — capped for the eye.
const flood = Math.min(1, s.flood / 6);
const pm = this.puddle.material as THREE.MeshBasicMaterial;
pm.opacity = Math.min(0.8, flood * 0.9);
this.puddle.scale.setScalar(0.4 + flood * 1.4);
// Knife tilts to the current stroke's implied angle when steady.
this.updateHud();
if (inp.justPressed('Enter')) {
finishSteak(s);
const cb = this.onDone;
this.onDone = null;
cb?.(steakResult(s));
}
}
private commitStroke(): void {
const s = this.session!;
const start = this.strokeStart!;
const end = this.hit;
const dx = end.x - start.x;
const dz = end.z - start.z;
const len = Math.hypot(dx, dz);
if (len < 0.35) return; // a tap, not a slice
const angle = Math.atan2(dz, dx);
// Wobble: how much the path deviated from the straight start→end line.
let dev = 0;
for (const p of this.strokePath) {
const t = ((p.x - start.x) * dx + (p.z - start.z) * dz) / (len * len);
const px = start.x + dx * t;
const pz = start.z + dz * t;
dev = Math.max(dev, Math.hypot(p.x - px, p.z - pz));
}
const wobble = Math.min(1, dev / 0.25);
const c = steakCut(s, angle, wobble);
audio.clunk(false);
if (c.torn) audio.clatter(0.5, 0.7);
// Draw the slice seam along the stroke.
const seam = new THREE.Mesh(
new THREE.BoxGeometry(len, 0.24, 0.02),
new THREE.MeshStandardMaterial({ color: 0x3a1712, roughness: 1 }),
);
seam.position.set((start.x + end.x) / 2, BOARD_Y * 2 + 0.11, (start.z + end.z) / 2);
seam.rotation.y = -angle;
this.root.add(seam);
this.seams.push(seam);
}
private updateHud(): void {
const s = this.session!;
const r = steakResult(s);
this.stateEl.textContent = `${s.cuts.length} slice${s.cuts.length === 1 ? '' : 's'}${s.flood > 0.5 ? ` · ${Math.round(Math.min(100, s.flood * 15))}% bled out` : ''}`;
if (s.cuts.length === 0) {
this.wordEl.textContent = restWord(s);
this.wordEl.style.color = s.moisture > 0.72 ? '#e2603a' : s.moisture < 0.66 ? '#8fce8f' : '';
} else {
const word = r.flood > 1.1 ? 'it bled — you cut it early' : r.meanAcross < 0.4 ? 'along the grain — chewy' : r.tears > 0 ? 'torn, not sliced' : 'across, clean, rested';
this.wordEl.textContent = word;
this.wordEl.style.color = r.cutScore > 0.7 && r.restScore > 0.7 ? '#8fce8f' : '#e2603a';
}
}
result() {
return this.session ? steakResult(this.session) : { cookScore: 0, restScore: 0, cutScore: 0, doneness: 0, target: 'medium' as const, flood: 0, meanAcross: 0, tears: 0 };
}
dispose(): void {
this.panel.dispose();
}
}

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';
}