import type { Slice } from '../sim/slice'; import { CHAR_THRESHOLD } from '../sim/slice'; import { SPREADS, amountClassOf } from '../sim/spreads'; import { TOOLS, type ToolId } from '../sim/cutlery'; import type { Order } from './orders'; import type { RoastResult } from '../sim/roasting'; import { BLISTER_AT, COLLAPSE_AT } from '../sim/roasting'; import type { AssemblyResult } from '../sim/assembly'; import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly'; import type { GrillResult } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal'; import type { PanResult } from '../sim/pan'; 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; label: string; /** 0..1 */ score: number; weight: number; /** What the number actually was, in words. */ detail: string; /** 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'; } /** What the prep bench hands the judge, when the order had prep on it. */ export interface PrepResult { /** Coefficient of variation of piece volumes, from sim/cutting pieceCV. */ cv?: number; pieces?: number; patternName?: string; slips?: number; /** From sim/mess Mess.stats() at serve time. */ bench?: { mass: number; spreadFrac: number; solids: number }; // --- M14 onion, present only on the technique-test dice -------------------- /** Eye sting endured, 0..1 (sim/cutting). Scored when `technique` is set. */ sting?: number; /** True on the root-stop dice: turns on The Dice + The Eyes rows. */ technique?: boolean; /** Stem discipline — how the root-ward cuts stopped short (sim/cutting). */ stem?: { through: number; stopMean: number; score: number }; } export type Grade = 'S' | 'A' | 'B' | 'C' | 'F'; export interface Verdict { criteria: Criterion[]; /** 0..10 */ total: number; grade: Grade; best: Criterion; worst: Criterion; lines: string[]; } const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); /** "There is visibly spread on this texel." Shared with the live HUD readout. */ export const COVER_FLOOR = 0.06; /** * Every number here is read off the same fields the player was pushing around * with the knife, so the scorecard can always point at something real. */ export function judge( slice: Slice, order: Order, usedTool: ToolId, seconds: number, prep?: PrepResult, /** Criteria a station scored for itself (the juicer's The Juice + Pips) — * folded straight into the card so the toast and the prep are one verdict. */ extra: Criterion[] = [], ): Verdict { const b = slice.browning.stats(slice.mask); const s = slice.spread.stats(slice.mask); const d = slice.damage.stats(slice.mask); const def = SPREADS[order.spread]; const [lo, hi] = def.amounts[order.amount]; const target = (lo + hi) / 2; const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); // Coverage asks one question: are there bare patches? The floor is absolute — // "is there visibly spread here" — NOT relative to the asked amount. Thinness // is Amount's complaint; when coverage also scaled with the ask, a thorough // thin layer scored 20% coverage and the two criteria punished it twice. const coverage = slice.spread.fraction(slice.mask, (v) => v >= COVER_FLOOR); // Uniformity only over the bits you actually covered — punishing the variance // of a half-spread slice twice (here and in coverage) isn't fair. const covered = spreadStdevOverCovered(slice, COVER_FLOOR); const criteria: Criterion[] = [ ...extra, ...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []), ...(prep?.technique && prep.stem ? [stemCriterion(prep.stem)] : []), ...(prep?.technique && prep.sting !== undefined ? [stingCriterion(prep.sting)] : []), ...(prep?.bench ? [benchCriterion(prep.bench)] : []), { key: 'browning', group: 'toast', label: 'Browning', score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), weight: 1.5, detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`, }, { key: 'evenness', group: 'toast', label: 'Evenness', score: clamp01(1 - b.stdev / 0.2), weight: 1.1, detail: `variation ${b.stdev.toFixed(3)}`, }, { key: 'coverage', group: 'spread', label: 'Coverage', score: clamp01((coverage - 0.15) / 0.75), weight: 1.3, detail: `${Math.round(coverage * 100)}% of the slice`, }, { key: 'uniformity', group: 'spread', label: 'Uniformity', score: clamp01(1 - covered / 0.22), weight: 0.9, detail: `variation ${covered.toFixed(3)}`, }, { key: 'char', group: 'toast', label: 'Char', score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), weight: order.noChar ? 1.3 : 0.5, detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`, }, { key: 'integrity', group: 'spread', label: 'Integrity', score: clamp01(1 - d.mean / 0.06), weight: 1.2, detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`, }, ...(order.fromLoaf ? [cutCriterion(slice, order)] : []), ...(def.particles ? [rindCriterion(slice, def.particles.name)] : []), ...(def.separates ? [consistencyCriterion(slice)] : []), { key: 'amount', group: 'spread', label: 'The Right Amount', score: amountScore(s.mean, lo, hi, target), weight: 1.4, detail: `${amountWord(slice, order)} — ${order.amount} asked`, }, { key: 'tool', label: 'Utensil', score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25, weight: 0.4, detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`, }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 45) / 90), 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; // Best/worst ignore Service — nobody wants a verdict about the clock. const rankable = criteria.filter((c) => c.key !== 'time'); const sorted = [...rankable].sort((a, b2) => a.score - b2.score); const worst = sorted[0]; const best = sorted[sorted.length - 1]; return { criteria, total, grade: gradeOf(total), best, worst, lines: [], }; } /** * The cut, when the bread came off a loaf. Three sins, weighted by how loudly * they announce themselves at the table: wrong thickness, wedge faces, and a * squashed crumb. Thick AND even is where the marks are — anyone can cut thin. */ function cutCriterion(slice: Slice, order: Order): Criterion { const c = slice.cut; if (!c) { return { key: 'cut', group: 'prep', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' }; } const [lo, hi] = order.cutBand ?? [0.16, 0.24]; const mid = (lo + hi) / 2; const band = c.thickness >= lo && c.thickness <= hi ? 1 : clamp01(1 - (c.thickness < lo ? lo - c.thickness : c.thickness - hi) / (mid * 0.9)); const even = clamp01(1 - c.wedge * 1.5); const intact = clamp01(1 - c.squash * 1.2); let score = 0.5 * band + 0.32 * even + 0.18 * intact; // The Best Thing leaves a signature. He notices. if (c.knife === 'the_best_thing') score = clamp01(score * 1.04); const mm = Math.round(c.thickness * 110); const word = c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean'; return { key: 'cut', group: 'prep', label: 'The Cut', score, weight: 1.3, detail: `${mm}mm, ${word} (asked ${Math.round(lo * 110)}–${Math.round(hi * 110)}mm)`, }; } /** * How evenly the rind is strewn, scored with the Clark–Evans index: the mean * nearest-neighbour distance between bits, over what that distance would be if * the same number of bits were scattered at random. R near 0 = one clump, * R = 1 = random scatter, R above 1 = deliberately even. The judge accepts * random; he rewards deliberate. */ function rindCriterion(slice: Slice, name: string): Criterion { const pts = slice.rind; const n = pts.length; if (n < 4) { return { key: 'rind', label: cap(name), score: n === 0 ? 0.05 : 0.3, weight: 1.1, detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`, }; } // Mask area in UV units — the bread is the study region, not the unit square. const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n); const R = clarkEvans(pts, area); const score = clamp01(R / 0.95); const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; return { key: 'rind', label: cap(name), score, weight: 1.1, detail: `${n} bits, ${word} (R ${R.toFixed(2)})`, }; } /** * How the spread that actually landed compares to a properly stirred jar. * 0.5 is right; the slick off the top of an unstirred jar pushes it up, the * dried paste underneath pulls it down. The fix was always the same: stir. */ function consistencyCriterion(slice: Slice): Criterion { const oil = slice.oilMean; const spread = slice.oilStdev; const off = Math.abs(oil - 0.5); // Two ways to fail: wrong on average, or right on average and wrong in every // individual bite. The stdev term is what stops "one oily strip, one dry // strip" from scoring as though the jar had been stirred. const score = clamp01(1 - off / 0.32 - spread / 0.24); const word = spread > 0.15 ? 'slick here, grout there' : oil > 0.78 ? 'a slick' : oil > 0.62 ? 'oily' : oil >= 0.38 ? 'just right' : oil >= 0.22 ? 'dry' : 'grout'; return { key: 'consistency', label: 'Consistency', score, weight: 1.1, detail: `oil ${oil.toFixed(2)} ±${spread.toFixed(2)} — ${word}`, }; } /** * Clark–Evans nearest-neighbour index over a point set in a study region of * `area` (UV² units). R ≈ 0 one clump, 1 random, >1 deliberately even. Used * by the rind today; topping distribution (M15) takes the same point list. */ export function clarkEvans(pts: { u: number; v: number }[], area: number): number { const n = pts.length; if (n < 2 || area <= 0) return 0; let sum = 0; for (let i = 0; i < n; i++) { let best = Infinity; for (let j = 0; j < n; j++) { if (i === j) continue; const du = pts[i].u - pts[j].u; const dv = pts[i].v - pts[j].v; const d2 = du * du + dv * dv; if (d2 < best) best = d2; } sum += Math.sqrt(best); } const observed = sum / n; const expected = 0.5 * Math.sqrt(area / n); return observed / expected; } /** * Piece evenness off the prep bench: the CV of piece volumes, banded the way * the live readout words it. Anyone can cut five pieces; the marks are for * five pieces that could be mistaken for each other. */ export function cutEvennessCriterion(cv: number, pieces: number, patternName: string): Criterion { const score = clamp01(1 - (cv - 0.05) / 0.32); const word = cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery'; return { key: 'cutcv', group: 'prep', label: 'The Pieces', score, weight: 1.3, detail: `${pieces} ${patternName}, CV ${cv.toFixed(3)} — ${word}`, }; } /** * Stem discipline — the onion dice's technique row. Stopping every root-ward * cut short of the root is the whole craft: the root holds the layers together * for the criss-cross, and cutting through it turns a dice into confetti. This * is scored SEPARATELY from The Pieces (which reads the criss-cross spacing) so * a player can be told exactly which half they got wrong. */ export function stemCriterion(stem: { through: number; stopMean: number; score: number }): Criterion { const detail = stem.through > 0 ? `${stem.through} cut through the root — it scattered` : `stopped short at ${Math.round(stem.stopMean * 100)}% — the root held`; return { key: 'dice', group: 'prep', label: 'The Dice', score: stem.score, weight: 1.3, detail }; } /** * The eyes. A cut onion off-gasses; the longer it's open and the more it was * crushed rather than sliced, the more it stings. Low sting means you were * sharp and quick — which is the point. Weighted lightly (0.7): it colours the * verdict, it doesn't decide it. */ export function stingCriterion(sting: number): Criterion { const score = clamp01(1 - sting / 0.85); const word = sting < 0.25 ? 'clear-eyed' : sting < 0.55 ? 'watering' : sting < 0.8 ? 'streaming' : 'blind'; return { key: 'sting', group: 'prep', label: 'The Eyes', score, weight: 0.7, detail: `${word} — sting ${sting.toFixed(2)}` }; } /** * The state of the board at serve time. Weighted mildly (0.9): mess is a * running gag before it's a crime, but it IS on the card. */ export function benchCriterion(bench: { mass: number; spreadFrac: number; solids: number }): Criterion { const badness = bench.spreadFrac * 3 + bench.solids * 0.04 + bench.mass * 0.4; const score = clamp01(1 - badness / 1.1); const worst = bench.solids >= 3 && bench.solids * 0.04 > bench.spreadFrac * 3 ? `${bench.solids} bits on my board` : bench.spreadFrac > 0.08 ? `juice over ${Math.round(bench.spreadFrac * 100)}% of it` : badness < 0.25 ? 'clean' : 'smeared'; return { key: 'bench', group: 'prep', label: 'The Bench', score, weight: 0.9, detail: worst, }; } function cap(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } function spreadStdevOverCovered(slice: Slice, floor: number): number { const sp = slice.spread.data; const m = slice.mask.data; let sum = 0; let n = 0; for (let i = 0; i < sp.length; i++) { if (m[i] < 0.5 || sp[i] < floor) continue; sum += sp[i]; n++; } if (n < 8) return 1; const mean = sum / n; let acc = 0; for (let i = 0; i < sp.length; i++) { if (m[i] < 0.5 || sp[i] < floor) continue; const d = sp[i] - mean; acc += d * d; } return Math.sqrt(acc / n); } /** Full marks anywhere inside the band, falling away outside it. */ function amountScore(mean: number, lo: number, hi: number, target: number): number { if (mean >= lo && mean <= hi) return 1; const dist = mean < lo ? lo - mean : mean - hi; return clamp01(1 - dist / (target * 1.5)); } function amountWord(slice: Slice, order: Order): string { const def = SPREADS[order.spread]; const mean = slice.spread.stats(slice.mask).mean; const cls = amountClassOf(def, mean); if (cls === 'none') return 'essentially none'; if (cls === 'obscene') return 'an obscene amount'; return cls; } export function gradeOf(total: number): Grade { if (total >= 9.2) return 'S'; if (total >= 7.6) return 'A'; if (total >= 5.8) return 'B'; if (total >= 3.8) return 'C'; return 'F'; } // ===================================================================== // M-H6 — The charcoal grill. One bed you arranged, judged: did the pieces land // a proper sear (not raw, not carbon), and did you keep the flares off them. export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'sear', group: 'grill', label: 'The Sear', // How many pieces landed the good window — an under-seared piece reads here, // and an uneven bed (one great, one raw) reads in the spread. score: clamp01(r.searedFrac * (1 - r.stdev * 0.35)), weight: 1.4, detail: `${grillWord(r)} (${Math.round(r.searedFrac * 100)}% well-seared)`, }, { key: 'grillchar', group: 'grill', label: 'The Char', score: clamp01(1 - r.charFrac * 1.5), weight: 1.0, detail: r.charFrac > 0.02 ? `${Math.round(r.charFrac * 100)}% carbon` : 'no carbon', }, { key: 'grillbench', group: 'bench', label: 'The Grill', score: clamp01(1 - r.flares * 0.1 - r.ash * 1.5), weight: 0.7, detail: r.flares > 0 ? `${r.flares} flare-up${r.flares > 1 ? 's' : ''}` : 'no flare-ups', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 55) / 90), 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 eggs — did you get the number asked, cleanly: no shell left in the // whites, no yolk in them, and no rotten egg he had to HEAR about. Fishing a // bit out is forgiven; dumping a ruined bowl costs only the time it took. export function judgeEggs(r: EggResult, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'eggcount', group: 'eggs', label: 'The Count', score: clamp01(r.separated / r.need), weight: 1.3, detail: `${r.separated} of ${r.need} separated`, }, { key: 'eggshell', group: 'eggs', label: 'The Shell', score: clamp01(1 - r.shellInBowl * 0.4), weight: 1.0, detail: r.shellInBowl > 0 ? `${r.shellInBowl} bit${r.shellInBowl > 1 ? 's' : ''} still in the whites` : 'none in the bowl', }, { key: 'eggwhites', group: 'eggs', label: 'The Whites', score: r.bowlRuined ? 0 : clamp01(1 - r.yolksBroken * 0.25), weight: 1.2, detail: r.bowlRuined ? (r.rottenIn > 0 ? 'a rotten egg went in' : 'yolk in the whites') : r.yolksBroken > 0 ? `${r.yolksBroken} yolk${r.yolksBroken > 1 ? 's' : ''} broke along the way` : 'clean, whippable', }, { key: 'eggnose', group: 'bench', label: 'The Nose', score: clamp01(1 - r.rottenIn * 0.5), weight: 0.7, detail: r.rottenIn > 0 ? `${r.rottenIn} rotten egg${r.rottenIn > 1 ? 's' : ''} cracked — he heard` : r.testsRun > 0 ? `${r.testsRun} float-tested, none slipped through` : 'lucky, or good', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 50) / 90), 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 poach — the white, the wobble, the form, the drain. He presses it. export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'poachwhite', group: 'poach', label: 'The White', score: clamp01(r.whiteScore), weight: 1.2, detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set', }, { key: 'poachwobble', group: 'poach', label: 'The Wobble', score: clamp01(r.yolkScore), weight: 1.3, detail: r.wobbleWord, }, { key: 'poachform', group: 'poach', label: 'The Form', score: clamp01(r.formScore), weight: 1.0, detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : `one neat comet (vortex ${Math.round(r.vortexAtDrop * 100)}%)`, }, { key: 'poachdrain', group: 'bench', label: 'The Drain', score: clamp01(r.drainScore), weight: 0.6, detail: r.cling > 0.3 ? 'served with its own puddle' : 'drained on the spoon', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 60) / 90), 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 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. export function judgeFryerPot(r: import('../sim/fryer').FryerPotResult, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'potgold', group: 'fryer', label: 'The Gold', score: r.gold < 0.6 ? clamp01(r.gold / 0.6) : r.gold <= 0.85 ? 1 : clamp01(1 - (r.gold - 0.85) / 0.15), weight: 1.2, detail: r.gold < 0.3 ? 'pale as an apology' : r.gold < 0.6 ? 'blond. chips should not be blond' : r.gold <= 0.85 ? 'proper gold' : 'past gold into mahogany', }, { key: 'potinside', group: 'fryer', label: 'The Inside', score: clamp01(r.cooked / 0.85), weight: 1.2, detail: r.cooked >= 0.85 ? 'fluffy inside' : r.cooked >= 0.5 ? 'firm in the middle — the seal beat the cook' : 'raw in the middle. golden outside means nothing now', }, { key: 'potgrease', group: 'fryer', label: 'The Grease', score: clamp01(1 - r.grease * 2.2), weight: 0.9, detail: r.grease > 0.35 ? 'a paper bag of regret — the oil was cold' : r.grease > 0.12 ? 'a little heavy' : 'dry. drained. correct', }, { key: 'pottwice', group: 'fryer', label: 'The Twice', score: r.fries >= 2 ? 1 : 0.35, weight: 0.7, detail: r.fries >= 2 ? `${r.fries} fries — as the card says` : 'once. it shows', }, { key: 'potspit', group: 'bench', label: 'The Spit', score: r.burns === 0 ? 1 : clamp01(1 - r.burns * 0.4), weight: 0.6, detail: r.burns > 0 ? `it caught you ${r.burns > 1 ? r.burns + ' times' : 'once'} — the hand was over the pot` : r.spits > 0 ? `it spat ${r.spits} times; you did not flinch` : 'the oil kept its opinions in the pot', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 100) / 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; } 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 air fryer — arrangement judged as air. The crisp is the browning band; // the turn reads the pale shadowed faces a lazy shake never showed the wind. export function judgeAirfryer(r: import('../sim/airfryer').FryerResult, order: Order, seconds: number): Verdict { const asked = order.airfryer?.count ?? 6; const criteria: Criterion[] = [ { key: 'frycrisp', group: 'fryer', label: 'The Crisp', score: r.crisp < 0.55 ? clamp01(r.crisp / 0.55) : r.crisp <= 0.8 ? 1 : clamp01(1 - (r.crisp - 0.8) / 0.2), weight: 1.3, detail: r.crisp < 0.3 ? 'raw in the middle of a MACHINE' : r.crisp < 0.55 ? 'pale — warm is not fried' : r.crisp <= 0.8 ? 'golden, audibly' : 'past golden into regret', }, { key: 'fryturn', group: 'fryer', label: 'The Turn', score: clamp01(r.evenness - r.pale * 0.6), weight: 1.0, detail: r.pale > 0.4 ? 'pale faces — they were never shaken' : r.pale > 0.18 ? 'one side saw more wind than the other' : 'turned like he taught you', }, { key: 'fryair', group: 'fryer', label: 'The Air', score: clamp01(1 - r.steamed / 5), weight: 1.1, detail: r.steamed > 2.5 ? 'that is a steamer wearing a fryer badge' : r.steamed > 0.6 ? 'crowded for a while — it shows' : 'every wing breathed', }, { key: 'frycard', group: 'fryer', label: 'The Card', ...(() => { const deg1 = order.airfryer?.deg1 ?? 180; const deg2 = order.airfryer?.deg2 ?? 190; const k1 = Math.round((deg1 - 120) / 10); const k2 = Math.round((deg2 - 120) / 10); const near = (m: Record, k: number) => (m[k] ?? 0) + ((m[k - 1] ?? 0) + (m[k + 1] ?? 0)) * 0.5; const tsum = (m: Record) => Object.values(m).reduce((a, b) => a + b, 0); const p1 = tsum(r.preTime) > 1 ? near(r.preTime, k1) / tsum(r.preTime) : 0; const p2 = tsum(r.postTime) > 1 ? near(r.postTime, k2) / tsum(r.postTime) : 0; const halfTime = r.shakes.some((b) => b >= 0.15 && b <= 0.5); const score = clamp01(p1 * 0.4 + p2 * 0.4 + (halfTime ? 0.2 : 0)); const detail = r.shakes.length === 0 ? 'no shake. the card wept' : !halfTime ? p1 > 0.7 && p2 > 0.7 ? 'right temps, wrong moment to shake' : 'shaken, but the temps ignored the card' : p1 > 0.7 && p2 > 0.7 ? `${deg1}° then ${deg2}°, shaken at half-time — as written` : p2 < 0.3 ? `never made the ${deg2}° finish` : 'roughly the card, squinting'; return { score, detail }; })(), weight: 0.9, }, { key: 'frycount', group: 'bench', label: 'The Count', score: clamp01(r.served / asked) * (r.lost > 0 ? Math.max(0.3, 1 - r.lost * 0.35) : 1), weight: 0.8, detail: r.lost > 0 ? `${r.lost} on the floor. His floor.` : r.served < asked ? `${r.served} of ${asked}` : 'all present', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 90) / 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); return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } // ===================================================================== // The benedict — two crafts, one plate. The Bread rows read the slice the // toaster made; The Poach rows read the egg; the cling sogs the toast beneath. export function judgeBenedict(slice: Slice, order: Order, r: PoachResult, seconds: number): Verdict { const b = slice.browning.stats(slice.mask); const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); const criteria: Criterion[] = [ { key: 'browning', group: 'bread', label: 'Browning', score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), weight: 1.1, detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`, }, { key: 'evenness', group: 'bread', label: 'Evenness', score: clamp01(1 - b.stdev / 0.2), weight: 0.8, detail: `variation ${b.stdev.toFixed(3)}`, }, { key: 'char', group: 'bread', label: 'Char', score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), weight: 0.6, detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`, }, { key: 'poachwhite', group: 'poach', label: 'The White', score: clamp01(r.whiteScore), weight: 1.1, detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set', }, { key: 'poachwobble', group: 'poach', label: 'The Wobble', score: clamp01(r.yolkScore), weight: 1.3, detail: r.wobbleWord, }, { key: 'poachform', group: 'poach', label: 'The Form', score: clamp01(r.formScore), weight: 0.9, detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : 'one neat comet', }, { key: 'benedictsog', group: 'bench', label: 'The Plate', // An undrained egg sogs the toast under it — both crafts pay. score: clamp01(1 - r.cling * 1.1), weight: 0.8, detail: r.cling > 0.3 ? 'poach water soaking the toast' : 'dry underneath — drained properly', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 75) / 100), 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, b2) => a.score - b2.score); return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } // ===================================================================== // The rib eye — cook it, rest it, cut it. The Rest is the nerve test: it reads // the flood, so cutting a hot steak bleeds points onto the board. export function judgeSteak(r: SteakResult, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'steakcook', group: 'steak', label: 'The Cook', score: clamp01(r.cookScore), weight: 1.2, detail: `${r.target} — interior ${Math.round(r.doneness * 100)}%`, }, { key: 'steakrest', group: 'steak', label: 'The Rest', score: clamp01(r.restScore), weight: 1.3, detail: r.flood > 1.1 ? 'it bled — you cut it early' : r.flood > 0.2 ? 'a little weep' : 'rested, held its juice', }, { key: 'steakcut', group: 'steak', label: 'The Cut', score: clamp01(r.cutScore), weight: 1.2, detail: r.meanAcross < 0.4 ? 'along the grain — chewy' : r.tears > 0 ? `${r.tears} torn, not sliced` : 'across the grain, clean', }, { key: 'steakboard', group: 'bench', label: 'The Board', score: clamp01(1 - r.flood / 8), weight: 0.6, detail: r.flood > 0.5 ? 'juice all over the board' : 'a clean board', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 55) / 90), 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 cold chain — the fridge day. After three days, how much of your delivery // survived (placement/cold), did anything drip (hygiene), and what did you waste. export function judgeFridge(h: ReturnType, order: Order, seconds: number): Verdict { const criteria: Criterion[] = [ { key: 'coldkept', group: 'cold', label: 'The Cold', score: clamp01(1 - h.off * 1.6), weight: 1.4, detail: h.off > 0.01 ? `${Math.round(h.off * 100)}% went off` : 'nothing spoiled', }, { key: 'hygiene', group: 'cold', label: 'Hygiene', score: clamp01(1 - h.contaminated * 2.2), weight: 1.2, detail: h.contaminated > 0.01 ? `${Math.round(h.contaminated * 100)}% dripped on` : 'no cross-contamination', }, { key: 'waste', group: 'bench', label: 'The Waste', score: clamp01(1 - h.waste * 0.25), weight: 0.7, detail: h.waste > 0 ? `${h.waste} thrown out` : 'nothing wasted', }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 40) / 90), 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: [] }; } // ===================================================================== // M17 — The pan. Did both faces land a proper sear (the flip), did you cook in // good butter not burnt (the bitterness), and is the finished piece even. export function judgePan(r: PanResult, order: Order, seconds: number, ingredient?: { quality: number; word: string }): Verdict { const seared = clamp01(r.doneScore); const criteria: Criterion[] = [ { key: 'pansear', group: 'pan', label: 'The Sear', score: seared, weight: 1.4, detail: `${Math.round(r.downMean * 100)}% / ${Math.round(r.upMean * 100)}% the two faces`, }, { key: 'paneven', group: 'pan', label: 'Both Sides', score: clamp01(1 - r.sideGap * 3), weight: 1.0, detail: r.sideGap < 0.08 ? 'evenly done, both faces' : `${Math.round(r.sideGap * 100)}% apart`, }, { key: 'panbutter', group: 'pan', label: 'The Butter', score: clamp01(1 - r.bitterness * 1.4), weight: 1.0, detail: r.bitterness > 0.15 ? `${r.butter} — bitter` : r.butter === 'noisette' || r.butter === 'foaming' ? `${r.butter}, good` : String(r.butter), }, { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 45) / 90), weight: 0.3, detail: `${Math.round(seconds)}s`, }, ]; // If the mushroom came out of the fridge, its freshness is judged on its own // row — and a genuinely off ingredient CAPS the whole dish, because no sear // saves rot. You can't cook your way out of bad storage. if (ingredient) { // Scored on the freshness BAND, not raw quality: a genuinely fresh ingredient // is full marks (so perfect storage still leaves S on the table — the floors // law), use-soon is a real dent, off/dangerous is near-zero and hard-caps below. const bandScore = ingredient.word === 'fresh' ? 1 : ingredient.word === 'use soon' ? 0.55 : ingredient.word === 'off' ? 0.2 : 0; criteria.push({ key: 'ingredient', group: 'pan', label: 'The Ingredient', score: bandScore, weight: 1.2, detail: `off the shelf — ${ingredient.word}`, }); } let sum = 0; let wsum = 0; for (const c of criteria) { sum += c.score * c.weight; wsum += c.weight; } let total = (sum / wsum) * 10; // A dangerous/off ingredient hard-caps the total — you served it, after all. if (ingredient && ingredient.quality < 0.4) total = Math.min(total, 3 + ingredient.quality * 5); 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: [] }; } // ===================================================================== // M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping / // The Bench. Every row is a number some station computed — the cut and toast // off the bread, the roast + Clark–Evans distribution + mass balance + sog off // the topping, the temperature-plan and the mess off the bench. Kept here beside // the toast judge so the game has one door to scoring. // ===================================================================== /** What the whole bruschetta hands the judge. */ export interface BruschettaResult { roast: RoastResult; assembly: AssemblyResult; /** Temperature-plan readiness, from TempClock.overall(). */ temp: { score: number; worstName: string; worstWord: string }; bench: { mass: number; spreadFrac: number; solids: number }; } /** A value in [lo,hi] scores 1, falling away outside over `soft` either side. */ function bandScore(v: number, lo: number, hi: number, soft: number): number { if (v >= lo && v <= hi) return 1; const d = v < lo ? lo - v : v - hi; return clamp01(1 - d / soft); } /** * The Roast. Blistered is the target (mean in the blister window); raw scores * low, and a rack that slumped to sauce is docked hard — collapsed tomatoes are * not a topping, they're a spill waiting to happen. */ export function roastCriterion(r: RoastResult): Criterion { const band = bandScore(r.mean, BLISTER_AT - 0.02, 0.82, 0.4); const score = clamp01(band - r.collapseFrac * 1.1); const word = r.collapseFrac > 0.35 ? 'collapsed to sauce' : r.mean < 0.3 ? 'still raw' : r.mean < BLISTER_AT ? 'under-roasted' : r.mean >= COLLAPSE_AT ? 'scorched' : 'blistered'; return { key: 'roast', group: 'topping', label: 'The Roast', score, weight: 1.2, detail: `${word} (${r.mean.toFixed(2)}${r.collapseFrac > 0.05 ? `, ${Math.round(r.collapseFrac * 100)}% slumped` : ''})`, }; } /** * Distribution — the same Clark–Evans nearest-neighbour index the rind is * scored with, fed the topping point list. Even scatter over the toast beats a * pile in one corner; every bite should get a bit of everything. */ export function toppingDistributionCriterion(points: { u: number; v: number }[]): Criterion { const n = points.length; if (n < 4) { return { key: 'topdist', group: 'topping', label: 'Distribution', score: n === 0 ? 0.05 : 0.35, weight: 1.2, detail: n === 0 ? 'nothing on it' : `only ${n} pieces`, }; } const R = clarkEvans(points, 1); const score = clamp01(R / 0.95); const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; return { key: 'topdist', group: 'topping', label: 'Distribution', score, weight: 1.2, detail: `${n} pieces, ${word} (R ${R.toFixed(2)})`, }; } /** * Balance — total topping mass per slice area, held to a band. "Thicker but * even and balanced" is the ask: too bare and the toast shows through, too * heaped and it slides off (and soaks through). Distribution is scored beside * this, so an even-but-thin or heaped-but-tidy build each only wins one row. */ export function toppingBalanceCriterion(a: AssemblyResult): Criterion { const [lo, hi] = MASS_BAND; const score = bandScore(a.massPerArea, lo, hi, (hi - lo) * 1.1); const word = a.massPerArea < lo * 0.85 ? 'bare in patches' : a.massPerArea > hi * 1.1 ? 'a heap, not a topping' : 'well balanced'; const missing = a.kinds.tomato === 0 ? ' — no tomato' : a.kinds.cheese === 0 ? ' — no cheese' : a.kinds.basil === 0 ? ' — no basil' : ''; return { key: 'balance', group: 'topping', label: 'Balance', score: clamp01(score - (missing ? 0.25 : 0)), weight: 1.1, detail: `${word}${missing} (mass ${a.massPerArea.toFixed(2)})`, }; } /** The sog clock's verdict — the finger-press. Serve before it bends. */ export function sogCriterion(sog: number): Criterion { const score = clamp01(1 - (sog - 0.2) / 0.6); return { key: 'sog', group: 'topping', label: 'Sog', score, weight: 1.1, detail: `${sogWord(sog)} (${sog.toFixed(2)})`, }; } /** The garlic rub — one light pass, judged gently. None reads raw; a scrub is * wasteful; an even light coat is right. */ export function rubCriterion(coverage: number): Criterion { const score = bandScore(coverage, RUB_TARGET * 0.6, 0.62, 0.32); const word = coverage < 0.06 ? 'no garlic at all' : coverage < RUB_TARGET * 0.6 ? 'barely rubbed' : coverage > 0.7 ? 'drenched in garlic' : 'a proper rub'; return { key: 'rub', group: 'bread', label: 'The Rub', score, weight: 0.7, detail: `${word} (${Math.round(coverage * 100)}%)`, }; } /** The temperature plan — brie soft, parmesan cold. The Bench's planning row. */ export function tempCriterion(temp: { score: number; worstName: string; worstWord: string }): Criterion { return { key: 'temp', group: 'bench', label: 'Temperature', score: temp.score, weight: 1.0, detail: temp.score > 0.8 ? 'brie soft, parmesan cold' : `${temp.worstName} ${temp.worstWord}`, }; } /** * The bruschetta verdict. `slice` is the toasted, cut sourdough (browning + cut * come off it); everything else comes from the three stations. */ export function judgeBruschetta( slice: Slice, order: Order, usedTool: ToolId, seconds: number, r: BruschettaResult, ): Verdict { const b = slice.browning.stats(slice.mask); const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); // The Bread: the cut, the toast, the rub. const cut = cutCriterion(slice, order); cut.group = 'bread'; const criteria: Criterion[] = [ cut, { key: 'browning', group: 'bread', label: 'Browning', score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), weight: 1.4, detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`, }, { key: 'char', group: 'bread', label: 'Char', score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), weight: 0.6, detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`, }, rubCriterion(r.assembly.rubCoverage), // The Topping. roastCriterion(r.roast), toppingDistributionCriterion(r.assembly.points), toppingBalanceCriterion(r.assembly), sogCriterion(r.assembly.sog), // The Bench. tempCriterion(r.temp), { ...benchCriterion(r.bench), group: 'bench' as const }, // Ungrouped. { key: 'time', label: 'Service', score: clamp01(1 - (seconds - 60) / 90), 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, b2) => a.score - b2.score); void usedTool; return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [], }; }