THE POACH: day 21 — the shiver, the vortex, the drop, the wobble test

The near-game, treated with respect (masterplan P3). sim/poach.ts (pure,
rides the heat-source bone): the pot chases the knob on the fuel's lag
and the poach wants the SHIVER (5.8-7.4) — cooler rags the white on the
way in, a rolling boil (>8.2) tears it the whole time. The VORTEX is the
reamer-twist at pot scale: swirl builds it, it DECAYS (swirl then drop,
not swirl then admire), and the strength at the drop is the judged form.
Two-layer set: white first, the yolk only firms once the white is mostly
there — lift in the window (white >=0.85, yolk <=0.35) or you've boiled
an egg the long way. Then DRAIN on the slotted spoon. Stirring a dropped
egg is vandalism (rags it directly).

scenes/poach.ts: WHEEL flame, circular-drag swirl (real angular sweep),
SPACE drop, L lift, hold-to-drain, ENTER serves; vortex streaks that spin
and fade, shiver-vs-boil bubbles, the white gathering tighter as it sets,
a hard yolk going pale. judgePoach: THE POACH (White/Wobble/Form) + The
Drain; the wobble line is the dossier's: 'It should tremble. Like you,
right now.' Day 21 authored + poach in the endless rotation (electric
pot past day 27).

Verified with REAL input: shiver + 91% vortex + lift-at-tremble + drain
= 9.9/10 'it TREMBLES — perfect', 'one neat comet (vortex 91%)'; still
water into a rolling boil, overstayed, undrained = 3.6/10, 'a pot of
rags', 'it does not move at all'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 02:34:50 +10:00
parent 717ef56150
commit bffee40c6f
8 changed files with 744 additions and 6 deletions

View File

