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:
parent
00e3f1694e
commit
cebff4bd69
125
src/game/drills.ts
Normal file
125
src/game/drills.ts
Normal 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' : '—';
|
||||
}
|
||||
@ -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<string, { medal: Medal; cv: number; seconds: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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<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 {
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number } },
|
||||
onStart: (day?: number | 'daily', shadow?: boolean) => void,
|
||||
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' | 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user