← back to CelebritySignatures

apps/mobile/screens/GameScreen.tsx

1091 lines

/**
 * GameScreen — "The Signature Games" (full 6-game suite, ported from the desktop
 * web build at public/game.html).
 *
 * Games:
 *   whose     Whose Hand?      A mystery signature, four names.
 *   art       Who Signed This? A real museum artwork — which artist's hand made it?
 *   match     Match the Hand   One signature, four artworks. Pick the one they signed.
 *   early     Early or Late?   Two signatures, same person, years apart. Which came first?
 *   century   Date the Hand    Read the ink — which century did this hand belong to?
 *   lightning ⚡ Lightning Round A painting flashes up. Name the ARTIST, then the TITLE
 *                              — 5 seconds each. Both right = bonus.
 *
 * Flow: setup (pick game + difficulty + category) → prep (build rounds, fetch
 * artworks for art/match/lightning) → play (10 rounds, tap to answer, Next to
 * advance) → results (score, verdict, misses, best, leaderboard submit + share).
 *
 * Backend: all data comes from the existing celebsignatures.com JSON APIs via
 * `api` (signatures, evolution, leaderboard). Public-domain artworks are fetched
 * client-side from the Art Institute of Chicago (keyless, no CORS issue on native).
 */

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  ActivityIndicator,
  Animated,
  Image,
  Linking,
  Pressable,
  ScrollView,
  Share,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { api, Signature, LeaderRow, Evolution, qidOf, sigImg } from '../api';
import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';

// ---------------------------------------------------------------------------
// Static game / option definitions (mirror public/game.html)
// ---------------------------------------------------------------------------

type GameKey = 'whose' | 'art' | 'match' | 'early' | 'century' | 'lightning';

const GAMES: { key: GameKey; title: string; desc: string }[] = [
  { key: 'whose', title: 'Whose Hand?', desc: 'A mystery signature, four names. The classic.' },
  { key: 'art', title: 'Who Signed This?', desc: "A real museum artwork — which artist's hand made it?" },
  { key: 'match', title: 'Match the Hand', desc: 'One signature, four artworks. Pick the piece they signed.' },
  { key: 'early', title: 'Early or Late?', desc: 'Two signatures, same person, years apart. Which came first?' },
  { key: 'century', title: 'Date the Hand', desc: 'Read the ink — which century did this hand belong to?' },
  { key: 'lightning', title: '⚡ Lightning Round', desc: 'A painting flashes up. Name the ARTIST, then the TITLE — 5s each. Both right = bonus.' },
];

const DIFFS: { key: string; label: string; note: string }[] = [
  { key: 'icons', label: 'Icons', note: 'top 100' },
  { key: 'scholar', label: 'Scholar', note: 'top 500' },
  { key: 'curator', label: 'Curator', note: 'top 2,000' },
  { key: 'deep', label: 'Deep Cuts', note: 'everyone' },
];
const DIFF_N: Record<string, number> = { icons: 100, scholar: 500, curator: 2000, deep: Infinity };

const CATS: { key: string; label: string }[] = [
  { key: 'All', label: 'Everything' },
  { key: 'Artists', label: 'Artists' },
  { key: 'Authors', label: 'Authors' },
  { key: 'Politics', label: 'Politics' },
  { key: 'Declaration of Independence', label: 'Declaration' },
];

const GAME_NAMES: Record<GameKey, string> = {
  whose: 'Whose Hand?', art: 'Who Signed This?', match: 'Match the Hand',
  early: 'Early or Late?', century: 'Date the Hand', lightning: '⚡ Lightning Round',
};
const PROMPTS: Record<GameKey, string> = {
  whose: 'Whose hand wrote this?',
  art: 'Who signed this piece of art?',
  match: 'Which of these did they sign?',
  early: 'Same hand, years apart — which is the EARLIER signature?',
  century: 'Which century did this hand belong to?',
  lightning: '⚡ Quick!',
};

const TOTAL = 10;
const LIGHTNING_MS = 5000;

// Personal-best cache. Kept in a synchronous module-level map for instant reads
// during render, and mirrored to AsyncStorage so bests survive an app restart.
const BEST: Record<string, number> = {};
const BEST_STORE_KEY = 'signatureGames.best.v1';

/** Hydrate the in-memory BEST cache from disk (merging, never lowering a value). */
async function loadBest(): Promise<void> {
  try {
    const raw = await AsyncStorage.getItem(BEST_STORE_KEY);
    if (!raw) return;
    const saved = JSON.parse(raw) as Record<string, number>;
    for (const [k, v] of Object.entries(saved)) {
      if (typeof v === 'number') BEST[k] = Math.max(v, BEST[k] || 0);
    }
  } catch {
    /* storage unavailable (e.g. module not linked) — stay in-memory only */
  }
}

/** Persist the current BEST map. Non-fatal on failure. */
async function saveBest(): Promise<void> {
  try {
    await AsyncStorage.setItem(BEST_STORE_KEY, JSON.stringify(BEST));
  } catch {
    /* non-fatal */
  }
}

// ---------------------------------------------------------------------------
// Helpers (ported from game.html)
// ---------------------------------------------------------------------------

