mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
660 lines
19 KiB
Python
660 lines
19 KiB
Python
"""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 add_noble(self, noble: Noble) -> None:
|
|
"""Add a noble to the player."""
|
|
self.nobles.append(noble)
|
|
self.noble_score = sum(noble.points for noble in self.nobles)
|
|
|
|
def add_card(self, card: Card) -> None:
|
|
"""Add a card to the player."""
|
|
self.cards.append(card)
|
|
self.card_score = sum(card.points for card in self.cards)
|
|
|
|
@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.add_card(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:
|
|
"""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
|
|
|
|
|
|
@dataclass
|
|
class BuyCardReserved(Action):
|
|
"""Buy a face-up card."""
|
|
|
|
tier: int
|
|
index: int
|
|
|
|
|
|
@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.add_noble(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
|
|
|
|
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_buy_card_reserved(game: GameState, _strategy: Strategy, action: BuyCardReserved) -> None:
|
|
"""Mutate game state according to action."""
|
|
player = game.current_player
|
|
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
|
|
|
|
|
|
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,
|
|
BuyCardReserved: apply_buy_card_reserved,
|
|
}
|
|
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
|