@ -637,6 +637,50 @@ export function installDevHarness(app: App, game: Game): void {
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows };
},
/** Play the day-21 poach with REAL input. good=true: shiver + vortex + drop
* + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */
poachDay(good = true) {
game.setDay(21);
run(6);
const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session;
if (!s) return { error: 'not at poach' };
const swirlCircles = (turns: number) => {
// real circular drags over the pot
for (let k = 0; k < turns * 24; k++) {
const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true);
run(1);
}
point(1.6, 0.7, 1.6, false);
run(2);
};
if (good) {
s.src.target = 7; // the shiver band
let g = 0;
while (s.src.output < 6 && g++ < 1200) run(1);
swirlCircles(3);
tap('Space'); // the drop, into the spin
g = 0;
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1);
tap('KeyL');
for (let i = 0; i < 60 * 4; i++) run(1); // drain
} else {
s.src.target = 9; // straight to a rolling boil
let g = 0;
while (s.src.output < 8.5 && g++ < 1200) run(1);
tap('Space'); // no vortex, into the boil
for (let i = 0; i < 60 * 30; i++) run(1); // overstay — yolk hardens, rags tear
tap('KeyL');
// no drain
}
tap('Enter');
run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const q = (n: number) => +n.toFixed(3);
return { white: q(s.whiteSet), yolk: q(s.yolkSet), rags: q(s.rags), vortexAtDrop: q(s.vortexAtDrop), 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

View File

@ -10,6 +10,7 @@ import { PanView } from '../scenes/pan';
import { FridgeView } from '../scenes/fridge';
import { SteakBoardView } from '../scenes/steakboard';
import { EggBenchView } from '../scenes/eggbench';
import { PoachView } from '../scenes/poach';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -18,7 +19,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, 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';
@ -64,6 +65,7 @@ export class Game {
private fridge: FridgeView;
private steakBoard: SteakBoardView;
private eggBench: EggBenchView;
private poach: PoachView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -103,6 +105,7 @@ export class Game {
this.fridge = new FridgeView(app);
this.steakBoard = new SteakBoardView(app);
this.eggBench = new EggBenchView(app);
this.poach = new PoachView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -116,6 +119,7 @@ export class Game {
app.scene.add(this.fridge.root);
app.scene.add(this.steakBoard.root);
app.scene.add(this.eggBench.root);
app.scene.add(this.poach.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -128,6 +132,7 @@ export class Game {
this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.poach.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -246,6 +251,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get poachView(): PoachView {
return this.poach;
}
get assemblyView(): AssemblyView {
return this.assembly;
}
@ -417,6 +425,7 @@ export class Game {
this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.poach.root.visible = false;
this.assembly.root.visible = false;
}
@ -571,9 +580,44 @@ export class Game {
this.enterEggs();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
return;
}
this.enterToastFlow();
}
/** The poach pot: spin it, drop it, lift it, drain it, serve it. */
private enterPoach(): void {
this.poach.reset(this.order.poach!.fuel);
this.hideAllScenes();
this.poach.root.visible = true;
this.app.setView(this.poach);
this.ticket.show();
this.poach.onDone = (result) => {
this.poach.root.visible = false;
this.servePoach(result);
};
}
private servePoach(result: ReturnType<PoachView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgePoach(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 egg bench: float-test the suspects, crack, separate, serve. */
private enterEggs(): void {
const e = this.order.eggs!;

View File

@ -13,6 +13,7 @@ import type { PanResult } from '../sim/pan';
import type { storeHealth } from '../sim/coldchain';
import type { SteakResult } from '../sim/steak';
import type { EggResult } from '../sim/eggs';
import type { PoachResult } from '../sim/poach';
export interface Criterion {
key: string;
@ -25,7 +26,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' | 'eggs';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -557,6 +558,64 @@ export function judgeEggs(r: EggResult, order: Order, seconds: number): Verdict
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The poach — the white, the wobble, the form, the drain. He presses it.
export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'poachwhite',
group: 'poach',
label: 'The White',
score: clamp01(r.whiteScore),
weight: 1.2,
detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set',
},
{
key: 'poachwobble',
group: 'poach',
label: 'The Wobble',
score: clamp01(r.yolkScore),
weight: 1.3,
detail: r.wobbleWord,
},
{
key: 'poachform',
group: 'poach',
label: 'The Form',
score: clamp01(r.formScore),
weight: 1.0,
detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : `one neat comet (vortex ${Math.round(r.vortexAtDrop * 100)}%)`,
},
{
key: 'poachdrain',
group: 'bench',
label: 'The Drain',
score: clamp01(r.drainScore),
weight: 0.6,
detail: r.cling > 0.3 ? 'served with its own puddle' : 'drained on the spoon',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 60) / 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.

View File

@ -402,6 +402,40 @@ Object.assign(BANK, {
},
});
// The poach — he presses it. He always presses it.
Object.assign(BANK, {
poachwhite: {
bad: [
'The white never set. This is egg soup wearing a napkin.',
'Snotty. The water was too cool and we are both paying for it.',
],
good: ['The white is set clean through. Silk, not snot.', 'Set, tender, done. The shiver did its work.'],
},
poachwobble: {
bad: [
'I pressed it. Nothing. You boiled an egg the long way.',
'The yolk is a rock. The whole point of this dish is that it is not a rock.',
'It should tremble. Like you, right now.',
],
good: [
'I pressed it and it TREMBLED. That is the dish. That is the whole dish.',
'Liquid gold under silk. You lifted it at the exact right breath.',
],
},
poachform: {
bad: [
'This is not an egg, it is a weather event. Rags everywhere.',
'You dropped it into still water and it simply left. Spin the vortex FIRST.',
'Ribbons. The pot was rolling, or the drop was from orbit. Either way: ribbons.',
],
good: ['One neat comet. The vortex held it like a thought.', 'Tucked, tight, whole. The spin was there when it mattered.'],
},
poachdrain: {
bad: ['Served with its own puddle. The spoon has HOLES for a reason.', 'Poach water on the plate. The toast beneath is a casualty.'],
good: ['Drained properly. Dry plate, intact dignity.'],
},
});
// The rib eye — the steakhouse grandfather who has watched a thousand cooks panic.
Object.assign(BANK, {
steakcook: {

View File

@ -114,6 +114,8 @@ export interface Order {
/** 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 };
/** The poach: fuel under the pot. The vortex, the shiver, the drop, the wobble. */
poach?: { fuel: string };
}
export const BROWNING_NAMES: [number, string][] = [
@ -455,6 +457,21 @@ export const DAYS: 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 },
},
{
day: 21,
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 benedict table',
text: 'One poached egg, proper. Bring the water to a SHIVER — not a boil, it will tear. Spin a vortex, drop it in the eye, and lift it when the white sets and the yolk still trembles. I am going to press it.',
poach: { fuel: 'gas' },
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -472,8 +489,9 @@ export function proceduralOrder(day: number, rand: () => number): Order {
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);
if (roll < 0.84) return proceduralSteak(day, rand);
if (roll < 0.91) return proceduralEggs(day, rand);
if (roll < 0.96) return proceduralPoach(day, rand);
return proceduralFridge(day, rand);
}
@ -571,6 +589,15 @@ function proceduralEggs(day: number, rand: () => number): Order {
return o;
}
function proceduralPoach(day: number, rand: () => number): Order {
// Electric arrives late here too — a pot with lag and memory is a cruel poach.
const fuel = day > 27 && rand() < 0.4 ? 'electric' : 'gas';
const o = baseOrder(day, pickOf(rand, ['the benedict table', 'a poet, apparently', 'the man who presses things']), '');
o.poach = { fuel };
o.text = `One poached egg${fuel === 'electric' ? ' — on the electric, mind the lag' : ''}. Shiver, vortex, drop, lift. White set, yolk trembling. I will press it.`;
return o;
}
function proceduralFridge(day: number, rand: () => number): Order {
const o = baseOrder(day, 'the delivery, at 6am', '');
void rand();

View File

@ -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.poach
? `Day ${order.day} · ${order.who} asked for the poached egg`
: order.eggs
? `Day ${order.day} · ${order.who} asked for the eggs`
: order.steak
@ -125,7 +127,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.group === 'eggs',
(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' || c.group === 'poach',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -138,9 +140,10 @@ export class JudgeView implements View {
cold: 'THE FRIDGE',
steak: 'THE STEAK',
eggs: 'THE EGGS',
poach: 'THE POACH',
bench: 'THE BENCH',
};
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 };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

343
src/scenes/poach.ts Normal file
View File

@ -0,0 +1,343 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import {
type PoachSession,
newPoachSession,
setPotKnob,
swirl,
dropEgg,
liftEgg,
drainStep,
poachStep,
poachResult,
waterWord,
SHIVER_LO,
SHIVER_HI,
BOIL_AT,
WHITE_SET,
YOLK_TREMBLE,
} from '../sim/poach';
import { el, Panel } from '../ui/hud';
const POT_Y = 0.55;
const POT_R = 1.15;
/**
* THE POACH POT the vortex, the shiver, the drop, the lift.
*
* WHEEL sets the flame (the water answers on the fuel's lag watch for the
* SHIVER, not a thermometer). DRAG CIRCLES over the pot to spin the vortex
* it decays, so swirl then drop, not swirl then admire. SPACE drops the egg
* into the eye. Wait for the white to gather and set. L lifts it onto the
* spoon; HOLD it there to drain. ENTER serves and he WILL press it.
*/
export class PoachView implements View {
readonly root = new THREE.Group();
private session: PoachSession | null = null;
private flame!: THREE.Mesh;
private water!: THREE.Mesh;
private streaks: THREE.Line[] = [];
private streakAngle = 0;
private bubbles: THREE.Mesh[] = [];
private eggWhite!: THREE.Mesh;
private eggYolk!: THREE.Mesh;
private ragBits: THREE.Mesh[] = [];
private spoon!: THREE.Group;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.12));
private lastAngle: number | null = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof poachResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('poach-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, 'POACH IT');
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: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
// Burner + flame under the pot (the pan's grammar).
const burner = new THREE.Mesh(
new THREE.CylinderGeometry(0.85, 0.95, 0.12, 24),
new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.8, metalness: 0.4 }),
);
burner.position.y = 0.06;
this.root.add(burner);
this.flame = new THREE.Mesh(
new THREE.ConeGeometry(0.65, 0.45, 20),
new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }),
);
this.flame.position.y = 0.32;
this.root.add(this.flame);
// The pot.
const pot = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R, POT_R * 0.92, 0.75, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x8a8f94, roughness: 0.35, metalness: 0.7, side: THREE.DoubleSide }),
);
pot.position.y = POT_Y - 0.05;
this.root.add(pot);
this.water = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R * 0.93, POT_R * 0.93, 0.06, 32),
new THREE.MeshStandardMaterial({ color: 0x7fa8bc, roughness: 0.2, transparent: true, opacity: 0.75 }),
);
this.water.position.y = POT_Y + 0.1;
this.root.add(this.water);
// Vortex streaks: arcs that spin with the swirl.
const streakMat = new THREE.LineBasicMaterial({ color: 0xd8ecf4, transparent: true, opacity: 0.0 });
for (let i = 0; i < 3; i++) {
const pts: THREE.Vector3[] = [];
const r = POT_R * (0.35 + i * 0.2);
for (let a = 0; a <= 20; a++) {
const th = (a / 20) * Math.PI * 1.1;
pts.push(new THREE.Vector3(Math.cos(th) * r, POT_Y + 0.14, Math.sin(th) * r));
}
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), streakMat);
this.streaks.push(line);
this.root.add(line);
}
// Shiver/boil bubbles.
for (let i = 0; i < 10; i++) {
const b = new THREE.Mesh(
new THREE.SphereGeometry(0.035, 8, 6),
new THREE.MeshBasicMaterial({ color: 0xeef6fa, transparent: true, opacity: 0 }),
);
b.position.set((Math.random() - 0.5) * POT_R * 1.4, POT_Y + 0.14, (Math.random() - 0.5) * POT_R * 1.4);
this.bubbles.push(b);
this.root.add(b);
}
// The egg: a white comet + a yolk, hidden until the drop.
this.eggWhite = new THREE.Mesh(
new THREE.SphereGeometry(0.3, 16, 12),
new THREE.MeshStandardMaterial({ color: 0xf2efe6, roughness: 0.5, transparent: true, opacity: 0.65 }),
);
this.eggWhite.scale.set(1.25, 0.5, 1.1);
this.eggWhite.position.set(0, POT_Y + 0.12, 0);
this.eggWhite.visible = false;
this.root.add(this.eggWhite);
this.eggYolk = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 14, 10),
new THREE.MeshStandardMaterial({ color: 0xf0b428, roughness: 0.45 }),
);
this.eggYolk.scale.set(1, 0.65, 1);
this.eggYolk.position.set(0, POT_Y + 0.16, 0);
this.eggYolk.visible = false;
this.root.add(this.eggYolk);
// The slotted spoon, arriving at the lift.
this.spoon = new THREE.Group();
const bowl = new THREE.Mesh(
new THREE.SphereGeometry(0.38, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2.4),
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7, side: THREE.DoubleSide }),
);
bowl.rotation.x = Math.PI;
const handle = new THREE.Mesh(
new THREE.BoxGeometry(1.3, 0.05, 0.1),
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7 }),
);
handle.position.set(0.85, 0.12, 0);
this.spoon.add(bowl, handle);
this.spoon.visible = false;
this.root.add(this.spoon);
}
/** A fresh pot on `fuel`, cold water, no egg. */
reset(fuel = 'gas', askText = 'one poached egg — white set, yolk trembling'): void {
this.session = newPoachSession(fuel);
this.lastAngle = null;
this.eggWhite.visible = false;
this.eggYolk.visible = false;
this.spoon.visible = false;
for (const r of this.ragBits) this.root.remove(r);
this.ragBits = [];
this.askEl.textContent = askText;
this.hintEl.textContent = 'WHEEL — flame · DRAG CIRCLES — spin the vortex · SPACE — drop · L — lift · hold to drain · ENTER serves';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.6, 3.1);
this.app.camera.lookAt(0, POT_Y, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
if (inp.wheel !== 0) {
const k = Math.max(0, Math.min(9, Math.round(s.src.target) + (inp.wheel > 0 ? 1 : -1)));
setPotKnob(s, k);
}
// Circular drag over the pot = the swirl (angular sweep around the centre).
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
if (inp.down && on && Math.hypot(this.hit.x, this.hit.z) < POT_R * 1.15) {
const ang = Math.atan2(this.hit.z, this.hit.x);
if (this.lastAngle !== null) {
let d = ang - this.lastAngle;
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
swirl(s, d);
if (Math.abs(d) > 0.02 && !s.dropped) audio.scrape(0.3, Math.abs(d) * 3);
}
this.lastAngle = ang;
} else {
this.lastAngle = null;
}
if (inp.justPressed('Space') && !s.dropped) {
dropEgg(s, 0);
this.eggWhite.visible = true;
this.eggYolk.visible = true;
audio.clatter(0.4, 0.9);
// Rags appear immediately if the drop was bad.
this.spawnRags(s.rags);
}
if (inp.justPressed('KeyL') && s.dropped && !s.lifted) {
liftEgg(s);
this.spoon.visible = true;
audio.clunk(true);
this.hintEl.textContent = 'hold it on the spoon — let it DRAIN · ENTER serves';
}
if (s.lifted) drainStep(s, dt);
poachStep(s, dt);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter') && s.dropped) {
const cb = this.onDone;
this.onDone = null;
cb?.(poachResult(s));
}
}
private spawnRags(rags: number): void {
const n = Math.round(rags * 6);
for (let i = this.ragBits.length; i < n; i++) {
const bit = new THREE.Mesh(
new THREE.SphereGeometry(0.07 + Math.random() * 0.06, 8, 6),
new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.6, transparent: true, opacity: 0.7 }),
);
const a = Math.random() * Math.PI * 2;
const r = POT_R * (0.4 + Math.random() * 0.45);
bit.scale.y = 0.4;
bit.position.set(Math.cos(a) * r, POT_Y + 0.12, Math.sin(a) * r);
this.ragBits.push(bit);
this.root.add(bit);
}
}
private syncVisuals(dt: number): void {
const s = this.session!;
const t = s.src.output;
// Flame answers the knob.
const fm = this.flame.material as THREE.MeshBasicMaterial;
fm.opacity = s.src.flame ? Math.min(0.8, t / 10) : 0;
this.flame.scale.y = 0.5 + t / 9;
// Vortex streaks spin with the swirl and fade with it.
this.streakAngle += s.vortex * dt * 7;
const sm = this.streaks[0].material as THREE.LineBasicMaterial;
sm.opacity = Math.min(0.85, s.vortex * 1.1);
this.streaks.forEach((line, i) => {
line.rotation.y = this.streakAngle * (1 + i * 0.15);
});
// Bubbles: none when still, a shy shiver in band, a jitterbug at boil.
const inBand = t >= SHIVER_LO && t <= SHIVER_HI;
const boiling = t > BOIL_AT;
for (const b of this.bubbles) {
const bm = b.material as THREE.MeshBasicMaterial;
bm.opacity = boiling ? 0.85 : inBand ? 0.35 : t > 4 ? 0.12 : 0;
if (boiling) b.position.y = POT_Y + 0.14 + Math.abs(Math.sin(s.seconds * 9 + b.position.x * 7)) * 0.09;
else b.position.y = POT_Y + 0.14 + (inBand ? Math.abs(Math.sin(s.seconds * 3.5 + b.position.z * 5)) * 0.02 : 0);
}
// The egg gathers as it sets: opacity and tightness track whiteSet; it rides
// the vortex; the spoon lifts it clear.
if (s.dropped) {
const em = this.eggWhite.material as THREE.MeshStandardMaterial;
em.opacity = 0.45 + s.whiteSet * 0.55;
const gather = 1.35 - s.whiteSet * 0.35 + s.rags * 0.3;
this.eggWhite.scale.set(gather, 0.5 + s.whiteSet * 0.12, gather * 0.9);
const ym = this.eggYolk.material as THREE.MeshStandardMaterial;
ym.color.setHex(s.yolkSet > 0.6 ? 0xd8c26a : 0xf0b428); // a hard yolk goes pale
const y = s.lifted ? POT_Y + 0.55 : POT_Y + 0.12;
this.eggWhite.position.set(0, y, 0);
this.eggYolk.position.set(0, y + 0.05, 0);
if (!s.lifted) {
this.eggWhite.rotation.y += s.vortex * dt * 4;
} else {
this.spoon.position.set(0, POT_Y + 0.42, 0);
// Drips while it drains.
this.eggWhite.position.y = POT_Y + 0.55;
}
}
}
private updateHud(): void {
const s = this.session!;
const r = poachResult(s);
const vortexWord = s.vortex > 0.6 ? 'spinning hard' : s.vortex > 0.25 ? 'a lazy spin' : 'still';
this.stateEl.textContent = `flame ${Math.round(s.src.target)} · ${waterWord(s)} · water: ${vortexWord}`;
if (!s.dropped) {
this.wordEl.textContent = s.vortex > 0.5 && s.src.output >= SHIVER_LO && s.src.output <= SHIVER_HI ? 'NOW — drop it in the eye' : '';
this.wordEl.style.color = '#8fce8f';
} else if (!s.lifted) {
const word = s.whiteSet < 0.55 ? 'the white is gathering…' : s.whiteSet < WHITE_SET ? 'nearly set…' : s.yolkSet <= YOLK_TREMBLE ? 'SET — lift it NOW (L)' : 'it is overstaying';
this.wordEl.textContent = word;
this.wordEl.style.color = s.whiteSet >= WHITE_SET && s.yolkSet <= YOLK_TREMBLE ? '#8fce8f' : s.yolkSet > YOLK_TREMBLE ? '#e2603a' : '';
} else {
this.wordEl.textContent = s.cling > 0.3 ? 'draining…' : `drained — serve it. ${r.wobbleWord}`;
this.wordEl.style.color = s.cling > 0.3 ? '' : '#8fce8f';
}
}
result() {
return this.session
? poachResult(this.session)
: { whiteScore: 0, yolkScore: 0, formScore: 0, drainScore: 0, whiteSet: 0, yolkSet: 0, rags: 0, cling: 1, vortexAtDrop: 0, wobbleWord: 'there is no egg' };
}
dispose(): void {
this.panel.dispose();
}
}

