STAGE: culinary school — one board, one spec, one clock

The drills mode from P8, and the one John asked for by name. No
customer, no ticket, no day: a spec, a board and a stopwatch. Three to
start — brunoise the onion, bâtonnets off a bell pepper, twelve
cucumber rounds — each with four medal tiers that must be earned on
BOTH axes at once. You do not get gold for being fast and ragged, and
you do not get gold for being immaculate and late, and the card names
the single thing standing between this run and the next tier: "Even
enough for gold. 6s too slow for it."

A drill is a SPEC, not a separate game — it runs the real prep bench
through the real cutting sim, and pieceCV was already sitting there
doing exactly the measurement a spec needs.

Sealed off from the campaign on purpose: no order, no streak, no
starter, no regulars, no verdict. Checked rather than assumed — after a
full drill run, save.day, save.total and save.streak were byte-identical
and the ONLY thing written was the drill's own line in the medal book,
which keeps your best (a better medal, or the same medal faster).

Two things the build surfaced:

- The prep bench only accepts ENTER when the pattern is COMPLETE, which
  is right for service (the order wants the whole cut) but made "7 of 16.
  Finish the cut before you stop the clock" a verdict nothing could ever
  reach. Drills now allow the panic-stop via an explicit
  allowEarlyServe flag; service explicitly sets it false, so an order can
  never be half-served.
- The title was built in the constructor and nowhere else, so there was
  no way back from anything. Extracted showTitle().

Also: a recorded run with no medal was labelled "not attempted", which
is a small lie about the worst runs. It says "attempted — no medal" now.

Verified: S on the cucumber rounds books S·4s on the title; a short
brunoise books no medal and says why; service prep still refuses an
early ENTER; bruschetta 9.6, grill 9.4, eggs 10.0, curry 10.0, greens
10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 20:45:19 +10:00
parent 00e3f1694e
commit cebff4bd69
5 changed files with 290 additions and 5 deletions

125
src/game/drills.ts Normal file
View File

