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 }; /** The egg bench: `need` separated eggs from a carton whose `age` sets the * rotten odds. The float test is on the bench; using it is the skill. */ eggs?: { need: number; cartonAge: 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 }, }, { day: 20, 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 pavlova order', text: "Three eggs, separated, for the pav. No shell in my whites, no yolk in my whites — and the carton's been sitting a bit, so FLOAT them first. Crack one rotten egg into that bowl and I will hear it from the front room.", eggs: { need: 3, cartonAge: 0.55 }, }, ]; /** Days beyond the script — keep going, with everything cranked. */ /** * Days beyond the script use the WHOLE kitchen. Toast stays the bread and * butter (a third of days); the other two thirds rotate through every station * the campaign taught, each with its own knobs turned by the day number. * Deterministic per day (the caller seeds by day) — a reload re-deals the SAME * order, so there's no reroll-scumming your way to an easy one. */ export function proceduralOrder(day: number, rand: () => number): Order { const roll = rand(); if (roll < 0.32) return proceduralToast(day, rand); if (roll < 0.44) return proceduralPrep(day, rand); if (roll < 0.53) return proceduralJuice(day, rand); if (roll < 0.64) return proceduralGrill(day, rand); if (roll < 0.75) return proceduralPan(day, rand); if (roll < 0.85) return proceduralSteak(day, rand); if (roll < 0.93) return proceduralEggs(day, rand); return proceduralFridge(day, rand); } const pickOf = (rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)]; /** The toast fields every Order carries; station days never read most of them. */ function baseOrder(day: number, who: string, text: string): Order { return { day, bread: 'white', brand: 'WONDERSLICE', spread: 'butter', amount: 'normal', browning: 0.55, browningName: 'golden', noChar: true, tool: 'butter_knife', butterSoftness: 0.5, who, text, }; } function proceduralPrep(day: number, rand: () => number): Order { const cuttables: IngredientId[] = ['tomato', 'onion', 'cucumber', 'bell_pepper', 'button_mushroom', 'portobello', 'king_oyster']; const ingredient = pickOf(rand, cuttables); const kinds = ['slices', 'wedges', 'dice'] as const; const kind = pickOf(rand, [...kinds]); // The knife pool loosens with the days — sometimes you get the good one. const knife: ToolId = rand() < 0.2 ? 'the_best_thing' : 'dinner_knife'; const n = kind === 'dice' ? 3 + Math.floor(rand() * 2) : 4 + Math.floor(rand() * (kind === 'wedges' ? 5 : 3)); // Past day 21, an onion dice is the TECHNIQUE dice — stop at the root. const root = kind === 'dice' && ingredient === 'onion' && day > 21; const pattern: CutPattern = kind === 'dice' ? { kind, n, ...(root ? { root: true } : {}) } : { kind, n }; const o = baseOrder( day, pickOf(rand, ['a regular', 'the lunch rush', 'Deidre', 'the caterer next door']), '', ); o.prep = [{ kind: 'cut', ingredient, pattern, knife }]; o.text = `${prepAsk(o.prep[0]).replace(/^./, (c) => c.toUpperCase())} — even ones, tidy bench. Then ${o.browningName} white, butter.`; return o; } function proceduralJuice(day: number, rand: () => number): Order { // Better reamers show up as the days go on — and more pips with them. const juicer: JuicerId = day > 25 && rand() < 0.4 ? 'juicernaut' : day > 21 && rand() < 0.5 ? 'ribbed_deluxe' : 'pyramid_classic'; const seeds = 3 + Math.floor(rand() * 4); const o = baseOrder(day, pickOf(rand, ['Deidre', 'a thirsty tradie', 'the morning walker']), ''); o.prep = [{ kind: 'juice', ingredient: 'orange', juicer, seeds }]; o.text = `Fresh orange juice with the toast — squeeze it properly, mind the pips. Then ${o.browningName} white, butter.`; return o; } function proceduralGrill(day: number, rand: () => number): Order { const pool = ['steak', 'snag', 'corn', 'mushroom']; const count = day > 24 ? 4 : 3; const foods: string[] = []; for (let i = 0; i < count; i++) foods.push(pool[Math.floor(rand() * pool.length)]); const o = baseOrder(day, pickOf(rand, ['the bloke from two doors down', 'half the street', 'the cricket club']), ''); o.grill = foods; o.text = `Barbie's on — ${count} bits. Bank the coals hot one side, sear 'em, then off to the lee before they flare. A proper char, not a cremation.`; return o; } function proceduralPan(day: number, rand: () => number): Order { const foods = ['button_mushroom', 'portobello', 'bell_pepper']; // Electric arrives day 22 (the coil that won't let go); induction day 26. const fuel = day > 25 && rand() < 0.35 ? 'induction' : day > 21 && rand() < 0.45 ? 'electric' : 'gas'; const food = pickOf(rand, foods); const o = baseOrder(day, pickOf(rand, ['the chef, on her day off', 'a food blogger', 'Ray']), ''); o.pan = { food, fuel }; const fuelLine = fuel === 'electric' ? 'On the electric — mind the coil, it remembers.' : fuel === 'induction' ? 'On the induction — silent, exact, no excuses.' : 'On the gas.'; o.text = `Pan-sear the ${food.replace(/_/g, ' ')}s. ${fuelLine} Butter in first — wait for the FOAM. Both faces. Burn the butter and I will know.`; return o; } function proceduralSteak(day: number, rand: () => number): Order { const target = pickOf(rand, ['rare', 'medium', 'medium', 'well'] as const); const grain = rand() * Math.PI; const fuel = day > 23 && rand() < 0.4 ? 'electric' : 'gas'; const o = baseOrder(day, pickOf(rand, ['the man at table nine', 'a birthday dinner', 'the butcher, testing you']), ''); o.steak = { fuel, target, grain }; o.text = `Rib eye, ${target}. Sear it, then REST it — do not cut it early, I will see it bleed. And slice ACROSS the grain, clean.`; return o; } function proceduralEggs(day: number, rand: () => number): Order { // The carton gets older as the days climb — more rotten odds, weaker yolks. const cartonAge = Math.min(0.85, 0.35 + (day - 19) * 0.02 + rand() * 0.25); const need = day > 26 ? 4 : 3; const o = baseOrder(day, pickOf(rand, ['the pavlova order', 'a meringue emergency', 'the CWA stall']), ''); o.eggs = { need, cartonAge }; o.text = `${need} eggs, separated — no shell, no yolk in the whites. The carton's ${cartonAge > 0.6 ? 'been sitting a WHILE' : 'not the freshest'}, so float them first.`; return o; } function proceduralFridge(day: number, rand: () => number): Order { const o = baseOrder(day, 'the delivery, at 6am', ''); void rand(); o.fridge = true; o.text = 'The order came in. Raw meat down low, perishables in the cold at the back, and don\'t cram one shelf. Three days till the inspector looks.'; return o; } function proceduralToast(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 = (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: '', }; }