THE POT: day 25 — you can always add more salt. you can never add less.
Pasta night, and the kitchen's first genuinely irreversible verb. Salt is a HELD pour, not a click: hold S and it falls, you stop it and nothing else will, and `salt` is a number that only ever goes up. The words walk you to the edge and then stop helping — 'shy of it, more while you still can' becomes 'seasoned like the sea. stop.' becomes 'heavy handed. there is no going back.' Salt thrown in after the pasta counts a quarter: it seasons the water you are about to pour down the sink. The rest is the atlas's pasta doctrine: pasta wants a ROLLING boil, and a lazy simmer glues the strands into one brick because they sit in their own starch instead of tumbling. Al dente is a window in immersion seconds that never appears on screen. And the last verb is restraint — RESERVE a cup of the starchy water before you drain, or the sauce has nothing to hold onto. Two things the adversarial pass caught, both about stakes being fake: - Brine scored 8.6. An inedible plate cannot be averaged away by good timing elsewhere, so ruin now falls off a cliff (not a ramp) and a brined pot is CAPPED at 3.0 — the one verb you could not take back gets the one score you cannot climb out of. - The sourdough starter was buttering up chip day. Her 'is this a toast day' test was a blocklist of stations that existed when she shipped, so air fryer, fryer and pot all silently qualified as toast. Replaced with stationOf()/isToastDay() in orders.ts — every station flag listed in exactly one place, where the next station cannot slip past it. Verified: honest pot 10.0; brine 3.0 at the cap; the lazy cook (simmer, salt after the drop, drained every drop) 4.8 with all three rows naming the exact sin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b13750f88a
commit
e9fca4f5c9
BIN
public/assets/models/air_fryer.glb
Normal file
BIN
public/assets/models/air_fryer.glb
Normal file
Binary file not shown.
@ -190,7 +190,32 @@ do_prep2d() {
|
||||
gen_2d wonderslice_bag 233 "a cheerful retro plastic bread bag with bold cartoon lettering style packaging design, red and yellow, claymation prop look, plain light grey background, no readable text" 768 768 cut
|
||||
}
|
||||
|
||||
# ---- THE NEW STATIONS: air fryer, deep fryer, eggs, poach (seeds 240-269) ----
|
||||
# Everything built in the July 2026 push is still procedural primitives. These
|
||||
# are the hero props for those benches. All free, all local, all regenerable.
|
||||
do_stations() {
|
||||
# the air fryer bench
|
||||
gen_3d air_fryer 241 "a chunky black modern air fryer appliance with a pull out drawer basket and a round dial on top, matte plastic body"
|
||||
gen_3d wire_basket 243 "a square wire mesh air fryer basket tray with a crumb rack, empty, seen at an angle"
|
||||
gen_3d chicken_wing 245 "a single raw chicken wing piece, pale pink skin, plump, on nothing"
|
||||
gen_3d wing_cooked 247 "a single golden crispy fried chicken wing, glistening browned skin"
|
||||
# the deep fryer bench
|
||||
gen_3d fryer_pot 249 "a deep stainless steel stockpot of golden cooking oil, tall straight sides, no handle visible, seen from a low angle"
|
||||
gen_3d fryer_basket 251 "a round wire mesh deep fryer basket with a long metal hooked handle, empty"
|
||||
gen_3d drain_rack 253 "a small rectangular wire cooling rack on short legs over a metal tray, empty"
|
||||
gen_3d chip_raw 255 "a single thick cut raw potato chip fry baton, pale starchy white"
|
||||
gen_3d chip_golden 257 "a single thick cut golden fried potato chip, crisp browned edges"
|
||||
# the egg bench
|
||||
gen_3d egg_carton 259 "an open cardboard egg carton holding six brown eggs, lid folded back"
|
||||
gen_3d mixing_bowl 261 "a wide shallow white ceramic mixing bowl, empty, seen at a three quarter angle"
|
||||
gen_3d tall_glass 263 "a plain tall clear drinking glass of water, empty of anything else"
|
||||
# the poach bench
|
||||
gen_3d slotted_spoon 265 "a stainless steel slotted spoon with a shallow perforated bowl and a long handle"
|
||||
gen_3d stock_pot 267 "a wide shallow stainless steel saucepan of simmering water, low straight sides, no lid"
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
stations) do_stations ;;
|
||||
3d) do_3d ;;
|
||||
2d) do_2d ;;
|
||||
bakery) do_bakery ;;
|
||||
|
||||
33
src/dev.ts
33
src/dev.ts
@ -678,6 +678,38 @@ 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-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() {
|
||||
game.setDay(25);
|
||||
await F(6);
|
||||
const pv = game.potView as unknown as { session: import('./sim/pot').PotSession };
|
||||
const s = pv.session;
|
||||
if (!s) return { error: 'not at pot' };
|
||||
s.knob = 9; // full flame — pasta wants the roll
|
||||
let g = 0;
|
||||
while (s.heat < 8.1 && g++ < 60 * 90) await F(1);
|
||||
// The pour: hold S, watch the salt, and STOP inside the band. This is
|
||||
// the only gesture in the game with no undo, so the driver watches it
|
||||
// the way a cook does — by looking, not by counting.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyS' }));
|
||||
g = 0;
|
||||
while (s.salt < 0.42 && g++ < 60 * 20) await F(1);
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyS' }));
|
||||
await F(3);
|
||||
tap('Space'); // pasta in
|
||||
g = 0;
|
||||
while (s.cook < 50 && g++ < 60 * 120) await F(1); // mid al dente window
|
||||
tap('KeyR'); // a cup of the water
|
||||
await F(3);
|
||||
tap('KeyD'); // drain
|
||||
await F(4);
|
||||
tap('Enter');
|
||||
await F(6);
|
||||
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
|
||||
return { salt: s.salt.toFixed(2), cook: Math.round(s.cook), glue: s.glue.toFixed(2), reserved: s.reserved, rows };
|
||||
},
|
||||
|
||||
/** Play the day-24 chips with REAL input: blanch in the shimmer, rest on
|
||||
* the rack till dry, back in HOT for the gold, drain, serve. Twice, love. */
|
||||
async chipDay() {
|
||||
@ -1486,6 +1518,7 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
22: () => harness.benedictDay(),
|
||||
23: () => harness.fryerDay(),
|
||||
24: () => harness.chipDay(),
|
||||
25: () => harness.potDay(),
|
||||
};
|
||||
(harness as unknown as { demos: typeof demos }).demos = demos;
|
||||
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {
|
||||
|
||||
@ -13,6 +13,7 @@ import { EggBenchView } from '../scenes/eggbench';
|
||||
import { PoachView } from '../scenes/poach';
|
||||
import { AirFryerView } from '../scenes/airfryer';
|
||||
import { FryerPotView } from '../scenes/fryerpot';
|
||||
import { PotView } from '../scenes/pot';
|
||||
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
|
||||
import { AssemblyView } from '../scenes/assembly';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
@ -21,7 +22,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, type BruschettaResult, type PrepResult, type Verdict } from './judging';
|
||||
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, 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';
|
||||
@ -29,7 +30,7 @@ import { audio } from '../core/audio';
|
||||
import { coolStep } from '../sim/toasting';
|
||||
import { SPREADS } from '../sim/spreads';
|
||||
import { judge, gradeOf, type Grade } from './judging';
|
||||
import { DAYS, proceduralOrder, nameBrowning, prepAsk, type Order, type PrepStep } from './orders';
|
||||
import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, type PrepStep } from './orders';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
import { Title } from '../ui/title';
|
||||
import { eye } from '../ui/eye';
|
||||
@ -84,6 +85,7 @@ export class Game {
|
||||
private poach: PoachView;
|
||||
private airFryer: AirFryerView;
|
||||
private fryerPot: FryerPotView;
|
||||
private pot: PotView;
|
||||
private assembly: AssemblyView;
|
||||
/** What the prep bench reported for the current order, if it ran. */
|
||||
private prepResult: PrepResult | null = null;
|
||||
@ -135,6 +137,7 @@ export class Game {
|
||||
this.poach = new PoachView(app);
|
||||
this.airFryer = new AirFryerView(app);
|
||||
this.fryerPot = new FryerPotView(app);
|
||||
this.pot = new PotView(app);
|
||||
this.assembly = new AssemblyView(app);
|
||||
app.scene.add(this.kitchen.root);
|
||||
app.scene.add(this.judgeView.root);
|
||||
@ -151,6 +154,7 @@ export class Game {
|
||||
app.scene.add(this.poach.root);
|
||||
app.scene.add(this.airFryer.root);
|
||||
app.scene.add(this.fryerPot.root);
|
||||
app.scene.add(this.pot.root);
|
||||
app.scene.add(this.assembly.root);
|
||||
this.judgeView.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
@ -166,6 +170,7 @@ export class Game {
|
||||
this.poach.root.visible = false;
|
||||
this.airFryer.root.visible = false;
|
||||
this.fryerPot.root.visible = false;
|
||||
this.pot.root.visible = false;
|
||||
this.assembly.root.visible = false;
|
||||
|
||||
this.ticket = new Panel('ticket');
|
||||
@ -323,6 +328,9 @@ export class Game {
|
||||
get eggBenchView(): EggBenchView {
|
||||
return this.eggBench;
|
||||
}
|
||||
get potView(): PotView {
|
||||
return this.pot;
|
||||
}
|
||||
get fryerPotView(): FryerPotView {
|
||||
return this.fryerPot;
|
||||
}
|
||||
@ -503,6 +511,7 @@ export class Game {
|
||||
this.poach.root.visible = false;
|
||||
this.airFryer.root.visible = false;
|
||||
this.fryerPot.root.visible = false;
|
||||
this.pot.root.visible = false;
|
||||
this.assembly.root.visible = false;
|
||||
}
|
||||
|
||||
@ -650,13 +659,14 @@ export class Game {
|
||||
if (o.poach) return this.enterPoach();
|
||||
if (o.airfryer) return this.enterAirFryer();
|
||||
if (o.fryerpot) return this.enterFryerPot();
|
||||
if (o.pot) return this.enterPot();
|
||||
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.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
|
||||
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 tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
|
||||
return `TOASTSIM ${tag} — ${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
|
||||
}
|
||||
@ -748,6 +758,11 @@ export class Game {
|
||||
this.enterFryerPot();
|
||||
return;
|
||||
}
|
||||
// Pasta night: the salt you cannot take back, the roll, the bite.
|
||||
if (this.order.pot) {
|
||||
this.enterPot();
|
||||
return;
|
||||
}
|
||||
// A poach day: the shiver, the vortex, the drop, the wobble.
|
||||
if (this.order.poach) {
|
||||
this.enterPoach();
|
||||
@ -956,6 +971,33 @@ export class Game {
|
||||
}
|
||||
|
||||
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
|
||||
private enterPot(): void {
|
||||
this.pot.reset('pasta \u2014 salted like the sea, rolling boil, al dente');
|
||||
this.hideAllScenes();
|
||||
this.pot.root.visible = true;
|
||||
this.app.setView(this.pot);
|
||||
this.ticket.show();
|
||||
this.pot.onDone = (result) => {
|
||||
this.pot.root.visible = false;
|
||||
this.servePot(result);
|
||||
};
|
||||
}
|
||||
|
||||
private servePot(result: ReturnType<PotView['result']>): void {
|
||||
this.timing = false;
|
||||
const seconds = this.orderSeconds;
|
||||
const slice = this.kitchen.currentSlice;
|
||||
const v = judgePot(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 enterFryerPot(): void {
|
||||
const f = this.order.fryerpot!;
|
||||
this.fryerPot.reset(f.count, 'THE CARD: blanch in the shimmer \u00b7 REST till dry \u00b7 back in HOT \u00b7 stand back when it spits', `${f.count} chips \u2014 golden out, fluffy in, dry`);
|
||||
@ -1094,7 +1136,7 @@ export class Game {
|
||||
const st = this.save.starter;
|
||||
if (st) {
|
||||
const gap = this.save.maxDay - st.fed;
|
||||
const toastDay = !this.order.grill && !this.order.pan && !this.order.fridge && !this.order.steak && !this.order.eggs && !this.order.poach;
|
||||
const toastDay = isToastDay(this.order);
|
||||
if (gap >= 5) extras.push('The jar on the shelf has gone still. You know what you did.');
|
||||
else if (gap <= 1 && toastDay) {
|
||||
v.total = Math.min(10, Math.round((v.total + 0.2) * 10) / 10);
|
||||
|
||||
@ -14,6 +14,7 @@ import type { storeHealth } from '../sim/coldchain';
|
||||
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';
|
||||
|
||||
export interface Criterion {
|
||||
key: string;
|
||||
@ -26,7 +27,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';
|
||||
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot';
|
||||
}
|
||||
|
||||
/** What the prep bench hands the judge, when the order had prep on it. */
|
||||
@ -616,6 +617,88 @@ 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 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.
|
||||
|
||||
export function judgePot(r: PotResult, order: Order, seconds: number): Verdict {
|
||||
const salt = saltScore(r);
|
||||
const criteria: Criterion[] = [
|
||||
{
|
||||
key: 'potsalt',
|
||||
group: 'pot',
|
||||
label: 'The Salt',
|
||||
score: clamp01(salt),
|
||||
weight: 1.3,
|
||||
detail: r.salt === 0
|
||||
? 'unsalted. you can add salt to the water or you can lie to me'
|
||||
: r.salt - r.lateSalt * 0.75 > SALT_RUIN
|
||||
? 'brine. there was a moment to stop and it went past'
|
||||
: r.lateSalt > 0.08
|
||||
? 'salted after the pasta went in — that seasons the sink'
|
||||
: r.salt - r.lateSalt * 0.75 < SALT_LO
|
||||
? 'under. the strand tastes of nothing'
|
||||
: 'seasoned like the sea. exactly there',
|
||||
},
|
||||
{
|
||||
key: 'potbite',
|
||||
group: 'pot',
|
||||
label: 'The Bite',
|
||||
score: clamp01(r.bite),
|
||||
weight: 1.3,
|
||||
detail: r.cook < 30
|
||||
? 'chalk in the middle'
|
||||
: r.cook < 42
|
||||
? 'a shade early — still stiff'
|
||||
: r.cook <= 58
|
||||
? 'al dente. bite left in it'
|
||||
: r.cook < 85
|
||||
? 'soft. the bite is gone'
|
||||
: 'soup with ambitions',
|
||||
},
|
||||
{
|
||||
key: 'potroll',
|
||||
group: 'pot',
|
||||
label: 'The Roll',
|
||||
score: clamp01(1 - r.glue * 1.6),
|
||||
weight: 1.0,
|
||||
detail: r.glue > 0.45 ? 'one gluey brick — it sat in a simmer' : r.glue > 0.15 ? 'clinging a little' : 'loose strands, tumbled properly',
|
||||
},
|
||||
{
|
||||
key: 'potwater',
|
||||
group: 'bench',
|
||||
label: 'The Water',
|
||||
score: r.reserved ? clamp01(0.55 + r.starchy * 0.9) : 0.25,
|
||||
weight: 0.7,
|
||||
detail: r.reserved
|
||||
? r.starchy > 0.35 ? 'a cup of it kept back, and properly starchy' : 'kept a cup, though it was thin water'
|
||||
: 'poured every drop away. the sauce has nothing to hold',
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: 'Service',
|
||||
score: clamp01(1 - (seconds - 110) / 140),
|
||||
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;
|
||||
}
|
||||
// Brine is not a bad row on a good card — it is an INEDIBLE PLATE, and no
|
||||
// amount of perfect timing elsewhere averages that away. The one verb you
|
||||
// could not take back gets the one score you cannot climb out of.
|
||||
const brined = r.salt - r.lateSalt * 0.75 > SALT_RUIN;
|
||||
const total = Math.min((sum / wsum) * 10, brined ? 3 : 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 fryer — the double-fry doctrine, judged. The gold is the postcard, the
|
||||
// inside is the truth, the grease is the confession, the burns are on you.
|
||||
|
||||
@ -124,6 +124,8 @@ export interface Order {
|
||||
airfryer?: { count: number; card: string; deg1: number; deg2: number };
|
||||
/** THE FRYER: the pot of oil. Twice, love — once for cooked, once for golden. */
|
||||
fryerpot?: { count: number };
|
||||
/** THE POT: pasta night. Salt is the one verb you cannot take back. */
|
||||
pot?: { dish: string };
|
||||
}
|
||||
|
||||
export const BROWNING_NAMES: [number, string][] = [
|
||||
@ -525,6 +527,21 @@ export const DAYS: Order[] = [
|
||||
text: 'Chips, love. Twice — once for cooked, once for golden. Blanch them soft in the shimmer, rest them on the rack till they stop steaming, then send them back HOT. And when it spits, you stand BACK.',
|
||||
fryerpot: { count: 8 },
|
||||
},
|
||||
{
|
||||
day: 25,
|
||||
brand: 'WONDERSLICE',
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.55,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.5,
|
||||
who: 'the chef, on her day off',
|
||||
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' },
|
||||
},
|
||||
];
|
||||
|
||||
/** Days beyond the script — keep going, with everything cranked. */
|
||||
@ -545,7 +562,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
|
||||
if (roll < 0.84) return proceduralSteak(day, rand);
|
||||
if (roll < 0.89) return proceduralEggs(day, rand);
|
||||
if (roll < 0.915) return proceduralAirfryer(day, rand);
|
||||
if (roll < 0.93) return proceduralFryerPot(day, rand);
|
||||
if (roll < 0.925) return proceduralFryerPot(day, rand);
|
||||
if (roll < 0.94) return proceduralPot(day, rand);
|
||||
if (roll < 0.945) return proceduralPoach(day, rand);
|
||||
if (roll < 0.97) return proceduralBenedict(day, rand);
|
||||
return proceduralFridge(day, rand);
|
||||
@ -574,6 +592,40 @@ function proceduralFryerPot(day: number, rand: () => number): Order {
|
||||
return o;
|
||||
}
|
||||
|
||||
function proceduralPot(day: number, rand: () => number): Order {
|
||||
const o = baseOrder(
|
||||
day,
|
||||
pickOf(rand, ['the chef, on her day off', 'Deidre', 'a regular', 'the man at table nine']),
|
||||
'Pasta, properly: seasoned water, a rolling boil, al dente, and a cup of the water kept back.',
|
||||
);
|
||||
o.pot = { dish: 'pasta' };
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which station an order sends you to, or null for a plain toast day. EVERY
|
||||
* station flag is listed here and nowhere else: the old "not grill and not pan
|
||||
* and not..." blocklists silently accepted each new station as toast, which is
|
||||
* how the sourdough starter ended up buttering up chip day.
|
||||
*/
|
||||
export function stationOf(o: Order): string | null {
|
||||
if (o.bruschetta) return 'bruschetta';
|
||||
if (o.benedict) return 'benedict';
|
||||
if (o.grill) return 'grill';
|
||||
if (o.pan) return 'pan';
|
||||
if (o.fridge) return 'fridge';
|
||||
if (o.steak) return 'steak';
|
||||
if (o.eggs) return 'eggs';
|
||||
if (o.poach) return 'poach';
|
||||
if (o.airfryer) return 'airfryer';
|
||||
if (o.fryerpot) return 'fryerpot';
|
||||
if (o.pot) return 'pot';
|
||||
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;
|
||||
|
||||
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. */
|
||||
|
||||
@ -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' : 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' : 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.fryerpot
|
||||
this.orderEl.textContent = order.pot
|
||||
? `Day ${order.day} · ${order.who} asked for pasta`
|
||||
: order.fryerpot
|
||||
? `Day ${order.day} · ${order.who} asked for the chips`
|
||||
: order.airfryer
|
||||
? `Day ${order.day} · ${order.who} asked for the wings`
|
||||
@ -231,7 +233,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) => 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',
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
prep: 'THE PREP',
|
||||
@ -246,9 +248,10 @@ export class JudgeView implements View {
|
||||
eggs: 'THE EGGS',
|
||||
poach: 'THE POACH',
|
||||
fryer: 'THE FRYER',
|
||||
pot: 'THE POT',
|
||||
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, 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, spread: 4, bench: 5 };
|
||||
let lastGroup: string | undefined;
|
||||
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
|
||||
const ordered = grouped
|
||||
|
||||
314
src/scenes/pot.ts
Normal file
314
src/scenes/pot.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App, View } from '../core/app';
|
||||
import { audio } from '../core/audio';
|
||||
import { eye } from '../ui/eye';
|
||||
import {
|
||||
type PotSession,
|
||||
newPotSession,
|
||||
setPotHeat,
|
||||
addSalt,
|
||||
dropPasta,
|
||||
reserveWater,
|
||||
drain,
|
||||
potStep,
|
||||
potResult,
|
||||
waterWord,
|
||||
saltWord,
|
||||
pastaWord,
|
||||
SIMMER_AT,
|
||||
ROLLING_AT,
|
||||
SALT_HI,
|
||||
SALT_RUIN,
|
||||
AL_DENTE_LO,
|
||||
AL_DENTE_HI,
|
||||
} from '../sim/pot';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
import { loadBackdrop } from './props';
|
||||
|
||||
const POT_Y = 0.55;
|
||||
const POT_R = 1.2;
|
||||
|
||||
/**
|
||||
* THE POT — pasta night, and the kitchen's one irreversible verb.
|
||||
*
|
||||
* WHEEL sets the flame; wait for the ROLL, not a simmer. HOLD S and the salt
|
||||
* POURS — you stop it, nothing else does, and there is no unsalting. SPACE
|
||||
* drops the pasta. R reserves a cup of the starchy water. D drains. ENTER
|
||||
* serves. The pasta speaks in words; the clock is not on screen and never
|
||||
* will be.
|
||||
*/
|
||||
export class PotView implements View {
|
||||
readonly root = new THREE.Group();
|
||||
|
||||
private session: PotSession | null = null;
|
||||
private flame!: THREE.Mesh;
|
||||
private water!: THREE.Mesh;
|
||||
private bubbles: THREE.Mesh[] = [];
|
||||
private pasta!: THREE.Group;
|
||||
private strands: THREE.Mesh[] = [];
|
||||
private cellar!: THREE.Group;
|
||||
private saltStream!: THREE.Mesh;
|
||||
private cup!: THREE.Mesh;
|
||||
private pouring = false;
|
||||
|
||||
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 potResult>) => void) | null = null;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.buildScenery();
|
||||
this.panel = new Panel('pot-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, 'PASTA NIGHT');
|
||||
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.68, 0.46, 20),
|
||||
new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }),
|
||||
);
|
||||
this.flame.position.y = 0.32;
|
||||
this.root.add(this.flame);
|
||||
|
||||
const pot = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(POT_R, POT_R * 0.94, 0.9, 32, 1, true),
|
||||
new THREE.MeshStandardMaterial({ color: 0x9298a0, roughness: 0.3, metalness: 0.8, 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: 0x86aec2, roughness: 0.18, transparent: true, opacity: 0.8 }),
|
||||
);
|
||||
this.water.position.y = POT_Y + 0.16;
|
||||
this.root.add(this.water);
|
||||
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const b = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.045, 8, 6),
|
||||
new THREE.MeshBasicMaterial({ color: 0xeef6fa, transparent: true, opacity: 0 }),
|
||||
);
|
||||
b.position.set((Math.random() - 0.5) * POT_R * 1.5, POT_Y + 0.19, (Math.random() - 0.5) * POT_R * 1.5);
|
||||
this.bubbles.push(b);
|
||||
this.root.add(b);
|
||||
}
|
||||
|
||||
// The pasta: a bundle of strands that droops as it softens.
|
||||
this.pasta = new THREE.Group();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const st = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.022, 0.022, 1.5, 6),
|
||||
new THREE.MeshStandardMaterial({ color: 0xe8c96a, roughness: 0.7 }),
|
||||
);
|
||||
st.position.set((i - 4) * 0.045, 0, (i % 3) * 0.03);
|
||||
this.strands.push(st);
|
||||
this.pasta.add(st);
|
||||
}
|
||||
this.pasta.position.set(0, POT_Y + 0.9, 1.6);
|
||||
this.pasta.visible = true;
|
||||
this.root.add(this.pasta);
|
||||
|
||||
// The salt cellar, stage right, and the stream it pours.
|
||||
this.cellar = new THREE.Group();
|
||||
const jar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.22, 0.26, 0.4, 16),
|
||||
new THREE.MeshStandardMaterial({ color: 0xe6ddc9, roughness: 0.75 }),
|
||||
);
|
||||
const lip = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(0.22, 0.03, 8, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0xcfc3a8, roughness: 0.7 }),
|
||||
);
|
||||
lip.rotation.x = Math.PI / 2;
|
||||
lip.position.y = 0.2;
|
||||
this.cellar.add(jar, lip);
|
||||
this.cellar.position.set(2.1, 0.6, 0.5);
|
||||
this.root.add(this.cellar);
|
||||
|
||||
this.saltStream = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.025, 0.05, 1.0, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfaf6ec, transparent: true, opacity: 0 }),
|
||||
);
|
||||
this.saltStream.position.set(0.55, POT_Y + 0.7, 0.2);
|
||||
this.root.add(this.saltStream);
|
||||
|
||||
// The reserved cup, stage left — appears when you keep some back.
|
||||
this.cup = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.24, 0.2, 0.36, 16),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd8d2c4, roughness: 0.6 }),
|
||||
);
|
||||
this.cup.position.set(-2.2, 0.58, 0.7);
|
||||
this.cup.visible = false;
|
||||
this.root.add(this.cup);
|
||||
}
|
||||
|
||||
reset(askText = 'pasta — salted like the sea, rolling boil, al dente'): void {
|
||||
this.session = newPotSession();
|
||||
this.pouring = false;
|
||||
this.cup.visible = false;
|
||||
this.pasta.position.set(0, POT_Y + 0.9, 1.6);
|
||||
this.pasta.rotation.set(0, 0, 0);
|
||||
for (const st of this.strands) (st.material as THREE.MeshStandardMaterial).color.setHex(0xe8c96a);
|
||||
this.askEl.textContent = askText;
|
||||
this.hintEl.textContent = 'WHEEL — flame · HOLD S — pour salt (there is no unsalting) · SPACE — pasta in · R — reserve a cup · D — drain · 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.5, 3.5);
|
||||
this.app.camera.lookAt(0, POT_Y, 0);
|
||||
const s = this.session;
|
||||
if (!s) return;
|
||||
const inp = this.app.input;
|
||||
|
||||
if (inp.wheel !== 0) setPotHeat(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1));
|
||||
|
||||
// THE POUR: held, not tapped. You stop it; nothing else will.
|
||||
const wasPouring = this.pouring;
|
||||
this.pouring = inp.held('KeyS');
|
||||
if (this.pouring) {
|
||||
const before = s.salt;
|
||||
addSalt(s, 0.16 * dt);
|
||||
// The moment it crosses into "no going back", he looks up.
|
||||
if (before <= SALT_HI && s.salt > SALT_HI) eye.mood('intrigued', 1.5);
|
||||
if (before <= SALT_RUIN && s.salt > SALT_RUIN) eye.mood('horrified', 3);
|
||||
audio.bed('salt', 0.03, 6200, 0.9, 1.2);
|
||||
} else if (wasPouring) {
|
||||
audio.bed('salt', 0, 6200);
|
||||
}
|
||||
|
||||
if (inp.justPressed('Space') && dropPasta(s)) {
|
||||
audio.clatter(0.4, 0.9);
|
||||
eye.mood(s.heat >= ROLLING_AT ? 'intrigued' : 'disappointed', 2);
|
||||
}
|
||||
if (inp.justPressed('KeyR') && reserveWater(s)) {
|
||||
this.cup.visible = true;
|
||||
audio.clatter(0.3, 1.4);
|
||||
eye.mood('impressed', 2); // he did not expect you to remember
|
||||
}
|
||||
if (inp.justPressed('KeyD') && drain(s)) {
|
||||
audio.clatter(0.6, 0.6);
|
||||
if (!s.reserved) eye.mood('disappointed', 2.5);
|
||||
}
|
||||
|
||||
potStep(s, dt);
|
||||
|
||||
// The pot's voice: bubbles thicken with the heat, the roll is audible.
|
||||
const roll = Math.max(0, Math.min(1, (s.heat - SIMMER_AT) / (ROLLING_AT - SIMMER_AT)));
|
||||
audio.bed('water', 0.02 + (s.heat / 10) * 0.06, 240 + (s.heat / 10) * 460, 0.6, 0.45 + roll * 0.3);
|
||||
const bps = s.heat > ROLLING_AT ? 14 : s.heat > SIMMER_AT ? 4 : s.heat > 3 ? 1 : 0.1;
|
||||
if (Math.random() < bps * dt) audio.blip(s.heat / 10);
|
||||
|
||||
this.syncVisuals(dt);
|
||||
this.updateHud();
|
||||
|
||||
if (inp.justPressed('Enter') && s.drained) {
|
||||
audio.bed('water', 0, 300);
|
||||
audio.bed('salt', 0, 6200);
|
||||
const cb = this.onDone;
|
||||
this.onDone = null;
|
||||
cb?.(potResult(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 clouds as the salt and starch go in — you can SEE the season.
|
||||
const wm = this.water.material as THREE.MeshStandardMaterial;
|
||||
const cloud = Math.min(1, s.salt * 0.9 + s.starchy * 0.5);
|
||||
wm.color.setHex(0x86aec2).lerp(new THREE.Color(0xd6dfe2), cloud);
|
||||
wm.opacity = s.drained ? 0.15 : 0.8;
|
||||
this.water.position.y = POT_Y + 0.16 + (s.drained ? -0.35 : 0);
|
||||
|
||||
const roll = Math.max(0, Math.min(1, (s.heat - SIMMER_AT) / (ROLLING_AT - SIMMER_AT)));
|
||||
for (const b of this.bubbles) {
|
||||
const bm = b.material as THREE.MeshBasicMaterial;
|
||||
bm.opacity = s.drained ? 0 : s.heat > ROLLING_AT ? 0.9 : s.heat > SIMMER_AT ? 0.2 + roll * 0.5 : s.heat > 3 ? 0.1 : 0;
|
||||
b.position.y = POT_Y + 0.19 + (s.heat > SIMMER_AT ? Math.abs(Math.sin(s.seconds * (6 + roll * 8) + b.position.x * 7)) * (0.03 + roll * 0.1) : 0);
|
||||
}
|
||||
|
||||
// The salt stream, and the cellar tipping to pour it.
|
||||
const sm = this.saltStream.material as THREE.MeshBasicMaterial;
|
||||
sm.opacity += ((this.pouring ? 0.85 : 0) - sm.opacity) * (1 - Math.exp(-dt / 0.05));
|
||||
this.cellar.rotation.z += ((this.pouring ? -0.9 : 0) - this.cellar.rotation.z) * (1 - Math.exp(-dt / 0.12));
|
||||
this.cellar.position.x += ((this.pouring ? 0.75 : 2.1) - this.cellar.position.x) * (1 - Math.exp(-dt / 0.12));
|
||||
this.cellar.position.y += ((this.pouring ? POT_Y + 0.85 : 0.6) - this.cellar.position.y) * (1 - Math.exp(-dt / 0.12));
|
||||
|
||||
// The pasta: held above until dropped, then softening and drooping.
|
||||
if (!s.pastaIn) {
|
||||
this.pasta.position.set(0, POT_Y + 0.95, 1.5);
|
||||
this.pasta.rotation.z = 0.35;
|
||||
} else if (!s.drained) {
|
||||
const soft = Math.min(1, s.cook / AL_DENTE_HI);
|
||||
this.pasta.position.set(0, POT_Y + 0.1 - soft * 0.08, 0);
|
||||
this.pasta.rotation.z = 0.35 + soft * 1.2; // it lies down as it gives
|
||||
for (let i = 0; i < this.strands.length; i++) {
|
||||
const st = this.strands[i];
|
||||
st.rotation.z = Math.sin(s.seconds * 2 + i) * 0.25 * soft * (0.3 + roll);
|
||||
// Glue pulls them together into one sad brick.
|
||||
st.position.x = (i - 4) * 0.045 * (1 - s.glue * 0.75);
|
||||
const done = Math.min(1, s.cook / (AL_DENTE_HI + 30));
|
||||
(st.material as THREE.MeshStandardMaterial).color
|
||||
.setHex(0xe8c96a)
|
||||
.lerp(new THREE.Color(0xf6efd8), done * 0.8);
|
||||
}
|
||||
} else {
|
||||
this.pasta.position.set(-0.1, POT_Y + 0.55, 1.2);
|
||||
this.pasta.rotation.z = 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
private updateHud(): void {
|
||||
const s = this.session!;
|
||||
this.stateEl.textContent = `flame ${Math.round(s.knob)}${s.pastaIn && !s.drained ? ` · in the water ${Math.round(s.cook)}s` : ''}${s.reserved ? ' · a cup kept back' : ''} · ${Math.round(s.seconds)}s`;
|
||||
this.wordEl.textContent = `${waterWord(s)} · ${saltWord(s)} · ${pastaWord(s)}`;
|
||||
void AL_DENTE_LO;
|
||||
}
|
||||
|
||||
result(): ReturnType<typeof potResult> {
|
||||
return potResult(this.session!);
|
||||
}
|
||||
}
|
||||
186
src/sim/pot.ts
Normal file
186
src/sim/pot.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { Rng } from '../core/rng';
|
||||
|
||||
/**
|
||||
* THE POT — pasta night, and the one irreversible verb in the kitchen.
|
||||
*
|
||||
* You can always add more salt. You cannot add less. That is the whole
|
||||
* lesson and it is enforced by arithmetic: `salt` only ever goes up, and
|
||||
* the judge tastes exactly where you stopped. The water wants a ROLLING
|
||||
* boil — pasta dropped into a lazy simmer goes gluey, because the strands
|
||||
* sit in their own starch instead of tumbling. Al dente is a window in
|
||||
* immersion-seconds, not a timer you can see: the pasta tells you in words.
|
||||
*
|
||||
* And the last verb is restraint: RESERVE a cup of the starchy water before
|
||||
* you drain, or the sauce has nothing to hold onto.
|
||||
*/
|
||||
|
||||
/** Water heat 0..10. */
|
||||
export const SIMMER_AT = 6.2;
|
||||
export const ROLLING_AT = 8.0;
|
||||
|
||||
/** Salt, in grams-ish. The band the judge calls seasoned. */
|
||||
export const SALT_LO = 0.34;
|
||||
export const SALT_HI = 0.52;
|
||||
/** Past this it is brine and nothing survives it. */
|
||||
export const SALT_RUIN = 0.75;
|
||||
|
||||
const WATER_TAU = 4.5; // the pot's thermal lag — slower than a pan, faster than oil
|
||||
/** Al dente lives here, in immersion seconds at a rolling boil. */
|
||||
export const AL_DENTE_LO = 42;
|
||||
export const AL_DENTE_HI = 58;
|
||||
const GLUE_RATE = 0.055; // starch cling per second when cooked below the roll
|
||||
|
||||
export interface PotSession {
|
||||
knob: number;
|
||||
/** The water, chasing the knob. */
|
||||
heat: number;
|
||||
/** Only ever increases. That is the point. */
|
||||
salt: number;
|
||||
/** Pasta is in the water. */
|
||||
pastaIn: boolean;
|
||||
/** Immersion seconds, scaled by how hard the water was actually moving. */
|
||||
cook: number;
|
||||
/** Starch gluing the strands together — the lazy-simmer sin. */
|
||||
glue: number;
|
||||
/** Starch handed to the water: what makes reserved water worth reserving. */
|
||||
starchy: number;
|
||||
/** A cup of the cooking water, put aside before the drain. */
|
||||
reserved: boolean;
|
||||
drained: boolean;
|
||||
/** Salt added AFTER the pasta went in — it never gets into the strand. */
|
||||
lateSalt: number;
|
||||
seconds: number;
|
||||
rng: Rng;
|
||||
}
|
||||
|
||||
export function newPotSession(seed = 20260723): PotSession {
|
||||
return {
|
||||
knob: 0, heat: 0, salt: 0, pastaIn: false, cook: 0, glue: 0,
|
||||
starchy: 0, reserved: false, drained: false, lateSalt: 0, seconds: 0,
|
||||
rng: new Rng(seed),
|
||||
};
|
||||
}
|
||||
|
||||
export function setPotHeat(s: PotSession, k: number): void {
|
||||
s.knob = Math.max(0, Math.min(9, Math.round(k)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinch of salt. There is no unpinch. Salt added once the pasta is in
|
||||
* seasons the water, not the strand — the judge can taste the difference.
|
||||
*/
|
||||
export function addSalt(s: PotSession, amount = 0.06): void {
|
||||
s.salt += amount;
|
||||
if (s.pastaIn) s.lateSalt += amount;
|
||||
}
|
||||
|
||||
/** In it goes. Only once — you cannot un-drop pasta either. */
|
||||
export function dropPasta(s: PotSession): boolean {
|
||||
if (s.pastaIn || s.drained) return false;
|
||||
s.pastaIn = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reserve a cup of the starchy water. Only worth anything before the drain. */
|
||||
export function reserveWater(s: PotSession): boolean {
|
||||
if (s.drained || s.reserved) return false;
|
||||
s.reserved = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function drain(s: PotSession): boolean {
|
||||
if (!s.pastaIn || s.drained) return false;
|
||||
s.drained = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function potStep(s: PotSession, dt: number): void {
|
||||
s.seconds += dt;
|
||||
s.heat += (s.knob - s.heat) * (1 - Math.exp(-dt / WATER_TAU));
|
||||
if (!s.pastaIn || s.drained) return;
|
||||
// How hard the water is actually moving decides everything: a rolling boil
|
||||
// tumbles the strands apart, a simmer lets them sit and paste together.
|
||||
const roll = Math.max(0, Math.min(1, (s.heat - SIMMER_AT) / (ROLLING_AT - SIMMER_AT)));
|
||||
s.cook += (0.35 + 0.65 * roll) * dt;
|
||||
if (roll < 0.6) s.glue = Math.min(1, s.glue + GLUE_RATE * (1 - roll) * dt);
|
||||
s.starchy = Math.min(1, s.starchy + 0.02 * (0.4 + roll) * dt);
|
||||
}
|
||||
|
||||
export interface PotResult {
|
||||
/** Immersion seconds achieved. */
|
||||
cook: number;
|
||||
bite: number;
|
||||
salt: number;
|
||||
lateSalt: number;
|
||||
glue: number;
|
||||
reserved: boolean;
|
||||
starchy: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/** 1 inside the al dente window, falling off either side. */
|
||||
export function biteScore(cook: number): number {
|
||||
if (cook < AL_DENTE_LO) return Math.max(0, 1 - (AL_DENTE_LO - cook) / 26);
|
||||
if (cook > AL_DENTE_HI) return Math.max(0, 1 - (cook - AL_DENTE_HI) / 34);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** The seasoning, judged where you STOPPED. Late salt barely counts. */
|
||||
export function saltScore(s: { salt: number; lateSalt: number }): number {
|
||||
// Salt thrown in after the pasta seasons the water it will be poured down
|
||||
// the sink with. It counts for a quarter of itself, and no more.
|
||||
const effective = s.salt - s.lateSalt * 0.75;
|
||||
// Past ruin it falls off a cliff, not a ramp: brine is not 'a bit salty'.
|
||||
if (effective > SALT_RUIN) return Math.max(0, 1 - (effective - SALT_RUIN) / 0.15);
|
||||
if (effective < SALT_LO) return Math.max(0, effective / SALT_LO);
|
||||
if (effective > SALT_HI) return Math.max(0, 1 - (effective - SALT_HI) / (SALT_RUIN - SALT_HI));
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function potResult(s: PotSession): PotResult {
|
||||
return {
|
||||
cook: s.cook,
|
||||
bite: biteScore(s.cook),
|
||||
salt: s.salt,
|
||||
lateSalt: s.lateSalt,
|
||||
glue: s.glue,
|
||||
reserved: s.reserved,
|
||||
starchy: s.starchy,
|
||||
seconds: s.seconds,
|
||||
};
|
||||
}
|
||||
|
||||
/** The water, out loud. No thermometer, ever. */
|
||||
export function waterWord(s: PotSession): string {
|
||||
const t = s.heat;
|
||||
if (t < 2) return 'cold water, waiting';
|
||||
if (t < 4.5) return 'the first small bubbles cling to the base';
|
||||
if (t < SIMMER_AT) return 'trembling — not yet a simmer';
|
||||
if (t < 7.2) return 'a simmer. pasta would SIT in this';
|
||||
if (t < ROLLING_AT) return 'nearly rolling — almost';
|
||||
if (t < 9.3) return 'a proper ROLLING boil';
|
||||
return 'boiling over the rim — turn it down';
|
||||
}
|
||||
|
||||
/** The salt, out loud. It never says a number; it says what you did. */
|
||||
export function saltWord(s: PotSession): string {
|
||||
const e = s.salt - s.lateSalt * 0.75;
|
||||
if (s.salt === 0) return 'unsalted water. he will know';
|
||||
if (e < SALT_LO * 0.6) return 'barely seasoned';
|
||||
if (e < SALT_LO) return 'shy of it — more, while you still can';
|
||||
if (e <= SALT_HI) return 'seasoned like the sea. stop.';
|
||||
if (e <= SALT_RUIN) return 'heavy handed. there is no going back';
|
||||
return 'BRINE. nothing survives this';
|
||||
}
|
||||
|
||||
export function pastaWord(s: PotSession): string {
|
||||
if (!s.pastaIn) return s.heat >= ROLLING_AT ? 'the water is ready for it' : 'waiting on the boil';
|
||||
if (s.drained) return s.reserved ? 'drained, and a cup of the water kept back' : 'drained. the water is gone — all of it';
|
||||
const c = s.cook;
|
||||
if (c < 18) return 'stiff — it has not begun';
|
||||
if (c < AL_DENTE_LO - 8) return 'softening, still chalky in the middle';
|
||||
if (c < AL_DENTE_LO) return 'nearly. taste it, do not trust the clock';
|
||||
if (c <= AL_DENTE_HI) return 'AL DENTE — bite left in it. out, now';
|
||||
if (c < AL_DENTE_HI + 25) return 'soft. it has gone past the bite';
|
||||
return 'this is soup with ambitions';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user