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>
1343 lines
46 KiB
TypeScript
1343 lines
46 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';
|
||
import type { storeHealth } from '../sim/coldchain';
|
||
import type { SteakResult } from '../sim/steak';
|
||
import type { EggResult } from '../sim/eggs';
|
||
import type { PoachResult } from '../sim/poach';
|
||
import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot';
|
||
|
||
export interface Criterion {
|
||
key: string;
|
||
label: string;
|
||
/** 0..1 */
|
||
score: number;
|
||
weight: number;
|
||
/** What the number actually was, in words. */
|
||
detail: string;
|
||
/** Which station earned it — the scorecard groups by this on prep orders.
|
||
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
|
||
* distribution, balance, sog), bench (temperature + mess). */
|
||
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot';
|
||
}
|
||
|
||
/** What the prep bench hands the judge, when the order had prep on it. */
|
||
export interface PrepResult {
|
||
/** Coefficient of variation of piece volumes, from sim/cutting pieceCV. */
|
||
cv?: number;
|
||
pieces?: number;
|
||
patternName?: string;
|
||
slips?: number;
|
||
/** From sim/mess Mess.stats() at serve time. */
|
||
bench?: { mass: number; spreadFrac: number; solids: number };
|
||
// --- M14 onion, present only on the technique-test dice --------------------
|
||
/** Eye sting endured, 0..1 (sim/cutting). Scored when `technique` is set. */
|
||
sting?: number;
|
||
/** True on the root-stop dice: turns on The Dice + The Eyes rows. */
|
||
technique?: boolean;
|
||
/** Stem discipline — how the root-ward cuts stopped short (sim/cutting). */
|
||
stem?: { through: number; stopMean: number; score: number };
|
||
}
|
||
|
||
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||
|
||
export interface Verdict {
|
||
criteria: Criterion[];
|
||
/** 0..10 */
|
||
total: number;
|
||
grade: Grade;
|
||
best: Criterion;
|
||
worst: Criterion;
|
||
lines: string[];
|
||
}
|
||
|
||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||
|
||
/** "There is visibly spread on this texel." Shared with the live HUD readout. */
|
||
export const COVER_FLOOR = 0.06;
|
||
|
||
/**
|
||
* Every number here is read off the same fields the player was pushing around
|
||
* with the knife, so the scorecard can always point at something real.
|
||
*/
|
||
export function judge(
|
||
slice: Slice,
|
||
order: Order,
|
||
usedTool: ToolId,
|
||
seconds: number,
|
||
prep?: PrepResult,
|
||
/** Criteria a station scored for itself (the juicer's The Juice + Pips) —
|
||
* folded straight into the card so the toast and the prep are one verdict. */
|
||
extra: Criterion[] = [],
|
||
): Verdict {
|
||
const b = slice.browning.stats(slice.mask);
|
||
const s = slice.spread.stats(slice.mask);
|
||
const d = slice.damage.stats(slice.mask);
|
||
const def = SPREADS[order.spread];
|
||
const [lo, hi] = def.amounts[order.amount];
|
||
const target = (lo + hi) / 2;
|
||
|
||
const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
|
||
// Coverage asks one question: are there bare patches? The floor is absolute —
|
||
// "is there visibly spread here" — NOT relative to the asked amount. Thinness
|
||
// is Amount's complaint; when coverage also scaled with the ask, a thorough
|
||
// thin layer scored 20% coverage and the two criteria punished it twice.
|
||
const coverage = slice.spread.fraction(slice.mask, (v) => v >= COVER_FLOOR);
|
||
// Uniformity only over the bits you actually covered — punishing the variance
|
||
// of a half-spread slice twice (here and in coverage) isn't fair.
|
||
const covered = spreadStdevOverCovered(slice, COVER_FLOOR);
|
||
|
||
const criteria: Criterion[] = [
|
||
...extra,
|
||
...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []),
|
||
...(prep?.technique && prep.stem ? [stemCriterion(prep.stem)] : []),
|
||
...(prep?.technique && prep.sting !== undefined ? [stingCriterion(prep.sting)] : []),
|
||
...(prep?.bench ? [benchCriterion(prep.bench)] : []),
|
||
{
|
||
key: 'browning',
|
||
group: 'toast',
|
||
label: 'Browning',
|
||
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
|
||
weight: 1.5,
|
||
detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
|
||
},
|
||
{
|
||
key: 'evenness',
|
||
group: 'toast',
|
||
label: 'Evenness',
|
||
score: clamp01(1 - b.stdev / 0.2),
|
||
weight: 1.1,
|
||
detail: `variation ${b.stdev.toFixed(3)}`,
|
||
},
|
||
{
|
||
key: 'coverage',
|
||
group: 'spread',
|
||
label: 'Coverage',
|
||
score: clamp01((coverage - 0.15) / 0.75),
|
||
weight: 1.3,
|
||
detail: `${Math.round(coverage * 100)}% of the slice`,
|
||
},
|
||
{
|
||
key: 'uniformity',
|
||
group: 'spread',
|
||
label: 'Uniformity',
|
||
score: clamp01(1 - covered / 0.22),
|
||
weight: 0.9,
|
||
detail: `variation ${covered.toFixed(3)}`,
|
||
},
|
||
{
|
||
key: 'char',
|
||
group: 'toast',
|
||
label: 'Char',
|
||
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
|
||
weight: order.noChar ? 1.3 : 0.5,
|
||
detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
|
||
},
|
||
{
|
||
key: 'integrity',
|
||
group: 'spread',
|
||
label: 'Integrity',
|
||
score: clamp01(1 - d.mean / 0.06),
|
||
weight: 1.2,
|
||
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
|
||
},
|
||
...(order.fromLoaf ? [cutCriterion(slice, order)] : []),
|
||
...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
|
||
...(def.separates ? [consistencyCriterion(slice)] : []),
|
||
{
|
||
key: 'amount',
|
||
group: 'spread',
|
||
label: 'The Right Amount',
|
||
score: amountScore(s.mean, lo, hi, target),
|
||
weight: 1.4,
|
||
detail: `${amountWord(slice, order)} — ${order.amount} asked`,
|
||
},
|
||
{
|
||
key: 'tool',
|
||
label: 'Utensil',
|
||
score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25,
|
||
weight: 0.4,
|
||
detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`,
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 45) / 90),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
const total = (sum / wsum) * 10;
|
||
|
||
// Best/worst ignore Service — nobody wants a verdict about the clock.
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
|
||
const worst = sorted[0];
|
||
const best = sorted[sorted.length - 1];
|
||
|
||
return {
|
||
criteria,
|
||
total,
|
||
grade: gradeOf(total),
|
||
best,
|
||
worst,
|
||
lines: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The cut, when the bread came off a loaf. Three sins, weighted by how loudly
|
||
* they announce themselves at the table: wrong thickness, wedge faces, and a
|
||
* squashed crumb. Thick AND even is where the marks are — anyone can cut thin.
|
||
*/
|
||
function cutCriterion(slice: Slice, order: Order): Criterion {
|
||
const c = slice.cut;
|
||
if (!c) {
|
||
return { key: 'cut', group: 'prep', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' };
|
||
}
|
||
const [lo, hi] = order.cutBand ?? [0.16, 0.24];
|
||
const mid = (lo + hi) / 2;
|
||
const band =
|
||
c.thickness >= lo && c.thickness <= hi
|
||
? 1
|
||
: clamp01(1 - (c.thickness < lo ? lo - c.thickness : c.thickness - hi) / (mid * 0.9));
|
||
const even = clamp01(1 - c.wedge * 1.5);
|
||
const intact = clamp01(1 - c.squash * 1.2);
|
||
let score = 0.5 * band + 0.32 * even + 0.18 * intact;
|
||
// The Best Thing leaves a signature. He notices.
|
||
if (c.knife === 'the_best_thing') score = clamp01(score * 1.04);
|
||
const mm = Math.round(c.thickness * 110);
|
||
const word =
|
||
c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean';
|
||
return {
|
||
key: 'cut',
|
||
group: 'prep',
|
||
label: 'The Cut',
|
||
score,
|
||
weight: 1.3,
|
||
detail: `${mm}mm, ${word} (asked ${Math.round(lo * 110)}–${Math.round(hi * 110)}mm)`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* How evenly the rind is strewn, scored with the Clark–Evans index: the mean
|
||
* nearest-neighbour distance between bits, over what that distance would be if
|
||
* the same number of bits were scattered at random. R near 0 = one clump,
|
||
* R = 1 = random scatter, R above 1 = deliberately even. The judge accepts
|
||
* random; he rewards deliberate.
|
||
*/
|
||
function rindCriterion(slice: Slice, name: string): Criterion {
|
||
const pts = slice.rind;
|
||
const n = pts.length;
|
||
if (n < 4) {
|
||
return {
|
||
key: 'rind',
|
||
label: cap(name),
|
||
score: n === 0 ? 0.05 : 0.3,
|
||
weight: 1.1,
|
||
detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`,
|
||
};
|
||
}
|
||
// Mask area in UV units — the bread is the study region, not the unit square.
|
||
const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
|
||
const R = clarkEvans(pts, area);
|
||
const score = clamp01(R / 0.95);
|
||
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
|
||
return {
|
||
key: 'rind',
|
||
label: cap(name),
|
||
score,
|
||
weight: 1.1,
|
||
detail: `${n} bits, ${word} (R ${R.toFixed(2)})`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* How the spread that actually landed compares to a properly stirred jar.
|
||
* 0.5 is right; the slick off the top of an unstirred jar pushes it up, the
|
||
* dried paste underneath pulls it down. The fix was always the same: stir.
|
||
*/
|
||
function consistencyCriterion(slice: Slice): Criterion {
|
||
const oil = slice.oilMean;
|
||
const spread = slice.oilStdev;
|
||
const off = Math.abs(oil - 0.5);
|
||
// Two ways to fail: wrong on average, or right on average and wrong in every
|
||
// individual bite. The stdev term is what stops "one oily strip, one dry
|
||
// strip" from scoring as though the jar had been stirred.
|
||
const score = clamp01(1 - off / 0.32 - spread / 0.24);
|
||
const word =
|
||
spread > 0.15
|
||
? 'slick here, grout there'
|
||
: oil > 0.78
|
||
? 'a slick'
|
||
: oil > 0.62
|
||
? 'oily'
|
||
: oil >= 0.38
|
||
? 'just right'
|
||
: oil >= 0.22
|
||
? 'dry'
|
||
: 'grout';
|
||
return {
|
||
key: 'consistency',
|
||
label: 'Consistency',
|
||
score,
|
||
weight: 1.1,
|
||
detail: `oil ${oil.toFixed(2)} ±${spread.toFixed(2)} — ${word}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Clark–Evans nearest-neighbour index over a point set in a study region of
|
||
* `area` (UV² units). R ≈ 0 one clump, 1 random, >1 deliberately even. Used
|
||
* by the rind today; topping distribution (M15) takes the same point list.
|
||
*/
|
||
export function clarkEvans(pts: { u: number; v: number }[], area: number): number {
|
||
const n = pts.length;
|
||
if (n < 2 || area <= 0) return 0;
|
||
let sum = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
let best = Infinity;
|
||
for (let j = 0; j < n; j++) {
|
||
if (i === j) continue;
|
||
const du = pts[i].u - pts[j].u;
|
||
const dv = pts[i].v - pts[j].v;
|
||
const d2 = du * du + dv * dv;
|
||
if (d2 < best) best = d2;
|
||
}
|
||
sum += Math.sqrt(best);
|
||
}
|
||
const observed = sum / n;
|
||
const expected = 0.5 * Math.sqrt(area / n);
|
||
return observed / expected;
|
||
}
|
||
|
||
/**
|
||
* Piece evenness off the prep bench: the CV of piece volumes, banded the way
|
||
* the live readout words it. Anyone can cut five pieces; the marks are for
|
||
* five pieces that could be mistaken for each other.
|
||
*/
|
||
export function cutEvennessCriterion(cv: number, pieces: number, patternName: string): Criterion {
|
||
const score = clamp01(1 - (cv - 0.05) / 0.32);
|
||
const word = cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery';
|
||
return {
|
||
key: 'cutcv',
|
||
group: 'prep',
|
||
label: 'The Pieces',
|
||
score,
|
||
weight: 1.3,
|
||
detail: `${pieces} ${patternName}, CV ${cv.toFixed(3)} — ${word}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Stem discipline — the onion dice's technique row. Stopping every root-ward
|
||
* cut short of the root is the whole craft: the root holds the layers together
|
||
* for the criss-cross, and cutting through it turns a dice into confetti. This
|
||
* is scored SEPARATELY from The Pieces (which reads the criss-cross spacing) so
|
||
* a player can be told exactly which half they got wrong.
|
||
*/
|
||
export function stemCriterion(stem: { through: number; stopMean: number; score: number }): Criterion {
|
||
const detail =
|
||
stem.through > 0
|
||
? `${stem.through} cut through the root — it scattered`
|
||
: `stopped short at ${Math.round(stem.stopMean * 100)}% — the root held`;
|
||
return { key: 'dice', group: 'prep', label: 'The Dice', score: stem.score, weight: 1.3, detail };
|
||
}
|
||
|
||
/**
|
||
* The eyes. A cut onion off-gasses; the longer it's open and the more it was
|
||
* crushed rather than sliced, the more it stings. Low sting means you were
|
||
* sharp and quick — which is the point. Weighted lightly (0.7): it colours the
|
||
* verdict, it doesn't decide it.
|
||
*/
|
||
export function stingCriterion(sting: number): Criterion {
|
||
const score = clamp01(1 - sting / 0.85);
|
||
const word = sting < 0.25 ? 'clear-eyed' : sting < 0.55 ? 'watering' : sting < 0.8 ? 'streaming' : 'blind';
|
||
return { key: 'sting', group: 'prep', label: 'The Eyes', score, weight: 0.7, detail: `${word} — sting ${sting.toFixed(2)}` };
|
||
}
|
||
|
||
/**
|
||
* The state of the board at serve time. Weighted mildly (0.9): mess is a
|
||
* running gag before it's a crime, but it IS on the card.
|
||
*/
|
||
export function benchCriterion(bench: { mass: number; spreadFrac: number; solids: number }): Criterion {
|
||
const badness = bench.spreadFrac * 3 + bench.solids * 0.04 + bench.mass * 0.4;
|
||
const score = clamp01(1 - badness / 1.1);
|
||
const worst =
|
||
bench.solids >= 3 && bench.solids * 0.04 > bench.spreadFrac * 3
|
||
? `${bench.solids} bits on my board`
|
||
: bench.spreadFrac > 0.08
|
||
? `juice over ${Math.round(bench.spreadFrac * 100)}% of it`
|
||
: badness < 0.25
|
||
? 'clean'
|
||
: 'smeared';
|
||
return {
|
||
key: 'bench',
|
||
group: 'prep',
|
||
label: 'The Bench',
|
||
score,
|
||
weight: 0.9,
|
||
detail: worst,
|
||
};
|
||
}
|
||
|
||
function cap(s: string): string {
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
function spreadStdevOverCovered(slice: Slice, floor: number): number {
|
||
const sp = slice.spread.data;
|
||
const m = slice.mask.data;
|
||
let sum = 0;
|
||
let n = 0;
|
||
for (let i = 0; i < sp.length; i++) {
|
||
if (m[i] < 0.5 || sp[i] < floor) continue;
|
||
sum += sp[i];
|
||
n++;
|
||
}
|
||
if (n < 8) return 1;
|
||
const mean = sum / n;
|
||
let acc = 0;
|
||
for (let i = 0; i < sp.length; i++) {
|
||
if (m[i] < 0.5 || sp[i] < floor) continue;
|
||
const d = sp[i] - mean;
|
||
acc += d * d;
|
||
}
|
||
return Math.sqrt(acc / n);
|
||
}
|
||
|
||
/** Full marks anywhere inside the band, falling away outside it. */
|
||
function amountScore(mean: number, lo: number, hi: number, target: number): number {
|
||
if (mean >= lo && mean <= hi) return 1;
|
||
const dist = mean < lo ? lo - mean : mean - hi;
|
||
return clamp01(1 - dist / (target * 1.5));
|
||
}
|
||
|
||
function amountWord(slice: Slice, order: Order): string {
|
||
const def = SPREADS[order.spread];
|
||
const mean = slice.spread.stats(slice.mask).mean;
|
||
const cls = amountClassOf(def, mean);
|
||
if (cls === 'none') return 'essentially none';
|
||
if (cls === 'obscene') return 'an obscene amount';
|
||
return cls;
|
||
}
|
||
|
||
export function gradeOf(total: number): Grade {
|
||
if (total >= 9.2) return 'S';
|
||
if (total >= 7.6) return 'A';
|
||
if (total >= 5.8) return 'B';
|
||
if (total >= 3.8) return 'C';
|
||
return 'F';
|
||
}
|
||
|
||
// =====================================================================
|
||
// M-H6 — The charcoal grill. One bed you arranged, judged: did the pieces land
|
||
// a proper sear (not raw, not carbon), and did you keep the flares off them.
|
||
|
||
export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdict {
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'sear',
|
||
group: 'grill',
|
||
label: 'The Sear',
|
||
// How many pieces landed the good window — an under-seared piece reads here,
|
||
// and an uneven bed (one great, one raw) reads in the spread.
|
||
score: clamp01(r.searedFrac * (1 - r.stdev * 0.35)),
|
||
weight: 1.4,
|
||
detail: `${grillWord(r)} (${Math.round(r.searedFrac * 100)}% well-seared)`,
|
||
},
|
||
{
|
||
key: 'grillchar',
|
||
group: 'grill',
|
||
label: 'The Char',
|
||
score: clamp01(1 - r.charFrac * 1.5),
|
||
weight: 1.0,
|
||
detail: r.charFrac > 0.02 ? `${Math.round(r.charFrac * 100)}% carbon` : 'no carbon',
|
||
},
|
||
{
|
||
key: 'grillbench',
|
||
group: 'bench',
|
||
label: 'The Grill',
|
||
score: clamp01(1 - r.flares * 0.1 - r.ash * 1.5),
|
||
weight: 0.7,
|
||
detail: r.flares > 0 ? `${r.flares} flare-up${r.flares > 1 ? 's' : ''}` : 'no flare-ups',
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 55) / 90),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
const total = (sum / wsum) * 10;
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b) => a.score - b.score);
|
||
void order;
|
||
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// The eggs — did you get the number asked, cleanly: no shell left in the
|
||
// whites, no yolk in them, and no rotten egg he had to HEAR about. Fishing a
|
||
// bit out is forgiven; dumping a ruined bowl costs only the time it took.
|
||
|
||
export function judgeEggs(r: EggResult, order: Order, seconds: number): Verdict {
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'eggcount',
|
||
group: 'eggs',
|
||
label: 'The Count',
|
||
score: clamp01(r.separated / r.need),
|
||
weight: 1.3,
|
||
detail: `${r.separated} of ${r.need} separated`,
|
||
},
|
||
{
|
||
key: 'eggshell',
|
||
group: 'eggs',
|
||
label: 'The Shell',
|
||
score: clamp01(1 - r.shellInBowl * 0.4),
|
||
weight: 1.0,
|
||
detail: r.shellInBowl > 0 ? `${r.shellInBowl} bit${r.shellInBowl > 1 ? 's' : ''} still in the whites` : 'none in the bowl',
|
||
},
|
||
{
|
||
key: 'eggwhites',
|
||
group: 'eggs',
|
||
label: 'The Whites',
|
||
score: r.bowlRuined ? 0 : clamp01(1 - r.yolksBroken * 0.25),
|
||
weight: 1.2,
|
||
detail: r.bowlRuined ? (r.rottenIn > 0 ? 'a rotten egg went in' : 'yolk in the whites') : r.yolksBroken > 0 ? `${r.yolksBroken} yolk${r.yolksBroken > 1 ? 's' : ''} broke along the way` : 'clean, whippable',
|
||
},
|
||
{
|
||
key: 'eggnose',
|
||
group: 'bench',
|
||
label: 'The Nose',
|
||
score: clamp01(1 - r.rottenIn * 0.5),
|
||
weight: 0.7,
|
||
detail: r.rottenIn > 0 ? `${r.rottenIn} rotten egg${r.rottenIn > 1 ? 's' : ''} cracked — he heard` : r.testsRun > 0 ? `${r.testsRun} float-tested, none slipped through` : 'lucky, or good',
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 50) / 90),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
const total = (sum / wsum) * 10;
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b) => a.score - b.score);
|
||
void order;
|
||
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// The poach — the white, the wobble, the form, the drain. He presses it.
|
||
|
||
export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdict {
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'poachwhite',
|
||
group: 'poach',
|
||
label: 'The White',
|
||
score: clamp01(r.whiteScore),
|
||
weight: 1.2,
|
||
detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set',
|
||
},
|
||
{
|
||
key: 'poachwobble',
|
||
group: 'poach',
|
||
label: 'The Wobble',
|
||
score: clamp01(r.yolkScore),
|
||
weight: 1.3,
|
||
detail: r.wobbleWord,
|
||
},
|
||
{
|
||
key: 'poachform',
|
||
group: 'poach',
|
||
label: 'The Form',
|
||
score: clamp01(r.formScore),
|
||
weight: 1.0,
|
||
detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : `one neat comet (vortex ${Math.round(r.vortexAtDrop * 100)}%)`,
|
||
},
|
||
{
|
||
key: 'poachdrain',
|
||
group: 'bench',
|
||
label: 'The Drain',
|
||
score: clamp01(r.drainScore),
|
||
weight: 0.6,
|
||
detail: r.cling > 0.3 ? 'served with its own puddle' : 'drained on the spoon',
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 60) / 90),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
const total = (sum / wsum) * 10;
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b) => a.score - b.score);
|
||
void order;
|
||
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// The pot — pasta night. The Salt row is the only one in the game that judges
|
||
// a decision you could never take back, which is exactly why it is first.
|
||
|
||
export function judgePot(r: PotResult, order: Order, seconds: number): Verdict {
|
||
const salt = saltScore(r);
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'potsalt',
|
||
group: 'pot',
|
||
label: 'The Salt',
|
||
score: clamp01(salt),
|
||
weight: 1.3,
|
||
detail: r.salt === 0
|
||
? 'unsalted. you can add salt to the water or you can lie to me'
|
||
: r.salt - r.lateSalt * 0.75 > SALT_RUIN
|
||
? 'brine. there was a moment to stop and it went past'
|
||
: r.lateSalt > 0.08
|
||
? 'salted after the pasta went in — that seasons the sink'
|
||
: r.salt - r.lateSalt * 0.75 < SALT_LO
|
||
? 'under. the strand tastes of nothing'
|
||
: 'seasoned like the sea. exactly there',
|
||
},
|
||
{
|
||
key: 'potbite',
|
||
group: 'pot',
|
||
label: 'The Bite',
|
||
score: clamp01(r.bite),
|
||
weight: 1.3,
|
||
detail: r.cook < 30
|
||
? 'chalk in the middle'
|
||
: r.cook < 42
|
||
? 'a shade early — still stiff'
|
||
: r.cook <= 58
|
||
? 'al dente. bite left in it'
|
||
: r.cook < 85
|
||
? 'soft. the bite is gone'
|
||
: 'soup with ambitions',
|
||
},
|
||
{
|
||
key: 'potroll',
|
||
group: 'pot',
|
||
label: 'The Roll',
|
||
score: clamp01(1 - r.glue * 1.6),
|
||
weight: 1.0,
|
||
detail: r.glue > 0.45 ? 'one gluey brick — it sat in a simmer' : r.glue > 0.15 ? 'clinging a little' : 'loose strands, tumbled properly',
|
||
},
|
||
{
|
||
key: 'potwater',
|
||
group: 'bench',
|
||
label: 'The Water',
|
||
score: r.reserved ? clamp01(0.55 + r.starchy * 0.9) : 0.25,
|
||
weight: 0.7,
|
||
detail: r.reserved
|
||
? r.starchy > 0.35 ? 'a cup of it kept back, and properly starchy' : 'kept a cup, though it was thin water'
|
||
: 'poured every drop away. the sauce has nothing to hold',
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 110) / 140),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
// Brine is not a bad row on a good card — it is an INEDIBLE PLATE, and no
|
||
// amount of perfect timing elsewhere averages that away. The one verb you
|
||
// could not take back gets the one score you cannot climb out of.
|
||
const brined = r.salt - r.lateSalt * 0.75 > SALT_RUIN;
|
||
const total = Math.min((sum / wsum) * 10, brined ? 3 : 10);
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b) => a.score - b.score);
|
||
void order;
|
||
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// The fryer — the double-fry doctrine, judged. The gold is the postcard, the
|
||
// inside is the truth, the grease is the confession, the burns are on you.
|
||
|
||
export function judgeFryerPot(r: import('../sim/fryer').FryerPotResult, order: Order, seconds: number): Verdict {
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'potgold',
|
||
group: 'fryer',
|
||
label: 'The Gold',
|
||
score: r.gold < 0.6 ? clamp01(r.gold / 0.6) : r.gold <= 0.85 ? 1 : clamp01(1 - (r.gold - 0.85) / 0.15),
|
||
weight: 1.2,
|
||
detail: r.gold < 0.3 ? 'pale as an apology' : r.gold < 0.6 ? 'blond. chips should not be blond' : r.gold <= 0.85 ? 'proper gold' : 'past gold into mahogany',
|
||
},
|
||
{
|
||
key: 'potinside',
|
||
group: 'fryer',
|
||
label: 'The Inside',
|
||
score: clamp01(r.cooked / 0.85),
|
||
weight: 1.2,
|
||
detail: r.cooked >= 0.85 ? 'fluffy inside' : r.cooked >= 0.5 ? 'firm in the middle — the seal beat the cook' : 'raw in the middle. golden outside means nothing now',
|
||
},
|
||
{
|
||
key: 'potgrease',
|
||
group: 'fryer',
|
||
label: 'The Grease',
|
||
score: clamp01(1 - r.grease * 2.2),
|
||
weight: 0.9,
|
||
detail: r.grease > 0.35 ? 'a paper bag of regret — the oil was cold' : r.grease > 0.12 ? 'a little heavy' : 'dry. drained. correct',
|
||
},
|
||
{
|
||
key: 'pottwice',
|
||
group: 'fryer',
|
||
label: 'The Twice',
|
||
score: r.fries >= 2 ? 1 : 0.35,
|
||
weight: 0.7,
|
||
detail: r.fries >= 2 ? `${r.fries} fries — as the card says` : 'once. it shows',
|
||
},
|
||
{
|
||
key: 'potspit',
|
||
group: 'bench',
|
||
label: 'The Spit',
|
||
score: r.burns === 0 ? 1 : clamp01(1 - r.burns * 0.4),
|
||
weight: 0.6,
|
||
detail: r.burns > 0 ? `it caught you ${r.burns > 1 ? r.burns + ' times' : 'once'} — the hand was over the pot` : r.spits > 0 ? `it spat ${r.spits} times; you did not flinch` : 'the oil kept its opinions in the pot',
|
||
},
|
||
{
|
||
key: 'time',
|
||
label: 'Service',
|
||
score: clamp01(1 - (seconds - 100) / 140),
|
||
weight: 0.3,
|
||
detail: `${Math.round(seconds)}s`,
|
||
},
|
||
];
|
||
let sum = 0;
|
||
let wsum = 0;
|
||
for (const c of criteria) {
|
||
sum += c.score * c.weight;
|
||
wsum += c.weight;
|
||
}
|
||
const total = (sum / wsum) * 10;
|
||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||
const sorted = [...rankable].sort((a, b) => a.score - b.score);
|
||
void order;
|
||
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
|
||
}
|
||
|
||
// =====================================================================
|
||
// The air fryer — arrangement judged as air. The crisp is the browning band;
|
||
// the turn reads the pale shadowed faces a lazy shake never showed the wind.
|
||
|
||
export function judgeAirfryer(r: import('../sim/airfryer').FryerResult, order: Order, seconds: number): Verdict {
|
||
const asked = order.airfryer?.count ?? 6;
|
||
const criteria: Criterion[] = [
|
||
{
|
||
key: 'frycrisp',
|
||
group: 'fryer',
|
||
label: 'The Crisp',
|
||
score: r.crisp < 0.55 ? clamp01(r.crisp / 0.55) : r.crisp <= 0.8 ? 1 : clamp01(1 - (r.crisp - 0.8) / 0.2),
|
||
weight: 1.3,
|
||
detail: r.crisp < 0.3 ? 'raw in the middle of a MACHINE' : r.crisp < 0.55 ? 'pale — warm is not fried' : r.crisp <= 0.8 ? 'golden, audibly' : 'past golden into regret',
|
||
},
|
||
{
|
||
key: 'fryturn',
|
||
group: 'fryer',
|
||
label: 'The Turn',
|
||
score: clamp01(r.evenness - r.pale * 0.6),
|
||
weight: 1.0,
|
||
detail: r.pale > 0.4 ? 'pale faces — they were never shaken' : r.pale > 0.18 ? 'one side saw more wind than the other' : 'turned like he taught you',
|
||
},
|
||
{
|
||
key: 'fryair',
|
||
group: 'fryer',
|
||
label: 'The Air',
|
||
score: clamp01(1 - r.steamed / 5),
|
||
weight: 1.1,
|
||
detail: r.steamed > 2.5 ? 'that is a steamer wearing a fryer badge' : r.steamed > 0.6 ? 'crowded for a while — it shows' : 'every wing breathed',
|
||
},
|
||
{
|
||
key: 'frycard',
|
||
group: 'fryer',
|
||
label: 'The Card',
|
||
...(() => {
|
||
const deg1 = order.airfryer?.deg1 ?? 180;
|
||
const deg2 = order.airfryer?.deg2 ?? 190;
|
||
const k1 = Math.round((deg1 - 120) / 10);
|
||
const k2 = Math.round((deg2 - 120) / 10);
|
||
const near = (m: Record<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 + 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: [],
|
||
};
|
||
}
|