diff --git a/src/game/drills.ts b/src/game/drills.ts index c1f8246..a02dfc9 100644 --- a/src/game/drills.ts +++ b/src/game/drills.ts @@ -18,6 +18,8 @@ import type { PrepResult } from './judging'; export type Medal = 'S' | 'gold' | 'silver' | 'bronze' | null; export interface Drill { + /** Present when the drill only appears once a skill unlocks it. */ + needsSkill?: string; id: string; label: string; /** What the ticket-less bench says you are practising. */ @@ -67,6 +69,18 @@ export const DRILLS: Drill[] = [ time: [28, 38, 52, 72], pieces: 12, }, + { + id: 'tourne_cucumber', + label: 'TOURNÉ — seven sides', + brief: 'Seven flat faces on a barrel the size of your thumb. Nobody needs this. Do it anyway.', + needsSkill: 'tourne', + ingredient: 'cucumber', + knife: 'the_best_thing', + pattern: { kind: 'wedges', n: 7 }, + cv: [0.07, 0.11, 0.17, 0.26], + time: [34, 46, 62, 85], + pieces: 7, + }, ]; export interface DrillScore { diff --git a/src/game/game.ts b/src/game/game.ts index 32f2264..7e9768b 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -42,6 +42,7 @@ 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 { SKILLS, canLearn, knows } from './skills'; import { eye } from '../ui/eye'; import { assetUrl } from '../scenes/assets'; @@ -62,6 +63,8 @@ interface Save { /** Money in the till, and what you have bought with it (P12). */ money?: number; owned?: string[]; + /** Techniques learned (P12). Verbs and floors, never stat sticks. */ + skills?: 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 @@ -359,6 +362,9 @@ export class Game { }, (id) => { audio.resume(); return this.buyItem(id); + }, (id) => { + audio.resume(); + return this.learnSkill(id); }); } @@ -637,6 +643,7 @@ export class Game { } this.prep.allowEarlyServe = false; // service wants the whole cut this.prep.boardTier = tierOf('board', this.save.owned ?? []); + this.prep.pinchGrip = knows(this.save, 'pinch_grip'); this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => { this.prepResult = mergePrep(this.prepResult, result); this.runNextPrep(); @@ -739,6 +746,7 @@ export class Game { this.judgeView.root.visible = false; this.hideAllScenes(); this.prep.boardTier = tierOf('board', this.save.owned ?? []); + this.prep.pinchGrip = knows(this.save, 'pinch_grip'); this.prep.reset(d.ingredient, d.knife, d.pattern, d.brief); this.prep.allowEarlyServe = true; this.prep.root.visible = true; @@ -1249,6 +1257,17 @@ export class Game { this.ticket.hide(); } + /** Learn a technique. Points are earned, never bought. */ + learnSkill(id: string): { ok: boolean; note: string } { + const check = canLearn(this.save, id); + if (!check.ok) return { ok: false, note: check.why }; + (this.save.skills ??= []).push(id); + writeSave(this.save); + audio.clatter(0.4, 1.5); + const node = SKILLS.find((n) => n.id === id); + return { ok: true, note: node ? node.blurb : 'learned' }; + } + /** 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); @@ -1279,6 +1298,7 @@ export class Game { } private enterBrulee(): void { + this.brulee.showCone = knows(this.save, 'steady_sweep'); this.brulee.reset(); this.hideAllScenes(); this.brulee.root.visible = true; @@ -1307,6 +1327,7 @@ export class Game { private enterMandoline(): void { const m = this.order.mandoline!; + this.mandoline.fastGuard = knows(this.save, 'spare_hand'); this.mandoline.reset(`${m.slices} slices \u2014 and your hands intact`); this.hideAllScenes(); this.mandoline.root.visible = true; @@ -1335,6 +1356,7 @@ export class Game { private enterSpice(): void { const sp = this.order.spice!; + this.spice.steadyPalate = knows(this.save, 'palate'); this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base); this.hideAllScenes(); this.spice.root.visible = true; @@ -1616,7 +1638,12 @@ export class Game { 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); + let tip = tipFor(v.grade, v.total); + if (tip > 0 && this.shadowMode && knows(this.save, 'confident_blind')) { + // They watched you cook a whole service without a word of help. + tip = Math.round(tip * 1.5); + extras.push('You did that blind and the room saw it.'); + } if (tip > 0) { this.save.money = (this.save.money ?? 0) + tip; extras.push(`${v.grade === 'S' ? 'They left you' : 'A tip:'} $${tip}.`); @@ -1785,7 +1812,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), money: num(s.money, 0), owned: s.owned ?? [] }; + 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 ?? [], skills: s.skills ?? [] }; } } } catch { diff --git a/src/game/skills.ts b/src/game/skills.ts new file mode 100644 index 0000000..4094db5 --- /dev/null +++ b/src/game/skills.ts @@ -0,0 +1,115 @@ +import { gradeOf } from './judging'; +import { medalRank, type Medal } from './drills'; + +/** + * THE SKILL PAGE — three short branches, and not one stat stick on it. + * + * The atlas banned "+5% score" nodes and it was right to: a number that + * quietly improves is not a skill, it is an apology for the last hour. Every + * node here is either a NEW VERB (something you could not do before) or a + * FLOOR (something that used to be able to go wrong and now cannot, because + * you have learned it). You should be able to feel every one of them in your + * hands within a single service. + * + * Points come from things you actually did: an S on a day, a gold or better + * in the drills, and simple time served. + */ + +export interface SkillNode { + id: string; + branch: 'KNIFE' | 'HEAT' | 'PALATE'; + label: string; + /** What it does, in the house voice — and it always names a verb. */ + blurb: string; + cost: number; + needs?: string; +} + +export const SKILLS: SkillNode[] = [ + { + id: 'pinch_grip', + branch: 'KNIFE', + label: 'THE PINCH GRIP', + blurb: 'You hold the board with the heel of your hand now without thinking about it. The small board stops moving under the knife. It still cannot hold a diced onion.', + cost: 1, + }, + { + id: 'tourne', + branch: 'KNIFE', + label: 'THE TOURNÉ', + blurb: 'Seven flat sides on a barrel the size of your thumb. Nobody needs it. It goes on the STAGE menu.', + cost: 2, + needs: 'pinch_grip', + }, + { + id: 'spare_hand', + branch: 'HEAT', + label: 'THE SPARE HAND', + blurb: 'The mandoline guard stops costing you time — you seat it in one movement now. Same safety, none of the fumbling.', + cost: 1, + }, + { + id: 'steady_sweep', + branch: 'HEAT', + label: 'THE STEADY SWEEP', + blurb: 'You can see where the flame has actually been: the torch leaves a faint ring on the sugar where its cone lands. It tells you nothing about the score, only about your own hand.', + cost: 2, + needs: 'spare_hand', + }, + { + id: 'palate', + branch: 'PALATE', + label: 'THE PALATE', + blurb: 'The tasting spoon stops guessing. The readout it gives you sits still instead of wobbling — the pot is no easier, you are just better at reading it.', + cost: 1, + }, + { + id: 'confident_blind', + branch: 'PALATE', + label: 'CONFIDENT BLIND', + blurb: "Cook a whole service under the Inspector's Shadow and the room notices. They tip for the nerve.", + cost: 2, + needs: 'palate', + }, +]; + +export interface SkillSave { + best: Record; + drills?: Record; + daysServed?: number; + skills?: string[]; +} + +/** Points you have EARNED, ever. Nothing here is a purchase. */ +export function earnedPoints(save: SkillSave): number { + const esses = Object.values(save.best ?? {}).filter((b) => gradeOf(b) === 'S').length; + const medals = Object.values(save.drills ?? {}).filter((d) => medalRank(d.medal) >= 3).length; + const served = Math.floor((save.daysServed ?? 0) / 5); + return esses + medals + served; +} + +export function spentPoints(save: SkillSave): number { + const have = save.skills ?? []; + return SKILLS.filter((n) => have.includes(n.id)).reduce((a, n) => a + n.cost, 0); +} + +export function freePoints(save: SkillSave): number { + return Math.max(0, earnedPoints(save) - spentPoints(save)); +} + +export function canLearn(save: SkillSave, id: string): { ok: boolean; why: string } { + const node = SKILLS.find((n) => n.id === id); + if (!node) return { ok: false, why: 'no such thing' }; + const have = save.skills ?? []; + if (have.includes(id)) return { ok: false, why: 'you know this' }; + if (node.needs && !have.includes(node.needs)) { + const need = SKILLS.find((n) => n.id === node.needs); + return { ok: false, why: `${need?.label ?? node.needs} first` }; + } + if (freePoints(save) < node.cost) return { ok: false, why: `wants ${node.cost} point${node.cost > 1 ? 's' : ''}` }; + return { ok: true, why: '' }; +} + +export function knows(save: { skills?: string[] } | undefined, id: string): boolean { + return !!save?.skills?.includes(id); +} diff --git a/src/scenes/brulee.ts b/src/scenes/brulee.ts index 560dbd9..055776e 100644 --- a/src/scenes/brulee.ts +++ b/src/scenes/brulee.ts @@ -58,6 +58,9 @@ export class BruleeView implements View { private hintEl!: HTMLElement; onDone: ((result: ReturnType) => void) | null = null; + /** THE STEADY SWEEP (skill): show where the cone actually lands. */ + showCone = false; + private coneRing!: THREE.Mesh; constructor(private app: App) { this.buildScenery(); @@ -150,6 +153,15 @@ export class BruleeView implements View { this.flame.rotation.x = Math.PI; // pointing down at the sugar this.root.add(this.flame); + // THE STEADY SWEEP's ring: where the flame is actually landing. It says + // nothing about the score — only about your own hand. + this.coneRing = new THREE.Mesh( + new THREE.RingGeometry(0.9, 1.0, 32), + new THREE.MeshBasicMaterial({ color: 0x9ec8ff, transparent: true, opacity: 0, side: THREE.DoubleSide }), + ); + this.coneRing.rotation.x = -Math.PI / 2; + this.root.add(this.coneRing); + // The spoon he taps it with. It arrives only at the verdict. this.spoon = new THREE.Mesh( new THREE.SphereGeometry(0.2, 14, 10, 0, Math.PI * 2, 0, Math.PI / 2.3), @@ -281,6 +293,17 @@ export class BruleeView implements View { fm.opacity += (0 - fm.opacity) * (1 - Math.exp(-dt / 0.06)); } + // The ring tracks the cone's real footprint, scaled by the height dial. + const rm = this.coneRing.material as THREE.MeshBasicMaterial; + if (this.showCone && at && !s.tapped) { + const world = radius * DISH_R * 1.88; + this.coneRing.position.set(at.x, TOP_Y + 0.012, at.z); + this.coneRing.scale.setScalar(world); + rm.opacity += (0.4 - rm.opacity) * (1 - Math.exp(-dt / 0.08)); + } else { + rm.opacity += (0 - rm.opacity) * (1 - Math.exp(-dt / 0.1)); + } + // The tap: the spoon comes down once, and stays where it landed. if (this.tapAnim > 0) { this.tapAnim = Math.max(0, this.tapAnim - dt * 2.2); diff --git a/src/scenes/mandoline.ts b/src/scenes/mandoline.ts index 7a73bde..4bb0c5b 100644 --- a/src/scenes/mandoline.ts +++ b/src/scenes/mandoline.ts @@ -127,8 +127,12 @@ export class MandolineView implements View { this.root.add(this.guardMesh); } + /** THE SPARE HAND (skill): the guard stops costing handling time. */ + fastGuard = false; + reset(askText = 'slices — as many as you can get, with your hands intact'): void { this.session = newMandolineSession(); + this.session.fastGuard = this.fastGuard; this.dragStartZ = null; this.strokeAnim = 0; for (const m of this.sliceMeshes) this.root.remove(m); diff --git a/src/scenes/prep.ts b/src/scenes/prep.ts index 55b1604..52113a4 100644 --- a/src/scenes/prep.ts +++ b/src/scenes/prep.ts @@ -85,6 +85,8 @@ export class PrepView implements View { * 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; + /** THE PINCH GRIP (skill): you hold a wobbly board still without thinking. */ + pinchGrip = false; private lostPieces = 0; constructor(private app: App) { @@ -308,7 +310,7 @@ export class PrepView implements View { // 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 wob = this.pinchGrip ? 0 : 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) { diff --git a/src/scenes/spice.ts b/src/scenes/spice.ts index e1b8c32..084fc78 100644 --- a/src/scenes/spice.ts +++ b/src/scenes/spice.ts @@ -58,6 +58,8 @@ export class SpiceView implements View { private hintEl!: HTMLElement; onDone: ((result: ReturnType) => void) | null = null; + /** THE PALATE (skill): the readout stops guessing and sits still. */ + steadyPalate = false; constructor(private app: App) { this.buildScenery(); @@ -271,8 +273,9 @@ export class SpiceView implements View { (zone as HTMLElement).style.left = `${Math.min(100, band[0] * 100)}%`; (zone as HTMLElement).style.width = `${Math.min(100 - band[0] * 100, (band[1] - band[0]) * 100)}%`; const pip = el('div', 'taste-pip', bar); - // The wobble: the spoon is a tongue, not an instrument. - const wob = (s.rng.next() - 0.5) * 0.03; + // The wobble: the spoon is a tongue, not an instrument — unless you have + // learned the palate, in which case it reports what it actually found. + const wob = this.steadyPalate ? 0 : (s.rng.next() - 0.5) * 0.03; (pip as HTMLElement).style.left = `${Math.max(0, Math.min(99, (s.lastTaste[a] / s.volume + wob) * 100))}%`; } } diff --git a/src/sim/mandoline.ts b/src/sim/mandoline.ts index 65a947b..ce1a207 100644 --- a/src/sim/mandoline.ts +++ b/src/sim/mandoline.ts @@ -38,12 +38,14 @@ export interface MandolineSession { bled: boolean; /** Strokes refused because the guard had nothing to hold. */ refused: number; + /** THE SPARE HAND: you seat the guard in one movement, so it costs no time. */ + fastGuard: boolean; seconds: number; rng: Rng; } export function newMandolineSession(seed = 20260726): MandolineSession { - return { length: 1, slices: 0, guard: false, nicks: 0, bled: false, refused: 0, seconds: 0, rng: new Rng(seed) }; + return { length: 1, slices: 0, guard: false, nicks: 0, bled: false, refused: 0, fastGuard: false, seconds: 0, rng: new Rng(seed) }; } export function setGuard(s: MandolineSession, on: boolean): void { @@ -73,7 +75,7 @@ export function stroke(s: MandolineSession): StrokeResult { return { sliced: false, nicked: false, refused: true }; } const risk = riskOf(s); - s.seconds += s.guard ? GUARD_COST : 0; + s.seconds += s.guard && !s.fastGuard ? GUARD_COST : 0; if (risk > 0 && s.rng.next() < risk) { s.nicks++; s.bled = true; diff --git a/src/style.css b/src/style.css index a610cfc..136d285 100644 --- a/src/style.css +++ b/src/style.css @@ -994,3 +994,7 @@ body:has(#ui > .title) #touchpad { display: none; } /* 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; } + +.skill-row.owned .drill-best { color: #d8c8f0; } +.skill-row:disabled:not(.owned) .drill-best { opacity: 0.6; } +.skill-row.owned .drill-name { white-space: normal; text-align: left; } diff --git a/src/ui/title.ts b/src/ui/title.ts index 3e98f2a..8bc23b6 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -5,6 +5,7 @@ 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'; +import { SKILLS, freePoints, canLearn } from '../game/skills'; /** * The front door. One button, one piece of art, one joke — and now the ledger: @@ -15,11 +16,12 @@ 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; money?: number; owned?: string[] }, + 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[]; skills?: 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 }, + onLearn?: (id: string) => { ok: boolean; note: string }, ) { this.panel = new Panel('title'); const wrap = el('div', 'title-wrap', this.panel.root); @@ -58,6 +60,7 @@ export class Title { void stageLbl; const stage = el('div', 'title-drills', txt); for (const d of DRILLS) { + if (d.needsSkill && !(save.skills ?? []).includes(d.needsSkill)) continue; const best = save.drills?.[d.id]; const b = el('button', 'btn ghost drill-btn', stage); el('span', 'drill-name', b, d.label); @@ -65,6 +68,33 @@ export class Title { b.addEventListener('click', () => go(`drill:${d.id}`)); } + // THE SKILL PAGE. Three short branches; every node is a verb or a floor. + { + const free = freePoints(save as never); + const have = save.skills ?? []; + if (free > 0 || have.length) { + el('div', 'title-days-lbl', txt, `TECHNIQUES \u2014 ${free} point${free === 1 ? '' : 's'} to spend`); + const page = el('div', 'title-drills', txt); + for (const n of SKILLS) { + const known = have.includes(n.id); + const check = canLearn(save as never, n.id); + const b = el('button', `btn ghost drill-btn skill-row${known ? ' owned' : ''}`, page); + el('span', 'drill-name', b, `${n.branch} \u00b7 ${n.label}`); + el('span', 'drill-best', b, known ? 'LEARNED' : check.ok ? `${n.cost} pt${n.cost > 1 ? 's' : ''}` : check.why); + b.title = n.blurb; + if (known || !check.ok) { (b as HTMLButtonElement).disabled = true; continue; } + b.addEventListener('click', () => { + const r = onLearn?.(n.id); + if (!r || !r.ok) return; + b.textContent = ''; + el('span', 'drill-name', b, r.note); + b.classList.add('owned'); + (b as HTMLButtonElement).disabled = true; + }); + } + } + } + // THE CATALOGUE. Cheap gear does not tax your score — it wobbles, and it // drops things on the floor. This is where that stops. {