toastsim/src/ui/title.ts
type-two 34e08321fd TECHNIQUES: a skill page with no stat sticks on it
The last of P12, and the atlas was emphatic: "+5% score" nodes are
BANNED. A number that quietly improves is not a skill, it is an apology
for the last hour. So every one of these six is either a NEW VERB or a
FLOOR, and you can feel all of them inside one service:

KNIFE — THE PINCH GRIP: you hold the small board still with the heel of
your hand, so its wobble stops reaching your stroke. It still cannot
hold a diced onion; the catalogue is for that. → THE TOURNÉ: seven flat
sides on a barrel the size of your thumb. Nobody needs it. It appears on
the STAGE menu.

HEAT — THE SPARE HAND: you seat the mandoline guard in one movement, so
guarding stops costing handling time. Same safety, none of the fumbling.
→ THE STEADY SWEEP: the torch now leaves a faint ring where its cone
actually lands. It tells you nothing about the score — only about your
own hand, which is the only thing an info tool should ever do.

PALATE — THE PALATE: the tasting spoon stops guessing and the readout
sits still instead of wobbling. The pot is no easier; you are better at
reading it. → CONFIDENT BLIND: cook a whole service under the
Inspector's Shadow and the room tips for the nerve.

Points are earned, never bought: an S on a day, a gold or better in the
drills, and time served. Prerequisites are enforced and the page says
what is missing ("THE PINCH GRIP first") rather than greying out in
silence.

Verified the flags actually reach the benches rather than just sitting
in the save: with two branches learned, prep.pinchGrip and
spice.steadyPalate read true while mandoline.fastGuard and
brulee.showCone read FALSE, then both flipped true the moment their
nodes were learned. The tourné drill was hidden and appeared on unlock.
Carve 9.8, mandoline 9.6, curry 10.0, brûlée 7.5, chips 10.0, grill 9.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:45:03 +10:00

206 lines
10 KiB
TypeScript

