// CONY · Soft Seoul — main app

const { useState, useEffect, useRef, useCallback, useMemo } = React;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "showLandDots": true,
  "showHints": true,
  "travelSpeed": 1.0,
  "palette": ["#7a8d6b","#d4775c","#b8924c"]
}/*EDITMODE-END*/;

function App() {
  const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
  // subscribe to language changes so the whole tree re-renders on switch
  const T = window.useT ? window.useT() : (k => k);

  useEffect(() => {
    const r = document.documentElement;
    if (t.palette && t.palette.length >= 3) {
      r.style.setProperty('--sage', t.palette[0]);
      r.style.setProperty('--coral', t.palette[1]);
      r.style.setProperty('--gold', t.palette[2]);
    }
  }, [t.palette]);

  // mobile keyboard awareness: add 'input-focused' class to <body> while an
  // input/textarea is active so the depart-btn doesn't cover the keyboard
  useEffect(() => {
    const onFocus = (e) => {
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) {
        document.body.classList.add('input-focused');
      }
    };
    const onBlur = () => document.body.classList.remove('input-focused');
    document.addEventListener('focusin', onFocus);
    document.addEventListener('focusout', onBlur);
    return () => {
      document.removeEventListener('focusin', onFocus);
      document.removeEventListener('focusout', onBlur);
    };
  }, []);

  const [introOpen, setIntroOpen] = useState(() => {
    return !localStorage.getItem('cony.startedAt');
  });
  const [currentCityId, setCurrentCityId] = useState(() => {
    const saved = localStorage.getItem('cony.startCity');
    if (saved) return saved;
    // First-time player: random spawn from CITIES.
    const cities = window.CITIES || [];
    const random = cities[Math.floor(Math.random() * cities.length)];
    const cityId = random?.id || 'tokyo';
    try { localStorage.setItem('cony.startCity', cityId); } catch (_) {}
    return cityId;
  });
  // Passport country (3-letter ISO) — set once on first spawn, used to compute visa fees
  const [passportCountry, setPassportCountry] = useState(() => {
    const saved = localStorage.getItem('cony.passport');
    if (saved) return saved;
    const cities = window.CITIES || [];
    const startId = localStorage.getItem('cony.startCity');
    const startCity = cities.find(c => c.id === startId);
    const code = startCity?.country || 'JPN';
    try { localStorage.setItem('cony.passport', code); } catch (_) {}
    return code;
  });
  // Visited countries (for Game Over stamps)
  const [visitedCountries, setVisitedCountries] = useState(() => {
    try {
      const arr = JSON.parse(localStorage.getItem('cony.visited') || '[]');
      return Array.isArray(arr) ? arr : [];
    } catch { return []; }
  });
  const markVisited = (countryCode) => {
    if (!countryCode) return;
    setVisitedCountries(prev => {
      if (prev.includes(countryCode)) return prev;
      const next = [...prev, countryCode];
      try { localStorage.setItem('cony.visited', JSON.stringify(next)); } catch (_) {}
      return next;
    });
  };
  // Make sure the spawn country shows up in the passport stamps from turn 1
  useEffect(() => {
    if (passportCountry) markVisited(passportCountry);
  // eslint-disable-next-line
  }, []);

  // Achievement counters (persisted)
  const [stats, setStats] = useState(() => {
    try {
      return JSON.parse(localStorage.getItem('cony.stats') || '{}');
    } catch { return {}; }
  });
  const bumpStat = (key, by = 1) => {
    setStats(prev => {
      const next = { ...prev, [key]: (prev[key] || 0) + by };
      try { localStorage.setItem('cony.stats', JSON.stringify(next)); } catch (_) {}
      return next;
    });
  };
  useEffect(() => {
    const onMediaTap = () => bumpStat('mediaTaps', 1);
    window.addEventListener('cony-media-tap', onMediaTap);
    return () => window.removeEventListener('cony-media-tap', onMediaTap);
  // eslint-disable-next-line
  }, []);
  // Visa modal state
  const [visaModal, setVisaModal] = useState(null); // { from, to, visa, pendingDepart }
  const [gameOver, setGameOver] = useState(false);
  // Why the run ended: 'broke' | 'expired' | 'manual' — drives the GameOverModal copy
  const [gameOverReason, setGameOverReason] = useState(null);

  // Monetization · 'free' | 'paid'. Persisted across rebirths in localStorage.
  // 'paid' unlocks the other 25 main cities, bumps starting balance from $20k to $50k,
  // and is granted by buying the $2.99 one-time unlock.
  // NOTE: the buy flow currently stubs the purchase locally; replace toggleEntitlement
  // with the real StoreKit / Stripe handler when those credentials exist.
  const [entitlement, setEntitlement] = useState(() => {
    try { return localStorage.getItem('cony.entitlement') || 'free'; } catch { return 'free'; }
  });
  useEffect(() => {
    try { localStorage.setItem('cony.entitlement', entitlement); } catch (_) {}
  }, [entitlement]);
  const isPaid = entitlement === 'paid';
  const [paywallOpen, setPaywallOpen] = useState(false);
  // Culture diary state: opened in response to the cony-write-culture event
  // fired by MediaDetailModal. Holds the topic + the location context so the
  // saved entry knows what the player was writing about.
  const [cultureWrite, setCultureWrite] = useState(null);
  // Anti-abuse counters for the $1000-per-entry stipend. Both wipe on rebirth.
  const [cultureClaimedTopics, setCultureClaimedTopics] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.cultureClaimedTopics') || '[]'); }
    catch { return []; }
  });
  const [cultureClaimCount, setCultureClaimCount] = useState(() => {
    return parseInt(localStorage.getItem('cony.cultureClaimCount') || '0', 10) || 0;
  });
  const CULTURE_MAX_CLAIMS = 50;
  useEffect(() => { try { localStorage.setItem('cony.cultureClaimedTopics', JSON.stringify(cultureClaimedTopics)); } catch (_) {} }, [cultureClaimedTopics]);
  useEffect(() => { try { localStorage.setItem('cony.cultureClaimCount', String(cultureClaimCount)); } catch (_) {} }, [cultureClaimCount]);
  // Generic styled confirm / alert. Anything in the codebase can call
  // `await window.conyConfirm("…")` or `window.conyAlert("…")` instead of the
  // native browser dialogs. Queued so two prompts in quick succession both show.
  const [dialogQueue, setDialogQueue] = useState([]);
  useEffect(() => {
    const enqueue = (entry) => new Promise(resolve => {
      setDialogQueue(prev => [...prev, { ...entry, resolve }]);
    });
    window.conyAlert = (msg, opts = {}) => enqueue({ type: 'alert', msg, ...opts });
    window.conyConfirm = (msg, opts = {}) => enqueue({ type: 'confirm', msg, ...opts });
  }, []);
  const resolveDialog = (result) => {
    setDialogQueue(prev => {
      const [head, ...rest] = prev;
      head?.resolve?.(result);
      return rest;
    });
  };

  // Two-stage reward UX: when a submission qualifies we queue a bonusClaim
  // (modal where the player taps "Claim" to actually receive). Only on claim
  // does the money/cap/dedup state update + a confirmation toast appears.
  // Queue (not singleton) so rapid-fire qualifying entries don't lose payouts.
  const [bonusClaimQueue, setBonusClaimQueue] = useState([]);
  const bonusClaim = bonusClaimQueue[0] || null;
  const [bonusToast, setBonusToast] = useState(null);
  useEffect(() => {
    if (!bonusToast) return;
    const t = setTimeout(() => setBonusToast(null), 3200);
    return () => clearTimeout(t);
  }, [bonusToast]);
  const claimCultureBonus = () => {
    if (!bonusClaim) return;
    const { amount, topic } = bonusClaim;
    setBalance(b => b + amount);
    setCultureClaimCount(c => c + 1);
    if (topic) setCultureClaimedTopics(prev => prev.includes(topic) ? prev : [...prev, topic]);
    setBonusToast({ amount, topic, ts: Date.now() });
    setBonusClaimQueue(prev => prev.slice(1));
  };
  const dismissCultureBonus = () => setBonusClaimQueue(prev => prev.slice(1));

  // Apply a successful "Pathfinder Lifetime" purchase (called both by the
  // native IAP callback and by the local web stub).
  const applyLifetimePurchase = () => {
    setEntitlement('paid');
    setBalance(b => Math.max(b, 50000));
    setPaywallOpen(false);
  };

  // On launch, ask the native IAP bridge whether this Apple ID already owns
  // the lifetime unlock. If yes — flip entitlement so the player skips the
  // paywall entirely. No-op on web.
  useEffect(() => {
    const handler = (e) => {
      if (e?.detail?.owned) applyLifetimePurchase();
    };
    window.addEventListener('cony-iap-ready', handler);
    // If the bridge was injected before this effect runs, also poll once.
    if (window.cony?.iap?.isOwned) {
      window.cony.iap.isOwned().then(owned => { if (owned) applyLifetimePurchase(); });
    }
    return () => window.removeEventListener('cony-iap-ready', handler);
  // eslint-disable-next-line
  }, []);

  // Buy handler used by PaywallModal. On iOS the native StoreKit sheet pops
  // up; on web it falls back to a local stub flip so we can still test the
  // UX without payment infra wired.
  const grantPaid = async () => {
    if (window.cony?.iap?.buy) {
      const lang = window.getLang ? window.getLang() : 'en';
      try {
        const r = await window.cony.iap.buy();
        if (r?.status === 'success' || r?.status === 'owned') {
          applyLifetimePurchase();
        } else if (r?.status === 'cancelled') {
          // user dismissed Apple's sheet — no-op
        } else if (r?.status === 'pending') {
          const m = lang === 'zh' ? '购买待定（家长批准 / 银行确认）。完成后会自动解锁。'
                  : lang === 'ko' ? '결제 대기 중 (보호자 승인 / 은행 확인). 완료되면 자동으로 잠금 해제됩니다.'
                  : lang === 'ja' ? '購入保留中（保護者承認 / 銀行確認）。完了後に自動的にロック解除されます。'
                  : 'Purchase pending (parental approval / bank confirmation). It will unlock automatically when finalized.';
          window.conyAlert ? window.conyAlert(m) : alert(m);
        } else {
          const m = (lang === 'zh' ? '购买失败：' : lang === 'ko' ? '구매 실패: ' : lang === 'ja' ? '購入失敗: ' : 'Purchase failed: ') + (r?.error || 'unknown');
          window.conyAlert ? window.conyAlert(m, { tone: 'warn' }) : alert(m);
        }
      } catch (err) {
        const lang2 = window.getLang ? window.getLang() : 'en';
        const m = (lang2 === 'zh' ? '购买失败：' : 'Purchase failed: ') + (err?.message || err);
        window.conyAlert ? window.conyAlert(m, { tone: 'warn' }) : alert(m);
      }
      return;
    }
    // Web fallback: instant local stub until Stripe is wired.
    applyLifetimePurchase();
  };

  // Listen for "write culture entry" requests from MediaDetailModal. The event
  // arrives with the topic info; we tack on the player's current location so
  // the saved diary entry knows where it was written. cityName is derived in
  // the player's language (not always Chinese) so the modal copy reads cleanly.
  useEffect(() => {
    const handler = (e) => {
      const topic = e?.detail?.topic || null;
      const lang = window.getLang ? window.getLang() : 'en';
      const cityName = currentCity
        ? (lang === 'zh' ? (currentCity.name_zh || currentCity.name) : (currentCity.name || currentCity.name_zh))
        : '';
      setCultureWrite({
        topic,
        cityName,
        countryId: currentCity?.country || passportCountry || '',
      });
    };
    window.addEventListener('cony-write-culture', handler);
    return () => window.removeEventListener('cony-write-culture', handler);
  }, [currentCity, passportCountry]);

  // Anti-abuse stipend logic. Three checks; all three must pass for the $1000.
  // Diary entry still saves either way — the bonus is what's gated.
  const matchesText = (body, needle) => {
    if (!body || !needle) return false;
    const b = body.toLowerCase();
    const n = String(needle).toLowerCase().trim();
    if (!n) return false;
    if (b.includes(n)) return true;
    // Allow partial matches on words 2+ chars (so "Malala" hits "I am Malala")
    const parts = n.split(/[\s·,，。、！？!?\.\-—:：]+/).filter(p => p.length >= 2);
    return parts.some(p => b.includes(p));
  };
  // Content-quality heuristic — refuses spam / "aaaa..." / chip-pasting that
  // technically hits 300 chars but isn't actual writing. Tuned by hand so
  // real entries pass ~80% of the time and obvious low-effort ones fail.
  // Returns { ok: bool, reason?: string }.
  const entryQuality = (raw) => {
    if (!raw) return { ok: false, reason: 'short' };
    const chars = [...raw].filter(c => !/\s/.test(c));
    if (chars.length < 50) return { ok: false, reason: 'short' };
    const lower = chars.map(c => c.toLowerCase ? c.toLowerCase() : c);

    // 1. Character variety — unique / total
    const unique = new Set(lower);
    const ratio = unique.size / chars.length;
    if (ratio < 0.12 || unique.size < 20) return { ok: false, reason: 'low-variety' };

    // 2. Longest consecutive run of the same character
    let maxRun = 1, run = 1;
    for (let i = 1; i < lower.length; i++) {
      if (lower[i] === lower[i - 1]) { run++; if (run > maxRun) maxRun = run; }
      else run = 1;
    }
    if (maxRun > 10) return { ok: false, reason: 'repeat-run' };

    // 3. Most-repeated 4-char substring (catches phrase-spam without
    // penalizing reasonable repetition of names that are 5+ chars).
    const ngrams = new Map();
    for (let i = 0; i <= lower.length - 4; i++) {
      const k = lower.slice(i, i + 4).join('');
      ngrams.set(k, (ngrams.get(k) || 0) + 1);
    }
    let maxN = 0;
    for (const v of ngrams.values()) { if (v > maxN) maxN = v; }
    if (maxN > 12) return { ok: false, reason: 'repeat-phrase' };

    return { ok: true };
  };

  // Shared bonus-eligibility check used by both the culture-diary modal and
  // the regular Diary inside TravelLog. Rule: any 3 of 4 user-controlled
  // checks pass → eligible (so e.g. forgetting the topic title is forgiven
  // when the rest of the entry is solid). Dedup + cap + quality stay as
  // hard guards regardless of the 3/4 vote.
  const checkAndAwardCultureBonus = (entry) => {
    const okChars  = [...entry.body].length >= 300;
    const okPublic = entry.visibility === 'public';

    // Collect every known name of the player's location so the location
    // check passes regardless of which language they typed in.
    const locNames = [];
    if (entry.cityName) locNames.push(entry.cityName);
    (window.CITIES || []).forEach(c => {
      const matchesEntry = c.country === entry.countryId
        && (c.name === entry.cityName || c.name_zh === entry.cityName);
      if (matchesEntry) {
        if (c.name) locNames.push(c.name);
        if (c.name_zh) locNames.push(c.name_zh);
      }
    });
    if (entry.countryId && window.COUNTRIES?.[entry.countryId]) {
      const co = window.COUNTRIES[entry.countryId];
      if (co.name) locNames.push(co.name);
      if (co.name_zh) locNames.push(co.name_zh);
    }
    if (entry.countryId && window.countryLabel) {
      const label = window.countryLabel(entry.countryId);
      if (label) locNames.push(label);
    }
    if (entry.countryId) locNames.push(entry.countryId);  // 'JPN', 'BRA' etc.
    const okLocation = locNames.some(n => matchesText(entry.body, n))
      || (entry.countryId && entry.body.toUpperCase().includes(entry.countryId));

    // Topic match: prefer the explicit one from CultureDiaryModal, otherwise
    // scan the country's media + landmarks for any title/credit substring.
    let topicMatched = null;
    if (entry.topic?.title && (matchesText(entry.body, entry.topic.title) || matchesText(entry.body, entry.topic.credit))) {
      topicMatched = entry.topic.title.trim();
    } else if (entry.countryId) {
      const candidates = [];
      (window.COUNTRY_MEDIA?.[entry.countryId] || []).forEach(m => {
        if (m.title) candidates.push(m.title);
        if (m.credit) candidates.push(m.credit);
      });
      (window.CITIES || []).filter(c => c.country === entry.countryId).forEach(c => {
        (c.landmarks || []).forEach(lm => {
          if (lm.title) candidates.push(lm.title);
          if (lm.name) candidates.push(lm.name);
          if (lm.name_zh) candidates.push(lm.name_zh);
        });
        (c.media || []).forEach(m => {
          if (m.title) candidates.push(m.title);
          if (m.credit) candidates.push(m.credit);
        });
      });
      for (const c of candidates) {
        if (c && matchesText(entry.body, c)) { topicMatched = String(c).trim(); break; }
      }
    }
    const okTopic = !!topicMatched;

    const passes = (okChars?1:0) + (okPublic?1:0) + (okLocation?1:0) + (okTopic?1:0);
    if (passes < 3) return { paid: false, reason: 'too-few-checks', passes };

    // Hard quality gate (separate from the 3/4 vote)
    const q = entryQuality(entry.body);
    if (!q.ok) return { paid: false, reason: 'low-quality', quality: q.reason };

    // Dedup only when an explicit topic was matched — otherwise there's
    // nothing meaningful to dedupe against.
    if (topicMatched && cultureClaimedTopics.includes(topicMatched)) return { paid: false, reason: 'dup' };
    if (cultureClaimCount >= CULTURE_MAX_CLAIMS)                       return { paid: false, reason: 'cap' };

    // Queue the claim modal; mutations happen inside claimCultureBonus().
    setBonusClaimQueue(prev => [...prev, { amount: 1000, topic: topicMatched || entry.cityName || (entry.countryId || 'entry') }]);
    return { paid: 'pending', topicMatched };
  };

  // Culture-diary modal calls this directly. If the entry qualifies for the
  // stipend, we close the diary modal right away so the claim modal isn't
  // sitting behind it for 1.2s (which made it look like nothing happened).
  const submitCultureEntry = (entry) => {
    setDiary(prev => [entry, ...prev]);
    const result = checkAndAwardCultureBonus(entry);
    if (result.paid) setCultureWrite(null);
    return result;
  };
  const [destinationId, setDestinationId] = useState(null);
  const [transport, setTransport] = useState('plane');
  const [cabinClass, setCabinClass] = useState('economy');

  // Auto-reset cabin class when transport changes if the current one isn't valid
  // (bus/subway/ship have different cabin ids than plane)
  useEffect(() => {
    const cabins = window.CABIN_CLASSES?.[transport] || [];
    if (cabins.length && !cabins.some(c => c.id === cabinClass)) {
      setCabinClass(cabins[0].id);
    }
  }, [transport]); // eslint-disable-line

  // Resume an in-progress flight on app load (persisted in localStorage).
  // Always restore + start the animation loop — even if the flight already
  // ended while the app was closed. The animation's `u = (now-start)/dur`
  // saturates at 1 in that case, so the first tick hits the arrival branch
  // and credits the ticket / deducts balance / updates currentCityId.
  // (Earlier behavior cleared the saved key and silently stranded the
  // player at the origin with no charge and no ticket.)
  useEffect(() => {
    try {
      const saved = JSON.parse(localStorage.getItem('cony.flight') || 'null');
      if (!saved) return;
      setDestinationId(saved.toId);
      setTransport(saved.transport);
      if (saved.cabinClass) setCabinClass(saved.cabinClass);
      setTraveling(true);
    } catch (_) { /* ignore */ }
  }, []); // eslint-disable-line
  const [traveling, setTraveling] = useState(false);
  const [progress, setProgress] = useState(0);
  const [panelOpen, setPanelOpen] = useState(false);
  const [routeKey, setRouteKey] = useState(0);
  const [arrivedFlash, setArrivedFlash] = useState(false);
  const [arrivalInfo, setArrivalInfo] = useState(null);  // {city, transport, durationLabel}
  const [countryOpen, setCountryOpen] = useState(null);
  const [countryOrigin, setCountryOrigin] = useState({ x: 50, y: 50 });
  const [countryEntry, setCountryEntry] = useState(null);
  const [balance, setBalance] = useState(() => {
    try {
      const v = JSON.parse(localStorage.getItem('cony.balance'));
      // first-time players start at 0 — they have to claim the $20,000 funds popup
      const claimed = !!localStorage.getItem('cony.fundsClaimed');
      return typeof v === 'number' ? v : (claimed ? (window.INITIAL_BALANCE || 1000) : 0);
    } catch { return 0; }
  });
  useEffect(() => { localStorage.setItem('cony.balance', JSON.stringify(balance)); }, [balance]);
  const [fundsClaimed, setFundsClaimed] = useState(() => !!localStorage.getItem('cony.fundsClaimed'));
  const [upgradeOffer, setUpgradeOffer] = useState(null);  // { upgrades, currentCabin }
  const [flightVersion, setFlightVersion] = useState(0); // bump to force flight-timer effect to re-read localStorage
  const [logOpen, setLogOpen] = useState(false);
  // petOpen / saveOpen removed (PetPanel + SaveFileModal cut)
  const [pendingDepart, setPendingDepart] = useState(null);
  const [albumOpen, setAlbumOpen] = useState(false);
  const [photos, setPhotos] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.photos') || '[]'); } catch { return []; }
  });
  useEffect(() => { localStorage.setItem('cony.photos', JSON.stringify(photos)); }, [photos]);
  const [diary, setDiary] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.diary') || '[]'); } catch { return []; }
  });
  useEffect(() => { localStorage.setItem('cony.diary', JSON.stringify(diary)); }, [diary]);
  // (NPC companion picker / chatter removed — only real-friend invites remain.)
  const [memoryCity, setMemoryCity] = useState(null);
  const [langOpen, setLangOpen] = useState(false);
  const [authOpen, setAuthOpen] = useState(false);
  const [friendsOpen, setFriendsOpen] = useState(false);
  const [user, setUser] = useState(null);
  const [profile, setProfile] = useState(null);
  const [profileSetupOpen, setProfileSetupOpen] = useState(false);
  // Supabase auth listener · sets up listener FIRST so we don't miss the SIGNED_IN
  // event that fires right after detectSessionInUrl finishes parsing the OAuth hash.
  useEffect(() => {
    if (!window.SUPA) return;
    const handleSession = async (u) => {
      setUser(u);
      if (!u) { setProfile(null); return; }
      try {
        const { data: p } = await window.SUPA.from('profiles').select('*').eq('id', u.id).maybeSingle();
        setProfile(p);
        if (!p) setProfileSetupOpen(true);
      } catch (e) {
        console.warn('profile fetch failed', e);
        setProfile(null);
        setProfileSetupOpen(true); // assume fresh login → ask for nickname
      }
    };
    // 1. listener first (catches the fresh OAuth state change reliably)
    const { data: sub } = window.SUPA.auth.onAuthStateChange((_ev, session) => {
      handleSession(session?.user || null);
    });
    // 2. then check current session for already-logged-in users
    window.SUPA.auth.getSession().then(({ data }) => {
      if (data?.session?.user) handleSession(data.session.user);
    });
    return () => sub?.subscription?.unsubscribe?.();
  }, []);
  const refreshProfile = useCallback(async () => {
    if (!user || !window.SUPA) return;
    const { data: p } = await window.SUPA.from('profiles').select('*').eq('id', user.id).maybeSingle();
    setProfile(p);
  }, [user]);
  // friends list at app-level so MessageBoard etc. can use friendIds
  const [friends, setFriendsList] = useState([]);
  const friendIds = useMemo(() => new Set(friends.map(f => f.id)), [friends]);
  // expose to global so MessageBoard / OnlineFriendsBar can access without prop drilling
  useEffect(() => {
    window.__conyAuth = { user, profile, friends, friendIds };
  }, [user, profile, friends, friendIds]);
  useEffect(() => {
    if (!user || !window.SUPA) { setFriendsList([]); return; }
    let ch;
    const load = async () => {
      const { data } = await window.SUPA.from('friendships').select('*')
        .or(`user_a.eq.${user.id},user_b.eq.${user.id}`).eq('status', 'accepted');
      const otherIds = (data || []).map(f => f.user_a === user.id ? f.user_b : f.user_a);
      if (otherIds.length === 0) { setFriendsList([]); return; }
      const { data: profs } = await window.SUPA.from('profiles').select('*').in('id', otherIds);
      setFriendsList(profs || []);
    };
    load();
    ch = window.SUPA.channel('frlist-' + user.id)
      .on('postgres_changes', { event: '*', schema: 'public', table: 'friendships' }, load)
      .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles' }, load)
      .subscribe();
    return () => window.SUPA.removeChannel(ch);
  }, [user?.id]); // eslint-disable-line
  // sync current_city to backend
  useEffect(() => {
    if (!user || !profile || !window.SUPA || !currentCity) return;
    const cityName = currentCity.name_zh || currentCity.name;
    if (profile.current_city === cityName) return;
    window.SUPA.from('profiles').update({ current_city: cityName }).eq('id', user.id).then(() => {
      setProfile(p => p ? { ...p, current_city: cityName } : p);
    });
  }, [currentCity?.id, user?.id]); // eslint-disable-line
  const signOut = useCallback(async () => {
    if (!window.SUPA) return;
    await window.SUPA.auth.signOut();
    setUser(null);
    setProfile(null);
  }, []);
  // NPC companions removed; ticket "companions" field is now sourced
  // exclusively from real-friend invites (travelInvitees).
  const [pet, setPet] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.pet') || 'null'); } catch { return null; }
  });
  const [tickets, setTickets] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.tickets') || '[]'); } catch { return []; }
  });
  const [payslips, setPayslips] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.payslips') || '[]'); } catch { return []; }
  });
  const [ticketSkin, setTicketSkin] = useState(() => localStorage.getItem('cony.skin') || 'classic');
  useEffect(() => { localStorage.setItem('cony.skin', ticketSkin); }, [ticketSkin]);
  useEffect(() => { localStorage.setItem('cony.pet', JSON.stringify(pet)); }, [pet]);
  useEffect(() => { localStorage.setItem('cony.tickets', JSON.stringify(tickets)); }, [tickets]);
  useEffect(() => { localStorage.setItem('cony.payslips', JSON.stringify(payslips)); }, [payslips]);

  // Sync travel record to Supabase so friends can view it (after auth + tickets exist)
  useEffect(() => {
    if (!user?.id || !window.SUPA) return;
    const supa = window.SUPA;
    const tier = window.getCountryTier ? window.getCountryTier(passportCountry) : 3;
    const visitedCities = Array.from(new Set(tickets.map(t => t.toId || t.to?.id).filter(Boolean)));
    const totalKm = Math.round(tickets.reduce((s, t) => s + (t.km || 0), 0));
    const payload = {
      user_id: user.id,
      passport_country: passportCountry,
      passport_tier: tier,
      visited_countries: visitedCountries,
      visited_cities: visitedCities,
      ticket_count: tickets.length,
      total_km: totalKm,
    };
    const tm = setTimeout(() => {
      supa.from('travel_records').upsert(payload).then(({ error }) => {
        if (error) console.warn('travel sync err', error);
      });
    }, 5000);
    return () => clearTimeout(tm);
  // eslint-disable-next-line
  }, [user?.id, passportCountry, visitedCountries.length, tickets.length]);
  // remember last visited city across sessions
  useEffect(() => { localStorage.setItem('cony.startCity', currentCityId); }, [currentCityId]);
  const addTicket = useCallback((tk) => setTickets(prev => [tk, ...prev]), []);
  const addPayslip = useCallback((ps) => setPayslips(prev => [ps, ...prev]), []);
  // visited city ids (both world cities and sub-cities) for showing flags on the map
  const visitedCityIds = useMemo(() => {
    const set = new Set();
    if (currentCityId) set.add(currentCityId);
    tickets.forEach(t => {
      if (t.fromId) set.add(t.fromId);
      if (t.toId) set.add(t.toId);
    });
    return set;
  }, [tickets, currentCityId]);
  // 无限模式：余额 ≥ $1,000,000 时不再扣钱
  const INFINITE = balance >= 1000000;

  // Paywall · auto-pop once when a free user has visited 5 distinct world cities.
  // Counts only main cities (the ones with their own `country` field) so sub-city
  // landmarks like `tokyo-tower` don't double-count.
  useEffect(() => {
    if (isPaid || gameOver || introOpen) return;
    if (localStorage.getItem('cony.paywallShown') === '1') return;
    const mains = (window.CITIES || []).map(c => c.id);
    const mainSet = new Set(mains);
    let n = 0; for (const id of visitedCityIds) if (mainSet.has(id)) n++;
    if (n >= 5) {
      localStorage.setItem('cony.paywallShown', '1');
      setPaywallOpen(true);
    }
  }, [visitedCityIds, isPaid, gameOver, introOpen]);
  // GAME OVER · condition 1 — broke and no active job
  // Triggers when balance ≤ 0 after the starting funds were claimed, the player
  // isn't in a flight, and no job is in progress in any city.
  //
  // Reversible: if the player was broke-locked but later picks up money (e.g.
  // claiming a culture stipend pops them back above 0), un-lock the run so
  // they can keep playing. Visa-expired / manual end stay terminal.
  useEffect(() => {
    if (INFINITE) return;
    if (!localStorage.getItem('cony.fundsClaimed')) return; // pre-claim grace
    if (traveling) return;
    const hasJob = Object.keys(localStorage).some(k => k.startsWith('cony.job.'));
    const isBroke = balance <= 0 && !hasJob;
    if (isBroke && !gameOver) {
      const lang = window.getLang ? window.getLang() : 'en';
      const msg = lang === 'zh' ? '钱用完了，也没工作在做——本局结束。'
                : lang === 'ko' ? '잔액이 0이 되었고 진행 중인 일도 없어 — 게임 종료.'
                : lang === 'ja' ? '所持金がゼロになり、進行中の仕事もありません — ゲーム終了。'
                : 'You are broke and have no job in progress — game over.';
      window.conyAlert ? window.conyAlert(msg, { tone: 'warn' }) : (function(){ try { window.alert(msg); } catch(_){} })();
      setGameOverReason('broke');
      setGameOver(true);
    } else if (!isBroke && gameOver && gameOverReason === 'broke') {
      // Player recovered (e.g. culture stipend deposited) → resume the run.
      setGameOver(false);
      setGameOverReason(null);
    }
  }, [balance, traveling, gameOver, gameOverReason, INFINITE]);

  // GAME OVER · condition 2 — 60-game-day visa period expired
  //
  // Now driven by accumulated *playtime* rather than wall-clock. Mapping:
  //   1 in-game day = 30 minutes of active play  → 60 days = 30 hours of play
  // Active play means the tab is visible. Switching apps / locking the phone
  // does NOT count down. Stored in `cony.playTime` (ms).
  // Warning fires once at ≤ 7 game days remaining.
  useEffect(() => {
    if (gameOver || INFINITE) return;
    const GAME_DAY_MS = 30 * 60 * 1000;       // 30 real minutes per game day
    const DAYS_MAX = 60;
    const WARN_DAYS = 7;
    const msMax = DAYS_MAX * GAME_DAY_MS;
    let lastTick = Date.now();

    const check = (justAccumulated) => {
      const playTime = parseInt(localStorage.getItem('cony.playTime') || '0', 10) || 0;
      if (playTime >= msMax) {
        const lang = window.getLang ? window.getLang() : 'en';
        const msg = lang === 'zh' ? '签证到期 · 60 天的旅行期限已经用完，本局结束。'
                  : lang === 'ko' ? '비자 만료 · 60일의 여행 기한이 끝나 게임이 종료됩니다.'
                  : lang === 'ja' ? 'ビザ失効 · 60 日間の旅行期限が終了しました。'
                  : 'Visa expired · your 60-day travel period is over — game over.';
        window.conyAlert ? window.conyAlert(msg, { tone: 'warn' }) : (function(){ try { window.alert(msg); } catch(_){} })();
        setGameOverReason('expired');
        setGameOver(true);
        return;
      }
      const daysLeft = Math.ceil((msMax - playTime) / GAME_DAY_MS);
      if (justAccumulated && daysLeft <= WARN_DAYS && !localStorage.getItem('cony.expiryWarned')) {
        localStorage.setItem('cony.expiryWarned', '1');
        const lang = window.getLang ? window.getLang() : 'en';
        const msg = lang === 'zh' ? `签证只剩 ${daysLeft} 天，到期会强制结束本局。`
                  : lang === 'ko' ? `비자가 ${daysLeft}일 남았습니다. 만료 시 게임이 종료됩니다.`
                  : lang === 'ja' ? `ビザ残り ${daysLeft} 日。期限切れで本局終了します。`
                  : `Only ${daysLeft} day(s) left on your visa. The run ends when it expires.`;
        window.conyAlert ? window.conyAlert(msg, { tone: 'warn' }) : (function(){ try { window.alert(msg); } catch(_){} })();
      }
    };

    const tick = () => {
      if (document.visibilityState === 'visible') {
        const now = Date.now();
        const dt = now - lastTick;
        // ignore long gaps (sleep / suspended tab) — only count contiguous play.
        if (dt > 0 && dt < 30 * 1000) {
          const prior = parseInt(localStorage.getItem('cony.playTime') || '0', 10) || 0;
          localStorage.setItem('cony.playTime', String(prior + dt));
          check(true);
        } else {
          check(false);
        }
        lastTick = now;
      } else {
        lastTick = Date.now();  // reset baseline so the hidden interval doesn't backfill
      }
    };
    check(false);
    const id = setInterval(tick, 5000);
    return () => clearInterval(id);
  }, [gameOver, INFINITE]);
  const rebirth = () => {
    // Wipe per-run state but preserve permanent personal records:
    //   - lang / sound / entitlement (player settings)
    //   - diary entries (cony.diary, cony.diary.* — only deleted by user)
    //   - photo album (cony.photos — same: only deleted by user)
    //   - travel stats history could be added here later if we want lifetime totals.
    const keysToKeep = [
      'cony.lang',
      'cony.sound.tracks', 'cony.sound.vols', 'cony.sound.master', 'cony.sound.hint.v2',
      'cony.entitlement',
      'cony.photos',
    ];
    const saved = {};
    keysToKeep.forEach(k => { const v = localStorage.getItem(k); if (v !== null) saved[k] = v; });
    Object.keys(localStorage).filter(k => k.startsWith('cony.diary')).forEach(k => {
      saved[k] = localStorage.getItem(k);
    });
    Object.keys(localStorage).filter(k => k.startsWith('cony.')).forEach(k => localStorage.removeItem(k));
    Object.entries(saved).forEach(([k, v]) => localStorage.setItem(k, v));
    location.reload();
  };
  const endGameRequest = () => {
    const lang = window.getLang ? window.getLang() : 'en';
    const msg = lang === 'zh' ? '确定结束本局？日记会保留，其他清空。'
              : lang === 'ko' ? '게임을 끝내시겠습니까? 일기는 보존되고 나머지는 초기화됩니다.'
              : lang === 'ja' ? 'ゲームを終了しますか？日記は保存されますが、他はリセットされます。'
              : 'End this run? Diary is preserved; everything else is cleared.';
    (window.conyConfirm || (m => Promise.resolve(window.confirm(m))))(msg, { tone: 'danger' }).then(ok => {
      if (ok) {
        setGameOverReason('manual');
        setGameOver(true);
      }
    });
  };
  const spendBalance = useCallback((usd) => {
    setBalance(b => b >= 1000000 ? b : Math.max(0, b - usd));
  }, []);
  // 奖励队列 — 不直接入账，弹出"领取"弹窗等用户点击
  const [rewardQueue, setRewardQueue] = useState([]);
  const earnBalance = useCallback((usd, source) => {
    if (!usd || usd <= 0) return;
    setRewardQueue(prev => [...prev, {
      id: `r-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
      amount: Math.round(usd),
      source: source || null,
    }]);
  }, []);
  const claimReward = useCallback(() => {
    setRewardQueue(prev => {
      if (!prev.length) return prev;
      const [head, ...rest] = prev;
      setBalance(b => b >= 1000000 ? b : b + head.amount);
      return rest;
    });
  }, []);

  // when transport changes, set a sensible default cabin class
  useEffect(() => {
    const cabins = (window.CABIN_CLASSES && window.CABIN_CLASSES[transport]) || [];
    if (cabins.length && !cabins.some(c => c.id === cabinClass)) {
      setCabinClass(cabins[0].id);
    }
  }, [transport]); // eslint-disable-line

  useEffect(() => {
    const handler = (e) => {
      const d = e.detail;
      if (typeof d === 'string') {
        setCountryOpen(d);
      } else if (d && d.id) {
        setCountryOpen(d.id);
        setCountryOrigin({ x: d.originX ?? 50, y: d.originY ?? 50 });
      }
    };
    window.addEventListener('open-country', handler);
    return () => window.removeEventListener('open-country', handler);
  }, []);

  const currentCity = window.CITIES.find(c => c.id === currentCityId);
  const destinationCity = window.CITIES.find(c => c.id === destinationId);
  const currentTransport = window.TRANSPORTS.find(x => x.id === transport);

  const onCityClick = (cityId) => {
    if (traveling) return;
    if (cityId === currentCityId) {
      setPanelOpen(true);
      return;
    }
    setDestinationId(cityId);
    setPanelOpen(false);
    setRouteKey(k => k + 1);
    // auto-fallback transport if current one is invalid
    const dest = window.CITIES.find(c => c.id === cityId);
    if (dest && !window.canTravel(currentCity, dest, transport).ok) {
      setTransport('plane');
    }
  };

  const onTransportSelect = (id) => {
    if (destinationCity && !window.canTravel(currentCity, destinationCity, id).ok) return;
    setTransport(id);
  };

  const onDepart = () => {
    if (!destinationId || traveling) return;
    // Free-tier gate: locked destination → paywall instead of departing.
    if (window.isCityUnlocked && !window.isCityUnlocked(destinationId)) {
      setPaywallOpen(true);
      return;
    }
    if (!INFINITE && window.calcWorldCost) {
      const cost = window.calcWorldCost(currentCity, destinationCity, currentTransport, cabinClass);
      const petFee = (pet && window.calcPetFee) ? window.calcPetFee(transport, cost) : 0;
      if (cost + petFee > balance) return; // hard guard
    }
    // VISA CHECK — block depart if destination country needs a visa we haven't gotten yet
    const destCountry = destinationCity?.country;
    const visa = (destCountry && window.getVisaInfo)
      ? window.getVisaInfo(passportCountry, destCountry)
      : { visaFree:true };
    if (!visa.visaFree) {
      // open visa modal instead of departing
      setVisaModal({
        from: passportCountry,
        to: destCountry,
        visa,
        pendingDepart: {
          from: currentCity,
          to: destinationCity,
          transport: currentTransport,
          cabinClass,
          pet,
          scope: 'world',
        },
      });
      return;
    }
    setPendingDepart({
      from: currentCity,
      to: destinationCity,
      transport: currentTransport,
      cabinClass,
      pet,
      scope: 'world',
    });
  };
  const [travelInvitees, setTravelInvitees] = useState([]);
  const confirmDepart = (invitedFriends = []) => {
    setTravelInvitees(invitedFriends);
    setPendingDepart(null);
    setTraveling(true);
    setProgress(0);
    setRouteKey(k => k + 1);
    setPanelOpen(false);
  };

  const skipTravelRef = useRef(false);
  const onSkipTravel = () => { skipTravelRef.current = true; };

  // Cancel an in-flight trip and return to the departure city.
  // Safe: fare is only charged on arrival (see arrival branch below), so
  // canceling mid-flight costs the player nothing. Just clears flight state.
  const onCancelTravel = () => {
    try { localStorage.removeItem('cony.flight'); } catch (_) {}
    setTraveling(false);
    setProgress(0);
    setDestinationId(null);
    setTravelInvitees([]);
  };

  // REAL-TIME flight — literal hours from real-world distance/speed.
  // Persisted in localStorage so closing the app doesn't lose the flight.
  // Cabin timeMult shortens the duration: economy 1.0 / business 0.5 / first 0.1.
  // Floor at 30 seconds so very short hops still feel like a brief animation.
  useEffect(() => {
    if (!traveling) return;
    skipTravelRef.current = false;
    const SPEED_KMH = { plane: 900, subway: 350, bus: 90, ship: 40 };
    const km = window.haversineKm
      ? window.haversineKm(currentCity, destinationCity)
      : 1000;
    const speed = SPEED_KMH[transport] || 200;
    const realHours = km / speed;
    // Literal real-world hours. Backpacker mechanic: you actually wait.
    // To speed up: upgrade cabin (Business ½, First instant). Floor 30s for hops.
    const realMs = Math.max(30_000, realHours * 60 * 60 * 1000);
    // cabin time multiplier
    const cabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === cabinClass);
    const timeMult = cabin?.timeMult ?? 1.0;
    // First class (timeMult ≤ 0.1) = instant arrival, even when chosen pre-flight
    if (timeMult <= 0.1) {
      skipTravelRef.current = true;
    }
    let dur = (realMs * timeMult) / Math.max(0.1, t.travelSpeed);
    // persist + restore from localStorage so refreshing/closing doesn't lose the flight
    const KEY = 'cony.flight';
    let start;
    const saved = (() => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } })();
    if (saved && saved.toId === destinationId && saved.transport === transport) {
      start = saved.start;
      dur = saved.dur;
    } else {
      start = Date.now();
      localStorage.setItem(KEY, JSON.stringify({ toId: destinationId, transport, cabinClass, start, dur }));
    }
    let raf;
    const tick = () => {
      const now = Date.now();
      let u = Math.min(1, (now - start) / dur);
      if (skipTravelRef.current) u = 1;
      setProgress(u);
      if (u < 1) {
        raf = requestAnimationFrame(tick);
      } else {
        localStorage.removeItem(KEY);
        const arrivedCity = destinationCity;
        // compute cost (including pet fee) and generate ticket
        const wCost = window.calcWorldCost
          ? window.calcWorldCost(currentCity, arrivedCity, currentTransport, cabinClass)
          : 0;
        const wPetFee = (pet && window.calcPetFee) ? window.calcPetFee(transport, wCost) : 0;
        if (currentCity && arrivedCity && currentTransport && window.makeTicket) {
          const tk = window.makeTicket({
            from: currentCity, to: arrivedCity, transport: currentTransport,
            cabinClassId: cabinClass, scope: 'world', countryId: arrivedCity.country,
            pet, costUSD: wCost, petFeeUSD: wPetFee,
            companions: travelInvitees.map(f => ({ id: f.id, name: f.display_name })),
          });
          setTickets(prev => [tk, ...prev]);
          setTravelInvitees([]);
        }
        if (wCost && !INFINITE) setBalance(b => Math.max(0, b - wCost - wPetFee));
        setTraveling(false);
        setCurrentCityId(destinationId);
        if (arrivedCity?.country) markVisited(arrivedCity.country);
        setDestinationId(null);
        setArrivedFlash(true);
        setTimeout(() => setArrivedFlash(false), 4000);
        // ArrivalPopup — gives user explicit confirmation of arrival
        const mins = Math.round(dur / 60_000);
        const _dLang = window.getLang ? window.getLang() : 'en';
        const minUnit = _dLang === 'zh' ? ' 分钟' : _dLang === 'ko' ? ' 분' : _dLang === 'ja' ? ' 分' : ' min';
        const durationLabel = mins >= 1
          ? (mins + minUnit)
          : (Math.round(dur / 1000) + ' s');
        setArrivalInfo({ city: arrivedCity, transport, durationLabel });
        // Note: country auto-open / panel-open are now triggered after popup dismissed
      }
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [traveling, currentTransport.duration, t.travelSpeed, destinationId, flightVersion]);

  useEffect(() => {
    const onKey = (e) => {
      if (e.code === 'Space' && destinationId && !traveling) {
        e.preventDefault();
        onDepart();
      }
      if (e.code === 'Escape') {
        setPanelOpen(false);
        setDestinationId(null);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [destinationId, traveling]);

  const stateFor = (cityId) => {
    if (cityId === currentCityId) return 'current';
    if (cityId === destinationId) return 'active';
    return '';
  };

  // hint state
  let hintStep = null;
  if (t.showHints && !introOpen && !traveling) {
    if (!destinationId && !panelOpen) hintStep = 'pick';
    else if (destinationId) hintStep = 'transit';
    else if (arrivedFlash) hintStep = 'arrive';
  }

  return (
    <div data-screen-label="01 World Map">
      <div className="bg-paper"></div>
      <div className="bg-noise"></div>

      <div className="map-scroll" ref={(el) => {
        if (!el || el.__inited) return;
        el.__inited = true;
        // center the map on the current city on first mount (mobile-only feature; desktop is full-fit so no effect)
        requestAnimationFrame(() => {
          if (!currentCity) return;
          const { x, y } = window.PROJECT(currentCity.lat, currentCity.lon);
          const sw = el.scrollWidth, sh = el.scrollHeight;
          const cw = el.clientWidth, ch = el.clientHeight;
          if (sw > cw) el.scrollLeft = Math.max(0, (x / 100) * sw - cw / 2);
          if (sh > ch) el.scrollTop  = Math.max(0, (y / 100) * sh - ch / 2);
        });
      }}>
       <div className="map-stage">
        {t.showLandDots && <window.WorldBackdrop />}
        {destinationId && !traveling && (
          <window.RoutePath
            key={`preview-${routeKey}`}
            from={currentCity}
            to={destinationCity}
            transport={transport}
            animateKey={routeKey}
          />
        )}
        {traveling && (
          <window.RoutePath
            key={`active-${routeKey}`}
            from={currentCity}
            to={destinationCity}
            transport={transport}
            animateKey={routeKey}
          />
        )}
        {traveling && (
          <window.VehicleOnRoute
            from={currentCity}
            to={destinationCity}
            transport={transport}
            progress={progress}
          />
        )}
        {window.CITIES.filter(city => {
          // MVP destination filter: only show Korea-bound cities + current spawn city
          // Future: expand window.PATHFINDER_DESTINATIONS to include more countries
          if (!window.PATHFINDER_DESTINATIONS) return true;
          if (city.id === currentCityId) return true;
          return window.PATHFINDER_DESTINATIONS.includes(city.country);
        }).map(city => (
          <window.CityNode
            key={city.id}
            city={city}
            state={stateFor(city.id)}
            visited={visitedCityIds.has(city.id)}
            onClick={() => onCityClick(city.id)}
            onMemoryClick={setMemoryCity}
          />
        ))}
       </div>
      </div>

      <window.TopBar
        currentCity={currentCity}
        currentTransport={currentTransport}
        traveling={traveling}
        onHelp={() => setIntroOpen(true)}
        onLog={() => setLogOpen(true)}
        onAlbum={() => setAlbumOpen(true)}
        onFriends={() => setFriendsOpen(true)}
        onUpgrade={() => setPaywallOpen(true)}
        user={user}
        isPaid={isPaid}
        onLang={() => setLangOpen(true)}
        onAuth={() => {
          if (!user) { setAuthOpen(true); return; }
          const lang = window.getLang ? window.getLang() : 'en';
          const who = profile?.display_name || user.email;
          const msg = lang === 'zh' ? `已登录 ${who}。退出登录？`
                    : lang === 'ko' ? `${who} 으로 로그인되어 있습니다. 로그아웃할까요?`
                    : lang === 'ja' ? `${who} としてログイン中。ログアウトしますか?`
                    : `Signed in as ${who}. Sign out?`;
          (window.conyConfirm || (m => Promise.resolve(window.confirm(m))))(msg).then(ok => { if (ok) signOut(); });
        }}
        profile={profile}
        ticketCount={tickets.length}
        pet={pet}
        passportCountry={passportCountry}
        onEndGame={endGameRequest}
        balance={balance}
      />

      {!traveling && <window.OnlineFriendsBar/>}

      {hintStep && <window.HintBanner step={hintStep} dest={currentCity?.name} />}

      {panelOpen && currentCity && !traveling && (
        <window.CityPanel
          city={currentCity}
          onClose={() => setPanelOpen(false)}
          balance={balance}
          onEarn={earnBalance}
          onPayslip={addPayslip}
          pet={pet}
        />
      )}

      <window.TransportBar
        active={transport}
        onSelect={onTransportSelect}
        from={currentCity}
        to={destinationCity}
      />

      {destinationId && !traveling && (transport === 'plane' || transport === 'ship') && (
        <window.ClassPicker
          transportId={transport}
          value={cabinClass}
          onChange={setCabinClass}
        />
      )}

      {destinationId && !traveling && (() => {
        const baseCost = window.calcWorldCost
          ? window.calcWorldCost(currentCity, destinationCity, currentTransport, cabinClass)
          : 0;
        const petFee = (pet && window.calcPetFee) ? window.calcPetFee(transport, baseCost) : 0;
        const totalCost = baseCost + petFee;
        const cur = window.COUNTRY_CURRENCY?.[destinationCity.country];
        const fmtLocal = (usd) => cur && cur.code !== 'USD'
          ? `${cur.symbol}${cur.decimals === 0 ? Math.round(usd * cur.perUSD).toLocaleString() : (usd * cur.perUSD).toFixed(cur.decimals)}`
          : '';
        const localTotal = fmtLocal(totalCost);
        const insufficient = !INFINITE && totalCost > balance;
        const lang = window.getLang ? window.getLang() : 'en';
        const cityName = lang === 'zh'
          ? (destinationCity.name_zh || destinationCity.name || '?')
          : (destinationCity.name || destinationCity.name_zh || '?');
        const depart = T('btn.depart') || (lang === 'zh' ? '出发' : 'Depart');
        const labelText = insufficient
          ? (lang === 'zh' ? '余额不足' : 'Insufficient')
          : `${depart} → ${cityName}`;
        return (
          <button className={`depart-btn ${insufficient ? 'insufficient' : ''}`}
                  onClick={() => { if (!insufficient) onDepart(); }}
                  disabled={insufficient}
                  title={insufficient ? `Insufficient · need $${totalCost - balance}` : ''}>
            <span className="depart-label">{labelText}</span>
            <span className="cost">
              ${totalCost}
              {petFee > 0 && <span className="cost-pet">+${petFee}</span>}
              {localTotal && <span className="cost-local">{localTotal}</span>}
            </span>
            <span className="key">Space</span>
          </button>
        );
      })()}

      {traveling && (
        <>
          <window.TravelOverlay
            from={currentCity}
            to={destinationCity}
            transport={transport}
            cabinClass={cabinClass}
            progress={progress}
            balance={balance}
            onSkip={onSkipTravel}
            onUpgrade={(upgrades, currentCabin) => setUpgradeOffer({ upgrades, currentCabin })}
            onCancel={onCancelTravel}
          />
          {/* NPC companion chatter removed */}
        </>
      )}

      {upgradeOffer && (
        <window.UpgradeCabinModal
          upgrades={upgradeOffer.upgrades}
          currentCabin={upgradeOffer.currentCabin}
          balance={balance}
          transport={transport}
          fromCity={currentCity}
          toCity={destinationCity}
          onCancel={() => setUpgradeOffer(null)}
          onConfirm={(newCabinId, cost) => {
            // pay → switch cabin → re-time remaining travel
            if (cost > balance && !INFINITE) { setUpgradeOffer(null); return; }
            if (cost > 0) setBalance(b => Math.max(0, b - cost));
            const newCabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === newCabinId);
            const newT = newCabin?.timeMult ?? 1;
            // First class (or any cabin with timeMult ≤ 0.1) = instant arrival
            if (newT <= 0.1) {
              setCabinClass(newCabinId);
              setUpgradeOffer(null);
              localStorage.removeItem('cony.flight');
              skipTravelRef.current = true; // next animation tick fires arrival
              return;
            }
            // Otherwise: shorten the remaining wall-clock proportionally to (newT / oldT)
            const saved = (() => { try { return JSON.parse(localStorage.getItem('cony.flight') || 'null'); } catch { return null; } })();
            if (saved) {
              const now = Date.now();
              const elapsed = now - saved.start;
              const remaining = Math.max(0, saved.dur - elapsed);
              const oldCabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === cabinClass);
              const oldT = oldCabin?.timeMult ?? 1;
              const ratio = Math.max(0.05, newT / Math.max(0.05, oldT));
              const newRemaining = Math.max(2_000, remaining * ratio);
              const p = Math.max(0, Math.min(0.95, progress));
              const newDur = newRemaining / Math.max(0.05, 1 - p);
              const newStart = (now + newRemaining) - newDur;
              localStorage.setItem('cony.flight', JSON.stringify({
                toId: saved.toId, transport, cabinClass: newCabinId,
                start: newStart, dur: newDur,
              }));
            }
            setCabinClass(newCabinId);
            setUpgradeOffer(null);
            // bump version → effect re-runs with fresh localStorage but TravelOverlay stays mounted
            // (so the ambient sound mixer keeps playing without interruption)
            setFlightVersion(v => v + 1);
          }}
        />
      )}

      {arrivalInfo && (
        <window.ArrivalPopup
          city={arrivalInfo.city}
          transport={arrivalInfo.transport}
          durationLabel={arrivalInfo.durationLabel}
          onContinue={() => {
            const arrived = arrivalInfo.city;
            setArrivalInfo(null);
            // auto-open country deep map if it has data — enter at the arrived sub-city
            if (arrived && window.COUNTRIES?.[arrived.country]) {
              const _lang = window.getLang ? window.getLang() : 'en';
              const entryName = _lang === 'zh'
                ? (arrived.name_zh || arrived.name)
                : (arrived.name || arrived.name_zh);
              setCountryOpen(arrived.country);
              setCountryEntry(entryName);
              setCountryOrigin({ x: 50, y: 50 });
            } else {
              setPanelOpen(true);
            }
          }}
        />
      )}

      {introOpen && (
        <window.Intro
          savedStartId={currentCityId}
          onStart={(startId) => {
            const startCity = window.CITIES.find(c => c.id === startId);
            if (startCity) {
              setCurrentCityId(startId);
              localStorage.setItem('cony.startCity', startId);
              // passport country = wherever you woke up. Always update to match new spawn.
              const code = startCity.country;
              setPassportCountry(code);
              localStorage.setItem('cony.passport', code);
              // reset visited countries for this run (only spawn country starts marked)
              setVisitedCountries([code]);
              localStorage.setItem('cony.visited', JSON.stringify([code]));
            }
            localStorage.setItem('cony.startedAt', String(Date.now()));
            setIntroOpen(false);
          }}
        />
      )}

      {/* one-time claim popup AFTER intro — grants $20,000 starting funds */}
      {!introOpen && !fundsClaimed && (
        <window.ClaimFundsModal
          onClaim={() => {
            const initial = isPaid ? 50000 : (window.INITIAL_BALANCE || 20000);
            setBalance(b => b + initial);
            localStorage.setItem('cony.fundsClaimed', '1');
            setFundsClaimed(true);
          }}
        />
      )}

      {countryOpen && (
        <window.CountryView
          countryId={countryOpen}
          originX={countryOrigin.x}
          originY={countryOrigin.y}
          entryHint={countryEntry}
          visitedSubIds={visitedCityIds}
          onClose={() => { setCountryOpen(null); setCountryEntry(null); }}
          pet={pet}
          balance={balance}
          onSpend={spendBalance}
          onEarn={earnBalance}
          onTicket={addTicket}
          onPayslip={addPayslip}
        />
      )}

      {logOpen && (
        <window.TravelLog
          tickets={tickets}
          payslips={payslips}
          diary={diary}
          currentCityName={currentCity?.name_zh || currentCity?.name || ''}
          currentCountryId={currentCity?.country || ''}
          skin={ticketSkin}
          onSkin={setTicketSkin}
          onAddDiary={(entry) => {
            setDiary(prev => [entry, ...prev]);
            // Even regular-Diary entries qualify for the $1000 stipend if they
            // hit all four gates (300+ chars, public, mentions topic, mentions place).
            checkAndAwardCultureBonus(entry);
          }}
          onUpdateDiary={(id, entry) => {
            setDiary(prev => prev.map(d => d.id === id ? { ...d, ...entry } : d));
            // An edit that newly satisfies the gates also pays (deduped per topic).
            checkAndAwardCultureBonus(entry);
          }}
          onDeleteDiary={(id) => setDiary(prev => prev.filter(d => d.id !== id))}
          onAddPhoto={(photo) => setPhotos(prev => [photo, ...prev])}
          onClose={() => setLogOpen(false)}
          onClear={() => {
            const lang = window.getLang ? window.getLang() : 'en';
            const msg = lang === 'zh' ? '清空所有车票？'
                      : lang === 'ko' ? '모든 티켓을 삭제할까요?'
                      : lang === 'ja' ? 'すべてのチケットを消去しますか？'
                      : 'Clear all tickets?';
            window.conyConfirm(msg, { tone: 'danger' }).then(ok => { if (ok) setTickets([]); });
          }}
          onClearPayslips={() => {
            const lang = window.getLang ? window.getLang() : 'en';
            const msg = lang === 'zh' ? '清空所有薪资单？'
                      : lang === 'ko' ? '모든 급여명세서를 삭제할까요?'
                      : lang === 'ja' ? 'すべての給与明細を消去しますか？'
                      : 'Clear all payslips?';
            window.conyConfirm(msg, { tone: 'danger' }).then(ok => { if (ok) setPayslips([]); });
          }}
        />
      )}

      {/* PetPanel removed — sub-feature distracted from core culture loop */}

      {pendingDepart && (
        <window.DepartConfirm
          {...pendingDepart}
          balance={balance}
          onCancel={() => setPendingDepart(null)}
          onConfirm={confirmDepart}
        />
      )}

      {/* SaveFileModal removed — localStorage auto-saves continuously */}

      {rewardQueue.length > 0 && (
        <window.RewardPopup
          reward={rewardQueue[0]}
          queueSize={rewardQueue.length}
          onClaim={claimReward}
        />
      )}

      {visaModal && (
        <window.VisaModal
          from={visaModal.from}
          to={visaModal.to}
          visa={visaModal.visa}
          balance={balance}
          onCancel={() => setVisaModal(null)}
          onApply={(approved, cost) => {
            // pay fee regardless of outcome
            if (cost > 0) setBalance(b => Math.max(0, b - cost));
            bumpStat('visaAttempts', 1);
            bumpStat('visaFeesPaid', cost);
            if (approved) bumpStat('visaApprovals', 1);
            else bumpStat('visaDenials', 1);
            if (approved) {
              const pending = visaModal.pendingDepart;
              setVisaModal(null);
              // proceed with departure
              setPendingDepart(pending);
            } else {
              // denied — just close, player keeps destination selected to retry
              setVisaModal(null);
            }
          }}
        />
      )}

      {gameOver && (
        <window.GameOverModal
          visitedCountries={visitedCountries}
          tickets={tickets}
          passportCountry={passportCountry}
          stats={stats}
          balance={balance}
          reason={gameOverReason}
          onRebirth={rebirth}
        />
      )}

      {paywallOpen && (
        <window.PaywallModal
          isPaid={isPaid}
          onBuy={grantPaid}
          onClose={() => setPaywallOpen(false)}
        />
      )}

      {cultureWrite && (
        <window.CultureDiaryModal
          topic={cultureWrite.topic}
          cityName={cultureWrite.cityName}
          countryId={cultureWrite.countryId}
          priorClaim={cultureWrite.topic?.title && cultureClaimedTopics.includes(cultureWrite.topic.title.trim())}
          capped={cultureClaimCount >= CULTURE_MAX_CLAIMS}
          claimCount={cultureClaimCount}
          claimMax={CULTURE_MAX_CLAIMS}
          onSubmit={submitCultureEntry}
          onClose={() => setCultureWrite(null)}
        />
      )}

      {/* Bonus claim + toast rendered AFTER the diary modal so they stack on top */}
      {dialogQueue[0] && (
        <window.ConyDialog dialog={dialogQueue[0]} onResolve={resolveDialog}/>
      )}

      {bonusClaim && (
        <window.BonusClaimModal
          amount={bonusClaim.amount}
          topic={bonusClaim.topic}
          queueSize={bonusClaimQueue.length}
          onClaim={claimCultureBonus}
          onClose={dismissCultureBonus}
        />
      )}

      {bonusToast && (() => {
        const lang = window.getLang ? window.getLang() : 'en';
        const msg = lang === 'zh' ? `+$${bonusToast.amount} 旅行补贴到账`
                  : lang === 'ko' ? `+$${bonusToast.amount} 여행 보조금 지급`
                  : lang === 'ja' ? `+$${bonusToast.amount} 旅行補助金 入金`
                  : `+$${bonusToast.amount} travel stipend credited`;
        return (
          <div className="bonus-toast" role="status">
            <span className="bt-amt">💰 {msg}</span>
            {bonusToast.topic && <span className="bt-topic">「{bonusToast.topic}」</span>}
          </div>
        );
      })()}

      {albumOpen && (
        <window.PhotoAlbum
          photos={photos}
          onAdd={(p) => {
            const lang = window.getLang ? window.getLang() : 'en';
            const cName = currentCity
              ? (lang === 'zh' ? (currentCity.name_zh || currentCity.name) : (currentCity.name || currentCity.name_zh))
              : '';
            setPhotos(prev => [{ ...p, cityName: cName || p.cityName }, ...prev]);
          }}
          onDelete={(id) => setPhotos(prev => prev.filter(p => p.id !== id))}
          onClose={() => setAlbumOpen(false)}
        />
      )}

      {/* CompanionPicker removed */}

      {memoryCity && (
        <window.CityMemoryModal
          city={memoryCity}
          diary={diary}
          photos={photos}
          onClose={() => setMemoryCity(null)}
        />
      )}

      {langOpen && (
        <window.LanguagePicker onClose={() => setLangOpen(false)}/>
      )}

      {authOpen && (
        <window.AuthModal onClose={() => setAuthOpen(false)}/>
      )}
      {profileSetupOpen && user && (
        <window.ProfileSetup
          user={user}
          onDone={() => { setProfileSetupOpen(false); refreshProfile(); }}
          onClose={() => setProfileSetupOpen(false)}
        />
      )}

      {friendsOpen && user && profile && (
        <window.FriendsModal
          user={user}
          profile={profile}
          onClose={() => setFriendsOpen(false)}
        />
      )}

      {/* TweaksPanel removed — was a dev-only panel, not user-facing */}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
