toastsim/src/game/judging.ts
type-two 8b13ac68a8 M17 pan shipped as a playable day (16); heat brief complete
Routed the pan into a real order: day 16 'pan-sear the mushrooms on the
gas' — butter in, wait for the FOAM not the smoke, land the food, brown
both faces, baste in the noisette. judgePan scores THE SEAR / BOTH SIDES
/ THE BUTTER with a butter-snob line bank. New scorecard group THE PAN;
the cast-iron skillet GLB renders with a fuel-coloured flame.

Balance fix (the floor): food shields the butter once it lands, so a
clean cook keeps the butter at noisette instead of racing to burnt — a
full sear is now winnable. Emergent fuel character kept: induction sears
both sides dead-even (0.016), gas flares the first side so you turn it
early (0.085) — both under the 0.1 bar.

Verified live: day 16 routes to the pan, plays end to end, grades A;
judge nails it ('cooked in different postcodes... foamed not burnt, you
were watching'). Cast-iron pan + butter + searing mushroom on screen.

Heat brief M-H1..M-H6 + M-H4 all shipped and live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:43:30 +10:00

772 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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';
}
/** 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 ClarkEvans 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}`,
};
}
/**
* ClarkEvans 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: [] };
}
// =====================================================================
// 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): 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`,
},
];
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: [] };
}
// =====================================================================
// 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 + ClarkEvans 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 ClarkEvans 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: [],
};
}