Rib-eye review fixes: rest-per-cut, no-cut guard, sear evenness, GPU leak (6 findings)

Adversarial review of the steak day found seven issues; fixed six:
- REST SCORE gamed by slice count (bug): flood summed over cuts, so
  fewer slices faked a better rest. Now scored on the PER-CUT bleed
  (moisture at the moment you committed) — cutting 1 vs 6 slices at the
  same rest is identical (verified 0.682 == 0.682); slice count is pure
  visual now.
- ZERO-CUT SERVE rewarded (design): an uncut steak scored restScore 1.0.
  steakResult now gives 0 rest with no cuts, and the board blocks serving
  until you've actually cut.
- UNEVEN SEAR masked (edge-case): The Cook used only (down+up)/2, so one
  charred + one raw face averaged to 'perfect'. sideGap now threads pan ->
  board and docks cookScore (an uneven sear isn't the right steak).
- GPU LEAK (bug): grain lines + slice seams were removed from the scene
  but never disposed across rounds. reset()/dispose() now free them.
- panIngredient could go stale onto a steak day (nit): cleared in beginDay.
- FLOOD FEEDBACK on three different scales (nit): the puddle + '% bled out'
  HUD now track the same /1.1 the judge scores on — what you see is what
  he tastes.
Left #7 (pan 'well-seared' tell sits hotter than a MEDIUM steak wants):
over-searing a medium is correctly a Cook penalty; S stays reachable.

Verified: good rest+across -> S(9.4); cut early -> 5.3 (bled); rest score
count-independent; zero-cut -> rest 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 23:29:05 +10:00
parent f81d1b5c82
commit 1e5d069c99
3 changed files with 53 additions and 22 deletions

View File

@ -491,6 +491,7 @@ export class Game {
this.orderSeconds = 0; this.orderSeconds = 0;
this.prepResult = null; this.prepResult = null;
this.juiceResult = null; this.juiceResult = null;
this.panIngredient = null; // a fetched-mushroom day's ingredient never leaks forward
this.lastBruschetta = null; this.lastBruschetta = null;
this.prepQueue = o.prep ? [...o.prep] : []; this.prepQueue = o.prep ? [...o.prep] : [];
this.timing = true; this.timing = true;
@ -557,7 +558,7 @@ export class Game {
this.pan.root.visible = false; this.pan.root.visible = false;
// The two seared faces stand in for the interior doneness carried to the board. // The two seared faces stand in for the interior doneness carried to the board.
const cookDoneness = (panResult.downMean + panResult.upMean) / 2; const cookDoneness = (panResult.downMean + panResult.upMean) / 2;
this.steakBoard.reset(cookDoneness, st.target, st.grain); this.steakBoard.reset(cookDoneness, st.target, st.grain, panResult.sideGap);
this.hideAllScenes(); this.hideAllScenes();
this.steakBoard.root.visible = true; this.steakBoard.root.visible = true;
this.app.setView(this.steakBoard); this.app.setView(this.steakBoard);

View File

@ -32,6 +32,7 @@ export class SteakBoardView implements View {
private session: SteakSession | null = null; private session: SteakSession | null = null;
private steak!: THREE.Mesh; private steak!: THREE.Mesh;
private grainLines: THREE.Line[] = []; private grainLines: THREE.Line[] = [];
private grainMat: THREE.LineBasicMaterial | null = null;
private seams: THREE.Mesh[] = []; private seams: THREE.Mesh[] = [];
private puddle!: THREE.Mesh; private puddle!: THREE.Mesh;
private knife!: THREE.Mesh; private knife!: THREE.Mesh;
@ -114,10 +115,9 @@ export class SteakBoardView implements View {
/** A steak fresh off the pan: its interior doneness, the ordered target, and /** A steak fresh off the pan: its interior doneness, the ordered target, and
* a fibre direction to cut across. */ * 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 { reset(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.35, sideGap = 0, askText = 'let it rest — then cut across the grain'): void {
this.session = newSteakSession(cookDoneness, target, grainAxis); this.session = newSteakSession(cookDoneness, target, grainAxis, sideGap);
for (const l of this.grainLines) this.root.remove(l); this.disposeSlab();
for (const s of this.seams) this.root.remove(s);
this.grainLines = []; this.grainLines = [];
this.seams = []; this.seams = [];
this.strokeStart = null; this.strokeStart = null;
@ -127,6 +127,7 @@ export class SteakBoardView implements View {
// Draw the grain: pale parallel fibre lines at grainAxis across the top face. // Draw the grain: pale parallel fibre lines at grainAxis across the top face.
const y = BOARD_Y * 2 + 0.23; const y = BOARD_Y * 2 + 0.23;
const mat = new THREE.LineBasicMaterial({ color: 0x9a6a5a, transparent: true, opacity: 0.7 }); const mat = new THREE.LineBasicMaterial({ color: 0x9a6a5a, transparent: true, opacity: 0.7 });
this.grainMat = mat;
const dir = new THREE.Vector2(Math.cos(grainAxis), Math.sin(grainAxis)); const dir = new THREE.Vector2(Math.cos(grainAxis), Math.sin(grainAxis));
const perp = new THREE.Vector2(-dir.y, dir.x); const perp = new THREE.Vector2(-dir.y, dir.x);
for (let i = -6; i <= 6; i++) { for (let i = -6; i <= 6; i++) {
@ -181,15 +182,16 @@ export class SteakBoardView implements View {
} }
this.wasDown = inp.down; this.wasDown = inp.down;
// The puddle grows with the flood — capped for the eye. // The puddle tracks the SCORE penalty — saturate as the rest is destroyed
const flood = Math.min(1, s.flood / 6); // (same /1.1 reference the judge uses), so what you see is what he tastes.
const bled = 1 - steakResult(s).restScore;
const pm = this.puddle.material as THREE.MeshBasicMaterial; const pm = this.puddle.material as THREE.MeshBasicMaterial;
pm.opacity = Math.min(0.8, flood * 0.9); pm.opacity = Math.min(0.82, bled * 0.9);
this.puddle.scale.setScalar(0.4 + flood * 1.4); this.puddle.scale.setScalar(0.4 + bled * 1.5);
// Knife tilts to the current stroke's implied angle when steady.
this.updateHud(); this.updateHud();
if (inp.justPressed('Enter')) { // No serving an uncut steak — the nerve test has to actually be taken.
if (inp.justPressed('Enter') && s.cuts.length > 0) {
finishSteak(s); finishSteak(s);
const cb = this.onDone; const cb = this.onDone;
this.onDone = null; this.onDone = null;
@ -197,6 +199,22 @@ export class SteakBoardView implements View {
} }
} }
/** Free the round's grain lines and slice seams (removed on reset/teardown
* three.js only frees GPU buffers on an explicit dispose). */
private disposeSlab(): void {
for (const l of this.grainLines) {
this.root.remove(l);
l.geometry.dispose();
}
this.grainMat?.dispose();
this.grainMat = null;
for (const s of this.seams) {
this.root.remove(s);
s.geometry.dispose();
(s.material as THREE.Material).dispose();
}
}
private commitStroke(): void { private commitStroke(): void {
const s = this.session!; const s = this.session!;
const start = this.strokeStart!; const start = this.strokeStart!;
@ -232,22 +250,26 @@ export class SteakBoardView implements View {
private updateHud(): void { private updateHud(): void {
const s = this.session!; const s = this.session!;
const r = steakResult(s); 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` : ''}`; // "% bled out" is the REST penalty the judge will read — 100% when the rest
// score is destroyed — not a raw flood number on a different scale.
const bled = Math.round((1 - r.restScore) * 100);
this.stateEl.textContent = `${s.cuts.length} slice${s.cuts.length === 1 ? '' : 's'}${s.cuts.length && bled > 5 ? ` · ${bled}% bled out` : ''}`;
if (s.cuts.length === 0) { if (s.cuts.length === 0) {
this.wordEl.textContent = restWord(s); this.wordEl.textContent = restWord(s);
this.wordEl.style.color = s.moisture > 0.72 ? '#e2603a' : s.moisture < 0.66 ? '#8fce8f' : ''; this.wordEl.style.color = s.moisture > 0.72 ? '#e2603a' : s.moisture < 0.66 ? '#8fce8f' : '';
} else { } 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'; const word = r.restScore < 0.5 ? '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.textContent = word;
this.wordEl.style.color = r.cutScore > 0.7 && r.restScore > 0.7 ? '#8fce8f' : '#e2603a'; this.wordEl.style.color = r.cutScore > 0.7 && r.restScore > 0.7 ? '#8fce8f' : '#e2603a';
} }
} }
result() { 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 }; return this.session ? steakResult(this.session) : { cookScore: 0, restScore: 0, cutScore: 0, doneness: 0, target: 'medium' as const, sideGap: 0, flood: 0, meanAcross: 0, tears: 0 };
} }
dispose(): void { dispose(): void {
this.disposeSlab();
this.panel.dispose(); this.panel.dispose();
} }
} }

View File

@ -45,6 +45,8 @@ export interface SteakSession {
/** Interior doneness the sear left (fed from the pan). */ /** Interior doneness the sear left (fed from the pan). */
cookDoneness: number; cookDoneness: number;
target: Doneness; target: Doneness;
/** |downMean upMean| from the pan — one face burnt, one raw drags The Cook. */
sideGap: number;
/** Fibre direction, radians. Cutting is scored against it. */ /** Fibre direction, radians. Cutting is scored against it. */
grainAxis: number; grainAxis: number;
/** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */ /** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */
@ -56,10 +58,11 @@ export interface SteakSession {
phase: 'resting' | 'cutting' | 'done'; phase: 'resting' | 'cutting' | 'done';
} }
export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4): SteakSession { export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4, sideGap = 0): SteakSession {
return { return {
cookDoneness, cookDoneness,
target, target,
sideGap,
grainAxis, grainAxis,
moisture: HOT_MOISTURE, moisture: HOT_MOISTURE,
restedSeconds: 0, restedSeconds: 0,
@ -99,14 +102,15 @@ export function finishSteak(s: SteakSession): void {
} }
export interface SteakResult { export interface SteakResult {
/** How close the interior came to the doneness asked. */ /** How close the interior came to the doneness asked (and how even the sear). */
cookScore: number; cookScore: number;
/** The nerve: 1 if you rested it, dropping with the flood. */ /** The nerve: 1 if you rested it, dropping the juicier you cut it. */
restScore: number; restScore: number;
/** Across the grain and clean, or a torn rope. */ /** Across the grain and clean, or a torn rope. */
cutScore: number; cutScore: number;
doneness: number; doneness: number;
target: Doneness; target: Doneness;
sideGap: number;
flood: number; flood: number;
meanAcross: number; meanAcross: number;
tears: number; tears: number;
@ -116,14 +120,18 @@ const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
export function steakResult(s: SteakSession): SteakResult { export function steakResult(s: SteakSession): SteakResult {
const tgt = DONENESS_TARGET[s.target]; const tgt = DONENESS_TARGET[s.target];
const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22); // The cook is the interior doneness AND the evenness — one face charred, one
// The rest is the flood: a whisper of juice is a well-rested steak; a puddle // raw averages to the right number but isn't the right steak.
// is a steak you couldn't wait for. const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22 - s.sideGap * 0.6);
const restScore = clamp01(1 - s.flood / 1.1); // The rest reads the juice released PER CUT (i.e. the moisture at the moment you
// committed to cutting) — not the summed flood, so slicing more doesn't fake a
// worse rest, and an uncut steak earns no rest credit at all.
const perCut = s.cuts.length ? s.flood / s.cuts.length : 0;
const restScore = s.cuts.length ? clamp01(1 - perCut / 1.1) : 0;
const meanAcross = s.cuts.length ? s.cuts.reduce((a, c) => a + c.across, 0) / s.cuts.length : 0; 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 tears = s.cuts.filter((c) => c.torn).length;
const cutScore = clamp01(meanAcross - (s.cuts.length ? tears / s.cuts.length : 0) * 0.8); 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 }; return { cookScore, restScore, cutScore, doneness: s.cookDoneness, target: s.target, sideGap: s.sideGap, flood: s.flood, meanAcross, tears };
} }
/** The live tell for the resting steak — no timer, just the meat. */ /** The live tell for the resting steak — no timer, just the meat. */