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'; export interface Criterion { key: string; label: string; /** 0..1 */ score: number; weight: number; /** What the number actually was, in words. */ detail: string; } 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); /** * 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): 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); const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75); // 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, lo * 0.75); const criteria: Criterion[] = [ { key: 'browning', 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', label: 'Evenness', score: clamp01(1 - b.stdev / 0.2), weight: 1.1, detail: `variation ${b.stdev.toFixed(3)}`, }, { key: 'coverage', label: 'Coverage', score: clamp01((coverage - 0.15) / 0.75), weight: 1.3, detail: `${Math.round(coverage * 100)}% of the slice`, }, { key: 'uniformity', label: 'Uniformity', score: clamp01(1 - covered / 0.22), weight: 0.9, detail: `variation ${covered.toFixed(3)}`, }, { key: 'char', 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', 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)}%`, }, { key: 'amount', 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: [], }; } 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'; }