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>
491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
import { BRANDS, type BreadId } from '../sim/bread';
|
|
import type { AmountClass, SpreadId } from '../sim/spreads';
|
|
import type { ToolId } from '../sim/cutlery';
|
|
import { CUT_BANDS, type CutClass } from '../sim/slicing';
|
|
import type { CutPattern } from '../sim/cutting';
|
|
import type { IngredientId } from '../sim/ingredients';
|
|
import type { JuicerId } from '../sim/juicing';
|
|
import { FRIDGE_MUSHROOM_ID } from '../sim/coldchain';
|
|
|
|
/**
|
|
* A stop at the prep bench before the toaster. M11 shipped the 'cut' kind;
|
|
* M12 adds 'juice' (the ribbed reamer). Zesting, grating and roasting arrive
|
|
* with their stations and extend this union.
|
|
*/
|
|
export type PrepStep =
|
|
| {
|
|
kind: 'cut';
|
|
ingredient: IngredientId;
|
|
pattern: CutPattern;
|
|
/** The knife the bench hands you. Prep knives don't come from the drawer
|
|
* (that trip is for the spread tool); the order supplies them. */
|
|
knife?: ToolId;
|
|
}
|
|
| {
|
|
kind: 'juice';
|
|
ingredient: IngredientId;
|
|
/** Which reamer sits on the bench for this order (progression, like bread
|
|
* brands). Day 12 hands you the Pyramid Classic — no seed moat. */
|
|
juicer: JuicerId;
|
|
/** The cut the halving left, capping the reachable juice. Absent = a clean
|
|
* cook's halve (dead-centre), so the whole test is the twist itself. */
|
|
halve?: { offset: number; wedge: number };
|
|
/** Seeds in this orange (fixed so the pip count is a skill test, not a
|
|
* dice roll). Defaults to the orange's own count via the station. */
|
|
seeds?: number;
|
|
};
|
|
|
|
/**
|
|
* The bruschetta capstone (day 14). Its presence on an order flips the day flow
|
|
* from cut→toast→spread to cut→toast→roast→assemble→serve, and arms the
|
|
* temperature clock for the perishables it names.
|
|
*/
|
|
export interface BruschettaSpec {
|
|
/** Tomato halves on the oven tray. */
|
|
roastHalves: number;
|
|
/** Perishables the temp clock tracks: brie wants out early (soft), parmesan
|
|
* wants to stay in (cold). */
|
|
perishables: IngredientId[];
|
|
/** The oven's fuel character (heatsource SKU): 'oven_gas' roasts hot-at-centre
|
|
* and uneven, 'oven_fan' laboratory-flat. Omit for the legacy dial-is-heat oven. */
|
|
oven?: string;
|
|
}
|
|
|
|
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
|
|
export function prepAsk(step: PrepStep): string {
|
|
const name = step.ingredient.replace(/_/g, ' ');
|
|
if (step.kind === 'juice') return `juice the ${name}`;
|
|
const p = step.pattern;
|
|
switch (p.kind) {
|
|
case 'halve':
|
|
return `halve the ${name}`;
|
|
case 'slices':
|
|
return `${p.n} slices of ${name}`;
|
|
case 'wedges':
|
|
return `${p.n} wedges of ${name}`;
|
|
case 'dice':
|
|
return `dice the ${name}`;
|
|
}
|
|
}
|
|
|
|
export interface Order {
|
|
day: number;
|
|
bread: BreadId;
|
|
spread: SpreadId;
|
|
amount: AmountClass;
|
|
/** Target mean browning, 0..1. */
|
|
browning: number;
|
|
browningName: string;
|
|
/** The customer specifically does not want char. Some don't care. */
|
|
noChar: boolean;
|
|
/** What the drawer will make you find. */
|
|
tool: ToolId;
|
|
/** Which brand of the bread — quality is mechanical, not cosmetic. */
|
|
brand: string;
|
|
/** Comes as a whole loaf: find a bread knife, slice it yourself. */
|
|
fromLoaf?: boolean;
|
|
/** How thick they want the slice, when it's a loaf. */
|
|
cutClass?: CutClass;
|
|
cutBand?: [number, number];
|
|
/** 0 = fridge-hard, 1 = soft. The single cruellest dial in the game. */
|
|
butterSoftness: number;
|
|
/** How the customer says it. */
|
|
text: string;
|
|
/** Who's asking. */
|
|
who: string;
|
|
/** Work at the prep bench, when the order has any. */
|
|
prep?: PrepStep[];
|
|
/** The bruschetta capstone: roast + assemble replace the spread flow. */
|
|
bruschetta?: BruschettaSpec;
|
|
/** The charcoal grill (M-H6): the food to cook over the coals. Presence sends
|
|
* the whole day to the outdoor grill instead of the toaster. */
|
|
grill?: string[];
|
|
/** The pan (M17): the food to sear, and the fuel the hob runs on. Presence
|
|
* sends the day to the pan — butter it, foam it, flip it, don't burn it.
|
|
* `fromFridge` pulls the food out of your stored stock — how you stocked it
|
|
* days ago decides how fresh it is now. */
|
|
pan?: { food: string; fuel: string; fromFridge?: boolean };
|
|
/** 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][] = [
|
|
[0.18, 'barely warmed through'],
|
|
[0.34, 'light'],
|
|
[0.5, 'golden'],
|
|
[0.66, 'well done'],
|
|
[0.8, 'dark'],
|
|
];
|
|
|
|
export function nameBrowning(v: number): string {
|
|
let best = BROWNING_NAMES[0];
|
|
for (const b of BROWNING_NAMES) if (Math.abs(b[0] - v) < Math.abs(best[0] - v)) best = b;
|
|
return best[1];
|
|
}
|
|
|
|
/**
|
|
* The handwritten first week. The curve is deliberate: day 1 is butter on white
|
|
* with a butter knife and butter that's been out all morning — every dial set to
|
|
* "forgiving". By day 7 you're doing a translucent film of MITEY on thick wet
|
|
* sourdough with fridge-hard butter... and a steak knife, because the drawer is
|
|
* the drawer.
|
|
*/
|
|
export const DAYS: Order[] = [
|
|
{
|
|
day: 1,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.5,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.85,
|
|
who: 'Deidre',
|
|
text: 'Just golden, love. Butter. Normal amount of butter.',
|
|
},
|
|
{
|
|
day: 2,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'thin',
|
|
browning: 0.66,
|
|
browningName: 'well done',
|
|
noChar: true,
|
|
tool: 'dinner_knife',
|
|
butterSoftness: 0.55,
|
|
who: 'Ray',
|
|
text: "Well done. Scrape of butter — I said a scrape, not a shovel.",
|
|
},
|
|
{
|
|
day: 3,
|
|
brand: 'Seedy Business',
|
|
bread: 'multigrain',
|
|
spread: 'peanut',
|
|
amount: 'thick',
|
|
browning: 0.42,
|
|
browningName: 'light',
|
|
noChar: true,
|
|
tool: 'dessert_spoon',
|
|
butterSoftness: 0.5,
|
|
who: 'a child',
|
|
text: 'PEANUT BUTTER. LOTS. Bread barely toasted please.',
|
|
},
|
|
{
|
|
day: 4,
|
|
brand: 'Sun Chariot',
|
|
bread: 'raisin',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.44,
|
|
browningName: 'light',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.3,
|
|
who: 'Deidre',
|
|
text: "Raisin loaf. Careful — it catches. I'll know if it catches.",
|
|
},
|
|
{
|
|
day: 5,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'mitey',
|
|
amount: 'normal',
|
|
browning: 0.62,
|
|
browningName: 'well done',
|
|
noChar: true,
|
|
tool: 'teaspoon',
|
|
butterSoftness: 0.4,
|
|
who: 'Ray',
|
|
text: 'MITEY. Proper amount. You know what proper means.',
|
|
},
|
|
{
|
|
day: 6,
|
|
brand: 'Longlife Sour-ish',
|
|
bread: 'sourdough',
|
|
spread: 'crunchy',
|
|
amount: 'normal',
|
|
browning: 0.72,
|
|
browningName: 'dark',
|
|
noChar: false,
|
|
tool: 'steak_knife',
|
|
butterSoftness: 0.25,
|
|
who: 'a man in a hurry',
|
|
text: "Dark. Sourdough. Crunchy peanut butter — the PROPER kind. Don't care if it catches a bit.",
|
|
},
|
|
{
|
|
day: 7,
|
|
brand: 'Longlife Sour-ish',
|
|
bread: 'sourdough',
|
|
spread: 'mitey',
|
|
amount: 'thin',
|
|
browning: 0.68,
|
|
browningName: 'well done',
|
|
noChar: true,
|
|
tool: 'steak_knife',
|
|
butterSoftness: 0.0,
|
|
who: 'the inspector himself',
|
|
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
|
|
},
|
|
{
|
|
day: 8,
|
|
brand: 'Crustworthy',
|
|
bread: 'white',
|
|
spread: 'marmalade',
|
|
amount: 'normal',
|
|
browning: 0.55,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'spreader',
|
|
butterSoftness: 0.5,
|
|
who: 'Deidre',
|
|
text: 'Marmalade! And mind the rind, love — a piece in every bite, not a pile in one corner.',
|
|
},
|
|
{
|
|
day: 9,
|
|
brand: 'the bakery loaf',
|
|
bread: 'sourdough',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.58,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.45,
|
|
fromLoaf: true,
|
|
cutClass: 'doorstop',
|
|
cutBand: CUT_BANDS.doorstop,
|
|
who: 'the inspector himself',
|
|
text: 'The bakery loaf. Slice it yourself — a DOORSTOP, and I want the faces parallel. Then golden, butter, normal.',
|
|
},
|
|
{
|
|
day: 10,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.5,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.6,
|
|
who: 'Deidre',
|
|
text: 'Dice me an onion first — small and square, mind the stem — then golden white, butter, normal.',
|
|
// The onion is the dice's showcase: layered flesh means a bad cut explodes
|
|
// the piece CV. The dinner knife bites clean if you saw instead of crush.
|
|
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4 }, knife: 'dinner_knife' }],
|
|
},
|
|
{
|
|
day: 11,
|
|
brand: 'Crustworthy',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'thin',
|
|
browning: 0.55,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.55,
|
|
who: 'a regular',
|
|
text: 'Six wedges of tomato — even ones — and a scrape of butter on golden white.',
|
|
// Tomato = the wet, slippery showcase for wedges: press without sawing and
|
|
// it skates and hoses the board; saw and it gives clean radial cuts.
|
|
prep: [{ kind: 'cut', ingredient: 'tomato', pattern: { kind: 'wedges', n: 6 }, knife: 'dinner_knife' }],
|
|
},
|
|
{
|
|
day: 12,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.5,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.55,
|
|
who: 'Deidre',
|
|
text: 'Fresh orange juice with the toast, love — squeeze it properly, mind the pips. Then golden white, butter, normal.',
|
|
// The juice detour, first outing. The Pyramid Classic has no seed moat, so
|
|
// every pip it frees goes to the glass — the twist is the whole test. Five
|
|
// seeds fixed so Pips is a skill row, not a lottery. The halve is left clean
|
|
// (dead-centre) — the halve→juice chain is M12's own later order.
|
|
prep: [{ kind: 'juice', ingredient: 'orange', juicer: 'pyramid_classic', seeds: 5 }],
|
|
},
|
|
{
|
|
day: 13,
|
|
brand: 'WONDERSLICE',
|
|
bread: 'white',
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.5,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.5,
|
|
who: 'the inspector himself',
|
|
text: 'Dice this onion PROPERLY — stop the cut short of the root or it falls to confetti. Sharp and quick, mind your eyes. Then golden white, butter.',
|
|
// The technique test. root: true arms the stop-line — the first pass of cuts
|
|
// must be RELEASED short of the root; saw through and the layers scatter and
|
|
// the Dice row collapses. The Best Thing is the sharpest edge, so squash (and
|
|
// therefore sting) stays low and the whole story is restraint at the root.
|
|
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }],
|
|
},
|
|
{
|
|
day: 14,
|
|
brand: 'the bakery loaf',
|
|
bread: 'sourdough',
|
|
// The spread flow is replaced by roast + assemble on this order — the garlic
|
|
// rub stands in for the spread — but the Order shape still wants these, and
|
|
// they stay harmless defaults the bruschetta path never reads.
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.55,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.5,
|
|
fromLoaf: true,
|
|
cutClass: 'doorstop',
|
|
cutBand: CUT_BANDS.doorstop,
|
|
who: 'the inspector himself',
|
|
text: 'Bruschetta. Cut the sourdough thick and toast it, then rub it with garlic. Roast the tomatoes till they BLISTER — not sauce. Brie soft, parmesan cold. Top it even and balanced, and serve it before it bends.',
|
|
// The capstone: cut the loaf → toast → roast the tomatoes → assemble (rub,
|
|
// top, watch the sog) → serve. The temp clock tracks the two cheeses from
|
|
// order start; take the brie out early, leave the parmesan in.
|
|
bruschetta: { roastHalves: 4, perishables: ['brie', 'parmesan'], oven: 'oven_gas' },
|
|
},
|
|
{
|
|
day: 15,
|
|
brand: 'the bakery loaf',
|
|
bread: 'white',
|
|
// The grill day routes entirely to the outdoor coals — the toast defaults are
|
|
// never read, same as the bruschetta order.
|
|
spread: 'butter',
|
|
amount: 'normal',
|
|
browning: 0.55,
|
|
browningName: 'golden',
|
|
noChar: true,
|
|
tool: 'butter_knife',
|
|
butterSoftness: 0.5,
|
|
who: 'the bloke from two doors down',
|
|
text: "Chuck these on the barbie, love. Bank the coals hot one side, cool the other — sear 'em then move 'em off before they flare. A proper char, not a cremation.",
|
|
grill: ['steak', 'snag', 'corn'],
|
|
},
|
|
{
|
|
day: 16,
|
|
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 chef, on her day off',
|
|
text: 'Pan-sear the mushrooms on the gas. Butter in first — wait for the FOAM, not the smoke. Land them in the foam, brown both faces, baste them in the noisette. Burn the butter and I will know.',
|
|
pan: { food: 'button_mushroom', fuel: 'gas' },
|
|
},
|
|
{
|
|
day: 17,
|
|
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 delivery, at 6am',
|
|
text: 'The order came in. Put it away before the inspector does his rounds — raw meat down low where it can\'t drip, the perishables in the cold at the back, and don\'t cram one shelf or nothing breathes. Three days till he looks.',
|
|
fridge: true,
|
|
},
|
|
{
|
|
day: 18,
|
|
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 lunch rush',
|
|
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. */
|
|
export function proceduralOrder(day: number, rand: () => number): Order {
|
|
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
|
|
const spreads: SpreadId[] = ['butter', 'peanut', 'crunchy', 'mitey', 'marmalade'];
|
|
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
|
|
const tools: ToolId[] = [
|
|
'butter_knife',
|
|
'dinner_knife',
|
|
'steak_knife',
|
|
'spreader',
|
|
'dinner_fork',
|
|
'dessert_fork',
|
|
'teaspoon',
|
|
'dessert_spoon',
|
|
'soup_spoon',
|
|
];
|
|
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
|
const spread = pick(spreads);
|
|
const bread = pick(breads);
|
|
// Brand: the good stuff shows up more as the days go on — and bakery
|
|
// sourdough is loaf-only, which means the bread knife, which means the drawer.
|
|
const pool = BRANDS[bread];
|
|
const wantGood = rand() < Math.min(0.85, 0.25 + (day - 9) * 0.06);
|
|
const sorted = [...pool].sort((a, b) => a.quality - b.quality);
|
|
const brand = wantGood ? sorted[sorted.length - 1] : sorted[0];
|
|
const cuts: CutClass[] = ['thin cut', 'regular cut', 'doorstop'];
|
|
const cutClass = pick(cuts);
|
|
const amount: AmountClass =
|
|
spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
|
|
const browning = 0.3 + rand() * 0.5;
|
|
return {
|
|
day,
|
|
bread,
|
|
brand: brand.name,
|
|
fromLoaf: !!brand.loaf,
|
|
cutClass: brand.loaf ? cutClass : undefined,
|
|
cutBand: brand.loaf ? CUT_BANDS[cutClass] : undefined,
|
|
spread,
|
|
amount,
|
|
browning,
|
|
browningName: nameBrowning(browning),
|
|
noChar: rand() < 0.8,
|
|
tool: pick(tools),
|
|
// It only gets colder from here.
|
|
butterSoftness: Math.max(0, 0.4 - (day - 9) * 0.08) * rand(),
|
|
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
|
|
text: '',
|
|
};
|
|
}
|