M11 finish: days route through the prep bench; dice & wedges driven for real

Order.prep is no longer decorative. beginDay() now drains an order's prep
queue through the bench BEFORE the toaster, folding each PrepResult into the
judged day, then falls through to the existing toast flow (loaf knife-trip or
straight to the kitchen). The bench→kitchen ENTER handoff is the same verified
door; both the standalone harness path (t.prep) and the day flow go through one
enterPrep().

What changed:
- orders.ts: PrepStep gains an optional `knife` (prep knives are supplied by
  the order, not fished from the drawer — that trip is for the spread tool).
  prepAsk() words a step for the ticket. Two new scripted days:
    day 10 — dice an onion (dinner knife), then golden white + butter.
    day 11 — six wedges of tomato (dinner knife), then a scrape on golden white.
  The onion showcases dice (layered flesh punishes a bad cut); the wet tomato
  showcases wedges (press-without-saw skates and hoses the board).
- game.ts: prepQueue drained by runNextPrep(); enterToastFlow() extracted from
  beginDay(); startPrep() now delegates to enterPrep(); mergePrep() folds a
  second step honestly (worst CV, summed mess, all pieces) though every shipped
  order is single-step. Ticket renders a warn-chip per prep step.
- dev.ts: t.wedges({n,dy,wobble}) and t.dice({n,dy,wobble}) drive a whole
  pattern deterministically off the existing t.chop() — the dice's two rotated
  passes are just 2·(n-1) chops (the sim banks pass A and rotates the board
  itself). t.handoff() taps the ENTER the bench waits on. Both return prepStats.

Measured (pure-sim, formulas from cutting.ts, positions the harness uses):
- wedges n=6, cuts at [-0.5,-0.167,0.167] → 6 wedges, CV 0.0000 (even).
- dice   n=4, cuts [-0.25,0,0.25] both passes → 16 cells, CV 0.0000 (even).
- wedges with wobbled spacing [-0.5,-0.35,0.28] → CV 0.6351 ("a lottery").
CV 0 for even placement confirms the pieces criterion is a real skill test on
both new patterns; wobble/dy in the helpers exercises the failure end.

Verify (dev build, window.__t):
  t.start(); t.day(10); t.dice({n:4}); t.prepStats()  // 16 pieces, low CV, bench
  t.handoff();                                          // lands in the kitchen
  t.toast(9); t.pick('butter_knife'); t.spread(); t.kitchen.serve()
    // scorecard groups THE PREP (The Pieces / The Bench) + THE TOAST / SPREAD
  t.start(); t.day(11); t.wedges({n:6}); t.prepStats()  // 6 pieces, low CV
  Lottery/mess check: t.day(10); t.dice({n:4, dy:0.35, wobble:0.06}) → high CV,
    messy bench, "a crime scene".

typecheck clean, vite build clean. Untouched: sim/juicing.ts, scenes/prep.ts,
gen-assets.sh, MASTERPLAN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 18:41:13 +10:00
parent 161d488190
commit d7ce7b7961
3 changed files with 173 additions and 9 deletions

View File

