diff --git a/src/game/drills.ts b/src/game/drills.ts index d1d3e4b..c1f8246 100644 --- a/src/game/drills.ts +++ b/src/game/drills.ts @@ -96,7 +96,12 @@ export function scoreDrill(d: Drill, r: PrepResult, seconds: number): DrillScore } } let note: string; - if (short) note = `${pieces} of ${d.pieces}. Finish the cut before you stop the clock.`; + const floor = r.lostToFloor ?? 0; + // Being short because the small board threw your work on the floor is not + // the same failure as bailing early, and telling a cook who DID finish that + // they stopped too soon is just wrong. + if (short && floor > 0) note = `${pieces} of ${d.pieces} \u2014 ${floor} went on the floor. That board is too small for this.`; + else 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. diff --git a/src/game/game.ts b/src/game/game.ts index 32e8533..3fe978e 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -40,6 +40,7 @@ import { el, Panel } from '../ui/hud'; import { Title } from '../ui/title'; import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills'; import { openCure, CURE_DAYS, type CureRecord } from '../sim/cure'; +import { CATALOGUE, tipFor, tierOf, boardName } from './shop'; import { eye } from '../ui/eye'; import { assetUrl } from '../scenes/assets'; @@ -57,6 +58,9 @@ interface Save { best: Record; /** THE CURING SHELF: jars started on one day and opened on another. */ cures?: CureRecord[]; + /** Money in the till, and what you have bought with it (P12). */ + money?: number; + owned?: string[]; /** Days SERVED, ever — a monotonic clock that only goes up. maxDay is the * wrong clock for a cure: it only advances when you reach a NEW day, so a * cook at day 45 replaying day 30 could never age a jar. Any service is a @@ -347,6 +351,9 @@ export class Game { }, (id) => { audio.resume(); return this.openCureJar(id); + }, (id) => { + audio.resume(); + return this.buyItem(id); }); } @@ -620,6 +627,7 @@ export class Game { return; } this.prep.allowEarlyServe = false; // service wants the whole cut + this.prep.boardTier = tierOf('board', this.save.owned ?? []); this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.prepResult = mergePrep(this.prepResult, result); this.runNextPrep(); @@ -721,6 +729,7 @@ export class Game { if (sl) this.kitchen.root.add(sl.mesh); this.judgeView.root.visible = false; this.hideAllScenes(); + this.prep.boardTier = tierOf('board', this.save.owned ?? []); this.prep.reset(d.ingredient, d.knife, d.pattern, d.brief); this.prep.allowEarlyServe = true; this.prep.root.visible = true; @@ -1197,6 +1206,20 @@ export class Game { this.ticket.hide(); } + /** Buy from the catalogue. Returns what the counter says back. */ + buyItem(id: string): { ok: boolean; note: string } { + const it = CATALOGUE.find((c) => c.id === id); + if (!it) return { ok: false, note: 'no such thing' }; + const owned = (this.save.owned ??= []); + if (owned.includes(id)) return { ok: false, note: 'you already have one' }; + if ((this.save.money ?? 0) < it.price) return { ok: false, note: `$${it.price - (this.save.money ?? 0)} short` }; + this.save.money = (this.save.money ?? 0) - it.price; + owned.push(id); + writeSave(this.save); + audio.clatter(0.5, 1.2); + return { ok: true, note: `${boardName(tierOf('board', owned))} is on the bench now.` }; + } + /** Open a jar off the shelf. The verdict is mostly about the WAITING. */ openCureJar(id: string): { note: string; score: number } | null { const list = this.save.cures ?? []; @@ -1549,6 +1572,14 @@ export class Game { d[this.dailyDate] = Math.max(d[this.dailyDate] ?? 0, v.total); extras.push('The Daily. Same deal for everyone today — come back tomorrow.'); } + // THE TILL. Tips scale with the grade because that is how it works. + const tip = tipFor(v.grade, v.total); + if (tip > 0) { + this.save.money = (this.save.money ?? 0) + tip; + extras.push(`${v.grade === 'S' ? 'They left you' : 'A tip:'} $${tip}.`); + } else { + extras.push('No tip. He watched them not leave one.'); + } this.judgeView.extraLines = extras; this.judgeView.shareText = this.shareLine(v); this.save.total += v.total; @@ -1711,7 +1742,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, drills: s.drills ?? {}, cures: s.cures ?? [], daysServed: num(s.daysServed, 0) }; + 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 ?? {}, cures: s.cures ?? [], daysServed: num(s.daysServed, 0), money: num(s.money, 0), owned: s.owned ?? [] }; } } } catch { diff --git a/src/game/judging.ts b/src/game/judging.ts index 9eb0980..d200e8e 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -49,6 +49,8 @@ export interface PrepResult { sting?: number; /** True on the root-stop dice: turns on The Dice + The Eyes rows. */ technique?: boolean; + /** Pieces that rolled off a small board onto the floor (P12 board tier). */ + lostToFloor?: number; /** Stem discipline — how the root-ward cuts stopped short (sim/cutting). */ stem?: { through: number; stopMean: number; score: number }; } diff --git a/src/game/shop.ts b/src/game/shop.ts new file mode 100644 index 0000000..3b3bfe6 --- /dev/null +++ b/src/game/shop.ts @@ -0,0 +1,89 @@ +import type { Grade } from './judging'; + +/** + * THE CATALOGUE — money, and gear that changes your hands. + * + * Tips scale with the grade, because that is how it works: an S pays, a D + * pays in abuse. The money is gentle on purpose. This is a kitchen, not a + * roguelike economy, and nothing here is a stat stick. + * + * THE TIER RULE, which is the whole design: cheap gear adds NOISE and + * EVENTS, never a flat score tax. The small board does not subtract points — + * it WOBBLES under the knife and lets the outer pieces roll onto the floor. + * You feel the upgrade in your hands, not in a stat sheet, and the day you + * buy the big maple board the first thing you notice is the silence of + * nothing rolling off the edge. + */ + +export interface CatalogueItem { + id: string; + slot: 'board'; + label: string; + /** The catalogue's own patter, in the house voice. */ + blurb: string; + price: number; + /** Higher is better. The slot's tier is the best item you own in it. */ + tier: number; +} + +export const CATALOGUE: CatalogueItem[] = [ + { + id: 'board_maple', + slot: 'board', + label: 'THE BIG MAPLE BOARD', + blurb: 'Twice the room and it does not budge. Dice a whole onion on it and every piece is still on the board when you look up.', + price: 42, + tier: 1, + }, + { + id: 'board_endgrain', + slot: 'board', + label: 'THE END-GRAIN BLOCK', + blurb: 'The fibres stand up to meet the blade instead of lying across it. Heavy as a small dog. Kind to knives.', + price: 120, + tier: 2, + }, +]; + +/** What you start with: a small board that wobbles and loses things. */ +export const STARTER_TIERS: Record = { board: 0 }; + +/** + * The tip. An S is worth having; below a C he is not paying you for it, and + * the number is small enough that money never becomes the point. + */ +export function tipFor(grade: Grade, total: number): number { + const base = grade === 'S' ? 14 : grade === 'A' ? 9 : grade === 'B' ? 5 : grade === 'C' ? 2 : 0; + // A whisker of scaling inside the band so a 9.9 beats a 9.0. + return Math.round(base + total * 0.4); +} + +/** The best tier owned in a slot — what the bench should actually hand you. */ +export function tierOf(slot: string, owned: string[]): number { + let best = STARTER_TIERS[slot] ?? 0; + for (const id of owned) { + const it = CATALOGUE.find((c) => c.id === id); + if (it && it.slot === slot && it.tier > best) best = it.tier; + } + return best; +} + +/** How much a board of this tier shakes under a sawing knife. */ +export function boardWobble(tier: number): number { + return tier === 0 ? 0.055 : 0; // the cheap one is the only one that moves +} + +/** + * Odds a finished piece rolls off the edge, per piece past what the board can + * hold. The small board holds about eight; everything after that is balanced + * on the rim, and the floor is right there. + */ +export function rollOffChance(tier: number, pieceIndex: number): number { + if (tier > 0) return 0; + const over = pieceIndex - 8; + return over <= 0 ? 0 : Math.min(0.5, over * 0.12); +} + +export function boardName(tier: number): string { + return tier === 0 ? 'the small board' : tier === 1 ? 'the big maple board' : 'the end-grain block'; +} diff --git a/src/scenes/prep.ts b/src/scenes/prep.ts index 25046cd..55b1604 100644 --- a/src/scenes/prep.ts +++ b/src/scenes/prep.ts @@ -21,6 +21,7 @@ import { Mess } from '../sim/mess'; import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery'; import type { PrepResult } from '../game/judging'; import { el, Panel } from '../ui/hud'; +import { boardWobble, rollOffChance } from '../game/shop'; import { Rng } from '../core/rng'; import { loadProp } from './assets'; @@ -80,6 +81,11 @@ export class PrepView implements View { * 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; + /** THE BOARD (P12). Tier 0 is the small one you start with: it shifts under + * a sawing knife and it cannot hold a whole diced onion, so the outer + * pieces go on the floor. Tiers above it simply do not do that. */ + boardTier = 0; + private lostPieces = 0; constructor(private app: App) { this.buildScenery(); @@ -244,6 +250,7 @@ export class PrepView implements View { this.root.add(this.stopLine); } this.mess.reset(); + this.lostPieces = 0; this.askEl.textContent = askText; this.doneTimer = 0; this.hasPrev = false; @@ -297,7 +304,13 @@ export class PrepView implements View { } else if (inp.down && this.hasPrev) { const dy = this.planePt.y - this.prev.y; const dx = this.planePt.x - this.prev.x; - const f = cutFrame(s, dy, dx, dt, () => this.rng.next()); + // A cheap board shifts under the saw. This is not a score penalty — + // it is literal lateral noise in the stroke, so the wedge and the CV + // it produces are earned by the hardware, exactly like a thin pan's + // hot spots. Buy the maple and the noise is simply gone. + const wob = boardWobble(this.boardTier); + const dxBoard = wob > 0 ? dx + (this.rng.next() - 0.5) * wob : dx; + const f = cutFrame(s, dy, dxBoard, dt, () => this.rng.next()); if (Math.abs(dy) > 0.004) { this.sawPhase += Math.abs(dy) * 3; audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22); @@ -372,7 +385,9 @@ export class PrepView implements View { const technique = s.pattern.kind === 'dice' && s.pattern.root === true; return { cv: pieceCV(s), - pieces: pieceVolumes(s).length, + // What made it to the plate, not what the knife produced. + pieces: Math.max(0, pieceVolumes(s).length - this.lostPieces), + lostToFloor: this.lostPieces, patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind, slips: s.slips, bench: this.mess.stats(), @@ -399,6 +414,14 @@ export class PrepView implements View { this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite'; } if (f.cutDone) { + // The floor seasoning: past what the small board can hold, the outer + // pieces are balanced on the rim and the floor is right there. + const idx = pieceVolumes(this.session!).length; + if (this.rng.next() < rollOffChance(this.boardTier, idx)) { + this.lostPieces++; + this.mess.addSolid(0.5 + (this.rng.next() - 0.5) * 0.7, 0.92, 'seed'); + audio.clatter(0.3, 0.7); + } audio.clunk(false); this.addSeam(s.cuts[s.cuts.length - 1]); for (let i = 0; i < f.seedsSpilled; i++) { diff --git a/src/style.css b/src/style.css index a361dcb..a610cfc 100644 --- a/src/style.css +++ b/src/style.css @@ -990,3 +990,7 @@ body:has(#ui > .title) #touchpad { display: none; } .jar-row.ready .drill-best { color: #8fce8f; } .jar-row.opened { opacity: 0.75; font-style: italic; } .jar-row.opened .drill-name { white-space: normal; text-align: left; } + +/* The catalogue: owned gear reads as settled, not as a disabled control. */ +.cat-row.owned { opacity: 0.8; } +.cat-row.owned .drill-best { color: #8fce8f; } diff --git a/src/ui/title.ts b/src/ui/title.ts index 32c9414..3e98f2a 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -4,6 +4,7 @@ import { gradeOf } from '../game/judging'; import { DAYS } from '../game/orders'; import { DRILLS, medalWord, type Medal } from '../game/drills'; import { CURE_DAYS, type CureRecord } from '../sim/cure'; +import { CATALOGUE } from '../game/shop'; /** * The front door. One button, one piece of art, one joke — and now the ledger: @@ -14,10 +15,11 @@ export class Title { private panel: Panel; constructor( - save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number }; drills?: Record; cures?: CureRecord[]; daysServed?: number }, + save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number }; drills?: Record; cures?: CureRecord[]; daysServed?: number; money?: number; owned?: string[] }, onStart: (day?: number | 'daily' | string, shadow?: boolean) => void, onFeed?: () => void, onOpenCure?: (id: string) => { note: string; score: number } | null, + onBuy?: (id: string) => { ok: boolean; note: string }, ) { this.panel = new Panel('title'); const wrap = el('div', 'title-wrap', this.panel.root); @@ -63,6 +65,34 @@ export class Title { b.addEventListener('click', () => go(`drill:${d.id}`)); } + // THE CATALOGUE. Cheap gear does not tax your score — it wobbles, and it + // drops things on the floor. This is where that stops. + { + const money = save.money ?? 0; + const owned = save.owned ?? []; + const unbought = CATALOGUE.filter((c) => !owned.includes(c.id)); + if (money > 0 || owned.length || unbought.length < CATALOGUE.length) { + el('div', 'title-days-lbl', txt, `THE CATALOGUE \u2014 $${money} in the till`); + const list = el('div', 'title-drills', txt); + for (const item of CATALOGUE) { + const have = owned.includes(item.id); + const b = el('button', `btn ghost drill-btn cat-row${have ? ' owned' : ''}`, list); + const name = el('span', 'drill-name', b, item.label); + void name; + el('span', 'drill-best', b, have ? 'ON THE BENCH' : `$${item.price}`); + b.title = item.blurb; + if (have) { (b as HTMLButtonElement).disabled = true; continue; } + b.addEventListener('click', () => { + const r = onBuy?.(item.id); + if (!r) return; + b.textContent = ''; + el('span', 'drill-name', b, r.ok ? r.note : `${item.label} \u2014 ${r.note}`); + if (r.ok) { b.classList.add('owned'); (b as HTMLButtonElement).disabled = true; } + }); + } + } + } + // THE CURING SHELF. The only thing in this kitchen you cannot finish // today: jars stamped with the day they were packed, counting up. if (save.cures && save.cures.length) {