"""Lost Cities — ship-quality single-match table GUI.

Launches straight into a match against the final PPO candidate: no menus,
no mode pickers.  You are the bottom seat; the model is the top seat.

    uv run lost-cities-play

All card art is procedural (no image assets): ivory faces with per-suit
glyphs, a compass-rose card back, soft shadows and animated card flights.
"""

from __future__ import annotations

import argparse
import logging
import math
import random
import sys
from collections import Counter
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any

import pygame

from .bots import build_bot
from .game import Card, GameState, classic_config, score_expedition
from .match_record import FORMAT_ID, MatchRecord, decode_public_hands, make_step

LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_table")

DEFAULT_CHECKPOINT = Path(
    "/mnt/2tbhdd/coolrl-lost-cities-artifacts/final-cycles/2026-07-05/final_candidate"
)
FALLBACK_BOT = "heuristic-balanced"

# ---------------------------------------------------------------------------
# Theme
# ---------------------------------------------------------------------------

FELT_TOP = (57, 57, 61)
FELT_BOTTOM = (21, 21, 24)
INK = (232, 230, 227)
MUTED = (158, 157, 156)
FAINT = (118, 118, 119)
GOLD = (214, 176, 98)
GOLD_DIM = (150, 126, 74)
CARD_IVORY = (244, 238, 223)
CARD_IVORY_EDGE = (214, 204, 181)
BACK_PANEL = (35, 36, 41)
BACK_PANEL_EDGE = (20, 21, 24)
DANGER = (204, 94, 80)

SS = 3  # supersampling factor for cached card art


@dataclass(frozen=True)
class Suit:
    name: str
    main: tuple[int, int, int]
    deep: tuple[int, int, int]


SUITS = [
    Suit("VOLCANO", (186, 64, 52), (128, 38, 30)),
    Suit("OCEAN", (52, 100, 168), (30, 62, 112)),
    Suit("JUNGLE", (58, 124, 74), (34, 82, 46)),
    Suit("DESERT", (182, 126, 36), (128, 86, 20)),
    Suit("CAVERN", (122, 80, 152), (84, 52, 108)),
]

SERIF_PATH = Path("/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf")
SANS_PATH = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
SANS_BOLD_PATH = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf")


def card_key(card: Card, min_rank: int) -> tuple[int, int]:
    """(color, value) identity; handshake cards use value 0."""
    return (card.color, 0 if card.is_handshake else card.numeric_value(min_rank))


def ease_out_cubic(t: float) -> float:
    t = max(0.0, min(1.0, t))
    return 1.0 - (1.0 - t) ** 3


# ---------------------------------------------------------------------------
# Suit glyph painters (drawn once at supersampled size, cached)
# ---------------------------------------------------------------------------


def _stroke(g: pygame.Surface, pts: list[tuple[float, float]], color, radius: float) -> None:
    if len(pts) < 2:
        return
    step = max(1.0, radius * 0.4)
    for (x0, y0), (x1, y1) in zip(pts, pts[1:], strict=False):
        dist = math.hypot(x1 - x0, y1 - y0)
        n = max(1, int(dist / step))
        for i in range(n + 1):
            t = i / n
            pygame.draw.circle(g, color, (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t), radius)


def _wave_points(s: float, y: float, amp: float, x0: float, x1: float) -> list:
    pts = []
    for i in range(25):
        t = i / 24
        x = (x0 + (x1 - x0) * t) * s
        pts.append((x, (y + amp * math.sin(t * math.tau * 1.5)) * s))
    return pts


def _bezier(p0, p1, p2, s: float, n: int = 18) -> list:
    pts = []
    for i in range(n + 1):
        t = i / n
        u = 1.0 - t
        x = u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0]
        y = u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1]
        pts.append((x * s, y * s))
    return pts


def _paint_volcano(g: pygame.Surface, s: float, main, deep) -> None:
    pts = [
        (0.10, 0.88),
        (0.36, 0.24),
        (0.44, 0.34),
        (0.50, 0.28),
        (0.56, 0.34),
        (0.64, 0.24),
        (0.90, 0.88),
    ]
    pygame.draw.polygon(g, main, [(x * s, y * s) for x, y in pts])
    pygame.draw.polygon(
        g,
        deep,
        [
            (x * s, y * s)
            for x, y in [(0.50, 0.28), (0.56, 0.34), (0.64, 0.24), (0.90, 0.88), (0.62, 0.88)]
        ],
    )
    for cx, cy, r in ((0.50, 0.14, 0.045), (0.38, 0.09, 0.03), (0.62, 0.08, 0.035)):
        pygame.draw.circle(g, main, (cx * s, cy * s), r * s)


def _paint_ocean(g: pygame.Surface, s: float, main, deep) -> None:
    _stroke(g, _wave_points(s, 0.30, 0.05, 0.12, 0.88), main, 0.045 * s)
    _stroke(g, _wave_points(s, 0.53, 0.05, 0.12, 0.88), deep, 0.045 * s)
    _stroke(g, _wave_points(s, 0.76, 0.05, 0.12, 0.88), main, 0.045 * s)


def _paint_jungle(g: pygame.Surface, s: float, main, deep) -> None:
    trunk = [(0.56, 0.94), (0.51, 0.70), (0.47, 0.48), (0.46, 0.40)]
    _stroke(g, [(x * s, y * s) for x, y in trunk], deep, 0.034 * s)
    crown = (0.46, 0.36)
    fronds = [
        ((0.24, 0.26), (0.08, 0.36)),
        ((0.64, 0.24), (0.84, 0.34)),
        ((0.28, 0.14), (0.12, 0.12)),
        ((0.62, 0.10), (0.80, 0.10)),
        ((0.42, 0.08), (0.30, 0.02)),
    ]
    for ctrl, end in fronds:
        _stroke(g, _bezier(crown, ctrl, end, s), main, 0.030 * s)
    pygame.draw.circle(g, deep, (0.42 * s, 0.42 * s), 0.045 * s)
    pygame.draw.circle(g, deep, (0.52 * s, 0.44 * s), 0.045 * s)


def _paint_desert(g: pygame.Surface, s: float, main, deep) -> None:
    apex, base_l, base_r = (0.44, 0.20), (0.06, 0.84), (0.82, 0.84)
    base_m = (0.56, 0.84)
    pygame.draw.polygon(g, main, [(x * s, y * s) for x, y in (apex, base_l, base_m)])
    pygame.draw.polygon(g, deep, [(x * s, y * s) for x, y in (apex, base_m, base_r)])
    pygame.draw.circle(g, main, (0.86 * s, 0.16 * s), 0.095 * s)


def _paint_cavern(g: pygame.Surface, s: float, main, deep) -> None:
    body = [(0.30, 0.24), (0.70, 0.24), (0.90, 0.46), (0.50, 0.92), (0.10, 0.46)]
    pygame.draw.polygon(g, main, [(x * s, y * s) for x, y in body])
    facet = [(0.30, 0.24), (0.50, 0.46), (0.70, 0.24), (0.90, 0.46), (0.50, 0.46), (0.10, 0.46)]
    pygame.draw.polygon(g, deep, [(x * s, y * s) for x, y in facet])
    _stroke(g, [(0.50 * s, 0.46 * s), (0.50 * s, 0.92 * s)], deep, 0.018 * s)


def _paint_handshake(g: pygame.Surface, s: float, main, deep) -> None:
    """Two clasping forearms meeting at a knuckle — a handshake, not wedding rings."""
    _stroke(g, [(0.12 * s, 0.84 * s), (0.50 * s, 0.52 * s)], main, 0.075 * s)
    _stroke(g, [(0.88 * s, 0.84 * s), (0.50 * s, 0.52 * s)], deep, 0.075 * s)
    pygame.draw.circle(g, deep, (0.50 * s, 0.50 * s), 0.115 * s)
    pygame.draw.circle(g, main, (0.50 * s, 0.50 * s), 0.115 * s, max(2, int(0.022 * s)))


SUIT_PAINTERS = [_paint_volcano, _paint_ocean, _paint_jungle, _paint_desert, _paint_cavern]


# ---------------------------------------------------------------------------
# Fonts and cached art
# ---------------------------------------------------------------------------


