M9: the fresh-eyes pass — played it cold, fixed what a stranger hits
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 <noreply@anthropic.com>
This commit is contained in:
parent
ff006e65db
commit
16860cf5d6
@ -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;
|
||||
|
||||
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user