From 717ef5615071a8747e0f6240e0f8984f1bcd9ad8 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 01:53:08 +1000 Subject: [PATCH] =?UTF-8?q?THE=20EGGS:=20day=2020=20=E2=80=94=20the=20crac?= =?UTF-8?q?k,=20the=20separation,=20the=20float=20test,=20the=20beat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The egg dossier lands (atlas §1.5). sim/eggs.ts (pure): a carton of six, each egg rolling freshness off the carton's age; the FLOAT TEST reads it out (sinks flat / stands up / FLOATS) for free; the CRACK is a strength window (soft = nothing, window = clean, hard = shell in the whites, fish the bits out); SEPARATION passes the yolk shell-to-shell (fast breaks it, old yolks are weaker — the float test also tells you how gently to pass); a ROTTEN egg cracked blind ruins the bowl: green puff + a seed-varied synth FART (audio.fart — pitch/length/wobble per egg, the tremolo loses its nerve halfway). D dumps a ruined bowl; the dump clears the whites but NOT the judge's memory (rottenCracked survives — no sin-laundering). An old carton always has at least one liar in it (deterministic; the glass finds it — floor, not lottery). scenes/eggbench.ts: drag-to-glass float test, drag-to-rim + HOLD-SPACE charge crack, alternate arrows to separate, click shell bits to fish. judgeEggs: THE EGGS (Count/Shell/Whites) + The Nose; full line bank ('There is a GLASS OF WATER on the bench. It exists for exactly this.'). Day 20 authored (pavlova order) + eggs arm in the procedural rotation (older cartons + need 4 past day 26). Verified via REAL input: float-all + dodge + patient passes = 10.0/10; blind rotten crack = the beat, dump recovers whites, The Nose remembers = 6.3. Sim bars: crack window, float words, old-yolk weakness, fishing. Co-Authored-By: Claude Fable 5 --- src/core/audio.ts | 43 +++++ src/dev.ts | 121 +++++++++++++ src/game/game.ts | 47 ++++- src/game/judging.ts | 63 ++++++- src/game/lines.ts | 35 ++++ src/game/orders.ts | 41 ++++- src/scenes/eggbench.ts | 380 +++++++++++++++++++++++++++++++++++++++++ src/scenes/judge.ts | 7 +- src/sim/eggs.ts | 212 +++++++++++++++++++++++ 9 files changed, 939 insertions(+), 10 deletions(-) create mode 100644 src/scenes/eggbench.ts create mode 100644 src/sim/eggs.ts diff --git a/src/core/audio.ts b/src/core/audio.ts index a36abf4..cf44748 100644 --- a/src/core/audio.ts +++ b/src/core/audio.ts @@ -120,6 +120,49 @@ export class Audio { src.stop(t + 0.2); } + /** + * The rotten egg. Seed-varied in pitch, length and wobble so it is funny the + * hundredth time (John's law). A sputtering sawtooth with a tremolo that + * loses its nerve halfway through. + */ + fart(seed = 0): void { + const ctx = this.ctx; + if (!ctx || !this.master) return; + const t = this.now(); + const r = (n: number) => { + // tiny deterministic hash off the seed so each egg has its own voice + const x = Math.sin(seed * 127.1 + n * 311.7) * 43758.5453; + return x - Math.floor(x); + }; + const base = 62 + r(1) * 38; // 62-100Hz fundamental + const dur = 0.45 + r(2) * 0.5; // 0.45-0.95s + const wob = 8 + r(3) * 9; // tremolo rate + const o = ctx.createOscillator(); + o.type = 'sawtooth'; + o.frequency.setValueAtTime(base, t); + o.frequency.exponentialRampToValueAtTime(base * (0.55 + r(4) * 0.2), t + dur); + const trem = ctx.createOscillator(); + trem.type = 'sine'; + trem.frequency.setValueAtTime(wob, t); + trem.frequency.exponentialRampToValueAtTime(wob * 0.5, t + dur); // it loses its nerve + const tremGain = ctx.createGain(); + tremGain.gain.value = 0.16; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.32, t + 0.03); + g.gain.setValueAtTime(0.32, t + dur * 0.7); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 520; + trem.connect(tremGain).connect(g.gain); + o.connect(lp).connect(g).connect(this.master); + o.start(t); + trem.start(t); + o.stop(t + dur + 0.05); + trem.stop(t + dur + 0.05); + } + /** The judge's stamp. */ stamp(): void { const ctx = this.ctx; diff --git a/src/dev.ts b/src/dev.ts index b926862..0d8b7a3 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -10,6 +10,7 @@ import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeili import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan'; import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain'; import { newSteakSession, restStep, cut as steakCut, finishSteak, steakResult, type Doneness } from './sim/steak'; +import { newEggSession, floatTest, crack, separatePass, fishShell, dumpBowl } from './sim/eggs'; /** * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes @@ -586,6 +587,126 @@ export function installDevHarness(app: App, game: Game): void { return { grade: gradeEl ? gradeEl.textContent : null, groups, rows }; }, + /** Play the day-20 egg order with REAL input. good=true floats everything + * first and only cracks the safe ones; false cracks blind (the beat). */ + eggDay(good = true) { + game.setDay(20); + run(6); + const ev = game.eggBenchView as unknown as { session: import('./sim/eggs').EggSession }; + const s = ev.session; + if (!s) return { error: 'not at eggs' }; + const GLASS = { x: 2.1, z: -0.4 }; + const BOWL = { x: 0, z: 0.55 }; + const slot = (i: number) => ({ x: -2.25 + (i % 3) * 0.55, z: -1.2 + Math.floor(i / 3) * 0.55 }); + const drag = (from: { x: number; z: number }, to: { x: number; z: number }) => { + point(from.x, 0.4, from.z, false); run(2); + point(from.x, 0.4, from.z, true); run(2); + point((from.x + to.x) / 2, 0.4, (from.z + to.z) / 2, true); run(2); + point(to.x, 0.4, to.z, true); run(2); + point(to.x, 0.4, to.z, false); run(2); + }; + const holdCrack = (frames: number) => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); + run(frames); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })); + run(3); + }; + if (good) { + for (let i = 0; i < 6; i++) drag(slot(i), GLASS); // float the lot + const safe = s.eggs.filter((e) => e.floatWord !== 'FLOATS').map((e) => e.index); + for (const i of safe.slice(0, s.need)) { + drag(slot(i), BOWL); + holdCrack(30); // ~0.55 — the clean window + // separate with patient alternation (~0.9s gaps) + for (let p = 0; p < 6 && s.eggs[i].state === 'cracked' && !s.eggs[i].yolkBroken; p++) { + run(54); + tap(p % 2 === 0 ? 'ArrowRight' : 'ArrowLeft'); + } + } + } else { + // Blind: crack the first ROTTEN egg straight into the bowl. The beat. + const rotten = s.eggs.find((e) => e.freshness < 0.25) ?? s.eggs[0]; + drag(slot(rotten.index), BOWL); + holdCrack(30); + tap('KeyD'); // dump the crime + run(4); + } + tap('Enter'); + run(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows }; + }, + + // ---- THE EGGS: the crack window, the float test, the rotten beat. ---- + + /** The crack window: soft tap does nothing, the window opens clean, past it + * shell goes in the bowl. Run on a guaranteed-fresh egg. */ + eggCrack() { + const mk = () => { + const s = newEggSession(3, 7, 0); + s.eggs[0].freshness = 0.9; + return s; + }; + const soft = crack(mk(), 0, 0.2); + const clean = crack(mk(), 0, 0.55); + const hard = crack(mk(), 0, 0.95); + return { soft: soft.result, clean: clean.result, hard: hard.result, hardBits: hard.shellBits }; + }, + + /** Float words track freshness; a rotten egg FLOATS — the free lie detector. */ + eggFloat() { + const s = newEggSession(3, 7, 0); + s.eggs[0].freshness = 0.9; + s.eggs[1].freshness = 0.4; + s.eggs[2].freshness = 0.1; + return { fresh: floatTest(s, 0), old: floatTest(s, 1), rotten: floatTest(s, 2) }; + }, + + /** The beat: crack an untested rotten egg → the bowl is ruined; dump it and + * the sin clears (at a time cost). */ + eggRotten() { + const s = newEggSession(3, 7, 0); + s.eggs[0].freshness = 0.1; + const out = crack(s, 0, 0.55); + const ruined = s.bowlRuined; + dumpBowl(s); + return { result: out.result, ruined, afterDump: s.bowlRuined, dumpCostS: 6 }; + }, + + /** Separation: slow passes always survive; a fast pass breaks the yolk, and + * an OLD egg's yolk breaks at speeds a fresh one shrugs off. */ + eggSeparate() { + const run = (freshness: number, speed: number) => { + const s = newEggSession(3, 7, 0); + s.eggs[0].freshness = freshness; + crack(s, 0, 0.55); + for (let p = 0; p < 3; p++) { + const r = separatePass(s, 0, speed); + if (r.broke) return 'broke'; + if (r.done) return 'separated'; + } + return 'incomplete'; + }; + return { + freshSlow: run(0.9, 0.3), + freshBrisk: run(0.9, 0.7), + oldSlow: run(0.45, 0.3), + oldBrisk: run(0.45, 0.7), + }; + }, + + /** Shrapnel: fish the bits back out and the bowl forgives you. */ + eggFish() { + const s = newEggSession(3, 7, 0); + s.eggs[0].freshness = 0.9; + const c = crack(s, 0, 0.95); + const before = s.bowlShell; + while (fishShell(s)) { + /* fish every bit */ + } + return { bits: c.shellBits, before, after: s.bowlShell, fished: s.fished }; + }, + // ---- THE RIB EYE: rest clock (the flood) + the grain-aware cut. ---- /** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain diff --git a/src/game/game.ts b/src/game/game.ts index a67562a..e26005d 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -9,6 +9,7 @@ import { GrillView } from '../scenes/grill'; import { PanView } from '../scenes/pan'; import { FridgeView } from '../scenes/fridge'; import { SteakBoardView } from '../scenes/steakboard'; +import { EggBenchView } from '../scenes/eggbench'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -17,7 +18,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, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, 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'; @@ -62,6 +63,7 @@ export class Game { private pan: PanView; private fridge: FridgeView; private steakBoard: SteakBoardView; + private eggBench: EggBenchView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -100,6 +102,7 @@ export class Game { this.pan = new PanView(app); this.fridge = new FridgeView(app); this.steakBoard = new SteakBoardView(app); + this.eggBench = new EggBenchView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -112,6 +115,7 @@ export class Game { app.scene.add(this.pan.root); app.scene.add(this.fridge.root); app.scene.add(this.steakBoard.root); + app.scene.add(this.eggBench.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -123,6 +127,7 @@ export class Game { this.pan.root.visible = false; this.fridge.root.visible = false; this.steakBoard.root.visible = false; + this.eggBench.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -238,6 +243,9 @@ export class Game { get steakBoardView(): SteakBoardView { return this.steakBoard; } + get eggBenchView(): EggBenchView { + return this.eggBench; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -408,6 +416,7 @@ export class Game { this.pan.root.visible = false; this.fridge.root.visible = false; this.steakBoard.root.visible = false; + this.eggBench.root.visible = false; this.assembly.root.visible = false; } @@ -557,9 +566,45 @@ export class Game { this.enterSteak(); return; } + // An egg day: the carton, the glass, the bowl, and your nerve. + if (this.order.eggs) { + this.enterEggs(); + return; + } this.enterToastFlow(); } + /** The egg bench: float-test the suspects, crack, separate, serve. */ + private enterEggs(): void { + const e = this.order.eggs!; + this.eggBench.reset(e.need, e.cartonAge); + this.hideAllScenes(); + this.eggBench.root.visible = true; + this.app.setView(this.eggBench); + this.ticket.show(); + this.eggBench.onDone = (result) => { + this.eggBench.root.visible = false; + this.serveEggs(result); + }; + } + + private serveEggs(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgeEggs(result, this.order, seconds); + 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 rib eye: sear it on the pan, carry the sear's doneness to the resting * board, rest + cut, serve. Pan → board → judge, like the bruschetta chain. */ private enterSteak(): void { diff --git a/src/game/judging.ts b/src/game/judging.ts index e6b32fd..2a12609 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -12,6 +12,7 @@ import { grillWord } from '../sim/charcoal'; import type { PanResult } from '../sim/pan'; import type { storeHealth } from '../sim/coldchain'; import type { SteakResult } from '../sim/steak'; +import type { EggResult } from '../sim/eggs'; export interface Criterion { key: string; @@ -24,7 +25,7 @@ export interface Criterion { /** Which station earned it — the scorecard groups by this on prep orders. * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * distribution, balance, sog), bench (temperature + mess). */ - group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -496,6 +497,66 @@ export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdi return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } +// ===================================================================== +// The eggs — did you get the number asked, cleanly: no shell left in the +// whites, no yolk in them, and no rotten egg he had to HEAR about. Fishing a +// bit out is forgiven; dumping a ruined bowl costs only the time it took. + +export function judgeEggs(r: EggResult, order: Order, seconds: number): Verdict { + const criteria: Criterion[] = [ + { + key: 'eggcount', + group: 'eggs', + label: 'The Count', + score: clamp01(r.separated / r.need), + weight: 1.3, + detail: `${r.separated} of ${r.need} separated`, + }, + { + key: 'eggshell', + group: 'eggs', + label: 'The Shell', + score: clamp01(1 - r.shellInBowl * 0.4), + weight: 1.0, + detail: r.shellInBowl > 0 ? `${r.shellInBowl} bit${r.shellInBowl > 1 ? 's' : ''} still in the whites` : 'none in the bowl', + }, + { + key: 'eggwhites', + group: 'eggs', + label: 'The Whites', + score: r.bowlRuined ? 0 : clamp01(1 - r.yolksBroken * 0.25), + weight: 1.2, + detail: r.bowlRuined ? (r.rottenIn > 0 ? 'a rotten egg went in' : 'yolk in the whites') : r.yolksBroken > 0 ? `${r.yolksBroken} yolk${r.yolksBroken > 1 ? 's' : ''} broke along the way` : 'clean, whippable', + }, + { + key: 'eggnose', + group: 'bench', + label: 'The Nose', + score: clamp01(1 - r.rottenIn * 0.5), + weight: 0.7, + detail: r.rottenIn > 0 ? `${r.rottenIn} rotten egg${r.rottenIn > 1 ? 's' : ''} cracked — he heard` : r.testsRun > 0 ? `${r.testsRun} float-tested, none slipped through` : 'lucky, or good', + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 50) / 90), + 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, b) => a.score - b.score); + void order; + 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 cd143b1..fa2d1b0 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -367,6 +367,41 @@ Object.assign(BANK, { }, }); +// The eggs — he has a nose, and he has heard things he cannot unhear. +Object.assign(BANK, { + eggcount: { + bad: [ + 'I asked for {asked}. Count what is in that bowl. We are not close.', + 'Short. An egg count is not a suggestion.', + ], + good: ['The full count, all clean. Correct.', 'Every egg accounted for. Good.'], + }, + eggshell: { + bad: [ + 'There is shell in the whites. Someone is going to bite that. It will be me.', + 'Shrapnel. You cracked it like it owed you money. Tap. TAP.', + 'I can hear the shell from here. Fish it out next time. All of it.', + ], + good: ['Not a fragment of shell. A civilized crack.', 'Clean cracks, clean bowl. That is the tap.'], + }, + eggwhites: { + bad: [ + 'Yolk in the whites. They will never whip now. They know it. You know it.', + 'You rushed the pass and the yolk went. Slow hands, love. The yolk is not in a hurry.', + 'These whites are ruined and we both watched it happen.', + ], + good: ['Whites clean enough to whip to glass. The pass was patient. I noticed.', 'Yolks whole, whites pure. That is separation.'], + }, + eggnose: { + bad: [ + 'You cracked a rotten one into the bowl. I heard it from here. The whole street heard it.', + 'There is a GLASS OF WATER on the bench. It exists for exactly this. Use it.', + 'That smell is now part of the kitchen. Part of me. Float them FIRST.', + ], + good: ['You floated the suspects and none slipped through. That is a nose you can trust.', 'Nothing rotten reached the bowl. The glass did its work.'], + }, +}); + // 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 604fa31..d4d76a2 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -111,6 +111,9 @@ export interface Order { /** 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][] = [ @@ -437,6 +440,21 @@ export const DAYS: Order[] = [ 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. */ @@ -449,12 +467,13 @@ export const DAYS: Order[] = [ */ 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); + 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); } @@ -542,6 +561,16 @@ function proceduralSteak(day: number, rand: () => number): Order { 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(); diff --git a/src/scenes/eggbench.ts b/src/scenes/eggbench.ts new file mode 100644 index 0000000..5eac6f7 --- /dev/null +++ b/src/scenes/eggbench.ts @@ -0,0 +1,380 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { + type EggSession, + newEggSession, + floatTest, + crack, + separatePass, + fishShell, + dumpBowl, + eggResult, + cartonWord, +} from '../sim/eggs'; +import { el, Panel } from '../ui/hud'; + +const BENCH_Y = 0; + +/** + * THE EGG BENCH — the carton, the glass, the bowl, and your nerve. + * + * DRAG an egg to the GLASS to float-test it (fresh sinks flat; rotten FLOATS — + * free information, use it). DRAG an egg to the BOWL to bring it to the rim, + * then HOLD SPACE and release to crack — the longer the hold, the harder the + * tap. Too soft does nothing; too hard puts shell in the whites (click the + * bits to fish them out). Crack a rotten one and... you'll know. D dumps a + * ruined bowl. Then separate: alternate ←/→ to pass the yolk shell-to-shell — + * slow is safe, fast breaks it, old yolks are weak. ENTER serves. + */ +export class EggBenchView implements View { + readonly root = new THREE.Group(); + + private session: EggSession | null = null; + private eggMeshes: THREE.Mesh[] = []; + private glass!: THREE.Mesh; + private bowl!: THREE.Mesh; + private bowlMix!: THREE.Mesh; + private puff: THREE.Mesh[] = []; + private shellBitMeshes: THREE.Mesh[] = []; + + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.4); + private grabbed = -1; + private wasDown = false; + + /** The egg staged at the bowl rim, waiting for the crack. */ + private staged = -1; + private charge = 0; + private charging = false; + /** The egg mid-separation, and which shell half holds the yolk. */ + private separating = -1; + private yolkSide: 'L' | 'R' = 'L'; + private lastPassAt = 0; + + private panel: Panel; + private askEl!: HTMLElement; + private stateEl!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: ReturnType) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + this.panel = new Panel('egg-panel'); + this.buildUi(); + this.panel.hide(); + } + + private buildUi(): void { + const p = this.panel.root; + const card = el('div', 'slicer-card', p); + el('div', 'drawer-lbl', card, 'THE EGGS'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.stateEl = el('div', 'slicer-thick', card, ''); + this.wordEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + + // The carton: a paper tray, back-left. + const carton = new THREE.Mesh( + new THREE.BoxGeometry(2.1, 0.18, 1.5), + new THREE.MeshStandardMaterial({ color: 0xb8a68c, roughness: 0.95 }), + ); + carton.position.set(-1.7, BENCH_Y + 0.09, -0.9); + carton.receiveShadow = true; + this.root.add(carton); + + // The glass of water, right — the lie detector. + this.glass = new THREE.Mesh( + new THREE.CylinderGeometry(0.42, 0.36, 0.9, 20, 1, true), + new THREE.MeshStandardMaterial({ color: 0x9fc4d8, roughness: 0.2, transparent: true, opacity: 0.4, side: THREE.DoubleSide }), + ); + this.glass.position.set(2.1, BENCH_Y + 0.45, -0.4); + this.root.add(this.glass); + const water = new THREE.Mesh( + new THREE.CylinderGeometry(0.4, 0.35, 0.62, 20), + new THREE.MeshStandardMaterial({ color: 0x6fa8c4, roughness: 0.15, transparent: true, opacity: 0.5 }), + ); + water.position.set(2.1, BENCH_Y + 0.34, -0.4); + this.root.add(water); + + // The bowl, centre front. + this.bowl = new THREE.Mesh( + new THREE.CylinderGeometry(1.0, 0.55, 0.62, 28, 1, true), + new THREE.MeshStandardMaterial({ color: 0xe8e2d6, roughness: 0.4, side: THREE.DoubleSide }), + ); + this.bowl.position.set(0, BENCH_Y + 0.31, 0.55); + this.root.add(this.bowl); + this.bowlMix = new THREE.Mesh( + new THREE.CylinderGeometry(0.92, 0.92, 0.05, 28), + new THREE.MeshStandardMaterial({ color: 0xf4f0e4, roughness: 0.35, transparent: true, opacity: 0 }), + ); + this.bowlMix.position.set(0, BENCH_Y + 0.35, 0.55); + this.root.add(this.bowlMix); + } + + /** A fresh carton of six and an empty bowl. */ + reset(need = 3, cartonAge = 0.5, askText = ''): void { + this.session = newEggSession(need, 20260721, cartonAge); + this.staged = -1; + this.separating = -1; + this.charge = 0; + this.charging = false; + for (const m of this.eggMeshes) { + this.root.remove(m); + m.geometry.dispose(); + (m.material as THREE.Material).dispose(); + } + this.eggMeshes = []; + for (const b of this.shellBitMeshes) this.root.remove(b); + this.shellBitMeshes = []; + (this.bowlMix.material as THREE.MeshStandardMaterial).opacity = 0; + for (let i = 0; i < 6; i++) { + const egg = new THREE.Mesh( + new THREE.SphereGeometry(0.19, 18, 14), + new THREE.MeshStandardMaterial({ color: 0xe9dcc4, roughness: 0.5 }), + ); + egg.scale.set(1, 1.28, 1); + egg.position.copy(this.cartonSlot(i)); + egg.castShadow = true; + this.root.add(egg); + this.eggMeshes.push(egg); + } + this.askEl.textContent = askText || `${this.session.need} separated eggs — no shell, no yolk in the whites`; + this.wordEl.textContent = `the carton says: ${cartonWord(cartonAge)}`; + this.hintEl.textContent = 'DRAG egg → glass to float-test · DRAG egg → bowl, HOLD SPACE to crack · ←/→ to separate · D dumps · ENTER serves'; + } + + private cartonSlot(i: number): THREE.Vector3 { + return new THREE.Vector3(-2.25 + (i % 3) * 0.55, BENCH_Y + 0.32, -1.2 + Math.floor(i / 3) * 0.55); + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + update(dt: number): void { + this.app.camera.position.set(0, 3.2, 3.3); + this.app.camera.lookAt(0, 0.2, -0.1); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + this.ray.setFromCamera(inp.ndc, this.app.camera); + const on = this.ray.ray.intersectPlane(this.plane, this.hit); + + // Dragging eggs about. + if (inp.down && on) { + if (!this.wasDown && this.separating < 0) this.grabbed = this.nearestEgg(this.hit); + if (this.grabbed >= 0) this.eggMeshes[this.grabbed].position.set(this.hit.x, 0.55, this.hit.z); + } + if (!inp.down && this.wasDown && this.grabbed >= 0) { + const m = this.eggMeshes[this.grabbed]; + const egg = s.eggs[this.grabbed]; + if (m.position.distanceTo(this.glass.position) < 0.9 && (egg.state === 'carton' || egg.state === 'tested')) { + // Into the glass: the float test. The egg sits at its verdict height. + const word = floatTest(s, this.grabbed) ?? egg.floatWord; + m.position.copy(this.cartonSlot(egg.index)); // back home; the verdict lives on the HUD + this.wordEl.textContent = `egg ${egg.index + 1}: ${word}`; + this.wordEl.style.color = word === 'FLOATS' ? '#e2603a' : word === 'stands up' ? '#e8a53a' : '#8fce8f'; + audio.clatter(0.2, 1.6); + } else if (m.position.distanceTo(this.bowl.position) < 1.3 && (egg.state === 'carton' || egg.state === 'tested')) { + // Staged at the rim, waiting for the tap. + this.staged = this.grabbed; + m.position.set(this.bowl.position.x - 0.9, 0.75, this.bowl.position.z - 0.2); + } else { + m.position.copy(this.cartonSlot(egg.index)); + } + this.grabbed = -1; + } + this.wasDown = inp.down; + + // The crack: hold SPACE to charge, release to tap. + if (this.staged >= 0 && this.separating < 0) { + if (inp.held('Space')) { + this.charging = true; + this.charge = Math.min(1, this.charge + dt / 0.9); + this.eggMeshes[this.staged].position.y = 0.75 + Math.sin(s.seconds * 40) * 0.02 * this.charge; + } else if (this.charging) { + this.doCrack(); + } + } + + // Separation passes: alternate arrows; speed = how fast you alternate. + if (this.separating >= 0) { + const press = (side: 'L' | 'R') => { + if (side === this.yolkSide) return; // same side — the yolk stays put + const gap = s.seconds - this.lastPassAt; + const speed = Math.max(0.1, Math.min(1, 0.9 - gap * 0.35)); + this.lastPassAt = s.seconds; + this.yolkSide = side; + const r = separatePass(s, this.separating, speed); + audio.clatter(0.15, 2); + if (r.broke) { + this.wordEl.textContent = 'the yolk broke into the whites — D dumps the bowl'; + this.wordEl.style.color = '#e2603a'; + (this.bowlMix.material as THREE.MeshStandardMaterial).color.setHex(0xf0d060); + (this.bowlMix.material as THREE.MeshStandardMaterial).opacity = 0.9; + this.finishSeparation(false); + } else if (r.done) { + this.wordEl.textContent = `separated — ${eggResult(s).separated}/${s.need}`; + this.wordEl.style.color = '#8fce8f'; + this.finishSeparation(true); + } + }; + if (inp.justPressed('ArrowLeft')) press('L'); + if (inp.justPressed('ArrowRight')) press('R'); + } + + if (inp.justPressed('KeyD')) { + dumpBowl(s); + (this.bowlMix.material as THREE.MeshStandardMaterial).opacity = 0; + for (const b of this.shellBitMeshes) this.root.remove(b); + this.shellBitMeshes = []; + this.wordEl.textContent = 'bowl dumped — start the whites again'; + this.wordEl.style.color = ''; + audio.clunk(true); + } + + // Click a shell bit to fish it out. + if (inp.down && !this.wasDown && this.shellBitMeshes.length && on) { + const near = this.shellBitMeshes.find((b) => Math.hypot(b.position.x - this.hit.x, b.position.z - this.hit.z) < 0.25); + if (near && fishShell(s)) { + this.root.remove(near); + this.shellBitMeshes = this.shellBitMeshes.filter((b) => b !== near); + audio.clatter(0.2, 2.2); + } + } + + // Fade the rotten puff. + for (const p of this.puff) { + p.scale.multiplyScalar(1 + dt * 1.6); + const m = p.material as THREE.MeshBasicMaterial; + m.opacity = Math.max(0, m.opacity - dt * 0.7); + if (m.opacity <= 0.01) this.root.remove(p); + } + this.puff = this.puff.filter((p) => (p.material as THREE.MeshBasicMaterial).opacity > 0.01); + + s.seconds += dt; + this.updateHud(); + + if (inp.justPressed('Enter')) { + const cb = this.onDone; + this.onDone = null; + cb?.(eggResult(s)); + } + } + + private doCrack(): void { + const s = this.session!; + const i = this.staged; + const strength = this.charge; + this.charge = 0; + this.charging = false; + const out = crack(s, i, strength); + const m = this.eggMeshes[i]; + if (out.result === 'nothing') { + this.wordEl.textContent = 'a polite knock. the egg declines.'; + this.wordEl.style.color = ''; + audio.clatter(0.15, 1.8); + return; + } + audio.clatter(0.5, 1.2); + if (out.result === 'ROTTEN') { + // The beat. Green puff, the sound, the ruined bowl. + m.visible = false; + this.staged = -1; + (this.bowlMix.material as THREE.MeshStandardMaterial).color.setHex(0x7a9a4a); + (this.bowlMix.material as THREE.MeshStandardMaterial).opacity = 0.95; + for (let k = 0; k < 5; k++) { + const p = new THREE.Mesh( + new THREE.SphereGeometry(0.16 + Math.random() * 0.12, 10, 8), + new THREE.MeshBasicMaterial({ color: 0x86b04e, transparent: true, opacity: 0.7 }), + ); + p.position.set(this.bowl.position.x + (Math.random() - 0.5) * 0.8, 0.8 + Math.random() * 0.4, this.bowl.position.z + (Math.random() - 0.5) * 0.8); + this.root.add(p); + this.puff.push(p); + } + audio.fart(s.eggs[i].index + s.rottenIn * 7); + this.wordEl.textContent = 'ROTTEN. in the bowl. D dumps it — and he heard that.'; + this.wordEl.style.color = '#e2603a'; + return; + } + if (out.result === 'shrapnel') { + for (let k = 0; k < out.shellBits; k++) { + const bit = new THREE.Mesh( + new THREE.TetrahedronGeometry(0.07), + new THREE.MeshStandardMaterial({ color: 0xe9dcc4, roughness: 0.5 }), + ); + bit.position.set(this.bowl.position.x + (Math.random() - 0.5) * 1.1, 0.42, this.bowl.position.z + (Math.random() - 0.5) * 1.1); + this.root.add(bit); + this.shellBitMeshes.push(bit); + } + this.wordEl.textContent = `shell in the bowl — ${out.shellBits} bit${out.shellBits > 1 ? 's' : ''}. click them out.`; + this.wordEl.style.color = '#e8a53a'; + } else { + this.wordEl.textContent = 'clean crack — now ←/→ to pass the yolk. slowly.'; + this.wordEl.style.color = '#8fce8f'; + } + // Into separation: the egg becomes two shell halves over the bowl. + m.visible = false; + this.separating = i; + this.yolkSide = 'L'; + this.lastPassAt = s.seconds; + const white = (this.bowlMix.material as THREE.MeshStandardMaterial); + white.color.setHex(0xf4f0e4); + white.opacity = Math.min(0.85, white.opacity + 0.25); + } + + private finishSeparation(ok: boolean): void { + void ok; + this.separating = -1; + this.staged = -1; + } + + private nearestEgg(p: THREE.Vector3): number { + const s = this.session!; + let best = -1; + let bd = 0.4; + for (let i = 0; i < this.eggMeshes.length; i++) { + const egg = s.eggs[i]; + if (egg.state !== 'carton' && egg.state !== 'tested') continue; + const d = Math.hypot(this.eggMeshes[i].position.x - p.x, this.eggMeshes[i].position.z - p.z); + if (d < bd) { + bd = d; + best = i; + } + } + return best; + } + + private updateHud(): void { + const s = this.session!; + const r = eggResult(s); + const bowl = s.bowlRuined ? 'RUINED — D dumps it' : s.bowlShell > 0 ? `${s.bowlShell} shell bit${s.bowlShell > 1 ? 's' : ''} in it` : 'clean'; + this.stateEl.textContent = `${r.separated}/${s.need} separated · bowl: ${bowl}${this.charging ? ` · tap: ${Math.round(this.charge * 100)}%` : ''}`; + } + + result() { + return this.session ? eggResult(this.session) : { separated: 0, need: 3, shellInBowl: 0, bowlRuined: false, rottenIn: 0, yolksBroken: 0, testsRun: 0, bowlsDumped: 0 }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index fdca047..e92e82d 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -107,6 +107,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.eggs + ? `Day ${order.day} · ${order.who} asked for the eggs` : order.steak ? `Day ${order.day} · ${order.who} asked for the rib eye` : order.pan @@ -123,7 +125,7 @@ export class JudgeView implements View { // Station orders (prep, and M15's bruschetta trio) get the grouped card; // a plain toast order keeps the flat card it always had. const grouped = verdict.criteria.some( - (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak', + (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs', ); const headers: Record = { prep: 'THE PREP', @@ -135,9 +137,10 @@ export class JudgeView implements View { pan: 'THE PAN', cold: 'THE FRIDGE', steak: 'THE STEAK', + eggs: 'THE EGGS', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped diff --git a/src/sim/eggs.ts b/src/sim/eggs.ts new file mode 100644 index 0000000..f183464 --- /dev/null +++ b/src/sim/eggs.ts @@ -0,0 +1,212 @@ +import { Rng } from '../core/rng'; + +/** + * THE EGG BENCH — getting INTO the egg is the game. + * + * A carton of six. The date band on the lid is information, not certainty: + * every egg rolls its own freshness, and the only way to KNOW is the float + * test — a glass of water on the bench, always available, costs nothing but + * seconds. Fresh sinks flat. Old stands on its end. Rotten FLOATS. + * + * THE CRACK — one tap on the bowl rim, judged by strength. Too soft and + * nothing happens. Right and it opens into two clean halves. Too hard + * and shell shrapnel goes in the bowl (fish it out — mess and time). + * THE SEPARATION — pass the yolk between the shell halves over the bowl; + * each pass drains white. Pass too fast and the yolk BREAKS — and a + * broken yolk in the whites ruins them for whipping. + * THE ROTTEN EGG — crack one you didn't test and it's in the bowl: the puff, + * the sound, the judge recoiling. The bowl is ruined; dump it and + * start the whites again. The float test exists so this is always, + * always your own fault. + * + * Pure — no three.js — so the harness cracks headless and reads the exact + * numbers the judge will. + */ + +/** Below this freshness an egg is ROTTEN — floats in the glass, ruins the bowl. */ +export const ROTTEN_AT = 0.25; +/** Crack strength window: below = nothing, inside = clean, above = shrapnel. */ +export const CRACK_LO = 0.35; +export const CRACK_HI = 0.75; +/** Passes needed to fully drain the white off a yolk. */ +export const PASSES_NEEDED = 3; +/** Pass speed above this risks the yolk; old yolks are weaker. */ +const SAFE_PASS_SPEED = 0.55; + +export interface Egg { + index: number; + /** 1 fresh .. 0 biohazard. Hidden — the float test reads it out. */ + freshness: number; + /** Where it is in the flow. */ + state: 'carton' | 'tested' | 'cracked' | 'separated' | 'binned'; + /** What the float test said, if you ran it. */ + floatWord: 'sinks flat' | 'stands up' | 'FLOATS' | null; + /** Shell bits this egg put in the bowl (crack too hard). */ + shellBits: number; + /** The yolk broke during separation. */ + yolkBroken: boolean; + /** Separation passes completed. */ + passes: number; +} + +export interface EggSession { + eggs: Egg[]; + /** How many separated eggs the order wants. */ + need: number; + /** Whites in the bowl: ruined if a rotten egg or a broken yolk went in. */ + bowlRuined: boolean; + /** Rotten eggs currently IN the bowl (cleared by a dump — the whites forget). */ + rottenIn: number; + /** Rotten eggs cracked EVER — the judge heard each one; a dump doesn't unring it. */ + rottenCracked: number; + /** Shell bits currently in the bowl, waiting to be fished. */ + bowlShell: number; + /** Shell bits fished back out (time spent, crime forgiven). */ + fished: number; + /** Float tests run (free info, costs seconds — the judge counts neither). */ + testsRun: number; + /** Bowls dumped and restarted after a ruin. */ + bowlsDumped: number; + seconds: number; + rng: Rng; +} + +export function newEggSession(need = 3, seed = 20260721, cartonAge = 0.5): EggSession { + const rng = new Rng(seed); + const eggs: Egg[] = []; + for (let i = 0; i < 6; i++) { + // The carton's age sets the odds; every egg still rolls its own. An old + // carton is a minefield; a fresh one still deserves one test out of respect. + const freshness = Math.max(0.05, Math.min(1, 1 - cartonAge * (0.4 + rng.next() * 0.9))); + eggs.push({ index: i, freshness, state: 'carton', floatWord: null, shellBits: 0, yolkBroken: false, passes: 0 }); + } + // An old carton always has at least one liar in it — otherwise the ticket's + // warning is theatre and the float test never earns its keep. Deterministic, + // and the glass finds it every time: a floor, not a lottery. + if (cartonAge >= 0.5 && !eggs.some((e) => e.freshness < ROTTEN_AT)) { + const stalest = eggs.reduce((a, b) => (b.freshness < a.freshness ? b : a)); + stalest.freshness = 0.12; + } + return { eggs, need, bowlRuined: false, rottenIn: 0, rottenCracked: 0, bowlShell: 0, fished: 0, testsRun: 0, bowlsDumped: 0, seconds: 0, rng }; +} + +/** Drop an egg in the glass. Free information; the words are the judge's own. */ +export function floatTest(s: EggSession, i: number): Egg['floatWord'] { + const egg = s.eggs[i]; + if (!egg || egg.state !== 'carton') return null; + egg.floatWord = egg.freshness < ROTTEN_AT ? 'FLOATS' : egg.freshness < 0.55 ? 'stands up' : 'sinks flat'; + egg.state = 'tested'; + s.testsRun++; + s.seconds += 2.5; + return egg.floatWord; +} + +export interface CrackOutcome { + result: 'nothing' | 'clean' | 'shrapnel' | 'ROTTEN'; + shellBits: number; +} + +/** + * Crack egg `i` on the bowl rim with `strength` 0..1. A rotten egg cracked here + * goes IN THE BOWL — the beat — regardless of how well you tapped it. + */ +export function crack(s: EggSession, i: number, strength: number): CrackOutcome { + const egg = s.eggs[i]; + if (!egg || (egg.state !== 'carton' && egg.state !== 'tested')) return { result: 'nothing', shellBits: 0 }; + if (strength < CRACK_LO) { + s.seconds += 1; + return { result: 'nothing', shellBits: 0 }; // a polite knock; the egg declines + } + egg.state = 'cracked'; + s.seconds += 2; + if (egg.freshness < ROTTEN_AT) { + s.rottenIn++; + s.rottenCracked++; + s.bowlRuined = true; + return { result: 'ROTTEN', shellBits: 0 }; + } + if (strength > CRACK_HI) { + const bits = 1 + Math.floor((strength - CRACK_HI) * 8) + Math.floor(s.rng.next() * 2); + egg.shellBits = bits; + s.bowlShell += bits; + return { result: 'shrapnel', shellBits: bits }; + } + return { result: 'clean', shellBits: 0 }; +} + +/** Fish one shell bit back out of the whites. Slow, humbling, effective. */ +export function fishShell(s: EggSession): boolean { + if (s.bowlShell <= 0) return false; + s.bowlShell--; + s.fished++; + s.seconds += 3; + return true; +} + +/** + * One separation pass at `speed` 0..1 — the yolk hops shell to shell, white + * drains. Slow is safe; fast risks the yolk, and an old egg's yolk is weaker. + */ +export function separatePass(s: EggSession, i: number, speed: number): { done: boolean; broke: boolean } { + const egg = s.eggs[i]; + if (!egg || egg.state !== 'cracked' || egg.yolkBroken) return { done: false, broke: false }; + s.seconds += 1.2; + const weakness = 1 - egg.freshness * 0.6; // older yolk = weaker membrane + if (speed > SAFE_PASS_SPEED * (2 - weakness)) { + egg.yolkBroken = true; + s.bowlRuined = true; // yolk in the whites — no whip for you + return { done: false, broke: true }; + } + egg.passes++; + if (egg.passes >= PASSES_NEEDED) { + egg.state = 'separated'; + return { done: true, broke: false }; + } + return { done: false, broke: false }; +} + +/** Dump the ruined bowl and start the whites again. Costs real time; clears the sin. */ +export function dumpBowl(s: EggSession): void { + if (!s.bowlRuined && s.bowlShell === 0) return; + s.bowlsDumped++; + s.seconds += 6; + s.bowlRuined = false; + s.rottenIn = 0; + s.bowlShell = 0; + // Separated eggs went with the bowl — their work is lost. + for (const e of s.eggs) if (e.state === 'separated') e.state = 'binned'; +} + +export interface EggResult { + /** Cleanly separated eggs vs asked. */ + separated: number; + need: number; + /** Shell still IN the bowl at serve (fished ones are forgiven). */ + shellInBowl: number; + /** The whites are ruined (rotten or broken yolk still in there). */ + bowlRuined: boolean; + rottenIn: number; + yolksBroken: number; + testsRun: number; + bowlsDumped: number; +} + +export function eggResult(s: EggSession): EggResult { + return { + separated: s.eggs.filter((e) => e.state === 'separated').length, + need: s.need, + shellInBowl: s.bowlShell, + bowlRuined: s.bowlRuined, + rottenIn: s.rottenCracked, + yolksBroken: s.eggs.filter((e) => e.yolkBroken).length, + testsRun: s.testsRun, + bowlsDumped: s.bowlsDumped, + }; +} + +/** The carton lid's date band — information, not certainty. */ +export function cartonWord(cartonAge: number): string { + if (cartonAge < 0.3) return 'best before: next week'; + if (cartonAge < 0.6) return 'best before: about now'; + return 'best before: a while ago, love'; +}