/* =============================================================================
   SheetHead — game screen (presentation + a thin controller)
   All rules live in the engine; this file only renders state, collects intents and plays feedback.
   Exposes window.SH.ui.GameScreen.
============================================================================= */
(function(){
  const { useState, useEffect, useMemo, useRef, useCallback } = React;
  const E = window.SH.engine;
  const P = window.SH.presenter;
  const { cx, Icon, Button, Modal, Card, JokerFace, Logo } = window.SH.ui;
  const FX = window.SH.fx;
  const ST = window.SH.stats;

  // Extra time the deal animation gets before play begins regardless (Phase 2B: AI pacing/interrupt timing now
  // live in src/game-authority.js's LocalGameAuthority, not here).
  const DEAL_TIMEOUT_MS = 6500;

  const PHASE_LABEL = { HAND: 'Hand', FACE_UP: 'Face-up', FACE_DOWN: 'Face-down', OUT: 'Out' };
  const JOKER_HINT = { '2': 'reset', '3': 'mirror', '4': 'burst', '7': 'low limit', '8': 'skips next', '10': 'burns pile', Q: 'blocker' };

  // Friends-mode (Phase 5C) game-intent rejection -> a short, non-technical message. `stale`/`conflict` are not
  // really failures: the authoritative view is already resyncing (see wrapRemoteAuthority below) and will settle
  // the UI a moment later, so they get a quiet "catching up" phrasing rather than sounding like a mistake.
  const REMOTE_STATUS_MESSAGES = {
    stale: 'The table moved on — catching up…', conflict: 'Someone else acted first — catching up…',
    invalid: 'Illegal move', forbidden: "You can't do that", not_found: 'This game no longer exists',
    version_mismatch: 'Please refresh the page', network_error: "Can't reach the server. Please try again.",
    disposed: ''
  };

  // Wraps a RemoteGameAdapter (src/remote-game.js) behind the SAME read surface LocalGameAuthority exposes to this
  // file (getState/subscribe/dispose/beginPlay), so `adopt()` and everything below it never need to know which
  // backend is live -- see game-authority.js's own getViewForSeat/subscribeForSeat comment, which documents exactly
  // this "future remote player client" contract. WRITE is deliberately NOT unified: Local's submitMove is
  // synchronous/throwing, Remote's is an async HTTP round trip -- `submit` (below) is the one place that branches.
  // `initialView` is the already-loaded game_player_views row content the caller (ui.jsx) resolved before ever
  // mounting GameScreen, exactly like SOLO's `initial` -- so getState() is never null on the first render.
  function wrapRemoteAuthority(adapter, initialView) {
    let currentView = initialView;
    const listeners = [];
    const offAdapter = adapter.subscribe((snap) => {
      if (!snap.view) return;
      const prev = currentView;
      currentView = snap.view;
      listeners.forEach((l) => l(currentView, prev));
    });
    return {
      getState: () => currentView,
      subscribe(listener) {
        listeners.push(listener);
        return () => { const i = listeners.indexOf(listener); if (i >= 0) listeners.splice(i, 1); };
      },
      dispose: () => { offAdapter(); adapter.dispose(); },
      beginPlay: () => {},   // the remote authority is already running turn 1 on the server; nothing to kick off here
      isRemote: true,
      submitRemoteIntent: (move) => adapter.submitIntent(move),
      onConnectionChange: adapter.onConnectionChange,
      getConnectionState: () => adapter.getSnapshot().connectionState
    };
  }

  // ---------------------------------------------------------------------------
  // Hooks
  // ---------------------------------------------------------------------------
  function useMeasure(ref) {
    const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
    useEffect(() => {
      const el = ref.current;
      if (!el) return undefined;
      const measure = () => {
        const w = el.clientWidth || window.innerWidth, h = el.clientHeight || window.innerHeight;
        setSize(prev => (prev.w === w && prev.h === h ? prev : { w, h }));
      };
      measure();
      const ro = new ResizeObserver(measure);
      ro.observe(el);
      window.addEventListener('orientationchange', measure);
      return () => { ro.disconnect(); window.removeEventListener('orientationchange', measure); };
    }, []);
    return size;
  }

  // The PixiJS layers are an enhancement: the DOM presentation below is complete on its own and takes over whenever
  // WebGL/PixiJS is unavailable, disabled, or fails. This hook creates the runtime with the screen and destroys it
  // (canvases, textures, listeners, ticker work) when the screen unmounts.
  function useFx(rootRef, settings, layoutKey) {
    const rtRef = useRef(null);
    const readyRef = useRef(null);
    if (!readyRef.current) { let res; const p = new Promise(r => { res = r; }); p.resolve = res; readyRef.current = p; }
    const params = new URLSearchParams(window.location.search);
    const conf = () => FX.configure({ quality: settings.quality || 'auto', reducedMotion: !!settings.reducedMotion || settings.animations === false, enabled: settings.fx !== false });
    const wanted = useRef(null);
    if (wanted.current === null) {
      const cfg = FX && FX.configure ? conf() : null;
      wanted.current = !!(cfg && FX.available && FX.available() && cfg.enabled && params.get('fx') !== '0');
    }
    const wantDeal = wanted.current && settings.deal !== false && !FX.resolve().reducedMotion && params.get('deal') !== '0';
    const [active, setActive] = useState(false);
    useEffect(() => {
      if (!wanted.current) { readyRef.current.resolve(null); return undefined; }
      let cancelled = false;
      const rt = new FX.FxRuntime(rootRef.current);
      const timer = setTimeout(() => { if (!rt.ready) { console.warn('SheetHead: graphics layer timed out, using the DOM view'); cancelled = true; rt.destroy(); readyRef.current.resolve(null); } }, 6000);
      rt.init().then(() => {
        clearTimeout(timer);
        if (cancelled) { rt.destroy(); return; }
        rtRef.current = rt; FX.current = rt; setActive(true);
        requestAnimationFrame(() => requestAnimationFrame(() => { if (!cancelled && rt.ready) { rt.layout(); readyRef.current.resolve(rt); } }));
      }).catch((err) => { clearTimeout(timer); console.error('SheetHead: graphics layer unavailable, using the DOM view', err); try { rt.destroy(); } catch (e) { /* ignore */ } readyRef.current.resolve(null); });
      return () => { cancelled = true; clearTimeout(timer); if (FX.current === rt) FX.current = null; rtRef.current = null; try { rt.destroy(); } catch (e) { console.error(e); } };
    }, []);
    useEffect(() => { if (FX && FX.configure && wanted.current) { conf(); const rt = rtRef.current; if (rt && rt.ready) rt.layout(); } }, [settings.quality, settings.reducedMotion, settings.animations]);
    useEffect(() => { const rt = rtRef.current; if (rt && rt.ready) rt.resize(); }, [layoutKey]);
    return { rtRef, readyRef, active, wantDeal };
  }

  // Owns React's view of the match (render state, interrupt/feedback presentation) and a LocalGameAuthority that
  // owns authoritative execution (Phase 2B): applying moves/timeouts through the engine, the turn clock, AI
  // driving, and the accepted-action log. This hook submits intents and adopts results; it no longer calls
  // E.applyMove/E.applyTimeout itself. See src/game-authority.js for the authority boundary.
  function useGameController({ initial, humanSeat, settings, onGameEnd, fx, remote }) {
    const GA = window.SH.gameAuthority;
    // The local host is the authority for the turn clock: it stamps the first turn, passes `now` to every
    // applyMove, and is the only place that resolves a timeout (online, the server plays this role).
    const dealInit = useRef(null);
    // Friends mode never gets the opening-deal animation: by the time ANY client observes a freshly initialized
    // remote game (or reconnects mid-game), the trusted server has already dealt -- there is no local "moment
    // before dealing" left to animate through (CLAUDE.md Phase 5C §13: simplify/defer what can't be derived safely).
    if (dealInit.current === null) dealInit.current = !remote && !!(fx && fx.wantDeal);
    // Statistics: an event tally derived from the authoritative engine events (never from UI state) + when play started.
    const tallyRef = useRef(ST ? ST.emptyTally() : null), startedAtRef = useRef(Date.now());
    // Bumped after every remote game-intent settles (accepted, rejected, or network failure) -- see `submit` below.
    // GameScreen's one-submission-per-state lock (`busy`) resets on `seq` change for a LOCAL accept, but a rejected
    // remote intent never changes `seq` at all (Phase 5B: a rejected action must never mutate the safe view), so it
    // needs its own settle signal or the UI would stay locked after any remote rejection.
    const [settle, setSettle] = useState(0);

    const authorityRef = useRef(null);
    if (!authorityRef.current) {
      if (remote) {
        authorityRef.current = wrapRemoteAuthority(remote.adapter, initial);
      } else {
        // ?autoplay=1 (test aid only): the bot engine also plays the human seat so whole tournaments can run unattended.
        const autoHuman = new URLSearchParams(window.location.search).get('autoplay') === '1';
        authorityRef.current = GA.createLocalGameAuthority({
          initial, humanSeat, aiSpeedMs: settings.aiSpeedMs, autoHuman,
          // The authority only ever calls this opaque predicate; it never touches Pixi/fx itself (Phase 2B: Pixi
          // animation timing stays outside the authority boundary).
          isReadyForAiReveal: () => { const rt = fx && fx.rtRef.current; const d = rt && rt.ready && rt.director; return !d || d.calm; }
        });
        // Without a deal animation, play begins immediately -- exactly as `E.startClock(initial, Date.now())` used
        // to seed the very first React state directly. With one, startPlay() (below) calls beginPlay() once it ends.
        if (!dealInit.current) authorityRef.current.beginPlay(Date.now());
      }
    }
    const authority = authorityRef.current;
    const [state, setState] = useState(() => authority.getState());
    const timers = useRef(new Set());
    const ids = useRef(0);
    const [banners, setBanners] = useState([]);
    const [ghost, setGhost] = useState(null);
    const [quad, setQuad] = useState(null);
    const [skipped, setSkipped] = useState(null), [jump, setJump] = useState(null);   // avatar states driven by engine events (presentation only)
    const [flash, setFlash] = useState(false);
    const [notice, setNotice] = useState(null);
    // While a match is being dealt the clock is not running and nobody may act; cards that are still in the air
    // (dealt, drawn, picked up) are hidden in the DOM until the flight lands ("arriving").
    const [dealing, setDealing] = useState(dealInit.current);
    const [arriving, setArriving] = useState(() => {
      if (!dealInit.current) return new Set();
      const me = initial.players[humanSeat], ids = ['fd0', 'fd1', 'fd2'];
      me.hand.forEach(c => ids.push(c.id)); me.faceUp.forEach(c => ids.push(c.id));
      return new Set(ids);
    });
    const ghostsRef = useRef(null);            // drag ghost transforms handed to the next presentation batch

    const quadTimer = useRef(null);
    const endQuad = useCallback((id) => {
      if (quadTimer.current) { clearTimeout(quadTimer.current); timers.current.delete(quadTimer.current); quadTimer.current = null; }
      setQuad(cur => (cur && (id == null || cur.id === id) ? null : cur));
    }, []);
    const later = useCallback((fn, ms) => {
      const t = setTimeout(() => { timers.current.delete(t); fn(); }, ms);
      timers.current.add(t);
      return t;
    }, []);
    useEffect(() => () => { timers.current.forEach(clearTimeout); }, []);

    const say = useCallback((text) => {
      const id = ++ids.current;
      setNotice({ id, text });
      later(() => setNotice(n => (n && n.id === id ? null : n)), 2600);
    }, [later]);

    // Presentation adoption: the authority already decided and committed `next` (from `before`); this only turns
    // that transition into what the player sees. It is never what decides whether a move happened.
    const adopt = useCallback((next, before) => {
      const pub = E.publicEvents(next.events, humanSeat);
      // Presentation: translate the change into typed events and give the director the DOM positions of the cards that
      // are about to leave (captured now, before React re-renders). Failure here can never affect the game.
      try {
        const rt = fx && fx.rtRef.current;
        if (rt && rt.ready && rt.director) {
          const pe = P.toPresentationEvents(before, next, pub, humanSeat);
          const ids = [], hold = [];
          pe.forEach(e => {
            if (e.type === 'CARD_PLAYED') e.cards.forEach(c => ids.push(c.id));
            if (e.type === 'PILE_PICKED_UP') { (e.taken || []).forEach(c => ids.push(c.id)); if (e.seat === humanSeat) { e.cards.forEach(c => hold.push(c.id)); (e.taken || []).forEach(c => hold.push(c.id)); } }
            if (e.type === 'CARDS_DRAWN' && e.seat === humanSeat && e.ids) e.ids.forEach(i => hold.push(i));
          });
          const cfgNow = FX.resolve();
          if (hold.length && cfgNow.flights) { setArriving(prev => { const n = new Set(prev); hold.forEach(i => n.add(i)); return n; }); later(() => setArriving(prev => { const n = new Set(prev); hold.forEach(i => n.delete(i)); return n; }), 2600); }
          const handCards = {}; next.players[humanSeat].hand.forEach(c => { handCards[c.id] = c; });
          const ghosts = ghostsRef.current; ghostsRef.current = null;
          rt.director.present({ events: pe, seq: next.actionSeq, names: next.players.map(p => p.name), before: rt.director.snap(before), after: rt.director.snap(next), humanSeat,
            origins: rt.director.snapshotOrigins(ids), ghosts, handCards, holdIds: cfgNow.flights ? hold : [] });
        }
      } catch (err) { console.error('SheetHead: presentation error (game unaffected)', err); }

      if (ST && tallyRef.current) { try { tallyRef.current = ST.tallyEvents(tallyRef.current, next.events, humanSeat); } catch (err) { console.error('SheetHead: stats tally failed', err); } }
      setState(next);

      const rtNow = fx && fx.rtRef.current, cineOn = !!(rtNow && rtNow.ready && FX.resolve().flights);
      const items = P.eventsToBanners(pub, next.players, humanSeat).filter(b => !(cineOn && b.kind === 'joker')).map(b => ({ ...b, id: ++ids.current }));
      if (items.length) {
        // the Joker show owns the middle of the screen: effect banners wait until it has cleared
        const jk = pub.find(e => (e.type === 'PLAY' || e.type === 'STICK' || e.type === 'INTERRUPT') && e.cards && e.cards.some(c => c.rank === 'JOKER'));
        const wait = cineOn && jk ? (jk.seat === humanSeat ? 1500 : 2300) : 0;
        later(() => {
          setBanners(cur => cur.concat(items).slice(-4));
          items.forEach(b => later(() => setBanners(cur => cur.filter(x => x.id !== b.id)), 2100));
        }, wait);
      }
      // Presentation only: this runs after the state above has been committed and never delays it.
      // Lifecycle: the overlay ends with its own animation (QuadBurst -> endQuad) and is dropped as soon as ANY newer state
      // arrives (fast turn change, extra-turn move, interruption). The timer below is only a safety net (reduced motion has
      // no animation to end; a backgrounded tab may never fire animationend) and is cancelled whenever the overlay goes.
      endQuad();
      const qi = P.quadBurstInfo(pub, next);
      if (qi) {
        const q = { id: ++ids.current, ...qi };
        setQuad(q);
        quadTimer.current = later(() => { quadTimer.current = null; setQuad(cur => (cur && cur.id === q.id ? null : cur)); }, 2600);
      }
      const skippedSeats = pub.filter(e => e.type === 'SKIP').map(e => e.seat);
      if (skippedSeats.length) { const sid = ++ids.current; setSkipped({ id: sid, ids: skippedSeats }); later(() => setSkipped(cur => (cur && cur.id === sid ? null : cur)), 1700); }
      const firstOut = pub.find(e => e.type === 'OUT' && e.place === 1);
      if (firstOut) { const jid = ++ids.current; setJump({ id: jid, seat: firstOut.seat }); later(() => setJump(cur => (cur && cur.id === jid ? null : cur)), 2600); }
      const burn = pub.find(e => e.type === 'BURN');
      if (burn) {
        const g = { id: ++ids.current, cards: next.burnPile.slice(-burn.count) };
        setGhost(g);
        later(() => setGhost(cur => (cur && cur.id === g.id ? null : cur)), 1050);
      }
      return next;
    }, [humanSeat, later, endQuad]);

    // The authority is the single source of truth from here on: every transition it accepts -- human, AI, or
    // timeout -- arrives through this one subscription, exactly once, in commit order.
    useEffect(() => {
      const unsubscribe = authority.subscribe((next, before) => adopt(next, before));
      return () => { unsubscribe(); authority.dispose(); };
    }, [authority, adopt]);

    // Realtime PLUMBING state only (Friends mode) -- never conflated with gameplay: a dropped/reconnecting channel
    // does not pause the game or imply the player left (CLAUDE.md Phase 5C §23/§26). Purely informational for the UI.
    const [connectionState, setConnectionState] = useState(() => (authority.isRemote ? authority.getConnectionState() : 'connected'));
    useEffect(() => {
      if (!authority.isRemote) return undefined;
      setConnectionState(authority.getConnectionState());
      return authority.onConnectionChange(setConnectionState);
    }, [authority]);

    // Human intent. `seq` is the action sequence the player was looking at: a double click or a
    // click on stale UI is rejected silently by the authority's stale-action check.
    const submit = useCallback((move, seq) => {
      if (authority.getState().actionSeq !== seq) return false;
      if (authority.isRemote) {
        // Fire-and-forget from the caller's point of view: the move is NOT applied locally (Phase 5C §12 -- limited
        // optimism only). The authoritative outcome arrives through the adapter's own subscription/resync a moment
        // later, whatever it turns out to be; this only surfaces a rejection as feedback and releases the UI's
        // one-submission-per-state lock (`settle`, since a rejection never changes `seq` the way a local accept does).
        authority.submitRemoteIntent(move)
          .then((result) => {
            if (result && result.status !== 'accepted' && result.status !== 'duplicate') {
              setFlash(true); later(() => setFlash(false), 450);
              const msg = REMOTE_STATUS_MESSAGES[result.status];
              if (msg) say(msg);
            }
          })
          .catch(() => { setFlash(true); later(() => setFlash(false), 450); say(REMOTE_STATUS_MESSAGES.network_error); })
          .finally(() => setSettle((s) => s + 1));
        return true;
      }
      try { authority.submitMove(humanSeat, { ...move, expectSeq: seq }); return true; }
      catch (e) {
        setFlash(true); later(() => setFlash(false), 450);
        say(e.reason ? e.reason.charAt(0).toUpperCase() + e.reason.slice(1) : 'Illegal move');
        return false;
      }
    }, [authority, humanSeat, later, say]);

    useEffect(() => {
      if (state.status !== 'FINISHED') return undefined;
      const t = setTimeout(() => onGameEnd(state, humanSeat), FX && FX.current ? 3800 : 2400);
      return () => clearTimeout(t);
    }, [state.status]);

    // Director hooks + first sync of the Pixi table with the authoritative state, and the opening deal.
    useEffect(() => {
      if (!fx) return undefined;
      let cancelled = false;
      const startPlay = () => {
        if (cancelled) return;
        setDealing(false); setArriving(new Set()); startedAtRef.current = Date.now();
        authority.beginPlay(Date.now());   // the resulting transition flows back through the subscription above
      };
      const race = Promise.race([fx.readyRef.current, new Promise(r => setTimeout(() => r(null), DEAL_TIMEOUT_MS))]);
      race.then(async (rt) => {
        if (cancelled) return;
        if (!rt || !rt.ready || !rt.director) { if (dealInit.current) startPlay(); return; }
        rt.director.humanSeat = humanSeat;
        rt.director.setHooks({ arrive: (ids) => { if (!cancelled) setArriving(prev => { let ch = false; const n = new Set(prev); ids.forEach(i => { if (n.delete(i)) ch = true; }); return ch ? n : prev; }); } });
        const s0 = authority.getState();
        if (dealInit.current) {
          const me = s0.players[humanSeat]; const ids = ['fd0', 'fd1', 'fd2']; me.hand.forEach(c => ids.push(c.id)); me.faceUp.forEach(c => ids.push(c.id));
          rt.director.holdIds(ids);
          try { await rt.director.deal(s0, humanSeat); } catch (err) { console.error('SheetHead: deal animation failed', err); }
          startPlay();
        } else { rt.director.reconcile(rt.director.snap(s0)); }
        if (!cancelled) rt.director.setActiveSeat(authority.getState().turnSeat, 'ok');
      }).catch((err) => { console.error(err); if (dealInit.current) startPlay(); });
      return () => { cancelled = true; };
    }, []);

    return { state, tallyRef, startedAtRef, submit, banners, ghost, quad, endQuad, skipped, jump, flash, notice, say, dealing, arriving, ghostsRef, authority, settle, connectionState };
  }

  // ---------------------------------------------------------------------------
  // Table pieces
  // ---------------------------------------------------------------------------
  function DrawPile({ count }) {
    return (
      <div className="pile-col">
        <div className="pile pile-draw" aria-label={`Draw pile, ${count} cards`}>
          {count > 0 ? (
            <>
              {count > 2 && <Card faceDown style={{ transform: 'translate(-4px,-4px)' }} />}
              {count > 1 && <Card faceDown style={{ transform: 'translate(-2px,-2px)' }} />}
              <Card faceDown />
              <span className="count">{count}</span>
            </>
          ) : <div className="empty">Draw pile empty</div>}
        </div>
        <div className="pile-cap">Draw</div>
      </div>
    );
  }

  function BurnPile({ count }) {
    return (
      <div className="pile-col">
        <div className={cx('pile pile-burn', count > 0 && 'has')} aria-label={`Burned pile, ${count} cards`}>
          {count > 0
            ? <><div className="flame"><Icon name="flame" size={30} /></div><span className="count">{count}</span></>
            : <div className="empty">Burn</div>}
        </div>
        <div className="pile-cap">Burned</div>
      </div>
    );
  }

  // Cards played to the centre stack and layer; new ones fly in from the seat that played them.
  // The table pile. Layout comes from P.pileSlot (shared with the Pixi pile): the top card is dominant, the card played just before it
  // stays clearly visible beside it, older cards are a heap underneath. Hovering it (mouse) or tapping it (touch) fans out the last five.
  // When the ENGINE says the player has no legal response (`pickup`), the pile itself is the red, pulsing "take me" button.
  // Nothing here decides legality or the effective rank; it renders what it is given.
  function TablePile({ cards, ghost, humanSeat, cwp, maxShown, drop, mirror, pickup, onPickup, onFan, seq }) {
    const seen = useRef(null);
    const landing = useRef(new Set());
    if (seen.current === null) seen.current = new Set(cards.map(c => c.id));
    for (const c of cards) if (!seen.current.has(c.id)) { seen.current.add(c.id); landing.current.add(c.id); }
    const [hover, setHover] = useState(false), [touchOpen, setTouchOpen] = useState(false), ptr = useRef('mouse');
    const fan = !drop && cards.length > 1 && (hover || touchOpen);
    useEffect(() => { if (onFan) onFan(fan); }, [fan]);
    useEffect(() => () => { if (onFan) onFan(false); }, []);
    useEffect(() => { setTouchOpen(false); }, [seq]);                          // any new game state collapses a touch-opened fan
    useEffect(() => { if (!touchOpen) return undefined; const t = setTimeout(() => setTouchOpen(false), 4200); return () => clearTimeout(t); }, [touchOpen]);
    const onClick = () => {
      if (pickup) { if (onPickup) onPickup(); return; }
      if (ptr.current !== 'mouse' && cards.length > 1 && !drop) setTouchOpen(o => !o);
    };
    const shown = cards.slice(-(maxShown || 8));
    const n = shown.length;
    const slotStyle = (c, i, len, extra) => {
      const s = P.pileSlot(c, len - 1 - i, cwp, fan);
      return Object.assign({ '--dx': s.dx.toFixed(1) + 'px', '--dy': s.dy.toFixed(1) + 'px', '--rot': s.rot.toFixed(1) + 'deg', '--sc': s.scale, zIndex: i + 1 }, extra || {});
    };
    return (
      <div className={cx('pile-col', drop && 'drop-armed', drop && drop.hot && 'drop-hot', drop && drop.hot && (drop.ok ? 'drop-ok' : 'drop-bad'), pickup && 'must-pickup', fan && 'fanned')}
           data-drop="pile" data-pickup={pickup ? pickup : 'no'} data-fan={fan ? '1' : '0'}
           onPointerDown={(e) => { ptr.current = e.pointerType || 'mouse'; }}
           onPointerEnter={(e) => { if (e.pointerType === 'mouse') setHover(true); }} onPointerLeave={(e) => { if (e.pointerType === 'mouse') setHover(false); }}
           onClick={onClick}
           role={pickup ? 'button' : undefined} tabIndex={pickup ? 0 : undefined} aria-label={pickup ? 'You cannot play. Pick up the pile.' : undefined}
           onKeyDown={pickup ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (onPickup) onPickup(); } } : undefined}>
        {mirror && (
          <div className="pile-mirror" data-testid="pile-mirror" aria-label={(mirror.joker ? 'Joker as 3' : '3') + ' mirrors ' + mirror.of}>
            <b>{mirror.joker ? '\u2605' : '3'}</b><i aria-hidden="true">{'\u21A9'}</i><b>{mirror.of}</b>
          </div>
        )}
        <div className="pile pile-table" data-testid="table-pile" aria-label={`Table pile, ${cards.length} cards`}>
          {n === 0 && !ghost && <div className="empty" style={{ left: '50%', width: 'var(--cwp)', marginLeft: 'calc(var(--cwp) * -.5)' }}>Table is empty</div>}
          {shown.map((c, i) => {
            const fv = P.flightVector(c.by == null ? humanSeat : c.by, humanSeat);
            return <Card key={c.id} card={c} showDeclared style={slotStyle(c, i, n, { '--fx': fv[0] * 2.6, '--fy': fv[1] * 2.2 })} label={P.cardLabel(c)}
                         className={landing.current.has(c.id) ? 'landing' : ''} />;
          })}
          {n === 0 && ghost && ghost.cards.slice(-6).map((c, i, arr) => <Card key={'g' + c.id} card={c} style={slotStyle(c, i, arr.length)} className="burning" />)}
          {ghost && (
            <div className="sparks" aria-hidden="true">
              {Array.from({ length: 14 }, (_, i) => {
                const a = (i / 14) * Math.PI * 2;
                const d = 50 + (i % 4) * 22;
                return <i key={i} style={{ '--sx': Math.cos(a) * d + 'px', '--sy': Math.sin(a) * d - 20 + 'px', animationDelay: (i % 5) * 30 + 'ms' }} />;
              })}
            </div>
          )}
        </div>
        {drop
          ? <div className="pile-cap drop-cap" data-testid="drop-cap">{drop.hot ? (drop.ok ? 'Release to play' : drop.reason) : 'Drop here to play'}</div>
          : pickup ? <div className="pile-cap must" data-testid="pickup-cap">{pickup === 'choose' ? 'Take the pile' : 'Pick up the pile'}</div>
          : <div className="pile-cap">{cards.length} on table</div>}
      </div>
    );
  }

  // Four-of-a-kind celebration: four cards slam together, ignite, shockwave rings + sparks, big title.
  function QuadBurst({ fx, name, onDone }) {
    const sparks = useMemo(() => Array.from({ length: 34 }, (_, i) => {
      const a = (i / 34) * Math.PI * 2 + (i % 3) * 0.13;
      const d = 90 + ((i * 37) % 7) * 26;
      const colors = ['#ffd76a', '#ffb547', '#ff7a18', '#fff3c4', '#ff4d2e'];
      return { '--sx': Math.cos(a) * d + 'px', '--sy': Math.sin(a) * d * 0.8 - 20 + 'px', '--c': colors[i % colors.length], '--dl': (i % 6) * 25 + 'ms' };
    }), [fx.id]);
    return (
      <div className="quad-burst" data-testid="quad-burst" data-rank={fx.rank} data-seat={fx.seat} aria-hidden="true"
           onAnimationEnd={(e) => { if (e.target.classList && e.target.classList.contains('quad-title')) onDone && onDone(fx.id); }}>
        <div className="quad-flash" />
        <div className="quad-ring" /><div className="quad-ring r2" />
        <div className="quad-cards">
          {fx.cards.map((c, i) => <Card key={c.id} card={c} style={{ '--i': i }} />)}
        </div>
        <div className="quad-sparks">{sparks.map((st, i) => <i key={i} style={st} />)}</div>
        <div className="quad-title">
          <span className="q-big">FOUR OF A KIND</span>
          <span className="q-sub">{[fx.rank, fx.rank, fx.rank, fx.rank].join(' · ')} — {name}</span>
        </div>
      </div>
    );
  }

  function EffectRibbon({ state }) {
    const f = useMemo(() => P.effectSummary(state), [state.actionSeq]);
    return (
      <div className="effect" role="status" aria-label="Current restriction">
        <span className="t">{f.title}</span>
        {f.joker
          ? <span className="joker-via" data-testid="joker-via" data-declared={f.joker.declared}>
              <JokerFace className="mini-joker" /> Joker <span aria-hidden="true">{'→'}</span> <b>{f.joker.declared}</b>
              {f.joker.mirrors && <em>mirrors {f.joker.mirrors}</em>}
            </span>
          : f.via && <span className="via">({f.via})</span>}
        {f.note && <span className="note">{f.note}</span>}
        <span className="ranks" aria-label={'Playable ranks: ' + f.allowed.join(', ')}>
          {f.allowed.map(r => <span key={r}>{r}</span>)}
        </span>
      </div>
    );
  }

  // ---------------------------------------------------------------------------
  // Seats
  // ---------------------------------------------------------------------------
  // Finished players show their finishing place (authoritative: player.finishedRank); the last one standing is the SheetHead.
  function PhaseTag({ phase, player, sheet }) {
    if (sheet) return <span className="tag sheethead" data-testid="tag-sheethead">SheetHead</span>;
    if (phase === 'OUT' && player) return <span className="tag out" data-testid="tag-out">{P.ordinal(player.finishedRank || 1)} out</span>;
    const cls = { HAND: 'hand', FACE_UP: 'up', FACE_DOWN: 'down' }[phase] || '';
    return <span className={cx('tag', cls)}>{PHASE_LABEL[phase] || phase}</span>;
  }

  // Countdown display. It only reads the authoritative deadline in state.turn; it never decides anything.
  function useCountdown(deadline) {
    const [now, setNow] = useState(() => Date.now());
    useEffect(() => {
      if (deadline == null) return undefined;
      setNow(Date.now());
      const h = setInterval(() => setNow(Date.now()), 100);
      return () => clearInterval(h);
    }, [deadline]);
    return deadline == null ? null : Math.max(0, deadline - now);
  }

  // Circular countdown around the active avatar; from 15 s the seconds appear as a large number.
  function TurnRing({ turn, isMe }) {
    const ms = useCountdown(turn.deadline);
    const total = Math.max(1, turn.deadline - turn.startedAt);
    const frac = Math.max(0, Math.min(1, ms / total));
    const secs = Math.ceil(ms / 1000);
    const tone = P.turnTone(secs);
    useEffect(() => {
      const rt = FX && FX.current;
      if (rt && rt.ready && rt.director) rt.director.setActiveSeat(turn.seat, tone);
    }, [tone, turn.seat, turn.id]);
    const say = isMe && [P.WARN_SECONDS, 10, 5, 3].includes(secs) ? `${secs} seconds left` : '';
    return (
      <>
        <svg className={cx('avatar-ring', tone)} viewBox="0 0 36 36" aria-hidden="true"
             data-testid="turn-ring" data-seat={turn.seat} data-turn={turn.id} data-seconds={secs} data-tone={tone}>
          <circle className="track" cx="18" cy="18" r="16" />
          <circle className="prog" cx="18" cy="18" r="16" pathLength="100" strokeDasharray="100"
                  strokeDashoffset={(100 * (1 - frac)).toFixed(2)} />
        </svg>
        {secs <= P.WARN_SECONDS && secs > 0 && (
          <div className="urgent" key={secs} data-testid="urgent-count" aria-hidden="true">{secs}</div>
        )}
        <span className="sr-only" role="status" aria-live="assertive">{say}</span>
      </>
    );
  }

  // `turn` is passed only to the seat whose turn it currently is, so only that avatar can show a countdown.
  // Persistent 1st-place mark: the crown belongs to the player the ENGINE recorded as the overall winner (first out). `fresh` only
  // plays the drop-in while the celebration is on; afterwards the crown just stays.
  function Crown({ fresh }) {
    return (
      <svg className={cx('crown', fresh && 'fresh')} viewBox="0 0 64 46" aria-hidden="true" data-testid="crown">
        <defs><linearGradient id="crG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff2b0" /><stop offset=".55" stopColor="#f1bf3c" /><stop offset="1" stopColor="#b7791a" /></linearGradient></defs>
        <path d="M6 36L2 10l16 12L32 4l14 18L62 10l-4 26z" fill="url(#crG)" stroke="#7a4a08" strokeWidth="3" strokeLinejoin="round" />
        <rect x="6" y="36" width="52" height="7" rx="2.5" fill="#d99a22" stroke="#7a4a08" strokeWidth="2.6" />
        <circle cx="2.5" cy="10" r="3.6" fill="#fff2b0" stroke="#7a4a08" strokeWidth="1.8" /><circle cx="32" cy="4" r="3.8" fill="#fff2b0" stroke="#7a4a08" strokeWidth="1.8" /><circle cx="61.5" cy="10" r="3.6" fill="#fff2b0" stroke="#7a4a08" strokeWidth="1.8" />
        <circle cx="32" cy="27" r="3.6" fill="#b81e2d" stroke="#7a4a08" strokeWidth="1.6" /><circle cx="18" cy="31" r="2.4" fill="#19e3c1" stroke="#7a4a08" strokeWidth="1.2" /><circle cx="46" cy="31" r="2.4" fill="#19e3c1" stroke="#7a4a08" strokeWidth="1.2" />
      </svg>
    );
  }
  function SkipBadge() {
    return (
      <span className="skip-badge" data-testid="seat-skip" role="img" aria-label="Skipped">
        <svg viewBox="0 0 24 24" width="60%" height="60%" aria-hidden="true"><path d="M5 5l9 7-9 7zM16 5v14" fill="none" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
      </span>
    );
  }
  function Avatar({ player, turn, isMe, crowned, jump, skipped }) {
    const av = P.avatarFor(player);
    return (
      <div className={cx('avatar-wrap', jump && 'jump', skipped && 'skipped')} style={{ '--hue': av.hue }} data-crowned={crowned ? '1' : undefined}>
        {crowned && <Crown fresh={jump} />}
        <div className="avatar" aria-hidden="true">{av.glyph}</div>
        {turn && turn.deadline != null && <TurnRing turn={turn} isMe={isMe} />}
        {skipped && <SkipBadge />}
        {jump && <i className="win-ring" aria-hidden="true" />}
      </div>
    );
  }

  // Red edge pulse on the whole table while it is MY turn and time is nearly up (never blocks input).
  function UrgencyVignette({ turn }) {
    const ms = useCountdown(turn.deadline);
    const secs = Math.ceil(ms / 1000);
    if (secs > P.WARN_SECONDS || secs <= 0) return null;
    return <div className="vignette" aria-hidden="true" />;
  }

  function TurnChip({ turn, name }) {
    const ms = useCountdown(turn.deadline);
    const secs = Math.ceil(ms / 1000);
    return (
      <span className={cx('chip turn-chip', P.turnTone(secs))} data-testid="turn-chip" title="Time left this turn">
        <span className="chip-name">{name}</span> <b>{secs}s</b>
      </span>
    );
  }

  // Face-down cards with the face-up cards layered on top of them.
  function Cluster({ faceDown, faceUp, me, renderFd, renderFu }) {
    const slots = Math.max(faceDown.length, faceUp.length);
    return (
      <div className={cx('cluster', me && 'me')}>
        {Array.from({ length: slots }, (_, i) => (
          <div className="slot" key={i}>
            {faceDown[i] && (renderFd ? renderFd(faceDown[i], i) : <Card faceDown className="fd" />)}
            {faceUp[i] && (renderFu ? renderFu(faceUp[i], i) : <Card card={faceUp[i]} className="fu" label={P.cardLabel(faceUp[i])} />)}
          </div>
        ))}
      </div>
    );
  }

  function BackFan({ count, cwo }) {
    const show = Math.min(count, 7);
    const step = Math.max(4, cwo * 0.26);
    if (show === 0) return null;
    return (
      <div className="fan" style={{ width: cwo + step * (show - 1) + (count > show ? 26 : 0) }} aria-hidden="true">
        {Array.from({ length: show }, (_, i) => <Card key={i} faceDown style={{ left: i * step }} />)}
        {count > show && <span className="more" style={{ left: cwo + step * (show - 1) + 4 }}>+{count - show}</span>}
      </div>
    );
  }

  function Seat({ player, pos, active, turn, layout, sheet, crowned, jump, skipped }) {
    const out = player.phase === 'OUT';
    const total = player.hand.length + player.faceUp.length + player.faceDown.length;
    return (
      <div className={cx('seat', 'pos-' + pos, active && 'active', out && 'out')} data-seat={player.seat}
           aria-label={`${player.name}: ${PHASE_LABEL[player.phase]}, ${total} cards${active ? ', their turn' : ''}`}>
        <div className="who">
          <Avatar player={player} turn={active && !out ? turn : null} crowned={crowned} jump={jump} skipped={skipped} />
          <div className="plate">
            <div className="name">{player.name}</div>
            <div className="sub">
              <PhaseTag phase={player.phase} player={player} sheet={sheet} />
              {!out && <span className="tag" title="Cards in hand">{player.hand.length}<span className="wide-only"> in hand</span></span>}
              {active && !out && <span className="tag turn">Turn</span>}
            </div>
          </div>
        </div>
        {!out && (
          <div className="stuff">
            <Cluster faceDown={player.faceDown} faceUp={player.faceUp} />
            <div className="fan-wrap"><BackFan count={player.hand.length} cwo={layout.cwOpp} /></div>
          </div>
        )}
      </div>
    );
  }

  // ---------------------------------------------------------------------------
  // Local player's area
  // ---------------------------------------------------------------------------
  // Fan of the local hand. Positions come from P.layoutHand (pure): the layout reacts to which cards are
  // selected, so the selected group keeps its neighbours readable, and the cards glide (CSS transition on left).
  function HandFan({ cards, cw, selectedIds, renderCard }) {
    const ref = useRef(null);
    const { w } = useMeasure(ref);
    const flags = cards.map(c => selectedIds.has(c.id));
    const lay = useMemo(() => P.layoutHand(flags, Math.max(0, w - 12), cw), [w, cw, flags.join('')]);
    return (
      <div className="hand-fan" ref={ref} data-scroll={lay.scroll ? '1' : undefined}>
        <div className="inner" style={{ width: lay.width + 12, paddingLeft: 6 }}>
          {cards.map((c, i) => renderCard(c, { left: lay.xs[i] + 6, zIndex: i + 1 }, lay.cw))}
        </div>
      </div>
    );
  }

  function MiniCards({ cards }) {
    return <span className="mini-cards">{cards.map(c => <Card key={c.id} card={c} />)}</span>;
  }

  // Same small preview as MiniCards, but each card is its own tap target: a single click plays THAT card immediately
  // (see onEligibleTap) -- the bolt badge marks it as instantly clickable, never as merely "selected".
  function BurstMiniCards({ cards, onTap }) {
    return (
      <span className="mini-cards mini-cards-pick">
        {cards.map(c => {
          return <Card key={c.id} card={c} interrupt
                       label={P.cardLabel(c) + ', click to interrupt'}
                       onClick={() => onTap(c.id)} />;
        })}
      </span>
    );
  }

  // Rank chooser for a Joker. The preview card shows exactly what will be placed: the same Card component
  // rendering the same `as` field the engine will store.
  function JokerPicker({ options, onPick, onClose, cine, dismissible = true }) {
    const [hover, setHover] = useState(null);
    const ref = useRef(null);
    useEffect(() => {
      if (!cine) return undefined;
      if (ref.current) ref.current.focus({ preventScroll: true });
      const onKey = (e) => { if (e.key === 'Escape' && dismissible) { e.stopPropagation(); onClose(); } };
      document.addEventListener('keydown', onKey);
      return () => { document.removeEventListener('keydown', onKey); cine.cancel(); };
    }, []);
    const show = (r) => { setHover(r); if (cine) cine.preview(r); };
    if (cine) {
      // The big Joker hangs in the middle of the table (Pixi); this sheet is the interactive part.
      return (
        <div className="joker-sheet" role="dialog" aria-modal="true" aria-label="Choose the Joker's rank" ref={ref} tabIndex={-1} data-testid="joker-sheet">
          <div className="js-head">
            <div>
              <div className="js-title">Choose the Joker's rank</div>
              <div className="js-sub" aria-live="polite">{hover ? <><b>{hover}</b>{JOKER_HINT[hover] ? ' \u00b7 ' + JOKER_HINT[hover] : ''}</> : 'Everyone sees it, and it decides what can be played next.'}</div>
            </div>
            {dismissible && <button type="button" className="js-x" onClick={onClose} aria-label="Cancel">{'\u2715'}</button>}
          </div>
          <div className="js-grid">
            {options.map(r => (
              <button key={r} type="button" className="js-rank" data-rank={r} onClick={() => onPick(r)}
                      onMouseEnter={() => show(r)} onFocus={() => show(r)} onMouseLeave={() => show(null)} onBlur={() => show(null)}>
                <span className="r">{r}</span>
                {JOKER_HINT[r] && <span className="h">{JOKER_HINT[r]}</span>}
              </button>
            ))}
          </div>
        </div>
      );
    }
    return (
      <Modal label="Choose the Joker's rank" onClose={onClose}>
        <div className="font-display text-xl font-bold mb-1 text-center">Joker: choose its rank</div>
        <div className="joker-preview" aria-hidden="true">
          <Card card={{ id: 'jpreview', rank: 'JOKER', suit: null, as: hover }} showDeclared />
        </div>
        <div className="text-white/60 text-sm mb-4 text-center">Everyone will see the rank you pick, and it decides what can be played next.</div>
        <div className="flex flex-wrap gap-2 justify-center">
          {options.map(r => (
            <Button key={r} variant="primary" onClick={() => onPick(r)}
                    onMouseEnter={() => setHover(r)} onFocus={() => setHover(r)} onMouseLeave={() => setHover(null)} onBlur={() => setHover(null)}
                    style={{ flexDirection: 'column', gap: 0, minWidth: 64, lineHeight: 1.15 }}>
              <span style={{ fontSize: 20 }}>{r}</span>
              {JOKER_HINT[r] && <span style={{ fontSize: 10, opacity: .8, fontWeight: 600 }}>{JOKER_HINT[r]}</span>}
            </Button>
          ))}
        </div>
        {dismissible && <div className="mt-4 text-center"><Button variant="ghost" onClick={onClose}>Cancel</Button></div>}
      </Modal>
    );
  }

  // TOURNAMENT ONLY. The previous winner picks 3 of their 6 private cards as FACE UP (the other 3 become the hand). This is a
  // real game state, not a modal: the 40 s clock keeps running, and a 4 burst by another seat settles it automatically
  // (the sheet then disappears because state.setup becomes null). It only submits the engine's CONFIRM_SETUP move.
  function SetupSheet({ pool, turn, onConfirm }) {
    const [sel, setSel] = useState([]);
    const ref = useRef(null);
    const ms = useCountdown(turn && turn.deadline != null ? turn.deadline : null);
    const secs = ms == null ? null : Math.ceil(ms / 1000);
    useEffect(() => { if (ref.current) ref.current.focus({ preventScroll: true }); }, []);
    const cards = useMemo(() => P.sortHand(pool), [pool]);
    const toggle = (id) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : s.length >= 3 ? s.slice(1).concat(id) : s.concat(id));
    const ready = sel.length === 3;
    const go = () => { if (ready) onConfirm(sel); };
    return (
      <div className="setup-sheet" role="dialog" aria-label="Choose your face-up cards" ref={ref} tabIndex={-1} data-testid="setup-sheet"
           onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }}>
        <div className="ss-head">
          <div>
            <div className="ss-kicker">Winner's advantage</div>
            <div className="ss-title">Choose 3 cards for your FACE UP</div>
            <div className="ss-sub">The other 3 stay in your hand. Only you can see these cards.</div>
          </div>
          {secs != null && <div className={cx('ss-timer', P.turnTone(secs))} data-testid="setup-timer" aria-live="off"><b>{secs}</b><span>sec</span></div>}
        </div>
        <div className="ss-cards" role="group" aria-label="Your six cards">
          {cards.map(c => {
            const on = sel.includes(c.id);
            return (
              <div key={c.id} className={cx('ss-slot', on && 'on')}>
                <Card card={c} selected={on} onClick={() => toggle(c.id)} label={P.cardLabel(c) + (on ? ', will be face up' : ', will stay in your hand')} style={{ '--cw': 'var(--setw)' }} />
                <span className="ss-tag">{on ? 'Face up' : 'Hand'}</span>
              </div>
            );
          })}
        </div>
        <div className="ss-foot">
          <span className="ss-count" aria-live="polite"><b>{sel.length}</b> of 3 chosen</span>
          <Button variant="teal" disabled={!ready} onClick={go}>Confirm face-up</Button>
        </div>
      </div>
    );
  }

  function TournamentBar({ t }) {
    const lead = Math.max.apply(null, t.scores);
    return (
      <div className="tbar" data-testid="tournament-bar" aria-label={`Tournament, first to ${t.targetWins}, game ${t.gameNumber}`}>
        <span className="tb-tag"><span className="tb-cup" aria-hidden="true"><Icon name="trophy" size={13}/></span><b>Tournament</b> <i>First to {t.targetWins}</i> <em>Game {t.gameNumber}</em></span>
        <ul className="tb-scores">
          {t.names.map((n, i) => <li key={i} className={cx(t.scores[i] === lead && lead > 0 && 'lead', i === t.humanSeat && 'me')}><span>{i === t.humanSeat ? 'You' : n}</span><b>{t.scores[i]}</b></li>)}
        </ul>
      </div>
    );
  }

  // ---------------------------------------------------------------------------
  // Game screen
  // ---------------------------------------------------------------------------
  function GameScreen({ gameSession, onGameEnd, back, settings }) {
    const { state: initial, humanSeat, mode, difficulty, tournament, remoteAdapter } = gameSession;
    const remote = useMemo(() => (remoteAdapter ? { adapter: remoteAdapter } : null), [remoteAdapter]);
    const rootRef = useRef(null);
    const { w, h } = useMeasure(rootRef);
    const L = useMemo(() => P.layoutForSize(w, h), [w, h]);
    // Friends mode (Phase 5C) never turns Pixi cinematics on: the flight/ghost/quad-burst choreography
    // (P.toPresentationEvents, the Pixi director) was designed and tested only against a LOCAL authority's full
    // before/after state and stable card ids. A remote-safe view intentionally omits ids on hidden cards and never
    // exposes a full authoritative `before`, so the cinematic path is unverified here and could leak/crash on
    // opponent hand or draw-pile transitions. Forcing the DOM fallback is the explicit, permitted "simplify/defer"
    // choice (CLAUDE.md Phase 5C §13) -- the DOM presentation is already a complete, independent rendering path
    // (see useFx's own header comment), so Friends games are fully playable, just without the Pixi show.
    const fxSettings = remote ? { ...settings, fx: false } : settings;
    const fx = useFx(rootRef, fxSettings, w + 'x' + h);
    const ctl = useGameController({ initial, humanSeat, settings, onGameEnd, fx, remote });
    const { state, submit, banners, ghost, quad, flash, notice, dealing, arriving, skipped, jump, settle, connectionState } = ctl;
    const seq = state.actionSeq;

    // Permanent record of the finished game, written the moment the authoritative state says FINISHED (not after the
    // celebration delay), by the authority that ran the game. Idempotent by game id. Only real app games set recordStats.
    useEffect(() => {
      if (state.status !== 'FINISHED' || !gameSession.recordStats || !ST) return;
      try {
        const venue = mode === 'online' ? 'online' : mode === 'friends' ? 'friends' : 'solo';
        const rec = ST.buildGameRecord(state, { seat: humanSeat, tally: ctl.tallyRef.current, startedAt: ctl.startedAtRef.current, endedAt: Date.now(), venue, difficulty,
                                                 tournament: tournament ? { id: tournament.id, gameNumber: tournament.gameNumber } : null });
        ST.local().addGame(rec);
      } catch (err) { console.error('SheetHead: could not record the game result', err); }
    }, [state.status]);

    const me = state.players[humanSeat];
    const playing = state.status === 'PLAYING';
    const myTurn = playing && !dealing && state.turnSeat === humanSeat;
    const current = state.players[state.turnSeat];

    const [selected, setSelected] = useState([]);
    const [picker, setPicker] = useState(null);
    const [confirmQuit, setConfirmQuit] = useState(false);
    // An interrupt is a reaction, not a workflow: no selection state to build up here. A click submits a real move
    // immediately (see onEligibleTap/interruptOne/interruptAll below). `addDismissed` is purely local UI: an explicit
    // "keep it" for the continuation callout, never an engine move.
    const [addDismissed, setAddDismissed] = useState(false);
    useEffect(() => { setSelected([]); setPicker(null); setAddDismissed(false); }, [seq]);

    const legal = useMemo(() => E.allLegalMoves(state, humanSeat), [seq]);
    const playable = useMemo(() => P.playableCardIds(state, humanSeat), [seq]);
    const interruptIds = useMemo(() => P.interruptCardIds(state, humanSeat), [seq]);
    // Own-turn empty-table HAND 4s (CLAUDE.md §21): the move is the same ordinary PLAY_FROM_HAND the player already
    // has -- this only adds the same visual language as an out-of-turn burst so it reads as "play this to burst and
    // keep your turn" instead of looking like any other card. Never set for FACE_UP (see presenter.js).
    const ownTurnBurst = useMemo(() => P.ownTurnBurstCardIds(state, humanSeat), [seq]);
    const eff = useMemo(() => P.effectSummary(state), [seq]);
    const fourOfKindMove = !myTurn ? legal.find(m => m.kind === 'INTERRUPT_FOUR_OF_KIND') : null;
    // Every legal burst QUANTITY (1..count of the player's real 4s); the largest entry names every card currently
    // eligible. A fresh opportunity and this player's own continuation never coexist (see engine.js), so at most one
    // of burstAllIds/addAllIds is ever non-empty.
    const burstMoves = !myTurn ? legal.filter(m => m.kind === 'BURST_FOUR') : [];
    const addMoves = !myTurn ? legal.filter(m => m.kind === 'BURST_FOUR_ADD') : [];
    const burstAllIds = burstMoves.length ? burstMoves[burstMoves.length - 1].cardIds : [];
    const addAllIds = addMoves.length ? addMoves[addMoves.length - 1].cardIds : [];
    const burstFamily = burstAllIds.length ? { kind: 'BURST_FOUR', allIds: burstAllIds }
                       : addAllIds.length ? { kind: 'BURST_FOUR_ADD', allIds: addAllIds }
                       : null;
    const stickMove = legal.find(m => m.kind === 'STICK_CARDS');
    const stickIds = useMemo(() => new Set(stickMove ? stickMove.cardIds : []), [seq]);
    const canPickup = legal.some(m => m.kind === 'PICK_UP_PILE');
    // Whether taking the pile has no real choice (CLAUDE.md §15: a chosen rank always takes every remaining face-up
    // card of that rank together, so 1 remaining card, or several of the very same rank, is already exactly one
    // legal move) is an engine/presenter fact, not a UI guess -- see P.noChoiceFailPickup.
    const canFailPickup = legal.some(m => m.kind === 'FACE_UP_FAIL_PICKUP');
    const noChoiceFailPickup = useMemo(() => P.noChoiceFailPickup(state, humanSeat), [seq]);
    // A revealed FACE_DOWN Joker awaiting its declaration: engine-held (turnSeat stays put, every other move from this
    // seat is refused), so -- unlike the client-side `picker` state below -- this is read straight from `legal` and
    // needs no local state of its own.
    const faceDownJokerRanks = useMemo(() => legal.filter(m => m.kind === 'DECLARE_FACE_DOWN_JOKER').map(m => m.asRank), [seq]);
    const inActionZone = myTurn && !stickMove && (me.phase === 'HAND' || me.phase === 'FACE_UP');

    // Display order only (engine order is untouched); selection layout follows what is currently selected.
    const sortedHand = useMemo(() => P.sortHand(me.hand), [me.hand]);
    const handSelected = useMemo(() => new Set(inActionZone && me.phase === 'HAND' ? selected : []), [selected, inActionZone, me.phase]);

    const analysis = useMemo(() => P.analyzeSelection(state, humanSeat, selected), [seq, selected]);

    // ---- intents ----
    const onToggle = (cardId) => {
      if (!inActionZone) return;
      const zone = P.zoneFor(me);
      const card = zone.find(c => c.id === cardId);
      const isJoker = card && card.rank === 'JOKER';
      if (!isJoker && !playable.has(cardId)) {
        const top = E.effectiveTop(state.tablePile);
        ctl.say(`${card.rank} cannot go on ${top === 'EMPTY' ? 'an empty table' : top}`);
        return;
      }
      setSelected(sel => P.toggleSelection(state, humanSeat, sel, cardId));
    };

    // Stick by double-click / double-tap. The candidate move is the engine's own STICK_CARDS move, so the
    // engine still validates it; the UI only recognises the gesture. Works only inside a real stick window.
    const lastTap = useRef({ id: null, t: 0 });
    const [armed, setArmed] = useState(null);
    useEffect(() => { lastTap.current = { id: null, t: 0 }; setArmed(null); }, [seq]);
    const onStickTap = (cardId) => {
      if (!stickMove) return;
      if (!stickIds.has(cardId)) { ctl.say('Only a glowing card can be stuck'); return; }
      const now = Date.now();
      if (lastTap.current.id === cardId && now - lastTap.current.t < 450) {
        lastTap.current = { id: null, t: 0 }; setArmed(null);
        submit(stickMove, seq);
        return;
      }
      lastTap.current = { id: cardId, t: now };
      setArmed(cardId);
      setTimeout(() => setArmed(a => (a === cardId ? null : a)), 450);
    };

    // ======================================================================================================
    // THE play action. PLAY button, Enter, double-click/double-tap and drag-and-drop all end up in runPlay(ids):
    // same analysis, same Joker/combo pickers, same engine submit (and therefore the same validation). The input
    // method only decides HOW the move is submitted, never whether it is legal.
    // Priority between gestures: (1) an active stick window (engine state) disables the play zone completely, so a
    // double-click during a stick opportunity can only ever be the stick gesture; (2) otherwise the gesture resolves
    // which ids it means (P.resolvePlayIds) and calls runPlay. Selection is optional: it only builds multi-card groups.
    // ======================================================================================================
    const playKind = me.phase === 'HAND' ? 'PLAY_FROM_HAND' : 'PLAY_FROM_FACE_UP';
    const busy = useRef(false);                                   // one submission per game state
    // `settle` also clears the lock: a rejected remote game-intent never changes `seq` (Phase 5B guarantees a
    // rejected action never mutates the safe view), so without it a remote rejection would leave input locked forever.
    useEffect(() => { busy.current = false; }, [seq, settle]);
    const lockedSubmit = (fn) => { busy.current = true; const ok = fn(); if (!ok) busy.current = false; return ok; };
    const submitPlay = (rank, ids) => submit({ kind: playKind, cardIds: ids, asRank: rank }, seq);
    const submitCombo = (rank, faceUpIds) => submit({ kind: 'HAND_FACEUP_COMBO', handCards: me.hand.map(c => c.id), faceUpCards: faceUpIds, asRank: rank }, seq);

    // The cinematic Joker (Pixi) hangs in the middle of the table while the rank is chosen. Presentation only.
    const getCine = () => { const rt = fx.rtRef.current; return rt && rt.ready && rt.director && FX.resolve().flights ? rt.director.cinema : null; };
    const startJokerPrompt = (ids) => {
      const cine = getCine(); if (!cine) return;
      const zone = P.zoneFor(me), jokers = ids.map(id => zone.find(c => c.id === id)).filter(c => c && c.rank === 'JOKER');
      if (!jokers.length) return;
      const rt = fx.rtRef.current, el = document.querySelector('[data-id="' + jokers[0].id + '"]');
      rt.rootRect = rt.root.getBoundingClientRect();
      try { cine.prompt(jokers, el ? rt.rel(el) : null); } catch (e) { console.error('SheetHead: joker prompt failed (game unaffected)', e); }
    };
    // returns 'submitted' | 'pending' (waiting for a Joker/combo choice) | 'rejected'
    const runPlay = (ids) => {
      if (!inActionZone || busy.current || picker) return 'rejected';
      const a = P.analyzeSelection(state, humanSeat, ids);
      if (!a.ok) { ctl.say(a.reason); return 'rejected'; }
      if (a.needsDeclaration) { setPicker({ type: 'joker', a, ids }); startJokerPrompt(ids); return 'pending'; }
      if (a.combo) { setPicker({ type: 'combo', a, ids, rank: a.rank, faceUp: [] }); return 'pending'; }
      return lockedSubmit(() => submitPlay(a.rank, ids)) ? 'submitted' : 'rejected';
    };
    // Out-of-turn double-click / double-tap on a hand or face-up card = the engine's own four-of-a-kind interrupt: a
    // fixed, forced set of cards with no choice involved, so a double-click always fires it outright. The MOVE is chosen
    // by P.interruptMoveFor from the engine's legal moves (so no rule lives here); a fire clears the tap log and swallows
    // the trailing click, and `busy` + the action seq stop any duplicate.
    const offTurnTap = useRef(null);
    useEffect(() => { offTurnTap.current = null; }, [seq]);
    const onInterruptTap = (cardId, ev) => {
      if (Date.now() < suppressClickUntil.current || busy.current || picker) return;
      const mv = P.interruptMoveFor(state, humanSeat, cardId);
      if (!mv) { offTurnTap.current = null; return; }
      const pos = ev && Number.isFinite(ev.clientX) ? { x: ev.clientX, y: ev.clientY } : null;
      const r = P.resolveInterruptTap(offTurnTap.current, cardId, Date.now(), null, pos);
      offTurnTap.current = r.log;
      if (r.kind === 'fire') { suppressClickUntil.current = Date.now() + 400; lockedSubmit(() => submit(mv, seq)); }
    };
    // A 4 interrupt/burst (and its continuation) is a REACTION, not a workflow: speed matters more than choice here.
    // A single click on an eligible 4 -- the callout's preview or the real card, both routes end up here -- submits
    // that ONE card immediately: no selection, no double-click, no confirmation. `interruptOne`/`interruptAll` back
    // the callout's two buttons for when clicking a specific card is slower than pressing a big obvious one. Using
    // more than one 4 without using all of them is still fully available: click one, then (for a fresh burst) use the
    // same-seat continuation window, or (during continuation) click again, stopping whenever the rest should be kept.
    const onEligibleTap = (cardId) => {
      if (busy.current) return;
      const mv = P.burstTapMoveFor(state, humanSeat, cardId);
      if (!mv) return;
      lockedSubmit(() => submit(mv, seq));
    };
    const interruptOne = () => {
      if (!burstFamily || busy.current) return;
      lockedSubmit(() => submit({ kind: burstFamily.kind, cardIds: [burstFamily.allIds[0]] }, seq));
    };
    const interruptAll = () => {
      if (!burstFamily || busy.current) return;
      lockedSubmit(() => submit({ kind: burstFamily.kind, cardIds: burstFamily.allIds.slice() }, seq));
    };
    // Own-turn empty-table HAND 4 (CLAUDE.md §21): the SAME PLAY_FROM_HAND the player already has, submitted through
    // the ordinary runPlay() pipeline (which already locks/validates) -- never a BURST_FOUR. "one" plays a single
    // real 4 (holding any others back is a deliberate, legal choice, exactly like the out-of-turn case); "all" plays
    // every currently-eligible real 4 together as one multi-card play.
    const ownTurnBurstOne = () => { if (ownTurnBurst.size) runPlay([[...ownTurnBurst][0]]); };
    const ownTurnBurstAll = () => { if (ownTurnBurst.size) runPlay([...ownTurnBurst]); };
    const onPlay = () => runPlay(selected);
    const onJokerPick = (rank) => {
      const { a, ids } = picker;
      if (a.combo && a.combo[rank]) { setPicker({ type: 'combo', a, ids, rank, faceUp: [] }); return; }
      const cine = getCine(); if (cine) cine.commit(rank);
      setPicker(null); lockedSubmit(() => submitPlay(rank, ids));
    };
    // The FACE_DOWN flip already committed (the card is revealed; the engine is holding the turn on this exact
    // decision) -- there is nothing left to cancel back to, so this is the one JokerPicker use with dismissible=false.
    const onFaceDownJokerPick = (rank) => {
      if (busy.current) return;
      lockedSubmit(() => submit({ kind: 'DECLARE_FACE_DOWN_JOKER', asRank: rank }, seq));
    };

    // ---- double-click / double-tap on a selected card plays the whole selection ----
    const tapLog = useRef(null);
    const suppressClickUntil = useRef(0);
    useEffect(() => { tapLog.current = null; }, [seq]);
    // One tap = one decision, made from the SAME selection the user sees. The double-click intent is resolved by
    // P.resolveTap (pure, unit-tested); a resolved double-click swallows the trailing click of the pair so it can never
    // toggle a card or start a second submission.
    const onCardTap = (cardId, ev) => {
      if (Date.now() < suppressClickUntil.current) return;        // the click that ends a drag / follows a resolved double-click
      if (!inActionZone) return;
      const pos = ev && Number.isFinite(ev.clientX) ? { x: ev.clientX, y: ev.clientY } : null;
      const r = P.resolveTap(tapLog.current, cardId, selected, Date.now(), null, pos);
      tapLog.current = r.log;
      if (r.kind === 'toggle') onToggle(cardId);
      else if (r.kind === 'play') { suppressClickUntil.current = Date.now() + 400; setSelected(r.restore); runPlay(r.ids); }
    };

    // ---- drag the selection onto the table pile (pointer events: mouse, pen and touch) ----
    const [drag, setDrag] = useState(null);
    const dragRef = useRef(null);
    const latest = useRef(null);
    latest.current = { state, selected, inActionZone, picker, runPlay, humanSeat, cwp: L.cwPile, fx, ghostsRef: ctl.ghostsRef };
    const dnd = useRef(null);
    if (!dnd.current) {
      const pileRect = () => { const el = document.querySelector('[data-drop="pile"]'); return el ? el.getBoundingClientRect() : null; };
      const pad = () => Math.max(40, latest.current.cwp * 0.6);
      const cleanup = () => {
        window.removeEventListener('pointermove', dnd.current.move);
        window.removeEventListener('pointerup', dnd.current.up);
        window.removeEventListener('pointercancel', dnd.current.cancel);
      };
      const fxRt = () => { const f = latest.current.fx; const rt = f && f.rtRef.current; return rt && rt.ready && rt.stage ? rt : null; };
      // the cards glide back to where they were (or, on the DOM fallback, animate back)
      const giveBack = (d) => {
        const rt = d && d.fx ? fxRt() : null;
        if (rt) {
          const targets = {}; d.ids.forEach(id => { const r = rt.director.domRect(id); if (r) targets[id] = r; });
          rt.stage.dragReturn(targets);
          setDrag(cur => cur && ({ ...cur, phase: 'return', hot: false }));
        } else {
          setDrag(cur => cur && ({ ...cur, phase: 'return', hot: false, items: cur.items.map(it => {
            const el = document.querySelector('[data-id="' + it.id + '"]');
            const r = el ? el.getBoundingClientRect() : null;
            return r ? { ...it, x: r.left, y: r.top, rot: 0 } : it;
          }) }));
        }
        setTimeout(() => setDrag(null), 420);
      };
      dnd.current = {
        move(e) {
          const d = dragRef.current; if (!d) return;
          const cur = latest.current;
          if (!d.started) {
            if (Math.hypot(e.clientX - d.x0, e.clientY - d.y0) < 7) return;
            const rects = d.ids.map(id => { const el = document.querySelector('[data-id="' + id + '"]'); if (!el) return null; const r = el.getBoundingClientRect(); return { id, left: r.left, top: r.top, w: r.width, h: r.height }; })
              .filter(Boolean).sort((p, q) => p.left - q.left);
            const gi = rects.findIndex(r => r.id === d.id);
            if (gi < 0) { cleanup(); dragRef.current = null; return; }
            const zone = P.zoneFor(cur.state.players[cur.humanSeat]);
            const an = P.analyzeSelection(cur.state, cur.humanSeat, d.ids);
            Object.assign(d, { started: true, rects, gi, offX: d.x0 - rects[gi].left, offY: d.y0 - rects[gi].top, ok: an.ok, reason: an.reason || '', cards: Object.fromEntries(rects.map(r => [r.id, zone.find(c => c.id === r.id)])), hot: false });
            suppressClickUntil.current = Date.now() + 1e9;
            const rt = fxRt();
            if (rt) {
              const rr = rt.root.getBoundingClientRect();
              d.fx = true;
              rt.stage.dragBegin(rects.map(r => ({ id: r.id, card: d.cards[r.id], x: r.left - rr.left, y: r.top - rr.top, w: r.w, h: r.h })), gi, e.clientX - rr.left, e.clientY - rr.top, d.offX, d.offY);
              setDrag({ phase: 'drag', hot: false, ok: d.ok, reason: d.reason, ids: d.ids, fx: true, items: [] });
            }
          }
          const hot = P.pointInRect(e.clientX, e.clientY, pileRect(), pad());
          if (d.fx) {
            const rt = fxRt();
            if (rt) {
              const rr = rt.root.getBoundingClientRect();
              rt.stage.dragMove(e.clientX - rr.left, e.clientY - rr.top, hot, d.ok);
              if (hot !== d.hot) { d.hot = hot; setDrag(c => c && ({ ...c, hot })); }
            }
            return;
          }
          const pos = P.ghostPositions(d.rects, d.gi, e.clientX, e.clientY, d.offX, d.offY);
          setDrag({ phase: 'drag', hot, ok: d.ok, reason: d.reason, ids: d.ids, items: pos.map(p => ({ ...p, card: d.cards[p.id] })) });
        },
        up(e) {
          const d = dragRef.current; dragRef.current = null; cleanup();
          if (!d || !d.started) return;
          suppressClickUntil.current = Date.now() + 350;
          const rt = d.fx ? fxRt() : null;
          if (P.pointInRect(e.clientX, e.clientY, pileRect(), pad())) {
            // hand the ghosts' exact positions to the presentation of the accepted move so the card flies on from there
            if (rt) latest.current.ghostsRef.current = rt.stage.dragPeek();
            const res = latest.current.runPlay(d.ids);
            if (res === 'submitted') {
              if (rt) { rt.stage.endDragSprites(); setDrag(null); return; }
              const r = pileRect();
              setDrag(cur => cur && ({ ...cur, phase: 'drop', hot: false, target: r ? { x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 } : null }));
              setTimeout(() => setDrag(null), 300);
              return;
            }
            if (rt) latest.current.ghostsRef.current = null;
          }
          giveBack(d);
        },
        cancel() {
          const d = dragRef.current; dragRef.current = null; cleanup();
          if (d && d.started) { suppressClickUntil.current = Date.now() + 350; giveBack(d); }
        },
        abort(d) { cleanup(); if (d && d.started) giveBack(d); }
      };
    }
    useEffect(() => () => {
      window.removeEventListener('pointermove', dnd.current.move);
      window.removeEventListener('pointerup', dnd.current.up);
      window.removeEventListener('pointercancel', dnd.current.cancel);
    }, []);
    // if the game moves on mid-drag (a bot interrupts, the timer fires) the drag is abandoned
    useEffect(() => {
      const d = dragRef.current;
      if (d) { dragRef.current = null; dnd.current.abort(d); }
      else setDrag(cur => (cur && cur.phase === 'drag' ? null : cur));
    }, [seq]);
    const onCardPointerDown = (e, cardId) => {
      const cur = latest.current;
      if (!cur.inActionZone || cur.picker) return;
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      // any card can be dragged: a selected card takes the whole selection along, an unselected one goes alone
      dragRef.current = { id: cardId, x0: e.clientX, y0: e.clientY, started: false, ids: P.resolvePlayIds(cur.selected, cardId) };
      window.addEventListener('pointermove', dnd.current.move);
      window.addEventListener('pointerup', dnd.current.up);
      window.addEventListener('pointercancel', dnd.current.cancel);
    };
    const dragIds = drag && drag.phase !== 'drop' ? new Set(drag.ids || drag.items.map(i => i.id)) : null;
    const dropState = drag && drag.phase === 'drag' ? { hot: drag.hot, ok: drag.ok, reason: drag.reason } : null;

    useEffect(() => {
      const onKey = (e) => {
        if (picker || confirmQuit || e.target !== document.body) return;
        if (e.key === 'Enter' && selected.length) { e.preventDefault(); onPlay(); }
        if (e.key === 'Escape') setSelected([]);
      };
      document.addEventListener('keydown', onKey);
      return () => document.removeEventListener('keydown', onKey);
    });

    // ---- derived view data ----
    const seatFor = (pos) => state.players[P.seatAtPosition(pos, humanSeat)];
    const style = {
      '--hdr': L.headerH + 'px', '--cwh': L.cw + 'px', '--cwo': L.cwOpp + 'px', '--cwp': L.cwPile + 'px',
      '--cwc': L.cwCluster + 'px', '--avatar': L.avatar + 'px', '--avatarme': Math.round(L.avatar * 0.8) + 'px'
    };
    // tournament setup (state-driven: appears while state.setup exists for me, vanishes the moment it is settled by any path)
    const setupMine = playing && state.setup && state.setup.seat === humanSeat && !dealing ? state.setup : null;
    const setupOther = playing && state.setup && state.setup.seat !== humanSeat ? state.players[state.setup.seat] : null;
    const status = dealing ? 'Dealing the cards\u2026' : setupMine ? 'Choose 3 of your 6 cards to be your FACE UP.' : !playing ? (state.sheetHeadSeat != null ? (state.sheetHeadSeat === humanSeat ? 'You are the SheetHead!' : `${state.players[state.sheetHeadSeat].name} is the SheetHead`)
                 : state.winner === humanSeat ? 'You won the match!' : `${state.players[state.winner].name} won the match`)
      : me.phase === 'OUT' ? `You finished ${P.ordinal(me.finishedRank || 1)}. Watching the rest of the game.`
      : stickMove ? 'Double-click (or double-tap) a glowing card to stick it.'
      : myTurn ? (faceDownJokerRanks.length > 0 ? 'A Joker! Choose the rank it represents.'
                 : canPickup ? 'No playable card. Pick up the pile.'
                 : canFailPickup ? 'No face-up card fits. Take the pile with a face-up rank.'
                 : me.phase === 'FACE_DOWN' ? 'Pick a face-down card to flip.'
                 : '')
      : '';                                              // no running commentary: the turn ring and animations say whose turn it is

    const jokerOptions = picker && picker.type === 'joker' ? picker.a.declarations : [];

    // Mandatory pickup, straight from the ENGINE's legal moves (never from how the pile looks): with no legal response the pile
    // becomes the red "take me" control. A face-up player still chooses which face-up rank goes with it (existing dialog).
    const pileMode = myTurn && !stickMove && !picker ? (canPickup ? 'take' : canFailPickup ? 'choose' : null) : null;
    const onPilePickup = () => {
      if (!pileMode || busy.current) return;
      if (pileMode === 'take') lockedSubmit(() => submit({ kind: 'PICK_UP_PILE' }, seq));
      else if (noChoiceFailPickup) lockedSubmit(() => submit(noChoiceFailPickup, seq));
      else setPicker({ type: 'fail' });
    };
    const onPileFan = useCallback((on) => { const rt = fx.rtRef.current; if (rt && rt.ready && rt.director) rt.director.setPileFan(on); }, []);

    return (
      <div ref={rootRef} className={cx('gs', fx.active && 'pixi-on', dealing && 'dealing', flash && 'flash-illegal', quad && 'quad-on', (settings.reducedMotion || settings.animations === false) && 'reduce-motion')}
           data-family={L.family} data-mode={L.mode} style={style}>
        <header className="gs-top">
          <button className="btn btn-ghost" style={{ minHeight: 40, padding: '.4rem .8rem' }} onClick={() => setConfirmQuit(true)}>
            <Icon name="back" size={16} /> <span>Quit</span>
          </button>
          <div className={cx('title', tournament && 'tourney')}>
            {Logo && <Logo variant="small" size={22} className="title-mark" />}
            <span className="title-row">
              {tournament && <span className="title-cup" aria-hidden="true"><Icon name="trophy" size={17} /></span>}
              <span className="title-word">{tournament ? 'Tournament' : mode === 'single' ? 'Single Player' : 'Match'}</span>
            </span>
            <small>{tournament ? `First to ${tournament.targetWins} \u00b7 Game ${tournament.gameNumber}` : difficulty ? difficulty.charAt(0).toUpperCase() + difficulty.slice(1) + ' bots' : ''}</small>
          </div>
          <div className="chips">
            {remote && (connectionState === 'reconnecting' || connectionState === 'offline') && (
              <span className="chip warn" role="status" data-testid="connection-chip">
                <Icon name="cw" size={14} /> {connectionState === 'reconnecting' ? 'Reconnecting…' : 'Offline'}
              </span>
            )}
            {playing && state.turn && state.turn.deadline != null && <TurnChip turn={state.turn} name={myTurn ? 'You' : current.name} />}
            <span className="chip" title="Play direction"><Icon name="cw" size={14} /> {L.mode === 'phone' ? '' : 'Clockwise'}</span>
            <span className="chip" title="Draw pile"><Icon name="draw" size={14} /> {state.drawPile.length}</span>
            <span className="chip" title="Burned cards"><Icon name="flame" size={14} /> {state.burnPile.length}</span>
          </div>
        </header>

        {tournament && <TournamentBar t={{ ...tournament, humanSeat }} />}
        {myTurn && state.turn && state.turn.deadline != null && <UrgencyVignette turn={state.turn} />}
        <div className="gs-stage">
          <div className={cx('game-table', myTurn && 'my-turn')}>
            <div className="rail" />
            <div className="felt-top" />
            <div className="brand" aria-hidden="true">SHEETHEAD</div>
            <div className="table-center">
              <div className="piles">
                <DrawPile count={state.drawPile.length} />
                <TablePile cards={state.tablePile} ghost={ghost} humanSeat={humanSeat} cwp={L.cwPile} maxShown={L.mode === 'phone' ? 5 : 8} drop={dropState}
                           mirror={eff.mirror} pickup={pileMode} onPickup={onPilePickup} onFan={onPileFan} seq={seq} />
                <BurnPile count={state.burnPile.length} />
              </div>
              <EffectRibbon state={state} />
              {quad && <QuadBurst key={quad.id} fx={quad} onDone={ctl.endQuad} name={quad.seat === humanSeat ? 'You' : state.players[quad.seat].name} />}
            </div>
          </div>

          {['top', 'left', 'right'].map(pos => {
            const p = seatFor(pos);
            return <Seat key={pos} player={p} pos={pos} layout={L} sheet={state.sheetHeadSeat === p.seat} active={playing && state.turnSeat === p.seat}
                         crowned={state.overallWinnerSeat === p.seat} jump={!!(jump && jump.seat === p.seat)} skipped={!!(skipped && skipped.ids.includes(p.seat))}
                         turn={playing && state.turn && state.turn.seat === p.seat ? state.turn : null} />;
          })}

          <div className="banners" role="status" aria-live="polite">
            {banners.map(b => <div key={b.id} className={cx('banner', b.tone)}>{b.text}</div>)}
          </div>
          {playing && !myTurn && setupOther && (
            <div className="turn-note" aria-hidden="true">{`${setupOther.name} is choosing their FACE UP cards`} <span className="dots"><i /><i /><i /></span></div>
          )}
        </div>

        <section className={cx('gs-me', myTurn && 'my-turn')} aria-label="Your cards">
          <div className="callouts">
          {fourOfKindMove && (
            <div className="callout interrupt" role="alert">
              <span>Four of a kind! Double-click a glowing card, or press the button.</span>
              <MiniCards cards={fourOfKindMove.cardIds.map(id => P.zoneFor(me).find(c => c.id === id)).filter(Boolean)} />
              <Button variant="amber" icon={<Icon name="bolt" size={16} />} onClick={() => submit(fourOfKindMove, seq)}>Interrupt!</Button>
            </div>
          )}
          {burstFamily && burstFamily.kind === 'BURST_FOUR' && (
            <div className="callout interrupt" role="alert" data-testid="burst-callout">
              <span>
                {burstFamily.allIds.length > 1
                  ? 'Empty table: click a 4 to interrupt instantly with just that one.'
                  : 'Empty table: click your 4 to interrupt instantly, or press the button.'}
              </span>
              <BurstMiniCards cards={burstFamily.allIds.map(id => P.zoneFor(me).find(c => c.id === id)).filter(Boolean)} onTap={onEligibleTap} />
              {burstFamily.allIds.length > 1 ? (
                <div className="interrupt-actions">
                  <Button variant="amber" icon={<Icon name="bolt" size={16} />} onClick={interruptOne}>Interrupt</Button>
                  <Button variant="coral" onClick={interruptAll}>Interrupt all {burstFamily.allIds.length}</Button>
                </div>
              ) : (
                <Button variant="amber" icon={<Icon name="bolt" size={16} />} onClick={interruptOne}>Interrupt!</Button>
              )}
            </div>
          )}
          {burstFamily && burstFamily.kind === 'BURST_FOUR_ADD' && !addDismissed && (
            <div className="callout stick" role="alert" data-testid="burst-add-callout">
              <span>
                <b className="stick-head">You can add another 4</b>{' '}
                {burstFamily.allIds.length > 1 ? `Click one to add it instantly, before ${current.name} plays` : `Click it to add instantly, before ${current.name} plays`}
                {' — or keep '}{burstFamily.allIds.length > 1 ? 'them' : 'it'} in your hand.
              </span>
              <BurstMiniCards cards={burstFamily.allIds.map(id => P.zoneFor(me).find(c => c.id === id)).filter(Boolean)} onTap={onEligibleTap} />
              {burstFamily.allIds.length > 1 ? (
                <div className="interrupt-actions">
                  <Button variant="teal" onClick={interruptOne}>Add</Button>
                  <Button variant="teal" onClick={interruptAll}>Add all {burstFamily.allIds.length}</Button>
                </div>
              ) : (
                <Button variant="teal" onClick={interruptOne}>Add it</Button>
              )}
              <Button variant="ghost" onClick={() => setAddDismissed(true)}>Keep it</Button>
            </div>
          )}
          {/* Own turn, empty table, a real 4 in HAND: the SAME PLAY_FROM_HAND the player already has already carries
              the burst/extra-turn effect (engine.js) -- this only borrows the off-turn burst's visual language (the
              bolt glow, the mini-cards and the Interrupt button(s)) so it reads as "play this to burst" instead of
              looking like any other card. No new move is submitted anywhere here: every path (tapping a mini-card,
              clicking the glowing hand card, or pressing Interrupt) ends at the same runPlay() -> PLAY_FROM_HAND. */}
          {ownTurnBurst.size > 0 && !stickMove && selected.length === 0 && (
            <div className="callout interrupt" role="status" data-testid="own-turn-burst-callout">
              <span>
                {ownTurnBurst.size > 1
                  ? 'Empty table: click a 4 to burst instantly with just that one.'
                  : 'Empty table: click your 4 to burst instantly, or press the button.'}
              </span>
              <span className="mini-cards mini-cards-pick">
                {[...ownTurnBurst].map(id => me.hand.find(c => c.id === id)).filter(Boolean).map(c => (
                  <Card key={c.id} card={c} interrupt label={P.cardLabel(c) + ', click to burst and keep your turn'} onClick={() => runPlay([c.id])} />
                ))}
              </span>
              {ownTurnBurst.size > 1 ? (
                <div className="interrupt-actions">
                  <Button variant="amber" icon={<Icon name="bolt" size={16} />} onClick={ownTurnBurstOne}>Interrupt</Button>
                  <Button variant="coral" onClick={ownTurnBurstAll}>Interrupt all {ownTurnBurst.size}</Button>
                </div>
              ) : (
                <Button variant="amber" icon={<Icon name="bolt" size={16} />} onClick={ownTurnBurstOne}>Interrupt!</Button>
              )}
            </div>
          )}
          {inActionZone && selected.length >= 2 && analysis.ok && (
            <div className="callout group" data-testid="group-chip" aria-live="polite">
              <b>{selected.length} {'\u00d7'} {analysis.needsDeclaration ? 'Joker' : analysis.rank}</b>
              <span>selected as one play. Press Play or double-click one of them to play them together.</span>
            </div>
          )}
          {stickMove && (
            <div className="callout stick" role="alert" data-testid="stick-callout">
              <span>
                <b className="stick-head">{stickMove.cardIds.length > 1 ? `Stick ${stickMove.cardIds.length} more ${state.stickWindow.rank}s` : `Stick another ${state.stickWindow.rank}`}</b>
                {' '}You drew {stickMove.cardIds.length > 1 ? 'matching cards' : 'a matching card'}. Double-click {stickMove.cardIds.length > 1 ? 'a glowing card' : 'the glowing card'} (or press the button) to add {stickMove.cardIds.length > 1 ? 'them' : 'it'} to your play before the next player acts.
              </span>
              <MiniCards cards={stickMove.cardIds.map(id => me.hand.find(c => c.id === id)).filter(Boolean)} />
              <Button variant="teal" onClick={() => submit(stickMove, seq)}>{stickMove.cardIds.length > 1 ? 'Stick them' : 'Stick it'}</Button>
            </div>
          )}
          </div>

          <div className="me-grid">
            <div className="me-plate" data-seat={humanSeat}>
              <Avatar player={me} isMe turn={myTurn && state.turn && state.turn.seat === humanSeat ? state.turn : null} crowned={state.overallWinnerSeat === humanSeat} jump={!!(jump && jump.seat === humanSeat)} skipped={!!(skipped && skipped.ids.includes(humanSeat))} />
              <div>
                <div style={{ font: '700 14px/1.1 Inter, sans-serif' }}>{me.name}</div>
                <div className="sub" style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
                  <PhaseTag phase={me.phase} player={me} sheet={state.sheetHeadSeat === humanSeat} />
                  {me.phase !== 'OUT' && <span className="tag">{me.hand.length + me.faceUp.length + me.faceDown.length} cards</span>}
                  {myTurn && <span className="tag turn">Your turn</span>}
                </div>
              </div>
            </div>
            <div className="me-status" role="status" aria-live="polite">
              {notice ? <strong style={{ color: '#ffd29b' }}>{notice.text}</strong> : status}
            </div>

            {/* One tray, two depth layers on the same horizontal area: the later-phase cards sit behind, the hand in front. */}
            <div className={cx('my-stack', me.hand.length === 0 && 'hand-empty', 'phase-' + me.phase.toLowerCase())} aria-label="Your cards">
              {(me.faceUp.length > 0 || me.faceDown.length > 0) && (
                <div className={cx('reserve', (me.phase === 'FACE_UP' || me.phase === 'FACE_DOWN') && 'active')}>
                  <div className="layer-cap">
                    {me.faceUp.length > 0 && <span className="tag up">Face-up {me.faceUp.length}</span>}
                    {me.faceDown.length > 0 && <span className="tag down">Face-down {me.faceDown.length}</span>}
                    {(me.phase === 'FACE_UP' || me.phase === 'FACE_DOWN') ? <span className="tag turn">Active</span> : <span className="tag">Later</span>}
                  </div>
                  <Cluster me faceDown={me.faceDown} faceUp={me.faceUp}
                    renderFd={(c, i) => {
                      const can = myTurn && me.phase === 'FACE_DOWN' && faceDownJokerRanks.length === 0;
                      return <Card faceDown className={cx('fd', can && 'ring-pulse', arriving.has('fd' + i) && 'is-arriving')} label={can ? `Flip face-down card ${i + 1}` : 'Face-down card'}
                                   onClick={can ? () => submit({ kind: 'FLIP_FACE_DOWN', cardIndex: i }, seq) : undefined}
                                   dim={me.phase === 'HAND'} />;
                    }}
                    renderFu={(c) => {
                      const can = inActionZone && me.phase === 'FACE_UP';
                      const ok = playable.has(c.id) || c.rank === 'JOKER';
                      return <Card card={c} className={cx('fu', dragIds && dragIds.has(c.id) && 'is-drag-src', arriving.has(c.id) && 'is-arriving')} label={P.cardLabel(c) + (can ? (ok ? ', playable' : ', not playable') : '')}
                                   selected={can ? selected.includes(c.id) : undefined} legal={can && ok && !selected.includes(c.id)}
                                   illegal={can && !ok}
                                   interrupt={interruptIds.has(c.id)} dim={me.phase === 'HAND'}
                                   onPointerDown={can ? (e) => onCardPointerDown(e, c.id) : undefined}
                                   onClick={can ? (e) => onCardTap(c.id, e) : fourOfKindMove ? (e) => onInterruptTap(c.id, e) : (burstFamily && burstFamily.allIds.includes(c.id)) ? () => onEligibleTap(c.id) : undefined} />;
                    }} />
                </div>
              )}
              {me.hand.length > 0 && (
                <div className="hand-layer">
                  <div className="layer-cap">
                    <span className="tag hand">Hand {me.hand.length}</span>
                    {me.phase === 'HAND' && <span className="tag turn">Active</span>}
                  </div>
                  <HandFan cards={sortedHand} cw={L.cw} selectedIds={handSelected} renderCard={(c, pos, cwEff) => {
                    const can = inActionZone && me.phase === 'HAND';
                    const ok = playable.has(c.id) || (c.rank === 'JOKER' && playable.size > 0);
                    const isSel = can && selected.includes(c.id);
                    const stuck = stickIds.has(c.id);
                    const bursts = ownTurnBurst.has(c.id);
                    return <Card key={c.id} card={c} size={cwEff}
                                 style={{ ...pos, zIndex: pos.zIndex + (isSel ? 100 : 0) + (stuck ? 50 : 0) }}
                                 label={P.cardLabel(c) + (can ? (ok ? ', playable' : ', not playable') : '') + (isSel ? ', selected' : '') + (stuck ? ', can be stuck: double-click' : '') + (bursts ? ', plays as a burst: you keep your turn' : '')}
                                 selected={can ? isSel : undefined} legal={can && ok && !isSel}
                                 illegal={can && !ok} interrupt={interruptIds.has(c.id) || bursts}
                                 stick={stuck} armed={armed === c.id} dim={!!stickMove && !stuck}
                                 className={cx(dragIds && dragIds.has(c.id) && 'is-drag-src', arriving.has(c.id) && 'is-arriving', isSel && selected.length >= 2 && 'in-group')}
                                 onPointerDown={can ? (e) => onCardPointerDown(e, c.id) : undefined}
                                 onClick={can ? (e) => onCardTap(c.id, e) : (stickMove ? () => onStickTap(c.id) : fourOfKindMove ? (e) => onInterruptTap(c.id, e) : (burstFamily && burstFamily.allIds.includes(c.id)) ? () => onEligibleTap(c.id) : undefined)} />;
                  }} />
                </div>
              )}
            </div>

            <div className="actions">
              {inActionZone && selected.length > 0 && (
                <Button variant="teal" onClick={onPlay} icon={<Icon name="play" size={14} />}>
                  {analysis.needsDeclaration ? `Play Joker${selected.length > 1 ? 's' : ''}` : `Play ${selected.length} × ${analysis.rank || ''}`}
                </Button>
              )}
              {inActionZone && selected.length > 0 && (
                <Button variant="ghost" onClick={() => setSelected([])}>Clear</Button>
              )}
              {myTurn && canPickup && !stickMove && (
                <Button variant="coral" onClick={() => submit({ kind: 'PICK_UP_PILE' }, seq)}>Pick up pile ({state.tablePile.length})</Button>
              )}
              {myTurn && canFailPickup && !stickMove && (
                <Button variant="coral" onClick={() => noChoiceFailPickup ? lockedSubmit(() => submit(noChoiceFailPickup, seq)) : setPicker({ type: 'fail' })}>Take the pile</Button>
              )}
            </div>
          </div>
        </section>

        {drag && !drag.fx && (
          <div className={cx('drag-layer', 'phase-' + drag.phase, drag.hot && !drag.ok && 'bad')} aria-hidden="true" data-testid="drag-layer">
            {drag.items.map((it, k) => {
              const t = drag.phase === 'drop' && drag.target ? { x: drag.target.x - it.w / 2 + (k - (drag.items.length - 1) / 2) * 8, y: drag.target.y - it.h / 2, rot: 0 } : it;
              return <Card key={it.id} card={it.card} size={it.w} className="drag-ghost"
                           style={{ transform: `translate(${t.x}px, ${t.y}px) rotate(${t.rot}deg) scale(${drag.phase === 'drop' ? 0.72 : 1.06})`, zIndex: k + 1 }} />;
            })}
          </div>
        )}

        {setupMine && (
          <SetupSheet pool={setupMine.pool} turn={state.turn} onConfirm={(ids) => submit({ kind: 'CONFIRM_SETUP', faceUpIds: ids }, seq)} />
        )}

        {picker && picker.type === 'joker' && (
          <JokerPicker options={jokerOptions} onPick={onJokerPick} onClose={() => setPicker(null)} cine={getCine()} />
        )}

        {faceDownJokerRanks.length > 0 && (
          <JokerPicker options={faceDownJokerRanks} onPick={onFaceDownJokerPick} onClose={() => {}} cine={null} dismissible={false} />
        )}

        {picker && picker.type === 'combo' && (
          <Modal label="Hand and face-up combo" onClose={() => setPicker(null)}>
            <div className="font-display text-xl font-bold mb-1">Play face-up {picker.rank}s too?</div>
            <div className="text-white/60 text-sm mb-4">
              Your draw pile is empty and your whole hand is {picker.rank}. Add matching face-up cards to play them in the same move, or just play your hand.
            </div>
            <div className="flex flex-wrap gap-3 justify-center mb-4" style={{ ['--cw']: '58px' }}>
              {picker.a.combo[picker.rank].map(c => (
                <Card key={c.id} card={c} selected={picker.faceUp.includes(c.id)} label={P.cardLabel(c)}
                      onClick={() => setPicker(pk => ({ ...pk, faceUp: pk.faceUp.includes(c.id) ? pk.faceUp.filter(x => x !== c.id) : pk.faceUp.concat(c.id) }))} />
              ))}
            </div>
            <div className="flex flex-wrap gap-2 justify-center">
              <Button variant="teal" disabled={picker.faceUp.length === 0} onClick={() => { const pk = picker; setPicker(null); lockedSubmit(() => submitCombo(pk.rank, pk.faceUp)); }}>
                Play combo ({picker.faceUp.length} face-up)
              </Button>
              <Button variant="primary" onClick={() => { const { rank, ids } = picker; setPicker(null); lockedSubmit(() => submitPlay(rank, ids)); }}>Hand only</Button>
              <Button variant="ghost" onClick={() => setPicker(null)}>Cancel</Button>
            </div>
          </Modal>
        )}

        {picker && picker.type === 'fail' && (
          <Modal label="Take the pile" onClose={() => setPicker(null)}>
            <div className="font-display text-xl font-bold mb-1">Take the pile</div>
            <div className="text-white/60 text-sm mb-4">
              No face-up card can be played. Choose one face-up rank: every face-up card of that rank and the whole pile go to your hand.
            </div>
            <div className="flex flex-wrap gap-4 justify-center" style={{ ['--cw']: '56px' }}>
              {[...new Set(me.faceUp.map(c => c.rank))].map(r => (
                <button key={r} className="btn btn-ghost" style={{ flexDirection: 'column', padding: '.6rem .8rem', height: 'auto' }}
                        onClick={() => { setPicker(null); submit({ kind: 'FACE_UP_FAIL_PICKUP', chosenRank: r }, seq); }}
                        aria-label={`Take rank ${r}`}>
                  <span className="inline-flex" style={{ gap: 4 }}>{me.faceUp.filter(c => c.rank === r).map(c => <Card key={c.id} card={c} />)}</span>
                  <span style={{ marginTop: 6, fontSize: 12 }}>Take {me.faceUp.filter(c => c.rank === r).length} × {r}</span>
                </button>
              ))}
            </div>
            <div className="mt-4 text-center"><Button variant="ghost" onClick={() => setPicker(null)}>Cancel</Button></div>
          </Modal>
        )}

        {confirmQuit && (
          <Modal label="Leave the match" onClose={() => setConfirmQuit(false)}>
            <div className="font-display text-xl font-bold mb-2">Leave the match?</div>
            <div className="text-white/60 text-sm mb-4">{remote ? 'The game keeps going without you. Come back any time to rejoin.' : 'The current game will be lost.'}</div>
            <div className="flex gap-2 justify-center">
              <Button variant="coral" onClick={back}>Leave</Button>
              <Button variant="ghost" onClick={() => setConfirmQuit(false)}>Keep playing</Button>
            </div>
          </Modal>
        )}
      </div>
    );
  }

  window.SH.ui.GameScreen = GameScreen;
})();