import { el, Panel } from './hud';
import { assetUrl } from '../scenes/assets';
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';
import { SKILLS, freePoints, canLearn } from '../game/skills';
/**
* The front door. One button, one piece of art, one joke — and now the ledger:
* every day you've reached, replayable, with your best grade stamped on it.
* Chasing the S column is the whole late game.
*/
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; 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);
const art = el('img', 'title-art', wrap);
art.src = assetUrl('/assets/img/title_art.png');
art.alt = '';
const txt = el('div', 'title-txt', wrap);
el('h1', undefined, txt, 'TOASTSIM');
el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
const btn = el('button', 'btn', txt, save.day > 1 ? `RESUME — DAY ${save.day}` : 'START DAY 1');
let shadow = false;
const go = (day?: number | 'daily' | string) => {
this.panel.dispose();
onStart(day, shadow);
};
btn.addEventListener('click', () => go());
const today = new Date().toISOString().slice(0, 10);
const dailyBest = (save as { daily?: Record<string, number> }).daily?.[today];
const daily = el('button', 'btn ghost daily-btn', txt, dailyBest !== undefined ? `THE DAILY ✓ ${dailyBest.toFixed(1)}` : 'THE DAILY — same deal for everyone');
daily.addEventListener('click', () => go('daily'));
// Hard mode, earned: once you've pulled an S he offers to stop talking.
// No words on the bench — you cook by the face, the sound, the food.
if (Object.values(save.best).some((b) => gradeOf(b) === 'S')) {
const sh = el('button', 'btn ghost shadow-btn', txt, "THE INSPECTOR'S SHADOW — he says nothing");
sh.addEventListener('click', () => {
shadow = !shadow;
sh.classList.toggle('on', shadow);
sh.textContent = shadow ? "THE INSPECTOR'S SHADOW — armed. no words today" : "THE INSPECTOR'S SHADOW — he says nothing";
});
}
// STAGE — culinary school. No customer, no clock but your own, and the
// only thing it can change is your own medal book.
const stageLbl = el('div', 'title-days-lbl', txt, 'STAGE \u2014 the drills. one board, one spec, one clock');
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);
el('span', `drill-best medal-${best?.medal ?? 'none'}`, b, best?.medal ? `${medalWord(best.medal)} \u00b7 ${Math.round(best.seconds)}s` : best ? 'attempted \u2014 no medal' : 'not attempted');
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.
{
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) {
el('div', 'title-days-lbl', txt, 'THE CURING SHELF \u2014 time is the ingredient');
const shelf = el('div', 'title-drills', txt);
for (const c of save.cures.slice()) {
const waited = Math.max(0, (save.daysServed ?? 0) - c.startedDay);
const ready = waited >= CURE_DAYS;
const b = el('button', `btn ghost drill-btn jar-row${ready ? ' ready' : ''}`, shelf);
el('span', 'drill-name', b, `${c.kind.toUpperCase()} \u2014 packed ${waited === 0 ? 'today' : `${waited} service${waited > 1 ? 's' : ''} ago`}`);
el('span', 'drill-best', b, ready ? `day ${waited} of ${CURE_DAYS} \u00b7 OPEN IT` : `day ${waited} of ${CURE_DAYS}`);
b.addEventListener('click', () => {
const r = onOpenCure?.(c.id);
if (!r) return;
// Opening is irreversible, so the answer replaces the row in place.
b.textContent = '';
el('span', 'drill-name', b, r.note);
b.classList.add('opened');
b.disabled = true;
});
}
}
// THE MOTHER: the sourdough starter. A pet that lives in the save and
// holds a grudge. Feeding is one click; forgetting is a funeral.
if (save.starter) {
const st = save.starter;
const jar = el('button', 'btn ghost jar-btn', txt, '');
const jarImg = el('img', 'jar-img', jar) as HTMLImageElement;
jarImg.alt = '';
const jarTxt = el('span', undefined, jar, '');
const label = () => {
const gap0 = save.maxDay - st.fed;
jarImg.src = assetUrl(`/assets/img/jar_${gap0 >= 5 ? 'dead' : gap0 >= 3 ? 'sulking' : 'bubbling'}.png`);
const gap = gap0;
const name = st.deaths > 0 ? `THE MOTHER (${['second', 'third', 'fourth', 'umpteenth'][Math.min(st.deaths - 1, 3)]} of her name)` : 'THE MOTHER';
if (gap >= 5) return `\u{1FAD9} ${name} has gone still. begin again \u2014 she forgives, mostly`;
if (gap === 0) return `\u{1FAD9} ${name} \u2014 fed. bubbling like gossip`;
if (gap === 1) return `\u{1FAD9} ${name} \u2014 peckish. FEED HER`;
if (gap === 4) return `\u{1FAD9} ${name} \u2014 one day from the end. FEED HER NOW`;
return `\u{1FAD9} ${name} \u2014 hungry (${gap} days). the crumb suffers`;
};
jarTxt.textContent = label();
jar.addEventListener('click', () => {
const gap = save.maxDay - st.fed;
if (gap >= 5) { st.deaths++; st.born = save.maxDay; }
st.fed = save.maxDay;
onFeed?.();
jarTxt.textContent = label();
jar.classList.add('fed');
window.setTimeout(() => jar.classList.remove('fed'), 700);
});
}
// The day ledger: one chip per reached day, best grade underneath. Click to
// replay it (progress never regresses — maxDay remembers how far you got).
if (save.maxDay > 1) {
el('div', 'title-days-lbl', txt, `THE LEDGER — ${DAYS.length} on the card, then the kitchen deals`);
const grid = el('div', 'title-days', txt);
for (let d = 1; d <= save.maxDay; d++) {
const chip = el('button', 'day-chip', grid);
el('span', 'day-num', chip, String(d));
const bestScore = save.best[`day${d}`];
const grade = bestScore !== undefined ? gradeOf(bestScore) : null;
const star = save.shadowed?.[`day${d}`] ? '★' : '';
const g = el('span', `day-grade${grade ? ` grade-${grade}` : ''}`, chip, (grade ?? '·') + star);
void g;
chip.title = bestScore !== undefined ? `day ${d} — best ${bestScore.toFixed(1)}/10` : `day ${d} — not judged yet`;
chip.addEventListener('click', () => go(d));
}
}
const keys = el('div', 'title-keys', txt);
el('div', undefined, keys, 'SPACE — lever down, then pop');
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');
el('div', undefined, keys, 'R — the radio (he pretends not to like it)');
}
}