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:
parent
64bf04c0b6
commit
717ef56150
@ -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;
|
||||
|
||||
121
src/dev.ts
121
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
|
||||
|
||||
@ -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<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
|
||||
* board, rest + cut, serve. Pan → board → judge, like the bruschetta chain. */
|
||||
private enterSteak(): void {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
380
src/scenes/eggbench.ts
Normal file
380
src/scenes/eggbench.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
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<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;
|
||||
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
|
||||
const ordered = grouped
|
||||
|
||||
212
src/sim/eggs.ts
Normal file
212
src/sim/eggs.ts
Normal 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';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user