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; 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); 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 }).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)'); } }