/* eslint-disable */
// Roulette: the seat card (name + Solo / Online / Play with friends, on every visit) and the online table.
//
// The online table is drawn from the table's snapshots: the server's (rl-online.js), or, when the server can't be
// reached, a table this page runs itself on the same engine (rl-table-model.js) with the CPU regulars only. Either way
// the page never decides a spin; it renders the snapshot and sends this player's chips.
//
// Chips: laid on the felt here and sent as the whole layout a moment later (at once on Ready). The table holds what it
// last heard, and when betting closes that is the spin; a chip that didn't reach it in time comes back off the felt.
// Money: the casino bankroll lives in this browser. Each snapshot says what this person has on the felt; the page
// takes the difference out of the bankroll (or puts it back when chips come off) and remembers it in a ticket
// (localStorage rlTableSpin). When the table reports the spin's result, the page pays in what came back and clears
// the ticket. A tab closed mid-spin banks the result on the next visit, because the table keeps the result.

const RL_MODE_KEY = 'rlMode';
const RL_TICKET_KEY = 'rlTableSpin';
const RL_LOCAL_TICKET_KEY = 'rlLocalSpin';
const RL_CODE = /^[A-HJ-NP-Z2-9]{4}$/;   // room codes: no I, O, 0 or 1, so they read out loud cleanly
const RL_PUBLIC = /^P([1-9]|1[0-2])$/;   // the server's public table ids
const RL_SYNC_MS = 250;                  // chips go to the table this long after the last one lands
const RL_GAP_MS = 110;                   // the table wants 80 ms between messages