class Fonts:
    def __init__(self) -> None:
        self._cache: dict[tuple[str, int], pygame.font.Font] = {}

    def _load(self, kind: str, size: int) -> pygame.font.Font:
        try:
            if kind == "serif" and SERIF_PATH.exists():
                return pygame.font.Font(str(SERIF_PATH), size)
            if kind == "sans" and SANS_PATH.exists():
                return pygame.font.Font(str(SANS_PATH), size)
            if kind == "sans_bold" and SANS_BOLD_PATH.exists():
                return pygame.font.Font(str(SANS_BOLD_PATH), size)
        except OSError:
            pass
        fallback = {"serif": "dejavuserif", "sans": "dejavusans", "sans_bold": "dejavusans"}
        return pygame.font.SysFont(fallback[kind], size, bold=kind != "sans")

    def get(self, kind: str, size: int) -> pygame.font.Font:
        key = (kind, size)
        if key not in self._cache:
            self._cache[key] = self._load(kind, size)
        return self._cache[key]

    def serif(self, size: int) -> pygame.font.Font:
        return self.get("serif", size)

    def sans(self, size: int, *, bold: bool = False) -> pygame.font.Font:
        return self.get("sans_bold" if bold else "sans", size)


def render_tracked(font: pygame.font.Font, text: str, color, tracking: int) -> pygame.Surface:
    glyphs = [font.render(ch, True, color) for ch in text]
    width = sum(g.get_width() for g in glyphs) + tracking * max(0, len(glyphs) - 1)
    out = pygame.Surface((max(1, width), font.get_height()), pygame.SRCALPHA)
    x = 0
    for g in glyphs:
        out.blit(g, (x, 0))
        x += g.get_width() + tracking
    return out


