THE BLOWTORCH: day 29 — and the verdict is a SOUND

P9's crown, and the dish that earns the tool. The torch is the spread
gesture with fire: DRAG and the flame paints caramel into a Field, and
the WHEEL is distance — close is a narrow vicious cone, back is wide and
gentle. Sweep evenly and the sugar goes to glass. Dwell and you get a
black coin in a sea of white, which the judge does not need to taste.

And then he taps it with the back of a spoon, and THE SOUND IS THE
VERDICT. audio.crack(quality) is the one synth in the game whose
parameters are a score: the highpass, the bandpass Q, the transient
length and the ring all slide off crack quality, so a good sheet gives
one short bright report that rings for a moment, and a bad one gives a
dull thud followed by more crumbs the worse it was. Mean in band, low
stdev, nothing burnt — that is literally the shape of the noise.

Four rounds of tuning, all of them things only running it could tell me:
- At 1.35 caramel/sec a full careful pass reached 0.12 — a decorative
  flame that cannot caramelise sugar. At 7.0 a SINGLE pass blew through
  glass to 0.88 with a third of the top carbonised. 3.2 makes one pass
  amber and two glass: sweep, look, sweep, stop.
- The colour ramp put almost all its travel in the amber band, so a
  second of honest flame changed the top by about six values of red —
  invisible. Sugar goes golden long before it is amber; the raw band now
  does the work and you can see where you have been.
- The DataTexture was not flagged sRGB, so the renderer treated artwork
  as linear and washed the mid-tones out — a top genuinely taken to
  amber still looked like raw sugar. (The bench wood carries this exact
  note; I walked into it anyway.)
- CircleGeometry fans every UV from one centre vertex, which drew spokes
  across the caramel. It is a masked plane now, sized to agree with the
  paint mapping instead of guessing.

Verified: an even sweep lands mean 0.68, stdev 0.105, nothing burnt,
crack 0.71 — 'it cracked. not all the way across, but it cracked.' — for
7.5, with real headroom above it for a tighter sweep. Holding the torch
still burns a coin and gets 'nothing. the spoon went in like it was wet
sand.' for 2.0. Mandoline 9.6, curry 10.0, greens 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 22:05:59 +10:00
parent 47f6c39e2b
commit 2e4af7a7eb
8 changed files with 744 additions and 8 deletions

View File

