toastsim/src/game/judging.ts
type-two e9fca4f5c9 THE POT: day 25 — you can always add more salt. you can never add less.
Pasta night, and the kitchen's first genuinely irreversible verb. Salt is
a HELD pour, not a click: hold S and it falls, you stop it and nothing
else will, and `salt` is a number that only ever goes up. The words walk
you to the edge and then stop helping — 'shy of it, more while you still
can' becomes 'seasoned like the sea. stop.' becomes 'heavy handed. there
is no going back.' Salt thrown in after the pasta counts a quarter: it
seasons the water you are about to pour down the sink.

The rest is the atlas's pasta doctrine: pasta wants a ROLLING boil, and
a lazy simmer glues the strands into one brick because they sit in their
own starch instead of tumbling. Al dente is a window in immersion
seconds that never appears on screen. And the last verb is restraint —
RESERVE a cup of the starchy water before you drain, or the sauce has
nothing to hold onto.

Two things the adversarial pass caught, both about stakes being fake:
- Brine scored 8.6. An inedible plate cannot be averaged away by good
  timing elsewhere, so ruin now falls off a cliff (not a ramp) and a
  brined pot is CAPPED at 3.0 — the one verb you could not take back
  gets the one score you cannot climb out of.
- The sourdough starter was buttering up chip day. Her 'is this a toast
  day' test was a blocklist of stations that existed when she shipped,
  so air fryer, fryer and pot all silently qualified as toast. Replaced
  with stationOf()/isToastDay() in orders.ts — every station flag listed
  in exactly one place, where the next station cannot slip past it.

Verified: honest pot 10.0; brine 3.0 at the cap; the lazy cook (simmer,
salt after the drop, drained every drop) 4.8 with all three rows naming
the exact sin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:50:05 +10:00

1343 lines
46 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';
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 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: [] };
}
// =====================================================================
// 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<number, number>, k: number) => (m[k] ?? 0) + ((m[k - 1] ?? 0) + (m[k + 1] ?? 0)) * 0.5;
const tsum = (m: Record<number, number>) => 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<typeof storeHealth>, 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 + 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: [],
};
}