THE STEAMER: day 26 — the lid is opaque, and that is the whole game

The last of the atlas's water page, and the only station that takes your
EYES away. Steam works under a lid; a lid you can see through is not a
lid. So every few seconds you make the same trade: LID ON and it cooks
properly while you are completely blind, or LID OFF and you can read
exactly how green they are while the steam walks out of the pot and the
clock drops to a fifth. There is no doneness meter here — the state line
refuses to print one, because with the lid on a cook could not know it.
There is a lid, and there is your nerve.

The lid is opaque in the literal sense: an actual dome that occludes the
florets in 3D. Take it off and it parks beside the pot and the greens
are simply readable again. That is the entire UI.

VIBRANCY is the headline row, exactly as the atlas asked — grey broccoli
is a visible confession. Steam keeps colour; a violent boil that leaps
up through the basket murders it, and so does cooking them past tender.
The florets slump as well as fade, so the shape confesses before the
colour does.

And the pot boils dry. That is the reason not to crank the flame to nine
and walk away: scorched metal caps the plate at 2.5, the same doctrine
as brine in the pasta water. First tuning had it drying in 58 seconds,
which made an enthusiastic cook flip a coin rather than make a mistake —
softened so neglect is punished and enthusiasm is not.

Verified: honest steam 10.0 (vivid, tender, water to spare); the hard
boil 4.7 with 'army green. I can see it from here' and 'that was a BOIL.
steam is gentler than you were'; the dry pot 0.7. Pasta, chips and eggs
all still 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:34:19 +10:00
parent 35e871599c
commit 9ec99add02
7 changed files with 668 additions and 8 deletions

View File