@ -0,0 +1,125 @@
import type { CutPattern } from '../sim/cutting';
import type { IngredientId } from '../sim/ingredients';
import type { ToolId } from '../sim/cutlery';
import type { PrepResult } from './judging';
/**
* STAGE culinary school. The drills John asked for.
*
* No customer, no judge, no day. One board, one spec, one clock: "brunoise
* this onion, under sixty seconds, CV below 0.15." The service game is about
* juggling; this is about the hand. Everything here rides the real prep bench
* and the real cutting sim a drill is a SPEC, not a separate game.
*
* Medals are earned on all three at once. You do not get gold for being fast
* and ragged, and you do not get gold for being immaculate and late.
*/
export type Medal = 'S' | 'gold' | 'silver' | 'bronze' | null;
export interface Drill {
id: string;
label: string;
/** What the ticket-less bench says you are practising. */
brief: string;
ingredient: IngredientId;
knife: ToolId;
pattern: CutPattern;
/** Evenness to beat, tightest first. [S, gold, silver, bronze] */
cv: [number, number, number, number];
/** Seconds to beat. [S, gold, silver, bronze] */
time: [number, number, number, number];
/** Pieces the spec asks for; being short is its own failure. */
pieces: number;
}
export const DRILLS: Drill[] = [
{
id: 'brunoise_onion',
label: 'BRUNOISE — onion',
brief: 'Two-pass dice, root intact, 2mm. The classical one. Evenness is everything.',
ingredient: 'onion',
knife: 'the_best_thing',
pattern: { kind: 'dice', n: 8, root: true },
cv: [0.11, 0.16, 0.23, 0.32],
time: [42, 58, 78, 105],
pieces: 16,
},
{
id: 'batonnet_pepper',
label: 'BÂTONNET — bell pepper',
brief: 'Even strips off a wall that wants to slide out from under the blade.',
ingredient: 'bell_pepper',
knife: 'the_best_thing',
pattern: { kind: 'slices', n: 10 },
cv: [0.09, 0.14, 0.20, 0.28],
time: [30, 42, 58, 80],
pieces: 10,
},
{
id: 'rounds_cucumber',
label: 'ROUNDS — cucumber',
brief: 'Twelve rounds, all the same coin. Simple, and simple is where the wobble shows.',
ingredient: 'cucumber',
knife: 'the_best_thing',
pattern: { kind: 'slices', n: 12 },
cv: [0.08, 0.13, 0.19, 0.27],
time: [28, 38, 52, 72],
pieces: 12,
},
];
export interface DrillScore {
medal: Medal;
cv: number;
seconds: number;
pieces: number;
/** Which requirement held it back, in his words. */
note: string;
}
const TIERS: Exclude<Medal, null>[] = ['S', 'gold', 'silver', 'bronze'];
/** The best tier this run satisfies on BOTH evenness and clock, or null. */
export function scoreDrill(d: Drill, r: PrepResult, seconds: number): DrillScore {
const cv = r.cv ?? 1;
const pieces = r.pieces ?? 0;
// Short of the spec is not a slow gold, it is a different dish.
const short = pieces < d.pieces;
let medal: Medal = null;
if (!short) {
for (let i = 0; i < TIERS.length; i++) {
if (cv <= d.cv[i] && seconds <= d.time[i]) {
medal = TIERS[i];
break;
}
}
}
let note: string;
if (short) note = `${pieces} of ${d.pieces}. Finish the cut before you stop the clock.`;
else if (medal === 'S') note = 'Nothing to say. Do it again tomorrow and I will believe it.';
else {
// Name the ONE thing between this run and the next tier up.
const next = medal ? TIERS.indexOf(medal) - 1 : TIERS.length - 1;
if (next < 0) note = 'Nothing to say. Do it again tomorrow and I will believe it.';
else {
const cvOk = cv <= d.cv[next];
const timeOk = seconds <= d.time[next];
note = cvOk && !timeOk
? `Even enough for ${TIERS[next]}. ${Math.ceil(seconds - d.time[next])}s too slow for it.`
: !cvOk && timeOk
? `Fast enough for ${TIERS[next]}. The pieces are not even enough for it.`
: `${TIERS[next]} wants ${d.cv[next].toFixed(2)} evenness in ${d.time[next]}s. You were ${cv.toFixed(2)} in ${Math.round(seconds)}s.`;
}
}
return { medal, cv, seconds, pieces, note };
}
/** Ordering for "is this run better than the one in the book". */
export function medalRank(m: Medal): number {
return m === 'S' ? 4 : m === 'gold' ? 3 : m === 'silver' ? 2 : m === 'bronze' ? 1 : 0;
}
export function medalWord(m: Medal): string {
return m === 'S' ? 'S' : m === 'gold' ? 'GOLD' : m === 'silver' ? 'SILVER' : m === 'bronze' ? 'BRONZE' : '—';
}

View File

