toastsim/src/sim/tempclock.ts
type-two cc39478a6e M15 bruschetta capstone: roast → assemble → the sog finger-press, day 14 end to end
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>
2026-07-18 15:25:21 +10:00

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' };
}