@ -251,6 +251,39 @@ export function installDevHarness(app: App, game: Game): void {
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f };
},
/**
* Drive a whole `wedges(n)` pattern: ceil(n/2) diametral cuts spaced evenly
* around the half-turn, which fall out as n even radial wedges. `wobble`
* throws the spacing off to make a lottery on purpose. Returns prepStats.
*/
wedges(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 6, dy = 0.05, wobble = 0 } = opts;
const k = Math.ceil(n / 2);
// aimPos reads as a fraction of the half-turn, so evenly spaced positions
// in [-0.5, 0.5) give angles 0, π/k, 2π/k … — even wedges, CV → 0.
for (let i = 0; i < k; i++) harness.chop({ pos: i / k - 0.5, dy, wobble });
return harness.prepStats();
},
/**
* Drive a whole `dice(n)` pattern: two rotated passes of n-1 evenly spaced
* parallel cuts. The sim banks the first pass and rotates the board itself,
* so this is just 2·(n-1) chops. Evenly spaced n×n even cells, CV 0.
*/
dice(opts: { n?: number; dy?: number; wobble?: number } = {}) {
const { n = 4, dy = 0.05, wobble = 0 } = opts;
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < n - 1; i++) harness.chop({ pos: (i + 1) / n - 0.5, dy, wobble });
}
return harness.prepStats();
},
/** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */
handoff() {
tap('Enter');
run(3);
},
/** Drag the cloth across the board `passes` times. */
wipe(passes = 1, vAt = 0.5) {
const bw = 4.6;

View File

@ -13,7 +13,7 @@ import { audio } from '../core/audio';
import { coolStep } from '../sim/toasting';
import { SPREADS } from '../sim/spreads';
import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { DAYS, proceduralOrder, nameBrowning, prepAsk, type Order, type PrepStep } from './orders';
import { el, Panel } from '../ui/hud';
import { Title } from '../ui/title';
@ -37,6 +37,8 @@ export class Game {
private prep: PrepView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
/** Remaining prep steps for the current order, drained as each hands off. */
private prepQueue: PrepStep[] = [];
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
private knifeTrip = false;
private rng = new Rng(20260716);
@ -135,25 +137,61 @@ export class Game {
}
/**
* Send the player to the prep bench. Orders with prep steps route through
* here (M12+); the harness drives it directly for verification.
* Send the player to the prep bench in isolation. The harness drives this
* for verification (`t.prep`); when it hands off (ENTER) it drops straight
* into the kitchen, no toast flow around it.
*/
startPrep(ingredient: IngredientId, pattern: CutPattern, knife: ToolId = 'dinner_knife', ask = ''): void {
this.prep.reset(ingredient, knife, pattern, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`);
this.enterPrep(ingredient, pattern, knife, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`, (result) => {
this.prepResult = result;
this.kitchen.root.visible = true;
this.app.setView(this.kitchen);
this.kitchen.flash('prep done — the bench remembers');
});
}
/**
* Put the prep bench on screen for one cut, and route wherever `onComplete`
* says once ENTER hands the board off. The single door into the prep scene
* both the standalone harness path and the real day flow go through here.
*/
private enterPrep(
ingredient: IngredientId,
pattern: CutPattern,
knife: ToolId,
ask: string,
onComplete: (result: PrepResult) => void,
): void {
this.prep.reset(ingredient, knife, pattern, ask);
this.kitchen.root.visible = false;
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.prep.root.visible = true;
this.app.setView(this.prep);
this.ticket.show();
this.prep.onDone = (result) => {
this.prepResult = result;
this.prep.root.visible = false;
this.kitchen.root.visible = true;
this.app.setView(this.kitchen);
this.kitchen.flash('prep done — the bench remembers');
onComplete(result);
};
}
/**
* Drain the current order's prep queue one bench trip at a time, folding each
* result into `prepResult`, then fall through to the toaster. Orders ship with
* a single step today; the merge keeps a two-step order honest anyway.
*/
private runNextPrep(): void {
const step = this.prepQueue.shift();
if (!step) {
this.enterToastFlow();
return;
}
this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => {
this.prepResult = mergePrep(this.prepResult, result);
this.runNextPrep();
});
}
/** Test hook: jump straight to a day, through the real order table. */
setDay(day: number): void {
this.save.day = day;
@ -214,6 +252,7 @@ export class Game {
this.order = o;
this.orderSeconds = 0;
this.prepResult = null;
this.prepQueue = o.prep ? [...o.prep] : [];
this.timing = true;
const s = this.judgeView.release();
@ -223,7 +262,20 @@ export class Game {
this.judgeView.root.visible = false;
this.renderTicket();
if (o.fromLoaf) {
// Prep comes before the toaster — dice the onion, wedge the tomato — then
// the toast flow picks up where it always did. Orders without prep skip
// straight to it. (The kitchen already reads 'ready' after applyOrder, so
// no toast cools while you're at the bench.)
if (this.prepQueue.length) {
this.runNextPrep();
return;
}
this.enterToastFlow();
}
/** The toaster half of the day: the loaf knife-trip, or straight to the bench. */
private enterToastFlow(): void {
if (this.order.fromLoaf) {
// Bakery bread: first find something to slice it with.
this.knifeTrip = true;
this.drawer.reset('bread_knife');
@ -238,6 +290,7 @@ export class Game {
}
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.prep.root.visible = false;
this.app.setView(this.kitchen);
this.kitchen.root.visible = true;
this.ticket.show();
@ -251,6 +304,7 @@ export class Game {
el('div', 'ticket-text', this.ticketEl, `${o.text}`);
const spec = el('div', 'ticket-spec', this.ticketEl);
el('span', 'chip brand', spec, o.brand);
if (o.prep) for (const step of o.prep) el('span', 'chip warn', spec, prepAsk(step));
if (o.fromLoaf) el('span', 'chip warn', spec, `${o.cutClass} — slice it yourself`);
el('span', 'chip', spec, o.browningName);
el('span', 'chip', spec, `${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}`);
@ -293,6 +347,30 @@ export class Game {
}
}
/**
* Fold a second prep step's result into the running one. A one-step order (all
* of them, today) just returns `b`. For two steps the judge sees the worst cut
* (highest CV), the summed mess, and every piece nothing gets silently
* dropped when the day grows a second bench trip.
*/
function mergePrep(a: PrepResult | null, b: PrepResult): PrepResult {
if (!a) return b;
return {
cv: Math.max(a.cv ?? 0, b.cv ?? 0),
pieces: (a.pieces ?? 0) + (b.pieces ?? 0),
patternName: b.patternName ?? a.patternName,
slips: (a.slips ?? 0) + (b.slips ?? 0),
bench:
a.bench && b.bench
? {
mass: a.bench.mass + b.bench.mass,
spreadFrac: Math.max(a.bench.spreadFrac, b.bench.spreadFrac),
solids: a.bench.solids + b.bench.solids,
}
: (b.bench ?? a.bench),
};
}
function loadSave(): Save {
try {
const raw = localStorage.getItem(SAVE_KEY);

View File

@ -14,6 +14,25 @@ export interface PrepStep {
kind: 'cut';
ingredient: IngredientId;
pattern: CutPattern;
/** The knife the bench hands you for this step. Prep knives don't come from
* the drawer (that trip is for the spread tool); the order supplies them. */
knife?: ToolId;
}
/** The ticket's short line for a prep step: "dice the onion", "6 wedges of tomato". */
export function prepAsk(step: PrepStep): string {
const name = step.ingredient.replace(/_/g, ' ');
const p = step.pattern;
switch (p.kind) {
case 'halve':
return `halve the ${name}`;
case 'slices':
return `${p.n} slices of ${name}`;
case 'wedges':
return `${p.n} wedges of ${name}`;
case 'dice':
return `dice the ${name}`;
}
}
export interface Order {
@ -196,6 +215,40 @@ export const DAYS: Order[] = [
who: 'the inspector himself',
text: 'The bakery loaf. Slice it yourself — a DOORSTOP, and I want the faces parallel. Then golden, butter, normal.',
},
{
day: 10,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.5,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.6,
who: 'Deidre',
text: 'Dice me an onion first — small and square, mind the stem — then golden white, butter, normal.',
// The onion is the dice's showcase: layered flesh means a bad cut explodes
// the piece CV. The dinner knife bites clean if you saw instead of crush.
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4 }, knife: 'dinner_knife' }],
},
{
day: 11,
brand: 'Crustworthy',
bread: 'white',
spread: 'butter',
amount: 'thin',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.55,
who: 'a regular',
text: 'Six wedges of tomato — even ones — and a scrape of butter on golden white.',
// Tomato = the wet, slippery showcase for wedges: press without sawing and
// it skates and hoses the board; saw and it gives clean radial cuts.
prep: [{ kind: 'cut', ingredient: 'tomato', pattern: { kind: 'wedges', n: 6 }, knife: 'dinner_knife' }],
},
];
/** Days beyond the script — keep going, with everything cranked. */