function shuffle<T>(arr: T[]): T[] {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

const notability = (r: Signature): number => {
  const m = (r.reason_for_ranking || '').match(/(\d+) language/);
  return m ? +m[1] : 0;
};
const centuryOf = (r: Signature): number | null => {
  const y = parseInt(String(r.death_date).slice(0, 4), 10);
  return isNaN(y) ? null : Math.ceil(y / 100);
};
const centuryLabel = (c: number): string => {
  const suf = c % 10 === 1 && c !== 11 ? 'st'
    : c % 10 === 2 && c !== 12 ? 'nd'
    : c % 10 === 3 && c !== 13 ? 'rd' : 'th';
  return `${c}${suf} century`;
};

type Artwork = { img: string; title: string; date: string; url: string };
const ART_CACHE: Record<string, Artwork | null> = {};

/** One public-domain artwork per artist via the Art Institute of Chicago. */
async function artworkFor(r: Signature): Promise<Artwork | null> {
  const qid = qidOf(r) || r.full_name;
  if (qid in ART_CACHE) return ART_CACHE[qid];
  let out: Artwork | null = null;
  try {
    const last = r.full_name.toLowerCase().split(' ').pop() || '';
    const res = await fetch(
      `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(r.full_name)}` +
      `&query[term][is_public_domain]=true&fields=id,title,image_id,artist_title,date_display&limit=8`,
    );
    const j = await res.json();
    const hits = (j.data || []).filter(
      (a: any) => a.image_id && (a.artist_title || '').toLowerCase().includes(last),
    );
    if (hits.length) {
      const a = hits[Math.floor(Math.random() * Math.min(hits.length, 4))];
      out = {
        img: `https://www.artic.edu/iiif/2/${a.image_id}/full/600,/0/default.jpg`,
        title: a.title || 'Untitled',
        date: a.date_display || '',
        url: `https://www.artic.edu/artworks/${a.id}`,
      };
    }
  } catch {
    /* leave out=null */
  }
  ART_CACHE[qid] = out;
  return out;
}

/** Near-category, notability-ranked wrong answers. */
function distractors(answer: Signature, p: Signature[], n = 3): Signature[] {
  const near = p.filter((r) => r !== answer && r.category === answer.category);
  const ranked = [...near].sort(
    (a, b) => Math.abs(notability(a) - notability(answer)) - Math.abs(notability(b) - notability(answer)),
  );
  const ds = shuffle(ranked.slice(0, 24)).slice(0, n);
  while (ds.length < n) {
    const x = shuffle(p).find((r) => r !== answer && !ds.includes(r));
    if (!x) break;
    ds.push(x);
  }
  return ds;
}

// ---------------------------------------------------------------------------
// Round model (discriminated union)
// ---------------------------------------------------------------------------

type WhoseRound = { kind: 'whose'; answer: Signature; options: Signature[] };
type ArtRound = { kind: 'art'; answer: Signature; artwork: Artwork; options: Signature[] };
type MatchRound = { kind: 'match'; answer: Signature; artwork: Artwork; options: { owner: Signature; art: Artwork }[] };
type EarlyRound = { kind: 'early'; person: string; qid: string; leftUrl: string; rightUrl: string; earlier: 'left' | 'right'; years: [number, number] };
type CenturyRound = { kind: 'century'; answer: Signature; century: number; options: number[] };
type LightningRound = { kind: 'lightning'; q: 'artist' | 'title'; pid: string; painting: { artist: Signature; title: string; img: string; url: string }; correct: string; options: string[] };
type Round = WhoseRound | ArtRound | MatchRound | EarlyRound | CenturyRound | LightningRound;

function poolFor(gameKey: GameKey, diff: string, cat: string, ALL: Signature[]): Signature[] {
  const c = gameKey === 'whose' || gameKey === 'century' ? cat : 'Artists';
  const p = c === 'All' ? ALL : ALL.filter((r) => r.category === c);
  return [...p].sort((a, b) => notability(b) - notability(a)).slice(0, DIFF_N[diff]);
}

async function buildRounds(
  gameKey: GameKey,
  diff: string,
  cat: string,
  ALL: Signature[],
  EVO: Evolution,
  setPrep: (s: string) => void,
): Promise<Round[]> {
  const p = poolFor(gameKey, diff, cat, ALL);
  const rounds: Round[] = [];

  if (gameKey === 'whose') {
    if (p.length < 8) throw new Error('Not enough signatures in this tier.');
    for (const answer of shuffle(p).slice(0, TOTAL)) {
      rounds.push({ kind: 'whose', answer, options: shuffle([answer, ...distractors(answer, p)]) });
    }
    return rounds;
  }

  if (gameKey === 'century') {
    const dated = p.filter((r) => centuryOf(r));
    if (dated.length < TOTAL) throw new Error('Not enough dated signatures in this tier.');
    for (const answer of shuffle(dated).slice(0, TOTAL)) {
      const c = centuryOf(answer)!;
      const opts = new Set<number>([c]);
      let step = 1;
      let guard = 0;
      while (opts.size < 4 && guard++ < 200) {
        const cand = c + (Math.random() < 0.5 ? -step : step);
        if (cand >= 6 && cand <= 21) opts.add(cand);
        step = Math.min(step + 1, 6);
      }
      // boundary fallback: fill any shortfall deterministically from the valid range
      for (let d = 1; opts.size < 4 && d <= 15; d++) {
        if (c - d >= 6) opts.add(c - d);
        if (opts.size < 4 && c + d <= 21) opts.add(c + d);
      }
      rounds.push({ kind: 'century', answer, century: c, options: shuffle([...opts]) });
    }
    return rounds;
  }

  if (gameKey === 'early') {
    const allPeople = Object.entries(EVO)
      .map(([qid, e]) => ({ qid, e, dated: (e.sigs || []).filter((s) => s.year) }))
      .filter((x) => new Set(x.dated.map((s) => s.year)).size >= 2);
    // Honor the Difficulty tier: keep only people inside the notability-ranked
    // top-N slice of the catalog, so Icons/Scholar/Curator/Deep Cuts actually
    // change the pool (previously 'early' sampled EVO directly and ignored diff).
    // Category is intentionally NOT applied here — the Category selector is hidden
    // for 'early' (catRelevant), so this stays catalog-wide. Fall back to the full
    // set if the tier is too thin to build a round, so the game never regresses
    // into an unstartable "still loading" state.
    const tierQids = new Set(
      [...ALL]
        .sort((a, b) => notability(b) - notability(a))
        .slice(0, DIFF_N[diff])
        .map((r) => qidOf(r))
        .filter((q): q is string => !!q),
    );
    const tiered = allPeople.filter((x) => tierQids.has(x.qid));
    const people = tiered.length >= 5 ? tiered : allPeople;
    if (people.length < 5) throw new Error('Evolution data still loading — try again in a moment.');
    for (let i = 0; i < TOTAL; i++) {
      const P = people[Math.floor(Math.random() * people.length)];
      const years = shuffle([...new Set(P.dated.map((s) => s.year as number))]);
      const [y1, y2] = [years[0], years[1]].sort((a, b) => a - b);
      const sigA = P.dated.find((s) => s.year === y1)!;
      const sigB = P.dated.find((s) => s.year === y2)!;
      const flip = Math.random() < 0.5;
      rounds.push({
        kind: 'early',
        person: P.e.name,
        qid: P.qid,
        leftUrl: flip ? sigB.url : sigA.url,
        rightUrl: flip ? sigA.url : sigB.url,
        earlier: flip ? 'right' : 'left',
        years: [y1, y2],
      });
    }
    return rounds;
  }

  if (gameKey === 'lightning') {
    const cands = shuffle(p);
    let idx = 0;
    const paintings: { artist: Signature; title: string; img: string; url: string }[] = [];
    while (paintings.length < 8 && idx < cands.length) {
      const batch = cands.slice(idx, idx + 6);
      idx += 6;
      setPrep(`Loading the gallery… ${paintings.length}/8 paintings ready`);
      const arts = await Promise.all(batch.map(artworkFor));
      for (let i = 0; i < batch.length && paintings.length < 8; i++) {
        if (arts[i]) paintings.push({ artist: batch[i], title: arts[i]!.title, img: arts[i]!.img, url: arts[i]!.url });
      }
    }
    if (paintings.length < 5) throw new Error('Not enough museum paintings at this tier — try Icons or Scholar.');
    const nameOf = (x: typeof paintings[number]) => x.artist.full_name;
    const titleOf = (x: typeof paintings[number]) => x.title;
    for (const pt of paintings) {
      const otherA = shuffle(paintings.filter((x) => x !== pt && nameOf(x) !== nameOf(pt))).slice(0, 3);
      const otherT = shuffle(paintings.filter((x) => x !== pt && titleOf(x) !== titleOf(pt))).slice(0, 3);
      if (otherA.length < 3 || otherT.length < 3) continue;
      rounds.push({ kind: 'lightning', q: 'artist', pid: pt.url, painting: pt, correct: nameOf(pt), options: shuffle([nameOf(pt), ...otherA.map(nameOf)]) });
      rounds.push({ kind: 'lightning', q: 'title', pid: pt.url, painting: pt, correct: titleOf(pt), options: shuffle([titleOf(pt), ...otherT.map(titleOf)]) });
    }
    if (rounds.length < 8) throw new Error('Not enough distinct paintings — try Icons or Scholar.');
    return rounds;
  }

  // art + match — both need real artworks
  const cands = shuffle(p);
  let idx = 0;
  const staged: { answer: Signature; artwork: Artwork }[] = [];
  while (staged.length < TOTAL && idx < cands.length) {
    const batch = cands.slice(idx, idx + 6);
    idx += 6;
    setPrep(`Hanging the gallery… ${staged.length}/${TOTAL} rooms ready`);
    const arts = await Promise.all(batch.map(artworkFor));
    for (let i = 0; i < batch.length && staged.length < TOTAL; i++) {
      if (arts[i]) staged.push({ answer: batch[i], artwork: arts[i]! });
    }
  }

  if (gameKey === 'art') {
    for (const s of staged) {
      rounds.push({ kind: 'art', answer: s.answer, artwork: s.artwork, options: shuffle([s.answer, ...distractors(s.answer, p)]) });
    }
    if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
    return rounds;
  }

  // match — each round needs 3 distractor ARTWORKS too
  for (const s of staged) {
    setPrep(`Matching hands… ${rounds.length}/${staged.length} ready`);
    const ds = distractors(s.answer, p, 8);
    const arts: { owner: Signature; art: Artwork }[] = [];
    for (const d of ds) {
      if (arts.length >= 3) break;
      const a = await artworkFor(d);
      if (a) arts.push({ owner: d, art: a });
    }
    if (arts.length < 3) continue;
    rounds.push({
      kind: 'match',
      answer: s.answer,
      artwork: s.artwork,
      options: shuffle([{ owner: s.answer, art: s.artwork }, ...arts.slice(0, 3)]),
    });
  }
  if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
  return rounds.slice(0, TOTAL);
}

// ---------------------------------------------------------------------------
// Small UI atoms
// ---------------------------------------------------------------------------

function OptionChip({
  label, sub, active, onPress,
}: { label: string; sub?: string; active: boolean; onPress: () => void }) {
  return (
    <Pressable onPress={onPress} style={[styles.chip, active && styles.chipActive]}>
      <Text style={[styles.chipText, active && styles.chipTextActive]}>{label}</Text>
      {sub ? <Text style={[styles.chipSub, active && styles.chipTextActive]}> {sub}</Text> : null}
    </Pressable>
  );
}

function LoadingView({ label }: { label: string }) {
  return (
    <View style={styles.centeredFill}>
      <ActivityIndicator size="large" color={GLASS.accent} />
      <Text style={styles.dimText}>{label}</Text>
    </View>
  );
}

function LeaderboardPanel({ rows, loading, tag }: { rows: LeaderRow[]; loading: boolean; tag?: string }) {
  return (
    <GlassPanel style={styles.leaderPanel}>
      <Text style={styles.sectionTitle}>🏆 Leaderboard{tag ? ` · ${tag}` : ''}</Text>
      {loading ? (
        <ActivityIndicator color={GLASS.accent} style={{ marginTop: 8 }} />
      ) : rows.length === 0 ? (
        <Text style={styles.dimText}>Be the first — post your score!</Text>
      ) : (
        rows.slice(0, 5).map((row, i) => (
          <View key={row.id ?? `${row.name}-${i}`} style={styles.leaderRow}>
            <Text style={styles.leaderRank}>{i + 1}.</Text>
            <Text style={styles.leaderName} numberOfLines={1}>{row.name}</Text>
            <GlassPill tintColor={GLASS.accent}><Text style={styles.leaderScore}>{row.score}</Text></GlassPill>
          </View>
        ))
      )}
    </GlassPanel>
  );
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

type Phase = 'setup' | 'prep' | 'playing' | 'over';

export default function GameScreen() {
  // catalog
  const [ALL, setALL] = useState<Signature[]>([]);
  const [EVO, setEVO] = useState<Evolution>({});
  const [dataError, setDataError] = useState(false);

  // selections
  const [gameKey, setGameKey] = useState<GameKey>('whose');
  const [diff, setDiff] = useState('icons');
  const [cat, setCat] = useState('All');

  // run state
  const [phase, setPhase] = useState<Phase>('setup');
  const [prepMsg, setPrepMsg] = useState('Hanging the gallery…');
  const [startError, setStartError] = useState('');
  const [rounds, setRounds] = useState<Round[]>([]);
  const [i, setI] = useState(0);
  const [score, setScore] = useState(0);
  const [streak, setStreak] = useState(0);
  const [misses, setMisses] = useState<{ full_name: string; qid: string | null }[]>([]);
  const [answered, setAnswered] = useState(false);
  const [pick, setPick] = useState<number | 'left' | 'right' | 'timeout' | null>(null);
  const lightningRes = useRef<Record<string, { artist: boolean; title: boolean }>>({});
  const lockRef = useRef(false);

  // leaderboard
  const [leaderRows, setLeaderRows] = useState<LeaderRow[]>([]);
  const [leaderLoading, setLeaderLoading] = useState(false);

  // lightning timer
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const barW = useRef(new Animated.Value(1)).current;

  const catRelevant = gameKey === 'whose' || gameKey === 'century';

  // --- load catalog (on mount + on retry) ---
  const loadCatalog = useCallback(async () => {
    setDataError(false);
    try {
      const [sigs, evo] = await Promise.all([api.signatures(), api.evolution().catch(() => ({} as Evolution))]);
      setALL(sigs.filter((r) => r.signature_image_url && /wikimedia|upload\./.test(r.signature_image_url)));
      setEVO(evo || {});
    } catch {
      setDataError(true);
    }
  }, []);

  useEffect(() => { loadCatalog(); }, [loadCatalog]);

  // hydrate personal bests from disk once, so they survive an app restart
  useEffect(() => { void loadBest(); }, []);

  const clearTimer = useCallback(() => {
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
    barW.stopAnimation();
  }, [barW]);

  useEffect(() => () => clearTimer(), [clearTimer]);

  // --- start a game ---
  const begin = useCallback(async () => {
    if (!ALL.length) return;
    setPhase('prep');
    setPrepMsg('Hanging the gallery…');
    setStartError('');
    try {
      const r = await buildRounds(gameKey, diff, cat, ALL, EVO, setPrepMsg);
      lightningRes.current = {};
      setRounds(r);
      setI(0); setScore(0); setStreak(0); setMisses([]);
      setAnswered(false); setPick(null); lockRef.current = false;
      setPhase('playing');
    } catch (e: any) {
      // surface the reason on the setup screen
      setStartError(e?.message || 'Could not start this game.');
      setPrepMsg('Hanging the gallery…');
      setPhase('setup');
    }
  }, [ALL, EVO, gameKey, diff, cat]);

  // --- lightning timer per round ---
  const round = rounds[i];
  useEffect(() => {
    clearTimer();
    if (phase === 'playing' && round?.kind === 'lightning' && !answered) {
      barW.setValue(1);
      Animated.timing(barW, { toValue: 0, duration: LIGHTNING_MS, useNativeDriver: false }).start();
      timerRef.current = setTimeout(() => answer('timeout'), LIGHTNING_MS);
    }
    return clearTimer;
    // answered is intentionally NOT a dep: answering calls clearTimer() directly,
    // so re-running this effect on answer would only redundantly cancel the timer.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, i, round?.kind]);

  // --- answer a round ---
  function answer(choice: number | 'left' | 'right' | 'timeout') {
    if (lockRef.current || answered || !round) return;
    lockRef.current = true;
    clearTimer();
    setPick(choice);
    setAnswered(true);

    let right = false;
    const addMiss = (m: { full_name: string; qid: string | null }) => setMisses((prev) => [...prev, m]);

    if (round.kind === 'whose' || round.kind === 'art') {
      const chosen = round.options[choice as number];
      right = chosen === round.answer;
      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
    } else if (round.kind === 'century') {
      right = round.options[choice as number] === round.century;
      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
    } else if (round.kind === 'match') {
      right = round.options[choice as number].owner === round.answer;
      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
    } else if (round.kind === 'early') {
      right = choice === round.earlier;
      if (!right) addMiss({ full_name: round.person, qid: round.qid });
    } else if (round.kind === 'lightning') {
      const chosen = choice === 'timeout' ? null : round.options[choice as number];
      right = chosen === round.correct;
      const rec = (lightningRes.current[round.pid] = lightningRes.current[round.pid] || { artist: false, title: false });
      rec[round.q] = right;
      if (!right && round.q === 'artist') addMiss({ full_name: round.painting.artist.full_name, qid: qidOf(round.painting.artist) });
    }

    void Haptics.notificationAsync(
      right ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error,
    );

    if (right) {
      setStreak((s) => s + 1);
      const bonus = Math.min(streak, 5);
      setScore((v) => v + (round.kind === 'lightning' ? 5 + bonus : 10 + bonus * 2));
    } else {
      setStreak(0);
    }
  }

  function next() {
    clearTimer(); // defensive: a lightning timeout can't fire into the next round
    const last = i >= rounds.length - 1;
    if (last) { finish(); return; }
    setI((v) => v + 1);
    setAnswered(false); setPick(null); lockRef.current = false;
  }

  function finish() {
    clearTimer();
    let finalScore = score;
    if (gameKey === 'lightning') {
      const both = Object.values(lightningRes.current).filter((x) => x.artist && x.title).length;
      finalScore += both * 5;
      setScore(finalScore);
    }
    const key = `${gameKey}.${diff}.${cat}`;
    BEST[key] = Math.max(finalScore, BEST[key] || 0);
    void saveBest();
    setPhase('over');
    loadLeaderboard();
  }

  const loadLeaderboard = useCallback(async () => {
    setLeaderLoading(true);
    try {
      const res = await api.leaderboardTop(gameKey, diff);
      if (res.ok) setLeaderRows(res.top);
    } catch { /* non-fatal */ } finally { setLeaderLoading(false); }
  }, [gameKey, diff]);

  // -------------------------------------------------------------------------
  // Renders
  // -------------------------------------------------------------------------

  if (dataError) {
    return (
      <View style={styles.centeredFill}>
        <Text style={styles.errorText}>Could not load the signature catalog.</Text>
        <View style={{ width: 160 }}>
          <GlassButton label="Retry" solid onPress={loadCatalog} />
        </View>
      </View>
    );
  }

  // ---- SETUP ----
  if (phase === 'setup' || phase === 'prep') {
    return (
      <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
        <Text style={styles.h1}>The Signature Games</Text>
        <Text style={styles.sub}>Choose your game</Text>

        <View style={styles.gameGrid}>
          {GAMES.map((g) => {
            const on = g.key === gameKey;
            return (
              <Pressable key={g.key} onPress={() => { setGameKey(g.key); setStartError(''); }} style={[styles.gcard, on && styles.gcardOn]}>
                <Text style={[styles.gcardTitle, on && styles.gcardTitleOn]}>{g.title}</Text>
                <Text style={styles.gcardDesc}>{g.desc}</Text>
              </Pressable>
            );
          })}
        </View>

        <Text style={styles.label}>Difficulty</Text>
        <View style={styles.optRow}>
          {DIFFS.map((d) => (
            <OptionChip key={d.key} label={d.label} sub={`(${d.note})`} active={diff === d.key} onPress={() => setDiff(d.key)} />
          ))}
        </View>

        {catRelevant && (
          <>
            <Text style={styles.label}>Category</Text>
            <View style={styles.optRow}>
              {CATS.map((c) => (
                <OptionChip key={c.key} label={c.label} active={cat === c.key} onPress={() => setCat(c.key)} />
              ))}
            </View>
          </>
        )}

        {startError !== '' && <Text style={styles.errorText}>{startError}</Text>}

        <View style={styles.beginBtn}>
          <GlassButton
            label="Begin"
            solid
            busy={phase === 'prep'}
            disabled={!ALL.length || phase === 'prep'}
            onPress={begin}
          />
        </View>
        {phase === 'prep' && <Text style={styles.dimText}>{prepMsg}</Text>}
        {!ALL.length && phase !== 'prep' && <Text style={styles.dimText}>Loading signatures…</Text>}
      </ScrollView>
    );
  }

  // ---- OVER ----
  if (phase === 'over') {
    return <ResultsView
      gameKey={gameKey} diff={diff} cat={cat}
      score={score} misses={misses} total={rounds.length}
      lightningRes={lightningRes.current}
      leaderRows={leaderRows} leaderLoading={leaderLoading}
      onReloadLeaderboard={loadLeaderboard}
      onAgain={() => { setPhase('setup'); }}
    />;
  }

  // ---- PLAYING ----
  return (
    <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
      <View style={styles.hud}>
        <GlassPill><Text style={styles.hudText}>Round {i + 1}/{rounds.length}</Text></GlassPill>
        {streak >= 2 && <GlassPill tintColor="rgba(201,168,106,0.22)"><Text style={styles.hudText}>🔥 {streak} streak</Text></GlassPill>}
        <GlassPill><Text style={styles.hudText}>Score {score}</Text></GlassPill>
      </View>

      <Text style={styles.prompt}>{round?.kind === 'early' ? `${(round as EarlyRound).person} — which is the EARLIER signature?` : PROMPTS[gameKey]}</Text>

      {round?.kind === 'lightning' && !answered && (
        <View style={styles.timerTrack}>
          <Animated.View style={[styles.timerFill, { width: barW.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }) }]} />
        </View>
      )}
      {round?.kind === 'lightning' && (
        <Text style={styles.tag}>{round.q === 'artist' ? 'Name the ARTIST' : 'Name the PAINTING'}</Text>
      )}

      <Stage round={round} />

      {round?.kind === 'match'
        ? <MatchChoices round={round as MatchRound} answered={answered} pick={pick} onPick={answer} />
        : <TextChoices round={round} answered={answered} pick={pick} onPick={answer} />}

      {answered && <Reveal round={round} pick={pick} />}
      {answered && (
        <View style={styles.nextBtn}>
          <GlassButton label={i >= rounds.length - 1 ? 'See results' : 'Next →'} solid onPress={next} />
        </View>
      )}
    </ScrollView>
  );
}

// ---------------------------------------------------------------------------
// Stage (the image(s) shown for the round)
// ---------------------------------------------------------------------------

function Stage({ round }: { round: Round | undefined }) {
  if (!round) return null;
  if (round.kind === 'whose' || round.kind === 'century' || round.kind === 'match') {
    return (
      <View style={styles.sigWrap}>
        <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
          <Image source={{ uri: sigImg(round.answer.signature_image_url) }} style={styles.sigImage} resizeMode="contain" accessibilityLabel="A handwritten signature" />
        </GlassPanel>
      </View>
    );
  }
  if (round.kind === 'art' || round.kind === 'lightning') {
    const img = round.kind === 'art' ? round.artwork.img : round.painting.img;
    return (
      <View style={styles.sigWrap}>
        <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
          <Image source={{ uri: img }} style={styles.artImage} resizeMode="cover" accessibilityLabel="A museum artwork" />
        </GlassPanel>
      </View>
    );
  }
  if (round.kind === 'early') {
    return (
      <View style={styles.duo}>
        {(['leftUrl', 'rightUrl'] as const).map((k, idx) => (
          <View key={k} style={styles.duoCell}>
            <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
              <Image source={{ uri: sigImg(round[k]) }} style={styles.duoImg} resizeMode="contain" accessibilityLabel={`Signature ${idx === 0 ? 'A' : 'B'}`} />
            </GlassPanel>
            <Text style={styles.duoLab}>{idx === 0 ? 'A' : 'B'}</Text>
          </View>
        ))}
      </View>
    );
  }
  return null;
}

// ---------------------------------------------------------------------------
// Text-choice buttons (whose / art / century / early / lightning)
// ---------------------------------------------------------------------------

function TextChoices({
  round, answered, pick, onPick,
}: { round: Round | undefined; answered: boolean; pick: any; onPick: (c: any) => void }) {
  if (!round) return null;

  // build the [label, isCorrect, value] list per game
  let items: { label: string; correct: boolean; value: number | 'left' | 'right' }[] = [];
  if (round.kind === 'whose' || round.kind === 'art') {
    items = round.options.map((o, idx) => ({ label: o.full_name, correct: o === round.answer, value: idx }));
  } else if (round.kind === 'century') {
    items = round.options.map((c, idx) => ({ label: centuryLabel(c), correct: c === round.century, value: idx }));
  } else if (round.kind === 'lightning') {
    items = round.options.map((o, idx) => ({ label: o, correct: o === round.correct, value: idx }));
  } else if (round.kind === 'early') {
    items = [
      { label: 'A is earlier', correct: round.earlier === 'left', value: 'left' },
      { label: 'B is earlier', correct: round.earlier === 'right', value: 'right' },
    ];
  } else {
    return null;
  }

  return (
    <View style={styles.choicesWrap}>
      {items.map((it, idx) => {
        let tint: string | undefined;
        let textColor: string | undefined;
        if (answered) {
          const isPicked = pick === it.value;
          if (it.correct) { tint = GLASS.accent; textColor = GLASS.ink; }
          else if (isPicked) { tint = '#b03a2e'; textColor = '#fff'; }
        }
        if (tint) {
          return (
            <View key={idx} style={styles.choiceOverrideWrap}>
              <View style={[styles.choiceOverridePill, { backgroundColor: tint }]}>
                <Text style={[styles.choiceOverrideText, { color: textColor }]} numberOfLines={2}>{it.label}</Text>
              </View>
            </View>
          );
        }
        return (
          <View key={idx} style={styles.choiceBtn}>
            <GlassButton label={it.label} disabled={answered} onPress={() => onPick(it.value)} />
          </View>
        );
      })}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Match choices — 2×2 grid of artwork images
// ---------------------------------------------------------------------------

function MatchChoices({
  round, answered, pick, onPick,
}: { round: MatchRound; answered: boolean; pick: any; onPick: (c: number) => void }) {
  return (
    <View style={styles.matchGrid}>
      {round.options.map((o, idx) => {
        const correct = o.owner === round.answer;
        const isPicked = pick === idx;
        const border = answered ? (correct ? GLASS.accent : isPicked ? '#b03a2e' : GLASS.edgeFaint) : GLASS.edgeFaint;
        return (
          <Pressable key={idx} disabled={answered} onPress={() => onPick(idx)} style={[styles.matchCell, { borderColor: border }]}>
            <Image source={{ uri: o.art.img }} style={styles.matchImg} resizeMode="cover" accessibilityLabel="Artwork option" />
            {answered && (
              <View style={styles.matchLabelWrap}>
                <Text style={styles.matchLabel} numberOfLines={2}>{o.owner.full_name}</Text>
              </View>
            )}
          </Pressable>
        );
      })}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Reveal line under the choices after answering
// ---------------------------------------------------------------------------

function Reveal({ round, pick }: { round: Round | undefined; pick: any }) {
  if (!round) return null;
  let right = false;
  let text = '';
  let link: { label: string; url: string } | null = null;

  if (round.kind === 'whose') {
    right = round.options[pick as number] === round.answer;
    text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
  } else if (round.kind === 'art') {
    right = round.options[pick as number] === round.answer;
    text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
    link = { label: `“${round.artwork.title}”${round.artwork.date ? ', ' + round.artwork.date : ''} →`, url: round.artwork.url };
  } else if (round.kind === 'century') {
    right = round.options[pick as number] === round.century;
    text = `${right ? '✓ Correct —' : '✗'} ${round.answer.full_name} died ${String(round.answer.death_date).slice(0, 4)} (${centuryLabel(round.century)})`;
  } else if (round.kind === 'match') {
    right = round.options[pick as number]?.owner === round.answer;
    const ownPiece = round.options.find((o) => o.owner === round.answer)!;
    text = `${right ? '✓ Correct —' : '✗ Their piece was'} “${ownPiece.art.title}” by ${round.answer.full_name}`;
    link = { label: 'View artwork →', url: ownPiece.art.url };
  } else if (round.kind === 'early') {
    right = pick === round.earlier;
    text = `${right ? '✓ Correct —' : '✗'} ${round.person}: ${round.earlier === 'left' ? 'A' : 'B'} is from ${round.years[0]}, the other from ${round.years[1]}`;
  } else if (round.kind === 'lightning') {
    const chosen = pick === 'timeout' ? null : round.options[pick as number];
    right = chosen === round.correct;
    const lead = right ? '✓' : pick === 'timeout' ? '⏱ Time!' : '✗';
    text = `${lead} ${round.q === 'artist' ? 'Artist' : 'Painting'}: ${round.correct}`;
    if (round.q === 'title') link = { label: 'see it →', url: round.painting.url };
  }

  return (
    <View style={styles.reveal}>
      <Text style={styles.revealText}>{text}</Text>
      {link && (
        <Text style={styles.revealLink} onPress={() => Linking.openURL(link!.url)}>{link.label}</Text>
      )}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Results view
// ---------------------------------------------------------------------------

function ResultsView({
  gameKey, diff, cat, score, misses, total, lightningRes,
  leaderRows, leaderLoading, onReloadLeaderboard, onAgain,
}: {
  gameKey: GameKey; diff: string; cat: string; score: number; total: number;
  misses: { full_name: string; qid: string | null }[];
  lightningRes: Record<string, { artist: boolean; title: boolean }>;
  leaderRows: LeaderRow[]; leaderLoading: boolean;
  onReloadLeaderboard: () => void; onAgain: () => void;
}) {
  const [name, setName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [rank, setRank] = useState<number | null>(null);
  const [submitError, setSubmitError] = useState('');

  const uniqueMisses = useMemo(() => {
    const m = new Map<string, { full_name: string; qid: string | null }>();
    misses.forEach((x) => m.set(x.qid || x.full_name, x));
    return [...m.values()];
  }, [misses]);

  const rightN = total - misses.length;
  const verdict =
    rightN === total ? 'Master archivist. The auction houses should be calling.'
    : rightN >= total * 0.8 ? "A connoisseur's eye."
    : rightN >= total * 0.6 ? 'A promising apprentice.'
    : rightN >= total * 0.4 ? 'The gallery guide is that way…'
    : 'Everyone starts somewhere.';

  const bonusBoth = gameKey === 'lightning'
    ? Object.values(lightningRes).filter((x) => x.artist && x.title).length : 0;
  const bonusTotal = gameKey === 'lightning' ? Object.keys(lightningRes).length : 0;

  const best = BEST[`${gameKey}.${diff}.${cat}`] || score;

  const submit = useCallback(async () => {
    const trimmed = name.trim();
    if (!trimmed) return;
    setSubmitting(true); setSubmitError('');
    try {
      const res = await api.leaderboardSubmit(gameKey, diff, score, trimmed);
      if (res.ok && res.rank != null) { setRank(res.rank); onReloadLeaderboard(); }
      else setSubmitError('Submit failed — try again.');
    } catch { setSubmitError('Network error.'); }
    finally { setSubmitting(false); }
  }, [name, score, gameKey, diff, onReloadLeaderboard]);

  const share = useCallback(() => {
    const msg = `I scored ${score} on ${GAME_NAMES[gameKey]} (${diff}) at The Signature Games — can you beat me? https://celebsignatures.com/game`;
    Share.share({ message: msg }).catch(() => {});
  }, [score, gameKey, diff]);

  return (
    <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
      <GlassPanel style={styles.gameOverPanel}>
        <Text style={styles.scoreBig}>{score}</Text>
        <Text style={styles.verdict}>{verdict}</Text>
        {bonusTotal > 0 && (
          <Text style={styles.dimText}>⚡ Named BOTH on {bonusBoth} of {bonusTotal} paintings — +{bonusBoth * 5} bonus</Text>
        )}
        <Text style={styles.bestText}>Best ({gameKey} · {diff}): {best}</Text>

        {uniqueMisses.length > 0 ? (
          <Text style={styles.missText}>
            Worth a second look: {uniqueMisses.map((m) => m.full_name).join(' · ')}
          </Text>
        ) : (
          <Text style={styles.missText}>A perfect run.</Text>
        )}

        {rank != null ? (
          <Text style={styles.rankText}>You ranked #{rank} — nice!</Text>
        ) : (
          <>
            <TextInput
              style={styles.nameInput}
              placeholder="Your name (optional for board)"
              placeholderTextColor={GLASS.textDim}
              value={name}
              onChangeText={setName}
              maxLength={24}
              returnKeyType="done"
              autoCorrect={false}
            />
            {submitError !== '' && <Text style={styles.errorText}>{submitError}</Text>}
            <View style={styles.submitBtn}>
              <GlassButton label="Post my score" solid busy={submitting} disabled={name.trim().length === 0} onPress={submit} />
            </View>
          </>
        )}

        <View style={styles.shareRow}>
          <View style={{ flex: 1 }}><GlassButton label="🔗 Share result" onPress={share} /></View>
        </View>
        <View style={styles.playAgainBtn}><GlassButton label="Play again" onPress={onAgain} /></View>
      </GlassPanel>

      <LeaderboardPanel rows={leaderRows} loading={leaderLoading} tag={`${GAME_NAMES[gameKey]} · ${diff}`} />
    </ScrollView>
  );
}

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const styles = StyleSheet.create({
  root: { flex: 1 },
  scroll: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 48, gap: 14 },

  centeredFill: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 },
  dimText: { color: GLASS.textDim, fontSize: 14, textAlign: 'center' },
  errorText: { color: '#e05c5c', fontSize: 14, textAlign: 'center' },

  // setup
  h1: { color: GLASS.accentSoft, fontSize: 28, fontWeight: '800', textAlign: 'center', letterSpacing: 0.4 },
  sub: { color: GLASS.textDim, fontSize: 15, textAlign: 'center', marginTop: -6 },
  gameGrid: { gap: 10, marginTop: 4 },
  gcard: {
    borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
    backgroundColor: '#ffffff', padding: 14,
  },
  gcardOn: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.14)' },
  gcardTitle: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '800', letterSpacing: 0.2 },
  gcardTitleOn: { color: GLASS.accent },
  gcardDesc: { color: GLASS.textDim, fontSize: 13, marginTop: 3, lineHeight: 18 },

  label: { color: GLASS.accent, fontSize: 13, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', marginTop: 6 },
  optRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
  chip: {
    flexDirection: 'row', alignItems: 'center',
    borderRadius: 999, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
    paddingHorizontal: 14, paddingVertical: 9, backgroundColor: '#ffffff',
  },
  chipActive: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.2)' },
  chipText: { color: GLASS.accentSoft, fontSize: 14, fontWeight: '600' },
  chipSub: { color: GLASS.textDim, fontSize: 12 },
  chipTextActive: { color: GLASS.accent },
  beginBtn: { marginTop: 12 },

  // hud / play
  hud: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 },
  hudText: { color: GLASS.accentSoft, fontSize: 13, fontWeight: '700', letterSpacing: 0.3 },
  prompt: { color: GLASS.accentSoft, fontSize: 18, fontWeight: '700', textAlign: 'center', letterSpacing: 0.2, lineHeight: 24 },
  tag: { color: GLASS.accent, fontSize: 13, fontWeight: '800', letterSpacing: 0.8, textAlign: 'center', textTransform: 'uppercase' },

  timerTrack: { height: 6, borderRadius: 3, backgroundColor: 'rgba(0,0,0,0.08)', overflow: 'hidden' },
  timerFill: { height: 6, backgroundColor: GLASS.accent },

  sigWrap: {
    borderRadius: 22, shadowColor: GLASS.accent, shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.22, shadowRadius: 24, elevation: 10,
  },
  sigPanel: {},
  sigImage: { width: '100%', height: 190, backgroundColor: '#ffffff', borderRadius: 8 },
  artImage: { width: '100%', height: 260, borderRadius: 8 },

  // early duo
  duo: { flexDirection: 'row', gap: 12 },
  duoCell: { flex: 1, alignItems: 'center', gap: 6 },
  duoImg: { width: '100%', height: 150, backgroundColor: '#ffffff', borderRadius: 8 },
  duoLab: { color: GLASS.accent, fontSize: 16, fontWeight: '800' },

  // choices
  choicesWrap: { gap: 10 },
  choiceBtn: {},
  choiceOverrideWrap: { borderRadius: 999, overflow: 'hidden' },
  choiceOverridePill: { borderRadius: 999, paddingVertical: 16, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
  choiceOverrideText: { fontSize: 16, fontWeight: '700', letterSpacing: 0.2, textAlign: 'center' },

  // match grid
  matchGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 10 },
  matchCell: {
    width: '48%', aspectRatio: 1, borderRadius: 14, overflow: 'hidden',
    borderWidth: 2, backgroundColor: '#ffffff',
  },
  matchImg: { width: '100%', height: '100%' },
  matchLabelWrap: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(255,255,254,0.92)', paddingVertical: 4, paddingHorizontal: 6 },
  matchLabel: { color: GLASS.accentSoft, fontSize: 11, fontWeight: '600', textAlign: 'center' },

  // reveal
  reveal: { alignItems: 'center', gap: 4, marginTop: 2 },
  revealText: { color: GLASS.accent, fontSize: 15, fontWeight: '700', textAlign: 'center', lineHeight: 20 },
  revealLink: { color: GLASS.accentSoft, fontSize: 14, textDecorationLine: 'underline' },
  nextBtn: { marginTop: 4 },

  // results
  gameOverPanel: { gap: 12, alignItems: 'stretch' },
  scoreBig: { color: GLASS.accent, fontSize: 52, fontWeight: '900', textAlign: 'center', letterSpacing: 0.5 },
  verdict: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '700', textAlign: 'center', lineHeight: 23 },
  bestText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center' },
  missText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center', lineHeight: 19 },
  rankText: { color: GLASS.accent, fontSize: 18, fontWeight: '700', textAlign: 'center' },
  nameInput: {
    borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edge,
    backgroundColor: '#ffffff', color: GLASS.accentSoft, fontSize: 16,
    paddingHorizontal: 16, paddingVertical: 13,
  },
  submitBtn: { marginTop: 2 },
  shareRow: { flexDirection: 'row', marginTop: 2 },
  playAgainBtn: { marginTop: 2 },

  // leaderboard
  leaderPanel: { gap: 10 },
  sectionTitle: { color: GLASS.accent, fontSize: 15, fontWeight: '700', letterSpacing: 0.5, marginBottom: 2 },
  leaderRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
  leaderRank: { color: GLASS.textDim, fontSize: 14, fontWeight: '600', width: 22 },
  leaderName: { flex: 1, color: GLASS.accentSoft, fontSize: 15, fontWeight: '500' },
  leaderScore: { color: GLASS.ink, fontSize: 14, fontWeight: '700' },
});