diff --git a/overlays/default.nix b/overlays/default.nix index e01e64d..362bd67 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -30,7 +30,9 @@ pytest-xdist requests ruff + scalene sqlalchemy + textual typer types-requests ] diff --git a/pyproject.toml b/pyproject.toml index 9514221..19bba30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ lint.ignore = [ "ERA001", # (perm) I don't care about print statements dir ] +"python/splendor/**" = [ + "S311", # (perm) there is no security issue here +] + [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/python/splendor/base.py b/python/splendor/base.py new file mode 100644 index 0000000..0238440 --- /dev/null +++ b/python/splendor/base.py @@ -0,0 +1,646 @@ +"""Base logic for the Splendor game.""" + +from __future__ import annotations + +import itertools +import random +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, Protocol + +if TYPE_CHECKING: + from collections.abc import Sequence + +GemColor = Literal["white", "blue", "green", "red", "black", "gold"] + +GEM_COLORS: tuple[GemColor, ...] = ( + "white", + "blue", + "green", + "red", + "black", + "gold", +) +BASE_COLORS: tuple[GemColor, ...] = ( + "white", + "blue", + "green", + "red", + "black", +) + +GEM_ORDER: list[GemColor] = list(GEM_COLORS) +GEM_INDEX: dict[GemColor, int] = {c: i for i, c in enumerate(GEM_ORDER)} +BASE_INDEX: dict[GemColor, int] = {c: i for i, c in enumerate(BASE_COLORS)} + + +@dataclass(frozen=True) +class Card: + """Development card: gives points + a permanent gem discount.""" + + tier: int + points: int + color: GemColor + cost: dict[GemColor, int] + + +@dataclass(frozen=True) +class Noble: + """Noble tile: gives points if you have enough bonuses.""" + + name: str + points: int + requirements: dict[GemColor, int] + + +@dataclass +class PlayerState: + """State of a player in the game.""" + + strategy: Strategy + tokens: dict[GemColor, int] = field(default_factory=lambda: dict.fromkeys(GEM_COLORS, 0)) + discounts: dict[GemColor, int] = field(default_factory=lambda: dict.fromkeys(GEM_COLORS, 0)) + cards: list[Card] = field(default_factory=list) + reserved: list[Card] = field(default_factory=list) + nobles: list[Noble] = field(default_factory=list) + card_score: int = 0 + noble_score: int = 0 + + def total_tokens(self) -> int: + """Total tokens in player's bank.""" + return sum(self.tokens.values()) + + def update_card_score(self) -> None: + """Recalculate card score.""" + self.card_score = sum(card.points for card in self.cards) + + def update_noble_score(self) -> None: + """Recalculate noble score.""" + self.noble_score = sum(noble.points for noble in self.nobles) + + @property + def score(self) -> int: + """Total points in player's cards + nobles.""" + return self.card_score + self.noble_score + + def can_afford(self, card: Card) -> bool: + """Check if player can afford card, using discounts + gold.""" + missing = 0 + gold = self.tokens["gold"] + + for color, cost in card.cost.items(): + missing += max(0, cost - self.discounts.get(color, 0) - self.tokens.get(color, 0)) + if missing > gold: + return False + + return True + + def pay_for_card(self, card: Card) -> dict[GemColor, int]: + """Pay tokens for card, move card to tableau, return payment for bank.""" + if not self.can_afford(card): + msg = f"{self.name} cannot afford card {card}" + raise ValueError(msg) + + payment: dict[GemColor, int] = dict.fromkeys(GEM_COLORS, 0) + gold_available = self.tokens["gold"] + + for color in BASE_COLORS: + cost = card.cost.get(color, 0) + effective_cost = max(0, cost - self.discounts.get(color, 0)) + + use = min(self.tokens[color], effective_cost) + self.tokens[color] -= use + payment[color] += use + + remaining = effective_cost - use + if remaining > 0: + use_gold = min(gold_available, remaining) + gold_available -= use_gold + self.tokens["gold"] -= use_gold + payment["gold"] += use_gold + + self.cards.append(card) + self.discounts[card.color] += 1 + return payment + + +def get_default_starting_tokens(player_count: int) -> dict[GemColor, int]: + """get_default_starting_tokens.""" + token_count = (player_count * player_count - 3 * player_count + 10) // 2 + return { + "white": token_count, + "blue": token_count, + "green": token_count, + "red": token_count, + "black": token_count, + "gold": 5, + } + + +@dataclass +class GameConfig: + """Game configuration: gems, bank, cards, nobles, etc.""" + + win_score: int = 15 + table_cards_per_tier: int = 4 + reserve_limit: int = 3 + token_limit: int = 10 + turn_limit: int = 1000 + minimum_tokens_to_buy_2: int = 4 + + cards: list[Card] = field(default_factory=list) + nobles: list[Noble] = field(default_factory=list) + + +class GameState: + """Game state: players, bank, decks, table, available nobles, etc.""" + + def __init__( + self, + config: GameConfig, + players: list[PlayerState], + bank: dict[GemColor, int], + decks_by_tier: dict[int, list[Card]], + table_by_tier: dict[int, list[Card]], + available_nobles: list[Noble], + ) -> None: + """Game state.""" + self.config = config + self.players = players + self.bank = bank + self.decks_by_tier = decks_by_tier + self.table_by_tier = table_by_tier + self.available_nobles = available_nobles + self.noble_min_requirements = 0 + self.get_noble_min_requirements() + self.current_player_index = 0 + self.finished = False + + def get_noble_min_requirements(self) -> None: + """Find the minimum requirement for all available nobles.""" + test = 0 + + for noble in self.available_nobles: + test = max(test, min(foo for foo in noble.requirements.values())) + + self.noble_min_requirements = test + + def next_player(self) -> None: + """Advance to the next player.""" + self.current_player_index = (self.current_player_index + 1) % len(self.players) + + @property + def current_player(self) -> PlayerState: + """Current player.""" + return self.players[self.current_player_index] + + def refill_table(self) -> None: + """Refill face-up cards from decks.""" + for tier, deck in self.decks_by_tier.items(): + table = self.table_by_tier[tier] + while len(table) < self.config.table_cards_per_tier and deck: + table.append(deck.pop()) + + def check_winner_simple(self) -> PlayerState | None: + """Simplified: end immediately when someone hits win_score.""" + eligible = [player for player in self.players if player.score >= self.config.win_score] + if not eligible: + return None + eligible.sort( + key=lambda p: (p.score, -len(p.cards)), + reverse=True, + ) + self.finished = True + return eligible[0] + + +class Action(Protocol): + """Marker protocol for actions.""" + + +@dataclass +class TakeDifferent(Action): + """Take up to 3 different gem colors.""" + + colors: list[GemColor] + + +@dataclass +class TakeDouble(Action): + """Take two of the same color.""" + + color: GemColor + + +@dataclass +class BuyCard(Action): + """Buy a face-up card.""" + + tier: int + index: int + from_reserved: bool = False + + +@dataclass +class ReserveCard(Action): + """Reserve a face-up card.""" + + tier: int + index: int | None = None + from_deck: bool = False + + +class Strategy(Protocol): + """Implement this to make a bot or human controller.""" + + def __init__(self, name: str) -> None: + """Initialize a strategy.""" + self.name = name + + def choose_action(self, game: GameState, player: PlayerState) -> Action | None: + """Return an Action, or None to concede/end.""" + raise NotImplementedError + + def choose_discard( + self, + game: GameState, # noqa: ARG002 + player: PlayerState, + excess: int, + ) -> dict[GemColor, int]: + """Called if player has more than token_limit tokens after an action. + + Default: naive auto-discard. + """ + return auto_discard_tokens(player, excess) + + def choose_noble( + self, + game: GameState, # noqa: ARG002 + player: PlayerState, # noqa: ARG002 + nobles: list[Noble], + ) -> Noble: + """Called if player qualifies for multiple nobles. Default: first.""" + return nobles[0] + + +def auto_discard_tokens(player: PlayerState, excess: int) -> dict[GemColor, int]: + """Very dumb discard logic: discard from colors you have the most of.""" + to_discard: dict[GemColor, int] = dict.fromkeys(GEM_COLORS, 0) + remaining = excess + while remaining > 0: + color = max(player.tokens, key=lambda c: player.tokens[c]) + if player.tokens[color] == 0: + break + player.tokens[color] -= 1 + to_discard[color] += 1 + remaining -= 1 + return to_discard + + +def enforce_token_limit( + game: GameState, + strategy: Strategy, + player: PlayerState, +) -> None: + """If player has more than token_limit tokens, force discards.""" + limit = game.config.token_limit + total = player.total_tokens() + if total <= limit: + return + excess = total - limit + discards = strategy.choose_discard(game, player, excess) + for color, amount in discards.items(): + available = player.tokens[color] + to_remove = min(amount, available) + if to_remove <= 0: + continue + player.tokens[color] -= to_remove + game.bank[color] += to_remove + remaining = player.total_tokens() - limit + if remaining > 0: + auto = auto_discard_tokens(player, remaining) + for color, amount in auto.items(): + game.bank[color] += amount + + +def check_nobles_for_player( + game: GameState, + strategy: Strategy, + player: PlayerState, +) -> None: + """Award at most one noble to player if they qualify.""" + if game.noble_min_requirements > max(player.discounts.values()): + return + + candidates = [ + noble + for noble in game.available_nobles + if all(player.discounts.get(color, 0) >= requirement for color, requirement in noble.requirements.items()) + ] + + if not candidates: + return + + chosen = candidates[0] if len(candidates) == 1 else strategy.choose_noble(game, player, candidates) + + if chosen not in game.available_nobles: + return + game.available_nobles.remove(chosen) + game.get_noble_min_requirements() + + player.nobles.append(chosen) + + +def apply_take_different(game: GameState, strategy: Strategy, action: TakeDifferent) -> None: + """Mutate game state according to action.""" + player = game.current_player + max_token_take = 3 + + colors = list(dict.fromkeys(action.colors)) + colors = [c for c in colors if c in BASE_COLORS and game.bank[c] > 0] + if not (1 <= len(colors) <= max_token_take): + return + + for color in colors: + game.bank[color] -= 1 + player.tokens[color] += 1 + + enforce_token_limit(game, strategy, player) + + +def apply_take_double(game: GameState, strategy: Strategy, action: TakeDouble) -> None: + """Mutate game state according to action.""" + player = game.current_player + color = action.color + if color not in BASE_COLORS: + return + if game.bank[color] < game.config.minimum_tokens_to_buy_2: + return + game.bank[color] -= 2 + player.tokens[color] += 2 + enforce_token_limit(game, strategy, player) + + +def apply_buy_card(game: GameState, _strategy: Strategy, action: BuyCard) -> None: + """Mutate game state according to action.""" + player = game.current_player + + if action.from_reserved: + if not (0 <= action.index < len(player.reserved)): + return + card = player.reserved[action.index] + if not player.can_afford(card): + return + player.reserved.pop(action.index) + payment = player.pay_for_card(card) + for color, amount in payment.items(): + game.bank[color] += amount + else: + row = game.table_by_tier.get(action.tier) + if row is None or not (0 <= action.index < len(row)): + return + card = row[action.index] + if not player.can_afford(card): + return + row.pop(action.index) + payment = player.pay_for_card(card) + for color, amount in payment.items(): + game.bank[color] += amount + game.refill_table() + + +def apply_reserve_card(game: GameState, strategy: Strategy, action: ReserveCard) -> None: + """Mutate game state according to action.""" + player = game.current_player + + if len(player.reserved) >= game.config.reserve_limit: + return + + card: Card | None = None + if action.from_deck: + deck = game.decks_by_tier.get(action.tier) + if deck: + card = deck.pop() + else: + row = game.table_by_tier.get(action.tier) + if row is None: + return + if action.index is None or not (0 <= action.index < len(row)): + return + card = row.pop(action.index) + game.refill_table() + + if card is None: + return + player.reserved.append(card) + + if game.bank["gold"] > 0: + game.bank["gold"] -= 1 + player.tokens["gold"] += 1 + enforce_token_limit(game, strategy, player) + + +def apply_action(game: GameState, strategy: Strategy, action: Action) -> None: + """Mutate game state according to action.""" + actions = { + TakeDifferent: apply_take_different, + TakeDouble: apply_take_double, + BuyCard: apply_buy_card, + ReserveCard: apply_reserve_card, + } + action_func = actions.get(type(action)) + if action_func is None: + msg = f"Unknown action type: {type(action)}" + raise ValueError(msg) + action_func(game, strategy, action) + + +def legal_actions( + game: GameState, + player_index: int | None = None, +) -> list[Action]: + """Enumerate all syntactically legal actions for the given player. + + This enforces: + - token-taking rules + - reserve limits + - affordability for buys + """ + if player_index is None: + player_index = game.current_player_index + player = game.players[player_index] + + actions: list[Action] = [] + + colors_available = [c for c in BASE_COLORS if game.bank[c] > 0] + for r in (1, 2, 3): + actions.extend(TakeDifferent(colors=list(combo)) for combo in itertools.combinations(colors_available, r)) + + actions.extend( + TakeDouble(color=color) for color in BASE_COLORS if game.bank[color] >= game.config.minimum_tokens_to_buy_2 + ) + + for tier, row in game.table_by_tier.items(): + for idx, card in enumerate(row): + if player.can_afford(card): + actions.append(BuyCard(tier=tier, index=idx)) + + for idx, card in enumerate(player.reserved): + if player.can_afford(card): + actions.append(BuyCard(tier=0, index=idx, from_reserved=True)) + + if len(player.reserved) < game.config.reserve_limit: + for tier, row in game.table_by_tier.items(): + for idx, _ in enumerate(row): + actions.append( + ReserveCard(tier=tier, index=idx, from_deck=False), + ) + for tier, deck in game.decks_by_tier.items(): + if deck: + actions.append( + ReserveCard(tier=tier, index=None, from_deck=True), + ) + + return actions + + +def create_random_cards_tier( + tier: int, + card_count: int, + cost_choices: list[int], + point_choices: list[int], +) -> list[Card]: + """Create a random set of cards for a given tier.""" + cards: list[Card] = [] + + for color in BASE_COLORS: + for _ in range(card_count): + cost = dict.fromkeys(GEM_COLORS, 0) + for c in BASE_COLORS: + if c == color: + continue + cost[c] = random.choice(cost_choices) + points = random.choice(point_choices) + cards.append(Card(tier=tier, points=points, color=color, cost=cost)) + + return cards + + +def create_random_cards() -> list[Card]: + """Generate a generic but Splendor-ish set of cards. + + This is not the official deck, but structured similarly enough for play. + """ + cards: list[Card] = [] + cards.extend( + create_random_cards_tier( + tier=1, + card_count=5, + cost_choices=[0, 1, 1, 2], + point_choices=[0, 0, 1], + ) + ) + cards.extend( + create_random_cards_tier( + tier=2, + card_count=4, + cost_choices=[2, 3, 4], + point_choices=[1, 2, 2, 3], + ) + ) + cards.extend( + create_random_cards_tier( + tier=3, + card_count=3, + cost_choices=[4, 5, 6], + point_choices=[3, 4, 5], + ) + ) + + random.shuffle(cards) + return cards + + +def create_random_nobles() -> list[Noble]: + """A small set of noble tiles, roughly Splendor-ish.""" + nobles: list[Noble] = [] + + base_requirements: list[dict[GemColor, int]] = [ + {"white": 3, "blue": 3, "green": 3}, + {"blue": 3, "green": 3, "red": 3}, + {"green": 3, "red": 3, "black": 3}, + {"red": 3, "black": 3, "white": 3}, + {"black": 3, "white": 3, "blue": 3}, + {"white": 4, "blue": 4}, + {"green": 4, "red": 4}, + {"blue": 4, "black": 4}, + ] + + for idx, req in enumerate(base_requirements, start=1): + nobles.append( + Noble( + name=f"Noble {idx}", + points=3, + requirements=dict(req.items()), + ), + ) + return nobles + + +def new_game( + strategies: Sequence[Strategy], + config: GameConfig, +) -> GameState: + """Create a new game state from a config + list of players.""" + num_players = len(strategies) + bank = get_default_starting_tokens(num_players) + + decks_by_tier: dict[int, list[Card]] = {1: [], 2: [], 3: []} + for card in config.cards: + decks_by_tier.setdefault(card.tier, []).append(card) + for deck in decks_by_tier.values(): + random.shuffle(deck) + + table_by_tier: dict[int, list[Card]] = {1: [], 2: [], 3: []} + players = [PlayerState(strategy=strategy) for strategy in strategies] + + nobles = list(config.nobles) + random.shuffle(nobles) + nobles = nobles[: num_players + 1] + + game = GameState( + config=config, + players=players, + bank=bank, + decks_by_tier=decks_by_tier, + table_by_tier=table_by_tier, + available_nobles=nobles, + ) + game.refill_table() + return game + + +def run_game(game: GameState) -> tuple[PlayerState, int]: + """Run a full game loop until someone wins or a player returns None.""" + turn_count = 0 + while not game.finished: + turn_count += 1 + player = game.current_player + strategy = player.strategy + action = strategy.choose_action(game, player) + if action is None: + game.finished = True + break + + apply_action(game, strategy, action) + check_nobles_for_player(game, strategy, player) + + winner = game.check_winner_simple() + if winner is not None: + return winner, turn_count + + game.next_player() + if turn_count >= game.config.turn_limit: + break + + fallback = max(game.players, key=lambda player: player.score) + return fallback, turn_count diff --git a/python/splendor/bot.py b/python/splendor/bot.py new file mode 100644 index 0000000..428ccae --- /dev/null +++ b/python/splendor/bot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import random + +from .base import ( + BASE_COLORS, + Action, + BuyCard, + GameState, + GemColor, + PlayerState, + ReserveCard, + Strategy, + TakeDifferent, + TakeDouble, + auto_discard_tokens, +) + + +class RandomBot(Strategy): + """Dumb bot that follows rules but doesn't think.""" + + def __init__(self, name: str = "Bot") -> None: + super().__init__(name=name) + + def choose_action(self, game: GameState, player: PlayerState) -> Action | None: + affordable: list[tuple[int, int]] = [] + for tier, row in game.table_by_tier.items(): + for idx, card in enumerate(row): + if player.can_afford(card): + affordable.append((tier, idx)) + if affordable and random.random() < 0.5: + tier, idx = random.choice(affordable) + return BuyCard(tier=tier, index=idx) + + if random.random() < 0.2: + tier = random.choice([1, 2, 3]) + row = game.table_by_tier.get(tier, []) + if row: + idx = random.randrange(len(row)) + return ReserveCard(tier=tier, index=idx, from_deck=False) + + if random.random() < 0.5: + colors_for_double = [c for c in BASE_COLORS if game.bank[c] >= 4] + if colors_for_double: + return TakeDouble(color=random.choice(colors_for_double)) + + colors_for_diff = [c for c in BASE_COLORS if game.bank[c] > 0] + random.shuffle(colors_for_diff) + return TakeDifferent(colors=colors_for_diff[:3]) + + def choose_discard( + self, + game: GameState, + player: PlayerState, + excess: int, + ) -> dict[GemColor, int]: + return auto_discard_tokens(player, excess) diff --git a/python/splendor/human.py b/python/splendor/human.py new file mode 100644 index 0000000..95e3e1a --- /dev/null +++ b/python/splendor/human.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import sys +from collections.abc import Mapping +from typing import 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, + Card, + GameState, + GemColor, + Noble, + PlayerState, + ReserveCard, + Strategy, + TakeDifferent, + TakeDouble, +) + +# 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] + + raise ValueError(f"Unknown color: {raw}") + + +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 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: + super().__init__(**kwargs) + self.game = game + self.me = me + + def compose(self) -> ComposeResult: + # 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: + self.refresh_content() + + def refresh_content(self) -> None: + 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: + for noble in self.game.available_nobles: + lines.append(" - " + format_noble(noble)) + 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: + super().__init__() + self.game = game + self.player = player + self.result: Action | None = None + self.message: str = "" + + def compose(self) -> ComposeResult: # type: ignore[override] + # 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: # type: ignore[override] + 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 on_input_submitted(self, event: Input.Submitted) -> None: # type: ignore[override] + 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() + cmd = parts[0] + + try: + if cmd == "1": + # Take up to 3 different gem colors: 1 white blue red OR 1 w b r + color_names = parts[1:] + if not color_names: + raise ValueError("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: + raise ValueError(f"No tokens left for color: {color}") + colors.append(color) + self.result = TakeDifferent(colors=colors[:3]) + self.exit() + return + + if cmd == "2": + # TakeDouble: 2 color (full name or abbreviation) + if len(parts) < 2: + raise ValueError("Usage: 2 ") + raw_color = parts[1] + color = parse_color_token(raw_color) + if self.game.bank[color] < 4: + raise ValueError("Bank must have at least 4 of that color.") + self.result = TakeDouble(color=color) + self.exit() + return + + if cmd == "3": + # Buy face-up card: 3 tier index + if len(parts) < 3: + raise ValueError("Usage: 3 ") + tier = int(parts[1]) + idx = int(parts[2]) + self.result = BuyCard(tier=tier, index=idx) + self.exit() + return + + if cmd == "4": + # Buy reserved card: 4 index + if len(parts) < 2: + raise ValueError("Usage: 4 ") + idx = int(parts[1]) + if not (0 <= idx < len(self.player.reserved)): + raise ValueError("Reserved index out of range.") + self.result = BuyCard(tier=0, index=idx, from_reserved=True) + self.exit() + return + + if cmd == "5": + # Reserve face-up card: 5 tier index + if len(parts) < 3: + raise ValueError("Usage: 5 ") + tier = int(parts[1]) + idx = int(parts[2]) + self.result = ReserveCard(tier=tier, index=idx, from_deck=False) + self.exit() + return + + if cmd == "6": + # Reserve top of deck: 6 tier + if len(parts) < 2: + raise ValueError("Usage: 6 ") + tier = int(parts[1]) + self.result = ReserveCard(tier=tier, index=None, from_deck=True) + self.exit() + return + + raise ValueError("Unknown command.") + + except ValueError as exc: + self.message = str(exc) + 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: + 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] + 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] + 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] + 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: + 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] + 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] + 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] + 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: + 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]: + 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: + if not sys.stdout.isatty(): + return nobles[0] + app = NobleChoiceApp(game, player, nobles) + app.run() + assert app.result is not None + return app.result diff --git a/python/splendor/main.py b/python/splendor/main.py new file mode 100644 index 0000000..5472378 --- /dev/null +++ b/python/splendor/main.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .base import new_game, run_game +from .bot import RandomBot +from .human import TuiHuman + + +def main() -> None: + """Main entry point.""" + human = TuiHuman() + bot = RandomBot() + game_state = new_game(["You", "Bot A"]) + run_game(game_state, [human, bot]) + + +if __name__ == "__main__": + main() diff --git a/python/splendor/public_state.py b/python/splendor/public_state.py new file mode 100644 index 0000000..b1f8552 --- /dev/null +++ b/python/splendor/public_state.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .base import ( + BASE_COLORS, + BASE_INDEX, + GEM_ORDER, + Card, + GameState, + Noble, + PlayerState, +) + + +@dataclass(frozen=True) +class ObsCard: + """Numeric-ish card view for RL/search.""" + + tier: int + points: int + color_index: int + cost: list[int] + + +@dataclass(frozen=True) +class ObsNoble: + points: int + requirements: list[int] + + +@dataclass(frozen=True) +class ObsPlayer: + tokens: list[int] + discounts: list[int] + score: int + cards: list[ObsCard] + reserved: list[ObsCard] + nobles: list[ObsNoble] + + +@dataclass(frozen=True) +class Observation: + current_player: int + bank: list[int] + players: list[ObsPlayer] + table_by_tier: dict[int, list[ObsCard]] + decks_remaining: dict[int, int] + available_nobles: list[ObsNoble] + + +def _encode_card(card: Card) -> ObsCard: + color_index = BASE_INDEX.get(card.color, -1) + cost_vec = [card.cost.get(c, 0) for c in BASE_COLORS] + return ObsCard( + tier=card.tier, + points=card.points, + color_index=color_index, + cost=cost_vec, + ) + + +def _encode_noble(noble: Noble) -> ObsNoble: + req_vec = [noble.requirements.get(c, 0) for c in BASE_COLORS] + return ObsNoble( + points=noble.points, + requirements=req_vec, + ) + + +def _encode_player(player: PlayerState) -> ObsPlayer: + tokens_vec = [player.tokens[c] for c in GEM_ORDER] + discounts_vec = [player.discounts[c] for c in GEM_ORDER] + cards_enc = [_encode_card(c) for c in player.cards] + reserved_enc = [_encode_card(c) for c in player.reserved] + nobles_enc = [_encode_noble(n) for n in player.nobles] + return ObsPlayer( + tokens=tokens_vec, + discounts=discounts_vec, + score=player.score, + cards=cards_enc, + reserved=reserved_enc, + nobles=nobles_enc, + ) + + +def to_observation(game: GameState) -> Observation: + """Create a structured observation of the full public state.""" + bank_vec = [game.bank[c] for c in GEM_ORDER] + players_enc = [_encode_player(p) for p in game.players] + table_enc: dict[int, list[ObsCard]] = { + tier: [_encode_card(c) for c in row] for tier, row in game.table_by_tier.items() + } + decks_remaining = {tier: len(deck) for tier, deck in game.decks_by_tier.items()} + nobles_enc = [_encode_noble(n) for n in game.available_nobles] + return Observation( + current_player=game.current_player_index, + bank=bank_vec, + players=players_enc, + table_by_tier=table_enc, + decks_remaining=decks_remaining, + available_nobles=nobles_enc, + ) diff --git a/python/splendor/sim.py b/python/splendor/sim.py new file mode 100644 index 0000000..c96d704 --- /dev/null +++ b/python/splendor/sim.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import copy + +from .base import Action, GameState, PlayerState, apply_action, check_nobles_for_player +from .bot import RandomBot + + +class SimStrategy(RandomBot): + """Strategy used in simulate_step. + + We never call choose_action here (caller chooses actions), + but we reuse discard/noble-selection logic. + """ + + def choose_action(self, game: GameState, player: PlayerState) -> Action | None: + msg = "SimStrategy.choose_action should not be used in simulate_step" + raise RuntimeError(msg) + + +def simulate_step(game: GameState, action: Action) -> GameState: + """Return a deep-copied next state after applying action for the current player. + + Useful for tree search / MCTS: + + next_state = simulate_step(state, action) + """ + next_state = copy.deepcopy(game) + sim_strategy = SimStrategy() + apply_action(next_state, sim_strategy, action) + check_nobles_for_player(next_state, sim_strategy, next_state.current_player) + next_state.next_player() + return next_state diff --git a/python/splendor/simulat.py b/python/splendor/simulat.py new file mode 100644 index 0000000..5f9ce42 --- /dev/null +++ b/python/splendor/simulat.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from .base import GameConfig, create_random_nobles, create_random_cards, new_game, run_game +from .bot import RandomBot + + +def main() -> None: + """Main entry point.""" + turn_limit = 10000 + for _ in range(1000): + bot_a = RandomBot("bot_a") + bot_b = RandomBot("bot_b") + bot_c = RandomBot("bot_c") + bot_d = RandomBot("bot_d") + config = GameConfig( + cards=create_random_cards(), + nobles=create_random_nobles(), + turn_limit=turn_limit, + ) + players = (bot_a, bot_b, bot_c, bot_d) + game_state = new_game(players, config) + winner, turns = run_game(game_state) + print(f"Winner is {winner.strategy.name} with {winner.score} points after {turns} turns.") + + +if __name__ == "__main__": + main()