@ -35,6 +35,7 @@ import { judge, gradeOf, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, type PrepStep } from './orders'; import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, type PrepStep } from './orders';
import { el, Panel } from '../ui/hud'; import { el, Panel } from '../ui/hud';
import { Title } from '../ui/title'; import { Title } from '../ui/title';
import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills';
import { eye } from '../ui/eye'; import { eye } from '../ui/eye';
import { assetUrl } from '../scenes/assets'; import { assetUrl } from '../scenes/assets';
@ -65,6 +66,8 @@ interface Save {
* every new day you reach, and remembers exactly how long you forgot her. * every new day you reach, and remembers exactly how long you forgot her.
* `fed`/`born` are maxDay stamps so replaying old days never starves her. */ * `fed`/`born` are maxDay stamps so replaying old days never starves her. */
starter?: { born: number; fed: number; deaths: number }; starter?: { born: number; fed: number; deaths: number };
/** STAGE: the best medal and time booked for each drill. */
drills?: Record<string, { medal: Medal; cv: number; seconds: number }>;
} }
/** /**
@ -111,6 +114,9 @@ export class Game {
private shadowMode = false; private shadowMode = false;
/** Rush hour: ticket one's total, parked on the pass while you cook two. */ /** Rush hour: ticket one's total, parked on the pass while you cook two. */
private rushFirst: number | null = null; private rushFirst: number | null = null;
/** STAGE: the drill being practised. No customer, no day, no verdict. */
private drill: Drill | null = null;
private drillPanel: Panel | null = null;
private demoSnapshot: string | null = null; private demoSnapshot: string | null = null;
private rng = new Rng(20260716); private rng = new Rng(20260716);
private order!: Order; private order!: Order;
@ -276,6 +282,13 @@ export class Game {
// Sit on the title until the player clicks — which also gives us the user // Sit on the title until the player clicks — which also gives us the user
// gesture the browser wants before any audio is allowed to make a sound. // gesture the browser wants before any audio is allowed to make a sound.
this.showTitle();
}
/** The front door. Built once at boot, and again whenever something sends
* you back to it STAGE has a way out, so this had to stop being a
* constructor-only affair. */
private showTitle(): void {
this.kitchen.root.visible = false; this.kitchen.root.visible = false;
this.ticket.hide(); this.ticket.hide();
eye.hide(); // he doesn't watch you read the menu eye.hide(); // he doesn't watch you read the menu
@ -287,11 +300,15 @@ export class Game {
new Title(this.save, (day, shadow) => { new Title(this.save, (day, shadow) => {
audio.resume(); audio.resume();
this.shadowMode = !!shadow; this.shadowMode = !!shadow;
if (typeof day === 'string' && day.startsWith('drill:')) {
this.startDrill(day.slice(6));
return;
}
if (day === 'daily') { if (day === 'daily') {
this.startDaily(); this.startDaily();
return; return;
} }
if (day !== undefined && day !== this.save.day) { if (typeof day === 'number' && day !== this.save.day) {
// Replaying an old day: move the cursor there, but maxDay holds the // Replaying an old day: move the cursor there, but maxDay holds the
// real progress so the ledger never shrinks. // real progress so the ledger never shrinks.
this.save.day = day; this.save.day = day;
@ -563,6 +580,7 @@ export class Game {
}); });
return; return;
} }
this.prep.allowEarlyServe = false; // service wants the whole cut
this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => {
this.prepResult = mergePrep(this.prepResult, result); this.prepResult = mergePrep(this.prepResult, result);
this.runNextPrep(); this.runNextPrep();
@ -643,6 +661,80 @@ export class Game {
void demo().finally(() => t.setDemoPlayback!(false)); void demo().finally(() => t.setDemoPlayback!(false));
} }
/**
* STAGE. The prep bench, a spec and a clock and none of the service
* machinery: no order, no customer, no streak, no starter, no verdict. The
* only thing a drill can write is its own line in the medal book.
*/
startDrill(id: string): void {
const d = DRILLS.find((x) => x.id === id);
if (!d) return;
this.drill = d;
this.rushFirst = null;
this.dailyDate = null;
this.shadowMode = false;
eye.show();
this.orderSeconds = 0;
this.timing = true;
this.ticket.hide();
this.hideTempToggle();
const sl = this.judgeView.release();
if (sl) this.kitchen.root.add(sl.mesh);
this.judgeView.root.visible = false;
this.hideAllScenes();
this.prep.reset(d.ingredient, d.knife, d.pattern, d.brief);
this.prep.allowEarlyServe = true;
this.prep.root.visible = true;
this.app.setView(this.prep);
this.prep.onDone = (result) => {
// The board STAYS on screen behind the medal card — you should be able
// to look at the pieces while he tells you what they were.
this.finishDrill(result);
};
}
private finishDrill(result: PrepResult): void {
const d = this.drill!;
this.timing = false;
const sc = scoreDrill(d, result, this.orderSeconds);
const book = (this.save.drills ??= {});
const prev = book[d.id];
// The book keeps your BEST: a better medal, or the same medal faster.
if (!prev || medalRank(sc.medal) > medalRank(prev.medal) || (medalRank(sc.medal) === medalRank(prev.medal) && sc.seconds < prev.seconds)) {
book[d.id] = { medal: sc.medal, cv: sc.cv, seconds: sc.seconds };
writeSave(this.save);
}
eye.mood(sc.medal === 'S' || sc.medal === 'gold' ? 'impressed' : sc.medal ? 'intrigued' : 'disappointed', 4);
this.showDrillCard(d, sc, !!prev && book[d.id] !== prev);
// Freeze the bench rather than hiding it: setView(null) stops it updating,
// but the scene still renders, so the cut board is right there under the card.
this.app.setView(null);
}
private showDrillCard(d: Drill, sc: ReturnType<typeof scoreDrill>, beat: boolean): void {
this.drillPanel?.dispose();
const p = new Panel('drill-card');
this.drillPanel = p;
el('div', 'drawer-lbl', p.root, 'STAGE');
el('div', 'drill-title', p.root, d.label);
el('div', `drill-medal medal-${sc.medal ?? 'none'}`, p.root, medalWord(sc.medal));
el('div', 'drill-stat', p.root, `${sc.pieces} pieces \u00b7 evenness ${sc.cv.toFixed(3)} \u00b7 ${Math.round(sc.seconds)}s`);
el('div', 'drill-note', p.root, sc.note);
if (beat) el('div', 'drill-beat', p.root, 'a better run than the one in the book');
const btns = el('div', 'judge-btns', p.root);
const again = el('button', 'btn', btns, 'AGAIN');
again.addEventListener('click', () => { p.dispose(); this.drillPanel = null; this.startDrill(d.id); });
const back = el('button', 'btn ghost', btns, 'BACK TO THE PASS');
back.addEventListener('click', () => {
p.dispose();
this.drillPanel = null;
this.drill = null;
this.prep.root.visible = false;
eye.hide();
this.showTitle();
});
}
startDaily(): void { startDaily(): void {
this.rushFirst = null; this.rushFirst = null;
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
@ -1452,7 +1544,7 @@ function loadSave(): Save {
const maxDay = Math.max(day, num(s.maxDay, day)); const maxDay = Math.max(day, num(s.maxDay, day));
const st = s.starter; const st = s.starter;
const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined; const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined;
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter }; return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {} };
} }
} }
} catch { } catch {

View File

@ -76,6 +76,10 @@ export class PrepView implements View {
private hintEl!: HTMLElement; private hintEl!: HTMLElement;
onDone: ((result: PrepResult) => void) | null = null; onDone: ((result: PrepResult) => void) | null = null;
/** STAGE only: let ENTER stop the clock mid-pattern. In service the order
* demands the whole cut, but a timed drill has to allow the panic-stop
* otherwise "6 of 16, finish the cut" is a verdict nothing can reach. */
allowEarlyServe = false;
constructor(private app: App) { constructor(private app: App) {
this.buildScenery(); this.buildScenery();
@ -315,6 +319,14 @@ export class PrepView implements View {
} }
if (!inp.down) this.wipePrev = null; if (!inp.down) this.wipePrev = null;
if (this.allowEarlyServe && s.phase !== 'done' && inp.justPressed('Enter') && this.onDone) {
const result = this.result();
const cb = this.onDone;
this.onDone = null;
cb(result);
return;
}
if (s.phase === 'done') { if (s.phase === 'done') {
this.doneTimer += dt; this.doneTimer += dt;
// No auto-exit: the bench is judged at serve, so the moment after the // No auto-exit: the bench is judged at serve, so the moment after the

View File

@ -943,3 +943,45 @@ body:has(#ui > .title) #touchpad { display: none; }
opacity: 0.5; opacity: 0.5;
} }
} }
/* STAGE — the drill list on the title, and the medal card after a run. */
.title-drills {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.drill-btn {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
text-align: left;
}
.drill-name { font-size: 11px; letter-spacing: 0.04em; }
.drill-best { font: 600 10px ui-monospace, monospace; opacity: 0.85; }
.medal-S { color: #d8c8f0; }
.medal-gold { color: #e8c46a; }
.medal-silver { color: #cfd4d8; }
.medal-bronze { color: #c08a5a; }
.medal-none { color: #7a7268; }
.drill-card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 320px;
padding: 20px 22px;
background: rgba(12, 9, 8, 0.9);
border: 1px solid rgba(243, 233, 220, 0.16);
border-radius: 12px;
backdrop-filter: blur(10px);
text-align: center;
}
.drill-title { font-size: 13px; letter-spacing: 0.05em; margin: 4px 0 10px; }
.drill-medal { font: 700 34px Georgia, serif; letter-spacing: 0.06em; margin: 4px 0 8px; }
.drill-stat { font: 11px ui-monospace, monospace; opacity: 0.8; }
.drill-note { font-size: 12px; font-style: italic; color: var(--gold); margin: 10px 0 4px; }
.drill-beat { font: 600 10px ui-monospace, monospace; color: #8fce8f; margin-top: 4px; }
.drill-card .judge-btns { justify-content: center; margin-top: 14px; }

View File

@ -2,6 +2,7 @@ import { el, Panel } from './hud';
import { assetUrl } from '../scenes/assets'; import { assetUrl } from '../scenes/assets';
import { gradeOf } from '../game/judging'; import { gradeOf } from '../game/judging';
import { DAYS } from '../game/orders'; import { DAYS } from '../game/orders';
import { DRILLS, medalWord, type Medal } from '../game/drills';
/** /**
* The front door. One button, one piece of art, one joke and now the ledger: * The front door. One button, one piece of art, one joke and now the ledger:
@ -12,8 +13,8 @@ export class Title {
private panel: Panel; private panel: Panel;
constructor( constructor(
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number } }, save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }> },
onStart: (day?: number | 'daily', shadow?: boolean) => void, onStart: (day?: number | 'daily' | string, shadow?: boolean) => void,
onFeed?: () => void, onFeed?: () => void,
) { ) {
this.panel = new Panel('title'); this.panel = new Panel('title');
@ -26,7 +27,7 @@ export class Title {
el('p', 'title-sub', txt, 'Bread goes in. You are judged.'); el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
const btn = el('button', 'btn', txt, save.day > 1 ? `RESUME — DAY ${save.day}` : 'START DAY 1'); const btn = el('button', 'btn', txt, save.day > 1 ? `RESUME — DAY ${save.day}` : 'START DAY 1');
let shadow = false; let shadow = false;
const go = (day?: number | 'daily') => { const go = (day?: number | 'daily' | string) => {
this.panel.dispose(); this.panel.dispose();
onStart(day, shadow); onStart(day, shadow);
}; };
@ -47,6 +48,19 @@ export class Title {
}); });
} }
// STAGE — culinary school. No customer, no clock but your own, and the
// only thing it can change is your own medal book.
const stageLbl = el('div', 'title-days-lbl', txt, 'STAGE \u2014 the drills. one board, one spec, one clock');
void stageLbl;
const stage = el('div', 'title-drills', txt);
for (const d of DRILLS) {
const best = save.drills?.[d.id];
const b = el('button', 'btn ghost drill-btn', stage);
el('span', 'drill-name', b, d.label);
el('span', `drill-best medal-${best?.medal ?? 'none'}`, b, best?.medal ? `${medalWord(best.medal)} \u00b7 ${Math.round(best.seconds)}s` : best ? 'attempted \u2014 no medal' : 'not attempted');
b.addEventListener('click', () => go(`drill:${d.id}`));
}
// THE MOTHER: the sourdough starter. A pet that lives in the save and // THE MOTHER: the sourdough starter. A pet that lives in the save and
// holds a grudge. Feeding is one click; forgetting is a funeral. // holds a grudge. Feeding is one click; forgetting is a funeral.
if (save.starter) { if (save.starter) {