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);
|
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;
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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
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.
|
// 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
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