THE CARVING BOARD: day 31 — the first job the off hand has ever had

P11's core, and the two-handed gesture John called for. A roast is not a
vegetable: it is heavy, round and greased in its own fat, so it will
slide out from under a blade given any excuse. The fork is not
decoration — planted it is a pin constraint and the knife cuts against
it; lifted, the joint travels with the stroke and what you get is not a
slice, it is a tear, and the torn face is there for the rest of the
carve.

So the station is a HOLD, deliberately: keep F down for the whole length
of a cut with one hand while the other drags the knife. Let go halfway
and you will see exactly where you let go. Where you START the drag along
the joint is the thickness, and the cut face TRAVELS as you carve — so
the way to get twelve identical slices is to move your start point with
the face, which is what carving actually is.

Judged on the ultimate pieces test: CV across every slice off the joint,
the thickness band, the plate count, and The Fork — which is a floor,
not a lottery. Hold it and that row is free.

Verified both hands: anchored throughout gives 12 slices at CV 0.027,
zero tears, 9.8 — 'every one the same. that is carving'. Letting the
fork go for exactly one stroke costs a tear and drops it to 8.4 — 'it
got away from you once, and the torn face shows'. Brûlée 7.5, curry
10.0, greens 10.0, eggs 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 13:26:18 +10:00
parent fa6cbb1276
commit 66e819a1fd
7 changed files with 577 additions and 8 deletions

View File