function rlOnlineEnabled() {
  try {
    if (window.CASINO_CONFIG && window.CASINO_CONFIG.rouletteOnline) return true;
    const q = new URLSearchParams(location.search);
    return q.has('online') || q.has('table');
  } catch (e) { return false; }
}
function rlStore(key, value) {
  try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); } catch (e) {}
}
function rlLoad(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
function rlTicket() { try { const t = JSON.parse(rlLoad(RL_TICKET_KEY) || 'null'); return t && t.table && t.round ? t : null; } catch (e) { return null; } }
// A stake left at the offline table by a page closed mid-spin: that table died with the page, so the stake comes back.
function rlRefundLocalTicket() {
  let t = null; try { t = JSON.parse(rlLoad(RL_LOCAL_TICKET_KEY) || 'null'); } catch (e) {}
  const B = window.CASINO_BANKROLL;
  if (t && t.staked > 0 && B) B.write(Math.max(0, B.read() + t.staked));
  rlStore(RL_LOCAL_TICKET_KEY, null);
}
function rlSetTableParam(code) {
  try {
    const u = new URL(location.href);
    if (code) u.searchParams.set('table', code); else u.searchParams.delete('table');
    history.replaceState(null, '', u.pathname + u.search + u.hash);
  } catch (e) {}
}
const rlTableLabel = id => RL_PUBLIC.test(id) ? 'Table ' + id.slice(1) : id === 'LOCAL' ? 'Offline table' : 'Private table';

// Every spot on the felt by id (rl-bets.js), so a layout from the table can be drawn and coached like the solo one.
const RL_SPOTS = (() => {
  const m = {};
  buildCells().concat(buildInsideZones()).forEach(c => { m[c.id] = { type: c.type, numbers: c.numbers }; });
  return m;
})();
const rlFull = list => (list || []).filter(b => RL_SPOTS[b.id]).map(b => ({ id: b.id, type: RL_SPOTS[b.id].type, numbers: RL_SPOTS[b.id].numbers, amount: b.amount }));
const rlKey = list => (list || []).map(b => b.id + ':' + b.amount).sort().join(',');
const rlSum = list => (list || []).reduce((a, b) => a + b.amount, 0);

// The solo table's per-game record (rouletteStats), kept going by online spins too.
function rlReadStats() {
  try { const s = JSON.parse(rlLoad(STORAGE_KEY) || 'null'); return s && typeof s === 'object' ? s : {}; } catch (e) { return {}; }
}
function rlRecordSpin(result, bankroll) {
  const saved = rlReadStats(), st = Object.assign({ spinsPlayed: 0, spinsWon: 0, biggestWin: 0, biggestBankroll: STARTING_BANKROLL }, saved.stats || {});
  st.spinsPlayed += 1;
  if (result.profit > 0) st.spinsWon += 1;
  st.biggestWin = Math.max(st.biggestWin, result.profit > 0 ? result.profit : 0);
  st.biggestBankroll = Math.max(st.biggestBankroll, bankroll);
  rlStore(STORAGE_KEY, { stats: st, history: Array.isArray(saved.history) ? saved.history : [] });
  return st;
}
// The banner for this player's spin, the same kinds as the solo table.
function rlBanner(r) {
  if (!r || !r.staked) return null;
  if (r.hadStraight && r.profit > 0) return { kind: 'straight', payout: r.profit };
  if (r.profit > 0 && r.profit >= r.staked * 5) return { kind: 'bigwin', payout: r.profit };
  if (r.profit > 0) return { kind: 'win', payout: r.profit };
  if (r.profit === 0 && r.returned > 0) return { kind: 'push', payout: 0 };
  if (r.lpBack > 0) return { kind: 'lp', payout: -r.profit };
  return { kind: 'lose', payout: -r.profit };
}

// ─── Who's at the table ─────────────────────────────────
function RlBadge({ children, tone = 'dim' }) {
  const tones = {
    dim: { background:'rgba(255,255,255,.08)', color:'var(--ivory-dim)', border:'1px solid rgba(255,255,255,.15)' },
    cpu: { background:'rgba(120,160,220,.16)', color:'#b9cdf0', border:'1px solid rgba(150,180,230,.35)' },
    gold: { background:'linear-gradient(180deg, #f5d896, #c9a26a)', color:'#1a1208', border:'1px solid rgba(245,216,150,.9)' },
    warn: { background:'rgba(255,107,90,.16)', color:'#f6b1a6', border:'1px solid rgba(255,107,90,.4)' },
    ready: { background:'rgba(126,219,156,.16)', color:'#9eddb8', border:'1px solid rgba(126,219,156,.45)' },
  };
  return <span style={{ ...tones[tone], fontSize: 8.5, fontWeight: 800, letterSpacing:'.14em', padding:'1px 5px', borderRadius: 4, textTransform:'uppercase', whiteSpace:'nowrap' }}>{children}</span>;
}
function RlNet({ result, small }) {
  if (!result || !result.staked) return null;
  const p = result.profit, good = p > 0, even = p === 0;
  return (
    <span style={{
      fontFamily:"'JetBrains Mono', monospace", fontSize: small ? 11 : 12.5, fontWeight: 700, whiteSpace:'nowrap',
      color: good ? '#9eddb8' : even ? '#ffd27a' : '#ff9286',
    }}>{good ? `+$${p.toLocaleString()}` : even ? 'Even' : `−$${(-p).toLocaleString()}`}</span>
  );
}
// One row per player: chip color, name, badges, what they have on the felt, then how the spin went for them.
function RlPlayers({ players, phase, shown, compact }) {
  return (
    <div data-rl-players style={{ display:'flex', flexDirection:'column', gap: compact ? 3 : 5, minWidth: 0 }}>
      {players.map(o => (
        <div key={o.seat + (o.left ? 'l' : '')} data-player={o.name} style={{
          display:'flex', alignItems:'center', gap: compact ? 5 : 7, minWidth: 0,
          padding: compact ? '2px 6px' : '4px 9px', borderRadius: 8,
          background: o.you ? 'rgba(245,216,150,.12)' : 'rgba(0,0,0,.28)',
          border: o.you ? '1px solid rgba(245,216,150,.55)' : '1px solid rgba(201,162,106,.16)',
          opacity: o.left ? .55 : 1,
        }}>
          {o.you
            ? <span style={{ width: compact ? 12 : 16, height: compact ? 12 : 16, borderRadius:'50%', flex:'none', background:'radial-gradient(circle at 35% 30%, #f5d896, #c9a26a 70%, #8c6a3f)', border:'1.5px solid #f5d896' }} />
            : <MiniChip color={o.color} size={compact ? 12 : 16} />}
          <span style={{ flex: 1, minWidth: 0, fontSize: compact ? 12 : 13.5, fontWeight: 700, color: o.you ? 'var(--brass-2)' : 'var(--ivory)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{o.you ? 'You' : o.name}</span>
          {o.kind === 'cpu' && <RlBadge tone="cpu">CPU</RlBadge>}
          {o.kind === 'human' && !o.you && !o.online && !o.left && <RlBadge tone="warn">Away</RlBadge>}
          {phase === 'betting' && o.ready && <RlBadge tone="ready">✓</RlBadge>}
          <span style={{ flex:'none', minWidth: compact ? 34 : 48, textAlign:'right' }}>
            {shown && o.result ? <RlNet result={o.result} small={compact} />
              : o.shownTotal ? <span style={{ fontFamily:"'JetBrains Mono', monospace", fontSize: compact ? 11 : 12.5, fontWeight: 700, color:'var(--brass-2)' }}>${o.shownTotal.toLocaleString()}</span>
              : <span style={{ fontSize: compact ? 10.5 : 11.5, color:'var(--ivory-dim)' }}>{phase === 'betting' ? '…' : '—'}</span>}
          </span>
        </div>
      ))}
    </div>
  );
}

// ─── Online table ──────────────────────────────────────
function OnlineTable({ mode, code, playerName, onOpenSeat, onBusy, onClosed }) {
  const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const M = window.RL_TABLE_MODEL, NET = window.RL_ONLINE, B = window.CASINO_BANKROLL;

  const [bankroll, setBankroll] = useState(() => (B ? B.read() : STARTING_BANKROLL));
  const [snap, setSnap] = useState(null);
  const [status, setStatus] = useState('connecting');
  const [bets, setBets] = useState([]);          // this player's chips as laid out here: [{ id, type, numbers, amount }]
  const [lastBets, setLastBets] = useState(null);
  const [selectedChip, setSelectedChip] = useState(5);
  const [message, setMessage] = useState('');
  const [expression, setExpression] = useState('idle');
  const [mood, setMood] = useState(0);
  const [flash, setFlash] = useState(null);
  const [stats, setStats] = useState(() => Object.assign({ spinsPlayed: 0, spinsWon: 0, biggestWin: 0, biggestBankroll: STARTING_BANKROLL }, rlReadStats().stats || {}));
  const [spinRound, setSpinRound] = useState(0);      // the spin the wheel is animating (or did)
  const [spinMs, setSpinMs] = useState(4000);
  const [landedRound, setLandedRound] = useState(0);  // the spin whose ball has landed here
  const [copied, setCopied] = useState(false);
  const [, setTick] = useState(0);
  const [canvas, setCanvas] = useState(() => window.RL_CANVAS || { w: 1380, h: 860, portrait: false });
  const portrait = canvas.portrait;
  const offsetRef = useRef(0);
  const localRef = useRef(null);        // the offline table: { state, timer }
  const localTicketRef = useRef(null);  // the offline table's ticket lives only in this page (and rlLocalSpin)
  const prevRef = useRef(null);
  const snapRef = useRef(null);
  const betsRef = useRef([]);           // the layout here, for the sender
  const sentRef = useRef('');           // the last layout sent (rlKey), to tell local changes from the table's
  const syncTimer = useRef(null);
  const lastSendAt = useRef(0);
  const pendingBank = useRef(null);     // a result that waits for the ball to land before it is paid in
  const bankedRef = useRef('');         // the last spin paid in (table:round)
  const startedRef = useRef(false);
  const brokeRef = useRef(false);
  const flashTimer = useRef(null);
  const name = playerName || '';
  const ctx = { player: name, dealer: tweaks.dealerName };

  useEffect(() => {
    const onCanvas = () => setCanvas({ ...window.RL_CANVAS });
    window.addEventListener('rl:canvas', onCanvas);
    const onHydrated = () => { if (B) setBankroll(B.read()); };
    const onBonus = (e) => { if (e.detail) e.detail.seen = true; onHydrated(); };   // bonus chips (casino-bonus.js) are already in the stored bankroll
    window.addEventListener('casino:hydrated', onHydrated); window.addEventListener('casino:bonus', onBonus);
    return () => { window.removeEventListener('rl:canvas', onCanvas); window.removeEventListener('casino:hydrated', onHydrated); window.removeEventListener('casino:bonus', onBonus); };
  }, []);

  function showFlash(text) {
    if (!text) return;
    setFlash(text);
    clearTimeout(flashTimer.current);
    flashTimer.current = setTimeout(() => setFlash(null), 2800);
  }
  function layBets(list) { betsRef.current = list; setBets(list); }

  // ── Money ──
  function readTicket(local) { return local ? localTicketRef.current : rlTicket(); }
  function writeTicket(local, t) { if (local) { localTicketRef.current = t; rlStore(RL_LOCAL_TICKET_KEY, t); } else rlStore(RL_TICKET_KEY, t); }
  function moveMoney(delta) {
    if (!delta || !B) return;
    const next = Math.max(0, B.read() + delta);
    B.write(next); setBankroll(next);
  }
  function bank(result) {
    moveMoney(result.returned);
    const nb = B ? B.read() : 0;
    if (window.CASINO_STATS) {
      window.CASINO_STATS.recordPeak(nb);
      window.CASINO_STATS.recordEvent('roulette', { won: result.profit > 0, payout: Math.max(0, result.profit) });
    }
    setStats(rlRecordSpin(result, nb));
  }
  // Pays a spin's result in once, however many snapshots carry it, and clears its ticket.
  function bankOnce(local, result) {
    const key = result.table + ':' + result.round;
    if (bankedRef.current === key) return;
    bankedRef.current = key;
    bank(result);
    const t = readTicket(local);
    if (t && t.table === result.table && t.round === result.round) writeTicket(local, null);
  }
  function settleMoney(s) {
    const local = s.table.id === 'LOCAL';
    // A result still waiting for this page's ball is paid before anything of the next spin.
    if (pendingBank.current && pendingBank.current.result.round !== s.round) payPending();
    let t = readTicket(local);
    const you = s.you;
    if (t && t.table === s.table.id) {
      if (you.result && you.result.table === t.table && you.result.round === t.round) {
        // Paid in when this page's ball lands (or now, if this page never saw that spin start). The ticket stays
        // until then, so a tab closed mid-spin is paid on the next visit.
        const watching = spinRoundRef.current === t.round && landedRef.current !== t.round;
        if (watching) pendingBank.current = { local, result: you.result };
        else bankOnce(local, you.result);
        t = null;
      }
      // The table was reset (spin numbers started over), or that spin is long gone and the table no longer has this
      // player's result (it forgets people after a day away): the stake comes back.
      else if (s.round !== t.round) { moveMoney(t.staked); t = null; writeTicket(local, null); }
    }
    if (s.phase === 'results') return;
    const me = s.players.find(x => x.you && !x.left);
    const staked = me ? (me.total || 0) : 0;
    const prev = t && t.table === s.table.id && t.round === s.round ? t.staked : 0;
    if (t && (t.table !== s.table.id || t.round !== s.round) && staked === 0) return;   // an older ticket waits for its result
    // Chips down here while a spin at another table was never paid in (a closed tab, then a friend's table): that
    // stake comes back rather than being written over.
    if (t && t.table !== s.table.id) { moveMoney(t.staked); writeTicket(local, null); t = null; }
    if (staked !== prev) {
      moveMoney(prev - staked);
      writeTicket(local, staked ? { table: s.table.id, round: s.round, staked } : null);
      if (staked > prev && !startedRef.current) { startedRef.current = true; if (window.LGPlayNext) window.LGPlayNext.started(); }
    }
  }
  function payPending() {
    const p = pendingBank.current;
    if (!p) return;
    pendingBank.current = null;
    bankOnce(p.local, p.result);
  }

  // ── Snapshots (server or offline table) ──
  const spinRoundRef = useRef(0), landedRef = useRef(0);
  spinRoundRef.current = spinRound; landedRef.current = landedRound;
  function onSnap(s, receivedAt) {
    offsetRef.current = s.now - receivedAt;
    const prev = prevRef.current;
    // The wheel: animate a spin seen from its start (what's left of it); a spin first seen after the ball landed just shows.
    if (s.spin && s.spin.n != null && s.round !== spinRoundRef.current) {
      if (s.phase === 'spinning') {
        spinRoundRef.current = s.round;
        setSpinMs(Math.max(600, s.spin.ends - s.now)); setSpinRound(s.round);
      } else if (s.phase === 'results' && landedRef.current !== s.round) {
        landedRef.current = s.round; setLandedRound(s.round);
      }
    }
    settleMoney(s);
    reconcile(prev, s);
    narrate(prev, s);
    prevRef.current = s; snapRef.current = s;
    setSnap(s);
  }
  // This player's chips: the table's copy wins unless there are changes here it hasn't heard yet.
  function reconcile(prev, s) {
    const me = s.players.find(x => x.you && !x.left);
    const theirs = me ? me.bets || [] : [];
    const newRound = !prev || prev.round !== s.round;
    if (s.phase === 'betting') {
      if (newRound) {
        clearTimeout(syncTimer.current); syncTimer.current = null;
        sentRef.current = rlKey(theirs); layBets(rlFull(theirs));
        return;
      }
      // Chips just sent may not have reached the table when someone else's change comes back: keep them a moment.
      const awaiting = rlKey(theirs) !== sentRef.current && Date.now() - lastSendAt.current < 1500;
      const unsent = !!syncTimer.current || rlKey(betsRef.current) !== sentRef.current || awaiting;
      if (!unsent) layBets(rlFull(theirs));
      return;
    }
    // Betting is over: what the table has is the spin.
    if (prev && prev.phase === 'betting' && prev.round === s.round) {
      clearTimeout(syncTimer.current); syncTimer.current = null;
      if (rlKey(betsRef.current) !== rlKey(theirs)) showFlash('The wheel closed before your last chips reached the table — they\'re back on your rail.');
      if (theirs.length) setLastBets(rlFull(theirs));
    } else if (newRound && theirs.length) setLastBets(rlFull(theirs));
    sentRef.current = rlKey(theirs);
    layBets(rlFull(theirs));
  }

  // ── The offline table: the same engine, run here, CPU regulars only ──
  function pumpLocal() {
    const L = localRef.current; if (!L) return;
    const now = Date.now();
    M.advance(L.state, now, ['me']);
    onSnap(M.snapshot(L.state, 'me', now, ['me']), now);
    clearTimeout(L.timer);
    L.timer = setTimeout(pumpLocal, Math.max(40, L.state.round.deadline - Date.now()));
  }
  function startLocal() {
    if (localRef.current) return;
    const now = Date.now(), state = M.makeTable('LOCAL', {}, now);
    M.join(state, 'me', name || 'You', now);
    localRef.current = { state, timer: null };
    pumpLocal();
  }
  function stopLocal() {
    const L = localRef.current; if (!L) return;
    clearTimeout(L.timer); localRef.current = null;
    // Chips on the offline felt come back before the live table takes over.
    const t = localTicketRef.current;
    if (t && t.staked) moveMoney(t.staked);
    writeTicket(true, null);
    prevRef.current = null; bankedRef.current = '';   // a new offline table numbers its spins from 1 again
  }
  // Back to the live table only between spins, with nothing riding on the offline one.
  function localIdle() {
    const L = localRef.current; if (!L) return true;
    const s = M.snapshot(L.state, 'me', Date.now(), ['me']), me = s.players.find(x => x.you);
    return s.phase === 'results' ? landedRef.current === s.round : (s.phase === 'betting' && !(me && me.total) && !betsRef.current.length);
  }

  useEffect(() => {
    const t = rlTicket();
    const resume = mode === 'online' && t && RL_PUBLIC.test(t.table) ? t.table : null;
    NET.start({ mode, code, table: resume, name: name || '' }, {
      onState: (s, at) => { if (localRef.current) stopLocal(); onSnap(s, at); },
      onError: msg => { lastSendAt.current = 0; showFlash(msg); },   // refused: the table's copy comes back
      onStatus: (st, info) => {
        setStatus(st);
        if (st === 'offline') startLocal();
        if (st === 'closed' && onClosed) onClosed(info);
      },
      canReconnect: localIdle,
    });
    return () => { NET.stop(); if (localRef.current) { clearTimeout(localRef.current.timer); localRef.current = null; } clearTimeout(syncTimer.current); };
  }, []);

  // A clock for the countdowns and the CPUs' chips landing: 4 times a second.
  useEffect(() => {
    const id = setInterval(() => setTick(n => n + 1), 250);
    return () => clearInterval(id);
  }, []);
  useEffect(() => {
    const id = setInterval(() => setMood(m => Math.abs(m) < 0.02 ? 0 : m * 0.94), 2500);
    return () => clearInterval(id);
  }, []);

  // ── Sending ──
  function sendNow(m) {
    const s = snapRef.current; if (!s) return false;
    const msg = { round: s.round, ...m };
    lastSendAt.current = Date.now();
    if (localRef.current) {
      const err = M.action(localRef.current.state, 'me', msg, Date.now(), ['me']);
      if (err) { lastSendAt.current = 0; showFlash(err); }
      pumpLocal();
      return true;
    }
    if (!NET.send(msg)) { showFlash('Reconnecting to the table…'); return false; }
    return true;
  }
  // Send this layout (and maybe Ready) now, keeping the table's gap between messages.
  function flush(ready) {
    clearTimeout(syncTimer.current); syncTimer.current = null;
    const wait = RL_GAP_MS - (Date.now() - lastSendAt.current);
    if (wait > 0) { syncTimer.current = setTimeout(() => flush(ready), wait); return; }
    const list = betsRef.current;
    if (sendNow({ type: 'bets', bets: list.map(b => ({ id: b.id, amount: b.amount })), lp: !!tweaks.laPartage, ready: !!ready })) sentRef.current = rlKey(list);
  }
  function syncSoon() { clearTimeout(syncTimer.current); syncTimer.current = setTimeout(() => flush(false), RL_SYNC_MS); }
  // La partage is this player's own rule: switching it re-sends the chips with it.
  useEffect(() => { if (snapRef.current && snapRef.current.phase === 'betting' && betsRef.current.length) syncSoon(); }, [tweaks.laPartage]);

  // ── Derived ──
  const serverNow = Date.now() + offsetRef.current;
  const phase = snap ? snap.phase : null, round = snap ? snap.round : 0;
  const me = snap ? snap.players.find(x => x.you && !x.left) : null;
  const seated = !!me;
  const acked = me ? me.total || 0 : 0;                 // what the table has (already out of the bankroll)
  const totalBet = rlSum(bets);
  const available = bankroll - (phase === 'betting' ? totalBet - acked : 0);   // left on the rail
  const secondsLeft = snap ? Math.max(0, Math.ceil((snap.deadline - serverNow) / 1000)) : 0;
  const n = snap && snap.spin && snap.spin.n != null ? snap.spin.n : null;
  const landed = landedRound === round && n !== null;
  const spinning = spinRound === round && landedRound !== round && n !== null;
  const shown = landed && (phase === 'results' || phase === 'spinning');
  const myResult = snap && snap.you && snap.you.result && snap.you.result.round === round && snap.you.result.table === snap.table.id ? snap.you.result : null;
  const banner = shown ? rlBanner(myResult) : null;
  const busy = !!(snap && phase !== 'results' && acked > 0) || totalBet > 0;
  useEffect(() => { if (onBusy) onBusy(busy); }, [busy]);
  const ready = !!(snap && snap.you && snap.you.ready);

  // Everyone else's chips, in their colors. A CPU's chips land through the window.
  const players = snap ? snap.players.map(o => {
    const list = (o.bets || []).filter(b => !(phase === 'betting' && o.kind === 'cpu' && b.at > serverNow));
    return { ...o, visible: list, shownTotal: o.you && phase === 'betting' ? totalBet : rlSum(list) };
  }) : [];
  const others = [];
  players.forEach(o => { if (!o.you) o.visible.forEach(b => others.push({ id: b.id, amount: b.amount, color: o.color, name: o.name })); });
  const history = snap ? snap.history.slice(shown || phase !== 'results' ? 0 : 1) : [];   // the new number joins the strip as the ball lands

  // Out of chips → the shared reset modal (it also asks for feedback).
  useEffect(() => {
    const broke = phase === 'betting' && bankroll < MIN_BET && !acked && !totalBet;
    if (broke && !brokeRef.current && window.CASINO_RELOAD) {
      brokeRef.current = true;
      setExpression('sad');
      window.CASINO_RELOAD.open({
        game: 'roulette', minBet: MIN_BET,
        message: `Wheel was unkind tonight, ${name || 'friend'}. Last of your chips just rode the rail.`,
        onReset: v => { setBankroll(v); brokeRef.current = false; },
        onClose: () => { brokeRef.current = false; },
      });
    }
  }, [phase, bankroll, acked, totalBet]);

  // ── The croupier ──
  function flashExpression(expr) { setExpression(expr); }
  function say(key, expr, extra) {
    setMessage(pickLine(key, extra ? { ...ctx, ...extra } : ctx));
    if (expr) {
      flashExpression(expr);
      const d = expr === 'happy' ? 0.18 : expr === 'shocked' ? 0.10 : (expr === 'sad' || expr === 'bust') ? -0.18 : 0;
      if (d) setMood(m => Math.max(-1, Math.min(1, m + d)));
    }
  }
  function narrate(prev, s) {
    if (s.notice && (!prev || !prev.notice || prev.notice.at !== s.notice.at) && s.phase === 'betting') { setMessage(s.notice.text); return; }
    if (!prev) { setMessage(s.table.id === 'LOCAL' ? 'The online tables are out of reach. I\'ll spin for you and the regulars until they\'re back.' : pickLine('greet', ctx)); return; }
    if (prev.round === s.round && prev.phase === s.phase) return;
    if (s.phase === 'betting') { setMessage('Place your bets.'); setExpression('idle'); return; }
    if (s.phase === 'closed') { say('no_more_bets', 'idle'); return; }
    if (s.phase === 'spinning' && prev.phase !== 'spinning') say('spin', 'idle');
  }
  // The ball lands: the number, then how it went for this player (the solo table's lines and sounds).
  function onWheelLanded(num) {
    setLandedRound(spinRoundRef.current); landedRef.current = spinRoundRef.current;
    payPending();
    const s = snapRef.current;
    const colorKey = num === 0 ? 'result_zero' : (RED_NUMBERS.includes(num) ? 'result_red' : 'result_black');
    const r = s && s.you && s.you.result && s.you.result.round === spinRoundRef.current ? s.you.result : null;
    // Watching a spin with nothing on it: just the call, not a line about how it went for you.
    if (!r || !r.staked) { setMessage(num === 0 ? 'Zero, green.' : (colorKey === 'result_red' ? 'Red ' : 'Black ') + num + '.'); return; }
    setMessage(pickLine(colorKey, { ...ctx, number: num }));
    setTimeout(() => {
      if (r.hadStraight && r.profit > 0) { say('win_straight', 'shocked'); if (tweaks.soundOn) RL_SFX.bigWin(); }
      else if (r.profit > 0 && r.profit >= r.staked * 5) { say('win_big', 'shocked'); if (tweaks.soundOn) RL_SFX.bigWin(); }
      else if (r.profit > 0) { say('win_small', 'happy'); if (tweaks.soundOn) RL_SFX.win(); }
      else if (r.profit === 0 && r.returned > 0) say('push');
      else { say('lose', 'sad'); if (tweaks.soundOn) RL_SFX.lose(); }
    }, 1400);
  }

  // ── Chips ──
  function placeBet(cell) {
    if (phase !== 'betting' || !seated) return;
    if (selectedChip > available) {
      showFlash(`That $${selectedChip} chip is $${selectedChip - Math.max(0, available)} more than you have left on the rail.`);
      return;
    }
    if (totalBet + selectedChip > M.MAX_TOTAL) { showFlash(`The online table takes up to $${M.MAX_TOTAL.toLocaleString()} a spin.`); return; }
    const list = betsRef.current, i = list.findIndex(b => b.id === cell.id);
    if (i < 0 && list.length >= M.MAX_SPOTS) { showFlash(`That's ${M.MAX_SPOTS} spots — the most one player can cover.`); return; }
    layBets(i >= 0 ? list.map((b, k) => k === i ? { ...b, amount: b.amount + selectedChip } : b) : list.concat([{ id: cell.id, type: cell.type, numbers: cell.numbers, amount: selectedChip }]));
    if (tweaks.soundOn) RL_SFX.chipPlace();
    syncSoon();
    const newTotal = totalBet + selectedChip;
    if (newTotal >= 500 && totalBet < 500) say('bet_high', 'shocked');
    else if (newTotal >= 100 && totalBet < 100) say('bet_mid', 'happy');
  }
  function clearBets() {
    if (phase !== 'betting' || !betsRef.current.length) return;
    layBets([]); syncSoon();
    if (tweaks.soundOn) RL_SFX.chipRemove();
  }
  const rebetTotal = lastBets ? rlSum(lastBets) : 0;
  const canRebet = phase === 'betting' && seated && !!lastBets && lastBets.length > 0 && bets.length === 0;
  function rebet() {
    if (!canRebet) return;
    if (rebetTotal > available) { showFlash(`Last spin's $${rebetTotal} bet is more than your bankroll.`); return; }
    layBets(lastBets); syncSoon();
    if (tweaks.soundOn) RL_SFX.chipPlace();
    say('bet_mid', 'happy');
  }
  function toggleReady() {
    if (phase !== 'betting' || !seated) return;
    if (ready) { flush(false); return; }
    flush(true);
  }

  // Keyboard: the solo table's keys — S ready · R rebet · C clear · 1-4 chip.
  const keysRef = useRef({});
  keysRef.current = { toggleReady, rebet, clearBets };
  useEffect(() => {
    function onKey(e) {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      if (document.querySelector('[data-rl-seat-card]')) return;
      const K = keysRef.current, k = e.key.toLowerCase();
      if (k === 's') { e.preventDefault(); K.toggleReady(); }
      else if (k === 'r') { e.preventDefault(); K.rebet(); }
      else if (k === 'c') { e.preventDefault(); K.clearBets(); }
      else if (k >= '1' && k <= '4') { const def = CHIP_DEFS[Number(k) - 1]; if (def) setSelectedChip(def.value); }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  function copyInvite() {
    const link = location.origin + '/roulette/?table=' + snap.table.id;
    const done = () => { setCopied(true); setTimeout(() => setCopied(false), 2200); };
    try { navigator.clipboard.writeText(link).then(done, () => window.prompt('Copy this link and send it to your friends:', link)); }
    catch (e) { window.prompt('Copy this link and send it to your friends:', link); }
  }

  // ── Render ──
  const local = !!localRef.current;
  const disabled = phase !== 'betting' || !seated;
  const dealerGender = CASINO_DEALERS.get(tweaks.dealerName).art;
  const wheelProps = {
    target: n, spinning, duration: spinMs, onLanded: onWheelLanded,
    onSpinStart: () => tweaks.soundOn && RL_SFX.spinStart(),
    onTickStart: (d) => tweaks.soundOn && RL_SFX.startSpinTicks(d),
    onTickStop: () => tweaks.soundOn && RL_SFX.stopSpinTicks(),
    onBallDrop: () => tweaks.soundOn && RL_SFX.ballDrop(),
  };
  const waitingFor = snap && phase === 'betting' && ready ? players.filter(o => o.kind === 'human' && !o.you && o.online && !o.ready && !o.left).length : 0;
  const statusLine = !snap ? (status === 'closed' ? '' : 'Finding you a table…')
    : phase === 'betting' ? (ready ? (waitingFor ? `Ready · waiting on ${waitingFor === 1 ? 'one more player' : waitingFor + ' players'} · ${secondsLeft}s` : 'Ready · spinning…') : `Place your bets · ${secondsLeft}s`)
    : phase === 'closed' ? 'No more bets'
    : phase === 'spinning' || !shown ? 'The ball is rolling…'
    : `Next spin in ${secondsLeft}s`;

  const statusPill = (() => {
    const live = status === 'live' && !local;
    const dot = live ? '#7edb9c' : status === 'retrying' || status === 'connecting' ? '#e6c590' : '#f29a8c';
    const label = local ? 'Offline table' : status === 'retrying' ? 'Reconnecting…' : status === 'connecting' ? 'Finding a table…'
      : snap ? (snap.table.private ? 'Room ' + snap.table.id : rlTableLabel(snap.table.id)) : '';
    return (
      <div style={{ display:'flex', gap: 6, alignItems:'center', flexWrap:'wrap' }}>
        <span data-rl-status={local ? 'local' : status} style={{ display:'inline-flex', alignItems:'center', gap: 6, padding: portrait ? '3px 8px' : '4px 10px', borderRadius: 999, background:'rgba(10,6,3,.7)', border:'1px solid rgba(201,162,106,.3)', fontSize: portrait ? 9.5 : 10.5, fontWeight: 700, letterSpacing:'.12em', textTransform:'uppercase', color:'var(--ivory)', whiteSpace:'nowrap' }}>
          <span style={{ width: 7, height: 7, borderRadius:'50%', background: dot, boxShadow: live ? '0 0 8px #7edb9c' : 'none' }} />{label}
        </span>
        {snap && snap.table.private && !local && (
          <button onClick={copyInvite} style={{ padding: portrait ? '3px 8px' : '4px 10px', borderRadius: 999, background: copied ? 'linear-gradient(180deg, #f5d896, #c9a26a)' : 'rgba(10,6,3,.7)', border:'1px solid rgba(201,162,106,.5)', color: copied ? '#1a1208' : 'var(--brass-2)', fontSize: portrait ? 9.5 : 10.5, fontWeight: 700, letterSpacing:'.12em', textTransform:'uppercase', cursor:'pointer', fontFamily:'inherit', whiteSpace:'nowrap' }}>
            {copied ? '✓ Copied' : 'Copy invite'}
          </button>
        )}
      </div>
    );
  })();

  const noMoreBetsBanner = (top) => phase === 'closed' && (
    <div style={{
      position:'absolute', top, left:'50%', padding:'8px 22px', background:'rgba(10,6,3,.9)', border:'1px solid var(--brass-2)',
      borderRadius: 999, color:'var(--brass-2)', fontFamily:"'Playfair Display', serif", fontStyle:'italic', fontSize: 16,
      letterSpacing:'.18em', textTransform:'uppercase', zIndex: 10, boxShadow:'0 12px 28px rgba(0,0,0,.5)'
    }} className="banner-in-centered">No more bets</div>
  );
  const flashToast = flash && (
    <div className="banner-in" style={{
      position:'absolute', bottom: portrait ? 110 : 92, right: portrait ? 12 : 24, left: portrait ? 12 : 'auto', zIndex: 11, padding:'8px 14px',
      background:'rgba(142,50,38,.94)', border:'1px solid rgba(255,146,134,.5)', borderRadius: 10, color:'#ffe2dd',
      fontSize: 12.5, fontStyle:'italic', fontFamily:"'Playfair Display', serif", boxShadow:'0 10px 24px rgba(0,0,0,.5)', maxWidth: portrait ? 'none' : 340
    }}>{flash}</div>
  );
  const readyLabel = phase !== 'betting' ? (phase === 'results' && shown ? 'Next spin…' : 'No more bets')
    : ready ? `Ready ✓ ${secondsLeft}s` : `Ready → ${secondsLeft}s`;
  const readyButton = (tall) => (
    <button data-rl-ready onClick={toggleReady} disabled={disabled}
      title={ready ? 'Not ready yet (S)' : 'Ready — spin as soon as everyone is (S)'}
      style={{
        padding: tall ? '0 14px' : '12px 18px', minHeight: tall ? 48 : undefined, minWidth: tall ? 118 : 150,
        background: disabled ? 'rgba(20,12,6,.4)' : ready ? 'linear-gradient(180deg, #9eddb8, #3f8f63)' : 'linear-gradient(180deg, #e6c590, #c9a26a)',
        color: disabled ? 'var(--ivory-dim)' : '#1a1208', border:`1px solid ${ready ? '#9eddb8' : 'var(--brass-2)'}`, borderRadius: 10,
        fontSize: 11, fontWeight: 700, letterSpacing:'.16em', textTransform:'uppercase', cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.55 : 1, whiteSpace:'nowrap', fontFamily:'inherit', fontVariantNumeric:'tabular-nums',
        boxShadow: !disabled ? '0 8px 18px rgba(230,197,144,.35)' : '0 4px 10px rgba(0,0,0,.4)'
      }}>{readyLabel}</button>
  );
  const totalBox = (big) => (
    <div style={{
      padding: big ? '8px 16px' : '6px 12px', background:'rgba(10,6,3,.5)', border:'1px dashed rgba(201,162,106,.5)', borderRadius: big ? 12 : 10,
      color: totalBet > 0 ? 'var(--brass-2)' : 'var(--ivory-dim)', fontFamily:"'Playfair Display', serif", fontStyle:'italic',
      fontSize: big ? 18 : 15, minWidth: big ? 120 : 0, textAlign:'center', whiteSpace:'nowrap'
    }}>
      {big && <div style={{ fontFamily:'system-ui, sans-serif', fontStyle:'normal', fontSize: 9, letterSpacing:'.28em', textTransform:'uppercase', color:'var(--ivory-dim)', marginBottom: 2 }}>Your Bet</div>}
      ${totalBet.toLocaleString()}
    </div>
  );
  const board = (
    <BettingBoard portrait={portrait} bets={bets} others={others} onPlace={placeBet}
      winningNumber={shown ? n : null} disabled={disabled} />
  );
  const overlay = status === 'elsewhere' ? (
    <div style={{ position:'absolute', inset: 0, zIndex: 50, background:'rgba(8,5,2,.78)', display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column', gap: 16, textAlign:'center', padding: 24 }}>
      <div style={{ fontFamily:"'Playfair Display', serif", fontStyle:'italic', fontSize: 26, color:'var(--brass-2)' }}>You're playing in another tab</div>
      <div style={{ color:'var(--ivory-dim)', fontSize: 14 }}>Your spot moved to the tab you opened last.</div>
      <button onClick={() => NET.reconnect()} style={{ padding:'10px 18px', borderRadius: 999, cursor:'pointer', fontFamily:'inherit', fontSize: 11, fontWeight: 700, letterSpacing:'.16em', textTransform:'uppercase', background:'linear-gradient(180deg, #f5d896, #c9a26a)', color:'#1a1208', border:'1px solid rgba(201,162,106,.45)' }}>Play here instead</button>
    </div>
  ) : null;
  const rail = (compact) => (
    <BrassRail compact={compact} bankroll={Math.max(0, available)}
      biggestWin={stats.biggestWin} spinsPlayed={stats.spinsPlayed} spinsWon={stats.spinsWon} peak={stats.biggestBankroll}
      showHints={tweaks.showHints} onToggleHints={() => setTweak('showHints', !tweaks.showHints)}
      laPartage={tweaks.laPartage} onToggleLaPartage={() => setTweak('laPartage', !tweaks.laPartage)}
      playerName={name} onOpenProfile={onOpenSeat} />
  );
  const statusText = (size) => (
    <div data-rl-phase={phase || ''} style={{ fontSize: size, letterSpacing:'.14em', textTransform:'uppercase', color: phase === 'betting' && secondsLeft <= 5 && !ready ? '#ff9286' : 'var(--ivory-dim)', fontWeight: 700, whiteSpace:'nowrap', fontVariantNumeric:'tabular-nums' }}>{statusLine}</div>
  );

  // ── Portrait (phones) ──
  if (portrait) {
    return (
      <div style={{ width:'100%', height:'100%', position:'relative', padding: 8 }}>
        <FeltBackdrop />
        <div style={{ position:'relative', height:'100%', display:'flex', flexDirection:'column', gap: 5 }}>
          <DealerStrip name={tweaks.dealerName} message={message} gender={dealerGender} expression={expression} />
          {rail(true)}
          <div style={{ display:'flex', alignItems:'flex-start', gap: 16, position:'relative', marginTop: 4, padding:'0 4px' }}>
            {/* The wheel's brass rim reaches past its box: room for it on the left and above. */}
            <div style={{ flex:'none', margin:'12px 0 0 14px' }}>
              <Wheel size={124} {...wheelProps} />
            </div>
            <div style={{ flex: 1, minWidth: 0, display:'flex', flexDirection:'column', gap: 4 }}>
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap: 6 }}>{statusPill}</div>
              <RlPlayers players={players} phase={phase} shown={shown} compact />
            </div>
            {noMoreBetsBanner(40)}
          </div>
          <div style={{ display:'flex', alignItems:'center', justifyContent:'center', gap: 8 }}>
            <HistoryStrip history={history} />
            {statusText(10)}
          </div>
          <CoachBar bets={bets} visible={tweaks.showHints && phase === 'betting' && seated} portrait laPartage={tweaks.laPartage} />
          {/* On a phone the banner sits over the top of the felt, leaving the players list (and how they did) in view. */}
          <div style={{ flex: 1, minHeight: 0, position:'relative' }}>
            {board}
            {banner && <ResultBanner kind={banner.kind} payout={banner.payout} />}
          </div>
          <div style={{ display:'flex', justifyContent:'center' }}>
            <ChipRack selected={selectedChip} onSelect={setSelectedChip} bankroll={available} size={42} />
          </div>
          <div style={{ display:'flex', alignItems:'center', justifyContent:'center', gap: 6, paddingBottom: 4, position:'relative' }}>
            {totalBox(false)}
            <ActionButton onClick={clearBets} label="Clear" tall disabled={disabled || bets.length === 0} />
            <ActionButton onClick={rebet} label="Rebet ↺" tall disabled={!canRebet} />
            {readyButton(true)}
          </div>
          {flashToast}
        </div>
        {overlay}
      </div>
    );
  }

  // ── Landscape (desktop) ──
  return (
    <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center', justifyContent:'center', padding: 24, position:'relative' }}>
      <div style={{ width: 1380, maxWidth:'100%', height:'100%', maxHeight: 860, display:'flex', gap: 20, position:'relative' }}>
        <DealerPanel name={tweaks.dealerName} gender={dealerGender} expression={expression} message={message} isIdle={false} mood={mood} />
        <div style={{ flex: 1, position:'relative', minWidth: 800, borderRadius: 18, overflow:'visible' }}>
          <FeltBackdrop />
          <div style={{ position:'absolute', inset: 0, display:'flex', flexDirection:'column' }}>
            {rail(false)}
            <div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', padding:'10px 24px 0', gap: 18, position:'relative' }}>
              <div style={{ position:'relative', marginTop: 6 }}>
                <Wheel size={232} {...wheelProps} />
              </div>
              <div style={{ display:'flex', flexDirection:'column', alignItems:'center', gap: 12, marginTop: 4 }}>
                <ResultBigNumber result={shown ? n : null} />
                <HistoryStrip history={history} />
                {statusText(11)}
              </div>
              <div style={{ width: 236, display:'flex', flexDirection:'column', gap: 6 }}>
                {statusPill}
                <RlPlayers players={players} phase={phase} shown={shown} />
              </div>
              {banner && <ResultBanner kind={banner.kind} payout={banner.payout} />}
            </div>
            {noMoreBetsBanner(190)}
            <div style={{ flex: 1, padding:'10px 0 22px', display:'flex', flexDirection:'column', gap: 10, justifyContent:'flex-end' }}>
              <CoachBar bets={bets} visible={tweaks.showHints && phase === 'betting' && seated} laPartage={tweaks.laPartage} />
              <div style={{ padding:'0 24px' }}>{board}</div>
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap: 14, padding:'0 24px' }}>
                <ChipRack selected={selectedChip} onSelect={setSelectedChip} bankroll={available} />
                <div style={{ display:'flex', alignItems:'center', gap: 10 }}>
                  {totalBox(true)}
                  <ActionButton onClick={clearBets} label="Clear" disabled={disabled || bets.length === 0} title="C" />
                  <ActionButton onClick={rebet} label="Rebet ↺" disabled={!canRebet} title="R" />
                  {readyButton(false)}
                </div>
              </div>
            </div>
            {flashToast}
          </div>
        </div>
      </div>
      {overlay}
    </div>
  );
}

// ─── Seat card ─────────────────────────────────────────
// Every visit (with online tables on): the name, and where to sit. The last choice is pre-selected, so Enter sits you
// straight back down. Opened again from the profile pill to change tables between spins.
// A real name: one the player chose (on any table, the profile, or a Google sign-in), not the "Player 7K2Q" the
// leaderboards fall back to (workers/api/boards/names.mjs fallbackName).
const rlRealName = n => !!(n && n.trim() && !/^Player [0-9A-Z]{4}$/.test(n.trim()));

// With a real name already saved, the card doesn't ask for it again: it says who you're playing as, with a Change link.
function RlSeatCard({ initialName = '', lastMode = 'solo', firstVisit, busy, currentMode, error, working, onChoose, onCancel }) {
  const named = rlRealName(initialName);
  const [name, setName] = React.useState(named ? initialName : '');
  const [editing, setEditing] = React.useState(!named);
  const modeRef = React.useRef(null);
  const [selected, setSelected] = React.useState(lastMode);
  const codeRef = React.useRef(null), startRef = React.useRef(null);
  const [code, setCode] = React.useState('');
  const inputRef = React.useRef(null);
  const downOnBackdrop = React.useRef(false);
  React.useEffect(() => {
    // Focus the name box when it's asked for, otherwise the pre-selected table, so Enter sits you down either way.
    const id = setTimeout(() => {
      if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); }
      else if (modeRef.current) modeRef.current.focus();
    }, 50);
    return () => clearTimeout(id);
  }, [editing]);
  const dismiss = firstVisit ? () => onChoose(lastMode, name) : onCancel;
  React.useEffect(() => {
    function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); dismiss(); } }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [dismiss]);
  const locked = m => busy && m !== currentMode;
  function choose(m, c) { if (locked(m) || working) return; setSelected(m); onChoose(m, name, c); }
  function submit(e) { e.preventDefault(); choose(selected); }
  const options = [
    { mode: 'solo', title: 'Solo', sub: 'Just you and the croupier' },
    { mode: 'online', title: 'Online table', sub: 'Real people on one wheel · CPU regulars fill the rail' },
    { mode: 'private', title: 'Play with friends', sub: 'A private table you share with a link' },
  ];
  const cleanCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4);
  const codeOk = RL_CODE.test(cleanCode);
  // Play with friends opens its own two choices: start a new table, or type the code a friend gave you.
  function openFriends(focusCode) {
    setSelected('private');
    setTimeout(() => { const el = focusCode ? codeRef.current : startRef.current; if (el) el.focus(); }, 30);
  }
  return (
    <div data-rl-seat-card
      onMouseDown={e => { downOnBackdrop.current = e.target === e.currentTarget; }}
      onClick={e => { if (e.target === e.currentTarget && downOnBackdrop.current) dismiss(); }}
      style={{ position:'fixed', inset: 0, zIndex: 9000, background:'rgba(8,5,2,.65)', backdropFilter:'blur(8px)', display:'flex', alignItems:'center', justifyContent:'center', padding: 12 }}>
      <form onSubmit={submit} style={{
        background:'linear-gradient(180deg, rgba(35,22,10,.96), rgba(20,12,6,.98))', border:'1px solid rgba(201,162,106,.5)', borderRadius: 16,
        padding:'26px 28px 22px', boxShadow:'0 30px 80px rgba(0,0,0,.7), inset 0 1px 0 rgba(230,197,144,.15)',
        width: 440, maxWidth:'100%', maxHeight:'100%', overflowY:'auto', textAlign:'center',
      }}>
        <div style={{ fontFamily:"'Playfair Display', serif", fontStyle:'italic', fontSize: 24, color:'var(--brass-2)', marginBottom: 14 }}>Take a seat</div>
        {editing ? (
          <>
            <input ref={inputRef} value={name} onChange={e => setName(e.target.value)} maxLength={20}
              placeholder="Your first name (optional)" aria-label="Your first name (optional)" autoComplete="off"
              style={{ width:'100%', padding:'11px 16px', background:'rgba(10,6,3,.6)', border:'1px solid rgba(201,162,106,.4)', borderRadius: 10, color:'var(--ivory)', fontFamily:"'Playfair Display', serif", fontSize: 18, fontStyle:'italic', textAlign:'center', outline:'none', boxSizing:'border-box' }} />
            <div style={{ marginTop: 6, fontSize: 12, color:'var(--ivory-dim)' }}>First name only · shown at the table and on the leaderboards</div>
          </>
        ) : (
          <div data-rl-playing-as style={{ fontSize: 15, color:'var(--ivory-dim)' }}>
            Playing as <b style={{ color:'var(--ivory)', fontFamily:"'Playfair Display', serif", fontStyle:'italic', fontSize: 18 }}>{name}</b>
            {' · '}<button type="button" onClick={() => setEditing(true)} style={{ background:'none', border:'none', padding: 0, color:'var(--brass-2)', textDecoration:'underline', cursor:'pointer', fontSize: 14, fontFamily:'inherit' }}>Change</button>
          </div>
        )}
        <div role="radiogroup" aria-label="Where to play" style={{ display:'flex', flexDirection:'column', gap: 8, marginTop: 16 }}>
          {options.map(o => {
            const on = selected === o.mode, off = locked(o.mode);
            return (
              <button key={o.mode} ref={on ? modeRef : null} type="button" role="radio" aria-checked={on} data-mode={o.mode} disabled={off || working}
                onClick={() => (o.mode === 'private' ? openFriends(false) : choose(o.mode))} onFocus={() => setSelected(o.mode)}
                style={{
                  display:'flex', alignItems:'center', gap: 12, textAlign:'left', padding:'12px 16px', borderRadius: 12, cursor: off ? 'not-allowed' : 'pointer', fontFamily:'inherit',
                  background: on ? 'linear-gradient(180deg, rgba(245,216,150,.22), rgba(201,162,106,.12))' : 'rgba(10,6,3,.5)',
                  border: on ? '1.5px solid rgba(245,216,150,.85)' : '1px solid rgba(201,162,106,.3)', opacity: off ? .45 : 1,
                  boxShadow: on ? '0 0 22px -6px rgba(245,216,150,.6)' : 'none',
                }}>
                <span style={{ width: 16, height: 16, borderRadius:'50%', flex:'none', border:'2px solid rgba(245,216,150,.8)', background: on ? 'radial-gradient(circle, #f5d896 45%, transparent 50%)' : 'transparent' }} />
                <span style={{ flex: 1 }}>
                  <span style={{ display:'block', fontFamily:"'Playfair Display', serif", fontStyle:'italic', fontSize: 19, fontWeight: 700, color:'var(--ivory)' }}>{o.title}</span>
                  <span style={{ display:'block', fontSize: 12.5, color:'var(--ivory-dim)', marginTop: 2 }}>{o.sub}</span>
                </span>
                {on && <span style={{ fontSize: 10, letterSpacing:'.16em', color:'var(--brass-2)', textTransform:'uppercase', fontWeight: 700 }}>{working && on ? '…' : 'Enter ↵'}</span>}
              </button>
            );
          })}
        </div>
        {selected === 'private' && !locked('private') && (
          <div data-friends style={{ marginTop: 10, padding:'12px 14px', borderRadius: 12, background:'rgba(10,6,3,.55)', border:'1px solid rgba(245,216,150,.45)', display:'flex', flexDirection:'column', gap: 10, alignItems:'stretch' }}>
            <button ref={startRef} type="button" data-start-private disabled={working} onClick={() => choose('private')} style={{ minHeight: 44, borderRadius: 999, background:'linear-gradient(180deg, #f5d896, #c9a26a)', border:'1px solid rgba(245,216,150,1)', color:'#1a1208', fontWeight: 800, fontSize: 12, letterSpacing:'.14em', textTransform:'uppercase', cursor:'pointer', fontFamily:'inherit' }}>{working ? 'Opening a table…' : 'Start a new table'}</button>
            <div style={{ fontSize: 12, letterSpacing:'.2em', textTransform:'uppercase', color:'var(--ivory-dim)' }}>or join a friend's table</div>
            <div style={{ display:'flex', gap: 8, justifyContent:'center' }}>
              <input ref={codeRef} value={cleanCode} onChange={e => setCode(e.target.value)} placeholder="Room code" aria-label="Room code from a friend" autoComplete="off" autoCapitalize="characters" spellCheck={false}
                onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (codeOk) choose('private', cleanCode); } }}
                style={{ flex: 1, minWidth: 0, maxWidth: 200, padding:'10px 12px', background:'rgba(10,6,3,.7)', border:'1px solid rgba(201,162,106,.5)', borderRadius: 10, color:'var(--ivory)', fontFamily:"'JetBrains Mono', monospace", fontSize: 22, letterSpacing:'.3em', textAlign:'center', textTransform:'uppercase' }} />
              <button type="button" data-join-code disabled={!codeOk || working} onClick={() => choose('private', cleanCode)} style={{ padding:'0 18px', borderRadius: 999, background: codeOk ? 'linear-gradient(180deg, #f5d896, #c9a26a)' : 'rgba(40,28,18,.6)', border:'1px solid rgba(201,162,106,.5)', color: codeOk ? '#1a1208' : 'rgba(255,255,255,.4)', fontWeight: 800, fontSize: 12, letterSpacing:'.14em', textTransform:'uppercase', cursor: codeOk ? 'pointer' : 'not-allowed', fontFamily:'inherit' }}>Join</button>
            </div>
            {cleanCode.length >= 4 && !codeOk && <div style={{ fontSize: 12.5, color:'var(--brass-2)' }}>Room codes are 4 letters and numbers (never I, O, 0 or 1).</div>}
          </div>
        )}
        {busy && <div style={{ marginTop: 10, fontSize: 12.5, color:'var(--brass-2)' }}>Finish this spin to change tables.</div>}
        {error && <div role="alert" style={{ marginTop: 10, fontSize: 13, color:'#ff9286' }}>{error}</div>}
        <div style={{ marginTop: 12, fontSize: 13, color:'var(--ivory-dim)' }}>
          {selected !== 'private' && (
            <button type="button" onClick={() => openFriends(true)} disabled={busy} style={{ background:'none', border:'none', color:'var(--brass-2)', textDecoration:'underline', cursor:'pointer', fontSize: 13, fontFamily:'inherit' }}>Have a room code from a friend?</button>
          )}
        </div>
        <RlSignInNote />
        <div style={{ display:'flex', gap: 10, marginTop: 14, justifyContent:'center', alignItems:'center' }}>
          {!firstVisit && (
            <button type="button" onClick={onCancel} style={{ minHeight: 40, padding:'0 18px', background:'rgba(20,12,6,.6)', border:'1px solid rgba(201,162,106,.3)', borderRadius: 999, color:'var(--ivory-dim)', fontSize: 11, fontWeight: 700, letterSpacing:'.18em', textTransform:'uppercase', cursor:'pointer' }}>Cancel</button>
          )}
          <a href="/profile/" style={{ fontSize: 10, letterSpacing:'.12em', textTransform:'uppercase', color:'var(--ivory-dim)', textDecoration:'none' }}>Full profile →</a>
        </div>
      </form>
    </div>
  );
}