@ -288,6 +288,86 @@ export class Audio {
o.stop(t + dur + 0.05);
}
/**
* THE CRACK. The verdict on a brûlée is a SOUND, so this is the one synth
* in the game whose parameters are a score: `quality` 0..1 slides it from a
* dead wet thud, through a dull snap and crumbs, to one clean report of a
* spoon back going through a sheet of glass.
*/
crack(quality: number): void {
const ctx = this.ctx;
if (!ctx || !this.master || !this.noise) return;
const t = this.now();
const q = Math.max(0, Math.min(1, quality));
// The strike itself — always there, it is a spoon on a ramekin.
const tapOsc = ctx.createOscillator();
tapOsc.type = 'sine';
tapOsc.frequency.setValueAtTime(320 + q * 180, t);
tapOsc.frequency.exponentialRampToValueAtTime(140, t + 0.07);
const tapG = ctx.createGain();
tapG.gain.setValueAtTime(0.16, t);
tapG.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
tapOsc.connect(tapG).connect(this.master);
tapOsc.start(t);
tapOsc.stop(t + 0.15);
// The break: a bright noise transient, and it only exists if there is
// actually a sheet of glass to break. Below ~0.2 you get the thud alone.
if (q > 0.18) {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.playbackRate.value = 0.9 + q * 1.4;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 900 + q * 3800; // sharper the better it is
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 2400 + q * 4200;
bp.Q.value = 0.7 + q * 1.8;
const g = ctx.createGain();
const dur = 0.035 + (1 - q) * 0.16; // a good crack is SHORT; a bad one crumbles
g.gain.setValueAtTime(0.05 + q * 0.3, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(hp).connect(bp).connect(g).connect(this.master);
src.start(t);
src.stop(t + dur + 0.05);
// The ring: a clean sheet keeps singing for a moment after it goes.
if (q > 0.6) {
const ring = ctx.createOscillator();
ring.type = 'triangle';
ring.frequency.value = 2600 + q * 1800;
const rg = ctx.createGain();
rg.gain.setValueAtTime(0.05 * (q - 0.6) * 2.5, t + 0.005);
rg.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
ring.connect(rg).connect(this.master);
ring.start(t);
ring.stop(t + 0.3);
}
}
// Crumbs: the worse it is, the more it falls apart afterwards.
if (q < 0.7) {
const bits = Math.round((1 - q) * 5);
for (let i = 0; i < bits; i++) {
const at = t + 0.06 + i * 0.035 + Math.random() * 0.02;
const c = ctx.createBufferSource();
c.buffer = this.noise;
c.playbackRate.value = 1.4 + Math.random();
const cb = ctx.createBiquadFilter();
cb.type = 'bandpass';
cb.frequency.value = 1800 + Math.random() * 2200;
const cg = ctx.createGain();
cg.gain.setValueAtTime(0.035, at);
cg.gain.exponentialRampToValueAtTime(0.0001, at + 0.05);
c.connect(cb).connect(cg).connect(this.master);
c.start(at);
c.stop(at + 0.08);
}
}
}
/** The judge's stamp. */
stamp(): void {
const ctx = this.ctx;

View File

@ -678,6 +678,58 @@ 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-29 brûlée with REAL input: hold the torch well back and
* sweep the whole top evenly until it is glass. Dwell nowhere. */
async bruleeDay(sloppy = false) {
game.setDay(29);
await F(6);
const bv = game.bruleeView as unknown as { session: import('./sim/brulee').BruleeSession };
const s = bv.session;
if (!s) return { error: 'not at the brulee' };
const R = 1.25;
s.height = sloppy ? 0.14 : 0.72; // sloppy = kissing the sugar in one spot
if (sloppy) {
// The mistake, on purpose: hold it still and burn a hole.
point(0, 0.62, 0, false); await F(2);
point(0, 0.62, 0, true);
await F(200);
point(0, 0.62, 0, false);
} else {
// Raster the disc in overlapping passes — the spread gesture, with fire.
// Nine overlapping rows carried right out to the rim — stopping at
// 0.82R left a pale ring, which is exactly the stdev the judge was
// hearing as crumbs. And the pass count is NOT fixed: a cook sweeps
// until the top looks right and then stops, so this watches the mean
// and puts the torch down inside the glass band instead of blindly
// running a third pass into carbon.
for (let pass = 0; pass < 6; pass++) {
if (s.caramel.stats(s.mask).mean > 0.66) break;
for (let row = 0; row < 9; row++) {
const z = (row / 8 - 0.5) * 1.72;
const half = Math.sqrt(Math.max(0.02, (R * 0.99) ** 2 - z * z));
const dir = row % 2 === 0 ? 1 : -1;
point(-half * dir, 0.62, z, false); await F(1);
point(-half * dir, 0.62, z, true); await F(1);
const steps = 12;
for (let i = 1; i <= steps; i++) {
point(-half * dir + (2 * half * dir * i) / steps, 0.62, z, true);
await F(1);
}
point(half * dir, 0.62, z, false); await F(1);
}
}
}
await F(4);
tap('Enter');
// The tap beat is a wall-clock setTimeout (you are meant to HEAR it),
// so stepping sim frames does not get you past it — wait for real.
await new Promise((r) => window.setTimeout(r, 1500));
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const r = bv as unknown as { result: () => import('./sim/brulee').BruleeResult };
const res = r.result();
return { mean: res.mean.toFixed(2), stdev: res.stdev.toFixed(3), burnt: res.burnt.toFixed(2), crack: res.crack.toFixed(2), rows };
},
/** Play the day-28 mandoline with REAL input: shave bare-handed while the
* vegetable is long, then put the GUARD on before the stub. Never bleeds. */
async mandolineDay(greedy = false) {
@ -1627,6 +1679,7 @@ export function installDevHarness(app: App, game: Game): void {
26: () => harness.steamDay(),
27: () => harness.spiceDay(),
28: () => harness.mandolineDay(),
29: () => harness.bruleeDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -17,6 +17,7 @@ import { PotView } from '../scenes/pot';
import { SteamerView } from '../scenes/steamer';
import { SpiceView } from '../scenes/spice';
import { MandolineView } from '../scenes/mandoline';
import { BruleeView } from '../scenes/brulee';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -25,7 +26,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, judgeSteamer, judgeSpice, judgeMandoline, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, 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';
@ -95,6 +96,7 @@ export class Game {
private steamer: SteamerView;
private spice: SpiceView;
private mandoline: MandolineView;
private brulee: BruleeView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -153,6 +155,7 @@ export class Game {
this.steamer = new SteamerView(app);
this.spice = new SpiceView(app);
this.mandoline = new MandolineView(app);
this.brulee = new BruleeView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -173,6 +176,7 @@ export class Game {
app.scene.add(this.steamer.root);
app.scene.add(this.spice.root);
app.scene.add(this.mandoline.root);
app.scene.add(this.brulee.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -192,6 +196,7 @@ export class Game {
this.steamer.root.visible = false;
this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -367,6 +372,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get bruleeView(): BruleeView {
return this.brulee;
}
get mandolineView(): MandolineView {
return this.mandoline;
}
@ -563,6 +571,7 @@ export class Game {
this.steamer.root.visible = false;
this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.assembly.root.visible = false;
}
@ -789,13 +798,14 @@ export class Game {
if (o.steamer) return this.enterSteamer();
if (o.spice) return this.enterSpice();
if (o.mandoline) return this.enterMandoline();
if (o.brulee) return this.enterBrulee();
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.steamer ? '🥦' : o.spice ? '🌶️' : o.mandoline ? '🔪' : 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.spice ? '🌶️' : o.mandoline ? '🔪' : o.brulee ? '🍮' : 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`;
}
@ -907,6 +917,11 @@ export class Game {
this.enterMandoline();
return;
}
// The blowtorch: paint sugar to glass, and hope it cracks.
if (this.order.brulee) {
this.enterBrulee();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -1115,6 +1130,33 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterBrulee(): void {
this.brulee.reset();
this.hideAllScenes();
this.brulee.root.visible = true;
this.app.setView(this.brulee);
this.ticket.show();
this.brulee.onDone = (result) => {
this.brulee.root.visible = false;
this.serveBrulee(result);
};
}
private serveBrulee(result: ReturnType<BruleeView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeBrulee(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 enterMandoline(): void {
const m = this.order.mandoline!;
this.mandoline.reset(`${m.slices} slices \u2014 and your hands intact`);

View File

@ -18,6 +18,7 @@ import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot';
import type { SteamerResult } from '../sim/steamer';
import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor';
import type { MandolineResult } from '../sim/mandoline';
import { type BruleeResult, crackWord } from '../sim/brulee';
export interface Criterion {
key: string;
@ -30,7 +31,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' | 'steam' | 'spice' | 'blade';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -620,6 +621,65 @@ 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 brûlée — the only card in the game whose headline row is a SOUND. He
// taps it with the back of a spoon and writes down what he heard.
export function judgeBrulee(r: BruleeResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'torchcrack',
group: 'torch',
label: 'The Crack',
score: clamp01(r.crack),
weight: 1.5,
detail: crackWord(r.crack),
},
{
key: 'torchsheet',
group: 'torch',
label: 'The Sheet',
score: clamp01(1 - r.stdev / 0.22),
weight: 1.2,
detail: r.stdev > 0.2
? 'half of it is glass and half of it is sugar'
: r.stdev > 0.11
? 'thicker in places than others'
: 'one even sheet, corner to corner',
},
{
key: 'torchcolour',
group: 'torch',
label: 'The Colour',
score: clamp01(1 - r.burnt * 2.4 - Math.max(0, r.raw - 0.06) * 1.6),
weight: 1.1,
detail: r.burnt > 0.08
? 'black patches. burnt sugar is not a flavour, it is an apology'
: r.raw > 0.2
? 'pale sugar left sitting there untouched'
: 'amber all the way across',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 80) / 120),
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 mandoline — yield against skin. Evenness is free here; what costs is
// how far down the vegetable you were willing to go, and what it cost you.

View File

@ -132,6 +132,8 @@ export interface Order {
spice?: { dish: string; target: import('../sim/flavor').Target; base?: Partial<Record<import('../sim/flavor').Axis, number>> };
/** THE MANDOLINE: yield against skin. The guard wastes what it protects. */
mandoline?: { slices: number };
/** THE BLOWTORCH: paint the sugar to glass. The verdict is a SOUND. */
brulee?: true;
}
export const BROWNING_NAMES: [number, string][] = [
@ -597,6 +599,21 @@ export const DAYS: Order[] = [
text: 'Twelve slices on the mandoline, paper thin. It will give you every one of them evenly \u2014 that is what the machine is FOR. What it will not do is tell you when to stop. The guard wastes the end; your knuckles are the alternative. Choose, and live with it.',
mandoline: { slices: 12 },
},
{
day: 29,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the pavlova order',
text: 'Cr\u00e8me br\u00fbl\u00e9e. Torch the sugar to a single sheet of GLASS \u2014 hold it close and you will burn a hole, hold it back and sweep and it goes even. And when I tap it with my spoon, I want to hear ONE crack. Not a tap. Not crumbs. A crack.',
brulee: true,
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -621,7 +638,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
if (roll < 0.935) return proceduralPot(day, rand);
if (roll < 0.945) return proceduralSteamer(day, rand);
if (roll < 0.955) return proceduralSpice(day, rand);
if (roll < 0.97) return proceduralMandoline(day, rand);
if (roll < 0.965) return proceduralMandoline(day, rand);
if (roll < 0.98) return proceduralBrulee(day, rand);
if (roll < 0.945) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand);
@ -681,6 +699,7 @@ export function stationOf(o: Order): string | null {
if (o.steamer) return 'steamer';
if (o.spice) return 'spice';
if (o.mandoline) return 'mandoline';
if (o.brulee) return 'brulee';
return null;
}
@ -731,6 +750,16 @@ function proceduralMandoline(day: number, rand: () => number): Order {
return o;
}
function proceduralBrulee(day: number, rand: () => number): Order {
const o = baseOrder(
day,
pickOf(rand, ['the pavlova order', 'the benedict table', 'the man at table nine', 'a regular']),
'Br\u00fbl\u00e9e. One even sheet of glass, and it had better crack.',
);
o.brulee = true;
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. */

307
src/scenes/brulee.ts Normal file
View File

@ -0,0 +1,307 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type BruleeSession,
newBruleeSession,
setHeight,
coneOf,
torch,
bruleeStep,
bruleeResult,
bruleeWord,
crackWord,
AMBER_AT,
GLASS_HI,
BURNT_AT,
} from '../sim/brulee';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
const TOP_Y = 0.62;
const DISH_R = 1.25;
/**
* THE BLOWTORCH the spread gesture, with fire.
*
* DRAG across the sugar and the flame paints caramel into it. WHEEL sets how
* far back you hold the torch: close is a narrow, vicious cone, back is wide
* and gentle. Sweep evenly and you get a sheet of glass; dwell and you get a
* black coin surrounded by white sugar. ENTER hands it over and then he
* taps it with the back of a spoon, and the SOUND is the verdict.
*/
export class BruleeView implements View {
readonly root = new THREE.Group();
private session: BruleeSession | null = null;
private topMesh!: THREE.Mesh;
private topTex!: THREE.DataTexture;
private topData!: Uint8Array;
private flame!: THREE.Mesh;
private torchBody!: THREE.Group;
private spoon!: THREE.Mesh;
private tapAnim = 0;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -TOP_Y);
private torching = false;
private bgTex = loadBackdrop('/assets/img/bg_pantry.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof bruleeResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('brulee-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, 'TORCH 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: 0x4a3a2c, roughness: 0.8 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
// The ramekin.
const dish = new THREE.Mesh(
new THREE.CylinderGeometry(DISH_R, DISH_R * 0.8, 0.6, 36),
new THREE.MeshStandardMaterial({ color: 0xf0ece4, roughness: 0.45 }),
);
dish.position.y = TOP_Y - 0.3;
dish.receiveShadow = true;
this.root.add(dish);
for (let i = 0; i < 22; i++) {
const rib = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.5, 0.05),
new THREE.MeshStandardMaterial({ color: 0xe6e0d6, roughness: 0.5 }),
);
const a = (i / 22) * Math.PI * 2;
rib.position.set(Math.cos(a) * DISH_R * 0.96, TOP_Y - 0.32, Math.sin(a) * DISH_R * 0.96);
this.root.add(rib);
}
// The sugar top — a live texture painted straight from the caramel Field.
// The player reads THIS, not a number: the amber, the glass, the black coin.
const n = 40;
this.topData = new Uint8Array(n * n * 4);
this.topTex = new THREE.DataTexture(this.topData, n, n, THREE.RGBAFormat);
this.topTex.minFilter = THREE.LinearFilter;
this.topTex.magFilter = THREE.LinearFilter;
// These bytes are ARTWORK, not data — say so, or the renderer treats them
// as linear, washes the mid-tones out, and a top you have genuinely taken
// to amber still looks like raw white sugar. (Same note as the bench wood.)
this.topTex.colorSpace = THREE.SRGBColorSpace;
// A masked PLANE, not a CircleGeometry: a circle's UVs all fan from a
// single centre vertex, so the interpolation drew visible spokes across
// the caramel. The round shape comes from the mask's alpha instead.
this.topMesh = new THREE.Mesh(
// MUST match the UV mapping in update() (DISH_R * 1.88), or the painted
// disc and the surface you are painting on disagree about where the rim is.
new THREE.PlaneGeometry(DISH_R * 1.88, DISH_R * 1.88),
new THREE.MeshStandardMaterial({ map: this.topTex, roughness: 0.35, transparent: true }),
);
this.topMesh.rotation.x = -Math.PI / 2;
this.topMesh.position.y = TOP_Y;
this.root.add(this.topMesh);
// The torch: a chrome body with a blue cone that follows your hand.
this.torchBody = new THREE.Group();
const barrel = new THREE.Mesh(
new THREE.CylinderGeometry(0.11, 0.13, 0.72, 14),
new THREE.MeshStandardMaterial({ color: 0xc8ccd0, roughness: 0.25, metalness: 0.9 }),
);
const grip = new THREE.Mesh(
new THREE.BoxGeometry(0.22, 0.42, 0.2),
new THREE.MeshStandardMaterial({ color: 0xb03828, roughness: 0.6 }),
);
grip.position.set(0, -0.5, 0.06);
this.torchBody.add(barrel, grip);
this.torchBody.rotation.x = 0.5;
this.torchBody.visible = false;
this.root.add(this.torchBody);
this.flame = new THREE.Mesh(
new THREE.ConeGeometry(0.12, 0.5, 16, 1, true),
new THREE.MeshBasicMaterial({ color: 0x6aa8ff, transparent: true, opacity: 0, side: THREE.DoubleSide }),
);
this.flame.rotation.x = Math.PI; // pointing down at the sugar
this.root.add(this.flame);
// The spoon he taps it with. It arrives only at the verdict.
this.spoon = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 14, 10, 0, Math.PI * 2, 0, Math.PI / 2.3),
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.3, metalness: 0.8, side: THREE.DoubleSide }),
);
this.spoon.scale.set(1, 0.5, 1.3);
this.spoon.visible = false;
this.root.add(this.spoon);
}
reset(askText = 'brûlée — an even sheet of glass, and it has to CRACK'): void {
this.session = newBruleeSession();
this.torching = false;
this.tapAnim = 0;
this.spoon.visible = false;
this.wordEl.textContent = '';
this.wordEl.style.color = '';
this.askEl.textContent = askText;
this.hintEl.textContent = 'DRAG across the sugar to torch it · WHEEL — how close you hold it · ENTER serves, and he TAPS it';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
audio.bed('torch', 0, 900);
this.panel.hide();
this.app.scene.background = this.prevBg as never;
}
update(dt: number): void {
this.app.camera.position.set(0, 3.4, 3.0);
this.app.camera.lookAt(0, TOP_Y, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
// The wheel is the distance, and distance is BOTH width and ferocity.
if (inp.wheel !== 0) setHeight(s, s.height + (inp.wheel > 0 ? 0.08 : -0.08));
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
const overDish = !!on && Math.hypot(this.hit.x, this.hit.z) < DISH_R;
this.torching = inp.down && overDish && !s.tapped;
if (this.torching) {
const u = this.hit.x / (DISH_R * 1.88) + 0.5;
const v = this.hit.z / (DISH_R * 1.88) + 0.5;
torch(s, u, v, dt);
const { intensity } = coneOf(s);
audio.bed('torch', 0.03 + intensity * 0.02, 700 + intensity * 700, 0.5, 0.8);
} else {
audio.bed('torch', 0, 900);
}
bruleeStep(s, dt);
this.syncVisuals(dt, on ? this.hit : null);
this.updateHud();
if (inp.justPressed('Enter') && !s.tapped) this.serveAndTap();
}
/** The verdict: he taps it, and the sound IS the answer. */
private serveAndTap(): void {
const s = this.session!;
s.tapped = true;
const r = bruleeResult(s);
audio.bed('torch', 0, 900);
this.spoon.visible = true;
this.tapAnim = 1;
audio.crack(r.crack);
this.wordEl.textContent = crackWord(r.crack);
this.wordEl.style.color = r.crack > 0.6 ? '#8fce8f' : r.crack > 0.3 ? '' : '#e2603a';
eye.mood(r.crack > 0.8 ? 'impressed' : r.crack > 0.45 ? 'intrigued' : 'disappointed', 4);
// A beat to hear it, then the scorecard.
window.setTimeout(() => {
const cb = this.onDone;
this.onDone = null;
cb?.(r);
}, 1300);
}
private syncVisuals(dt: number, at: THREE.Vector3 | null): void {
const s = this.session!;
const n = 40;
// Paint the Field: white sugar → amber → deep glass → black.
for (let i = 0; i < n * n; i++) {
const inDish = s.mask.data[i] > 0;
const c = s.caramel.data[i];
let r: number, g: number, b: number;
// The ramp has to SHOW the work. The first cut moved from 248,242,226 to
// 230,202,146 across the whole raw band, which meant a second of honest
// flame changed the top by about six values of red — invisible. Sugar
// goes visibly golden long before it is amber, so the raw band now does
// most of the colour travel and you can see where you have been.
this.topData[i * 4 + 3] = inDish ? 255 : 0; // the mask IS the disc
if (!inDish) {
r = 240; g = 236; b = 228;
} else if (c < AMBER_AT) {
const k = Math.sqrt(c / AMBER_AT); // fast off white, then easing
r = 250 - k * 28; g = 246 - k * 76; b = 232 - k * 142;
} else if (c <= GLASS_HI) {
const k = (c - AMBER_AT) / (GLASS_HI - AMBER_AT);
r = 222 - k * 72; g = 170 - k * 82; b = 90 - k * 62;
} else {
const k = Math.min(1, (c - GLASS_HI) / (1 - GLASS_HI));
r = 150 - k * 128; g = 88 - k * 74; b = 28 - k * 20;
}
this.topData[i * 4] = r;
this.topData[i * 4 + 1] = g;
this.topData[i * 4 + 2] = b;
}
this.topTex.needsUpdate = true;
// The torch follows your hand, at the height the wheel chose.
const { radius, intensity } = coneOf(s);
const show = !!at && !s.tapped;
this.torchBody.visible = show;
const fm = this.flame.material as THREE.MeshBasicMaterial;
if (show && at) {
const h = 0.35 + s.height * 0.85;
this.torchBody.position.set(at.x, TOP_Y + h + 0.55, at.z + 0.3);
this.flame.position.set(at.x, TOP_Y + h * 0.5, at.z);
this.flame.scale.set(radius / 0.12 * 1.6, h / 0.5, radius / 0.12 * 1.6);
fm.opacity += ((this.torching ? 0.55 + intensity * 0.1 : 0.12) - fm.opacity) * (1 - Math.exp(-dt / 0.05));
fm.color.setHex(this.torching ? 0x6aa8ff : 0x3a6aff);
} else {
fm.opacity += (0 - fm.opacity) * (1 - Math.exp(-dt / 0.06));
}
// The tap: the spoon comes down once, and stays where it landed.
if (this.tapAnim > 0) {
this.tapAnim = Math.max(0, this.tapAnim - dt * 2.2);
const drop = Math.sin(Math.min(1, 1 - this.tapAnim) * Math.PI);
this.spoon.position.set(0.1, TOP_Y + 0.12 + drop * 0.5, 0.05);
this.spoon.rotation.z = 0.4 - drop * 0.3;
}
}
private updateHud(): void {
const s = this.session!;
const close = s.height < 0.32 ? 'close and fierce' : s.height < 0.62 ? 'a hand back' : 'well back, wide and gentle';
this.stateEl.textContent = `torch held ${close} · ${s.torchSeconds.toFixed(1)}s of flame · ${Math.round(s.seconds)}s`;
if (!s.tapped) {
this.wordEl.textContent = bruleeWord(s);
this.wordEl.style.color = '';
}
void BURNT_AT;
}
result(): ReturnType<typeof bruleeResult> {
return bruleeResult(this.session!);
}
}

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' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : 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' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : 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.mandoline
this.orderEl.textContent = order.brulee
? `Day ${order.day} · ${order.who} asked for the br\u00fbl\u00e9e`
: order.mandoline
? `Day ${order.day} · ${order.who} asked for the mandoline`
: order.spice
? `Day ${order.day} · ${order.who} asked for it SEASONED`
@ -239,7 +241,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.group === 'steam' || c.group === 'spice' || c.group === 'blade',
(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' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -258,9 +260,10 @@ export class JudgeView implements View {
steam: 'THE STEAMER',
spice: 'THE SEASONING',
blade: 'THE BLADE',
torch: 'THE TORCH',
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, steam: 3, spice: 3, blade: 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, spice: 3, blade: 3, torch: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

162
src/sim/brulee.ts Normal file
View File

@ -0,0 +1,162 @@
import { Field } from '../core/field';
import { Rng } from '../core/rng';
/**
* THE BLOWTORCH, and the sound it is judged by.
*
* The torch is the spread gesture with fire: you PAINT heat across a sugar
* top, and the flame is a cone hold it close and it is narrow and vicious,
* pull it back and it is wide and gentle. Sweep evenly and the sugar goes to
* glass. Dwell anywhere and that spot goes to carbon while its neighbours are
* still white, and the judge does not need to taste that to know.
*
* The verdict is AUDIO. He taps the top with the back of a spoon: an even
* sheet of glass gives one clean CRACK, a pale one gives a dull tap, and a
* thick scorched one gives a dead thud. The Field's mean, its stdev and its
* burnt fraction are literally the parameters of that sound.
*/
const N = 40;
/** Caramel bands. Below AMBER it is still sugar; past BURNT it is carbon. */
export const AMBER_AT = 0.42;
export const GLASS_LO = 0.56;
export const GLASS_HI = 0.82;
export const BURNT_AT = 0.93;
/** Torch height 0..1: 0 = kissing the sugar, 1 = well back. */
export const HEIGHT_MIN = 0.12;
export const HEIGHT_MAX = 1;
/** Caramelisation per second at the hottest, tightest setting. */
// Tuned against a real sweep, twice. At 1.35 a full careful pass reached 0.12
// (a decorative flame); at 7.0 a SINGLE pass blew straight through glass to
// 0.88 with a third of the top carbonised. 3.2 puts one unhurried pass at
// roughly amber and two at glass — so the rhythm is sweep, look, sweep, stop.
const HEAT_RATE = 3.2;
export interface BruleeSession {
/** Caramel depth per texel, 0 raw sugar .. 1 carbon. */
caramel: Field;
/** The ramekin's round mask — only sugar inside it counts. */
mask: Field;
/** How far back the torch is held. Sets cone width AND intensity. */
height: number;
/** Seconds the flame has actually been on the sugar. */
torchSeconds: number;
seconds: number;
/** Set once the judge has tapped it — you only get one crack. */
tapped: boolean;
rng: Rng;
}
export function newBruleeSession(seed = 20260727): BruleeSession {
const mask = new Field(N);
const caramel = new Field(N);
// A round ramekin: everything inside the circle is sugar, outside is china.
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const u = (x + 0.5) / N - 0.5;
const v = (y + 0.5) / N - 0.5;
mask.data[y * N + x] = Math.hypot(u, v) <= 0.46 ? 1 : 0;
}
}
return { caramel, mask, height: 0.5, torchSeconds: 0, seconds: 0, tapped: false, rng: new Rng(seed) };
}
export function setHeight(s: BruleeSession, h: number): void {
s.height = Math.max(HEIGHT_MIN, Math.min(HEIGHT_MAX, h));
}
/** The cone: close is narrow and fierce, far is wide and mild. */
export function coneOf(s: BruleeSession): { radius: number; intensity: number } {
return {
radius: 0.045 + s.height * 0.16,
// Inverse-square-ish. Kissing the sugar is roughly six times a lazy hover.
intensity: 1 / (0.16 + s.height * 1.5),
};
}
/**
* One frame of flame at (u,v). `dt` is the dose sweeping fast spreads it
* thin, holding still stacks it in one place, which is exactly the mistake.
*/
export function torch(s: BruleeSession, u: number, v: number, dt: number): void {
const { radius, intensity } = coneOf(s);
s.torchSeconds += dt;
s.caramel.brush(u, v, radius, (i, w) => {
if (s.mask.data[i] <= 0) return;
s.caramel.data[i] = Math.min(1, s.caramel.data[i] + HEAT_RATE * intensity * w * dt);
});
}
export function bruleeStep(s: BruleeSession, dt: number): void {
s.seconds += dt;
}
export interface BruleeResult {
mean: number;
stdev: number;
/** Fraction of the top that went past carbon. */
burnt: number;
/** Fraction still raw white sugar. */
raw: number;
torchSeconds: number;
seconds: number;
/** 0..1 — how good the sound is going to be. */
crack: number;
}
/**
* The crack, as a number. An even sheet in the glass band cracks; pale sugar
* has nothing to break, and a burnt slab is too thick and too soft-bitter to
* ring. Unevenness is the killer half glass and half sugar makes a mess of
* a noise, not a crack.
*/
export function crackScore(mean: number, stdev: number, burnt: number, raw: number): number {
const band = mean < GLASS_LO
? Math.max(0, 1 - (GLASS_LO - mean) / 0.34)
: mean > GLASS_HI
? Math.max(0, 1 - (mean - GLASS_HI) / 0.2)
: 1;
const even = Math.max(0, 1 - stdev / 0.22);
return Math.max(0, band * (0.45 + 0.55 * even) - burnt * 1.3 - raw * 0.5);
}
export function bruleeResult(s: BruleeSession): BruleeResult {
const st = s.caramel.stats(s.mask);
const burnt = s.caramel.fraction(s.mask, (v) => v > BURNT_AT);
const raw = s.caramel.fraction(s.mask, (v) => v < AMBER_AT);
return {
mean: st.mean,
stdev: st.stdev,
burnt,
raw,
torchSeconds: s.torchSeconds,
seconds: s.seconds,
crack: crackScore(st.mean, st.stdev, burnt, raw),
};
}
/** What the top looks like, out loud. No numbers, ever. */
export function bruleeWord(s: BruleeSession): string {
const st = s.caramel.stats(s.mask);
const burnt = s.caramel.fraction(s.mask, (v) => v > BURNT_AT);
const raw = s.caramel.fraction(s.mask, (v) => v < AMBER_AT);
if (burnt > 0.1) return 'black patches. that is not caramel any more';
if (raw > 0.5) return st.mean < 0.05 ? 'white sugar, waiting' : 'mostly still sugar — keep sweeping';
if (raw > 0.18) return 'pale patches left — they will not crack';
if (st.stdev > 0.2) return 'patchy. some of it is glass and some of it is sugar';
if (st.mean < GLASS_LO) return 'amber, but thin. it wants a little more';
if (st.mean <= GLASS_HI) return 'an even sheet of glass. STOP';
return 'dark. past glass and into bitter';
}
/** The sound he gets, in words, once he taps it. */
export function crackWord(c: number): string {
if (c > 0.82) return 'one clean CRACK, straight across. that is the sound.';
if (c > 0.6) return 'it cracked. not all the way across, but it cracked.';
if (c > 0.35) return 'a dull snap, and then crumbs.';
if (c > 0.15) return 'a tap. just a tap.';
return 'nothing. the spoon went in like it was wet sand.';
}