/**
 * PET CAFE — Pet Cafe Tycoon Game
 * =====================================
 * A browser-based React game where players manage a pet cafe with breeding,
 * shows, staff, menus, and economic simulation.
 *
 * ARCHITECTURE:
 * - Single-file React app using CDN React 18 + Babel standalone (no npm/build step)
 * - Game logic modules (pure functions): RNG, Genetics, Pers, Staff, Cafe, Trend, Show, MenuGen, Time, Save, Econ
 * - State management: useReducer with action types centralized in the A constant
 * - Rendering: SVG pet sprites (Cat, Dog, Bird, Lizard) + React UI panels
 * - Tests: tests/game-logic-extractor.cjs strips JSX and evals pure logic for Node.js testing
 *
 * ═══════════════════════════════════════════════════════════════════════════
 * TABLE OF CONTENTS
 * ═══════════════════════════════════════════════════════════════════════════
 * TOAST SYSTEM ............................. ~36    Minimal toast notification
 * STYLES .................................. ~70    Injected CSS (single IIFE)
 * SOUND SYSTEM ............................. ~336   SFX + Ambient audio
 * HELPERS ................................. ~580   w(), capitalize()
 * SPECIES CONFIG .......................... ~615   Species metadata + names
 * TRAIT TABLES ............................ ~650   Genetic trait pools per species
 * COLOR MAPS .............................. ~670   Phenotype → SVG colors
 * GAME CONSTANTS .......................... ~715   GAME_CONFIG tuning params + catalog
 * MILESTONES .............................. ~780   Achievement definitions
 * UTILS ................................... ~1085  RNG class, math helpers
 * GENETICS ................................ ~1100  Breeding, mutation, rarity
 * PERSONALITY ............................. ~1190  Pers.tipsMult, showBonus
 * LETTERS ................................. ~1205  Mayor/NPC correspondence
 * NPCS .................................... ~1780  Recurring NPC registry
 * REGULAR CUSTOMERS ....................... ~1795  REGULARS_POOL + beat templates
 * MENU GENERATION ......................... ~1920  Menu item factory
 * STAFF ................................... ~1950  Staff.gen, wages, efficiency
 * CAFE SIM ................................ ~1980  Cafe.runWeek (Phase A/B/C)
 * TREND ................................... ~2600  Trend generation + scoring
 * SHOW .................................... ~2660  Show.gen + judging
 * TIME .................................... ~2735  Week/month/year helpers
 * SAVE .................................... ~2750  Save + Save.migrate
 * ECONOMY ................................. ~2870  Econ.salePrice, finalScore
 * COSMETICS / PET HELPERS ................. ~2900  generateCosmetics, makePet
 * STATE_DEFAULTS .......................... ~3075  Single source of truth for state shape
 * INITIAL STATE ........................... ~3125  makeState
 * BREEDING/ANCESTRY/RELATIONSHIPS ......... ~3165  getAncestors, processRelationships
 * ADVANCE_WEEK HELPERS .................... ~3220  agePets, processRetirements,
 *                                                  processPetWishes, processRegularCustomers,
 *                                                  scheduleNpcVisit, processTrendRumor, etc.
 * PET STATUS HELPERS ...................... ~3490  isPetBusy, petBusyStatus
 * ACTION TYPES (A) ........................ ~3520  All reducer action strings
 * REDUCER ................................. ~3560  Main state machine
 * ─── SVG: CAT ──────────────────────────── ~4310  (test extractor split point)
 * SVG RENDERERS ........................... ~4310  Cat/Dog/Bird/Lizard sprites
 * UI HELPERS .............................. ~4995  TraitSummary, Modal, Tutorial, EmptyState
 * LETTER MODAL / NPC VISIT MODAL .......... ~5080  Narrative overlays
 * PET TILE / PET ROW ...................... ~5185  Reusable pet display components
 * PANELS .................................. ~5380  Pets/Breeding/Menu/Staff/Show/Upgrade/Summary
 * SUMMARY PANEL SUB-COMPONENTS ............ ~6750  TrendRumorCard, RegularBeatsCard, NpcAffinityStrip
 * TITLE / TOP BAR / BOTTOM NAV ............ ~7170  Frame UI
 * MODALS .................................. ~7660  PetDetail, Farewell, Inspector, Celebrity
 * GAME SCREEN ............................. ~7930  GameScreen orchestrator
 * APP / ENTRY ............................. ~8095  Root render
 *
 * IMPORTANT FOR TESTING:
 * - Everything above the "SVG: CAT" marker is extractable pure game logic
 * - tests/game-logic-extractor.cjs finds that marker to split logic from UI
 * - New pure functions must be placed ABOVE that marker
 * - GAME_CONFIG centralizes all tuning constants (no magic numbers in logic)
 * - A (ACTION_TYPES) centralizes all reducer action strings — never use raw strings
 * - STATE_DEFAULTS is the single source of truth for state shape (new fields go there)
 * - ADVANCE_WEEK helper functions are individually testable pure functions
 */
const { useState, useReducer, useEffect, useRef, useMemo, useCallback, createContext, useContext } = React;
const memo = React.memo;

// ─── TOAST SYSTEM (mobile-friendly disabled button feedback) ─────────────────
const ToastCtx = createContext(()=>{});
function ToastProvider({children}) {
  const [toast,setToast]=useState(null); // {text, onClick, duration}
  const timerRef=useRef(null);
  const show=useCallback((textOrOpts,opts)=>{
    if(timerRef.current){clearTimeout(timerRef.current);timerRef.current=null;}
    const t = typeof textOrOpts==='string' ? {text:textOrOpts,...opts} : textOrOpts;
    setToast(t);
    const dur = t.duration||2000;
    timerRef.current=setTimeout(()=>setToast(null),dur);
  },[]);
  const dismiss=useCallback(()=>{
    if(timerRef.current){clearTimeout(timerRef.current);timerRef.current=null;}
    setToast(null);
  },[]);
  return React.createElement(ToastCtx.Provider,{value:show},
    children,
    toast && React.createElement('div',{
      className:'toast'+(toast.onClick?' toast-clickable':''),
      onClick: toast.onClick ? ()=>{toast.onClick();dismiss();} : undefined,
    },
      React.createElement('span',{className:'toast-text'},toast.text),
      React.createElement('button',{
        className:'toast-close',
        onClick:(e)=>{e.stopPropagation();dismiss();},
        'aria-label':'Dismiss'
      },'×')
    )
  );
}
const useToast=()=>useContext(ToastCtx);

// ─── STYLES ───────────────────────────────────────────────────────────────────
(function injectStyles() {
  const el = document.createElement('style');
  el.textContent = `
    @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Fraunces:opsz,wght@9..144,800&display=swap');
    :root{--bg:#f6ede2;--wall:#f0dfd0;--card:#fdf9f4;--orange:#c8703c;--od:#a85a2c;--green:#5a9048;--gold:#b8922e;--blue:#4a7eb8;--red:#c04040;--text:#2e2010;--muted:#5a4030;--border:#d4b898;--shadow:rgba(50,28,8,.12);--espresso:#4a2e16;--latte:#c8a880;--mocha:#7a5230;--cream:#faf5ec;--caramel:#d4944c;}
    body.dark{--bg:#1c1510;--wall:#231a12;--card:#2a1f14;--text:#e8d8c4;--muted:#8a7060;--border:#3d2d1e;--shadow:rgba(0,0,0,.4);--cream:#1a1208;--orange:#d4845a;--od:#b86840;--green:#6aaa58;--gold:#c8a040;--blue:#5a90c8;--red:#d05050;--latte:#8a6848;--mocha:#c89060;}
    @keyframes pop-in{0%{transform:scale(.92);opacity:0}100%{transform:scale(1);opacity:1}}
    @keyframes gentle-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
    @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
    @keyframes steam{0%,100%{opacity:.3;transform:translateY(0) scaleX(1)}50%{opacity:.6;transform:translateY(-3px) scaleX(1.1)}}
    @keyframes fw-burst{0%{transform:translate(0,0) scale(1);opacity:1}100%{transform:translate(var(--fx),var(--fy)) scale(0.2);opacity:0}}
    @keyframes fw-pop{0%{transform:scale(0);opacity:1}30%{transform:scale(1.4);opacity:1}100%{transform:scale(0.8);opacity:0}}
    .fireworks-overlay{position:fixed;inset:0;pointer-events:none;z-index:9999;}
    .fw-particle{position:absolute;border-radius:50%;animation:fw-burst var(--fd,1.2s) ease-out forwards;}
    *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
    html{color-scheme:light;}
    body{font-family:'Nunito',sans-serif;font-size:13px;line-height:1.4;background:var(--bg);background-image:url("data:image/svg+xml,%3Csvg width='60' height='60' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='30' cy='30' r='1.5' fill='%23d4b896' opacity='.18'/%3E%3C/svg%3E"),linear-gradient(170deg,#f3e8dc 0%,#ecddd0 50%,#f0e4d4 100%);color:var(--text);overflow:hidden;height:100dvh;user-select:none;color-scheme:light;forced-color-adjust:none;}
    body.dark{background-image:url("data:image/svg+xml,%3Csvg width='60' height='60' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='30' cy='30' r='1.5' fill='%23332010' opacity='.4'/%3E%3C/svg%3E"),linear-gradient(170deg,#1e1510 0%,#1a1208 50%,#1c1410 100%);color-scheme:dark;}
    #root{display:flex;flex-direction:column;height:100dvh;}
    .top-bar{display:flex;align-items:center;gap:6px;padding:6px 12px;min-height:54px;background:linear-gradient(180deg,var(--cream) 0%,var(--card) 100%);border-bottom:3px solid var(--latte);flex-shrink:0;box-shadow:0 2px 12px rgba(60,36,12,.06);}
    .money{font-weight:900;font-size:18px;color:var(--green);min-width:76px;text-shadow:0 1px 0 rgba(106,158,92,.12);}
    .date-lbl{flex:1;font-size:11px;color:var(--mocha);text-align:center;font-weight:700;letter-spacing:.2px;line-height:1.25;word-break:normal;overflow-wrap:anywhere;}
    .nw-btn{border:none;background:linear-gradient(135deg,var(--green) 0%,#7cb06c 100%);color:#fff;border-radius:14px;padding:7px 16px;font-family:'Nunito',sans-serif;font-size:13px;font-weight:800;cursor:pointer;touch-action:manipulation;white-space:nowrap;box-shadow:0 3px 0 #4c7e3e,0 4px 12px rgba(106,158,92,.2);transition:all .15s;}
    .nw-btn:hover{transform:translateY(-1px);box-shadow:0 4px 0 #4c7e3e,0 6px 16px rgba(106,158,92,.28);}
    .nw-btn:active{transform:translateY(2px);box-shadow:0 1px 0 #4c7e3e;}
    .nw-btn:disabled{opacity:.42;cursor:default;transform:none;box-shadow:0 3px 0 #4c7e3e;}
    .ham-btn{border:none;background:none;font-size:20px;cursor:pointer;padding:0 6px;touch-action:manipulation;color:var(--text);display:flex;align-items:center;line-height:1;transition:transform .15s;}
    .ham-btn:hover{transform:scale(1.15);}
    .content-area{display:flex;flex-direction:column;flex:1;overflow:hidden;}
    .main-view{flex:1;overflow:hidden;position:relative;}
    .panel{position:absolute;inset:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:10px;padding-bottom:80px;display:flex;flex-direction:column;gap:8px;}
    .panel-wrap{position:absolute;inset:0;will-change:transform,opacity;}
    .panel-slide-right{animation:panel-slide-right .22s ease-out;}
    .panel-slide-left{animation:panel-slide-left .22s ease-out;}
    @keyframes panel-slide-right{from{opacity:0;transform:translateX(22px);}to{opacity:1;transform:translateX(0);}}
    @keyframes panel-slide-left{from{opacity:0;transform:translateX(-22px);}to{opacity:1;transform:translateX(0);}}
    @keyframes screen-fade{from{opacity:0;}to{opacity:1;}}
    .screen-enter{animation:screen-fade .3s ease-out;}
    .panel>*{flex-shrink:0;}
    .event-ticker{height:30px;background:linear-gradient(90deg,var(--wall),var(--latte));border-top:1px solid var(--border);display:flex;align-items:center;padding:0 10px;font-size:12px;color:var(--mocha);overflow:hidden;flex-shrink:0;}
    .bottom-nav{display:flex;height:58px;background:linear-gradient(0deg,var(--cream) 0%,var(--card) 100%);border-top:3px solid var(--latte);flex-shrink:0;box-shadow:0 -2px 12px rgba(60,36,12,.05);}
    .nav-btn{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border:none;background:none;font-family:'Nunito',sans-serif;font-size:10px;font-weight:700;color:var(--mocha);cursor:pointer;touch-action:manipulation;transition:all .15s;border-radius:8px;margin:4px 2px;position:relative;}
    .nav-btn:hover{background:rgba(140,98,64,.06);}
    .nav-btn.on{color:var(--espresso);background:rgba(212,184,150,.30);font-weight:800;}
    .nav-btn.on .nav-icon{animation:gentle-bob 1.5s ease-in-out infinite;}
    .nav-icon{font-size:20px;line-height:1;transition:transform .15s;}
    .nav-btn:hover .nav-icon{transform:scale(1.15);}
    .card{background:var(--card);border-radius:16px;border:2px solid var(--border);box-shadow:0 3px 12px var(--shadow),0 1px 3px rgba(60,36,12,.05);padding:10px;transition:box-shadow .2s;}
    .card:hover{box-shadow:0 4px 16px var(--shadow),0 2px 6px rgba(60,36,12,.08);}
    .btn{border:none;border-radius:12px;padding:8px 16px;font-family:'Nunito',sans-serif;font-weight:800;font-size:13px;cursor:pointer;touch-action:manipulation;transition:all .12s;letter-spacing:.2px;}
    .btn:active:not(:disabled){transform:translateY(2px);}
    .btn:disabled{opacity:.42;cursor:default;}
    .btn-p{background:linear-gradient(135deg,var(--caramel) 0%,var(--orange) 100%);color:#fff;box-shadow:0 3px 0 var(--od),0 4px 10px rgba(200,112,60,.18);}
    .btn-p:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 0 var(--od),0 6px 14px rgba(200,112,60,.25);}
    .btn-p:active:not(:disabled){box-shadow:0 1px 0 var(--od);}
    .btn-g{background:linear-gradient(135deg,var(--green) 0%,#7cb06c 100%);color:#fff;box-shadow:0 3px 0 #4c7e3e,0 4px 10px rgba(106,158,92,.18);}
    .btn-g:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 0 #4c7e3e,0 6px 14px rgba(106,158,92,.25);}
    .btn-gold{background:linear-gradient(135deg,var(--gold) 0%,#d4b450 100%);color:#fff;box-shadow:0 3px 0 #9a8428,0 4px 10px rgba(196,162,64,.18);}
    .btn-gold:hover:not(:disabled){transform:translateY(-1px);}
    .btn-r{background:linear-gradient(135deg,var(--red) 0%,#d06858 100%);color:#fff;box-shadow:0 3px 0 #a04434,0 4px 10px rgba(196,88,72,.18);}
    .btn-r:hover:not(:disabled){transform:translateY(-1px);}
    .btn-o{background:transparent;color:var(--mocha);border:2px solid var(--latte);box-shadow:none;transition:all .15s;}
    .btn-o:hover:not(:disabled){background:var(--espresso);color:var(--cream);border-color:var(--espresso);}
    .btn-sm{padding:5px 10px;font-size:12px;border-radius:10px;}
    .btn-xs{padding:3px 8px;font-size:12px;border-radius:8px;}
    h2.sh{font-family:'Fraunces',serif;font-size:18px;font-weight:800;color:var(--espresso);text-shadow:0 1px 0 rgba(92,58,30,.08);}
    h3.sub{font-size:13px;font-weight:800;color:var(--mocha);margin-bottom:4px;}
    .flex{display:flex;align-items:center;gap:8px;}
    .between{display:flex;align-items:center;justify-content:space-between;}
    .muted{color:var(--muted);font-size:12px;}
    :focus-visible{outline:2px solid var(--orange);outline-offset:2px;border-radius:4px;}
    @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;transition-duration:0.01ms!important;}}
    .tg{color:var(--green);font-weight:700;}
    .tr{color:var(--red);font-weight:700;}
    .divider{border:none;border-top:2px dashed var(--border);margin:6px 0;}
    .badge{border-radius:8px;padding:2px 8px;font-size:12px;font-weight:800;letter-spacing:.3px;}
    .bg{background:linear-gradient(135deg,#e0f5e3,#d0ecd4);color:#2a7030;}
    .bo{background:linear-gradient(135deg,#fde8df,#fad8cc);color:#b84010;}
    .bu{background:linear-gradient(135deg,#dceefb,#d0e4f8);color:#1860a8;}
    .bk{background:linear-gradient(135deg,#f0ede8,#e8e2d8);color:#6b5d50;}
    .by{background:linear-gradient(135deg,#fff3cd,#ffedb0);color:#7a6114;}
    .rbar{height:6px;border-radius:3px;background:var(--border);overflow:hidden;margin-top:3px;}
    .rfill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--latte),var(--caramel),var(--mocha));background-size:200% 100%;animation:shimmer 3s linear infinite;}
    .stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;}
    .stat-box{background:linear-gradient(135deg,var(--cream),var(--wall));border-radius:12px;padding:8px;text-align:center;border:1px solid var(--border);}
    .sv{font-size:17px;font-weight:900;}
    .sl{font-size:10px;color:var(--muted);font-weight:600;}
    .coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:9px 12px;cursor:pointer;background:linear-gradient(135deg,var(--cream),var(--wall));border:2px solid var(--border);border-radius:12px;font-weight:800;font-size:13px;transition:all .15s;}
    .coll-hdr:hover{background:linear-gradient(135deg,var(--wall),var(--latte));border-color:var(--latte);}
    .coll-hdr.open{border-radius:12px 12px 0 0;}
    .coll-body{border:2px solid var(--border);border-top:none;border-radius:0 0 12px 12px;padding:8px;background:rgba(255,252,248,.5);}
    .warn{background:linear-gradient(135deg,#fff8e0,#fff2c8);border:2px solid #e8d880;border-radius:10px;padding:6px 10px;font-size:11px;color:#7a6114;font-weight:700;}
    .empty{text-align:center;color:var(--muted);font-size:13px;padding:20px 0;font-style:italic;}
    .empty-state{text-align:center;padding:28px 16px;color:var(--muted);display:flex;flex-direction:column;align-items:center;gap:6px;}
    .empty-state-icon{font-size:44px;opacity:.5;line-height:1;}
    .empty-state-title{font-weight:800;font-size:14px;color:var(--text);}
    .empty-state-hint{font-size:12px;line-height:1.5;max-width:280px;}
    .title-screen{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;gap:20px;background:linear-gradient(170deg,#f5e6d4 0%,#ecdac4 40%,#e4d0bc 100%);background-image:url("data:image/svg+xml,%3Csvg width='80' height='80' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='40' cy='40' r='2' fill='%23d4b896' opacity='.15'/%3E%3Ccircle cx='15' cy='60' r='1' fill='%23c4a280' opacity='.10'/%3E%3C/svg%3E"),linear-gradient(170deg,#f5e6d4 0%,#ecdac4 40%,#e4d0bc 100%);padding:24px;overflow:hidden;width:100%;}
    .title-logo{font-size:64px;position:relative;}
    .steam{position:absolute;top:-50px;left:50%;transform:translateX(-50%);pointer-events:none;width:44px;height:80px;}
    .steam i{position:absolute;bottom:0;display:block;width:13px;height:24px;border-radius:50%;background:rgba(200,185,170,0.55);-webkit-filter:blur(4px);filter:blur(4px);-webkit-backface-visibility:hidden;will-change:transform,opacity;animation:steam-rise 2.6s ease-out infinite;opacity:0;}
    .steam i:nth-child(1){left:2px;animation-delay:0s;}
    .steam i:nth-child(2){left:16px;animation-delay:0.85s;}
    .steam i:nth-child(3){left:30px;animation-delay:1.7s;}
    @keyframes steam-rise{0%{opacity:0;transform:translateY(0) scaleX(1) scaleY(1);}10%{opacity:0.85;}40%{opacity:0.6;transform:translateY(-28px) scaleX(1.5) scaleY(1.2);}70%{opacity:0.3;transform:translateY(-54px) scaleX(2) scaleY(1.4);}100%{opacity:0;transform:translateY(-78px) scaleX(2.4) scaleY(1.6);}}
    .paw-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;}
    .title-screen>*:not(.paw-canvas):not(.title-bg-layer){position:relative;z-index:2;}
    .title-bg-layer{position:absolute;inset:-10%;pointer-events:none;z-index:0;will-change:transform;}
    .title-bg-layer-1{background:radial-gradient(ellipse at 30% 40%,rgba(244,164,96,.25) 0%,rgba(244,164,96,0) 55%),radial-gradient(ellipse at 70% 70%,rgba(210,140,80,.18) 0%,rgba(210,140,80,0) 60%);animation:title-drift-a 48s ease-in-out infinite;}
    .title-bg-layer-2{background-image:url("data:image/svg+xml,%3Csvg width='240' height='240' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23b8855a' opacity='.09'%3E%3Cg transform='translate(30,60)'%3E%3Cellipse cx='0' cy='12' rx='9' ry='7'/%3E%3Cellipse cx='-12' cy='-2' rx='4' ry='5'/%3E%3Cellipse cx='-5' cy='-9' rx='4' ry='5'/%3E%3Cellipse cx='5' cy='-9' rx='4' ry='5'/%3E%3Cellipse cx='12' cy='-2' rx='4' ry='5'/%3E%3C/g%3E%3Cg transform='translate(160,170) rotate(18)'%3E%3Cellipse cx='0' cy='10' rx='7' ry='5.5'/%3E%3Cellipse cx='-10' cy='-1' rx='3.2' ry='4'/%3E%3Cellipse cx='-4' cy='-7' rx='3.2' ry='4'/%3E%3Cellipse cx='4' cy='-7' rx='3.2' ry='4'/%3E%3Cellipse cx='10' cy='-1' rx='3.2' ry='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");background-size:260px 260px;animation:title-drift-b 72s linear infinite;}
    body.dark .title-bg-layer-1{background:radial-gradient(ellipse at 30% 40%,rgba(244,164,96,.12) 0%,rgba(244,164,96,0) 55%),radial-gradient(ellipse at 70% 70%,rgba(140,90,50,.18) 0%,rgba(140,90,50,0) 60%);}
    @keyframes title-drift-a{0%{transform:translate(-3%,-2%) scale(1);}50%{transform:translate(3%,2%) scale(1.04);}100%{transform:translate(-3%,-2%) scale(1);}}
    @keyframes title-drift-b{0%{transform:translate(0,0);}100%{transform:translate(-220px,-220px);}}
    .title-name{font-family:'Fraunces',serif;font-size:36px;font-weight:800;color:var(--espresso);text-align:center;text-shadow:0 2px 0 rgba(92,58,30,.10);}
    .title-sub{color:var(--mocha);font-size:14px;text-align:center;font-weight:600;}
    .score-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:2px dashed var(--border);font-size:13px;font-weight:600;}
    .score-total{font-size:26px;font-weight:900;color:var(--espresso);text-align:center;padding:12px 0;text-shadow:0 2px 0 rgba(92,58,30,.08);}
    .skill-bar{height:6px;border-radius:3px;background:var(--border);overflow:hidden;margin-top:2px;}
    .skill-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--latte),var(--caramel));}
    input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:6px;background:var(--border);border-radius:3px;outline:none;}
    input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;border-radius:50%;background:var(--orange);cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.2);}
    input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--orange);cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.2);}
    .cat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;}
    .cat-tile{background:var(--card);border-radius:14px;border:2px solid var(--border);padding:8px;display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;transition:all .2s;}
    .cat-tile:hover{border-color:var(--orange);transform:translateY(-2px);box-shadow:0 4px 12px rgba(224,120,64,.15);}
    .cat-tile.sel{border-color:var(--orange);background:rgba(224,120,64,.05);}
    .cat-tile .nm{font-weight:800;font-size:13px;text-align:center;}
    .cat-tile .tr{font-size:10px;color:var(--muted);text-align:center;}
    .menu-dropdown{position:fixed;top:54px;left:0;background:var(--card);border:2px solid var(--border);border-radius:0 0 16px 16px;box-shadow:0 6px 24px var(--shadow);padding:8px 0;min-width:180px;z-index:101;animation:pop-in .15s ease-out;}
    .menu-item{padding:10px 16px;font-size:14px;font-weight:700;cursor:pointer;display:flex;align-items:center;gap:8px;border-radius:8px;margin:0 4px;transition:all .1s;}
    .menu-item:hover{background:linear-gradient(135deg,var(--wall),#f0e4d4);transform:translateX(4px);}
    .modal-overlay{position:fixed;inset:0;background:rgba(30,20,10,.45);backdrop-filter:blur(4px);z-index:200;display:flex;align-items:center;justify-content:center;}
    .modal{background:linear-gradient(170deg,var(--cream),var(--card));border-radius:24px;padding:28px;max-width:320px;width:90%;text-align:center;box-shadow:0 12px 48px rgba(60,36,12,.18);animation:pop-in .2s ease-out;border:2px solid var(--latte);position:relative;}
    .toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:var(--espresso);color:#fff;padding:10px 14px 10px 16px;border-radius:12px;font-size:12px;font-weight:700;z-index:300;animation:toast-in .2s ease-out;box-shadow:0 4px 16px rgba(0,0,0,.35);pointer-events:auto;max-width:calc(100vw - 32px);display:flex;align-items:center;gap:10px;}
    .toast-text{flex:1;min-width:0;white-space:normal;line-height:1.4;}
    .toast-clickable{cursor:pointer;border:1px solid rgba(255,255,255,.15);}
    .toast-clickable:hover{background:var(--mocha);}
    .toast-clickable:active{transform:translateX(-50%) scale(.98);}
    .toast-close{background:rgba(255,255,255,.18);border:none;color:#fff;width:26px;height:26px;border-radius:50%;font-size:18px;line-height:1;cursor:pointer;flex-shrink:0;padding:0;display:flex;align-items:center;justify-content:center;touch-action:manipulation;}
    .toast-close:hover{background:rgba(255,255,255,.32);}
    @keyframes toast-in{from{opacity:0;transform:translateX(-50%) translateY(-8px);}to{opacity:1;transform:translateX(-50%) translateY(0);}}
    .species-btn{border:2px solid var(--border);background:var(--card);border-radius:16px;padding:14px 22px;font-family:'Nunito',sans-serif;font-weight:800;font-size:15px;cursor:pointer;touch-action:manipulation;transition:all .15s;display:flex;align-items:center;gap:8px;box-shadow:0 2px 8px rgba(80,50,20,.06);}
    .species-btn:hover{border-color:var(--caramel);transform:translateY(-2px);box-shadow:0 4px 12px rgba(212,148,76,.15);}
    .species-btn.sel{border-color:var(--espresso);background:linear-gradient(135deg,var(--mocha),var(--espresso));color:var(--cream);box-shadow:0 3px 0 #3a2410;}
    .rd-slider{display:flex;align-items:center;gap:10px;padding:8px;background:var(--wall);border-radius:8px;}
    .pet-section{margin-bottom:6px;}
    .pet-section-hdr{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;cursor:pointer;background:var(--wall);border:1px solid var(--border);border-radius:8px;font-weight:700;font-size:13px;}
    .pet-section-hdr.open{border-radius:8px 8px 0 0;}
    .pet-section-body{border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;overflow:hidden;}
    .pet-row{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--card);border-bottom:1px solid var(--border);}
    .pet-row:last-child{border-bottom:none;}
    .pet-row-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px;}
    .pet-row-name{font-weight:800;font-size:13px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
    .pet-row-meta{font-size:11px;color:var(--muted);overflow-wrap:break-word;word-break:break-word;}
    .pet-row-actions{display:flex;gap:4px;flex-shrink:0;}
    .pet-row-status{font-size:11px;font-weight:700;color:var(--muted);overflow-wrap:break-word;}
    .tutorial-tip{position:fixed;z-index:150;background:linear-gradient(135deg,#fff8e8,#fff3d0);border:2px solid var(--gold);border-radius:14px;padding:12px 14px;max-width:300px;box-shadow:0 6px 24px rgba(184,146,46,.2);animation:pop-in .3s ease-out;}
    .tutorial-tip .tt-text{font-size:13px;font-weight:600;color:var(--text);line-height:1.5;}
    .tutorial-tip .tt-text strong{color:var(--orange);font-weight:800;}
    .tutorial-tip .tt-skip{font-size:11px;color:var(--muted);cursor:pointer;text-decoration:underline;margin-top:6px;display:inline-block;}
    /* Tablet: slightly larger controls */
    @media(min-width:600px) and (max-width:899px){
      .modal{max-width:400px;}
      .toast{max-width:440px;}
    }
    @media(min-width:768px) and (max-width:899px){
      body{font-size:14px;}
      .cat-grid{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;}
      .btn{padding:10px 20px;font-size:14px;}
      .btn-sm{padding:6px 12px;font-size:13px;}
      .btn-xs{padding:4px 10px;font-size:12px;}
      .panel{padding:14px;gap:10px;}
      .card{padding:14px;}
      .stat-box{padding:12px;}
      .sv{font-size:20px;}
      .sl{font-size:11px;}
      h2.sh{font-size:20px;}
      .top-bar{min-height:58px;padding:6px 16px;}
      .money{font-size:20px;}
      .date-lbl{font-size:13px;}
      .bottom-nav{height:64px;}
      .nav-btn{font-size:11px;}
      .nav-icon{font-size:22px;}
      .pet-row{padding:8px 12px;gap:10px;}
      .pet-row-name{font-size:14px;}
      .pet-row-meta{font-size:12px;}
      .species-btn{padding:16px 28px;font-size:16px;}
    }
    /* Desktop: side navigation layout */
    @media(min-width:900px){
      body{font-size:14px;}
      .top-bar{min-height:58px;padding:6px 20px;}
      .money{font-size:20px;}
      .date-lbl{font-size:14px;}
      .content-area{flex-direction:row!important;}
      .bottom-nav{
        flex-direction:column;width:110px;height:auto;
        border-top:none;border-right:3px solid var(--latte);
        order:-1;box-shadow:2px 0 12px rgba(60,36,12,.05);
        justify-content:flex-start;padding-top:8px;
      }
      .nav-btn{flex:none;width:100%;height:72px;flex-direction:column;border-radius:10px;margin:2px 6px;width:calc(100% - 12px);}
      .nav-icon{font-size:22px;}
      .nav-btn{font-size:11px;}
      .panel{padding:16px;padding-bottom:16px;gap:10px;}
      .card{padding:14px;}
      .stat-box{padding:12px;}
      .sv{font-size:20px;}
      h2.sh{font-size:20px;}
      .cat-grid{grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
      .btn{padding:10px 20px;font-size:14px;}
      .btn-sm{padding:6px 12px;font-size:13px;}
      .btn-xs{padding:4px 10px;font-size:12px;}
      .pet-row{padding:8px 12px;gap:10px;}
      .pet-row-name{font-size:14px;}
      .pet-row-meta{font-size:12px;}
      .species-btn{padding:16px 28px;font-size:16px;}
      .modal{max-width:460px;}
      .toast{max-width:500px;}
    }
    /* Landscape phone fix */
    @media(max-height:480px) and (orientation:landscape){
      .top-bar{min-height:42px;padding:4px 12px;}
      .bottom-nav{height:48px;}
      .panel{padding-bottom:60px;}
      .nav-icon{font-size:18px;}
      .nav-btn{font-size:9px;}
    }
    /* ── Dark mode overrides ─────────────────────────────────────────────── */
    body.dark h2.sh{color:var(--text);}
    body.dark h3.sub{color:var(--mocha);}
    body.dark .tutorial-tip{background:linear-gradient(135deg,#2a1f14,#231a12);border-color:var(--gold);}
    body.dark .tutorial-tip .tt-text{color:var(--text);}
    body.dark .tutorial-tip .tt-skip{color:var(--muted);}
    body.dark .modal.letter-modal{background:#2a1f14!important;border-color:var(--gold)!important;}
    body.dark .letter-modal-inner .letter-title{color:var(--orange)!important;}
    body.dark .letter-modal-inner .letter-from{color:var(--muted)!important;}
    body.dark .letter-modal-inner .letter-divider{border-top-color:var(--border)!important;}
    body.dark .letter-modal-inner .letter-body p{color:var(--text)!important;}
    body.dark .menu-market-card{background:var(--card)!important;border-color:var(--blue)!important;}
    body.dark .trend-news-card{background:linear-gradient(90deg,rgba(90,144,200,.15),rgba(60,120,180,.08))!important;border-color:var(--blue)!important;}
    body.dark .pet-select-screen{background:linear-gradient(160deg,var(--wall),var(--cream))!important;}
    body.dark .bk{background:#3a2e24!important;color:#c8b09a!important;}
    body.dark .bg{background:#1e3020!important;color:#7ad07a!important;}
    body.dark .bu{background:#1a2840!important;color:#7ab0e8!important;}
    body.dark .by{background:#2a2408!important;color:#d4b040!important;}
    body.dark .bo{background:#2e1c0a!important;color:#d4844a!important;}
    body.dark .coll-body{background:rgba(30,20,10,.5)!important;}
    body.dark .coll-hdr{background:linear-gradient(135deg,var(--card),var(--wall))!important;}
    body.dark .coll-hdr:hover{background:linear-gradient(135deg,var(--wall),var(--card))!important;border-color:var(--latte)!important;}
    body.dark .title-name{color:#e8d0b0;text-shadow:0 2px 8px rgba(0,0,0,.5);}
    body.dark .nav-btn.on{color:#e8d0b0;background:rgba(200,160,96,.15);}
    body.dark .title-screen{background-image:url("data:image/svg+xml,%3Csvg width='80' height='80' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='40' cy='40' r='2' fill='%23332010' opacity='.35'/%3E%3Ccircle cx='15' cy='60' r='1' fill='%23281808' opacity='.25'/%3E%3C/svg%3E"),linear-gradient(170deg,#1e1510 0%,#1a1208 40%,#1c1410 100%)!important;}
    body.dark .warn{background:linear-gradient(135deg,#2a2008,#221a06)!important;border-color:#5a4a10!important;color:#c8b050!important;}
  `;
  document.head.appendChild(el);
  // Apply dark mode immediately before first render to avoid flash
  (function(){
    var v=localStorage.getItem('darkMode');
    if(v==='true')v='dark'; else if(v==='false')v='light'; else if(v!=='dark'&&v!=='light')v='system';
    var dark = v==='dark' || (v==='system' && window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches);
    if(dark) document.body.classList.add('dark');
  })();
})();

// ─── SOUND SYSTEM (Web Audio API — procedural, no external files) ────────────
const SFX = (() => {
  let ctx = null;
  let muted = false;
  let volume = 0.3;
  const getCtx = () => {
    if (!ctx) try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
    if (ctx && ctx.state === 'suspended') ctx.resume();
    return ctx;
  };
  const play = (freq, type, dur, vol=1, ramp=0) => {
    if (muted) return;
    const c = getCtx(); if (!c) return;
    const osc = c.createOscillator();
    const gain = c.createGain();
    osc.type = type;
    osc.frequency.setValueAtTime(freq, c.currentTime);
    if (ramp) osc.frequency.linearRampToValueAtTime(ramp, c.currentTime + dur);
    gain.gain.setValueAtTime(volume * vol, c.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
    osc.connect(gain);
    gain.connect(c.destination);
    osc.start(c.currentTime);
    osc.stop(c.currentTime + dur);
  };

  // ── AMBIENT LAYER ──────────────────────────────────────────────────────────
  // Persistent background sound: cafe chatter + seasonal weather.
  let ambientOn = false;
  let currentSeason = null;
  let chatterNode = null;     // BufferSource for crowd
  let chatterGain = null;
  let weatherNode = null;     // BufferSource for rain/wind OR bird interval
  let weatherGain = null;
  let weatherInterval = null; // for Spring birds (scheduled chirps)
  let ambientBuffers = {};    // cached noise buffers

  const makeNoiseBuffer = (c, seconds, pinkish=true) => {
    const len = c.sampleRate * seconds;
    const buf = c.createBuffer(1, len, c.sampleRate);
    const data = buf.getChannelData(0);
    if (pinkish) {
      // simple pink noise approximation
      let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0;
      for (let i=0;i<len;i++) {
        const white = Math.random()*2-1;
        b0 = 0.99886*b0 + white*0.0555179;
        b1 = 0.99332*b1 + white*0.0750759;
        b2 = 0.96900*b2 + white*0.1538520;
        b3 = 0.86650*b3 + white*0.3104856;
        b4 = 0.55000*b4 + white*0.5329522;
        b5 = -0.7616*b5 - white*0.0168980;
        data[i] = (b0+b1+b2+b3+b4+b5+b6+white*0.5362)*0.11;
        b6 = white*0.115926;
      }
    } else {
      // brown noise (for rain/wind)
      let last=0;
      for (let i=0;i<len;i++) {
        const white = Math.random()*2-1;
        last = (last + 0.02*white) / 1.02;
        data[i] = last*3.5;
      }
    }
    return buf;
  };

  const getBuf = (key) => {
    const c = getCtx(); if (!c) return null;
    if (ambientBuffers[key]) return ambientBuffers[key];
    if (key==='pink') ambientBuffers[key] = makeNoiseBuffer(c, 4, true);
    else if (key==='brown') ambientBuffers[key] = makeNoiseBuffer(c, 6, false);
    return ambientBuffers[key];
  };

  const stopWeatherLayer = () => {
    if (weatherInterval) { clearInterval(weatherInterval); weatherInterval = null; }
    if (weatherNode) { try { weatherNode.stop(); } catch(e){} weatherNode = null; }
    if (weatherGain) { try { weatherGain.disconnect(); } catch(e){} weatherGain = null; }
  };

  const buildWeatherLayer = (season) => {
    const c = getCtx(); if (!c) return;
    stopWeatherLayer();
    weatherGain = c.createGain();
    weatherGain.gain.value = 0;
    weatherGain.connect(c.destination);
    const target = ambientOn ? volume * 0.18 : 0;
    weatherGain.gain.linearRampToValueAtTime(target, c.currentTime + 1.5);

    if (season === 'Autumn' || season === 'Winter') {
      // Brown noise filtered = rain / wind
      const src = c.createBufferSource();
      src.buffer = getBuf('brown');
      src.loop = true;
      const filt = c.createBiquadFilter();
      filt.type = 'lowpass';
      filt.frequency.value = season==='Winter' ? 500 : 1200;
      filt.Q.value = 0.7;
      src.connect(filt); filt.connect(weatherGain);
      src.start();
      weatherNode = src;
    } else if (season === 'Summer') {
      // Very quiet high-frequency hiss = cicadas
      const src = c.createBufferSource();
      src.buffer = getBuf('pink');
      src.loop = true;
      const filt = c.createBiquadFilter();
      filt.type = 'highpass';
      filt.frequency.value = 3000;
      const sub = c.createGain();
      sub.gain.value = 0.4;
      src.connect(filt); filt.connect(sub); sub.connect(weatherGain);
      src.start();
      weatherNode = src;
    } else {
      // Spring: scheduled chirps
      weatherInterval = setInterval(() => {
        if (!ambientOn || muted) return;
        const c2 = getCtx(); if (!c2) return;
        const t = c2.currentTime;
        const base = 2000 + Math.random()*2000;
        const osc = c2.createOscillator();
        const g = c2.createGain();
        osc.type = 'triangle';
        osc.frequency.setValueAtTime(base, t);
        osc.frequency.linearRampToValueAtTime(base*1.25, t+0.08);
        osc.frequency.linearRampToValueAtTime(base*0.9, t+0.18);
        g.gain.setValueAtTime(0, t);
        g.gain.linearRampToValueAtTime(volume*0.08, t+0.02);
        g.gain.exponentialRampToValueAtTime(0.001, t+0.25);
        osc.connect(g); g.connect(c2.destination);
        osc.start(t); osc.stop(t+0.3);
      }, 1800 + Math.random()*2500);
    }
  };

  const startChatter = () => {
    const c = getCtx(); if (!c) return;
    if (chatterNode) return;
    const src = c.createBufferSource();
    src.buffer = getBuf('pink');
    src.loop = true;
    const filt = c.createBiquadFilter();
    filt.type = 'bandpass';
    filt.frequency.value = 600;
    filt.Q.value = 0.8;
    chatterGain = c.createGain();
    chatterGain.gain.value = 0;
    src.connect(filt); filt.connect(chatterGain); chatterGain.connect(c.destination);
    src.start();
    chatterNode = src;
    const target = ambientOn ? volume * 0.12 : 0;
    chatterGain.gain.linearRampToValueAtTime(target, c.currentTime + 1.5);
  };

  const stopChatter = () => {
    if (!chatterNode) return;
    const c = getCtx(); if (!c) return;
    try { chatterGain.gain.linearRampToValueAtTime(0, c.currentTime + 0.8); } catch(e){}
    const node = chatterNode, gain = chatterGain;
    setTimeout(() => {
      try { node.stop(); } catch(e){}
      try { gain.disconnect(); } catch(e){}
    }, 900);
    chatterNode = null; chatterGain = null;
  };

  const ambientRefresh = () => {
    if (ambientOn && !muted) {
      startChatter();
      if (currentSeason) buildWeatherLayer(currentSeason);
    } else {
      stopChatter();
      stopWeatherLayer();
    }
  };

  return {
    click: () => play(800, 'sine', 0.06, 0.2),
    weekAdvance: () => { play(523, 'sine', 0.12, 0.4); setTimeout(() => play(659, 'sine', 0.12, 0.4), 80); setTimeout(() => play(784, 'sine', 0.18, 0.4), 160); },
    milestone: () => { play(523, 'triangle', 0.15, 0.5); setTimeout(() => play(659, 'triangle', 0.15, 0.5), 120); setTimeout(() => play(784, 'triangle', 0.15, 0.5), 240); setTimeout(() => play(1047, 'triangle', 0.3, 0.5), 360); },
    petBorn: () => { play(880, 'sine', 0.1, 0.3); setTimeout(() => play(1100, 'sine', 0.15, 0.3), 100); setTimeout(() => play(1320, 'sine', 0.2, 0.3), 200); },
    showWin: () => { [523,659,784,1047].forEach((f,i) => setTimeout(() => play(f, 'triangle', 0.2, 0.5), i * 100)); },
    sell: () => { play(440, 'square', 0.08, 0.15); setTimeout(() => play(554, 'square', 0.08, 0.15), 60); setTimeout(() => play(659, 'square', 0.1, 0.15), 120); },
    error: () => play(220, 'sawtooth', 0.15, 0.15),
    toggle: () => play(600, 'sine', 0.05, 0.15),
    setMuted: (m) => { muted = m; ambientRefresh(); },
    isMuted: () => muted,
    setVolume: (v) => { volume = Math.max(0, Math.min(1, v)); ambientRefresh(); },
    getVolume: () => volume,
    init: () => getCtx(),
    ambient: {
      isEnabled: () => ambientOn,
      setEnabled: (on) => { ambientOn = !!on; ambientRefresh(); },
      setSeason: (season) => {
        if (season === currentSeason) return;
        currentSeason = season;
        if (ambientOn && !muted) buildWeatherLayer(season);
      },
      currentSeason: () => currentSeason,
    },
  };
})();

// ─── HELPERS ──────────────────────────────────────────────────────────────────
const w = (vals) => vals.map(([value,weight]) => ({value,weight}));
const capitalize = (s) => {
  if(!s) return '';
  return s.replace(/([a-z])([A-Z])/g,'$1 $2')
    .replace(/(^|\s|-)\w/g, m => m.toUpperCase());
};

// ─── SPECIES CONFIG ───────────────────────────────────────────────────────────
const SPECIES = {
  cats: {
    name:'Cats', single:'Cat', baby:'Kitten', babies:'Kittens', icon:'🐱',
    furLabel:'fur', coatLabel:'coat',
    maleNames:['Oliver','Felix','Simba','Theo','Cosmo','Ash','Rusty','Tiger','Bandit','Oscar','Toby','Figaro','Salem','Binx','Rascal','Gizmo','Ziggy','Oreo','Domino','Phantom','Apollo','Mercury','Neptune','Jupiter','Saturn','Pluto','Orbit','Indie','Jazz','Blues','Rumi','Basil','Thyme','Frodo','Pippin','Merlin','Thunder','Blizzard','Frost','Cobalt','Cedar','Onyx','Rocky','Milo','Bruno','Duke','Biscuit','Gouda','Nacho','Taco','Wasabi','Kimchi','Bean','Nutmeg','Espresso','Mocha','Truffle','Latte','Croissant','Baguette','Gyoza','Nori','Ramen','Sesame','Smokey','Dusty','Sooty','Charcoal','Granite','Flint','Sterling','Slate','Bramble','Acorn','Thistle','Hawthorn','Cheddar','Pretzel','Churro','Crouton','Tempura','Dango','Sprout','Sprocket','Gumbo'],
    femaleNames:['Luna','Mochi','Duchess','Whisper','Pudding','Maple','Misty','Pearl','Clover','Lotus','Fern','Opal','Hazel','Willow','Olive','Ivy','Peach','Pumpkin','Cinnamon','Mango','Nova','Pixel','Stella','Lyra','Vega','Athena','Artemis','Venus','Nebula','Galaxy','Cosmos','Eclipse','Zenith','Sonnet','Aria','Rosemary','Saffron','Ginger','Turmeric','Cumin','Clove','Arwen','Drizzle','Flurry','Breeze','Dewdrop','Icicle','Snowball','Frosty','Glacier','Garnet','Ruby','Amber','Topaz','Jade','Coral','Ivory','Ebony','Crimson','Scarlet','Indigo','Violet','Copper','Bronze','Silver','Crystal','Quartz','Agate','Pebbles','River','Creek','Brook','Delta','Reef','Cove','Meadow','Prairie','Tundra','Valley','Ridge','Canyon','Summit','Cappuccino','Scone','Brioche','Bubbles','Dahlia','Lilac','Iris','Orchid','Zinnia','Azalea','Marigold','Heather','Flora','Missy','Callie','Tabitha','Minnie','Sable','Noodle','Pickles','Socks','Waffles','Mittens','Muffin','Button','Toffee','Miso','Chai'],
    prefixes:['Whisker','Paw','Purr','Meow','Tabby','Calico','Kitty','Feline','Neko','Mittens','Mew','Catnip','Siamese','Persian','Bengal','Ragdoll','Sphinx','Claw','Sable','Furball'],
    get names() { return [...this.maleNames,...this.femaleNames]; },
  },
  dogs: {
    name:'Dogs', single:'Dog', baby:'Puppy', babies:'Puppies', icon:'🐕',
    furLabel:'fur', coatLabel:'coat',
    maleNames:['Buddy','Max','Charlie','Cooper','Rocky','Duke','Bear','Tucker','Bandit','Zeus','Tank','Maverick','Rex','Ziggy','Bruno','Otis','Archie','Finn','Jasper','Ace','Axel','Baxter','Bentley','Blue','Buster','Cash','Chase','Chewy','Chief','Chip','Clifford','Cosmo','Dash','Dexter','Diesel','Flash','Frankie','Ghost','Gunner','Gus','Hank','Harley','Henry','Hugo','Hunter','Jack','Jake','Jax','Jett','Koda','Leo','Lucky','Milo','Moose','Murphy','Oakley','Oscar','Ozzy','Paco','Porter','Prince','Ranger','Rocco','Romeo','Rudy','Rusty','Sam','Scout','Skip','Snoopy','Sparky','Storm','Tango','Teddy','Thor','Toby','Tucker','Turbo','Waldo','Watson','Whiskey','Winston','Woody','Yogi','Atlas','Blaze','Bodhi','Bolt','Boomer','Brody','Bucky','Cairo','Clyde','Comet','Dakota','Denver','Domino','Edison','Everest','Falcon','Flint','Fox','Gadget','Goose','Griffin','Harbor','Hawk','Hickory','Homer','Hopper','Hudson','Knox','Kodiak','Link','Mars','Memphis','Merlin','Montego','North','Onyx','Otto','Pine','Pluto','Quest','Ranger','Reed','Ridge','River','Rocket','Sage','Sequoia','Sierra','Slate','Steel','Summit','Swift','Taco','Timber','Trapper','Trek','Vega','Walnut'],
    femaleNames:['Bella','Daisy','Sadie','Molly','Rosie','Nala','Willow','Penny','Poppy','Lola','Hazel','Olive','Clover','Ellie','Ember','Fiona','Gracie','Indie','Ivy','Lady','Lexi','Luna','Mabel','Maggie','Maple','Marley','Minnie','Mochi','Nova','Patches','Pearl','Peanut','Pickles','Pippa','Remi','Riley','Ripley','Roxy','Sandy','Shelby','Sophie','Stella','Sugar','Sunny','Tilly','Trixie','Zara','Zelda','Zoe','Aurora','Bamboo','Biscotti','Breezy','Brooklyn','Butterscotch','Cedar','Cinnamon','Clay','Cloud','Cookie','Cricket','Crimson','Doodle','Echo','Fern','Galaxy','Honey','Jasmine','Juniper','Karma','Kiki','Kit','Lemon','Liberty','Lotus','Lyric','Marble','Mist','Mocha','Noodle','Nutmeg','Olive','Panda','Peach','Pixel','Rain','Raven','Ripple','Rosebud','Savanna','Sprout','Sunset','Truffles','Tulip','Twigs','Wren','Yarrow','Yuki','Zinnia'],
    prefixes:['Bark','Woof','Fetch','Paw','Rover','Snoot','Boop','Puppy','Hound','Golden','Collie','Beagle','Setter','Doodle','Mutt','Spaniel','Husky','Corgi','Terrier','Retriever'],
    get names() { return [...this.maleNames,...this.femaleNames]; },
  },
  birds: {
    name:'Birds', single:'Bird', baby:'Chick', babies:'Chicks', icon:'🐦',
    furLabel:'feathers', coatLabel:'plumage',
    maleNames:['Rio','Phoenix','Jazz','Ziggy','Drake','Eagle','Falcon','Hawk','Jay','Merlin','Oriole','Osprey','Vireo','Atlas','Blaze','Bolt','Cobalt','Comet','Copper','Cosmos','Crimson','Drake','Flicker','Flint','Gale','Granite','Halo','Harpy','Horizon','Hymn','Jupiter','Kai','Lark','Mars','Neptune','Onyx','Orbit','Pluto','Prism','Quail','Raptor','Raven','Reed','Ridge','Riff','Robin','Sage','Saturn','Seraph','Silver','Slate','Solo','Sparrow','Starling','Sterling','Summit','Swift','Tempo','Thorn','Tide','Timber','Titan','Trek','Trill','Vortex','Wren','Zenith','Alto','Anthem','Ballad','Cadence','Cello','Chorus','Cirrus','Clef','Crescendo','Delta','Diamond','Drift','Duet','Fog','Frost','Glow','Haze','Helix','Jubilee','Note','Octave','Overture','Prelude','Pulse','Reef','Rhapsody','Sable','Seraph','Trek','Verse','Vivace','Volta','Wisp'],
    femaleNames:['Tweety','Kiwi','Sunny','Sky','Mango','Peaches','Robin','Echo','Breezy','Piper','Jade','Tiki','Skye','Indigo','Berry','Zephyr','Coral','Stardust','Nimbus','Aurora','Jewel','Topaz','Pearl','Wren','Finch','Pepper','Coco','Honey','Melody','Cricket','Cloud','Clover','Luna','Aria','Azure','Birdie','Calypso','Canary','Cardinal','Celeste','Cherry','Chippy','Cleo','Dawn','Dove','Ember','Feather','Fern','Flame','Flora','Flutter','Galaxy','Garnet','Ginger','Gold','Harmony','Haven','Iris','Ivory','Lapis','Lavender','Lemon','Lily','Marigold','Midnight','Misty','Moonbeam','Nebula','Nightingale','Olive','Opal','Oracle','Paloma','Pebble','Petal','Plume','Primrose','Rain','Ripple','Rosemary','Ruby','Saffron','Sapphire','Scarlet','Shadow','Shimmer','Soleil','Sonata','Songbird','Sunrise','Sunset','Tanager','Tropic','Tulip','Tundra','Turquoise','Twilight','Velvet','Venus','Violet','Whisper','Willow','Wing','Winter','Wisteria','Zinnia','Blossom','Breeze','Radiance','Lyric','Mist','Pollen'],
    prefixes:['Feather','Wing','Tweet','Chirp','Perch','Robin','Finch','Parrot','Plume','Canary','Falcon','Sparrow','Warble','Nest','Lark','Dove','Raven','Songbird','Avian','Flutter'],
    get names() { return [...this.maleNames,...this.femaleNames]; },
  },
  lizards: {
    name:'Lizards', single:'Lizard', baby:'Hatchling', babies:'Hatchlings', icon:'🦎',
    furLabel:'skin', coatLabel:'scales',
    maleNames:['Spike','Rex','Fang','Drago','Basil','Iggy','Onyx','Blaze','Zen','Cobalt','Kai','Atlas','Venom','Camo','Obsidian','Flint','Titan','Jasper','Gizmo','Echo','Ridley','Zilla','Rocky','Frost','Storm','Sage','Amber','Ancient','Anvil','Apache','Apex','Bandit','Basalt','Bishop','Blitz','Bolt','Boulder','Brimstone','Bronze','Canyon','Carbon','Cedar','Cinder','Cipher','Clay','Cliff','Coal','Cobra','Crater','Crest','Crimson','Crux','Crystal','Cyclone','Cypress','Dagger','Delta','Desert','Diamond','Drake','Drift','Dune','Dust','Dynamo','Eclipse','Edge','Everest','Fable','Falcon','Ferro','Flare','Forge','Fossil','Fury','Gale','Garnet','Gecko','Ghost','Glacier','Glint','Granite','Gravel','Grit','Hammer','Havoc','Hawk','Haze','Helix','Hunter','Hydra','Ignite','Iron','Jaws','Jet','Jungle','Kestrel','King','Kindle','Knight','Kodiak','Komodo','Lance','Lava','Legend','Lightning','Limestone','Magma','Marble','Mars','Mesa','Metal','Meteor','Mica','Mirage','Mojave','Monarch','Nebula','Nero','Night','Nitro','Noble','Nomad','North','Nova','Nugget','Omega','Oracle','Orbit','Outback','Oxide','Ozark','Palisade','Pangea','Peak','Phoenix','Pine','Pioneer','Plasma','Plateau','Prism','Prowl','Pyrite','Quake','Quartz','Quest','Quicksilver','Rage','Raptor','Raven','Rebel','Ridge','Rift','River','Rogue','Rune','Saber','Scarab','Scorch','Sentinel','Shale','Sierra','Slate','Smoke','Solar','Solstice','Spark','Sphinx','Spire','Steel','Stone','Strike','Summit','Talon','Tempest','Thorn','Thunder','Topaz','Torch','Trek','Turbo','Tusk','Vanguard','Vapor','Vault','Vigor','Viper','Volcanic','Vortex','Warden','Wildfire','Wolf','Wraith','Xenon','Zenith','Zinc'],
    femaleNames:['Jade','Terra','Sahara','Opal','Naga','Slinky','Mossy','Coral','Pebble','Copper','Ember','Luna','Dot','Sage','Ivy','Fern','Malachite','Citrine','Azurite','Beryl','Carnelian','Shimmer','Shadow','Dawn','Dusk','Frost','Garnet','Glint','Iris','Ivory','Jasmine','Karma','Lotus','Lyra','Meadow','Mist','Misty','Moss','Nimbus','Nova','Pearl','Petra','Prairie','Rain','Raven','Reed','River','Sandy','Scarlet','Seraph','Shimmer','Sierra','Smoke','Solar','Stella','Summit','Sunset','Terra','Thorn','Tide','Tundra','Twilight','Valley','Vesper','Vivid','Whisper','Willow','Yucca','Zinnia','Zircon'],
    prefixes:['Scale','Basking','Gecko','Iguana','Dragon','Viper','Cobra','Turtle','Chameleon','Newt','Croc','Lizard','Serpent','Shell','Fang','Slither','Komodo','Gila','Raptor','Ancient'],
    get names() { return [...this.maleNames,...this.femaleNames]; },
  },
};

// ─── TRAIT TABLES PER SPECIES (2 genetic traits each) ────────────────────────
const TT_CATS = {
  fur:w([['short',30],['medium',24],['long',18]]),
  eyes:w([['green',22],['blue',18],['amber',16],['copper',14]]),
};

const TT_DOGS = {
  size:w([['medium',45],['large',30],['small',25]]),
  breed:w([['labrador',14],['poodle',14],['beagle',14],['husky',14],['corgi',14],['dalmatian',14],['shiba',14]]),
};

const TT_BIRDS = {
  plumage:w([['sleek',28],['fluffy',22],['crested',20],['ruffled',16]]),
  color:w([['scarlet',14],['azure',14],['emerald',12],['golden',12],['violet',10],['white',10]]),
};

const TT_LIZARDS = {
  scales:w([['smooth',25],['ridged',20],['keeled',18],['spiny',14],['armored',12]]),
  pattern:w([['solid',22],['striped',18],['banded',16],['spotted',14],['mottled',12]]),
};

const TRAIT_TABLES = { cats:TT_CATS, dogs:TT_DOGS, birds:TT_BIRDS, lizards:TT_LIZARDS };

// ─── COLOR MAPS PER SPECIES (for SVGs) ───────────────────────────────────────
const COLOR_MAPS = {
  cats:{
    black:{body:'#2d2d2d',out:'#1a1a1a',pat:'#444'},orange:{body:'#f4a460',out:'#c4784a',pat:'#d4844a'},
    gray:{body:'#9e9e9e',out:'#737373',pat:'#858585'},
    cream:{body:'#ffe4c4',out:'#d4b896',pat:'#eecda4'},white:{body:'#fafafa',out:'#d0d0d0',pat:'#eaeaea'},
    chocolate:{body:'#5c3317',out:'#3a200e',pat:'#4d2812'},lilac:{body:'#c8a2c8',out:'#9d7a9d',pat:'#b890b8'},
    cinnamon:{body:'#d4956a',out:'#b07850',pat:'#c4855a'},'blue-gray':{body:'#7a8fa0',out:'#5d7080',pat:'#6a7f90'},
  },
  dogs:{
    golden:{body:'#daa520',out:'#b8860b',pat:'#c89418'},black:{body:'#2d2d2d',out:'#1a1a1a',pat:'#444'},
    brown:{body:'#8B6F47',out:'#6B5535',pat:'#7A6040'},white:{body:'#fafafa',out:'#d0d0d0',pat:'#eaeaea'},
    cream:{body:'#ffe4c4',out:'#d4b896',pat:'#eecda4'},
    gray:{body:'#9e9e9e',out:'#737373',pat:'#858585'},chocolate:{body:'#5c3317',out:'#3a200e',pat:'#4d2812'},
    brindle:{body:'#8B7355',out:'#6B5535',pat:'#7A6340'},blue:{body:'#7a8fa0',out:'#5d7080',pat:'#6a7f90'},
  },
  birds:{
    scarlet:{body:'#dc3545',out:'#a02030',pat:'#c42030'},azure:{body:'#4169e1',out:'#2850b0',pat:'#3558c0'},
    emerald:{body:'#2ecc71',out:'#1a9a50',pat:'#25b060'},golden:{body:'#ffd700',out:'#c8a800',pat:'#e0c000'},
    violet:{body:'#8b5cf6',out:'#6a40c0',pat:'#7a4ce0'},white:{body:'#fafafa',out:'#d0d0d0',pat:'#eaeaea'},
    obsidian:{body:'#2d2d2d',out:'#1a1a1a',pat:'#444'},coral:{body:'#ff7f50',out:'#d06040',pat:'#e07048'},
    teal:{body:'#20b2aa',out:'#188a84',pat:'#1c9e98'},sunset:{body:'#ff6b35',out:'#d05528',pat:'#e06030'},
  },
  lizards:{
    emerald:{body:'#2ecc71',out:'#1a9a50',pat:'#25b060'},sand:{body:'#d4a76a',out:'#b08850',pat:'#c49860'},
    obsidian:{body:'#2d2d2d',out:'#1a1a1a',pat:'#444'},rust:{body:'#b7410e',out:'#8a300a',pat:'#a0380c'},
    azure:{body:'#4169e1',out:'#2850b0',pat:'#3558c0'},crimson:{body:'#dc143c',out:'#a0102c',pat:'#c01030'},
    jade:{body:'#00a86b',out:'#008050',pat:'#009060'},amber:{body:'#ffbf00',out:'#cc9900',pat:'#e0aa00'},
    slate:{body:'#708090',out:'#546070',pat:'#607080'},ivory:{body:'#fffff0',out:'#d8d8c8',pat:'#e8e8d8'},
  },
};

const EYE_COLORS_ALL = {
  yellow:'#ffd700',green:'#2e8b57',copper:'#b87333',amber:'#ffbf00',blue:'#4169e1',violet:'#8b5cf6',
  heterochromia:'#4169e1',brown:'#8B4513',hazel:'#8E7618',dark:'#2d2d2d',black:'#1a1a1a',orange:'#ff8c00',
  red:'#cc2200',gold:'#daa520',silver:'#c0c0c0',
};

// ─── GAME CONSTANTS ───────────────────────────────────────────────────────────
// All tunable game parameters in one place. Change values here, not in logic code.
/*
 * ─── CONSTANTS CATALOG ───────────────────────────────────────────────────
 * All tunable game parameters live in GAME_CONFIG below. Related tables
 * live elsewhere in the file — this index is a map.
 *
 *   GAME_CONFIG       — week/month/year length, caps, thresholds, RD/recruit
 *                       base rates, retirement ages, breeding cooldowns
 *   CAFE_UPGRADES     — cafe level costs, floor capacity, customer base
 *   PET_HOUSE_UPGRADES — pet house level costs, pen capacity
 *   INGS / ING_POOLS  — menu item ingredients + weighted pools
 *   SEASON_MOD        — per-category seasonal demand multipliers
 *   SEASONAL_EVENTS   — Spring/Summer/Fall/Winter special events
 *   SHOW_THEMES_ALL   — judged trait combinations per species
 *   PERS_TABLE / PERS_DESC — personality multipliers for tips/shows
 *   TRAINING_COSTS    — staff training cost curve
 *   MILESTONES        — achievement unlock conditions + point values
 *   LETTERS           — narrative story beats
 *   NPCS              — recurring NPC definitions (visit cadence, perks)
 *   REGULARS_POOL     — named customer characters with arcs
 *   REGULAR_BEAT_TEMPLATES — shared bank of weekly beat one-liners
 *
 * When adding a new tunable, add it to GAME_CONFIG rather than inlining
 * a magic number in a simulation function.
 */
const GAME_CONFIG = {
  // Time system
  WEEKS_PER_MONTH: 4,
  MONTHS_PER_YEAR: 12,
  WEEKS_PER_YEAR: 48,
  GAME_END_WEEKS: 720,             // 15 years × 48 weeks
  RETIREMENT_AGE_WEEKS: 480,       // 10 years × 48 weeks
  RETIREMENT_FAREWELL_WEEKS: 4,    // goodbye period before pet is removed

  // Breeding lifecycle
  BREEDING_AWAY_WEEKS: 1,          // parents unavailable during mating
  PREGNANCY_WEEKS: 3,              // mother carries babies
  NURSING_WEEKS: 1,                // mother nurses after birth (can't work in cafe)
  BREED_MIN_AGE_WEEKS: 24,         // 6 months minimum to breed
  KITTEN_AGE_THRESHOLD: 1,         // age < this = baby (can't work or breed)

  // Genetics & mutation
  LUCKY_SPARK_CHANCE: 0.12,        // chance one trait gets rarest alleles during breeding
  MUTATION_RATE: 0.03,             // per-allele chance of random replacement

  // Menu system
  MENU_ITEMS_PER_CATEGORY: 100,    // total items generated per category
  MASTERY_THRESHOLDS: [50, 100, 200], // sales needed for tier 1, 2, 3

  // Staff system
  MAX_SKILL_LEVEL: 10,

  // Economy
  STARTING_MONEY: 800,
  DEBT_WEEKS_TO_BANKRUPTCY: 4,     // consecutive negative weeks before game over
  MAX_RD_BUDGET: 500,
  MAX_RECRUIT_BUDGET: 500,

  // Pools (refreshed weekly)
  WILD_POOL_SIZE: 4,               // adoption candidates (1 per species)
  STAFF_CANDIDATES_COUNT: 3,       // weekly staff refresh
  INITIAL_STAFF_CANDIDATES: 2,     // at game start

  // Shows
  MAX_SHOW_ENTRIES: 3,             // player pets per show
  SHOW_INTERVAL_WEEKS: 4,         // show + trend cycle = monthly

  // Cafe simulation
  OPEN_DAYS_PER_WEEK: 6,          // days the cafe is open
  MAX_EVENTS_SHOWN: 6,            // max events returned from weekly sim
  EVENT_DISPLAY_LIMIT: 3,         // max events from pet personality per day
  EVENT_LOG_MAX: 50,              // rolling event history
  TREND_HISTORY_MAX: 30,          // past trends kept

  // Progression
  MAX_CAFE_LEVEL: 6,
  PET_UPKEEP_MULTIPLIER: 6,          // weekly pet upkeep multiplier
};
const TRAINING_COSTS = [0, 30, 80, 200, 500, 1200, 2800, 6500, 14000, 30000];

// ─── MILESTONES ──────────────────────────────────────────────────────────────
const MILESTONES = [
  // ── CAFE (pillar total: 20,000) ──────────────────────────────────────────────
  // Earnings ×7 = 10,000
  {id:'cafe_1k',name:'First Earnings',icon:'💰',desc:'Earn $1,000 total',pillar:'cafe',page:'home',pts:300,check:s=>({reached:(s.totalEarned||0)>=1000,progress:Math.min(1,(s.totalEarned||0)/1000),label:`$${Math.round(s.totalEarned||0)} / $1,000`})},
  {id:'cafe_5k',name:'Growing Business',icon:'💰',desc:'Earn $5,000 total',pillar:'cafe',page:'home',pts:500,check:s=>({reached:(s.totalEarned||0)>=5000,progress:Math.min(1,(s.totalEarned||0)/5000),label:`$${Math.round(s.totalEarned||0)} / $5,000`})},
  {id:'cafe_25k',name:'Thriving Cafe',icon:'💰',desc:'Earn $25,000 total',pillar:'cafe',page:'home',pts:700,check:s=>({reached:(s.totalEarned||0)>=25000,progress:Math.min(1,(s.totalEarned||0)/25000),label:`$${Math.round(s.totalEarned||0)} / $25,000`})},
  {id:'cafe_100k',name:'Cafe Tycoon',icon:'💰',desc:'Earn $100,000 total',pillar:'cafe',page:'home',pts:1000,check:s=>({reached:(s.totalEarned||0)>=100000,progress:Math.min(1,(s.totalEarned||0)/100000),label:`$${Math.round(s.totalEarned||0)} / $100,000`})},
  {id:'cafe_500k',name:'Cafe Empire',icon:'💰',desc:'Earn $500,000 total',pillar:'cafe',page:'home',pts:1500,check:s=>({reached:(s.totalEarned||0)>=500000,progress:Math.min(1,(s.totalEarned||0)/500000),label:`$${Math.round(s.totalEarned||0)} / $500,000`})},
  {id:'cafe_1m',name:'Millionaire',icon:'💰',desc:'Earn $1,000,000 total',pillar:'cafe',page:'home',pts:2500,check:s=>({reached:(s.totalEarned||0)>=1000000,progress:Math.min(1,(s.totalEarned||0)/1000000),label:`$${Math.round(s.totalEarned||0)} / $1,000,000`})},
  {id:'cafe_5m',name:'Cat Cafe Legend',icon:'💰',desc:'Earn $5,000,000 total',pillar:'cafe',page:'home',pts:3500,check:s=>({reached:(s.totalEarned||0)>=5000000,progress:Math.min(1,(s.totalEarned||0)/5000000),label:`$${Math.round(s.totalEarned||0)} / $5,000,000`})},
  // Upgrades ×5 = 3,000
  {id:'upg_2',name:'First Upgrade',icon:'⬆️',desc:'Upgrade your cafe once',pillar:'cafe',page:'upg',pts:200,check:s=>{const u=(s.cafeLevel||1)-1;return{reached:u>=1,progress:u>=1?1:Math.min(0.99,u),label:`${u} / 1`};}},
  {id:'upg_3',name:'Comfy Lounge',icon:'⬆️',desc:'Upgrade your cafe 2 times',pillar:'cafe',page:'upg',pts:400,check:s=>{const u=(s.cafeLevel||1)-1;return{reached:u>=2,progress:Math.min(1,u/2),label:`${u} / 2`};}},
  {id:'upg_4',name:'Grand Parlour',icon:'⬆️',desc:'Upgrade your cafe 3 times',pillar:'cafe',page:'upg',pts:600,check:s=>{const u=(s.cafeLevel||1)-1;return{reached:u>=3,progress:Math.min(1,u/3),label:`${u} / 3`};}},
  {id:'upg_5',name:'Luxe Retreat',icon:'⬆️',desc:'Upgrade your cafe 4 times',pillar:'cafe',page:'upg',pts:700,check:s=>{const u=(s.cafeLevel||1)-1;return{reached:u>=4,progress:Math.min(1,u/4),label:`${u} / 4`};}},
  {id:'upg_6',name:'Legendary Haven',icon:'🏰',desc:'Upgrade your cafe 5 times (max level)',pillar:'cafe',page:'upg',pts:1100,check:s=>{const u=(s.cafeLevel||1)-1;return{reached:u>=5,progress:Math.min(1,u/5),label:`${u} / 5`};}},
  // Pet House upgrades ×6 = 3,000
  {id:'ph_2',name:'Cozy Pen',icon:'🏠',desc:'Upgrade the pet house to level 2',pillar:'cafe',page:'upg',pts:200,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=2,progress:Math.min(1,u-1),label:`Lv${u} / Lv2`};}},
  {id:'ph_3',name:'Pet Cottage',icon:'🏠',desc:'Upgrade the pet house to level 3',pillar:'cafe',page:'upg',pts:300,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=3,progress:Math.min(1,(u-1)/2),label:`Lv${u} / Lv3`};}},
  {id:'ph_5',name:'Pet Manor',icon:'🏡',desc:'Upgrade the pet house to level 5',pillar:'cafe',page:'upg',pts:500,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=5,progress:Math.min(1,(u-1)/4),label:`Lv${u} / Lv5`};}},
  {id:'ph_6',name:'Pet Estate',icon:'🏡',desc:'Upgrade the pet house to level 6',pillar:'cafe',page:'upg',pts:500,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=6,progress:Math.min(1,(u-1)/5),label:`Lv${u} / Lv6`};}},
  {id:'ph_8',name:'Pet Kingdom',icon:'🏰',desc:'Upgrade the pet house to level 8',pillar:'cafe',page:'upg',pts:700,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=8,progress:Math.min(1,(u-1)/7),label:`Lv${u} / Lv8`};}},
  {id:'ph_10',name:'Pet Paradise',icon:'🏰',desc:'Upgrade the pet house to max level (10)',pillar:'cafe',page:'upg',pts:800,check:s=>{const u=(s.petHouseLevel||1);return{reached:u>=10,progress:Math.min(1,(u-1)/9),label:`Lv${u} / Lv10`};}},
  // Staff ×10 = 4,000
  {id:'staff_recruit500',name:'Talent Investor',icon:'💼',desc:'Spend $500 on recruitment',pillar:'cafe',page:'staff',pts:200,check:s=>{const r=s.totalRecruitSpent||0;return{reached:r>=500,progress:Math.min(1,r/500),label:`$${Math.round(r)} / $500`};}},
  {id:'staff_recruit2500',name:'Top Recruiter',icon:'💼',desc:'Spend $2,500 on recruitment',pillar:'cafe',page:'staff',pts:300,check:s=>{const r=s.totalRecruitSpent||0;return{reached:r>=2500,progress:Math.min(1,r/2500),label:`$${Math.round(r)} / $2,500`};}},
  {id:'staff_recruit10k',name:'Headhunter',icon:'💼',desc:'Spend $10,000 on recruitment',pillar:'cafe',page:'staff',pts:500,check:s=>{const r=s.totalRecruitSpent||0;return{reached:r>=10000,progress:Math.min(1,r/10000),label:`$${Math.round(r)} / $10,000`};}},
  {id:'staff_train5',name:'Skilled Worker',icon:'📚',desc:'Train any skill to level 5',pillar:'cafe',page:'staff',pts:200,check:s=>{const best=Math.max(0,...(s.staff||[]).flatMap(st=>Object.values(st.skills||{})));return{reached:best>=5,progress:Math.min(1,best/5),label:`${best} / 5`};}},
  {id:'staff_train8',name:'Expert Staff',icon:'🎓',desc:'Train any skill to level 8',pillar:'cafe',page:'staff',pts:300,check:s=>{const best=Math.max(0,...(s.staff||[]).flatMap(st=>Object.values(st.skills||{})));return{reached:best>=8,progress:Math.min(1,best/8),label:`${best} / 8`};}},
  {id:'staff_cook10',name:'Master Chef',icon:'👨‍🍳',desc:'Train Cooking to level 10',pillar:'cafe',page:'staff',pts:300,check:s=>{const best=Math.max(0,...(s.staff||[]).map(st=>st.skills?.cooking||0));return{reached:best>=10,progress:Math.min(1,best/10),label:`${best} / 10`};}},
  {id:'staff_service10',name:'Service Excellence',icon:'🤝',desc:'Train Service to level 10',pillar:'cafe',page:'staff',pts:300,check:s=>{const best=Math.max(0,...(s.staff||[]).map(st=>st.skills?.service||0));return{reached:best>=10,progress:Math.min(1,best/10),label:`${best} / 10`};}},
  {id:'staff_petcare10',name:'Pet Whisperer',icon:'🐾',desc:'Train Pet Care to level 10',pillar:'cafe',page:'staff',pts:300,check:s=>{const best=Math.max(0,...(s.staff||[]).map(st=>st.skills?.petCare||0));return{reached:best>=10,progress:Math.min(1,best/10),label:`${best} / 10`};}},
  {id:'staff_cleaning10',name:'Spotless',icon:'✨',desc:'Train Cleaning to level 10',pillar:'cafe',page:'staff',pts:300,check:s=>{const best=Math.max(0,...(s.staff||[]).map(st=>st.skills?.cleaning||0));return{reached:best>=10,progress:Math.min(1,best/10),label:`${best} / 10`};}},
  {id:'staff_allmax',name:'Perfect Employee',icon:'🌟',desc:'Have a staff member with level 10 in all skills',pillar:'cafe',page:'staff',pts:1300,check:s=>{const perfect=(s.staff||[]).some(st=>Object.values(st.skills||{}).every(v=>v>=10));return{reached:perfect,progress:perfect?1:Math.max(0,...(s.staff||[]).map(st=>{const vals=Object.values(st.skills||{});return vals.length?vals.reduce((a,b)=>a+b,0)/(vals.length*10):0;})),label:`${perfect?1:0} / 1`};}},
  // ── MENU (pillar total: 20,000) ──────────────────────────────────────────────
  // Discovery ×4 = 3,500
  {id:'menu_10',name:'Recipe Explorer',icon:'🔬',desc:'Discover 10 recipes',pillar:'menu',page:'menu',pts:300,check:s=>{const d=Math.max(0,(s.discoveredItemIds?.length||0)-6);return{reached:d>=10,progress:Math.min(1,d/10),label:`${d} / 10`};}},
  {id:'menu_25',name:'Recipe Hunter',icon:'🔬',desc:'Discover 25 recipes',pillar:'menu',page:'menu',pts:600,check:s=>{const d=Math.max(0,(s.discoveredItemIds?.length||0)-6);return{reached:d>=25,progress:Math.min(1,d/25),label:`${d} / 25`};}},
  {id:'menu_50',name:'Recipe Master',icon:'🔬',desc:'Discover 50 recipes',pillar:'menu',page:'menu',pts:1000,check:s=>{const d=Math.max(0,(s.discoveredItemIds?.length||0)-6);return{reached:d>=50,progress:Math.min(1,d/50),label:`${d} / 50`};}},
  {id:'menu_100',name:'Recipe Legend',icon:'🔬',desc:'Discover 100 recipes',pillar:'menu',page:'menu',pts:1600,check:s=>{const d=Math.max(0,(s.discoveredItemIds?.length||0)-6);return{reached:d>=100,progress:Math.min(1,d/100),label:`${d} / 100`};}},
  // Stars ×4 = 3,500
  {id:'star_1',name:'First Star',icon:'⭐',desc:'Upgrade a menu item to ★',pillar:'menu',page:'menu',pts:200,check:s=>{const t=Object.values(s.masteredItems||{}).reduce((mx,m)=>Math.max(mx,m.tier||0),0);return{reached:t>=1,progress:t>=1?1:0,label:`${t>=1?1:0} / 1`};}},
  {id:'star_3',name:'Triple Star',icon:'⭐',desc:'Upgrade an item to ★★★',pillar:'menu',page:'menu',pts:500,check:s=>{const t=Object.values(s.masteredItems||{}).reduce((mx,m)=>Math.max(mx,m.tier||0),0);return{reached:t>=3,progress:Math.min(1,t/3),label:`${t>=3?1:0} / 1`};}},
  {id:'stars_10',name:'Star Collector',icon:'🌟',desc:'Earn 10 total stars',pillar:'menu',page:'menu',pts:800,check:s=>{const t=Object.values(s.masteredItems||{}).reduce((sum,m)=>sum+(m.tier||0),0);return{reached:t>=10,progress:Math.min(1,t/10),label:`${t} / 10`};}},
  {id:'stars_50',name:'Star Hoarder',icon:'🌟',desc:'Earn 50 total stars',pillar:'menu',page:'menu',pts:2000,check:s=>{const t=Object.values(s.masteredItems||{}).reduce((sum,m)=>sum+(m.tier||0),0);return{reached:t>=50,progress:Math.min(1,t/50),label:`${t} / 50`};}},
  // Collection ×3 = 13,000
  {id:'menu_alldiscovered',name:'Complete Collection',icon:'📚',desc:'Discover all available recipes',pillar:'menu',page:'menu',pts:3000,check:s=>{const total=(s.allMenuItems||[]).length;const disc=(s.discoveredItemIds||[]).length;return{reached:total>0&&disc>=total,progress:total>0?Math.min(1,disc/total):0,label:`${disc} / ${total}`};}},
  {id:'menu_allgold',name:'Golden Menu',icon:'🥇',desc:'Upgrade all active menu items to ★★★',pillar:'menu',page:'menu',pts:3000,check:s=>{const active=Object.values(s.activeMenuIds||{}).flat();if(!active.length)return{reached:false,progress:0,label:'No items on menu'};const gold=active.filter(id=>(s.masteredItems||{})[id]?.tier>=3).length;return{reached:gold===active.length,progress:active.length?gold/active.length:0,label:`${gold} / ${active.length}`};}},
  {id:'menu_alldisc_gold',name:'Recipe Perfectionist',icon:'🌟',desc:'Get ★★★ on all discovered recipes',pillar:'menu',page:'menu',pts:7000,check:s=>{const disc=s.discoveredItemIds||[];if(!disc.length)return{reached:false,progress:0,label:'0 / 0'};const gold=disc.filter(id=>(s.masteredItems||{})[id]?.tier>=3).length;return{reached:gold>=disc.length,progress:disc.length?gold/disc.length:0,label:`${gold} / ${disc.length}`};}},
  // ── PETS (pillar total: 20,000) ──────────────────────────────────────────────
  // 4 species × 5 tiers = 20 milestones, each species = 5,000 pts
  {id:'pets_cat2',name:'Cat Lover',icon:'🐱',desc:'Have 2 cats',pillar:'pets',page:'pets',pts:250,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='cats').length;return{reached:n>=2,progress:Math.min(1,n/2),label:`${n} / 2`};}},
  {id:'pets_cat5',name:'Cat Colony',icon:'🐱',desc:'Have 5 cats',pillar:'pets',page:'pets',pts:500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='cats').length;return{reached:n>=5,progress:Math.min(1,n/5),label:`${n} / 5`};}},
  {id:'pets_cat10',name:'Cat Kingdom',icon:'🐱',desc:'Have 10 cats',pillar:'pets',page:'pets',pts:1000,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='cats').length;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'pets_cat20',name:'Cat Dynasty',icon:'🐱',desc:'Have 20 cats',pillar:'pets',page:'pets',pts:1500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='cats').length;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'pets_cat30',name:'Cat Empire',icon:'🐱',desc:'Have 30 cats',pillar:'pets',page:'pets',pts:1750,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='cats').length;return{reached:n>=30,progress:Math.min(1,n/30),label:`${n} / 30`};}},
  {id:'pets_dog2',name:'Dog Lover',icon:'🐶',desc:'Have 2 dogs',pillar:'pets',page:'pets',pts:250,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='dogs').length;return{reached:n>=2,progress:Math.min(1,n/2),label:`${n} / 2`};}},
  {id:'pets_dog5',name:'Dog Pack',icon:'🐶',desc:'Have 5 dogs',pillar:'pets',page:'pets',pts:500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='dogs').length;return{reached:n>=5,progress:Math.min(1,n/5),label:`${n} / 5`};}},
  {id:'pets_dog10',name:'Dog Squad',icon:'🐶',desc:'Have 10 dogs',pillar:'pets',page:'pets',pts:1000,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='dogs').length;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'pets_dog20',name:'Dog Brigade',icon:'🐶',desc:'Have 20 dogs',pillar:'pets',page:'pets',pts:1500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='dogs').length;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'pets_dog30',name:'Dog Army',icon:'🐶',desc:'Have 30 dogs',pillar:'pets',page:'pets',pts:1750,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='dogs').length;return{reached:n>=30,progress:Math.min(1,n/30),label:`${n} / 30`};}},
  {id:'pets_bird2',name:'Bird Watcher',icon:'🦜',desc:'Have 2 birds',pillar:'pets',page:'pets',pts:250,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='birds').length;return{reached:n>=2,progress:Math.min(1,n/2),label:`${n} / 2`};}},
  {id:'pets_bird5',name:'Bird Aviary',icon:'🦜',desc:'Have 5 birds',pillar:'pets',page:'pets',pts:500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='birds').length;return{reached:n>=5,progress:Math.min(1,n/5),label:`${n} / 5`};}},
  {id:'pets_bird10',name:'Bird Sanctuary',icon:'🦜',desc:'Have 10 birds',pillar:'pets',page:'pets',pts:1000,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='birds').length;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'pets_bird20',name:'Bird Paradise',icon:'🦜',desc:'Have 20 birds',pillar:'pets',page:'pets',pts:1500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='birds').length;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'pets_bird30',name:'Bird Kingdom',icon:'🦜',desc:'Have 30 birds',pillar:'pets',page:'pets',pts:1750,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='birds').length;return{reached:n>=30,progress:Math.min(1,n/30),label:`${n} / 30`};}},
  {id:'pets_liz2',name:'Lizard Keeper',icon:'🦎',desc:'Have 2 lizards',pillar:'pets',page:'pets',pts:250,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='lizards').length;return{reached:n>=2,progress:Math.min(1,n/2),label:`${n} / 2`};}},
  {id:'pets_liz5',name:'Lizard Den',icon:'🦎',desc:'Have 5 lizards',pillar:'pets',page:'pets',pts:500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='lizards').length;return{reached:n>=5,progress:Math.min(1,n/5),label:`${n} / 5`};}},
  {id:'pets_liz10',name:'Lizard Empire',icon:'🦎',desc:'Have 10 lizards',pillar:'pets',page:'pets',pts:1000,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='lizards').length;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'pets_liz20',name:'Lizard Dynasty',icon:'🦎',desc:'Have 20 lizards',pillar:'pets',page:'pets',pts:1500,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='lizards').length;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'pets_liz30',name:'Lizard Dominion',icon:'🦎',desc:'Have 30 lizards',pillar:'pets',page:'pets',pts:1750,check:s=>{const n=(s.pets||[]).filter(p=>p.species==='lizards').length;return{reached:n>=30,progress:Math.min(1,n/30),label:`${n} / 30`};}},
  // ── BREEDING (pillar total: 20,000) ──────────────────────────────────────────
  // Breed count ×4 = 2,000
  {id:'breed_1',name:'First Litter',icon:'🧬',desc:'Breed your first pet',pillar:'breeding',page:'breed',pts:200,check:s=>({reached:(s.totalBred||0)>=1,progress:Math.min(1,(s.totalBred||0)),label:`${s.totalBred||0} / 1`})},
  {id:'breed_5',name:'Budding Breeder',icon:'🧬',desc:'Breed 5 pets',pillar:'breeding',page:'breed',pts:400,check:s=>({reached:(s.totalBred||0)>=5,progress:Math.min(1,(s.totalBred||0)/5),label:`${s.totalBred||0} / 5`})},
  {id:'breed_20',name:'Expert Breeder',icon:'🧬',desc:'Breed 20 pets',pillar:'breeding',page:'breed',pts:700,check:s=>({reached:(s.totalBred||0)>=20,progress:Math.min(1,(s.totalBred||0)/20),label:`${s.totalBred||0} / 20`})},
  {id:'breed_50',name:'Master Breeder',icon:'🧬',desc:'Breed 50 pets',pillar:'breeding',page:'breed',pts:700,check:s=>({reached:(s.totalBred||0)>=50,progress:Math.min(1,(s.totalBred||0)/50),label:`${s.totalBred||0} / 50`})},
  // Generation ×5 = 6,000
  {id:'gen_2',name:'Second Generation',icon:'🧬',desc:'Reach generation 2',pillar:'breeding',page:'breed',pts:300,check:s=>({reached:(s.maxGeneration||0)>=2,progress:Math.min(1,(s.maxGeneration||0)/2),label:`${s.maxGeneration||0} / 2`})},
  {id:'gen_5',name:'Fifth Generation',icon:'🧬',desc:'Reach generation 5',pillar:'breeding',page:'breed',pts:600,check:s=>({reached:(s.maxGeneration||0)>=5,progress:Math.min(1,(s.maxGeneration||0)/5),label:`${s.maxGeneration||0} / 5`})},
  {id:'gen_10',name:'Dynasty',icon:'👑',desc:'Reach generation 10',pillar:'breeding',page:'breed',pts:1100,check:s=>({reached:(s.maxGeneration||0)>=10,progress:Math.min(1,(s.maxGeneration||0)/10),label:`${s.maxGeneration||0} / 10`})},
  {id:'gen_20',name:'Ancient Lineage',icon:'👑',desc:'Reach generation 20',pillar:'breeding',page:'breed',pts:1500,check:s=>({reached:(s.maxGeneration||0)>=20,progress:Math.min(1,(s.maxGeneration||0)/20),label:`${s.maxGeneration||0} / 20`})},
  {id:'gen_30',name:'Immortal Bloodline',icon:'👑',desc:'Reach generation 30',pillar:'breeding',page:'breed',pts:2500,check:s=>({reached:(s.maxGeneration||0)>=30,progress:Math.min(1,(s.maxGeneration||0)/30),label:`${s.maxGeneration||0} / 30`})},
  // Rarity ×9 = 12,000
  {id:'rare_unc',name:'Uncommon Find',icon:'💎',desc:'Breed an uncommon pet',pillar:'breeding',page:'breed',pts:300,check:s=>({reached:(s.highestRarity||0)>0.25,progress:Math.min(1,(s.highestRarity||0)/0.26),label:`${(s.highestRarity||0)>0.25?1:0} / 1`})},
  {id:'rare_unc10',name:'Uncommon Breeder',icon:'💎',desc:'Breed 10 uncommon pets',pillar:'breeding',page:'breed',pts:700,check:s=>{const n=s.totalUncommonBred||0;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'rare_unc20',name:'Uncommon Master',icon:'💎',desc:'Breed 20 uncommon pets',pillar:'breeding',page:'breed',pts:1000,check:s=>{const n=s.totalUncommonBred||0;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'rare_rare',name:'Rare Gem',icon:'💎',desc:'Breed a rare pet',pillar:'breeding',page:'breed',pts:600,check:s=>({reached:(s.highestRarity||0)>0.45,progress:Math.min(1,(s.highestRarity||0)/0.46),label:`${(s.highestRarity||0)>0.45?1:0} / 1`})},
  {id:'rare_rare10',name:'Rare Breeder',icon:'💎',desc:'Breed 10 rare pets',pillar:'breeding',page:'breed',pts:1200,check:s=>{const n=s.totalRareBred||0;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'rare_rare20',name:'Rare Master',icon:'💎',desc:'Breed 20 rare pets',pillar:'breeding',page:'breed',pts:1700,check:s=>{const n=s.totalRareBred||0;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  {id:'rare_leg',name:'Legendary',icon:'💎',desc:'Breed a legendary pet',pillar:'breeding',page:'breed',pts:1000,check:s=>({reached:(s.highestRarity||0)>0.65,progress:Math.min(1,(s.highestRarity||0)/0.66),label:`${(s.highestRarity||0)>0.65?1:0} / 1`})},
  {id:'rare_leg10',name:'Legendary Breeder',icon:'💎',desc:'Breed 10 legendary pets',pillar:'breeding',page:'breed',pts:2000,check:s=>{const n=s.totalLegendaryBred||0;return{reached:n>=10,progress:Math.min(1,n/10),label:`${n} / 10`};}},
  {id:'rare_leg20',name:'Legendary Master',icon:'💎',desc:'Breed 20 legendary pets',pillar:'breeding',page:'breed',pts:3500,check:s=>{const n=s.totalLegendaryBred||0;return{reached:n>=20,progress:Math.min(1,n/20),label:`${n} / 20`};}},
  // ── SHOWS (pillar total: 20,000) ─────────────────────────────────────────────
  {id:'show_pod',name:'Podium Finish',icon:'🥉',desc:'Finish in the top 3',pillar:'shows',page:'show',pts:500,check:s=>({reached:(s.totalPodiums||0)>=1,progress:Math.min(1,(s.totalPodiums||0)),label:`${s.totalPodiums||0} / 1`})},
  {id:'show_1st',name:'Champion',icon:'🥇',desc:'Win first place',pillar:'shows',page:'show',pts:1500,check:s=>({reached:(s.totalFirstPlace||0)>=1,progress:Math.min(1,(s.totalFirstPlace||0)),label:`${s.totalFirstPlace||0} / 1`})},
  {id:'show_5',name:'Show Star',icon:'🏆',desc:'Win 5 first places',pillar:'shows',page:'show',pts:3000,check:s=>({reached:(s.totalFirstPlace||0)>=5,progress:Math.min(1,(s.totalFirstPlace||0)/5),label:`${s.totalFirstPlace||0} / 5`})},
  {id:'show_15',name:'Show Legend',icon:'🏆',desc:'Win 15 first places',pillar:'shows',page:'show',pts:6000,check:s=>({reached:(s.totalFirstPlace||0)>=15,progress:Math.min(1,(s.totalFirstPlace||0)/15),label:`${s.totalFirstPlace||0} / 15`})},
  {id:'show_30',name:'Show Master',icon:'👑',desc:'Win 30 first places',pillar:'shows',page:'show',pts:9000,check:s=>({reached:(s.totalFirstPlace||0)>=30,progress:Math.min(1,(s.totalFirstPlace||0)/30),label:`${s.totalFirstPlace||0} / 30`})},
];

/** Cafe upgrade tiers: cafe capacity, customers, staff, costs. pen removed (now in PET_HOUSE_UPGRADES). */
const CAFE_UPGRADES = {
  1:{name:'Tiny Nook',       cust:12, floor:3,  opCost:10,  maxStaff:1,minStaff:1,cost:0},
  2:{name:'Cozy Corner',     cust:14, floor:5,  opCost:35,  maxStaff:2,minStaff:1,cost:4000},
  3:{name:'Comfy Lounge',    cust:22, floor:8,  opCost:65,  maxStaff:3,minStaff:2,cost:18000},
  4:{name:'Grand Parlour',   cust:32, floor:12, opCost:110, maxStaff:4,minStaff:2,cost:60000},
  5:{name:'Luxe Retreat',    cust:45, floor:16, opCost:220, maxStaff:5,minStaff:3,cost:180000},
  6:{name:'Legendary Haven', cust:60, floor:20, opCost:350, maxStaff:6,minStaff:4,cost:500000},
};

/** Pet house upgrade tiers: max pets owned (pen), staff bonus, costs. */
const PET_HOUSE_UPGRADES = {
  1: {name:'Small Den',    pen:5,   staffBonus:0,cost:0},
  2: {name:'Cozy Pen',     pen:8,   staffBonus:0,cost:800},
  3: {name:'Pet Cottage',  pen:15,  staffBonus:1,cost:2500},
  4: {name:'Pet Lodge',    pen:27,  staffBonus:1,cost:6000},
  5: {name:'Pet Manor',    pen:38,  staffBonus:1,cost:14000},
  6: {name:'Pet Estate',   pen:52,  staffBonus:2,cost:30000},
  7: {name:'Pet Palace',   pen:68,  staffBonus:2,cost:60000},
  8: {name:'Pet Kingdom',  pen:82,  staffBonus:3,cost:120000},
  9: {name:'Pet Empire',   pen:105, staffBonus:3,cost:250000},
  10:{name:'Pet Paradise', pen:135, staffBonus:4,cost:450000},
};

// Legacy UPGRADES alias for backward compat in save migration
const UPGRADES = CAFE_UPGRADES;

/** Helper: total pet slots = cafe floor + pet house pen */
function getMaxPets(state) { return (CAFE_UPGRADES[state.cafeLevel]?.floor||3)+(PET_HOUSE_UPGRADES[state.petHouseLevel||1]?.pen||3); }
/** Helper: total staff slots = cafe maxStaff + pet house staffBonus */
function getMaxStaff(state) { return (CAFE_UPGRADES[state.cafeLevel]?.maxStaff||1)+(PET_HOUSE_UPGRADES[state.petHouseLevel||1]?.staffBonus||0); }
/** Helper: max menu items per category = 2 + cafeLevel */
function getMaxMenuPerCat(cafeLevel) { return 2+(cafeLevel||1); }
/** Staff assignment helpers — player works both locations */
function getCafeStaff(state) { return (state.staff||[]).filter(s=>s.isPlayer||(state.staffAssignments||{})[s.id]==='cafe'); }
function getPetHouseStaff(state) { return (state.staff||[]).filter(s=>s.isPlayer||(state.staffAssignments||{})[s.id]==='petHouse'); }
function getMaxCafeAddlStaff(state) { return (CAFE_UPGRADES[state.cafeLevel]?.maxStaff||1)-1; }
function getMaxPetHouseAddlStaff(state) { return PET_HOUSE_UPGRADES[state.petHouseLevel||1]?.staffBonus||0; }
function getCafeAssigned(state) { return (state.staff||[]).filter(s=>!s.isPlayer&&(state.staffAssignments||{})[s.id]==='cafe'); }
function getPetHouseAssigned(state) { return (state.staff||[]).filter(s=>!s.isPlayer&&(state.staffAssignments||{})[s.id]==='petHouse'); }
/** Show entry fee: flat 10% of 1st prize per pet (regardless of entry index). Championship is capped at $1000. */
function showEntryFee(entryIndex, showNum, firstPrize, isChampionship=false) {
  // Championship: override to $1000 (10% of $25k would be too high)
  if(isChampionship) return 1000;
  // 10% of first prize per pet, flat — entryIndex is unused (kept for backward compat)
  return Math.round((firstPrize||500)*0.10);
}

const INGS = {
  coffee:{name:'Coffee',price:8},milk:{name:'Milk',price:5},cream:{name:'Cream',price:7},
  herbs:{name:'Herbs',price:4},matcha:{name:'Matcha',price:12},flour:{name:'Flour',price:3},
  butter:{name:'Butter',price:6},sugar:{name:'Sugar',price:3},eggs:{name:'Eggs',price:4},
  bread:{name:'Bread',price:4},tuna:{name:'Tuna',price:10},salmon:{name:'Salmon',price:14},
  rice:{name:'Rice',price:3},seaweed:{name:'Seaweed',price:6},fruit:{name:'Fruit',price:5},
  honey:{name:'Honey',price:7},cocoa:{name:'Cocoa',price:9},cinnamon:{name:'Cinnamon',price:5},
  vanilla:{name:'Vanilla',price:8},oat_milk:{name:'Oat Milk',price:6},coconut:{name:'Coconut',price:7},
  ginger:{name:'Ginger',price:4},lavender:{name:'Lavender',price:6},mint:{name:'Mint',price:3},
  caramel:{name:'Caramel',price:7},lemon:{name:'Lemon',price:3},chai:{name:'Chai',price:6},
  turmeric:{name:'Turmeric',price:5},rose:{name:'Rose',price:8},chicken:{name:'Chicken',price:9},
  cheese:{name:'Cheese',price:7},mushroom:{name:'Mushroom',price:5},avocado:{name:'Avocado',price:8},
  bacon:{name:'Bacon',price:10},tofu:{name:'Tofu',price:4},noodles:{name:'Noodles',price:4},
  peppers:{name:'Peppers',price:3},garlic:{name:'Garlic',price:2},onion:{name:'Onion',price:2},
  shrimp:{name:'Shrimp',price:12},basil:{name:'Basil',price:3},truffle:{name:'Truffle',price:18},
  chocolate:{name:'Chocolate',price:9},strawberry:{name:'Strawberry',price:5},mango_fruit:{name:'Mango',price:6},
  peanut_butter:{name:'Peanut Butter',price:5},marshmallow:{name:'Marshmallow',price:4},toffee:{name:'Toffee',price:6},
  red_bean:{name:'Red Bean',price:5},mochi_rice:{name:'Mochi',price:7},saffron:{name:'Saffron',price:15},
  tapioca:{name:'Tapioca',price:4},almond:{name:'Almond',price:6},pistachio:{name:'Pistachio',price:8},
};
const ING_KEYS = Object.keys(INGS);
const ING_POOLS = {
  drinks:['coffee','milk','cream','herbs','matcha','sugar','fruit','honey','cocoa','cinnamon','vanilla','oat_milk','coconut','ginger','lavender','mint','caramel','lemon','chai','turmeric','rose','tapioca','chocolate','saffron'],
  food:['flour','butter','eggs','bread','tuna','salmon','rice','seaweed','chicken','cheese','mushroom','avocado','bacon','tofu','noodles','peppers','garlic','onion','shrimp','basil','truffle','herbs','cream','saffron'],
  desserts:['flour','butter','sugar','cream','eggs','fruit','chocolate','vanilla','caramel','honey','cinnamon','strawberry','mango_fruit','coconut','peanut_butter','marshmallow','toffee','lemon','matcha','red_bean','mochi_rice','salmon','almond','pistachio'],
};

const MENU_TREND_TEMPLATES = [
  // Ingredient price changes (~60%)
  {type:'ingredient',text:"Egg shortage — farmers are on strike! Egg prices doubled.",icon:'🥚',effect:{ingredient:'Eggs',costMult:2.0}},
  {type:'ingredient',text:"Bumper cocoa harvest — chocolate is dirt cheap!",icon:'🍫',effect:{ingredient:'Cocoa',costMult:0.4}},
  {type:'ingredient',text:"Coffee blight hits the highlands — coffee prices have spiked!",icon:'☕',effect:{ingredient:'Coffee',costMult:1.8}},
  {type:'ingredient',text:"Matcha craze: farmers can't keep up — matcha is pricey!",icon:'🍵',effect:{ingredient:'Matcha',costMult:1.9}},
  {type:'ingredient',text:"Salmon is running — fishers report record hauls! Salmon is cheap.",icon:'🐟',effect:{ingredient:'Salmon',costMult:0.5}},
  {type:'ingredient',text:"Truffle season arrived early — truffle prices are down!",icon:'🍄',effect:{ingredient:'Truffle',costMult:0.6}},
  {type:'ingredient',text:"Sugar futures soar — sugar costs more this month.",icon:'🍬',effect:{ingredient:'Sugar',costMult:1.7}},
  {type:'ingredient',text:"Honey bees are thriving — honey flows freely and cheaply!",icon:'🍯',effect:{ingredient:'Honey',costMult:0.5}},
  {type:'ingredient',text:"Vanilla bean shortage hits global supply. Vanilla is expensive!",icon:'🌿',effect:{ingredient:'Vanilla',costMult:2.2}},
  {type:'ingredient',text:"Avocado surplus — growers overproduced this quarter!",icon:'🥑',effect:{ingredient:'Avocado',costMult:0.45}},
  {type:'ingredient',text:"Chicken flu scare — chicken prices skyrocket!",icon:'🐔',effect:{ingredient:'Chicken',costMult:2.1}},
  {type:'ingredient',text:"Flour mill strike settled — flour prices drop back down.",icon:'🌾',effect:{ingredient:'Flour',costMult:0.5}},
  {type:'ingredient',text:"Saffron heist! A truck of saffron was stolen — prices are wild.",icon:'✨',effect:{ingredient:'Saffron',costMult:2.5}},
  {type:'ingredient',text:"Almond trees are flourishing — almond prices fell sharply.",icon:'🌰',effect:{ingredient:'Almond',costMult:0.4}},
  {type:'ingredient',text:"Shrimp farming boom — shrimp are practically free this month!",icon:'🦐',effect:{ingredient:'Shrimp',costMult:0.4}},
  {type:'ingredient',text:"Strawberry season is peak — strawberries are overflowing!",icon:'🍓',effect:{ingredient:'Strawberry',costMult:0.35}},
  {type:'ingredient',text:"Oat crisis: oat fields flooded — oat milk costs more.",icon:'🌾',effect:{ingredient:'Oat Milk',costMult:1.8}},
  {type:'ingredient',text:"Local farm surplus — all butter and milk are half price!",icon:'🐄',effect:{ingredient:'Milk',costMult:0.5}},
  {type:'ingredient',text:"Mochi rice shortage — supply chain delays from overseas.",icon:'🍡',effect:{ingredient:'Mochi',costMult:1.9}},
  {type:'ingredient',text:"Caramel competition heating up — caramel is surprisingly cheap.",icon:'🍮',effect:{ingredient:'Caramel',costMult:0.55}},
  // Food fads (~25%)
  {type:'fad',text:"Smoothie summer! Everyone is craving chilled drinks.",icon:'🥤',effect:{category:'drinks',demandMult:1.4}},
  {type:'fad',text:"\"Breakfast is back\" trend — food items are flying off the shelves!",icon:'🍳',effect:{category:'food',demandMult:1.45}},
  {type:'fad',text:"Dessert-tok is viral — customers can't stop ordering sweets!",icon:'🍰',effect:{category:'desserts',demandMult:1.5}},
  {type:'fad',text:"Cold brew craze sweeping the city — drinks in high demand.",icon:'🧊',effect:{category:'drinks',demandMult:1.35}},
  {type:'fad',text:"Comfort food season — everyone wants hearty meals.",icon:'🍲',effect:{category:'food',demandMult:1.4}},
  {type:'fad',text:"Healthy eating challenge — light menu items are trending.",icon:'🥗',effect:{category:'food',demandMult:1.3}},
  {type:'fad',text:"Afternoon tea culture is taking off — all menu items in demand.",icon:'🫖',effect:{category:null,demandMult:1.2}},
  // General (~15%)
  {type:'general',text:"Health inspectors are strict this month — quality ingredients cost more.",icon:'🔍',effect:{category:null,costMult:1.3}},
  {type:'general',text:"Local ingredient subsidy — everything is slightly cheaper!",icon:'🌱',effect:{category:null,costMult:0.75}},
  {type:'general',text:"Heat wave drives everyone indoors — customer demand surges!",icon:'🌡️',effect:{category:null,demandMult:1.25}},
  {type:'general',text:"Food festival in town — menu interest is at an all-time high!",icon:'🎪',effect:{category:null,demandMult:1.3}},
];
const PERS_TABLE = [
  {value:'playful',    weight:14,tm:1.15,sb:2, e:'🎾'},
  {value:'cuddly',     weight:14,tm:1.10,sb:4, e:'🫶'},
  {value:'showoff',    weight:10,tm:1.00,sb:6, e:'✨',trendBonus:true},
  {value:'shy',        weight:10,tm:.90, sb:4, e:'🙈',shyBonus:true},
  {value:'mischievous',weight:9, tm:1.00,sb:-2,e:'😈',chaos:true},
  {value:'lazy',       weight:10,tm:1.05,sb:2, e:'😴'},
  {value:'regal',      weight:4, tm:1.15,sb:4, e:'👑'},
  {value:'glutton',    weight:5, tm:1.10,sb:0, e:'🍖'},
  {value:'adventurous',weight:8, tm:1.08,sb:4, e:'🗺️'},
  {value:'nurturing',  weight:6, tm:1.05,sb:4, e:'🤱'},
  {value:'vocal',      weight:6, tm:1.10,sb:2, e:'🎵'},
  {value:'zen',        weight:4, tm:1.08,sb:6, e:'🧘'},
];

const PERS_DESC = {
  playful:'Energetic and fun — earns the most tips and a small show boost',
  cuddly:'Affectionate with everyone — solid tips and performs well in shows',
  showoff:'Lives for the spotlight — bonus tips when trending, dominates in shows',
  shy:'Secretly charming — lower base tips but a big bonus at smaller cafes',
  mischievous:'Wild card — tips spike or crash unpredictably each week, terrible in shows',
  lazy:'Chill and low-maintenance — cheap to keep, earns a little extra in winter',
  regal:'High-maintenance royalty — pricey upkeep but earns premium tips and holds up in shows',
  glutton:'The most expensive to keep, but customers adore hand-feeding it — good steady tips',
  adventurous:'Always exploring — keeps customers engaged and impresses show judges',
  nurturing:'Gentle and calming — modest tips but great when paired with young pets and good in shows',
  vocal:'Serenades the room — usually earns more tips, but can occasionally annoy customers',
  zen:'Creates a peaceful atmosphere — steady tips and exceptional in shows',
};

const PET_UPKEEP = {glutton:30,regal:24,showoff:20,mischievous:18,playful:16,adventurous:16,cuddly:14,nurturing:14,vocal:12,zen:12,shy:10,lazy:10};
function rarityUpkeepMult(r){return r>0.65?2.5:r>0.45?1.8:r>0.25?1.3:1.0;}
function rarityTipMult(r){return r>0.65?2.0:r>0.45?1.5:r>0.25?1.2:1.0;}
function petUpkeepCost(pet){return Math.round((PET_UPKEEP[pet.personality.primary]||6)*rarityUpkeepMult(pet.stats.rarity));}
const petGender = (pet) => pet.gender || (parseInt(pet.id.replace(/\D/g,'').slice(0,4)||'0')%2===0?'male':'female');
function petPronouns(pet){
  const g=petGender(pet);
  if(g==='female')return{they:'she',them:'her',their:'her',theirs:'hers',They:'She',Them:'Her',Their:'Her'};
  return{they:'he',them:'him',their:'his',theirs:'his',They:'He',Them:'Him',Their:'His'};
}
function randomPronouns(rng){
  if(rng.bool())return{they:'she',them:'her',their:'her',They:'She',Their:'Her'};
  return{they:'he',them:'him',their:'his',They:'He',Their:'His'};
}

const SKILL_NAMES = {cooking:'Cooking',service:'Service',petCare:'Pet Care',cleaning:'Cleaning'};

const SHOW_THEMES_ALL = {
  cats:[
    {name:'Fluffiest Fur Gala',traits:['fur','rarity']},
    {name:'Eye of the Beholder',traits:['eyes','personality']},
    {name:'Fur & Eyes Exhibition',traits:['fur','eyes']},
    {name:'Rarest of the Rare',traits:['rarity','generation']},
    {name:'Purrsonality Pageant',traits:['personality','rarity']},
    {name:'Legacy Lineage Show',traits:['generation','fur']},
    {name:'Color Showcase',traits:['displayColor','fur']},
  ],
  dogs:[
    {name:'Best in Show Classic',traits:['breed','rarity']},
    {name:'Good Boy Gala',traits:['size','personality']},
    {name:'Breed & Size Exhibition',traits:['breed','size']},
    {name:'Top Dog Tournament',traits:['rarity','generation']},
    {name:'Loyal Heart Gala',traits:['personality','rarity']},
    {name:'Pack Leader Prize',traits:['generation','breed']},
    {name:'Color & Breed Show',traits:['displayColor','breed']},
  ],
  birds:[
    {name:'Plumage Paradise',traits:['plumage','rarity']},
    {name:'Rainbow Wing Gala',traits:['color','personality']},
    {name:'Plumage & Color Show',traits:['plumage','color']},
    {name:'Rarest Feathers Expo',traits:['rarity','generation']},
    {name:'Song of the Season',traits:['personality','rarity']},
    {name:'Fledgling Festival',traits:['generation','plumage']},
  ],
  lizards:[
    {name:'Scale Spectacular',traits:['scales','rarity']},
    {name:'Pattern Pageant',traits:['pattern','personality']},
    {name:'Scales & Pattern Show',traits:['scales','pattern']},
    {name:'Rarest Lizards Expo',traits:['rarity','generation']},
    {name:'Ancient Heritage Show',traits:['personality','rarity']},
    {name:"Dragon's Den Show",traits:['generation','scales']},
    {name:'Color & Scale Show',traits:['displayColor','scales']},
  ],
};

const STAFF_NAMES_FEMALE = ['Hana','Suki','Zara','Nora','Emi','Kira','Aiko','Rue','Yuki','Mia','Yuna','Lena','Saki','Nami','Mika','Rei','Haru','Yui','Asuka','Naomi'];
const STAFF_NAMES_MALE   = ['Kai','Milo','Leo','Ryo','Tomo','Dax','Finn','Jun','Ren','Hiro','Max','Kenji','Sho','Bex','Koji','Taichi','Soren','Eli','Nico','Dante'];
const STAFF_NAMES_NEUTRAL= ['Sora','Quinn','Ash','Sky','River','Sage','Robin','Blair','Lex','Phoenix','Avery','Remy','Pax','Riven','Indigo','Cleo','Onyx','Wren','Zuri','Jules'];

const MENU_SUFFIXES = {
  drinks:['Latte','Mocha','Brew','Tea','Cappuccino','Espresso','Smoothie','Frappe','Chai','Matcha','Cocoa','Americano','Fizz','Tonic','Shake','Toddy','Elixir','Infusion','Cooler','Sipper'],
  food:['Sandwich','Bowl','Wrap','Bites','Toast','Salad','Rolls','Stew','Burger','Tacos','Noodles','Platter','Stack','Melt','Medley','Feast','Delight','Special','Plate','Pot'],
  desserts:['Cookie','Cake','Pie','Tart','Scone','Muffin','Brownie','Parfait','Sundae','Crumble','Pudding','Macaron','Truffle','Donut','Cupcake','Fudge','Sorbet','Gelato','Crisp','Delight'],
};

const SEASON_MOD = {
  drinks:{Winter:1.2,Summer:0.9},food:{Autumn:1.1},desserts:{Summer:1.15,Spring:1.1}
};

const SEASONAL_EVENTS = [
  {id:'spring_adoption_drive', name:'Spring Adoption Drive', icon:'🌸', startWoy:12, description:'Pets are 30% cheaper to adopt this week!'},
  {id:'summer_beach_festival', name:'Summer Beach Festival', icon:'🏖️', startWoy:24, description:'Customers flock in (+15%) and drinks fly off the shelf (+50% demand)!'},
  {id:'autumn_harvest_fair', name:'Autumn Harvest Fair', icon:'🍂', startWoy:36, description:'Food sales surge (+50% demand) and customers tip generously.'},
  {id:'winter_holiday_cheer', name:'Winter Holiday Cheer', icon:'❄️', startWoy:44, description:'Holiday spirit boosts all pet tips by 25%!'},
];

// ─── UTILS ────────────────────────────────────────────────────────────────────
class RNG {
  constructor(seed) { this.s = (seed>>>0)||1; }
  n() { let t=(this.s+=0x6D2B79F5);t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return((t^(t>>>14))>>>0)/4294967296; }
  r()           { return this.n(); }
  i(lo,hi)      { return lo+Math.floor(this.n()*(hi-lo+1)); }
  pick(a)       { return a[Math.floor(this.n()*a.length)]; }
  bool(p=.5)    { return this.n()<p; }
  sample(a,k)   { const b=[...a],o=[];for(let i=0;i<k&&b.length;i++){const j=Math.floor(this.n()*b.length);o.push(b.splice(j,1)[0]);}return o; }
  wp(t)         { let r=this.n()*t.reduce((s,e)=>s+e.weight,0);for(const e of t){r-=e.weight;if(r<=0)return e.value;}return t[t.length-1].value; }
  iwp(t)        { return this.wp(t.map(e=>({value:e.value,weight:1/e.weight}))); }
}
const clamp=(v,lo,hi)=>Math.max(lo,Math.min(hi,v));
const $=(n)=>n<0?'-$'+Math.round(-n).toLocaleString():'$'+Math.round(n).toLocaleString();
const comboKey=(ph)=>Object.entries(ph).sort().map(([k,v])=>k[0]+v[0]).join('');
// Fisher-Yates shuffle using a seeded RNG. Produces a uniform shuffle, unlike
// sort(() => rng.bool() ? 1 : -1) which yields biased orderings.
const shuffle=(arr,rng)=>{const a=[...arr];for(let i=a.length-1;i>0;i--){const j=Math.floor(rng.n()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;};

// ─── GENETICS ─────────────────────────────────────────────────────────────────
/**
 * Genetics module: handles pet genome creation, breeding, mutation, and rarity.
 * Each pet has a genome with 2 traits (species-specific, e.g. cats=fur+eyes).
 * Each trait has 2 alleles (a,b). Rarer alleles dominate in expression.
 * Rarity is 0-1 based on how uncommon the expressed phenotype is.
 */
const Genetics = {
  mk(rng,tt,gen=0,alo=7,ahi=21) {
    const genome={};
    for(const[t,al]of Object.entries(tt)) genome[t]={a:rng.wp(al),b:rng.wp(al)};
    const ph=this.express(genome,tt),r=this.rarity(ph,tt),p=Pers.gen(rng);
    const age=rng.i(alo,ahi);
    return {
      id:'p'+rng.i(1e6,9e6).toString(36),
      name:'?', // set by caller with species names
      gender:rng.bool()?'male':'female',
      genome,phenotype:ph,personality:p,
      stats:{appeal:Math.max(1,Math.ceil(r*10)),rarity:r},
      state:{age,breedCooldown:0,isKitten:age<1,isRetired:false,retiring:0,generation:gen,sick:0},
      location:'pen',parentA:null,parentB:null,totalTipsEarned:0,
      wish:null,fulfilledWishes:0,relationships:[],
    };
  },
  breed(pA,pB,rng,tt) {
    const n=rng.i(1,3); // 1-3 babies per litter (capped to pen space by caller)
    const rarityTier=r=>r>0.65?3:r>0.45?2:r>0.25?1:0;
    const avgTier=Math.round((rarityTier(pA.stats.rarity)+rarityTier(pB.stats.rarity))/2);
    const minTier=Math.max(0,avgTier-1), maxTier=Math.min(3,avgTier+1);
    const tierFloors=[0,0.26,0.46,0.66], tierCeils=[0.25,0.45,0.65,1.0];
    const rollOnce=()=>{
      const g={};
      for(const t of Object.keys(tt)) g[t]={a:rng.pick([pA.genome[t].a,pA.genome[t].b]),b:rng.pick([pB.genome[t].a,pB.genome[t].b])};
      // Lucky spark: chance one trait gets the rarest alleles (always some chance of uncommon+)
      if(rng.bool(GAME_CONFIG.LUCKY_SPARK_CHANCE)){
        const traitKeys=Object.keys(tt);
        const t=rng.pick(traitKeys);
        const rarest=[...tt[t]].sort((a,b)=>a.weight-b.weight)[0].value;
        g[t]={a:rarest,b:rarest};
      }
      const mg=this.mut(g,rng,tt),ph=this.express(mg,tt);
      return {mg,ph,r:this.rarity(ph,tt)};
    };
    return Array.from({length:n},()=>{
      // Re-roll up to 8 times to land within parent-avg ±1 tier so phenotype matches rarity number.
      let result=rollOnce();
      let tries=0;
      while(tries<8){
        const t=rarityTier(result.r);
        if(t>=minTier&&t<=maxTier) break;
        result=rollOnce();
        tries++;
      }
      let {mg,ph,r}=result;
      // Final safety clamp if we never landed in range (rare with 8 tries)
      const babyTier=rarityTier(r);
      if(babyTier<minTier) r=tierFloors[minTier]+rng.r()*(tierCeils[minTier]-tierFloors[minTier]);
      if(babyTier>maxTier) r=tierFloors[maxTier]+rng.r()*(tierCeils[maxTier]-tierFloors[maxTier]);
      return {
        id:'p'+rng.i(1e6,9e6).toString(36),name:'?',
        gender:rng.bool()?'male':'female',
        genome:mg,phenotype:ph,personality:Pers.gen(rng),
        stats:{appeal:Math.max(1,Math.ceil(r*10)),rarity:r},
        state:{age:0,breedCooldown:0,isKitten:true,isRetired:false,generation:Math.max(pA.state.generation,pB.state.generation)+1,sick:0},
        location:'nursery',parentA:pA.id,parentB:pB.id,
        wish:null,fulfilledWishes:0,relationships:[],
      };
    });
  },
  mut(g,rng,tt) {
    const o={};
    for(const[t,al]of Object.entries(tt)){
      // Mutations pick uniformly so rare traits are genuinely possible
      o[t]={a:rng.bool(GAME_CONFIG.MUTATION_RATE)?rng.pick(al.map(a=>a.value)):g[t].a,b:rng.bool(GAME_CONFIG.MUTATION_RATE)?rng.pick(al.map(a=>a.value)):g[t].b};
    }
    return o;
  },
  express(g,tt) {
    // Rarer alleles dominate: trait tables are sorted commons-first (higher index = rarer),
    // so we pick the allele with the GREATER index.
    const ph={};
    for(const[t,al]of Object.entries(tt)){const ns=al.map(a=>a.value),ia=ns.indexOf(g[t].a),ib=ns.indexOf(g[t].b);ph[t]=ia>=ib?g[t].a:g[t].b;}
    return ph;
  },
  rarity(ph,tt) {
    let s=0;
    for(const[t,al]of Object.entries(tt)){const e=al.find(a=>a.value===ph[t]);if(e){const mx=Math.max(...al.map(a=>a.weight));s+=(mx-e.weight)/mx;}}
    return s/Object.keys(tt).length;
  },
};

// ─── PERSONALITY ──────────────────────────────────────────────────────────────
/** Personality module: 12 traits affecting tips, show performance, and upkeep costs. */
const Pers = {
  gen(rng) { return{primary:rng.wp(PERS_TABLE)}; },
  entry(n) { return PERS_TABLE.find(p=>p.value===n)||PERS_TABLE[0]; },
  emoji(n) { return this.entry(n).e; },
  tipsMult(pers,floorCt,season,hasYoungPets=false) {
    const e=this.entry(pers.primary);let m=e.tm||1;
    if(e.shyBonus&&floorCt<=3) m=1.25;
    if(pers.primary==='lazy'&&season==='Winter') m*=1.05;
    if(pers.primary==='nurturing'&&hasYoungPets) m*=1.2;
    return m;
  },
  showBonus(pers) { return (this.entry(pers.primary).sb||0); },
};

// ─── LETTERS ──────────────────────────────────────────────────────────────────
const LETTERS = [
  {
    id: 'mayor_intro',
    from: 'Mayor Hollis',
    title: 'A welcome from City Hall',
    icon: '🏛️',
    condition: (s) => s.week >= 0,
    body: (s) => [
      `Dear ${s.playerName||'friend'},`,
      `On behalf of the city, welcome. I won't pretend things have been easy here. Three of our high-street shops shuttered this year alone, the old market hall stands half-empty, and folks have started taking the train out of town for their weekend treats. We need a heart again, somewhere people want to GO.`,
      `That's where you and ${s.cafeName||'your cafe'} come in. The council voted to back you because we believe a place full of warm light, good food, and animals people can love is exactly the kind of small magic this city needs. I know that's a lot to put on a single cafe. But I've seen what one good corner can do for a neighborhood.`,
      `I'll check in from time to time. Build something we can be proud of, and the city will rise with you.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_cafe_upgrade',
    from: 'Mayor Hollis',
    title: 'The first step up',
    icon: '⬆️',
    condition: (s) => (s.cafeLevel||1) >= 2,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I walked past ${s.cafeName||'your cafe'} this morning and noticed the work you've put into the place. That first upgrade matters more than you might think. When people see a business investing in itself, they invest back.`,
      `There's been chatter at the market already. "Have you seen what they've done to the corner place?" That's the kind of talk this street has been missing. Keep expanding, keep improving. The city is paying attention.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_3_podiums',
    from: 'Mayor Hollis',
    title: 'The city is proud',
    icon: '🥉',
    condition: (s) => (s.totalPodiums||0) >= 3,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Three podium finishes. I've started keeping a tally on my desk. The judging community is beginning to recognise this city as a place that produces quality animals, and that reputation doesn't grow by accident.`,
      `A few parents have brought their children in after school specifically to see the pets. The tourism board is asking me what's driving the visitor numbers. I tell them it's the work of one very dedicated cafe owner.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year1',
    from: 'Mayor Hollis',
    title: 'One year on',
    icon: '📅',
    condition: (s) => Time.year(s.week) >= 2,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `A full year since we shook hands. I want to be honest with you: I wasn't certain this would work. Not because I doubted you, but because this city has seen a lot of promising ventures fade after the first winter. You didn't fade.`,
      `The bakery next door told me their morning trade is up because people come early for ${s.cafeName||'your cafe'} and stop in. The flower shop is seeing the same. You are quietly becoming the anchor of this end of the high street, and I couldn't be more grateful.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_show_win',
    from: 'Mayor Hollis',
    title: 'The whole city heard',
    icon: '🏆',
    condition: (s) => (s.totalFirstPlace || 0) >= 1,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Word reached City Hall before lunchtime. Your first ribbon! Three different people stopped me on Main Street to make sure I'd heard. ${s.cafeName||'Your cafe'} is on every front porch this morning, and someone has already put up a notice in the bakery window.`,
      `This is exactly what I hoped for when we backed you. People needed something to talk about that wasn't another closure. They needed something to be proud of together. You've given them that.`,
      `Keep going. The city is watching, and for the first time in a long while, that's a good thing.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_legendary',
    from: 'Mayor Hollis',
    title: 'People are taking the train IN',
    icon: '✨',
    condition: (s) => (s.totalLegendaryBred || 0) >= 1,
    body: (s) => [
      `${s.playerName||'Dear friend'},`,
      `I had to write the moment I heard. A legendary pet, bred right here, in our city. Do you know what that means? Visitors. Real visitors. The station master told me three families came in on the morning train just to see ${s.cafeName||'your cafe'}, and the bakery sold out of pastries by ten.`,
      `For years we've watched our young people leave for bigger places. Now bigger places are sending their people HERE. I am not exaggerating when I say this changes things for us. The council is already discussing repairing the old market square, partly because of what you've started.`,
      `Whatever you're doing, keep doing it. The whole city is in your debt.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_10_pets',
    from: 'Mayor Hollis',
    title: 'Quite the family',
    icon: '🐾',
    condition: (s) => (s.pets||[]).length >= 10,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Someone in the council meeting mentioned that ${s.cafeName||'your cafe'} now houses over ten animals. Someone else immediately asked if they could bring their grandchildren for a visit. This is what I mean when I say you've changed the character of this city.`,
      `The vet on Elm Street told me her business is up and she suspects it's partly because residents are more aware of animals now. There's a civic warmth here that wasn't here before. You did that.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_pet_house_upgrade',
    from: 'Mayor Hollis',
    title: 'A proper home for your animals',
    icon: '🏠',
    condition: (s) => (s.petHouseLevel||1) >= 3,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I drove by the expanded pet house this morning. It looks wonderful. A few neighbours came out to watch the builders finish the last panels. There's something about seeing that investment, people feel it as a signal that you're here to stay, not to cash out and leave.`,
      `That's the kind of commitment that earns a city's loyalty. Not words. Walls.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year2',
    from: 'Mayor Hollis',
    title: 'Two years, and growing',
    icon: '📈',
    condition: (s) => Time.year(s.week) >= 3 && (s.totalFirstPlace||0) < 1,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Two years is a milestone most new businesses on this street never reach. You've reached it, and you've grown. The high street vacancy rate is at its lowest since I took office. I won't pretend that's all you, but I'll tell you this: it started with you.`,
      `Stay focused. The best years are still ahead.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year3',
    from: 'Mayor Hollis',
    title: 'Trouble on the horizon',
    icon: '⚠️',
    condition: (s) => Time.year(s.week) >= 3,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I hate to bring you difficult news, but you should hear it from me first. A national chain, one of the big ones, has been quietly buying up property two blocks from ${s.cafeName||'your cafe'}. They've made no announcement yet, but the paperwork is on file at City Hall. They will open. They will undercut. That's how they work.`,
      `The council can't legally block them, much as I'd like to. What we CAN do is stand behind you publicly, and I will. Every storefront that fails on our high street, every neighbor who switches to their bland coffee, that's another piece of who we are gone for good.`,
      `Don't let them push you out. The city remembers who showed up first, and that's you.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_rival1_end',
    from: 'Mayor Hollis',
    title: 'You outlasted them',
    icon: '🎉',
    condition: (s) => s.rivalCafe1Ended,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `The chain is gone. Their signs came down overnight, and by morning the windows were papered over. I'll be honest, there were weeks I worried. Their marketing budget is ten times ours and they undercut everyone in sight. But people came back to ${s.cafeName||'your cafe'} anyway, because you give them something a franchise never can.`,
      `This city owes you one. The small shops on the high street have noticed, and two of the empty storefronts have new lease enquiries because of the foot traffic you kept alive. That's your doing.`,
      `You've earned this one. Enjoy it.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_cafe_max',
    from: 'Mayor Hollis',
    title: 'A landmark of the city',
    icon: '🏰',
    condition: (s) => (s.cafeLevel||1) >= 6,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `${s.cafeName||'Your cafe'} has reached a scale I genuinely did not imagine when we first spoke. Fully built out, full staff, the whole operation humming along. People come from three towns over now.`,
      `The council has formally designated you as a "City Anchor Business." That's a new category I invented last month specifically because you exist. Frame the certificate if you'd like, but know it means more than paper: it means this city considers your success our success.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year4',
    from: 'Mayor Hollis',
    title: 'Four strong years',
    icon: '🌟',
    condition: (s) => Time.year(s.week) >= 4,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Four years. The city looks different than it did when you opened. There are children here who grew up knowing ${s.cafeName||'your cafe'} has always been there. That's a strange and beautiful thing.`,
      `I'm starting to draft the city's ten-year development plan. For the first time in a long while, I'm writing it with optimism. You're a large part of why.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year5',
    from: 'Mayor Hollis',
    title: 'Represent us at the Nationals',
    icon: '👑',
    condition: (s) => Time.year(s.week) >= 5,
    body: (s) => [
      `${s.playerName||'Dear friend'},`,
      `Five years. When I wrote you that first letter, I was hoping for a bright spot on a tired street. What you've built has become so much more than that.`,
      `So I have one more thing to ask. The National Championship is held once a year, the finest breeders in the country, the most rarefied judging in the sport. No one from this city has ever entered. The council met last week and voted unanimously: when you're ready, we want you to represent us. Not for the prize money. For the simple, stubborn fact that we are HERE, and we deserve to be on that stage.`,
      `Win or lose, you'll be carrying the city with you. We'll be cheering from every window.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_pet_house_max',
    from: 'Mayor Hollis',
    title: 'The finest animal facility in the region',
    icon: '🏰',
    condition: (s) => (s.petHouseLevel||1) >= 10,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I received a letter this week from the Regional Breeding Association. They want to use our city as the example when making the case for independent breeding facilities nationwide. The reason they cited: the quality of your pet house.`,
      `I don't say this lightly, but what you've built is genuinely world-class. I only ask that you keep letting school groups visit. The look on those children's faces is worth more to this city than any statistic.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year6',
    from: 'Mayor Hollis',
    title: 'The city has changed',
    icon: '🌆',
    condition: (s) => Time.year(s.week) >= 6,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Six years. The property values on our high street are up for the third consecutive year. We have a waiting list for the market square stalls. Three new independent shops opened this spring, and all three told me they chose our city specifically because of the foot traffic and the atmosphere you've helped create.`,
      `You came here to run a cafe. You ended up reviving a neighborhood. I'm not sure you fully understand what you've done here. The city does.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_rival2_start',
    from: 'Mayor Hollis',
    title: 'A much bigger threat',
    icon: '🚨',
    condition: (s) => s.rivalCafe2Started,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I won't dress this up. What's coming is not the same as last time. This is one of the largest hospitality chains in the country, and they have just purchased the old market hall, two blocks from ${s.cafeName||'your cafe'}. Their fit-out is funded, their staff already hired, and their launch campaign is national.`,
      `City Hall has done everything legally possible to slow their planning permission. We bought you a few months. That's all I could manage.`,
      `What I can tell you is this: the people who matter, the ones who've been coming every week for years, they don't want the chain. They want you. Hold on. The city is behind you.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year9',
    from: 'Mayor Hollis',
    title: 'The final stretch',
    icon: '⏳',
    condition: (s) => Time.year(s.week) >= 9,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I've been in this office for nearly a decade now, and I can tell you without hesitation: no single decision I made has done more for this city than backing you and ${s.cafeName||'your cafe'}.`,
      `You're in the final stretch of the commitment we discussed. Whatever happens, the legacy is already written. This city is more alive than it has been in thirty years. Finish strong.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_rival2_end',
    from: 'Mayor Hollis',
    title: 'Against all odds',
    icon: '🏆',
    condition: (s) => s.rivalCafe2Ended,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I am writing this from the steps of City Hall with a cup of your coffee in my hand, because I needed to do something ceremonial. The chain's regional director confirmed this morning that they are pulling out of this location. They said it wasn't profitable enough. What they mean is: you made sure of that.`,
      `Do you know how rare this is? National chains don't lose to independent cafes. But you built something they couldn't buy, loyalty you can't fake, animals people genuinely love, a place with a soul.`,
      `${s.cafeName||'Your cafe'} is now part of this city's story in a way no chain ever could be. I'll be proud to tell it for the rest of my time in this office.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_breed',
    from: 'Mayor Hollis',
    title: 'A new generation begins',
    icon: '🐣',
    condition: (s) => (s.totalBred||0) >= 1,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `I heard from one of the morning regulars that ${s.cafeName||'your cafe'} has its first new arrival. There's something about a successful breeding that lifts the spirit of an entire street, even for those of us who only catch a glimpse through the window.`,
      `Take care of the little one. The city is already invested in seeing them grow up here.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_5_pets',
    from: 'Mayor Hollis',
    title: 'A growing little family',
    icon: '🐾',
    condition: (s) => (s.pets||[]).length >= 5,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Five animals at ${s.cafeName||'your cafe'} now. I overheard a few council staff debating which one was their favourite over coffee this morning. I won't name names, but the city accountant has very strong opinions about the orange one.`,
      `Keep growing the family. Each animal you bring in seems to bring a few more people through the door.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_uncommon',
    from: 'Mayor Hollis',
    title: 'Out of the ordinary',
    icon: '💎',
    condition: (s) => (s.totalUncommonBred||0) >= 1,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `Word at the council is that you've bred something unusual. I'm no expert in pet genetics, but the neighbours are excited, and that's all I really need to know.`,
      `Whatever colour, pattern, or trait you've coaxed out of those parents, the city is curious. Keep at it.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_rare',
    from: 'Mayor Hollis',
    title: 'A genuine rarity',
    icon: '💎',
    condition: (s) => (s.totalRareBred||0) >= 1,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `I came by ${s.cafeName||'your cafe'} on my lunch break specifically to see the rare pet I'd been hearing about. I know it's not exactly mayoral business, but neither is half of what I do these days, and this one felt important.`,
      `Whatever it is you understand about animals that I don't, it's clearly working. I left smiling, and I needed to leave smiling today.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_high_earnings',
    from: 'Mayor Hollis',
    title: 'A serious enterprise',
    icon: '💰',
    condition: (s) => (s.totalEarned||0) >= 100000,
    body: (s) => [
      `${s.playerName||'Friend'},`,
      `The city accountant flagged something in his quarterly review and brought it to me with a small smile. Apparently ${s.cafeName||'your cafe'} has now generated over a hundred thousand in cumulative revenue. I told him to put a star next to it for me.`,
      `I know money isn't why you do this. But there is something deeply satisfying about a community business that simply works. One that keeps the lights on, pays its people, and asks for nothing it has not earned. Be proud of this one.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_5_first_places',
    from: 'Mayor Hollis',
    title: 'Five blue ribbons',
    icon: '🎀',
    condition: (s) => (s.totalFirstPlace||0) >= 5,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Five wins now. Someone has put up a small handwritten sign in the bakery window: "Champion Cafe, see them at ${s.cafeName||'the cafe'}." I think the baker did it himself.`,
      `Most cities measure their success in business permits and tax receipts. Ours is starting to measure it in your ribbons. I would take the ribbons any day.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_25_podiums',
    from: 'Mayor Hollis',
    title: 'A long shelf',
    icon: '🏅',
    condition: (s) => (s.totalPodiums||0) >= 25,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `I've just been told ${s.cafeName||'your cafe'} now has twenty-five podium finishes to its name. I have started picturing the wall where you keep them. Is there even room left? If you need a bigger frame, the city woodworker on Hill Street owes me a favour.`,
      `Either way, the consistency is what gets me. Anyone can have a lucky season. Twenty-five podiums is no luck. That is craft.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_5_legendaries',
    from: 'Mayor Hollis',
    title: 'A reputation that travels',
    icon: '✨',
    condition: (s) => (s.totalLegendaryBred||0) >= 5,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Five legendaries now. Five! When I told my wife at dinner she said "From OUR town?" with a look on her face I have not seen in years. That's what you've done. You have made us look at our own city differently.`,
      `A breeder from two regions over wrote me asking if she could visit ${s.cafeName||'your cafe'} as a "professional courtesy." I told her absolutely, and to please bring her appetite for our pastries while she's at it.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_first_celebrity',
    from: 'Mayor Hollis',
    title: 'A famous customer',
    icon: '🌟',
    condition: (s) => (s.celebrityAdoptions||[]).length >= 1,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Did the celebrity actually come into ${s.cafeName||'your cafe'}? In OUR city? My press secretary nearly fell out of her chair when she saw the news. Whatever you decided to do, keep the pet or not, the simple fact that they came here is going to be talked about for weeks.`,
      `I would ask for an autograph but I think you have earned more autographs than they have at this point.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_10_first_places',
    from: 'Mayor Hollis',
    title: 'Ten ribbons, and counting',
    icon: '🏆',
    condition: (s) => (s.totalFirstPlace||0) >= 10,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Ten first-place wins. Do you remember our first letter? I wrote it because I was worried about a city losing its character. Now I write you because the city has gained a new character of its own, yours.`,
      `My daughter asked me last week if she could intern at ${s.cafeName||'your cafe'} after school. She is twelve. I told her she would have to ask you herself. Don't be too gentle with her. She needs to learn what real work looks like, and there is no better place I can think of.\n\nMayor Hollis`,
    ],
  },
  {
    id: 'mayor_year7',
    from: 'Mayor Hollis',
    title: 'Seven years on the high street',
    icon: '🕯️',
    condition: (s) => Time.year(s.week) >= 7,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Seven years. I sat down to write you tonight and realised I no longer think of ${s.cafeName||'your cafe'} as a business. It is just part of the city now, the way the river is, or the old clock tower. People give directions by it. "Turn left at ${s.cafeName||'the cafe'}." It is woven in.`,
      `My wife says I write you too often and that you must be sick of these letters. I do not think you are. I hope you're not. Some Sundays, writing to you is the most honest thing I do all week.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_year8',
    from: 'Mayor Hollis',
    title: 'A confession',
    icon: '🍂',
    condition: (s) => Time.year(s.week) >= 8,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Eight years. I'll tell you something I have not told many people. When I took this office, I was sure I'd be a one-term mayor. The numbers were against this city, the mood was against me, and frankly, I was running out of ideas. Then a council member dropped a folder on my desk with your application in it.`,
      `I read it three times before I voted yes. Something in the way you wrote about the cafe, I remember it word for word, convinced me you saw what I saw. That a place could be more than what it sold.`,
      `You proved me right. I am running for a fourth term in the spring. Wish me luck.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_year10',
    from: 'Mayor Hollis',
    title: 'A decade of you',
    icon: '🎂',
    condition: (s) => Time.year(s.week) >= 10,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Ten years. I will not write a long letter for this one. Some milestones do not need a speech.`,
      `I came in this morning, before opening, and just sat at the corner table with a coffee for half an hour. Nobody said anything because I did not want anyone to. I just wanted to be in the room you built. It is a remarkable room, and you should feel that, because most of the world will not tell you so out loud.`,
      `Ten years. Thank you.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_25_legendaries',
    from: 'Mayor Hollis',
    title: 'A legacy of legends',
    icon: '👑',
    condition: (s) => (s.totalLegendaryBred||0) >= 25,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Twenty-five legendary pets, all from ${s.cafeName||'one cafe'} on a quiet street in a quiet city that nobody outside the region had heard of a decade ago. The Regional Breeding Association now sends students here to study what you do. I sit in on the first day of every cohort and pretend not to be the proudest person in the room.`,
      `I do not think you fully understand what you have built. That is part of why it works, the people who change things rarely do.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_50_pets_total',
    from: 'Mayor Hollis',
    title: 'Fifty animal lives',
    icon: '💝',
    condition: (s) => ((s.pets||[]).length + (s.pastPets||[]).length) >= 50,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `Fifty animals have lived under your care. Some of them have moved on, some are still curled up by your window. I think about that sometimes, what it must be like to be responsible for fifty small lives across a decade of work.`,
      `I lost my old terrier last winter. He was eighteen, deaf, and grumpy, and I loved him more than I love most people in this office. So I think I understand, in a small way, what every one of those fifty meant to you. Thank you for taking it seriously.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_year11',
    from: 'Mayor Hollis',
    title: 'Some things I wanted to say',
    icon: '✉️',
    condition: (s) => Time.year(s.week) >= 11,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `My daughter came home from her shift at ${s.cafeName||'your cafe'} last night and told me you had let her name one of the new arrivals. She has been walking around the house calling out the name to herself, very quietly, like it is a secret.`,
      `I do not know if you remember the letter where I mentioned her wanting to intern. That was four years ago. Now she talks about going to school for veterinary work. I have no doubt where that came from.`,
      `Some things you change without ever knowing you changed them. This is one of them. I needed you to know.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_year12',
    from: 'Mayor Hollis',
    title: 'On stepping back',
    icon: '🌅',
    condition: (s) => Time.year(s.week) >= 12,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `I am going to tell you something before I make it public, because you have earned it. I am not running for another term. After the spring election, a younger council member will sit in this chair. I wanted you to hear it from me.`,
      `Do not worry about the cafe. Whoever takes my seat will be smart enough to know that ${s.cafeName||'your cafe'} is the cornerstone of this city's identity now. Nobody who sees the numbers could miss it.`,
      `I am tired, ${s.playerName||'my friend'}. But I am tired the way you should be after a long good day. That is the gift you and a handful of others gave me. I do not think I would have lasted this long otherwise.\n\nHollis`,
    ],
  },
  {
    id: 'mayor_year13',
    from: 'Hollis',
    title: 'From a private citizen now',
    icon: '☕',
    condition: (s) => Time.year(s.week) >= 13,
    body: (s) => [
      `${s.playerName||'My friend'},`,
      `My last day in office was Tuesday. I packed up the corner office and cried, briefly, in the elevator. Then I went straight to ${s.cafeName||'your cafe'} and ordered the same thing I always order. The girl at the counter knew it without me saying it, and that's when I cried again.`,
      `I am writing you as a private citizen now. No more "Mayor" at the bottom of this page. Just an old friend who shares a city with you.`,
      `I will see you Sundays.\n\nWith love,\nHollis`,
    ],
  },
  {
    id: 'mayor_year14',
    from: 'Hollis',
    title: 'Almost the whole story',
    icon: '🌳',
    condition: (s) => Time.year(s.week) >= 14,
    body: (s) => [
      `${s.playerName||'Dear friend'},`,
      `It is odd writing you now without the title at the top. You were the first person I wrote a Mayor's letter to, and you are one of the few I still write to in retirement.`,
      `I have been thinking about all the years. About the way ${s.cafeName||'your cafe'} smelled in the early days when you were still learning the espresso machine. About the first show you won. About the chains you outlasted, both of them. About the children who grew up here and now bring their own children to see the animals.`,
      `Whatever you do next, however you decide to write the last chapter, I will be there to read it. You owe nothing more to anyone. Everything from here is grace.\n\nHollis`,
    ],
  },
  // ── GIDEON VALE (rival owner) ─────────────────────────────────────────────
  {
    id:'gideon_intro', from:'Gideon Vale', title:'A welcome, of sorts', icon:'😼', npc:'rival_owner',
    condition:(s)=>s.week>=8,
    body:(s)=>[
      `${s.playerName||'You'},`,
      `I run the Copper Kettle, six blocks over. You may not have heard of us. That's fine — we've heard of you. My barista came back from your place last week with a coffee and a smile I haven't seen in months, and I had to ask her why.`,
      `So, a professional courtesy: welcome to the high street. I am not a sentimental man. I don't believe the city "needs a heart" the way the mayor keeps saying it does. I believe it needs espresso that doesn't taste burnt and a chair that doesn't wobble. If you provide those, we are colleagues. If not, we are not.\n\nGideon Vale, Copper Kettle`,
    ],
  },
  {
    id:'gideon_criticism', from:'Gideon Vale', title:'I read the review', icon:'☕', npc:'rival_owner',
    condition:(s)=>(s.npcs?.rival_owner?.affinity||0)>=2&&s.week>=16,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `Marigold Sterne reviewed you in the weekend paper. I read it three times. She wrote about your cafe the way she wrote about my father's place when I was twelve. I thought that kind of writing had retired with her arthritis.`,
      `Don't let it go to your head. She is ruthless with her second visits. But — and I will deny this if anyone asks — it was a fair review.\n\nGideon`,
    ],
  },
  {
    id:'gideon_trade', from:'Gideon Vale', title:'A quiet proposal', icon:'🤝', npc:'rival_owner',
    condition:(s)=>(s.npcs?.rival_owner?.affinity||0)>=5&&s.week>=24,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `I have a line on a litter nobody else knows about. A breeder upstate, retiring, wants his animals to go to places that will love them. I cannot take all of them. You can.`,
      `If you are interested, the offer stays open. This is the kind of thing one says to a colleague, not a competitor. Take that however you wish.\n\nGideon`,
    ],
  },
  {
    id:'gideon_max', from:'Gideon Vale', title:'To my friend', icon:'💛', npc:'rival_owner',
    condition:(s)=>(s.npcs?.rival_owner?.affinity||0)>=9,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `The mayor cornered me last Tuesday and asked when I'd admit that you'd changed my mind about all of this. I told him I would admit it when I was ready, and he laughed, because he knew I already had.`,
      `You have made this street something worth defending. When the next chain comes — and one always comes — I will stand with you. Not for sentiment. For the work.\n\nYour colleague,\nGideon`,
    ],
  },
  // ── MARIGOLD STERNE (food critic) ────────────────────────────────────────
  {
    id:'critic_intro', from:'Marigold Sterne', title:'A note before the review', icon:'🍴', npc:'food_critic',
    condition:(s)=>s.week>=20&&(s.discoveredItemIds||[]).length>=6,
    body:(s)=>[
      `To the proprietor of ${s.cafeName||'your cafe'},`,
      `I visited twice last week — once on Tuesday in the rain, once on Saturday at the height of the lunch rush. You did not recognize me. Nobody does; I have made a career of not being noticed until the column comes out.`,
      `The review will run next Sunday. I will not spoil it. I will only say that I have been writing about this city's food for thirty-one years, and there are exactly four places in it where I would bring a friend and mean it. You may be on the edge of becoming the fifth. Don't rush it.\n\nMarigold Sterne`,
    ],
  },
  {
    id:'critic_review', from:'Marigold Sterne', title:'The column', icon:'📝', npc:'food_critic',
    condition:(s)=>(s.npcs?.food_critic?.affinity||0)>=3&&s.week>=28,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `My editor asked me why I wrote so many words about a small cafe on a tired corner, and I told her I wrote the number of words I thought the place deserved. She let it run at full length. That does not happen.`,
      `Gideon Vale wrote me a note about the piece. "Fair," he said. From Gideon, that is a standing ovation. Take the compliment, keep your head down, and make sure the espresso stays exactly the way it was on the Saturday I came in.\n\nMarigold`,
    ],
  },
  {
    id:'critic_revisit', from:'Marigold Sterne', title:'The dangerous visit', icon:'🔍', npc:'food_critic',
    condition:(s)=>(s.npcs?.food_critic?.affinity||0)>=6&&s.week>=30,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `Every cafe I review gets a second visit twelve weeks later. That is the dangerous one. The first review is a snapshot; the second is a judgment on whether the person in the kitchen believed their own praise.`,
      `I was there last Thursday. You did not coast. That is the highest compliment I know how to give.\n\nMarigold`,
    ],
  },
  {
    id:'critic_max', from:'Marigold Sterne', title:'The fifth place', icon:'⭐', npc:'food_critic',
    condition:(s)=>(s.npcs?.food_critic?.affinity||0)>=9,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `In my office I keep a small card index — yes, still paper — of the places I would bring a friend and mean it. For eleven years the card has held four names. I retired one last spring. Today I wrote yours in the empty slot.`,
      `I am not in the habit of sending thank-you notes to restaurants. I am making an exception.\n\nMarigold`,
    ],
  },
  // ── MARCUS BELL (loyal regular) ──────────────────────────────────────────
  {
    id:'marcus_intro', from:'Marcus Bell', title:'A note from the corner table', icon:'☕', npc:'loyal_regular',
    condition:(s)=>s.week>=14,
    body:(s)=>[
      `Hi ${s.playerName||'there'},`,
      `You don't know me by name — I'm the guy in the blue cardigan who sits at the corner table most mornings and orders whatever the barista recommends. My name is Marcus. I work two blocks from here and I come to ${s.cafeName||'your cafe'} because it is, quite simply, the best part of my day.`,
      `I don't usually write letters to cafes. But the steam from your espresso machine and the cat that sleeps in the window have made a hard year more bearable, and I wanted someone to know it. Thank you.\n\nMarcus`,
    ],
  },
  {
    id:'marcus_friend', from:'Marcus Bell', title:'I brought someone today', icon:'👋', npc:'loyal_regular',
    condition:(s)=>(s.npcs?.loyal_regular?.affinity||0)>=3&&s.week>=22,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `I brought my sister today. She drove down from the city, which she never does, and I wanted her to see the place that has been keeping me afloat. She got the almond croissant. She cried a little. She said "I see why you love it here" and then she ordered a second one.`,
      `Marigold Sterne's review is taped to our fridge at home. My sister asked if I knew the owner. I said "a little." I felt proud about it, which is an odd thing to feel about a coffee shop, except that it isn't, not really.\n\nMarcus`,
    ],
  },
  {
    id:'marcus_retirement', from:'Marcus Bell', title:'About that old girl in the window', icon:'🐾', npc:'loyal_regular',
    condition:(s)=>(s.npcs?.loyal_regular?.affinity||0)>=6&&s.week>=28,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `The old cat who used to sleep in the front window — your barista told me she's getting on in years and that you've been wondering what happens when she's ready to retire from the cafe floor. I want to ask, carefully, if I might be part of that.`,
      `I have a quiet apartment and a soft chair by the heater. I would give her the kind of days a senior cat deserves, and I would bring her in for visits whenever she liked. No pressure. I only wanted you to know the option exists, from someone she already knows.\n\nMarcus`,
    ],
  },
  {
    id:'marcus_max', from:'Marcus Bell', title:'The corner table', icon:'💝', npc:'loyal_regular',
    condition:(s)=>(s.npcs?.loyal_regular?.affinity||0)>=9,
    body:(s)=>[
      `${s.playerName||'Friend'},`,
      `Mayor Hollis stopped me on the street last week. He said "You're the Marcus who writes to ${s.playerName||'them'}, aren't you?" I asked how he knew, and he said "Because everybody on the high street writes letters to them now. You started something."`,
      `I don't think I started anything. I think ${s.cafeName||'your cafe'} did, and the rest of us just finally noticed. Anyway — the corner table is still the best seat in town. See you tomorrow morning.\n\nMarcus`,
    ],
  },
];

// ─── NPCS ─────────────────────────────────────────────────────────────────────
const NPCS = {
  mayor_hollis: {name:'Mayor Hollis', icon:'🏛️', visitEveryWeeks:8, perkLabel:'+5% menu revenue', preferredGift:'food', greeting:"I was passing through the neighborhood and thought I'd drop in. Do you have a minute?"},
  rival_owner: {name:'Gideon Vale',  icon:'😼', visitEveryWeeks:12, perkLabel:'+5% total revenue', preferredGift:'drinks', greeting:"I'm here against my better judgment. Don't make me regret it."},
  food_critic: {name:'Marigold Sterne', icon:'🍴', visitEveryWeeks:16, perkLabel:'+10% tips', preferredGift:'desserts', greeting:"I won't stay long. Make me something you're proud of."},
  loyal_regular:{name:'Marcus Bell',  icon:'☕',  visitEveryWeeks:6,  perkLabel:'+$15/week per regular', preferredGift:'drinks', greeting:"Hi! I was in the neighborhood. Mind if I sit at the corner table?"},
};
const MAX_AFFINITY = 15;
const NPC_IDS = Object.keys(NPCS);

// ─── REGULAR CUSTOMERS ───────────────────────────────────────────────────────
// Each regular has 4 scripted "milestone" arc beats that trigger once at visit
// thresholds 1/4/9/16. Between milestones, regulars draw from REGULAR_BEAT_TEMPLATES
// — a large shared pool of one-liners parameterized by the regular's name and
// category. Each regular tracks which templates it has already shown so beats
// don't repeat until the pool is exhausted.
const REGULARS_POOL = [
  {id:'reg_priya', name:'Priya', icon:'👩🏽', pronoun:'she', loves:'drinks', arc:[
    `{name} ordered her usual — two sugars, extra cream — and left a generous tip.`,
    `{name} brought a coworker today. "You have to try this place," she kept saying.`,
    `{name} started bringing a book. She stays for an hour now, nursing her coffee.`,
    `{name} asked if you host open-mic nights. "Just thinking ahead," she said, smiling.`,
  ]},
  {id:'reg_darius', name:'Darius', icon:'👨🏾', pronoun:'he', loves:'food', arc:[
    `{name} tried the soup of the week. "Remind me what's in this," he asked, already finishing the bowl.`,
    `{name} brought his laptop today. He's started working from a table in the back.`,
    `{name} asked the staff for the soup recipe. They politely declined; he laughed and ordered another bowl.`,
    `{name} told you "you've ruined every other lunch place for me," and he meant it.`,
  ]},
  {id:'reg_elena', name:'Elena', icon:'👩🏻', pronoun:'she', loves:'desserts', arc:[
    `{name} ordered dessert before her coffee. "I know what I'm here for," she said.`,
    `{name} photographed her plate and posted it online. A dozen new faces came in the next day.`,
    `{name} left a drawing of her cat on the napkin. It's pinned to the bulletin board now.`,
    `{name} admitted she's saving up to open her own bakery someday. "Your place gave me the idea."`,
  ]},
  {id:'reg_tomas', name:'Tomás', icon:'👨🏻', pronoun:'he', loves:'drinks', arc:[
    `{name} came in drenched from the rain and ordered the hottest drink on the menu.`,
    `{name} recognizes the whole staff by name now. He asks about their weekends.`,
    `{name} fixed the wobbly chair at his favorite table. Wouldn't take a drink on the house.`,
    `{name} proposed to his partner at the corner table last Saturday. She said yes.`,
  ]},
  {id:'reg_amelia', name:'Amelia', icon:'👩🏼', pronoun:'she', loves:'food', arc:[
    `{name} works night shifts and comes in for a late breakfast. She tips in coins she counts out carefully.`,
    `{name} brought her daughter in for the first time. The daughter asked every pet's name.`,
    `{name} got a new job. She told the barista before she told her own mother.`,
    `{name} came in just to say thank you. That's all. Just thank you.`,
  ]},
  {id:'reg_jun', name:'Jun', icon:'👨🏻', pronoun:'he', loves:'desserts', arc:[
    `{name} came in with headphones on, ordered by pointing, and left the biggest tip of the day.`,
    `{name} took the headphones off today. Asked the cat's name. Smiled for the first time.`,
    `{name} is a regular now. The staff start his order before he reaches the counter.`,
    `{name} brought cookies he made at home. "Trade?" he asked. You traded.`,
  ]},
  {id:'reg_harriet',name:'Harriet',icon:'👵🏼', pronoun:'she', loves:'drinks', arc:[
    `{name}, eighty-one, ordered a decaf and sat by the window for an hour.`,
    `{name} tells the staff about her late husband on Tuesdays. They always listen.`,
    `{name} brought a plant clipping from her garden. It's in a little pot on the counter now.`,
    `{name} said this is the first place she's felt at home since she moved here in 1974.`,
  ]},
  {id:'reg_felix', name:'Felix', icon:'👨🏻‍🦱', pronoun:'he', loves:'food', arc:[
    `{name} ordered off-menu and the barista improvised. He was delighted.`,
    `{name} brought his kid today. The kid has already named every pet.`,
    `{name} asked a coworker to meet him here for a job interview. "It's where the good news happens."`,
    `{name} got the job. He stopped by to say thank you before he went home to tell his family.`,
  ]},
  {id:'reg_nadia', name:'Nadia', icon:'👩🏻‍🦱', pronoun:'she', loves:'drinks', arc:[
    `{name} has an impressive tattoo sleeve and asks for a different single-origin every visit.`,
    `{name} brought a sketchbook and drew the cafe from the corner table. She left the page.`,
    `{name} is teaching a pottery class nearby. She brought a mug she made, for the staff.`,
    `{name} said the mug on your counter is the best thing she's ever made. You can tell she means it.`,
  ]},
  {id:'reg_amir', name:'Amir', icon:'👨🏽', pronoun:'he', loves:'drinks', arc:[
    `{name} comes in before sunrise in a suit, always with his newspaper under one arm.`,
    `{name} finally asked the barista what their name was. Wrote it in his pocket notebook.`,
    `{name} brought a homemade date cookie for the staff. "My mother's recipe," he said shyly.`,
    `{name} told the staff he's retiring next month. "This was my favorite part of the day for three years."`,
  ]},
  {id:'reg_sofia', name:'Sofía', icon:'👩🏽‍🦱', pronoun:'she', loves:'desserts', arc:[
    `{name} brought her tiny yappy dog in. Your cats regarded him with profound disdain.`,
    `{name} asked if you had a loyalty card. You didn't — so she made one herself, on an index card.`,
    `{name} left her umbrella here twice in one week. She picked it up with apologies and pastries.`,
    `{name} introduced her fiancée to the cafe. "I wanted you to meet the place that got me through last year."`,
  ]},
  {id:'reg_wei', name:'Wei', icon:'👩🏻‍🦱', pronoun:'she', loves:'food', arc:[
    `{name} is writing a novel in the corner booth. She's been on chapter three for four weeks.`,
    `{name} finished chapter three today. She looked at the barista like she'd just summited something.`,
    `{name} asked if she could include the cafe in her book. You said yes.`,
    `{name} brought an advance copy of the manuscript. The cafe is on page 41. The corner booth is on page 107.`,
  ]},
];

// Shared bank of weekly beats. `{name}` is substituted with the regular's name,
// `{love}` with "drink"/"dish"/"dessert", `{they}` / `{them}` / `{their}` with pronouns.
// 50+ templates so repetition is rare even in long playthroughs.
const REGULAR_BEAT_TEMPLATES = [
  `{name} came in for {their} usual and left a bigger tip than usual.`,
  `{name} waved to the barista from across the street and came in just to chat.`,
  `{name} stayed two hours with a book today. Didn't check {their} phone once.`,
  `{name} struck up a conversation with a stranger at the next table. They swapped numbers.`,
  `{name} asked how every pet was doing, by name.`,
  `{name} ordered something new today. Looked surprised in the good way.`,
  `{name} brought a bag of treats for the pets. The barista let them hand one out.`,
  `{name} was the first one in this morning and the last one out.`,
  `{name} asked if the cafe ever does private rentals. "Just wondering," {they} said.`,
  `{name} helped a new customer figure out the menu and didn't want credit.`,
  `{name} was reading the same page for twenty minutes. Something's on {their} mind.`,
  `{name} left with a smile that wasn't there when {they} arrived.`,
  `{name} ordered the special and then came back to the counter to compliment the kitchen.`,
  `{name} tipped in cash and slipped the note under the saucer, the way regulars do.`,
  `{name} brought a friend visiting from out of town. The friend ordered two {love}s.`,
  `{name} settled into {their} usual seat like it had been waiting for {them}.`,
  `{name} asked about the art on the wall. The artist lives two blocks away.`,
  `{name} debated oat milk versus almond milk with the barista for ten full minutes.`,
  `{name} dropped off a book {they} thought the cafe would enjoy. It's on the shelf now.`,
  `{name} brought flowers. Just because. They're on the counter.`,
  `{name} stayed through a brief rainstorm, nursing one {love} the whole time.`,
  `{name} made the staff laugh harder than anyone had all week.`,
  `{name} was deep in a phone call but still waved hello.`,
  `{name} came in on crutches and insisted on carrying {their} own tray.`,
  `{name} brought a homemade something for the staff — {they} wouldn't say what was in it.`,
  `{name} asked the barista's opinion on what to order. The barista picked well.`,
  `{name} left a handwritten note tucked into the tip jar.`,
  `{name} came in during the lunch rush, took one look, and ordered anyway.`,
  `{name} told the staff a story about {their} week that made the whole corner go quiet listening.`,
  `{name} sat by the window watching the street go by for an hour.`,
  `{name} tried to pay for the order behind {them} in line. It got awkward in the nicest way.`,
  `{name} ordered to go, then stayed anyway.`,
  `{name} brought {their} own travel mug. "Save a cup," {they} said.`,
  `{name} was wearing the cafe's merch shirt. Nobody sells merch. You checked.`,
  `{name} recommended the cafe to a coworker loud enough for the whole dining room to hear.`,
  `{name} asked if {they} could take a photo of the cat. The cat consented. It was a good photo.`,
  `{name} came in with a different haircut. The staff all noticed.`,
  `{name} asked what the staff recommended for a hard day. The staff recommended staying a while.`,
  `{name} was humming the whole time {they} waited. {They} looked embarrassed when {they} noticed.`,
  `{name} got the seasonal special and made the "wow" face.`,
  `{name} dropped {their} keys on the way out and three people caught them for {them}.`,
  `{name} brought in a new hire from work. "I told them this place was required orientation."`,
  `{name} came in looking defeated. Left looking appreciated.`,
  `{name} did a crossword at the counter and asked the staff for a seven-letter word for "contentment."`,
  `{name} helped an elderly customer to {their} seat without being asked.`,
  `{name} ordered {their} first coffee of the morning from you. Said it's a weekday ritual now.`,
  `{name} was wearing mismatched socks on purpose. {They} showed them to the barista.`,
  `{name} brought {their} mother in. {Their} mother approved.`,
  `{name} came in specifically to ask how the cat who sleeps in the window has been.`,
  `{name} told the barista about a promotion at work before {they} told {their} family.`,
  `{name} left a review online that used the words "home" and "refuge."`,
  `{name} mentioned {they} live forty minutes away. {They} come here anyway.`,
  `{name} brought a pot of soup for the staff when someone said the barista was sick.`,
  `{name} sat reading the same paragraph aloud, quietly, three times. Something about it mattered.`,
  `{name} walked in holding hands with someone new. {Their} usual seat now has two chairs.`,
  `{name} asked the staff how their week was going and waited for the real answer.`,
  `{name} ordered {their} usual but asked for an extra shot. "Long week," {they} said.`,
  `{name} came in ten minutes before closing and apologized. Nobody minded.`,
  `{name} left a five-star review that mentioned the barista by name.`,
  `{name} asked if {they} could move {their} usual table closer to the window. It fits better there.`,
  `{name} brought a friend who asked for {their} recommendation. {They} gave it confidently.`,
  `{name} tapped {their} foot to the background music all morning.`,
  `{name} held the door open for six people in a row without stopping.`,
  `{name} pointed out a typo on the menu. The barista fixed it with a marker.`,
  `{name} sat at the wrong table by accident and liked it more. It's {their} table now.`,
  `{name} ordered a {love} and then stared out the window for forty-five minutes.`,
  `{name} asked if {they} could plug in {their} phone. {They} thanked the barista three times.`,
  `{name} came in with a sunburn and a story about a weekend hike.`,
  `{name} shared a table with a stranger and both ended up laughing.`,
  `{name} asked the barista if {they} were hiring. "For a friend," {they} said.`,
  `{name} got the seasonal {love} and decided it's now {their} regular order.`,
  `{name} brought a board game and played it with the person at the next table.`,
  `{name} noticed a pet was sleeping and whispered {their} order so as not to wake it.`,
  `{name} asked the barista for a pen to write something on a napkin. Kept the napkin.`,
  `{name} ordered the same thing three days in a row. The barista stopped asking.`,
  `{name} came in during the quiet hour and said it felt like a secret.`,
  `{name} waved goodbye from the sidewalk. The barista waved back through the glass.`,
  `{name} told the barista about a dream {they} had about the cafe. It was nice.`,
  `{name} brought a jar of homemade jam with no label. It was very good.`,
  `{name} asked what other regulars usually order. {They} tried one.`,
  `{name} spent the whole visit sketching the view from the window.`,
  `{name} came in with paint on {their} hands and ordered without looking at the menu.`,
  `{name} got recognized by another regular. They talked for an hour.`,
  `{name} told the staff {they} had a bad morning but this helped.`,
  `{name} brought back a postcard from a trip. It's on the wall now.`,
  `{name} overheard someone compliment the cafe and smiled like it was about {them}.`,
  `{name} put {their} phone in {their} bag and didn't take it out once.`,
  `{name} asked the barista to surprise {them}. The surprise was a hit.`,
  `{name} came in wearing a scarf that matched {their} usual drink order.`,
  `{name} told a customer at the door that the almond croissant was worth the trip. It was.`,
  `{name} sat in the back corner with headphones in and a look of complete peace.`,
  `{name} wrote a long email at the counter and then deleted the whole thing. Ordered another {love}.`,
  `{name} asked if the cafe had Wi-Fi. It does. {They} didn't use it.`,
  `{name} started calling the barista by {their} first name. Nobody told {them} it — {they} just knew.`,
  `{name} left the exact change on the table along with a note that said "perfect."`,
  `{name} argued gently with the barista about the best {love} on the menu. Both were right.`,
  `{name} stood outside for a full minute looking at the cafe before coming in.`,
  `{name} asked if {they} could reserve {their} table. "Not officially," the barista said. It's reserved.`,
  `{name} came in with a cold and was told to sit by the heater. {They} did.`,
  `{name} forgot {their} wallet but the barista covered it. {They} paid double the next day.`,
  `{name} said the cafe smells like {their} grandmother's kitchen. That was clearly a compliment.`,
  `{name} ate the whole pastry in three bites and immediately ordered another one.`,
  `{name} tipped more than the order cost. The barista tried to object. {They} insisted.`,
  `{name} introduced {their} partner to {their} favorite barista like it was a formal ceremony.`,
  `{name} ordered one of everything in the pastry case. Shared it with the whole room.`,
];

// Substitute {name}, {they}, {them}, {their}, {They}, {Their}, {love} in a beat template.
function fillRegularBeat(template, regular) {
  const pronoun = regular.pronoun || 'they';
  const subj = pronoun;
  const obj  = pronoun==='she'?'her'  : pronoun==='he'?'him' : 'them';
  const poss = pronoun==='she'?'her'  : pronoun==='he'?'his' : 'their';
  const Subj = subj[0].toUpperCase()+subj.slice(1);
  const Poss = poss[0].toUpperCase()+poss.slice(1);
  const loveLabel = regular.loves==='drinks'?'drink':regular.loves==='food'?'dish':'dessert';
  return template
    .replace(/\{name\}/g, regular.name)
    .replace(/\{they\}/g, subj)
    .replace(/\{them\}/g, obj)
    .replace(/\{their\}/g, poss)
    .replace(/\{They\}/g, Subj)
    .replace(/\{Their\}/g, Poss)
    .replace(/\{love\}/g, loveLabel);
}

// ─── CELEBRITY NAMES (for celebrity adoption events) ─────────────────────────
const CELEBRITY_NAMES = [
  'Stella Vance','Remy Holloway','Zara Osei','Jasper Thorn','Nadia Belfort',
  'Cleo Vasquez','Orion Blake','Tilda Marsh','Dex Fontaine','Simone Nakamura',
];

// ─── CUSTOMER TYPES ──────────────────────────────────────────────────────────
const CUSTOMER_TYPES = [
  {value:'regular',    weight:60, label:'Regular',    icon:'🧑'},
  {value:'tourist',    weight:25, label:'Tourist',    icon:'🎒'},
  {value:'influencer', weight:10, label:'Influencer', icon:'📸'},
  {value:'critic',     weight:5,  label:'Critic',     icon:'📝'},
];

// ─── MENU GENERATION ──────────────────────────────────────────────────────────
const ALL_PREFIXES = Object.values(SPECIES).flatMap(sp => sp.prefixes);
const MenuGen = {
  generateAll(rng) {
    const items = [];
    const usedNames = new Set();
    for(const cat of ['drinks','food','desserts']) {
      const suffixes = MENU_SUFFIXES[cat];
      for(let i=0; i<100; i++) {
        let name, attempts=0;
        do { name = rng.pick(ALL_PREFIXES)+' '+rng.pick(suffixes); attempts++; } while(usedNames.has(name) && attempts<30);
        if(usedNames.has(name)) name += ' #'+(i+1);
        usedNames.add(name);
        const ingCount = rng.i(2,4);
        const pool = ING_POOLS[cat] || ING_KEYS;
        const ingKeys = rng.sample(pool, Math.min(ingCount, pool.length));
        const ingNames = ingKeys.map(k=>INGS[k].name);
        const baseCost = ingKeys.reduce((s,k)=>s+INGS[k].price,0);
        const tier = Math.floor(i/25)+1;
        const demand = Math.round((0.4+rng.r()*0.5)*100)/100;
        const price = Math.round(baseCost*(2.2+rng.r()*0.3));
        items.push({ id:cat+'_'+i, name, cat, ingNames, baseCost, price, demand, tier });
      }
    }
    return items;
  },
};

// ─── STAFF ────────────────────────────────────────────────────────────────────
const Staff = {
  gen(rng, budget=50) {
    // 20% chance of a "walk-in" applicant — slightly lower quality but still meaningful
    const isWalkIn=rng.r()<0.2;
    const effBudget=isWalkIn?rng.i(Math.floor(budget*0.3),Math.max(100,Math.floor(budget*0.7))):budget;
    // Target average skill scales with budget: ~1 at $0, ~9.5 at max budget ($1000+)
    const targetAvg=Math.min(9.5,1+effBudget/80);
    // Each skill rolls independently with wide ±3.5 variance, allowing uneven specialists like 7/1/2/6
    const rollSkill=()=>{
      const offset=(rng.r()*2-1)*3.5;
      return Math.max(1,Math.min(10,Math.round(targetAvg+offset)));
    };
    const sk={cooking:rollSkill(),service:rollSkill(),petCare:rollSkill(),cleaning:rollSkill()};
    const tot=Object.values(sk).reduce((a,b)=>a+b,0);
    const FEMALE_EMOJIS=['👩','👩‍🦱'],MALE_EMOJIS=['👨','👨‍🦰'],NEUTRAL_EMOJIS=['🧑','🧑‍🦳'];
    const emoji=rng.pick([...FEMALE_EMOJIS,...FEMALE_EMOJIS,...MALE_EMOJIS,...MALE_EMOJIS,...NEUTRAL_EMOJIS]);
    const namePool=FEMALE_EMOJIS.includes(emoji)?STAFF_NAMES_FEMALE:MALE_EMOJIS.includes(emoji)?STAFF_NAMES_MALE:STAFF_NAMES_NEUTRAL;
    return{id:'s'+rng.i(1e4,9e4).toString(36),name:rng.pick(namePool),
      emoji,skills:sk,wage:10+tot*3,morale:80,hireWeek:0};
  },
  eff(staff,lv) {
    if(!staff?.length)return .2;
    const svc=staff.reduce((s,m)=>s+(m.training?Math.ceil(m.skills.service/2):m.skills.service),0);
    const req=UPGRADES[lv].minStaff;
    return clamp(svc/(req*5)*.7+Math.min(staff.length/req,1.5)*.3,.2,1);
  },
  wages(staff) { return(staff||[]).reduce((s,m)=>s+m.wage,0); },
};

// ─── CAFE SIM ─────────────────────────────────────────────────────────────────
/**
 * Cafe simulation: runs a 6-day week of customer visits, menu sales, and tip generation.
 * Revenue formula: customers × (menu purchases + tips) × level multiplier, with saturation curve.
 * Key factors: staff skills, pet appeal, cleaning, price tolerance, trend bonuses, menu mastery.
 */
const Cafe = {
  runWeek(floor,lv,activeItems,staffList,trend,season,rng,menuItemPrices={},menuItemWeeksActive={},masteredItems={},trophies=[],menuTrends=[],allPets=[],seasonalEvent=null,customerMod=1,loyaltyBuff=1,tvCrewBuff=1,currentWeek=0,lastCriticWeek=0) {
    // ════════════════════════════════════════════════════════════════════════
    // PHASE A — Setup: staff skill averages, pet appeal, satisfaction mult,
    //                   menu trend multipliers per item, seasonal mod
    // ════════════════════════════════════════════════════════════════════════
    const hasYoungPets=floor.some(p=>p.state.age<48);
    let totalMenuRev=0, totalTipRev=0, totalCost=0, totalCust=0, totalTrendBonus=0;
    const itemSales={}, petTipsMap={};
    const allEvents=[];
    const cap=UPGRADES[lv];
    // Staff skill averages (training halves contribution)
    const cookAvg=getStaffSkillAvg(staffList,'cooking')||1;
    const cleanAvg=getStaffSkillAvg(staffList,'cleaning');
    const petCareAvgCafe=getStaffSkillAvg(staffList,'petCare');
    // Cooking: reduces price sensitivity (accept higher prices without demand drop)
    const cookTolerance=cookAvg/10*0.8;
    // Pet Care (cafe): staff who bond with pets get better tips from customers
    const petCareTipMult=1+petCareAvgCafe/10*0.5;
    // Average price ratio across active items (for satisfaction calc)
    const avgPriceRatio=activeItems.length?activeItems.reduce((s,item)=>s+(menuItemPrices[item.id]??item.price)/Math.max(1,item.price),0)/activeItems.length:1;
    // Pet appeal (quality of pets on floor)
    const adultFloor=floor.filter(p=>!p.state.isKitten);
    const petAppeal=adultFloor.length?adultFloor.reduce((s,p)=>s+p.stats.appeal,0)/adultFloor.length:3;
    // Satisfaction multiplier: pets + cleaning + prices all drive customer count
    const petBonus=petAppeal/10*0.4;
    const cleanBonus=cleanAvg/10*0.7;
    const priceBonus=Math.max(-0.3,1/Math.max(0.5,avgPriceRatio)-1);
    // Cross-system synergies: gold menu items + species diversity
    const goldMenuCount=Object.values(masteredItems||{}).filter(m=>m.tier>=3).length;
    const menuMasteryBonus=Math.min(0.15,goldMenuCount*0.02);
    const speciesOnFloor=new Set(adultFloor.map(p=>p.species)).size;
    const diversityBonus=speciesOnFloor>=4?0.15:speciesOnFloor>=3?0.08:speciesOnFloor>=2?0.03:0;
    const satisfactionMult=Math.max(0.2,1+petBonus+cleanBonus+priceBonus+menuMasteryBonus+diversityBonus);
    const seasonMod=season==='Summer'?3:season==='Winter'?-2:0;
    // Pre-compute menu trend multipliers per item
    const itemCostMult={},itemDemandMult={};
    for(const item of activeItems){
      let cm=1,dm=1;
      for(const mt of (menuTrends||[])){
        const e=mt.effect||{};
        const matchesIng=e.ingredient&&item.ingNames&&item.ingNames.includes(e.ingredient);
        const matchesCat=e.category===null||e.category===undefined||e.category===item.cat;
        if(matchesIng||(!e.ingredient&&matchesCat)){
          if(e.costMult){
            if(matchesIng){
              // Blend: only the affected ingredient's share of cost changes
              const n=item.ingNames?.length||1;
              cm*=1+(e.costMult-1)/n;
            } else {
              cm*=e.costMult;
            }
          }
          if(e.demandMult) dm*=e.demandMult;
        }
      }
      itemCostMult[item.id]=cm;
      itemDemandMult[item.id]=dm;
    }
    // ════════════════════════════════════════════════════════════════════════
    // PHASE B — Daily loop: customer flow, menu purchases, pet tips, events
    // (6 days per week, accumulating totalMenuRev/totalTipRev/totalCust)
    // ════════════════════════════════════════════════════════════════════════
    const customerBreakdown={regular:0,tourist:0,influencer:0,critic:0};
    let criticVisited=false;
    for(let day=0;day<GAME_CONFIG.OPEN_DAYS_PER_WEEK;day++){
      const cust=Math.max(1,Math.round((cap.cust+rng.i(-3,3)+seasonMod)*satisfactionMult*(seasonalEvent?.id==='summer_beach_festival'?1.15:1)*customerMod*tvCrewBuff));
      const served=Math.round(cust*Staff.eff(staffList,lv));
      totalCust+=served;
      let dayMenuRev=0,dayCost=0;
      let dayRegularCount=0,dayInfluencerCount=0;
      // Pre-compute per-day pet flags for customer type bonuses
      const dayHasCuddlyOrZen=floor.some(p=>!p.state.isKitten&&['cuddly','zen'].includes(p.personality?.primary));
      const dayHasRare=floor.some(p=>!p.state.isKitten&&p.stats.rarity>0.45);
      for(let c=0;c<served;c++){
        // Roll customer type for this visitor
        const ctype=rng.wp(CUSTOMER_TYPES);
        customerBreakdown[ctype]++;
        if(ctype==='regular') dayRegularCount++;
        if(ctype==='influencer') dayInfluencerCount++;
        if(ctype==='critic'&&!criticVisited) criticVisited=true;
        // Tourist buys 2 menu items; all others buy 1
        const purchaseCount=ctype==='tourist'?2:1;
        for(let pc=0;pc<purchaseCount;pc++){
          // Each customer picks ONE random menu item and decides whether to buy
          if(activeItems.length>0){
            const item=activeItems[Math.floor(rng.r()*activeItems.length)];
            const actualPrice=(menuItemPrices[item.id]??item.price);
            let sm=SEASON_MOD[item.cat]?.[season]||1;
            if(seasonalEvent?.id==='summer_beach_festival'&&item.cat==='drinks') sm*=1.5;
            if(seasonalEvent?.id==='autumn_harvest_fair'&&item.cat==='food') sm*=1.5;
            // Freshness: new items get boost, stale items lose demand
            const weeksOn=menuItemWeeksActive[item.id]||0;
            const mt=(masteredItems?.[item.id]?.tier)||0;
            const masteryDemand=[0,.10,.20,.30][mt];
            const masteryTol=mt>=3?.15:0;
            const freshMod=mt>=2?1.0:(weeksOn>=0&&weeksOn<=2?1.5:1.0);
            const harvestTol=seasonalEvent?.id==='autumn_harvest_fair'?0.2:0;
            const effRatio=actualPrice/Math.max(1,item.price)/(1+cookTolerance+masteryTol+harvestTol);
            const trendDm=itemDemandMult[item.id]||1;
            // Single price elasticity term: 1/effRatio. Removed the redundant volumeMult that
            // was double-counting price sensitivity (it caused ~1/price² behavior).
            const d=Math.min(0.99,Math.max(0.01,item.demand*(1+masteryDemand)*sm*freshMod*trendDm*0.8*(1/effRatio)));
            if(rng.bool(d)){
              dayMenuRev+=actualPrice;
              dayCost+=item.baseCost*(itemCostMult[item.id]||1);
              itemSales[item.id]=(itemSales[item.id]||{sold:0,revenue:0});
              itemSales[item.id].sold++;
              itemSales[item.id].revenue+=actualPrice;
            }
          }
        }
      }
      totalMenuRev+=dayMenuRev; totalCost+=dayCost;
      let dayTips=0;
      for(const pet of floor){
        if(pet.state.isKitten)continue;
        if((pet.state.sick||0)>0)continue;
        const base=pet.stats.appeal+rng.i(0,3)+Math.round(pet.stats.rarity*8);
        const ts=Trend.score(pet,trend);
        const tb=ts>0?base*((trend?.bonusMultiplier||1)-1)*ts*5:0;
        const sb=pet.personality.primary==='showoff'&&ts>0?base*.2:0;
        const isChamp=(trophies||[]).some(t=>t.petId===pet.id&&t.placement===1);
        const champTip=isChamp?Math.round(base*0.3):0;
        const tm=Pers.tipsMult(pet.personality,floor.length,season,hasYoungPets);
        const rm=rarityTipMult(pet.stats.rarity);
        const holidayTipMult=seasonalEvent?.id==='winter_holiday_cheer'?1.25:1;
        // Relationship bonus: friends/best-friends boost tips; rivals reduce them
        const floorIds=new Set(floor.map(fp=>fp.id));
        let relMult=1;
        for(const rel of (pet.relationships||[])){
          if(!floorIds.has(rel.petId)||rel.petId===pet.id)continue;
          if(rel.strength>=8) relMult*=1.15;
          else if(rel.strength>=4) relMult*=1.05;
          else if(rel.strength<=-4) relMult*=0.95;
        }
        relMult=Math.min(1.3,relMult);
        const petTip=Math.round((base+tb+sb+champTip)*tm*rm*petCareTipMult*holidayTipMult*relMult*loyaltyBuff*4);
        dayTips+=petTip;
        petTipsMap[pet.id]=(petTipsMap[pet.id]||0)+petTip;
        if(ts>0) totalTrendBonus+=Math.round(tb*tm*rm*petCareTipMult*holidayTipMult*relMult*loyaltyBuff*4);
      }
      // Customer type tip multipliers applied to the whole day's tips
      // Influencer bonus: big tip boost when a rare+ pet is on floor
      if(dayInfluencerCount>0&&dayHasRare){
        dayTips=Math.round(dayTips*Math.min(1.5,1+dayInfluencerCount*0.05));
      }
      // Regular bonus: slight tip boost when a cuddly or zen pet is on floor
      if(dayRegularCount>0&&dayHasCuddlyOrZen){
        dayTips=Math.round(dayTips*Math.min(1.1,1+dayRegularCount*0.005));
      }
      totalTipRev+=dayTips;
      // Cap total pet events at 3 per week, deduplicated by message text.
      // Critic events fire later, outside this loop, and are not counted here.
      const evts=this.events(floor,rng,allPets,trophies);
      const seenMessages=new Set(allEvents.map(e=>e.message));
      let dayLoopEventCount=allEvents.filter(e=>!e.isRare).length;
      for(const e of evts){
        if(dayLoopEventCount>=3) break;
        if(seenMessages.has(e.message)) continue;
        allEvents.push(e);
        seenMessages.add(e.message);
        dayLoopEventCount++;
      }
    }
    // ════════════════════════════════════════════════════════════════════════
    // PHASE C — Post-loop events: critic visit, revenue scaling, assembly
    // ════════════════════════════════════════════════════════════════════════
    // Critic event: not in first 2 months, then minimum 6-week gap + 25% chance per eligible week
    // (averages once every ~10 weeks, never two close together). Effect lasts 3 weeks.
    // Pass probability scales with cook skill vs current month difficulty, always 5–95%
    // Difficulty grows linearly with month, capped at 12 so max-skill staff stay competitive in late game.
    const currentMonth=Math.min(12,Math.floor(currentWeek/4)+1);
    const bestCook=Math.max(...(staffList||[]).map(s=>s.training?Math.ceil(s.skills.cooking/2):s.skills.cooking),0);
    const effectiveCook=(cookAvg+bestCook)/2;
    const criticPassChance=Math.min(0.95,Math.max(0.05,0.5+(effectiveCook-currentMonth)*0.07));
    let newCriticEffect=null;
    let newLastCriticWeek=lastCriticWeek;
    const weeksSinceLastCritic=currentWeek-lastCriticWeek;
    if(criticVisited&&currentWeek>=8&&weeksSinceLastCritic>=6&&rng.bool(0.25)){
      newLastCriticWeek=currentWeek;
      if(rng.bool(criticPassChance)){
        newCriticEffect={mult:1.15,weeksLeft:3};
        allEvents.push({message:'📝 A food critic raved about the menu! Customers will flock for the next 3 weeks.',tipsDelta:0,isRare:true});
      } else {
        newCriticEffect={mult:0.85,weeksLeft:3};
        allEvents.push({message:'📝 A food critic was unimpressed with the cooking. Word will spread for the next 3 weeks.',tipsDelta:0,isRare:true,isNegative:true});
      }
    }
    // Revenue scaling: level multiplier + saturation curve (diminishing returns above $5000/week)
    const lm=1+(lv-1)*.08;
    const rawGross=Math.round((totalMenuRev+totalTipRev)*lm);
    // Saturation: kicks in above $5000/wk, asymptotes to 50% (was 30%) for less punishing late game
    const satFactor=1/(1+Math.max(0,rawGross-5000)/15000);
    const satMult=0.5+0.5*satFactor;
    const gross=Math.round(rawGross*satMult);
    const eventDelta=allEvents.reduce((s,e)=>s+(e.tipsDelta||0),0);
    // All reported revenue components are post-saturation so the summary breakdown
    // (menu + tips + bonuses − costs) reconciles to the player's actual bank delta.
    return{
      customers:totalCust,
      menuRevenue:Math.round(totalMenuRev*lm*satMult),
      tipRevenue:Math.round(totalTipRev*lm*satMult),
      trendBonus:Math.round(totalTrendBonus*lm*satMult),
      costOfGoods:Math.round(totalCost),
      netRevenue:gross+eventDelta-totalCost,
      events:[...allEvents].sort((a,b)=>(b.isRare?1:0)-(a.isRare?1:0)||(b.isMishap?1:0)-(a.isMishap?1:0)),
      itemSales,petTipsMap,
      customerBreakdown,
      newCriticEffect,
      newLastCriticWeek,
    };
  },
  events(pets,rng,allPets=[],trophies=[]) {
    const LINEAGE_TEMPLATES = [
      (n,a,rel)=>({msg:`${n}, ${rel} of champion ${a}, charmed the room with familiar grace.`,d:5}),
      (n,a,rel)=>({msg:`Customers said ${n} reminded them of ${a} — they had the same easy confidence.`,d:5}),
      (n,a,rel)=>({msg:`${n} struck a pose by the window, looking every bit ${a}'s ${rel}.`,d:4}),
      (n,a,rel)=>({msg:`An old regular pointed at ${n} and whispered: "That one's got ${a}'s eyes."`,d:6}),
      (n,a,rel)=>({msg:`${n} won over the toughest customer in the cafe — pure ${a} energy.`,d:6}),
      (n,a,rel)=>({msg:`${n} held court like ${a} used to, drawing every gaze in the room.`,d:5}),
      (n,a,rel,pr)=>({msg:`Someone took a photo of ${n} just to show a friend who'd loved ${a}.`,d:7}),
      (n,a,rel,pr)=>({msg:`${n} settled into ${pr.their} favorite spot — the very same one ${a} always claimed.`,d:4}),
    ];
    const getRelationLabel = (depth, gender) => {
      const g = gender === 'female' ? 1 : 0;
      if (depth === 1) return g ? 'daughter' : 'son';
      if (depth === 2) return g ? 'granddaughter' : 'grandson';
      if (depth === 3) return g ? 'great-granddaughter' : 'great-grandson';
      return 'descendant';
    };
    const getRelationDepth = (petId, ancestorId, allPets) => {
      const queue = [{id: petId, d: 0}];
      const seen = new Set([petId]);
      while (queue.length) {
        const {id, d} = queue.shift();
        if (id === ancestorId && d > 0) return d;
        if (d >= 4) continue;
        const pet = allPets.find(x => x.id === id);
        if (!pet) continue;
        for (const parentId of [pet.parentA, pet.parentB]) {
          if (parentId && !seen.has(parentId)) {
            seen.add(parentId);
            queue.push({id: parentId, d: d + 1});
          }
        }
      }
      return 4; // fallback
    };
    const champIds = new Set((trophies||[]).filter(t=>t.placement===1).map(t=>t.petId));
    // Mishap pool — generic accidents that cost the cafe money. Fire independently of personality events.
    const MISHAPS = [
      (n)=>({msg:`${n} knocked a full latte off a table. Cleanup and a comped drink.`,d:-4}),
      (n)=>({msg:`${n} scratched a customer's bag. The cafe covered a small replacement.`,d:-6}),
      (n)=>({msg:`${n} shed all over a regular's wool coat. A free pastry smoothed it over.`,d:-3}),
      (n)=>({msg:`${n} got into the pantry and ruined a tub of flour.`,d:-5}),
      (n)=>({msg:`${n} startled a waiter — an entire tray went down.`,d:-7}),
      (n)=>({msg:`${n} broke a ceramic mug. Good mugs aren't cheap.`,d:-3}),
      (n)=>({msg:`${n} had an accident behind the counter. Needed extra cleaning supplies.`,d:-4}),
      (n)=>({msg:`${n} chewed through a charging cable.`,d:-5}),
      (n)=>({msg:`${n} knocked a potted plant off the window sill.`,d:-3}),
      (n)=>({msg:`${n} scared off a nervous first-time visitor — they didn't order.`,d:-4}),
      (n)=>({msg:`${n} got stuck on a high shelf and a customer helped them down. Free coffee as thanks.`,d:-2}),
      (n)=>({msg:`${n} tracked muddy pawprints across three tables.`,d:-3}),
      (n)=>({msg:`${n} bit a corner off a display pastry. It had to be discarded.`,d:-4}),
      (n)=>({msg:`${n} shredded a stack of clean napkins.`,d:-2}),
      (n)=>({msg:`${n} tangled with the register cord and unplugged it mid-transaction.`,d:-5}),
      (n)=>({msg:`${n} sneezed spectacularly into an open cake display.`,d:-6}),
      (n)=>({msg:`${n} stole a sandwich right off someone's plate.`,d:-5}),
      (n)=>({msg:`${n} made a customer sneeze all afternoon — they left early.`,d:-3}),
      (n)=>({msg:`${n} dragged a toy through the kitchen and it had to be deep-cleaned.`,d:-4}),
      (n)=>({msg:`${n} fought with the broom and lost — to everyone except the broom.`,d:-2}),
    ];
    const M={
      playful:[
        (n,sp)=>({msg:`${n} played with a toy mouse!`,d:2}),
        (n,sp)=>({msg:`${n} chased a ball across the cafe!`,d:2}),
        (n,sp)=>({msg:`${n} pounced on a dangling ribbon!`,d:3}),
        (n,sp)=>({msg:`${n} started a playful game with a customer!`,d:2}),
        (n,sp)=>({msg:`${n} bounced a toy off a customer's lap.`,d:2}),
        (n,sp)=>({msg:`${n} zoomed around the cafe for no reason at all!`,d:3}),
        (n,sp)=>({msg:`${n} ambushed a customer's shoelaces.`,d:2}),
        (n,sp)=>({msg:`${n} batted a crumpled receipt across the floor.`,d:1}),
        (n,sp)=>({msg:`${n} leapt sideways at a shadow.`,d:2}),
        (n,sp)=>({msg:`${n} invited a small child to a tug-of-war.`,d:3}),
        (n,sp)=>({msg:`${n} flipped over a toy and looked very proud.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} discovered ${pr.their} own tail and gave chase with great enthusiasm.`,d:3}),
        (n,sp)=>({msg:`${n} launched a surprise attack on a feather duster.`,d:2}),
        (n,sp)=>({msg:`${n} performed an elaborate fake-out before pouncing on a toy.`,d:3}),
        (n,sp)=>({msg:`${n} skidded across the floor and bounced off a table leg, unbothered.`,d:2}),
        (n,sp)=>({msg:`${n} stole a straw and ran circuits around the cafe with it.`,d:2}),
        (n,sp)=>({msg:`${n} tossed a bottle cap in the air and caught it repeatedly.`,d:2}),
        (n,sp)=>({msg:`${n} challenged a customer to a staring contest and won decisively.`,d:3}),
        (n,sp)=>({msg:`${n} treated a balled-up napkin like it was a dangerous creature.`,d:2}),
        (n,sp)=>({msg:`${n} exploded out of a paper bag and startled nobody — they were ready.`,d:1}),
        (n,sp)=>({msg:`${n} spent ten minutes methodically batting a pen toward the shelf edge.`,d:2}),
        (n,sp)=>({msg:`${n} invented a new game involving a cardboard box and two bewildered customers.`,d:2}),
        (n,sp)=>({msg:`${n} refused to let a customer's bookmark go without a spirited fight.`,d:3}),
        (n,sp)=>({msg:`${n} wrestled dramatically with a plush mouse.`,d:2}),
      ],
      cuddly:[
        (n,sp)=>({msg:`${n} snuggled up to a customer.`,d:3}),
        (n,sp)=>({msg:`${n} curled up in a regular's lap.`,d:3}),
        (n,sp)=>({msg:`${n} nuzzled a child's hand gently.`,d:4}),
        (n,sp)=>({msg:`${n} fell asleep cuddling a teddy bear.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} pressed ${pr.their} head against a guest's arm.`,d:3}),
        (n,sp)=>({msg:`${n} followed a customer around for pats.`,d:3}),
        (n,sp)=>({msg:`${n} burrowed into a pile of cushions with a guest.`,d:3}),
        (n,sp)=>({msg:`${n} fell asleep on someone's warm jacket.`,d:2}),
        (n,sp)=>({msg:`${n} pawed gently at a customer until they sat down.`,d:4}),
        (n,sp)=>({msg:`${n} licked a customer's hand repeatedly.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} rested ${pr.their} chin on the edge of someone's mug.`,d:3}),
        (n,sp)=>({msg:`${n} tucked themselves under a customer's arm and refused to leave.`,d:4}),
        (n,sp)=>({msg:`${n} kneaded a soft blanket draped over someone's lap.`,d:3}),
        (n,sp)=>({msg:`${n} greeted a returning regular like an old friend.`,d:4}),
        (n,sp)=>({msg:`${n} draped themselves over two customers at once.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} pressed ${pr.their} nose softly to a guest's cheek.`,d:4}),
        (n,sp)=>({msg:`${n} curled into a perfect circle beside a dozing guest.`,d:3}),
        (n,sp)=>({msg:`${n} purred so loudly a nearby table went quiet to listen.`,d:3}),
        (n,sp)=>({msg:`${n} claimed a cozy spot on a customer's scarf.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} butted ${pr.their} head gently against everyone who passed.`,d:3}),
        (n,sp)=>({msg:`${n} made a lonely customer's afternoon considerably better.`,d:4}),
        (n,sp)=>({msg:`${n} settled in and made it clear they weren't leaving for a while.`,d:3}),
        (n,sp)=>({msg:`${n} found the coldest hands in the cafe and warmed them up.`,d:4}),
        (n,sp)=>({msg:`${n} chose the shyest customer for cuddles today.`,d:4}),
      ],
      mischievous:[
        (n,sp)=>({msg:`${n} caused a little chaos!`,d:rng.bool()?4:-2}),
        (n,sp)=>({msg:`${n} knocked over a cup — customers loved it!`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} stole a customer's scarf!`,d:rng.bool()?4:-3}),
        (n,sp)=>({msg:`${n} hid under a table and surprised a guest!`,d:rng.bool()?3:-1}),
        (n,sp)=>({msg:`${n} swatted a pen off the counter and ran.`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} unravelled a customer's ball of yarn.`,d:rng.bool()?4:-2}),
        (n,sp)=>({msg:`${n} bolted through a group photo.`,d:rng.bool()?3:-1}),
        (n,sp)=>({msg:`${n} knocked the tip jar suspiciously close to the edge.`,d:rng.bool()?2:-3}),
        (n,sp)=>({msg:`${n} rearranged the sugar packets into a pile.`,d:rng.bool()?2:-1}),
        (n,sp)=>({msg:`${n} sat directly on someone's laptop.`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} dragged a napkin off three tables in a row.`,d:rng.bool()?4:-3}),
        (n,sp)=>({msg:`${n} hid a customer's phone under a cushion.`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} unhooked the chalk menu sign and dragged it two feet.`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} pretended to be asleep, then ambushed a passing ankle.`,d:rng.bool()?4:-2}),
        (n,sp)=>({msg:`${n} knocked a plant pot incrementally toward the table edge all afternoon.`,d:rng.bool()?2:-3}),
        (n,sp)=>({msg:`${n} inserted themselves into someone else's conversation uninvited.`,d:rng.bool()?3:-1}),
        (n,sp)=>({msg:`${n} flipped a customer's bookmark to a completely wrong page.`,d:rng.bool()?2:-2}),
        (n,sp)=>({msg:`${n} systematically removed every pen from the counter cup.`,d:rng.bool()?3:-2}),
        (n,sp)=>({msg:`${n} knocked a hat off a coat hook and wore it briefly.`,d:rng.bool()?4:-1}),
        (n,sp)=>({msg:`${n} hid behind a plant and watched all the confused reactions.`,d:rng.bool()?3:-1}),
        (n,sp)=>({msg:`${n} opened a cabinet door and left it ajar just to see who would close it.`,d:rng.bool()?2:-2}),
        (n,sp)=>({msg:`${n} tangled a customer's headphone cord while they weren't looking.`,d:rng.bool()?2:-3}),
        (n,sp)=>({msg:`${n} relocated someone's to-go cup three tables over.`,d:rng.bool()?3:-2}),
        (n,sp,pr,rng)=>{const cr=randomPronouns(rng);return{msg:`${n} stared at a customer until ${cr.they} dropped ${cr.their} fork.`,d:rng.bool()?2:-1};},
      ],
      showoff:[
        (n,sp)=>({msg:`${n} posed perfectly for photos.`,d:4}),
        (n,sp)=>({msg:`${n} did a little trick for the crowd!`,d:4}),
        (n,sp)=>({msg:`${n} strutted across the counter like a model.`,d:3}),
        (n,sp)=>({msg:`${n} winked at a customer taking selfies.`,d:4}),
        (n,sp)=>({msg:`${n} balanced on the highest shelf for everyone to admire.`,d:3}),
        (n,sp)=>({msg:`${n} spun around twice and sat down perfectly.`,d:4}),
        (n,sp)=>({msg:`${n} stretched magnificently in a sunbeam.`,d:3}),
        (n,sp)=>({msg:`${n} leapt between two chairs in front of a packed table.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} tilted ${pr.their} head and made everyone gasp.`,d:3}),
        (n,sp)=>({msg:`${n} performed a perfect slow-blink for an entire table.`,d:4}),
        (n,sp)=>({msg:`${n} paused mid-walk and let the crowd catch up.`,d:3}),
        (n,sp)=>({msg:`${n} executed a flawless jump and landed without looking.`,d:4}),
        (n,sp)=>({msg:`${n} sat in the best-lit spot in the cafe all afternoon.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} made a customer abandon ${randomPronouns(rng).their} book to watch instead.`,d:4}),
        (n,sp)=>({msg:`${n} walked the full length of the counter without knocking a thing.`,d:3}),
        (n,sp)=>({msg:`${n} turned yawning into an art form — people applauded.`,d:3}),
        (n,sp)=>({msg:`${n} preened elaborately in front of a very attentive crowd.`,d:4}),
        (n,sp)=>({msg:`${n} performed a flawless dismount from the bookshelf.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} waited until the whole cafe was quiet to make ${pr.their} entrance.`,d:3}),
        (n,sp)=>({msg:`${n} posed beside the specials board as if they were on it.`,d:3}),
        (n,sp)=>({msg:`${n} gave a solo grooming performance that left customers speechless.`,d:4}),
        (n,sp)=>({msg:`${n} claimed the window seat and let customers come to them.`,d:3}),
        (n,sp)=>({msg:`${n} bowed — apparently on purpose — after stepping off the counter.`,d:4}),
        (n,sp)=>({msg:`${n} casually knocked something off the shelf for the reaction.`,d:3}),
      ],
      shy:[
        (n,sp)=>({msg:`${n} peeked out from a cozy corner.`,d:2}),
        (n,sp)=>({msg:`${n} hid behind the menu board.`,d:1}),
        (n,sp)=>({msg:`${n} slowly approached a patient regular.`,d:3}),
        (n,sp)=>({msg:`${n} blinked softly at a quiet customer.`,d:2}),
        (n,sp)=>({msg:`${n} crept out when things got quieter.`,d:2}),
        (n,sp)=>({msg:`${n} let a child approach but retreated from an adult.`,d:2}),
        (n,sp)=>({msg:`${n} watched from a high shelf, too curious to fully hide.`,d:2}),
        (n,sp)=>({msg:`${n} accepted one gentle pat before darting off.`,d:3}),
        (n,sp)=>({msg:`${n} sat at the edge of the room looking beautiful.`,d:2}),
        (n,sp)=>({msg:`${n} chose to sit beside a very still customer.`,d:3}),
        (n,sp)=>({msg:`${n} ventured a little further out each hour.`,d:2}),
        (n,sp)=>({msg:`${n} made brief but intense eye contact from across the room.`,d:2}),
        (n,sp)=>({msg:`${n} curled up just out of reach but close enough to be seen.`,d:2}),
        (n,sp)=>({msg:`${n} tiptoed past the busiest table without anyone noticing.`,d:1}),
        (n,sp)=>({msg:`${n} warmed up to one regular and followed them quietly all afternoon.`,d:3}),
        (n,sp)=>({msg:`${n} peeked around a corner at exactly the right moment for a photo.`,d:3}),
        (n,sp)=>({msg:`${n} retreated when approached but left a tiny pawprint on someone's notebook.`,d:2}),
        (n,sp)=>({msg:`${n} sat behind a vase of flowers, peering out like a tiny spy.`,d:2}),
        (n,sp)=>({msg:`${n} let one very quiet customer earn a single chin scratch.`,d:3}),
        (n,sp)=>({msg:`${n} appeared from nowhere during the calm of mid-morning.`,d:2}),
        (n,sp)=>({msg:`${n} settled in the back corner and watched the world with soft eyes.`,d:2}),
        (n,sp)=>({msg:`${n} inched toward the warmth of a sunny table, then stopped just short.`,d:1}),
        (n,sp)=>({msg:`${n} was spotted once and not again — a mystery for the afternoon crowd.`,d:2}),
        (n,sp)=>({msg:`${n} hid inside a paper bag left on the floor.`,d:1}),
      ],
      lazy:[
        (n,sp)=>({msg:`${n} dozed off in a warm sunbeam.`,d:1}),
        (n,sp)=>({msg:`${n} yawned dramatically mid-afternoon.`,d:1}),
        (n,sp)=>({msg:`${n} sprawled across the comfiest chair.`,d:1}),
        (n,sp)=>({msg:`${n} refused to move from the windowsill.`,d:2}),
        (n,sp)=>({msg:`${n} spent the whole morning in one spot.`,d:1}),
        (n,sp)=>({msg:`${n} watched someone else play from a safe, flat surface.`,d:1}),
        (n,sp)=>({msg:`${n} napped through the lunch rush.`,d:1}),
        (n,sp)=>({msg:`${n} didn't move but did open one eye occasionally.`,d:2}),
        (n,sp)=>({msg:`${n} relocated from one cushion to a slightly better cushion.`,d:1}),
        (n,sp)=>({msg:`${n} looked at a toy, considered it, and went back to sleep.`,d:1}),
        (n,sp)=>({msg:`${n} obliged someone with a slow stretch before continuing to nap.`,d:2}),
        (n,sp)=>({msg:`${n} achieved a record-setting nap that spanned two table cleanings.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} rolled onto ${pr.their} back and stayed there for an impressive duration.`,d:1}),
        (n,sp)=>({msg:`${n} sighed contentedly and inspired three customers to put away their phones.`,d:2}),
        (n,sp)=>({msg:`${n} found the exact warmest patch of sun and refused to share.`,d:1}),
        (n,sp)=>({msg:`${n} was gently offered a toy and simply blinked.`,d:1}),
        (n,sp)=>({msg:`${n} permitted a customer to sit nearby, as long as they were quiet.`,d:1}),
        (n,sp)=>({msg:`${n} twitched an ear once during the entire afternoon rush.`,d:1}),
        (n,sp)=>({msg:`${n} managed to look rested and photogenic doing absolutely nothing.`,d:2}),
        (n,sp)=>({msg:`${n} was so still a customer asked if they were a plushie.`,d:2}),
        (n,sp)=>({msg:`${n} performed the slowest blink in recorded cafe history.`,d:1}),
        (n,sp)=>({msg:`${n} found a warm lap and settled in like they owned it.`,d:2}),
        (n,sp)=>({msg:`${n} spent an hour deciding which side to lie on and chose neither.`,d:1}),
        (n,sp)=>({msg:`${n} was so relaxed a customer couldn't stop watching.`,d:2}),
      ],
      regal:[
        (n,sp,pr,rng)=>({msg:`${n} surveyed ${pr.their} domain majestically.`,d:4}),
        (n,sp)=>({msg:`${n} sat on the highest perch, observing all.`,d:3}),
        (n,sp)=>({msg:`${n} accepted head scratches gracefully.`,d:4}),
        (n,sp)=>({msg:`${n} ignored a commoner who dared approach.`,d:3}),
        (n,sp)=>({msg:`${n} walked slowly through the cafe, granting attention selectively.`,d:4}),
        (n,sp)=>({msg:`${n} allowed exactly one photo before turning away.`,d:3}),
        (n,sp)=>({msg:`${n} descended from a high shelf as if this were an audience.`,d:4}),
        (n,sp)=>({msg:`${n} sat with perfect posture near the door.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} flicked ${pr.their} tail at a customer who wasn't attentive enough.`,d:3}),
        (n,sp)=>({msg:`${n} demanded a cushion be repositioned before sitting.`,d:4}),
        (n,sp)=>({msg:`${n} received tribute in the form of treats.`,d:3}),
        (n,sp)=>({msg:`${n} processed through the cafe as if leading a procession.`,d:4}),
        (n,sp)=>({msg:`${n} accepted exactly three strokes before indicating they were done.`,d:3}),
        (n,sp)=>({msg:`${n} permitted a small child to approach — this was considered an honour.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} cast a withering glance at someone who sat in ${pr.their} preferred chair.`,d:3}),
        (n,sp)=>({msg:`${n} inspected a new decoration and found it acceptable.`,d:3}),
        (n,sp)=>({msg:`${n} made an entire table feel they had been granted an audience.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} turned ${pr.their} back on a noisy group — a clear statement.`,d:3}),
        (n,sp)=>({msg:`${n} sat beside the window like a painting that had chosen itself.`,d:4}),
        (n,sp)=>({msg:`${n} regarded a newcomer with measured suspicion before bestowing a nod.`,d:3}),
        (n,sp)=>({msg:`${n} issued a single meow of command and was instantly obeyed.`,d:4}),
        (n,sp)=>({msg:`${n} wore the afternoon light like a crown.`,d:3}),
        (n,sp)=>({msg:`${n} found the velvet cushion in the corner and assumed it as a throne.`,d:4}),
        (n,sp)=>({msg:`${n} approved of a customer's outfit with a slow blink.`,d:4}),
      ],
      glutton:[
        (n,sp)=>({msg:`${n} eyed someone's pastry longingly!`,d:2}),
        (n,sp)=>({msg:`${n} sniffed a fresh croissant on the counter.`,d:2}),
        (n,sp)=>({msg:`${n} begged for treats from a soft-hearted guest.`,d:3}),
        (n,sp)=>({msg:`${n} inspected the kitchen with great interest.`,d:1}),
        (n,sp)=>({msg:`${n} stationed themselves next to the dessert display.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} followed a customer's sandwich with ${pr.their} eyes.`,d:2}),
        (n,sp)=>({msg:`${n} stared at the oven until something came out.`,d:1}),
        (n,sp)=>({msg:`${n} performed best when treats were nearby.`,d:3}),
        (n,sp)=>({msg:`${n} convinced three separate customers to share a bite.`,d:3}),
        (n,sp)=>({msg:`${n} discovered a dropped crumb and looked very satisfied.`,d:2}),
        (n,sp)=>({msg:`${n} sat beside the till and refused to leave.`,d:1}),
        (n,sp)=>({msg:`${n} developed a suspicious interest in a customer's to-go bag.`,d:2}),
        (n,sp)=>({msg:`${n} positioned themselves at the kitchen door all morning.`,d:1}),
        (n,sp)=>({msg:`${n} tracked the scent of a fresh scone across three tables.`,d:2}),
        (n,sp)=>({msg:`${n} perfected a look of starvation so convincing it earned a treat.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} licked ${pr.their} lips very deliberately next to a cheese plate.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} sat politely during a food delivery and expected ${pr.their} share.`,d:2}),
        (n,sp)=>({msg:`${n} memorised the exact time the afternoon pastries come out.`,d:1}),
        (n,sp)=>({msg:`${n} nudged an empty treat bowl loudly across the tile floor.`,d:3}),
        (n,sp)=>({msg:`${n} showed great emotional range upon learning there were no more cakes.`,d:1}),
        (n,sp)=>({msg:`${n} attended every coffee order as though supervising quality control.`,d:2}),
        (n,sp)=>({msg:`${n} inhaled the scent of a warm pie and looked personally offended it wasn't for them.`,d:2}),
        (n,sp)=>({msg:`${n} gave a customer the most soulful look — and received a treat for it.`,d:3}),
        (n,sp)=>({msg:`${n} watched every muffin that entered the building.`,d:2}),
      ],
      adventurous:[
        (n,sp)=>({msg:`${n} explored every corner of the cafe!`,d:3}),
        (n,sp)=>({msg:`${n} climbed to the top of the bookshelf!`,d:3}),
        (n,sp)=>({msg:`${n} investigated a customer's bag curiously.`,d:2}),
        (n,sp)=>({msg:`${n} found a secret hiding spot behind the counter!`,d:3}),
        (n,sp)=>({msg:`${n} squeezed into a gap nobody else noticed.`,d:3}),
        (n,sp)=>({msg:`${n} scaled the tallest bookshelf before anyone could stop them.`,d:3}),
        (n,sp)=>({msg:`${n} peered into the supply closet out of sheer curiosity.`,d:2}),
        (n,sp)=>({msg:`${n} discovered a new route between the tables.`,d:3}),
        (n,sp)=>({msg:`${n} launched off a chair onto a much higher ledge.`,d:3}),
        (n,sp)=>({msg:`${n} snuck behind the counter and supervised a coffee order.`,d:2}),
        (n,sp)=>({msg:`${n} led a group of customers on an impromptu tour.`,d:3}),
        (n,sp)=>({msg:`${n} discovered that the ceiling beam was climbable.`,d:3}),
        (n,sp)=>({msg:`${n} inspected the outside window ledge and reported back.`,d:2}),
        (n,sp)=>({msg:`${n} found an entirely new way up to the loft area.`,d:3}),
        (n,sp)=>({msg:`${n} tucked themselves behind the espresso machine like a tiny engineer.`,d:2}),
        (n,sp)=>({msg:`${n} crawled inside a returned delivery box and rated the experience.`,d:3}),
        (n,sp)=>({msg:`${n} leapt onto the chalkboard ledge and studied the menu carefully.`,d:2}),
        (n,sp)=>({msg:`${n} ventured into the garden for a full inspection before being retrieved.`,d:3}),
        (n,sp)=>({msg:`${n} found a vent nobody else had noticed and sat beside it thoughtfully.`,d:2}),
        (n,sp)=>({msg:`${n} established a new lookout post on top of the coffee machine.`,d:3}),
        (n,sp)=>({msg:`${n} mapped the underside of every table before noon.`,d:2}),
        (n,sp,pr,rng)=>({msg:`${n} reached the top of the coat hooks and surveyed ${pr.their} achievement.`,d:3}),
        (n,sp)=>({msg:`${n} charted an ambitious course through the afternoon crowd.`,d:3}),
        (n,sp)=>({msg:`${n} set out to map every corner of the room.`,d:2}),
      ],
      nurturing:[
        (n,sp)=>({msg:`${n} groomed a nearby ${sp.baby.toLowerCase()}.`,d:3}),
        (n,sp)=>({msg:`${n} watched over the little ones protectively.`,d:3}),
        (n,sp)=>({msg:`${n} comforted a nervous newcomer.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} shared ${pr.their} favorite bed with a younger pet.`,d:2}),
        (n,sp)=>({msg:`${n} stayed close to a ${sp.baby.toLowerCase()} all morning.`,d:3}),
        (n,sp)=>({msg:`${n} brought a toy over to a timid pet.`,d:3}),
        (n,sp)=>({msg:`${n} sat with a nervous customer until they relaxed.`,d:4}),
        (n,sp)=>({msg:`${n} gently herded the smallest pets into a warmer spot.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} let a much smaller pet take ${pr.their} favourite cushion.`,d:2}),
        (n,sp)=>({msg:`${n} spent the afternoon checking on every guest.`,d:4}),
        (n,sp)=>({msg:`${n} curled up with a crying child until they smiled.`,d:4}),
        (n,sp)=>({msg:`${n} cleaned the ears of a younger ${sp.baby.toLowerCase()} with great care.`,d:3}),
        (n,sp)=>({msg:`${n} guided a lost-looking customer to a quiet table.`,d:3}),
        (n,sp)=>({msg:`${n} settled beside a sneezing pet and kept them company.`,d:3}),
        (n,sp)=>({msg:`${n} kept watch over a sleeping ${sp.baby.toLowerCase()} for a full hour.`,d:4}),
        (n,sp)=>({msg:`${n} collected the scattered toys and brought them to the youngest pet.`,d:3}),
        (n,sp)=>({msg:`${n} pressed close to a customer having a hard day.`,d:4}),
        (n,sp)=>({msg:`${n} lay down with a pet who was feeling unwell until the vet arrived.`,d:4}),
        (n,sp)=>({msg:`${n} showed a nervous first-time customer that everything was safe.`,d:3}),
        (n,sp)=>({msg:`${n} placed themselves gently between two pets having a disagreement.`,d:3}),
        (n,sp)=>({msg:`${n} carried a toy to a sad-looking customer without being asked.`,d:4}),
        (n,sp)=>({msg:`${n} made sure every ${sp.baby.toLowerCase()} had eaten before settling down.`,d:3}),
        (n,sp)=>({msg:`${n} stayed by the door until every guest had safely left for the night.`,d:4}),
        (n,sp)=>({msg:`${n} nudged a newcomer pet towards the food bowl.`,d:3}),
      ],
      vocal:[
        (n,sp)=>({msg:sp===SPECIES.birds?`${n} sang a beautiful melody!`:`${n} made adorable sounds!`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:sp===SPECIES.dogs?`${n} let out an excited bark!`:`${n} chirped at passing birds outside!`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:`${n} chattered away at the morning crowd.`,d:2}),
        (n,sp)=>({msg:`${n} serenaded the afternoon customers.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} announced every customer who walked through the door.`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:`${n} held a one-sided conversation with the barista.`,d:2}),
        (n,sp)=>({msg:`${n} responded to every noise in the kitchen.`,d:rng.bool(.7)?2:-1}),
        (n,sp,pr,rng)=>({msg:`${n} was very vocal about the sun moving off ${pr.their} spot.`,d:rng.bool(.7)?1:-2}),
        (n,sp)=>({msg:`${n} sang along to the music playing in the cafe.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} had a lot to say about the new menu items.`,d:rng.bool(.6)?2:-1}),
        (n,sp,pr,rng)=>({msg:`${n} narrated ${pr.their} own entrance into the room.`,d:2}),
        (n,sp)=>({msg:`${n} delivered a lengthy address to a potted plant.`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:`${n} demanded attention from across the room at considerable volume.`,d:rng.bool(.7)?2:-2}),
        (n,sp)=>({msg:`${n} provided running commentary on the barista's technique.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} issued a soft trill that made everyone nearby look up and smile.`,d:2}),
        (n,sp)=>({msg:`${n} woke from a nap and immediately had opinions.`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:`${n} greeted a regular with a very specific and familiar sound.`,d:3}),
        (n,sp)=>({msg:`${n} conducted a duet with the milk steamer.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} commented extensively on a customer's phone call.`,d:rng.bool(.6)?2:-2}),
        (n,sp)=>({msg:`${n} spoke at length about the injustice of the treat tin being closed.`,d:rng.bool(.7)?2:-1}),
        (n,sp)=>({msg:`${n} made sounds nobody could name but everyone found charming.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} answered the bell above the door every single time it rang.`,d:rng.bool(.7)?2:-2}),
        (n,sp)=>({msg:`${n} started a call-and-response with a customer that lasted five minutes.`,d:rng.bool(.7)?3:-1}),
        (n,sp)=>({msg:`${n} expressed a strong opinion about closing time.`,d:rng.bool(.6)?2:-2}),
      ],
      zen:[
        (n,sp)=>({msg:`${n} sat still, mesmerising everyone.`,d:3}),
        (n,sp)=>({msg:`${n} meditated peacefully by the window.`,d:3}),
        (n,sp)=>({msg:`${n} emanated a calm energy across the cafe.`,d:4}),
        (n,sp)=>({msg:`${n} slow-blinked at every customer who looked.`,d:3}),
        (n,sp)=>({msg:`${n} turned the busiest corner of the cafe into a quiet zone.`,d:4}),
        (n,sp)=>({msg:`${n} achieved complete stillness for two full hours.`,d:3}),
        (n,sp)=>({msg:`${n} sat in the center of the room like a living sculpture.`,d:3}),
        (n,sp)=>({msg:`${n} breathed slowly while chaos happened around them.`,d:4}),
        (n,sp)=>({msg:`${n} looked out the window with the serenity of a monk.`,d:3}),
        (n,sp)=>({msg:`${n} had three customers tell the barista they felt calmer.`,d:4}),
        (n,sp)=>({msg:`${n} was the most peaceful thing in the room, effortlessly.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} settled beside a stressed customer and seemed to absorb ${randomPronouns(rng).their} tension.`,d:4}),
        (n,sp)=>({msg:`${n} sat perfectly still through a minor incident that alarmed everyone else.`,d:3}),
        (n,sp)=>({msg:`${n} made the noisiest table in the cafe go quiet without doing a thing.`,d:4}),
        (n,sp)=>({msg:`${n} found an unoccupied beam of light and occupied it with full commitment.`,d:3}),
        (n,sp)=>({msg:`${n} appeared to be listening to something no one else could hear.`,d:3}),
        (n,sp)=>({msg:`${n} kept perfect composure while a fire alarm was tested nearby.`,d:4}),
        (n,sp)=>({msg:`${n} regarded a passing pigeon with an equanimity that bordered on the divine.`,d:3}),
        (n,sp,pr,rng)=>({msg:`${n} inspired a customer to close ${randomPronouns(rng).their} laptop and just sit quietly.`,d:4}),
        (n,sp)=>({msg:`${n} blinked once, slowly, at each person who entered — a blessing of sorts.`,d:3}),
        (n,sp)=>({msg:`${n} rested in the eye of the lunchtime storm without a care.`,d:4}),
        (n,sp,pr,rng)=>({msg:`${n} brought a stillness to the room that outlasted ${pr.their} nap.`,d:3}),
        (n,sp)=>({msg:`${n} watched clouds from the window with a patience that shamed everyone present.`,d:4}),
        (n,sp)=>({msg:`${n} held a staring contest with the rain outside.`,d:3}),
      ],
    };
    const out=[];
    for(const p of pets){
      if(p.state.isKitten||!rng.bool(.25))continue;
      const sp=SPECIES[p.species]||SPECIES.cats;
      const rm=rarityTipMult(p.stats.rarity);
      // Check for champion ancestor lineage vignette (30% chance if ancestor found)
      let usedLineage = false;
      if(allPets.length && champIds.size) {
        const ancestors = getAncestors(p.id, allPets, 4);
        const champAncestor = [...ancestors].find(id => id !== p.id && champIds.has(id));
        if(champAncestor && rng.bool(0.3)) {
          const ancestorName = allPets.find(x => x.id === champAncestor)?.name || 'an old champion';
          const depth = getRelationDepth(p.id, champAncestor, allPets);
          const relLabel = getRelationLabel(depth, p.gender);
          const fn = rng.pick(LINEAGE_TEMPLATES);
          const pr = petPronouns(p);
          const r = fn(p.name, ancestorName, relLabel, pr);
          out.push({petId:p.id,message:r.msg,tipsDelta:Math.round(r.d*rm*10)});
          usedLineage = true;
        }
      }
      if(!usedLineage) {
        const fns=M[p.personality.primary];
        if(fns){const pr=petPronouns(p);const fn=rng.pick(fns);const r=fn(p.name,sp,pr,rng);out.push({petId:p.id,message:r.msg,tipsDelta:r.d>0?Math.round(r.d*rm*10):r.d*10});}
      }
    }
    // Mishap pass — independent of personality events. Many minor accidents that cost money.
    for(const p of pets){
      if(p.state.isKitten) continue;
      if((p.state.sick||0)>0) continue;
      if(!rng.bool(0.30)) continue;
      const fn = rng.pick(MISHAPS);
      const r = fn(p.name);
      // Mishaps use the same ×10 scaling as other events. Rarer pets have slightly smaller mishap costs
      // because they're better-behaved (divide by rarity mult instead of multiplying).
      const rm2 = rarityTipMult(p.stats.rarity);
      out.push({petId:p.id, message:r.msg, tipsDelta: Math.round(r.d * 10 / rm2), isMishap:true});
    }
    // Duo vignettes for best-friend pairs (strength >= 8), 10% chance per pair per day
    const DUO_TEMPLATES = [
      (a,b)=>({msg:`${a} and ${b} played tag together, delighting every customer!`,d:25}),
      (a,b)=>({msg:`${a} and ${b} curled up together in the sunny window. Customers couldn't stop taking photos!`,d:25}),
      (a,b)=>({msg:`${a} groomed ${b} by the counter — a regular called it the highlight of the week.`,d:20}),
      (a,b)=>({msg:`${a} and ${b} chased each other through the cafe to thunderous applause.`,d:30}),
      (a,b)=>({msg:`${a} and ${b} shared a cushion all afternoon. Tips flowed freely.`,d:20}),
      (a,b)=>({msg:`${a} stole ${b}'s toy and handed it back immediately — the crowd loved it.`,d:20}),
      (a,b)=>({msg:`${a} and ${b} staged a dramatic slow-blink contest. Everyone was transfixed.`,d:25}),
      (a,b)=>({msg:`${b} napped on ${a}'s back — a photo went straight to the cafe's social page.`,d:30}),
    ];
    const adultPetsOnFloor = pets.filter(p => !p.state.isKitten);
    for (let i = 0; i < adultPetsOnFloor.length; i++) {
      for (let j = i + 1; j < adultPetsOnFloor.length; j++) {
        const pa = adultPetsOnFloor[i], pb = adultPetsOnFloor[j];
        const rel = (pa.relationships || []).find(r => r.petId === pb.id);
        if (!rel || rel.strength < 8) continue;
        if (!rng.bool(0.10)) continue;
        const fn = rng.pick(DUO_TEMPLATES);
        const r = fn(pa.name, pb.name);
        out.push({petId: pa.id, message: r.msg, tipsDelta: r.d, isDuo: true});
      }
    }
    // Return everything; runWeek deduplicates and caps personality events at 3 per week.
    return out;
  },
};

// ─── TREND ────────────────────────────────────────────────────────────────────
// Turn a single phenotype trait into a grammatical adjective phrase.
// e.g. fur='long' → 'long-haired', eyes='blue' → 'blue-eyed',
//      plumage='crested' → 'crested', scales='smooth' → 'smooth-scaled'.
function traitAdjective(key, value) {
  if (!value) return '';
  const v = String(value).toLowerCase();
  switch (key) {
    case 'fur':     return `${v}-haired`;
    case 'eyes':    return `${v}-eyed`;
    case 'scales':  return `${v}-scaled`;
    // plumage values (sleek/fluffy/crested/ruffled) already work as adjectives on their own
    case 'plumage': return v;
    // pattern values (solid/striped/banded/spotted/mottled) work as adjectives
    case 'pattern': return v;
    // color values (scarlet/azure/...) work as adjectives
    case 'color':   return v;
    // size values (small/medium/large) work as adjectives
    case 'size':    return v;
    // breed is a proper noun; capitalize it — e.g. 'labrador' → 'Labrador'
    case 'breed':   return capitalize(v);
    default:        return v;
  }
}

function describeTrendPrediction(predicted) {
  if (!predicted) return 'something surprising';
  const spName = (SPECIES?.[predicted.species]?.name || predicted.species || 'pets').toLowerCase();
  const traits = predicted.targetTraits || {};
  const entries = Object.entries(traits).filter(([,v])=>!!v);
  if (entries.length === 0) return spName;
  // For dogs with a breed trait, prefer "Labradors" over "Labrador dogs"
  if (entries.length === 1 && entries[0][0] === 'breed' && predicted.species === 'dogs') {
    return `${capitalize(entries[0][1])}s`;
  }
  const adjectives = entries.map(([k,v])=>traitAdjective(k,v));
  return `${adjectives.join(' ')} ${spName}`;
}
const Trend = {
  gen(rng,wk,simple=false,maxTraits=2) {
    const species=rng.pick(['cats','dogs','birds','lizards']);
    const tt=TRAIT_TABLES[species];
    const sp=SPECIES[species];
    // Pick specificity: 0 traits (species-only), 1 trait, 2 traits
    const n=simple?0:Math.min(rng.wp([{value:0,weight:30},{value:1,weight:45},{value:2,weight:25}]),maxTraits);
    const tgt={};
    if(n>0){
      const traitKeys=rng.sample(Object.keys(tt),n);
      for(const t of traitKeys) tgt[t]=rng.iwp(tt[t]);
    }
    const mult=n===0?Math.round((1.2+rng.r()*.2)*10)/10:n===1?Math.round((1.6+rng.r()*.4)*10)/10:Math.round((2.2+rng.r()*.5)*10)/10;
    // Traits that act as adjectives (placed before species name)
    const ADJ_TRAITS = new Set(['fur','scales','plumage','size']);
    let description;
    if(n===0){
      description=`Customers love ${sp.name}!`;
    } else if(n===1&&tgt.breed){
      // "Customers love Shiba Dogs!" instead of "Customers love Dogs with Shiba!"
      description=`Customers love ${capitalize(tgt.breed)} ${sp.name}!`;
    } else if(n===1&&tgt.size){
      // "Customers love small Dogs!" instead of "Customers love Dogs with small"
      description=`Customers love ${capitalize(tgt.size)} ${sp.name}!`;
    } else if(n===1&&tgt.fur){
      description=`Customers love ${capitalize(tgt.fur)}-haired ${sp.name}!`;
    } else if(n===1&&tgt.plumage){
      description=`Customers love ${capitalize(tgt.plumage)} ${sp.name}!`;
    } else if(n===1&&tgt.scales){
      description=`Customers love ${capitalize(tgt.scales)}-scaled ${sp.name}!`;
    } else {
      // Split into adjective traits (go before name) and descriptive traits (go after "with")
      const adjParts=Object.entries(tgt).filter(([k])=>ADJ_TRAITS.has(k)&&k!=='breed').map(([k,v])=>
        k==='fur'?`${capitalize(v)}-haired`:k==='scales'?`${capitalize(v)}-scaled`:capitalize(v)
      );
      const descParts=Object.entries(tgt).filter(([k])=>!ADJ_TRAITS.has(k)&&k!=='breed').map(([k,v])=>traitFullLabel(k,v));
      const breedPart=tgt.breed?`${capitalize(tgt.breed)} `:'';
      const adjStr=adjParts.length?adjParts.join(' ')+' ':'';
      const descStr=descParts.length?` with ${descParts.join(' & ')}`:'';
      description=`Customers love ${adjStr}${breedPart}${sp.name}${descStr}!`;
    }
    return{id:'t'+wk,description,species,targetTraits:tgt,bonusMultiplier:mult};
  },
  score(pet,trend) {
    if(!trend)return 0;
    if(pet.species!==trend.species)return 0;
    const ks=Object.keys(trend.targetTraits||{});
    if(ks.length===0)return 1;
    return ks.filter(k=>pet.phenotype[k]===trend.targetTraits[k]).length/ks.length;
  },
};

// ─── SHOW ─────────────────────────────────────────────────────────────────────
/** Show module: competitive pet showcases every SHOW_INTERVAL_WEEKS. NPC difficulty scales with year. */
const Show = {
  gen(rng,showNum,prevSpecies=null,week=null) {
    // Championship: the show judged nearest to week-of-year 30 each year (woy 28-32), year >= 5
    const woy=week!==null?week%GAME_CONFIG.WEEKS_PER_YEAR:-1;
    const yr=week!==null?Time.year(week):0;
    const isChampionship=week!==null&&woy>=28&&woy<=32&&yr>=5;
    const speciesPool=prevSpecies?['cats','dogs','birds','lizards'].filter(s=>s!==prevSpecies):['cats','dogs','birds','lizards'];
    const species=isChampionship?rng.pick(['cats','dogs','birds','lizards']):rng.pick(speciesPool);
    const themes=SHOW_THEMES_ALL[species]||SHOW_THEMES_ALL.cats;
    const theme=rng.pick(themes);
    const base=isChampionship?25000:(2000+showNum*200);
    // For each judged trait, randomly pick ~1/3 of values as preferred for this show
    const preferredTraitValues={};
    for(const t of theme.traits){
      if(t==='rarity'||t==='generation'||t==='personality')continue;
      if(t==='displayColor'){
        const colorKeys=Object.keys(COLOR_MAPS[species]||{});
        if(colorKeys.length){const count=Math.max(1,Math.round(colorKeys.length/3));preferredTraitValues.displayColor=rng.sample(colorKeys,count);}
        continue;
      }
      const tt2=TRAIT_TABLES[species];
      const vals=tt2?.[t]?.map(e=>e.value)||[];
      if(!vals.length)continue;
      const count=Math.max(1,Math.round(vals.length/3));
      preferredTraitValues[t]=rng.sample(vals,count);
    }
    const prizes=isChampionship?[25000,12500,6250]:[base,Math.round(base*.5),Math.round(base*.25)];
    const title=isChampionship?'National Championship — '+theme.name:theme.name;
    return{id:'sh'+showNum,title,traits:theme.traits,prizes,showNum,species,preferredTraitValues,isChampionship:isChampionship||false};
  },
  score(pet,show,playerWinCount=0) {
    const tt=TRAIT_TABLES[pet.species]||TT_CATS;
    // Species match bonus
    let s=pet.stats.rarity*30;
    if(show.species&&pet.species===show.species) s+=10;
    for(const t of show.traits){
      if(t==='rarity'){s+=pet.stats.rarity*20;continue;}
      if(t==='generation'){s+=pet.state.generation*8;continue;}
      if(t==='personality'){continue;} // applied unconditionally below
      if(t==='displayColor'){
        const ptv=show.preferredTraitValues?.displayColor;
        if(ptv&&ptv.includes(pet.displayColor)) s+=20;
        continue;
      }
      // Preferred trait values (new system: rotating per show)
      const ptv=show.preferredTraitValues?.[t];
      if(ptv!==undefined){
        if(ptv.includes(pet.phenotype[t])) s+=20;
        continue;
      }
      // Fallback for old saves: rarity-based scoring
      const al=tt[t];if(!al)continue;
      const e=al.find(a=>a.value===pet.phenotype[t]);
      if(e){const mx=Math.max(...al.map(a=>a.weight));s+=(mx-e.weight)/mx*20;}
    }
    // Champion title bonus
    const champBonus=playerWinCount>=25?8:playerWinCount>=10?4:playerWinCount>=3?2:0;
    // Personality always matters (handled here, not in the trait loop) so it applies even in non-personality shows
    return s+Pers.showBonus(pet.personality)*5+champBonus;
  },
  run(entries,show,rng,year=1,playerWinCount=0) {
    const showSpecies=show.species||'cats';
    const npcTT=TRAIT_TABLES[showSpecies]||TT_CATS;
    const nr=new RNG(rng.i(1,999999));
    // Championship uses max-tier NPC difficulty regardless of year
    const npcMaxGen=show.isChampionship?15:Math.min(15,Math.floor(year*0.8)+3);
    const npcBonus=show.isChampionship?22:(8+(year-1)*1.0);
    const npcs=Array.from({length:rng.i(5,8)},()=>{
      const c=Genetics.mk(nr,npcTT,nr.i(0,npcMaxGen));
      c.name='NPC';c.species=showSpecies;
      c.cosmetics=generateCosmetics(nr,showSpecies);
      c.displayColor=getDisplayColor(nr, showSpecies, c.phenotype);
      return{pet:c,ip:false,score:this.score(c,show)+nr.r()*npcBonus};
    });
    const players=entries.map(p=>({pet:p,ip:true,score:this.score(p,show,playerWinCount)+rng.r()*5}));
    return[...players,...npcs].sort((a,b)=>b.score-a.score).map((e,i)=>({
      placement:i+1,petId:e.pet.id,petName:e.pet.name,isPlayer:e.ip,
      score:Math.round(e.score),prize:i<show.prizes.length?show.prizes[i]:0,
    }));
  },
};

// ─── TIME (week-based) ───────────────────────────────────────────────────────
/** Time module: week-based calendar (48 weeks/year, 4 seasons, 15-year max). */
const Time = {
  SEASONS:['Spring','Summer','Autumn','Winter'],
  monthOfYear(w){ return Math.floor((w%GAME_CONFIG.WEEKS_PER_YEAR)/GAME_CONFIG.WEEKS_PER_MONTH)+1; },
  season(w)     { return this.SEASONS[Math.floor((this.monthOfYear(w)-1)/3)]; },
  weekOfMonth(w){ return(w%GAME_CONFIG.WEEKS_PER_MONTH)+1; },
  year(w)       { return Math.floor(w/GAME_CONFIG.WEEKS_PER_YEAR)+1; },
  over(w)       { return w>=GAME_CONFIG.GAME_END_WEEKS; },
  fmt(w)        { return`Year ${this.year(w)}/15 · ${this.season(w)} · Month ${this.monthOfYear(w)} · Week ${this.weekOfMonth(w)}`; },
  // Returns weeks until next show judging. 1 = show this week. Always in [1, SHOW_INTERVAL_WEEKS].
  weeksUntilJudging(w){ return((GAME_CONFIG.SHOW_INTERVAL_WEEKS-(w%GAME_CONFIG.SHOW_INTERVAL_WEEKS))%GAME_CONFIG.SHOW_INTERVAL_WEEKS)||GAME_CONFIG.SHOW_INTERVAL_WEEKS; },
};

// ─── SAVE ─────────────────────────────────────────────────────────────────────
const Save = {
  KEY:'pc-v1',
  save(s) { try{localStorage.setItem(this.KEY,JSON.stringify(s));}catch{} },
  load()  { try{const d=localStorage.getItem(this.KEY);return d?JSON.parse(d):null;}catch{return null;} },
  has()   { try{return!!localStorage.getItem(this.KEY);}catch{return false;} },
  del()   { try{localStorage.removeItem(this.KEY);}catch{} },
  migrate(s) {
    if(!s||typeof s!=='object') return s;
    // Step 1: version-specific structural migrations (run FIRST, before overlay)
    // Old saves stored petType at top level. Species is now per-pet.
    if(s.petType) {
      const oldSpecies=s.petType;
      const rng=new RNG((s.seed||1)+9999);
      const migratePet=(p)=>p.species?p:({...p,
        species:oldSpecies,
        displayColor:getDisplayColor(rng, oldSpecies, p.phenotype),
        cosmetics:generateCosmetics(rng,oldSpecies),
      });
      s={...s,pets:(s.pets||[]).map(migratePet),wildPetPool:(s.wildPetPool||[]).map(migratePet)};
      delete s.petType;
    }
    // Old saves had rdBudget as a single number. It's now per-category.
    if(typeof s.rdBudget==='number'){
      const total=s.rdBudget,base=Math.floor(total/3),rem=total-base*3;
      s={...s,rdBudget:{drinks:base+rem,food:base,desserts:base}};
    }
    // Backfill gender on pets from legacy id-parity scheme
    const backfillGender=(p)=>p.gender?p:({...p,gender:parseInt(p.id.replace(/\D/g,'').slice(0,4)||'0')%2===0?'male':'female'});
    s={...s,pets:(s.pets||[]).map(backfillGender),wildPetPool:(s.wildPetPool||[]).map(backfillGender)};
    // Backfill per-pet fields
    const backfillPetFields=(p)=>({...p,
      totalTipsEarned:p.totalTipsEarned===undefined?0:p.totalTipsEarned,
      wish:p.wish===undefined?null:p.wish,
      fulfilledWishes:p.fulfilledWishes===undefined?0:p.fulfilledWishes,
      relationships:p.relationships||[],
      state:{...p.state,sick:p.state?.sick||0},
    });
    s={...s,pets:(s.pets||[]).map(backfillPetFields)};
    if(s.wildPetPool) s={...s,wildPetPool:s.wildPetPool.map(backfillPetFields)};
    if(s.starterPets) s={...s,starterPets:s.starterPets.map(backfillPetFields)};

    // Step 2: overlay all scalar/object defaults from STATE_DEFAULTS.
    // STATE_DEFAULTS provides every non-seeded field; the spread preserves anything
    // the save already has and fills missing fields with defaults.
    const merged={...STATE_DEFAULTS,...s};

    // Step 3: context-dependent backfills that STATE_DEFAULTS can't express
    // Player name + isPlayer flag on first staff
    if(merged.playerName===undefined) merged.playerName=merged.staff?.[0]?.name||'You';
    if(merged.staff&&!merged.staff[0]?.isPlayer) merged.staff=merged.staff.map((st,i)=>i===0?{...st,isPlayer:true}:st);
    // Auto-assign unassigned non-player staff to the cafe if slots exist
    {
      const cafeSlots=getMaxCafeAddlStaff(merged);
      let cafeCount=Object.values(merged.staffAssignments||{}).filter(v=>v==='cafe').length;
      const sa={...(merged.staffAssignments||{})};let changed=false;
      for(const st of (merged.staff||[])) {
        if(!st.isPlayer&&sa[st.id]===undefined){
          if(cafeCount<cafeSlots){sa[st.id]='cafe';cafeCount++;changed=true;}
        }
      }
      if(changed) merged.staffAssignments=sa;
    }
    // Retroactively mark currently-reached milestones for old saves (no celebration)
    if(s.completedMilestoneIds===undefined) {
      merged.completedMilestoneIds=MILESTONES.filter(m=>m.check(merged).reached).map(m=>m.id);
    }
    // Letters: existing players (week>0) skip the intro letter
    if(!s.lettersSeen) merged.lettersSeen=merged.week>0?['mayor_intro']:[];
    // criticEffect legacy shape
    if(s.criticEffect===undefined&&s.nextWeekCustomerMod!==undefined) {
      merged.criticEffect={mult:s.nextWeekCustomerMod||1,weeksLeft:(s.nextWeekCustomerMod&&s.nextWeekCustomerMod!==1)?1:0};
    }

    return merged;
  },
};

// ─── ECONOMY ──────────────────────────────────────────────────────────────────
/** Economy module: pet sale/adoption pricing and end-game Five Pillars scoring (0-100 each). */
const Econ = {
  // NOTE: salePrice is the canonical "real value" with age multipliers, but the only call site is
  // adoptCost(). Actual sell flow uses adoptCost(p)*(0.8 + cleaning/10*0.2) ≈ salePrice × (0.889..1.111).
  salePrice(pet) {
    const base=100+pet.stats.rarity*2000+pet.state.generation*300+(pet.state.generation>=5?500:0)+(pet.state.generation>=10?1500:0);
    // Adults are worth more; value decreases linearly from 7 years (336w) to retirement (480w)
    const age=pet.state.age||0;
    const PRIME_AGE=336, RETIRE_AGE=GAME_CONFIG.RETIREMENT_AGE_WEEKS;
    let ageMult=1.0;
    if(age>=GAME_CONFIG.BREED_MIN_AGE_WEEKS&&age<PRIME_AGE) ageMult=1.5; // adult prime
    else if(age>=PRIME_AGE&&age<RETIRE_AGE) ageMult=Math.max(0,1.5*(1-(age-PRIME_AGE)/(RETIRE_AGE-PRIME_AGE))); // linear decline to 0
    else if(age>=RETIRE_AGE) ageMult=0;
    else ageMult=0.5; // young/kitten
    return Math.round(base*ageMult);
  },
  adoptCost(pet) { return Math.round(this.salePrice(pet)/0.9); },
  finalScore(st) {
    // Score = sum of pts from permanently-earned milestones, grouped by pillar
    const completedSet=new Set(st.completedMilestoneIds||[]);
    const earned={cafe:0,menu:0,pets:0,breeding:0,shows:0};
    const max={cafe:0,menu:0,pets:0,breeding:0,shows:0};
    for(const m of MILESTONES){
      const pts=m.pts||0;
      max[m.pillar]=(max[m.pillar]||0)+pts;
      if(completedSet.has(m.id)||m.check(st).reached) earned[m.pillar]=(earned[m.pillar]||0)+pts;
    }
    const total=earned.cafe+earned.menu+earned.pets+earned.breeding+earned.shows;
    const maxTotal=max.cafe+max.menu+max.pets+max.breeding+max.shows;
    return{cafe:earned.cafe,menu:earned.menu,pets:earned.pets,breeding:earned.breeding,shows:earned.shows,
           maxCafe:max.cafe,maxMenu:max.menu,maxPets:max.pets,maxBreeding:max.breeding,maxShows:max.shows,
           total,maxTotal};
  },
};

// ─── HELPER: generate cosmetic (SVG-visual-only) traits per species ───────────
function generateCosmetics(rng, species) {
  if(species==='cats') {
    return {
      coat: rng.pick(['tabby','solid','bicolor','calico','tuxedo','siamese','tortoiseshell']),
      color: rng.pick(['black','orange','gray','brown','cream','white','chocolate','lilac','cinnamon','blue-gray']),
      pattern: rng.pick(['stripes','mackerel','spots','patches','rosettes','marbled','none']),
      patternColor: rng.pick(['dark','warm','cool','silver','golden','faded']),
      ears: rng.pick(['normal','tall','folded','pointed','curled','rounded','tufted']),
      eyeShape: rng.pick(['round','almond','oval','hooded','wide']),
      tail: rng.pick(['long','medium','short','bobbed','pom-pom']),
      tailCurl: rng.pick(['straight','slight','curled','corkscrew','kinked']),
      size: rng.pick(['medium','large','small','tiny','chonky']),
      fur: rng.pick(['short','medium','long','extra-long']),
      furTexture: rng.pick(['smooth','plush','fluffy','wiry','curly','velvety']),
      face: rng.pick(['round','oval','wedge','heart','square','flat']),
      whiskers: rng.pick(['long','medium','short','curly','magnificent']),
    };
  }
  if(species==='dogs') {
    return {
      coat: rng.pick(['smooth','double','wiry','curly','silky','merle','spotted']),
      color: rng.pick(['golden','black','brown','white','cream','red','gray','chocolate','brindle','blue']),
      pattern: rng.pick(['solid','bicolor','tricolor','tuxedo','spotted','merle','sable']),
      patternColor: rng.pick(['tan','white','dark','cream','red','gray','silver','golden']),
      ears: rng.pick(['floppy','erect','semi-erect','rose','button','folded','bat']),
      eyeShape: rng.pick(['round','almond','oval','triangular','wide']),
      tail: rng.pick(['long','medium','curled','short','docked','plume']),
      tailCurl: rng.pick(['straight','curved','sickle','ring','corkscrew','wagging']),
      size: rng.pick(['medium','large','small','tiny','giant','teacup']),
      fur: rng.pick(['short','medium','long','wire','double']),
      furTexture: rng.pick(['smooth','fluffy','wiry','curly','silky','rough','velvety']),
      face: rng.pick(['round','long','square','wedge','flat','broad']),
      whiskers: rng.pick(['medium','long','short','bristly','fine']),
    };
  }
  if(species==='birds') {
    return {
      coat: rng.pick(['sleek','fluffy','crested','smooth','ruffled','iridescent','downy']),
      color: rng.pick(['scarlet','azure','emerald','golden','violet','white','obsidian','coral','teal','sunset']),
      pattern: rng.pick(['solid','barred','spotted','streaked','mottled','banded','speckled']),
      patternColor: rng.pick(['dark','light','golden','silver','bronze','iridescent']),
      ears: rng.pick(['none','small','tall','mohawk','fan','crown','tuft']),
      eyeShape: rng.pick(['round','beady','large','hooded','alert']),
      tail: rng.pick(['long','fan','forked','pointed','short','display']),
      tailCurl: rng.pick(['compact','moderate','display','flowing','bobbed','spread']),
      size: rng.pick(['medium','small','tiny','large','majestic','petite']),
      fur: rng.pick(['short','medium','long','downy','sleek','fluffy']),
      furTexture: rng.pick(['smooth','soft','glossy','matte','iridescent','velvety']),
      face: rng.pick(['short','long','curved','hooked','straight','broad']),
      whiskers: rng.pick(['melodic','chirpy','quiet','loud','warbling','silent']),
    };
  }
  // lizards
  return {
    coat: rng.pick(['smooth','ridged','keeled','granular','beaded','plated','spiny','armored']),
    color: rng.pick(['emerald','sand','obsidian','rust','azure','crimson','jade','amber','slate','ivory']),
    pattern: rng.pick(['solid','striped','banded','spotted','mottled','reticulated','blotched']),
    patternColor: rng.pick(['dark','golden','pale','vivid','subtle','metallic']),
    ears: rng.pick(['none','small','prominent','frilled','spiny','crested','ridged']),
    eyeShape: rng.pick(['slit','round','elliptical','hooded','bulging','narrow']),
    tail: rng.pick(['long','medium','short','whip','club','curled']),
    tailCurl: rng.pick(['straight','curved','prehensile','ringed','stumpy','coiled']),
    size: rng.pick(['medium','large','small','tiny','massive','miniature']),
    fur: rng.pick(['smooth','rough','bumpy','scaly','leathery','textured']),
    furTexture: rng.pick(['glossy','matte','iridescent','dull','shimmering','pebbly']),
    face: rng.pick(['narrow','broad','flat','wedge','domed','angular']),
    whiskers: rng.pick(['short','long','forked','flicking','hidden','split']),
  };
}

// Breed-appropriate display colors for dogs (AKC-researched)
const DOG_BREED_COLORS = {
  labrador:  ['black','chocolate','brown','cream','golden'],  // AKC: black, yellow (cream→golden), chocolate
  poodle:    ['black','white','gray','brown','chocolate','cream','blue'], // AKC: many solid colors incl. blue (dilute black)
  beagle:    ['black','white','brown','blue'],                // AKC: any true hound color; tricolor, blue/tan/white
  husky:     ['black','white','gray'],                        // AKC: black to white; gray most common
  corgi:     ['black','golden'],                              // AKC: sable (golden), black and tan
  dalmatian: ['white'],                                       // Always white base coat; spots drawn separately in SVG
  shiba:     ['black','golden'],                              // AKC: black and tan, sesame (golden); red removed
};
function dogDisplayColor(rng, breed) {
  const colors = DOG_BREED_COLORS[breed];
  return colors ? rng.pick(colors) : rng.pick(Object.keys(COLOR_MAPS.dogs));
}

/**
 * Pick a display color for a pet based on species.
 * Dogs use breed-appropriate colors; birds use their plumage color phenotype;
 * cats/lizards pick randomly from their COLOR_MAPS palette.
 * Used at: pet creation, breeding births, save migration, NPC generation.
 */
function getDisplayColor(rng, species, phenotype) {
  if (species === 'dogs') return dogDisplayColor(rng, phenotype?.breed);
  if (species === 'birds') return phenotype?.color || rng.pick(Object.keys(COLOR_MAPS.birds));
  return rng.pick(Object.keys(COLOR_MAPS[species] || COLOR_MAPS.cats));
}

/**
 * Average effective skill level across all staff for a given skill.
 * Training flag halves a staff member's contribution (they're learning, not working).
 * Returns 0 if staff list is empty.
 */
function getStaffSkillAvg(staff, skillName) {
  if (!staff || !staff.length) return 0;
  return staff.reduce((s, m) => s + (m.training ? Math.ceil(m.skills[skillName] / 2) : m.skills[skillName]), 0) / staff.length;
}

// ─── HELPER: pick a unique pet name ─────────────────────────────────────────
function pickPetName(rng, species, existingPets=[], gender=null) {
  const sp=SPECIES[species];
  // Pick from gender-specific pool if available, else fall back to unisex names
  const genderedPool = gender==='male'&&sp.maleNames ? sp.maleNames :
                       gender==='female'&&sp.femaleNames ? sp.femaleNames :
                       (sp.maleNames&&sp.femaleNames ? [...sp.maleNames,...sp.femaleNames] : sp.names);
  const allNames=genderedPool||sp.names;
  const usedNames=new Set(existingPets.map(p=>p.name));
  const available=allNames.filter(n=>!usedNames.has(n));
  const pool=available.length>0?available:allNames;
  return pool[Math.floor(rng.r()*pool.length)];
}

// ─── HELPER: create a named pet ──────────────────────────────────────────────
function makePet(rng,species,gen=0,alo=7,ahi=21,existingPets=[]) {
  const tt=TRAIT_TABLES[species]||TT_CATS;
  const pet = Genetics.mk(rng,tt,gen,alo,ahi);
  pet.name = pickPetName(rng, species, existingPets, pet.gender);
  pet.species = species;
  pet.displayColor = getDisplayColor(rng, species, pet.phenotype);
  pet.cosmetics = generateCosmetics(rng, species);
  return pet;
}

const ALL_SPECIES_KEYS = ['cats','dogs','birds','lizards'];
function makeCommonPet(rng, species) {
  const tt=TRAIT_TABLES[species]||TT_CATS;
  const genome={};
  for(const[t,al]of Object.entries(tt)){
    const common=[...al].sort((a,b)=>b.weight-a.weight)[0].value;
    genome[t]={a:common,b:common};
  }
  const ph=Genetics.express(genome,tt),r=Genetics.rarity(ph,tt),p=Pers.gen(rng);
  const pet={
    id:'p'+rng.i(1e6,9e6).toString(36),name:'?',
    gender:rng.bool()?'male':'female',
    genome,phenotype:ph,personality:p,
    stats:{appeal:Math.max(1,Math.ceil(r*10)),rarity:r},
    state:{age:52,breedCooldown:0,isKitten:false,isRetired:false,retiring:0,generation:0},
    location:'pen',parentA:null,parentB:null,
  };
  pet.species=species;
  pet.name=pickPetName(rng, species, [], pet.gender);
  pet.displayColor=getDisplayColor(rng, species, pet.phenotype);
  pet.cosmetics=generateCosmetics(rng,species);
  return pet;
}
// ─── MILESTONE GRANT HELPER ──────────────────────────────────────────────────
/** Check for newly-reached milestones and record them permanently.
 *  Adds to completedMilestoneIds and pendingMilestones (for immediate celebration). */
function grantMilestones(prevState, nextState) {
  const prev=new Set(prevState.completedMilestoneIds||[]);
  const newMs=MILESTONES.filter(m=>!prev.has(m.id)&&m.check(nextState).reached);
  if(!newMs.length) return nextState;
  return{...nextState,
    completedMilestoneIds:[...(nextState.completedMilestoneIds||[]),...newMs.map(m=>m.id)],
    pendingMilestones:[...(nextState.pendingMilestones||[]),...newMs.map(m=>({id:m.id,name:m.name,icon:m.icon,pts:m.pts}))],
  };
}
// ─── STATE DEFAULTS ───────────────────────────────────────────────────────────
// Single source of truth for every state field that doesn't depend on a seed.
// Both makeState (new game) and Save.migrate (load game) build from this so
// new fields added here automatically appear in both paths — no backfill drift.
const STATE_DEFAULTS = {
  week:0,
  pets:[], starterPets:null, assignedPetIds:[], breedingQueue:[], wildPetPool:[],
  cafeLevel:1, petHouseLevel:1, weekResult:null, weekSummary:null,
  allMenuItems:[], discoveredItemIds:[], activeMenuIds:{drinks:[],food:[],desserts:[]},
  rdBudget:{drinks:0,food:0,desserts:0}, lastWeekItemSales:{},
  staff:[], staffCandidates:[],
  currentTrend:null, trendHistory:[],
  currentShow:null, showEntries:[], showEntryFromCafe:[], trophies:[],
  money:GAME_CONFIG.STARTING_MONEY, totalEarned:0, totalSpent:0, totalRecruitSpent:0,
  debtWeeks:0, menuItemPrices:{}, recruitBudget:0, recruitHistory:[0],
  lockedBudgets:{drinks:false,food:false,desserts:false,recruitment:false},
  openMilestonesPanel:null, staffAssignments:{}, currentMenuTrends:[], purchaseOffers:[],
  menuItemWeeksActive:{}, discoveredCombos:[], discoveredCount:0,
  totalBred:0, totalPetsSold:0, highestRarity:0, maxGeneration:0,
  totalFirstPlace:0, totalPodiums:0, masteredItems:{},
  totalUncommonBred:0, totalRareBred:0, totalLegendaryBred:0,
  lastSoldPet:null, lastSoldPrice:0,
  activePanel:'home', eventLog:[], lastShowSpecies:null,
  tutorialStep:1, revenueHistory:[],
  completedMilestoneIds:[], pendingMilestones:[], pastPets:[],
  cafeName:'Pet Cafe',
  lettersSeen:[],
  npcs:{mayor_hollis:{affinity:0,lastVisit:0,arc:0},rival_owner:{affinity:0,lastVisit:0,arc:0},food_critic:{affinity:0,lastVisit:0,arc:0},loyal_regular:{affinity:0,lastVisit:0,arc:0}},
  npcVisit:null, npcBuffs:{},
  regulars:[], pastRegulars:[], regularBeats:[],
  trendRumor:null,
  adoptionLastGender:{},
  activeSeasonalEvent:null,
  criticEffect:{mult:1,weeksLeft:0},
  rivalCafe:null, rivalCafeTriggered:false,
  rivalCafe2:null, rivalCafe2Triggered:false,
  rivalCafe1Ended:false, rivalCafe2Started:false, rivalCafe2Ended:false,
  lastInspectorWeek:0, lastCriticWeek:0,
  pendingModal:null,
  celebrityAdoptions:[], lastCelebrityWeek:0,
  activeTvCrew:null, lastTvCrewWeek:0,
  tvCrewBuff:1, loyaltyBuff:1,
};

// ─── INITIAL STATE ────────────────────────────────────────────────────────────
function makeState(seed) {
  const rng=new RNG(seed);
  // Generate 16 starter pets for selection (4 per species: 2M + 2F)
  const starterPets=[];
  for(const sp of ALL_SPECIES_KEYS){
    for(const gender of ['male','male','female','female']){
      const pet=makeCommonPet(rng,sp);
      pet.gender=gender;
      starterPets.push(pet);
    }
  }
  const pets=[]; // empty until player selects
  // Wild pool: 1 of each species (4 total) + 2 random extras
  const adoptionLastGender={};
  const wild=ALL_SPECIES_KEYS.map(sp=>{const p=makePet(rng,sp,0,0,240);adoptionLastGender[sp]=p.gender;return{...p,adoptPriceMult:0.75+rng.r()*0.5};});
  for(let i=0;i<2;i++){const sp=rng.pick(ALL_SPECIES_KEYS);const p=makePet(rng,sp,0,0,240);wild.push({...p,adoptPriceMult:0.75+rng.r()*0.5});}
  const staffList=[Staff.gen(rng)];
  staffList[0].skills={cooking:1,service:1,petCare:1,cleaning:1};
  staffList[0].wage=10+4*3;
  const cands=Array.from({length:GAME_CONFIG.INITIAL_STAFF_CANDIDATES},()=>Staff.gen(rng));
  const trend=Trend.gen(rng,0,true);
  const show=Show.gen(rng,1);
  const allMenuItems=MenuGen.generateAll(rng);
  const starters=allMenuItems.filter(i=>{const idx=parseInt(i.id.split('_')[1]);return idx<2;});
  const discoveredItemIds=starters.map(i=>i.id);
  const activeMenuIds={
    drinks:starters.filter(i=>i.cat==='drinks').map(i=>i.id),
    food:starters.filter(i=>i.cat==='food').map(i=>i.id),
    desserts:starters.filter(i=>i.cat==='desserts').map(i=>i.id),
  };
  return{
    ...STATE_DEFAULTS,
    // Seeded/generated values override the defaults
    pets, starterPets,
    wildPetPool:wild,
    allMenuItems, discoveredItemIds, activeMenuIds,
    staff:staffList, staffCandidates:cands,
    currentTrend:trend,
    currentShow:show,
    adoptionLastGender,
    seed, rngSeed:rng.s,
  };
}

// ─── BREEDING ANCESTRY CHECK ─────────────────────────────────────────────────
function getAncestors(petId, allPets, depth=4) {
  const ancestors=new Set();
  const queue=[{id:petId,d:0}];
  while(queue.length>0){
    const{id,d}=queue.shift();
    if(!id||d>depth)continue;
    const pet=allPets.find(p=>p.id===id);
    if(!pet)continue;
    if(pet.parentA){ancestors.add(pet.parentA);queue.push({id:pet.parentA,d:d+1});}
    if(pet.parentB){ancestors.add(pet.parentB);queue.push({id:pet.parentB,d:d+1});}
  }
  return ancestors;
}
function areRelated(petA, petB, allPets) {
  // Siblings: share a parent
  if(petA.parentA&&petB.parentA&&petA.parentA===petB.parentA)return true;
  if(petA.parentA&&petB.parentB&&petA.parentA===petB.parentB)return true;
  if(petA.parentB&&petB.parentA&&petA.parentB===petB.parentA)return true;
  if(petA.parentB&&petB.parentB&&petA.parentB===petB.parentB)return true;
  // Ancestor check: is one an ancestor of the other?
  const ancA=getAncestors(petA.id,allPets);
  const ancB=getAncestors(petB.id,allPets);
  if(ancA.has(petB.id)||ancB.has(petA.id))return true;
  return false;
}

// ─── PET RELATIONSHIPS ────────────────────────────────────────────────────────
/**
 * Process relationship changes for pets sharing the cafe floor this week.
 * Compatible/opposite personality pairs shift strength; neutral pairs have a 50% chance.
 * Returns a new pets array (immutable).
 */
function processRelationships(pets, assignedPetIds, week, rng) {
  const COMPLEMENT_PAIRS = new Set([
    'lazy|playful','regal|nurturing','cuddly|shy','vocal|adventurous','zen|mischievous'
  ]);
  const OPPOSITE_PAIRS = new Set([
    'showoff|shy','glutton|zen','regal|mischievous'
  ]);
  const pairKey = (a, b) => [a, b].sort().join('|');

  const onFloor = pets.filter(p => assignedPetIds.includes(p.id) && !p.state.isKitten && !p.state.isRetired);
  if (onFloor.length < 2) return pets;

  // Build delta map: {petId: {otherPetId: delta}}
  const deltas = {};
  for (let i = 0; i < onFloor.length; i++) {
    for (let j = i + 1; j < onFloor.length; j++) {
      const a = onFloor[i], b = onFloor[j];
      const key = pairKey(a.personality.primary, b.personality.primary);
      let delta = 0;
      if (a.personality.primary === b.personality.primary) delta = 1;
      else if (COMPLEMENT_PAIRS.has(key)) delta = 1;
      else if (OPPOSITE_PAIRS.has(key)) delta = -1;
      else delta = rng.bool(0.5) ? 1 : 0;
      if (delta === 0) continue;
      deltas[a.id] = deltas[a.id] || {};
      deltas[b.id] = deltas[b.id] || {};
      deltas[a.id][b.id] = delta;
      deltas[b.id][a.id] = delta;
    }
  }

  return pets.map(p => {
    const petDeltas = deltas[p.id];
    if (!petDeltas) return p;
    const existing = p.relationships || [];
    const updated = [...existing];
    for (const [otherId, delta] of Object.entries(petDeltas)) {
      const idx = updated.findIndex(r => r.petId === otherId);
      if (idx === -1) {
        updated.push({petId: otherId, strength: delta, since: week});
      } else {
        const newStrength = Math.max(-10, Math.min(10, updated[idx].strength + delta));
        updated[idx] = {...updated[idx], strength: newStrength};
      }
    }
    return {...p, relationships: updated};
  });
}

// ─── ADVANCE_WEEK HELPERS ────────────────────────────────────────────────────
// Pure functions extracted from ADVANCE_WEEK for testability.
// Each handles one concern and can be unit-tested independently.

/**
 * Age all pets by 1 week. Decrements timers (pregnancy, nursing, settling, breedCooldown).
 * Triggers retirement at RETIREMENT_AGE_WEEKS, decrements farewell countdown.
 * Returns new pets array (immutable).
 */
function agePets(pets) {
  return pets.map(p => {
    const age = p.state.age + 1;
    const pregnant = Math.max(0, (p.state.pregnant || 0) - 1);
    const nursing = Math.max(0, (p.state.nursing || 0) - 1);
    const settling = Math.max(0, (p.state.settling || 0) - 1);
    const wasRetired = p.state.isRetired || false;
    const wasRetiring = p.state.retiring || 0;
    let isRetired = wasRetired, retiring = wasRetiring;
    if (age >= GAME_CONFIG.RETIREMENT_AGE_WEEKS && !wasRetired) {
      isRetired = true; retiring = GAME_CONFIG.RETIREMENT_FAREWELL_WEEKS;
    } else if (wasRetiring > 0) {
      retiring = Math.max(0, wasRetiring - 1);
    }
    const sick = Math.max(0, (p.state.sick || 0) - 1);
    return { ...p, state: { ...p.state, age, isKitten: age < GAME_CONFIG.KITTEN_AGE_THRESHOLD, isRetired, retiring, breedCooldown: Math.max(0, p.state.breedCooldown - 1), pregnant, nursing, settling, sick } };
  });
}

/**
 * Remove newly retired pets from cafe floor; filter out pets whose farewell period ended.
 * Returns { pets, assignedPetIds }.
 */
function processRetirements(pets, assignedPetIds) {
  const newlyRetiredIds = new Set(pets.filter(p => p.state.isRetired && (p.state.retiring || 0) === GAME_CONFIG.RETIREMENT_FAREWELL_WEEKS).map(p => p.id));
  let newAssigned = [...assignedPetIds];
  if (newlyRetiredIds.size > 0) newAssigned = newAssigned.filter(id => !newlyRetiredIds.has(id));
  const retiredThisWeek = pets.filter(p => p.state.isRetired && (p.state.retiring || 0) === 0);
  const filteredPets = pets.filter(p => !(p.state.isRetired && (p.state.retiring || 0) === 0));
  return { pets: filteredPets, assignedPetIds: newAssigned, retiredThisWeek };
}

/**
 * Update menu mastery tiers based on weekly sales data.
 * Tiers: 0 (none), 1 (50+ sold), 2 (100+ sold), 3 (200+ sold / gold).
 * Returns new masteredItems object (immutable).
 */
function processMenuMastery(existingMastered, activeMenuIds, allMenuItems, rdBudget, cookingAvg, rng) {
  const masteredItems = { ...(existingMastered || {}) };
  const rb=typeof rdBudget==='object'?rdBudget:{drinks:0,food:0,desserts:0};
  const upgradedItems=[];
  const allActiveIds=Object.values(activeMenuIds).flat();
  for(const id of allActiveIds){
    const item=allMenuItems.find(i=>i.id===id);
    if(!item)continue;
    const prev=masteredItems[id]||{tier:0};
    if(prev.tier>=3)continue;
    const catBudget=rb[item.cat]||0;
    if(!catBudget)continue;
    const prob=catBudget/500*0.3+cookingAvg/10*0.2;
    if(prob>0&&rng.bool(Math.min(prob,0.5))){
      const newTier=prev.tier+1;
      masteredItems[id]={tier:newTier};
      upgradedItems.push({id,name:item.name,tier:newTier});
    }
  }
  return{masteredItems,upgradedItems};
}

/**
 * Calculate all weekly costs: staff wages, operations, pet upkeep, R&D, recruitment.
 * Returns { weeklyWages, weeklyOp, petUpkeep, rdCost, recCost, totalCost }.
 */
function calculateWeeklyCosts(state, pets, petCareAvg) {
  const staff = state.staff.map(s => s.training ? { ...s, training: false } : s);
  const paidStaff = staff.filter(s => !s.isPlayer); // player works for free
  const weeklyWages = Staff.wages(paidStaff) * 7;
  const weeklyOp = UPGRADES[state.cafeLevel].opCost * 7;
  // Pet house pet care skill reduces upkeep (gentle handling, efficient feeding)
  const upkeepDiscount = Math.min(0.5, petCareAvg / 10 * 0.5);
  const petUpkeep = Math.round(pets.filter(p => !p.state.isKitten).reduce((s, p) => s + petUpkeepCost(p), 0) * (1 - upkeepDiscount) * GAME_CONFIG.PET_UPKEEP_MULTIPLIER);
  const rdCost = typeof state.rdBudget==='object'?Object.values(state.rdBudget).reduce((s,v)=>s+v,0):(state.rdBudget||0);
  const recCost = state.recruitBudget || 0;
  return { weeklyWages, weeklyOp, petUpkeep, rdCost, recCost, totalCost: weeklyWages + weeklyOp + petUpkeep + rdCost + recCost };
}

/**
 * R&D discovery: chance to unlock a new menu item based on budget and cooking skill.
 * Higher budget + cooking skill = higher probability.
 * Returns { discoveredItemIds, newlyDiscoveredIds }.
 */
function processRD(state, rng, cafeStaff) {
  const rdBudget=typeof state.rdBudget==='object'?state.rdBudget:{drinks:0,food:0,desserts:0};
  let discoveredItemIds = [...state.discoveredItemIds];
  let newlyDiscoveredIds = [];
  const cookingAvg = getStaffSkillAvg(cafeStaff||getCafeStaff(state), 'cooking');
  // Per-category discovery
  for(const cat of ['drinks','food','desserts']){
    const catBudget=rdBudget[cat]||0;
    if(catBudget<=0)continue;
    const prob = Math.min(catBudget / 500 + cookingAvg / 10 * 0.3, 0.95);
    if (rng.bool(prob)) {
      const undiscovered = state.allMenuItems.filter(i => i.cat===cat && !discoveredItemIds.includes(i.id));
      if (undiscovered.length > 0) {
        const weighted = undiscovered.map(i => ({ value: i.id, weight: Math.max(1, 5 - i.tier) }));
        const newId = rng.wp(weighted);
        discoveredItemIds.push(newId);
        newlyDiscoveredIds.push(newId);
      }
    }
  }
  return { discoveredItemIds, newlyDiscoveredIds };
}

/**
 * Generate new wishes for adult pets (5% per week) and expire stale ones.
 * Returns a new pets array; does not mutate input.
 */
function processPetWishes(pets, rng, next) {
  const MENU_CATS=['drinks','food','desserts'];
  const ALL_SP=ALL_SPECIES_KEYS;
  return pets.map(p=>{
    // Expire stale wishes first
    if(p.state.isKitten||p.state.isRetired||p.wish) return p;
    if(!rng.bool(0.05)) return p;
    const wishType=rng.pick(['toy','treat','friend']);
    let wish;
    if(wishType==='toy'){
      const cost=50+rng.i(0,30)*5;
      wish={type:'toy',value:cost,expiresWeek:next+8,label:`Wants a new toy ($${cost})`};
    } else if(wishType==='treat'){
      const cat=rng.pick(MENU_CATS);
      const singular={drinks:'drink',food:'food',desserts:'dessert'}[cat];
      wish={type:'treat',target:cat,expiresWeek:next+8,label:`Wants to taste a ${singular} from the menu`};
    } else {
      const otherSp=ALL_SP.filter(sp=>sp!==p.species);
      const sp=rng.pick(otherSp);
      wish={type:'friend',target:sp,expiresWeek:next+8,label:`Wants to befriend a ${(SPECIES[sp]?.name||sp).toLowerCase()} by working together in the cafe`};
    }
    return {...p,wish};
  });
}

/**
 * Determine the active seasonal event for a given week.
 * Returns the existing event (if still active), a newly activated one, or null.
 */
function computeActiveSeasonalEvent(prevEvent, next) {
  const woy = next % 48;
  if (prevEvent && (woy < prevEvent.startWoy || woy > prevEvent.startWoy + 1)) {
    prevEvent = null;
  }
  if (!prevEvent) {
    for (const ev of SEASONAL_EVENTS) {
      if (woy >= ev.startWoy && woy <= ev.startWoy + 1) {
        return {id: ev.id, startWoy: ev.startWoy, name: ev.name, icon: ev.icon, description: ev.description};
      }
    }
  }
  return prevEvent;
}

/**
 * Advance the named-regular customer beats for this week.
 * Pure — returns {regulars, pastRegulars, regularBeats}.
 */
function processRegularCustomers(prevRegulars, prevPastRegulars, rng, next) {
  let regulars = prevRegulars || [];
  let pastRegulars = prevPastRegulars || [];
  let regularBeats = [];
  const ARC_THRESHOLDS = [1, 4, 9, 16];
  const REG_RETIRE_VISITS = 30;
  const usedThisWeek = new Set(); // prevent same template text in one week
  if (regulars.length === 0) {
    // seed with 5 random regulars on first advance
    const shuffled = shuffle(REGULARS_POOL, rng);
    regulars = shuffled.slice(0,5).map(r=>({id:r.id,visits:0,shownArcs:[],shownTemplates:[],firstWeek:next}));
  }
  regulars = regulars.map(r => {
    if (!rng.bool(0.75)) return r;
    const entry = REGULARS_POOL.find(p=>p.id===r.id);
    if (!entry) return {...r, visits:r.visits+1};
    const visits = r.visits + 1;
    let shownArcs = r.shownArcs || [];
    let shownTemplates = r.shownTemplates || [];
    // Determine if a new arc milestone has unlocked this visit
    let newArcIdx = -1;
    for (let i = ARC_THRESHOLDS.length - 1; i >= 0; i--) {
      if (visits >= ARC_THRESHOLDS[i] && !shownArcs.includes(i)) { newArcIdx = i; break; }
    }
    let line = null;
    if (newArcIdx >= 0 && rng.bool(0.6)) {
      line = fillRegularBeat(entry.arc[newArcIdx], entry);
      shownArcs = [...shownArcs, newArcIdx];
    } else {
      if (shownTemplates.length >= REGULAR_BEAT_TEMPLATES.length) shownTemplates = [];
      const available = REGULAR_BEAT_TEMPLATES.map((_,i)=>i).filter(i => !shownTemplates.includes(i) && !usedThisWeek.has(i));
      if (available.length) {
        const idx = rng.pick(available);
        line = fillRegularBeat(REGULAR_BEAT_TEMPLATES[idx], entry);
        shownTemplates = [...shownTemplates, idx];
        usedThisWeek.add(idx);
      }
      if (newArcIdx >= 0) shownArcs = [...shownArcs, newArcIdx];
    }
    if (line) regularBeats.push({id:r.id, name:entry.name, icon:entry.icon, line});
    return {...r, visits, shownArcs, shownTemplates};
  });
  regularBeats = shuffle(regularBeats, rng).slice(0,4);
  // Retire long-standing regulars and pull replacements
  const retiredRegs = regulars.filter(r => r.visits >= REG_RETIRE_VISITS);
  if (retiredRegs.length > 0) {
    pastRegulars = [...pastRegulars, ...retiredRegs];
    const activeIds = new Set(regulars.filter(r=>r.visits<REG_RETIRE_VISITS).map(r=>r.id));
    const pastIds = new Set(pastRegulars.map(r=>r.id));
    const available = REGULARS_POOL.filter(r=>!activeIds.has(r.id)&&!pastIds.has(r.id));
    const pool = available.length ? available : REGULARS_POOL.filter(r=>!activeIds.has(r.id));
    const replacements = shuffle(pool, rng).slice(0,retiredRegs.length);
    regulars = regulars.filter(r=>r.visits<REG_RETIRE_VISITS).concat(
      replacements.map(r=>({id:r.id,visits:0,shownArcs:[],shownTemplates:[],firstWeek:next}))
    );
  }
  return {regulars, pastRegulars, regularBeats};
}

/**
 * Schedule an NPC visit if one is due.
 * Pure — returns the visit object or null if none.
 */
function scheduleNpcVisit(currentVisit, npcs, activeMenuIds, week, rng) {
  if (currentVisit || week < 3) return currentVisit;
  const anyMenu = ['drinks','food','desserts'].some(c => (activeMenuIds||{})[c]?.length>0);
  if (!anyMenu) return null;
  for (const id of NPC_IDS) {
    const npcState = (npcs||{})[id];
    if (!npcState) continue;
    const every = NPCS[id].visitEveryWeeks;
    if (week - (npcState.lastVisit||0) >= every && rng.bool(0.5)) {
      return {npcId: id, weekArrived: week};
    }
  }
  return null;
}

/**
 * Resolve an existing trend rumor against the actual trend, and/or generate a new one.
 * Pure — returns the updated trendRumor (or null if nothing changes).
 */
function processTrendRumor(prevRumor, actualTrend, rng, rngSeedNext, next) {
  let trendRumor = prevRumor;
  // Resolve a pending rumor once its target week arrives
  if (trendRumor && !trendRumor.resolved && next >= trendRumor.weekTargetTrend) {
    const predicted = trendRumor.predicted;
    const matches = actualTrend && predicted && actualTrend.species===predicted.species &&
      Object.entries(predicted.targetTraits||{}).every(([k,v])=>(actualTrend.targetTraits||{})[k]===v);
    const partialMatch = !matches && actualTrend && predicted && (
      actualTrend.species===predicted.species ||
      Object.entries(predicted.targetTraits||{}).some(([k,v])=>(actualTrend.targetTraits||{})[k]===v)
    );
    trendRumor = {...trendRumor, resolved:true, accurate:matches, partiallyAccurate:partialMatch||false,
      actual:actualTrend?{species:actualTrend.species,targetTraits:actualTrend.targetTraits,description:actualTrend.description}:null};
  }
  // Generate a new rumor one week before the next trend rolls
  if (!trendRumor || trendRumor.resolved) {
    const weeksUntilRoll = ((GAME_CONFIG.SHOW_INTERVAL_WEEKS - (next % GAME_CONFIG.SHOW_INTERVAL_WEEKS)) % GAME_CONFIG.SHOW_INTERVAL_WEEKS) || GAME_CONFIG.SHOW_INTERVAL_WEEKS;
    if (weeksUntilRoll === 1) {
      const roll = rng.r();
      const accurate = roll < 0.5;
      const partial = roll >= 0.5 && roll < 0.7;
      let predicted;
      if (accurate) {
        const probeRng = new RNG(rngSeedNext + 1);
        const probedTrend = Trend.gen(probeRng, next+1, false, 1);
        predicted = {species:probedTrend.species, targetTraits:probedTrend.targetTraits};
      } else if (partial) {
        // Partially correct: start with real trend, corrupt one element
        const realRng = new RNG(rngSeedNext + 1);
        const realTrend = Trend.gen(realRng, next+1, false, 1);
        const corruptRng = new RNG(rngSeedNext + 47);
        const traitKeys = Object.keys(realTrend.targetTraits || {});
        if (traitKeys.length > 0 && corruptRng.bool(0.5)) {
          // Correct species, wrong trait value
          const keyToCorrupt = corruptRng.pick(traitKeys);
          const tt2 = TRAIT_TABLES[realTrend.species];
          const vals = tt2?.[keyToCorrupt]?.map(e=>e.value) || [];
          const wrongVal = corruptRng.pick(vals.filter(v => v !== realTrend.targetTraits[keyToCorrupt])) || corruptRng.pick(vals);
          predicted = {species: realTrend.species, targetTraits: {...realTrend.targetTraits, [keyToCorrupt]: wrongVal}};
        } else {
          // Correct traits, wrong species
          const otherSpecies = ['cats','dogs','birds','lizards'].filter(s => s !== realTrend.species);
          predicted = {species: corruptRng.pick(otherSpecies), targetTraits: realTrend.targetTraits};
        }
      } else {
        const probeRng = new RNG(rngSeedNext + 97);
        const probedTrend = Trend.gen(probeRng, next+1, false, 1);
        predicted = {species:probedTrend.species, targetTraits:probedTrend.targetTraits};
      }
      const sourcePool=['mayor_hollis','food_critic','loyal_regular','rival_owner'];
      const sourceNpc = rng.pick(sourcePool);
      trendRumor = {sourceNpcId:sourceNpc, weekCreated:next, weekTargetTrend:next+1, predicted, shown:false, resolved:false};
    }
  }
  return trendRumor;
}

// ─── PET STATUS HELPERS ──────────────────────────────────────────────────────
/**
 * Check if a pet is unavailable for assignment/breeding.
 * Shared by PetsPanel and ShowPanel to classify pets consistently.
 */
function isPetBusy(pet, breedingQueue, showEntries, weeksUntilJudging) {
  if ((pet.state.retiring || 0) > 0) return true;
  if (pet.state.isRetired) return true;
  if ((pet.state.sick || 0) > 0) return true;
  if (breedingQueue.some(b => b.pA === pet.id || b.pB === pet.id)) return true;
  if ((pet.state.pregnant || 0) > 0) return true;
  if ((pet.state.nursing || 0) > 0) return true;
  if ((pet.state.settling || 0) > 0) return true;
  if (showEntries?.includes(pet.id) && weeksUntilJudging === 1) return true;
  return false;
}

/**
 * Human-readable status string for a busy pet.
 * Returns empty string if pet is not in a notable state.
 */
function petBusyStatus(pet, breedingQueue, showEntries, weeksUntilJudging, assignedPetIds) {
  if ((pet.state.sick || 0) > 0) return `🤒 Unwell (${pet.state.sick}w)`;
  if (assignedPetIds?.includes(pet.id)) return '🏪 At the Cafe';
  if ((pet.state.retiring || 0) > 0) return `🏠 Retired (${pet.state.retiring}w)`;
  if (breedingQueue.some(b => b.pA === pet.id || b.pB === pet.id)) return '💕 Breeding';
  if ((pet.state.pregnant || 0) > 0) return `🤰 Pregnant ${pet.state.pregnant}w`;
  if ((pet.state.nursing || 0) > 0) return '🍼 Nursing';
  if ((pet.state.settling || 0) > 0) return '🏠 Settling in';
  if (showEntries?.includes(pet.id) && weeksUntilJudging === 1) return '🏆 In Show';
  return '';
}

// ─── ACTION TYPES ────────────────────────────────────────────────────────────
// Centralized action type strings. Use A.X instead of 'X' everywhere to catch typos at dev time.
const A = {
  NEW_GAME:'NEW_GAME', LOAD_GAME:'LOAD_GAME', SET_PANEL:'SET_PANEL', QUIT_GAME:'QUIT_GAME',
  ADVANCE_WEEK:'ADVANCE_WEEK',
  ASSIGN_PET:'ASSIGN_PET', UNASSIGN_PET:'UNASSIGN_PET', RENAME_PET:'RENAME_PET', SET_PET_MARKING:'SET_PET_MARKING',
  SELL_PET:'SELL_PET', ADOPT_WILD:'ADOPT_WILD', START_BREEDING:'START_BREEDING',
  SET_RD_BUDGET:'SET_RD_BUDGET', SET_RECRUIT_BUDGET:'SET_RECRUIT_BUDGET',
  SET_MENU_PRICE:'SET_MENU_PRICE', SELECT_MENU_ITEM:'SELECT_MENU_ITEM', DESELECT_MENU_ITEM:'DESELECT_MENU_ITEM',
  HIRE:'HIRE', FIRE:'FIRE', REPLACE_STAFF:'REPLACE_STAFF', TRAIN:'TRAIN',
  ENTER_SHOW:'ENTER_SHOW', REMOVE_ENTRY:'REMOVE_ENTRY', UPGRADE:'UPGRADE',
  UNDO_SELL:'UNDO_SELL',
  SELECT_STARTERS:'SELECT_STARTERS',
  TOGGLE_BUDGET_LOCK:'TOGGLE_BUDGET_LOCK',
  OPEN_MILESTONES:'OPEN_MILESTONES',
  UPGRADE_CAFE:'UPGRADE_CAFE',
  UPGRADE_PET_HOUSE:'UPGRADE_PET_HOUSE',
  ASSIGN_STAFF:'ASSIGN_STAFF',
  ACCEPT_PURCHASE_OFFER:'ACCEPT_PURCHASE_OFFER',
  DECLINE_PURCHASE_OFFER:'DECLINE_PURCHASE_OFFER',
  MARK_LETTER_SEEN:'mark_letter_seen',
  NPC_VISIT_RESPONSE:'npc_visit_response',
  DISMISS_RUMOR:'dismiss_rumor',
  FULFILL_WISH:'fulfill_wish',
  PAY_VET_BILL:'PAY_VET_BILL',
  CELEBRITY_SELL:'CELEBRITY_SELL',
  CELEBRITY_REFUSE:'CELEBRITY_REFUSE',
  CLEAR_MILESTONE_OPEN:'CLEAR_MILESTONE_OPEN',
  CLEAR_PENDING_MILESTONES:'CLEAR_PENDING_MILESTONES',
  SKIP_TUTORIAL:'SKIP_TUTORIAL',
  ADVANCE_TUTORIAL:'ADVANCE_TUTORIAL',
  DISMISS_PENDING_MODAL:'DISMISS_PENDING_MODAL',
};

// ─── REDUCER ──────────────────────────────────────────────────────────────────
function reducer(state,action) {
  if(!action||!action.type) return state;
  switch(action.type) {
    case A.NEW_GAME:  return{...makeState(action.seed||(Date.now()%1e6|0)),screen:'petselect',activePanel:'home'};
    case A.SELECT_STARTERS: {
      if(!state.starterPets||!action.ids||action.ids.length!==4)return state;
      const selected=action.ids.map(id=>state.starterPets.find(p=>p.id===id)).filter(Boolean);
      if(selected.length!==4)return state;
      const pName=action.playerName||'You';
      const newStaff=state.staff.map((s,i)=>i===0?{...s,name:pName,isPlayer:true}:s);
      const autoAssigned=selected.slice(0,3).map(p=>p.id); // auto-assign first 3 to cafe (level 1 = 3 floor slots)
      // Give one starter a treat wish
      const MENU_CATS=['drinks','food','desserts'];
      const wishCat=MENU_CATS[Math.floor(Math.random()*MENU_CATS.length)];
      const singular={drinks:'drink',food:'food',desserts:'dessert'}[wishCat];
      selected[0]={...selected[0],wish:{type:'treat',target:wishCat,expiresWeek:999,label:`Wants to taste a ${singular} from the menu`}};
      // Ensure first show matches one of the selected species
      let show=state.currentShow;
      const selectedSpecies=[...new Set(selected.map(p=>p.species))];
      if(show&&!selectedSpecies.includes(show.species)){
        const rng2=new RNG(state.seed+7);
        const targetSp=selectedSpecies[Math.floor(Math.random()*selectedSpecies.length)];
        show={...show,species:targetSp};
        // Update description to match species
        const spInfo=SPECIES[targetSp];
        if(spInfo) show={...show,description:`Customers love ${spInfo.name}!`};
      }
      return{...state,pets:selected,starterPets:null,screen:'game',playerName:pName,staff:newStaff,assignedPetIds:autoAssigned,cafeName:action.cafeName||state.cafeName||'Pet Cafe',currentShow:show};
    }
    case A.LOAD_GAME: {
      if(!action.state||typeof action.state!=='object') return state;
      return{...Save.migrate(action.state),screen:'game'};
    }
    case A.SET_PANEL: return{...state,activePanel:action.v,lastSoldPet:action.v!=='pets'?null:state.lastSoldPet,lastSoldPrice:action.v!=='pets'?0:state.lastSoldPrice};
    case A.OPEN_MILESTONES: return{...state,activePanel:action.panel,openMilestonesPanel:action.panel};
    case A.CLEAR_MILESTONE_OPEN: return{...state,openMilestonesPanel:null};
    case A.CLEAR_PENDING_MILESTONES: return{...state,pendingMilestones:[]};
    case A.SKIP_TUTORIAL: return{...state,tutorialStep:0};
    case A.ADVANCE_TUTORIAL: return{...state,tutorialStep:action.step};
    case A.QUIT_GAME: return{screen:'title'};

    case A.ADVANCE_WEEK: {
      if(Time.over(state.week)) return{...state,screen:'gameover'};
      let menuTrends=state.currentMenuTrends||[]; // declared early to avoid Babel TDZ in switch-case
      const prevReached=new Set(state.completedMilestoneIds||[]);
      const rng=new RNG(state.rngSeed), next=state.week+1;
      // Seasonal event activation/expiration (pure helper)
      let activeSeasonalEvent = computeActiveSeasonalEvent(state.activeSeasonalEvent, next);
      const cafeStaff=getCafeStaff(state);
      const petHouseStaff=getPetHouseStaff(state);
      const petCareAvg=getStaffSkillAvg(petHouseStaff,'petCare');
      const petHouseCleanAvg=getStaffSkillAvg(petHouseStaff,'cleaning');
      let totalBred=state.totalBred||0,highestRarity=state.highestRarity||0,maxGeneration=state.maxGeneration||0;
      let totalUncommonBred=state.totalUncommonBred||0,totalRareBred=state.totalRareBred||0,totalLegendaryBred=state.totalLegendaryBred||0;
      let totalFirstPlace=state.totalFirstPlace||0,totalPodiums=state.totalPodiums||0;

      // Age pets + handle retirements (using extracted helpers)
      let pets=agePets(state.pets);
      let bq=state.breedingQueue.map(b=>({...b,weeksLeft:b.weeksLeft-1}));
      const retResult=processRetirements(pets,state.assignedPetIds);
      const retiredThisWeek=retResult.retiredThisWeek||[];
      pets=retResult.pets;
      let assignedPetIds=retResult.assignedPetIds;

      // Generate new wishes for adults (~5% per week) and expire stale ones
      pets = processPetWishes(pets, rng, next);

      for(const b of bq.filter(b=>b.weeksLeft<=0)){
        const pA=pets.find(p=>p.id===b.pA),pB=pets.find(p=>p.id===b.pB);
        if(pA&&pB){
          // After away period: mother becomes pregnant, both parents free
          pets=pets.map(p=>p.id===b.pA?{...p,state:{...p.state,pregnant:GAME_CONFIG.PREGNANCY_WEEKS,breedPartner:b.pB}}:p);
        }
      }
      bq=bq.filter(b=>b.weeksLeft>0);

      // Birth: check for mothers whose pregnancy counter just hit 0 (was 1 before decrement, now 0)
      // We detect this by: state had pregnant>0, now has pregnant===0, and breedPartner set
      for(const p of pets.filter(p=>p.state.pregnant===0&&p.state.breedPartner)){
        const pA=p,pB=pets.find(pp=>pp.id===p.state.breedPartner);
        if(pA&&pB){
          const breedTT=TRAIT_TABLES[pA.species]||TT_CATS;
          const breedSp=SPECIES[pA.species]||SPECIES.cats;
          let babies=Genetics.breed(pA,pB,rng,breedTT);
          babies.forEach(baby=>{
            baby.name=pickPetName(rng, pA.species, pets, baby.gender);
            baby.species=pA.species;
            baby.displayColor=getDisplayColor(rng, pA.species, baby.phenotype);
            baby.cosmetics=generateCosmetics(rng,pA.species);
            // Dogs always inherit one of the parents' breeds (never a mutation)
            if(pA.species==='dogs'&&pA.phenotype.breed&&pB.phenotype.breed){
              const inheritedBreed=rng.pick([pA.phenotype.breed,pB.phenotype.breed]);
              baby.phenotype={...baby.phenotype,breed:inheritedBreed};
              baby.genome={...baby.genome,breed:{a:inheritedBreed,b:inheritedBreed}};
            }
          });
          // Pet Care skill: gates rarity tier of offspring
          // Legendary (>0.65): requires petCareAvg >= 7, scales 0%→100% from 4→10
          // Rare (>0.45): requires petCareAvg >= 4, scales 0%→100% from 2→8
          // NOTE: Checks are SEQUENTIAL — a demoted legendary (now in rare band) still rolls the
          // rare check. Both reaching 100% at petCareAvg=10 simply means nothing demotes.
          babies=babies.map(baby=>{
            let r=baby.stats.rarity;
            if(r>0.65){
              // Legendary: needs petCare >= 7 to keep; below that, clamp to rare tier
              const legendaryChance=petCareAvg>=7?Math.min(1,(petCareAvg-4)/6):0;
              if(!rng.bool(legendaryChance)) r=Math.min(0.64,r);
            }
            if(r>0.45&&r<=0.65){
              // Rare: needs petCare >= 4 to keep; below that, clamp to uncommon
              const rareChance=petCareAvg>=4?Math.min(1,(petCareAvg-2)/6):0;
              if(!rng.bool(rareChance)) r=Math.min(0.44,r);
            }
            // Small passive appeal bonus for high petCare
            const appealBonus=petCareAvg>=6?1:0;
            return r!==baby.stats.rarity||appealBonus?{...baby,stats:{...baby.stats,rarity:r,appeal:Math.min(10,baby.stats.appeal+appealBonus)}}:baby;
          });
          // Cap litter to remaining pen space to prevent overflow
          const penSpace=Math.max(0,getMaxPets({cafeLevel:state.cafeLevel,petHouseLevel:state.petHouseLevel||1})-pets.length);
          babies=babies.slice(0,penSpace);
          pets=[...pets,...babies];
          // Track breeding stats
          totalBred+=babies.length;
          for(const b of babies){
            if(b.stats.rarity>highestRarity)highestRarity=b.stats.rarity;
            if(b.state.generation>maxGeneration)maxGeneration=b.state.generation;
            if(b.stats.rarity>0.65)totalLegendaryBred++;
            else if(b.stats.rarity>0.45)totalRareBred++;
            else if(b.stats.rarity>0.25)totalUncommonBred++;
          }
          // Mother starts nursing, can't go to cafe; clear breedPartner
          pets=pets.map(pp=>pp.id===pA.id?{...pp,state:{...pp.state,nursing:GAME_CONFIG.NURSING_WEEKS,breedPartner:null,breedCooldown:24}}:pp);
          assignedPetIds=assignedPetIds.filter(id=>id!==pA.id);
        } else {
          // Father missing — clear breedPartner, no babies
          pets=pets.map(pp=>pp.id===p.id?{...pp,state:{...pp.state,breedPartner:null}}:pp);
        }
      }

      // Show week: pets entering shows are already removed from cafe floor when entered.
      // After the show runs (below), pets that came from cafe are returned if there's room.

      // Menu staleness: increment weeks active for current menu items
      const menuItemWeeksActive={...(state.menuItemWeeksActive||{})};
      const allActiveIds=Object.values(state.activeMenuIds).flat();
      for(const id of allActiveIds) menuItemWeeksActive[id]=(menuItemWeeksActive[id]||0)+1;
      // Clean up deactivated items
      for(const id of Object.keys(menuItemWeeksActive)) if(!allActiveIds.includes(id)) delete menuItemWeeksActive[id];

      // New trend + menu trends (changes monthly, computed before cafe sim so new trends apply immediately)
      let trendHist=state.trendHistory;
      let trend=state.currentTrend;
      // menuTrends already declared at top of case block
      let newMenuTrends=null;
      if(next%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0){
        trendHist=[state.currentTrend,...state.trendHistory].slice(0,GAME_CONFIG.TREND_HISTORY_MAX);
        trend=Trend.gen(rng,next,false,Time.year(next)===1?1:2);
        const trendCount=rng.i(1,5);
        // Weight price-up ingredient events 2x so they appear more often than price-down
        const weightedPool=MENU_TREND_TEMPLATES.flatMap(t=>(t.type==='ingredient'&&(t.effect?.costMult||1)>1)?[t,t]:[t]);
        const shuffledPool=[...weightedPool].sort(()=>rng.r()-0.5);
        const seen=new Set();
        newMenuTrends=shuffledPool.filter(t=>{if(seen.has(t))return false;seen.add(t);return true;}).slice(0,trendCount);
        menuTrends=newMenuTrends;
      }

      // Rival cafe 1: random trigger in year 3 (weeks 96-143), lasts 12 weeks
      let rivalCafeTriggered=state.rivalCafeTriggered||false;
      let rivalCafe=state.rivalCafe||null;
      let rivalCafe1Ended=state.rivalCafe1Ended||false;
      let pendingModal=null; // reset each advance; inspector check may set it below
      if(Time.year(next)===3&&!rivalCafeTriggered&&rng.bool(0.07)){
        rivalCafeTriggered=true;
        rivalCafe={weeksLeft:12};
      } else if(Time.year(next)>3&&!rivalCafeTriggered){
        // Ensure it always triggers by end of year 3; fire at year 4 start if missed
        rivalCafeTriggered=true;
        rivalCafe={weeksLeft:12};
      } else if(rivalCafe){
        const wl=rivalCafe.weeksLeft-1;
        if(wl<=0){rivalCafe=null;rivalCafe1Ended=true;}
        else rivalCafe={weeksLeft:wl};
      }
      // Rival cafe 2: random trigger in year 8 (weeks 336-383), lasts 24 weeks
      let rivalCafe2Triggered=state.rivalCafe2Triggered||false;
      let rivalCafe2=state.rivalCafe2||null;
      let rivalCafe2Started=state.rivalCafe2Started||false;
      let rivalCafe2Ended=state.rivalCafe2Ended||false;
      if(Time.year(next)===8&&!rivalCafe2Triggered&&rng.bool(0.07)){
        rivalCafe2Triggered=true;rivalCafe2Started=true;
        rivalCafe2={weeksLeft:24};
      } else if(Time.year(next)>8&&!rivalCafe2Triggered){
        rivalCafe2Triggered=true;rivalCafe2Started=true;
        rivalCafe2={weeksLeft:24};
      } else if(rivalCafe2){
        const wl2=rivalCafe2.weeksLeft-1;
        if(wl2<=0){rivalCafe2=null;rivalCafe2Ended=true;}
        else rivalCafe2={weeksLeft:wl2};
      }

      // Generate sick pets (~1% per adult non-busy non-sick pet per week; 4-week duration)
      const prevSickIds=new Set(state.pets.filter(p=>(p.state.sick||0)>0).map(p=>p.id));
      pets=pets.map(p=>{
        if(p.state.isKitten||p.state.isRetired||(p.state.sick||0)>0) return p;
        // Only available/cafe pets can get sick (not breeding/pregnant/nursing/settling)
        const isOccupied=(p.state.pregnant||0)>0||(p.state.nursing||0)>0||(p.state.settling||0)>0||(p.state.retiring||0)>0||(state.breedingQueue||[]).some(b=>b.pA===p.id||b.pB===p.id);
        if(isOccupied) return p;
        return rng.bool(0.01)?{...p,state:{...p.state,sick:4}}:p;
      });
      const newlySickPets=pets.filter(p=>(p.state.sick||0)>0&&!prevSickIds.has(p.id));
      // Auto-unassign pets that just became sick
      {const sickIds=new Set(pets.filter(p=>(p.state.sick||0)>0).map(p=>p.id));assignedPetIds=assignedPetIds.filter(id=>!sickIds.has(id));}

      // Cafe sim (6 open days)
      const floor=pets.filter(p=>assignedPetIds.includes(p.id)&&!p.state.isKitten);
      const activeItems=Object.values(state.activeMenuIds).flat().map(id=>state.allMenuItems.find(i=>i.id===id)).filter(Boolean);
      const season=Time.season(next);
      // Auto-calculate optimal menu prices based on cafe staff cook skill and mastery
      const autoCookAvg=getStaffSkillAvg(cafeStaff,'cooking')||1;
      const autoCookTol=autoCookAvg/10*0.4;
      const autoMenuPrices={...(state.menuItemPrices||{})};
      for(const item of activeItems){
        const mt=(state.masteredItems||{})[item.id]?.tier||0;
        const mTol=mt>=3?0.15:0;
        autoMenuPrices[item.id]=Math.round(item.price*(1+autoCookTol+mTol));
      }
      // Critic effect: read existing effect (set in a previous week), apply its mult, then decrement after.
      const existingCriticEffect=state.criticEffect||{mult:1,weeksLeft:0};
      const criticMultIn=existingCriticEffect.weeksLeft>0?existingCriticEffect.mult:1;
      const finalCustomerMod=criticMultIn*(rivalCafe?0.75:1)*(rivalCafe2?0.70:1);
      const cafeResult=Cafe.runWeek(floor,state.cafeLevel,activeItems,cafeStaff,trend,season,rng,autoMenuPrices,menuItemWeeksActive,state.masteredItems||{},state.trophies||[],menuTrends,pets,activeSeasonalEvent,finalCustomerMod,state.loyaltyBuff||1,state.tvCrewBuff||1,next,state.lastCriticWeek||0);
      // Resolve next week's critic effect: a fresh one overrides; otherwise decrement.
      let criticEffect;
      if(cafeResult.newCriticEffect){
        criticEffect=cafeResult.newCriticEffect;
      } else if(existingCriticEffect.weeksLeft>0){
        const wl=existingCriticEffect.weeksLeft-1;
        criticEffect=wl>0?{mult:existingCriticEffect.mult,weeksLeft:wl}:{mult:1,weeksLeft:0};
      } else {
        criticEffect={mult:1,weeksLeft:0};
      }

      // Update per-pet tip earnings
      if(cafeResult.petTipsMap){
        pets=pets.map(p=>cafeResult.petTipsMap[p.id]
          ? {...p,totalTipsEarned:(p.totalTipsEarned||0)+cafeResult.petTipsMap[p.id]}
          : p);
      }

      // Update pet relationships based on who shared the floor this week
      pets = processRelationships(pets, state.assignedPetIds, next, rng);

      // Calculate weekly costs (using extracted helper)
      let staff=state.staff.map(s=>s.training?{...s,training:false,trainedThisWeek:false}:{...s,trainedThisWeek:false});
      const costs=calculateWeeklyCosts(state,pets,petCareAvg);
      const{weeklyWages,weeklyOp,petUpkeep,rdCost,recCost}=costs;
      let money=state.money;

      // R&D discovery (using extracted helper)
      const rdResult=processRD(state,rng,cafeStaff);
      const{discoveredItemIds,newlyDiscoveredIds}=rdResult;

      // Menu mastery tracking (R&D-based upgrade, not sales-based)
      const cookingAvgForMastery=getStaffSkillAvg(cafeStaff,'cooking');
      const masteryResult=processMenuMastery(state.masteredItems,state.activeMenuIds,state.allMenuItems,state.rdBudget,cookingAvgForMastery,rng);
      const masteredItems=masteryResult.masteredItems;
      const upgradedItems=masteryResult.upgradedItems;

      // Auto-zero R&D budget for categories where all items are at gold mastery
      let rdBudgetUpdated={...(typeof state.rdBudget==='object'?state.rdBudget:{drinks:0,food:0,desserts:0})};
      for(const cat of ['drinks','food','desserts']){
        const catItems=state.allMenuItems.filter(i=>i.cat===cat);
        const allDiscovered=catItems.every(i=>discoveredItemIds.includes(i.id));
        const allGold=allDiscovered&&catItems.every(i=>(masteredItems[i.id]?.tier||0)>=3);
        if(allGold) rdBudgetUpdated[cat]=0;
      }

      // Rare positive events (5% chance after week 3)
      const RARE_EVENTS=[
        {icon:'🍽️',msg:'A food critic visited and loved your cafe!',moneyBonus:500},
        {icon:'⭐',msg:'A celebrity posted about your pets — tips surged!',tipBonus:200},
        {icon:'🎪',msg:'Local festival brought extra foot traffic!',moneyBonus:300},
        {icon:'🌟',msg:'Your cafe was featured in the local newspaper!',moneyBonus:400},
      ];
      let rareEventBonus=0;
      if(rng.r()<0.05&&state.week>3){
        const re=RARE_EVENTS[rng.i(0,RARE_EVENTS.length-1)];
        rareEventBonus=(re.moneyBonus||0)+(re.tipBonus||0);
        cafeResult.events.push({message:re.icon+' '+re.msg,tipsDelta:rareEventBonus,isRare:true});
      }

      // Health inspector (~3% chance after week 20, at least 16 weeks between visits)
      // Fines scale with current cash, reduced by cleaning skill. Easier in year 1.
      let lastInspectorWeek=state.lastInspectorWeek||0;
      let inspectorFine=0;
      if(next>20&&(next-lastInspectorWeek)>=16&&rng.r()<0.03){
        lastInspectorWeek=next;
        const cleanAvgInspect=getStaffSkillAvg(cafeStaff,'cleaning');
        const cashForFine=Math.max(0,state.money);
        const inspYear=Time.year(next);
        const cleanPassThresh=inspYear<=1?5:7;
        const cleanWarnThresh=inspYear<=1?3:4;
        if(cleanAvgInspect>=cleanPassThresh){
          inspectorFine=300; // positive: city grant
          pendingModal={type:'inspector',result:'pass',amount:300};
        } else if(cleanAvgInspect>=cleanWarnThresh){
          const rawFine=Math.max(100,Math.round(cashForFine*0.125));
          const discount=cleanAvgInspect/10;
          const warningFine=Math.max(50,Math.round(rawFine*(1-discount)));
          inspectorFine=-warningFine;
          pendingModal={type:'inspector',result:'warning',amount:warningFine};
        } else {
          const rawFine=Math.max(200,Math.round(cashForFine*0.25));
          const discount=cleanAvgInspect/10;
          const failFine=Math.max(100,Math.round(rawFine*(1-discount)));
          inspectorFine=-failFine;
          pendingModal={type:'inspector',result:'fail',amount:failFine};
        }
      }

      // Celebrity Adoption event (~5% per week, year >= 4, at least 32 weeks between events)
      let lastCelebrityWeek=state.lastCelebrityWeek||0;
      let celebrityAdoptions=state.celebrityAdoptions||[];
      let loyaltyBuff=state.loyaltyBuff||1;
      if(Time.year(next)>=4&&(next-lastCelebrityWeek)>=32&&!pendingModal&&rng.r()<0.05){
        const adultEligible=pets.filter(p=>!p.state.isKitten&&!p.state.isRetired&&(p.state.sick||0)===0);
        if(adultEligible.length>0){
          const bestPet=adultEligible.reduce((b,p)=>(!b||p.stats.rarity>b.stats.rarity)?p:b,null);
          if(bestPet){
            const celebName=CELEBRITY_NAMES[rng.i(0,CELEBRITY_NAMES.length-1)];
            pendingModal={type:'celebrity_offer',petId:bestPet.id,petName:bestPet.name,celebrityName:celebName,amount:20000};
            lastCelebrityWeek=next;
          }
        }
      }

      // TV Crew Filming event (~3% per week, year >= 3, at least 24 weeks between events)
      let activeTvCrew=state.activeTvCrew||null;
      let lastTvCrewWeek=state.lastTvCrewWeek||0;
      let tvCrewBuff=state.tvCrewBuff||1;
      let tvCrewBonus=0;
      if(activeTvCrew){
        // Check if this is the deadline week
        if(next>=activeTvCrew.deadlineWeek){
          // Check cafe floor for 3+ matching pets
          const onFloor=assignedPetIds.map(id=>pets.find(p=>p.id===id)).filter(Boolean);
          const matchCount=onFloor.filter(p=>p.species===activeTvCrew.targetSpecies&&activeTvCrew.targetTraits.every(tv=>Object.values(p.phenotype).includes(tv))).length;
          if(matchCount>=3){
            tvCrewBonus=15000;
            tvCrewBuff=1.10;
            cafeResult.events.push({message:'📺 The TV crew got the perfect shot! City fame skyrockets!',tipsDelta:0,isRare:true});
          } else {
            cafeResult.events.push({message:"📺 The film crew couldn't find the right shot. Better luck next time.",tipsDelta:0,isRare:false});
          }
          activeTvCrew=null;
        }
      } else if(Time.year(next)>=3&&(next-lastTvCrewWeek)>=24&&rng.r()<0.03){
        // Spawn a new TV crew event
        const allSp=ALL_SPECIES_KEYS;
        const targetSpecies=rng.pick(allSp);
        const tt=TRAIT_TABLES[targetSpecies];
        const traitKeys=Object.keys(tt);
        // Pick up to 3 random trait values from random traits of the target species
        const targetTraits=[];
        const shuffledKeys=[...traitKeys].sort(()=>rng.r()-0.5);
        for(const key of shuffledKeys){
          if(targetTraits.length>=3)break;
          const vals=tt[key].map(e=>e.value);
          if(vals.length>0) targetTraits.push(rng.pick(vals));
        }
        activeTvCrew={targetSpecies,targetTraits,deadlineWeek:next+4};
        lastTvCrewWeek=next;
        cafeResult.events.push({message:`📺 A TV crew is coming to film! They want ${targetTraits.join(', ')} ${(SPECIES[targetSpecies]?.name||targetSpecies).toLowerCase()}s on the cafe floor within 4 weeks.`,tipsDelta:0,isRare:true});
      }

      // Revenue & costs (allow negative balance)
      const gross=cafeResult.netRevenue+rareEventBonus;
      money=money+gross-weeklyWages-weeklyOp-rdCost-recCost-petUpkeep+inspectorFine+tvCrewBonus;
      let earned=Math.max(0,gross);

      // Discoveries
      let disc=state.discoveredCombos,discN=state.discoveredCount;
      for(const p of floor){const k=comboKey(p.phenotype);if(!disc.includes(k)){disc=[...disc,k];discN++;}}

      // Events
      let eventLog=cafeResult.events.length?[...cafeResult.events.map(e=>({...e,week:next})),...state.eventLog].slice(0,GAME_CONFIG.EVENT_LOG_MAX):state.eventLog;


      // Refresh wild pets every week (random species, age 0–5 years)
      // 1 adoption candidate per species each week (alternating gender) + 2 random extras (6 total)
      const prevAdoptGender={...(state.adoptionLastGender||{})};
      // Seed from current wild pool if no history (existing saves)
      if(!state.adoptionLastGender){
        for(const sp of ALL_SPECIES_KEYS){
          const cur=(state.wildPetPool||[]).find(p=>p.species===sp);
          if(cur) prevAdoptGender[sp]=cur.gender||'male';
        }
      }
      let wild=ALL_SPECIES_KEYS.map(sp=>{
        const p=makePet(rng,sp,0,0,240);
        const targetGender=prevAdoptGender[sp]==='male'?'female':'male';
        p.gender=targetGender;
        p.name=pickPetName(rng,sp,[],targetGender);
        prevAdoptGender[sp]=targetGender;
        return{...p,adoptPriceMult:0.75+rng.r()*0.5};
      });
      for(let i=0;i<2;i++){const sp=rng.pick(ALL_SPECIES_KEYS);const p=makePet(rng,sp,0,0,240);wild.push({...p,adoptPriceMult:0.75+rng.r()*0.5});}

      // Refresh staff candidates every week (scaled by rolling recruit budget average)
      const recruitHistory=[...(state.recruitHistory||[0]),state.recruitBudget||0].slice(-26); // 6-month rolling avg
      const effectiveRecBudget=Math.round(recruitHistory.reduce((a,b)=>a+b,0)/recruitHistory.length);
      const cands=Array.from({length:GAME_CONFIG.STAFF_CANDIDATES_COUNT},()=>Staff.gen(rng,effectiveRecBudget));

      // Shows every 4 weeks
      let show=state.currentShow,showEnt=state.showEntries.filter(id=>pets.find(p=>p.id===id)),trophies=state.trophies;
      let showEntFromCafe=(state.showEntryFromCafe||[]).filter(id=>pets.find(p=>p.id===id));
      const prevShowSpecies=state.currentShow?.species||state.lastShowSpecies||null;
      let showWeekResults=null;
      let showPrizeIncome=0;
      if(show&&showEnt.length>0&&next%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0){
        const ePets=pets.filter(p=>showEnt.includes(p.id));
        if(ePets.length){
          const allResults=Show.run(ePets,show,rng,Time.year(next),totalFirstPlace);
          const playerResults=allResults.filter(r=>r.isPlayer);
          for(const r of playerResults){
            trophies=[...trophies,{...r,showTitle:show.title,week:next}];
            money+=r.prize;
            showPrizeIncome+=r.prize;
            if(r.placement===1)totalFirstPlace++;
            if(r.placement<=3)totalPodiums++;
          }
          showWeekResults={title:show.title,results:playerResults};
        }
        // Return cafe-origin pets to the cafe floor if there's still room
        const cap=UPGRADES[state.cafeLevel];
        for(const id of showEntFromCafe){
          if(assignedPetIds.length>=cap.floor)break;
          if(!assignedPetIds.includes(id)) assignedPetIds=[...assignedPetIds,id];
        }
        // Pass the judging week of the UPCOMING show (next + SHOW_INTERVAL_WEEKS) for championship detection
        show=Show.gen(rng,Math.floor(next/GAME_CONFIG.SHOW_INTERVAL_WEEKS)+1,prevShowSpecies,next+GAME_CONFIG.SHOW_INTERVAL_WEEKS);
        showEnt=[];
        showEntFromCafe=[];
      } else if(next%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0) {
        show=Show.gen(rng,Math.floor(next/GAME_CONFIG.SHOW_INTERVAL_WEEKS)+1,prevShowSpecies,next+GAME_CONFIG.SHOW_INTERVAL_WEEKS);
      }

      // Random purchase offers (~7% chance per cafe pet per week at 1.8x sale price)
      const BUYER_DESCS=['A wealthy visitor','A cat influencer','A local collector','A curious tourist','A café investor','An enthusiastic regular','A charmed customer'];
      const newPurchaseOffers=[];
      for(const p of floor.filter(p=>!p.state.isKitten&&!(p.state.pregnant||0)&&!(p.state.nursing||0))){
        if(rng.r()<0.07){
          const phClean=getStaffSkillAvg(petHouseStaff,'cleaning');
          const offerPrice=Math.round(Econ.adoptCost(p)*(0.8+phClean/10*0.2)*1.8);
          const buyer=rng.pick(BUYER_DESCS);
          newPurchaseOffers.push({id:'po'+rng.i(1e6,9e6).toString(36),petId:p.id,petName:p.name,petSpecies:p.species,price:offerPrice,buyer,week:next});
        }
      }

      // Debt tracking: 4 consecutive weeks below $0 = bankruptcy
      const debtWeeks=money<=0?(state.debtWeeks||0)+1:0;

      // NPC visit scheduling (pure helper)
      const npcVisit = scheduleNpcVisit(state.npcVisit, state.npcs, state.activeMenuIds, next, rng);

      // Apply NPC affinity buffs as a small post-hoc weekly bonus
      const npcBuffs = state.npcBuffs || {};
      let npcBonus = 0;
      if (npcBuffs.mayor_hollis)  npcBonus += Math.round(cafeResult.menuRevenue * 0.05);
      if (npcBuffs.food_critic)   npcBonus += Math.round(cafeResult.tipRevenue * 0.10);
      if (npcBuffs.loyal_regular) npcBonus += (state.regulars?.length || 0) * 15;
      if (npcBuffs.rival_owner)   npcBonus += Math.round((cafeResult.menuRevenue + cafeResult.tipRevenue) * 0.05);
      money += npcBonus;
      earned += npcBonus;

      // Weekly summary — built AFTER all money adjustments so `net` reconciles
      // exactly with the player's bank delta (money - state.money). Individual
      // revenue/cost fields are informational breakdowns.
      const weekSummary={
        week:next,
        customers:cafeResult.customers,
        menuRevenue:cafeResult.menuRevenue,
        tipRevenue:cafeResult.tipRevenue,
        trendBonus:cafeResult.trendBonus,
        npcBonus,
        showPrizeIncome,
        inspectorFine,
        tvCrewBonus,
        costOfGoods:cafeResult.costOfGoods,
        staffWages:weeklyWages,
        operatingCost:weeklyOp,
        rdCost,
        recCost,
        petUpkeep,
        net: money - state.money,
        events:cafeResult.events,
        showResults:showWeekResults,
        debtWeeks,
        newlyDiscovered:newlyDiscoveredIds,
        upgradedItems:upgradedItems,
        trendChanged:next%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0,
        newTrend:next%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0?trend:null,
        newMenuTrends,
        retiredThisWeek,
        newlySickPets,
      };

      // Regular customer beats (pure helper)
      const regResult = processRegularCustomers(state.regulars, state.pastRegulars, rng, next);
      const regulars = regResult.regulars;
      const pastRegulars = regResult.pastRegulars;
      const regularBeats = regResult.regularBeats;

      // Trend rumor resolve-and-generate (pure helper)
      const trendRumor = processTrendRumor(state.trendRumor, trend, rng, rng.s, next);

      const ns={...state,week:next,pets,breedingQueue:bq,assignedPetIds,money,totalEarned:state.totalEarned+earned+showPrizeIncome+tvCrewBonus,
        npcVisit,regulars,pastRegulars,regularBeats,trendRumor,
        weekResult:cafeResult,weekSummary,staff,trophies,currentTrend:trend,trendHistory:trendHist,
        currentShow:show,showEntries:showEnt,showEntryFromCafe:showEntFromCafe,lastShowSpecies:show?.species||null,wildPetPool:wild,adoptionLastGender:prevAdoptGender,staffCandidates:cands,eventLog,
        discoveredCombos:disc,discoveredCount:discN,discoveredItemIds,lastWeekItemSales:cafeResult.itemSales,
        menuItemWeeksActive,recruitHistory,debtWeeks,currentMenuTrends:menuTrends,purchaseOffers:newPurchaseOffers,rdBudget:rdBudgetUpdated,
        totalBred,highestRarity,maxGeneration,totalFirstPlace,totalPodiums,masteredItems,totalRecruitSpent:(state.totalRecruitSpent||0)+recCost,totalUncommonBred,totalRareBred,totalLegendaryBred,
        lastSoldPet:null,lastSoldPrice:0,
        pastPets:[...(state.pastPets||[]),...retiredThisWeek],
        revenueHistory:[...(state.revenueHistory||[]),{week:next,net:weekSummary.net,tips:cafeResult.tipRevenue,menu:cafeResult.menuRevenue}].slice(-60),
        rngSeed:rng.s,activePanel:'home',activeSeasonalEvent,
        criticEffect,
        rivalCafe,rivalCafeTriggered,rivalCafe2,rivalCafe2Triggered,
        rivalCafe1Ended,rivalCafe2Started,rivalCafe2Ended,
        lastInspectorWeek,lastCriticWeek:cafeResult.newLastCriticWeek||state.lastCriticWeek||0,pendingModal,
        celebrityAdoptions,lastCelebrityWeek,loyaltyBuff,
        activeTvCrew,lastTvCrewWeek,tvCrewBuff};

      const newMilestones=MILESTONES.filter(m=>!prevReached.has(m.id)&&m.check(ns).reached).map(m=>({id:m.id,name:m.name,icon:m.icon,pts:m.pts}));
      const nsWithMilestones={...ns,
        completedMilestoneIds:[...(state.completedMilestoneIds||[]),...newMilestones.map(m=>m.id)],
        weekSummary:{...ns.weekSummary,newMilestones}};
      if(debtWeeks>=GAME_CONFIG.DEBT_WEEKS_TO_BANKRUPTCY)return{...nsWithMilestones,screen:'gameover'};
      if(Time.over(next))return{...nsWithMilestones,screen:'gameover'};
      return nsWithMilestones;
    }

    case A.ASSIGN_PET: {
      const p=state.pets.find(p=>p.id===action.id);
      if(!p||p.state.isKitten||(p.state.settling||0)>0||(p.state.nursing||0)>0||(p.state.sick||0)>0||state.assignedPetIds.includes(action.id)||state.assignedPetIds.length>=UPGRADES[state.cafeLevel].floor)return state;
      // Block assigning show-entry pets during the week before judging (show week)
      const weeksUntilJudging=state.currentShow?Time.weeksUntilJudging(state.week):0;
      if(weeksUntilJudging===1&&state.showEntries.includes(action.id))return state;
      return{...state,assignedPetIds:[...state.assignedPetIds,action.id]};
    }
    case A.UNASSIGN_PET: return{...state,assignedPetIds:state.assignedPetIds.filter(id=>id!==action.id)};
    case A.RENAME_PET: return{...state,pets:state.pets.map(p=>p.id===action.id?{...p,name:action.name||p.name}:p)};
    case A.SET_PET_MARKING: return{...state,pets:state.pets.map(p=>p.id===action.id?{...p,marking:action.marking}:p)};
    case A.SELL_PET: {
      const p=state.pets.find(p=>p.id===action.id);if(!p)return state;
      if((p.state.pregnant||0)>0||(p.state.nursing||0)>0)return state;
      const phClean=getStaffSkillAvg(getPetHouseStaff(state),'cleaning');
      const price=Math.round(Econ.adoptCost(p)*(0.8+phClean/10*0.2));
      return{...state,pets:state.pets.filter(p=>p.id!==action.id),
        assignedPetIds:state.assignedPetIds.filter(id=>id!==action.id),
        showEntries:state.showEntries.filter(id=>id!==action.id),
        showEntryFromCafe:(state.showEntryFromCafe||[]).filter(id=>id!==action.id),
        breedingQueue:state.breedingQueue.filter(b=>b.pA!==action.id&&b.pB!==action.id),
        money:state.money+price,totalEarned:state.totalEarned+price,totalPetsSold:(state.totalPetsSold||0)+1,
        pastPets:[...(state.pastPets||[]),p],
        lastSoldPet:p,lastSoldPrice:price};
    }
    case A.UNDO_SELL: {
      if(!state.lastSoldPet||state.money<state.lastSoldPrice)return state;
      return{...state,
        pets:[...state.pets,state.lastSoldPet],
        assignedPetIds:state.lastSoldPet.location==='cafe'?[...state.assignedPetIds,state.lastSoldPet.id]:state.assignedPetIds,
        money:state.money-state.lastSoldPrice,totalEarned:state.totalEarned-state.lastSoldPrice,
        lastSoldPet:null,lastSoldPrice:0};
    }
    case A.ADOPT_WILD: {
      const p=state.wildPetPool.find(p=>p.id===action.id);if(!p)return state;
      const springDiscount=state.activeSeasonalEvent?.id==='spring_adoption_drive'?0.7:1;
      const cost=Math.round(Econ.adoptCost(p)*(p.adoptPriceMult||1)*springDiscount);
      const pregnantCount=state.pets.filter(p=>(p.state.pregnant||0)>0).length;
      const effectivePetCount=state.pets.length+pregnantCount+state.breedingQueue.length;
      if(state.money<cost||effectivePetCount>=getMaxPets(state))return state;
      return grantMilestones(state,{...state,pets:[...state.pets,{...p,location:'pen',state:{...p.state,settling:1}}],wildPetPool:state.wildPetPool.filter(p=>p.id!==action.id),money:state.money-cost,totalSpent:state.totalSpent+cost,lastSoldPet:null,lastSoldPrice:0});
    }
    case A.START_BREEDING: {
      const{pA:rawA,pB:rawB}=action;
      const a=state.pets.find(p=>p.id===rawA),b=state.pets.find(p=>p.id===rawB);
      if(!a||!b||a.state.age<GAME_CONFIG.BREED_MIN_AGE_WEEKS||b.state.age<GAME_CONFIG.BREED_MIN_AGE_WEEKS||a.state.breedCooldown>0||b.state.breedCooldown>0||(a.state.pregnant||0)>0||(b.state.pregnant||0)>0||(a.state.sick||0)>0||(b.state.sick||0)>0)return state;
      if(a.species!==b.species)return state;
      const gA=petGender(a),gB=petGender(b);
      if(gA===gB)return state;
      // Ensure female is pA (she gets pregnant)
      const[femaleId,maleId]=gA==='female'?[rawA,rawB]:[rawB,rawA];
      if(areRelated(a,b,state.pets))return state;
      const inQ=id=>state.breedingQueue.some(b=>b.pA===id||b.pB===id);
      if(inQ(femaleId)||inQ(maleId))return state;
      const pregCt=state.pets.filter(p=>(p.state.pregnant||0)>0).length;
      const effPetCt=state.pets.length+pregCt+state.breedingQueue.length;
      if(effPetCt>=getMaxPets(state))return state;
      return{...state,
        breedingQueue:[...state.breedingQueue,{pA:femaleId,pB:maleId,weeksLeft:GAME_CONFIG.BREEDING_AWAY_WEEKS}],
        assignedPetIds:state.assignedPetIds.filter(id=>id!==femaleId&&id!==maleId),
      };
    }
    case A.SET_RD_BUDGET: {
      if((state.lockedBudgets||{})[action.cat]) return state; // locked
      const rb={...(typeof state.rdBudget==='object'?state.rdBudget:{drinks:0,food:0,desserts:0})};
      rb[action.cat]=clamp(action.v,0,GAME_CONFIG.MAX_RD_BUDGET);
      return{...state,rdBudget:rb};
    }
    case A.SET_RECRUIT_BUDGET: {
      if((state.lockedBudgets||{}).recruitment) return state; // locked
      return{...state,recruitBudget:clamp(action.v,0,GAME_CONFIG.MAX_RECRUIT_BUDGET)};
    }
    case A.TOGGLE_BUDGET_LOCK: {
      const lb={...(state.lockedBudgets||{drinks:false,food:false,desserts:false,recruitment:false})};
      lb[action.key]=!lb[action.key];
      return{...state,lockedBudgets:lb};
    }
    case A.SET_MENU_PRICE: return{...state,menuItemPrices:{...(state.menuItemPrices||{}),[action.id]:Math.max(0,action.price)}};
    case A.SELECT_MENU_ITEM: {
      const item=state.allMenuItems.find(i=>i.id===action.id);
      if(!item||!state.discoveredItemIds.includes(action.id))return state;
      const cat=item.cat;
      if(state.activeMenuIds[cat].length>=getMaxMenuPerCat(state.cafeLevel)||state.activeMenuIds[cat].includes(action.id))return state;
      const mwa={...(state.menuItemWeeksActive||{})};
      mwa[action.id]=0;
      return{...state,activeMenuIds:{...state.activeMenuIds,[cat]:[...state.activeMenuIds[cat],action.id]},menuItemWeeksActive:mwa};
    }
    case A.DESELECT_MENU_ITEM: {
      const item=state.allMenuItems.find(i=>i.id===action.id);if(!item)return state;
      const mwa={...(state.menuItemWeeksActive||{})};delete mwa[action.id];
      return{...state,activeMenuIds:{...state.activeMenuIds,[item.cat]:state.activeMenuIds[item.cat].filter(id=>id!==action.id)},menuItemWeeksActive:mwa};
    }
    case A.HIRE: {
      const c=state.staffCandidates.find(s=>s.id===action.id);if(!c)return state;
      if(state.staff.length>=getMaxStaff(state))return state;
      return grantMilestones(state,{...state,staff:[...state.staff,{...c,training:true,hireWeek:state.week}],staffCandidates:state.staffCandidates.filter(s=>s.id!==action.id)});
    }
    case A.FIRE: {
      if(state.staff.length<=1)return state;
      const targetStaff=state.staff.find(s=>s.id===action.id);
      if(targetStaff?.isPlayer)return state; // cannot fire the player
      return{...state,staff:state.staff.filter(s=>s.id!==action.id)};
    }
    case A.REPLACE_STAFF:
      // Deprecated: this flow used to fire when maxStaffCap===1, but it would have
      // wiped the player staff entry. The UI button that dispatched it has been
      // removed (see StaffPanel recruitment card). Kept as no-op for save compat.
      return state;
    case A.TRAIN: {
      const m=state.staff.find(s=>s.id===action.id);if(!m||m.skills[action.skill]>=GAME_CONFIG.MAX_SKILL_LEVEL)return state;
      if(m.trainedThisWeek)return state;
      const cost=TRAINING_COSTS[m.skills[action.skill]]||0;if(state.money<cost)return state;
      return{...state,money:state.money-cost,totalSpent:state.totalSpent+cost,
        staff:state.staff.map(s=>s.id!==action.id?s:{...s,skills:{...s.skills,[action.skill]:s.skills[action.skill]+1},trainedThisWeek:true,wage:s.wage+3})};
    }
    case A.ENTER_SHOW: {
      if(!state.currentShow||state.showEntries.length>=GAME_CONFIG.MAX_SHOW_ENTRIES||state.showEntries.includes(action.id))return state;
      // Only allow entry during the show week itself (weeksUntilJudging===1)
      const wuj=Time.weeksUntilJudging(state.week);
      if(wuj!==1)return state;
      const p=state.pets.find(p=>p.id===action.id);
      if(!p||p.state.isKitten||(p.state.pregnant||0)>0||(p.state.nursing||0)>0||(p.state.settling||0)>0||(p.state.sick||0)>0||p.state.isRetired||state.breedingQueue.some(b=>b.pA===p.id||b.pB===p.id))return state;
      if(state.currentShow.species&&p.species!==state.currentShow.species)return state;
      const fee=showEntryFee(state.showEntries.length,state.currentShow.showNum||1,state.currentShow.prizes?.[0],state.currentShow.isChampionship||false);
      if(state.money<fee)return state; // can't afford entry fee
      const wasInCafe=state.assignedPetIds.includes(action.id);
      const prevFromCafe=state.showEntryFromCafe||[];
      return{...state,
        showEntries:[...state.showEntries,action.id],
        showEntryFromCafe:wasInCafe?[...prevFromCafe,action.id]:[...prevFromCafe],
        assignedPetIds:state.assignedPetIds.filter(id=>id!==action.id),
        money:state.money-fee,totalSpent:state.totalSpent+fee};
    }
    case A.REMOVE_ENTRY: {
      // During show week, removing an entry is allowed (refunds fee)
      const fee=showEntryFee(0,state.currentShow?.showNum||1,state.currentShow?.prizes?.[0],state.currentShow?.isChampionship||false);
      const wasFromCafe=(state.showEntryFromCafe||[]).includes(action.id);
      const cap=UPGRADES[state.cafeLevel];
      const canReturnToCafe=wasFromCafe&&state.assignedPetIds.length<cap.floor;
      return{...state,
        showEntries:state.showEntries.filter(id=>id!==action.id),
        showEntryFromCafe:(state.showEntryFromCafe||[]).filter(id=>id!==action.id),
        assignedPetIds:canReturnToCafe?[...state.assignedPetIds,action.id]:[...state.assignedPetIds],
        money:state.money+fee};
    }
    case A.UPGRADE:
    case A.UPGRADE_CAFE: {
      const nl=state.cafeLevel+1;if(nl>GAME_CONFIG.MAX_CAFE_LEVEL)return state;
      const cost=CAFE_UPGRADES[nl].cost;if(state.money<cost)return state;
      return grantMilestones(state,{...state,cafeLevel:nl,money:state.money-cost,totalSpent:state.totalSpent+cost});
    }
    case A.UPGRADE_PET_HOUSE: {
      const nl=(state.petHouseLevel||1)+1;if(nl>10)return state;
      const cost=PET_HOUSE_UPGRADES[nl].cost;if(state.money<cost)return state;
      return grantMilestones(state,{...state,petHouseLevel:nl,money:state.money-cost,totalSpent:state.totalSpent+cost});
    }
    case A.ASSIGN_STAFF: {
      const s=state.staff.find(st=>st.id===action.id);
      if(!s||s.isPlayer)return state; // player always works both
      const sa={...(state.staffAssignments||{})};
      if(action.location===null||action.location===undefined){
        delete sa[action.id]; // unassign
      } else {
        // Validate slot availability before assigning
        const tempState={...state,staffAssignments:sa};
        const cafeCount=getCafeAssigned(tempState).length;
        const phCount=getPetHouseAssigned(tempState).length;
        if(action.location==='cafe'&&sa[action.id]!=='cafe'&&cafeCount>=getMaxCafeAddlStaff(state))return state;
        if(action.location==='petHouse'&&sa[action.id]!=='petHouse'&&phCount>=getMaxPetHouseAddlStaff(state))return state;
        sa[action.id]=action.location;
      }
      return{...state,staffAssignments:sa};
    }
    case A.ACCEPT_PURCHASE_OFFER: {
      const offer=(state.purchaseOffers||[]).find(o=>o.id===action.id);
      if(!offer)return state;
      const p=state.pets.find(p=>p.id===offer.petId);
      if(!p)return{...state,purchaseOffers:(state.purchaseOffers||[]).filter(o=>o.id!==action.id)};
      return{...state,
        pets:state.pets.filter(p=>p.id!==offer.petId),
        assignedPetIds:state.assignedPetIds.filter(id=>id!==offer.petId),
        showEntries:state.showEntries.filter(id=>id!==offer.petId),
        showEntryFromCafe:(state.showEntryFromCafe||[]).filter(id=>id!==offer.petId),
        breedingQueue:state.breedingQueue.filter(b=>b.pA!==offer.petId&&b.pB!==offer.petId),
        money:state.money+offer.price,totalEarned:state.totalEarned+offer.price,totalPetsSold:(state.totalPetsSold||0)+1,
        purchaseOffers:(state.purchaseOffers||[]).filter(o=>o.id!==action.id),
        pastPets:[...(state.pastPets||[]),p],
        lastSoldPet:p,lastSoldPrice:offer.price};
    }
    case A.DECLINE_PURCHASE_OFFER:
      return{...state,purchaseOffers:(state.purchaseOffers||[]).filter(o=>o.id!==action.id)};
    case A.MARK_LETTER_SEEN:
      return{...state,lettersSeen:[...(state.lettersSeen||[]),action.id]};
    case A.NPC_VISIT_RESPONSE: {
      const visit=state.npcVisit; if(!visit) return state;
      const npcs={...(state.npcs||{})};
      const current={affinity:0,lastVisit:0,arc:0,...(npcs[visit.npcId]||{})};
      let money=state.money;
      let affinityGain=0;
      let messageIcon='🙂';
      if(action.gift){
        // Find the highest-rarity active item in the requested category
        const catIds=(state.activeMenuIds||{})[action.gift]||[];
        const items=(state.allMenuItems||[]).filter(i=>catIds.includes(i.id));
        const best=items.sort((a,b)=>(b.rarity||0)-(a.rarity||0))[0];
        if(best){
          const cost=Math.round((best.cost||5)*1.5);
          if(money>=cost){
            money-=cost;
            const isPreferred=NPCS[visit.npcId]?.preferredGift===action.gift;
            // Marigold is unimpressed by low-quality offerings
            if(visit.npcId==='food_critic'&&(best.rarity||0)<2){
              affinityGain=0;
            } else {
              affinityGain=isPreferred?2:1;
            }
            messageIcon=isPreferred?'💝':'🙂';
          }
        }
      } else {
        // Just chatting still builds a little rapport
        affinityGain=1;
      }
      current.affinity=Math.min(MAX_AFFINITY,current.affinity+affinityGain);
      current.lastVisit=state.week;
      current.arc=Math.max(current.arc,Math.floor(current.affinity/3));
      npcs[visit.npcId]=current;
      const npcBuffs={...(state.npcBuffs||{})};
      if(current.affinity>=MAX_AFFINITY&&!npcBuffs[visit.npcId]){
        npcBuffs[visit.npcId]=true;
      }
      return{...state,money,npcs,npcBuffs,npcVisit:null};
    }
    case A.DISMISS_RUMOR:
      return{...state,trendRumor:state.trendRumor?{...state.trendRumor,shown:true}:null};
    case A.FULFILL_WISH: {
      const pet=state.pets.find(p=>p.id===action.id);
      if(!pet||!pet.wish) return state;
      const w=pet.wish;
      // Validate the player can fulfill it
      if(w.type==='toy'&&state.money<w.value) return state;
      if(w.type==='treat'){
        // Verify the requested category has at least one active item
        const cat=w.target;
        if(!(state.activeMenuIds[cat]||[]).length) return state;
      }
      if(w.type==='friend'){
        // Verify the wishing pet has a positive cafe relationship with a pet of the target species
        const sp=w.target;
        const rels=pet.relationships||[];
        const hasCafeFriend=state.pets.some(p=>p.id!==pet.id&&p.species===sp&&rels.some(r=>r.petId===p.id&&r.strength>=1));
        if(!hasCafeFriend) return state;
      }
      // Apply reward
      let appealDelta=0;
      let moneyDelta=0;
      if(w.type==='toy'){ appealDelta=0.5; moneyDelta=-w.value; }
      if(w.type==='treat'){ appealDelta=0.3; moneyDelta=20; } // gives $20 immediate tip
      if(w.type==='friend'){ appealDelta=1; }
      // TODO: future chunk — roam wish: +0.5 appeal, +10% future tips (pet.tipBuff: 1.1), requires 4 consecutive weeks in cafe tracking
      return{
        ...state,
        money:state.money+moneyDelta,
        pets:state.pets.map(p=>p.id===pet.id
          ?{...p,wish:null,fulfilledWishes:(p.fulfilledWishes||0)+1,stats:{...p.stats,appeal:Math.min(10,(p.stats.appeal||0)+appealDelta)}}
          :p
        ),
      };
    }
    case A.PAY_VET_BILL: {
      const pet=state.pets.find(p=>p.id===action.id);
      if(!pet||(pet.state.sick||0)===0)return state;
      if(state.money<200)return state;
      return{...state,money:state.money-200,pets:state.pets.map(p=>p.id===action.id?{...p,state:{...p.state,sick:0}}:p)};
    }
    case A.DISMISS_PENDING_MODAL: {
      return{...state,pendingModal:null};
    }
    case A.CELEBRITY_SELL: {
      const modal=state.pendingModal;
      if(!modal||modal.type!=='celebrity_offer')return state;
      const p=state.pets.find(p=>p.id===modal.petId);
      if(!p)return{...state,pendingModal:null};
      return{...state,
        pets:state.pets.filter(pp=>pp.id!==modal.petId),
        assignedPetIds:state.assignedPetIds.filter(id=>id!==modal.petId),
        showEntries:state.showEntries.filter(id=>id!==modal.petId),
        showEntryFromCafe:(state.showEntryFromCafe||[]).filter(id=>id!==modal.petId),
        breedingQueue:state.breedingQueue.filter(b=>b.pA!==modal.petId&&b.pB!==modal.petId),
        money:state.money+modal.amount,
        totalEarned:state.totalEarned+modal.amount,
        totalPetsSold:(state.totalPetsSold||0)+1,
        pastPets:[...(state.pastPets||[]),p],
        lastSoldPet:p,lastSoldPrice:modal.amount,
        celebrityAdoptions:[...(state.celebrityAdoptions||[]),{petId:modal.petId,petName:modal.petName,celebName:modal.celebrityName,week:state.week,amount:modal.amount}],
        pendingModal:null};
    }
    case A.CELEBRITY_REFUSE: {
      const modal=state.pendingModal;
      if(!modal||modal.type!=='celebrity_offer')return state;
      // Cumulative +5% tip bonus per refusal, capped at 1.25 so it can't spiral
      return{...state,
        loyaltyBuff:Math.min(1.25,(state.loyaltyBuff||1)*1.05),
        pendingModal:null};
    }
    default: return state;
  }
}

// ─── SVG: CAT ─────────────────────────────────────────────────────────────────
// Visual inputs: phenotype.color (body, from genetics), phenotype.fur (trait1), phenotype.eyes (trait2), phenotype.ageSize
const CatSVG = memo(function CatSVG({phenotype, size=64}) {
  const cc = COLOR_MAPS.cats[phenotype.color] || {body:'#f4a460',out:'#c4784a',pat:'#d4844a'};
  const ec = EYE_COLORS_ALL[phenotype.eyes] || '#2e8b57';
  const ageSc = ({baby:0.55, young:0.80, adult:1.0})[phenotype.ageSize] || 1.0;
  const furSc = ({short:0.96, medium:1.0, long:1.05})[phenotype.fur] || 1.0;
  const bs = ageSc * furSc;
  const hf = ({baby:1.35, young:1.15, adult:1.0})[phenotype.ageSize] || 1.0; // head larger relative to body when young
  // by=39/bh=14 keeps by+bh+2 (paw y) identical to old by=40/bh=13 at every bs
  const bx=32, by=39, bw=15*bs, bh=14*bs;
  const fur = phenotype.fur || 'medium';
  const isLong = fur === 'long';
  const faceRx=15*bs*hf, faceRy=13*bs*hf;
  const headY=by-bh+1;
  const eTop=headY-faceRy;
  // eBaseY: y on the head ellipse surface at x = bx ± faceRx*0.70, so base is inside the head
  const eBaseY=headY-faceRy*.70;
  const eS=(f)=>f?-1:1;
  // Rounded ears: straight sides up to ~55% height, Q bezier rounds the tip
  const earPath=(f)=>`M${bx+eS(f)*faceRx*.15},${eBaseY} L${bx+eS(f)*faceRx*.37},${eTop-faceRy*.52} Q${bx+eS(f)*faceRx*.50},${eTop-faceRy*.80} ${bx+eS(f)*faceRx*.63},${eTop-faceRy*.52} L${bx+eS(f)*faceRx*.70},${eBaseY}Z`;
  const earInner=(f)=>`M${bx+eS(f)*faceRx*.23},${eBaseY+1} L${bx+eS(f)*faceRx*.40},${eTop-faceRy*.37} Q${bx+eS(f)*faceRx*.48},${eTop-faceRy*.58} ${bx+eS(f)*faceRx*.57},${eTop-faceRy*.37} L${bx+eS(f)*faceRx*.62},${eBaseY+1}Z`;
  // Tail: long for long fur, medium otherwise
  const tailD=isLong
    ?`M${bx+bw-2},${by+2} Q${bx+bw+16},${by+bh+14} ${bx-4},${by+bh+6}`
    :`M${bx+bw-2},${by+2} Q${bx+bw+12},${by+bh+11} ${bx+2},${by+bh+6}`;
  const tailW=isLong?5*bs:4*bs, tailIW=isLong?3.5*bs:2.5*bs;
  const eyeOff=5*bs, eyeY=headY+faceRy*.22, muzzleCy=headY+faceRy*.68;
  const ruffPath=`M${bx-8*bs},${by-4*bs} Q${bx},${by+6*bs} ${bx+8*bs},${by-4*bs}`;
  const gid='catG';

  return (
    <svg width={size} height={size} viewBox="-2 0 84 64" style={{display:'block',flexShrink:0,overflow:'visible'}}>
      <defs>
        <linearGradient id={`${gid}B`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.35"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.22"/>
        </linearGradient>
        <linearGradient id={`${gid}F`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.28"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.18"/>
        </linearGradient>
      </defs>
      <ellipse cx={bx} cy={by+bh+5} rx={22*bs} ry={2.5} fill="rgba(0,0,0,.12)"/>
      <path d={tailD} fill="none" stroke={cc.out} strokeWidth={tailW} strokeLinecap="round"/>
      <path d={tailD} fill="none" stroke={cc.body} strokeWidth={tailIW} strokeLinecap="round"/>
      {/* Body — ry extended +5 so bottom edge meets the ground shadow */}
      <ellipse cx={bx} cy={by} rx={bw} ry={bh+5} fill={cc.out}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh+4.2} fill={cc.body}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh+4.2} fill={`url(#${gid}B)`}/>
      {/* Fur texture — denser/longer strokes for long fur */}
      {[[-12,-6],[-8,2],[-4,-10],[2,5],[8,-4],[14,1]].map((p,i)=>(
        <line key={i} x1={bx+p[0]*bs} y1={by+p[1]*bs} x2={bx+(p[0]+(isLong?3:1.5))*bs} y2={by+(p[1]+(i%2?3:-3))*bs}
          stroke={cc.out} strokeWidth={isLong?.9:.5} strokeOpacity={isLong?.4:.25} strokeLinecap="round"/>
      ))}
      {/* Long fur chest ruff */}
      {isLong&&<path d={ruffPath} fill="none" stroke={cc.body} strokeWidth={6*bs} strokeLinecap="round" strokeOpacity=".45"/>}
      {isLong&&<path d={ruffPath} fill="none" stroke="#fff" strokeWidth={2*bs} strokeLinecap="round" strokeOpacity=".15"/>}
      {/* Sitting front paws — drawn after body so they appear on top */}
      <ellipse cx={bx-8*bs} cy={by+bh+2} rx={5.5*bs} ry={3*bs} fill={cc.out}/>
      <ellipse cx={bx+8*bs} cy={by+bh+2} rx={5.5*bs} ry={3*bs} fill={cc.out}/>
      <ellipse cx={bx-8*bs} cy={by+bh+2} rx={4.5*bs} ry={2.2*bs} fill={cc.body}/>
      <ellipse cx={bx+8*bs} cy={by+bh+2} rx={4.5*bs} ry={2.2*bs} fill={cc.body}/>
      <ellipse cx={bx-8*bs} cy={by+bh+2.5} rx={1.6*bs} ry={.9*bs} fill="#d4848a" fillOpacity=".55"/>
      <ellipse cx={bx+8*bs} cy={by+bh+2.5} rx={1.6*bs} ry={.9*bs} fill="#d4848a" fillOpacity=".55"/>
      <ellipse cx={bx-10.5*bs} cy={by+bh+1.5} rx={.85*bs} ry={.65*bs} fill="#d4848a" fillOpacity=".45"/>
      <ellipse cx={bx-5.5*bs} cy={by+bh+1.5} rx={.85*bs} ry={.65*bs} fill="#d4848a" fillOpacity=".45"/>
      <ellipse cx={bx+5.5*bs} cy={by+bh+1.5} rx={.85*bs} ry={.65*bs} fill="#d4848a" fillOpacity=".45"/>
      <ellipse cx={bx+10.5*bs} cy={by+bh+1.5} rx={.85*bs} ry={.65*bs} fill="#d4848a" fillOpacity=".45"/>
      {/* Head */}
      <ellipse cx={bx} cy={headY} rx={faceRx} ry={faceRy} fill={cc.out}/>
      <ellipse cx={bx} cy={headY} rx={faceRx-.8} ry={faceRy-.8} fill={cc.body}/>
      <ellipse cx={bx} cy={headY} rx={faceRx-.8} ry={faceRy-.8} fill={`url(#${gid}F)`}/>
      {/* Ears */}
      <path d={earPath(false)} fill={cc.out}/>
      <path d={earPath(true)} fill={cc.out}/>
      <path d={earInner(false)} fill="#e8708a" fillOpacity=".8"/>
      <path d={earInner(true)} fill="#e8708a" fillOpacity=".8"/>
      {/* Ear tufts for long fur */}
      {isLong&&<path d={`M${bx+faceRx*.62},${eBaseY-1} L${bx+faceRx*.50},${eTop-faceRy*.5}`} fill="none" stroke={cc.body} strokeWidth={1.5} strokeOpacity=".55" strokeLinecap="round"/>}
      {isLong&&<path d={`M${bx-faceRx*.62},${eBaseY-1} L${bx-faceRx*.50},${eTop-faceRy*.5}`} fill="none" stroke={cc.body} strokeWidth={1.5} strokeOpacity=".55" strokeLinecap="round"/>}
      {/* Muzzle */}
      <ellipse cx={bx} cy={muzzleCy} rx={faceRx*.38} ry={faceRy*.32} fill={cc.body} fillOpacity=".55"/>
      <ellipse cx={bx} cy={muzzleCy} rx={faceRx*.38} ry={faceRy*.32} fill="#fff" fillOpacity=".1"/>
      {/* Eyes — color from trait2 (eyes) */}
      <ellipse cx={bx-eyeOff} cy={eyeY} rx={4} ry={3.8} fill="#fff"/>
      <ellipse cx={bx+eyeOff} cy={eyeY} rx={4} ry={3.8} fill="#fff"/>
      <ellipse cx={bx-eyeOff} cy={eyeY} rx={2.6} ry={3.0} fill={ec}/>
      <ellipse cx={bx+eyeOff} cy={eyeY} rx={2.6} ry={3.0} fill={ec}/>
      <ellipse cx={bx-eyeOff} cy={eyeY} rx={1.1} ry={2.0} fill="#1a1a1a"/>
      <ellipse cx={bx+eyeOff} cy={eyeY} rx={1.1} ry={2.0} fill="#1a1a1a"/>
      <circle cx={bx-eyeOff+1.2} cy={eyeY-1} r={.9} fill="#fff" fillOpacity=".85"/>
      <circle cx={bx+eyeOff+1.2} cy={eyeY-1} r={.9} fill="#fff" fillOpacity=".85"/>
      {/* Nose */}
      <ellipse cx={bx} cy={muzzleCy-1} rx={1.8} ry={1.2} fill="#e88080"/>
      <ellipse cx={bx-.4} cy={muzzleCy-1.4} rx={.6} ry={.4} fill="#fff" fillOpacity=".4"/>
      {/* Whiskers — fixed 4 */}
      {[-9,-7,7,9].map((x,i)=>(
        <line key={i} x1={bx} y1={muzzleCy} x2={bx+x*bs} y2={muzzleCy-1+(i%2?1.5:-1.5)} stroke="#ddd" strokeWidth={.9} strokeOpacity=".75"/>
      ))}
      {raritySparkle(phenotype.rarity||0)}
    </svg>
  );
});

// ─── SVG: DOG ─────────────────────────────────────────────────────────────────
// Visual inputs: phenotype.color (body, from genetics), phenotype.size (trait1), phenotype.breed (trait2), phenotype.ageSize
const DogSVG = memo(function DogSVG({phenotype, size=64}) {
  const cc = COLOR_MAPS.dogs[phenotype.color] || {body:'#daa520',out:'#b8860b',pat:'#c89418'};
  const ageSc = ({baby:0.55, young:0.80, adult:1.0})[phenotype.ageSize] || 1.0;
  const sizeSc = ({small:.78, medium:1.0, large:1.22})[phenotype.size] || 1.0;
  const bs = ageSc * sizeSc;
  const hf = ({baby:1.35, young:1.15, adult:1.0})[phenotype.ageSize] || 1.0;
  const breed = phenotype.breed || 'labrador';
  const brachi      = false;
  const foxy        = breed==='husky'||breed==='shiba';
  const longBd      = breed==='corgi';
  const curlTl      = false;
  const isDalmatian = breed==='dalmatian';
  const isHusky     = breed==='husky';
  const isShiba     = breed==='shiba';
  const isPoodle    = breed==='poodle';
  const isBeagle    = breed==='beagle';
  const isCorgi     = breed==='corgi';
  const isBulldog   = false;
  const isPug       = false;
  // Ear type
  const earFloppy   = breed==='labrador'||breed==='beagle'||breed==='poodle';

  // Body — dalmatian slim, bulldog/pug squat, corgi long
  const bw=(longBd?22:isDalmatian?16:18)*bs, bh=(brachi?10:8)*bs;
  const bx=28, by=46;
  const headR=(brachi?12:10)*bs*hf;
  const headCx=bx+bw, headCy=by-bh-1;

  // Snout — brachycephalic flat, foxy/beagle longer
  const snoutW=(brachi?5:foxy||isBeagle?6.5:5)*bs;
  const snoutH=(brachi?5:4)*bs;
  const snoutCx=headCx+headR*.85;
  const snoutCy=headCy+headR*.2;

  // Floppy ears — beagle hangs much lower; cubic bezier gives a natural outward bow
  const earDroop = isBeagle ? 1.7 : 1.0;
  const flopEarPath =`M${headCx-headR*.4},${headCy-headR*.85} C${headCx-headR*.78},${headCy+headR*.05} ${headCx-headR*.6},${headCy+headR*earDroop*.72} ${headCx-headR*.42},${headCy+headR*earDroop}`;
  const flopEarPath2=`M${headCx-headR*.3},${headCy-headR*.85} C${headCx-headR*.65},${headCy+headR*.12} ${headCx-headR*.42},${headCy+headR*earDroop*.78} ${headCx+headR*.05},${headCy+headR*earDroop}`;

  // Erect ears — corgi dramatically taller
  const erectH   = isCorgi ? 15*bs : 9*bs;   // a bit shorter than before
  const headTop  = headCy - headR*.9;
  const earBaseY = headTop + headR*.22;
  const earHW    = headR*.58;               // wider base than before (was .44)
  // Rounded tips via Q bezier: sides go up then arc over the peak
  const erectEarBack      =`M${headCx-earHW},${earBaseY} L${headCx-headR*.18},${headTop-erectH*.6} Q${headCx-headR*.08},${headTop-erectH} ${headCx+headR*.08},${headTop-erectH*.6} L${headCx+earHW*.42},${earBaseY}Z`;
  const erectEarFront     =`M${headCx-earHW*.52},${earBaseY} L${headCx+headR*.12},${headTop-erectH*.82} Q${headCx+headR*.24},${headTop-erectH*.95} ${headCx+headR*.36},${headTop-erectH*.82} L${headCx+earHW*.82},${earBaseY}Z`;
  const erectEarFrontInner=`M${headCx-earHW*.36},${earBaseY+1} L${headCx+headR*.14},${headTop-erectH*.58} Q${headCx+headR*.24},${headTop-erectH*.72} ${headCx+headR*.34},${headTop-erectH*.58} L${headCx+earHW*.66},${earBaseY+1}Z`;

  // Tail per breed
  const tailX=bx-bw+2, tailY=by-3;
  const tailSw =(isHusky?7:5)*bs;
  const tailSw2=(isHusky?5:3)*bs;
  const tailD=curlTl
    ?`M${tailX},${tailY} Q${tailX-12*ageSc},${tailY-14*ageSc} ${tailX-4*ageSc},${tailY-22*ageSc}`
    :isShiba
    ?`M${tailX},${tailY} C${tailX-10*ageSc},${tailY-16*ageSc} ${tailX+2*ageSc},${tailY-28*ageSc} ${tailX+8*ageSc},${tailY-22*ageSc}`
    :isBulldog
    ?`M${tailX},${tailY} L${tailX-6*ageSc},${tailY-5*ageSc}`
    :isHusky
    ?`M${tailX},${tailY} Q${tailX-18*ageSc},${tailY-6*ageSc} ${tailX-14*ageSc},${tailY-20*ageSc}`
    :`M${tailX},${tailY} Q${tailX-14*ageSc},${tailY-7*ageSc} ${tailX-9*ageSc},${tailY-18*ageSc}`;

  // Leg height — corgi stubby, dalmatian tall
  const legH=(isCorgi?7:isDalmatian?14:11)*bs;
  const legTop=by+bh-2, legBot=legTop+legH;
  const fLegX1=bx+bw*.55, fLegX2=fLegX1-5*bs;
  const bLegX1=bx-bw*.55, bLegX2=bLegX1+5*bs;
  const eyeX=headCx+headR*.18, eyeY=headCy-headR*.12;
  const spotPos=[[-8,-2],[4,-5],[-2,3],[8,0],[-5,4],[6,-2]];
  const gid='dogG';

  return (
    <svg width={size} height={size} viewBox="-2 0 84 64" style={{display:'block',flexShrink:0,overflow:'visible'}}>
      <defs>
        <linearGradient id={`${gid}B`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.32"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.24"/>
        </linearGradient>
        <linearGradient id={`${gid}H`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.28"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.2"/>
        </linearGradient>
        <linearGradient id={`${gid}M`} x1="1" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.2"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.15"/>
        </linearGradient>
      </defs>
      <ellipse cx={bx} cy={legBot+2} rx={22*bs} ry={3} fill="rgba(0,0,0,.12)"/>
      {/* Tail */}
      <path d={tailD} fill="none" stroke={cc.out} strokeWidth={tailSw} strokeLinecap="round"/>
      <path d={tailD} fill="none" stroke={cc.body} strokeWidth={tailSw2} strokeLinecap="round"/>
      {/* Labrador: otter-thick tail halo */}
      {breed==='labrador'&&<path d={tailD} fill="none" stroke={cc.out} strokeWidth={9*bs} strokeLinecap="round" strokeOpacity=".2"/>}
      {/* Poodle: tail pom-pom */}
      {isPoodle&&<circle cx={tailX-9*ageSc} cy={tailY-18*ageSc} r={5*bs} fill={cc.out}/>}
      {isPoodle&&<circle cx={tailX-9*ageSc} cy={tailY-18*ageSc} r={4.2*bs} fill={cc.body}/>}
      {/* Back legs */}
      <rect x={bLegX2-2*bs} y={legTop} width={4*bs} height={legH} rx={2*bs} fill={cc.out} opacity=".65"/>
      <ellipse cx={bLegX2} cy={legBot} rx={4*bs} ry={2.5*bs} fill={cc.out} opacity=".65"/>
      <rect x={bLegX1-2*bs} y={legTop} width={4*bs} height={legH} rx={2*bs} fill={cc.out}/>
      <ellipse cx={bLegX1} cy={legBot} rx={4.5*bs} ry={2.5*bs} fill={cc.out}/>
      <ellipse cx={bLegX1} cy={legBot+1} rx={1.8*bs} ry={1*bs} fill="#c07070" fillOpacity=".6"/>
      {/* Body */}
      <ellipse cx={bx} cy={by} rx={bw} ry={bh} fill={cc.out}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh-.8} fill={cc.body}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh-.8} fill={`url(#${gid}B)`}/>
      {/* Husky: body fur texture */}
      {isHusky&&[[-12,-2],[-6,3],[0,-5],[6,2],[12,-1]].map((p,i)=>(
        <line key={i} x1={bx+p[0]*bs} y1={by+p[1]*bs} x2={bx+(p[0]+2)*bs} y2={by+(p[1]+(i%2?4:-4))*bs}
          stroke={cc.out} strokeWidth={1.1} strokeOpacity=".38" strokeLinecap="round"/>
      ))}
      {/* Poodle: body pom-poms */}
      {isPoodle&&[[bx-bw*.48,by-bh*.15,6.5],[bx-bw*.05,by-bh*.55,6],[bx+bw*.35,by-bh*.25,5.5]].map(([px,py,r],i)=>(
        <g key={i}><circle cx={px} cy={py} r={r*bs} fill={cc.out}/><circle cx={px} cy={py} r={(r-.9)*bs} fill={cc.body}/></g>
      ))}
      {/* Dalmatian: black spots */}
      {isDalmatian&&spotPos.map((p,i)=>(
        <circle key={i} cx={bx+p[0]*bs} cy={by+p[1]*bs} r={2.5*bs} fill="#2d2d2d" fillOpacity={.72}/>
      ))}
      {/* Beagle: dark saddle marking */}
      {isBeagle&&<ellipse cx={bx-3*bs} cy={by-bh*.65} rx={9*bs} ry={3.5*bs} fill={cc.out} fillOpacity=".55"/>}
      {/* Front legs */}
      <rect x={fLegX2-2*bs} y={legTop} width={4*bs} height={legH} rx={2*bs} fill={cc.out} opacity=".65"/>
      <ellipse cx={fLegX2} cy={legBot} rx={4*bs} ry={2.5*bs} fill={cc.out} opacity=".65"/>
      <rect x={fLegX1-2*bs} y={legTop} width={4*bs} height={legH} rx={2*bs} fill={cc.out}/>
      <ellipse cx={fLegX1} cy={legBot} rx={4.5*bs} ry={2.5*bs} fill={cc.out}/>
      <ellipse cx={fLegX1} cy={legBot+1} rx={1.8*bs} ry={1*bs} fill="#c07070" fillOpacity=".6"/>
      {/* Floppy ear — back layer */}
      {earFloppy&&<path d={flopEarPath2} fill="none" stroke={cc.out} strokeWidth={(isBeagle?10:8)*bs} strokeLinecap="round" strokeOpacity=".65"/>}
      {earFloppy&&<path d={flopEarPath2} fill="none" stroke={cc.body} strokeWidth={(isBeagle?7:5)*bs} strokeLinecap="round" strokeOpacity=".45"/>}
      {/* Erect ear — back triangle */}
      {!earFloppy&&<path d={erectEarBack} fill={cc.out} fillOpacity=".75"/>}
      {/* Head */}
      <ellipse cx={headCx} cy={headCy} rx={headR} ry={headR*.9} fill={cc.out}/>
      <ellipse cx={headCx} cy={headCy} rx={headR-.8} ry={headR*.9-.8} fill={cc.body}/>
      <ellipse cx={headCx} cy={headCy} rx={headR-.8} ry={headR*.9-.8} fill={`url(#${gid}H)`}/>
      {/* Erect ear — front triangle + inner */}
      {!earFloppy&&<path d={erectEarFront} fill={cc.out}/>}
      {!earFloppy&&<path d={erectEarFrontInner} fill="#e8888a" fillOpacity=".55"/>}
      {/* Husky: face mask markings */}
      {isHusky&&<ellipse cx={headCx-headR*.3} cy={headCy-headR*.15} rx={headR*.42} ry={headR*.35} fill="#fff" fillOpacity=".22"/>}
      {isHusky&&<ellipse cx={headCx} cy={headCy+headR*.25} rx={headR*.55} ry={headR*.28} fill="#fff" fillOpacity=".2"/>}
      {/* Husky: cheek puff */}
      {isHusky&&<ellipse cx={headCx-headR*.5} cy={headCy+headR*.3} rx={4*bs} ry={3*bs} fill={cc.body} fillOpacity=".5"/>}
      {/* Poodle: head topknot pom */}
      {isPoodle&&<circle cx={headCx-headR*.15} cy={headCy-headR*.85} r={6.5*bs} fill={cc.out}/>}
      {isPoodle&&<circle cx={headCx-headR*.15} cy={headCy-headR*.85} r={5.7*bs} fill={cc.body}/>}
      {/* Bulldog/Pug: brow wrinkles */}
      {brachi&&<path d={`M${headCx-headR*.5},${headCy-headR*.3} Q${headCx-headR*.15},${headCy-headR*.48} ${headCx+headR*.1},${headCy-headR*.3}`} fill="none" stroke={cc.out} strokeWidth={1} strokeOpacity=".5"/>}
      {brachi&&<path d={`M${headCx-headR*.42},${headCy-headR*.1} Q${headCx-headR*.1},${headCy-headR*.28} ${headCx+headR*.12},${headCy-headR*.1}`} fill="none" stroke={cc.out} strokeWidth={1} strokeOpacity=".42"/>}
      {isPug&&<path d={`M${headCx-headR*.55},${headCy-headR*.48} Q${headCx-headR*.12},${headCy-headR*.68} ${headCx+headR*.08},${headCy-headR*.48}`} fill="none" stroke={cc.out} strokeWidth={1} strokeOpacity=".38"/>}
      {/* Bulldog: underbite jaw */}
      {isBulldog&&<ellipse cx={snoutCx+snoutW*.3} cy={snoutCy+snoutH*.75} rx={snoutW*.65} ry={snoutH*.45} fill={cc.out} fillOpacity=".5"/>}
      {isBulldog&&<ellipse cx={snoutCx+snoutW*.3} cy={snoutCy+snoutH*.75} rx={snoutW*.55} ry={snoutH*.35} fill={cc.body} fillOpacity=".85"/>}
      {/* Snout */}
      <ellipse cx={snoutCx} cy={snoutCy} rx={snoutW} ry={snoutH} fill={cc.out} fillOpacity=".5"/>
      <ellipse cx={snoutCx} cy={snoutCy} rx={snoutW-.6} ry={snoutH-.6} fill={cc.body}/>
      <ellipse cx={snoutCx} cy={snoutCy} rx={snoutW-.6} ry={snoutH-.6} fill={`url(#${gid}M)`}/>
      <ellipse cx={snoutCx+snoutW-1} cy={snoutCy-snoutH*.1} rx={2.8*bs} ry={1.8*bs} fill="#1a1a1a"/>
      <ellipse cx={snoutCx+snoutW-2} cy={snoutCy-snoutH*.1-.5} rx={.9} ry={.6} fill="#fff" fillOpacity=".4"/>
      {/* Eye brow shadow */}
      <ellipse cx={eyeX} cy={eyeY-3*bs} rx={2*bs} ry={1.2*bs} fill={cc.out} fillOpacity=".55"/>
      <ellipse cx={eyeX} cy={eyeY} rx={3.5} ry={3.5} fill="#fff"/>
      <ellipse cx={eyeX} cy={eyeY} rx={2.3} ry={2.3} fill="#8B4513"/>
      <ellipse cx={eyeX} cy={eyeY} rx={1.1} ry={1.4} fill="#1a1a1a"/>
      <circle cx={eyeX+1.2} cy={eyeY-1.2} r={.9} fill="#fff" fillOpacity=".88"/>
      {/* Floppy ear — front layer */}
      {earFloppy&&<path d={flopEarPath} fill="none" stroke={cc.out} strokeWidth={(isBeagle?9:7)*bs} strokeLinecap="round"/>}
      {earFloppy&&<path d={flopEarPath} fill="none" stroke={cc.body} strokeWidth={(isBeagle?6:4.5)*bs} strokeLinecap="round" strokeOpacity=".7"/>}
      {/* Dalmatian: ear spot */}
      {isDalmatian&&<circle cx={headCx-headR*.38} cy={headCy+headR*.65} r={2.2*bs} fill="#2d2d2d" fillOpacity=".7"/>}
      {raritySparkle(phenotype.rarity||0)}
    </svg>
  );
});

// ─── SVG: BIRD ────────────────────────────────────────────────────────────────
// Visual inputs: phenotype.color (trait2 = body color), phenotype.plumage (trait1), phenotype.ageSize
const BirdSVG = memo(function BirdSVG({phenotype, size=64}) {
  const cc = COLOR_MAPS.birds[phenotype.color] || {body:'#4169e1',out:'#2850b0',pat:'#3558c0'};
  const bs = ({baby:0.55, young:0.80, adult:1.0})[phenotype.ageSize] || 1.0;
  const isBaby = phenotype.ageSize === 'baby';
  const bodySc = isBaby ? 0.6 : 1.0;  // baby body 40% smaller
  const bx=32, by=40;
  const birdBy = isBaby ? by+5 : by;   // baby bird sits a bit lower inside the egg
  const bodyBy = isBaby ? birdBy + 5 : birdBy;  // baby body shifted down inside egg; others unchanged
  const bw=16*bs*bodySc, bh=14*bs*bodySc;
  const headR=8*bs;
  const headCx=bx+4, headCy=birdBy-bh-headR+4;  // head anchored to birdBy, not bodyBy
  // Plumage (trait1) drives visual effects
  const plumage = phenotype.plumage || 'sleek';
  const isIridescent = plumage==='iridescent';
  const isFluffy   = plumage==='fluffy';
  const isRuffled  = plumage==='ruffled';
  const hasCrest = plumage==='crested';
  const crestH = hasCrest ? 12*bs : 0;
  // Fixed straight medium beak
  const beakLen=8*bs;
  const beakD=`M${headCx+headR-2},${headCy-1} L${headCx+headR+beakLen},${headCy+2} L${headCx+headR-2},${headCy+4}Z`;
  const beakUpperD=`M${headCx+headR-2},${headCy-1} L${headCx+headR+beakLen},${headCy+1} L${headCx+headR-2},${headCy+2}Z`;
  const wingFillD=`M${bx-bw*.25},${bodyBy-bh*.45} Q${bx-bw-5*bs},${bodyBy-2} ${bx-bw*.5},${bodyBy+bh*.5} Q${bx-bw*.3},${bodyBy+bh*.3} ${bx-bw*.25},${bodyBy-bh*.45}Z`;
  // Fixed medium tail
  const tailLen=10*bs;
  const tailBase=`${bx-bw+3},${bodyBy+bh*.25}`;
  const tailLines=[
    `M${tailBase} L${bx-bw-tailLen*1.05},${bodyBy+bh*.55}`,
    `M${tailBase} L${bx-bw-tailLen},${bodyBy+bh*.35}`,
    `M${tailBase} L${bx-bw-tailLen*.9},${bodyBy+bh*.15}`,
    `M${tailBase} L${bx-bw-tailLen*.7},${bodyBy+bh*-.05}`,
  ];
  const eyeX=headCx+headR*.35, eyeY=headCy-headR*.08;
  const gid='birdG';
  // Egg shell — anchored to original by so moving birdBy doesn't shift the egg
  const fullBw = 16*bs, fullBh = 14*bs;
  const eggCy = by + fullBh * 0.55;
  const eggRx = fullBw * 1.05;
  const eggRy = fullBh * 1.65;
  // Egg opening follows the upper crack zigzag; arc sweeps around the bottom back to start
  const eggD = `M${bx-eggRx*.92},${eggCy-eggRy*.3} L${bx-eggRx*.58},${eggCy-eggRy*.15} L${bx-eggRx*.28},${eggCy-eggRy*.25} L${bx+eggRx*.05},${eggCy-eggRy*.08} L${bx+eggRx*.32},${eggCy-eggRy*.22} L${bx+eggRx*.65},${eggCy-eggRy*.05} L${bx+eggRx*.9},${eggCy-eggRy*.18} A${eggRx} ${eggRy} 0 1 1 ${bx-eggRx*.92} ${eggCy-eggRy*.3}Z`;

  return (
    <svg width={size} height={size} viewBox="-2 0 84 64" style={{display:'block',flexShrink:0,overflow:'visible'}}>
      <defs>
        <linearGradient id={`${gid}B`} x1="0.3" y1="0" x2="0.7" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.38"/>
          <stop offset="50%" stopColor="#fff" stopOpacity="0.08"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.28"/>
        </linearGradient>
        <linearGradient id={`${gid}H`} x1="0.3" y1="0" x2="0.7" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.32"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.22"/>
        </linearGradient>
        <linearGradient id={`${gid}I`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.5"/>
          <stop offset="40%" stopColor={cc.pat} stopOpacity="0.4"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.15"/>
        </linearGradient>
        <linearGradient id={`${gid}W`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.18"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.3"/>
        </linearGradient>
        <linearGradient id={`${gid}E`} x1="0.25" y1="0" x2="0.75" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.55"/>
          <stop offset="60%" stopColor="#fff" stopOpacity="0.1"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.12"/>
        </linearGradient>
      </defs>
      {/* Shadow — non-baby drawn first; baby drawn after egg so it's not hidden behind it */}
      {!isBaby&&<ellipse cx={32} cy={bodyBy+bh+11} rx={16*bs} ry={2.5} fill="rgba(0,0,0,.1)"/>}
      {/* Tail — hidden for baby (tucked inside egg) */}
      {!isBaby&&tailLines.map((d,i)=>(
        <path key={i} d={d} fill="none" stroke={cc.out} strokeWidth={(3-i*.5)*bs} strokeLinecap="round" strokeOpacity={.85-i*.12}/>
      ))}
      {!isBaby&&tailLines.map((d,i)=>(
        <path key={`ti${i}`} d={d} fill="none" stroke={cc.body} strokeWidth={(1.8-i*.3)*bs} strokeLinecap="round" strokeOpacity={.7-i*.1}/>
      ))}
      <path d={wingFillD} fill={cc.out} fillOpacity=".75"/>
      <path d={wingFillD} fill={`url(#${gid}W)`}/>
      {[.25,.45,.65,.82].map((t,i)=>{
        const wy=bodyBy-bh*.45+t*(bh*1.1);
        const wx1=bx-bw*.25-t*7*bs;
        return <line key={i} x1={wx1} y1={wy} x2={wx1-6*bs} y2={wy+3*bs} stroke={cc.out} strokeWidth={.9} strokeOpacity=".55" strokeLinecap="round"/>;
      })}
      {/* FLUFFY: body-coloured circles with dark stroke form the bumpy silhouette */}
      {!isBaby&&isFluffy&&Array.from({length:15},(_,i)=>{
        const a=i*Math.PI*2/15;
        return<circle key={i} cx={bx+bw*Math.cos(a)} cy={bodyBy+bh*Math.sin(a)} r={4.5*bs} fill={cc.body} stroke={cc.out} strokeWidth={1}/>;
      })}
      {/* Body — fluffy fills centre (no outline ring, so bumpy edges stay visible) */}
      {isFluffy ? <>
        <ellipse cx={bx} cy={bodyBy} rx={bw} ry={bh} fill={cc.body}/>
        <ellipse cx={bx} cy={bodyBy} rx={bw} ry={bh} fill={`url(#${gid}B)`}/>
      </> : <>
        <ellipse cx={bx} cy={bodyBy} rx={bw} ry={bh} fill={cc.out}/>
        <ellipse cx={bx} cy={bodyBy} rx={bw-.8} ry={bh-.8} fill={cc.body}/>
        <ellipse cx={bx} cy={bodyBy} rx={bw-.8} ry={bh-.8} fill={isIridescent?`url(#${gid}I)`:`url(#${gid}B)`}/>
      </>}
      {/* Fluffy soft inner texture strokes */}
      {!isBaby&&isFluffy&&[[-8,-5],[0,-8],[8,-4],[-5,3],[5,3],[-2,7]].map((p,i)=>(
        <line key={i} x1={bx+p[0]*bs} y1={bodyBy+p[1]*bs} x2={bx+(p[0]+2)*bs} y2={bodyBy+(p[1]+3)*bs}
          stroke={cc.out} strokeWidth={1.2} strokeOpacity=".4" strokeLinecap="round"/>
      ))}
      {/* RUFFLED: small oval feathers — hidden for baby */}
      {!isBaby&&isRuffled&&[0,15,32, 80,95,115, 160,178,195, 255,270,290, 340].map((deg,i)=>{
        const a=deg*Math.PI/180;
        const ex=bx+bw*Math.cos(a), ey=bodyBy+bh*Math.sin(a);
        const mag=Math.sqrt(Math.pow(bh*Math.cos(a),2)+Math.pow(bw*Math.sin(a),2));
        const nx=bh*Math.cos(a)/mag, ny=bw*Math.sin(a)/mag;
        const fLen=[5,4,6, 4,6,5, 6,4,5, 5,6,4, 5][i]*bs;
        const fcx=ex+nx*fLen*.4, fcy=ey+ny*fLen*.4;
        const rot=Math.atan2(ny,nx)*180/Math.PI-90;
        return<ellipse key={i} cx={fcx} cy={fcy} rx={1.5*bs} ry={fLen*.55}
          transform={`rotate(${rot},${fcx},${fcy})`}
          fill={cc.body} stroke={cc.out} strokeWidth={.7} fillOpacity=".95"/>;
      })}
      <ellipse cx={bx+3} cy={bodyBy+2} rx={bw*.55} ry={bh*.55} fill="#fff" fillOpacity=".12"/>
      {/* Egg shell — drawn after body, before head, so head pokes out above crack */}
      {isBaby&&<>
        {/* Shadow behind egg */}
        <ellipse cx={bx} cy={eggCy+eggRy+3} rx={eggRx*.8} ry={2.2} fill="rgba(0,0,0,.15)"/>
        <path d={eggD} fill="#ede3c8" stroke="#b09868" strokeWidth={1.4}/>
        <path d={eggD} fill={`url(#${gid}E)`} fillOpacity=".55"/>
        {/* Small branch crack dropping from the lowest dip of the opening edge */}
        <path d={`M${bx+eggRx*.05},${eggCy-eggRy*.08} L${bx+eggRx*.02},${eggCy+eggRy*.10}`} fill="none" stroke="#8a7848" strokeWidth={.9} strokeOpacity=".72" strokeLinecap="round"/>
        {/* Lower crack — spans full width with branches */}
        <path d={`M${bx-eggRx*.88},${eggCy+eggRy*.22} L${bx-eggRx*.52},${eggCy+eggRy*.33} L${bx-eggRx*.18},${eggCy+eggRy*.18} L${bx+eggRx*.12},${eggCy+eggRy*.36} L${bx+eggRx*.48},${eggCy+eggRy*.24} L${bx+eggRx*.82},${eggCy+eggRy*.38} M${bx-eggRx*.52},${eggCy+eggRy*.33} L${bx-eggRx*.44},${eggCy+eggRy*.48} M${bx+eggRx*.12},${eggCy+eggRy*.36} L${bx+eggRx*.06},${eggCy+eggRy*.52} M${bx+eggRx*.48},${eggCy+eggRy*.24} L${bx+eggRx*.58},${eggCy+eggRy*.12}`} fill="none" stroke="#8a7848" strokeWidth={.85} strokeOpacity=".65" strokeLinecap="round" strokeLinejoin="round"/>
      </>}
      <circle cx={headCx} cy={headCy} r={headR} fill={cc.out}/>
      <circle cx={headCx} cy={headCy} r={headR-.8} fill={cc.body}/>
      <circle cx={headCx} cy={headCy} r={headR-.8} fill={`url(#${gid}H)`}/>
      {/* Crest — present only for crested plumage (trait1) */}
      {hasCrest&&<>
        <path d={`M${headCx-3},${headCy-headR+2} Q${headCx-1},${headCy-headR-crestH} ${headCx+2},${headCy-headR+2}`} fill={cc.pat} stroke={cc.out} strokeWidth={.8} strokeOpacity=".7"/>
        <path d={`M${headCx-1},${headCy-headR+3} Q${headCx+2},${headCy-headR-crestH*.8} ${headCx+4},${headCy-headR+3}`} fill={cc.pat} fillOpacity=".7" stroke={cc.out} strokeWidth={.7} strokeOpacity=".5"/>
        <path d={`M${headCx-5},${headCy-headR+4} Q${headCx-3},${headCy-headR-crestH*.6} ${headCx},${headCy-headR+3}`} fill={cc.pat} fillOpacity=".55" stroke={cc.out} strokeWidth={.6} strokeOpacity=".4"/>
      </>}
      <circle cx={eyeX} cy={eyeY} r={3.2} fill="#1a1a1a" fillOpacity=".3"/>
      <circle cx={eyeX} cy={eyeY} r={2.8} fill="#fff"/>
      <circle cx={eyeX} cy={eyeY} r={1.8} fill="#1a1a1a"/>
      <circle cx={eyeX} cy={eyeY} r={.85} fill="#1a1a1a"/>
      <circle cx={eyeX+.8} cy={eyeY-.8} r={.55} fill="#fff" fillOpacity=".9"/>
      <path d={beakD} fill="#e09828" stroke="#b07818" strokeWidth={.5}/>
      <path d={beakUpperD} fill="#f0b030" stroke="#c08820" strokeWidth={.4}/>
      <circle cx={headCx+headR+1} cy={headCy} r={.6} fill="#fff" fillOpacity=".4"/>
      {/* Legs — hidden for baby (inside egg) */}
      {!isBaby&&<>
        <line x1={bx-2} y1={bodyBy+bh-.5} x2={bx-4} y2={bodyBy+bh+7} stroke="#b07020" strokeWidth={1.8} strokeLinecap="round"/>
        <line x1={bx+4} y1={bodyBy+bh-.5} x2={bx+6} y2={bodyBy+bh+7} stroke="#b07020" strokeWidth={1.8} strokeLinecap="round"/>
        <line x1={bx-4} y1={bodyBy+bh+7} x2={bx-8} y2={bodyBy+bh+9} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
        <line x1={bx-4} y1={bodyBy+bh+7} x2={bx-4} y2={bodyBy+bh+11} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
        <line x1={bx-4} y1={bodyBy+bh+7} x2={bx} y2={bodyBy+bh+9} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
        <line x1={bx+6} y1={bodyBy+bh+7} x2={bx+2} y2={bodyBy+bh+9} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
        <line x1={bx+6} y1={bodyBy+bh+7} x2={bx+6} y2={bodyBy+bh+11} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
        <line x1={bx+6} y1={bodyBy+bh+7} x2={bx+10} y2={bodyBy+bh+9} stroke="#b07020" strokeWidth={1.2} strokeLinecap="round"/>
      </>}
      {raritySparkle(phenotype.rarity||0)}
    </svg>
  );
});

// ─── SVG: LIZARD ──────────────────────────────────────────────────────────────
// Visual inputs: phenotype.color (body, from genetics), phenotype.scales (trait1), phenotype.pattern (trait2), phenotype.ageSize
const LizardSVG = memo(function LizardSVG({phenotype, size=64}) {
  const cc = COLOR_MAPS.lizards[phenotype.color] || {body:'#2ecc71',out:'#1a9a50',pat:'#25b060'};
  const bs = ({baby:0.55, young:0.80, adult:1.0})[phenotype.ageSize] || 1.0;
  const isBaby = phenotype.ageSize === 'baby';
  const bx=34, by=38, bw=19*bs, bh=12*bs;
  const hRx=10*bs, hRy=6*bs;
  const headX=isBaby?(bx+(bx-bw+2))/2:bx-bw+2, headY=isBaby?by:by-2;
  // Fixed default tail
  const tailLen=14*bs;
  const tailD=`M${bx+bw-2},${by} Q${bx+bw+tailLen*.6},${by+5} ${bx+bw+tailLen},${by-3}`;
  // Scale trait (trait1) drives dorsal decoration and scale texture
  const scales = phenotype.scales || 'smooth';
  const spiny   = scales==='spiny';
  const armored = scales==='armored';
  const ridged  = scales==='ridged';
  const keeled  = scales==='keeled';
  const spineXs = [-10,-5,0,5,10,15].map(d=>bx+d*bs);
  // y of ellipse body surface at x — used to anchor dorsal features to the back curve
  const ellipseTopY = (x) => { const t=Math.max(0,1-Math.pow((x-bx)/bw,2)); return by-bh*Math.sqrt(t); };
  // Pattern trait (trait2) drives body markings
  const pat = phenotype.pattern || 'solid';
  const eyeX=headX+hRx*.35, eyeY=headY-hRy*.3;
  const leg1x=bx-bw*.45, leg2x=bx+bw*.3, legY=by+bh-1, legH=5;
  const scaleRows = scales==='smooth' ? [] :
    [-6,-2,2,6].map(dy=>[-12,-6,0,6,12].map(dx=>({dx,dy}))).flat();
  const gid='reptG';

  return (
    <svg width={size} height={size} viewBox="-2 0 84 64" style={{display:'block',flexShrink:0,overflow:'visible'}}>
      <defs>
        <linearGradient id={`${gid}B`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.3"/>
          <stop offset="60%" stopColor="#fff" stopOpacity="0.04"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.28"/>
        </linearGradient>
        <linearGradient id={`${gid}H`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.25"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.22"/>
        </linearGradient>
        <linearGradient id={`${gid}L`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.35"/>
          <stop offset="100%" stopColor="#fff" stopOpacity="0.05"/>
        </linearGradient>
        <linearGradient id={`${gid}E`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="#fff" stopOpacity="0.4"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0.3"/>
        </linearGradient>
      </defs>
      {/* Baby: head rendered BEHIND egg, then egg on top */}
      {isBaby&&(<>
        <ellipse cx={headX} cy={headY} rx={hRx} ry={hRy} fill={cc.out}/>
        <ellipse cx={headX} cy={headY} rx={hRx-.8} ry={hRy-.8} fill={cc.body}/>
        <ellipse cx={headX} cy={headY} rx={hRx-.8} ry={hRy-.8} fill={`url(#${gid}H)`}/>
        <ellipse cx={eyeX} cy={eyeY} rx={3.5} ry={3.5} fill="#fff"/>
        <ellipse cx={eyeX} cy={eyeY} rx={2.8} ry={3.2} fill="#daa520"/>
        <ellipse cx={eyeX} cy={eyeY} rx={2.8} ry={3.2} fill={`url(#${gid}E)`}/>
        <ellipse cx={eyeX} cy={eyeY} rx={.65} ry={2.8} fill="#0a0a0a"/>
        <ellipse cx={eyeX} cy={eyeY} rx={3.5} ry={3.5} fill="none" stroke={cc.out} strokeWidth={.7}/>
        <circle cx={eyeX+1.1} cy={eyeY-1.1} r={.75} fill="#fff" fillOpacity=".8"/>
        <line x1={headX-hRx} y1={headY+1} x2={headX-hRx-9*bs} y2={headY+1} stroke="#cc2222" strokeWidth={2} strokeLinecap="round"/>
        <line x1={headX-hRx-9*bs} y1={headY+1} x2={headX-hRx-14*bs} y2={headY-3} stroke="#cc2222" strokeWidth={1.4} strokeLinecap="round"/>
        <line x1={headX-hRx-9*bs} y1={headY+1} x2={headX-hRx-14*bs} y2={headY+5} stroke="#cc2222" strokeWidth={1.4} strokeLinecap="round"/>
      </>)}
      {/* Baby egg (on top of head) */}
      {isBaby&&(()=>{
        const eggRx=12*bs*1.2, eggRy=14*bs*1.2;
        const eggCy=by+4;
        const crackY=eggCy-eggRy*0.25;
        const eggD=`M${bx-eggRx},${crackY} L${bx-eggRx*0.6},${crackY-eggRy*0.15} L${bx-eggRx*0.25},${crackY+eggRy*0.05} L${bx+eggRx*0.1},${crackY-eggRy*0.1} L${bx+eggRx*0.4},${crackY+eggRy*0.08} L${bx+eggRx*0.7},${crackY-eggRy*0.12} L${bx+eggRx},${crackY} A${eggRx} ${eggRy} 0 1 1 ${bx-eggRx} ${crackY}Z`;
        return(<>
          <ellipse cx={bx} cy={eggCy+eggRy-3} rx={eggRx*0.8} ry={3} fill="rgba(60,36,12,.12)"/>
          <path d={eggD} fill="#e8dcc0" stroke="#b09868" strokeWidth={1.2}/>
        </>);
      })()}
      {/* Ground shadow (egg shadow for babies, body shadow for adults) */}
      {!isBaby&&<ellipse cx={32} cy={by+bh+10} rx={22*bs} ry={2.5} fill="rgba(0,0,0,.1)"/>}
      {/* Tail + Legs (hidden when baby is in egg) */}
      {!isBaby&&(<>
      {/* Tail */}
      <path d={tailD} fill="none" stroke={cc.out} strokeWidth={6*bs} strokeLinecap="round"/>
      <path d={tailD} fill="none" stroke={cc.body} strokeWidth={4*bs} strokeLinecap="round"/>
      <path d={tailD} fill="none" stroke={`url(#${gid}Body)`} strokeWidth={4*bs} strokeLinecap="round"/>
      {/* Tail pattern banding */}
      {(pat==='banded'||pat==='striped')&&<path d={tailD} fill="none" stroke={cc.pat} strokeWidth={2*bs} strokeLinecap="round" strokeOpacity=".45" strokeDasharray={`${3*bs} ${4*bs}`}/>}
      {/* Legs behind body */}
      <line x1={leg1x} y1={legY} x2={leg1x-4*bs} y2={legY+legH} stroke={cc.out} strokeWidth={3.5*bs} strokeLinecap="round"/>
      <line x1={leg2x} y1={legY} x2={leg2x+3*bs} y2={legY+legH} stroke={cc.out} strokeWidth={3.5*bs} strokeLinecap="round"/>
      {/* Leg sheen */}
      <line x1={leg1x} y1={legY} x2={leg1x-4*bs} y2={legY+legH} stroke="#fff" strokeWidth={1*bs} strokeLinecap="round" strokeOpacity=".18"/>
      <line x1={leg2x} y1={legY} x2={leg2x+3*bs} y2={legY+legH} stroke="#fff" strokeWidth={1*bs} strokeLinecap="round" strokeOpacity=".18"/>
      {/* Toe claws */}
      <line x1={leg1x-4*bs} y1={legY+legH} x2={leg1x-7*bs} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      <line x1={leg1x-4*bs} y1={legY+legH} x2={leg1x-3*bs} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      <line x1={leg1x-4*bs} y1={legY+legH} x2={leg1x} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      {/* Claw tips */}
      <line x1={leg1x-7*bs} y1={legY+legH+3} x2={leg1x-8.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      <line x1={leg1x-3*bs} y1={legY+legH+3} x2={leg1x-3.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      <line x1={leg1x} y1={legY+legH+3} x2={leg1x+.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      <line x1={leg2x+3*bs} y1={legY+legH} x2={leg2x} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      <line x1={leg2x+3*bs} y1={legY+legH} x2={leg2x+4*bs} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      <line x1={leg2x+3*bs} y1={legY+legH} x2={leg2x+8*bs} y2={legY+legH+3} stroke={cc.out} strokeWidth={1.8} strokeLinecap="round"/>
      <line x1={leg2x} y1={legY+legH+3} x2={leg2x-.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      <line x1={leg2x+4*bs} y1={legY+legH+3} x2={leg2x+4.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      <line x1={leg2x+8*bs} y1={legY+legH+3} x2={leg2x+9.5*bs} y2={legY+legH+5} stroke="#1a1a1a" strokeWidth={1.2} strokeLinecap="round"/>
      </>)}
      {/* Body + dorsal features (hidden when baby is in egg) */}
      {!isBaby&&(<>
      <ellipse cx={bx} cy={by} rx={bw} ry={bh} fill={cc.out}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh-.8} fill={cc.body}/>
      <ellipse cx={bx} cy={by} rx={bw-.8} ry={bh-.8} fill={`url(#${gid}Body)`}/>
      <ellipse cx={bx+2} cy={by+bh*.35} rx={bw*.65} ry={bh*.3} fill={`url(#${gid}L)`}/>
      {scaleRows.map((s,i)=>(
        <ellipse key={i} cx={bx+s.dx*bs} cy={by+s.dy*bs} rx={2.2*bs} ry={1.4*bs}
          fill="none" stroke={cc.out} strokeWidth={.6} strokeOpacity={.35}/>
      ))}
      {pat==='striped'&&[-8,0,8].map((dx,i)=>(
        <line key={i} x1={bx+dx*bs} y1={by-bh+2} x2={bx+dx*bs} y2={by+bh-2} stroke={cc.pat} strokeWidth={3*bs} strokeOpacity={.6} strokeLinecap="round"/>
      ))}
      {pat==='banded'&&[-5,0,5].map((dy,i)=>(
        <ellipse key={i} cx={bx} cy={by+dy*bs} rx={bw-1} ry={2.8*bs} fill={cc.pat} fillOpacity={.5}/>
      ))}
      {pat==='spotted'&&[[-10,-2],[0,-5],[10,-2],[-6,4],[6,4],[-3,0],[9,-4]].map((p,i)=>(
        <circle key={i} cx={bx+p[0]*bs} cy={by+p[1]*bs} r={3.2*bs} fill={cc.pat} fillOpacity={.58}/>
      ))}
      {pat==='mottled'&&[[-11,0],[0,-4],[10,2],[-5,5],[8,-6],[3,0]].map((p,i)=>(
        <ellipse key={i} cx={bx+p[0]*bs} cy={by+p[1]*bs} rx={5.5*bs} ry={3.8*bs} fill={cc.pat} fillOpacity={.45}/>
      ))}
      {ridged&&(()=>{
        const pts=Array.from({length:12},(_,i)=>{const x=(bx-bw+4)+i*(bw*2-8)/11;return`${i===0?'M':'L'}${x.toFixed(1)},${ellipseTopY(x).toFixed(1)}`;}).join(' ');
        return<>
          <path d={pts} fill="none" stroke={cc.out} strokeWidth={4.5} strokeOpacity={.65} strokeLinejoin="round"/>
          <path d={pts} fill="none" stroke="#fff" strokeWidth={1.5} strokeOpacity={.2} strokeLinejoin="round"/>
        </>;
      })()}
      {keeled&&(()=>{
        const n=12;
        const pts=Array.from({length:n},(_,i)=>{
          const t=i/(n-1);
          const x=(bx-bw+4)+t*(bw*2-8);
          const baseY=ellipseTopY(x);
          const finH=13*bs*Math.sin(Math.PI*t);
          return{x,baseY,tipY:baseY-finH};
        });
        const tipD=pts.map((p,i)=>`${i===0?'M':'L'}${p.x.toFixed(1)},${p.tipY.toFixed(1)}`).join(' ');
        const fullD=tipD+' '+[...pts].reverse().map(p=>`L${p.x.toFixed(1)},${p.baseY.toFixed(1)}`).join(' ')+'Z';
        return<>
          <path d={fullD} fill={cc.out} fillOpacity=".8" stroke={cc.out} strokeWidth={.5} strokeLinejoin="round"/>
          <path d={fullD} fill="#fff" fillOpacity=".1" strokeLinejoin="round"/>
          <path d={tipD} fill="none" stroke="#fff" strokeWidth={.8} strokeOpacity=".3" strokeLinejoin="round"/>
        </>;
      })()}
      {spiny&&spineXs.map((x,i)=>{
        const sh=(i%2===0?8:5)*bs; const by0=ellipseTopY(x);
        return <polygon key={i} points={`${x-3*bs},${by0} ${x},${by0-sh} ${x+3*bs},${by0}`} fill={cc.out} stroke={cc.out} strokeWidth={.5}/>;
      })}
      {spiny&&spineXs.map((x,i)=>{
        const sh=(i%2===0?8:5)*bs; const by0=ellipseTopY(x);
        return <polygon key={`si${i}`} points={`${x-2*bs},${by0} ${x},${by0-sh+1} ${x+2*bs},${by0}`} fill={cc.body} fillOpacity=".5"/>;
      })}
      {armored&&spineXs.map((x,i)=>{
        const ph=[4,6,7,6,5,4][i]*bs; const pw=5.5*bs; const by0=ellipseTopY(x);
        return<g key={i}>
          <path d={`M${x-pw},${by0} L${x-pw*.55},${by0-ph} Q${x},${by0-ph-.8*bs} ${x+pw*.55},${by0-ph} L${x+pw},${by0}`}
            fill={cc.out} stroke={cc.out} strokeWidth={.5} strokeLinejoin="round"/>
          <path d={`M${x-pw*.5},${by0-ph*.35} Q${x},${by0-ph*.75} ${x+pw*.5},${by0-ph*.35}`}
            fill="none" stroke="#fff" strokeWidth={.8} strokeOpacity=".22"/>
        </g>;
      })}
      </>)}
      {/* Head + tongue (adults only — baby head rendered before egg above) */}
      {!isBaby&&(<>
      <ellipse cx={headX} cy={headY} rx={hRx} ry={hRy} fill={cc.out}/>
      <ellipse cx={headX} cy={headY} rx={hRx-.8} ry={hRy-.8} fill={cc.body}/>
      <ellipse cx={headX} cy={headY} rx={hRx-.8} ry={hRy-.8} fill={`url(#${gid}H)`}/>
      {[-4,0,4].map((dx,i)=>(
        <ellipse key={i} cx={headX+dx*bs} cy={headY-hRy*.2} rx={1.8*bs} ry={1.1*bs} fill="none" stroke={cc.out} strokeWidth={.5} strokeOpacity=".4"/>
      ))}
      <ellipse cx={eyeX} cy={eyeY} rx={3.5} ry={3.5} fill="#fff"/>
      <ellipse cx={eyeX} cy={eyeY} rx={2.8} ry={3.2} fill="#daa520"/>
      <ellipse cx={eyeX} cy={eyeY} rx={2.8} ry={3.2} fill={`url(#${gid}E)`}/>
      <ellipse cx={eyeX} cy={eyeY} rx={.65} ry={2.8} fill="#0a0a0a"/>
      <ellipse cx={eyeX} cy={eyeY} rx={3.5} ry={3.5} fill="none" stroke={cc.out} strokeWidth={.7}/>
      <circle cx={eyeX+1.1} cy={eyeY-1.1} r={.75} fill="#fff" fillOpacity=".8"/>
      <line x1={headX-hRx} y1={headY+1} x2={headX-hRx-9*bs} y2={headY+1} stroke="#cc2222" strokeWidth={2} strokeLinecap="round"/>
      <line x1={headX-hRx-9*bs} y1={headY+1} x2={headX-hRx-14*bs} y2={headY-3} stroke="#cc2222" strokeWidth={1.4} strokeLinecap="round"/>
      <line x1={headX-hRx-9*bs} y1={headY+1} x2={headX-hRx-14*bs} y2={headY+5} stroke="#cc2222" strokeWidth={1.4} strokeLinecap="round"/>
      </>)}
      {raritySparkle(phenotype.rarity||0)}
    </svg>
  );
});

// ─── SVG WRAPPER ──────────────────────────────────────────────────────────────
function raritySparkle(rarity) {
  if(rarity<0.65)return null;
  const stars=rarity>0.85?3:rarity>0.75?2:1;
  return Array.from({length:stars},(_,i)=>(
    <text key={i} x={72-i*8} y={10} fontSize={8} textAnchor="middle" style={{pointerEvents:'none'}}>⭐</text>
  ));
}

function PetSVG({petType,phenotype,size=64}) {
  const props={phenotype,size};
  if(petType==='dogs') return <DogSVG {...props}/>;
  if(petType==='birds') return <BirdSVG {...props}/>;
  if(petType==='lizards') return <LizardSVG {...props}/>;
  return <CatSVG {...props}/>;
}

// Builds a phenotype from only the 4 visual inputs: species, trait1, trait2, and age.
// Body color (displayColor) is genetic and kept as a 5th implicit input for visual identity.
function PetMini({pet, petType, size=48}) {
  const species = pet.species || petType || 'cats';
  const p = pet.phenotype || {};
  const age = pet.state?.age ?? 0;
  // baby = first week of life; young = under breeding age; adult = full grown
  const ageSize = age < 1 ? 'baby' : age < GAME_CONFIG.BREED_MIN_AGE_WEEKS ? 'young' : 'adult';
  // Body color from genetics; birds use their color trait (trait2) directly
  const bodyColor = pet.displayColor || (species==='birds' ? p.color : null) || 'orange';
  const ph = {
    color:   bodyColor,       // body color (genetic)
    fur:     p.fur,           // cats trait1 (short/medium/long)
    eyes:    p.eyes,          // cats trait2 (green/blue/yellow/amber/copper)
    size:    p.size,          // dogs trait1 (small/medium/large)
    breed:   p.breed,         // dogs trait2 (labrador/poodle/etc)
    plumage: p.plumage,       // birds trait1 (sleek/fluffy/crested/ruffled/iridescent)
    // birds trait2 = color, already in bodyColor above
    scales:  p.scales,        // lizards trait1 (smooth/ridged/keeled/spiny/armored)
    pattern: p.pattern,       // lizards trait2 (solid/striped/banded/spotted/mottled)
    ageSize,                  // baby/young/adult → size scale
    rarity:  pet.stats?.rarity || 0,
  };
  return <PetSVG petType={species} phenotype={ph} size={size}/>;
}

// ─── TRAIT SUMMARY ────────────────────────────────────────────────────────────
function traitRarityTag(value, key, tt) {
  const al=tt[key]; if(!al) return '';
  // If all weights are equal, no rarity distinction
  const weights=al.map(a=>a.weight);
  if(weights.every(w=>w===weights[0])) return 'common';
  const sorted=[...al].sort((a,b)=>a.weight-b.weight);
  const idx=sorted.findIndex(a=>a.value===value);
  if(idx<0) return '';
  return idx===0?'rare':idx<Math.ceil(sorted.length/3)?'uncommon':'common';
}
// Returns the full human-readable display name for a pet trait value.
// E.g. traitFullLabel('fur','short') → 'Short hair', traitFullLabel('pattern','mottled') → 'Mottled pattern'
function traitFullLabel(key, value) {
  const suffix = {eyes:'eyes', fur:'hair', scales:'skin', pattern:'pattern', plumage:'plumage'}[key] || '';
  return capitalize(value) + (suffix ? ' ' + suffix : '');
}

function traitSummary(pet, hideIcon) {
  if(!pet) return '';
  const sp=SPECIES[pet.species]||SPECIES.cats;
  const tt=TRAIT_TABLES[pet.species]||TT_CATS;
  const parts=Object.entries(pet.phenotype).map(([k,v])=>{
    const label=traitRarityTag(v,k,tt);
    return traitFullLabel(k,v)+(label&&label!=='common'?` (${label})`:'');
  });
  return hideIcon?parts.join(' · '):`${sp.icon} ${parts.join(' · ')}`;
}
function rarityLabel(r) {
  if(r>0.65) return{text:'Legendary',cls:'by'};
  if(r>0.45) return{text:'Rare',cls:'bu'};
  if(r>0.25) return{text:'Uncommon',cls:'bg'};
  return{text:'Common',cls:'bk'};
}

// ─── GENDER ICON ─────────────────────────────────────────────────────────────
function GenderIcon({gender, size=14}) {
  const isFemale=gender==='female';
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" style={{verticalAlign:'middle',flexShrink:0}}>
      {isFemale?(
        <g fill="none" stroke="#e06080" strokeWidth="2.5" strokeLinecap="round">
          <circle cx="12" cy="9" r="6"/>
          <line x1="12" y1="15" x2="12" y2="23"/>
          <line x1="9" y1="19" x2="15" y2="19"/>
        </g>
      ):(
        <g fill="none" stroke="#5b8ecf" strokeWidth="2.5" strokeLinecap="round">
          <circle cx="10" cy="14" r="6"/>
          <line x1="14.5" y1="9.5" x2="21" y2="3"/>
          <line x1="15" y1="3" x2="21" y2="3"/>
          <line x1="21" y1="3" x2="21" y2="9"/>
        </g>
      )}
    </svg>
  );
}

// ─── TUTORIAL TIP ─────────────────────────────────────────────────────────────
function TutorialTip({text, position, onDismiss, onSkip}) {
  const style = position === 'top' ? {bottom:70,left:'50%',transform:'translateX(-50%)'}
    : position === 'top-right' ? {top:60,right:10}
    : {bottom:70,left:'50%',transform:'translateX(-50%)'};
  return (
    <div className="tutorial-tip" style={{...style,paddingRight:30}}>
      <button onClick={onDismiss} style={{position:'absolute',top:2,right:2,background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--muted)',fontWeight:700,lineHeight:1,padding:'4px 8px',touchAction:'manipulation',minWidth:36,minHeight:36,display:'flex',alignItems:'center',justifyContent:'center'}} aria-label="Close">&times;</button>
      <div className="tt-text" dangerouslySetInnerHTML={{__html:text}}/>
      <span className="tt-skip" onClick={onSkip}>Skip tutorial</span>
    </div>
  );
}

// ─── MODAL ────────────────────────────────────────────────────────────────────
function Modal({onClose, children, style, className}) {
  useEffect(()=>{
    const handler=e=>{if(e.key==='Escape')onClose();};
    window.addEventListener('keydown',handler);
    return ()=>window.removeEventListener('keydown',handler);
  },[onClose]);
  return (
    <div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
      <div className={`modal${className?' '+className:''}`} onClick={e=>e.stopPropagation()} style={style}>
        <button onClick={onClose} style={{position:'absolute',top:4,right:4,background:'none',border:'none',fontSize:26,cursor:'pointer',color:'var(--muted)',fontWeight:700,lineHeight:1,padding:'6px 10px',touchAction:'manipulation',minWidth:44,minHeight:44,display:'flex',alignItems:'center',justifyContent:'center'}} aria-label="Close">&times;</button>
        {children}
      </div>
    </div>
  );
}

// ─── LETTER MODAL ─────────────────────────────────────────────────────────────
function EmptyState({icon, title, hint}) {
  return (
    <div className="empty-state">
      <div className="empty-state-icon">{icon}</div>
      <div className="empty-state-title">{title}</div>
      {hint&&<div className="empty-state-hint">{hint}</div>}
    </div>
  );
}

function NpcVisitModal({state, dispatch}) {
  const visit=state.npcVisit; if(!visit) return null;
  const npc=NPCS[visit.npcId]; if(!npc) return null;
  const npcState=(state.npcs||{})[visit.npcId]||{affinity:0};
  const bestInCat=(cat)=>{
    const ids=(state.activeMenuIds||{})[cat]||[];
    const items=(state.allMenuItems||[]).filter(i=>ids.includes(i.id));
    return items.sort((a,b)=>(b.rarity||0)-(a.rarity||0))[0];
  };
  const giftBtn=(cat,icon,label)=>{
    const item=bestInCat(cat);
    const cost=item?Math.round((item.cost||5)*1.5):null;
    const canAfford=item&&state.money>=cost;
    return (
      <button
        className="btn btn-sm btn-p"
        disabled={!canAfford}
        style={{flex:1,opacity:canAfford?1:.5}}
        onClick={()=>dispatch({type:A.NPC_VISIT_RESPONSE,gift:cat})}
        title={item?`${item.name} (${cat}) — $${cost}`:`No ${cat} on menu`}>
        {icon}<br/>{label}{item&&<><br/><span style={{fontSize:10}}>${cost}</span></>}
      </button>
    );
  };
  const affinityBar=()=>{
    const pct=(npcState.affinity/MAX_AFFINITY)*100;
    return (
      <div style={{marginTop:10}}>
        <div style={{fontSize:11,color:'var(--muted)',marginBottom:3}}>Affinity: {npcState.affinity}/{MAX_AFFINITY} {(state.npcBuffs||{})[visit.npcId]?`· ${npc.perkLabel} unlocked`:''}</div>
        <div style={{height:6,background:'var(--border)',borderRadius:3,overflow:'hidden'}}>
          <div style={{width:`${pct}%`,height:'100%',background:'var(--gold)',transition:'width .4s'}}/>
        </div>
      </div>
    );
  };
  return (
    <Modal onClose={()=>dispatch({type:A.NPC_VISIT_RESPONSE,gift:null})} style={{maxWidth:360}}>
      <div style={{fontSize:42,textAlign:'center'}}>{npc.icon}</div>
      <div style={{fontWeight:800,fontSize:16,marginTop:4,textAlign:'center'}}>{npc.name} stopped by</div>
      <div className="muted" style={{fontSize:13,fontStyle:'italic',margin:'10px 0',textAlign:'center'}}>&ldquo;{npc.greeting}&rdquo;</div>
      {affinityBar()}
      {npcState.affinity>=3&&npc.preferredGift&&(
        <div style={{fontSize:11,color:'var(--blue,#4A90D9)',marginTop:6,textAlign:'center'}}>{npc.name} seems to particularly enjoy {npc.preferredGift==='drinks'?'a good drink':npc.preferredGift==='food'?'good food':'fine desserts'}.</div>
      )}
      <div style={{fontSize:12,marginTop:14,marginBottom:6,fontWeight:700}}>Offer something from the menu?</div>
      <div style={{display:'flex',gap:6}}>
        {giftBtn('drinks','☕','Drink')}
        {giftBtn('food','🥐','Food')}
        {giftBtn('desserts','🍰','Dessert')}
      </div>
      <button className="btn btn-o" style={{width:'100%',marginTop:10}} onClick={()=>dispatch({type:A.NPC_VISIT_RESPONSE,gift:null})}>Just chat (+1 affinity)</button>
    </Modal>
  );
}

function LetterModal({letter, state, onClose}) {
  const paragraphs = letter.body(state);
  return (
    <Modal onClose={onClose} className="letter-modal" style={{maxWidth:480,maxHeight:'90dvh',overflowY:'auto',background:'#fdf8ee',border:'2px solid #c9b07a',boxShadow:'0 6px 32px rgba(120,80,20,0.18)'}}>
      <div className="letter-modal-inner" style={{padding:'28px 28px 20px',fontFamily:'Georgia,Palatino,serif'}}>
        <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:14}}>
          <span style={{fontSize:26}}>{letter.icon}</span>
          <div>
            <div className="letter-title" style={{fontFamily:'Fraunces,serif',fontWeight:800,fontSize:17,color:'#6b4a1a',lineHeight:1.2}}>{letter.title}</div>
            <div className="letter-from" style={{fontSize:12,color:'#9a7a40',marginTop:2,textAlign:'left'}}>From: {letter.from}</div>
          </div>
        </div>
        <div className="letter-divider" style={{borderTop:'1px solid #d9c08a',marginBottom:16}}/>
        <div className="letter-body">
          {paragraphs.map((p,i)=>(
            <p key={i} style={{fontStyle:'italic',fontSize:14.5,lineHeight:1.75,color:'#3d2b0e',margin:'0 0 12px 0'}}>{p}</p>
          ))}
        </div>
        <div style={{textAlign:'right',marginTop:8}}>
          <button
            onClick={onClose}
            style={{background:'var(--gold)',color:'#fff',border:'none',borderRadius:8,padding:'9px 22px',fontFamily:'Nunito,sans-serif',fontWeight:700,fontSize:14,cursor:'pointer',touchAction:'manipulation'}}
          >Close</button>
        </div>
      </div>
    </Modal>
  );
}

// ─── COLLAPSIBLE ──────────────────────────────────────────────────────────────
function Collapsible({title, badge, children, defaultOpen=false}) {
  const [open,setOpen]=useState(defaultOpen);
  return (
    <div>
      <div className={`coll-hdr${open?' open':''}`} onClick={()=>setOpen(!open)}>
        <span>{(()=>{const m=title.match(/^(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*/u);return m?(<><span style={{fontSize:18,verticalAlign:'middle',marginRight:4}}>{m[0].trim()}</span>{title.slice(m[0].length)}</>):title;})()}{badge&&<span className={`badge ${badge.cls}`}> {badge.text}</span>}</span>
        <span>{open?'▲':'▼'}</span>
      </div>
      {open&&<div className="coll-body">{children}</div>}
    </div>
  );
}

// ─── PET TILE ─────────────────────────────────────────────────────────────────
function PetTile({pet, petType, selected, onClick, actions, onRename, showGender, dimmed, isBreeding, hidePersonality, hideUpkeep, showJudgedTraits, hideSpeciesIcon}) {
  const [renaming,setRenaming]=useState(false);
  const [nameVal,setNameVal]=useState(pet.name);
  const [persOpen,setPersOpen]=useState(false);
  const rl=rarityLabel(pet.stats.rarity);
  const sp=SPECIES[pet.species]||SPECIES[petType]||SPECIES.cats;
  const upkeep=petUpkeepCost(pet);
  const g=petGender(pet);
  return (
    <div className={`cat-tile${selected?' sel':''}`}
      style={{...(dimmed?{opacity:.35,pointerEvents:'none'}:undefined),position:'relative'}}
      onClick={onClick}>
      {pet.wish&&<span title={pet.wish.label} style={{position:'absolute',top:4,right:4,fontSize:14,lineHeight:1,pointerEvents:'none'}}>💭</span>}
      <PetMini pet={pet} petType={pet.species||petType} size={56}/>
      {renaming ? (
        <input value={nameVal} onChange={e=>setNameVal(e.target.value)} autoFocus
          style={{width:'90%',border:'1px solid var(--orange)',borderRadius:6,padding:'2px 4px',fontSize:13,fontWeight:800,textAlign:'center'}}
          onBlur={()=>{if(nameVal.trim())onRename(nameVal.trim());setRenaming(false);}}
          onKeyDown={e=>{if(e.key==='Enter')e.target.blur();if(e.key==='Escape'){setNameVal(pet.name);setRenaming(false);}}}
          onClick={e=>e.stopPropagation()}/>
      ) : (
        <div className="nm" style={{display:'flex',alignItems:'center',gap:3}}>
          {showGender&&<GenderIcon gender={g} size={14}/>}
          {pet.name}
          {onRename&&<span style={{fontSize:10,cursor:'pointer',opacity:.5}} onClick={e=>{e.stopPropagation();setRenaming(true);setNameVal(pet.name);}}>✏️</span>}
        </div>
      )}
      <div className="tr">{showJudgedTraits?showJudgedTraits.map(t=>{
        if(t==='rarity')return rarityLabel(pet.stats.rarity).text;
        if(t==='generation')return'Gen '+pet.state.generation;
        if(t==='personality')return Pers.emoji(pet.personality.primary)+' '+capitalize(pet.personality.primary);
        if(pet.phenotype[t])return traitFullLabel(t,pet.phenotype[t]);
        return null;
      }).filter(Boolean).join(' · '):traitSummary(pet, hideSpeciesIcon)}</div>
      <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:3,marginTop:3}}>
        <span className={`badge ${rl.cls}`}>{rl.text}</span>
        {pet.state.isKitten&&<span className="badge bo">{sp.baby}</span>}
        {pet.state.isRetired&&<span className="badge bk">Senior</span>}
        {isBreeding&&<span className="badge bo">💕 Breeding</span>}
        {(pet.state.pregnant||0)>0&&<span className="badge bo">🤰 Pregnant: {pet.state.pregnant}w</span>}
        {(pet.state.nursing||0)>0&&<span className="badge bu">🍼 Nursing</span>}
        {pet.state.breedCooldown>0&&<span className="badge bk">Resting: {pet.state.breedCooldown}w</span>}
        {!hidePersonality&&<span className={`badge bg`} style={{cursor:'pointer'}}
          onClick={e=>{e.stopPropagation();setPersOpen(o=>!o);}}>
          {Pers.emoji(pet.personality.primary)} {capitalize(pet.personality.primary)}
        </span>}
      </div>
      {!hidePersonality&&persOpen&&PERS_DESC[pet.personality.primary]&&(
        <div onClick={e=>e.stopPropagation()}
          style={{fontSize:11,color:'var(--muted)',background:'var(--wall)',border:'1px solid var(--border)',borderRadius:6,padding:'4px 8px',marginTop:4,textAlign:'center'}}>
          {PERS_DESC[pet.personality.primary]}
        </div>
      )}
      {!pet.state.isKitten&&!hideUpkeep&&<div className="muted" style={{fontSize:10,marginTop:2}}>${upkeep}/week upkeep</div>}
      <div className="rbar"><div className="rfill" style={{width:`${pet.stats.rarity*100}%`}}/></div>
      {actions&&<div style={{display:'flex',gap:4,marginTop:4}} onClick={e=>e.stopPropagation()}>{actions}</div>}
    </div>
  );
}

// ─── PET SECTION ──────────────────────────────────────────────────────────────
function PetSection({icon, title, count, countLabel, forceShow=false, children, defaultOpen=true}) {
  const [open,setOpen]=useState(defaultOpen);
  if(count===0&&!forceShow) return null;
  return (
    <div className="pet-section">
      <div className={`pet-section-hdr${open?' open':''}`} onClick={()=>setOpen(!open)}>
        <span><span style={{fontSize:18,verticalAlign:'middle',marginRight:4}}>{icon}</span>{title} <span className="muted" style={{fontWeight:600,fontSize:12}}>({countLabel??count})</span></span>
        <span style={{fontSize:11,color:'var(--muted)'}}>{open?'▲':'▼'}</span>
      </div>
      {open&&<div className="pet-section-body">
        {children}
        <div style={{textAlign:'center',padding:'4px 0'}}>
          <button className="btn btn-xs btn-o" onClick={()=>setOpen(false)} style={{fontSize:11,opacity:0.7}}>▲ Collapse</button>
        </div>
      </div>}
    </div>
  );
}

// ─── COFFEE CUP SVG ───────────────────────────────────────────────────────────
function CoffeeCupSVG({size=24, style={}}) {
  return (
    <svg width={size} height={size} viewBox="2 3 26 24" fill="none" overflow="visible" xmlns="http://www.w3.org/2000/svg" style={{display:'inline-block',verticalAlign:'middle',...style}}>
      <defs>
        <radialGradient id="ccCup" cx="36%" cy="42%" r="64%">
          <stop offset="0%" stopColor="#ffffff"/>
          <stop offset="52%" stopColor="#ededed"/>
          <stop offset="100%" stopColor="#c4c4c4"/>
        </radialGradient>
        <radialGradient id="ccSau" cx="40%" cy="40%" r="60%">
          <stop offset="0%" stopColor="#f6f6f6"/>
          <stop offset="100%" stopColor="#cccccc"/>
        </radialGradient>
        <radialGradient id="ccCof" cx="36%" cy="36%" r="64%">
          <stop offset="0%" stopColor="#2e1306"/>
          <stop offset="100%" stopColor="#0e0400"/>
        </radialGradient>
        <radialGradient id="ccInt" cx="50%" cy="55%" r="55%">
          <stop offset="0%" stopColor="#f2f2f2"/>
          <stop offset="100%" stopColor="#d4d4d4"/>
        </radialGradient>
        {/* Clip path = inner rim opening — keeps coffee from bleeding onto cup walls */}
        <clipPath id="ccClip">
          <ellipse cx="15" cy="12" rx="7.4" ry="2.2"/>
        </clipPath>
      </defs>
      {/* Saucer */}
      <ellipse cx="15" cy="26.5" rx="13.5" ry="3.2" fill="url(#ccSau)"/>
      <ellipse cx="15" cy="26.5" rx="13.5" ry="3.2" fill="none" stroke="#b8b8b8" strokeWidth="0.4"/>
      {/* Cup shadow on saucer */}
      <ellipse cx="14.5" cy="25" rx="7.5" ry="1.6" fill="#b0b0b0" opacity="0.3"/>
      {/* Cup body */}
      <path d="M6.5 11.5 Q5.2 19 7.5 23.5 Q10.5 25.8 15 25.8 Q19.5 25.8 22.5 23.5 Q24.8 19 23.5 11.5 Z" fill="url(#ccCup)"/>
      <path d="M6.5 11.5 Q5.2 19 7.5 23.5 Q10.5 25.8 15 25.8 Q19.5 25.8 22.5 23.5 Q24.8 19 23.5 11.5" fill="none" stroke="#c0c0c0" strokeWidth="0.45"/>
      {/* Rim top face */}
      <ellipse cx="15" cy="11.5" rx="8.5" ry="2.8" fill="url(#ccCup)"/>
      <ellipse cx="15" cy="11.5" rx="8.5" ry="2.8" fill="none" stroke="#cacaca" strokeWidth="0.55"/>
      {/* Interior + coffee clipped to inner rim — coffee can never bleed outside opening */}
      <g clipPath="url(#ccClip)">
        <ellipse cx="15" cy="12" rx="7.4" ry="2.2" fill="url(#ccInt)"/>
        <ellipse cx="15" cy="13.8" rx="6.5" ry="2.2" fill="url(#ccCof)"/>
        <ellipse cx="12.8" cy="13.2" rx="3" ry="1" fill="#3a1808" opacity="0.5"/>
      </g>
      {/* Paw print logo on cup front */}
      <circle cx="15.2" cy="20.2" r="1.5" fill="rgba(140,100,60,0.38)"/>
      <circle cx="13.4" cy="18.2" r="0.7" fill="rgba(140,100,60,0.35)"/>
      <circle cx="14.6" cy="17.5" r="0.7" fill="rgba(140,100,60,0.35)"/>
      <circle cx="16.0" cy="17.5" r="0.7" fill="rgba(140,100,60,0.35)"/>
      <circle cx="17.2" cy="18.3" r="0.7" fill="rgba(140,100,60,0.35)"/>
      {/* Handle outer */}
      <path d="M22.8 15 Q29.5 15 29.5 19.8 Q29.5 24.5 22.8 24.5" stroke="#d2d2d2" strokeWidth="3.2" strokeLinecap="round" fill="none"/>
      {/* Handle inner highlight */}
      <path d="M22.8 15 Q28 15 28 19.8 Q28 24.5 22.8 24.5" stroke="white" strokeWidth="1.4" strokeLinecap="round" fill="none"/>
    </svg>
  );
}

// ─── PET ROW ──────────────────────────────────────────────────────────────────
function PetRow({pet, currentTrend, statusLabel, actions, onRename, showJudgedTraits, hideUpkeepAge, showRarityTags=false, onClick, rowStyle}) {
  const [renaming,setRenaming]=useState(false);
  const [nameVal,setNameVal]=useState(pet.name);
  const rl=rarityLabel(pet.stats.rarity);
  const g=petGender(pet);
  const sp=SPECIES[pet.species]||SPECIES.cats;
  const isTrending=Trend.score(pet,currentTrend)>0;
  const persEntry=PERS_TABLE.find(p=>p.value===pet.personality.primary);
  const upkeep=petUpkeepCost(pet);
  const ageYrs=pet.state.age/48;
  const ageLabel=ageYrs<1?`Age: ${Math.max(1,Math.floor(pet.state.age/4))} months`:`Age: ${Math.floor(ageYrs)} years`;
  const tt=TRAIT_TABLES[pet.species]||TT_CATS;
  const traitPart=showJudgedTraits
    ?showJudgedTraits.map(t=>{
        if(t==='rarity')return null; // rarity badge shown in name row
        if(t==='generation')return'Gen '+pet.state.generation;
        if(t==='personality')return Pers.emoji(pet.personality.primary)+' '+capitalize(pet.personality.primary);
        if(t==='displayColor')return pet.displayColor?'Color: '+capitalize(pet.displayColor):null;
        if(pet.phenotype[t]){const tag=traitRarityTag(pet.phenotype[t],t,tt);return traitFullLabel(t,pet.phenotype[t])+(tag&&tag!=='common'?` (${tag})`:'');}
        return null;
      }).filter(Boolean).join(' · ')
    :Object.entries(pet.phenotype).map(([k,v])=>{const tag=showRarityTags?traitRarityTag(v,k,tt):'';return traitFullLabel(k,v)+(tag&&tag!=='common'?` (${tag})`:'');}).join(' · ');
  const persPart=`${persEntry?.e||''} ${capitalize(pet.personality.primary)}`;
  const showPersBonus=showJudgedTraits&&(persEntry?.sb||0)>0;
  const upkeepAgePart=hideUpkeepAge?'':` · $${upkeep}/w · ${ageLabel}`;

  return (
    <div className="pet-row" onClick={onClick} style={{...rowStyle,...(onClick?{cursor:'pointer'}:{})}}>
      <PetMini pet={pet} petType={pet.species||'cats'} size={48}/>
      <div className="pet-row-info">
        <div className="pet-row-name">
          <GenderIcon gender={g} size={14}/>
          {renaming?(
            <input value={nameVal} onChange={e=>setNameVal(e.target.value)} autoFocus
              style={{border:'1px solid var(--orange)',borderRadius:5,padding:'1px 4px',fontSize:12,fontWeight:800,width:80}}
              onBlur={()=>{if(nameVal.trim()&&onRename)onRename(nameVal.trim());setRenaming(false);}}
              onKeyDown={e=>{if(e.key==='Enter')e.target.blur();if(e.key==='Escape'){setNameVal(pet.name);setRenaming(false);}}}
              onClick={e=>e.stopPropagation()}/>
          ):(
            <span style={{cursor:onRename?'pointer':undefined}}
              onClick={e=>{if(onRename){e.stopPropagation();setRenaming(true);setNameVal(pet.name);}}}>
              {pet.name}
            </span>
          )}
          <span className={`badge ${rl.cls}`} style={{fontSize:10,padding:'1px 5px'}}>{rl.text}</span>
          {pet.marking&&<span style={{fontSize:12}}>{pet.marking==='star'?'⭐':pet.marking==='heart'?'❤️':'👍'}</span>}
          {isTrending&&<span title="Trending">🔥</span>}
        </div>
        <div className="pet-row-meta">{traitPart}{!showJudgedTraits&&<span title={PERS_DESC[pet.personality.primary]}> · {persPart}</span>}{showPersBonus&&!showJudgedTraits?.includes('personality')&&<span style={{color:'var(--green)',fontWeight:700}} title={PERS_DESC[persEntry?.value]}> · {persEntry.e} {capitalize(pet.personality.primary)}</span>}{upkeepAgePart}</div>
        {statusLabel&&<div className="pet-row-status" style={{marginTop:2}}>{statusLabel}</div>}
      </div>
      {actions&&<div className="pet-row-actions">{actions}</div>}
    </div>
  );
}

// ─── PETS PANEL ───────────────────────────────────────────────────────────────
function PetsPanel({state, dispatch, onSelectPet}) {
  const toast = useToast();
  const [speciesFilter,setSpeciesFilter]=useState(null);
  const [trendFilter,setTrendFilter]=useState(false);
  const [showPersInfo,setShowPersInfo]=useState(false);
  const [confirmSell,setConfirmSell]=useState(null);
  const [sortMode,setSortMode]=useState('rarity'); // 'rarity'|'upkeep'|'species'|'name'|'age'
  const [markingFilter,setMarkingFilter]=useState(null);
  const {pets,assignedPetIds,cafeLevel,currentTrend,wildPetPool,money,breedingQueue,showEntries,currentShow,week}=state;
  const cap=UPGRADES[cafeLevel];
  const pregnantCount=pets.filter(p=>(p.state.pregnant||0)>0).length;
  const effectivePetCount=pets.length+pregnantCount+breedingQueue.length;

  // Compute show week info
  const weeksUntilJudging=currentShow?Time.weeksUntilJudging(week):0;

  // Classification helpers (use shared isPetBusy/petBusyStatus from game logic)
  const isBusy=(pet)=>isPetBusy(pet,breedingQueue,showEntries,weeksUntilJudging);
  const busyStatus=(pet)=>petBusyStatus(pet,breedingQueue,showEntries,weeksUntilJudging);

  // Apply species + trend + marking filter
  const applyFilter=(list)=>{
    let r=list;
    if(speciesFilter) r=r.filter(p=>p.species===speciesFilter);
    if(trendFilter&&currentTrend) r=r.filter(p=>Trend.score(p,currentTrend)>0);
    if(markingFilter) r=r.filter(p=>p.marking===markingFilter);
    return r;
  };

  const sortPets=(list)=>{
    if(sortMode==='upkeep') return [...list].sort((a,b)=>petUpkeepCost(b)-petUpkeepCost(a));
    if(sortMode==='species') return [...list].sort((a,b)=>a.species.localeCompare(b.species));
    if(sortMode==='name') return [...list].sort((a,b)=>a.name.localeCompare(b.name));
    if(sortMode==='age') return [...list].sort((a,b)=>(b.state.age||0)-(a.state.age||0));
    return [...list].sort((a,b)=>b.stats.rarity-a.stats.rarity); // default: rarity
  };

  const cafePets=sortPets(applyFilter(pets.filter(p=>assignedPetIds.includes(p.id))));
  const busyPets=sortPets(applyFilter(pets.filter(p=>!assignedPetIds.includes(p.id)&&!p.state.isKitten&&isBusy(p))));
  const availPets=sortPets(applyFilter(pets.filter(p=>!assignedPetIds.includes(p.id)&&!p.state.isKitten&&!isBusy(p))));
  const babyPets=sortPets(applyFilter(pets.filter(p=>p.state.isKitten)));

  const SPECIES_BTNS=[['cats','🐱'],['dogs','🐕'],['birds','🐦'],['lizards','🦎']];

  return (
    <div className="panel">
      {/* Trend */}
      {currentTrend&&(
        <div className="card trend-news-card" style={{background:'#fff8e6',border:'1px solid #e8d98a'}}>
          <h3 className="sub">🔥 This Month's Trend</h3>
          <div style={{fontSize:13,fontWeight:700}}>{currentTrend.description}</div>
        </div>
      )}

      {/* Header */}
      <div className="between" style={{marginBottom:4}}>
        <h2 className="sh">Pets ({pets.length}/{getMaxPets(state)})</h2>
        <div style={{display:'flex',gap:4,alignItems:'center'}}>
          <MilestoneButton state={state} pageFilter="pets" label="🏅" autoOpen={state.openMilestonesPanel==='pets'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/>
          <button className="btn btn-sm btn-o" onClick={()=>setShowPersInfo(v=>!v)}>ℹ️ Traits</button>
        </div>
      </div>
      {/* Filter row */}
      <div style={{display:'flex',flexWrap:'wrap',gap:6,marginBottom:4,alignItems:'center',justifyContent:'flex-end'}}>
        <span style={{fontSize:11,fontWeight:700,color:'var(--muted)',textTransform:'uppercase',letterSpacing:'0.04em',whiteSpace:'nowrap'}}>Filter</span>
        {currentTrend&&<button className={`btn btn-xs ${trendFilter?'btn-p':'btn-o'}`}
          onClick={()=>setTrendFilter(f=>!f)} title="Trending pets only">🔥 Trending</button>}
        {SPECIES_BTNS.map(([sp,icon])=>(
          <button key={sp} className={`btn btn-xs ${speciesFilter===sp?'btn-p':'btn-o'}`}
            onClick={()=>setSpeciesFilter(speciesFilter===sp?null:sp)}>{icon} {capitalize(sp)}</button>
        ))}
        {[['star','⭐'],['heart','❤️'],['thumbsup','👍']].map(([mk,icon])=>(
          <button key={mk} className={`btn btn-xs ${markingFilter===mk?'btn-p':'btn-o'}`}
            onClick={()=>setMarkingFilter(markingFilter===mk?null:mk)}>{icon}</button>
        ))}
      </div>
      {/* Sort row */}
      <div style={{display:'flex',flexWrap:'wrap',gap:6,marginBottom:6,alignItems:'center',justifyContent:'flex-end'}}>
        <span style={{fontSize:11,fontWeight:700,color:'var(--muted)',textTransform:'uppercase',letterSpacing:'0.04em',whiteSpace:'nowrap'}}>Sort</span>
        {[['rarity','⭐','Rarity'],['species','🐾','Species'],['name','🔤','Name'],['age','🎂','Age']].map(([v,icon,label])=>(
          <button key={v} className={`btn btn-xs ${sortMode===v?'btn-p':'btn-o'}`} onClick={()=>setSortMode(v)}>{icon} {label}</button>
        ))}
      </div>

      {showPersInfo&&(
        <div className="card" style={{fontSize:12}}>
          <h3 className="sub" style={{marginBottom:6}}>Personality Traits</h3>
          <div style={{display:'flex',flexDirection:'column',gap:5}}>
            {PERS_TABLE.map(p=>(
              <div key={p.value}><strong>{p.e} {capitalize(p.value)}</strong> — {PERS_DESC[p.value]}</div>
            ))}
          </div>
        </div>
      )}

      {state.lastSoldPet&&(
        <div className="warn" style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
          <span>Sold {state.lastSoldPet.name} for {$(state.lastSoldPrice)}</span>
          <button className="btn btn-xs btn-o" onClick={()=>dispatch({type:A.UNDO_SELL})}>Undo</button>
        </div>
      )}

      {/* Adoption Pool */}
      {(()=>{
        const filteredWild=wildPetPool
          .filter(p=>!speciesFilter||p.species===speciesFilter)
          .filter(p=>!trendFilter||Trend.score(p,currentTrend)>0);
        return (
          <Collapsible title="🏡 Adoption" defaultOpen={false}>
            {filteredWild.map(pet=>{
              const cost=Math.round(Econ.adoptCost(pet)*(pet.adoptPriceMult||1)),canAfford=money>=cost;
              const g=petGender(pet);
              const rl=rarityLabel(pet.stats.rarity);
              const sp=SPECIES[pet.species]||SPECIES.cats;
              const isTrending=Trend.score(pet,currentTrend)>0;
              return (
                <div key={pet.id} style={{display:'flex',alignItems:'center',gap:8,marginBottom:4,padding:'6px 10px',background:'var(--wall)',borderRadius:8}}>
                  <PetMini pet={pet} petType={pet.species||'cats'} size={48}/>
                  <div style={{flex:1,minWidth:0}}>
                    <div style={{display:'flex',alignItems:'center',gap:3,flexWrap:'wrap',fontWeight:800,fontSize:13}}>
                      <GenderIcon gender={g} size={14}/>
                      {pet.name}
                      {(()=>{
                        const ageW=pet.state.age||0;
                        const ageY=Math.floor(ageW/48);
                        const ageM=Math.floor((ageW%48)/4);
                        const label=ageY>=1?`${ageY} year${ageY>1?'s':''}`:`${Math.max(1,ageM)} month${Math.max(1,ageM)>1?'s':''}`;
                        return <span className="badge bk" style={{fontSize:9,padding:'1px 4px'}}>{label}</span>;
                      })()}
                      {isTrending&&<span title="Trending">🔥</span>}
                    </div>
                    <div className="muted" style={{fontSize:11}}>{Object.entries(pet.phenotype).map(([k,v])=>traitFullLabel(k,v)).join(' · ')}</div>
                  </div>
                  <span style={{display:'inline-block',position:'relative'}} onClick={effectivePetCount>=getMaxPets(state)?()=>toast('Pen is full (including pregnant pets)'):!canAfford?()=>toast('Not enough money'):undefined}>
                    <button className="btn btn-sm btn-gold" disabled={!canAfford||effectivePetCount>=getMaxPets(state)} style={(!canAfford||effectivePetCount>=getMaxPets(state))?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.ADOPT_WILD,id:pet.id})}>
                      Adopt {$(cost)}
                    </button>
                  </span>
                </div>
              );
            })}
            {filteredWild.length===0&&<EmptyState icon="🌲" title="It is quiet here" hint="New pets appear every week — check back after advancing the week."/>}
          </Collapsible>
        );
      })()}

      {/* In the Cafe */}
      <PetSection icon="🏪" title="In the Cafe" count={cafePets.length} countLabel={`${cafePets.length}/${cap.floor}`} forceShow={true}>
        {cafePets.map(pet=>(
          <PetRow key={pet.id} pet={pet} currentTrend={currentTrend}
            onClick={()=>onSelectPet&&onSelectPet(pet.id)}
            statusLabel={pet.wish?<span style={{color:'var(--blue)',fontWeight:700,fontSize:11}}>💭 {pet.wish.label}</span>:undefined}
            actions={<>
              <button className="btn btn-xs btn-o" onClick={(e)=>{e.stopPropagation();dispatch({type:A.UNASSIGN_PET,id:pet.id});}}>Remove from Cafe</button>
              <button className="btn btn-xs btn-gold" onClick={(e)=>{e.stopPropagation();setConfirmSell(pet);}}>Sell</button>
            </>}
          />
        ))}
      </PetSection>

      {/* Available */}
      <PetSection icon="🛋️" title="Available" count={availPets.length}>
        {availPets.map(pet=>(
          <PetRow key={pet.id} pet={pet} currentTrend={currentTrend}
            onClick={()=>onSelectPet&&onSelectPet(pet.id)}
            statusLabel={pet.wish?<span style={{color:'var(--blue)',fontWeight:700,fontSize:11}}>💭 {pet.wish.label}</span>:undefined}
            actions={<>
              <span style={{display:'inline-block',position:'relative'}} onClick={assignedPetIds.length>=cap.floor?(e)=>{e.stopPropagation();toast('Cafe floor is full — upgrade your cafe to add more space');}:undefined}>
                <button className="btn btn-xs btn-g" disabled={assignedPetIds.length>=cap.floor} style={assignedPetIds.length>=cap.floor?{pointerEvents:'none'}:undefined} onClick={(e)=>{e.stopPropagation();dispatch({type:A.ASSIGN_PET,id:pet.id});}}>To Cafe</button>
              </span>
              <button className="btn btn-xs btn-gold" onClick={(e)=>{e.stopPropagation();setConfirmSell(pet);}}>Sell</button>
            </>}
          />
        ))}
      </PetSection>

      {/* Busy */}
      <PetSection icon="⏳" title="Busy" count={busyPets.length} defaultOpen={false}>
        {busyPets.map(pet=>(
          <PetRow key={pet.id} pet={pet} currentTrend={currentTrend}
            onClick={()=>onSelectPet&&onSelectPet(pet.id)}
            statusLabel={busyStatus(pet)}
          />
        ))}
      </PetSection>

      {/* Babies */}
      <PetSection icon="🍼" title="Babies" count={babyPets.length} defaultOpen={false}>
        {babyPets.map(pet=>{
          const sp=SPECIES[pet.species]||SPECIES.cats;
          return (
            <PetRow key={pet.id} pet={pet} currentTrend={currentTrend}
              onClick={()=>onSelectPet&&onSelectPet(pet.id)}
              statusLabel={`${sp.baby} · matures next week`}
              />
          );
        })}
      </PetSection>

      {cafePets.length===0&&availPets.length===0&&busyPets.length===0&&babyPets.length===0&&(
        <EmptyState icon="🐾" title={speciesFilter?`No ${SPECIES[speciesFilter]?.name||speciesFilter} yet`:trendFilter?'No trend matches':'No pets in your care'} hint={trendFilter?'Try clearing the trend filter, or adopt a matching pet from the Adoption section.':'Scroll down to the Adoption section to adopt your first companions.'}/>
      )}

      {confirmSell&&(
        <Modal onClose={()=>setConfirmSell(null)}>
            <div style={{fontSize:28,marginBottom:6}}>💰</div>
            <div style={{fontWeight:800,fontSize:15,marginBottom:6}}>Sell {confirmSell.name}?</div>
            <div className="muted" style={{marginBottom:16}}>You'll receive {$(Econ.salePrice(confirmSell))}. This cannot be undone.</div>
            <div style={{display:'flex',gap:8,justifyContent:'center'}}>
              <button className="btn btn-o" onClick={()=>setConfirmSell(null)}>Cancel</button>
              <button className="btn btn-gold" onClick={()=>{SFX.sell();dispatch({type:A.SELL_PET,id:confirmSell.id});setConfirmSell(null);}}>Sell</button>
            </div>
        </Modal>
      )}

    </div>
  );
}

// ─── BREEDING PANEL ───────────────────────────────────────────────────────────
function BreedingPanel({state, dispatch, onSelectPet}) {
  const toast = useToast();
  const [selA,setSelA]=useState(null);
  const [selB,setSelB]=useState(null);
  const [showPersInfo,setShowPersInfo]=useState(false);
  const [sortMode,setSortMode]=useState('availability');
  const [markingFilter,setMarkingFilter]=useState(null);
  const {pets,breedingQueue,staff,assignedPetIds,currentTrend}=state;
  const phStaff=getPetHouseStaff(state);
  const petCareAvg=getStaffSkillAvg(phStaff,'petCare');
  const rareChance=petCareAvg>=4?Math.round(Math.min(1,(petCareAvg-2)/6)*100):0;
  const babies=pets.filter(p=>p.state.isKitten);
  const isEligible=(p)=>!p.state.isKitten&&p.state.age>=GAME_CONFIG.BREED_MIN_AGE_WEEKS&&p.state.breedCooldown===0&&!(p.state.pregnant||0)&&!(p.state.nursing||0)&&!(p.state.settling||0)&&!p.state.isRetired&&!(p.state.sick||0)&&!breedingQueue.some(b=>b.pA===p.id||b.pB===p.id)&&!(state.showEntries||[]).includes(p.id);
  const ineligibleReason=(p)=>{
    if(p.state.isKitten)return`Too young (${(SPECIES[p.species]||SPECIES.cats).baby.toLowerCase()})`;
    if(p.state.age<GAME_CONFIG.BREED_MIN_AGE_WEEKS)return`Too young (needs ${GAME_CONFIG.BREED_MIN_AGE_WEEKS - p.state.age}w more)`;
    if((p.state.sick||0)>0)return`Unwell (${p.state.sick}w until recovered)`;
    if(breedingQueue.some(b=>b.pA===p.id||b.pB===p.id))return'Already breeding';
    if((p.state.pregnant||0)>0)return`Pregnant (${p.state.pregnant}w left)`;
    if((p.state.nursing||0)>0)return'Nursing newborns';
    if((p.state.settling||0)>0)return'Still settling in';
    if(p.state.breedCooldown>0)return`Breed cooldown (${p.state.breedCooldown}w)`;
    if(p.state.isRetired)return'Retired';
    if((state.showEntries||[]).includes(p.id))return'Entered in show';
    return null;
  };
  const weeksUntilBreedable=(p)=>{
    if(isEligible(p))return 0;
    if(p.state.isRetired)return Infinity;
    if(breedingQueue.some(b=>b.pA===p.id||b.pB===p.id))return Infinity;
    if((state.showEntries||[]).includes(p.id))return Infinity;
    if(p.state.isKitten)return Math.max(1,GAME_CONFIG.BREED_MIN_AGE_WEEKS-(p.state.age||0));
    if(p.state.age<GAME_CONFIG.BREED_MIN_AGE_WEEKS)return GAME_CONFIG.BREED_MIN_AGE_WEEKS-p.state.age;
    if((p.state.sick||0)>0)return p.state.sick;
    if((p.state.pregnant||0)>0)return p.state.pregnant;
    if((p.state.nursing||0)>0)return p.state.nursing;
    if((p.state.settling||0)>0)return p.state.settling;
    if(p.state.breedCooldown>0)return p.state.breedCooldown;
    return Infinity;
  };
  const sortPets=(list)=>{
    if(sortMode==='availability') return [...list].sort((a,b)=>{
      const wa=weeksUntilBreedable(a),wb=weeksUntilBreedable(b);
      if(wa===0&&wb===0)return a.name.localeCompare(b.name);
      if(wa===0)return -1;
      if(wb===0)return 1;
      if(wa===Infinity&&wb===Infinity)return a.name.localeCompare(b.name);
      if(wa===Infinity)return 1;
      if(wb===Infinity)return -1;
      return wa-wb;
    });
    if(sortMode==='upkeep') return [...list].sort((a,b)=>petUpkeepCost(b)-petUpkeepCost(a));
    if(sortMode==='name') return [...list].sort((a,b)=>a.name.localeCompare(b.name));
    if(sortMode==='age') return [...list].sort((a,b)=>(b.state.age||0)-(a.state.age||0));
    return [...list].sort((a,b)=>b.stats.rarity-a.stats.rarity);
  };

  const selPet=selA?pets.find(p=>p.id===selA):null;
  const selGender=selPet?petGender(selPet):null;
  const selSpecies=selPet?.species;
  function doBreed() {
    if(selA&&selB&&selA!==selB){dispatch({type:A.START_BREEDING,pA:selA,pB:selB});setSelA(null);setSelB(null);}
  }
  function handlePetClick(pet) {
    if(!isEligible(pet))return;
    if(selA===pet.id){setSelA(null);}
    else if(selB===pet.id){setSelB(null);}
    else if(!selA){setSelA(pet.id);}
    else if(!selB&&pet.id!==selA){
      const petA=pets.find(p=>p.id===selA);
      if(petGender(pet)===petGender(petA)||pet.species!==petA?.species)return;
      if(areRelated(pet,petA,pets))return;
      setSelB(pet.id);
    }
  }
  function rarityChances(pA,pB) {
    const tt=TRAIT_TABLES[pA.species]||TT_CATS;
    const traitKeys=Object.keys(tt);
    // Monte Carlo with mutations so display matches actual breeding outcomes
    const simRng=new RNG(((pA.id.charCodeAt(1)||0)*997+(pB.id.charCodeAt(1)||0)*31)|0||42);
    const SAMPLES=300;
    const results=[];
    for(let s=0;s<SAMPLES;s++){
      const g={};
      for(const t of traitKeys) g[t]={a:simRng.pick([pA.genome[t].a,pA.genome[t].b]),b:simRng.pick([pB.genome[t].a,pB.genome[t].b])};
      const mg=Genetics.mut(g,simRng,tt);
      const ph=Genetics.express(mg,tt);
      results.push(Genetics.rarity(ph,tt));
    }
    const n=results.length;
    // Determine allowed rarity tiers: one below, same, one above parents' average tier
    const rarityTier=r=>r>0.65?3:r>0.45?2:r>0.25?1:0;
    const avgTier=Math.round((rarityTier(pA.stats.rarity)+rarityTier(pB.stats.rarity))/2);
    const minTier=Math.max(0,avgTier-1), maxTier=Math.min(3,avgTier+1);
    const allowed=t=>t>=minTier&&t<=maxTier;
    // Raw simulation fractions
    const raw=[
      results.filter(r=>r<=0.25).length/n,
      results.filter(r=>r>0.25&&r<=0.45).length/n,
      results.filter(r=>r>0.45&&r<=0.65).length/n,
      results.filter(r=>r>0.65).length/n,
    ];
    // Zero disallowed tiers, apply minimum floors within allowed range
    const floors=[0,0.12,0.04,0.01];
    const vals=raw.map((v,i)=>allowed(i)?Math.max(v,floors[i]):0);
    // Normalize to 100%
    const sum=vals.reduce((a,b)=>a+b,0)||1;
    const norm=vals.map(v=>Math.round(v/sum*100));
    norm[minTier]+=100-norm.reduce((a,b)=>a+b,0); // fix rounding
    return{common:norm[0],uncommon:norm[1],rare:norm[2],legendary:norm[3]};
  }

  return (
    <div className="panel">
      <div className="between" style={{marginBottom:4}}>
        <h2 className="sh">Breeding</h2>
        <div style={{display:'flex',gap:4}}>
          <MilestoneButton state={state} pageFilter="breed" label="🏅" autoOpen={state.openMilestonesPanel==='breed'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/>
          <button className="btn btn-sm btn-o" onClick={()=>setShowPersInfo(v=>!v)}>ℹ️ Traits</button>
        </div>
      </div>
      <div style={{display:'flex',flexWrap:'wrap',gap:6,marginBottom:6,alignItems:'center',justifyContent:'flex-end'}}>
        <span style={{fontSize:11,fontWeight:700,color:'var(--muted)',textTransform:'uppercase',letterSpacing:'0.04em',whiteSpace:'nowrap'}}>Sort</span>
        {[['availability','✅','Availability'],['rarity','⭐','Rarity'],['name','🔤','Name'],['age','🎂','Age'],['upkeep','💰','Upkeep']].map(([v,icon,label])=>(
          <button key={v} className={`btn btn-xs ${sortMode===v?'btn-p':'btn-o'}`} onClick={()=>setSortMode(v)}>{icon} {label}</button>
        ))}
        {[['star','⭐'],['heart','❤️'],['thumbsup','👍']].map(([mk,icon])=>(
          <button key={mk} className={`btn btn-xs ${markingFilter===mk?'btn-p':'btn-o'}`}
            onClick={()=>setMarkingFilter(markingFilter===mk?null:mk)}>{icon}</button>
        ))}
      </div>

      {showPersInfo&&(
        <div className="card" style={{fontSize:12}}>
          <h3 className="sub" style={{marginBottom:6}}>Personality Traits</h3>
          <div style={{display:'flex',flexDirection:'column',gap:5}}>
            {PERS_TABLE.map(p=>(
              <div key={p.value}><strong>{p.e} {capitalize(p.value)}</strong> — {PERS_DESC[p.value]}</div>
            ))}
          </div>
        </div>
      )}

      {/* Breeding away status */}
      {breedingQueue.length>0&&(
        <div className="card">
          <h3 className="sub">💕 Breeding</h3>
          {breedingQueue.map((b,i)=>{
            const pA=pets.find(p=>p.id===b.pA),pB=pets.find(p=>p.id===b.pB);
            const bSp=SPECIES[pA?.species]||SPECIES.cats;
            return <div key={i} style={{marginBottom:4,fontSize:13}}>
              {bSp.icon} {pA?.name} × {pB?.name} — away for <strong>{b.weeksLeft}w</strong>
            </div>;
          })}
        </div>
      )}

      {/* Newborns */}
      {babies.length>0&&(
        <div className="card">
          <h3 className="sub">🍼 Newborns</h3>
          {babies.map(b=>{
            const bSp=SPECIES[b.species]||SPECIES.cats;
            return (
              <div key={b.id} className="muted" style={{marginBottom:2,fontSize:12}}>
                {bSp.icon} {b.name} — newborn, matures next week
              </div>
            );
          })}
        </div>
      )}

      {/* Per-species sections — show all adults, dim ineligible with reason */}
      {ALL_SPECIES_KEYS.map(sp=>{
        const spInfo=SPECIES[sp];
        const spPets=sortPets(pets.filter(p=>p.species===sp&&(!markingFilter||p.marking===markingFilter)));
        if(spPets.length===0)return null;
        const eligibleCount=spPets.filter(p=>isEligible(p)).length;
        const renderBreedRow=(pet)=>{
          const eligible=isEligible(pet);
          const isSelected=selA===pet.id||selB===pet.id;
          const reason=!eligible?ineligibleReason(pet):null;
          const isRelated=eligible&&selA&&!isSelected&&!selB&&pet.id!==selA&&petGender(pet)!==selGender&&pet.species===selSpecies&&areRelated(pet,pets.find(p=>p.id===selA),pets);
          const dimmed=!eligible||isRelated||(!isSelected&&(
            (selA&&selB) ||
            (!!selA&&!selB&&(petGender(pet)===selGender||pet.species!==selSpecies))
          ));
          const pairingReason=eligible&&selA&&!isSelected&&!selB&&pet.id!==selA?(
            pet.species!==selSpecies?'Different species':
            petGender(pet)===selGender?`Same gender (${petGender(pet)})`:
            isRelated?'Related':null
          ):null;
          const isCafePet=eligible&&assignedPetIds.includes(pet.id);
          const label=isSelected?<span style={{color:'var(--orange)',fontWeight:700}}>✓ Selected{isCafePet?' (will no longer be assigned to the cafe)':''}</span>
            :pairingReason?<span className="muted" style={{fontSize:11}}>{pairingReason}</span>
            :isRelated?<span className="muted" style={{fontSize:11}}>Related</span>
            :reason?<span className="muted" style={{fontSize:11}}>{reason}</span>
            :isCafePet?<span style={{color:'var(--orange)',fontSize:11}}>☕ Currently in the cafe</span>
            :undefined;
          return (
            <PetRow key={pet.id} pet={pet} currentTrend={currentTrend}
              hideUpkeepAge={true}
              rowStyle={{opacity:dimmed?.4:1}}
              onClick={()=>onSelectPet&&onSelectPet(pet.id)}
              statusLabel={label}
              actions={eligible&&!isRelated&&!(selA&&selB&&!isSelected)?
                <button className={`btn btn-xs ${isSelected?'btn-r':'btn-g'}`}
                  onClick={e=>{e.stopPropagation();handlePetClick(pet);}}>
                  {isSelected?'Deselect':'Select'}
                </button>
              :undefined}
            />
          );
        };
        // When one pet is selected, hide incompatible; when both selected, show only those two
        const filteredPets=selA&&selB?spPets.filter(p=>p.id===selA||p.id===selB)
          :selA&&!selB?spPets.filter(p=>{
          if(p.id===selA)return true;
          if(!isEligible(p))return false;
          if(p.species!==selSpecies)return false;
          if(petGender(p)===selGender)return false;
          if(areRelated(p,pets.find(x=>x.id===selA),pets))return false;
          return true;
        }):spPets;
        if(filteredPets.length===0)return null;
        return (
          <PetSection key={sp} icon={spInfo.icon} title={spInfo.name} count={eligibleCount} countLabel={`${eligibleCount}/${spPets.length}`} defaultOpen={false}>
            {filteredPets.map(p=>renderBreedRow(p))}
          </PetSection>
        );
      })}

      {pets.length===0&&<EmptyState icon="💕" title="No pets yet" hint="Adopt some pets first, then come back when they're old enough to breed."/>}

      {/* Breed action card — shown once both parents are selected */}
      {selA&&selB&&(()=>{
        const pA=pets.find(p=>p.id===selA),pB=pets.find(p=>p.id===selB);
        const rc=rarityChances(pA,pB);
        const penCap=getMaxPets(state);
        const effectivePets=pets.length+(state.breedingQueue?.length||0)+pets.filter(p=>(p.state.pregnant||0)>0).length;
        const atMax=effectivePets>=penCap;
        return(
          <div className="card">
            <div style={{display:'flex',gap:6,justifyContent:'center',marginBottom:6,fontSize:11,flexWrap:'wrap'}}>
              {rc.common>0&&<span className="badge bk">{rc.common}% Common</span>}
              {rc.uncommon>0&&<span className="badge bg">{rc.uncommon}% Uncommon</span>}
              {rc.rare>0&&<span className="badge bu">{rc.rare}% Rare</span>}
              {rc.legendary>0&&<span className="badge by">{rc.legendary}% Legendary</span>}
            </div>
            {(assignedPetIds.includes(selA)||assignedPetIds.includes(selB))&&<div className="warn" style={{marginBottom:6,fontSize:11}}>⚠ Breeding will remove the selected pet(s) from the cafe floor.</div>}
            {atMax&&<div className="warn" style={{marginBottom:6,fontSize:11}}>⚠ Pen is full ({pets.length}/{penCap})! Sell or rehome a pet before breeding.</div>}
            <span style={{display:'inline-block',position:'relative',width:'100%'}} onClick={atMax?()=>toast('Pen is full'):undefined}>
              <button className="btn btn-p" disabled={atMax} style={atMax?{width:'100%',pointerEvents:'none'}:{width:'100%'}} onClick={doBreed}>
                Breed {pA?.name} × {pB?.name}
              </button>
            </span>
            <div className="muted" style={{fontSize:11,textAlign:'center',marginTop:4}}>Father available after 1 week. Mother available after 5 weeks, then 24-week cooldown before breeding again.</div>
          </div>
        );
      })()}
    </div>
  );
}

// ─── MENU PANEL ───────────────────────────────────────────────────────────────
function MenuPanel({state, dispatch}) {
  const toast = useToast();
  const {allMenuItems,discoveredItemIds,activeMenuIds,rdBudget,lastWeekItemSales,menuItemPrices,staff,menuItemWeeksActive,masteredItems,lockedBudgets,cafeLevel}=state;
  const maxPerCat=getMaxMenuPerCat(cafeLevel);
  const weeksActive=menuItemWeeksActive||{};
  const prices=menuItemPrices||{};
  const mastery=masteredItems||{};
  const season=Time.season(state.week);
  const categories=['drinks','food','desserts'];
  const cookAvg=staff.length?staff.reduce((s,m)=>s+m.skills.cooking,0)/staff.length:1;
  const cookTolerance=cookAvg/10*0.8;

  const currentMenuTrends=state.currentMenuTrends||[];
  // Compute effective cost multiplier per item from active market trends (mirrors cafe simulation)
  function getItemCostMult(item) {
    let cm=1;
    for(const mt of currentMenuTrends){
      const e=mt.effect||{};
      const matchesIng=e.ingredient&&item.ingNames&&item.ingNames.includes(e.ingredient);
      const matchesCat=e.category===null||e.category===undefined||e.category===item.cat;
      if(matchesIng||(!e.ingredient&&matchesCat)){
        if(e.costMult){
          if(matchesIng){const n=item.ingNames?.length||1;cm*=1+(e.costMult-1)/n;}
          else cm*=e.costMult;
        }
      }
    }
    return cm;
  }

  return (
    <div className="panel">
      <div className="between"><h2 className="sh">Menu</h2><MilestoneButton state={state} pageFilter="menu" label="🏅" autoOpen={state.openMilestonesPanel==='menu'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/></div>

      {/* Active market conditions */}
      {currentMenuTrends.length>0&&(
        <div className="card menu-market-card" style={{background:'#e8f4f8',border:'1px solid #a8d4e0'}}>
          <h3 className="sub">📰 Active Market Conditions</h3>
          {currentMenuTrends.map((t,i)=>{
            const e=t.effect||{};
            let tag='';
            if(e.demandMult){
              const pct=Math.round((e.demandMult-1)*100);
              const scope=e.category?`all ${e.category}`:'all items';
              tag=`+${pct}% demand on ${scope}`;
            } else if(e.costMult&&e.ingredient){
              const pct=Math.round(Math.abs(e.costMult-1)*100);
              tag=e.costMult>1?`${e.ingredient} costs +${pct}% more`:`${e.ingredient} costs ${pct}% less`;
            } else if(e.costMult){
              const pct=Math.round(Math.abs(e.costMult-1)*100);
              tag=e.costMult>1?`all ingredients +${pct}% more expensive`:`all ingredients ${pct}% cheaper`;
            }
            return(
              <div key={i} style={{marginBottom:6}}>
                <div style={{display:'flex',gap:6,alignItems:'flex-start',fontSize:12}}>
                  <span>{t.icon}</span><span>{t.text}</span>
                </div>
                {tag&&<div style={{marginLeft:22,fontSize:11,fontWeight:700,color:e.demandMult?'var(--green)':e.costMult>1?'var(--red)':'var(--green)'}}>{tag}</div>}
              </div>
            );
          })}
        </div>
      )}

      {/* R&D Budget — per-category sliders are inside each category section below */}

      {/* Per category */}
      {categories.map(cat=>{
        const active=activeMenuIds[cat].map(id=>allMenuItems.find(i=>i.id===id)).filter(Boolean);
        const discovered=allMenuItems.filter(i=>i.cat===cat&&discoveredItemIds.includes(i.id)&&!activeMenuIds[cat].includes(i.id));
        const totalCat=allMenuItems.filter(i=>i.cat===cat).length;
        const discCat=allMenuItems.filter(i=>i.cat===cat&&discoveredItemIds.includes(i.id)).length;
        const allDiscovered=discCat>=totalCat;
        const allMaxStars=allDiscovered&&allMenuItems.filter(i=>i.cat===cat).every(i=>(mastery[i.id]?.tier||0)>=3);
        return (
          <Collapsible key={cat} title={`${capitalize(cat)} (${active.length}/${maxPerCat} on menu)`}
            badge={{text:allMaxStars?'⭐ All Gold':allDiscovered?'All Discovered':`${discCat}/${totalCat} Discovered`,cls:allMaxStars?'by':allDiscovered?'bg':'bu'}} defaultOpen={cat==='drinks'}>
            {!allMaxStars&&<div className="rd-slider" style={{marginBottom:8}}>
              <span style={{fontSize:11,fontWeight:700}}>R&D: {$((typeof rdBudget==='object'?rdBudget:{drinks:0,food:0,desserts:0})[cat]||0)}/week</span>
              <input type="range" min={0} max={500} step={10}
                value={(typeof rdBudget==='object'?rdBudget:{drinks:0,food:0,desserts:0})[cat]||0}
                disabled={!!(lockedBudgets||{})[cat]}
                onChange={e=>dispatch({type:A.SET_RD_BUDGET,cat,v:+e.target.value})}/>
              <button onClick={()=>dispatch({type:A.TOGGLE_BUDGET_LOCK,key:cat})}
                style={{background:(lockedBudgets||{})[cat]?'var(--red)':'var(--border)',color:(lockedBudgets||{})[cat]?'#fff':'var(--text)',border:'none',borderRadius:6,cursor:'pointer',fontSize:16,fontWeight:800,padding:'4px 8px',lineHeight:1,flexShrink:0}}
                title={(lockedBudgets||{})[cat]?'Unlock slider':'Lock slider'}>
                {(lockedBudgets||{})[cat]?'🔒':'🔓'}
              </button>
            </div>}
            {/* On the Menu items */}
            {active.length>0&&<div style={{marginBottom:8}}>
              <div style={{fontWeight:700,fontSize:12,marginBottom:4,color:'var(--green)'}}>On the Menu</div>
              {active.map(item=>{
                const sales=lastWeekItemSales[item.id];
                const wksOn=weeksActive[item.id]||0;
                const mt=(mastery[item.id]?.tier)||0;
                const masteryTol=mt>=3?.15:0;
                const curPrice=Math.round(item.price*(1+cookTolerance+masteryTol));
                const masteryDemand=[0,.10,.20,.30][mt];
                const freshMod=mt>=2?1.0:(wksOn>=0&&wksOn<=2?1.5:1.0);
                const estDemand=Math.round(Math.min(99,Math.max(1,item.demand*(1+masteryDemand)*freshMod*100)));
                const effectiveCost=Math.round(item.baseCost*getItemCostMult(item));
                const margin=curPrice>0?Math.round((curPrice-effectiveCost)/curPrice*100):0;
                const isFresh=wksOn>=0&&wksOn<=2;
                return (
                  <div key={item.id} style={{marginBottom:8,padding:8,background:'var(--wall)',borderRadius:8}}>
                    <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:6}}>
                      <div style={{flex:1}}>
                        <div style={{fontWeight:700,fontSize:13,display:'flex',alignItems:'center',gap:4}}>
                          {item.name}
                          {mt>=3&&<span style={{color:'#d4a017',fontSize:11}}>★★★</span>}
                          {mt===2&&<span style={{color:'#aaa',fontSize:11}}>★★</span>}
                          {mt===1&&<span style={{color:'#cd7f32',fontSize:11}}>★</span>}
                          {isFresh&&!mt&&<span className="badge bg" style={{fontSize:9,padding:'1px 4px'}}>🆕 New</span>}
                        </div>
                        <div className="muted" style={{fontSize:11}}>{item.ingNames.join(', ')} · cost {$(effectiveCost)}{effectiveCost!==item.baseCost&&<span style={{color:effectiveCost>item.baseCost?'var(--red)':'var(--green)',marginLeft:2}}>(base {$(item.baseCost)})</span>}</div>
                        {sales&&<div className="muted" style={{fontSize:11}}>Last week: {sales.sold} sold · {$(sales.revenue)}</div>}
                      </div>
                      <button className="btn btn-xs btn-r" onClick={()=>dispatch({type:A.DESELECT_MENU_ITEM,id:item.id})}>✕</button>
                    </div>
                    <div style={{display:'flex',gap:10,marginTop:4,fontSize:11,flexWrap:'wrap'}}>
                      <span>Price: <strong style={{color:'var(--green)'}}>{$(curPrice)}</strong></span>
                      <span>Margin: <strong style={{color:'var(--green)'}}>{margin}%</strong></span>
                    </div>
                  </div>
                );
              })}
            </div>}

            {/* Available (discovered but not on menu) */}
            {discovered.length>0&&<div>
              <div style={{fontWeight:700,fontSize:12,marginBottom:4,color:'var(--blue)'}}>Available</div>
              {discovered.map(item=>{
                const mt=(mastery[item.id]?.tier)||0;
                const masteryTol=mt>=3?.15:0;
                const curPrice=Math.round(item.price*(1+cookTolerance+masteryTol));
                const effectiveCost=Math.round(item.baseCost*getItemCostMult(item));
                const margin=curPrice>0?Math.round((curPrice-effectiveCost)/curPrice*100):0;
                return (
                <div key={item.id} style={{marginBottom:8,padding:8,background:'var(--wall)',borderRadius:8}}>
                  <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:6}}>
                    <div style={{flex:1}}>
                      <div style={{fontWeight:700,fontSize:13,display:'flex',alignItems:'center',gap:4}}>
                        {item.name}
                        {mt>=3&&<span style={{color:'#d4a017',fontSize:11}}>★★★</span>}
                        {mt===2&&<span style={{color:'#aaa',fontSize:11}}>★★</span>}
                        {mt===1&&<span style={{color:'#cd7f32',fontSize:11}}>★</span>}
                      </div>
                      <div className="muted" style={{fontSize:11}}>{item.ingNames.join(', ')} · cost {$(effectiveCost)}{effectiveCost!==item.baseCost&&<span style={{color:effectiveCost>item.baseCost?'var(--red)':'var(--green)',marginLeft:2}}>(base {$(item.baseCost)})</span>}</div>
                    </div>
                    <span style={{display:'inline-block',position:'relative'}} onClick={activeMenuIds[cat].length>=maxPerCat?()=>toast(`Max ${maxPerCat} items per category at this cafe level`):undefined}>
                      <button className="btn btn-xs btn-g" disabled={activeMenuIds[cat].length>=maxPerCat} style={activeMenuIds[cat].length>=maxPerCat?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.SELECT_MENU_ITEM,id:item.id})}>
                        Add
                      </button>
                    </span>
                  </div>
                  <div style={{display:'flex',gap:10,marginTop:4,fontSize:11,flexWrap:'wrap'}}>
                    <span>Price: <strong style={{color:'var(--green)'}}>{$(curPrice)}</strong></span>
                    <span>Margin: <strong style={{color:'var(--green)'}}>{margin}%</strong></span>
                  </div>
                </div>
                );
              })}
            </div>}
            {active.length===0&&discovered.length===0&&<EmptyState icon="📖" title="Your recipe book is empty" hint="Set an R&D budget above to start discovering new recipes next week."/>}
          </Collapsible>
        );
      })}
    </div>
  );
}

// ─── STAFF PANEL ─────────────────────────────────────────────────────────────
function StaffPanel({state, dispatch}) {
  const toast = useToast();
  const [confirmFire,setConfirmFire]=useState(null);
  const [showSkillInfo,setShowSkillInfo]=useState(false);
  const {staff,staffCandidates,cafeLevel,recruitBudget,recruitHistory,lockedBudgets,staffAssignments}=state;
  const assignments=staffAssignments||{};
  const maxStaffCap=getMaxStaff(state);
  const cafeSlots=getMaxCafeAddlStaff(state);
  const petHouseSlots=getMaxPetHouseAddlStaff(state);
  const cafeAssigned=getCafeAssigned(state);
  const petHouseAssigned=getPetHouseAssigned(state);
  const nonPlayerStaff=(staff||[]).filter(s=>!s.isPlayer);
  const unassignedStaff=nonPlayerStaff.filter(s=>assignments[s.id]===undefined);
  const cafeFull=cafeAssigned.length>=cafeSlots;
  const petHouseFull=petHouseAssigned.length>=petHouseSlots;
  const budget=recruitBudget??0;
  const hist=recruitHistory||[0];
  const effectiveBudget=Math.round(hist.reduce((a,b)=>a+b,0)/hist.length);
  const minSkill=1+Math.floor(effectiveBudget/125);
  const maxSkill=Math.min(10,3+Math.floor(effectiveBudget/100));

  return (
    <div className="panel">
      <div className="between">
        <h2 className="sh">Staff</h2>
        <div style={{display:'flex',gap:4}}>
          <MilestoneButton state={state} pageFilter="staff" label="🏅" autoOpen={state.openMilestonesPanel==='staff'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/>
          <button className="btn btn-sm btn-o" onClick={()=>setShowSkillInfo(v=>!v)}>ℹ️ Skills</button>
        </div>
      </div>
      <div className="muted" style={{marginBottom:4}}>{staff.length}/{maxStaffCap} staff</div>

      {showSkillInfo&&(
        <div className="card" style={{fontSize:12}}>
          <h3 className="sub" style={{marginBottom:6}}>Skill Effects</h3>
          <div style={{display:'flex',flexDirection:'column',gap:6}}>
            <div><strong>🍳 Cooking</strong> — <em>Cafe only:</em> customers tolerate higher prices &amp; R&D chance.</div>
            <div><strong>🤝 Service</strong> — <em>Cafe only:</em> more customers handled per day.</div>
            <div><strong>🐾 Pet Care</strong> — <em>Pet House only:</em> cheaper upkeep &amp; higher rarity chance when breeding.</div>
            <div><strong>🧹 Cleaning</strong> — <em>Cafe:</em> more returning customers. <em>Pet House:</em> higher pet sale prices.</div>
            <div className="muted" style={{marginTop:4,fontSize:11}}>Assign staff below to the cafe or pet house — the Owner works both.</div>
          </div>
        </div>
      )}

      {/* Owner */}
      {(()=>{const owner=staff.find(s=>s.isPlayer);return owner&&(
        <div className="card">
          <h3 className="sub">👑 Owner</h3>
          <div style={{display:'flex',gap:10,padding:8,background:'var(--wall)',borderRadius:8,alignItems:'flex-start'}}>
            <div style={{fontSize:28,lineHeight:1}}>{owner.emoji}</div>
            <div style={{flex:1}}>
              <div className="between">
                <div style={{display:'flex',alignItems:'center',gap:6}}>
                  <span style={{fontWeight:800}}>{owner.name}</span>
                  <span className="badge" style={{fontSize:9,background:'var(--blue)',color:'#fff'}}>Works both in the Cafe and in the Pet House</span>
                </div>
                <span className="muted">Free</span>
              </div>
              <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:4,marginTop:4}}>
                {Object.entries(owner.skills).map(([sk,v])=>{const display=owner.training?Math.ceil(v/2):v;return(
                  <div key={sk}>
                    <div className="between"><span style={{fontSize:11,fontWeight:700}}>{SKILL_NAMES[sk]||capitalize(sk)}</span><span style={{fontSize:11}}>{display}/10</span></div>
                    <div className="skill-bar"><div className="skill-fill" style={{width:`${display*10}%`}}/></div>
                  </div>
                );})}
              </div>
              {!owner.training&&(
                <div style={{display:'flex',gap:6,marginTop:6,flexWrap:'wrap'}}>
                  {Object.entries(owner.skills).map(([sk,v])=>v<10&&(
                    <span key={sk} style={{display:'inline-block'}} onClick={owner.trainedThisWeek?()=>toast('Already trained this week'):state.money<(TRAINING_COSTS[v]||0)?()=>toast('Not enough money'):undefined}>
                      <button className="btn btn-sm btn-p" disabled={state.money<(TRAINING_COSTS[v]||0)||owner.trainedThisWeek} style={(state.money<(TRAINING_COSTS[v]||0)||owner.trainedThisWeek)?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.TRAIN,id:owner.id,skill:sk})}>Train {SKILL_NAMES[sk]||capitalize(sk)} {$(TRAINING_COSTS[v]||0)}</button>
                    </span>
                  ))}
                </div>
              )}
            </div>
          </div>
        </div>
      );})()}

      {/* Cafe Staff */}
      {cafeSlots>0?(
        <div className="card">
          <h3 className="sub">☕ Cafe Staff ({cafeAssigned.length}/{cafeSlots})</h3>
          {cafeAssigned.length===0&&<div className="muted" style={{fontSize:12,padding:'4px 0 2px'}}>No staff assigned to the cafe yet.</div>}
          {cafeAssigned.map(s=>(
            <div key={s.id} style={{display:'flex',gap:10,marginBottom:10,padding:8,background:'var(--wall)',borderRadius:8,alignItems:'flex-start'}}>
              <div style={{fontSize:28,lineHeight:1}}>{s.emoji}</div>
              <div style={{flex:1}}>
                <div className="between">
                  <div style={{display:'flex',alignItems:'center',gap:6}}>
                    <span style={{fontWeight:800}}>{s.name}</span>
                    {s.training&&<span className="badge by">🎓 Training</span>}
                  </div>
                  <span className="muted">{$(s.wage*7)}/week</span>
                </div>
                {s.training&&<div className="muted" style={{fontSize:11,marginTop:2}}>Stats halved during training week</div>}
                <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:4,marginTop:4}}>
                  {Object.entries(s.skills).map(([sk,v])=>{const display=s.training?Math.ceil(v/2):v;return(
                    <div key={sk}>
                      <div className="between"><span style={{fontSize:11,fontWeight:700}}>{SKILL_NAMES[sk]||capitalize(sk)}</span><span style={{fontSize:11}}>{display}{s.training?<span className="muted">/{v}</span>:'/10'}</span></div>
                      <div className="skill-bar"><div className="skill-fill" style={{width:`${display*10}%`}}/></div>
                    </div>
                  );})}
                </div>
                <div style={{display:'flex',gap:6,marginTop:6,flexWrap:'wrap'}}>
                  {!s.training&&Object.entries(s.skills).map(([sk,v])=>v<10&&(
                    <span key={sk} style={{display:'inline-block'}} onClick={s.trainedThisWeek?()=>toast('Already trained this week'):state.money<(TRAINING_COSTS[v]||0)?()=>toast('Not enough money'):undefined}>
                      <button className="btn btn-sm btn-p" disabled={state.money<(TRAINING_COSTS[v]||0)||s.trainedThisWeek} style={(state.money<(TRAINING_COSTS[v]||0)||s.trainedThisWeek)?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.TRAIN,id:s.id,skill:sk})}>Train {SKILL_NAMES[sk]||capitalize(sk)} {$(TRAINING_COSTS[v]||0)}</button>
                    </span>
                  ))}
                  <span style={{display:'inline-block'}} onClick={petHouseSlots===0?()=>toast('Upgrade Pet House to unlock pet house staff slots'):petHouseFull?()=>toast('Pet house staff is full'):undefined}>
                    <button className="btn btn-sm btn-o" disabled={petHouseSlots===0||petHouseFull} style={petHouseSlots===0||petHouseFull?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:'petHouse'})}>Move to 🏠</button>
                  </span>
                  <button className="btn btn-sm" style={{background:'#e8e0d8',color:'#555'}} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:null})}>Unassign</button>
                  <span style={{display:'inline-block'}} onClick={staff.length<=1?()=>toast('Must have at least 1 staff member'):undefined}>
                    <button className="btn btn-sm btn-r" disabled={staff.length<=1} style={staff.length<=1?{pointerEvents:'none'}:undefined} onClick={()=>setConfirmFire(s)}>Fire</button>
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      ):(
        <div className="muted" style={{fontSize:12,padding:'0 0 6px'}}>☕ Upgrade your Cafe to unlock cafe staff slots.</div>
      )}

      {/* Pet House Staff */}
      {petHouseSlots>0?(
        <div className="card">
          <h3 className="sub">🏠 Pet House Staff ({petHouseAssigned.length}/{petHouseSlots})</h3>
          {petHouseAssigned.length===0&&<div className="muted" style={{fontSize:12,padding:'4px 0 2px'}}>No staff assigned to the pet house yet.</div>}
          {petHouseAssigned.map(s=>(
            <div key={s.id} style={{display:'flex',gap:10,marginBottom:10,padding:8,background:'var(--wall)',borderRadius:8,alignItems:'flex-start'}}>
              <div style={{fontSize:28,lineHeight:1}}>{s.emoji}</div>
              <div style={{flex:1}}>
                <div className="between">
                  <div style={{display:'flex',alignItems:'center',gap:6}}>
                    <span style={{fontWeight:800}}>{s.name}</span>
                    {s.training&&<span className="badge by">🎓 Training</span>}
                  </div>
                  <span className="muted">{$(s.wage*7)}/week</span>
                </div>
                {s.training&&<div className="muted" style={{fontSize:11,marginTop:2}}>Stats halved during training week</div>}
                <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:4,marginTop:4}}>
                  {Object.entries(s.skills).map(([sk,v])=>{const display=s.training?Math.ceil(v/2):v;return(
                    <div key={sk}>
                      <div className="between"><span style={{fontSize:11,fontWeight:700}}>{SKILL_NAMES[sk]||capitalize(sk)}</span><span style={{fontSize:11}}>{display}{s.training?<span className="muted">/{v}</span>:'/10'}</span></div>
                      <div className="skill-bar"><div className="skill-fill" style={{width:`${display*10}%`}}/></div>
                    </div>
                  );})}
                </div>
                <div style={{display:'flex',gap:6,marginTop:6,flexWrap:'wrap'}}>
                  {!s.training&&Object.entries(s.skills).map(([sk,v])=>v<10&&(
                    <span key={sk} style={{display:'inline-block'}} onClick={s.trainedThisWeek?()=>toast('Already trained this week'):state.money<(TRAINING_COSTS[v]||0)?()=>toast('Not enough money'):undefined}>
                      <button className="btn btn-sm btn-p" disabled={state.money<(TRAINING_COSTS[v]||0)||s.trainedThisWeek} style={(state.money<(TRAINING_COSTS[v]||0)||s.trainedThisWeek)?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.TRAIN,id:s.id,skill:sk})}>Train {SKILL_NAMES[sk]||capitalize(sk)} {$(TRAINING_COSTS[v]||0)}</button>
                    </span>
                  ))}
                  <span style={{display:'inline-block'}} onClick={cafeSlots===0?()=>toast('Upgrade Cafe to unlock cafe staff slots'):cafeFull?()=>toast('Cafe staff is full'):undefined}>
                    <button className="btn btn-sm btn-o" disabled={cafeSlots===0||cafeFull} style={cafeSlots===0||cafeFull?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:'cafe'})}>Move to ☕</button>
                  </span>
                  <button className="btn btn-sm" style={{background:'#e8e0d8',color:'#555'}} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:null})}>Unassign</button>
                  <span style={{display:'inline-block'}} onClick={staff.length<=1?()=>toast('Must have at least 1 staff member'):undefined}>
                    <button className="btn btn-sm btn-r" disabled={staff.length<=1} style={staff.length<=1?{pointerEvents:'none'}:undefined} onClick={()=>setConfirmFire(s)}>Fire</button>
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      ):(
        <div className="muted" style={{fontSize:12,padding:'0 0 6px'}}>🏠 Upgrade your Pet House to unlock pet house staff slots.</div>
      )}

      {/* Unassigned Staff */}
      {unassignedStaff.length>0&&(
        <div className="card">
          <h3 className="sub">📋 Unassigned Staff</h3>
          <div className="muted" style={{fontSize:11,marginBottom:8}}>Assign them to a location to put them to work.</div>
          {unassignedStaff.map(s=>(
            <div key={s.id} style={{display:'flex',gap:10,marginBottom:10,padding:8,background:'var(--wall)',borderRadius:8,alignItems:'flex-start'}}>
              <div style={{fontSize:28,lineHeight:1}}>{s.emoji}</div>
              <div style={{flex:1}}>
                <div className="between">
                  <div style={{display:'flex',alignItems:'center',gap:6}}>
                    <span style={{fontWeight:800}}>{s.name}</span>
                    {s.training&&<span className="badge by">🎓 Training</span>}
                  </div>
                  <span className="muted">{$(s.wage*7)}/week</span>
                </div>
                <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:4,marginTop:4}}>
                  {Object.entries(s.skills).map(([sk,v])=>{const display=s.training?Math.ceil(v/2):v;return(
                    <div key={sk}>
                      <div className="between"><span style={{fontSize:11,fontWeight:700}}>{SKILL_NAMES[sk]||capitalize(sk)}</span><span style={{fontSize:11}}>{display}{s.training?<span className="muted">/{v}</span>:'/10'}</span></div>
                      <div className="skill-bar"><div className="skill-fill" style={{width:`${display*10}%`}}/></div>
                    </div>
                  );})}
                </div>
                <div style={{display:'flex',gap:6,marginTop:6,flexWrap:'wrap'}}>
                  <span style={{display:'inline-block'}} onClick={cafeSlots===0?()=>toast('Upgrade Cafe to unlock cafe staff slots'):cafeFull?()=>toast('Cafe staff is full'):undefined}>
                    <button className="btn btn-sm btn-o" disabled={cafeSlots===0||cafeFull} style={cafeSlots===0||cafeFull?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:'cafe'})}>Assign to ☕ Cafe</button>
                  </span>
                  <span style={{display:'inline-block'}} onClick={petHouseSlots===0?()=>toast('Upgrade Pet House to unlock pet house staff slots'):petHouseFull?()=>toast('Pet house staff is full'):undefined}>
                    <button className="btn btn-sm btn-o" disabled={petHouseSlots===0||petHouseFull} style={petHouseSlots===0||petHouseFull?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.ASSIGN_STAFF,id:s.id,location:'petHouse'})}>Assign to 🏠 Pet House</button>
                  </span>
                  <span style={{display:'inline-block'}} onClick={staff.length<=1?()=>toast('Must have at least 1 staff member'):undefined}>
                    <button className="btn btn-sm btn-r" disabled={staff.length<=1} style={staff.length<=1?{pointerEvents:'none'}:undefined} onClick={()=>setConfirmFire(s)}>Fire</button>
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}

      {confirmFire&&(
        <Modal onClose={()=>setConfirmFire(null)}>
            <div style={{fontSize:28,marginBottom:6}}>{confirmFire.emoji}</div>
            <div style={{fontWeight:800,fontSize:15,marginBottom:6}}>Fire {confirmFire.name}?</div>
            <div className="muted" style={{marginBottom:16}}>This cannot be undone.</div>
            <div style={{display:'flex',gap:8,justifyContent:'center'}}>
              <button className="btn btn-o" onClick={()=>setConfirmFire(null)}>Cancel</button>
              <button className="btn btn-r" onClick={()=>{dispatch({type:A.FIRE,id:confirmFire.id});setConfirmFire(null);}}>Fire</button>
            </div>
        </Modal>
      )}

      {/* Recruitment budget — shown whenever staff capacity > 1 (cafe or pet house upgrade) */}
      {maxStaffCap>1&&<div className="card">
        <h3 className="sub">📋 Recruitment Budget</h3>
        <div className="rd-slider">
          <span style={{fontSize:12,fontWeight:700,minWidth:40}}>{$(budget)}</span>
          <input type="range" min={0} max={500} step={10} value={budget}
            disabled={!!(lockedBudgets||{}).recruitment}
            onChange={e=>dispatch({type:A.SET_RECRUIT_BUDGET,v:+e.target.value})}/>
          <button onClick={()=>dispatch({type:A.TOGGLE_BUDGET_LOCK,key:'recruitment'})}
            style={{background:(lockedBudgets||{}).recruitment?'var(--red)':'var(--border)',color:(lockedBudgets||{}).recruitment?'#fff':'var(--text)',border:'none',borderRadius:6,cursor:'pointer',fontSize:16,fontWeight:800,padding:'4px 8px',lineHeight:1,flexShrink:0}}
            title={(lockedBudgets||{}).recruitment?'Unlock slider':'Lock slider'}>
            {(lockedBudgets||{}).recruitment?'🔒':'🔓'}
          </button>
        </div>
      </div>}

      {maxStaffCap>1&&staffCandidates.length>0&&(
        <div className="card">
          <h3 className="sub">Candidates</h3>
          <div className="muted" style={{fontSize:11,marginBottom:6}}>New hires are less effective for one week.</div>
          {staffCandidates.map(c=>(
            <div key={c.id} style={{display:'flex',gap:10,marginBottom:8,padding:8,background:'var(--wall)',borderRadius:8,alignItems:'center'}}>
              <div style={{fontSize:24}}>{c.emoji}</div>
              <div style={{flex:1}}>
                <div style={{fontWeight:800,fontSize:13}}>{c.name}</div>
                <div className="muted" style={{fontSize:11}}>
                  {Object.entries(c.skills).map(([k,v])=>`${SKILL_NAMES[k]||capitalize(k)}: ${v}`).join(' · ')} · {$(c.wage*7)}/week
                </div>
              </div>
              {(
                <span style={{display:'inline-block',position:'relative'}} onClick={staff.length>=maxStaffCap?()=>toast('Staff capacity full \u2014 upgrade cafe or pet house'):undefined}>
                  <button className="btn btn-sm btn-g" disabled={staff.length>=maxStaffCap} style={staff.length>=maxStaffCap?{pointerEvents:'none'}:undefined} onClick={()=>dispatch({type:A.HIRE,id:c.id})}>Hire</button>
                </span>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── SHOW PANEL ───────────────────────────────────────────────────────────────
function ShowPanel({state, dispatch, onSelectPet}) {
  const {currentShow,showEntries,pets,trophies,assignedPetIds,breedingQueue}=state;
  const toast=useToast();
  const [confirmEntry,setConfirmEntry]=useState(null);
  const [showTraitsInfo,setShowTraitsInfo]=useState(false);
  const [sortMode,setSortMode]=useState('rarity');
  const sortPets=(list)=>{
    if(sortMode==='upkeep') return [...list].sort((a,b)=>petUpkeepCost(b)-petUpkeepCost(a));
    if(sortMode==='name') return [...list].sort((a,b)=>a.name.localeCompare(b.name));
    return [...list].sort((a,b)=>b.stats.rarity-a.stats.rarity);
  };
  const weeksUntilJudging = currentShow ? Time.weeksUntilJudging(state.week) : 0;
  const showSp=currentShow?.species?SPECIES[currentShow.species]:null;
  const locked=weeksUntilJudging===1;

  // Classify pets of the correct species into sections
  const speciesPets=currentShow?pets.filter(p=>!p.state.isKitten&&(!currentShow.species||p.species===currentShow.species)):[];
  const isBusy=(p)=>isPetBusy(p,breedingQueue,showEntries,weeksUntilJudging);
  const isInCafe=(p)=>assignedPetIds.includes(p.id);
  const busyStatus=(p)=>petBusyStatus(p,breedingQueue,showEntries,weeksUntilJudging,assignedPetIds);
  const scheduled=sortPets(speciesPets.filter(p=>showEntries.includes(p.id)));
  // Available now includes cafe pets — they'll be returned to the cafe after the show if there's room
  const available=sortPets(speciesPets.filter(p=>!showEntries.includes(p.id)&&!isBusy(p)));
  const busy=sortPets(speciesPets.filter(p=>!showEntries.includes(p.id)&&isBusy(p)&&!isInCafe(p)));
  const isShowWeek=weeksUntilJudging===1;
  const flatFee=currentShow?showEntryFee(0,currentShow.showNum||1,currentShow.prizes?.[0],currentShow.isChampionship||false):0;
  // Backfill preferredTraitValues for old saves that lack it
  const effectivePreferredTraitValues=(()=>{
    if(!currentShow) return {};
    if(currentShow.preferredTraitValues) return currentShow.preferredTraitValues;
    // Old save: generate deterministic fallback from showNum
    const tt2=TRAIT_TABLES[currentShow.species||'cats'];
    if(!tt2) return {};
    const result={};
    const n=currentShow.showNum||0;
    for(const t of (currentShow.traits||[])){
      if(t==='rarity'||t==='generation'||t==='personality') continue;
      const vals=tt2[t]?.map(e=>e.value)||[];
      if(!vals.length) continue;
      const count=Math.max(1,Math.round(vals.length/3));
      // Deterministic shuffle using showNum+trait as seed
      const seed=n*31+t.charCodeAt(0);
      const shuffled=[...vals].sort((a,b)=>((seed*a.charCodeAt(0))%7)-((seed*b.charCodeAt(0))%7));
      result[t]=shuffled.slice(0,count);
    }
    return result;
  })();
  const effectivePreferredBreeds=effectivePreferredTraitValues?.breed;

  return (
    <div className="panel">
      <div className="between" style={{marginBottom:4}}>
        <h2 className="sh">Pet Shows</h2>
        <div style={{display:'flex',gap:4}}>
          <MilestoneButton state={state} pageFilter="show" label="🏅" autoOpen={state.openMilestonesPanel==='show'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/>
          <button className="btn btn-sm btn-o" onClick={()=>setShowTraitsInfo(v=>!v)}>ℹ️ Traits</button>
        </div>
      </div>
      <div style={{display:'flex',flexWrap:'wrap',gap:6,marginBottom:6,alignItems:'center',justifyContent:'flex-end'}}>
        <span style={{fontSize:11,fontWeight:700,color:'var(--muted)',textTransform:'uppercase',letterSpacing:'0.04em',whiteSpace:'nowrap'}}>Sort</span>
        {[['rarity','⭐','Rarity'],['name','🔤','Name']].map(([v,icon,label])=>(
          <button key={v} className={`btn btn-xs ${sortMode===v?'btn-p':'btn-o'}`} onClick={()=>setSortMode(v)}>{icon} {label}</button>
        ))}
      </div>
      {currentShow&&(
        <div className="card" style={{fontSize:12}}>
          <h3 className="sub" style={{marginBottom:6}}>Judging Criteria</h3>
          <div style={{display:'flex',flexDirection:'column',gap:8}}>
            {currentShow.traits.map(trait=>{
              if(trait==='rarity'){
                return(
                  <div key="rarity">
                    <div style={{fontWeight:700,marginBottom:3}}>⭐ Rarity <span className="muted" style={{fontWeight:400,fontSize:10}}>— higher tiers score more</span></div>
                    <div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:2}}>
                      {[['Common','bk'],['Uncommon','bu'],['Rare','bg'],['Legendary','bg']].map(([label,cls])=>(
                        <span key={label} className={`badge ${cls}`} style={{fontSize:10}}>{label}{label==='Legendary'?' 🥇':label==='Rare'?' ⭐':''}</span>
                      ))}
                    </div>
                    <div className="muted" style={{fontSize:10}}>Breed rarer trait combinations to increase your pet's rarity tier.</div>
                  </div>
                );
              }
              if(trait==='generation'){
                return(
                  <div key="generation">
                    <div style={{fontWeight:700,marginBottom:3}}>🧬 Generation <span className="muted" style={{fontWeight:400,fontSize:10}}>— higher is better</span></div>
                    <div className="muted" style={{fontSize:10}}>Each breeding generation adds points. Breed your best pets to build up generations.</div>
                  </div>
                );
              }
              if(trait==='personality'){
                return(
                  <div key="personality">
                    <div style={{fontWeight:700,marginBottom:3}}>😸 Personality <span className="muted" style={{fontWeight:400,fontSize:10}}>— some traits give a show bonus</span></div>
                    <div className="muted" style={{fontSize:10}}>Tap "Traits" to see which personalities boost your show score.</div>
                  </div>
                );
              }
              if(trait==='breed'){
                const tt2=TRAIT_TABLES[currentShow.species||'cats'];
                const allBreeds=tt2?.breed?.map(b=>b.value)||[];
                const preferred=effectivePreferredBreeds||[];
                return(
                  <div key="breed">
                    <div style={{fontWeight:700,marginBottom:3}}>🐕 Breed <span className="muted" style={{fontWeight:400,fontSize:10}}>— judges currently prefer these breeds</span></div>
                    <div style={{display:'flex',flexWrap:'wrap',gap:4}}>
                      {allBreeds.map(b=>{
                        const isPref=preferred.includes(b);
                        return <span key={b} className={`badge ${isPref?'bg':'bk'}`} style={{fontSize:10}}>{capitalize(b)}{isPref?' 🥇':''}</span>;
                      })}
                    </div>
                    <div className="muted" style={{fontSize:10,marginTop:2}}>Preferred breeds score a bonus. All other breeds score equally.</div>
                  </div>
                );
              }
              if(trait==='displayColor'){
                const allColors=Object.keys(COLOR_MAPS[currentShow.species||'cats']||{});
                const preferred=effectivePreferredTraitValues.displayColor||[];
                if(!preferred.length) return null;
                return(
                  <div key={trait}>
                    <div style={{fontWeight:700,marginBottom:3}}>🎨 Color <span className="muted" style={{fontWeight:400,fontSize:10}}>— judges currently prefer these colors</span></div>
                    <div style={{display:'flex',flexWrap:'wrap',gap:4}}>
                      {allColors.map(v=>{
                        const isPref=preferred.includes(v);
                        return <span key={v} className={`badge ${isPref?'bg':'bk'}`} style={{fontSize:10}}>{capitalize(v)}{isPref?' 🥇':''}</span>;
                      })}
                    </div>
                    <div className="muted" style={{fontSize:10,marginTop:2}}>Preferred colors score a bonus. All others score equally.</div>
                  </div>
                );
              }
              const tt=TRAIT_TABLES[currentShow.species||'cats'];
              if(!tt||!tt[trait])return null;
              const allVals=tt[trait].map(a=>a.value);
              const preferred=effectivePreferredTraitValues[trait]||[];
              if(!preferred.length) return null;
              return(
                <div key={trait}>
                  <div style={{fontWeight:700,marginBottom:3}}>{capitalize(trait)} <span className="muted" style={{fontWeight:400,fontSize:10}}>— judges currently prefer these</span></div>
                  <div style={{display:'flex',flexWrap:'wrap',gap:4}}>
                    {allVals.map(v=>{
                      const isPref=preferred.includes(v);
                      return <span key={v} className={`badge ${isPref?'bg':'bk'}`} style={{fontSize:10}}>{capitalize(v)}{isPref?' 🥇':''}</span>;
                    })}
                  </div>
                  <div className="muted" style={{fontSize:10,marginTop:2}}>Preferred values score a bonus. All others score equally.</div>
                </div>
              );
            })}
          </div>
        </div>
      )}
      {showTraitsInfo&&currentShow&&(
        <div className="card" style={{fontSize:12}}>
          <h3 className="sub" style={{marginBottom:6}}>Personality Show Bonuses</h3>
          <div style={{display:'flex',flexWrap:'wrap',gap:4}}>
            {[...PERS_TABLE].sort((a,b)=>b.sb-a.sb).map(p=>{
              const stars=p.sb>=5?'⭐⭐⭐':p.sb>=3?'⭐⭐':p.sb>=1?'⭐':p.sb<0?'✗':'';
              return(
                <span key={p.value} className="badge bk" style={{fontSize:10}}>
                  {p.e} {capitalize(p.value)}{stars?` ${stars}`:''}
                </span>
              );
            })}
          </div>
        </div>
      )}
      {currentShow&&(
        <div className="card" style={currentShow.isChampionship?{border:'2px solid var(--gold)',background:'linear-gradient(135deg,rgba(184,146,46,.08),rgba(255,215,0,.04))'}:undefined}>
          <h3 className="sub" style={{display:'flex',alignItems:'center',gap:8}}>
            {currentShow.isChampionship&&<span style={{background:'var(--gold)',color:'#fff',borderRadius:8,padding:'2px 8px',fontSize:11,fontWeight:900,letterSpacing:'0.05em',whiteSpace:'nowrap'}}>👑 NATIONAL CHAMPIONSHIP</span>}
            {currentShow.title}
          </h3>
          {currentShow.isChampionship&&<div style={{fontSize:12,color:'var(--gold)',fontWeight:700,marginBottom:4}}>The biggest show in the country — max-difficulty judges. Win glory for the city!</div>}
          {showSp&&<div className="muted" style={{marginBottom:4}}>Species: {showSp.icon} {showSp.name}</div>}
          <div className="muted" style={{marginBottom:6}}>Judged on: {currentShow.traits.map(t=>t==='displayColor'?'Color':capitalize(t)).join(', ')}</div>
          <div className="muted" style={{marginBottom:4}}>Prizes: {currentShow.prizes.map((p,i)=>['🥇','🥈','🥉'][i]+$(p)).join(' · ')}</div>
          <div className="muted" style={{marginBottom:4,fontSize:11}}>Entry fee: {$(flatFee)} per pet{currentShow.isChampionship?' (flat championship rate)':' (10% of 1st prize)'}</div>
          <div className="warn" style={{marginBottom:8}}>
            {isShowWeek
              ?<span><strong>🏆 Show is this week!</strong> Add up to 3 pets below.</span>
              :<span>Results in {weeksUntilJudging} weeks. Pets can be entered during the show week.</span>}
          </div>
        </div>
      )}

      {confirmEntry&&(
        <Modal onClose={()=>setConfirmEntry(null)}>
            <div style={{fontSize:28,marginBottom:6}}>🏆</div>
            <div style={{fontWeight:800,fontSize:15,marginBottom:8}}>Enter {confirmEntry.name} in the show?</div>
            <div className="muted" style={{marginBottom:16}}>This will make <strong>{confirmEntry.name}</strong> unavailable for other tasks this week.</div>
            <div style={{display:'flex',gap:8,justifyContent:'center'}}>
              <button className="btn btn-o" onClick={()=>setConfirmEntry(null)}>Cancel</button>
              <button className="btn btn-g" onClick={()=>{dispatch({type:A.ENTER_SHOW,id:confirmEntry.id});setConfirmEntry(null);}}>Yes, enter show</button>
            </div>
        </Modal>
      )}

      {currentShow&&(
        <>

          <PetSection icon="🏆" title="Participating" count={`${scheduled.length}/3`} defaultOpen={true}>
            {scheduled.map(pet=>{
              const fromCafe=(state.showEntryFromCafe||[]).includes(pet.id);
              return(
                <PetRow key={pet.id} pet={pet} currentTrend={state.currentTrend}
                  showJudgedTraits={currentShow.traits} hideUpkeepAge={true}
                  onClick={()=>onSelectPet&&onSelectPet(pet.id)}
                  statusLabel={fromCafe?'☕ From cafe':undefined}
                  actions={
                    <button className="btn btn-xs btn-r" onClick={e=>{e.stopPropagation();dispatch({type:A.REMOVE_ENTRY,id:pet.id});}}>Remove</button>
                  }
                />
              );
            })}
            {scheduled.length===0&&<EmptyState icon="🏆" title={isShowWeek?'No pets entered yet':'Too early to enter pets'} hint={isShowWeek?'Pick a pet from Available below and enter them before the judges arrive.':'You can add show entries starting the week the show opens.'}/>}
          </PetSection>

          <PetSection icon="🏠" title="Available" count={available.length||'0'} defaultOpen={true}>
            {available.map(pet=>{
              const inCafe=isInCafe(pet);
              return(
                <PetRow key={pet.id} pet={pet} currentTrend={state.currentTrend}
                  showJudgedTraits={currentShow.traits} hideUpkeepAge={true}
                  onClick={()=>onSelectPet&&onSelectPet(pet.id)}
                  statusLabel={inCafe?'☕ In cafe':undefined}
                  actions={showEntries.length<GAME_CONFIG.MAX_SHOW_ENTRIES?(()=>{
                    const canAfford=state.money>=flatFee;
                    const disabled=!canAfford||!isShowWeek;
                    const weeksToEntry=weeksUntilJudging-1;
                    const disabledMsg=!isShowWeek?`Entry opens in ${weeksToEntry} week${weeksToEntry!==1?'s':''} (show week only)`:!canAfford?`Not enough money (need ${$(flatFee)})`:'';
                    return(
                      <span style={{display:'inline-block',position:'relative'}}
                        onClick={disabled?e=>{e.stopPropagation();toast(disabledMsg);}:undefined}>
                        <button className="btn btn-xs btn-g" disabled={disabled} style={disabled?{pointerEvents:'none',opacity:0.5}:undefined}
                          onClick={e=>{e.stopPropagation();dispatch({type:A.ENTER_SHOW,id:pet.id});}}>
                          Enter {$(flatFee)}
                        </button>
                      </span>
                    );
                  })():undefined}
                />
              );
            })}
            {available.length===0&&<EmptyState icon="🪄" title={`No eligible ${showSp?showSp.name.toLowerCase():'pets'}`} hint="Pets must be adults and not busy (breeding, sick, or already entered) to compete."/>}
          </PetSection>

          <PetSection icon="⏳" title="Busy" count={busy.length} defaultOpen={false}>
            {busy.map(pet=>(
              <PetRow key={pet.id} pet={pet} currentTrend={state.currentTrend}
                showJudgedTraits={currentShow.traits} hideUpkeepAge={true}
                onClick={()=>onSelectPet&&onSelectPet(pet.id)}
                statusLabel={busyStatus(pet)}
              />
            ))}
            {busy.length===0&&<EmptyState icon="😌" title="Everyone's free" hint="Pets currently breeding, sick, or entered in a show would show up here."/>}
          </PetSection>
        </>
      )}

      {trophies.filter(t=>t.placement<=3).length>0&&(
        <div className="card">
          <h3 className="sub">Trophy Cabinet</h3>
          {[...trophies]
            .filter(t=>t.placement<=3)
            .sort((a,b)=>a.placement!==b.placement?a.placement-b.placement:b.week-a.week)
            .slice(0,20)
            .map((t,i)=>(
            <div key={i} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',borderBottom:'1px solid var(--border)',fontSize:13}}>
              <span>{['🥇','🥈','🥉'][t.placement-1]} {t.petName} — {t.showTitle}</span>
              <span style={{display:'flex',gap:8,alignItems:'center'}}>
                <span className="muted" style={{fontSize:11}}>Yr {Time.year(t.week)}</span>
                <span className="tg">{$(t.prize)}</span>
              </span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── UPGRADE PANEL ────────────────────────────────────────────────────────────
function Fireworks({onDone}) {
  const COLORS=['#ff4444','#ff8c00','#ffd700','#44cc44','#4488ff','#cc44cc','#ff69b4','#00cccc'];
  const particles=useMemo(()=>{
    const ps=[];
    for(let b=0;b<8;b++){
      const cx=15+Math.random()*70, cy=10+Math.random()*50;
      const delay=b*0.18;
      for(let i=0;i<14;i++){
        const angle=(i/14)*Math.PI*2;
        const dist=40+Math.random()*60;
        const fx=Math.round(Math.cos(angle)*dist), fy=Math.round(Math.sin(angle)*dist);
        const size=4+Math.random()*5;
        const col=COLORS[Math.floor(Math.random()*COLORS.length)];
        const dur=(0.8+Math.random()*0.6).toFixed(2);
        ps.push({cx,cy,fx,fy,size,col,dur,delay});
      }
    }
    return ps;
  },[]);
  useEffect(()=>{const t=setTimeout(onDone,2400);return()=>clearTimeout(t);},[]);
  return (
    <div className="fireworks-overlay">
      {particles.map((p,i)=>(
        <div key={i} className="fw-particle" style={{
          left:`${p.cx}%`,top:`${p.cy}%`,
          width:p.size,height:p.size,
          background:p.col,
          '--fx':`${p.fx}px`,'--fy':`${p.fy}px`,
          '--fd':`${p.dur}s`,
          animationDelay:`${p.delay}s`,
        }}/>
      ))}
    </div>
  );
}

function UpgradeTierList({tiers, currentLevel, money, actionType, dispatch, setShowFireworks, labelFn}) {
  const toast = useToast();
  return Object.entries(tiers).map(([lv,info])=>{
    const lvn=+lv;
    const isCur=lvn===currentLevel,isDone=lvn<currentLevel,isNext=lvn===currentLevel+1;
    return (
      <div key={lv} style={{border:`2px solid ${isCur?'var(--green)':'var(--border)'}`,borderRadius:10,overflow:'hidden',marginBottom:4}}>
        <div style={{padding:'7px 10px',background:isCur?'var(--green)':isDone?'var(--border)':'var(--wall)',color:isCur?'#fff':'var(--text)',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
          <span style={{fontWeight:800,fontSize:12}}>Lv{lv} — {info.name}</span>
          {isCur&&<span className="badge" style={{background:'rgba(255,255,255,.3)',color:'#fff',fontSize:9}}>Current</span>}
          {isDone&&<span style={{fontSize:12}}>✓</span>}
        </div>
        <div style={{padding:'6px 10px',background:'var(--card)'}}>
          <div className="muted" style={{fontSize:11,marginBottom:4}}>{labelFn(info,lvn)}</div>
          {isNext&&(
            <div style={{display:'flex',alignItems:'center',gap:8}}>
              <span style={{fontWeight:800,fontSize:14,color:money>=info.cost?'var(--green)':'var(--red)'}}>
                {$(info.cost)}{money<info.cost&&<span style={{fontSize:10,fontWeight:600,marginLeft:6}}>Need {$(info.cost-money)} more</span>}
              </span>
              <span style={{display:'inline-block',position:'relative'}} onClick={money<info.cost?()=>toast(`Need ${$(info.cost-money)} more`):undefined}>
                <button className="btn btn-xs btn-p" disabled={money<info.cost} style={money<info.cost?{pointerEvents:'none'}:undefined}
                  onClick={()=>{dispatch({type:actionType});if(money>=info.cost)setShowFireworks(true);}}>
                  Upgrade
                </button>
              </span>
            </div>
          )}
        </div>
      </div>
    );
  });
}

function UpgradePanel({state, dispatch}) {
  const {cafeLevel,money}=state;
  const petHouseLevel=state.petHouseLevel||1;
  const [showFireworks,setShowFireworks]=useState(false);

  return (
    <div className="panel">
      {showFireworks&&<Fireworks onDone={()=>setShowFireworks(false)}/>}
      <div className="between"><h2 className="sh">Upgrades</h2><MilestoneButton state={state} pageFilter="upg" label="🏅" autoOpen={state.openMilestonesPanel==='upg'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/></div>

      {/* Two-column layout on wider screens */}
      <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:10}}>
        <div>
          <div style={{fontWeight:800,fontSize:13,marginBottom:6,color:'var(--text)'}}>☕ Cafe <span className="muted" style={{fontWeight:400,fontSize:11}}>Lv{cafeLevel}/6</span></div>
          <UpgradeTierList tiers={CAFE_UPGRADES} currentLevel={cafeLevel} money={money} actionType={A.UPGRADE_CAFE} dispatch={dispatch} setShowFireworks={setShowFireworks}
            labelFn={(info,lv)=>{const extra=info.maxStaff-1;return`${info.floor} cafe pets${extra>0?` · ${extra} cafe staff`:''} · ${getMaxMenuPerCat(lv)} menu items per category`;}}/>
        </div>
        <div>
          <div style={{fontWeight:800,fontSize:13,marginBottom:6,color:'var(--text)'}}>🏠 Pet House <span className="muted" style={{fontWeight:400,fontSize:11}}>Lv{petHouseLevel}/10</span></div>
          <UpgradeTierList tiers={PET_HOUSE_UPGRADES} currentLevel={petHouseLevel} money={money} actionType={A.UPGRADE_PET_HOUSE} dispatch={dispatch} setShowFireworks={setShowFireworks}
            labelFn={info=>`${info.pen} pet slots${info.staffBonus>0?` · ${info.staffBonus} pet house staff`:''}`}/>
        </div>
      </div>
    </div>
  );
}

// ─── SUMMARY PANEL (Home/Dashboard) ──────────────────────────────────────────
// ─── MILESTONE MODAL ─────────────────────────────────────────────────────────
function MilestoneModal({state, pageFilter, onClose}) {
  const completedSet=new Set(state.completedMilestoneIds||[]);
  const items=MILESTONES
    .filter(m=>!pageFilter||m.page===pageFilter)
    .map(m=>{const d=m.check(state);return{...m,...d,reached:completedSet.has(m.id)||d.reached};})
    .sort((a,b)=>{
      if(a.reached&&!b.reached)return 1;
      if(!a.reached&&b.reached)return -1;
      if(!a.reached&&!b.reached)return b.progress-a.progress;
      return 0;
    });
  return(
    <Modal onClose={onClose} style={{maxWidth:380,maxHeight:'80vh',overflow:'hidden',display:'flex',flexDirection:'column',padding:'20px 16px'}}>
        <div style={{fontWeight:800,fontSize:16,marginBottom:10,textAlign:'center'}}>🏅 Milestones</div>
        <div style={{flex:1,overflowY:'auto',display:'flex',flexDirection:'column',gap:6}}>
          {items.map(m=>(
            <div key={m.id} style={{background:m.reached?'rgba(106,158,92,.1)':'var(--wall)',borderRadius:10,padding:'8px 10px',border:`1.5px solid ${m.reached?'var(--green)':'var(--border)'}`}}>
              <div style={{display:'flex',alignItems:'center',gap:6}}>
                <span style={{fontSize:16}}>{m.reached?'✅':m.icon}</span>
                <div style={{flex:1,minWidth:0}}>
                  <div style={{fontWeight:700,fontSize:12,color:m.reached?'var(--green)':'var(--text)',textAlign:'left'}}>{m.name} <span style={{fontSize:9,fontWeight:700,color:m.reached?'var(--green)':'var(--orange)'}}>{m.pts?`+${m.pts} pts`:''}</span></div>
                  <div style={{fontSize:10,color:'var(--muted)',textAlign:'left'}}>{m.desc}</div>
                </div>
              </div>
              {!m.reached&&<div style={{display:'flex',alignItems:'center',gap:6,marginTop:4}}>
                <div style={{flex:1,height:4,background:'var(--border)',borderRadius:2,overflow:'hidden'}}>
                  <div style={{width:`${Math.round(m.progress*100)}%`,height:'100%',background:m.progress>0.7?'var(--orange)':m.progress>0.3?'var(--blue)':'var(--muted)',borderRadius:2,transition:'width .3s'}}/>
                </div>
                <span style={{fontSize:10,fontWeight:700,color:'var(--muted)',whiteSpace:'nowrap'}}>{m.label}</span>
              </div>}
            </div>
          ))}
        </div>
        <button className="btn btn-o" style={{width:'100%',marginTop:10}} onClick={onClose}>Close</button>
    </Modal>
  );
}

function MilestoneButton({state, pageFilter, label, autoOpen=false, onAutoOpenDone}) {
  const [show,setShow]=useState(false);
  React.useEffect(()=>{
    if(autoOpen){setShow(true);if(onAutoOpenDone)onAutoOpenDone();}
  },[autoOpen]);
  return(<>
    <button className="btn btn-sm btn-o" onClick={()=>setShow(true)}>{label||'🏅 Milestones'}</button>
    {show&&<MilestoneModal state={state} pageFilter={pageFilter} onClose={()=>setShow(false)}/>}
  </>);
}

function getTip(state) {
  const {pets,staff,cafeLevel,week,activeMenuIds,assignedPetIds,rdBudget,money,breedingQueue,showEntries,currentShow}=state;
  // R&D budget is 0
  const totalRD=(rdBudget?.drinks||0)+(rdBudget?.food||0)+(rdBudget?.desserts||0);
  if(totalRD===0&&week>2)
    return{icon:'🔬',panel:'menu',text:'Your R&D budget is $0 — tap to invest in R&D and discover new recipes.'};
  // No breeding pair: check if any species has M+F available
  if(pets.length>=2&&week>4){
    const species=new Set(pets.map(p=>p.species));
    let hasPair=false;
    for(const sp of species){
      const spPets=pets.filter(p=>p.species===sp&&!isPetBusy(p,breedingQueue,showEntries,0));
      if(spPets.some(p=>p.gender==='male')&&spPets.some(p=>p.gender==='female')){hasPair=true;break;}
    }
    if(!hasPair)
      return{icon:'🧬',panel:'pets',text:'You have no available breeding pair — tap to manage your pets and adopt a mate.'};
  }
  // Can afford upgrade
  if(cafeLevel<GAME_CONFIG.MAX_CAFE_LEVEL&&money>=CAFE_UPGRADES[cafeLevel+1]?.cost)
    return{icon:'⬆️',panel:'upg',text:'You can afford a cafe upgrade! Tap to expand your capacity.'};
  if((state.petHouseLevel||1)<10&&money>=PET_HOUSE_UPGRADES[(state.petHouseLevel||1)+1]?.cost)
    return{icon:'🏠',panel:'upg',text:'You can afford a pet house upgrade! Tap to expand your pet capacity.'};
  // No training done after 8 weeks
  if(week>8&&staff.length>0){
    const totalSkill=staff.reduce((s,st)=>s+Object.values(st.skills||{}).reduce((a,b)=>a+b,0),0);
    const baseSkill=staff.length*Object.keys(SKILL_NAMES).length;
    if(totalSkill<=baseSkill)
      return{icon:'👩‍🍳',panel:'staff',text:'You haven\'t trained any staff yet — tap to upgrade their skills and boost your cafe.'};
  }
  return null;
}

// ─── SUMMARY PANEL SUB-COMPONENTS ───────────────────────────────────────────

function TrendRumorCard({rumor, dispatch}) {
  if (!rumor) return null;
  if (!rumor.resolved) return (
    <div className="card" style={{borderColor:'var(--gold)',background:'linear-gradient(90deg,rgba(212,175,55,.10),rgba(212,175,55,.04))',marginBottom:8}}>
      <h3 className="sub" style={{display:'flex',alignItems:'center',gap:6}}>🗣️ Rumor from {NPCS[rumor.sourceNpcId]?.name||'a regular'}</h3>
      <div style={{fontSize:12,fontStyle:'italic',color:'var(--text)'}}>&ldquo;I hear customers will love {describeTrendPrediction(rumor.predicted)} next month.&rdquo;</div>
      <div className="muted" style={{fontSize:10,marginTop:4}}>Rumors are sometimes wrong.</div>
    </div>
  );
  if (rumor.resolved && !rumor.shown) return (
    <div className="card" style={{borderColor:rumor.accurate?'var(--green)':rumor.partiallyAccurate?'var(--gold)':'var(--red)',marginBottom:8}}>
      <h3 className="sub">🗣️ {NPCS[rumor.sourceNpcId]?.name||'A regular'} — {rumor.accurate?'✅ right':rumor.partiallyAccurate?'〜 partially right':'❌ wrong'}</h3>
      <div style={{fontSize:12,color:'var(--text)'}}>
        {rumor.accurate
          ? `The rumor was accurate — ${describeTrendPrediction(rumor.actual||rumor.predicted)} are indeed trending.`
          : rumor.partiallyAccurate
          ? `Close, but not quite — the actual trend is ${describeTrendPrediction(rumor.actual||rumor.predicted)}. They had part of it right.`
          : `Turns out the trend is ${describeTrendPrediction(rumor.actual||rumor.predicted)}. Rumors, eh?`}
      </div>
      <button className="btn btn-sm btn-o" style={{marginTop:6}} onClick={()=>dispatch({type:A.DISMISS_RUMOR})}>Got it</button>
    </div>
  );
  return null;
}

function RegularBeatsCard({beats}) {
  if (!beats || beats.length===0) return null;
  return (
    <div className="card" style={{background:'linear-gradient(90deg,rgba(140,98,64,.07),rgba(140,98,64,.03))',marginBottom:8}}>
      <h3 className="sub">☕ Regulars this week</h3>
      {beats.map((b,i)=>(
        <div key={i} style={{fontSize:12,lineHeight:1.5,marginBottom:3,color:'var(--text)'}}>
          <span style={{marginRight:6}}>{b.icon}</span>{b.line}
        </div>
      ))}
    </div>
  );
}

function NpcAffinityStrip({npcs, npcBuffs}) {
  if (!npcs || !Object.keys(npcs).some(id=>(npcs[id]?.affinity||0)>0)) return null;
  return (
    <div className="card" style={{marginBottom:8}}>
      <h3 className="sub">🤝 City Friends</h3>
      <div style={{display:'flex',flexDirection:'column',gap:6}}>
        {NPC_IDS.map(id=>{
          const npcState=npcs[id]; if(!npcState||!npcState.affinity)return null;
          const def=NPCS[id];
          const pct=(npcState.affinity/MAX_AFFINITY)*100;
          const buffOn=(npcBuffs||{})[id];
          return (
            <div key={id} style={{display:'flex',alignItems:'center',gap:8}}>
              <span style={{fontSize:18}}>{def.icon}</span>
              <div style={{flex:1,minWidth:0}}>
                <div style={{fontSize:11,fontWeight:700,display:'flex',justifyContent:'space-between'}}>
                  <span>{def.name}</span>
                  <span className="muted" style={{fontSize:10}}>{npcState.affinity}/{MAX_AFFINITY}{buffOn?` · ${def.perkLabel}`:''}</span>
                </div>
                <div style={{height:5,background:'var(--border)',borderRadius:3,overflow:'hidden'}}>
                  <div style={{width:`${pct}%`,height:'100%',background:buffOn?'var(--green)':'var(--gold)',transition:'width .4s'}}/>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function SummaryPanel({state, dispatch, onSelectPet}) {
  const {weekSummary,week,pets,staff,cafeLevel,assignedPetIds,activeMenuIds,allMenuItems,currentShow,showEntries,currentMenuTrends,purchaseOffers,cafeName}=state;
  // Play milestone sound only once per week (not on every re-mount from navigation)
  const milestoneSoundWeekRef=useRef(-1);
  useEffect(()=>{
    if(milestoneSoundWeekRef.current===week) return;
    if(weekSummary?.newMilestones?.length>0||weekSummary?.showResults?.results?.some(r=>r.placement===1)){
      milestoneSoundWeekRef.current=week;
      if(weekSummary?.newMilestones?.length>0) setTimeout(()=>SFX.milestone(),300);
      if(weekSummary?.showResults?.results?.some(r=>r.placement===1)) setTimeout(()=>SFX.showWin(),500);
    }
  },[week]);
  const cap=UPGRADES[cafeLevel];
  const maxPetsCap=getMaxPets(state);
  const maxStaffCap=getMaxStaff(state);
  const maxMenuPerCat=getMaxMenuPerCat(cafeLevel);
  // Pre-compute best/worst moments for use in both Results and Highlights sections
  const _hlEvts=(state.weekResult?.events||weekSummary?.events||[]);
  const _bestEvt=(()=>{const pos=_hlEvts.filter(e=>e.tipsDelta>0&&!e.isNegative);return pos.length>0?pos.reduce((a,b)=>b.tipsDelta>a.tipsDelta?b:a):null;})();
  const _worstEvt=(()=>{const neg=_hlEvts.filter(e=>e.tipsDelta<0||e.isNegative);return neg.length>0?neg.reduce((a,b)=>b.tipsDelta<a.tipsDelta?b:a):null;})();
  const weeksUntilJudging=currentShow?Time.weeksUntilJudging(week):0;
  const showWarning=weeksUntilJudging===1&&currentShow&&(showEntries||[]).length===0;
  const activeCount=Object.values(activeMenuIds).flat().length;

  return (
    <div className="panel">
      <div className="between"><div/><MilestoneButton state={state} label="🏅 Milestones" autoOpen={state.openMilestonesPanel==='home'} onAutoOpenDone={()=>dispatch({type:A.CLEAR_MILESTONE_OPEN})}/></div>
      {/* In debt warning — shown at very top */}
      {(state.debtWeeks||0)>0&&(
        <div className="warn" style={{borderColor:'var(--red)',background:'#fff0ee',color:'#c00'}}>
          💸 In debt — {GAME_CONFIG.DEBT_WEEKS_TO_BANKRUPTCY-(state.debtWeeks||0)} week{GAME_CONFIG.DEBT_WEEKS_TO_BANKRUPTCY-(state.debtWeeks||0)!==1?'s':''} left before the cafe closes!
        </div>
      )}
      {/* Seasonal event banner */}
      {state.activeSeasonalEvent&&(
        <div className="card" style={{background:'linear-gradient(90deg,rgba(255,193,7,.13),rgba(255,152,0,.07))',border:'1.5px solid rgba(255,193,7,.45)',marginBottom:8}}>
          <div style={{display:'flex',alignItems:'center',gap:10}}>
            <span style={{fontSize:28}}>{state.activeSeasonalEvent.icon}</span>
            <div>
              <div style={{fontWeight:800,fontSize:13,color:'var(--orange)'}}>{state.activeSeasonalEvent.name}</div>
              <div style={{fontSize:12,color:'var(--text-muted)'}}>{state.activeSeasonalEvent.description}</div>
            </div>
          </div>
        </div>
      )}
      {/* TV Crew Filming banner */}
      {state.activeTvCrew&&(
        <div className="card" style={{background:'linear-gradient(90deg,rgba(74,126,184,.13),rgba(74,126,184,.06))',border:'1.5px solid var(--blue)',marginBottom:8}}>
          <div style={{display:'flex',alignItems:'center',gap:10}}>
            <span style={{fontSize:24}}>📺</span>
            <div style={{flex:1}}>
              <div style={{fontWeight:800,fontSize:13,color:'var(--blue)'}}>TV Crew Filming!</div>
              <div style={{fontSize:12,color:'var(--muted)'}}>
                Need 3+ {(SPECIES[state.activeTvCrew.targetSpecies]?.name||state.activeTvCrew.targetSpecies).toLowerCase()}s with <strong>{state.activeTvCrew.targetTraits.join(', ')}</strong> on the cafe floor
              </div>
              <div style={{fontSize:11,color:'var(--blue)',marginTop:2}}>
                Deadline: Week {state.activeTvCrew.deadlineWeek} ({Math.max(0,state.activeTvCrew.deadlineWeek-week)} week{Math.max(0,state.activeTvCrew.deadlineWeek-week)!==1?'s':''} left) — Reward: $15,000 + 10% customer boost
              </div>
            </div>
          </div>
        </div>
      )}
      {/* TV Crew Buff active indicator */}
      {(state.tvCrewBuff||1)>1&&(
        <div className="card" style={{background:'linear-gradient(90deg,rgba(74,126,184,.08),rgba(74,126,184,.04))',border:'1px solid var(--blue)',marginBottom:8,padding:'6px 12px'}}>
          <span style={{fontSize:12,color:'var(--blue)',fontWeight:700}}>📺 City Fame: +10% customers (TV appearance)</span>
        </div>
      )}
      {/* Loyalty Buff active indicator */}
      {(state.loyaltyBuff||1)>1&&(
        <div className="card" style={{background:'linear-gradient(90deg,rgba(90,144,72,.08),rgba(90,144,72,.04))',border:'1px solid var(--green)',marginBottom:8,padding:'6px 12px'}}>
          <span style={{fontSize:12,color:'var(--green)',fontWeight:700}}>🤝 Community Loyalty: +5% tip bonus (refused celebrity offer)</span>
        </div>
      )}
      <TrendRumorCard rumor={state.trendRumor} dispatch={dispatch}/>
      <NpcAffinityStrip npcs={state.npcs} npcBuffs={state.npcBuffs}/>
      {/* Purchase offers — shown at top for visibility */}
      {(purchaseOffers||[]).length>0&&(
        <div className="card" style={{border:'2px solid var(--gold)',background:'rgba(184,146,46,.06)'}}>
          <h3 className="sub" style={{color:'var(--gold)',marginBottom:6}}>💰 Purchase Offers!</h3>
          {(purchaseOffers||[]).map(offer=>(
            <div key={offer.id} style={{display:'flex',alignItems:'center',gap:8,marginBottom:6,padding:'6px 8px',background:'var(--wall)',borderRadius:8}}>
              <div style={{flex:1}}>
                <div style={{fontWeight:800,fontSize:12}}>{offer.petName} <span className="muted" style={{fontWeight:400}}>({offer.buyer})</span></div>
                <div style={{fontSize:11,fontWeight:700}}>
                  <span style={{color:'var(--gold)'}}>{$(offer.price)}</span>
                  {(()=>{const pet=pets.find(p=>p.id===offer.petId);const normal=pet?Math.round(Econ.salePrice(pet)):null;return normal?<span className="muted" style={{fontWeight:400}}> · normal {$(normal)}</span>:null;})()}
                </div>
              </div>
              <button className="btn btn-xs btn-g" onClick={()=>dispatch({type:A.ACCEPT_PURCHASE_OFFER,id:offer.id})}>Sell</button>
              <button className="btn btn-xs btn-r" onClick={()=>dispatch({type:A.DECLINE_PURCHASE_OFFER,id:offer.id})}>Decline</button>
            </div>
          ))}
        </div>
      )}
      {/* Weekly Highlights — shown above results */}
      {weekSummary&&week>0&&state.weekResult&&(()=>{
        const wr=state.weekResult;
        const tipsEntries=Object.entries(wr.petTipsMap||{});
        let topEarner=null;
        if(tipsEntries.length>0){
          const mishapCostByPet={};
          for(const e of (wr.events||[])){if(e.isMishap&&e.petId&&e.tipsDelta<0) mishapCostByPet[e.petId]=(mishapCostByPet[e.petId]||0)+e.tipsDelta;}
          const netEntries=tipsEntries.map(([id,amt])=>[id,amt+(mishapCostByPet[id]||0)]);
          const [topId,topNet]=netEntries.reduce((a,b)=>b[1]>a[1]?b:a);
          const topPet=pets.find(p=>p.id===topId);
          if(topNet>0) topEarner={name:topPet?.name||'Unknown',amount:topNet};
        }
        const salesEntries=Object.entries(wr.itemSales||{});
        let crowdFav=null;
        if(salesEntries.length>0){
          const [favId,favData]=salesEntries.reduce((a,b)=>b[1].sold>a[1].sold?b:a);
          const favItem=allMenuItems.find(i=>i.id===favId);
          crowdFav={name:favItem?.name||'Unknown',sold:favData.sold};
        }
        const bestEvt=_bestEvt,worstEvt=_worstEvt;
        if(!topEarner&&!crowdFav&&!bestEvt)return null;
        const cb=wr.customerBreakdown||{};
        const hasCB=cb.regular||cb.tourist||cb.influencer||cb.critic;
        return(
          <div className="card" style={{marginTop:0}}>
            <h3 className="sub" style={{marginBottom:6}}>✨ Week {week} Highlights</h3>
            {hasCB&&(
              <div style={{fontSize:11,color:'var(--muted)',marginBottom:6,padding:'4px 6px',background:'var(--wall)',borderRadius:6}}>
                👥 {cb.regular||0}× 🧑 regular · {cb.tourist||0}× 🎒 tourist · {cb.influencer||0}× 📸 influencer · {cb.critic||0}× 📝 critic
              </div>
            )}
            <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:6}}>
              {topEarner&&(
                <div className="stat-box" style={{textAlign:'left',padding:'6px 8px'}}>
                  <div style={{fontSize:11,color:'var(--muted)',marginBottom:2}}>🏆 Top Earner</div>
                  <div style={{fontWeight:700,fontSize:12,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{topEarner.name}</div>
                  <div style={{fontSize:12,color:'var(--green)'}}>{$(topEarner.amount)}</div>
                </div>
              )}
              {crowdFav&&(
                <div className="stat-box" style={{textAlign:'left',padding:'6px 8px'}}>
                  <div style={{fontSize:11,color:'var(--muted)',marginBottom:2}}>🍰 Crowd Fave</div>
                  <div style={{fontWeight:700,fontSize:12,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{crowdFav.name}</div>
                  <div style={{fontSize:12,color:'var(--muted)'}}>{crowdFav.sold} sold</div>
                </div>
              )}
              {bestEvt&&(
                <div className="stat-box" style={{textAlign:'left',padding:'6px 8px'}}>
                  <div style={{fontSize:11,color:'var(--muted)',marginBottom:2}}>⭐ Best Moment</div>
                  <div style={{fontSize:11,lineHeight:1.35}}>{bestEvt.message}</div>
                  <div style={{fontSize:11,color:bestEvt.tipsDelta>0?'var(--green)':bestEvt.tipsDelta<0?'var(--red)':'var(--muted)'}}>{bestEvt.tipsDelta>0?'+':''}{$(bestEvt.tipsDelta)}</div>
                </div>
              )}
              <div className="stat-box" style={{textAlign:'left',padding:'6px 8px'}}>
                <div style={{fontSize:11,color:'var(--muted)',marginBottom:2}}>😬 Worst Moment</div>
                {worstEvt?(
                  <>
                    <div style={{fontSize:11,lineHeight:1.35}}>{worstEvt.message}</div>
                    <div style={{fontSize:11,color:worstEvt.tipsDelta>0?'var(--green)':worstEvt.tipsDelta<0?'var(--red)':'var(--muted)'}}>{worstEvt.tipsDelta>0?'+':''}{$(worstEvt.tipsDelta)}</div>
                  </>
                ):(
                  <div style={{fontSize:11,color:'var(--muted)'}}>Quiet week</div>
                )}
              </div>
            </div>
          </div>
        );
      })()}
      {/* Weekly summary */}
      {weekSummary&&week>0?(
        <div className="card">
          <h3 className="sub">📊 Week {week} Results</h3>
          <div className="stat-grid">
            <div className="stat-box"><div className={`sv ${weekSummary.net>=0?'tg':'tr'}`}>{$(weekSummary.net)}</div><div className="sl">Net Profit</div></div>
            <div className="stat-box"><div className="sv">{weekSummary.customers}</div><div className="sl">Customers</div></div>
            <div className="stat-box"><div className="sv tg">{$(weekSummary.menuRevenue)}</div><div className="sl">Menu Sales</div></div>
            <div className="stat-box"><div className="sv tg">{$(weekSummary.tipRevenue)}</div><div className="sl">Pet Tips</div></div>
          </div>
          <div style={{marginTop:6,fontSize:12}}>
            <div className="between"><span className="muted">Cost of Goods</span><span className="tr">-{$(weekSummary.costOfGoods)}</span></div>
            <div className="between"><span className="muted">Staff Wages</span><span className="tr">-{$(weekSummary.staffWages)}</span></div>
            <div className="between"><span className="muted">Operating Costs</span><span className="tr">-{$(weekSummary.operatingCost)}</span></div>
            <div className="between"><span className="muted">Pet Upkeep</span><span className="tr">-{$(weekSummary.petUpkeep||0)}</span></div>
            {(weekSummary.rdCost||0)>0&&<div className="between"><span className="muted">R&D</span><span className="tr">-{$(weekSummary.rdCost)}</span></div>}
            {(weekSummary.recCost||0)>0&&<div className="between"><span className="muted">Recruitment</span><span className="tr">-{$(weekSummary.recCost)}</span></div>}
            {weekSummary.trendBonus>0&&<div className="between"><span className="muted">🔥 Trend Bonus</span><span className="tg">+{$(weekSummary.trendBonus)}</span></div>}
          </div>
          {weekSummary.showResults&&(
            <div style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6}}>
              <div style={{fontWeight:700,fontSize:12,marginBottom:4}}>🏆 {weekSummary.showResults.title}</div>
              {weekSummary.showResults.results.map((r,i)=>(
                <div key={i} className="between" style={{fontSize:12}}>
                  <span>{['🥇','🥈','🥉','4th','5th'][r.placement-1]||`${r.placement}th`} {r.petName}</span>
                  <span className={r.prize>0?'tg':'muted'}>{r.prize>0?`+${$(r.prize)}`:'No prize'}</span>
                </div>
              ))}
            </div>
          )}
          {weekSummary.newlyDiscovered?.length>0&&(
            <div style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6}}>
              {weekSummary.newlyDiscovered.map(id=>{
                const item=allMenuItems.find(i=>i.id===id);
                if(!item)return null;
                return(
                  <div key={id} style={{display:'flex',alignItems:'center',gap:6,fontSize:12}}>
                    <span style={{fontSize:16}}>🔬</span>
                    <span><strong>New recipe discovered:</strong> {item.name}</span>
                  </div>
                );
              })}
            </div>
          )}
          {weekSummary.upgradedItems?.length>0&&(
            <div style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6}}>
              {weekSummary.upgradedItems.map((item,i)=>(
                <div key={i} style={{display:'flex',alignItems:'center',gap:6,fontSize:12}}>
                  <span style={{fontSize:16}}>⭐</span>
                  <span><strong>{item.name}</strong> upgraded to {'★'.repeat(item.tier)}</span>
                </div>
              ))}
            </div>
          )}
          {weekSummary.newMilestones?.length>0&&(
            <div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8,display:'flex',flexDirection:'column',gap:6}}>
              {weekSummary.newMilestones.map(m=>(
                <div key={m.id} style={{background:'linear-gradient(90deg,rgba(255,193,7,.15),rgba(255,152,0,.08))',border:'1.5px solid rgba(255,193,7,.5)',borderRadius:10,padding:'8px 12px',display:'flex',alignItems:'center',gap:8}}>
                  <span style={{fontSize:20}}>{m.icon}</span>
                  <div style={{flex:1}}>
                    <div style={{fontWeight:800,fontSize:12,color:'var(--orange)'}}>🏅 Milestone Unlocked!</div>
                    <div style={{fontWeight:700,fontSize:12}}>{m.name}</div>
                  </div>
                  <span style={{fontWeight:800,fontSize:13,color:'var(--orange)',whiteSpace:'nowrap'}}>+{m.pts} pts</span>
                </div>
              ))}
            </div>
          )}
          {(()=>{
            const evtsFiltered=(weekSummary.events||[]).filter(e=>e!==_bestEvt&&e!==_worstEvt);
            return evtsFiltered.length>0?(
              <div style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6}}>
                {evtsFiltered.map((e,i)=>(
                  <div key={i} style={e.isRare?{fontSize:12,marginBottom:4,padding:'4px 8px',background:'linear-gradient(90deg,rgba(255,200,60,.18),rgba(255,160,40,.08))',border:'1px solid #e0a830',borderRadius:6,fontWeight:700,color:'#8a6200'}:{fontSize:11,marginBottom:2,color:'var(--muted)'}}>
                    {e.isRare?'':'• '}{e.message} {e.tipsDelta>0?<span className="tg">+{$(e.tipsDelta)}</span>:e.tipsDelta<0?<span className="tr">{$(e.tipsDelta)}</span>:null}
                  </div>
                ))}
              </div>
            ):null;
          })()}
          {weekSummary.trendChanged&&weekSummary.newTrend&&(
            <div style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6,background:'linear-gradient(90deg,rgba(255,180,60,.12),rgba(255,120,60,.08))',borderRadius:8,padding:'8px 10px',border:'1.5px solid var(--orange)'}}>
              <div style={{fontWeight:800,fontSize:13,color:'var(--orange)',marginBottom:3}}>🔥 New Pet Trend This Month!</div>
              <div style={{fontSize:12}}>{weekSummary.newTrend.description}</div>
              <div className="muted" style={{fontSize:11,marginTop:2}}>Tip bonus ×{weekSummary.newTrend.bonusMultiplier} for matching pets</div>
            </div>
          )}
          {weekSummary.newMenuTrends?.length>0&&(
            <div className="trend-news-card" style={{marginTop:6,borderTop:'1px solid var(--border)',paddingTop:6,background:'linear-gradient(90deg,rgba(92,158,160,.10),rgba(60,140,160,.06))',borderRadius:8,padding:'8px 10px',border:'1.5px solid var(--blue)'}}>
              <div style={{fontWeight:800,fontSize:13,color:'var(--blue)',marginBottom:4}}>📰 This Month's Market News</div>
              {weekSummary.newMenuTrends.map((t,i)=>(
                <div key={i} style={{display:'flex',gap:6,alignItems:'flex-start',marginBottom:i<weekSummary.newMenuTrends.length-1?4:0}}>
                  <span style={{fontSize:14}}>{t.icon}</span>
                  <div style={{fontSize:11,lineHeight:1.4}}>{t.text}</div>
                </div>
              ))}
            </div>
          )}
        </div>
      ):(
        <div className="card" style={{textAlign:'center',padding:20}}>
          <div style={{fontSize:36}}>🐾<CoffeeCupSVG size={36} style={{color:'var(--orange)'}}/></div>
          <div style={{fontWeight:800,fontSize:16,color:'var(--orange)',marginTop:8}}>Welcome to {cafeName||'Pet Cafe'}!</div>
          <div className="muted" style={{marginTop:4}}>Set up your cafe and click <strong>Next Week</strong> to begin.</div>
        </div>
      )}
      <RegularBeatsCard beats={state.regularBeats}/>

      {/* "One more week" teasers — moved ABOVE quick status */}
      {week>0&&(()=>{
        const teasers=[];
        // Breeding due soon
        const bq=state.breedingQueue||[];
        bq.forEach(b=>{
          const mom=pets.find(p=>p.id===b.motherId);
          if(mom&&b.weeksLeft<=1) teasers.push(`🍼 ${mom.name}'s baby arrives next week!`);
          else if(mom&&b.weeksLeft<=2) teasers.push(`🤰 ${mom.name} is due in ${b.weeksLeft} weeks!`);
        });
        // Pregnant pets about to give birth
        pets.forEach(p=>{
          if(p.state.pregnant===1) teasers.push(`🍼 ${p.name}'s baby arrives next week!`);
          else if(p.state.pregnant===2) teasers.push(`🤰 ${p.name} is due in 2 weeks!`);
        });
        // Show coming up. weeksToShow===1 IS the show week (handled by showWarning above),
        // so weeksToShow===2 means one week away, weeksToShow===3 means two weeks away.
        const weeksToShow=GAME_CONFIG.SHOW_INTERVAL_WEEKS-((week)%GAME_CONFIG.SHOW_INTERVAL_WEEKS);
        if(weeksToShow===2) teasers.push(`🏆 Pet Show next week — get ready to enter your best pets!`);
        else if(weeksToShow===3) teasers.push(`🏆 Pet Show in 2 weeks!`);
        // New trend arriving (last week of month only)
        if((week+1)%GAME_CONFIG.SHOW_INTERVAL_WEEKS===0) teasers.push('📰 New market trends arriving next week!');
        // Pet retirement soon
        pets.forEach(p=>{
          if(p.state.retiring===1) teasers.push(`🌅 ${p.name} retires next week — say goodbye!`);
        });
        if(teasers.length===0) return null;
        return(
          <div style={{background:'linear-gradient(135deg,rgba(74,126,184,.06),rgba(90,144,72,.06))',border:'1.5px solid rgba(74,126,184,.2)',borderRadius:10,padding:'8px 10px'}}>
            <div style={{fontSize:10,fontWeight:700,color:'var(--blue)',marginBottom:4}}>COMING UP</div>
            {teasers.slice(0,3).map((t,i)=><div key={i} style={{fontSize:12,color:'var(--text)',lineHeight:1.6}}>{t}</div>)}
          </div>
        );
      })()}

      {/* Next Goal */}
      {(()=>{
        const nm=MILESTONES.map(m=>({...m,...m.check(state)})).filter(m=>!m.reached).sort((a,b)=>b.progress-a.progress)[0];
        return nm?(
          <div style={{background:'var(--wall)',borderRadius:10,padding:'8px 10px',border:'1.5px solid var(--border)',cursor:'pointer'}}
            onClick={()=>dispatch({type:A.OPEN_MILESTONES,panel:nm.page})}
            title="Click to view milestones">
            <div style={{fontSize:10,fontWeight:700,color:'var(--muted)',marginBottom:4}}>YOU ARE CLOSE TO A MILESTONE <span style={{color:'var(--orange)'}}>→</span></div>
            <div style={{display:'flex',alignItems:'center',gap:6}}>
              <span style={{fontSize:14}}>{nm.icon}</span>
              <div style={{flex:1}}>
                <div style={{fontWeight:700,fontSize:12}}>{nm.name} <span style={{fontSize:9,fontWeight:700,color:'var(--orange)'}}>+{nm.pts} pts</span></div>
                <div style={{fontSize:10,color:'var(--muted)'}}>{nm.label}</div>
              </div>
            </div>
            <div style={{height:4,background:'var(--border)',borderRadius:2,marginTop:6,overflow:'hidden'}}>
              <div style={{width:`${Math.round(nm.progress*100)}%`,height:'100%',background:'var(--orange)',borderRadius:2}}/>
            </div>
          </div>
        ):null;
      })()}

      {/* Tip */}
      {(()=>{
        const tip=getTip(state);
        return tip?(
          <div style={{background:'rgba(255,193,7,.08)',border:'1.5px solid rgba(255,193,7,.3)',borderRadius:10,padding:'8px 10px',display:'flex',gap:8,alignItems:'flex-start',cursor:tip.panel?'pointer':'default'}}
            onClick={tip.panel?()=>dispatch({type:A.SET_PANEL,v:tip.panel}):undefined}>
            <span style={{fontSize:16}}>{tip.icon}</span>
            <div style={{fontSize:11,color:'var(--text)',lineHeight:1.5}}>{tip.text}</div>
          </div>
        ):null;
      })()}

      {/* Alerts */}
      {staff.length<cap.minStaff&&<div className="warn" style={{cursor:'pointer'}} onClick={()=>dispatch({type:A.SET_PANEL,v:'staff'})}>⚠️ You need at least {cap.minStaff} staff for this cafe level! Service quality is low.</div>}
      {activeCount===0&&<div className="warn" style={{cursor:'pointer'}} onClick={()=>dispatch({type:A.SET_PANEL,v:'menu'})}>⚠️ No menu items active! Set some up on the Menu tab.</div>}
      {assignedPetIds.length===0&&<div className="warn" style={{cursor:'pointer'}} onClick={()=>dispatch({type:A.SET_PANEL,v:'pets'})}>⚠️ No pets in the cafe! Assign some on the Pets tab.</div>}
      {showWarning&&<div className="warn" style={{cursor:'pointer'}} onClick={()=>dispatch({type:A.SET_PANEL,v:'show'})}>🏆 Show is this week — add up to 3 pets now!</div>}

    </div>
  );
}

// ─── GAME OVER ────────────────────────────────────────────────────────────────
function GameOver({state, dispatch}) {
  const sc=Econ.finalScore(state);
  const pillars=[
    {icon:<CoffeeCupSVG size={14} style={{color:'var(--orange)'}}/>,label:'Cafe',score:sc.cafe,max:sc.maxCafe},
    {icon:'🍽️',label:'Menu',score:sc.menu,max:sc.maxMenu},
    {icon:'🐾',label:'Pets',score:sc.pets,max:sc.maxPets},
    {icon:'🧬',label:'Breeding',score:sc.breeding,max:sc.maxBreeding},
    {icon:'🏆',label:'Shows',score:sc.shows,max:sc.maxShows},
  ];
  return (
    <div className="panel" style={{background:'linear-gradient(160deg,#fde8d8,#e8f5e9)',justifyContent:'center',alignItems:'center'}}>
      <div style={{textAlign:'center',fontSize:48}}>🏆</div>
      <div style={{fontFamily:'Fraunces,serif',fontSize:28,fontWeight:800,color:'var(--orange)',textAlign:'center'}}>Game Over!</div>
      <div className="muted" style={{textAlign:'center',marginBottom:8}}>{state.debtWeeks>=4?'Closed due to debt':'15-year pet cafe career complete'}</div>
      <div className="card" style={{width:'100%',maxWidth:360,margin:'0 auto'}}>
        {pillars.map((p,i)=>{
          const pct=p.max>0?Math.round(p.score/p.max*100):0;
          return(
            <div key={i} style={{marginBottom:8}}>
              <div style={{display:'flex',justifyContent:'space-between',marginBottom:3}}>
                <span style={{fontWeight:700,fontSize:13}}>{p.icon} {p.label}</span>
                <span style={{fontSize:12,color:'var(--muted)'}}>{p.score.toLocaleString()} / {p.max.toLocaleString()} pts</span>
              </div>
              <div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
                <div style={{height:'100%',width:`${pct}%`,background:pct>=75?'var(--green)':pct>=50?'var(--orange)':pct>=25?'#e8b931':'var(--muted)',borderRadius:4,transition:'width .5s'}}/>
              </div>
            </div>
          );
        })}
        <div style={{borderTop:'2px solid var(--border)',paddingTop:8,marginTop:4,display:'flex',justifyContent:'space-between',alignItems:'center'}}>
          <span style={{fontWeight:800,fontSize:15}}>Total Score</span>
          <span style={{fontWeight:800,fontSize:22,color:'var(--orange)'}}>{sc.total.toLocaleString()} <span style={{fontSize:13,color:'var(--muted)',fontWeight:600}}>/ {sc.maxTotal.toLocaleString()}</span></span>
        </div>
      </div>
      <button className="btn btn-p" style={{width:'100%',maxWidth:360,padding:14,fontSize:16,margin:'0 auto'}}
        onClick={()=>{Save.del();dispatch({type:A.QUIT_GAME});}}>
        Play Again
      </button>
    </div>
  );
}

// ─── TITLE SCREEN ─────────────────────────────────────────────────────────────
function drawPaw(ctx,x,y,size,angle,opacity,isLeft){
  ctx.save();
  ctx.translate(x,y);
  ctx.rotate(angle);
  ctx.scale(isLeft?-1:1,1);
  const toes=[[-.26,-.19],[-.09,-.31],[.09,-.31],[.26,-.19]];
  // Dark outer silhouette — drawn at boosted opacity so it reads as black even when fading
  ctx.globalAlpha=Math.min(1,opacity*1.5);
  ctx.fillStyle='#1a1a1a';
  ctx.beginPath();
  ctx.ellipse(0,size*.13,size*.305,size*.245,0,0,Math.PI*2);
  ctx.fill();
  for(const[tx,ty]of toes){
    ctx.beginPath();
    ctx.ellipse(tx*size,ty*size,size*.108,size*.126,0,0,Math.PI*2);
    ctx.fill();
  }
  // Pink main pad
  ctx.globalAlpha=opacity;
  ctx.fillStyle='#f4a8bf';
  ctx.beginPath();
  ctx.ellipse(0,size*.13,size*.265,size*.21,0,0,Math.PI*2);
  ctx.fill();
  // Pink toe pads
  for(const[tx,ty]of toes){
    ctx.beginPath();
    ctx.ellipse(tx*size,ty*size,size*.088,size*.106,0,0,Math.PI*2);
    ctx.fill();
  }
  // Gloss highlight on main pad
  ctx.fillStyle='rgba(255,255,255,0.32)';
  ctx.beginPath();
  ctx.ellipse(-size*.04,size*.04,size*.11,size*.07,-0.3,0,Math.PI*2);
  ctx.fill();
  // Gloss highlights on toes
  ctx.fillStyle='rgba(255,255,255,0.38)';
  for(const[tx,ty]of toes){
    ctx.beginPath();
    ctx.ellipse(tx*size-size*.008,(ty-.045)*size,size*.032,size*.038,0,0,Math.PI*2);
    ctx.fill();
  }
  ctx.restore();
}

function TitleScreen({dispatch}) {
  const hasSave=Save.has();
  const canvasRef=useRef(null);
  const animRef=useRef(null);

  useEffect(()=>{
    const cvs=canvasRef.current;if(!cvs)return;
    const ctx=cvs.getContext('2d');
    function resize(){
      const w=cvs.clientWidth,h=cvs.clientHeight;
      cvs.width=w*devicePixelRatio;
      cvs.height=h*devicePixelRatio;
      ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
    }
    resize();
    window.addEventListener('resize',resize);

    const PAW=42;
    const STRIDE=PAW*1.8;        // distance between left-right steps
    const STRADDLE=PAW*0.45;     // side offset for left vs right paw
    const FADE_RATE=0.00016;
    const SPEED=0.20;            // px per ms

    function makeWalker(forceEdge){
      const w=cvs.clientWidth,h=cvs.clientHeight;
      const edge=forceEdge!=null?forceEdge:(Math.random()*4|0);
      let x,y,angle;
      if(edge===0){x=w*.1+Math.random()*w*.8;y=-40;angle=Math.PI/2+(Math.random()-.5)*.6;}
      else if(edge===1){x=w+40;y=h*.1+Math.random()*h*.8;angle=Math.PI+(Math.random()-.5)*.6;}
      else if(edge===2){x=w*.1+Math.random()*w*.8;y=h+40;angle=-Math.PI/2+(Math.random()-.5)*.6;}
      else{x=-40;y=h*.1+Math.random()*h*.8;angle=(Math.random()-.5)*.6;}
      return{x,y,angle,stepLeft:true,dist:0,turnRate:0,turnTimer:0,alive:true,prints:[]};
    }

    const walkers=[makeWalker()];
    let lastT=performance.now();
    let spawnTimer=0;

    function loop(t){
      const dt=Math.min(t-lastT,50);lastT=t;
      const w=cvs.clientWidth,h=cvs.clientHeight;
      ctx.clearRect(0,0,w,h);

      spawnTimer+=dt;
      if(spawnTimer>3500&&walkers.filter(wk=>wk.alive).length<1){spawnTimer=0;walkers.push(makeWalker());}

      for(const wk of walkers){
        if(!wk.alive){
          for(const p of wk.prints){p.age+=dt;p.opacity=Math.max(0,p.startOp-p.age*FADE_RATE);}
          wk.prints=wk.prints.filter(p=>p.opacity>0.005);
          continue;
        }
        // Gentle random turning
        wk.turnTimer+=dt;
        if(wk.turnTimer>800+Math.random()*1200){
          wk.turnTimer=0;
          wk.turnRate=(Math.random()-.5)*0.0028;
        }
        const step=SPEED*dt;
        wk.angle+=wk.turnRate*dt;
        wk.x+=Math.cos(wk.angle)*step;
        wk.y+=Math.sin(wk.angle)*step;
        wk.dist+=step;

        // Place alternating left/right paw prints
        if(wk.dist>=STRIDE){
          wk.dist-=STRIDE;
          // Perpendicular offset: left foot goes left of center, right foot goes right
          const sign=wk.stepLeft?-1:1;
          const perpAngle=wk.angle+Math.PI/2;
          const px=wk.x+Math.cos(perpAngle)*STRADDLE*sign;
          const py=wk.y+Math.sin(perpAngle)*STRADDLE*sign;
          wk.prints.push({
            x:px,y:py,
            angle:wk.angle+Math.PI/2,  // rotate so toes point in travel direction
            isLeft:wk.stepLeft,
            startOp:0.55,opacity:0.55,age:0
          });
          wk.stepLeft=!wk.stepLeft;
        }
        // Fade prints
        for(const p of wk.prints){p.age+=dt;p.opacity=Math.max(0,p.startOp-p.age*FADE_RATE);}
        wk.prints=wk.prints.filter(p=>p.opacity>0.005);
        // Kill when off-screen
        if(wk.x<-80||wk.x>w+80||wk.y<-80||wk.y>h+80) wk.alive=false;
      }
      // Clean dead walkers with no visible prints
      for(let i=walkers.length-1;i>=0;i--){
        if(!walkers[i].alive&&walkers[i].prints.length===0) walkers.splice(i,1);
      }
      // Draw
      for(const wk of walkers){
        for(const p of wk.prints) drawPaw(ctx,p.x,p.y,PAW,p.angle,p.opacity,p.isLeft);
      }
      animRef.current=requestAnimationFrame(loop);
    }
    animRef.current=requestAnimationFrame(loop);
    return()=>{cancelAnimationFrame(animRef.current);window.removeEventListener('resize',resize);};
  },[]);

  return (
    <div className="title-screen">
      <div className="title-bg-layer title-bg-layer-1"/>
      <div className="title-bg-layer title-bg-layer-2"/>
      <canvas ref={canvasRef} className="paw-canvas"/>
      <div className="title-logo"><CoffeeCupSVG size={64} style={{color:'var(--orange)'}}/><span className="steam"><i/><i/><i/></span></div>
      <div className="title-name">Pet Cafe</div>

      <button className="btn btn-p" style={{width:220,padding:14,fontSize:17}}
        onClick={()=>dispatch({type:A.NEW_GAME,seed:Date.now()%1e6|0})}>
        New Game
      </button>
      {hasSave&&(
        <button className="btn btn-g" style={{width:220,padding:14,fontSize:17}}
          onClick={()=>{const s=Save.load();if(s)dispatch({type:A.LOAD_GAME,state:s});}}>
          Continue
        </button>
      )}
    </div>
  );
}

// ─── TOP BAR ──────────────────────────────────────────────────────────────────
function TopBar({state, dispatch}) {
  const [menuOpen,setMenuOpen]=useState(false);
  const [showQuit,setShowQuit]=useState(false);
  const [showScore,setShowScore]=useState(false);
  const [showSettings,setShowSettings]=useState(false);
  const [muted,setMuted]=useState(SFX.isMuted());
  const [volume,setVolume]=useState(Math.round(SFX.getVolume()*100));
  const [ambientOn,setAmbientOn]=useState(()=>localStorage.getItem('ambient')==='true');
  useEffect(()=>{SFX.ambient.setEnabled(ambientOn);localStorage.setItem('ambient',ambientOn);},[ambientOn]);
  useEffect(()=>{const season=Time.season(state.week);SFX.ambient.setSeason(season);},[state.week]);
  const [themeMode,setThemeMode]=useState(()=>{
    var v=localStorage.getItem('darkMode');
    if(v==='true')return'dark'; if(v==='false')return'light';
    return (v==='dark'||v==='light')?v:'system';
  });
  useEffect(()=>{
    localStorage.setItem('darkMode',themeMode);
    const mq=window.matchMedia('(prefers-color-scheme: dark)');
    const apply=()=>document.body.classList.toggle('dark', themeMode==='dark' || (themeMode==='system'&&mq.matches));
    apply();
    if(themeMode==='system'){ mq.addEventListener('change',apply); return ()=>mq.removeEventListener('change',apply); }
  },[themeMode]);

  return (
    <>
      <div className="top-bar">
        <button className="ham-btn" onClick={()=>setMenuOpen(!menuOpen)}>☰</button>
        <span className="money" style={state.money<0?{color:'var(--red)'}:undefined}>{$(state.money)}</span>
        <span className="date-lbl">{Time.fmt(state.week)}</span>
        {state.activeSeasonalEvent&&(
          <span style={{fontSize:18,cursor:'default'}} title={`${state.activeSeasonalEvent.name}: ${state.activeSeasonalEvent.description}`}>{state.activeSeasonalEvent.icon}</span>
        )}
        <button className="nw-btn" disabled={Time.over(state.week)}
          onClick={()=>{SFX.weekAdvance();dispatch({type:A.ADVANCE_WEEK});}}>
          Next Week ▶
        </button>
      </div>
      {/* Rival cafe banners */}
      {state.rivalCafe&&(
        <div style={{background:'var(--red)',color:'#fff',padding:'5px 12px',flexShrink:0}}>
          <span style={{fontSize:13,fontWeight:700}}>⚠️ Rival Cafe — {state.rivalCafe.weeksLeft} week{state.rivalCafe.weeksLeft!==1?'s':''} left · Customers −25%</span>
        </div>
      )}
      {state.rivalCafe2&&(
        <div style={{background:'#7b2d2d',color:'#fff',padding:'5px 12px',flexShrink:0}}>
          <span style={{fontSize:13,fontWeight:700}}>⚠️ Major Rival Chain — {state.rivalCafe2.weeksLeft} week{state.rivalCafe2.weeksLeft!==1?'s':''} left · Customers −30%</span>
        </div>
      )}

      {/* Dropdown menu */}
      {menuOpen&&(
        <>
          <div className="menu-dropdown">
            <div className="menu-item" onClick={()=>{setMenuOpen(false);setShowSettings(true);}}>
              ⚙️ Settings
            </div>
            <div className="menu-item" onClick={()=>{setMenuOpen(false);setShowQuit(true);}}>
              🚪 Quit Game
            </div>
          </div>
          <div style={{position:'fixed',inset:0,zIndex:99}} onClick={()=>setMenuOpen(false)}/>
        </>
      )}

      {/* Quit confirmation modal */}
      {showQuit&&(
        <Modal onClose={()=>setShowQuit(false)}>
            <div style={{fontSize:32,marginBottom:8}}>🚪</div>
            <div style={{fontWeight:800,fontSize:16,marginBottom:8}}>Quit Game?</div>
            <div className="muted" style={{marginBottom:16}}>Your progress is auto-saved. You can continue later.</div>
            <div style={{display:'flex',gap:8,justifyContent:'center'}}>
              <button className="btn btn-o" onClick={()=>setShowQuit(false)}>Cancel</button>
              <button className="btn btn-r" onClick={()=>{setShowQuit(false);dispatch({type:A.QUIT_GAME});}}>Quit</button>
            </div>
        </Modal>
      )}

      {/* Score modal */}
      {showScore&&(
        <Modal onClose={()=>setShowScore(false)} style={{maxWidth:340}}>
            <div style={{fontWeight:800,fontSize:16,marginBottom:10,textAlign:'center'}}>📊 Current Score</div>
            {(()=>{
              const sc=Econ.finalScore(state);
              const pillars=[
                {icon:<CoffeeCupSVG size={14} style={{color:'var(--orange)'}}/>,label:'Cafe',score:sc.cafe,max:sc.maxCafe},
                {icon:'🍽️',label:'Menu',score:sc.menu,max:sc.maxMenu},
                {icon:'🐾',label:'Pets',score:sc.pets,max:sc.maxPets},
                {icon:'🧬',label:'Breeding',score:sc.breeding,max:sc.maxBreeding},
                {icon:'🏆',label:'Shows',score:sc.shows,max:sc.maxShows},
              ];
              return(
                <div>
                  {pillars.map((p,i)=>{
                    const pct=p.max>0?Math.round(p.score/p.max*100):0;
                    return(
                      <div key={i} style={{marginBottom:8}}>
                        <div style={{display:'flex',justifyContent:'space-between',marginBottom:3}}>
                          <span style={{fontWeight:700,fontSize:13}}>{p.icon} {p.label}</span>
                          <span style={{fontSize:11,color:'var(--muted)'}}>{p.score.toLocaleString()} / {p.max.toLocaleString()} pts</span>
                        </div>
                        <div style={{height:6,background:'var(--border)',borderRadius:3,overflow:'hidden'}}>
                          <div style={{width:`${pct}%`,height:'100%',background:pct>=75?'var(--orange)':pct>=50?'var(--green)':pct>=25?'var(--blue)':'var(--muted)',borderRadius:3,transition:'width .3s'}}/>
                        </div>
                      </div>
                    );
                  })}
                  <div style={{textAlign:'center',marginTop:12,fontWeight:800,fontSize:18,color:'var(--orange)'}}>
                    {sc.total.toLocaleString()} <span style={{fontSize:12,color:'var(--muted)',fontWeight:600}}>/ {sc.maxTotal.toLocaleString()} pts</span>
                  </div>
                </div>
              );
            })()}
            <button className="btn btn-o" style={{width:'100%',marginTop:12}} onClick={()=>setShowScore(false)}>Close</button>
        </Modal>
      )}

      {/* Settings modal */}
      {showSettings&&(
        <Modal onClose={()=>setShowSettings(false)} style={{maxWidth:340}}>
          <div style={{fontWeight:800,fontSize:16,marginBottom:12,textAlign:'center'}}>⚙️ Settings</div>

          <div className="card" style={{marginBottom:10}}>
            <h3 className="sub">🌙 Display</h3>
            <div style={{display:'flex',gap:6,width:'100%'}}>
              <button className={`btn btn-sm ${themeMode==='system'?'btn-p':'btn-o'}`} style={{flex:1,lineHeight:1.2}} onClick={()=>setThemeMode('system')}>🖥️<br/>System</button>
              <button className={`btn btn-sm ${themeMode==='light'?'btn-p':'btn-o'}`} style={{flex:1,lineHeight:1.2}} onClick={()=>setThemeMode('light')}>☀️<br/>Light</button>
              <button className={`btn btn-sm ${themeMode==='dark'?'btn-p':'btn-o'}`} style={{flex:1,lineHeight:1.2}} onClick={()=>setThemeMode('dark')}>🌙<br/>Dark</button>
            </div>
          </div>

          <div className="card" style={{marginBottom:10}}>
            <h3 className="sub">🔊 Sound</h3>
            <div style={{marginBottom:6}}>
              <input type="range" min={0} max={100} step={5} value={volume} style={{width:'100%'}}
                onChange={e=>{const v=+e.target.value;setVolume(v);SFX.setVolume(v/100);if(muted&&v>0){SFX.setMuted(false);setMuted(false);}}}/>
            </div>
            <button className="btn btn-sm btn-o" onClick={()=>{const m=!muted;SFX.setMuted(m);setMuted(m);if(!m)SFX.toggle();}}>
              {muted?'🔇 Unmute':'🔊 Mute'}
            </button>
          </div>

          <div className="card" style={{marginBottom:10}}>
            <h3 className="sub">🎶 Ambience</h3>
            <button className={`btn btn-sm ${ambientOn?'btn-p':'btn-o'}`} style={{width:'100%'}} onClick={()=>setAmbientOn(v=>!v)}>
              🌧️ {ambientOn?'Ambient On':'Ambient Off'}
            </button>
          </div>

          <div className="card" style={{marginBottom:10}}>
            <h3 className="sub">💾 Save Data</h3>
            <div style={{display:'flex',gap:6,justifyContent:'center'}}>
              <button className="btn btn-sm btn-g" onClick={()=>{
                const blob=new Blob([JSON.stringify(state)],{type:'application/json'});
                const url=URL.createObjectURL(blob);
                const a=document.createElement('a');a.href=url;a.download='pet-cafe-save.json';a.click();URL.revokeObjectURL(url);
              }}>Export</button>
              <label className="btn btn-sm btn-p" style={{cursor:'pointer'}}>
                Import
                <input type="file" accept=".json" style={{display:'none'}} onChange={e=>{
                  const f=e.target.files[0];if(!f)return;
                  const reader=new FileReader();
                  reader.onload=ev=>{try{const s=JSON.parse(ev.target.result);dispatch({type:A.LOAD_GAME,state:s});setShowSettings(false);}catch{alert('Invalid save file.');}};
                  reader.readAsText(f);
                }}/>
              </label>
            </div>
          </div>

<button className="btn btn-o" style={{width:'100%',marginTop:12}} onClick={()=>setShowSettings(false)}>Close</button>
        </Modal>
      )}
    </>
  );
}

// ─── STATS PANEL ─────────────────────────────────────────────────────────────
function StatsPanel({state, dispatch}) {
  const sc=Econ.finalScore(state);
  const pillars=[
    {icon:'☕',label:'Cafe',score:sc.cafe,max:sc.maxCafe},
    {icon:'🍽️',label:'Menu',score:sc.menu,max:sc.maxMenu},
    {icon:'🐾',label:'Pets',score:sc.pets,max:sc.maxPets},
    {icon:'🧬',label:'Breeding',score:sc.breeding,max:sc.maxBreeding},
    {icon:'🏆',label:'Shows',score:sc.shows,max:sc.maxShows},
  ];
  const rh=state.revenueHistory||[];
  const allPetsEver=[...(state.pets||[]),...(state.pastPets||[])];
  const topPets=[...allPetsEver].filter(p=>(p.totalTipsEarned||0)>0).sort((a,b)=>(b.totalTipsEarned||0)-(a.totalTipsEarned||0));

  // Revenue chart — last 48 weeks (one year)
  const chartData=rh.slice(-48);
  const svgW=260,svgH=60,yAxisW=38;
  let sparkPath='',chartMn=0,chartMx=0;
  if(chartData.length>1){
    const vals=chartData.map(r=>r.net);
    chartMn=Math.min(...vals);chartMx=Math.max(...vals);
    const range=chartMx-chartMn||1;
    sparkPath=vals.map((v,i)=>{
      const x=(i/(vals.length-1))*svgW;
      const y=svgH-((v-chartMn)/range)*(svgH-8)-4;
      return `${i===0?'M':'L'}${x.toFixed(1)},${y.toFixed(1)}`;
    }).join(' ');
  }

  return (
    <div className="panel">
      <h2 className="sh">Stats</h2>

      {/* Score */}
      <div className="card">
        <h3 className="sub">📊 Score</h3>
        {pillars.map((p,i)=>{
          const pct=p.max>0?Math.round(p.score/p.max*100):0;
          return(
            <div key={i} style={{marginBottom:8}}>
              <div style={{display:'flex',justifyContent:'space-between',marginBottom:3}}>
                <span style={{fontWeight:700,fontSize:13}}>{p.icon} {p.label}</span>
                <span style={{fontSize:11,color:'var(--muted)'}}>{p.score.toLocaleString()} / {p.max.toLocaleString()}</span>
              </div>
              <div style={{height:6,background:'var(--border)',borderRadius:3,overflow:'hidden'}}>
                <div style={{width:`${pct}%`,height:'100%',background:pct>=75?'var(--orange)':pct>=50?'var(--green)':pct>=25?'var(--blue)':'var(--muted)',borderRadius:3,transition:'width .3s'}}/>
              </div>
            </div>
          );
        })}
        <div style={{textAlign:'center',marginTop:8,fontWeight:800,fontSize:18,color:'var(--orange)'}}>
          {sc.total.toLocaleString()} <span style={{fontSize:12,color:'var(--muted)',fontWeight:600}}>/ {sc.maxTotal.toLocaleString()} pts</span>
        </div>
      </div>

      {/* Revenue Trend */}
      {chartData.length>1&&(
        <div className="card">
          <h3 className="sub">💰 Weekly Net Profit</h3>
          <div style={{display:'flex',gap:4,alignItems:'stretch'}}>
            {/* Y-axis labels */}
            <div style={{display:'flex',flexDirection:'column',justifyContent:'space-between',alignItems:'flex-end',width:yAxisW,flexShrink:0,paddingBottom:2}}>
              <span style={{fontSize:9,color:'var(--muted)',whiteSpace:'nowrap'}}>{chartMx>=0?'+':''}{chartMx>=1000?Math.round(chartMx/100)/10+'k':Math.round(chartMx)}</span>
              <span style={{fontSize:9,color:chartMn<0?'var(--red)':'var(--muted)',whiteSpace:'nowrap'}}>{chartMn>=0?'+':''}{Math.abs(chartMn)>=1000?Math.round(chartMn/100)/10+'k':Math.round(chartMn)}</span>
            </div>
            <svg viewBox={`0 0 ${svgW} ${svgH}`} style={{flex:1,height:70}}>
              {chartMn<0&&chartMx>0&&(()=>{
                const zeroY=svgH-((0-chartMn)/(chartMx-chartMn))*(svgH-8)-4;
                return <line x1="0" y1={zeroY.toFixed(1)} x2={svgW} y2={zeroY.toFixed(1)} stroke="var(--border)" strokeWidth="1" strokeDasharray="3,3"/>;
              })()}
              <path d={sparkPath} fill="none" stroke="var(--orange)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
            </svg>
          </div>
          <div className="muted" style={{fontSize:10,textAlign:'center',marginTop:2}}>
            Last {chartData.length} week{chartData.length!==1?'s':''}{chartData.length>=48?' (last year)':''}
          </div>
        </div>
      )}

      {/* Lifetime Totals */}
      <div className="card">
        <h3 className="sub">📈 Lifetime Totals</h3>
        <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,fontSize:12}}>
          <div><strong>Total Earned:</strong> {$(state.totalEarned||0)}</div>
          <div><strong>Total Spent:</strong> {$(state.totalSpent||0)}</div>
          <div><strong>Weeks Played:</strong> {state.week}</div>
          <div><strong>Pets Owned:</strong> {(state.pets||[]).length+(state.totalPetsSold||0)}</div>
          <div><strong>Pets Sold:</strong> {state.totalPetsSold||0}</div>
          <div><strong>Pets Bred:</strong> {state.totalBred||0}</div>
          <div><strong>Shows Entered:</strong> {(state.trophies||[]).filter(t=>t.isPlayer).length}</div>
          <div><strong>First Places:</strong> {state.totalFirstPlace||0}</div>
        </div>
      </div>

      {/* Best Performers */}
      {topPets.length>0&&(
        <div className="card">
          <h3 className="sub">⭐ Top Tip Earners</h3>
          {topPets.map((p,i)=>{
            const isPast=(state.pastPets||[]).some(pp=>pp.id===p.id);
            return(
              <div key={p.id} style={{display:'flex',gap:8,alignItems:'center',marginBottom:6}}>
                <span style={{fontWeight:800,fontSize:16,color:i===0?'#e0a830':i===1?'#999':'#b87333',width:20}}>{i+1}.</span>
                <PetMini pet={p} size={32}/>
                <div style={{flex:1}}>
                  <div style={{fontWeight:700,fontSize:13}}>{p.name}{isPast&&<span className="muted" style={{fontWeight:400,fontSize:10,marginLeft:4}}>retired/sold</span>}</div>
                  <div className="muted" style={{fontSize:11}}>{$(p.totalTipsEarned)} earned</div>
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ─── BOTTOM NAV ───────────────────────────────────────────────────────────────
function BottomNav({active, dispatch}) {
  const NAV=[
    {id:'home',  icon:'🏠', label:'Summary'},
    {id:'pets',  icon:'🐾', label:'Pets'},
    {id:'breed', icon:'💕', label:'Breed'},
    {id:'menu',  icon:'🍽️', label:'Menu'},
    {id:'staff', icon:'👥', label:'Staff'},
    {id:'show',  icon:'🏆', label:'Show'},
    {id:'upg',   icon:'⬆️', label:'Upgrade'},
    {id:'stats', icon:'📊', label:'Stats'},
  ];
  return (
    <nav className="bottom-nav" style={{position:'relative'}}>
      {NAV.map(n=>(
        <button key={n.id} className={`nav-btn${active===n.id?' on':''}`} onClick={()=>{SFX.click();dispatch({type:A.SET_PANEL,v:n.id});}}>
          <span className="nav-icon">{n.icon}</span>
          <span>{n.label}</span>
        </button>
      ))}
    </nav>
  );
}

// ─── PET DETAIL MODAL ────────────────────────────────────────────────────────
function PetDetailModal({pet, state, dispatch, onClose}) {
  const toast=useToast();
  const [renaming,setRenaming]=useState(false);
  const [nameVal,setNameVal]=useState(pet.name);
  const [confirmSell,setConfirmSell]=useState(false);
  const wuj=state.currentShow?Time.weeksUntilJudging(state.week):0;
  const petBusy=isPetBusy(pet,state.breedingQueue||[],state.showEntries||[],wuj);
  const rl=rarityLabel(pet.stats.rarity);
  const g=petGender(pet);
  const sp=SPECIES[pet.species]||SPECIES.cats;
  const tt=TRAIT_TABLES[pet.species]||TT_CATS;
  const trophies=(state.trophies||[]).filter(t=>t.petId===pet.id);
  const allPetsEver=[...(state.pets||[]),...(state.pastPets||[])];
  const offspring=allPetsEver.filter(p=>p.parentA===pet.id||p.parentB===pet.id);
  const parentA=pet.parentA?allPetsEver.find(p=>p.id===pet.parentA):null;
  const parentB=pet.parentB?allPetsEver.find(p=>p.id===pet.parentB):null;
  const ageYrs=pet.state.age/48;
  const ageLabel=ageYrs<1?`${Math.max(1,Math.floor(pet.state.age/4))} months`:`${Math.floor(ageYrs)} year${Math.floor(ageYrs)>1?'s':''}`;
  const persEntry=PERS_TABLE.find(p=>p.value===pet.personality.primary);
  const isAssigned=state.assignedPetIds.includes(pet.id);
  const isShowEntry=(state.showEntries||[]).includes(pet.id);
  return (
    <Modal onClose={onClose} style={{maxHeight:'90dvh',overflowY:'auto'}}>
      <div style={{textAlign:'center',padding:'4px 0'}}>
        <PetMini pet={pet} size={80}/>
        <div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:6,marginTop:8}}>
          <GenderIcon gender={g} size={16}/>
          {renaming?(
            <input value={nameVal} onChange={e=>setNameVal(e.target.value)} autoFocus
              style={{border:'1px solid var(--orange)',borderRadius:5,padding:'2px 6px',fontSize:15,fontWeight:800,width:120,textAlign:'center'}}
              onBlur={()=>{if(nameVal.trim()){dispatch({type:A.RENAME_PET,id:pet.id,name:nameVal.trim()});}setRenaming(false);}}
              onKeyDown={e=>{if(e.key==='Enter')e.target.blur();if(e.key==='Escape'){setNameVal(pet.name);setRenaming(false);}}}/>
          ):(
            <span style={{fontFamily:'var(--font-display)',fontSize:20,fontWeight:800,cursor:'pointer'}} onClick={()=>{setRenaming(true);setNameVal(pet.name);}}>
              {pet.name} ✏️
            </span>
          )}
          <span className={`badge ${rl.cls}`} style={{fontSize:10}}>{rl.text}</span>
        </div>
        <div className="muted" style={{fontSize:12}}>{sp.icon} {sp.name} · Gen {pet.state.generation} · {ageLabel}</div>
        <div style={{display:'flex',gap:6,justifyContent:'center',marginTop:6}}>
          {[['star','⭐'],['heart','❤️'],['thumbsup','👍']].map(([mk,icon])=>(
            <button key={mk} className={`btn btn-xs ${pet.marking===mk?'btn-p':'btn-o'}`}
              onClick={()=>dispatch({type:A.SET_PET_MARKING,id:pet.id,marking:pet.marking===mk?null:mk})}>
              {icon}
            </button>
          ))}
        </div>
      </div>
      <div style={{marginTop:10,textAlign:'left'}}>
        <div style={{fontSize:12,marginBottom:6}}>
          <strong>Traits:</strong> {Object.entries(pet.phenotype).map(([k,v])=>{
            const tag=traitRarityTag(v,k,tt);
            return traitFullLabel(k,v)+(tag&&tag!=='common'?` (${tag})`:'');
          }).join(' · ')}
        </div>
        <div style={{fontSize:12,marginBottom:6}}>
          <strong>Personality:</strong> {Pers.emoji(pet.personality.primary)} {capitalize(pet.personality.primary)}
          <span className="muted" style={{fontSize:11}}> — {PERS_DESC[pet.personality.primary]||''}</span>
        </div>
        <div style={{fontSize:12,marginBottom:6}}>
          <strong>Happiness:</strong> {parseFloat(pet.stats.appeal.toFixed(1))}/10 · <strong>Upkeep:</strong> {$(petUpkeepCost(pet))}/wk · <strong>Tips earned:</strong> {$(pet.totalTipsEarned||0)}
        </div>
        {(parentA||parentB)&&(
          <div style={{fontSize:12,marginBottom:6}}>
            <strong>Parents:</strong> {parentA?parentA.name:'(gone)'} + {parentB?parentB.name:'(gone)'}
          </div>
        )}
        {offspring.length>0&&(
          <div style={{fontSize:12,marginBottom:6}}>
            <strong>Offspring:</strong> {offspring.map(o=>o.name).join(', ')}
          </div>
        )}
        {trophies.length>0&&(
          <div style={{fontSize:12,marginBottom:6}}>
            <strong>Show results:</strong>{' '}
            {[1,2,3].map(pl=>{const n=trophies.filter(t=>t.placement===pl).length;return n>0?<span key={pl} style={{marginRight:6}}>{['🥇','🥈','🥉'][pl-1]} ×{n}</span>:null;})}
            <div style={{marginTop:3}}>{trophies.map((t,i)=><span key={i}>{['🥇','🥈','🥉'][t.placement-1]||'🏅'} {t.showTitle}{i<trophies.length-1?', ':''}</span>)}</div>
          </div>
        )}
      </div>
      {/* Relationships section */}
      {(()=>{
        const rels=(pet.relationships||[]).filter(r=>{
          const other=(state.pets||[]).find(p=>p.id===r.petId);
          return !!other;
        });
        if(rels.length===0)return null;
        const bestFriends=rels.filter(r=>r.strength>=8).sort((a,b)=>b.strength-a.strength);
        const friends=rels.filter(r=>r.strength>=4&&r.strength<8).sort((a,b)=>b.strength-a.strength);
        const rivals=rels.filter(r=>r.strength<=-4).sort((a,b)=>a.strength-b.strength);
        const acquaintances=rels.filter(r=>r.strength>=-3&&r.strength<4&&r.strength>=0).sort((a,b)=>b.strength-a.strength).slice(0,5);
        const relLabel=(r)=>r.strength>=8?'Best Friend':r.strength>=4?'Friend':r.strength<=-4?'Rival':'Acquaintance';
        const relColor=(r)=>r.strength>=8?'var(--green)':r.strength>=4?'#4CAF50':r.strength<=-4?'var(--red)':'var(--muted)';
        const sections=[
          {label:'Best Friends 💛',items:bestFriends},
          {label:'Friends 🐾',items:friends},
          {label:'Rivals 😾',items:rivals},
          {label:'Top 5 acquaintances',items:acquaintances},
        ].filter(s=>s.items.length>0);
        return(
          <div style={{marginTop:10,borderTop:'1px solid var(--border)',paddingTop:10}}>
            <div style={{fontSize:13,fontWeight:700,marginBottom:6}}>Relationships from working in the cafe</div>
            {sections.map(sec=>(
              <div key={sec.label} style={{marginBottom:6}}>
                <div style={{fontSize:11,fontWeight:700,color:'var(--muted)',marginBottom:3}}>{sec.label}</div>
                {sec.items.map(r=>{
                  const other=(state.pets||[]).find(p=>p.id===r.petId);
                  if(!other)return null;
                  return(
                    <div key={r.petId} style={{display:'flex',justifyContent:'space-between',fontSize:12,padding:'2px 0'}}>
                      <span>{other.name}</span>
                      <span style={{color:relColor(r)}}>{relLabel(r)} ({r.strength>0?'+':''}{r.strength})</span>
                    </div>
                  );
                })}
              </div>
            ))}
          </div>
        );
      })()}
      {/* Sick section */}
      {(pet.state.sick||0)>0&&(
        <div style={{marginTop:10,borderTop:'1px solid var(--border)',paddingTop:10,background:'#fff3f3',borderRadius:8,padding:'10px 12px'}}>
          <div style={{fontWeight:700,fontSize:13,color:'var(--red)',marginBottom:6}}>🤒 This pet is unwell</div>
          <div style={{fontSize:12,color:'var(--muted)',marginBottom:10}}>
            {pet.name} cannot work in the cafe or enter shows while sick. {petPronouns(pet).They} will recover in {pet.state.sick} week{pet.state.sick>1?'s':''}.
          </div>
          <button className="btn btn-r" style={{fontSize:12,padding:'5px 14px'}}
            disabled={state.money<200}
            onClick={()=>{dispatch({type:A.PAY_VET_BILL,id:pet.id});onClose();}}>
            Pay $200 Vet Bill (Instant Recovery)
          </button>
          {state.money<200&&<div style={{fontSize:11,color:'var(--muted)',marginTop:4}}>Not enough money</div>}
        </div>
      )}
      {/* Wish section — hidden for busy pets */}
      {!petBusy&&<div style={{marginTop:10,borderTop:'1px solid var(--border)',paddingTop:10}}>
        <div style={{fontSize:13,fontWeight:700,marginBottom:4}}>💭 Current Wish</div>
        <div style={{fontSize:11,color:'var(--muted)',marginBottom:6}}>({pet.fulfilledWishes||0} wish{(pet.fulfilledWishes||0)===1?'':'es'} fulfilled)</div>
        {pet.wish ? (()=>{
          const w=pet.wish;
          let canFulfill=true;
          let disabledReason='';
          if(w.type==='toy'){
            if(state.money<w.value){canFulfill=false;disabledReason=`Need $${w.value} (have $${Math.floor(state.money)})`;}
          } else if(w.type==='treat'){
            const hasItem=(state.activeMenuIds[w.target]||[]).length>0;
            if(!hasItem){canFulfill=false;disabledReason=`No ${w.target} on menu this week`;}
          } else if(w.type==='friend'){
            const rels=pet.relationships||[];
            const hasCafeFriend=state.pets.some(p=>p.id!==pet.id&&p.species===w.target&&rels.some(r=>r.petId===p.id&&r.strength>=1));
            if(!hasCafeFriend){canFulfill=false;disabledReason=`Need a positive cafe relationship with a ${(SPECIES[w.target]?.name||w.target).toLowerCase()}`;}
          }
          const rewardText=w.type==='toy'?'+0.5 happiness':w.type==='treat'?'+0.3 happiness & $20 tip':'+1 happiness';
          return (
            <div style={{background:'var(--wall)',borderRadius:8,padding:'8px 10px',border:'1px solid var(--border)'}}>
              <div style={{fontSize:12,marginBottom:6}}>{w.label}</div>
              <div style={{fontSize:11,color:'var(--green)',marginBottom:6}}>Reward: {rewardText}</div>
              {!canFulfill&&<div style={{fontSize:11,color:'var(--muted)',marginBottom:6}}>{disabledReason}</div>}
              <button className="btn btn-p" style={{fontSize:12,padding:'4px 12px'}}
                disabled={!canFulfill}
                onClick={()=>{dispatch({type:A.FULFILL_WISH,id:pet.id});}}>
                Fulfill Wish
              </button>
            </div>
          );
        })() : (
          <div style={{fontSize:12,color:'var(--muted)'}}>No active wish right now.</div>
        )}
      </div>}
      {/* Sell button — only for non-busy pets */}
      {!petBusy&&!pet.state.isKitten&&(
        <div style={{marginTop:10,borderTop:'1px solid var(--border)',paddingTop:10}}>
          {!confirmSell?(
            <button className="btn btn-o" style={{width:'100%',fontSize:12,color:'var(--red)'}} onClick={()=>setConfirmSell(true)}>Sell {pet.name}</button>
          ):(
            <div style={{textAlign:'center'}}>
              <div style={{fontSize:12,marginBottom:6}}>Sell {pet.name} for <strong>{$(Math.round(Econ.adoptCost(pet)*(0.8+getStaffSkillAvg(getPetHouseStaff(state),'cleaning')/10*0.2)))}</strong>?</div>
              <div style={{display:'flex',gap:8,justifyContent:'center'}}>
                <button className="btn btn-o" style={{fontSize:12}} onClick={()=>setConfirmSell(false)}>Cancel</button>
                <button className="btn btn-gold" style={{fontSize:12}} onClick={()=>{SFX.sell();dispatch({type:A.SELL_PET,id:pet.id});onClose();}}>Confirm Sale</button>
              </div>
            </div>
          )}
        </div>
      )}
    </Modal>
  );
}

// ─── FAREWELL MODAL ──────────────────────────────────────────────────────────
function FarewellModal({pet, state, onDismiss}) {
  const trophies=(state.trophies||[]).filter(t=>t.petId===pet.id);
  const offspring=(state.pets||[]).filter(p=>p.parentA===pet.id||p.parentB===pet.id);
  const ageYears=Math.floor(pet.state.age/48);
  const ageMonths=Math.floor((pet.state.age%48)/4);
  return (
    <Modal onClose={onDismiss}>
      <div style={{textAlign:'center',padding:'8px 0'}}>
        <PetMini pet={pet} size={80}/>
        <h2 style={{fontFamily:'var(--font-display)',fontSize:22,marginTop:8,color:'var(--brown)'}}>Farewell, {pet.name}!</h2>
        <div className="muted" style={{fontSize:13,margin:'6px 0 12px'}}>After {ageYears>0?ageYears+' year'+(ageYears>1?'s':''):''}{ageYears>0&&ageMonths>0?' and ':''}{ageMonths>0?ageMonths+' month'+(ageMonths>1?'s':''):''} at the cafe, {pet.name} is retiring to a cozy forever home.</div>
        <div style={{display:'flex',gap:16,justifyContent:'center',flexWrap:'wrap',margin:'12px 0'}}>
          <div style={{textAlign:'center'}}>
            <div style={{fontSize:24}}>💰</div>
            <div style={{fontWeight:800,fontSize:15}}>{$(pet.totalTipsEarned||0)}</div>
            <div className="muted" style={{fontSize:10}}>Tips Earned</div>
          </div>
          <div style={{textAlign:'center'}}>
            <div style={{fontSize:24}}>🏆</div>
            <div style={{fontWeight:800,fontSize:15}}>{trophies.length}</div>
            <div className="muted" style={{fontSize:10}}>Trophies</div>
          </div>
          <div style={{textAlign:'center'}}>
            <div style={{fontSize:24}}>🐣</div>
            <div style={{fontWeight:800,fontSize:15}}>{offspring.length}</div>
            <div className="muted" style={{fontSize:10}}>Offspring</div>
          </div>
        </div>
        {trophies.length>0&&(
          <div style={{fontSize:11,color:'var(--muted)',marginBottom:8}}>
            {trophies.map((t,i)=><span key={i}>{['🥇','🥈','🥉'][t.placement-1]||'🏅'} {t.showTitle}{i<trophies.length-1?' · ':''}</span>)}
          </div>
        )}
        <div style={{color:'var(--orange)',fontSize:14,fontStyle:'italic',margin:'8px 0'}}>They'll be missed 🌅</div>
        <button className="btn btn-p" onClick={onDismiss} style={{marginTop:8}}>Goodbye, {pet.name}</button>
      </div>
    </Modal>
  );
}

// ─── INSPECTOR MODAL ─────────────────────────────────────────────────────────
function InspectorModal({modal, dispatch}) {
  const isPas=modal.result==='pass';
  const isWarn=modal.result==='warning';
  const isFail=modal.result==='fail';
  const icon=isPas?'✅':isWarn?'⚠️':'🚫';
  const color=isPas?'var(--green)':isWarn?'var(--gold)':'var(--red)';
  let headline, body;
  if(isPas){
    headline='Sparkling Cafe!';
    body=`A city health inspector gave your cafe a glowing report. A $${modal.amount} city grant was awarded.`;
  } else if(isWarn){
    headline='Cleanliness Warning';
    body=`The inspector found your cafe marginally clean. A $${modal.amount} fine has been issued. Improve your cleaning staff to avoid future penalties.`;
  } else {
    headline='Failed Inspection';
    body=`The inspector cited serious hygiene concerns. A $${modal.amount} fine has been levied and a stern warning issued. Hire better cleaning staff before the next visit.`;
  }
  return (
    <Modal onClose={()=>dispatch({type:A.DISMISS_PENDING_MODAL})}>
      <div style={{textAlign:'center',padding:'8px 0'}}>
        <div style={{fontSize:40,marginBottom:8}}>{icon}</div>
        <div style={{fontWeight:800,fontSize:17,color,marginBottom:10}}>{headline}</div>
        <div style={{fontSize:13,color:'var(--text)',marginBottom:16,lineHeight:1.5}}>{body}</div>
        <div style={{fontWeight:700,fontSize:16,color,marginBottom:16}}>
          {isPas?`+$${modal.amount}`:`-$${modal.amount}`}
        </div>
        <button className="btn btn-p" onClick={()=>dispatch({type:A.DISMISS_PENDING_MODAL})}>Got it</button>
      </div>
    </Modal>
  );
}

// ─── CELEBRITY MODAL ─────────────────────────────────────────────────────────
function CelebrityModal({modal, state, dispatch}) {
  const pet=(state.pets||[]).find(p=>p.id===modal.petId);
  const rl=pet?rarityLabel(pet.stats.rarity):{text:'Unknown',cls:'bk'};
  const spIcon=pet?SPECIES[pet.species]?.icon||'🐾':'🐾';
  return (
    <Modal onClose={()=>dispatch({type:A.DISMISS_PENDING_MODAL})}>
      <div style={{textAlign:'center',padding:'8px 0'}}>
        <div style={{fontSize:40,marginBottom:8}}>🌟</div>
        <div style={{fontWeight:800,fontSize:17,color:'var(--gold)',marginBottom:8}}>Celebrity Offer!</div>
        <div style={{fontSize:13,color:'var(--text)',marginBottom:6,lineHeight:1.5}}>
          <strong>{modal.celebrityName}</strong> has visited {state.cafeName||'your cafe'} and fallen in love with:
        </div>
        <div style={{background:'var(--wall)',borderRadius:10,padding:'10px 14px',marginBottom:12,display:'inline-block',textAlign:'left',minWidth:180}}>
          <div style={{fontWeight:800,fontSize:15}}>{spIcon} {modal.petName}</div>
          <div style={{fontSize:12,color:'var(--muted)'}}><span className={`badge ${rl.cls}`}>{rl.text}</span> {pet?capitalize(pet.species?.slice(0,-1)||'pet'):''}</div>
        </div>
        <div style={{fontSize:14,fontWeight:700,color:'var(--green)',marginBottom:4}}>Offering: ${modal.amount.toLocaleString()}</div>
        <div style={{fontSize:12,color:'var(--muted)',marginBottom:16,lineHeight:1.4}}>
          Mayor Hollis says loyalty matters too — refusing earns a permanent +5% tip bonus from your regulars.
        </div>
        <div style={{display:'flex',gap:10,justifyContent:'center',flexWrap:'wrap'}}>
          <button className="btn btn-g" style={{minWidth:110}} onClick={()=>dispatch({type:A.CELEBRITY_SELL})}>
            Sell — ${modal.amount.toLocaleString()}
          </button>
          <button className="btn btn-o" style={{minWidth:110}} onClick={()=>dispatch({type:A.CELEBRITY_REFUSE})}>
            Refuse — +5% tips
          </button>
        </div>
      </div>
    </Modal>
  );
}

// ─── GAME SCREEN ──────────────────────────────────────────────────────────────
const NAV_ORDER=['home','pets','breed','menu','staff','show','upg','stats'];
function GameScreen({state, dispatch}) {
  const panel=state.activePanel;
  const prevPanelRef=useRef(panel);
  const slideDir=NAV_ORDER.indexOf(panel)>=NAV_ORDER.indexOf(prevPanelRef.current)?'right':'left';
  useEffect(()=>{prevPanelRef.current=panel;},[panel]);
  const ts=state.tutorialStep||0;
  const skip=()=>dispatch({type:A.SKIP_TUTORIAL});
  const advance=(step)=>dispatch({type:A.ADVANCE_TUTORIAL,step});
  const [dismissedTipStep,setDismissedTipStep]=useState(-1);
  const toast=useToast();
  const [showFireworks,setShowFireworks]=useState(false);
  const [farewellPets,setFarewellPets]=useState([]);
  const [currentLetter,setCurrentLetter]=useState(null);
  const [selectedPetId,setSelectedPetId]=useState(null);
  const selectedPet=(state.pets||[]).find(p=>p.id===selectedPetId);
  const lastFireworksWeek=useRef(null);
  const lastFarewellWeek=useRef(null);
  const lastSickToastWeek=useRef(null);
  useEffect(()=>{
    const w=state.weekSummary?.week;
    if(state.weekSummary?.newMilestones?.length>0&&w!==lastFireworksWeek.current){lastFireworksWeek.current=w;setShowFireworks(true);}
    if(state.weekSummary?.retiredThisWeek?.length>0&&w!==lastFarewellWeek.current){lastFarewellWeek.current=w;setFarewellPets([...state.weekSummary.retiredThisWeek]);}
    if(state.weekSummary?.newlySickPets?.length>0&&w!==lastSickToastWeek.current){
      lastSickToastWeek.current=w;
      state.weekSummary.newlySickPets.forEach((p,i)=>setTimeout(()=>toast({
        text:`🤒 ${p.name} is feeling unwell and has been removed from the cafe.`,
        duration:8000,
        onClick:()=>{dispatch({type:A.SET_PANEL,v:'pets'});setSelectedPetId(p.id);},
      }),i*6500));
    }
  },[state.weekSummary]);
  useEffect(()=>{
    const ms=state.pendingMilestones||[];
    if(ms.length>0){
      setTimeout(()=>SFX.milestone(),300);
      setShowFireworks(true);
      ms.forEach((m,i)=>setTimeout(()=>toast(`🏅 ${m.icon} ${m.name} unlocked! +${m.pts} pts`),400+i*1800));
      dispatch({type:A.CLEAR_PENDING_MILESTONES});
    }
  },[state.pendingMilestones]);
  useEffect(()=>{document.title=state.cafeName?`${state.cafeName} — Pet Cafe`:'Pet Cafe';},[state.cafeName]);

  // Letter trigger logic
  useEffect(()=>{
    if(currentLetter) return;
    const seen=new Set(state.lettersSeen||[]);
    for(const letter of LETTERS){
      if(seen.has(letter.id)) continue;
      if(letter.condition(state)){setCurrentLetter(letter);break;}
    }
  },[state.week,state.lettersSeen,state.cafeName,currentLetter,state.totalFirstPlace,state.totalLegendaryBred,state.npcs]);


  // Tutorial auto-advance logic — staged, progressive disclosure
  // Steps: 1=first week,2=summary,3=menu intro,4=R&D,5=pets/wishes,
  //        6=show intro (show week),7=show panel (traits),8=trend,
  //        9=upgrade,10=staff,11=rumor,0=done
  const wujForTutorial = Time.weeksUntilJudging(state.week);
  useEffect(()=>{
    if(ts===0) return;
    if(ts===1&&state.week>=1&&panel==='home') advance(2);
    if(ts===2&&panel==='menu') advance(3);
    if(ts===3&&(state.rdBudget?.drinks>0||state.rdBudget?.food>0||state.rdBudget?.desserts>0)) advance(4);
    if(ts===4&&panel==='pets') advance(5);
    // Advance to the show intro only on a show week, so the tip appears
    // exactly when the player can actually enter a pet.
    if(ts===5&&wujForTutorial===1) advance(6);
    if(ts===6&&panel==='show') advance(7);
    // Safety: if the player is still stuck on step 6 two weeks after a show
    // week opened (weeksUntilJudging was 1), move forward anyway.
    if(ts===6&&wujForTutorial>=3) advance(7);
    if(ts===7&&panel!=='show'&&state.week>=6) advance(8);
    if(ts===8&&state.currentTrend) advance(9);
    if(ts===9&&state.money>=2000&&panel==='upg') advance(10);
    if(ts===9&&state.money>=2000&&state.week>=10) advance(10);
    if(ts===10&&state.staff.length>1&&panel==='staff') advance(11);
    if(ts===11&&state.trendRumor) advance(0);
    if(ts>0&&state.week>=20) advance(0); // safety: end by week 20 no matter what
  },[ts,state.week,panel,state.staff.length,state.rdBudget,state.pets,state.currentTrend,state.money,state.trendRumor]);

  // Determine current tip
  let tip=null;
  if(ts===1&&state.week===0)
    tip={text:'Your pets earn tips on the cafe floor each week. Tap <strong>Next Week ▶</strong> to see how they do.',position:'top-right'};
  else if(ts===2&&panel==='home')
    tip={text:"Nice! The Summary card shows how your week went. When you're ready, open the <strong>Menu</strong> tab to plan recipes.",position:'top'};
  else if(ts===3&&panel==='menu')
    tip={text:'Drag the <strong>R&amp;D slider</strong> up for any category to discover new items over time.',position:'top'};
  else if(ts===4&&panel==='home')
    tip={text:'Check out the <strong>Pets</strong> tab. Traits and personality affect how much each pet earns.',position:'top'};
  else if(ts===5&&panel==='pets')
    tip={text:'Pets will start asking for things they want. When one has a <strong>💭 wish</strong>, fulfilling it boosts their happiness.',position:'top'};
  else if(ts===6&&panel==='home'&&wujForTutorial===1)
    tip={text:'A <strong>Pet Show</strong> is happening this week! Head to the <strong>Show</strong> tab and enter your best pet before the week ends.',position:'top'};
  else if(ts===7&&panel==='show')
    tip={text:'Judged traits are listed above. Pick a pet whose stats match and enter them before the show starts.',position:'top'};
  else if(ts===8&&panel==='home'&&state.currentTrend)
    tip={text:"A <strong>trend</strong> just started — customers love specific traits this month. Prioritize matching pets.",position:'top'};
  else if(ts===9&&state.money>=2000&&panel==='home')
    tip={text:"You've saved up! The <strong>Upgrade</strong> tab lets you expand the cafe or pet house.",position:'top'};
  else if(ts===10&&state.staff.length>1&&panel!=='staff')
    tip={text:'You hired staff! In the <strong>Staff</strong> tab you can train their skills and assign them.',position:'top'};
  else if(ts===11&&state.trendRumor&&panel==='home')
    tip={text:'A <strong>rumor</strong> appeared — an NPC is hinting at next month\'s trend. Rumors are sometimes wrong.',position:'top'};

  return (
    <>
      <TopBar state={state} dispatch={dispatch}/>
      <div className="content-area">
        <div className="main-view">
          <div key={panel} className={`panel-wrap panel-slide-${slideDir}`}>
            {panel==='home'  && <SummaryPanel state={state} dispatch={dispatch} onSelectPet={setSelectedPetId}/>}
            {panel==='pets'  && <PetsPanel state={state} dispatch={dispatch} onSelectPet={setSelectedPetId}/>}
            {panel==='breed' && <BreedingPanel state={state} dispatch={dispatch} onSelectPet={setSelectedPetId}/>}
            {panel==='menu'  && <MenuPanel state={state} dispatch={dispatch}/>}
            {panel==='staff' && <StaffPanel state={state} dispatch={dispatch}/>}
            {panel==='show'  && <ShowPanel state={state} dispatch={dispatch} onSelectPet={setSelectedPetId}/>}
            {panel==='upg'   && <UpgradePanel state={state} dispatch={dispatch}/>}
            {panel==='stats' && <StatsPanel state={state} dispatch={dispatch}/>}
          </div>
        </div>
        <BottomNav active={panel} dispatch={dispatch}/>
      </div>
      {tip&&dismissedTipStep!==ts&&<TutorialTip text={tip.text} position={tip.position} onDismiss={()=>setDismissedTipStep(ts)} onSkip={skip}/>}
      {showFireworks&&<Fireworks onDone={()=>setShowFireworks(false)}/>}
      {farewellPets.length>0&&<FarewellModal pet={farewellPets[0]} state={state} onDismiss={()=>setFarewellPets(fp=>fp.slice(1))}/>}
      {selectedPet&&<PetDetailModal pet={selectedPet} state={state} dispatch={dispatch} onClose={()=>setSelectedPetId(null)}/>}
      {currentLetter&&<LetterModal letter={currentLetter} state={state} onClose={()=>{dispatch({type:A.MARK_LETTER_SEEN,id:currentLetter.id});setCurrentLetter(null);}}/>}
      {state.npcVisit&&<NpcVisitModal state={state} dispatch={dispatch}/>}
      {state.pendingModal?.type==='inspector'&&<InspectorModal modal={state.pendingModal} dispatch={dispatch}/>}
      {state.pendingModal?.type==='celebrity_offer'&&<CelebrityModal modal={state.pendingModal} state={state} dispatch={dispatch}/>}
    </>
  );
}

// ─── PET SELECT SCREEN ───────────────────────────────────────────────────────
function PetSelectScreen({state, dispatch}) {
  const [selected, setSelected] = useState([]);
  const [playerName, setPlayerName] = useState('');
  const [cafeName, setCafeName] = useState('');
  const {starterPets} = state;
  if(!starterPets) return null;

  function togglePet(id) {
    setSelected(prev => {
      if(prev.includes(id)) return prev.filter(x=>x!==id);
      if(prev.length>=4) return prev;
      return [...prev, id];
    });
  }

  return (
    <div className="screen-enter pet-select-screen" style={{height:'100dvh',display:'flex',flexDirection:'column',background:'linear-gradient(160deg,#f5e6d3,#ecdac8)',width:'100%'}}>
      <div style={{textAlign:'center',padding:'20px 16px 10px'}}>
        <div style={{fontSize:36}}>🐾<CoffeeCupSVG size={36} style={{color:'var(--orange)'}}/></div>
        <div style={{fontFamily:'Fraunces,serif',fontSize:22,fontWeight:800,color:'var(--orange)',marginTop:6}}>Choose Your Starting Pets</div>
        <div style={{color:'var(--muted)',fontSize:13,marginTop:4}}>Pick 4 pets to begin your cafe adventure</div>
        {/* Player name input */}
        <div style={{marginTop:10,display:'flex',justifyContent:'center'}}>
          <input
            type="text" maxLength={20}
            placeholder="Your name"
            value={playerName}
            onChange={e=>setPlayerName(e.target.value)}
            style={{padding:'8px 14px',borderRadius:10,border:'2px solid var(--border)',fontFamily:'Nunito,sans-serif',fontSize:14,fontWeight:700,background:'var(--card)',color:'var(--text)',textAlign:'center',width:220,outline:'none'}}
          />
        </div>
        {/* Cafe name input */}
        <div style={{marginTop:8,display:'flex',justifyContent:'center'}}>
          <input
            type="text" maxLength={30}
            placeholder="Cafe name"
            value={cafeName}
            onChange={e=>setCafeName(e.target.value)}
            style={{padding:'8px 14px',borderRadius:10,border:'2px solid var(--border)',fontFamily:'Nunito,sans-serif',fontSize:14,fontWeight:700,background:'var(--card)',color:'var(--text)',textAlign:'center',width:220,outline:'none'}}
          />
        </div>
        <div style={{fontWeight:800,fontSize:14,marginTop:8,color:selected.length===4?'var(--green)':'var(--muted)'}}>{selected.length} / 4 selected</div>
      </div>
      <div style={{flex:1,overflowY:'auto',padding:'0 12px 20px'}}>
        {ALL_SPECIES_KEYS.map(sp=>{
          const spInfo=SPECIES[sp];
          const spPets=starterPets.filter(p=>p.species===sp);
          return (
            <div key={sp} style={{marginBottom:12}}>
              <div style={{fontWeight:800,fontSize:14,marginBottom:6,display:'flex',alignItems:'center',gap:6}}>
                {spInfo.icon} {spInfo.name}
              </div>
              <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
                {spPets.map(pet=>{
                  const isSel=selected.includes(pet.id);
                  const g=pet.gender;
                  return (
                    <div key={pet.id} onClick={()=>togglePet(pet.id)}
                      style={{background:isSel?'rgba(106,158,92,.15)':'var(--card)',border:`2px solid ${isSel?'var(--green)':'var(--border)'}`,
                        borderRadius:14,padding:10,cursor:'pointer',display:'flex',alignItems:'center',gap:8,transition:'all .15s',
                        boxShadow:isSel?'0 2px 8px rgba(106,158,92,.2)':'none'}}>
                      <PetMini pet={pet} petType={sp} size={48}/>
                      <div>
                        <div style={{fontWeight:800,fontSize:13,display:'flex',alignItems:'center',gap:4}}>
                          <GenderIcon gender={g} size={14}/>
                          {pet.name}
                        </div>
                        <div style={{fontSize:11,color:'var(--muted)',lineHeight:1.3}}>{capitalize(pet.personality.primary)} · {traitSummary(pet,true)}</div>
                      </div>
                      {isSel&&<span style={{marginLeft:'auto',color:'var(--green)',fontWeight:800,fontSize:16}}>✓</span>}
                    </div>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
      <div style={{padding:'12px 16px 20px',background:'var(--cream)',borderTop:'2px solid var(--border)'}}>
        <button className="btn btn-p" style={{width:'100%',fontSize:16,padding:'12px 0'}}
          disabled={selected.length!==4}
          onClick={()=>dispatch({type:A.SELECT_STARTERS,ids:selected,playerName:playerName.trim()||'You',cafeName:cafeName.trim()||'Pet Cafe'})}>
          Open Cafe
        </button>
      </div>
    </div>
  );
}

// ─── APP ──────────────────────────────────────────────────────────────────────
function App() {
  const [state,dispatch]=useReducer(reducer,null,()=>({screen:'title',week:0,money:0,activePanel:'home',pets:[],staff:[],allMenuItems:[],discoveredItemIds:[],activeMenuIds:{drinks:[],food:[],desserts:[]},rdBudget:50,lastWeekItemSales:{},assignedPetIds:[],breedingQueue:[],wildPetPool:[],staffCandidates:[],currentTrend:null,trendHistory:[],currentShow:null,showEntries:[],trophies:[],totalEarned:0,totalSpent:0,discoveredCombos:[],discoveredCount:0,cafeLevel:1,weekResult:null,weekSummary:null,eventLog:[],rngSeed:1,seed:1,cafeName:'Pet Cafe',activeSeasonalEvent:null}));

  // Auto-save
  useEffect(()=>{ if(state.screen==='game'&&state.week>0) Save.save(state); },[state.week]);

  if(state.screen==='title')    return <TitleScreen dispatch={dispatch}/>;
  if(state.screen==='petselect') return <PetSelectScreen state={state} dispatch={dispatch}/>;
  if(state.screen==='gameover') return <GameOver state={state} dispatch={dispatch}/>;
  return <GameScreen state={state} dispatch={dispatch}/>;
}

// ─── ENTRY ────────────────────────────────────────────────────────────────────
const root=ReactDOM.createRoot(document.getElementById('root'));
root.render(<ToastProvider><App/></ToastProvider>);
