diff --git a/src/dev.ts b/src/dev.ts index d382d96..f016c57 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -637,6 +637,37 @@ 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-22 benedict: toast the bread, then poach the egg for it. */ + benedictDay() { + game.setDay(22); + run(6); + harness.toast(10, 6); // golden-ish; pops -> routes to the poach + run(10); + const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession }; + const s = pv.session; + if (!s) return { error: 'not at poach after toast', view: app.currentView === game.poachView ? 'poach' : 'other' }; + s.src.target = 7; + let g = 0; + while (s.src.output < 6 && g++ < 1200) run(1); + for (let k = 0; k < 3 * 24; k++) { + const a = (k / 24) * Math.PI * 2; + point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true); + run(1); + } + point(1.6, 0.7, 1.6, false); + run(2); + tap('Space'); + g = 0; + while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1); + tap('KeyL'); + for (let i = 0; i < 60 * 4; i++) run(1); + tap('Enter'); + run(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + const lines = [...document.querySelectorAll('.judge-lines p')].map((e) => e.textContent); + return { rows, lines }; + }, + /** Play the day-21 poach with REAL input. good=true: shiver + vortex + drop * + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */ poachDay(good = true) { diff --git a/src/game/game.ts b/src/game/game.ts index 80c9bf9..c1ca1e2 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -19,7 +19,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, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, 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'; @@ -46,6 +46,10 @@ interface Save { /** The cold store, persisted: what you stocked on a fridge day is still there * (and older) when a later cook reaches in for it. */ fridge?: StoredItemSave[]; + /** Consecutive S grades. The judge notices. Breaking it, he notices more. */ + streak?: number; + /** The regulars remember: per-customer visit count and how last time went. */ + customers?: Record; } /** @@ -148,6 +152,7 @@ export class Game { // the drawer as it always did. this.kitchen.onPopped = () => { if (this.order.bruschetta) this.enterOven(); + else if (this.order.benedict) this.enterBenedictPoach(); else this.openDrawer(); }; this.kitchen.onServed = () => this.serve(); @@ -398,6 +403,7 @@ export class Game { }; const v = judgeBruschetta(slice, this.order, this.kitchen.toolId, seconds, result); this.lastBruschetta = { result, verdict: v }; + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -588,6 +594,37 @@ export class Game { this.enterToastFlow(); } + /** The benedict's second act: the toast is made — now poach the egg for it. */ + private enterBenedictPoach(): void { + this.poach.reset(this.order.benedict!.fuel, 'now the egg — poach it and land it on the toast'); + this.hideAllScenes(); + this.poach.root.visible = true; + this.app.setView(this.poach); + this.ticket.show(); + this.poach.onDone = (result) => { + this.poach.root.visible = false; + this.serveBenedict(result); + }; + } + + private serveBenedict(poach: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgeBenedict(slice, this.order, poach, seconds); + this.recordVerdict(v); + this.save.total += v.total; + const key = `day${this.order.day}`; + this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); + writeSave(this.save); + 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(); + } + /** The poach pot: spin it, drop it, lift it, drain it, serve it. */ private enterPoach(): void { this.poach.reset(this.order.poach!.fuel); @@ -606,6 +643,7 @@ export class Game { const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgePoach(result, this.order, seconds); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -637,6 +675,7 @@ export class Game { const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeEggs(result, this.order, seconds); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -679,6 +718,7 @@ export class Game { const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeSteak(result, this.order, seconds); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -712,6 +752,7 @@ export class Game { // fridge, at whatever freshness your storage (and the days) left each item. this.save.fridge = serializeStore(result.store); const v = judgeFridge(result, this.order, seconds); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -763,6 +804,7 @@ export class Game { const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgePan(result, this.order, seconds, this.panIngredient ?? undefined); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -793,6 +835,7 @@ export class Game { const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judgeGrill(result, this.order, seconds); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -830,11 +873,48 @@ export class Game { this.ticket.show(); } + /** + * The kitchen's memory: streaks the judge acknowledges, and regulars who + * remember how last time went. Runs before the verdict shows, so its lines + * ride the judge screen via extraLines. + */ + private recordVerdict(v: Verdict): void { + const extras: string[] = []; + // The streak. He notices. He hates that he notices. + const prev = this.save.streak ?? 0; + if (v.grade === 'S') { + this.save.streak = prev + 1; + if (this.save.streak === 3) extras.push('Three in a row. I am beginning to suspect competence.'); + else if (this.save.streak === 5) extras.push('Five. I have started a file.'); + else if (this.save.streak > 5 && this.save.streak % 5 === 0) extras.push(`${this.save.streak} straight. This is no longer luck and we both know it.`); + } else { + if (prev >= 3) extras.push(`The streak is dead. It was ${prev}. We do not speak of it.`); + this.save.streak = 0; + } + // The regulars remember. + const who = this.order.who; + const book = (this.save.customers ??= {}); + const c = book[who] ?? { visits: 0, last: v.grade, best: 0 }; + if (c.visits > 0) { + if (c.last === 'S' && v.grade !== 'S') extras.push(`${who} remembers last time being better. So do I.`); + else if ((c.last === 'C' || c.last === 'F') && (v.grade === 'S' || v.grade === 'A')) extras.push(`An improvement on ${who}'s last visit. It was noted. It was needed.`); + } + c.visits++; + c.last = v.grade; + c.best = Math.max(c.best, v.total); + book[who] = c; + this.judgeView.extraLines = extras; + } + private renderTicket(): void { const o = this.order; this.ticketEl.innerHTML = ''; el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`); el('div', 'ticket-who', this.ticketEl, o.who); + const knownAs = this.save.customers?.[o.who]; + if (knownAs && knownAs.visits > 0) { + el('div', 'ticket-hint', this.ticketEl, `visit №${knownAs.visits + 1} — last time: ${knownAs.last}${knownAs.last === 'F' ? '. they came BACK.' : ''}`); + } el('div', 'ticket-text', this.ticketEl, `“${o.text}”`); const spec = el('div', 'ticket-spec', this.ticketEl); el('span', 'chip brand', spec, o.brand); @@ -900,6 +980,7 @@ export class Game { // one verdict covers the toast and everything the order asked beside it. const extra = this.juiceResult ? juiceCriteria(this.juiceResult) : []; const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined, extra); + this.recordVerdict(v); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); @@ -956,13 +1037,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, maxDay: Math.max(s.day, s.maxDay ?? 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, streak: s.streak ?? 0, customers: s.customers ?? {} }; } } } catch { /* a corrupt save is not worth a crash */ } - return { day: 1, maxDay: 1, total: 0, best: {} }; + return { day: 1, maxDay: 1, total: 0, best: {}, streak: 0, customers: {} }; } function writeSave(s: Save): void { diff --git a/src/game/judging.ts b/src/game/judging.ts index 66e8bd0..f721eeb 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -616,6 +616,91 @@ 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 benedict — two crafts, one plate. The Bread rows read the slice the +// toaster made; The Poach rows read the egg; the cling sogs the toast beneath. + +export function judgeBenedict(slice: Slice, order: Order, r: PoachResult, seconds: number): Verdict { + const b = slice.browning.stats(slice.mask); + const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); + const criteria: Criterion[] = [ + { + key: 'browning', + group: 'bread', + label: 'Browning', + score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), + weight: 1.1, + detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`, + }, + { + key: 'evenness', + group: 'bread', + label: 'Evenness', + score: clamp01(1 - b.stdev / 0.2), + weight: 0.8, + detail: `variation ${b.stdev.toFixed(3)}`, + }, + { + key: 'char', + group: 'bread', + label: 'Char', + score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), + weight: 0.6, + detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`, + }, + { + key: 'poachwhite', + group: 'poach', + label: 'The White', + score: clamp01(r.whiteScore), + weight: 1.1, + detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set', + }, + { + key: 'poachwobble', + group: 'poach', + label: 'The Wobble', + score: clamp01(r.yolkScore), + weight: 1.3, + detail: r.wobbleWord, + }, + { + key: 'poachform', + group: 'poach', + label: 'The Form', + score: clamp01(r.formScore), + weight: 0.9, + detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : 'one neat comet', + }, + { + key: 'benedictsog', + group: 'bench', + label: 'The Plate', + // An undrained egg sogs the toast under it — both crafts pay. + score: clamp01(1 - r.cling * 1.1), + weight: 0.8, + detail: r.cling > 0.3 ? 'poach water soaking the toast' : 'dry underneath — drained properly', + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 75) / 100), + 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, b2) => a.score - b2.score); + return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; +} + // ===================================================================== // The rib eye — cook it, rest it, cut it. The Rest is the nerve test: it reads // the flood, so cutting a hot steak bleeds points onto the board. diff --git a/src/game/lines.ts b/src/game/lines.ts index 2eceab5..f6ee906 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -436,6 +436,16 @@ Object.assign(BANK, { }, }); +Object.assign(BANK, { + benedictsog: { + bad: [ + 'You put a wet egg on good toast. Now it is neither.', + 'The bread beneath is a sponge. The spoon has holes, use them.', + ], + good: ['Dry toast under a trembling egg. That is the whole architecture.'], + }, +}); + // The rib eye — the steakhouse grandfather who has watched a thousand cooks panic. Object.assign(BANK, { steakcook: { diff --git a/src/game/orders.ts b/src/game/orders.ts index 23e109d..95bf5cf 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -116,6 +116,9 @@ export interface Order { eggs?: { need: number; cartonAge: number }; /** The poach: fuel under the pot. The vortex, the shiver, the drop, the wobble. */ poach?: { fuel: string }; + /** Eggs benedict: toast the bread, then poach the egg and land it ON the + * toast — one plate, two crafts, one verdict. */ + benedict?: { fuel: string }; } export const BROWNING_NAMES: [number, string][] = [ @@ -472,6 +475,21 @@ export const DAYS: Order[] = [ text: 'One poached egg, proper. Bring the water to a SHIVER — not a boil, it will tear. Spin a vortex, drop it in the eye, and lift it when the white sets and the yolk still trembles. I am going to press it.', poach: { fuel: 'gas' }, }, + { + day: 22, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the benedict table, again', + text: 'The full benedict. Golden toast — THEN the egg: shiver, vortex, drop, lift it trembling, drain it, and land it on the bread. Two crafts, one plate. Ruin either and the other dies with it.', + benedict: { fuel: 'gas' }, + }, ]; /** Days beyond the script — keep going, with everything cranked. */ @@ -490,8 +508,9 @@ export function proceduralOrder(day: number, rand: () => number): Order { if (roll < 0.64) return proceduralGrill(day, rand); if (roll < 0.75) return proceduralPan(day, rand); if (roll < 0.84) return proceduralSteak(day, rand); - if (roll < 0.91) return proceduralEggs(day, rand); - if (roll < 0.96) return proceduralPoach(day, rand); + if (roll < 0.90) return proceduralEggs(day, rand); + if (roll < 0.94) return proceduralPoach(day, rand); + if (roll < 0.97) return proceduralBenedict(day, rand); return proceduralFridge(day, rand); } @@ -598,6 +617,16 @@ function proceduralPoach(day: number, rand: () => number): Order { return o; } +function proceduralBenedict(day: number, rand: () => number): Order { + const fuel = day > 28 && rand() < 0.4 ? 'electric' : 'gas'; + const o = baseOrder(day, pickOf(rand, ['the benedict table, again', 'a hollandaise denialist', 'brunch, incarnate']), ''); + o.browning = 0.45 + rand() * 0.2; + o.browningName = nameBrowning(o.browning); + o.benedict = { fuel }; + o.text = `The full benedict — ${o.browningName} toast, then the poach on top. White set, yolk trembling, bread not soggy. Two crafts, one plate.`; + return o; +} + function proceduralFridge(day: number, rand: () => number): Order { const o = baseOrder(day, 'the delivery, at 6am', ''); void rand(); diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index cfafd4c..f5d5dad 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -30,6 +30,8 @@ export class JudgeView implements View { private viewBtn!: HTMLButtonElement; onNext: (() => void) | null = null; + /** One-shot lines the game adds to this verdict (streaks, regulars' memory). */ + extraLines: string[] = []; constructor(private app: App) { this.pedestal = new THREE.Group(); @@ -107,6 +109,8 @@ export class JudgeView implements View { // asked and what for, not the unread toast defaults. this.orderEl.textContent = order.grill ? `Day ${order.day} · ${order.who} asked for the barbie` + : order.benedict + ? `Day ${order.day} · ${order.who} asked for the full benedict` : order.poach ? `Day ${order.day} · ${order.who} asked for the poached egg` : order.eggs @@ -170,6 +174,8 @@ export class JudgeView implements View { clear(this.linesEl); const lines = verdictLines(verdict, order, TOOLS[tool].name, rand); for (const l of lines) el('p', undefined, this.linesEl, l); + for (const l of this.extraLines) el('p', 'judge-extra', this.linesEl, l); + this.extraLines = []; this.faceEl.src = assetUrl(`/assets/img/judge_${judgeFace(verdict.grade)}.png`); this.stampEl.textContent = verdict.grade;