From 16860cf5d6814d8402e4baa69dd87f1612c65198 Mon Sep 17 00:00:00 2001 From: monster Date: Fri, 17 Jul 2026 15:42:09 +1000 Subject: [PATCH] =?UTF-8?q?M9:=20the=20fresh-eyes=20pass=20=E2=80=94=20pla?= =?UTF-8?q?yed=20it=20cold,=20fixed=20what=20a=20stranger=20hits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sat down with it like someone who'd never seen it. Five findings, all reproduced before fixing, all verified after: 1. The kitchen panel was a cheat console. BREAD/SPREAD/TOOL/BUTTER are the order's facts, but the widgets stayed live — you could fetch a soup spoon and then quietly re-select the butter knife. Locked during play; the browning dial stays yours because it's a real control. 2. The drawer card printed the ANSWER under the silhouette. The entire "similar but not identical" puzzle, solved by a caption. The name is gone; after ten seconds of fumbling it fades in as a pity: "fine. it's the butter knife." 3. Lifting anything above the rim committed it instantly — but fishing means lifting blockers, so you could accidentally "choose" whatever you were moving aside. Commit now needs 0.45s of continuous hold above the rim, which doubles as a beat to compare what you're holding against the silhouette. Verified: a wobbly lift takes ~0.95s because dipping below the rim resets the clock, and that's correct. 4. Coverage scored 20% on a slice a player had visibly covered corner to corner. Root cause: the "covered" floor scaled with the ASKED amount, so Coverage and Amount both punished thinness — double jeopardy. Coverage now asks its own question (are there bare patches? floor = 0.06 absolute) and the same conscientious play scores 84%. 5. Nothing told you how much was on the toast until the verdict. New live readout, computed by the judge's own functions: "on the toast: thin — normal asked · 84% covered". Watching it, a played-well day 1 went 8.8 -> 9.0, and the missing 0.2 is named on the card (stripes), not hidden. Also: the drawer holds 11 pieces dropped down a tighter column (a heap you rummage in, not an exploded diagram — padded with extra teaspoons, like every real drawer), it sits on a bench instead of floating in a void, Service is sim-time rather than wall-clock, and the title mentions the drawer exists. Co-Authored-By: Claude Fable 5 --- src/game/game.ts | 11 +++++-- src/game/judging.ts | 11 +++++-- src/scenes/drawer.ts | 67 ++++++++++++++++++++++++++++++++++++------- src/scenes/kitchen.ts | 31 ++++++++++++++++++++ src/style.css | 33 +++++++++++++++++++++ src/ui/title.ts | 3 +- 6 files changed, 140 insertions(+), 16 deletions(-) diff --git a/src/game/game.ts b/src/game/game.ts index 9e514f0..194ee3f 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -30,7 +30,9 @@ export class Game { private drawer: DrawerView; private rng = new Rng(20260716); private order!: Order; - private startedAt = 0; + /** Sim-time service clock — wall clock lies whenever the tab throttles. */ + private orderSeconds = 0; + private timing = false; private save: Save; private ticket: Panel; private ticketEl!: HTMLElement; @@ -65,6 +67,7 @@ export class Game { // stops updating the moment the drawer opens, and "your toast goes cold // while you rummage" is the entire reason the drawer exists. app.onTick((dt) => { + if (this.timing) this.orderSeconds += dt; const k = this.kitchen; if (k.phase === 'ready' || k.phase === 'toasting') return; coolStep(k.currentSlice, dt); @@ -128,7 +131,8 @@ export class Game { : proceduralOrder(day, () => this.rng.next()); if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`; this.order = o; - this.startedAt = performance.now(); + this.orderSeconds = 0; + this.timing = true; const s = this.judgeView.release(); if (s) this.kitchen.root.add(s.mesh); @@ -166,7 +170,8 @@ export class Game { /** Called when the player hands the plate over. */ serve(): void { - const seconds = (performance.now() - this.startedAt) / 1000; + this.timing = false; + const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; const v = judge(slice, this.order, this.kitchen.toolId, seconds); this.save.total += v.total; diff --git a/src/game/judging.ts b/src/game/judging.ts index c2a11f6..43d2625 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -28,6 +28,9 @@ export interface Verdict { const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); +/** "There is visibly spread on this texel." Shared with the live HUD readout. */ +export const COVER_FLOOR = 0.06; + /** * Every number here is read off the same fields the player was pushing around * with the knife, so the scorecard can always point at something real. @@ -41,10 +44,14 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num const target = (lo + hi) / 2; const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD); - const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75); + // Coverage asks one question: are there bare patches? The floor is absolute — + // "is there visibly spread here" — NOT relative to the asked amount. Thinness + // is Amount's complaint; when coverage also scaled with the ask, a thorough + // thin layer scored 20% coverage and the two criteria punished it twice. + const coverage = slice.spread.fraction(slice.mask, (v) => v >= COVER_FLOOR); // Uniformity only over the bits you actually covered — punishing the variance // of a half-spread slice twice (here and in coverage) isn't fair. - const covered = spreadStdevOverCovered(slice, lo * 0.75); + const covered = spreadStdevOverCovered(slice, COVER_FLOOR); const criteria: Criterion[] = [ { diff --git a/src/scenes/drawer.ts b/src/scenes/drawer.ts index 66435bc..e4f4c0e 100644 --- a/src/scenes/drawer.ts +++ b/src/scenes/drawer.ts @@ -56,6 +56,15 @@ export class DrawerView implements View { /** How warm the toast still is, 0..1 — drawn as the timer. */ warmth = 1; + /** The silhouette is the puzzle: the name only appears as a pity, later. */ + private wantName = ''; + private nameShown = false; + private fumbleSeconds = 0; + /** Holding a piece above the rim commits after a beat — a moment to compare + * it against the silhouette, and no accidental soup-spoon commitments while + * rummaging blockers out of the way. */ + private rimDwell = 0; + constructor( private app: App, seed = 7, @@ -82,6 +91,15 @@ export class DrawerView implements View { private buildScenery(): void { const wood = new THREE.MeshStandardMaterial({ color: 0x6f4c2e, roughness: 0.8 }); const inner = new THREE.MeshStandardMaterial({ color: 0x8a6740, roughness: 0.9 }); + // The drawer used to float in a black void; give it the same kitchen it + // came from — a bench under it and a warm pool of light around it. + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }), + ); + bench.position.y = -0.33; + bench.receiveShadow = true; + this.root.add(bench); const floor = new THREE.Mesh(new THREE.BoxGeometry(W, WALL, D), inner); floor.position.y = -WALL / 2; floor.receiveShadow = true; @@ -121,7 +139,7 @@ export class DrawerView implements View { } /** Fill the drawer and let it settle into a mess. */ - reset(want: ToolId, extras = 7): void { + reset(want: ToolId, extras = 10): void { for (const p of this.pieces) { this.world.removeRigidBody(p.body); this.root.remove(p.mesh); @@ -138,9 +156,20 @@ export class DrawerView implements View { ids.push(rest.splice(Math.floor(this.rng.next() * rest.length), 1)[0]); } + // If the pool runs dry, pad with extra spoons and forks — every real drawer + // has too many teaspoons, and density is what makes fishing fishing. + const padding: ToolId[] = ['teaspoon', 'dessert_spoon', 'dinner_fork', 'soup_spoon', 'dinner_knife']; + let pi = 0; + while (ids.length < extras + 1) ids.push(padding[pi++ % padding.length]); + ids.forEach((id, i) => this.spawn(id, i)); + // The name is the answer, and the silhouette is the question — so no name. + // It fades in after ten seconds of fumbling, like a sigh. this.cardEl.innerHTML = toolSilhouette(TOOLS[want]); - el('div', 'sil-name', this.cardEl, TOOLS[want].name); + this.wantName = TOOLS[want].name; + this.nameShown = false; + this.fumbleSeconds = 0; + this.rimDwell = 0; // Settle offline so the player opens the drawer on an existing mess rather // than watching the cutlery rain in. @@ -165,9 +194,9 @@ export class DrawerView implements View { // Drop them down a narrow column in the middle so they land on top of // one another rather than politely finding their own patch of floor. .setTranslation( - this.rng.range(-0.5, 0.5), - 0.7 + i * 0.5, - this.rng.range(-0.35, 0.35), + this.rng.range(-0.32, 0.32), + 0.65 + i * 0.42, + this.rng.range(-0.22, 0.22), ) .setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) .setLinearDamping(0.35) @@ -237,12 +266,30 @@ export class DrawerView implements View { this.timerFill.style.width = `${Math.max(0, this.warmth * 100)}%`; this.timerFill.style.background = this.warmth > 0.5 ? '#e8a53a' : this.warmth > 0.25 ? '#d2703a' : '#c0392b'; - // Lifted clear of the rim? That's your pick. + // Pity reveal: after ten seconds the card quietly tells you the name. + if (!this.nameShown) { + this.fumbleSeconds += dt; + if (this.fumbleSeconds > 10) { + this.nameShown = true; + el('div', 'sil-name revealed', this.cardEl, `fine. it's the ${this.wantName.toLowerCase()}.`); + } + } + + // Holding a piece above the rim for a beat commits it. The beat matters + // twice over: no accidental commitment while lifting blockers around, and + // a moment to hold your candidate against the silhouette and doubt yourself. if (this.grabbed && this.grabbed.body.translation().y > RIM_Y) { - const got = this.grabbed.id; - this.grabbed = null; - this.onPicked?.(got); - return; + this.rimDwell += dt; + this.hintEl.textContent = 'holding it up… keep holding to take it'; + if (this.rimDwell > 0.45) { + const got = this.grabbed.id; + this.grabbed = null; + this.onPicked?.(got); + return; + } + } else { + this.rimDwell = 0; + this.hintEl.textContent = "drag a piece up and out · ENTER takes what you're holding"; } if (inp.justPressed('Enter') && this.grabbed) { const got = this.grabbed.id; diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts index bc97f14..f3493cd 100644 --- a/src/scenes/kitchen.ts +++ b/src/scenes/kitchen.ts @@ -7,11 +7,14 @@ import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting'; import { SPREADS, SCRAPE_ANGLE, + amountClassOf, effectiveYield, pressureFromAngle, + type AmountClass, type SpreadDef, type SpreadId, } from '../sim/spreads'; +import { COVER_FLOOR } from '../game/judging'; import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading'; import { TOOLS, type ToolId } from '../sim/cutlery'; import { makeBench, makePlate, makeToaster, type Toaster } from './props'; @@ -89,6 +92,10 @@ export class KitchenView implements View { private loadFill!: HTMLElement; private jarRow!: HTMLElement; private jarFill!: HTMLElement; + private amountEl!: HTMLElement; + /** What the current order asked for — drives the live readout. */ + private askedAmount: AmountClass | null = null; + private amountTick = 0; private breadSel!: HTMLSelectElement; private spreadSel!: HTMLSelectElement; private toolSel!: HTMLSelectElement; @@ -210,6 +217,7 @@ export class KitchenView implements View { const loadBar = el('div', 'load', p); this.loadFill = el('div', 'load-fill', loadBar); + this.amountEl = el('div', 'amount', p, ''); this.cueEl = el('div', 'cue', p, 'smells like bread'); this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down'); @@ -262,6 +270,14 @@ export class KitchenView implements View { /** Set the bench up for one order. The tool comes from the drawer (M4). */ applyOrder(o: Order): void { + // These four are the ORDER's facts, and the tool is whatever the drawer + // hands back. Leaving them editable was an open cheat: re-pick the butter + // knife after fetching a soup spoon, soften the butter, swap the bread. + this.breadSel.disabled = true; + this.spreadSel.disabled = true; + this.toolSel.disabled = true; + this.softInput.disabled = true; + this.askedAmount = o.amount; this.butterSoftness = o.butterSoftness; this.toolId = o.tool; this.knifeRig.setTool(TOOLS[this.toolId]); @@ -521,6 +537,21 @@ export class KitchenView implements View { this.jarFill.style.background = mixed > 0.75 ? '#6cbf6c' : mixed > 0.4 ? '#e8a53a' : '#c0392b'; } + // Live readout of the two things the judge will actually measure, from the + // same functions he uses — so "thin" at serve time is never a surprise. + if (++this.amountTick % 12 === 0) { + const mean = this.slice.spread.stats(this.slice.mask).mean; + const cls = amountClassOf(def, mean); + const cov = this.slice.spread.fraction(this.slice.mask, (v) => v >= COVER_FLOOR); + const askTxt = this.askedAmount ? ` — ${this.askedAmount} asked` : ''; + this.amountEl.textContent = + cls === 'none' && cov < 0.02 + ? 'nothing on the toast yet' + : `on the toast: ${cls === 'none' ? 'barely anything' : cls}${askTxt} · ${Math.round(cov * 100)}% covered`; + this.amountEl.style.color = + this.askedAmount && cls === this.askedAmount && cov > 0.85 ? '#8fce8f' : ''; + } + const fx = this.lastFx; let msg = scraping ? 'scraping' : 'spreading'; if (fx) { diff --git a/src/style.css b/src/style.css index 3a32717..7a197cb 100644 --- a/src/style.css +++ b/src/style.css @@ -479,6 +479,25 @@ canvas#c { margin-top: 2px; } +/* The pity reveal: the answer arriving like a sigh, ten seconds in. */ +.sil-name.revealed { + font-size: 12px; + font-style: italic; + color: var(--ink-dim); + animation: sigh 1.6s ease-out; +} + +@keyframes sigh { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: none; + } +} + .drawer-timer { margin-top: 12px; } @@ -561,3 +580,17 @@ canvas#c { letter-spacing: 0.08em; color: #6d6155; } + +.toast-panel .amount { + margin-top: 8px; + font-size: 11px; + letter-spacing: 0.06em; + color: var(--ink-dim); + min-height: 14px; + font-family: var(--mono); +} + +.toast-panel .sel:disabled, +.toast-panel .dial:disabled { + opacity: 0.55; +} diff --git a/src/ui/title.ts b/src/ui/title.ts index be94ea0..e3266dd 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -20,7 +20,8 @@ export class Title { }); const keys = el('div', 'title-keys', txt); el('div', undefined, keys, 'SPACE — lever down, then pop'); - el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife'); + el('div', undefined, keys, 'DRAG — fish the right tool from the drawer'); + el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife · dip when empty'); el('div', undefined, keys, 'ENTER — serve it to him'); } }