toastsim/src/sim/spreads.ts
monster ff006e65db M8: crunchy vs smooth, and the jar that separates
Natural peanut butter stratifies while it sits. The jar carries the state:
strat 0..1, set per order (smooth 0.75, crunchy 0.9 — chunks sink). The first
unstirred dip takes the oil slick off the top; the ones after it dig into what
the oil left behind. Verified dip trace, no stirring: 0.875, 0.669, 0.462,
0.256. Swirl the knife in the jar (~2.5 circles of angular sweep; holding still
stirs nothing) and every dip is 0.5. Stirring costs time, and time costs toast
warmth — the mechanic pays the same tax as everything else.

Consistency pushes back through the physics that already exist: an oily load
multiplies the yield down (glides at any angle, goes on thin), a dry one
multiplies it up (fights like cold butter, tears). The shader shows it — slick
is dark and shiny, grout is pale and matte.

The judge reads what LANDED, not what the jar was: every deposit remembers the
oil it landed at, and the criterion scores the mass-weighted mean AND stdev.
The stdev is the design catch, found by verification: a slick strip and a grout
strip average to 0.54 — "just right" — and the first cut scored the lazy play
as if the jar had been stirred. Nobody eats the average:

  never stirred, strips   oil 0.54 ±0.23  "slick here, grout there"  C
  one slick dip, whole    oil 0.87 ±0.00  "a slick"
  stirred first           oil 0.50 ±0.00  "just right"

