From 7e481ca06441e0da77a14973ffcc1553410907d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Sun, 12 Jul 2026 14:58:59 +0900 Subject: [PATCH] Add polished Lost Cities table GUI --- pyproject.toml | 1 + .../games/classic/jax_ppo_policy.py | 27 +- .../games/classic/pygame_table.py | 1717 +++++++++++++++++ src/lost_cities_jax/ppo.py | 5 +- tests/games/classic/test_jax_ppo_policy.py | 19 + tests/games/classic/test_pygame_table.py | 130 ++ 6 files changed, 1893 insertions(+), 6 deletions(-) create mode 100644 src/coolrl_lost_cities/games/classic/pygame_table.py create mode 100644 tests/games/classic/test_pygame_table.py diff --git a/pyproject.toml b/pyproject.toml index 8b90bb4..83639e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ wandb = [ lost-cities-classic = "coolrl_lost_cities.games.classic:main" lost-cities-eval = "coolrl_lost_cities.games.classic.evaluation:main" lost-cities-classic-gui = "coolrl_lost_cities.games.classic.pygame_pvp:main" +lost-cities-play = "coolrl_lost_cities.games.classic.pygame_table:main" lost-cities-deep-cfr = "coolrl_lost_cities.games.classic.deep_cfr.cli:main" lost-cities-ismcts = "coolrl_lost_cities.games.classic.ismcts.cli:main" lost-cities-jax-ppo = "lost_cities_jax.ppo_cli:main" diff --git a/src/coolrl_lost_cities/games/classic/jax_ppo_policy.py b/src/coolrl_lost_cities/games/classic/jax_ppo_policy.py index 83283b6..3cb4afa 100644 --- a/src/coolrl_lost_cities/games/classic/jax_ppo_policy.py +++ b/src/coolrl_lost_cities/games/classic/jax_ppo_policy.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -36,7 +37,13 @@ def _jax_card_id(card: Card, handshake_copy: int = 0) -> int: return card.color * 12 + slot -def snapshot_to_jax_state(snapshot: Snapshot) -> tuple[State, list[int]]: +PublicHandCounts = Sequence[Mapping[tuple[int, int], int]] + + +def snapshot_to_jax_state( + snapshot: Snapshot, + public_hand_counts: PublicHandCounts | None = None, +) -> tuple[State, list[int]]: """Convert a classic card-phase snapshot into the equivalent JAX state. The returned list maps each JAX sorted hand slot back to the classic hand @@ -67,16 +74,22 @@ def snapshot_to_jax_state(snapshot: Snapshot) -> tuple[State, list[int]]: return _jax_card_id(card, copy) card_loc = np.full(N_CARDS, LOC_DECK, dtype=np.int8) + hand_public = np.zeros(N_CARDS, dtype=bool) zone_ids: list[int] = [] hand_pairs: list[tuple[int, int]] = [] deck_ids = [allocate(card) for card in snapshot.deck] zone_ids.extend(deck_ids) for player, hand in enumerate(snapshot.hands): + public_remaining = dict(public_hand_counts[player]) if public_hand_counts else {} for classic_slot, card in enumerate(hand): card_id = allocate(card) card_loc[card_id] = LOC_P0_HAND + player zone_ids.append(card_id) + face = (card.color, 0 if card.is_handshake else card.numeric_value(2)) + if public_remaining.get(face, 0) > 0: + hand_public[card_id] = True + public_remaining[face] -= 1 if player == snapshot.current_player: hand_pairs.append((card_id, classic_slot)) @@ -120,7 +133,7 @@ def snapshot_to_jax_state(snapshot: Snapshot) -> tuple[State, list[int]]: deck_order=jnp.asarray(deck_order), draw_ptr=jnp.asarray(draw_ptr, dtype=jnp.int32), card_loc=jnp.asarray(card_loc), - hand_public=jnp.zeros((N_CARDS,), dtype=jnp.bool_), + hand_public=jnp.asarray(hand_public), col_top=jnp.asarray(col_top), col_hs=jnp.asarray(col_hs), col_len=jnp.asarray(col_len), @@ -149,7 +162,11 @@ class JaxPPOPolicy: self.pending_draw: int | None = None self.last_evaluation: Any | None = None - def act(self, obs_or_state: Any) -> int: + def act( + self, + obs_or_state: Any, + public_hand_counts: PublicHandCounts | None = None, + ) -> int: if not isinstance(obs_or_state, GameState): raise TypeError("JAX PPO GUI policy requires a GameState") if obs_or_state.phase == "draw": @@ -160,7 +177,7 @@ class JaxPPOPolicy: return draw snapshot = snapshot_from_state(obs_or_state) - jax_state, hand_slot_map = snapshot_to_jax_state(snapshot) + jax_state, hand_slot_map = snapshot_to_jax_state(snapshot, public_hand_counts) result = evaluate_agent_policy( self.cfg, self.params, @@ -176,4 +193,4 @@ class JaxPPOPolicy: return 2 * hand_slot_map[jax_hand_slot] + place -__all__ = ["JaxPPOPolicy", "snapshot_to_jax_state"] +__all__ = ["JaxPPOPolicy", "PublicHandCounts", "snapshot_to_jax_state"] diff --git a/src/coolrl_lost_cities/games/classic/pygame_table.py b/src/coolrl_lost_cities/games/classic/pygame_table.py new file mode 100644 index 0000000..271b242 --- /dev/null +++ b/src/coolrl_lost_cities/games/classic/pygame_table.py @@ -0,0 +1,1717 @@ +"""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 + +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 + cx = (w - deck_zone) // 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, + ) -> 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.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.end_overlay_at: int | None = None + self.end_backdrop: pygame.Surface | None = None + self.play_again_rect: pygame.Rect | None = None + 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()] + + 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.end_overlay_at = None + self.end_backdrop = None + self.ai_next_at = 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.""" + self.history.append((self.state.clone(), self._copy_public_counts())) + self.future.clear() + state = self.state + seat = state.current_player + 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 + if state.terminal and self.end_overlay_at is None: + self.end_overlay_at = pygame.time.get_ticks() + 1300 + + # -- 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 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] + if self._at_human_decision(): + break + self._sync_view_to_state() + + def redo(self) -> None: + if 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] + if self._at_human_decision(): + break + self._sync_view_to_state() + + def _sync_view_to_state(self) -> 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 + 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 + self.end_backdrop = None + self.end_overlay_at = now if self.state.terminal else None + + # -- helpers --------------------------------------------------------------- + + @property + def ai_seat(self) -> int: + return 1 - self.human_seat + + def human_turn(self) -> bool: + return 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) + self.end_backdrop = None + 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: + self.selected = None + elif event.key in (pygame.K_RETURN, pygame.K_SPACE) and self.state.terminal: + self.new_game() + 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.state.terminal: + if ( + self.end_overlay_at is not None + and now >= self.end_overlay_at + and (self.play_again_rect and self.play_again_rect.collidepoint(pos)) + ): + self.new_game() + 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 _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 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.state.terminal: + return bool(self.play_again_rect and self.play_again_rect.collidepoint(pos)) + 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.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) + if self.state.terminal and self.end_overlay_at is not None: + if now >= self.end_overlay_at: + self._draw_end_overlay(now) + else: + self.play_again_rect = None + + 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", + self.opponent.label if self.opponent.ready else "loading model…", + 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", + "expedition leader", + self.total_score(self.human_seat), + active=human_active, + thinking=False, + now=now, + ) + self._draw_hints(lay) + title_font = self.fonts.serif(20) + title = render_tracked(title_font, "LOST CITIES", GOLD_DIM, 6) + self.screen.blit(title, title.get_rect(topright=(lay.w - lay.margin, 20))) + + def _draw_hints(self, lay: Layout) -> None: + disabled = (72, 72, 74) + parts = [ + ("N NEW GAME", True), + ("CTRL+Y REDO", self.can_redo()), + ("CTRL+Z UNDO", self.can_undo()), + ] + x = lay.w - lay.margin + y = lay.h - 16 + for text, enabled in parts: + surf = render_tracked( + self.fonts.sans(11, bold=True), text, MUTED if enabled else disabled, 2 + ) + rect = surf.get_rect(bottomright=(x, y)) + self.screen.blit(surf, rect) + x = rect.left - 22 + + 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) + 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))) + + # -- end overlay ------------------------------------------------------------- + + def _draw_end_overlay(self, now: int) -> None: + lay = self.layout + if self.end_backdrop is None or self.end_backdrop.get_size() != self.screen.get_size(): + snapshot = self.screen.copy() + blurred = _blur(snapshot, 6) + scrim = pygame.Surface(blurred.get_size(), pygame.SRCALPHA) + scrim.fill((4, 9, 7, 175)) + blurred.blit(scrim, (0, 0)) + self.end_backdrop = blurred + reveal = ease_out_cubic((now - self.end_overlay_at) / 420) + self.screen.blit(self.end_backdrop, (0, 0)) + + me = self.total_score(self.human_seat) + rival = self.total_score(self.ai_seat) + if me > rival: + verdict, vcolor = "YOU PREVAIL", GOLD + elif me < rival: + verdict, vcolor = "THE RIVAL PREVAILS", (206, 130, 116) + else: + verdict, vcolor = "DEAD HEAT", INK + + panel_w, panel_h = 620, 420 + panel = pygame.Rect(0, 0, panel_w, panel_h) + panel.center = (lay.w // 2, int(lay.h * (0.52 - 0.03 * (1 - reveal)))) + surface = pygame.Surface(panel.size, pygame.SRCALPHA) + pygame.draw.rect(surface, (10, 19, 16, 235), surface.get_rect(), border_radius=18) + pygame.draw.rect(surface, (*GOLD_DIM, 220), surface.get_rect(), width=1, border_radius=18) + + header = render_tracked(self.fonts.sans(13, bold=True), "EXPEDITION COMPLETE", MUTED, 4) + surface.blit(header, header.get_rect(midtop=(panel_w // 2, 34))) + title = render_tracked(self.fonts.serif(34), verdict, vcolor, 2) + surface.blit(title, title.get_rect(midtop=(panel_w // 2, 62))) + score_font = self.fonts.serif(26) + dash = score_font.render("—", True, FAINT) + surface.blit(dash, dash.get_rect(center=(panel_w // 2, 132))) + mine_score = score_font.render(str(me), True, INK) + rival_score = score_font.render(str(rival), True, INK) + surface.blit(mine_score, mine_score.get_rect(center=(panel_w // 2 - 90, 132))) + surface.blit(rival_score, rival_score.get_rect(center=(panel_w // 2 + 90, 132))) + for text, dx in (("YOU", -90), ("RIVAL", 90)): + label = render_tracked(self.fonts.sans(10, bold=True), text, FAINT, 2) + surface.blit(label, label.get_rect(center=(panel_w // 2 + dx, 158))) + + # per-suit breakdown + table_y = 196 + col_step = 104 + x0 = panel_w // 2 - col_step * 2 + for color in range(self.config.n_colors): + cx = x0 + color * col_step + glyph = self.art.glyph(color, 30) + surface.blit(glyph, glyph.get_rect(center=(cx, table_y))) + mine = self.expedition_score(self.human_seat, color) + theirs = self.expedition_score(self.ai_seat, color) + mine_s = self.fonts.sans(15, bold=True).render( + f"{mine:+d}" if self.state.expeditions[self.human_seat][color] else "—", + True, + INK if mine >= 0 else (222, 148, 134), + ) + their_s = self.fonts.sans(13).render( + f"{theirs:+d}" if self.state.expeditions[self.ai_seat][color] else "—", True, MUTED + ) + surface.blit(mine_s, mine_s.get_rect(center=(cx, table_y + 36))) + surface.blit(their_s, their_s.get_rect(center=(cx, table_y + 60))) + row_label = self.fonts.sans(11).render("you", True, FAINT) + surface.blit(row_label, row_label.get_rect(midright=(x0 - 64, table_y + 36))) + row_label2 = self.fonts.sans(11).render("rival", True, FAINT) + surface.blit(row_label2, row_label2.get_rect(midright=(x0 - 64, table_y + 60))) + + button = pygame.Rect(0, 0, 220, 46) + button.midtop = (panel_w // 2, 306) + mouse = pygame.mouse.get_pos() + hovered = button.move(panel.topleft).collidepoint(mouse) + pygame.draw.rect(surface, (*GOLD, 40 if hovered else 0), button, border_radius=12) + pygame.draw.rect(surface, GOLD, button, width=1, border_radius=12) + btn_text = render_tracked(self.fonts.sans(14, bold=True), "PLAY AGAIN", GOLD, 3) + surface.blit(btn_text, btn_text.get_rect(center=button.center)) + hint = self.fonts.sans(11).render("enter / space / n", True, FAINT) + surface.blit(hint, hint.get_rect(midtop=(panel_w // 2, 362))) + + surface.set_alpha(int(255 * reveal)) + self.screen.blit(surface, panel) + self.play_again_rect = button.move(panel.topleft) + + # -- 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( + "--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, + ) + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/lost_cities_jax/ppo.py b/src/lost_cities_jax/ppo.py index 61ba9f0..0f9266c 100644 --- a/src/lost_cities_jax/ppo.py +++ b/src/lost_cities_jax/ppo.py @@ -1216,7 +1216,10 @@ def save_checkpoint(path: Path, state: TrainState, cfg: JaxPPOConfig) -> None: def restore_checkpoint(path: Path, state: TrainState) -> TrainState: - return ocp.PyTreeCheckpointer().restore(path.resolve(), item=state) + # Explicit restore_args keep per-leaf shardings; the bare item= form loses + # them on this orbax version and fails with "sharding ... Got None". + restore_args = ocp.checkpoint_utils.construct_restore_args(state) + return ocp.PyTreeCheckpointer().restore(path.resolve(), item=state, restore_args=restore_args) def cli_main(argv: list[str] | None = None) -> None: diff --git a/tests/games/classic/test_jax_ppo_policy.py b/tests/games/classic/test_jax_ppo_policy.py index 264671b..c0bb44b 100644 --- a/tests/games/classic/test_jax_ppo_policy.py +++ b/tests/games/classic/test_jax_ppo_policy.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections import Counter + import numpy as np from coolrl_lost_cities.games.classic.game import GameState, classic_config @@ -58,3 +60,20 @@ def test_jax_policy_splits_atomic_action_across_classic_phases(monkeypatch) -> N assert state.phase == "draw" assert policy.act(state) == expected_draw assert policy.pending_draw is None + + +def test_snapshot_conversion_marks_known_discard_draw_in_public_hand() -> None: + state = GameState.new_game(classic_config(), seed=19) + snapshot = snapshot_from_state(state) + card = snapshot.hands[1][0] + face = (card.color, 0 if card.is_handshake else card.numeric_value(2)) + + converted, _ = snapshot_to_jax_state( + snapshot, + [Counter(), Counter({face: 1})], + ) + + public = np.asarray(converted.hand_public, dtype=bool) + locations = np.asarray(converted.card_loc) + assert public.sum() == 1 + assert np.all(locations[public] == 2) diff --git a/tests/games/classic/test_pygame_table.py b/tests/games/classic/test_pygame_table.py new file mode 100644 index 0000000..d5d5fab --- /dev/null +++ b/tests/games/classic/test_pygame_table.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest +from coolrl_lost_cities.games.classic.game import GameState, classic_config + +pytest.importorskip("pygame") + +from coolrl_lost_cities.games.classic import pygame_table # noqa: E402 + + +def test_argparser_defaults_to_final_candidate() -> None: + args = pygame_table.build_argparser().parse_args([]) + + assert args.checkpoint == str(pygame_table.DEFAULT_CHECKPOINT) + assert args.seed is None + assert args.width == 1600 + assert args.height == 1000 + assert args.offline is False + + +def test_argparser_accepts_overrides() -> None: + args = pygame_table.build_argparser().parse_args( + ["--checkpoint", "/tmp/ckpt", "--seed", "3", "--offline"] + ) + + assert args.checkpoint == "/tmp/ckpt" + assert args.seed == 3 + assert args.offline is True + + +def test_one_suit_per_classic_color() -> None: + config = classic_config() + assert len(pygame_table.SUITS) == config.n_colors + assert len(pygame_table.SUIT_PAINTERS) == config.n_colors + + +def test_card_key_distinguishes_wagers_from_numbers() -> None: + config = classic_config() + state = GameState.new_game(config, seed=0) + keys = [pygame_table.card_key(card, config.min_rank) for card in state.deck] + wagers = [key for key in keys if key[1] == 0] + numbers = [key for key in keys if key[1] != 0] + assert all(config.min_rank <= value <= config.max_rank for _, value in numbers) + assert all(0 <= color < config.n_colors for color, _ in keys) + assert wagers # a shuffled deck slice still contains wager cards + + +def test_layout_scales_between_window_sizes() -> None: + small = pygame_table.Layout(1024, 720, 5) + large = pygame_table.Layout(1920, 1200, 5) + + for layout in (small, large): + assert layout.board_top < layout.discard_cy < layout.board_bottom + assert len(layout.col_x) == 5 + slots = layout.hand_slots(8) + assert len(slots) == 8 + assert slots[0][0] >= 0 + assert slots[-1][0] + layout.hand_w <= layout.w + assert large.hand_w >= small.hand_w + + +def _settle(app: pygame_table.TableApp) -> None: + now = pygame_table.pygame.time.get_ticks() + 10_000 + app._assign_targets(now) + for sprite in app.sprites: + sprite.pos.update(sprite.target) + sprite.release_at = 0 + sprite.pending_flip_at = None + + +def test_discard_draw_stays_public_and_survives_undo_redo() -> None: + app = pygame_table.TableApp(seed=5, offline=True, headless=True) + try: + app.input_locked_until = 0 + # Human discards, draws from deck, then the rival takes that public card. + human_discard = next(i for i in app.state.unified_legal_actions() if i % 2 == 1) + discarded = app.state.hands[app.human_seat][human_discard // 2] + app.apply_unified(human_discard) + app.apply_unified(app.state.card_action_size) + + rival_discard = next( + i + for i in app.state.unified_legal_actions() + if i % 2 == 1 and app.state.hands[app.ai_seat][i // 2].color != discarded.color + ) + app.apply_unified(rival_discard) + draw_discard = app.state.card_action_size + 1 + discarded.color + assert app._legal(draw_discard) + app.apply_unified(draw_discard) + + face = pygame_table.card_key(discarded, app.min_rank) + assert app.public_hand_counts[app.ai_seat][face] == 1 + assert any( + sprite.public and (sprite.color, sprite.value) == face + for sprite in app.hand_zones[app.ai_seat] + ) + + before = app._copy_public_counts() + app.undo() + app.redo() + assert app.public_hand_counts == before + assert any( + sprite.public and sprite.face_up and (sprite.color, sprite.value) == face + for sprite in app.hand_zones[app.ai_seat] + ) + finally: + app.opponent.shutdown() + pygame_table.pygame.quit() + + +def test_missing_checkpoint_is_visible_failure_not_heuristic_fallback(tmp_path) -> None: + opponent = pygame_table.Opponent(tmp_path / "missing", None, seed=1, offline=False) + try: + opponent.load_future.result(timeout=5) + assert opponent.policy is None + assert opponent.load_error is not None + assert opponent.label == "MODEL LOAD FAILED" + finally: + opponent.shutdown() + + +def test_offline_mode_explicitly_uses_heuristic() -> None: + opponent = pygame_table.Opponent(None, None, seed=1, offline=True) + try: + opponent.load_future.result(timeout=5) + assert opponent.policy is not None + assert opponent.load_error is None + assert "offline" in opponent.label + finally: + opponent.shutdown()