The multi-station order, on the proven bones. Cut the sourdough (M10) →
toast (M1) → roast the tomatoes (toaster generalized) → assemble (garlic rub,
topping point-set, sog clock) → serve. Judge groups The Bread / The Topping /
The Bench. Planning is the mechanic: the temperature clock drifts the two
cheeses on real service time.
What I built on top of the partial work (sims + judge criteria + lines were
already in place and good — kept them):
- Temperature clock WIRED (sim/tempclock.ts): TempClock armed on bruschetta
days, steps every tick while timing; a per-perishable fridge/bench toggle UI
(game.showTempToggle) that reads the judge's own readiness word live; a
toggle costs 8s service (reopening the fridge has teeth). Ticket hints:
"get the brie out now", "leave the parmesan in".
- Roast tomatoes WIRED: OvenView routed after the toast lands on bruschetta
orders (kitchen.onPopped branch → enterOven). Browning Field reused, blister
past 0.6, collapse to sauce past 0.9. oven_tray.glb is absent → loadProp
catch-fallback to the procedural tray (graceful, as designed).
- Assembly scene BUILT (scenes/assembly.ts, new): own toasted garlic-rubbed
slice; G rubs the clove (one-swipe coverage), 1/2/3 drop tomato/cheese/basil
as a point set; ENTER serves. Distribution scored by Clark-Evans (the rind
code, fed the topping list); balance by mass-per-area band.
- Sog clock: rate x wet-mass x moisture / thickness — the doorstop resists,
the thin slice surrenders; the judge presses a finger ("It bends. Toast
should not bend.").
- Day 14 order added; enterOven/enterAssembly/serveBruschetta follow the exact
runNextPrep/enterPrep/enterJuice machinery. Scorecard grouped
bread/topping/bench via judge()'s existing group path.
- Harness (src/dev.ts): t.roast(s,power), t.rub(passes), t.assemble({...}),
t.sogWait(s), t.tempToggle(id), and t.bruschetta() happy path returning the
full grouped stats.
Fixes to the partial work: judge.ts shadowed the `order` param with a local
`const order` (rename → groupRank; would not typecheck); oven.ts material
opacity access typed as Material|Material[] (cast once).
Tuning (measured headless — the pure sims run without three.js):
- assembly SOG_RATE 0.006 → 0.004: doorstop (0.31) under a normal wet load
(5 tomato ~2.1) served promptly (8s) stays CRISP (sog 0.21); dawdled 30s goes
soft (0.77); a thin slice (0.12) bends inside ~15s. The bakery loaf matters.
- assembly MASS_BAND [1.1,2.1] → [2.5,4.5]: the old band scored a normal build
(5t+4c+3b = 3.82 total mass) as an off-band 0; the band now sits around a
proper generous topping. Sparse ~1.3 reads bare, a heap ~6.6 slides off.
- roasting RATE 0.042 kept; comment corrected to the measured window (power 6:
stalls wet to ~26s, blisters 28-36s, collapses past ~40s).
- brie softensInSec 80 (from partial work) kept: out at order start, spreadable
by assembly.
VERIFY (dev build; run in the console):
t.start(); t.bruschetta()
Expected (headless sim + live path):
roast: {mean ~0.70, blister ~1.0, collapse 0, score ~1.0}
distribution: ~1.0 (Clark-Evans R ~1.25, evenly strewn)
balance: {mass ~3.82 in band [2.5,4.5], score ~1.0}
sog: {value ~0.21, word "crisp", score ~0.99}
rub: {coverage ~0.35-0.5, score ~1.0}
temp: {score 1.0 (brie taken out, parmesan left in)}
bench: ~1.0 (clean roast, no slump)
grade A/S, total ~9
Sub-mechanic checks (after t.bruschetta up to the oven, or standalone):
t.roast(26,6) → mean ~0.56, blister ~0 ("under-roasted")
t.roast(42,6) → collapse ~0.69 ("collapsing to sauce")
skip t.tempToggle('brie') → Temperature 0, worst "brie fridge-hard"
thin slice + t.sogWait(15) → sog ~1.0, "it bends"
typecheck clean (tsc --noEmit), vite build clean. node_modules is a symlink to
the main checkout and is deliberately NOT committed (dir-only .gitignore rule
misses the symlink; staged the 12 source files explicitly).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
4.8 KiB
TypeScript
142 lines
4.8 KiB
TypeScript
import { INGREDIENTS, type IngredientId } from './ingredients';
|
|
|
|
/**
|
|
* The temperature clock, generalized from butter softness (M2) to any perishable
|
|
* that drifts on the bench.
|
|
*
|
|
* Butter softness was always a static dial the order handed you. This is that
|
|
* dial made real: a cheese sat in the fridge is cold and firm; left on the bench
|
|
* it warms toward soft in real time, on its own `softensInSec`. Two perishables,
|
|
* two opposite needs, and that is the whole planning game of the bruschetta:
|
|
*
|
|
* brie needsSoft — spreadable by assembly time. Get it OUT early or it
|
|
* places like a stone.
|
|
* parmesan needsCold — shaves clean only cold (the M13 grater rule: warm
|
|
* cheese smears). Leave it IN until you need it.
|
|
*
|
|
* You set each state at order start and can revisit it — for a price in service
|
|
* time, because opening the fridge again mid-service costs you (the game charges
|
|
* the seconds; this sim just flips the state). The pressure is felt through the
|
|
* ticket hint and the clock, never a tutorial: by the time you assemble, the
|
|
* cheese is however warm your planning left it, and the judge tastes it.
|
|
*
|
|
* Pure and time-stepped — no three.js — so the harness can warm a cheese headless
|
|
* and read exactly how ready it is.
|
|
*/
|
|
|
|
export type TempState = 'fridge' | 'bench';
|
|
|
|
export interface Perishable {
|
|
id: IngredientId;
|
|
name: string;
|
|
state: TempState;
|
|
/** 0 = fridge-cold and firm, 1 = out-all-morning warm and soft. */
|
|
temp: number;
|
|
/** Wants to be warm/soft by assembly (brie). */
|
|
needsSoft: boolean;
|
|
/** Wants to be cold/firm at assembly (parmesan). */
|
|
needsCold: boolean;
|
|
/** Seconds on the bench to drift a full 0→1. Fridge cools at the same rate. */
|
|
softensInSec: number;
|
|
}
|
|
|
|
export interface TempReadiness {
|
|
/** The perishable's current 0..1 warmth. */
|
|
temp: number;
|
|
/** 0..1 — how well it meets its need right now. */
|
|
score: number;
|
|
/** The readout word, in the judge's own thresholds. */
|
|
word: string;
|
|
}
|
|
|
|
/** A perishable starts cold in the fridge — you decide what comes out. */
|
|
function makePerishable(id: IngredientId): Perishable {
|
|
const ing = INGREDIENTS[id];
|
|
return {
|
|
id,
|
|
name: ing.name,
|
|
state: 'fridge',
|
|
temp: 0,
|
|
needsSoft: !!ing.temp?.needsSoft,
|
|
needsCold: !!ing.temp?.needsCold,
|
|
softensInSec: ing.temp?.softensInSec ?? 90,
|
|
};
|
|
}
|
|
|
|
export class TempClock {
|
|
readonly items: Perishable[];
|
|
|
|
constructor(ids: IngredientId[]) {
|
|
this.items = ids.map(makePerishable);
|
|
}
|
|
|
|
get(id: IngredientId): Perishable | undefined {
|
|
return this.items.find((p) => p.id === id);
|
|
}
|
|
|
|
/** Flip fridge↔bench. The game charges the service-time cost; this just flips. */
|
|
toggle(id: IngredientId): void {
|
|
const p = this.get(id);
|
|
if (p) p.state = p.state === 'fridge' ? 'bench' : 'fridge';
|
|
}
|
|
|
|
setState(id: IngredientId, state: TempState): void {
|
|
const p = this.get(id);
|
|
if (p) p.state = state;
|
|
}
|
|
|
|
/** Drift every perishable one tick toward its state's target temperature. */
|
|
step(dt: number): void {
|
|
if (dt <= 0) return;
|
|
for (const p of this.items) {
|
|
const target = p.state === 'bench' ? 1 : 0;
|
|
const rate = dt / p.softensInSec;
|
|
if (p.temp < target) p.temp = Math.min(target, p.temp + rate);
|
|
else if (p.temp > target) p.temp = Math.max(target, p.temp - rate);
|
|
}
|
|
}
|
|
|
|
/** How ready one perishable is for assembly, scored against its need. */
|
|
readiness(id: IngredientId): TempReadiness {
|
|
const p = this.get(id);
|
|
if (!p) return { temp: 0, score: 1, word: 'n/a' };
|
|
return readinessOf(p);
|
|
}
|
|
|
|
/**
|
|
* The one number The Bench temperature row reads: the whole plan's readiness,
|
|
* dragged down to its worst perishable — one cold-hard brie or one sweaty
|
|
* parmesan is enough to hear about it.
|
|
*/
|
|
overall(): { score: number; worst: Perishable; word: string } {
|
|
let worst = this.items[0];
|
|
let worstScore = 1;
|
|
for (const p of this.items) {
|
|
const s = readinessOf(p).score;
|
|
if (s < worstScore) {
|
|
worstScore = s;
|
|
worst = p;
|
|
}
|
|
}
|
|
return { score: worstScore, worst, word: readinessOf(worst).word };
|
|
}
|
|
}
|
|
|
|
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
|
|
function readinessOf(p: Perishable): TempReadiness {
|
|
if (p.needsSoft) {
|
|
// Soft is the whole point: cold-hard scores nothing, spreadable scores full.
|
|
const score = clamp01((p.temp - 0.12) / 0.45);
|
|
const word = p.temp < 0.2 ? 'fridge-hard' : p.temp < 0.5 ? 'softening' : 'spreadable';
|
|
return { temp: p.temp, score, word };
|
|
}
|
|
if (p.needsCold) {
|
|
// Cold shaves clean; warm smears (the grater's lesson, one clock over).
|
|
const score = clamp01(1 - (p.temp - 0.2) / 0.45);
|
|
const word = p.temp < 0.25 ? 'cold' : p.temp < 0.55 ? 'sweating' : 'smearing';
|
|
return { temp: p.temp, score, word };
|
|
}
|
|
return { temp: p.temp, score: 1, word: 'fine' };
|
|
}
|