THE CATALOGUE: money, and the silence of nothing rolling off the edge
P12's core — the meta-layer John asked for on day one. Tips scale with
the grade because that is how it works: an S pays, a D pays in abuse,
and the scorecard says so ('They left you $18.' / 'No tip. He watched
them not leave one.'). The money is deliberately gentle. This is a
kitchen, not a roguelike economy.
And then the tier rule, which is the entire design: CHEAP GEAR ADDS
NOISE AND EVENTS, NEVER A FLAT SCORE TAX. The small board you start on
does not subtract points anywhere. It does two things:
- it SHIFTS under a sawing knife, as literal lateral noise added to the
stroke, so the wedge and the CV it produces are earned by the hardware
the way a thin pan's hot spots are;
- it cannot hold a whole diced onion, so past about eight pieces the
outer ones are balanced on the rim and go on the floor.
Buy the big maple board off the catalogue and both simply stop. That is
the feel the atlas asked for and it is now measurable: the SAME drill,
the same cuts, went 10 of 12 with two on the floor and no medal on the
small board, and 12 of 12 with an S on the maple.
One bug that only existed because of this feature: the drill's short
verdict said 'Finish the cut before you stop the clock' — which is a
plain lie to a cook who DID finish and watched the board throw two
pieces away. It now names the floor when the floor is the reason.
Verified: an S tips $18; $42 buys the maple through the real catalogue
UI leaving $18; the board tier reaches both service prep and drills;
brûlée 7.5, mandoline 9.6, curry 10.0, chips 10.0, grill 9.4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
26a49d26fb
commit
fa6cbb1276
@ -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.
|
||||
|
||||
@ -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<string, number>;
|
||||
/** 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 {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
89
src/game/shop.ts
Normal file
89
src/game/shop.ts
Normal file
@ -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<string, number> = { 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';
|
||||
}
|
||||
@ -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++) {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }>; cures?: CureRecord[]; daysServed?: number },
|
||||
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 }>; 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user