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>
This commit is contained in:
parent
848f4b3fcb
commit
f81d1b5c82
31
src/dev.ts
31
src/dev.ts
@ -603,6 +603,37 @@ export function installDevHarness(app: App, game: Game): void {
|
|||||||
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) };
|
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;
|
/** The marquee, side by side: cut it straight off the heat vs after a rest;
|
||||||
* and across the grain vs along it. */
|
* and across the grain vs along it. */
|
||||||
steakMarquee() {
|
steakMarquee() {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { OvenView } from '../scenes/oven';
|
|||||||
import { GrillView } from '../scenes/grill';
|
import { GrillView } from '../scenes/grill';
|
||||||
import { PanView } from '../scenes/pan';
|
import { PanView } from '../scenes/pan';
|
||||||
import { FridgeView } from '../scenes/fridge';
|
import { FridgeView } from '../scenes/fridge';
|
||||||
|
import { SteakBoardView } from '../scenes/steakboard';
|
||||||
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
|
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
|
||||||
import { AssemblyView } from '../scenes/assembly';
|
import { AssemblyView } from '../scenes/assembly';
|
||||||
import type { CutPattern } from '../sim/cutting';
|
import type { CutPattern } from '../sim/cutting';
|
||||||
@ -16,7 +17,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
|
|||||||
import { TempClock } from '../sim/tempclock';
|
import { TempClock } from '../sim/tempclock';
|
||||||
import type { RoastResult } from '../sim/roasting';
|
import type { RoastResult } from '../sim/roasting';
|
||||||
import type { AssemblyResult } from '../sim/assembly';
|
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 { JudgeView } from '../scenes/judge';
|
||||||
import { DrawerView } from '../scenes/drawer';
|
import { DrawerView } from '../scenes/drawer';
|
||||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||||
@ -57,6 +58,7 @@ export class Game {
|
|||||||
private grill: GrillView;
|
private grill: GrillView;
|
||||||
private pan: PanView;
|
private pan: PanView;
|
||||||
private fridge: FridgeView;
|
private fridge: FridgeView;
|
||||||
|
private steakBoard: SteakBoardView;
|
||||||
private assembly: AssemblyView;
|
private assembly: AssemblyView;
|
||||||
/** What the prep bench reported for the current order, if it ran. */
|
/** What the prep bench reported for the current order, if it ran. */
|
||||||
private prepResult: PrepResult | null = null;
|
private prepResult: PrepResult | null = null;
|
||||||
@ -94,6 +96,7 @@ export class Game {
|
|||||||
this.grill = new GrillView(app);
|
this.grill = new GrillView(app);
|
||||||
this.pan = new PanView(app);
|
this.pan = new PanView(app);
|
||||||
this.fridge = new FridgeView(app);
|
this.fridge = new FridgeView(app);
|
||||||
|
this.steakBoard = new SteakBoardView(app);
|
||||||
this.assembly = new AssemblyView(app);
|
this.assembly = new AssemblyView(app);
|
||||||
app.scene.add(this.kitchen.root);
|
app.scene.add(this.kitchen.root);
|
||||||
app.scene.add(this.judgeView.root);
|
app.scene.add(this.judgeView.root);
|
||||||
@ -105,6 +108,7 @@ export class Game {
|
|||||||
app.scene.add(this.grill.root);
|
app.scene.add(this.grill.root);
|
||||||
app.scene.add(this.pan.root);
|
app.scene.add(this.pan.root);
|
||||||
app.scene.add(this.fridge.root);
|
app.scene.add(this.fridge.root);
|
||||||
|
app.scene.add(this.steakBoard.root);
|
||||||
app.scene.add(this.assembly.root);
|
app.scene.add(this.assembly.root);
|
||||||
this.judgeView.root.visible = false;
|
this.judgeView.root.visible = false;
|
||||||
this.drawer.root.visible = false;
|
this.drawer.root.visible = false;
|
||||||
@ -115,6 +119,7 @@ export class Game {
|
|||||||
this.grill.root.visible = false;
|
this.grill.root.visible = false;
|
||||||
this.pan.root.visible = false;
|
this.pan.root.visible = false;
|
||||||
this.fridge.root.visible = false;
|
this.fridge.root.visible = false;
|
||||||
|
this.steakBoard.root.visible = false;
|
||||||
this.assembly.root.visible = false;
|
this.assembly.root.visible = false;
|
||||||
|
|
||||||
this.ticket = new Panel('ticket');
|
this.ticket = new Panel('ticket');
|
||||||
@ -220,6 +225,9 @@ export class Game {
|
|||||||
get fridgeView(): FridgeView {
|
get fridgeView(): FridgeView {
|
||||||
return this.fridge;
|
return this.fridge;
|
||||||
}
|
}
|
||||||
|
get steakBoardView(): SteakBoardView {
|
||||||
|
return this.steakBoard;
|
||||||
|
}
|
||||||
get assemblyView(): AssemblyView {
|
get assemblyView(): AssemblyView {
|
||||||
return this.assembly;
|
return this.assembly;
|
||||||
}
|
}
|
||||||
@ -389,6 +397,7 @@ export class Game {
|
|||||||
this.grill.root.visible = false;
|
this.grill.root.visible = false;
|
||||||
this.pan.root.visible = false;
|
this.pan.root.visible = false;
|
||||||
this.fridge.root.visible = false;
|
this.fridge.root.visible = false;
|
||||||
|
this.steakBoard.root.visible = false;
|
||||||
this.assembly.root.visible = false;
|
this.assembly.root.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,9 +536,56 @@ export class Game {
|
|||||||
this.enterFridge();
|
this.enterFridge();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A steak day: sear on the pan, then rest + cut on the board.
|
||||||
|
if (this.order.steak) {
|
||||||
|
this.enterSteak();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.enterToastFlow();
|
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. */
|
/** The cold-chain day: stock the fridge right, age it, let the inspector look. */
|
||||||
private enterFridge(): void {
|
private enterFridge(): void {
|
||||||
this.fridge.reset();
|
this.fridge.reset();
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { GrillResult } from '../sim/charcoal';
|
|||||||
import { grillWord } from '../sim/charcoal';
|
import { grillWord } from '../sim/charcoal';
|
||||||
import type { PanResult } from '../sim/pan';
|
import type { PanResult } from '../sim/pan';
|
||||||
import type { storeHealth } from '../sim/coldchain';
|
import type { storeHealth } from '../sim/coldchain';
|
||||||
|
import type { SteakResult } from '../sim/steak';
|
||||||
|
|
||||||
export interface Criterion {
|
export interface Criterion {
|
||||||
key: string;
|
key: string;
|
||||||
@ -23,7 +24,7 @@ export interface Criterion {
|
|||||||
/** Which station earned it — the scorecard groups by this on prep orders.
|
/** Which station earned it — the scorecard groups by this on prep orders.
|
||||||
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
|
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
|
||||||
* distribution, balance, sog), bench (temperature + mess). */
|
* 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. */
|
/** 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: [] };
|
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
|
// 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.
|
// survived (placement/cold), did anything drip (hygiene), and what did you waste.
|
||||||
|
|||||||
@ -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.
|
// The fetched ingredient — the cook who reads a use-by date.
|
||||||
Object.assign(BANK, {
|
Object.assign(BANK, {
|
||||||
ingredient: {
|
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) {
|
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]));
|
out.push(pick(GRADE_CLOSERS[v.grade]));
|
||||||
|
|||||||
@ -108,6 +108,9 @@ export interface Order {
|
|||||||
/** The cold chain: a delivery to put away. Presence sends the day to the
|
/** 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 — raw low, perishables cold, don't cram it, then three days pass. */
|
||||||
fridge?: boolean;
|
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][] = [
|
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.',
|
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 },
|
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. */
|
/** Days beyond the script — keep going, with everything cranked. */
|
||||||
|
|||||||
@ -107,7 +107,9 @@ export class JudgeView implements View {
|
|||||||
// asked and what for, not the unread toast defaults.
|
// asked and what for, not the unread toast defaults.
|
||||||
this.orderEl.textContent = order.grill
|
this.orderEl.textContent = order.grill
|
||||||
? `Day ${order.day} · ${order.who} asked for the barbie`
|
? `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`
|
? `Day ${order.day} · ${order.who} asked for the pan-sear`
|
||||||
: order.fridge
|
: order.fridge
|
||||||
? `Day ${order.day} · the fridge, three days on`
|
? `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;
|
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
|
||||||
// a plain toast order keeps the flat card it always had.
|
// a plain toast order keeps the flat card it always had.
|
||||||
const grouped = verdict.criteria.some(
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
prep: 'THE PREP',
|
prep: 'THE PREP',
|
||||||
@ -132,9 +134,10 @@ export class JudgeView implements View {
|
|||||||
grill: 'THE GRILL',
|
grill: 'THE GRILL',
|
||||||
pan: 'THE PAN',
|
pan: 'THE PAN',
|
||||||
cold: 'THE FRIDGE',
|
cold: 'THE FRIDGE',
|
||||||
|
steak: 'THE STEAK',
|
||||||
bench: 'THE BENCH',
|
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;
|
let lastGroup: string | undefined;
|
||||||
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
|
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
|
||||||
const ordered = grouped
|
const ordered = grouped
|
||||||
|
|||||||
253
src/scenes/steakboard.ts
Normal file
253
src/scenes/steakboard.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user