// CONY · Soft Seoul — main app

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

const FIRST_JOURNEY_TARGET = 3;

// The product visual direction is intentionally fixed so the web preview and
// native WKWebView share the same editorial journey desk on first paint.
if (typeof document !== 'undefined') document.documentElement.classList.add('cyberpunk', 'atlas-ui');

function journeyStampTheme(city) {
  if (!city) return 'CITY';
  const media = [
    ...(city.media || []),
    ...(city.landmarks || []).flatMap(lm => lm.media || []),
    ...((window.COUNTRY_MEDIA && window.COUNTRY_MEDIA[city.country]) || []),
  ];
  const types = new Set(media.map(item => item?.type));
  if (types.has('FILM') || types.has('ANIME')) return 'CINEMA';
  if (types.has('SONG') || types.has('MUSICAL')) return 'MUSIC';
  if (types.has('BOOK')) return 'LITERATURE';
  if (types.has('FOOD')) return 'TASTE';
  return 'CITY';
}

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);

  // The palette used to be injected here as inline styles on <html>, which
  // outranks every stylesheet — so the accent tokens in atlas-desk.css were
  // silently ignored and the app kept painting the old coral and gold no
  // matter what the CSS said. The palette is CSS-owned now; nothing writes
  // colour from JavaScript. (t.palette stays in the defaults block because the
  // EDITMODE markers are rewritten wholesale by the tweaks panel.)

  // 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);

  // 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));

  // 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: all three writing checks must
  // pass. A fourth "publish to the friends feed" check existed while the app
  // had accounts; the offline build has no feed, and entries were private by
  // default, so the three writing checks are the whole test now. Dedup + cap
  // + quality stay as hard guards on top.
  const checkAndAwardCultureBonus = (entry) => {
    const okChars  = [...entry.body].length >= 300;

    // 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) + (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;
      const validCity = (window.CITIES || []).some(city => city.id === saved.toId);
      const validTransport = (window.TRANSPORTS || []).some(item => item.id === saved.transport);
      const validTiming = Number.isFinite(Number(saved.start))
        && Number.isFinite(Number(saved.dur))
        && Number(saved.dur) > 0;
      if (!validCity || !validTransport || !validTiming) {
        localStorage.removeItem('cony.flight');
        return;
      }
      setDestinationId(saved.toId);
      setTransport(saved.transport);
      const cabins = window.CABIN_CLASSES?.[saved.transport] || [];
      const restoredCabin = cabins.some(cabin => cabin.id === saved.cabinClass)
        ? saved.cabinClass
        : (cabins[0]?.id || 'economy');
      setCabinClass(restoredCabin);
      setTraveling(true);
    } catch (_) {
      try { localStorage.removeItem('cony.flight'); } catch (_) {}
    }
  }, []); // 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 [travelDurationMs, setTravelDurationMs] = useState(0);
  const [travelMoment, setTravelMoment] = useState(null);
  const travelMomentShownRef = useRef(false);
  const travelPauseStartedRef = useRef(null);
  const journeyOutcomeRef = useRef(null);
  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 and explicitly claim their travel fund.
      const claimed = !!localStorage.getItem('cony.fundsClaimed');
      return typeof v === 'number' ? v : (claimed ? (window.INITIAL_BALANCE || 1500) : 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);
  // Pathfinder is a fully offline, account-free game: no remote backend and
  // no cloud profile. Every social surface was removed rather than hidden, so
  // there is nothing here to hold an identity.
  const [accountOpen, setAccountOpen] = useState(false);

  // 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 []; }
  });
  // Read the latest ledger when the animation settles, not its starting render.
  const reportLedgerRef = useRef({ balance, tickets });
  reportLedgerRef.current = { balance, tickets };
  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]);

  // 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]);
  // Tickets drive stamp progress; the completion flag prevents a cleared log
  // from replaying onboarding after the player has already finished it once.
  // Domestic tickets and repeat visits do not mint extra cultural stamps.
  const worldStampedCityIds = useMemo(() => {
    const mainIds = new Set((window.CITIES || []).map(city => city.id));
    const stamped = new Set();
    tickets.forEach(ticket => {
      if (ticket.scope === 'domestic') return;
      if (ticket.toId && mainIds.has(ticket.toId)) stamped.add(ticket.toId);
    });
    return stamped;
  }, [tickets]);
  const [firstJourneyCompleted, setFirstJourneyCompleted] = useState(
    () => localStorage.getItem('cony.firstJourneyCompleted') === '1'
  );
  useEffect(() => {
    if (firstJourneyCompleted || worldStampedCityIds.size < FIRST_JOURNEY_TARGET) return;
    localStorage.setItem('cony.firstJourneyCompleted', '1');
    setFirstJourneyCompleted(true);
  }, [firstJourneyCompleted, worldStampedCityIds]);
  const firstJourneyCount = firstJourneyCompleted
    ? FIRST_JOURNEY_TARGET
    : Math.min(FIRST_JOURNEY_TARGET, worldStampedCityIds.size);
  const firstJourneyActive = !firstJourneyCompleted && firstJourneyCount < FIRST_JOURNEY_TARGET;
  const [firstJourneyNotice, setFirstJourneyNotice] = useState('');
  useEffect(() => {
    if (!firstJourneyNotice) return;
    const id = setTimeout(() => setFirstJourneyNotice(''), 2800);
    return () => clearTimeout(id);
  }, [firstJourneyNotice]);
  // 无限模式：余额 ≥ $1,000,000 时不再扣钱
  const INFINITE = balance >= 1000000;

  // 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 (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.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);
  // Pathfinder is a paid download: every destination ships with the game,
  // so nothing on the map is ever locked.
  const isLockedCity = () => false;
  const recommendedCities = useMemo(() => {
    if (!firstJourneyActive || !currentCity) return [];
    const freeIds = new Set(window.FREE_CITY_IDS || (window.CITIES || []).map(city => city.id));
    const tutorialPool = (window.CITIES || []).filter(city => freeIds.has(city.id));
    const candidates = tutorialPool.filter(city => {
      if (city.id === currentCity.id || visitedCityIds.has(city.id)) return false;
      return !isLockedCity(city.id);
    });
    const plane = (window.TRANSPORTS || []).find(item => item.id === 'plane');
    const fare = (from, to) => {
      const baseFare = window.calcWorldCost && plane
        ? window.calcWorldCost(from, to, plane, 'economy')
        : 0;
      const petFee = (pet && window.calcPetFee)
        ? window.calcPetFee('plane', baseFare)
        : 0;
      return baseFare + petFee;
    };
    const minFutureCost = (from, blocked, legs) => {
      if (legs <= 0) return 0;
      let best = Infinity;
      tutorialPool.forEach(city => {
        if (blocked.has(city.id)) return;
        const nextBlocked = new Set(blocked);
        nextBlocked.add(city.id);
        best = Math.min(best, fare(from, city) + minFutureCost(city, nextBlocked, legs - 1));
      });
      return best;
    };
    const remainingLegs = FIRST_JOURNEY_TARGET - firstJourneyCount;
    const missionReserve = 25;
    const feasible = candidates.filter(city => {
      const nextBlocked = new Set(visitedCityIds);
      nextBlocked.add(city.id);
      return fare(currentCity, city) + minFutureCost(city, nextBlocked, remainingLegs - 1) <= balance;
    });
    const safeFeasible = feasible.filter(city => {
      const nextBlocked = new Set(visitedCityIds);
      nextBlocked.add(city.id);
      return fare(currentCity, city) + minFutureCost(city, nextBlocked, remainingLegs - 1)
        <= Math.max(0, balance - missionReserve);
    });
    const affordable = candidates.filter(city => fare(currentCity, city) <= balance);
    const routePool = safeFeasible.length
      ? safeFeasible
      : (feasible.length ? feasible : (affordable.length ? affordable : candidates));
    return routePool.sort((a, b) => {
      const da = window.haversineKm ? window.haversineKm(currentCity, a) : 0;
      const db = window.haversineKm ? window.haversineKm(currentCity, b) : 0;
      return (da - db) || a.id.localeCompare(b.id);
    }).slice(0, 3);
  }, [firstJourneyActive, firstJourneyCount, currentCityId, visitedCityIds, balance, pet]);
  const recommendedCityIds = useMemo(
    () => new Set(recommendedCities.map(city => city.id)),
    [recommendedCities]
  );

  // The tutorial is deliberately one clear economy loop; cabin upgrades return
  // after the third stamp, once the player understands the base trade-off.
  useEffect(() => {
    if (firstJourneyActive && cabinClass !== 'economy') setCabinClass('economy');
  }, [firstJourneyActive, cabinClass]);

  const explainLockedDuringMission = () => {
    const lang = window.getLang ? window.getLang() : 'en';
    setFirstJourneyNotice(
      lang === 'zh' ? '先从推荐的免费城市收集 3 枚印章。' :
      lang === 'ko' ? '추천 무료 도시에서 먼저 스탬프 3개를 모아 보세요.' :
      lang === 'ja' ? 'まずおすすめの無料都市でスタンプを3つ集めよう。' :
      'Collect 3 stamps from the recommended free cities first.'
    );
  };

  const onCityClick = (cityId) => {
    if (traveling) return;
    if (cityId === currentCityId) {
      setPanelOpen(true);
      return;
    }
    if (firstJourneyActive && isLockedCity(cityId)) {
      explainLockedDuringMission();
      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;
    // The first three stamps stay inside the tutorial pool; off-route picks
    // only get the mission explainer, never a wall.
    if (isLockedCity(destinationId)) {
      if (firstJourneyActive) explainLockedDuringMission();
      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 };
    const tutorialVisaWaiver = firstJourneyActive
      && (window.FREE_CITY_IDS || []).includes(destinationId);
    if (!visa.visaFree && !tutorialVisaWaiver) {
      // 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 = []) => {
    setTravelMoment(null);
    setTravelDurationMs(0);
    travelMomentShownRef.current = false;
    travelPauseStartedRef.current = null;
    journeyOutcomeRef.current = null;
    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([]);
    setTravelMoment(null);
    setTravelDurationMs(0);
    travelMomentShownRef.current = false;
    travelPauseStartedRef.current = null;
    journeyOutcomeRef.current = null;
  };

  const chooseTravelMoment = (kind) => {
    const KEY = 'cony.flight';
    const now = Date.now();
    const saved = (() => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } })();
    const outcome = kind === 'rest'
      ? { kind:'rest', speedBoost:0.30 }
      : kind === 'scenic'
        ? { kind:'scenic', walletDelta:25, speedBoost:-0.25 }
        : { kind:'clue', walletDelta:15 };
    journeyOutcomeRef.current = outcome;
    if (saved && saved.toId === destinationId) {
      const pauseAt = saved.pauseStarted || travelPauseStartedRef.current || now;
      const p = Math.max(0, Math.min(0.92, (pauseAt - saved.start) / Math.max(1, saved.dur)));
      const completedMs = saved.dur * p;
      const remainingMs = saved.dur * (1 - p) * (
        kind === 'rest' ? 0.70 : kind === 'scenic' ? 1.25 : 1
      );
      const nextDur = Math.max(2500, completedMs + remainingMs);
      const next = {
        ...saved,
        schema: 2,
        quickTrip: true,
        start: now - completedMs,
        dur: nextDur,
        momentPending: false,
        momentDone: true,
        pauseStarted: null,
        journeyOutcome: outcome,
      };
      try { localStorage.setItem(KEY, JSON.stringify(next)); } catch (_) {}
    }
    bumpStat(
      kind === 'rest' ? 'travelRestChoices'
        : kind === 'scenic' ? 'travelScenicChoices'
        : 'travelClueChoices',
      1
    );
    setTravelMoment(null);
    travelPauseStartedRef.current = null;
    travelMomentShownRef.current = true;
    setFlightVersion(v => v + 1);
  };

  // A real route compressed into an 8–28 second playable beat. Flight state,
  // including the paused mid-trip choice, survives backgrounding and relaunch.
  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;
    const playableMs = Math.max(8_000, Math.min(28_000, realHours * 3_500));
    const cabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === cabinClass);
    const timeMult = cabin?.timeMult ?? 1.0;
    const quickDur = Math.max(4_500, (playableMs * timeMult) / Math.max(0.1, t.travelSpeed));
    const quotedFare = window.calcWorldCost
      ? window.calcWorldCost(currentCity, destinationCity, currentTransport, cabinClass)
      : 0;
    const quotedPetFee = (pet && window.calcPetFee)
      ? window.calcPetFee(transport, quotedFare)
      : 0;
    let dur = quickDur;
    // persist + restore from localStorage so refreshing/closing doesn't lose the flight
    const KEY = 'cony.flight';
    let start;
    let saved = (() => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } })();
    if (saved && saved.toId === destinationId && saved.transport === transport) {
      const wasLegacyFlight = saved.quickTrip !== true;
      start = Number(saved.start);
      dur = Number(saved.dur) || quickDur;
      let saveNeedsRefresh = false;
      // Migrate an unfinished literal-hours flight without losing its progress.
      if (saved.quickTrip !== true && dur > 45_000 && Date.now() < start + dur) {
        const oldProgress = Math.max(0, Math.min(0.9, (Date.now() - start) / dur));
        dur = quickDur;
        start = Date.now() - oldProgress * dur;
        saved = { ...saved, schema:2, quickTrip:true, start, dur };
        saveNeedsRefresh = true;
      }
      // Freeze settlement at departure. Legacy economy trips were free, so an
      // unfinished pre-update save must not be charged by the new fare table.
      if (!Number.isFinite(Number(saved.fareUSD)) || Number(saved.fareUSD) < 0) {
        const savedCabin = saved.cabinClass || cabinClass;
        const legacyEconomy = wasLegacyFlight && savedCabin === 'economy';
        const frozenFare = legacyEconomy ? 0 : quotedFare;
        const frozenPetFee = (pet && window.calcPetFee)
          ? window.calcPetFee(transport, frozenFare)
          : 0;
        saved = {
          ...saved,
          originId: saved.originId || currentCity?.id,
          cabinClass: savedCabin,
          fareUSD: frozenFare,
          petFeeUSD: frozenPetFee,
        };
        saveNeedsRefresh = true;
      }
      if (saveNeedsRefresh) {
        try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (_) {}
      }
      journeyOutcomeRef.current = saved.journeyOutcome || null;
    } else {
      start = Date.now();
      saved = {
        schema:2,
        quickTrip:true,
        originId:currentCity?.id,
        toId:destinationId,
        transport,
        cabinClass,
        fareUSD:quotedFare,
        petFeeUSD:quotedPetFee,
        start,
        dur,
        momentDone:false,
      };
      localStorage.setItem(KEY, JSON.stringify(saved));
    }
    setTravelDurationMs(dur);

    // Relaunching while the choice is open restores the same paused moment.
    if (saved.momentPending && !saved.momentDone) {
      const pauseAt = saved.pauseStarted || Date.now();
      travelMomentShownRef.current = true;
      travelPauseStartedRef.current = pauseAt;
      setProgress(Math.max(0, Math.min(0.92, (pauseAt - start) / Math.max(1, dur))));
      setTravelMoment({ city: destinationCity });
      return;
    }
    travelMomentShownRef.current = !!saved.momentDone;
    let raf;
    const tick = () => {
      const now = Date.now();
      let u = Math.min(1, (now - start) / dur);
      const firstVisitMoment = firstJourneyActive || !visitedCityIds.has(destinationId);
      if (firstVisitMoment && !travelMomentShownRef.current && u >= 0.38 && u < 1) {
        travelMomentShownRef.current = true;
        travelPauseStartedRef.current = now;
        const paused = {
          ...saved,
          schema:2,
          quickTrip:true,
          start,
          dur,
          momentPending:true,
          momentDone:false,
          pauseStarted:now,
        };
        try { localStorage.setItem(KEY, JSON.stringify(paused)); } catch (_) {}
        setProgress(u);
        setTravelMoment({ city: destinationCity });
        return;
      }
      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 = Number.isFinite(Number(saved.fareUSD))
          ? Math.max(0, Number(saved.fareUSD))
          : quotedFare;
        const wPetFee = Number.isFinite(Number(saved.petFeeUSD))
          ? Math.max(0, Number(saved.petFeeUSD))
          : quotedPetFee;
        const journeyOutcome = journeyOutcomeRef.current;
        const stampEarned = !!arrivedCity && !worldStampedCityIds.has(arrivedCity.id);
        const nextMissionProgress = Math.min(
          FIRST_JOURNEY_TARGET,
          firstJourneyCount + (stampEarned ? 1 : 0)
        );
        const stampTheme = journeyStampTheme(arrivedCity);
        let keepsakeTicket = null;
        if (currentCity && arrivedCity && currentTransport && window.makeTicket) {
          const baseTicket = 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 })),
          });
          const tk = { ...baseTicket, journeyOutcome, stampTheme };
          keepsakeTicket = tk;
          setTickets(prev => [tk, ...prev]);
          setTravelInvitees([]);
        }
        if (!INFINITE) {
          const travelReward = journeyOutcome?.walletDelta || 0;
          setBalance(b => Math.max(0, b - wCost - wPetFee + travelReward));
        }
        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 _dLang = window.getLang ? window.getLang() : 'en';
        const secUnit = _dLang === 'zh' ? ' 秒' : _dLang === 'ko' ? '초' : _dLang === 'ja' ? ' 秒' : ' s';
        const durationLabel = Math.max(1, Math.round(dur / 1000)) + secUnit;
        setArrivalInfo({
          city: arrivedCity,
          ticket: keepsakeTicket,
          transport,
          durationLabel,
          missionProgress: firstJourneyActive ? nextMissionProgress : null,
          missionTotal: FIRST_JOURNEY_TARGET,
          journeyOutcome,
          stampTheme,
          stampEarned,
        });
        setTravelMoment(null);
        setTravelDurationMs(0);
        travelPauseStartedRef.current = null;
        journeyOutcomeRef.current = null;
        // 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) => {
      const fromControl = e.target instanceof Element
        && !!e.target.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]');
      if (e.code === 'Space' && !e.defaultPrevented && !fromControl && destinationId && !traveling) {
        e.preventDefault();
        onDepart();
      }
      if (e.code === 'Escape' && !traveling) {
        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>

      <window.AtlasDesk
        current={currentCity}
        destination={destinationCity}
        recommendations={recommendedCities}
        visited={visitedCityIds}
        firstActive={firstJourneyActive}
        count={firstJourneyCount}
        stampTheme={journeyStampTheme}
        balance={balance}
        pet={pet}
        speed={t.travelSpeed}
        isLocked={isLockedCity}
        transport={transport}
        cabin={cabinClass}
        traveling={traveling}
        notice={firstJourneyNotice}
        onSelect={onCityClick}
        onTransport={onTransportSelect}
        onCabin={setCabinClass}
        onDepart={onDepart}
        onExplore={() => setPanelOpen(true)}
        onLog={() => setLogOpen(true)}
        onAlbum={() => setAlbumOpen(true)}
        onMemory={setMemoryCity}
        onHelp={() => setIntroOpen(true)}
        onLang={() => setLangOpen(true)}
        onAccount={() => setAccountOpen(true)}
        onEnd={endGameRequest}
      />
      {panelOpen && currentCity && !traveling && (
        <window.CityPanel city={currentCity} onClose={() => setPanelOpen(false)}
          balance={balance} onEarn={earnBalance} onPayslip={addPayslip} pet={pet}/>
      )}

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

      {travelMoment && (
        <window.JourneyMomentModal
          city={travelMoment.city}
          onChoose={chooseTravelMoment}
        />
      )}

      {upgradeOffer && (
        <window.UpgradeCabinModal
          upgrades={upgradeOffer.upgrades}
          currentCabin={upgradeOffer.currentCabin}
          balance={balance}
          transport={transport}
          fromCity={currentCity}
          toCity={destinationCity}
          pet={pet}
          onCancel={() => setUpgradeOffer(null)}
          onConfirm={(newCabinId, cost) => {
            // Quote the full upgraded journey, freeze it into the in-flight
            // record, then settle once at arrival. `cost` is display-only delta.
            const newCabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === newCabinId);
            const newT = newCabin?.timeMult ?? 1;
            const upgradedFare = window.calcWorldCost
              ? window.calcWorldCost(currentCity, destinationCity, currentTransport, newCabinId)
              : Math.max(0, Number(cost) || 0);
            const upgradedPetFee = (pet && window.calcPetFee)
              ? window.calcPetFee(transport, upgradedFare)
              : 0;
            if (!INFINITE && upgradedFare + upgradedPetFee > balance) {
              setUpgradeOffer(null);
              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({
                ...saved,
                schema:2,
                quickTrip:true,
                originId:saved.originId || currentCity?.id,
                toId:saved.toId,
                transport,
                cabinClass:newCabinId,
                fareUSD:upgradedFare,
                petFeeUSD:upgradedPetFee,
                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}
          ticket={arrivalInfo.ticket}
          onPhoto={(image, caption) => setPhotos(prev => [{
            id: `pa-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
            type: 'ticket',
            cityName: arrivalInfo.city?.name || '',
            image, caption, ts: Date.now(),
          }, ...prev])}
          transport={arrivalInfo.transport}
          durationLabel={arrivalInfo.durationLabel}
          missionProgress={arrivalInfo.missionProgress}
          missionTotal={arrivalInfo.missionTotal}
          journeyOutcome={arrivalInfo.journeyOutcome}
          stampTheme={arrivalInfo.stampTheme}
          stampEarned={arrivalInfo.stampEarned}
          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}
          helpMode={!!localStorage.getItem('cony.startedAt')}
          startingFunds={window.INITIAL_BALANCE || 1500}
          onClose={() => setIntroOpen(false)}
          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 */}
      {!introOpen && !fundsClaimed && (
        <window.ClaimFundsModal
          amount={window.INITIAL_BALANCE || 1500}
          onClaim={() => {
            setBalance(b => Math.max(b, window.INITIAL_BALANCE || 1500));
            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}
        />
      )}

      {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)}/>
      )}

      {accountOpen && (
        <window.AccountModal
          onClose={() => setAccountOpen(false)}
        />
      )}

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

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