Crunchy reuses the particle system wholesale — chunks ride the knife, shed per
stroke-distance, get judged with the same Clark–Evans index ("Peanut chunks —
21 bits, evenly strewn (R 1.20)"), and the rind line bank is genericised via a
{bits} placeholder so he complains correctly about either.

Day 6 is crunchy now ("the PROPER kind"), the ticket warns when a jar has been
sitting, and the JAR bar shows how mixed it is. Five new judge lines, including
"You did not stir the jar. The jar knows. I know."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:03:57 +10:00

212 lines
6.7 KiB
TypeScript

/**
* Spread rheology. The whole difficulty curve of this game lives in these numbers.
*
* The central tension: you need PRESSURE to make a stiff spread flow, but pressure
* comes from tilting the knife steeper — and past `SCRAPE_ANGLE` a steep knife stops
* spreading and starts scraping. So cold butter can want more pressure than you are
* physically allowed to apply without flipping into scrape mode. The way out isn't a
* better technique, it's warmer toast: don't dawdle in the drawer.
*/
export type SpreadId = 'butter' | 'peanut' | 'crunchy' | 'mitey' | 'marmalade';
export type AmountClass = 'thin' | 'normal' | 'thick';
/**
* Solid bits suspended in a spread — marmalade rind. They ride the knife with
* the jelly, drop off as the blade travels, and the judge scores how evenly
* they ended up scattered. Dabbing makes a pile; a moving knife lays a trail;
* covering the whole slice takes several dips placed with intent.
*/
export interface ParticleDef {
/** What the judge calls them. */
name: string;
color: [number, number, number];
/** World-space size of a bit. */
size: number;
/** How many the knife picks up per dip. */
perDip: number;
/**
* Fraction of the carried bits shed per WORLD-UNIT of stroke, at a full load.
* Distance-based on purpose: a moving knife lays an even trail, a dabbing one
* makes a pile.
*/
shedRate: number;
}
export interface SpreadDef {
id: SpreadId;
name: string;
blurb: string;
color: [number, number, number];
gloss: number;
/** Thickness at which the film fully hides the toast underneath. */
opaqueAt: number;
/** Surface relief strength. */
bump: number;
/** Suspended solids, if any. */
particles?: ParticleDef;
/**
* Natural nut butters stratify: how badly the jar has separated when the
* order starts (0..1). The oil rises, the paste below dries out — the first
* unstirred dip is a slick and the ones after it are grout. Swirl the knife
* in the jar to mix it back; the fix costs time, and time costs toast warmth.
*/
separates?: number;
/** Pressure needed to make it flow, before any warming. */
baseYield: number;
/** How much heat (soft butter + hot toast) reduces that. */
tempSoftening: number;
/** 0 self-levels instantly, 1 stays exactly where you put it. */
viscosity: number;
/** How much the knife picks up per dip. */
pickup: number;
/** How fast it leaves the knife once it IS flowing. */
transferRate: number;
/** How much the spread resists the knife (drag on the cursor). */
drag: number;
/** How readily it comes back off when you scrape. */
scrapeEase: number;
amounts: Record<AmountClass, [number, number]>;
}
export const SPREADS: Record<SpreadId, SpreadDef> = {
butter: {
id: 'butter',
name: 'Butter',
blurb: 'Straightforward — as long as something in this scene is warm.',
color: [0.97, 0.72, 0.19],
gloss: 0.85,
opaqueAt: 0.5,
bump: 0.10,
baseYield: 0.72,
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 3.0,
drag: 0.5,
scrapeEase: 1.0,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
},
peanut: {
id: 'peanut',
name: 'Smooth Peanut Butter',
blurb: 'Goes on thick, drags like wet sand — and the jar has been sitting.',
color: [0.64, 0.41, 0.18],
gloss: 0.3,
opaqueAt: 0.16,
bump: 0.28,
separates: 0.75,
baseYield: 0.38,
tempSoftening: 0.3,
viscosity: 0.96,
pickup: 0.95,
transferRate: 4.5,
drag: 1.0,
scrapeEase: 0.75,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
crunchy: {
id: 'crunchy',
name: 'Crunchy Peanut Butter',
blurb: 'Everything smooth is, plus chunks with opinions about where they sit.',
color: [0.6, 0.38, 0.16],
gloss: 0.28,
opaqueAt: 0.17,
bump: 0.32,
// Chunks sink; the jar splits worse than the smooth stuff.
separates: 0.9,
particles: {
name: 'peanut chunks',
color: [0.78, 0.6, 0.34],
size: 0.042,
perDip: 8,
shedRate: 0.85,
},
baseYield: 0.42,
tempSoftening: 0.3,
viscosity: 0.97,
pickup: 0.95,
transferRate: 4.3,
drag: 1.1,
scrapeEase: 0.7,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
mitey: {
id: 'mitey',
name: 'MITEY',
blurb: 'Spreads like nothing. The skill is taking almost all of it back off.',
color: [0.14, 0.08, 0.05],
gloss: 0.62,
opaqueAt: 0.26,
bump: 0.03,
baseYield: 0.14,
tempSoftening: 0.5,
viscosity: 0.34,
pickup: 0.5,
transferRate: 3.5,
drag: 0.25,
scrapeEase: 1.25,
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
},
marmalade: {
id: 'marmalade',
name: 'Marmalade',
blurb: 'The jelly is easy. The rind goes where it wants — your job is to argue.',
color: [0.91, 0.45, 0.08],
gloss: 0.95,
// Jelly stays translucent even laid on thick — you always see toast through it.
opaqueAt: 0.85,
bump: 0.05,
particles: {
name: 'rind',
color: [0.62, 0.24, 0.04],
size: 0.055,
perDip: 9,
// ~0.12 world-units between bits at a full load — and wider as the blade
// empties, so one dip can't do the whole slice. Several dips, placed with
// intent, is the skill.
shedRate: 0.95,
},
baseYield: 0.2,
tempSoftening: 0.45,
viscosity: 0.55,
pickup: 0.62,
transferRate: 3.2,
drag: 0.35,
scrapeEase: 1.1,
amounts: { thin: [0.08, 0.18], normal: [0.22, 0.42], thick: [0.48, 0.8] },
},
};
/** Knife angle past which contact narrows enough to scrape instead of spread. */
export const SCRAPE_ANGLE = 0.62;
/** Angle (0 flat .. 1 on-edge) -> contact pressure. */
export function pressureFromAngle(angle: number): number {
return 0.1 + 0.9 * Math.pow(Math.max(0, Math.min(1, angle)), 1.3);
}
/** Angle -> contact patch radius in UV units. Flat knife = wide, steep = a line. */
export function contactRadius(angle: number): number {
return 0.115 - 0.078 * Math.max(0, Math.min(1, angle));
}
/**
* What it actually takes to make this spread flow right now. Butter on a cold
* slice with fridge-hard butter is the worst case in the game, by design.
*/
export function effectiveYield(def: SpreadDef, softness: number, toastWarmth: number): number {
const heat = Math.min(1, softness * 0.55 + toastWarmth * 0.65);
return def.baseYield * (1 - def.tempSoftening * heat);
}
export function amountClassOf(def: SpreadDef, mean: number): AmountClass | 'none' | 'obscene' {
if (mean < def.amounts.thin[0] * 0.5) return 'none';
if (mean <= def.amounts.thin[1]) return 'thin';
if (mean <= def.amounts.normal[1]) return 'normal';
if (mean <= def.amounts.thick[1]) return 'thick';
return 'obscene';
}