toastsim/src/game/orders.ts
type-two f81d1b5c82 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>
2026-07-19 23:05:45 +10:00

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: '',
};
}