mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
starting splendor
This commit is contained in:
646
python/splendor/base.py
Normal file
646
python/splendor/base.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user