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:
parent
f81d1b5c82
commit
1e5d069c99
@ -491,6 +491,7 @@ export class Game {
|
||||
this.orderSeconds = 0;
|
||||
this.prepResult = null;
|
||||
this.juiceResult = null;
|
||||
this.panIngredient = null; // a fetched-mushroom day's ingredient never leaks forward
|
||||
this.lastBruschetta = null;
|
||||
this.prepQueue = o.prep ? [...o.prep] : [];
|
||||
this.timing = true;
|
||||
@ -557,7 +558,7 @@ export class Game {
|
||||
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.steakBoard.reset(cookDoneness, st.target, st.grain, panResult.sideGap);
|
||||
this.hideAllScenes();
|
||||
this.steakBoard.root.visible = true;
|
||||
this.app.setView(this.steakBoard);
|
||||
|
||||
@ -32,6 +32,7 @@ export class SteakBoardView implements View {
|
||||
private session: SteakSession | null = null;
|
||||
private steak!: THREE.Mesh;
|
||||
private grainLines: THREE.Line[] = [];
|
||||
private grainMat: THREE.LineBasicMaterial | null = null;
|
||||
private seams: THREE.Mesh[] = [];
|
||||
private puddle!: 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 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);
|
||||
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, sideGap);
|
||||
this.disposeSlab();
|
||||
this.grainLines = [];
|
||||
this.seams = [];
|
||||
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.
|
||||
const y = BOARD_Y * 2 + 0.23;
|
||||
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 perp = new THREE.Vector2(-dir.y, dir.x);
|
||||
for (let i = -6; i <= 6; i++) {
|
||||
@ -181,15 +182,16 @@ export class SteakBoardView implements View {
|
||||
}
|
||||
this.wasDown = inp.down;
|
||||
|
||||
// The puddle grows with the flood — capped for the eye.
|
||||
const flood = Math.min(1, s.flood / 6);
|
||||
// The puddle tracks the SCORE penalty — saturate as the rest is destroyed
|
||||
// (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;
|
||||
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.
|
||||
pm.opacity = Math.min(0.82, bled * 0.9);
|
||||
this.puddle.scale.setScalar(0.4 + bled * 1.5);
|
||||
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);
|
||||
const cb = this.onDone;
|
||||
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 {
|
||||
const s = this.session!;
|
||||
const start = this.strokeStart!;
|
||||
@ -232,22 +250,26 @@ export class SteakBoardView implements View {
|
||||
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` : ''}`;
|
||||
// "% 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) {
|
||||
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';
|
||||
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.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 };
|
||||
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 {
|
||||
this.disposeSlab();
|
||||
this.panel.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,8 @@ export interface SteakSession {
|
||||
/** Interior doneness the sear left (fed from the pan). */
|
||||
cookDoneness: number;
|
||||
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. */
|
||||
grainAxis: number;
|
||||
/** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */
|
||||
@ -56,10 +58,11 @@ export interface SteakSession {
|
||||
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 {
|
||||
cookDoneness,
|
||||
target,
|
||||
sideGap,
|
||||
grainAxis,
|
||||
moisture: HOT_MOISTURE,
|
||||
restedSeconds: 0,
|
||||
@ -99,14 +102,15 @@ export function finishSteak(s: SteakSession): void {
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
/** Across the grain and clean, or a torn rope. */
|
||||
cutScore: number;
|
||||
doneness: number;
|
||||
target: Doneness;
|
||||
sideGap: number;
|
||||
flood: number;
|
||||
meanAcross: number;
|
||||
tears: number;
|
||||
@ -116,14 +120,18 @@ 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);
|
||||
// The cook is the interior doneness AND the evenness — one face charred, one
|
||||
// raw averages to the right number but isn't the right steak.
|
||||
const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22 - s.sideGap * 0.6);
|
||||
// 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 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 };
|
||||
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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user