@ -678,6 +678,33 @@ 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-26 greens with REAL input: steam in the band, lid ON to
* cook, lift it to LOOK near the end, serve tender and still green. */
async steamDay() {
game.setDay(26);
await F(6);
const sv = game.steamerView as unknown as { session: import('./sim/steamer').SteamerSession };
const s = sv.session;
if (!s) return { error: 'not at steamer' };
s.knob = 7; // steam, well under the leaping boil
let g = 0;
while (s.heat < 6.6 && g++ < 60 * 60) await F(1);
tap('Space'); // lid ON — blind, but it cooks
g = 0;
while (s.cook < 40 && g++ < 60 * 120) await F(1);
tap('Space'); // lift it and LOOK
await F(45);
tap('Space'); // back on for the last of it
g = 0;
while (s.cook < 48 && g++ < 60 * 60) await F(1);
tap('Space'); // off to serve
await F(6);
tap('Enter');
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { cook: Math.round(s.cook), vibrancy: s.vibrancy.toFixed(2), boiled: s.boiled.toFixed(1), water: s.water.toFixed(2), rows };
},
/** Play the day-25 pasta with REAL input: rolling boil, salt poured to the
* band and STOPPED there, pasta to al dente, a cup kept back, drained. */
async potDay() {
@ -1519,6 +1546,7 @@ export function installDevHarness(app: App, game: Game): void {
23: () => harness.fryerDay(),
24: () => harness.chipDay(),
25: () => harness.potDay(),
26: () => harness.steamDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -14,6 +14,7 @@ import { PoachView } from '../scenes/poach';
import { AirFryerView } from '../scenes/airfryer';
import { FryerPotView } from '../scenes/fryerpot';
import { PotView } from '../scenes/pot';
import { SteamerView } from '../scenes/steamer';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -22,7 +23,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, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, 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';
@ -86,6 +87,7 @@ export class Game {
private airFryer: AirFryerView;
private fryerPot: FryerPotView;
private pot: PotView;
private steamer: SteamerView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -138,6 +140,7 @@ export class Game {
this.airFryer = new AirFryerView(app);
this.fryerPot = new FryerPotView(app);
this.pot = new PotView(app);
this.steamer = new SteamerView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -155,6 +158,7 @@ export class Game {
app.scene.add(this.airFryer.root);
app.scene.add(this.fryerPot.root);
app.scene.add(this.pot.root);
app.scene.add(this.steamer.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -171,6 +175,7 @@ export class Game {
this.airFryer.root.visible = false;
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -328,6 +333,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get steamerView(): SteamerView {
return this.steamer;
}
get potView(): PotView {
return this.pot;
}
@ -512,6 +520,7 @@ export class Game {
this.airFryer.root.visible = false;
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.assembly.root.visible = false;
}
@ -660,13 +669,14 @@ export class Game {
if (o.airfryer) return this.enterAirFryer();
if (o.fryerpot) return this.enterFryerPot();
if (o.pot) return this.enterPot();
if (o.steamer) return this.enterSteamer();
this.enterToastFlow();
}
/** The one-line shareable result for the current order's verdict. */
private shareLine(v: Verdict): string {
const o = this.order;
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
}
@ -763,6 +773,11 @@ export class Game {
this.enterPot();
return;
}
// Greens: the lid is opaque, and that is the entire game.
if (this.order.steamer) {
this.enterSteamer();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -971,6 +986,33 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterSteamer(): void {
this.steamer.reset('greens \u2014 tender, and still GREEN');
this.hideAllScenes();
this.steamer.root.visible = true;
this.app.setView(this.steamer);
this.ticket.show();
this.steamer.onDone = (result) => {
this.steamer.root.visible = false;
this.serveSteamer(result);
};
}
private serveSteamer(result: ReturnType<SteamerView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSteamer(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterPot(): void {
this.pot.reset('pasta \u2014 salted like the sea, rolling boil, al dente');
this.hideAllScenes();

View File

@ -15,6 +15,7 @@ import type { SteakResult } from '../sim/steak';
import type { EggResult } from '../sim/eggs';
import type { PoachResult } from '../sim/poach';
import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot';
import type { SteamerResult } from '../sim/steamer';
export interface Criterion {
key: string;
@ -27,7 +28,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' | 'poach' | 'fryer' | 'pot';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -617,6 +618,91 @@ export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdi
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The steamer — the only card where COLOUR is the headline row. Grey broccoli
// is a visible confession and he will not be talked out of it.
export function judgeSteamer(r: SteamerResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'steamcolour',
group: 'steam',
label: 'The Colour',
score: clamp01(r.vibrancy),
weight: 1.4,
detail: r.scorched
? 'grey, and tasting of a dry pot'
: r.vibrancy > 0.82
? 'vivid. as green on the plate as in the box'
: r.vibrancy > 0.6
? 'a little dulled'
: r.vibrancy > 0.38
? 'drab. this is what a hard boil does'
: 'army green. I can see it from here',
},
{
key: 'steambite',
group: 'steam',
label: 'The Bite',
score: clamp01(r.bite),
weight: 1.2,
detail: r.cook < 18
? 'raw and squeaking against the teeth'
: r.cook < 38
? 'firm — it wanted another minute'
: r.cook <= 56
? 'tender, with something still to bite'
: r.cook < 81
? 'soft, past its best'
: 'collapsed. this is a side dish that gave up',
},
{
key: 'steamgentle',
group: 'steam',
label: 'The Steam',
score: clamp01(1 - r.boiled / 22),
weight: 1.0,
detail: r.boiled > 14
? 'that was a BOIL. steam is gentler than you were'
: r.boiled > 4
? 'the water was leaping into the basket for a while'
: 'steamed, properly — the water stayed where it belongs',
},
{
key: 'steampot',
group: 'bench',
label: 'The Pot',
score: r.scorched ? 0 : clamp01(0.55 + r.water * 0.9),
weight: 0.7,
detail: r.scorched
? 'you boiled the pot DRY. the kitchen still smells of it'
: r.water < 0.25
? 'very nearly dry — I was watching it'
: 'water to spare',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 100) / 130),
weight: 0.3,
detail: `${Math.round(seconds)}s${r.peeks ? `, looked ${r.peeks}\u00d7` : ''}`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
// A scorched pot is not a bad row, it is a ruined dish and a smell that
// outlives the service. Same doctrine as brine in the pasta water.
const total = Math.min((sum / wsum) * 10, r.scorched ? 2.5 : 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 pot — pasta night. The Salt row is the only one in the game that judges
// a decision you could never take back, which is exactly why it is first.

View File

@ -126,6 +126,8 @@ export interface Order {
fryerpot?: { count: number };
/** THE POT: pasta night. Salt is the one verb you cannot take back. */
pot?: { dish: string };
/** THE STEAMER: the lid is opaque. Cooking blind is the game. */
steamer?: { veg: string };
}
export const BROWNING_NAMES: [number, string][] = [
@ -542,6 +544,21 @@ export const DAYS: Order[] = [
text: 'Pasta. Salt the water like the sea \u2014 and know that you can always add more and never take it back, so stop when it is right. A ROLLING boil, not a simmer, or it glues. Al dente means bite. And keep a cup of that water before you drain it.',
pot: { dish: 'pasta' },
},
{
day: 26,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the greens order',
text: 'Steamed greens. STEAMED \u2014 not boiled, I can tell the difference by looking at them. Lid on or it will never cook, lid off if you want to see what you are doing, and you cannot have both. Tender, and still green.',
steamer: { veg: 'broccoli' },
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -563,7 +580,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
if (roll < 0.89) return proceduralEggs(day, rand);
if (roll < 0.915) return proceduralAirfryer(day, rand);
if (roll < 0.925) return proceduralFryerPot(day, rand);
if (roll < 0.94) return proceduralPot(day, rand);
if (roll < 0.935) return proceduralPot(day, rand);
if (roll < 0.95) return proceduralSteamer(day, rand);
if (roll < 0.945) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand);
@ -620,12 +638,23 @@ export function stationOf(o: Order): string | null {
if (o.airfryer) return 'airfryer';
if (o.fryerpot) return 'fryerpot';
if (o.pot) return 'pot';
if (o.steamer) return 'steamer';
return null;
}
/** A day whose whole job is bread and a spread — the starter's only business. */
export const isToastDay = (o: Order): boolean => stationOf(o) === null;
function proceduralSteamer(day: number, rand: () => number): Order {
const o = baseOrder(
day,
pickOf(rand, ['the greens order', 'the chef, on her day off', 'Deidre', 'a regular']),
'Greens, steamed. Tender, still green, and nothing boiled into submission.',
);
o.steamer = { veg: 'broccoli' };
return o;
}
const pickOf = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)];
/** The toast fields every Order carries; station days never read most of them. */

View File

@ -195,7 +195,7 @@ export class JudgeView implements View {
// Grill and bruschetta days don't have a browning/spread to name — show who
// asked and what for, not the unread toast defaults.
// The dish itself, in miniature, beside the ask.
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : null;
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : null;
this.orderEl.parentElement?.querySelector('.judge-hero')?.remove();
if (heroKey) {
const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement;
@ -203,7 +203,9 @@ export class JudgeView implements View {
hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`);
this.orderEl.parentElement?.insertBefore(hero, this.orderEl);
}
this.orderEl.textContent = order.pot
this.orderEl.textContent = order.steamer
? `Day ${order.day} · ${order.who} asked for the greens`
: order.pot
? `Day ${order.day} · ${order.who} asked for pasta`
: order.fryerpot
? `Day ${order.day} · ${order.who} asked for the chips`
@ -233,7 +235,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.group === 'poach' || c.group === 'fryer' || c.group === 'pot',
(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' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -249,9 +251,10 @@ export class JudgeView implements View {
poach: 'THE POACH',
fryer: 'THE FRYER',
pot: 'THE POT',
steam: 'THE STEAMER',
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, poach: 3, fryer: 3, pot: 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, fryer: 3, pot: 3, steam: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

303
src/scenes/steamer.ts Normal file
View File

@ -0,0 +1,303 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type SteamerSession,
newSteamerSession,
setSteamerHeat,
setLid,
steamerStep,
steamerResult,
steamWord,
greensWord,
STEAM_LO,
BOIL_OVER,
TENDER_LO,
TENDER_HI,
} from '../sim/steamer';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
import { loadProp } from './assets';
const POT_Y = 0.55;
const POT_R = 1.15;
const BASKET_Y = POT_Y + 0.44;
/**
* THE STEAMER the station that takes your eyes away.
*
* WHEEL sets the flame: enough for steam, not so much that the water leaps up
* through the basket and murders the colour. SPACE puts the lid ON (it cooks,
* and you cannot see a thing) or takes it OFF (you can see exactly how green
* they are, while the steam walks out and the clock nearly stops). ENTER
* serves. There is no doneness meter. There is a lid, and there is your nerve.
*/
export class SteamerView implements View {
readonly root = new THREE.Group();
private session: SteamerSession | null = null;
private flame!: THREE.Mesh;
private water!: THREE.Mesh;
private basket!: THREE.Mesh;
private lid!: THREE.Group;
private florets: THREE.Mesh[] = [];
private wisps: THREE.Mesh[] = [];
private bgTex = loadBackdrop('/assets/img/bg_pot.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof steamerResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('steamer-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, 'STEAM 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(9, 0.4, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const burner = new THREE.Mesh(
new THREE.CylinderGeometry(0.9, 1.0, 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.66, 0.44, 20),
new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }),
);
this.flame.position.y = 0.31;
this.root.add(this.flame);
// The pot underneath — this one holds only water.
const pot = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R, POT_R * 0.94, 0.95, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x9298a0, roughness: 0.3, metalness: 0.8, side: THREE.DoubleSide }),
);
pot.position.y = POT_Y;
this.root.add(pot);
void loadProp('/assets/models/stock_pot.glb', 2.8)
.then((prop) => {
prop.position.set(0, 0.08, 0);
pot.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
this.water = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R * 0.88, POT_R * 0.88, 0.06, 32),
new THREE.MeshStandardMaterial({ color: 0x86aec2, roughness: 0.18, transparent: true, opacity: 0.85 }),
);
this.water.position.y = POT_Y + 0.1;
this.root.add(this.water);
// The basket sits ABOVE the water, on the rim. That gap is the whole idea.
this.basket = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R * 0.82, POT_R * 0.6, 0.34, 24, 1, true),
new THREE.MeshStandardMaterial({ color: 0xa8aeb4, roughness: 0.35, metalness: 0.75, side: THREE.DoubleSide, transparent: true, opacity: 0.75 }),
);
this.basket.position.y = BASKET_Y;
this.root.add(this.basket);
// The greens. Their colour is the entire verdict, so they are lit plainly.
for (let i = 0; i < 7; i++) {
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(0.07, 0.09, 0.22, 8),
new THREE.MeshStandardMaterial({ color: 0xd8e0c0, roughness: 0.8 }),
);
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.19, 10, 8),
new THREE.MeshStandardMaterial({ color: 0x3f8f2e, roughness: 0.85 }),
);
head.position.y = 0.18;
head.scale.set(1.15, 0.85, 1.15);
const floret = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial());
floret.add(stem, head);
const a = (i / 7) * Math.PI * 2;
const r = i === 0 ? 0 : 0.42;
floret.position.set(Math.cos(a) * r, BASKET_Y + 0.12, Math.sin(a) * r);
floret.rotation.y = a;
this.florets.push(floret);
this.root.add(floret);
}
// The lid — an opaque dome. When it is on, you genuinely cannot see them.
this.lid = new THREE.Group();
const dome = new THREE.Mesh(
new THREE.SphereGeometry(POT_R * 0.9, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2),
new THREE.MeshStandardMaterial({ color: 0xb4bac0, roughness: 0.28, metalness: 0.8 }),
);
const knob = new THREE.Mesh(
new THREE.SphereGeometry(0.11, 12, 10),
new THREE.MeshStandardMaterial({ color: 0x24242a, roughness: 0.6 }),
);
knob.position.y = POT_R * 0.9;
this.lid.add(dome, knob);
this.lid.position.set(0, BASKET_Y + 0.12, 0);
this.root.add(this.lid);
// Steam wisps — the only thing the lid lets you read.
for (let i = 0; i < 9; i++) {
const wisp = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 8, 6),
new THREE.MeshBasicMaterial({ color: 0xeef4f6, transparent: true, opacity: 0 }),
);
wisp.position.set((Math.random() - 0.5) * 1.7, BASKET_Y + 0.4 + Math.random() * 0.8, (Math.random() - 0.5) * 1.7);
this.wisps.push(wisp);
this.root.add(wisp);
}
}
reset(askText = 'greens — tender, and still GREEN'): void {
this.session = newSteamerSession();
this.askEl.textContent = askText;
this.hintEl.textContent = 'WHEEL — flame · SPACE — lid on (it cooks, you are blind) / off (you can see, it stalls) · ENTER serves';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
this.app.scene.background = this.prevBg as never;
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.1, 4.0);
this.app.camera.lookAt(0, POT_Y + 0.5, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
if (inp.wheel !== 0) setSteamerHeat(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1));
if (inp.justPressed('Space')) {
const was = s.lidOn;
setLid(s, !s.lidOn);
audio.clatter(was ? 0.5 : 0.35, was ? 1.5 : 0.9);
// Lifting the lid on a violent boil is the moment he sees the damage.
if (was && s.heat > BOIL_OVER) eye.mood('disappointed', 2);
}
const scorchedBefore = s.scorched;
steamerStep(s, dt);
if (s.scorched && !scorchedBefore) {
audio.clatter(1, 2.4);
eye.mood('horrified', 3.5);
}
// The pot's voice — and with the lid on it is ALL you have.
const heatN = s.heat / 10;
const steaming = s.heat >= STEAM_LO && s.water > 0 && !s.scorched;
audio.bed('water', 0.015 + heatN * 0.055, 220 + heatN * 400, 0.6, 0.45 + heatN * 0.35);
audio.bed('steam', steaming ? (s.lidOn ? 0.05 : 0.028) + heatN * 0.05 : 0, 3800 + heatN * 1800, 0.7, 0.9);
const bps = s.heat > BOIL_OVER ? 15 : s.heat > STEAM_LO ? 5 : s.heat > 3 ? 1 : 0.1;
if (!s.scorched && s.water > 0 && Math.random() < bps * dt) audio.blip(heatN);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter')) {
audio.bed('water', 0, 300);
audio.bed('steam', 0, 3800);
const cb = this.onDone;
this.onDone = null;
cb?.(steamerResult(s));
}
}
private syncVisuals(dt: number): void {
const s = this.session!;
const fm = this.flame.material as THREE.MeshBasicMaterial;
fm.opacity = Math.min(0.85, s.knob / 9);
this.flame.scale.y = 0.5 + (s.knob / 9) * 1.2;
// The water level actually falls — the dry pot is visible before it bites.
const wm = this.water.material as THREE.MeshStandardMaterial;
this.water.scale.set(1, Math.max(0.05, s.water), 1);
this.water.position.y = POT_Y - 0.18 + s.water * 0.28;
wm.opacity = s.water > 0 ? 0.85 : 0;
wm.color.setHex(s.scorched ? 0x6a5a4a : 0x86aec2);
if (s.heat > STEAM_LO && s.water > 0) this.water.position.y += Math.sin(s.seconds * (s.heat > BOIL_OVER ? 18 : 8)) * 0.012;
// THE LID: on means down and opaque over the greens. Off means parked
// beside the pot, and everything is suddenly readable.
// ON: seated on the rim, opaque, and the greens are simply gone. OFF:
// parked well clear of the pot so it never reads as still covering.
const lidTarget = s.lidOn
? { x: 0, y: BASKET_Y + 0.02, rot: 0 }
: { x: -2.75, y: 0.2, rot: -1.15 };
const k = 1 - Math.exp(-dt / 0.1);
this.lid.position.x += (lidTarget.x - this.lid.position.x) * k;
this.lid.position.y += (lidTarget.y - this.lid.position.y) * k;
this.lid.rotation.z += (lidTarget.rot - this.lid.rotation.z) * k;
// The colour IS the score. Vivid green → army drab, and scorched goes brown.
const v = s.vibrancy;
const green = new THREE.Color(0x3f8f2e).lerp(new THREE.Color(0x6b6f42), 1 - v);
if (s.scorched) green.lerp(new THREE.Color(0x4a3a28), 0.5);
const soft = Math.min(1, s.cook / (TENDER_HI + 30));
for (let i = 0; i < this.florets.length; i++) {
const f = this.florets[i];
const head = f.children[1] as THREE.Mesh;
(head.material as THREE.MeshStandardMaterial).color = green;
// They slump as they overcook — the shape confesses before the colour.
head.scale.set(1.15 + soft * 0.12, 0.85 - soft * 0.28, 1.15 + soft * 0.12);
f.position.y = BASKET_Y + 0.12 - soft * 0.05;
}
// Steam: billows freely with the lid off, escapes in a thin ring with it on.
const steaming = s.heat >= STEAM_LO && s.water > 0 && !s.scorched;
for (let i = 0; i < this.wisps.length; i++) {
const w = this.wisps[i];
const m = w.material as THREE.MeshBasicMaterial;
m.opacity = steaming ? (s.lidOn ? 0.07 : 0.18) * (0.5 + (s.heat / 10) * 0.5) : 0;
w.position.y += (0.25 + (s.heat / 10) * 0.5) * dt;
if (w.position.y > BASKET_Y + 1.6) {
w.position.y = BASKET_Y + 0.3;
w.position.x = (Math.random() - 0.5) * (s.lidOn ? 2.2 : 1.4);
w.position.z = (Math.random() - 0.5) * (s.lidOn ? 2.2 : 1.4);
}
w.scale.setScalar(0.6 + (w.position.y - BASKET_Y) * 0.35);
}
}
private updateHud(): void {
const s = this.session!;
// Deliberately no doneness number: with the lid on the state line can only
// tell you what a cook could actually know — the flame, and the water.
this.stateEl.textContent = `flame ${Math.round(s.knob)} · water ${s.water > 0.66 ? 'full' : s.water > 0.33 ? 'dropping' : s.water > 0 ? 'LOW' : 'DRY'} · lid ${s.lidOn ? 'ON' : 'off'}${s.peeks ? ` · looked ${s.peeks}×` : ''} · ${Math.round(s.seconds)}s`;
this.wordEl.textContent = `${steamWord(s)} · ${greensWord(s)}`;
void TENDER_LO;
}
result(): ReturnType<typeof steamerResult> {
return steamerResult(this.session!);
}
}

169
src/sim/steamer.ts Normal file
View File

@ -0,0 +1,169 @@
import { Rng } from '../core/rng';
/**
* THE STEAMER the gentlest clock, and the only station that takes your
* EYES away.
*
* Steam does its work under a lid, and a lid is opaque. So the whole game is
* a trade you make every few seconds: LID ON and it cooks properly but you
* are cooking blind, or LID OFF and you can see exactly how green they are
* while the steam walks out of the pot and the clock nearly stops. There is
* no meter for doneness. There is a lid, and there is your nerve.
*
* VIBRANCY is what the judge actually looks at. Steam keeps colour; a violent
* boil that leaps up through the basket murders it, and so does simply
* cooking them into submission. Grey broccoli is a visible confession.
*
* And the pot can run dry. Crank the flame to nine and walk away and you get
* scorched metal and a kitchen that smells of it.
*/
/** Water heat 0..10. */
export const STEAM_LO = 5.4; // below this there is not enough steam to cook with
export const BOIL_OVER = 8.3; // above this the water leaps into the basket — boiling, not steaming
/** Tender lives here, in effective steaming seconds. */
export const TENDER_LO = 38;
export const TENDER_HI = 56;
const WATER_TAU = 4.0;
/** How fast the pot boils itself dry, per second at full heat. */
const EVAP_RATE = 0.015;
/** Cook per second with the lid ON and proper steam. */
const COOK_RATE = 1.0;
/** With the lid off the steam walks out — this is what is left of the clock. */
const LID_OFF_FACTOR = 0.22;
/** Colour lost per second just from being cooked, once tender. */
const FADE_RATE = 0.016;
/** Colour lost per second while the water is leaping through the basket. */
const BOIL_FADE = 0.075;
export interface SteamerSession {
knob: number;
heat: number;
lidOn: boolean;
/** 1 = full pot, 0 = dry and scorching. */
water: number;
/** Effective steaming seconds — the doneness clock. */
cook: number;
/** 1 = vivid as picked, 0 = army drab. The whole judgement. */
vibrancy: number;
/** Seconds spent boiling rather than steaming. */
boiled: number;
scorched: boolean;
/** How many times the lid came off. Time, not sin. */
peeks: number;
seconds: number;
rng: Rng;
}
export function newSteamerSession(seed = 20260724): SteamerSession {
return {
knob: 0, heat: 0, lidOn: false, water: 1, cook: 0, vibrancy: 1,
boiled: 0, scorched: false, peeks: 0, seconds: 0, rng: new Rng(seed),
};
}
export function setSteamerHeat(s: SteamerSession, k: number): void {
s.knob = Math.max(0, Math.min(9, Math.round(k)));
}
/** The lid. Off to look, on to cook. You cannot do both. */
export function setLid(s: SteamerSession, on: boolean): void {
if (on === s.lidOn) return;
s.lidOn = on;
if (!on) s.peeks++;
}
export function steamerStep(s: SteamerSession, dt: number): void {
s.seconds += dt;
s.heat += (s.knob - s.heat) * (1 - Math.exp(-dt / WATER_TAU));
// The pot boils itself away, faster the harder you drive it. Dry metal on a
// live flame is the end of the dish and the beginning of a smell.
if (s.heat > 3 && s.water > 0) {
s.water = Math.max(0, s.water - EVAP_RATE * (s.heat / 10) * (s.lidOn ? 0.6 : 1) * dt);
if (s.water <= 0) s.scorched = true;
}
if (s.scorched) {
s.vibrancy = Math.max(0, s.vibrancy - 0.12 * dt);
return;
}
const steaming = s.heat >= STEAM_LO && s.water > 0;
if (!steaming) return;
// Boiling is not steaming: past BOIL_OVER the water is throwing itself up
// through the basket, and colour is the first thing it takes.
const violent = s.heat > BOIL_OVER;
if (violent) {
s.boiled += dt;
s.vibrancy = Math.max(0, s.vibrancy - BOIL_FADE * ((s.heat - BOIL_OVER) / (10 - BOIL_OVER)) * dt);
}
const rate = COOK_RATE * (s.lidOn ? 1 : LID_OFF_FACTOR);
s.cook += rate * dt;
// Past tender, colour goes even in perfect steam — this is what "overdone"
// looks like before it is anything else.
if (s.cook > TENDER_HI) s.vibrancy = Math.max(0, s.vibrancy - FADE_RATE * dt);
}
export interface SteamerResult {
cook: number;
bite: number;
vibrancy: number;
boiled: number;
scorched: boolean;
water: number;
peeks: number;
seconds: number;
}
/** 1 inside the tender window, falling away either side. */
export function biteScore(cook: number): number {
if (cook < TENDER_LO) return Math.max(0, 1 - (TENDER_LO - cook) / 24);
if (cook > TENDER_HI) return Math.max(0, 1 - (cook - TENDER_HI) / 30);
return 1;
}
export function steamerResult(s: SteamerSession): SteamerResult {
return {
cook: s.cook,
bite: biteScore(s.cook),
vibrancy: s.vibrancy,
boiled: s.boiled,
scorched: s.scorched,
water: s.water,
peeks: s.peeks,
seconds: s.seconds,
};
}
/** The pot, out loud — and it says LESS when the lid is on. That is the point. */
export function steamWord(s: SteamerSession): string {
if (s.scorched) return 'the pot has boiled DRY. that smell is the pot itself';
if (s.water < 0.25) return 'very little water left — listen to it';
const t = s.heat;
if (t < 2.5) return 'cold water. nothing is happening';
if (t < STEAM_LO) return 'warming — not steaming yet';
if (t <= BOIL_OVER) return s.lidOn ? 'a steady steam, trapped under the lid' : 'steaming — and walking straight out of the open pot';
return 'a violent boil — the water is LEAPING into the basket';
}
/**
* The greens, out loud but only if you can SEE them. With the lid on you
* get sound and nothing else, which is the whole reason to take it off.
*/
export function greensWord(s: SteamerSession): string {
if (s.lidOn) return s.heat >= STEAM_LO ? 'lid on. you can hear it hissing and that is all you know' : 'lid on, and nothing much happening under it';
if (s.scorched) return 'grey, and tasting of a hot dry pot';
const c = s.cook;
const v = s.vibrancy;
const colour = v > 0.82 ? 'vivid green' : v > 0.6 ? 'still green, a little dulled' : v > 0.38 ? 'going drab' : 'army green. the colour is gone';
if (c < 18) return `raw and squeaky — ${colour}`;
if (c < TENDER_LO) return `firm still — ${colour}`;
if (c <= TENDER_HI) return `TENDER — ${colour}. this is the moment`;
if (c < TENDER_HI + 25) return `soft, past its best — ${colour}`;
return `collapsing — ${colour}`;
}