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: [], }; }