"""Splendor game.""" from __future__ import annotations import sys from typing import TYPE_CHECKING, Any from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.widget import Widget from textual.widgets import Footer, Header, Input, Static from .base import ( BASE_COLORS, GEM_COLORS, Action, BuyCard, BuyCardReserved, Card, GameState, GemColor, Noble, PlayerState, ReserveCard, Strategy, TakeDifferent, TakeDouble, ) if TYPE_CHECKING: from collections.abc import Mapping # Abbreviations used when rendering costs COST_ABBR: dict[GemColor, str] = { "white": "W", "blue": "B", "green": "G", "red": "R", "black": "K", "gold": "O", } # Abbreviations players can type on the command line COLOR_ABBR_TO_FULL: dict[str, GemColor] = { "w": "white", "b": "blue", "g": "green", "r": "red", "k": "black", "o": "gold", } def parse_color_token(raw: str) -> GemColor: """Convert user input into a GemColor. Supports: - full names: white, blue, green, red, black, gold - abbreviations: w, b, g, r, k, o """ key = raw.lower() # full color names first if key in BASE_COLORS: return key # type: ignore[return-value] # abbreviations if key in COLOR_ABBR_TO_FULL: return COLOR_ABBR_TO_FULL[key] error = f"Unknown color: {raw}" raise ValueError(error) def format_cost(cost: Mapping[GemColor, int]) -> str: """Format a cost/requirements dict as colored tokens like 'B:2, R:1'. Uses `color_token` internally so colors are guaranteed to match your bank. """ parts: list[str] = [] for color in GEM_COLORS: n = cost.get(color, 0) if not n: continue # color_token gives us e.g. "[blue]blue: 3[/]" token = color_token(color, n) # Turn the leading color name into the abbreviation (blue: 3 → B:3) # We only replace the first occurrence. full = f"{color}:" abbr = f"{COST_ABBR[color]}:" token = token.replace(full, abbr, 1) parts.append(token) return ", ".join(parts) if parts else "-" def format_card(card: Card) -> str: """Readable card line using dataclass fields instead of __str__.""" color_abbr = COST_ABBR[card.color] header = f"T{card.tier} {color_abbr} P{card.points}" cost_str = format_cost(card.cost) return f"{header} ({cost_str})" def format_noble(noble: Noble) -> str: """Readable noble line using dataclass fields instead of __str__.""" cost_str = format_cost(noble.requirements) return f"{noble.name} +{noble.points} ({cost_str})" def format_tokens(tokens: Mapping[GemColor, int]) -> str: """Colored 'color: n' list for a token dict.""" return " ".join(color_token(c, tokens.get(c, 0)) for c in GEM_COLORS) def format_discounts(discounts: Mapping[GemColor, int]) -> str: """Colored discounts, skipping zeros.""" parts: list[str] = [] for c in GEM_COLORS: n = discounts.get(c, 0) if not n: continue abbr = COST_ABBR[c] fg, bg = COLOR_STYLE[c] parts.append(f"[{fg} on {bg}]{abbr}:{n}[/{fg} on {bg}]") return ", ".join(parts) if parts else "-" COLOR_STYLE: dict[GemColor, tuple[str, str]] = { "white": ("black", "white"), # fg, bg "blue": ("bright_white", "blue"), "green": ("bright_white", "sea_green4"), "red": ("white", "red3"), "black": ("white", "grey0"), "gold": ("black", "yellow3"), } def fmt_gem(color: GemColor) -> str: """Render gem name with fg/bg matching real token color.""" fg, bg = COLOR_STYLE[color] return f"[{fg} on {bg}] {color} [/{fg} on {bg}]" def fmt_number(value: int) -> str: """Return a Rich-markup colored 'value' string.""" return f"[bold cyan]{value}[/]" def color_token(name: GemColor, amount: int) -> str: """Return a Rich-markup colored 'name: n' string.""" # Map Splendor colors -> terminal colors color_map: Mapping[GemColor, str] = { "white": "white", "blue": "blue", "green": "green", "red": "red", "black": "grey70", # 'black' is unreadable on dark backgrounds "gold": "yellow", } style = color_map.get(name, "white") return f"[{style}]{name}: {amount}[/]" class Board(Widget): """Big board widget with the layout you sketched.""" def __init__(self, game: GameState, me: PlayerState, **kwargs: Any) -> None: # noqa: ANN401 """Initialize the board widget.""" super().__init__(**kwargs) self.game = game self.me = me def compose(self) -> ComposeResult: """Compose the board widget.""" # Structure: # ┌ bank row # ├ middle row (tiers | nobles) # └ players row with Vertical(id="board_root"): yield Static(id="bank_box") with Horizontal(id="middle_row"): with Vertical(id="tiers_box"): yield Static(id="tier1_box") yield Static(id="tier2_box") yield Static(id="tier3_box") yield Static(id="nobles_box") yield Static(id="players_box") def on_mount(self) -> None: """Refresh the board content.""" self.refresh_content() def refresh_content(self) -> None: """Refresh the board content.""" self._render_bank() self._render_tiers() self._render_nobles() self._render_players() # --- sections ---------------------------------------------------- def _render_bank(self) -> None: bank = self.game.bank parts: list[str] = ["[b]Bank:[/b]"] # One line, all tokens colored parts.append(format_tokens(bank)) self.query_one("#bank_box", Static).update("\n".join(parts)) def _render_tiers(self) -> None: for tier in (1, 2, 3): box = self.query_one(f"#tier{tier}_box", Static) cards: list[Card] = self.game.table_by_tier.get(tier, []) lines: list[str] = [f"[b]Tier {tier} cards:[/b]"] if not cards: lines.append(" (none)") else: for idx, card in enumerate(cards): lines.append(f" [{idx}] {format_card(card)}") box.update("\n".join(lines)) def _render_nobles(self) -> None: nobles_box = self.query_one("#nobles_box", Static) lines: list[str] = ["[b]Nobles[/b]"] if not self.game.available_nobles: lines.append(" (none)") else: lines.extend(" - " + format_noble(noble) for noble in self.game.available_nobles) nobles_box.update("\n".join(lines)) def _render_players(self) -> None: players_box = self.query_one("#players_box", Static) lines: list[str] = ["[b]Players:[/b]", ""] for player in self.game.players: mark = "*" if player is self.me else " " token_str = format_tokens(player.tokens) discount_str = format_discounts(player.discounts) lines.append( f"{mark} {player.name:10} Score={player.score:2d} Discounts={discount_str}", ) lines.append(f" Tokens: {token_str}") if player.nobles: noble_names = ", ".join(n.name for n in player.nobles) lines.append(f" Nobles: {noble_names}") # Optional: show counts of cards / reserved if player.cards: lines.append(f" Cards: {len(player.cards)}") if player.reserved: lines.append(f" Reserved: {len(player.reserved)}") lines.append("") players_box.update("\n".join(lines)) class ActionApp(App[None]): """Textual app that asks for a single action command and returns an Action.""" CSS = """ Screen { /* 3 rows: command zone, board, footer */ layout: grid; grid-size: 1 3; grid-rows: auto 1fr auto; } /* Top area with input + instructions */ #command_zone { grid-columns: 1; grid-rows: 1; padding: 1 1; } /* Board sits in the middle row and can grow */ #board { grid-columns: 1; grid-rows: 2; padding: 0 1 1 1; } Footer { grid-columns: 1; grid-rows: 3; } Input { border: round $accent; } /* === Board layout === */ #board_root { /* outer frame around the whole board area */ border: heavy white; padding: 0 1; } /* Bank row: full width */ #bank_box { border: heavy white; padding: 0 1; } /* Middle row: tiers (left) + nobles (right) */ #middle_row { layout: horizontal; } #tiers_box { border: heavy white; padding: 0 1; width: 70%; } #tier1_box, #tier2_box, #tier3_box { border-bottom: heavy white; padding: 0 0 1 0; margin-bottom: 1; } #nobles_box { border: heavy white; padding: 0 1; width: 30%; } /* Players row: full width at bottom */ #players_box { border: heavy white; padding: 0 1; } """ def __init__(self, game: GameState, player: PlayerState) -> None: """Initialize the action app.""" super().__init__() self.game = game self.player = player self.result: Action | None = None self.message: str = "" def compose(self) -> ComposeResult: """Compose the action app.""" # Row 1: input + Actions text with Vertical(id="command_zone"): yield Input( placeholder="Enter command, e.g. '1 white blue red' or '1 w b r' or 'q'", id="input_line", ) yield Static("", id="prompt") # Row 2: board yield Board(self.game, self.player, id="board") # Row 3: footer yield Footer() def on_mount(self) -> None: """Mount the action app.""" self._update_prompt() self.query_one(Input).focus() def _update_prompt(self) -> None: lines: list[str] = [] lines.append("[bold underline]Actions:[/]") lines.append( " [bold green]1[/] - Take up to 3 different gem colors " "(e.g. [cyan]1 white blue red[/] or [cyan]1 w b r[/])", ) lines.append( f" [bold green]2[/] - Take 2 of the same color (needs {fmt_number(4)} in bank, " "e.g. [cyan]2 blue[/] or [cyan]2 b[/])", ) lines.append( " [bold green]3[/] - Buy a face-up card (e.g. [cyan]3 1 0[/] for tier 1, index 0)", ) lines.append(" [bold green]4[/] - Buy a reserved card") lines.append(" [bold green]5[/] - Reserve a face-up card") lines.append(" [bold green]6[/] - Reserve top card of a deck") lines.append(" [bold red]q[/] - Quit game") if self.message: lines.append("") lines.append(f"[bold red]Message:[/] {self.message}") self.query_one("#prompt", Static).update("\n".join(lines)) def _cmd_1(self, parts: list[str]) -> str | None: """Take up to 3 different gem colors: 1 white blue red OR 1 w b r.""" color_names = parts[1:] if not color_names: return "Need at least one color (full name or abbreviation)." colors: list[GemColor] = [] for name in color_names: color = parse_color_token(name) if self.game.bank[color] <= 0: return f"No tokens left for color: {color}" colors.append(color) self.result = TakeDifferent(colors=colors[:3]) self.exit() return None def _cmd_2(self, parts: list[str]) -> str | None: """Take two of the same color.""" if len(parts) < 2: # noqa: PLR2004 return "Usage: 2 " color = parse_color_token(parts[1]) if self.game.bank[color] < self.game.config.minimum_tokens_to_buy_2: return "Bank must have at least 4 of that color." self.result = TakeDouble(color=color) self.exit() return None def _cmd_3(self, parts: list[str]) -> str | None: """Buy face-up card.""" if len(parts) < 3: # noqa: PLR2004 return "Usage: 3 " tier = int(parts[1]) idx = int(parts[2]) self.result = BuyCard(tier=tier, index=idx) self.exit() return None def _cmd_4(self, parts: list[str]) -> str | None: """Buy reserved card.""" if len(parts) < 2: # noqa: PLR2004 return "Usage: 4 " idx = int(parts[1]) if not (0 <= idx < len(self.player.reserved)): return "Reserved index out of range." self.result = BuyCardReserved(tier=0, index=idx) self.exit() return None def _cmd_5(self, parts: list[str]) -> str | None: """Reserve face-up card.""" if len(parts) < 3: # noqa: PLR2004 return "Usage: 5 " tier = int(parts[1]) idx = int(parts[2]) self.result = ReserveCard(tier=tier, index=idx, from_deck=False) self.exit() return None def _cmd_6(self, parts: list[str]) -> str | None: """Reserve top of deck.""" if len(parts) < 2: # noqa: PLR2004 return "Usage: 6 " tier = int(parts[1]) self.result = ReserveCard(tier=tier, index=None, from_deck=True) self.exit() return None def _unknown_cmd(self, _parts: list[str]) -> str: return "Unknown command." def on_input_submitted(self, event: Input.Submitted) -> None: """Handle user input.""" text = (event.value or "").strip() event.input.value = "" if not text: return if text.lower() in {"q", "quit", "0"}: self.result = None self.exit() return parts = text.split() cmds = { "1": self._cmd_1, "2": self._cmd_2, "3": self._cmd_3, "4": self._cmd_4, "5": self._cmd_5, "6": self._cmd_6, } cmd = parts[0] error = cmds.get(cmd, self._unknown_cmd)(parts) if error: self.message = error self._update_prompt() return class DiscardApp(App[None]): """Textual app to choose discards when over token limit.""" CSS = """ Screen { layout: vertical; } #command_zone { padding: 1 1; } #board { padding: 0 1 1 1; } Input { border: round $accent; } """ def __init__(self, game: GameState, player: PlayerState) -> None: """Initialize the discard app.""" super().__init__() self.game = game self.player = player self.discards: dict[GemColor, int] = dict.fromkeys(GEM_COLORS, 0) self.message: str = "" def compose(self) -> ComposeResult: # type: ignore[override] """Compose the discard app.""" yield Header(show_clock=False) with Vertical(id="command_zone"): yield Input( placeholder="Enter color to discard, e.g. 'blue' or 'b'", id="input_line", ) yield Static("", id="prompt") # Board directly under the command zone yield Board(self.game, self.player, id="board") yield Footer() def on_mount(self) -> None: # type: ignore[override] """Mount the discard app.""" self._update_prompt() self.query_one(Input).focus() def _remaining_to_discard(self) -> int: return self.player.total_tokens() - sum(self.discards.values()) - self.game.config.token_limit def _update_prompt(self) -> None: remaining = max(self._remaining_to_discard(), 0) lines: list[str] = [] lines.append( "You must discard " f"{fmt_number(remaining)} token(s) " f"to get down to {fmt_number(self.game.config.token_limit)}.", ) disc_str = ", ".join(f"{fmt_gem(c)}={fmt_number(self.discards[c])}" for c in GEM_COLORS) lines.append(f"Current planned discards: {{ {disc_str} }}") lines.append( "Type a color name or abbreviation (e.g. 'blue' or 'b') to discard one token.", ) if self.message: lines.append("") lines.append(f"[bold red]Message:[/] {self.message}") self.query_one("#prompt", Static).update("\n".join(lines)) def on_input_submitted(self, event: Input.Submitted) -> None: # type: ignore[override] """Handle user input.""" raw = (event.value or "").strip() event.input.value = "" if not raw: return try: color = parse_color_token(raw) except ValueError: self.message = f"Unknown color: {raw}" self._update_prompt() return available = self.player.tokens[color] - self.discards[color] if available <= 0: self.message = f"No more {color} tokens available to discard." self._update_prompt() return self.discards[color] += 1 if self._remaining_to_discard() <= 0: self.exit() return self.message = "" self._update_prompt() # --------------------------------------------------------------------------- # Noble choice app # --------------------------------------------------------------------------- class NobleChoiceApp(App[None]): """Textual app to choose one noble.""" CSS = """ Screen { layout: vertical; } #command_zone { padding: 1 1; } #board { padding: 0 1 1 1; } Input { border: round $accent; } """ def __init__( self, game: GameState, player: PlayerState, nobles: list[Noble], ) -> None: """Initialize the noble choice app.""" super().__init__() self.game = game self.player = player self.nobles = nobles self.result: Noble | None = None self.message: str = "" def compose(self) -> ComposeResult: # type: ignore[override] """Compose the noble choice app.""" yield Header(show_clock=False) with Vertical(id="command_zone"): yield Input( placeholder="Enter noble index, e.g. '0'", id="input_line", ) yield Static("", id="prompt") # Board directly under the command zone yield Board(self.game, self.player, id="board") yield Footer() def on_mount(self) -> None: # type: ignore[override] """Mount the noble choice app.""" self._update_prompt() self.query_one(Input).focus() def _update_prompt(self) -> None: lines: list[str] = [] lines.append("[bold underline]You qualify for nobles:[/]") for i, noble in enumerate(self.nobles): lines.append(f" [bright_cyan]{i})[/] {format_noble(noble)}") lines.append("Enter the index of the noble you want.") if self.message: lines.append("") lines.append(f"[bold red]Message:[/] {self.message}") self.query_one("#prompt", Static).update("\n".join(lines)) def on_input_submitted(self, event: Input.Submitted) -> None: # type: ignore[override] """Handle user input.""" raw = (event.value or "").strip() event.input.value = "" if not raw: return try: idx = int(raw) except ValueError: self.message = "Please enter a valid integer index." self._update_prompt() return if not (0 <= idx < len(self.nobles)): self.message = "Index out of range." self._update_prompt() return self.result = self.nobles[idx] self.exit() class TuiHuman(Strategy): """Textual-based human player Strategy with colorful board.""" def choose_action( self, game: GameState, player: PlayerState, ) -> Action | None: """Choose an action for the player.""" if not sys.stdout.isatty(): return None app = ActionApp(game, player) app.run() return app.result def choose_discard( self, game: GameState, player: PlayerState, excess: int, # noqa: ARG002 ) -> dict[GemColor, int]: """Choose tokens to discard.""" if not sys.stdout.isatty(): return dict.fromkeys(GEM_COLORS, 0) app = DiscardApp(game, player) app.run() return app.discards def choose_noble( self, game: GameState, player: PlayerState, nobles: list[Noble], ) -> Noble: """Choose a noble for the player.""" if not sys.stdout.isatty(): return nobles[0] app = NobleChoiceApp(game, player, nobles) app.run() return app.result