THE EGGS: day 20 — the crack, the separation, the float test, the beat

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 01:53:08 +10:00
parent 64bf04c0b6
commit 717ef56150
9 changed files with 939 additions and 10 deletions

View File

@ -120,6 +120,49 @@ export class Audio {
src.stop(t + 0.2); 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. */ /** The judge's stamp. */
stamp(): void { stamp(): void {
const ctx = this.ctx; const ctx = this.ctx;

View File

@ -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 { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan';
import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain'; 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 { 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 * 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 }; 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. ---- // ---- THE RIB EYE: rest clock (the flood) + the grain-aware cut. ----
/** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain /** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain

View File

@ -9,6 +9,7 @@ import { GrillView } from '../scenes/grill';
import { PanView } from '../scenes/pan'; import { PanView } from '../scenes/pan';
import { FridgeView } from '../scenes/fridge'; import { FridgeView } from '../scenes/fridge';
import { SteakBoardView } from '../scenes/steakboard'; import { SteakBoardView } from '../scenes/steakboard';
import { EggBenchView } from '../scenes/eggbench';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly'; import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting'; import type { CutPattern } from '../sim/cutting';
@ -17,7 +18,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock'; import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting'; import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; 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 { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -62,6 +63,7 @@ export class Game {
private pan: PanView; private pan: PanView;
private fridge: FridgeView; private fridge: FridgeView;
private steakBoard: SteakBoardView; private steakBoard: SteakBoardView;
private eggBench: EggBenchView;
private assembly: AssemblyView; private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */ /** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null; private prepResult: PrepResult | null = null;
@ -100,6 +102,7 @@ export class Game {
this.pan = new PanView(app); this.pan = new PanView(app);
this.fridge = new FridgeView(app); this.fridge = new FridgeView(app);
this.steakBoard = new SteakBoardView(app); this.steakBoard = new SteakBoardView(app);
this.eggBench = new EggBenchView(app);
this.assembly = new AssemblyView(app); this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root); app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root); app.scene.add(this.judgeView.root);
@ -112,6 +115,7 @@ export class Game {
app.scene.add(this.pan.root); app.scene.add(this.pan.root);
app.scene.add(this.fridge.root); app.scene.add(this.fridge.root);
app.scene.add(this.steakBoard.root); app.scene.add(this.steakBoard.root);
app.scene.add(this.eggBench.root);
app.scene.add(this.assembly.root); app.scene.add(this.assembly.root);
this.judgeView.root.visible = false; this.judgeView.root.visible = false;
this.drawer.root.visible = false; this.drawer.root.visible = false;
@ -123,6 +127,7 @@ export class Game {
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false; this.fridge.root.visible = false;
this.steakBoard.root.visible = false; this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
@ -238,6 +243,9 @@ export class Game {
get steakBoardView(): SteakBoardView { get steakBoardView(): SteakBoardView {
return this.steakBoard; return this.steakBoard;
} }
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get assemblyView(): AssemblyView { get assemblyView(): AssemblyView {
return this.assembly; return this.assembly;
} }
@ -408,6 +416,7 @@ export class Game {
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false; this.fridge.root.visible = false;
this.steakBoard.root.visible = false; this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
} }
@ -557,9 +566,45 @@ export class Game {
this.enterSteak(); this.enterSteak();
return; return;
} }
// An egg day: the carton, the glass, the bowl, and your nerve.
if (this.order.eggs) {
this.enterEggs();
return;
}
this.enterToastFlow(); 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<EggBenchView['result']>): 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 /** 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. */ * board, rest + cut, serve. Pan board judge, like the bruschetta chain. */
private enterSteak(): void { private enterSteak(): void {

View File

@ -12,6 +12,7 @@ import { grillWord } from '../sim/charcoal';
import type { PanResult } from '../sim/pan'; import type { PanResult } from '../sim/pan';
import type { storeHealth } from '../sim/coldchain'; import type { storeHealth } from '../sim/coldchain';
import type { SteakResult } from '../sim/steak'; import type { SteakResult } from '../sim/steak';
import type { EggResult } from '../sim/eggs';
export interface Criterion { export interface Criterion {
key: string; key: string;
@ -24,7 +25,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders. /** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */ * 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. */ /** 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: [] }; 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 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. // the flood, so cutting a hot steak bleeds points onto the board.

View File

@ -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. // The rib eye — the steakhouse grandfather who has watched a thousand cooks panic.
Object.assign(BANK, { Object.assign(BANK, {
steakcook: { steakcook: {

View File

@ -111,6 +111,9 @@ export interface Order {
/** The rib eye: sear it on the pan, then REST it and cut across the grain. /** 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. */ * `target` is the doneness asked; `grain` is the fibre direction to cut across. */
steak?: { fuel: string; target: 'rare' | 'medium' | 'well'; grain: number }; 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][] = [ 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.', 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 }, 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 — keep going, with everything cranked. */
@ -449,12 +467,13 @@ export const DAYS: Order[] = [
*/ */
export function proceduralOrder(day: number, rand: () => number): Order { export function proceduralOrder(day: number, rand: () => number): Order {
const roll = rand(); const roll = rand();
if (roll < 0.34) return proceduralToast(day, rand); if (roll < 0.32) return proceduralToast(day, rand);
if (roll < 0.47) return proceduralPrep(day, rand); if (roll < 0.44) return proceduralPrep(day, rand);
if (roll < 0.57) return proceduralJuice(day, rand); if (roll < 0.53) return proceduralJuice(day, rand);
if (roll < 0.69) return proceduralGrill(day, rand); if (roll < 0.64) return proceduralGrill(day, rand);
if (roll < 0.81) return proceduralPan(day, rand); if (roll < 0.75) return proceduralPan(day, rand);
if (roll < 0.92) return proceduralSteak(day, rand); if (roll < 0.85) return proceduralSteak(day, rand);
if (roll < 0.93) return proceduralEggs(day, rand);
return proceduralFridge(day, rand); return proceduralFridge(day, rand);
} }
@ -542,6 +561,16 @@ function proceduralSteak(day: number, rand: () => number): Order {
return o; 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 { function proceduralFridge(day: number, rand: () => number): Order {
const o = baseOrder(day, 'the delivery, at 6am', ''); const o = baseOrder(day, 'the delivery, at 6am', '');
void rand(); void rand();

380
src/scenes/eggbench.ts Normal file
View File

@ -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<typeof eggResult>) => 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();
}
}

View File

@ -107,6 +107,8 @@ export class JudgeView implements View {
// asked and what for, not the unread toast defaults. // asked and what for, not the unread toast defaults.
this.orderEl.textContent = order.grill this.orderEl.textContent = order.grill
? `Day ${order.day} · ${order.who} asked for the barbie` ? `Day ${order.day} · ${order.who} asked for the barbie`
: order.eggs
? `Day ${order.day} · ${order.who} asked for the eggs`
: order.steak : order.steak
? `Day ${order.day} · ${order.who} asked for the rib eye` ? `Day ${order.day} · ${order.who} asked for the rib eye`
: order.pan : order.pan
@ -123,7 +125,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card; // Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had. // a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some( 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<string, string> = { const headers: Record<string, string> = {
prep: 'THE PREP', prep: 'THE PREP',
@ -135,9 +137,10 @@ export class JudgeView implements View {
pan: 'THE PAN', pan: 'THE PAN',
cold: 'THE FRIDGE', cold: 'THE FRIDGE',
steak: 'THE STEAK', steak: 'THE STEAK',
eggs: 'THE EGGS',
bench: 'THE BENCH', bench: 'THE BENCH',
}; };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, spread: 4, bench: 5 }; const groupRank: Record<string, number> = { 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; let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped const ordered = grouped

212
src/sim/eggs.ts Normal file
View File

@ -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';
}