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>
772 lines
26 KiB
TypeScript
772 lines
26 KiB
TypeScript
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 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: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// 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 + 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: [],
|
||
};
|
||
}
|