@ -678,6 +678,39 @@ export function installDevHarness(app: App, game: Game): void {
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows };
},
/** Play the day-31 carve with REAL input: fork DOWN for every stroke,
* each one started the same distance along the joint. `slip` lets go. */
async carveDay(slip = false) {
game.setDay(31);
await F(6);
const cv = game.carveView as unknown as { session: import('./sim/carve').CarveSession };
const s = cv.session;
if (!s) return { error: 'not at the board' };
const stroke = async (holdFork: boolean, startX: number) => {
if (holdFork) window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF' }));
await F(2);
point(startX, 0.88, 1.0, false); await F(2);
point(startX, 0.88, 1.0, true); await F(2);
point(startX, 0.88, -1.0, true); await F(2);
point(startX, 0.88, -1.0, false); await F(2);
if (holdFork) window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyF' }));
await F(2);
};
for (let i = 0; i < 12; i++) {
// The face travels as you carve, so the start point travels with it —
// a fixed offset is what makes every slice the same thickness.
const face = -1.5 + (1 - s.remaining) * 3;
// A slip run lets the fork go on the fourth stroke, once, on purpose.
await stroke(!(slip && i === 3), face + 0.2);
if (s.remaining <= 0.07) break;
}
tap('Enter');
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const r = (cv as unknown as { result: () => import('./sim/carve').CarveResult }).result();
return { count: r.count, cv: r.cv.toFixed(3), mean: r.meanThickness.toFixed(3), tears: r.tears, rows };
},
/** Play the day-29 brûlée with REAL input: hold the torch well back and
* sweep the whole top evenly until it is glass. Dwell nowhere. */
async bruleeDay(sloppy = false) {
@ -1680,6 +1713,7 @@ export function installDevHarness(app: App, game: Game): void {
27: () => harness.spiceDay(),
28: () => harness.mandolineDay(),
29: () => harness.bruleeDay(),
31: () => harness.carveDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -19,6 +19,7 @@ import { SpiceView } from '../scenes/spice';
import { MandolineView } from '../scenes/mandoline';
import { BruleeView } from '../scenes/brulee';
import { CureView } from '../scenes/cure';
import { CarveView } from '../scenes/carve';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -27,7 +28,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, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, judgeCurePack, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, judgeCurePack, judgeCarve, 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';
@ -111,6 +112,7 @@ export class Game {
private mandoline: MandolineView;
private brulee: BruleeView;
private cure: CureView;
private carve: CarveView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -171,6 +173,7 @@ export class Game {
this.mandoline = new MandolineView(app);
this.brulee = new BruleeView(app);
this.cure = new CureView(app);
this.carve = new CarveView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -193,6 +196,7 @@ export class Game {
app.scene.add(this.mandoline.root);
app.scene.add(this.brulee.root);
app.scene.add(this.cure.root);
app.scene.add(this.carve.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -214,6 +218,7 @@ export class Game {
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.cure.root.visible = false;
this.carve.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -396,6 +401,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get carveView(): CarveView {
return this.carve;
}
get cureView(): CureView {
return this.cure;
}
@ -600,6 +608,7 @@ export class Game {
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.cure.root.visible = false;
this.carve.root.visible = false;
this.assembly.root.visible = false;
}
@ -830,13 +839,14 @@ export class Game {
if (o.mandoline) return this.enterMandoline();
if (o.brulee) return this.enterBrulee();
if (o.cure) return this.enterCure();
if (o.carve) return this.enterCarve();
this.enterToastFlow();
}
/** The one-line shareable result for the current order's verdict. */
private shareLine(v: Verdict): string {
const o = this.order;
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.spice ? '🌶️' : o.mandoline ? '🔪' : o.brulee ? '🍮' : o.cure ? '🧂' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.spice ? '🌶️' : o.mandoline ? '🔪' : o.brulee ? '🍮' : o.cure ? '🧂' : o.carve ? '🍖' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
}
@ -958,6 +968,11 @@ export class Game {
this.enterCure();
return;
}
// The carving board: the fork is a hold, and the joint knows it.
if (this.order.carve) {
this.enterCarve();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -1166,6 +1181,34 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterCarve(): void {
const c = this.order.carve!;
this.carve.reset(`${c.slices} slices \u2014 even, and hold the fork`);
this.hideAllScenes();
this.carve.root.visible = true;
this.app.setView(this.carve);
this.ticket.show();
this.carve.onDone = (result) => {
this.carve.root.visible = false;
this.serveCarve(result);
};
}
private serveCarve(result: ReturnType<CarveView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeCarve(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterCure(): void {
this.cure.reset();
this.hideAllScenes();

View File

@ -20,6 +20,7 @@ import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim
import type { MandolineResult } from '../sim/mandoline';
import { type BruleeResult, crackWord } from '../sim/brulee';
import { type PackResult, packScore } from '../sim/cure';
import { type CarveResult, thicknessScore } from '../sim/carve';
export interface Criterion {
key: string;
@ -32,7 +33,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' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch' | 'cure';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch' | 'cure' | 'carve';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -624,6 +625,81 @@ export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdi
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The carve — the ultimate pieces test, and the only row in the game that is
// really about your OTHER hand.
export function judgeCarve(r: CarveResult, order: Order, seconds: number): Verdict {
const asked = order.carve?.slices ?? 10;
const criteria: Criterion[] = [
{
key: 'carveeven',
group: 'carve',
label: 'The Slices',
score: clamp01(1 - r.cv / 0.4),
weight: 1.3,
detail: r.count === 0
? 'there are no slices'
: r.cv > 0.35
? 'no two the same. that is a pile, not a plate'
: r.cv > 0.18
? 'close, but they do not match'
: 'every one the same. that is carving',
},
{
key: 'carvethick',
group: 'carve',
label: 'The Thickness',
score: clamp01(thicknessScore(r.meanThickness)),
weight: 1.0,
detail: r.meanThickness < 0.055
? 'shaved. this is not deli meat'
: r.meanThickness > 0.115
? 'doorstops'
: 'the thickness it was asked for',
},
{
key: 'carveanchor',
group: 'carve',
label: 'The Fork',
// The floor: hold the fork and this row is free. Every tear is a
// decision to carve against nothing.
score: r.tears === 0 ? 1 : Math.max(0, 0.4 - (r.tears - 1) * 0.15),
weight: 1.2,
detail: r.tears === 0
? 'anchored the whole way. nothing moved'
: r.tears === 1
? 'it got away from you once, and the torn face shows'
: `${r.tears} tears. put the fork IN it`,
},
{
key: 'carvecount',
group: 'bench',
label: 'The Plate',
score: clamp01(r.count / asked),
weight: 0.8,
detail: r.count >= asked ? `${r.count} of ${asked} \u2014 the table is fed` : `${r.count} of ${asked}. Someone is going without`,
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 90) / 120),
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);
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The cure, part one. Today he can only judge the PACK — the verdict on the
// cure itself is days away, and he is quite clear that he has not forgotten.

View File

@ -136,6 +136,8 @@ export interface Order {
brulee?: true;
/** THE CURE: pack it today, open it days from now. */
cure?: { kind: string };
/** THE CARVING BOARD: the fork is a hold, not a decoration. */
carve?: { slices: number };
}
export const BROWNING_NAMES: [number, string][] = [
@ -631,6 +633,21 @@ export const DAYS: Order[] = [
text: 'Gravlax. Pack the whole slab in salt and sugar \u2014 an EVEN blanket, and every bare patch is somewhere it will spoil instead of cure. Then it goes on the shelf and you leave it alone for three days. I will ask you for it. I will remember what day it was.',
cure: { kind: 'gravlax' },
},
{
day: 31,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the lunch rush',
text: 'Ten slices off the joint, all the same. And use the FORK \u2014 hold it down the whole way through the cut. A roast that moves under the knife does not give you a slice, it gives you a tear, and I will see the face where it happened.',
carve: { slices: 10 },
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -656,7 +673,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
if (roll < 0.945) return proceduralSteamer(day, rand);
if (roll < 0.955) return proceduralSpice(day, rand);
if (roll < 0.965) return proceduralMandoline(day, rand);
if (roll < 0.98) return proceduralBrulee(day, rand);
if (roll < 0.975) return proceduralBrulee(day, rand);
if (roll < 0.99) return proceduralCarve(day, rand);
if (roll < 0.945) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand);
@ -718,6 +736,7 @@ export function stationOf(o: Order): string | null {
if (o.mandoline) return 'mandoline';
if (o.brulee) return 'brulee';
if (o.cure) return 'cure';
if (o.carve) return 'carve';
return null;
}
@ -778,6 +797,17 @@ function proceduralBrulee(day: number, rand: () => number): Order {
return o;
}
function proceduralCarve(day: number, rand: () => number): Order {
const slices = 8 + Math.floor(rand() * 5);
const o = baseOrder(
day,
pickOf(rand, ['the lunch rush', 'the pavlova order', 'a regular', 'the man at table nine']),
`${slices} slices off the joint, even, and hold the fork through every one.`,
);
o.carve = { slices };
return o;
}
const pickOf = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)];
/** The toast fields every Order carries; station days never read most of them. */

263
src/scenes/carve.ts Normal file
View File

@ -0,0 +1,263 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type CarveSession,
newCarveSession,
setAnchor,
carve,
carveStep,
carveResult,
carveWord,
THIN_AT,
THICK_AT,
} from '../sim/carve';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
const BOARD_Y = 0.58;
/** The joint runs along X, from the face at -ROAST_HALF to the far end. */
const ROAST_HALF = 1.5;
/**
* THE CARVING BOARD the only station that wants both hands.
*
* HOLD F to plant the carving fork. While it is down the joint cannot move
* and the knife cuts against it; the moment you let go it slides, and a
* stroke against a sliding roast is a tear, not a slice. DRAG across the
* joint to carve where you START the drag along its length is how thick
* the slice is. ENTER sends the plate out.
*/
export class CarveView implements View {
readonly root = new THREE.Group();
private session: CarveSession | null = null;
private roast!: THREE.Mesh;
private fork!: THREE.Group;
private knife!: THREE.Mesh;
private sliceMeshes: THREE.Mesh[] = [];
private slideAnim = 0;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y + 0.3));
private dragStart: THREE.Vector3 | null = null;
private bgTex = loadBackdrop('/assets/img/bg_butcher.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof carveResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('carve-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, 'CARVE IT');
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(9, 0.4, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
// The board, with the groove that catches what the joint gives up.
const board = new THREE.Mesh(
new THREE.BoxGeometry(4.6, 0.22, 3.0),
new THREE.MeshStandardMaterial({ color: 0x8a6234, roughness: 0.75 }),
);
board.position.y = BOARD_Y - 0.11;
board.receiveShadow = true;
this.root.add(board);
const groove = new THREE.Mesh(
new THREE.TorusGeometry(1.9, 0.045, 8, 40),
new THREE.MeshStandardMaterial({ color: 0x6d4c26, roughness: 0.8 }),
);
groove.rotation.x = Math.PI / 2;
groove.scale.set(1, 0.62, 1);
groove.position.y = BOARD_Y;
this.root.add(groove);
// The joint. It shortens from the face end as you carve.
this.roast = new THREE.Mesh(
new THREE.CylinderGeometry(0.52, 0.56, ROAST_HALF * 2, 22),
new THREE.MeshStandardMaterial({ color: 0x9a5a3a, roughness: 0.72 }),
);
this.roast.rotation.z = Math.PI / 2;
this.roast.position.set(0, BOARD_Y + 0.5, 0);
this.roast.castShadow = true;
this.root.add(this.roast);
// The fork: two tines and a handle, planted or held clear.
this.fork = new THREE.Group();
for (let i = 0; i < 2; i++) {
const tine = new THREE.Mesh(
new THREE.CylinderGeometry(0.035, 0.02, 0.75, 8),
new THREE.MeshStandardMaterial({ color: 0xc8ccd0, roughness: 0.3, metalness: 0.85 }),
);
tine.position.set(0, -0.35, (i - 0.5) * 0.16);
this.fork.add(tine);
}
const handle = new THREE.Mesh(
new THREE.BoxGeometry(0.14, 0.6, 0.14),
new THREE.MeshStandardMaterial({ color: 0x2c2018, roughness: 0.6 }),
);
handle.position.y = 0.32;
this.fork.add(handle);
this.fork.position.set(0.75, BOARD_Y + 1.2, 0);
this.root.add(this.fork);
// The knife rides with the pointer.
this.knife = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.16, 1.7),
new THREE.MeshStandardMaterial({ color: 0xe8ecf0, roughness: 0.15, metalness: 0.9 }),
);
this.knife.position.set(0, BOARD_Y + 1.1, 0);
this.root.add(this.knife);
}
reset(askText = 'carve it — even slices, and hold the fork'): void {
this.session = newCarveSession();
this.dragStart = null;
this.slideAnim = 0;
for (const m of this.sliceMeshes) this.root.remove(m);
this.sliceMeshes = [];
this.wordEl.textContent = '';
this.wordEl.style.color = '';
this.askEl.textContent = askText;
this.hintEl.textContent = 'HOLD F — plant the fork · DRAG across the joint to carve (start further along = thicker) · ENTER serves';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
this.panel.hide();
this.app.scene.background = this.prevBg as never;
}
update(dt: number): void {
this.app.camera.position.set(0, 3.5, 3.4);
this.app.camera.lookAt(0, BOARD_Y + 0.3, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
// The off hand. This is a HOLD, not a toggle, because that is the whole
// point: it has to stay down for the length of the cut.
setAnchor(s, inp.held('KeyF'));
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 + 1.1, 0);
if (inp.down && on) {
if (!this.dragStart) this.dragStart = this.hit.clone();
else if (this.dragStart.z > 0.3 && this.hit.z < -0.3) {
this.doCarve(this.dragStart.x);
this.dragStart = null;
}
} else {
this.dragStart = null;
}
carveStep(s, dt);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter')) {
const cb = this.onDone;
this.onDone = null;
cb?.(carveResult(s));
}
}
/** Where you started the drag along the joint decides the thickness. */
private doCarve(startX: number): void {
const s = this.session!;
const faceX = this.faceX();
const want = Math.max(0.02, (startX - faceX) * 0.42);
const r = carve(s, want);
if (r.torn) {
this.slideAnim = 1;
audio.scrape(0.9, 2.2);
audio.clatter(0.5, 0.6);
eye.mood('disappointed', 2.5);
this.wordEl.textContent = 'IT SLID. that is a tear, not a slice.';
this.wordEl.style.color = '#e2603a';
return;
}
if (!r.sliced) return;
audio.scrape(0.45, 1.1);
// The slice lands in front of the board, stacking as it goes.
const n = this.sliceMeshes.length;
const sl = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.5, Math.max(0.02, r.thickness * 1.6), 20),
new THREE.MeshStandardMaterial({ color: 0xb06a48, roughness: 0.7 }),
);
sl.rotation.x = Math.PI / 2 - 0.25;
sl.position.set(-1.5 + (n % 6) * 0.42, BOARD_Y + 0.06 + Math.floor(n / 6) * 0.07, 1.35);
this.sliceMeshes.push(sl);
this.root.add(sl);
}
/** The cut face travels as the joint is carved down. */
private faceX(): number {
const s = this.session!;
return -ROAST_HALF + (1 - s.remaining) * ROAST_HALF * 2;
}
private syncVisuals(dt: number): void {
const s = this.session!;
this.slideAnim = Math.max(0, this.slideAnim - dt * 3);
const len = Math.max(0.05, s.remaining) * ROAST_HALF * 2;
this.roast.scale.set(1, len / (ROAST_HALF * 2), 1);
// The remaining joint sits from the face to the far end.
this.roast.position.x = this.faceX() + len / 2 + Math.sin(this.slideAnim * 26) * this.slideAnim * 0.14;
// The fork: planted IN the joint, or held up and useless.
const fx = Math.min(ROAST_HALF - 0.2, this.faceX() + len * 0.62);
this.fork.position.x += (fx - this.fork.position.x) * (1 - Math.exp(-dt / 0.1));
const fy = s.anchored ? BOARD_Y + 0.72 : BOARD_Y + 1.35;
this.fork.position.y += (fy - this.fork.position.y) * (1 - Math.exp(-dt / 0.07));
this.fork.rotation.z = s.anchored ? 0 : 0.35;
}
private updateHud(): void {
const s = this.session!;
const r = carveResult(s);
this.stateEl.textContent = `${r.count} slice${r.count === 1 ? '' : 's'} · fork ${s.anchored ? 'DOWN' : 'up'}${r.tears ? ` · ${r.tears} torn` : ''} · ${Math.round(r.waste * 100)}% of the joint left`;
if (!this.wordEl.textContent?.includes('SLID')) {
this.wordEl.textContent = carveWord(s);
this.wordEl.style.color = '';
}
void THIN_AT;
void THICK_AT;
}
result(): ReturnType<typeof carveResult> {
return carveResult(this.session!);
}
}

View File

@ -195,7 +195,7 @@ export class JudgeView implements View {
// Grill and bruschetta days don't have a browning/spread to name — show who
// asked and what for, not the unread toast defaults.
// The dish itself, in miniature, beside the ask.
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : order.cure ? 'cure' : null;
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : order.cure ? 'cure' : order.carve ? 'roast' : null;
this.orderEl.parentElement?.querySelector('.judge-hero')?.remove();
if (heroKey) {
const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement;
@ -203,7 +203,9 @@ export class JudgeView implements View {
hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`);
this.orderEl.parentElement?.insertBefore(hero, this.orderEl);
}
this.orderEl.textContent = order.cure
this.orderEl.textContent = order.carve
? `Day ${order.day} · ${order.who} asked for the joint carved`
: order.cure
? `Day ${order.day} · ${order.who} started the cure`
: order.brulee
? `Day ${order.day} · ${order.who} asked for the br\u00fbl\u00e9e`
@ -243,7 +245,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.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch' || c.group === 'cure',
(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' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch' || c.group === 'cure' || c.group === 'carve',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -264,9 +266,10 @@ export class JudgeView implements View {
blade: 'THE BLADE',
torch: 'THE TORCH',
cure: 'THE PACK',
carve: 'THE CARVE',
bench: 'THE BENCH',
};
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, cure: 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, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, cure: 3, carve: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

120
src/sim/carve.ts Normal file
View File

@ -0,0 +1,120 @@
import { Rng } from '../core/rng';
/**
* CARVING the first job the off hand has ever had.
*
* A roast is not a vegetable: it is heavy, it is round, and it is greased in
* its own fat, so it will slide out from under a blade given the smallest
* excuse. The fork is not decoration. Planted, it is a pin constraint and the
* knife cuts against it; lifted, the joint travels with the stroke and what
* you get is not a slice, it is a tear.
*
* So the whole station is a hold: one hand keeps the fork down for the entire
* length of a cut while the other does the carving. Let go halfway and you
* will see exactly where you let go.
*/
/** The band a carved slice wants to be in. */
export const THIN_AT = 0.055;
export const THICK_AT = 0.115;
/** How much roast is left when there is nothing worth carving. */
const SPENT_AT = 0.06;
export interface CarveSession {
/** 1 = a whole joint, 0 = bones. */
remaining: number;
/** Every slice that made it to the plate, by thickness. */
slices: number[];
/** Slices ruined by carving against nothing. */
tears: number;
/** True while the fork is down. */
anchored: boolean;
seconds: number;
rng: Rng;
}
export function newCarveSession(seed = 20260728): CarveSession {
return { remaining: 1, slices: [], tears: 0, anchored: false, seconds: 0, rng: new Rng(seed) };
}
export function setAnchor(s: CarveSession, on: boolean): void {
s.anchored = on;
}
export interface CarveStroke {
sliced: boolean;
torn: boolean;
thickness: number;
}
/**
* One stroke of the knife. `want` is the thickness the stroke asked for
* how far down the joint you started the cut. Unanchored, the joint slides:
* the blade skates, and what comes off is a ragged fraction of the ask.
*/
export function carve(s: CarveSession, want: number): CarveStroke {
if (s.remaining <= SPENT_AT) return { sliced: false, torn: false, thickness: 0 };
const asked = Math.max(0.02, Math.min(0.2, want));
if (!s.anchored) {
// The joint travels with the stroke. Some meat comes off; none of it is
// a slice, and the judge can see the torn face for the rest of the carve.
s.tears++;
s.remaining = Math.max(0, s.remaining - asked * 0.55);
s.seconds += 1.2;
return { sliced: false, torn: true, thickness: asked * 0.55 };
}
const took = Math.min(asked, s.remaining);
s.remaining -= took;
s.slices.push(took);
s.seconds += 0.8;
return { sliced: true, torn: false, thickness: took };
}
export function carveStep(s: CarveSession, dt: number): void {
s.seconds += dt;
}
export interface CarveResult {
count: number;
meanThickness: number;
/** Coefficient of variation across the slices — the ultimate pieces test. */
cv: number;
tears: number;
/** Joint left uncarved. */
waste: number;
seconds: number;
}
export function carveResult(s: CarveSession): CarveResult {
const n = s.slices.length;
const mean = n ? s.slices.reduce((a, b) => a + b, 0) / n : 0;
const sd = n > 1 ? Math.sqrt(s.slices.reduce((a, b) => a + (b - mean) ** 2, 0) / n) : 0;
return {
count: n,
meanThickness: mean,
cv: mean > 0 ? sd / mean : 0,
tears: s.tears,
waste: s.remaining,
seconds: s.seconds,
};
}
/** 1 inside the thickness band, falling off outside it. */
export function thicknessScore(mean: number): number {
if (mean < THIN_AT) return Math.max(0, 1 - (THIN_AT - mean) / 0.045);
if (mean > THICK_AT) return Math.max(0, 1 - (mean - THICK_AT) / 0.07);
return 1;
}
/** The joint, out loud. It tells you about the fork, because that is the game. */
export function carveWord(s: CarveSession): string {
if (s.remaining <= SPENT_AT) return 'carved down to the bone';
if (!s.anchored) return s.tears > 0 ? 'it SLID. hold the fork and cut against it' : 'nothing is holding that joint down';
const n = s.slices.length;
if (n === 0) return 'forked and steady. carve';
const last = s.slices[n - 1];
if (last < THIN_AT) return `${n} on the plate — that one was shaved, not sliced`;
if (last > THICK_AT) return `${n} on the plate — that one is a doorstop`;
return `${n} on the plate, and even`;
}