// ─── Root ──────────────────────────────────────────────
function RLRoot() {
  const enabled = React.useMemo(rlOnlineEnabled, []);
  const linkCode = React.useMemo(() => {
    try { const c = (new URLSearchParams(location.search).get('table') || '').toUpperCase(); return RL_CODE.test(c) ? c : null; } catch (e) { return null; }
  }, []);
  const lastMode = (() => { const m = rlLoad(RL_MODE_KEY); return m === 'online' || m === 'private' || m === 'solo' ? m : 'solo'; })();
  const [mode, setMode] = React.useState(!enabled ? 'solo' : linkCode ? 'private' : null);
  const [code, setCode] = React.useState(linkCode);
  const [card, setCard] = React.useState(enabled && !linkCode ? 'first' : null);
  const [name, setName] = React.useState(() => (window.CASINO_PLAYER && window.CASINO_PLAYER.read()) || '');
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState('');
  const [working, setWorking] = React.useState(false);

  function choose(m, typed, joinCode) {
    const trimmed = (typed || '').trim();
    if (trimmed) { const saved = window.CASINO_PLAYER.write(trimmed); setName(saved || trimmed); }
    else if (card === 'first' && !name) window.CASINO_PLAYER.set('nameSkipped', true);
    setError('');
    if (m === 'private' && !joinCode && !(mode === 'private' && code)) {
      setWorking(true);
      window.RL_ONLINE.createPrivate().then(c => {
        setWorking(false); rlStore(RL_MODE_KEY, 'private'); setCode(c); setMode('private'); rlSetTableParam(c); setCard(null);
      }, () => { setWorking(false); setError('Couldn\'t open a private table just now. Try again, or play Solo.'); });
      return;
    }
    rlStore(RL_MODE_KEY, m);
    if (m === 'private') { const c = joinCode || code; setCode(c); rlSetTableParam(c); }
    else rlSetTableParam(null);
    setMode(m); setCard(null);
  }
  function closed(info) {
    setError(info && info.message ? info.message + ' Start a new one, or pick another table.' : 'That table is closed.');
    rlSetTableParam(null); setCode(null); setBusy(false); setMode(null); setCard('edit');
  }

  return (
    <>
      {mode === 'online' || mode === 'private'
        ? <OnlineTable key={mode + ':' + (code || '')} mode={mode} code={code} playerName={name}
            onOpenSeat={() => setCard('edit')} onBusy={setBusy} onClosed={closed} />
        : <App playerName={name} seatCard={enabled} onOpenSeat={() => setCard('edit')} onBusy={setBusy} />}
      {card && (
        <RlSeatCard initialName={name} lastMode={card === 'first' ? lastMode : (mode || lastMode)} firstVisit={card === 'first'}
          busy={busy} currentMode={mode} error={error} working={working}
          onChoose={choose} onCancel={() => { setCard(null); setError(''); }} />
      )}
    </>
  );
}

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