184
src/sim/poach.ts Normal file
View File

@ -0,0 +1,184 @@
import { type HeatSource, newHeatSource, setKnob, heatStep } from './heatsource';
/**
* THE POACH almost a whole game, and treated like one.
*
* Four acts, none skippable:
*
* THE WATER the pot chases the knob on the fuel's lag (the heat-source
* bone). The poach wants the SHIVER that band where the surface
* trembles but nothing rolls. Too cool and the white disperses into
* rags; a rolling boil and it tears the egg apart while it cooks.
* THE VORTEX swirl the water first. A good vortex wraps the white around
* the yolk as it lands; drop into still water and the white walks off
* on its own. The vortex DECAYS swirl, then drop, not swirl and
* admire.
* THE DROP from low, into the eye of the spin. Height splats it.
* THE SET white sets first, then the yolk starts to firm. Lift when the
* white is set and the yolk still trembles; every second past that is
* a second toward a boiled egg in disguise. Then DRAIN it on the
* spoon nobody wants poach water on the plate.
*
* The wobble test at the pass reads yolkSet: the judge presses it, on camera.
* "It should tremble. Like you, right now."
*
* Pure no three.js the harness poaches headless.
*/
/** The shiver: where a poach wants the water. Above BOIL it tears. */
export const SHIVER_LO = 5.8;
export const SHIVER_HI = 7.4;
export const BOIL_AT = 8.2;
/** White is SET past here (snotty below ~0.6). */
export const WHITE_SET = 0.85;
/** Yolk still trembles below here; past it you've boiled an egg in disguise. */
export const YOLK_TREMBLE = 0.35;
/** How fast the vortex dies once you stop stirring. */
const VORTEX_DECAY = 0.09;
export interface PoachSession {
src: HeatSource;
/** Vortex strength 0..1 — built by swirling, decays on its own. */
vortex: number;
dropped: boolean;
lifted: boolean;
/** 0 raw .. 1 fully set. */
whiteSet: number;
/** 0 liquid .. 1 hard. The wobble test reads this. */
yolkSet: number;
/** White lost to rags — still water, a high drop, or a rolling boil. */
rags: number;
/** Poach water still clinging — drain on the spoon before the pass. */
cling: number;
/** Vortex strength at the moment of the drop (the judged number). */
vortexAtDrop: number;
tempAtDrop: number;
seconds: number;
}
export function newPoachSession(fuel = 'gas'): PoachSession {
return {
src: newHeatSource(fuel),
vortex: 0,
dropped: false,
lifted: false,
whiteSet: 0,
yolkSet: 0,
rags: 0,
cling: 1,
vortexAtDrop: 0,
tempAtDrop: 0,
seconds: 0,
};
}
export function setPotKnob(s: PoachSession, k: number): void {
setKnob(s.src, k);
}
/** Stir: add angular sweep (radians this frame) into the vortex. */
export function swirl(s: PoachSession, sweep: number): void {
if (s.dropped) {
// Stirring a dropped egg is vandalism — it rags the white directly.
s.rags = Math.min(1, s.rags + Math.abs(sweep) * 0.06);
return;
}
s.vortex = Math.min(1, s.vortex + Math.abs(sweep) * 0.055);
}
/**
* Drop the egg from `height` (0 rim-low .. 1 show-off). The vortex catches the
* white; still water and altitude both cost you form, immediately.
*/
export function dropEgg(s: PoachSession, height = 0): void {
if (s.dropped) return;
s.dropped = true;
s.vortexAtDrop = s.vortex;
s.tempAtDrop = s.src.output;
s.rags = Math.min(1, s.rags + (1 - s.vortex) * 0.45 + height * 0.4);
// Cold water at the drop lets the white walk before any heat can catch it.
if (s.src.output < SHIVER_LO) s.rags = Math.min(1, s.rags + (SHIVER_LO - s.src.output) * 0.12);
}
/** Lift it out on the spoon. The set stops; the drain begins. */
export function liftEgg(s: PoachSession): void {
if (s.dropped) s.lifted = true;
}
/** Hold it on the spoon: cling drains away. Patience, again. */
export function drainStep(s: PoachSession, dt: number): void {
if (s.lifted) s.cling = Math.max(0, s.cling - dt / 4);
}
export function poachStep(s: PoachSession, dt: number): void {
heatStep(s.src, dt);
s.vortex = Math.max(0, s.vortex - VORTEX_DECAY * dt);
const t = s.src.output;
if (s.dropped && !s.lifted) {
// The white sets fastest in the shiver; cooler is slower (and it rags on
// the way in); a boil sets it fast but TEARS it the whole time.
const inBand = t >= SHIVER_LO && t <= SHIVER_HI;
const rate = inBand ? 0.055 : t > SHIVER_HI ? 0.07 : t > 4 ? 0.03 : 0.012;
s.whiteSet = Math.min(1, s.whiteSet + rate * dt);
if (t > BOIL_AT) s.rags = Math.min(1, s.rags + (t - BOIL_AT) * 0.045 * dt);
// The yolk only starts once the white is mostly there.
if (s.whiteSet > 0.75) s.yolkSet = Math.min(1, s.yolkSet + 0.035 * (inBand ? 1 : 1.4) * dt);
}
s.seconds += dt;
}
export interface PoachResult {
/** Set, not snotty. */
whiteScore: number;
/** The wobble: liquid yolk full marks, hard yolk none. */
yolkScore: number;
/** One neat comet, not a pot of rags. */
formScore: number;
/** Drained on the spoon, or served with its own puddle. */
drainScore: number;
whiteSet: number;
yolkSet: number;
rags: number;
cling: number;
vortexAtDrop: number;
wobbleWord: string;
}
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
export function poachResult(s: PoachSession): PoachResult {
const whiteScore = !s.dropped ? 0 : clamp01((s.whiteSet - 0.55) / (WHITE_SET - 0.55));
const yolkScore = !s.dropped ? 0 : clamp01(1 - Math.max(0, s.yolkSet - YOLK_TREMBLE) / 0.4);
const formScore = clamp01(1 - s.rags * 1.3);
const drainScore = clamp01(1 - s.cling * 0.9);
const wobbleWord =
!s.dropped ? 'there is no egg'
: s.yolkSet < 0.15 && s.whiteSet < WHITE_SET ? 'it collapses — raw inside'
: s.yolkSet <= YOLK_TREMBLE ? 'it TREMBLES — perfect'
: s.yolkSet < 0.6 ? 'a stiff little nod'
: 'it does not move at all';
return {
whiteScore,
yolkScore,
formScore,
drainScore,
whiteSet: s.whiteSet,
yolkSet: s.yolkSet,
rags: s.rags,
cling: s.cling,
vortexAtDrop: s.vortexAtDrop,
wobbleWord,
};
}
/** The water's diegetic tell — never a thermometer. */
export function waterWord(s: PoachSession): string {
const t = s.src.output;
if (t < 3) return 'still water';
if (t < SHIVER_LO) return 'warming — not yet';
if (t <= SHIVER_HI) return 'a gentle SHIVER — now';
if (t <= BOIL_AT) return 'simmering hard';
return 'ROLLING — it will tear';
}