The loop closes: ticket -> toast -> spread -> ENTER -> verdict -> next day.
- judging.ts: 9 weighted criteria, each reading the same fields the knife was
pushing around, so every line of the scorecard points at something real
("0.78 against 0.68 asked", "thick — thin asked", "28% burnt").
- lines.ts: ~45 lines keyed to whichever criterion actually decided the score,
so the verdict and the scorecard always agree — the verdict just has feelings
about it. MITEY has its own vocabulary.
- judge.ts: the toast turns on a pedestal under a spotlight, grade stamps in,
heatmap toggle for browning/spread. The generated inspector reacts by grade.
- orders.ts: seven handwritten days, then procedural. Day 1 is soft butter on
white; day 7 is a translucent film of MITEY on wet sourdough with fridge-hard
butter and a steak knife.
The find: every art colour was authored as sRGB and fed straight into a linear
lighting pipeline. Linear 0.14 encodes back out to sRGB ~0.4, so "near-black"
MITEY rendered as TAN and saturated butter washed to pale cream. This was the
root cause of the legibility fights in M0 and M2 — I'd been treating the symptom
by pushing the specular around. One line (albedo = pow(albedo, 2.2), mixing
stays in sRGB because that's the space the palette was picked in) and the whole
art direction landed: MITEY is genuinely black, butter is butter, the crust went
from cream to a rich golden brown.
Also: the specular lobe was far too broad. The slice is flat with the light and
camera both above it, so dot(N,H) ~0.98 everywhere and a wide lobe blankets the
whole slice in white. Tightened, so only ridges tilted into the light catch —
which is what a spread actually looks like.
The slice rolls its own lighting and ignores scene lights, so the judge's
spotlight did nothing to it; it now swaps its own rig for presentation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
183 lines
5.2 KiB
TypeScript
183 lines
5.2 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';
|
|
|
|
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';
|
|
}
|