def _blur(surface: pygame.Surface, radius: int) -> pygame.Surface:
    blur = getattr(pygame.transform, "gaussian_blur", None)
    if blur is not None:
        return blur(surface, radius)
    small = pygame.transform.smoothscale(
        surface, (max(1, surface.get_width() // 4), max(1, surface.get_height() // 4))
    )
    return pygame.transform.smoothscale(small, surface.get_size())


class Art:
    """Cached procedural card art. All caches are keyed by target pixel size."""

    def __init__(self, fonts: Fonts) -> None:
        self.fonts = fonts
        self._cache: dict[tuple, pygame.Surface] = {}

    def _memo(self, key: tuple, build) -> pygame.Surface:
        if key not in self._cache:
            self._cache[key] = build()
        return self._cache[key]

    # -- glyphs ------------------------------------------------------------

    def glyph(
        self, suit_index: int, size: int, *, mono: tuple | None = None, alpha: int = 255
    ) -> pygame.Surface:
        key = ("glyph", suit_index, size, mono, alpha)

        def build() -> pygame.Surface:
            s = size * SS
            g = pygame.Surface((s, s), pygame.SRCALPHA)
            suit = SUITS[suit_index]
            main, deep = (mono, mono) if mono else (suit.main, suit.deep)
            SUIT_PAINTERS[suit_index](g, float(s), main, deep)
            out = pygame.transform.smoothscale(g, (size, size))
            if alpha < 255:
                out.set_alpha(alpha)
            return out

        return self._memo(key, build)

    def handshake_glyph(self, suit_index: int, size: int) -> pygame.Surface:
        key = ("handshake", suit_index, size)

        def build() -> pygame.Surface:
            s = size * SS
            g = pygame.Surface((s, s), pygame.SRCALPHA)
            suit = SUITS[suit_index]
            _paint_handshake(g, float(s), suit.main, suit.deep)
            return pygame.transform.smoothscale(g, (size, size))

        return self._memo(key, build)

    def _fit_label(
        self, text: str, color, max_width: int, start_size: int, *, min_size: int = 10
    ) -> pygame.Surface:
        """Shrink font size until the tracked label fits max_width."""
        size = start_size
        while True:
            tracking = max(1, size // 6)
            surf = render_tracked(self.fonts.sans(size, bold=True), text, color, tracking)
            if surf.get_width() <= max_width or size <= min_size:
                return surf
            size -= 1

    # -- cards -------------------------------------------------------------

    @staticmethod
    def card_height(width: int) -> int:
        return int(width * 1.4)

    def face(self, color: int, value: int, width: int) -> pygame.Surface:
        key = ("face", color, value, width)
        return self._memo(key, lambda: self._build_face(color, value, width))

    def _build_face(self, color: int, value: int, width: int) -> pygame.Surface:
        w, h = width * SS, self.card_height(width) * SS
        suit = SUITS[color]
        g = pygame.Surface((w, h), pygame.SRCALPHA)
        radius = int(w * 0.10)
        pygame.draw.rect(g, CARD_IVORY, (0, 0, w, h), border_radius=radius)
        pygame.draw.rect(g, CARD_IVORY_EDGE, (0, 0, w, h), width=SS, border_radius=radius)
        inset = int(w * 0.055)
        inner = pygame.Rect(inset, inset, w - 2 * inset, h - 2 * inset)
        pygame.draw.rect(g, (*suit.main, 80), inner, width=SS, border_radius=int(radius * 0.7))

        label = "H" if value == 0 else str(value)
        corner_font = self.fonts.serif(int(w * 0.185))
        corner = corner_font.render(label, True, suit.deep)
        corner_glyph = self.glyph(color, int(w * 0.135))
        pad = int(w * 0.105)
        g.blit(corner, (pad, int(pad * 0.82)))
        g.blit(corner_glyph, (pad, int(pad * 0.82) + corner.get_height()))
        # mirrored upright index bottom-left so covered stack strips stay readable
        g.blit(corner, (pad, h - pad - corner.get_height()))

        if value == 0:
            clasp = self.handshake_glyph(color, int(w * 0.46))
            g.blit(clasp, clasp.get_rect(center=(w // 2, int(h * 0.44))))
            tag = self._fit_label("HANDSHAKE", suit.deep, int(w * 0.86), int(w * 0.085))
            g.blit(tag, tag.get_rect(center=(w // 2, int(h * 0.63))))
        else:
            big_font = self.fonts.serif(int(w * 0.42))
            shadow = big_font.render(label, True, CARD_IVORY_EDGE)
            big = big_font.render(label, True, suit.main)
            center = (w // 2, int(h * 0.42))
            g.blit(shadow, shadow.get_rect(center=(center[0] + SS, center[1] + SS)))
            g.blit(big, big.get_rect(center=center))
            mid = self.glyph(color, int(w * 0.30))
            g.blit(mid, mid.get_rect(center=(w // 2, int(h * 0.70))))
        return pygame.transform.smoothscale(g, (width, self.card_height(width)))

    def back(self, width: int) -> pygame.Surface:
        key = ("back", width)
        return self._memo(key, lambda: self._build_back(width))

    def _build_back(self, width: int) -> pygame.Surface:
        w, h = width * SS, self.card_height(width) * SS
        g = pygame.Surface((w, h), pygame.SRCALPHA)
        radius = int(w * 0.10)
        pygame.draw.rect(g, BACK_PANEL, (0, 0, w, h), border_radius=radius)
        pygame.draw.rect(g, BACK_PANEL_EDGE, (0, 0, w, h), width=SS, border_radius=radius)
        for factor, inset_ratio in ((1.0, 0.06), (0.7, 0.10)):
            inset = int(w * inset_ratio)
            pygame.draw.rect(
                g,
                (*GOLD_DIM, int(170 * factor)),
                (inset, inset, w - 2 * inset, h - 2 * inset),
                width=SS,
                border_radius=int(radius * 0.7),
            )
        cx, cy = w // 2, h // 2
        rose_r = w * 0.30
        pygame.draw.circle(g, (*GOLD_DIM, 160), (cx, cy), int(rose_r), SS)
        for long_r, short_r, rot in (
            (rose_r * 0.92, rose_r * 0.18, 0.0),
            (rose_r * 0.58, rose_r * 0.14, math.pi / 4),
        ):
            pts = []
            for i in range(8):
                angle = rot + i * math.pi / 4
                r = long_r if i % 2 == 0 else short_r
                pts.append((cx + r * math.cos(angle), cy + r * math.sin(angle)))
            pygame.draw.polygon(g, (*GOLD_DIM, 200), pts)
        pygame.draw.circle(g, BACK_PANEL, (cx, cy), int(rose_r * 0.08))
        return pygame.transform.smoothscale(g, (width, self.card_height(width)))

    # -- furniture ---------------------------------------------------------

    def shadow(self, width: int, height: int) -> pygame.Surface:
        key = ("shadow", width, height)

        def build() -> pygame.Surface:
            pad = 14
            g = pygame.Surface((width + 2 * pad, height + 2 * pad), pygame.SRCALPHA)
            pygame.draw.rect(
                g,
                (0, 0, 0, 130),
                (pad, pad, width, height),
                border_radius=int(width * 0.1),
            )
            return _blur(g, 7)

        return self._memo(key, build)

    def glow(self, width: int, height: int, color: tuple[int, int, int]) -> pygame.Surface:
        key = ("glow", width, height, color)

        def build() -> pygame.Surface:
            pad = 18
            g = pygame.Surface((width + 2 * pad, height + 2 * pad), pygame.SRCALPHA)
            pygame.draw.rect(
                g,
                (*color, 255),
                (pad, pad, width, height),
                width=6,
                border_radius=int(width * 0.1),
            )
            return _blur(g, 9)

        return self._memo(key, build)

    def ghost(self, suit_index: int, width: int, height: int) -> pygame.Surface:
        key = ("ghost", suit_index, width, height)

        def build() -> pygame.Surface:
            g = pygame.Surface((width, height), pygame.SRCALPHA)
            pygame.draw.rect(
                g,
                (*INK, 48),
                (0, 0, width, height),
                width=1,
                border_radius=int(width * 0.1),
            )
            glyph = self.glyph(suit_index, int(width * 0.42), mono=INK, alpha=36)
            g.blit(glyph, glyph.get_rect(center=(width // 2, height // 2)))
            return g

        return self._memo(key, build)


# ---------------------------------------------------------------------------
# Sprites
# ---------------------------------------------------------------------------


class Sprite:
    """A physical card on the table. Glides toward its layout target."""

    FLY_DIST = 40.0

    def __init__(
        self,
        color: int,
        value: int,
        pos: tuple[float, float],
        *,
        face_up: bool,
        public: bool = False,
    ) -> None:
        self.color = color
        self.value = value  # 0 == handshake
        self.face_up = face_up
        self.public = public
        self.pos = pygame.Vector2(pos)
        self.target = pygame.Vector2(pos)
        self.width = 0
        self.release_at = 0
        self.pending_flip_at: int | None = None
        self.flip_t = 1.0  # 1.0 == not flipping
        self._flip_shows_face = face_up

    def set_face(self, face_up: bool, *, animate: bool = True) -> None:
        self.pending_flip_at = None
        if face_up == self.face_up:
            return
        self.face_up = face_up
        if animate:
            self.flip_t = 0.0
        else:
            self._flip_shows_face = face_up

    def update(self, dt: float, now_ms: int) -> None:
        if self.pending_flip_at is not None and now_ms >= self.pending_flip_at:
            self.set_face(True)
        if now_ms < self.release_at:
            return
        delta = self.target - self.pos
        if delta.length() < 0.6:
            self.pos.update(self.target)
        else:
            self.pos += delta * min(1.0, 1.0 - math.exp(-dt * 9.5))
        if self.flip_t < 1.0:
            self.flip_t = min(1.0, self.flip_t + dt / 0.26)
            if self.flip_t >= 0.5:
                self._flip_shows_face = self.face_up

    @property
    def flying(self) -> bool:
        return (self.target - self.pos).length() > self.FLY_DIST

    def draw(
        self, screen: pygame.Surface, art: Art, width: int, *, with_shadow: bool = True
    ) -> pygame.Rect:
        height = art.card_height(width)
        rect = pygame.Rect(int(self.pos.x), int(self.pos.y), width, height)
        if with_shadow:
            shadow = art.shadow(width, height)
            screen.blit(shadow, (rect.x - 14, rect.y - 10))
        face = self._flip_shows_face
        surface = art.face(self.color, self.value, width) if face else art.back(width)
        if self.flip_t < 1.0:
            sx = abs(1.0 - 2.0 * self.flip_t)
            fw = max(2, int(width * sx))
            surface = pygame.transform.smoothscale(surface, (fw, height))
            screen.blit(surface, (rect.centerx - fw // 2, rect.y))
        else:
            screen.blit(surface, rect)
        return rect


# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------


class Layout:
    def __init__(self, w: int, h: int, n_colors: int) -> None:
        self.w, self.h = w, h
        self.n_colors = n_colors
        self.margin = 24
        self.top_h = 58

        self.hand_w = max(78, min(124, int(w * 0.072)))
        self.hand_h = Art.card_height(self.hand_w)
        self.hand_y = h - self.hand_h - 30
        self.prompt_y = self.hand_y - 26

        self.board_w = max(64, min(104, int(w * 0.056)))
        self.board_h = Art.card_height(self.board_w)
        self.board_top = self.top_h + 14
        self.board_bottom = self.prompt_y - 26

        deck_zone = int(self.board_w * 2.1)
        col_gap = int(self.board_w * 0.52)
        self.col_w = self.board_w + col_gap
        total = self.col_w * n_colors
        # The five expedition lanes are the table's visual anchor. Center them
        # on the window; the deck occupies independent space on the far right.
        cx = w // 2
        self.col_x = [cx - total // 2 + self.col_w * i + col_gap // 2 for i in range(n_colors)]
        self.discard_cy = (self.board_top + self.board_bottom) // 2
        self.deck_rect = pygame.Rect(0, 0, self.board_w, self.board_h)
        self.deck_rect.center = (w - self.margin - deck_zone // 2, self.discard_cy)

        self.ai_back_w = max(30, int(self.hand_w * 0.36))
        self.ai_back_h = Art.card_height(self.ai_back_w)

    def discard_rect(self, color: int) -> pygame.Rect:
        rect = pygame.Rect(0, 0, self.board_w, self.board_h)
        rect.center = (self.col_x[color] + self.board_w // 2, self.discard_cy)
        return rect

    def expedition_pos(
        self, seat_is_human: bool, color: int, index: int, count: int
    ) -> tuple[int, int]:
        x = self.col_x[color]
        span_gap = 16
        if seat_is_human:
            base = self.board_bottom - self.board_h
            top_limit = self.discard_cy + self.board_h // 2 + span_gap
            avail = base - top_limit
            step = min(30, avail // max(1, count - 1)) if count > 1 else 0
            return (x, base - step * index)
        base = self.board_top
        bottom_limit = self.discard_cy - self.board_h // 2 - span_gap
        avail = bottom_limit - (base + self.board_h)
        step = min(30, avail // max(1, count - 1)) if count > 1 else 0
        return (x, base + step * index)

    def expedition_slot_rect(self, seat_is_human: bool, color: int, count: int) -> pygame.Rect:
        x, y = self.expedition_pos(seat_is_human, color, max(0, count), count + 1)
        return pygame.Rect(x, y, self.board_w, self.board_h)

    def hand_slots(self, count: int) -> list[tuple[int, int]]:
        gap = 14
        total = self.hand_w * count + gap * (count - 1)
        max_w = self.w - 2 * self.margin - 240
        if total > max_w and count > 1:
            gap = (max_w - self.hand_w * count) // (count - 1)
            total = self.hand_w * count + gap * (count - 1)
        x0 = (self.w - total) // 2
        return [(x0 + i * (self.hand_w + gap), self.hand_y) for i in range(count)]

    def ai_hand_slots(self, count: int) -> list[tuple[int, int]]:
        stride = int(self.ai_back_w * 0.62)
        total = self.ai_back_w + stride * (count - 1) if count else 0
        x0 = (self.w - total) // 2
        y = self.top_h - self.ai_back_h + 8
        return [(x0 + i * stride, y) for i in range(count)]


# ---------------------------------------------------------------------------
# Opponent (background-loaded policy)
# ---------------------------------------------------------------------------


class Opponent:
    """Loads the JAX PPO policy off-thread; falls back to the heuristic bot."""

    def __init__(
        self, checkpoint: Path | None, config: str | None, seed: int | None, *, offline: bool
    ) -> None:
        self.checkpoint = checkpoint
        self.config = config
        self.seed = seed
        self.offline = offline
        self.policy: Any = None
        self.load_error: str | None = None
        self.label = "loading"
        self.executor = ThreadPoolExecutor(max_workers=1)
        self.load_future: Future = self.executor.submit(self._load)
        self.act_future: Future | None = None

    def _load(self) -> None:
        if not self.offline and self.checkpoint is not None and self.checkpoint.exists():
            try:
                from .jax_ppo_policy import JaxPPOPolicy

                self.policy = JaxPPOPolicy(self.checkpoint, config=self.config)
                self.label = f"{self.checkpoint.name} · JAX PPO"
                LOGGER.info("상대 모델 로드 완료: %s", self.checkpoint)
                return
            except Exception as exc:
                self.load_error = f"{type(exc).__name__}: {exc}"
                self.label = "MODEL LOAD FAILED"
                LOGGER.exception("JAX PPO 로드 실패: %s", self.checkpoint)
                return
        elif not self.offline:
            self.load_error = f"Checkpoint not found: {self.checkpoint}"
            self.label = "MODEL LOAD FAILED"
            LOGGER.error("체크포인트 없음: %s", self.checkpoint)
            return
        self.policy = build_bot(FALLBACK_BOT, seed=self.seed)
        self.label = f"{FALLBACK_BOT} · offline"

    @property
    def ready(self) -> bool:
        return self.load_future.done()

    def reset_between_games(self) -> None:
        if self.policy is not None and hasattr(self.policy, "pending_draw"):
            self.policy.pending_draw = None

    def begin_act(
        self,
        state: GameState,
        public_hand_counts: list[Counter[tuple[int, int]]],
    ) -> None:
        clone = state.clone()
        counts = [Counter(values) for values in public_hand_counts]
        self.act_future = self.executor.submit(self._act, clone, counts)

    def _act(
        self,
        state: GameState,
        public_hand_counts: list[Counter[tuple[int, int]]],
    ) -> int:
        if self.policy is None:
            raise RuntimeError(self.load_error or "opponent policy is unavailable")
        if hasattr(self.policy, "checkpoint"):
            return self.policy.act(state, public_hand_counts)
        return self.policy.act(state)

    def act_now(
        self,
        state: GameState,
        public_hand_counts: list[Counter[tuple[int, int]]],
    ) -> int:
        return self._act(
            state.clone(),
            [Counter(values) for values in public_hand_counts],
        )

    def shutdown(self) -> None:
        self.executor.shutdown(wait=False, cancel_futures=True)


# ---------------------------------------------------------------------------
# The table app
# ---------------------------------------------------------------------------

PROMPT_PLAY = "Play or discard a card"
PROMPT_TARGET = "Choose a destination — expedition or discard"
PROMPT_DRAW = "Draw — deck or a discard pile"
PROMPT_THINKING = "The rival is thinking"
PROMPT_LOADING = "The rival is unpacking gear (loading model)"
PROMPT_LOAD_FAILED = "The rival model failed to load — check the launcher terminal"


class TableApp:
    def __init__(
        self,
        *,
        seed: int | None = None,
        checkpoint: str | None = str(DEFAULT_CHECKPOINT),
        jax_config: str | None = None,
        width: int = 1600,
        height: int = 1000,
        offline: bool = False,
        headless: bool = False,
        export_dir: str | Path = "exports",
    ) -> None:
        if headless:
            import os

            os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
        pygame.init()
        pygame.display.set_caption("Lost Cities")
        self.screen = pygame.display.set_mode((width, height), pygame.RESIZABLE)
        self.clock = pygame.time.Clock()
        self.fonts = Fonts()
        self.art = Art(self.fonts)
        pygame.display.set_icon(self.art.back(32))

        self.rng = random.Random(seed)
        self.seed = seed
        self.export_dir = Path(export_dir)
        self.config = classic_config()
        self.min_rank = self.config.min_rank
        self.opponent = Opponent(
            Path(checkpoint) if checkpoint else None, jax_config, seed, offline=offline
        )

        self.layout = Layout(width, height, self.config.n_colors)
        self.background = self._make_background(width, height)
        self.games_played = 0
        self.human_seat = 0
        self.state: GameState = None  # type: ignore[assignment]
        self.public_hand_counts: list[Counter[tuple[int, int]]] = [Counter(), Counter()]
        self.history: list[tuple[GameState, list[Counter[tuple[int, int]]]]] = []
        self.future: list[tuple[GameState, list[Counter[tuple[int, int]]]]] = []
        self.sprites: list[Sprite] = []
        self.hand_zones: list[list[Sprite]] = [[], []]
        self.expedition_zones: list[list[list[Sprite]]] = []
        self.discard_zones: list[list[Sprite]] = []
        self.selected: Sprite | None = None
        self.hovered: Sprite | None = None
        self.input_locked_until = 0
        self.ai_next_at = 0
        self.ai_min_reveal_at = 0
        self.prompt_text = ""
        self.prompt_since = 0
        self.menu_open = False
        self.menu_button_rect = pygame.Rect(0, 0, 1, 1)
        self.menu_items: list[tuple[str, pygame.Rect]] = []
        self.review_index: int | None = None
        self.match_metadata: dict[str, Any] = {}
        self.match_steps: list[dict[str, Any]] = []
        self.timeline_cursor = 0
        self.toast_text = ""
        self.toast_until = 0
        self.shake_until = 0
        self.shake_rect: pygame.Rect | None = None
        self.running = False
        self.new_game()

    # -- setup ---------------------------------------------------------------

    def _make_background(self, w: int, h: int) -> pygame.Surface:
        import numpy as np

        y = np.linspace(0.0, 1.0, h)[:, None]
        x = np.linspace(0.0, 1.0, w)[None, :]
        top = np.array(FELT_TOP, dtype=np.float64)
        bottom = np.array(FELT_BOTTOM, dtype=np.float64)
        grad = top[None, None, :] + (bottom - top)[None, None, :] * (y**1.2)[..., None]
        dist = np.sqrt(((x - 0.5) * 1.7) ** 2 + ((y - 0.46) * 1.15) ** 2)
        vignette = np.clip(1.10 - 0.42 * dist**1.6, 0.72, 1.0)
        noise = np.random.default_rng(7).normal(0.0, 2.6, size=(h, w, 1))
        rgb = np.clip(grad * vignette[..., None] + noise, 0, 255).astype(np.uint8)
        return pygame.surfarray.make_surface(rgb.swapaxes(0, 1))

    def new_game(self) -> None:
        now = pygame.time.get_ticks()
        game_seed = None if self.seed is None else self.seed + self.games_played
        self.state = GameState.new_game(self.config, seed=game_seed)
        self.human_seat = self.games_played % 2
        self.games_played += 1
        self.opponent.reset_between_games()
        self.opponent.act_future = None
        self.history = []
        self.future = []
        self.public_hand_counts = [Counter(), Counter()]
        self.review_index = None
        self.menu_open = False
        self.toast_text = ""

        n = self.config.n_colors
        self.sprites = []
        self.hand_zones = [[], []]
        self.expedition_zones = [[[] for _ in range(n)] for _ in range(2)]
        self.discard_zones = [[] for _ in range(n)]
        self.selected = None
        self.hovered = None
        self.ai_next_at = 0

        self.match_metadata = {
            "type": "metadata",
            "format": FORMAT_ID,
            "created_at": datetime.now().astimezone().isoformat(),
            "complete": False,
            "seed": game_seed,
            "human_seat": self.human_seat,
            "opponent": {
                "kind": "heuristic" if self.opponent.offline else "jax_ppo",
                "checkpoint": (str(self.opponent.checkpoint) if self.opponent.checkpoint else None),
            },
            "config": self.config.to_snapshot(),
        }
        self.match_steps = [
            make_step(
                index=0,
                state=self.state,
                public_hands=self.public_hand_counts,
                actor=None,
                phase_before=None,
                action_id=None,
            )
        ]
        self.timeline_cursor = 0

        deck_pos = self.layout.deck_rect.topleft
        deal_order: list[tuple[int, Sprite]] = []
        for slot in range(self.config.hand_size):
            for seat in range(2):
                card = self.state.hands[seat][slot]
                sprite = Sprite(*card_key(card, self.min_rank), deck_pos, face_up=False)
                self.sprites.append(sprite)
                self.hand_zones[seat].append(sprite)
                deal_order.append((slot * 2 + seat, sprite))
        for order, sprite in deal_order:
            sprite.release_at = now + 260 + order * 55
        self._sort_human_hand()
        self.input_locked_until = now + 260 + len(deal_order) * 55 + 500
        for sprite in self.hand_zones[self.human_seat]:
            sprite.pending_flip_at = sprite.release_at + 230
        LOGGER.info(
            "새 대국 시작: 게임=%s 사람자리=%s 시드=%s",
            self.games_played,
            self.human_seat,
            game_seed,
        )

    # -- zone bookkeeping ------------------------------------------------------

    def _sort_human_hand(self) -> None:
        self.hand_zones[self.human_seat].sort(key=lambda s: (s.color, s.value))

    def _take_hand_sprite(self, seat: int, card: Card, *, prefer_public: bool) -> Sprite:
        key = card_key(card, self.min_rank)
        zone = self.hand_zones[seat]
        candidates = [sprite for sprite in zone if (sprite.color, sprite.value) == key]
        candidates.sort(key=lambda sprite: sprite.public != prefer_public)
        for sprite in candidates:
            if (sprite.color, sprite.value) == key:
                zone.remove(sprite)
                return sprite
        raise LookupError(f"hand sprite missing for {key}")

    def _copy_public_counts(self) -> list[Counter[tuple[int, int]]]:
        return [Counter(values) for values in self.public_hand_counts]

    def apply_unified(self, action_id: int) -> None:
        """Apply an engine action and move the matching sprites."""
        if self.timeline_cursor < len(self.match_steps) - 1:
            self.match_steps = self.match_steps[: self.timeline_cursor + 1]
        self.history.append((self.state.clone(), self._copy_public_counts()))
        self.future.clear()
        state = self.state
        seat = state.current_player
        phase_before = state.phase
        is_human = seat == self.human_seat
        if state.phase == "card":
            slot, place = divmod(action_id, 2)
            card = state.hands[seat][slot]
            face = card_key(card, self.min_rank)
            was_public = self.public_hand_counts[seat][face] > 0
            if was_public:
                self.public_hand_counts[seat][face] -= 1
                if self.public_hand_counts[seat][face] <= 0:
                    del self.public_hand_counts[seat][face]
            state.apply_unified_action(action_id)
            sprite = self._take_hand_sprite(seat, card, prefer_public=was_public)
            sprite.public = False
            sprite.set_face(True)
            if place == 0:
                self.expedition_zones[seat][card.color].append(sprite)
            else:
                self.discard_zones[card.color].append(sprite)
        else:
            deck_action = state.card_action_size
            if action_id == deck_action:
                card = state.deck[-1]
                state.apply_unified_action(action_id)
                sprite = Sprite(
                    *card_key(card, self.min_rank),
                    self.layout.deck_rect.topleft,
                    face_up=False,
                )
                self.sprites.append(sprite)
                if is_human:
                    sprite.pending_flip_at = pygame.time.get_ticks() + 140
            else:
                color = action_id - deck_action - 1
                card = state.discards[color][-1]
                state.apply_unified_action(action_id)
                sprite = self.discard_zones[color].pop()
                sprite.public = True
                self.public_hand_counts[seat][card_key(card, self.min_rank)] += 1
                sprite.set_face(True)
            self.hand_zones[seat].append(sprite)
            if is_human:
                self._sort_human_hand()
        self.selected = None
        self.match_steps.append(
            make_step(
                index=len(self.match_steps),
                state=state,
                public_hands=self.public_hand_counts,
                actor=seat,
                phase_before=phase_before,
                action_id=action_id,
            )
        )
        self.timeline_cursor += 1
        if state.terminal and not self.match_metadata.get("complete"):
            self.match_metadata.update(
                {
                    "complete": True,
                    "completed_at": datetime.now().astimezone().isoformat(),
                    "scores": [state.total_score(0), state.total_score(1)],
                }
            )
            self.enter_review(len(self.match_steps) - 1)

    # -- undo / redo -------------------------------------------------------------

    def can_undo(self) -> bool:
        return bool(self.history)

    def can_redo(self) -> bool:
        return bool(self.future)

    def _at_human_decision(self) -> bool:
        return not self.state.terminal and self.state.current_player == self.human_seat

    def undo(self) -> None:
        """Step back to the most recent point the human had agency, skipping
        over the rival's automatic moves in between."""
        if self.review_index is not None or not self.history:
            return
        while self.history:
            self.future.append((self.state.clone(), self._copy_public_counts()))
            self.state, counts = self.history.pop()
            self.public_hand_counts = [Counter(values) for values in counts]
            self.timeline_cursor = max(0, self.timeline_cursor - 1)
            if self._at_human_decision():
                break
        self._sync_view_to_state()

    def redo(self) -> None:
        if self.review_index is not None or not self.future:
            return
        while self.future:
            self.history.append((self.state.clone(), self._copy_public_counts()))
            self.state, counts = self.future.pop()
            self.public_hand_counts = [Counter(values) for values in counts]
            self.timeline_cursor = min(len(self.match_steps) - 1, self.timeline_cursor + 1)
            if self._at_human_decision():
                break
        self._sync_view_to_state()

    def _sync_view_to_state(self, *, reveal_all: bool = False) -> None:
        """Rebuild every sprite/zone from self.state — used after an undo/redo
        jump, since animating backward through discarded history isn't sound."""
        now = pygame.time.get_ticks()
        n = self.config.n_colors
        self.sprites = []
        self.hand_zones = [[], []]
        self.expedition_zones = [[[] for _ in range(n)] for _ in range(2)]
        self.discard_zones = [[] for _ in range(n)]
        self.selected = None
        self.hovered = None

        deck_pos = self.layout.deck_rect.topleft
        for seat in range(2):
            face_up = seat == self.human_seat or reveal_all
            public_remaining = Counter(self.public_hand_counts[seat])
            for card in self.state.hands[seat]:
                face = card_key(card, self.min_rank)
                public = public_remaining[face] > 0
                if public:
                    public_remaining[face] -= 1
                sprite = Sprite(
                    *face,
                    deck_pos,
                    face_up=face_up or public,
                    public=public,
                )
                self.sprites.append(sprite)
                self.hand_zones[seat].append(sprite)
        self._sort_human_hand()
        for seat in range(2):
            for color in range(n):
                for card in self.state.expeditions[seat][color]:
                    sprite = Sprite(*card_key(card, self.min_rank), deck_pos, face_up=True)
                    self.sprites.append(sprite)
                    self.expedition_zones[seat][color].append(sprite)
        for color in range(n):
            for card in self.state.discards[color]:
                sprite = Sprite(*card_key(card, self.min_rank), deck_pos, face_up=True)
                self.sprites.append(sprite)
                self.discard_zones[color].append(sprite)

        self._assign_targets(now)
        for sprite in self.sprites:
            sprite.pos.update(sprite.target)
            sprite.release_at = 0
            sprite.pending_flip_at = None
            sprite.flip_t = 1.0
            sprite._flip_shows_face = sprite.face_up

        # a stale in-flight AI decision no longer applies to the jumped-to state
        self.opponent.act_future = None
        self.opponent.reset_between_games()
        self.ai_next_at = 0
        self.ai_min_reveal_at = 0
        self.shake_until = 0

    # -- replay / export -------------------------------------------------------

    def enter_review(self, index: int | None = None) -> None:
        if not self.match_steps or not self.match_metadata.get("complete"):
            return
        target = len(self.match_steps) - 1 if index is None else index
        target = max(0, min(target, len(self.match_steps) - 1))
        step_row = self.match_steps[target]
        self.state = GameState.from_snapshot(step_row["state"])
        self.public_hand_counts = decode_public_hands(step_row["public_hands"])
        self.review_index = target
        self._sync_view_to_state(reveal_all=True)

    def review_step(self, delta: int) -> None:
        if self.review_index is None:
            self.enter_review()
            return
        self.enter_review(self.review_index + delta)

    def current_match_record(self) -> MatchRecord:
        return MatchRecord(
            dict(self.match_metadata),
            list(self.match_steps[: self.timeline_cursor + 1]),
        )

    def export_match(self) -> Path:
        stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
        path = self.export_dir / f"lost-cities-match-{stamp}.jsonl"
        written = self.current_match_record().write_jsonl(path)
        self.toast_text = f"EXPORTED  {written}"
        self.toast_until = pygame.time.get_ticks() + 4000
        LOGGER.info("대국 기록 내보내기: %s", written)
        return written

    # -- helpers ---------------------------------------------------------------

    @property
    def ai_seat(self) -> int:
        return 1 - self.human_seat

    def human_turn(self) -> bool:
        return (
            self.review_index is None
            and not self.state.terminal
            and self.state.current_player == self.human_seat
        )

    def _legal(self, action_id: int) -> bool:
        mask = self.state.unified_legal_mask()
        return 0 <= action_id < len(mask) and mask[action_id]

    def _engine_slot(self, sprite: Sprite) -> int:
        hand = self.state.hands[self.human_seat]
        for i, card in enumerate(hand):
            if card_key(card, self.min_rank) == (sprite.color, sprite.value):
                return i
        raise LookupError("selected sprite is not in hand")

    def expedition_multiplier(self, seat: int, color: int) -> int:
        return 1 + sum(1 for card in self.state.expeditions[seat][color] if card.is_handshake)

    def expedition_score(self, seat: int, color: int) -> int:
        return score_expedition(self.state.expeditions[seat][color], self.config)

    def total_score(self, seat: int) -> int:
        return sum(self.expedition_score(seat, c) for c in range(self.config.n_colors))

    # -- main loop ---------------------------------------------------------------

    def run(self) -> None:
        self.running = True
        while self.running:
            dt = self.clock.tick(60) / 1000.0
            for event in pygame.event.get():
                self.handle_event(event)
            self.update(dt)
            self.draw()
            pygame.display.flip()
        self.opponent.shutdown()
        pygame.quit()

    def handle_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.QUIT:
            self.running = False
        elif event.type == pygame.VIDEORESIZE:
            w, h = max(1024, event.w), max(720, event.h)
            self.screen = pygame.display.set_mode((w, h), pygame.RESIZABLE)
            self.layout = Layout(w, h, self.config.n_colors)
            self.background = self._make_background(w, h)
        elif event.type == pygame.KEYDOWN:
            mod = getattr(event, "mod", 0)
            ctrl = bool(mod & pygame.KMOD_CTRL)
            shift = bool(mod & pygame.KMOD_SHIFT)
            if event.key == pygame.K_n:
                self.new_game()
            elif event.key == pygame.K_ESCAPE:
                if self.menu_open:
                    self.menu_open = False
                else:
                    self.selected = None
            elif event.key == pygame.K_m:
                self.menu_open = not self.menu_open
            elif event.key in (pygame.K_LEFT, pygame.K_PAGEUP):
                self.review_step(-1)
            elif event.key in (pygame.K_RIGHT, pygame.K_PAGEDOWN):
                self.review_step(1)
            elif event.key == pygame.K_HOME:
                self.enter_review(0)
            elif event.key == pygame.K_END:
                self.enter_review(len(self.match_steps) - 1)
            elif event.key == pygame.K_e and self.review_index is not None:
                self.export_match()
            elif event.key == pygame.K_F12:
                self.save_screenshot()
            elif ctrl and event.key == pygame.K_z and shift:
                self.redo()
            elif ctrl and event.key == pygame.K_z:
                self.undo()
            elif ctrl and event.key == pygame.K_y:
                self.redo()
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
            self.selected = None
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            self.on_click(event.pos)

    def on_click(self, pos: tuple[int, int]) -> None:
        now = pygame.time.get_ticks()
        if self._handle_chrome_click(pos):
            return
        if self.review_index is not None or self.state.terminal:
            return
        if now < self.input_locked_until or not self.human_turn():
            return
        if self.state.phase == "card":
            self._click_card_phase(pos)
        else:
            self._click_draw_phase(pos)

    def _handle_chrome_click(self, pos: tuple[int, int]) -> bool:
        if self.menu_button_rect.collidepoint(pos):
            self.menu_open = not self.menu_open
            return True
        for action, rect in self.menu_items:
            if not rect.collidepoint(pos):
                continue
            if action == "review":
                self.enter_review()
                self.menu_open = False
            elif action == "export":
                self.export_match()
                self.menu_open = False
            elif action == "new":
                self.new_game()
            elif action == "close":
                self.running = False
            elif action == "previous":
                self.review_step(-1)
            elif action == "next":
                self.review_step(1)
            return True
        if self.menu_open:
            self.menu_open = False
            return True
        return False

    def _click_card_phase(self, pos: tuple[int, int]) -> None:
        hand = self.hand_zones[self.human_seat]
        for sprite in reversed(hand):
            if self._sprite_rect(sprite, self.layout.hand_w).collidepoint(pos):
                self.selected = None if sprite is self.selected else sprite
                return
        if self.selected is None:
            return
        slot = self._engine_slot(self.selected)
        color = self.selected.color
        play_rect = self._expedition_target_rect(color)
        if play_rect.collidepoint(pos):
            if self._legal(2 * slot):
                self.apply_unified(2 * slot)
            else:
                self._shake(play_rect)
            return
        if self.layout.discard_rect(color).collidepoint(pos):
            if self._legal(2 * slot + 1):
                self.apply_unified(2 * slot + 1)
            return
        self.selected = None

    def _click_draw_phase(self, pos: tuple[int, int]) -> None:
        deck_action = self.state.card_action_size
        if self.layout.deck_rect.collidepoint(pos) and self._legal(deck_action):
            self.apply_unified(deck_action)
            return
        for color in range(self.config.n_colors):
            rect = self.layout.discard_rect(color)
            if rect.collidepoint(pos):
                action = deck_action + 1 + color
                if self._legal(action):
                    self.apply_unified(action)
                elif self.discard_zones[color]:
                    self._shake(rect)
                return

    def _shake(self, rect: pygame.Rect) -> None:
        self.shake_until = pygame.time.get_ticks() + 320
        self.shake_rect = rect

    # -- per-frame update -----------------------------------------------------

    def update(self, dt: float) -> None:
        now = pygame.time.get_ticks()
        self._assign_targets(now)
        for sprite in self.sprites:
            sprite.update(dt, now)
        self._update_hover()
        if (
            self.review_index is None
            and not self.state.terminal
            and self.state.current_player == self.ai_seat
        ):
            self._update_ai(now)
        self._update_prompt(now)
        if pygame.display.get_active():
            interactive = self._cursor_interactive()
            cursor = pygame.SYSTEM_CURSOR_HAND if interactive else pygame.SYSTEM_CURSOR_ARROW
            try:
                pygame.mouse.set_cursor(cursor)
            except pygame.error:
                pass  # headless drivers cannot create system cursors

    def _update_ai(self, now: int) -> None:
        if now < self.input_locked_until or now < self.ai_next_at:
            return
        if not self.opponent.ready:
            return
        if self.opponent.load_error is not None:
            return
        if self.state.phase == "card":
            if self.opponent.act_future is None:
                self.opponent.begin_act(self.state, self.public_hand_counts)
                self.ai_min_reveal_at = now + 750 + self.rng.randint(0, 450)
                return
            if not self.opponent.act_future.done() or now < self.ai_min_reveal_at:
                return
            future, self.opponent.act_future = self.opponent.act_future, None
            try:
                action = self.state.to_unified_action(future.result())
                if not self._legal(action):
                    raise ValueError(f"illegal AI action {action}")
            except Exception:
                LOGGER.exception("AI 카드 액션 실패, 안전 대체 사용")
                action = self._fallback_action()
            self.apply_unified(action)
            self.ai_next_at = now + 620
        else:
            try:
                action = self.state.to_unified_action(
                    self.opponent.act_now(self.state, self.public_hand_counts)
                )
                if not self._legal(action):
                    raise ValueError(f"illegal AI draw {action}")
            except Exception:
                LOGGER.exception("AI 드로우 실패, 덱 드로우로 대체")
                action = self._fallback_action()
            self.apply_unified(action)
            self.ai_next_at = now + 500

    def _fallback_action(self) -> int:
        mask = self.state.unified_legal_mask()
        deck_action = self.state.card_action_size
        if self.state.phase == "draw" and mask[deck_action]:
            return deck_action
        return next(i for i, ok in enumerate(mask) if ok)

    def _update_hover(self) -> None:
        self.hovered = None
        if not self.human_turn() or self.state.phase != "card":
            return
        pos = pygame.mouse.get_pos()
        for sprite in reversed(self.hand_zones[self.human_seat]):
            if self._sprite_rect(sprite, self.layout.hand_w).collidepoint(pos):
                self.hovered = sprite
                return

    def _cursor_interactive(self) -> bool:
        pos = pygame.mouse.get_pos()
        if self.menu_button_rect.collidepoint(pos) or any(
            rect.collidepoint(pos) for _, rect in self.menu_items
        ):
            return True
        if self.state.terminal:
            return False
        if not self.human_turn():
            return False
        if self.hovered is not None:
            return True
        if self.state.phase == "card" and self.selected is not None:
            color = self.selected.color
            if self._expedition_target_rect(color).collidepoint(pos):
                return True
            return self.layout.discard_rect(color).collidepoint(pos)
        if self.state.phase == "draw":
            if self.layout.deck_rect.collidepoint(pos):
                return True
            deck_action = self.state.card_action_size
            return any(
                self.layout.discard_rect(c).collidepoint(pos) and self._legal(deck_action + 1 + c)
                for c in range(self.config.n_colors)
            )
        return False

    def _update_prompt(self, now: int) -> None:
        if self.review_index is not None or self.state.terminal:
            text = ""
        elif not self.human_turn():
            if self.opponent.load_error is not None:
                text = PROMPT_LOAD_FAILED
            else:
                text = PROMPT_THINKING if self.opponent.ready else PROMPT_LOADING
        elif self.state.phase == "draw":
            text = PROMPT_DRAW
        elif self.selected is not None:
            text = PROMPT_TARGET
        else:
            text = PROMPT_PLAY
        if text != self.prompt_text:
            self.prompt_text = text
            self.prompt_since = now

    # -- layout targets ----------------------------------------------------------

    def _sprite_rect(self, sprite: Sprite, width: int) -> pygame.Rect:
        return pygame.Rect(int(sprite.pos.x), int(sprite.pos.y), width, self.art.card_height(width))

    def _expedition_target_rect(self, color: int) -> pygame.Rect:
        count = len(self.expedition_zones[self.human_seat][color])
        return self.layout.expedition_slot_rect(True, color, count)

    def _assign_targets(self, now: int) -> None:
        lay = self.layout
        human = self.human_seat
        for seat in range(2):
            zone = self.hand_zones[seat]
            if seat == human:
                slots = lay.hand_slots(len(zone))
                for sprite, (x, y) in zip(zone, slots, strict=False):
                    lift = 0
                    if sprite is self.selected:
                        lift = 30
                    elif sprite is self.hovered:
                        lift = 16
                    sprite.target.update(x, y - lift)
                    sprite.width = lay.hand_w
            else:
                slots = lay.ai_hand_slots(len(zone))
                for sprite, (x, y) in zip(zone, slots, strict=False):
                    sprite.target.update(x, y)
                    sprite.width = lay.ai_back_w
        for seat in range(2):
            for color in range(self.config.n_colors):
                zone = self.expedition_zones[seat][color]
                for index, sprite in enumerate(zone):
                    x, y = lay.expedition_pos(seat == human, color, index, len(zone))
                    sprite.target.update(x, y)
                    sprite.width = lay.board_w
        for color in range(self.config.n_colors):
            rect = lay.discard_rect(color)
            for sprite in self.discard_zones[color]:
                sprite.target.update(rect.x, rect.y)
                sprite.width = lay.board_w

    # -- drawing -------------------------------------------------------------

    def draw(self) -> None:
        now = pygame.time.get_ticks()
        screen = self.screen
        screen.blit(self.background, (0, 0))
        self._draw_board_base(now)
        self._draw_zone_sprites(now)
        self._draw_board_overlays(now)
        self._draw_plaques(now)
        self._draw_prompt(now)
        for sprite in self.sprites:
            if sprite.flying:
                sprite.draw(screen, self.art, sprite.width or self.layout.board_w)
        self.menu_items = []
        self._draw_review_controls()
        self._draw_menu(now)

    def _pulse(self, now: int, speed: float = 2.4) -> float:
        return 0.5 + 0.5 * math.sin(now / 1000.0 * speed * math.tau / 2)

    def _lane_panel(self, color: int, width: int, height: int) -> pygame.Surface:
        key = ("lane_panel", color, width, height)
        return self.art._memo(key, lambda: self._build_lane_panel(color, width, height))

    def _build_lane_panel(self, color: int, width: int, height: int) -> pygame.Surface:
        suit = SUITS[color]
        g = pygame.Surface((width, height), pygame.SRCALPHA)
        pygame.draw.rect(
            g, (*suit.main, 20), (0, 0, width, height), border_radius=int(width * 0.06)
        )
        pygame.draw.rect(
            g, (*suit.main, 60), (0, 0, width, height), width=1, border_radius=int(width * 0.06)
        )
        return g

    def _draw_board_base(self, now: int) -> None:
        lay = self.layout
        screen = self.screen
        human = self.human_seat
        lane_pad = int(lay.board_w * 0.30)
        for color in range(self.config.n_colors):
            lane_rect = pygame.Rect(
                lay.col_x[color] - lane_pad,
                lay.board_top,
                lay.board_w + 2 * lane_pad,
                lay.board_bottom - lay.board_top,
            )
            screen.blit(self._lane_panel(color, lane_rect.w, lane_rect.h), lane_rect)
        for color in range(self.config.n_colors):
            suit = SUITS[color]
            discard_rect = lay.discard_rect(color)
            for seat in range(2):
                if not self.expedition_zones[seat][color]:
                    rect = lay.expedition_slot_rect(seat == human, color, 0)
                    screen.blit(self.art.ghost(color, rect.w, rect.h), rect)
            if not self.discard_zones[color]:
                screen.blit(self.art.ghost(color, discard_rect.w, discard_rect.h), discard_rect)
            label = render_tracked(self.fonts.sans(11, bold=True), suit.name, FAINT, 2)
            screen.blit(
                label,
                label.get_rect(midtop=(discard_rect.centerx, discard_rect.bottom + 6)),
            )
        self._draw_deck(now)

    def _draw_board_overlays(self, now: int) -> None:
        lay = self.layout
        screen = self.screen
        state = self.state
        draw_phase_active = (
            self.human_turn() and state.phase == "draw" and now >= self.input_locked_until
        )
        deck_action = state.card_action_size
        selected_color = self.selected.color if self.selected is not None else None

        for color in range(self.config.n_colors):
            discard_rect = lay.discard_rect(color)
            if self.human_turn() and state.phase == "card" and selected_color == color:
                slot = self._engine_slot(self.selected)
                if self._legal(2 * slot):
                    self._draw_target_glow(self._expedition_target_rect(color), now)
                if self._legal(2 * slot + 1):
                    self._draw_target_glow(discard_rect, now)
            if draw_phase_active and self._legal(deck_action + 1 + color):
                self._draw_target_glow(discard_rect, now)

            count = len(self.discard_zones[color])
            if count > 1:
                pip_font = self.fonts.sans(12, bold=True)
                pip = pip_font.render(f"×{count}", True, INK)
                pip_rect = pip.get_rect()
                pip_rect.inflate_ip(10, 6)
                pip_rect.bottomright = (discard_rect.right + 6, discard_rect.bottom + 4)
                pygame.draw.rect(screen, (10, 18, 15), pip_rect, border_radius=8)
                pygame.draw.rect(screen, (*GOLD_DIM, 255), pip_rect, width=1, border_radius=8)
                screen.blit(pip, pip.get_rect(center=pip_rect.center))

            self._draw_expedition_chip(color, self.human_seat, now)
            self._draw_expedition_chip(color, self.ai_seat, now)
        if draw_phase_active and self._legal(deck_action):
            self._draw_deck_glow(now)

    def _draw_expedition_chip(self, color: int, seat: int, now: int) -> None:
        zone = self.expedition_zones[seat][color]
        if not zone:
            return
        lay = self.layout
        is_human = seat == self.human_seat
        score = self.expedition_score(seat, color)
        mult = self.expedition_multiplier(seat, color)
        text = f"{score:+d}"
        if mult > 1:
            text += f"  ×{mult}"
        font = self.fonts.sans(13, bold=True)
        surf = font.render(text, True, INK if score >= 0 else (222, 148, 134))
        chip = surf.get_rect()
        chip.inflate_ip(14, 8)
        cx = lay.col_x[color] + lay.board_w // 2
        # sits on the outer edge of the expedition's base card, like a clipped token
        if is_human:
            chip.center = (cx, lay.board_bottom - 6)
        else:
            chip.center = (cx, lay.board_top + 6)
        pygame.draw.rect(self.screen, (9, 17, 14), chip, border_radius=9)
        pygame.draw.rect(self.screen, (*GOLD_DIM, 255), chip, width=1, border_radius=9)
        self.screen.blit(surf, surf.get_rect(center=chip.center))

    def _draw_target_glow(self, rect: pygame.Rect, now: int) -> None:
        glow = self.art.glow(rect.w, rect.h, GOLD)
        glow.set_alpha(int(120 + 100 * self._pulse(now)))
        self.screen.blit(glow, (rect.x - 18, rect.y - 18))
        pygame.draw.rect(self.screen, GOLD, rect, width=2, border_radius=int(rect.w * 0.1))

    def _draw_deck_glow(self, now: int) -> None:
        self._draw_target_glow(self.layout.deck_rect, now)

    def _draw_deck(self, now: int) -> None:
        lay = self.layout
        rect = lay.deck_rect
        count = len(self.state.deck)
        if count > 0:
            back = self.art.back(rect.w)
            shadow = self.art.shadow(rect.w, rect.h)
            for i in range(min(3, max(1, count // 14 + 1)) - 1, -1, -1):
                offset = i * 4
                self.screen.blit(shadow, (rect.x - 14 + offset, rect.y - 10 + offset))
                self.screen.blit(back, (rect.x + offset, rect.y + offset))
        else:
            self.screen.blit(self.art.ghost(0, rect.w, rect.h), rect)
        if count <= 5:
            color = DANGER
        elif count <= 10:
            color = GOLD
        else:
            color = INK
        num = self.fonts.serif(30).render(str(count), True, color)
        self.screen.blit(num, num.get_rect(midtop=(rect.centerx, rect.bottom + 12)))
        label = render_tracked(self.fonts.sans(11, bold=True), "CARDS LEFT", FAINT, 2)
        self.screen.blit(label, label.get_rect(midtop=(rect.centerx, rect.bottom + 46)))

    def _draw_zone_sprites(self, now: int) -> None:
        human = self.human_seat
        # expeditions (older cards first so newer overlap)
        for seat in (1 - human, human):
            for color in range(self.config.n_colors):
                for sprite in self.expedition_zones[seat][color]:
                    if not sprite.flying:
                        sprite.draw(self.screen, self.art, self.layout.board_w)
        # discards: only the top two matter visually
        shake_dx = 0
        if now < self.shake_until:
            t = (self.shake_until - now) / 320.0
            shake_dx = int(math.sin(now * 0.09) * 5 * t)
        for color in range(self.config.n_colors):
            for sprite in self.discard_zones[color][-2:]:
                if not sprite.flying:
                    if shake_dx and self.shake_rect == self.layout.discard_rect(color):
                        sprite.pos.x += shake_dx
                        sprite.draw(self.screen, self.art, self.layout.board_w)
                        sprite.pos.x -= shake_dx
                    else:
                        sprite.draw(self.screen, self.art, self.layout.board_w)
        # AI hand (flat, no shadow: the tight overlap makes shadows muddy)
        for sprite in self.hand_zones[1 - human]:
            if not sprite.flying:
                sprite.draw(self.screen, self.art, self.layout.ai_back_w, with_shadow=False)
        # human hand, highlighted card last
        raised = [s for s in (self.hovered, self.selected) if s is not None]
        for sprite in self.hand_zones[human]:
            if not sprite.flying and sprite not in raised:
                sprite.draw(self.screen, self.art, self.layout.hand_w)
        for sprite in dict.fromkeys(raised):
            if not sprite.flying:
                if sprite is self.selected:
                    rect = self._sprite_rect(sprite, self.layout.hand_w)
                    glow = self.art.glow(rect.w, rect.h, GOLD)
                    glow.set_alpha(230)
                    self.screen.blit(glow, (rect.x - 18, rect.y - 18))
                    self.screen.blit(glow, (rect.x - 18, rect.y - 18))
                sprite.draw(self.screen, self.art, self.layout.hand_w)
                if sprite is self.selected:
                    rect = self._sprite_rect(sprite, self.layout.hand_w)
                    pygame.draw.rect(
                        self.screen, GOLD, rect, width=2, border_radius=int(rect.w * 0.1)
                    )

    def _draw_plaques(self, now: int) -> None:
        lay = self.layout
        ai_active = not self.state.terminal and self.state.current_player == self.ai_seat
        human_active = self.human_turn()
        self._draw_plaque(
            pygame.Rect(lay.margin, 14, 236, 64),
            "THE RIVAL",
            (
                "FINAL PPO"
                if self.opponent.ready and self.opponent.load_error is None
                else self.opponent.label
                if self.opponent.ready
                else "LOADING"
            ),
            self.total_score(self.ai_seat),
            active=ai_active,
            thinking=ai_active and self.opponent.load_error is None,
            now=now,
        )
        self._draw_plaque(
            pygame.Rect(lay.margin, lay.h - 78, 236, 64),
            "YOU",
            "",
            self.total_score(self.human_seat),
            active=human_active,
            thinking=False,
            now=now,
        )
        # The table itself carries the identity; keep chrome intentionally sparse.

    def _draw_review_controls(self) -> None:
        if self.review_index is None:
            return
        lay = self.layout
        y = lay.prompt_y - 8
        label = f"STEP {self.review_index} / {len(self.match_steps) - 1}"
        label_surface = render_tracked(self.fonts.sans(12, bold=True), label, INK, 2)
        label_rect = label_surface.get_rect(center=(lay.w // 2, y))
        self.screen.blit(label_surface, label_rect)

        prev_rect = pygame.Rect(label_rect.left - 66, y - 18, 44, 36)
        next_rect = pygame.Rect(label_rect.right + 22, y - 18, 44, 36)
        self.menu_items.extend([("previous", prev_rect), ("next", next_rect)])
        for text, rect, enabled in (
            ("‹", prev_rect, self.review_index > 0),
            ("›", next_rect, self.review_index < len(self.match_steps) - 1),
        ):
            color = GOLD if enabled else (72, 72, 74)
            pygame.draw.rect(self.screen, color, rect, width=1, border_radius=9)
            glyph = self.fonts.serif(25).render(text, True, color)
            self.screen.blit(glyph, glyph.get_rect(center=rect.center))

    def _draw_menu(self, now: int) -> None:
        lay = self.layout
        self.menu_button_rect = pygame.Rect(lay.w - lay.margin - 92, 16, 92, 38)
        pygame.draw.rect(self.screen, (9, 17, 14), self.menu_button_rect, border_radius=10)
        pygame.draw.rect(self.screen, GOLD_DIM, self.menu_button_rect, width=1, border_radius=10)
        menu_text = render_tracked(self.fonts.sans(11, bold=True), "MENU", INK, 2)
        self.screen.blit(menu_text, menu_text.get_rect(center=self.menu_button_rect.center))

        if self.toast_text and now < self.toast_until:
            toast = self.fonts.sans(11).render(self.toast_text, True, GOLD)
            self.screen.blit(
                toast,
                toast.get_rect(
                    topright=(self.menu_button_rect.right, self.menu_button_rect.bottom + 8)
                ),
            )
        if not self.menu_open:
            return

        labels: list[tuple[str, str]] = []
        if self.match_metadata.get("complete"):
            labels.append(("review", "REVIEW MATCH"))
        labels.extend(
            [
                ("export", "EXPORT JSONL"),
                ("new", "NEW GAME"),
                ("close", "QUIT"),
            ]
        )
        panel = pygame.Rect(self.menu_button_rect.right - 238, 62, 238, 18 + 48 * len(labels))
        pygame.draw.rect(self.screen, (8, 16, 13), panel, border_radius=12)
        pygame.draw.rect(self.screen, GOLD_DIM, panel, width=1, border_radius=12)
        for index, (action, text) in enumerate(labels):
            rect = pygame.Rect(panel.x + 10, panel.y + 9 + index * 48, panel.w - 20, 38)
            self.menu_items.append((action, rect))
            if rect.collidepoint(pygame.mouse.get_pos()):
                pygame.draw.rect(self.screen, (*GOLD, 35), rect, border_radius=8)
            label = render_tracked(self.fonts.sans(11, bold=True), text, INK, 2)
            self.screen.blit(label, label.get_rect(midleft=(rect.x + 12, rect.centery)))

    def _draw_plaque(
        self,
        rect: pygame.Rect,
        name: str,
        sub: str,
        score: int,
        *,
        active: bool,
        thinking: bool,
        now: int,
    ) -> None:
        panel = pygame.Surface(rect.size, pygame.SRCALPHA)
        pygame.draw.rect(panel, (8, 16, 13, 165), panel.get_rect(), border_radius=12)
        border = GOLD if active else (70, 84, 74)
        pygame.draw.rect(
            panel, (*border, 255 if active else 160), panel.get_rect(), width=1, border_radius=12
        )
        self.screen.blit(panel, rect)
        name_surf = render_tracked(self.fonts.sans(13, bold=True), name, INK, 3)
        self.screen.blit(name_surf, (rect.x + 16, rect.y + 11))
        sub_text = sub
        if thinking:
            dots = "·" * (1 + (now // 350) % 3)
            sub_text = f"{sub}  {dots}"
        sub_surf = self.fonts.sans(11).render(sub_text, True, MUTED)
        if sub_text:
            self.screen.blit(sub_surf, (rect.x + 16, rect.y + 34))
        score_surf = self.fonts.serif(26).render(str(score), True, GOLD if active else INK)
        self.screen.blit(score_surf, score_surf.get_rect(midright=(rect.right - 16, rect.centery)))
        if active:
            dot_x = rect.x + 16 + name_surf.get_width() + 10
            alpha_pulse = self._pulse(now, 3.0)
            pygame.draw.circle(self.screen, GOLD, (dot_x, rect.y + 18), 3 + int(1.5 * alpha_pulse))

    def _draw_prompt(self, now: int) -> None:
        if not self.prompt_text:
            return
        alpha = int(255 * ease_out_cubic((now - self.prompt_since) / 240))
        text = self.prompt_text
        if text == PROMPT_THINKING:
            text += " " + "·" * (1 + (now // 350) % 3)
        surf = self.fonts.sans(16).render(text, True, INK)
        surf.set_alpha(alpha)
        self.screen.blit(surf, surf.get_rect(center=(self.layout.w // 2, self.layout.prompt_y)))

    # -- misc ---------------------------------------------------------------------

    def save_screenshot(self) -> Path:
        stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        path = Path("/tmp") / f"lost-cities-table-{stamp}.png"
        pygame.image.save(self.screen, str(path))
        LOGGER.info("스크린샷 저장: %s", path)
        return path


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def build_argparser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="lost-cities-play",
        description="Play Lost Cities against the final PPO candidate.",
    )
    parser.add_argument(
        "--checkpoint",
        default=str(DEFAULT_CHECKPOINT),
        help="JAX PPO checkpoint directory (default: final candidate)",
    )
    parser.add_argument(
        "--config", default=None, help="Optional JAX PPO config (normally inferred)"
    )
    parser.add_argument("--seed", type=int, default=None)
    parser.add_argument("--width", type=int, default=1600)
    parser.add_argument("--height", type=int, default=1000)
    parser.add_argument(
        "--export-dir",
        default="exports",
        help="Directory for versioned match JSONL exports",
    )
    parser.add_argument(
        "--offline",
        action="store_true",
        help="Skip the model and play the built-in heuristic rival",
    )
    return parser


def main(argv: list[str] | None = None) -> None:
    logging.basicConfig(
        level=logging.INFO,
        stream=sys.stderr,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )
    args = build_argparser().parse_args(argv)
    app = TableApp(
        seed=args.seed,
        checkpoint=args.checkpoint,
        jax_config=args.config,
        width=args.width,
        height=args.height,
        offline=args.offline,
        export_dir=args.export_dir,
    )
    app.run()


if __name__ == "__main__":
    main()
