diff --git a/tui_client/pyproject.toml b/tui_client/pyproject.toml new file mode 100644 index 0000000..6b3ddc4 --- /dev/null +++ b/tui_client/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "golf-tui" +version = "0.1.0" +description = "Terminal client for the Golf card game" +requires-python = ">=3.11" +dependencies = [ + "textual>=0.47.0", + "websockets>=12.0", + "httpx>=0.25.0", +] + +[project.scripts] +golf-tui = "tui_client.__main__:main" + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/tui_client/src/tui_client/__init__.py b/tui_client/src/tui_client/__init__.py new file mode 100644 index 0000000..c78d8fb --- /dev/null +++ b/tui_client/src/tui_client/__init__.py @@ -0,0 +1 @@ +"""TUI client for the Golf card game.""" diff --git a/tui_client/src/tui_client/__main__.py b/tui_client/src/tui_client/__main__.py new file mode 100644 index 0000000..e333674 --- /dev/null +++ b/tui_client/src/tui_client/__main__.py @@ -0,0 +1,59 @@ +"""Entry point: python -m tui_client [--server HOST] [--no-tls] + +Reads defaults from ~/.config/golf-tui.conf (create with --save-config). +""" + +import argparse +import sys + +from tui_client.config import load_config, save_config, CONFIG_PATH + + +def main(): + cfg = load_config() + + parser = argparse.ArgumentParser(description="Golf Card Game TUI Client") + parser.add_argument( + "--server", + default=cfg.get("server", "golfcards.club"), + help=f"Server host[:port] (default: {cfg.get('server', 'golfcards.club')})", + ) + parser.add_argument( + "--no-tls", + action="store_true", + default=cfg.get("tls", "true").lower() != "true", + help="Use ws:// and http:// instead of wss:// and https://", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging to tui_debug.log", + ) + parser.add_argument( + "--save-config", + action="store_true", + help=f"Save current options as defaults to {CONFIG_PATH}", + ) + args = parser.parse_args() + + if args.save_config: + save_config({ + "server": args.server, + "tls": str(not args.no_tls).lower(), + }) + print(f"Config saved to {CONFIG_PATH}") + print(f" server = {args.server}") + print(f" tls = {str(not args.no_tls).lower()}") + return + + if args.debug: + import logging + logging.basicConfig(level=logging.DEBUG, filename="tui_debug.log") + + from tui_client.app import GolfApp + app = GolfApp(server=args.server, use_tls=not args.no_tls) + app.run() + + +if __name__ == "__main__": + main() diff --git a/tui_client/src/tui_client/app.py b/tui_client/src/tui_client/app.py new file mode 100644 index 0000000..5323153 --- /dev/null +++ b/tui_client/src/tui_client/app.py @@ -0,0 +1,115 @@ +"""Main Textual App for the Golf TUI client.""" + +from __future__ import annotations + +import time + +from textual.app import App, ComposeResult +from textual.message import Message +from textual.widgets import Static + +from tui_client.client import GameClient + + +class ServerMessage(Message): + """A message received from the game server.""" + + def __init__(self, data: dict) -> None: + super().__init__() + self.msg_type: str = data.get("type", "") + self.data: dict = data + + +class KeymapBar(Static): + """Bottom bar showing available keys for the current context.""" + + DEFAULT_CSS = """ + KeymapBar { + dock: bottom; + height: 1; + background: #1a1a2e; + color: #888888; + padding: 0 1; + } + """ + + +class GolfApp(App): + """Golf Card Game TUI Application.""" + + TITLE = "GolfCards.club" + CSS_PATH = "styles.tcss" + + BINDINGS = [ + ("escape", "esc_pressed", ""), + ] + + def __init__(self, server: str, use_tls: bool = True): + super().__init__() + self.client = GameClient(server, use_tls) + self.client._app = self + self.player_id: str | None = None + self._last_escape: float = 0.0 + + def compose(self) -> ComposeResult: + yield KeymapBar(id="keymap-bar") + + def on_mount(self) -> None: + from tui_client.screens.connect import ConnectScreen + self.push_screen(ConnectScreen()) + self._update_keymap() + + def on_screen_resume(self) -> None: + self._update_keymap() + + def post_server_message(self, data: dict) -> None: + """Called from GameClient listener to inject server messages.""" + msg = ServerMessage(data) + self.call_later(self._route_server_message, msg) + + def _route_server_message(self, msg: ServerMessage) -> None: + """Forward a server message to the active screen.""" + screen = self.screen + handler = getattr(screen, "on_server_message", None) + if handler: + handler(msg) + + def action_esc_pressed(self) -> None: + """Double-escape to hard quit. Single escape does nothing.""" + now = time.monotonic() + if now - self._last_escape < 0.5: + self.exit() + else: + self._last_escape = now + + def _update_keymap(self) -> None: + """Update the keymap bar based on current screen.""" + screen_name = self.screen.__class__.__name__ + keymap = getattr(self.screen, "KEYMAP_HINT", None) + if keymap: + text = keymap + elif screen_name == "ConnectScreen": + text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit" + elif screen_name == "LobbyScreen": + text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit" + else: + text = "[Esc Esc] Quit" + try: + self.query_one("#keymap-bar", KeymapBar).update(text) + except Exception: + pass + + def set_keymap(self, text: str) -> None: + """Allow screens to update the keymap bar dynamically. + + Always appends [Esc Esc] Quit on the right for discoverability. + """ + if "[Esc Esc]" not in text: + text = f"{text} [Esc Esc] Quit" + try: + self.query_one("#keymap-bar", KeymapBar).update(text) + except Exception: + pass + + async def on_unmount(self) -> None: + await self.client.disconnect() diff --git a/tui_client/src/tui_client/client.py b/tui_client/src/tui_client/client.py new file mode 100644 index 0000000..10dc6b0 --- /dev/null +++ b/tui_client/src/tui_client/client.py @@ -0,0 +1,114 @@ +"""WebSocket + HTTP networking for the TUI client.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Optional + +import httpx +import websockets +from websockets.asyncio.client import ClientConnection + +logger = logging.getLogger(__name__) + + +class GameClient: + """Handles HTTP auth and WebSocket game communication.""" + + def __init__(self, host: str, use_tls: bool = True): + self.host = host + self.use_tls = use_tls + self._token: Optional[str] = None + self._ws: Optional[ClientConnection] = None + self._listener_task: Optional[asyncio.Task] = None + self._app = None # Set by GolfApp + self._username: Optional[str] = None + + @property + def http_base(self) -> str: + scheme = "https" if self.use_tls else "http" + return f"{scheme}://{self.host}" + + @property + def ws_url(self) -> str: + scheme = "wss" if self.use_tls else "ws" + url = f"{scheme}://{self.host}/ws" + if self._token: + url += f"?token={self._token}" + return url + + @property + def is_authenticated(self) -> bool: + return self._token is not None + + @property + def username(self) -> Optional[str]: + return self._username + + async def login(self, username: str, password: str) -> dict: + """Login via HTTP and store JWT token. + + Returns the response dict on success, raises on failure. + """ + async with httpx.AsyncClient(verify=self.use_tls) as http: + resp = await http.post( + f"{self.http_base}/api/auth/login", + json={"username": username, "password": password}, + ) + if resp.status_code != 200: + detail = resp.json().get("detail", "Login failed") + raise ConnectionError(detail) + data = resp.json() + self._token = data["token"] + self._username = data["user"]["username"] + return data + + async def connect(self) -> None: + """Open WebSocket connection to the server.""" + self._ws = await websockets.connect(self.ws_url) + self._listener_task = asyncio.create_task(self._listen()) + + async def disconnect(self) -> None: + """Close WebSocket connection.""" + if self._listener_task: + self._listener_task.cancel() + try: + await self._listener_task + except asyncio.CancelledError: + pass + self._listener_task = None + if self._ws: + await self._ws.close() + self._ws = None + + async def send(self, msg_type: str, **kwargs) -> None: + """Send a JSON message over WebSocket.""" + if not self._ws: + raise ConnectionError("Not connected") + msg = {"type": msg_type, **kwargs} + logger.debug(f"TX: {msg}") + await self._ws.send(json.dumps(msg)) + + async def _listen(self) -> None: + """Background task: read messages from WebSocket and post to app.""" + try: + async for raw in self._ws: + try: + data = json.loads(raw) + logger.debug(f"RX: {data.get('type', '?')}") + if self._app: + self._app.post_server_message(data) + except json.JSONDecodeError: + logger.warning(f"Non-JSON message: {raw[:100]}") + except websockets.ConnectionClosed as e: + logger.info(f"WebSocket closed: {e}") + if self._app: + self._app.post_server_message({"type": "connection_closed", "reason": str(e)}) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"WebSocket listener error: {e}") + if self._app: + self._app.post_server_message({"type": "connection_error", "reason": str(e)}) diff --git a/tui_client/src/tui_client/config.py b/tui_client/src/tui_client/config.py new file mode 100644 index 0000000..f6be968 --- /dev/null +++ b/tui_client/src/tui_client/config.py @@ -0,0 +1,41 @@ +"""User configuration for the TUI client. + +Config file: ~/.config/golf-tui.conf + +Example contents: + server = golfcards.club + tls = true +""" + +from __future__ import annotations + +import os +from pathlib import Path + +CONFIG_PATH = Path(os.environ.get("GOLF_TUI_CONFIG", "~/.config/golf-tui.conf")).expanduser() + +DEFAULTS = { + "server": "golfcards.club", + "tls": "true", +} + + +def load_config() -> dict[str, str]: + """Load config from file, falling back to defaults.""" + cfg = dict(DEFAULTS) + if CONFIG_PATH.exists(): + for line in CONFIG_PATH.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + cfg[key.strip().lower()] = value.strip() + return cfg + + +def save_config(cfg: dict[str, str]) -> None: + """Write config to file.""" + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{k} = {v}" for k, v in sorted(cfg.items())] + CONFIG_PATH.write_text("\n".join(lines) + "\n") diff --git a/tui_client/src/tui_client/models.py b/tui_client/src/tui_client/models.py new file mode 100644 index 0000000..2e5a25b --- /dev/null +++ b/tui_client/src/tui_client/models.py @@ -0,0 +1,133 @@ +"""Data models for the TUI client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class CardData: + """A single card as received from the server.""" + + suit: Optional[str] = None # "hearts", "diamonds", "clubs", "spades" + rank: Optional[str] = None # "A", "2".."10", "J", "Q", "K", "★" + face_up: bool = False + deck_id: Optional[int] = None + + @classmethod + def from_dict(cls, d: dict) -> CardData: + return cls( + suit=d.get("suit"), + rank=d.get("rank"), + face_up=d.get("face_up", False), + deck_id=d.get("deck_id"), + ) + + @property + def display_suit(self) -> str: + """Unicode suit symbol.""" + return { + "hearts": "\u2665", + "diamonds": "\u2666", + "clubs": "\u2663", + "spades": "\u2660", + }.get(self.suit or "", "") + + @property + def display_rank(self) -> str: + if self.rank == "10": + return "10" + return self.rank or "" + + @property + def is_red(self) -> bool: + return self.suit in ("hearts", "diamonds") + + @property + def is_joker(self) -> bool: + return self.rank == "\u2605" + + +@dataclass +class PlayerData: + """A player as received in game state.""" + + id: str = "" + name: str = "" + cards: list[CardData] = field(default_factory=list) + score: Optional[int] = None + total_score: int = 0 + rounds_won: int = 0 + all_face_up: bool = False + + @classmethod + def from_dict(cls, d: dict) -> PlayerData: + return cls( + id=d.get("id", ""), + name=d.get("name", ""), + cards=[CardData.from_dict(c) for c in d.get("cards", [])], + score=d.get("score"), + total_score=d.get("total_score", 0), + rounds_won=d.get("rounds_won", 0), + all_face_up=d.get("all_face_up", False), + ) + + +@dataclass +class GameState: + """Full game state from the server.""" + + phase: str = "waiting" + players: list[PlayerData] = field(default_factory=list) + current_player_id: Optional[str] = None + dealer_id: Optional[str] = None + discard_top: Optional[CardData] = None + deck_remaining: int = 0 + current_round: int = 1 + total_rounds: int = 1 + has_drawn_card: bool = False + drawn_card: Optional[CardData] = None + drawn_player_id: Optional[str] = None + can_discard: bool = True + waiting_for_initial_flip: bool = False + initial_flips: int = 2 + flip_on_discard: bool = False + flip_mode: str = "never" + flip_is_optional: bool = False + flip_as_action: bool = False + knock_early: bool = False + finisher_id: Optional[str] = None + card_values: dict = field(default_factory=dict) + active_rules: list = field(default_factory=list) + deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"]) + + @classmethod + def from_dict(cls, d: dict) -> GameState: + discard = d.get("discard_top") + drawn = d.get("drawn_card") + return cls( + phase=d.get("phase", "waiting"), + players=[PlayerData.from_dict(p) for p in d.get("players", [])], + current_player_id=d.get("current_player_id"), + dealer_id=d.get("dealer_id"), + discard_top=CardData.from_dict(discard) if discard else None, + deck_remaining=d.get("deck_remaining", 0), + current_round=d.get("current_round", 1), + total_rounds=d.get("total_rounds", 1), + has_drawn_card=d.get("has_drawn_card", False), + drawn_card=CardData.from_dict(drawn) if drawn else None, + drawn_player_id=d.get("drawn_player_id"), + can_discard=d.get("can_discard", True), + waiting_for_initial_flip=d.get("waiting_for_initial_flip", False), + initial_flips=d.get("initial_flips", 2), + flip_on_discard=d.get("flip_on_discard", False), + flip_mode=d.get("flip_mode", "never"), + flip_is_optional=d.get("flip_is_optional", False), + flip_as_action=d.get("flip_as_action", False), + knock_early=d.get("knock_early", False), + finisher_id=d.get("finisher_id"), + card_values=d.get("card_values", {}), + active_rules=d.get("active_rules", []), + deck_colors=d.get("deck_colors", ["red", "blue", "gold"]), + ) diff --git a/tui_client/src/tui_client/screens/__init__.py b/tui_client/src/tui_client/screens/__init__.py new file mode 100644 index 0000000..0954073 --- /dev/null +++ b/tui_client/src/tui_client/screens/__init__.py @@ -0,0 +1 @@ +"""Screen modules for the TUI client.""" diff --git a/tui_client/src/tui_client/screens/connect.py b/tui_client/src/tui_client/screens/connect.py new file mode 100644 index 0000000..6160098 --- /dev/null +++ b/tui_client/src/tui_client/screens/connect.py @@ -0,0 +1,77 @@ +"""Connection screen: server URL + optional login form.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Input, Static + + +class ConnectScreen(Screen): + """Initial screen for connecting to the server.""" + + def compose(self) -> ComposeResult: + with Container(id="connect-container"): + yield Static("GolfCards.club", id="connect-title") + yield Static("Login (optional - leave blank to play as guest)") + yield Input(placeholder="Username", id="input-username") + yield Input(placeholder="Password", password=True, id="input-password") + with Horizontal(id="connect-buttons"): + yield Button("Connect as Guest", id="btn-guest", variant="default") + yield Button("Login & Connect", id="btn-login", variant="primary") + yield Static("", id="connect-status") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-guest": + self._connect(login=False) + elif event.button.id == "btn-login": + self._connect(login=True) + + def on_input_submitted(self, event: Input.Submitted) -> None: + # Enter key in password field triggers login + if event.input.id == "input-password": + self._connect(login=True) + + def _connect(self, login: bool) -> None: + self._set_status("Connecting...") + self._disable_buttons() + self.run_worker(self._do_connect(login), exclusive=True) + + async def _do_connect(self, login: bool) -> None: + client = self.app.client + + try: + if login: + username = self.query_one("#input-username", Input).value.strip() + password = self.query_one("#input-password", Input).value + if not username or not password: + self._set_status("Username and password required") + self._enable_buttons() + return + self._set_status("Logging in...") + await client.login(username, password) + self._set_status(f"Logged in as {client.username}") + + self._set_status("Connecting to WebSocket...") + await client.connect() + self._set_status("Connected!") + + # Move to lobby + from tui_client.screens.lobby import LobbyScreen + self.app.push_screen(LobbyScreen()) + + except Exception as e: + self._set_status(f"Error: {e}") + self._enable_buttons() + + def _set_status(self, text: str) -> None: + self.query_one("#connect-status", Static).update(text) + + def _disable_buttons(self) -> None: + for btn in self.query("Button"): + btn.disabled = True + + def _enable_buttons(self) -> None: + for btn in self.query("Button"): + btn.disabled = False diff --git a/tui_client/src/tui_client/screens/game.py b/tui_client/src/tui_client/screens/game.py new file mode 100644 index 0000000..527180c --- /dev/null +++ b/tui_client/src/tui_client/screens/game.py @@ -0,0 +1,522 @@ +"""Main game board screen with keyboard actions and message dispatch.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.events import Resize +from textual.screen import Screen +from textual.widgets import Static + +from tui_client.models import GameState, PlayerData +from tui_client.widgets.hand import HandWidget +from tui_client.widgets.play_area import PlayAreaWidget +from tui_client.widgets.scoreboard import ScoreboardScreen +from tui_client.widgets.status_bar import StatusBarWidget + + +class GameScreen(Screen): + """Main game board with card display and keyboard controls.""" + + BINDINGS = [ + ("d", "draw_deck", "Draw from deck"), + ("s", "pick_discard", "Pick from discard"), + ("1", "select_1", "Position 1"), + ("2", "select_2", "Position 2"), + ("3", "select_3", "Position 3"), + ("4", "select_4", "Position 4"), + ("5", "select_5", "Position 5"), + ("6", "select_6", "Position 6"), + ("x", "discard_held", "Discard held card"), + ("c", "cancel_draw", "Cancel draw"), + ("f", "flip_mode", "Flip card"), + ("p", "skip_flip", "Skip flip"), + ("k", "knock_early", "Knock early"), + ("n", "next_round", "Next round"), + ] + + def __init__(self, initial_state: dict, is_host: bool = False): + super().__init__() + self._state = GameState.from_dict(initial_state) + self._is_host = is_host + self._player_id: str = "" + self._awaiting_flip = False + self._awaiting_initial_flip = False + self._initial_flip_positions: list[int] = [] + self._can_flip_optional = False + self._term_width: int = 80 + self._term_height: int = 24 + + def compose(self) -> ComposeResult: + yield StatusBarWidget(id="status-bar") + with Container(id="game-content"): + yield Static("", id="opponents-area") + with Horizontal(id="play-area-row"): + yield PlayAreaWidget(id="play-area") + yield Static("", id="local-hand-label") + yield HandWidget(id="local-hand") + yield Static("", id="action-bar") + + def on_mount(self) -> None: + self._player_id = self.app.player_id or "" + self._term_width = self.app.size.width + self._term_height = self.app.size.height + self._full_refresh() + + def on_resize(self, event: Resize) -> None: + self._term_width = event.size.width + self._term_height = event.size.height + self._full_refresh() + + def on_server_message(self, event) -> None: + """Dispatch server messages to handlers.""" + handler = getattr(self, f"_handle_{event.msg_type}", None) + if handler: + handler(event.data) + + # ------------------------------------------------------------------ + # Server message handlers + # ------------------------------------------------------------------ + + def _handle_game_state(self, data: dict) -> None: + state_data = data.get("game_state", data) + self._state = GameState.from_dict(state_data) + self._full_refresh() + + def _handle_your_turn(self, data: dict) -> None: + self._awaiting_flip = False + self._refresh_action_bar() + + def _handle_card_drawn(self, data: dict) -> None: + from tui_client.models import CardData + + card = CardData.from_dict(data.get("card", {})) + source = data.get("source", "deck") + rank = card.display_rank + suit = card.display_suit + + if source == "discard": + self._set_action( + f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel" + ) + self._set_keymap("[1-6] Swap [C] Cancel") + else: + self._set_action( + f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[x] to discard" + ) + self._set_keymap("[1-6] Swap [X] Discard") + + def _handle_can_flip(self, data: dict) -> None: + self._awaiting_flip = True + optional = data.get("optional", False) + self._can_flip_optional = optional + if optional: + self._set_action("Keyboard: Flip a card \[1] thru \[6] or \[p] to skip") + self._set_keymap("[1-6] Flip card [P] Skip") + else: + self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]") + self._set_keymap("[1-6] Flip card") + + def _handle_round_over(self, data: dict) -> None: + scores = data.get("scores", []) + round_num = data.get("round", 1) + total_rounds = data.get("total_rounds", 1) + + self.app.push_screen( + ScoreboardScreen( + scores=scores, + title=f"Round {round_num} Complete", + is_game_over=False, + is_host=self._is_host, + round_num=round_num, + total_rounds=total_rounds, + ), + callback=self._on_scoreboard_dismiss, + ) + + def _handle_game_over(self, data: dict) -> None: + scores = data.get("final_scores", []) + + self.app.push_screen( + ScoreboardScreen( + scores=scores, + title="Game Over!", + is_game_over=True, + is_host=self._is_host, + ), + callback=self._on_scoreboard_dismiss, + ) + + def _handle_round_started(self, data: dict) -> None: + state_data = data.get("game_state", data) + self._state = GameState.from_dict(state_data) + self._awaiting_flip = False + self._awaiting_initial_flip = False + self._initial_flip_positions = [] + self._full_refresh() + + def _handle_game_ended(self, data: dict) -> None: + reason = data.get("reason", "Game ended") + self._set_action(f"{reason}. Press Escape to return to lobby.") + + def _handle_error(self, data: dict) -> None: + msg = data.get("message", "Unknown error") + self._set_action(f"[red]Error: {msg}[/red]") + + def _handle_connection_closed(self, data: dict) -> None: + self._set_action("[red]Connection lost.[/red]") + + def _on_scoreboard_dismiss(self, result: str | None) -> None: + if result == "next_round": + self.run_worker(self._send("next_round")) + elif result == "lobby": + self.run_worker(self._send("leave_game")) + self.app.pop_screen() + + # ------------------------------------------------------------------ + # Click handlers (from widget messages) + # ------------------------------------------------------------------ + + def on_hand_widget_card_clicked(self, event: HandWidget.CardClicked) -> None: + """Handle click on a card in the local hand.""" + self._select_position(event.position) + + def on_play_area_widget_deck_clicked(self, event: PlayAreaWidget.DeckClicked) -> None: + """Handle click on the deck.""" + self.action_draw_deck() + + def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None: + """Handle click on the discard pile.""" + self.action_pick_discard() + + # ------------------------------------------------------------------ + # Keyboard actions + # ------------------------------------------------------------------ + + def action_draw_deck(self) -> None: + if not self._is_my_turn() or self._state.has_drawn_card: + return + self.run_worker(self._send("draw", source="deck")) + + def action_pick_discard(self) -> None: + if not self._is_my_turn() or self._state.has_drawn_card: + return + if not self._state.discard_top: + return + self.run_worker(self._send("draw", source="discard")) + + def action_select_1(self) -> None: + self._select_position(0) + + def action_select_2(self) -> None: + self._select_position(1) + + def action_select_3(self) -> None: + self._select_position(2) + + def action_select_4(self) -> None: + self._select_position(3) + + def action_select_5(self) -> None: + self._select_position(4) + + def action_select_6(self) -> None: + self._select_position(5) + + def _select_position(self, pos: int) -> None: + # Initial flip phase + if self._state.waiting_for_initial_flip: + self._handle_initial_flip_select(pos) + return + + # Flip after discard + if self._awaiting_flip: + self._do_flip(pos) + return + + # Swap with held card + if self._state.has_drawn_card and self._is_my_turn(): + self.run_worker(self._send("swap", position=pos)) + return + + def _handle_initial_flip_select(self, pos: int) -> None: + if pos in self._initial_flip_positions: + return # already selected + # Reject already face-up cards + me = self._get_local_player() + if me and pos < len(me.cards) and me.cards[pos].face_up: + return + + self._initial_flip_positions.append(pos) + + # Immediately show the card as face-up locally for visual feedback + if me and pos < len(me.cards): + me.cards[pos].face_up = True + hand = self.query_one("#local-hand", HandWidget) + hand.update_player( + me, + deck_colors=self._state.deck_colors, + is_current_turn=(me.id == self._state.current_player_id), + is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"), + is_dealer=(me.id == self._state.dealer_id), + ) + + needed = self._state.initial_flips + selected = len(self._initial_flip_positions) + + if selected >= needed: + self.run_worker( + self._send("flip_initial", positions=self._initial_flip_positions) + ) + self._awaiting_initial_flip = False + self._initial_flip_positions = [] + else: + self._set_action( + f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})" + ) + + def _do_flip(self, pos: int) -> None: + me = self._get_local_player() + if me and pos < len(me.cards) and me.cards[pos].face_up: + self._set_action("That card is already face-up! Pick a face-down card.") + return + self.run_worker(self._send("flip_card", position=pos)) + self._awaiting_flip = False + + def action_discard_held(self) -> None: + if not self._is_my_turn() or not self._state.has_drawn_card: + return + if not self._state.can_discard: + self._set_action("Can't discard a card drawn from discard. Swap or cancel.") + return + self.run_worker(self._send("discard")) + + def action_cancel_draw(self) -> None: + if not self._is_my_turn() or not self._state.has_drawn_card: + return + self.run_worker(self._send("cancel_draw")) + + def action_flip_mode(self) -> None: + if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card: + self._awaiting_flip = True + self._set_action("Flip mode: select a face-down card [1-6]") + + def action_skip_flip(self) -> None: + if self._awaiting_flip and self._can_flip_optional: + self.run_worker(self._send("skip_flip")) + self._awaiting_flip = False + + def action_knock_early(self) -> None: + if not self._is_my_turn() or self._state.has_drawn_card: + return + if not self._state.knock_early: + return + self.run_worker(self._send("knock_early")) + + def action_next_round(self) -> None: + if self._is_host and self._state.phase == "round_over": + self.run_worker(self._send("next_round")) + + def action_leave_game(self) -> None: + self.run_worker(self._send("leave_game")) + self.app.pop_screen() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _is_my_turn(self) -> bool: + return self._state.current_player_id == self._player_id + + def _get_local_player(self) -> PlayerData | None: + for p in self._state.players: + if p.id == self._player_id: + return p + return None + + async def _send(self, msg_type: str, **kwargs) -> None: + try: + await self.app.client.send(msg_type, **kwargs) + except Exception as e: + self._set_action(f"[red]Send error: {e}[/red]") + + def _set_action(self, text: str) -> None: + try: + self.query_one("#action-bar", Static).update(text) + except Exception: + pass + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _full_refresh(self) -> None: + """Refresh all widgets from current game state.""" + state = self._state + + # Status bar + status = self.query_one("#status-bar", StatusBarWidget) + status.update_state(state, self._player_id) + + # Play area + play_area = self.query_one("#play-area", PlayAreaWidget) + play_area.update_state(state, local_player_id=self._player_id) + + # Local player hand (in bordered box with turn/knocker indicators) + me = self._get_local_player() + if me: + self.query_one("#local-hand-label", Static).update("") + hand = self.query_one("#local-hand", HandWidget) + hand._is_local = True + hand.update_player( + me, + deck_colors=state.deck_colors, + is_current_turn=(me.id == state.current_player_id), + is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"), + is_dealer=(me.id == state.dealer_id), + ) + else: + self.query_one("#local-hand-label", Static).update("") + + # Opponents - bordered boxes in a single Static + opponents = [p for p in state.players if p.id != self._player_id] + self._render_opponents(opponents) + + # Action bar + self._refresh_action_bar() + + def _render_opponents(self, opponents: list[PlayerData]) -> None: + """Render all opponent hands as bordered boxes into the opponents area. + + Adapts layout based on terminal width: + - Narrow (<80): stack opponents vertically + - Medium (80-119): 2-3 side-by-side with moderate spacing + - Wide (120+): all side-by-side with generous spacing + """ + if not opponents: + self.query_one("#opponents-area", Static).update("") + return + + from tui_client.widgets.hand import _check_column_match, _render_card_lines + from tui_client.widgets.player_box import _visible_len, render_player_box + + state = self._state + deck_colors = state.deck_colors + width = self._term_width + + # Build each opponent's boxed display + opp_blocks: list[list[str]] = [] + for opp in opponents: + cards = opp.cards + matched = _check_column_match(cards) + card_lines = _render_card_lines( + cards, deck_colors=deck_colors, matched=matched, + ) + + box = render_player_box( + opp.name, + score=opp.score, + total_score=opp.total_score, + content_lines=card_lines, + is_current_turn=(opp.id == state.current_player_id), + is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"), + is_dealer=(opp.id == state.dealer_id), + all_face_up=opp.all_face_up, + ) + opp_blocks.append(box) + + # Determine how many opponents fit per row + # Each box is ~21-24 chars wide + opp_width = 22 + if width < 80: + per_row = 1 + gap = " " + elif width < 120: + gap = " " + per_row = max(1, (width - 4) // (opp_width + len(gap))) + else: + gap = " " + per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap))) + + # Render in rows of per_row opponents + all_row_lines: list[str] = [] + for chunk_start in range(0, len(opp_blocks), per_row): + chunk = opp_blocks[chunk_start : chunk_start + per_row] + + if len(chunk) == 1: + all_row_lines.extend(chunk[0]) + else: + max_height = max(len(b) for b in chunk) + # Pad shorter blocks with spaces matching each block's visible width + for b in chunk: + if b: + pad_width = _visible_len(b[0]) + else: + pad_width = 0 + while len(b) < max_height: + b.append(" " * pad_width) + for row_idx in range(max_height): + parts = [b[row_idx] for b in chunk] + all_row_lines.append(gap.join(parts)) + + if chunk_start + per_row < len(opp_blocks): + all_row_lines.append("") + + self.query_one("#opponents-area", Static).update("\n".join(all_row_lines)) + + def _refresh_action_bar(self) -> None: + """Update action bar and keymap based on current game state.""" + state = self._state + + if state.phase in ("round_over", "game_over"): + self._set_action("Keyboard: \[n]ext round") + self._set_keymap("[N] Next round") + return + + if state.waiting_for_initial_flip: + needed = state.initial_flips + selected = len(self._initial_flip_positions) + self._set_action( + f"Keyboard: Choose {needed} cards \[1] thru \[6] to flip ({selected}/{needed})" + ) + self._set_keymap("[1-6] Select card") + return + + if not self._is_my_turn(): + if state.current_player_id: + for p in state.players: + if p.id == state.current_player_id: + self._set_action(f"Waiting for {p.name}...") + self._set_keymap("Waiting...") + return + self._set_action("Waiting...") + self._set_keymap("Waiting...") + return + + if state.has_drawn_card: + keys = ["[1-6] Swap"] + if state.can_discard: + self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[x] to discard") + keys.append("[X] Discard") + else: + self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel") + keys.append("[C] Cancel") + self._set_keymap(" ".join(keys)) + return + + parts = ["Choose \[d]eck or di\[s]card pile"] + keys = ["[D] Draw", "[S] Pick discard"] + if state.flip_as_action: + parts.append("\[f]lip a card") + keys.append("[F] Flip") + if state.knock_early: + parts.append("\[k]nock early") + keys.append("[K] Knock") + self._set_action("Keyboard: " + " or ".join(parts)) + self._set_keymap(" ".join(keys)) + + def _set_keymap(self, text: str) -> None: + try: + self.app.set_keymap(text) + except Exception: + pass diff --git a/tui_client/src/tui_client/screens/lobby.py b/tui_client/src/tui_client/screens/lobby.py new file mode 100644 index 0000000..5f5f067 --- /dev/null +++ b/tui_client/src/tui_client/screens/lobby.py @@ -0,0 +1,445 @@ +"""Lobby screen: create/join room, add CPUs, configure, start game.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + Collapsible, + Input, + Label, + OptionList, + Select, + Static, + Switch, +) +from textual.widgets.option_list import Option + + +DECK_PRESETS = { + "classic": ["red", "blue", "gold"], + "ninja": ["green", "purple", "orange"], + "ocean": ["blue", "teal", "cyan"], + "forest": ["green", "gold", "brown"], + "sunset": ["orange", "red", "purple"], + "berry": ["purple", "pink", "red"], + "neon": ["pink", "cyan", "green"], + "royal": ["purple", "gold", "red"], + "earth": ["brown", "green", "gold"], + "all-red": ["red", "red", "red"], + "all-blue": ["blue", "blue", "blue"], + "all-green": ["green", "green", "green"], +} + + +class LobbyScreen(Screen): + """Room creation, joining, and pre-game configuration.""" + + BINDINGS = [ + ("plus_sign", "add_cpu", "Add CPU"), + ("equals_sign", "add_cpu", "Add CPU"), + ("hyphen_minus", "remove_cpu", "Remove CPU"), + ("enter", "start_or_create", "Start/Create"), + ] + + def __init__(self): + super().__init__() + self._room_code: str | None = None + self._player_id: str | None = None + self._is_host: bool = False + self._players: list[dict] = [] + self._in_room: bool = False + + def compose(self) -> ComposeResult: + with Container(id="lobby-container"): + yield Static("[bold]GolfCards.club[/bold]", id="lobby-title") + yield Static("", id="room-info") + + # Pre-room: join/create + with Vertical(id="pre-room"): + yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code") + with Horizontal(id="pre-room-buttons"): + yield Button("Create Room", id="btn-create", variant="primary") + yield Button("Join Room", id="btn-join", variant="default") + + # In-room: player list + controls + settings + with Vertical(id="in-room"): + yield Static("[bold]Players[/bold]", id="player-list-label") + yield Static("", id="player-list") + + # CPU controls: compact [+] [-] + with Horizontal(id="cpu-controls"): + yield Label("CPU:", id="cpu-label") + yield Button("+", id="btn-cpu-add", variant="default") + yield Button("−", id="btn-cpu-remove", variant="warning") + yield Button("?", id="btn-cpu-random", variant="default") + + # CPU profile picker (hidden by default) + yield OptionList(id="cpu-profile-list") + + # Host settings (collapsible sections) + with Vertical(id="host-settings"): + with Collapsible(title="Game Settings", collapsed=True, id="coll-game"): + with Horizontal(classes="setting-row"): + yield Label("Rounds") + yield Select( + [(str(v), v) for v in (1, 3, 9, 18)], + value=9, + id="sel-rounds", + allow_blank=False, + ) + with Horizontal(classes="setting-row"): + yield Label("Decks") + yield Select( + [(str(v), v) for v in (1, 2, 3)], + value=1, + id="sel-decks", + allow_blank=False, + ) + with Horizontal(classes="setting-row"): + yield Label("Initial Flips") + yield Select( + [(str(v), v) for v in (0, 1, 2)], + value=2, + id="sel-initial-flips", + allow_blank=False, + ) + with Horizontal(classes="setting-row"): + yield Label("Flip Mode") + yield Select( + [("Never", "never"), ("Always", "always"), ("Endgame", "endgame")], + value="never", + id="sel-flip-mode", + allow_blank=False, + ) + + with Collapsible(title="House Rules", collapsed=True, id="coll-rules"): + # Joker variant + with Horizontal(classes="setting-row"): + yield Label("Jokers") + yield Select( + [ + ("None", "none"), + ("Standard (−2)", "standard"), + ("Lucky Swing (−5)", "lucky_swing"), + ("Eagle Eye (+2/−4)", "eagle_eye"), + ], + value="none", + id="sel-jokers", + allow_blank=False, + ) + + # Scoring rules + yield Static("[bold]Scoring[/bold]", classes="rules-header") + with Horizontal(classes="rule-row"): + yield Label("Super Kings (K = −2)") + yield Switch(id="sw-super_kings") + with Horizontal(classes="rule-row"): + yield Label("Ten Penny (10 = 1)") + yield Switch(id="sw-ten_penny") + with Horizontal(classes="rule-row"): + yield Label("One-Eyed Jacks (J♥/J♠ = 0)") + yield Switch(id="sw-one_eyed_jacks") + with Horizontal(classes="rule-row"): + yield Label("Negative Pairs Keep Value") + yield Switch(id="sw-negative_pairs_keep_value") + with Horizontal(classes="rule-row"): + yield Label("Four of a Kind (−20)") + yield Switch(id="sw-four_of_a_kind") + + # Knock & Endgame + yield Static("[bold]Knock & Endgame[/bold]", classes="rules-header") + with Horizontal(classes="rule-row"): + yield Label("Knock Penalty (+10)") + yield Switch(id="sw-knock_penalty") + with Horizontal(classes="rule-row"): + yield Label("Knock Bonus (−5)") + yield Switch(id="sw-knock_bonus") + with Horizontal(classes="rule-row"): + yield Label("Knock Early") + yield Switch(id="sw-knock_early") + with Horizontal(classes="rule-row"): + yield Label("Flip as Action") + yield Switch(id="sw-flip_as_action") + + # Bonuses & Penalties + yield Static("[bold]Bonuses & Penalties[/bold]", classes="rules-header") + with Horizontal(classes="rule-row"): + yield Label("Underdog Bonus (−3)") + yield Switch(id="sw-underdog_bonus") + with Horizontal(classes="rule-row"): + yield Label("Tied Shame (+5)") + yield Switch(id="sw-tied_shame") + with Horizontal(classes="rule-row"): + yield Label("Blackjack (21→0)") + yield Switch(id="sw-blackjack") + with Horizontal(classes="rule-row"): + yield Label("Wolfpack") + yield Switch(id="sw-wolfpack") + + with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"): + with Horizontal(classes="setting-row"): + yield Select( + [(name.replace("-", " ").title(), name) for name in DECK_PRESETS], + value="classic", + id="sel-deck-style", + allow_blank=False, + ) + yield Static( + self._render_deck_preview("classic"), + id="deck-preview", + ) + + yield Button("Start Game", id="btn-start", variant="success") + + yield Static("", id="lobby-status") + + def on_mount(self) -> None: + self._update_visibility() + self._update_keymap() + + def _update_visibility(self) -> None: + try: + self.query_one("#pre-room").display = not self._in_room + self.query_one("#in-room").display = self._in_room + # Host-only controls + self.query_one("#cpu-controls").display = self._in_room and self._is_host + self.query_one("#host-settings").display = self._in_room and self._is_host + self.query_one("#btn-start").display = self._in_room and self._is_host + except Exception: + pass + + def _update_keymap(self) -> None: + try: + if self._in_room and self._is_host: + self.app.set_keymap("[+] Add CPU [−] Remove CPU [Enter] Start Game [Esc Esc] Quit") + elif self._in_room: + self.app.set_keymap("Waiting for host to start... [Esc Esc] Quit") + else: + self.app.set_keymap("[Tab] Navigate [Enter] Create/Join [Esc Esc] Quit") + except Exception: + pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-create": + self._create_room() + elif event.button.id == "btn-join": + self._join_room() + elif event.button.id == "btn-cpu-add": + self._show_cpu_picker() + elif event.button.id == "btn-cpu-remove": + self._remove_cpu() + elif event.button.id == "btn-cpu-random": + self._add_random_cpu() + elif event.button.id == "btn-start": + self._start_game() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "input-room-code": + code = event.value.strip() + if code: + self._join_room() + else: + self._create_room() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if event.option_list.id == "cpu-profile-list": + profile_name = str(event.option.id) if event.option.id else "" + self.run_worker(self._send("add_cpu", profile_name=profile_name)) + event.option_list.display = False + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "sel-deck-style" and event.value is not None: + try: + preview = self.query_one("#deck-preview", Static) + preview.update(self._render_deck_preview(str(event.value))) + except Exception: + pass + + def action_add_cpu(self) -> None: + if self._in_room and self._is_host: + self._show_cpu_picker() + + def action_remove_cpu(self) -> None: + if self._in_room and self._is_host: + self._remove_cpu() + + def action_start_or_create(self) -> None: + if self._in_room and self._is_host: + self._start_game() + elif not self._in_room: + code = self.query_one("#input-room-code", Input).value.strip() + if code: + self._join_room() + else: + self._create_room() + + def _create_room(self) -> None: + player_name = self.app.client.username or "Player" + self.run_worker(self._send("create_room", player_name=player_name)) + + def _join_room(self) -> None: + code = self.query_one("#input-room-code", Input).value.strip().upper() + if not code: + self._set_status("Enter a room code to join") + return + player_name = self.app.client.username or "Player" + self.run_worker(self._send("join_room", room_code=code, player_name=player_name)) + + @staticmethod + def _render_deck_preview(preset_name: str) -> str: + """Render mini card-back swatches for a deck color preset.""" + from tui_client.widgets.card import BACK_COLORS + + colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"]) + # Show unique colors only (e.g. all-red shows one wider swatch) + seen: list[str] = [] + for c in colors: + if c not in seen: + seen.append(c) + + parts: list[str] = [] + for color_name in seen: + hex_color = BACK_COLORS.get(color_name, BACK_COLORS["red"]) + parts.append(f"[{hex_color}]░░░[/]") + return " ".join(parts) + + def _add_random_cpu(self) -> None: + """Add a random CPU (server picks the profile).""" + self.run_worker(self._send("add_cpu")) + + def _show_cpu_picker(self) -> None: + """Request CPU profiles from server and show picker.""" + self.run_worker(self._send("get_cpu_profiles")) + + def _handle_cpu_profiles(self, data: dict) -> None: + """Populate and show the CPU profile option list.""" + profiles = data.get("profiles", []) + option_list = self.query_one("#cpu-profile-list", OptionList) + option_list.clear_options() + for p in profiles: + name = p.get("name", "?") + style = p.get("style", "") + option_list.add_option(Option(f"{name} — {style}", id=name)) + option_list.display = True + option_list.focus() + + def _remove_cpu(self) -> None: + self.run_worker(self._send("remove_cpu")) + + def _collect_settings(self) -> dict: + """Read all Select/Switch values and return kwargs for start_game.""" + settings: dict = {} + + try: + settings["rounds"] = self.query_one("#sel-rounds", Select).value + settings["decks"] = self.query_one("#sel-decks", Select).value + settings["initial_flips"] = self.query_one("#sel-initial-flips", Select).value + settings["flip_mode"] = self.query_one("#sel-flip-mode", Select).value + except Exception: + settings.setdefault("rounds", 9) + settings.setdefault("decks", 1) + settings.setdefault("initial_flips", 2) + settings.setdefault("flip_mode", "never") + + # Joker variant → booleans + try: + joker_mode = self.query_one("#sel-jokers", Select).value + except Exception: + joker_mode = "none" + + settings["use_jokers"] = joker_mode != "none" + settings["lucky_swing"] = joker_mode == "lucky_swing" + settings["eagle_eye"] = joker_mode == "eagle_eye" + + # Boolean house rules from switches + rule_ids = [ + "super_kings", "ten_penny", "one_eyed_jacks", + "negative_pairs_keep_value", "four_of_a_kind", + "knock_penalty", "knock_bonus", "knock_early", "flip_as_action", + "underdog_bonus", "tied_shame", "blackjack", "wolfpack", + ] + for rule_id in rule_ids: + try: + settings[rule_id] = self.query_one(f"#sw-{rule_id}", Switch).value + except Exception: + settings[rule_id] = False + + # Deck colors from preset + try: + preset = self.query_one("#sel-deck-style", Select).value + settings["deck_colors"] = DECK_PRESETS.get(preset, ["red", "blue", "gold"]) + except Exception: + settings["deck_colors"] = ["red", "blue", "gold"] + + return settings + + def _start_game(self) -> None: + self._set_status("Starting game...") + settings = self._collect_settings() + self.run_worker(self._send("start_game", **settings)) + + async def _send(self, msg_type: str, **kwargs) -> None: + try: + await self.app.client.send(msg_type, **kwargs) + except Exception as e: + self._set_status(f"Error: {e}") + + def on_server_message(self, event) -> None: + handler = getattr(self, f"_handle_{event.msg_type}", None) + if handler: + handler(event.data) + + def _handle_room_created(self, data: dict) -> None: + self._room_code = data.get("room_code", "") + self._player_id = data.get("player_id", "") + self.app.player_id = self._player_id + self._is_host = True + self._in_room = True + self._set_room_info(f"Room [bold]{self._room_code}[/bold] (You are host)") + self._set_status("Add CPU opponents, then start when ready.") + self._update_visibility() + self._update_keymap() + + def _handle_room_joined(self, data: dict) -> None: + self._room_code = data.get("room_code", "") + self._player_id = data.get("player_id", "") + self.app.player_id = self._player_id + self._in_room = True + self._set_room_info(f"Room [bold]{self._room_code}[/bold]") + self._set_status("Waiting for host to start the game.") + self._update_visibility() + self._update_keymap() + + def _handle_player_joined(self, data: dict) -> None: + self._players = data.get("players", []) + self._refresh_player_list() + + def _handle_game_started(self, data: dict) -> None: + from tui_client.screens.game import GameScreen + game_state = data.get("game_state", {}) + self.app.push_screen(GameScreen(game_state, self._is_host)) + + def _handle_error(self, data: dict) -> None: + self._set_status(f"[red]Error: {data.get('message', 'Unknown error')}[/red]") + + def _refresh_player_list(self) -> None: + lines = [] + for i, p in enumerate(self._players, 1): + name = p.get("name", "?") + tags = [] + if p.get("is_host"): + tags.append("[bold cyan]Host[/bold cyan]") + if p.get("is_cpu"): + tags.append("[yellow]CPU[/yellow]") + suffix = f" {' '.join(tags)}" if tags else "" + lines.append(f" {i}. {name}{suffix}") + self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)") + + def _set_room_info(self, text: str) -> None: + self.query_one("#room-info", Static).update(text) + + def _set_status(self, text: str) -> None: + self.query_one("#lobby-status", Static).update(text) diff --git a/tui_client/src/tui_client/styles.tcss b/tui_client/src/tui_client/styles.tcss new file mode 100644 index 0000000..65f7716 --- /dev/null +++ b/tui_client/src/tui_client/styles.tcss @@ -0,0 +1,294 @@ +/* Base app styles */ +Screen { + background: $surface; +} + +/* Connect screen */ +ConnectScreen { + align: center middle; +} + +#connect-container { + width: 80%; + max-width: 64; + min-width: 40; + height: auto; + border: thick $primary; + padding: 1 2; +} + +#connect-container Static { + text-align: center; + width: 100%; +} + +#connect-title { + text-style: bold; + color: $text; + margin-bottom: 1; +} + +#connect-container Input { + margin-bottom: 1; +} + +#connect-buttons { + height: 3; + align: center middle; + margin-top: 1; +} + +#connect-buttons Button { + margin: 0 1; +} + +#connect-status { + text-align: center; + color: $warning; + margin-top: 1; + height: 1; +} + +/* Lobby screen */ +LobbyScreen { + align: center middle; +} + +#lobby-container { + width: 80%; + max-width: 72; + min-width: 40; + height: auto; + border: thick $primary; + padding: 1 2; +} + +#lobby-title { + text-style: bold; + text-align: center; + width: 100%; + margin-bottom: 1; +} + +#room-info { + text-align: center; + height: auto; + margin-bottom: 1; +} + +/* Pre-room: join/create controls */ +#pre-room { + height: auto; +} + +#input-room-code { + margin-bottom: 1; +} + +#pre-room-buttons { + height: 3; + align: center middle; +} + +#pre-room-buttons Button { + margin: 0 1; +} + +/* In-room: player list + controls */ +#in-room { + height: auto; +} + +#player-list-label { + margin-bottom: 0; +} + +#player-list { + height: auto; + min-height: 3; + max-height: 12; + border: tall $primary; + padding: 0 1; + margin-bottom: 1; +} + +/* CPU controls: compact [+] [-] */ +#cpu-controls { + height: 3; + align: center middle; +} + +#cpu-controls Button { + min-width: 5; + margin: 0 1; +} + +#cpu-label { + padding: 1 1 0 0; +} + +#cpu-profile-list { + height: auto; + max-height: 12; + border: tall $accent; + margin-bottom: 1; + display: none; +} + +/* Host settings */ +#host-settings { + height: auto; + margin-top: 1; +} + +.setting-row { + height: 3; + align: left middle; +} + +.setting-row Label { + width: 1fr; + padding: 1 1 0 0; +} + +.setting-row Select { + width: 24; +} + +#deck-preview { + width: auto; + padding: 1 1 0 1; +} + +.rule-row { + height: 3; + align: left middle; +} + +.rule-row Label { + width: 1fr; + padding: 1 1 0 0; +} + +.rule-row Switch { + width: auto; +} + +.rules-header { + margin-top: 1; + margin-bottom: 0; +} + +#btn-start { + width: 100%; + margin-top: 1; +} + +#lobby-status { + text-align: center; + color: $warning; + height: auto; + margin-top: 1; +} + +/* Game screen */ +GameScreen { + align: center top; + layout: vertical; +} + +#game-content { + width: 100%; + max-width: 120; + height: 100%; + layout: vertical; +} + +#status-bar { + height: 1; + dock: top; + background: $primary; + color: $text; + padding: 0 2; +} + +#opponents-area { + height: auto; + max-height: 50%; + padding: 1 2 0 2; + text-align: center; + content-align: center middle; +} + +#play-area-row { + height: auto; + align: center middle; +} + +#play-area { + height: auto; + width: auto; + padding: 0 2; + border: round $primary-lighten-2; + text-align: center; + content-align: center middle; +} + +#action-bar { + height: auto; + min-height: 1; + max-height: 3; + dock: bottom; + background: $surface-darken-1; + padding: 0 2; + text-align: center; + content-align: center middle; +} + +/* Local hand label */ +#local-hand-label { + text-align: center; + height: 1; +} + +/* Local hand widget */ +#local-hand { + height: auto; + margin-top: 1; + text-align: center; + content-align: center middle; +} + +/* Scoreboard overlay */ +#scoreboard-overlay { + align: center middle; + background: $surface 80%; +} + +#scoreboard-container { + width: 80%; + max-width: 64; + min-width: 40; + height: auto; + max-height: 80%; + border: thick $primary; + padding: 1 2; + background: $surface; +} + +#scoreboard-title { + text-style: bold; + text-align: center; + margin-bottom: 1; +} + +#scoreboard-table { + width: 100%; + height: auto; +} + +#scoreboard-buttons { + height: 3; + align: center middle; + margin-top: 1; +} diff --git a/tui_client/src/tui_client/widgets/__init__.py b/tui_client/src/tui_client/widgets/__init__.py new file mode 100644 index 0000000..f5e7e5f --- /dev/null +++ b/tui_client/src/tui_client/widgets/__init__.py @@ -0,0 +1 @@ +"""Widget modules for the TUI client.""" diff --git a/tui_client/src/tui_client/widgets/card.py b/tui_client/src/tui_client/widgets/card.py new file mode 100644 index 0000000..71a0f3b --- /dev/null +++ b/tui_client/src/tui_client/widgets/card.py @@ -0,0 +1,161 @@ +"""Single card widget using Unicode box-drawing with Rich color markup.""" + +from __future__ import annotations + +from textual.widgets import Static + +from tui_client.models import CardData + + +# Web UI card back colors mapped to terminal hex equivalents +BACK_COLORS: dict[str, str] = { + "red": "#c41e3a", + "blue": "#2e5cb8", + "green": "#228b22", + "gold": "#daa520", + "purple": "#6a0dad", + "teal": "#008b8b", + "pink": "#db7093", + "slate": "#4a5568", + "orange": "#e67e22", + "cyan": "#00bcd4", + "brown": "#8b4513", + "yellow": "#daa520", +} + +# Face-up card text colors (matching web UI) +SUIT_RED = "#c0392b" # hearts, diamonds +SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg) +JOKER_COLOR = "#9b59b6" # purple +BORDER_COLOR = "#888888" # card border +EMPTY_COLOR = "#555555" # empty card slot +POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs + + +def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str: + """Get the hex color for a face-down card's back based on deck_id.""" + if deck_colors and card.deck_id is not None and card.deck_id < len(deck_colors): + name = deck_colors[card.deck_id] + else: + name = "red" + return BACK_COLORS.get(name, BACK_COLORS["red"]) + + +def _top_border(position: int | None, d: str, color: str) -> str: + """Top border line, with position number replacing ┌ when present.""" + if position is not None: + return f"[{d}{color}]{position}───┐[/{d}{color}]" + return f"[{d}{color}]┌───┐[/{d}{color}]" + + +def render_card( + card: CardData | None, + selected: bool = False, + position: int | None = None, + deck_colors: list[str] | None = None, + dim: bool = False, +) -> str: + """Render a card as a 4-line Rich-markup string. + + Face-up: Face-down: Empty: + ┌───┐ 1───┐ ┌───┐ + │ A │ │░░░│ │ │ + │ ♠ │ │░░░│ │ │ + └───┘ └───┘ └───┘ + """ + d = "dim " if dim else "" + bc = BORDER_COLOR + + # Empty slot + if card is None: + c = EMPTY_COLOR + return ( + f"[{d}{c}]┌───┐[/{d}{c}]\n" + f"[{d}{c}]│ │[/{d}{c}]\n" + f"[{d}{c}]│ │[/{d}{c}]\n" + f"[{d}{c}]└───┘[/{d}{c}]" + ) + + top = _top_border(position, d, bc) + + # Face-down card with colored back + if not card.face_up: + back = _back_color_for_card(card, deck_colors) + return ( + f"{top}\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]└───┘[/{d}{bc}]" + ) + + # Joker + if card.is_joker: + jc = JOKER_COLOR + icon = "🐉" if card.suit == "hearts" else "👹" + return ( + f"{top}\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]└───┘[/{d}{bc}]" + ) + + # Face-up normal card + fc = SUIT_RED if card.is_red else SUIT_BLACK + rank = card.display_rank + suit = card.display_suit + rank_line = f"{rank:^3}" + suit_line = f"{suit:^3}" + + return ( + f"{top}\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{suit_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]└───┘[/{d}{bc}]" + ) + + +class CardWidget(Static): + """A single card display widget.""" + + def __init__( + self, + card: CardData | None = None, + selected: bool = False, + position: int | None = None, + matched: bool = False, + deck_colors: list[str] | None = None, + **kwargs, + ): + super().__init__(**kwargs) + self._card = card + self._selected = selected + self._position = position + self._matched = matched + self._deck_colors = deck_colors + + def on_mount(self) -> None: + self._refresh_display() + + def update_card( + self, + card: CardData | None, + selected: bool = False, + matched: bool = False, + deck_colors: list[str] | None = None, + ) -> None: + self._card = card + self._selected = selected + self._matched = matched + if deck_colors is not None: + self._deck_colors = deck_colors + self._refresh_display() + + def _refresh_display(self) -> None: + text = render_card( + self._card, + self._selected, + self._position, + deck_colors=self._deck_colors, + dim=self._matched, + ) + self.update(text) diff --git a/tui_client/src/tui_client/widgets/hand.py b/tui_client/src/tui_client/widgets/hand.py new file mode 100644 index 0000000..9e00546 --- /dev/null +++ b/tui_client/src/tui_client/widgets/hand.py @@ -0,0 +1,230 @@ +"""2x3 card grid for one player's hand.""" + +from __future__ import annotations + +from textual.events import Click +from textual.message import Message +from textual.widgets import Static + +from tui_client.models import CardData, PlayerData +from tui_client.widgets.card import render_card + +# Color for the match connector lines (muted green — pairs cancel to 0) +_MATCH_COLOR = "#6a9955" + + +def _check_column_match(cards: list[CardData]) -> list[bool]: + """Check which cards are in matched columns (both face-up, same rank). + + Cards layout: [0][1][2] + [3][4][5] + Columns: (0,3), (1,4), (2,5) + """ + matched = [False] * 6 + if len(cards) < 6: + return matched + + for col in range(3): + top = cards[col] + bot = cards[col + 3] + if ( + top.face_up + and bot.face_up + and top.rank is not None + and top.rank == bot.rank + ): + matched[col] = True + matched[col + 3] = True + return matched + + +def _build_match_connector(matched: list[bool]) -> str | None: + """Build a connector line with ║ ║ under each matched column. + + Card layout: 5-char card, 1-char gap, repeated. + Positions: 01234 5 6789A B CDEFG + card0 card1 card2 + + For each matched column, place ║ at offsets 1 and 3 within the card span. + """ + has_any = matched[0] or matched[1] or matched[2] + if not has_any: + return None + + # Build a 17-char line (3 cards * 5 + 2 gaps) + chars = list(" " * 17) + for col in range(3): + if matched[col]: # top card matched implies column matched + base = col * 6 # card start position (0, 6, 12) + chars[base + 1] = "║" + chars[base + 3] = "║" + + line = "".join(chars) + return f"[{_MATCH_COLOR}]{line}[/]" + + +def _render_card_lines( + cards: list[CardData], + *, + is_local: bool = False, + deck_colors: list[str] | None = None, + matched: list[bool] | None = None, +) -> list[str]: + """Render the 2x3 card grid as a list of text lines (no box). + + Inserts a connector line with ║ ║ between rows for matched columns. + """ + if matched is None: + matched = _check_column_match(cards) + lines: list[str] = [] + for row_idx, row_start in enumerate((0, 3)): + # Insert connector between row 0 and row 1 + if row_idx == 1: + connector = _build_match_connector(matched) + if connector: + lines.append(connector) + + row_line_parts: list[list[str]] = [] + for i in range(3): + idx = row_start + i + card = cards[idx] if idx < len(cards) else None + pos = idx + 1 if is_local else None + text = render_card( + card, + position=pos, + deck_colors=deck_colors, + dim=matched[idx], + ) + card_lines = text.split("\n") + while len(row_line_parts) < len(card_lines): + row_line_parts.append([]) + for ln_idx, ln in enumerate(card_lines): + row_line_parts[ln_idx].append(ln) + for parts in row_line_parts: + lines.append(" ".join(parts)) + return lines + + +class HandWidget(Static): + """Displays a player's 2x3 card grid as rich text, wrapped in a player box.""" + + class CardClicked(Message): + """Posted when a card position is clicked in the local hand.""" + + def __init__(self, position: int) -> None: + super().__init__() + self.position = position + + def __init__( + self, + player: PlayerData | None = None, + is_local: bool = False, + deck_colors: list[str] | None = None, + **kwargs, + ): + super().__init__(**kwargs) + self._player = player + self._is_local = is_local + self._deck_colors = deck_colors + # State flags for the player box + self._is_current_turn: bool = False + self._is_knocker: bool = False + self._is_dealer: bool = False + self._has_connector: bool = False + + def update_player( + self, + player: PlayerData, + deck_colors: list[str] | None = None, + *, + is_current_turn: bool = False, + is_knocker: bool = False, + is_dealer: bool = False, + ) -> None: + self._player = player + if deck_colors is not None: + self._deck_colors = deck_colors + self._is_current_turn = is_current_turn + self._is_knocker = is_knocker + self._is_dealer = is_dealer + self._refresh() + + def on_mount(self) -> None: + self._refresh() + + def on_click(self, event: Click) -> None: + """Map click coordinates to card position (0-5).""" + if not self._is_local: + return + + # Box layout: + # Line 0: top border + # Lines 1-4: row 0 cards (4 lines each) + # Line 5 (optional): match connector + # Lines 5-8 or 6-9: row 1 cards + # Last line: bottom border + # + # Content x: │ then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap) + + x, y = event.x, event.y + + # Determine column from x (content starts at x=2 inside box) + # Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18 + col = -1 + if 2 <= x <= 6: + col = 0 + elif 8 <= x <= 12: + col = 1 + elif 14 <= x <= 18: + col = 2 + + if col < 0: + return + + # Determine row from y + # y=0: top border, y=1..4: row 0 cards, then optional connector, then row 1 + row = -1 + if 1 <= y <= 4: + row = 0 + else: + row1_start = 6 if self._has_connector else 5 + if row1_start <= y <= row1_start + 3: + row = 1 + + if row < 0: + return + + position = row * 3 + col + self.post_message(self.CardClicked(position)) + + def _refresh(self) -> None: + if not self._player or not self._player.cards: + self.update("") + return + + from tui_client.widgets.player_box import render_player_box + + cards = self._player.cards + matched = _check_column_match(cards) + self._has_connector = any(matched[:3]) + + card_lines = _render_card_lines( + cards, + is_local=self._is_local, + deck_colors=self._deck_colors, + matched=matched, + ) + + box_lines = render_player_box( + self._player.name, + score=self._player.score, + total_score=self._player.total_score, + content_lines=card_lines, + is_current_turn=self._is_current_turn, + is_knocker=self._is_knocker, + is_dealer=self._is_dealer, + is_local=self._is_local, + all_face_up=self._player.all_face_up, + ) + + self.update("\n".join(box_lines)) diff --git a/tui_client/src/tui_client/widgets/play_area.py b/tui_client/src/tui_client/widgets/play_area.py new file mode 100644 index 0000000..141f9bd --- /dev/null +++ b/tui_client/src/tui_client/widgets/play_area.py @@ -0,0 +1,128 @@ +"""Deck + discard + held card area.""" + +from __future__ import annotations + +import re +from dataclasses import replace + +from textual.events import Click +from textual.message import Message +from textual.widgets import Static + +from tui_client.models import CardData, GameState +from tui_client.widgets.card import render_card + +# Fixed column width for each card section (card is 5 wide) +_COL_WIDTH = 12 + +# Lime green for the held card highlight +_HOLDING_COLOR = "#80ff00" + + +def _pad_center(text: str, width: int) -> str: + """Center-pad a plain or Rich-markup string to *width* visible chars.""" + visible = re.sub(r"\[.*?\]", "", text) + pad = max(0, width - len(visible)) + left = pad // 2 + right = pad - left + return " " * left + text + " " * right + + +class PlayAreaWidget(Static): + """Displays the deck, discard pile, and held card. + + Layout order: DECK [HOLDING] DISCARD + HOLDING only appears when the player has drawn a card. + """ + + class DeckClicked(Message): + """Posted when the deck is clicked.""" + + class DiscardClicked(Message): + """Posted when the discard pile is clicked.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._state: GameState | None = None + self._local_player_id: str = "" + self._has_holding: bool = False + + def update_state(self, state: GameState, local_player_id: str = "") -> None: + self._state = state + if local_player_id: + self._local_player_id = local_player_id + self._refresh() + + def on_mount(self) -> None: + self._refresh() + + def on_click(self, event: Click) -> None: + """Map click to deck or discard column.""" + x = event.x + # Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col) + if x < _COL_WIDTH: + self.post_message(self.DeckClicked()) + elif self._has_holding and x >= 2 * _COL_WIDTH: + self.post_message(self.DiscardClicked()) + elif not self._has_holding and x >= _COL_WIDTH: + self.post_message(self.DiscardClicked()) + + def _refresh(self) -> None: + if not self._state: + self.update("") + return + + state = self._state + + # Deck card (face-down) + deck_card = CardData(face_up=False, deck_id=0) + deck_text = render_card(deck_card, deck_colors=state.deck_colors) + deck_lines = deck_text.split("\n") + + # Discard card + discard_text = render_card(state.discard_top, deck_colors=state.deck_colors) + discard_lines = discard_text.split("\n") + + # Held card — force face_up so render_card shows the face + # Only show when it's the local player holding + held_lines = None + local_holding = ( + state.has_drawn_card + and state.drawn_card + and state.drawn_player_id == self._local_player_id + ) + if local_holding: + revealed = replace(state.drawn_card, face_up=True) + held_text = render_card(revealed, deck_colors=state.deck_colors) + held_lines = held_text.split("\n") + + self._has_holding = held_lines is not None + + # Always render 3 columns so the box stays a fixed width + num_card_lines = max(len(deck_lines), len(discard_lines)) + lines = [] + for i in range(num_card_lines): + d = deck_lines[i] if i < len(deck_lines) else " " + c = discard_lines[i] if i < len(discard_lines) else " " + row = _pad_center(d, _COL_WIDTH) + if held_lines: + h = held_lines[i] if i < len(held_lines) else " " + row += _pad_center(h, _COL_WIDTH) + else: + row += " " * _COL_WIDTH + row += _pad_center(c, _COL_WIDTH) + lines.append(row) + + # Labels row — always 3 columns + deck_label = f"DECK:{state.deck_remaining}" + discard_label = "DISCARD" + label = _pad_center(deck_label, _COL_WIDTH) + if held_lines: + holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]" + label += _pad_center(holding_label, _COL_WIDTH) + else: + label += " " * _COL_WIDTH + label += _pad_center(discard_label, _COL_WIDTH) + lines.append(label) + + self.update("\n".join(lines)) diff --git a/tui_client/src/tui_client/widgets/player_box.py b/tui_client/src/tui_client/widgets/player_box.py new file mode 100644 index 0000000..4d5856c --- /dev/null +++ b/tui_client/src/tui_client/widgets/player_box.py @@ -0,0 +1,115 @@ +"""Bordered player container with name, score, and state indicators.""" + +from __future__ import annotations + +import re + +# Border colors matching web UI palette +_BORDER_NORMAL = "#555555" +_BORDER_TURN_LOCAL = "#9ab973" # green — your turn +_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn +_BORDER_KNOCKER = "#ff6b35" # red-orange — went out + +_NAME_COLOR = "#e0e0e0" + + +def _visible_len(text: str) -> int: + """Length of text with Rich markup tags stripped.""" + return len(re.sub(r"\[.*?\]", "", text)) + + +def render_player_box( + name: str, + score: int | None, + total_score: int, + content_lines: list[str], + *, + is_current_turn: bool = False, + is_knocker: bool = False, + is_dealer: bool = False, + is_local: bool = False, + all_face_up: bool = False, +) -> list[str]: + """Render a bordered player container with name/score header. + + Every line in the returned list has the same visible width (``box_width``). + + Layout:: + + ╭─ Name ────── 15 ─╮ + │ ┌───┐ ┌───┐ ┌───┐│ + │ │ A │ │░░░│ │ 7 ││ + │ │ ♠ │ │░░░│ │ ♦ ││ + │ └───┘ └───┘ └───┘│ + │ ┌───┐ ┌───┐ ┌───┐│ + │ │ 4 │ │ 5 │ │ Q ││ + │ │ ♣ │ │ ♥ │ │ ♦ ││ + │ └───┘ └───┘ └───┘│ + ╰──────────────────╯ + """ + # Pick border color based on state + if is_knocker: + bc = _BORDER_KNOCKER + elif is_current_turn and is_local: + bc = _BORDER_TURN_LOCAL + elif is_current_turn: + bc = _BORDER_TURN_OPPONENT + else: + bc = _BORDER_NORMAL + + # Build display name + display_name = name + if is_dealer: + display_name = f"Ⓓ {display_name}" + if all_face_up: + display_name += " ✓" + if is_knocker: + display_name += " OUT" + + # Score text + score_text = f"{score}" if score is not None else f"{total_score}" + + # Compute box width. Every line is exactly box_width visible chars. + # Content row: │ │ => box_width = vis(content) + 4 + max_vis = max((_visible_len(line) for line in content_lines), default=17) + name_part = f" {display_name} " + score_part = f" {score_text} " + # Top row: ╭─ ─╮ + # = 4 + len(name_part) + fill + len(score_part) + min_top = 4 + len(name_part) + 1 + len(score_part) # fill>=1 + box_width = max(max_vis + 4, 21, min_top) + + # Possibly truncate name if it still doesn't fit + fill_len = box_width - 4 - len(name_part) - len(score_part) + if fill_len < 1: + max_name = box_width - 4 - len(score_part) - 4 + display_name = display_name[: max(3, max_name)] + "…" + name_part = f" {display_name} " + fill_len = box_width - 4 - len(name_part) - len(score_part) + + fill = "─" * max(1, fill_len) + + # Top border + top = ( + f"[{bc}]╭─[/]" + f"[bold {_NAME_COLOR}]{name_part}[/]" + f"[{bc}]{fill}[/]" + f"[bold]{score_part}[/]" + f"[{bc}]─╮[/]" + ) + + result = [top] + + # Content lines + inner = box_width - 2 # chars between │ and │ + for line in content_lines: + vis_len = _visible_len(line) + right_pad = max(0, inner - 1 - vis_len) + result.append( + f"[{bc}]│[/] {line}{' ' * right_pad}[{bc}]│[/]" + ) + + # Bottom border + result.append(f"[{bc}]╰{'─' * inner}╯[/]") + + return result diff --git a/tui_client/src/tui_client/widgets/scoreboard.py b/tui_client/src/tui_client/widgets/scoreboard.py new file mode 100644 index 0000000..2a22c59 --- /dev/null +++ b/tui_client/src/tui_client/widgets/scoreboard.py @@ -0,0 +1,69 @@ +"""Scoreboard overlay for round/game over.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, DataTable, Static + + +class ScoreboardScreen(ModalScreen[str]): + """Modal overlay showing round or game scores.""" + + def __init__( + self, + scores: list[dict], + title: str = "Round Over", + is_game_over: bool = False, + is_host: bool = False, + round_num: int = 1, + total_rounds: int = 1, + ): + super().__init__() + self._scores = scores + self._title = title + self._is_game_over = is_game_over + self._is_host = is_host + self._round_num = round_num + self._total_rounds = total_rounds + + def compose(self) -> ComposeResult: + with Container(id="scoreboard-container"): + yield Static(self._title, id="scoreboard-title") + yield DataTable(id="scoreboard-table") + with Horizontal(id="scoreboard-buttons"): + if self._is_game_over: + yield Button("Back to Lobby", id="btn-lobby", variant="primary") + elif self._is_host: + yield Button("Next Round", id="btn-next-round", variant="primary") + else: + yield Button("Waiting for host...", id="btn-waiting", disabled=True) + + def on_mount(self) -> None: + table = self.query_one("#scoreboard-table", DataTable) + + if self._is_game_over: + table.add_columns("Rank", "Player", "Total", "Rounds Won") + for i, s in enumerate(self._scores, 1): + table.add_row( + str(i), + s.get("name", "?"), + str(s.get("total", 0)), + str(s.get("rounds_won", 0)), + ) + else: + table.add_columns("Player", "Round Score", "Total", "Rounds Won") + for s in self._scores: + table.add_row( + s.get("name", "?"), + str(s.get("score", 0)), + str(s.get("total", 0)), + str(s.get("rounds_won", 0)), + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-next-round": + self.dismiss("next_round") + elif event.button.id == "btn-lobby": + self.dismiss("lobby") diff --git a/tui_client/src/tui_client/widgets/status_bar.py b/tui_client/src/tui_client/widgets/status_bar.py new file mode 100644 index 0000000..3ed59a1 --- /dev/null +++ b/tui_client/src/tui_client/widgets/status_bar.py @@ -0,0 +1,75 @@ +"""Status bar showing phase, turn info, and action prompts.""" + +from __future__ import annotations + +from textual.widgets import Static + +from tui_client.models import GameState + + +class StatusBarWidget(Static): + """Top status bar with round, phase, and turn info.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._state: GameState | None = None + self._player_id: str | None = None + self._extra: str = "" + + def update_state(self, state: GameState, player_id: str | None = None) -> None: + self._state = state + self._player_id = player_id + self._refresh() + + def set_extra(self, text: str) -> None: + self._extra = text + self._refresh() + + def _refresh(self) -> None: + if not self._state: + self.update("Connecting...") + return + + state = self._state + parts = [] + + # Round info + parts.append(f"Round {state.current_round}/{state.total_rounds}") + + # Phase + phase_display = { + "waiting": "Waiting", + "initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]", + "playing": "Playing", + "final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]", + "round_over": "[white on #555555] Round Over [/white on #555555]", + "game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]", + }.get(state.phase, state.phase) + parts.append(phase_display) + + # Turn info (skip during initial flip - it's misleading) + if state.current_player_id and state.players and state.phase != "initial_flip": + if state.current_player_id == self._player_id: + parts.append("[bold white on #2e7d32] YOUR TURN [/bold white on #2e7d32]") + else: + for p in state.players: + if p.id == state.current_player_id: + parts.append(f"[white on #555555] {p.name}'s Turn [/white on #555555]") + break + + # Finisher indicator + if state.finisher_id: + for p in state.players: + if p.id == state.finisher_id: + parts.append(f"[bold white on #b8860b] {p.name} finished! [/bold white on #b8860b]") + break + + # Active rules + if state.active_rules: + parts.append(f"Rules: {', '.join(state.active_rules)}") + + text = " │ ".join(parts) + if self._extra: + text += f" {self._extra}" + + self.update(text)