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>
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
import type { Criterion, Grade, Verdict } from './judging';
|
|
import type { Order } from './orders';
|
|
import { SPREADS } from '../sim/spreads';
|
|
import { JUICE_LINES, juicerRecognitionLine } from '../sim/juicing';
|
|
|
|
/**
|
|
* The judge. Deadpan, specific, never cruel about anything except MITEY, about
|
|
* which he is unwell.
|
|
*
|
|
* Lines are keyed to the criterion that actually decided the score, so the
|
|
* verdict always tells you the same thing the scorecard does — just meaner.
|
|
*/
|
|
|
|
type Bank = Record<string, { bad: string[]; good: string[] }>;
|
|
|
|
const BANK: Bank = {
|
|
browning: {
|
|
bad: [
|
|
'I asked for {asked}. This is {got}. These are different words.',
|
|
'You have made something adjacent to toast.',
|
|
'The colour is a decision. You appear not to have made one.',
|
|
'This is bread that has been near a toaster. Not the same thing.',
|
|
'Somewhere in this kitchen is a dial. It has numbers on it.',
|
|
],
|
|
good: [
|
|
'The colour is correct. I want that on the record.',
|
|
'That is the shade I asked for. Precisely the shade.',
|
|
'Colour: no notes.',
|
|
],
|
|
},
|
|
evenness: {
|
|
bad: [
|
|
'One half of this has had a completely different morning to the other.',
|
|
'It is striped. Toast should not be striped.',
|
|
'I can see where you rescued it. So can everyone.',
|
|
'This slice has weather.',
|
|
'There are three separate climates on this toast.',
|
|
],
|
|
good: [
|
|
'Even, corner to corner. That is harder than it looks.',
|
|
'No hot spots, no pale spots. Good.',
|
|
'Uniform. Genuinely uniform.',
|
|
],
|
|
},
|
|
coverage: {
|
|
bad: [
|
|
'You have buttered an island and left a continent.',
|
|
'The corners are not decorative. They are toast.',
|
|
'There is a border of bare toast. Why is there a border.',
|
|
'You got most of it. Most.',
|
|
'A generous interpretation of the word "spread".',
|
|
],
|
|
good: [
|
|
'Edge to edge. Somebody was raised properly.',
|
|
'Full coverage. Not a bare corner on it.',
|
|
'You went to the crusts. I noticed.',
|
|
],
|
|
},
|
|
uniformity: {
|
|
bad: [
|
|
'It has a thick end and a thin end, like a bad argument.',
|
|
'Lumpy. I can feel it from here.',
|
|
'The {spread} is doing whatever it likes.',
|
|
'This is not a film. It is terrain.',
|
|
],
|
|
good: [
|
|
'Flat, even, no ridges. Very tidy.',
|
|
'Consistent all the way across.',
|
|
'A properly level spread. Rare.',
|
|
],
|
|
},
|
|
char: {
|
|
bad: [
|
|
'I said no burnt bits. This is a burnt bit convention.',
|
|
'There is carbon on this. Actual carbon.',
|
|
'You burnt it. Then you served it. The second decision is the interesting one.',
|
|
'I asked for toast, not evidence.',
|
|
],
|
|
good: [
|
|
'Not a scorch on it.',
|
|
'No char. Well caught.',
|
|
'Clean. Nothing burnt.',
|
|
],
|
|
},
|
|
integrity: {
|
|
bad: [
|
|
'You have not spread this. You have assaulted it.',
|
|
'The surface is gone. You dragged it off and served the wound.',
|
|
'Cold butter. Warm toast. One of these was your job.',
|
|
'There is a crater. The coverage is admirable. The crater is not.',
|
|
'This bread has been through something and would rather not discuss it.',
|
|
],
|
|
good: [
|
|
'Not a tear on it. That is the whole trick, and you did it.',
|
|
'The surface is perfect. No drag, no gouges.',
|
|
'Intact. You let something be warm first, didn\'t you.',
|
|
],
|
|
},
|
|
amount: {
|
|
bad: [
|
|
'I said {asked}. You have applied {got}.',
|
|
'That is not {asked}. That is a statement.',
|
|
'Somebody has never been told no.',
|
|
],
|
|
good: [
|
|
'Exactly the right amount. Exactly.',
|
|
'The quantity is correct. Thank you.',
|
|
],
|
|
},
|
|
cut: {
|
|
bad: [
|
|
'You have cut a ramp. This toast has a gradient.',
|
|
'Thin at one end, a plank at the other. Pick a thickness.',
|
|
'You leaned on it. The loaf remembers. So do I.',
|
|
'The bakery wept when this happened. I heard them from here.',
|
|
'That is not a slice, that is a decision you kept changing.',
|
|
],
|
|
good: [
|
|
'A clean cut. The bakery would nod.',
|
|
'Thick, even, parallel faces. That is a SLICE.',
|
|
'Someone respected the loaf. It shows.',
|
|
],
|
|
},
|
|
rind: {
|
|
bad: [
|
|
'All the {bits}, in one postcode.',
|
|
'You have made a toast with a {bits} district.',
|
|
'Every piece of {bits}, huddled together for warmth. Spread them OUT.',
|
|
'One bite of this is all {bits}. The others are an apology.',
|
|
'There is no {bits} on this. Did you eat it? You ate it.',
|
|
],
|
|
good: [
|
|
'Evenly strewn {bits}. That takes patience, and you had it.',
|
|
'Every bite gets its {bits}. That is the entire craft.',
|
|
'The {bits}: textbook distribution. I did not expect that today.',
|
|
],
|
|
},
|
|
consistency: {
|
|
bad: [
|
|
'It is weeping oil. I can hear it.',
|
|
'You did not stir the jar. The jar knows. I know.',
|
|
'This has the texture of grout. Grout with ambitions.',
|
|
'Half slick, half plaster. Pick a toast.',
|
|
'The oil went on first and the cement went on after. Stir. The. Jar.',
|
|
],
|
|
good: [
|
|
'Properly stirred. You can taste the difference, and I intend to.',
|
|
'The consistency is exactly right. Someone respected the jar.',
|
|
'Smooth, even, no slick. The jar was stirred by an adult.',
|
|
],
|
|
},
|
|
tool: {
|
|
bad: [
|
|
'You did this with a {tool}. I can tell. Everyone can tell.',
|
|
'The {tool} was a choice.',
|
|
'There was a whole drawer. You picked the {tool}.',
|
|
],
|
|
good: [
|
|
'Right tool. It shows.',
|
|
'Correct utensil. Small thing. Not nothing.',
|
|
],
|
|
},
|
|
cutcv: {
|
|
bad: [
|
|
'These pieces are related, at best. Distant cousins.',
|
|
'One of these is a slice. The rest are opinions.',
|
|
'I lined them up by size. That took a while. That is the problem.',
|
|
'You cut this the way weather cuts a coastline.',
|
|
'Every piece is a surprise. Nobody ordered surprises.',
|
|
],
|
|
good: [
|
|
'Even pieces. Actually even. I measured.',
|
|
'You could deal these like cards.',
|
|
'Uniform. The knife was under instruction, and it listened.',
|
|
],
|
|
},
|
|
bench: {
|
|
bad: [
|
|
'There is juice on everything. Everything.',
|
|
"Somebody's seeds are on my floor.",
|
|
'The board looks like it lost an argument.',
|
|
'You cooked here the way storms cook.',
|
|
'I am not inspecting the food yet. I am inspecting the aftermath.',
|
|
],
|
|
good: [
|
|
"A clean board. You'd be surprised how rare.",
|
|
'The bench is spotless. I checked the corners.',
|
|
'Somebody wiped as they went. Somebody was raised right.',
|
|
],
|
|
},
|
|
dice: {
|
|
bad: [
|
|
'You went through the stem. The onion became confetti. You served confetti.',
|
|
'The root was the one thing holding it together. You cut the root.',
|
|
'It fell apart into slivers. A dice has corners. This has regrets.',
|
|
'You did not stop. The onion did — all over my board.',
|
|
'That is not diced. That is shredded, by someone in a hurry.',
|
|
],
|
|
good: [
|
|
'Square. Actually square. I am keeping one.',
|
|
'You stopped at the root every time. That is the whole skill, and you have it.',
|
|
'Even little cubes, and the root held. Textbook.',
|
|
],
|
|
},
|
|
sting: {
|
|
bad: [
|
|
'You were crying before the second cut. It shows in the pieces.',
|
|
'Blunt and slow. The onion made you pay in tears.',
|
|
'You crushed it more than you cut it. I could smell it from the pass.',
|
|
'Take your time and the onion takes your eyes. You took your time.',
|
|
],
|
|
good: [
|
|
'Sharp, quick, dry-eyed. That is how you beat an onion.',
|
|
'Not a tear. You were faster than it was.',
|
|
'Clean and fast. The onion never got a word in.',
|
|
],
|
|
},
|
|
// --- M15 bruschetta ---
|
|
roast: {
|
|
bad: [
|
|
'These tomatoes are raw. You showed them the oven. That is not the same as using it.',
|
|
'You roasted them into sauce. This is not a topping, it is a spill with ambitions.',
|
|
'Half blistered, half collapsed. You opened the oven at exactly the wrong two moments.',
|
|
'A tomato should blister, not surrender. These surrendered.',
|
|
'You cooked the water out and then kept going. Now there is nothing but skin and regret.',
|
|
],
|
|
good: [
|
|
'Blistered, not burst. That is the whole window and you were in it.',
|
|
'The tomatoes caught just enough. Sweet, not sludge.',
|
|
'Properly roasted. They hold their shape and give up their sugar. Good.',
|
|
],
|
|
},
|
|
topdist: {
|
|
bad: [
|
|
'All the topping, one corner. The rest of the toast is a rumour.',
|
|
'You built a little hill. Bruschetta is not a hill.',
|
|
'One bite has everything. The next has toast and disappointment.',
|
|
'It is clumped. Spread it OUT — every bite earns its share.',
|
|
'This is a garnish that gave up and sat down together.',
|
|
],
|
|
good: [
|
|
'Evenly strewn, corner to corner. Every bite is the same bite. Good.',
|
|
'You placed each piece like it mattered. It did.',
|
|
'Textbook scatter. I did not have to hunt for the tomato.',
|
|
],
|
|
},
|
|
balance: {
|
|
bad: [
|
|
'There is barely anything on this. The toast is doing all the work.',
|
|
'You heaped it. It is sliding off as I look at it.',
|
|
'Thin in the middle of a thick idea. Commit to the topping.',
|
|
'A tomato, a thought, and a lot of bare bread.',
|
|
'This is either a very sad pizza or a very confused piece of toast.',
|
|
],
|
|
good: [
|
|
'Thick, even, and it stays put. That is the balance, exactly.',
|
|
'Enough of everything, too much of nothing. Well judged.',
|
|
'A generous, level topping. Nobody gets a bare bite.',
|
|
],
|
|
},
|
|
sog: {
|
|
bad: [
|
|
'It bends. Toast should not bend.',
|
|
'I pressed it and it folded like a wet letter. You were too slow.',
|
|
'The juice won. It always wins if you dawdle, and you dawdled.',
|
|
'Soggy. The doorstop was your one defence and you let it soak anyway.',
|
|
'This has the structural integrity of an apology.',
|
|
],
|
|
good: [
|
|
'It snapped. That is the sound a bruschetta is meant to make.',
|
|
'Crisp under the finger. You served it in time. Good.',
|
|
'Still crunching. The thick cut earned its keep.',
|
|
],
|
|
},
|
|
rub: {
|
|
bad: [
|
|
'No garlic. It is the name of the dish, more or less.',
|
|
'You forgot the clove. I can taste the absence of it.',
|
|
'You have not rubbed this, you have varnished it. My eyes are watering.',
|
|
'A whole clove, in one streak. The rest is plain toast.',
|
|
],
|
|
good: [
|
|
'A light, even rub. You can smell it before you taste it. Correct.',
|
|
'The garlic is there and it knows its place. Good.',
|
|
'One pass, all over. That is all it ever needed.',
|
|
],
|
|
},
|
|
temp: {
|
|
bad: [
|
|
'The brie is fridge-hard. You had all service to take it out. You took none of it.',
|
|
'The parmesan is sweating. You left it on the bench like it owed you money.',
|
|
'Cold cheese does not spread and warm cheese does not shave. You managed both, wrongly.',
|
|
'You did not plan. The clock planned for you, and the clock is cruel.',
|
|
],
|
|
good: [
|
|
'Brie soft, parmesan cold. You worked the fridge like someone who has done this.',
|
|
'Both cheeses exactly as they should be. That is planning, and I noticed.',
|
|
'You took the brie out early and left the parmesan in. Textbook.',
|
|
],
|
|
},
|
|
};
|
|
|
|
// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the
|
|
// station owns its whole voice. Merge them in rather than duplicate them here.
|
|
Object.assign(BANK, JUICE_LINES);
|
|
|
|
/** MITEY gets its own vocabulary. */
|
|
const MITEY_CRIMES = [
|
|
'This is not a scrape of MITEY. This is a hate crime.',
|
|
'You have applied MITEY the way one applies paint.',
|
|
'I asked to see the toast THROUGH it. I can see nothing. There is only night.',
|
|
'That is a decade of MITEY on one slice. Some of us have to live here.',
|
|
];
|
|
|
|
const GRADE_OPENERS: Record<Grade, string[]> = {
|
|
S: ['Well.', 'I have been doing this for thirty-one years.', 'Hm.'],
|
|
A: ['Close.', 'Nearly.', 'Good.'],
|
|
B: ['Adequate.', 'It is toast.', 'Fine.'],
|
|
C: ['Hm.', 'Right.', 'Well, it exists.'],
|
|
F: ['No.', 'Absolutely not.', 'Take it away.'],
|
|
};
|
|
|
|
const GRADE_CLOSERS: Record<Grade, string[]> = {
|
|
S: [
|
|
'That is the toast. Do it again tomorrow and I will start to worry.',
|
|
'I have no notes. I dislike having no notes.',
|
|
'Perfect. Irritatingly perfect.',
|
|
],
|
|
A: ['Very nearly the toast.', 'One thing away from excellent.', 'Good work. Not finished work.'],
|
|
B: ['It will be eaten. That is the bar it cleared.', 'Serviceable.', 'Nobody will complain. Nobody will remember.'],
|
|
C: ['I have eaten worse. Not recently.', 'Try again.', 'This is why we have a scorecard.'],
|
|
F: ['I am writing this down.', 'Start again. Properly.', 'The bread deserved better.'],
|
|
};
|
|
|
|
function fill(s: string, order: Order, v: Verdict, tool: string): string {
|
|
const spread = SPREADS[order.spread];
|
|
return s
|
|
.replace('{asked}', order.amount === 'thin' ? 'a scrape' : order.amount)
|
|
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
|
|
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
|
|
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
|
|
.replaceAll('{bits}', spread.particles?.name ?? 'rind')
|
|
.replace('{tool}', tool.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Two lines: what went worst, and what went best. Which is what the scorecard
|
|
* says too — this just says it with feeling.
|
|
*/
|
|
export function verdictLines(v: Verdict, order: Order, tool: string, rand: () => number): string[] {
|
|
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
|
const out: string[] = [];
|
|
out.push(pick(GRADE_OPENERS[v.grade]));
|
|
|
|
// The Juicernaut announces itself before the verdict proper — if the order
|
|
// was juiced on one, and only then.
|
|
for (const step of order.prep ?? []) {
|
|
if (step.kind === 'juice') {
|
|
const rec = juicerRecognitionLine(step.juicer);
|
|
if (rec) out.push(rec);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const mitey = order.spread === 'mitey';
|
|
const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4;
|
|
if (mitey && amountBad) {
|
|
out.push(pick(MITEY_CRIMES));
|
|
} else if (v.worst.score < 0.72) {
|
|
out.push(fill(pick(BANK[v.worst.key]?.bad ?? ['Hm.']), order, v, tool));
|
|
}
|
|
|
|
if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
|
|
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool));
|
|
}
|
|
|
|
out.push(pick(GRADE_CLOSERS[v.grade]));
|
|
return out.filter(Boolean);
|
|
}
|
|
|
|
/** Which portrait to show. */
|
|
export function judgeFace(grade: Grade): string {
|
|
switch (grade) {
|
|
case 'S':
|
|
return 'impressed';
|
|
case 'A':
|
|
return 'intrigued';
|
|
case 'B':
|
|
return 'neutral';
|
|
case 'C':
|
|
return 'disappointed';
|
|
default:
|
|
return 'horrified';
|
|
}
|
|
}
|
|
|
|
export function describeCriterion(c: Criterion): string {
|
|
return c.detail;
|
|
}
|