diff --git a/src/game/drills.ts b/src/game/drills.ts new file mode 100644 index 0000000..d1d3e4b --- /dev/null +++ b/src/game/drills.ts @@ -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[] = ['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' : '—'; +} diff --git a/src/game/game.ts b/src/game/game.ts index 2c6e4d2..7b3959e 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -35,6 +35,7 @@ import { judge, gradeOf, type Grade } from './judging'; import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, type PrepStep } from './orders'; import { el, Panel } from '../ui/hud'; import { Title } from '../ui/title'; +import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills'; import { eye } from '../ui/eye'; import { assetUrl } from '../scenes/assets'; @@ -65,6 +66,8 @@ interface Save { * 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. */ starter?: { born: number; fed: number; deaths: number }; + /** STAGE: the best medal and time booked for each drill. */ + drills?: Record; } /** @@ -111,6 +114,9 @@ export class Game { private shadowMode = false; /** Rush hour: ticket one's total, parked on the pass while you cook two. */ 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 rng = new Rng(20260716); private order!: Order; @@ -276,6 +282,13 @@ export class Game { // 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. + 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.ticket.hide(); eye.hide(); // he doesn't watch you read the menu @@ -287,11 +300,15 @@ export class Game { new Title(this.save, (day, shadow) => { audio.resume(); this.shadowMode = !!shadow; + if (typeof day === 'string' && day.startsWith('drill:')) { + this.startDrill(day.slice(6)); + return; + } if (day === 'daily') { this.startDaily(); 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 // real progress so the ledger never shrinks. this.save.day = day; @@ -563,6 +580,7 @@ export class Game { }); return; } + this.prep.allowEarlyServe = false; // service wants the whole cut this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.prepResult = mergePrep(this.prepResult, result); this.runNextPrep(); @@ -643,6 +661,80 @@ export class Game { 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, 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 { this.rushFirst = null; 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 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; - 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 { diff --git a/src/scenes/prep.ts b/src/scenes/prep.ts index e6e70e7..25046cd 100644 --- a/src/scenes/prep.ts +++ b/src/scenes/prep.ts @@ -76,6 +76,10 @@ export class PrepView implements View { private hintEl!: HTMLElement; 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) { this.buildScenery(); @@ -315,6 +319,14 @@ export class PrepView implements View { } 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') { this.doneTimer += dt; // No auto-exit: the bench is judged at serve, so the moment after the diff --git a/src/style.css b/src/style.css index 5d3f6b6..14a21d7 100644 --- a/src/style.css +++ b/src/style.css @@ -943,3 +943,45 @@ body:has(#ui > .title) #touchpad { display: none; } 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; } diff --git a/src/ui/title.ts b/src/ui/title.ts index e0712f9..4510d71 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -2,6 +2,7 @@ import { el, Panel } from './hud'; import { assetUrl } from '../scenes/assets'; import { gradeOf } from '../game/judging'; 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: @@ -12,8 +13,8 @@ export class Title { private panel: Panel; constructor( - save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number } }, - onStart: (day?: number | 'daily', shadow?: boolean) => void, + save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number }; drills?: Record }, + onStart: (day?: number | 'daily' | string, shadow?: boolean) => void, onFeed?: () => void, ) { this.panel = new Panel('title'); @@ -26,7 +27,7 @@ export class Title { 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'); let shadow = false; - const go = (day?: number | 'daily') => { + const go = (day?: number | 'daily' | string) => { this.panel.dispose(); 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 // holds a grudge. Feeding is one click; forgetting is a funeral. if (save.starter) {