diff --git a/src/game/game.ts b/src/game/game.ts index 7ff7d99..a67562a 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -36,6 +36,9 @@ const TEMP_TOGGLE_COST = 8; interface Save { day: number; + /** The furthest day ever reached — the day-select grid unlocks up to here, + * so replaying an old day for the S never locks the future away. */ + maxDay: number; total: number; best: Record; /** The cold store, persisted: what you stocked on a fridge day is still there @@ -155,6 +158,7 @@ export class Game { this.pendingFridge = null; } this.save.day++; + this.save.maxDay = Math.max(this.save.maxDay, this.save.day); writeSave(this.save); this.beginDay(); }; @@ -186,8 +190,14 @@ export class Game { // gesture the browser wants before any audio is allowed to make a sound. this.kitchen.root.visible = false; this.ticket.hide(); - new Title(this.save.day, () => { + new Title(this.save, (day) => { audio.resume(); + if (day !== undefined && day !== this.save.day) { + // Replaying an old day: move the cursor there, but maxDay holds the + // real progress so the ledger never shrinks. + this.save.day = day; + writeSave(this.save); + } this.beginDay(); }); } @@ -433,6 +443,7 @@ export class Game { /** Test hook: jump straight to a day, through the real order table. */ setDay(day: number): void { this.save.day = day; + this.save.maxDay = Math.max(this.save.maxDay ?? day, day); writeSave(this.save); this.beginDay(); } @@ -482,10 +493,14 @@ export class Game { beginDay(): void { const day = this.save.day; + // Procedural days are seeded BY the day, not by a running stream: a reload + // re-deals the identical order (no reroll-scumming), and the harness can + // reproduce any day by number. + const dayRng = new Rng((day * 2654435761) >>> 0); const o = day <= DAYS.length ? DAYS[day - 1] - : proceduralOrder(day, () => this.rng.next()); + : proceduralOrder(day, () => dayRng.next()); if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`; this.order = o; this.orderSeconds = 0; @@ -852,13 +867,13 @@ function loadSave(): Save { if (raw) { const s = JSON.parse(raw) as Partial; if (typeof s.day === 'number' && s.day >= 1) { - return { day: s.day, total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge }; + return { day: s.day, maxDay: Math.max(s.day, s.maxDay ?? s.day), total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge }; } } } catch { /* a corrupt save is not worth a crash */ } - return { day: 1, total: 0, best: {} }; + return { day: 1, maxDay: 1, total: 0, best: {} }; } function writeSave(s: Save): void { diff --git a/src/game/orders.ts b/src/game/orders.ts index 8dfb4a4..604fa31 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -440,7 +440,117 @@ export const DAYS: Order[] = [ ]; /** 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.34) return proceduralToast(day, rand); + if (roll < 0.47) return proceduralPrep(day, rand); + if (roll < 0.57) return proceduralJuice(day, rand); + if (roll < 0.69) return proceduralGrill(day, rand); + if (roll < 0.81) return proceduralPan(day, rand); + if (roll < 0.92) return proceduralSteak(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 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']; diff --git a/src/style.css b/src/style.css index c9d18e5..c7bc2fe 100644 --- a/src/style.css +++ b/src/style.css @@ -595,6 +595,74 @@ canvas#c { color: #6d6155; } +/* The day ledger: replay any reached day, best grade stamped on the chip. */ +.title-days-lbl { + margin-top: 20px; + font-size: 10px; + letter-spacing: 0.22em; + color: var(--ink-dim); +} + +.title-days { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 5px; + max-width: 380px; + max-height: 118px; + overflow-y: auto; +} + +.day-chip { + width: 34px; + padding: 4px 0 3px; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + background: rgba(243, 233, 220, 0.06); + border: 1px solid rgba(243, 233, 220, 0.14); + border-radius: 6px; + color: var(--ink); + cursor: pointer; + font-family: var(--mono); +} + +.day-chip:hover { + background: rgba(232, 165, 58, 0.18); + border-color: rgba(232, 165, 58, 0.5); +} + +.day-num { + font-size: 11px; +} + +.day-grade { + font-size: 10px; + color: var(--ink-dim); +} + +.day-grade.grade-S { + color: #6cbf6c; + font-weight: 700; +} + +.day-grade.grade-A { + color: #8fce8f; +} + +.day-grade.grade-B { + color: #e8a53a; +} + +.day-grade.grade-C { + color: #d2703a; +} + +.day-grade.grade-F { + color: #c0392b; +} + .toast-panel .amount { margin-top: 8px; font-size: 11px; diff --git a/src/ui/title.ts b/src/ui/title.ts index 7fb8ef4..4900813 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -1,11 +1,20 @@ import { el, Panel } from './hud'; import { assetUrl } from '../scenes/assets'; +import { gradeOf } from '../game/judging'; +import { DAYS } from '../game/orders'; -/** The front door. One button, one piece of art, one joke. */ +/** + * The front door. One button, one piece of art, one joke — and now the ledger: + * every day you've reached, replayable, with your best grade stamped on it. + * Chasing the S column is the whole late game. + */ export class Title { private panel: Panel; - constructor(day: number, onStart: () => void) { + constructor( + save: { day: number; maxDay: number; best: Record }, + onStart: (day?: number) => void, + ) { this.panel = new Panel('title'); const wrap = el('div', 'title-wrap', this.panel.root); const art = el('img', 'title-art', wrap); @@ -14,11 +23,30 @@ export class Title { const txt = el('div', 'title-txt', wrap); el('h1', undefined, txt, 'TOASTSIM'); el('p', 'title-sub', txt, 'Bread goes in. You are judged.'); - const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1'); - btn.addEventListener('click', () => { + const btn = el('button', 'btn', txt, save.day > 1 ? `RESUME — DAY ${save.day}` : 'START DAY 1'); + const go = (day?: number) => { this.panel.dispose(); - onStart(); - }); + onStart(day); + }; + btn.addEventListener('click', () => go()); + + // The day ledger: one chip per reached day, best grade underneath. Click to + // replay it (progress never regresses — maxDay remembers how far you got). + if (save.maxDay > 1) { + el('div', 'title-days-lbl', txt, `THE LEDGER — ${DAYS.length} on the card, then the kitchen deals`); + const grid = el('div', 'title-days', txt); + for (let d = 1; d <= save.maxDay; d++) { + const chip = el('button', 'day-chip', grid); + el('span', 'day-num', chip, String(d)); + const bestScore = save.best[`day${d}`]; + const grade = bestScore !== undefined ? gradeOf(bestScore) : null; + const g = el('span', `day-grade${grade ? ` grade-${grade}` : ''}`, chip, grade ?? '·'); + void g; + chip.title = bestScore !== undefined ? `day ${d} — best ${bestScore.toFixed(1)}/10` : `day ${d} — not judged yet`; + chip.addEventListener('click', () => go(d)); + } + } + const keys = el('div', 'title-keys', txt); el('div', undefined, keys, 'SPACE — lever down, then pop'); el('div', undefined, keys, 'DRAG — fish the right tool from the drawer');