diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cb7790 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + - name: Install dependencies + run: uv sync + - name: Run lint + run: uv run ruff check + - name: Run type-check + run: uv run pyrefly check --summarize-errors + - name: Run tests + run: uv run pytest -v + + build: + runs-on: ubuntu-latest + needs: checks + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + - name: Build + run: uv build --wheel diff --git a/main.py b/main.py index b76e464..362f551 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ # This is a sample Python script. from clanker_bench.agents.agent import Agent from clanker_bench.agents.random_agent import RandomAgent -from clanker_bench.game.setup import new_game from clanker_bench.runner.run import play_game # Press ⌃R to execute it or replace it with your code. @@ -9,9 +8,9 @@ # Press the green button in the gutter to run the script. -if __name__ == '__main__': +if __name__ == "__main__": random_agents: list[Agent] = [RandomAgent() for i in range(3)] play_game(random_agents, 0) - + # See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/pyproject.toml b/pyproject.toml index bbe2de2..9a97e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,51 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "pydantic>=2.13.4", + "pyrefly>=0.60.0", ] [dependency-groups] dev = [ "hypothesis>=6.155.7", "pytest>=9.1.1", + "ruff>=0.15.21", ] [tool.pytest.ini_options] -pythonpath = ["src", "tests"] \ No newline at end of file +pythonpath = ["src", "tests"] + +[tool.ruff.lint] +extend-select = [ + "F", # Pyflakes rules + "W", # PyCodeStyle warnings + "E", # PyCodeStyle errors + "I", # Sort imports properly + "UP", # Warn if certain things can changed due to newer Python versions + "C4", # Catch incorrect use of comprehensions, dict, list, etc + "FA", # Enforce from __future__ import annotations + "ISC", # Good use of string concatenation + "ICN", # Use common import conventions + "RET", # Good return practices + "SIM", # Common simplification rules + "TID", # Some good import practices + "TC", # Enforce importing certain types in a TYPE_CHECKING block + "PTH", # Use pathlib instead of os.path + "TD", # Be diligent with TO-DO comments + "NPY", # Some numpy-specific things + "A", # detect shadowed builtins + "BLE", # disallow catch-all exceptions + "COM", # enforce trailing comma rules + "FBT", # detect boolean traps + "N", # enforce naming conventions, e.g. ClassName vs function_name +] + +ignore = ["E501"] # Formatter does that + +[tool.pyrefly] +project-includes = [ + "**/*.py*", + "**/*.ipynb", +] + +search-path = ["src", "."] \ No newline at end of file diff --git a/src/clanker_bench/agents/agent.py b/src/clanker_bench/agents/agent.py index d3ff4cc..787c9ff 100644 --- a/src/clanker_bench/agents/agent.py +++ b/src/clanker_bench/agents/agent.py @@ -6,5 +6,7 @@ class Agent(ABC): @abstractmethod - def act(self, observation: Observation, action_space: tuple[GameAction, ...]) -> GameAction: - pass \ No newline at end of file + def act( + self, observation: Observation, action_space: tuple[GameAction, ...], + ) -> GameAction: + pass diff --git a/src/clanker_bench/agents/random_agent.py b/src/clanker_bench/agents/random_agent.py index 97507f8..fccd725 100644 --- a/src/clanker_bench/agents/random_agent.py +++ b/src/clanker_bench/agents/random_agent.py @@ -1,9 +1,13 @@ +import random + from clanker_bench.agents.agent import Agent from clanker_bench.game.model.action import GameAction from clanker_bench.game.model.gamestate import Observation -import random + class RandomAgent(Agent): - def act(self, observation: Observation, action_space: tuple[GameAction, ...]) -> GameAction: - rng = random.randint(0, len(action_space)-1) + def act( + self, observation: Observation, action_space: tuple[GameAction, ...], + ) -> GameAction: + rng = random.randint(0, len(action_space) - 1) return action_space[rng] diff --git a/src/clanker_bench/game/deck.py b/src/clanker_bench/game/deck.py index 2899087..bd12ded 100644 --- a/src/clanker_bench/game/deck.py +++ b/src/clanker_bench/game/deck.py @@ -1,8 +1,9 @@ import random -from typing import Sequence +from collections.abc import Sequence from clanker_bench.game.model.card import Card, Suit + def shuffle_deck(cards: Sequence[Card], rng: random.Random) -> list[Card]: shuffled_deck = list(cards) rng.shuffle(shuffled_deck) @@ -11,7 +12,7 @@ def shuffle_deck(cards: Sequence[Card], rng: random.Random) -> list[Card]: def init_deck() -> list[Card]: cards: list[Card] = [] - for (suit) in Suit: + for suit in Suit: if suit == Suit.CLANKER or suit == Suit.SINGULARITY: cards.extend([Card(suit=suit)] * 4) continue @@ -19,11 +20,13 @@ def init_deck() -> list[Card]: cards.append(Card(suit=suit, rank=rank + 1)) return cards + def deal_cards(current_round: int, deck: list[Card]) -> tuple[list[Card], list[Card]]: dealt = deck[:current_round] remaining = deck[current_round:] return dealt, remaining + def determine_trump(card_list: list[Card]) -> tuple[None | Suit, bool]: if not card_list: return None, False @@ -32,8 +35,6 @@ def determine_trump(card_list: list[Card]) -> tuple[None | Suit, bool]: return None, False if card.suit == Suit.CLANKER: return None, False - elif card.suit == Suit.SINGULARITY: + if card.suit == Suit.SINGULARITY: return None, True - else: - return card.suit, False - + return card.suit, False diff --git a/src/clanker_bench/game/engine.py b/src/clanker_bench/game/engine.py index ffd3530..3633fa7 100644 --- a/src/clanker_bench/game/engine.py +++ b/src/clanker_bench/game/engine.py @@ -1,61 +1,93 @@ from itertools import count -from clanker_bench.game.deck import shuffle_deck -from clanker_bench.game.model.action import GameAction, PredictTricksAction, PlayCardAction, SelectTrumpAction +from clanker_bench.game.model.action import ( + GameAction, + PlayCardAction, + PredictTricksAction, + SelectTrumpAction, +) from clanker_bench.game.model.card import Card, Suit -from clanker_bench.game.model.exception import IllegalActionException, IllegalStateException -from clanker_bench.game.model.gamestate import GameState, PlayedCard, TrickState, RoundState, Phase -from clanker_bench.game.model.scoreboard import RoundScore, Scoreboard -from clanker_bench.game.rules.play_card_rules import is_legal_card_play, compute_demanded_suit, get_allowed_suits, \ - get_allowed_cards -from clanker_bench.game.rules.predict_trick_rules import is_legal_trick_prediction, legal_trick_predictions -from clanker_bench.game.rules.trump_selection_rules import is_legal_trump_select, legal_trump_selects +from clanker_bench.game.model.exception import ( + IllegalActionError, + IllegalStateError, +) +from clanker_bench.game.model.gamestate import ( + GameState, + Phase, + PlayedCard, + RoundState, + TrickState, +) +from clanker_bench.game.model.scoreboard import RoundScore +from clanker_bench.game.rules.play_card_rules import ( + compute_demanded_suit, + get_allowed_cards, + is_legal_card_play, +) +from clanker_bench.game.rules.predict_trick_rules import ( + is_legal_trick_prediction, + legal_trick_predictions, +) +from clanker_bench.game.rules.trump_selection_rules import ( + is_legal_trump_select, + legal_trump_selects, +) from clanker_bench.game.setup import deal_round -def _is_trick_complete (state: GameState) -> bool: +def _is_trick_complete(state: GameState) -> bool: next_player = (state.trick_state.current_player + 1) % state.player_count return next_player == state.trick_state.starting_player + def _move_to_next_player(state: GameState) -> GameState: next_player = (state.trick_state.current_player + 1) % state.player_count new_state = state.model_copy() - new_state.trick_state = state.trick_state.model_copy(update={"current_player": next_player}) + new_state.trick_state = state.trick_state.model_copy( + update={"current_player": next_player}, + ) return new_state + def _remove_card_from_hand(card: Card, player_hand: list[Card]) -> list[Card]: t = count() return [c for c in player_hand if c != card or next(t) != 0] + def _play_card_to_trick(state: GameState, player_id: int, card: Card) -> GameState: new_state = state.model_copy() played_card: PlayedCard = PlayedCard(player_id=player_id, card=card) new_trick: list[PlayedCard] = [*state.trick_state.current_trick, played_card] - new_state.trick_state = state.trick_state.model_copy(update={ - "current_trick": new_trick, - "current_demanded_suit": compute_demanded_suit(new_trick), - }) + new_state.trick_state = state.trick_state.model_copy( + update={ + "current_trick": new_trick, + "current_demanded_suit": compute_demanded_suit(new_trick), + }, + ) player_hand: list[Card] = state.players[player_id].own_hand new_state.players[player_id].own_hand = _remove_card_from_hand(card, player_hand) return new_state -def _is_higher_card(new_card: Card, old_card: Card, trump_suit: Suit | None, demanded_suit: Suit | None) -> bool: +def _is_higher_card( + new_card: Card, old_card: Card, trump_suit: Suit | None, demanded_suit: Suit | None, +) -> bool: suit_map = {Suit.SINGULARITY: 4, trump_suit: 3, demanded_suit: 2, Suit.CLANKER: 0} for suit in Suit: - if suit == trump_suit or suit == demanded_suit or suit == Suit.CLANKER or suit == Suit.SINGULARITY: + if ( + suit in (trump_suit, demanded_suit) + or suit == Suit.CLANKER + or suit == Suit.SINGULARITY + ): continue suit_map[suit] = 1 if suit_map[new_card.suit] > suit_map[old_card.suit]: return True - elif suit_map[new_card.suit] < suit_map[old_card.suit]: + if suit_map[new_card.suit] < suit_map[old_card.suit]: return False - if new_card.rank > old_card.rank: - return True - - return False + return new_card.rank > old_card.rank def _determine_trick_winner(state: GameState) -> int: @@ -63,25 +95,36 @@ def _determine_trick_winner(state: GameState) -> int: demanded_suit = compute_demanded_suit(current_trick) highest_played_card = current_trick[0] for played in current_trick: - if _is_higher_card(played.card, highest_played_card.card, state.round_state.current_trump_suit, demanded_suit): + if _is_higher_card( + played.card, + highest_played_card.card, + state.round_state.current_trump_suit, + demanded_suit, + ): highest_played_card = played return highest_played_card.player_id - def _finish_trick(state: GameState) -> GameState: new_state: GameState = state.model_copy() winner: int = _determine_trick_winner(new_state) # move played cards into the game-wide history - new_state.round_state.played_cards = [*state.round_state.played_cards, *state.trick_state.current_trick] + new_state.round_state.played_cards = [ + *state.round_state.played_cards, + *state.trick_state.current_trick, + ] # add win to trick count for actual winner - new_state.round_state = state.round_state.model_copy(update={ - "actual_player_tricks": [ - count + 1 if player_id == winner else count - for player_id, count in enumerate(state.round_state.actual_player_tricks) - ], - }) + new_state.round_state = state.round_state.model_copy( + update={ + "actual_player_tricks": [ + count + 1 if player_id == winner else count + for player_id, count in enumerate( + state.round_state.actual_player_tricks, + ) + ], + }, + ) # start a fresh trick led by the winner new_state.trick_state = TrickState(starting_player=winner, current_player=winner) new_state.round_state.trick_nr = state.round_state.trick_nr + 1 @@ -98,7 +141,7 @@ def _is_round_complete(new_state: GameState) -> bool: def _calculate_round_score(state: GameState) -> RoundScore: round_score = [] round_state: RoundState = state.round_state - predicted_player_tricks: list[int | None] = round_state.predicted_player_tricks + predicted_player_tricks: list[int] = round_state.predicted_player_tricks if None in predicted_player_tricks: raise RuntimeError actual_player_tricks: list[int] = round_state.actual_player_tricks @@ -106,7 +149,12 @@ def _calculate_round_score(state: GameState) -> RoundScore: if predicted_player_tricks[player_id] == actual_player_tricks[player_id]: round_score.append(20 + actual_player_tricks[player_id] * 10) else: - round_score.append(-abs(predicted_player_tricks[player_id] - actual_player_tricks[player_id]) * 10) + round_score.append( + -abs( + predicted_player_tricks[player_id] - actual_player_tricks[player_id], + ) + * 10, + ) new_score: RoundScore = RoundScore( predicted_trick_count=state.round_state.predicted_player_tricks, @@ -119,9 +167,12 @@ def _calculate_round_score(state: GameState) -> RoundScore: def _finish_round(state: GameState) -> GameState: score: RoundScore = _calculate_round_score(state) scoreboard = state.scoreboard.model_copy( - update={"rounds": [*state.scoreboard.rounds, score]}) + update={"rounds": [*state.scoreboard.rounds, score]}, + ) if state.round_nr + 1 >= state.round_count: - return state.model_copy(update={"scoreboard": scoreboard, "phase": Phase.FINISHED}) + return state.model_copy( + update={"scoreboard": scoreboard, "phase": Phase.FINISHED}, + ) return deal_round( seed=state.seed, round_nr=state.round_nr + 1, @@ -133,9 +184,11 @@ def _finish_round(state: GameState) -> GameState: def _apply_play_card_action(state: GameState, action: PlayCardAction) -> GameState: if not is_legal_card_play(state, action.card): - raise IllegalActionException(f"Action to play the {action.card} is not legal!") + raise IllegalActionError(f"Action to play the {action.card} is not legal!") - new_state = _play_card_to_trick(state, state.trick_state.current_player, action.card) + new_state = _play_card_to_trick( + state, state.trick_state.current_player, action.card, + ) if not _is_trick_complete(new_state): return _move_to_next_player(new_state) @@ -145,25 +198,36 @@ def _apply_play_card_action(state: GameState, action: PlayCardAction) -> GameSta return _finish_round(new_state) -def _apply_select_trump_action(state: GameState, action: SelectTrumpAction) -> GameState: +def _apply_select_trump_action( + state: GameState, action: SelectTrumpAction, +) -> GameState: new_state = state.model_copy() if not is_legal_trump_select(state, action.suit): - raise IllegalActionException(f"Action to select trump {action.suit} is not legal!") - new_state.round_state = state.round_state.model_copy(update={ - "current_trump_suit": action.suit, - }) + raise IllegalActionError( + f"Action to select trump {action.suit} is not legal!", + ) + new_state.round_state = state.round_state.model_copy( + update={ + "current_trump_suit": action.suit, + }, + ) new_state.phase = Phase.PREDICT return new_state + def _apply_predict_action(state: GameState, action: PredictTricksAction) -> GameState: new_state = state.model_copy() if not is_legal_trick_prediction(state, action.trick_count): - raise IllegalActionException(f"Action to predict {action.trick_count} tricks is not legal!") + raise IllegalActionError( + f"Action to predict {action.trick_count} tricks is not legal!", + ) predicted_tricks = state.round_state.predicted_player_tricks.copy() predicted_tricks[state.trick_state.current_player] = action.trick_count - new_state.round_state = state.round_state.model_copy(update={ - "predicted_player_tricks": predicted_tricks, - }) + new_state.round_state = state.round_state.model_copy( + update={ + "predicted_player_tricks": predicted_tricks, + }, + ) new_state = _move_to_next_player(new_state) if new_state.trick_state.current_player == new_state.trick_state.starting_player: new_state.phase = Phase.PLAY @@ -180,15 +244,29 @@ def step(state: GameState, action: GameAction) -> GameState: case PlayCardAction() if state.phase is Phase.PLAY: return _apply_play_card_action(state, action) case _: - raise IllegalActionException(f"{type(action).__name__} nicht erlaubt in Phase {state.phase}") + raise IllegalActionError( + f"{type(action).__name__} nicht erlaubt in Phase {state.phase}", + ) -def _get_predict_actions(state:GameState) -> tuple[PredictTricksAction, ...]: - legal_trick_counts :list[int] = legal_trick_predictions(state.round_nr, state.round_state.predicted_player_tricks, state.round_state.dealer_id, state.trick_state.current_player) - return tuple(PredictTricksAction(trick_count=trick_count) for trick_count in legal_trick_counts) +def _get_predict_actions(state: GameState) -> tuple[PredictTricksAction, ...]: + legal_trick_counts: list[int] = legal_trick_predictions( + state.round_nr, + state.round_state.predicted_player_tricks, + state.round_state.dealer_id, + state.trick_state.current_player, + ) + return tuple( + PredictTricksAction(trick_count=trick_count) + for trick_count in legal_trick_counts + ) + def _get_card_actions(state: GameState) -> tuple[PlayCardAction, ...]: - allowed_cards: list[Card] = get_allowed_cards(state.trick_state.current_trick, state.players[state.trick_state.current_player].own_hand) + allowed_cards: list[Card] = get_allowed_cards( + state.trick_state.current_trick, + state.players[state.trick_state.current_player].own_hand, + ) return tuple(PlayCardAction(card=card) for card in allowed_cards) @@ -206,7 +284,10 @@ def get_action_space(state: GameState) -> tuple[GameAction, ...]: case Phase.SELECT_TRUMP: return _get_select_actions(state) case _: - raise IllegalStateException(f"{type(state).__name__} befindet sich in nicht erlaubter {state.phase}") + raise IllegalStateError( + f"{type(state).__name__} befindet sich in nicht erlaubter {state.phase}", + ) + def active_player(state: GameState) -> int: match state.phase: @@ -215,4 +296,6 @@ def active_player(state: GameState) -> int: case Phase.PLAY | Phase.PREDICT: return state.trick_state.current_player case _: - raise IllegalStateException(f"{type(state).__name__} befindet sich in nicht erlaubter {state.phase}") \ No newline at end of file + raise IllegalStateError( + f"{type(state).__name__} befindet sich in nicht erlaubter {state.phase}", + ) diff --git a/src/clanker_bench/game/model/action.py b/src/clanker_bench/game/model/action.py index 0839eb3..59e9f1d 100644 --- a/src/clanker_bench/game/model/action.py +++ b/src/clanker_bench/game/model/action.py @@ -4,6 +4,7 @@ from clanker_bench.game.model.card import Card, Suit + class PredictTricksAction(pydantic.BaseModel): type: Literal["predict_tricks"] = "predict_tricks" trick_count: int = pydantic.Field(ge=0) @@ -13,6 +14,7 @@ class PlayCardAction(pydantic.BaseModel): type: Literal["play_card"] = "play_card" card: Card + class SelectTrumpAction(pydantic.BaseModel): type: Literal["select_trump"] = "select_trump" suit: Suit @@ -21,4 +23,4 @@ class SelectTrumpAction(pydantic.BaseModel): GameAction = Annotated[ PredictTricksAction | PlayCardAction | SelectTrumpAction, pydantic.Field(discriminator="type"), -] \ No newline at end of file +] diff --git a/src/clanker_bench/game/model/card.py b/src/clanker_bench/game/model/card.py index 74167c1..3521127 100644 --- a/src/clanker_bench/game/model/card.py +++ b/src/clanker_bench/game/model/card.py @@ -1,8 +1,9 @@ -from enum import Enum +from enum import StrEnum import pydantic -class Suit(str, Enum): + +class Suit(StrEnum): BLUE = "Blue" GREEN = "Green" RED = "Red" @@ -10,6 +11,7 @@ class Suit(str, Enum): CLANKER = "Clanker" SINGULARITY = "Singularity" + class Card(pydantic.BaseModel): model_config = pydantic.ConfigDict(frozen=True) diff --git a/src/clanker_bench/game/model/config.py b/src/clanker_bench/game/model/config.py index 22a16f6..7f124cd 100644 --- a/src/clanker_bench/game/model/config.py +++ b/src/clanker_bench/game/model/config.py @@ -3,4 +3,3 @@ class Config(BaseModel): player_count: int = 3 - diff --git a/src/clanker_bench/game/model/exception.py b/src/clanker_bench/game/model/exception.py index bffc673..0fea952 100644 --- a/src/clanker_bench/game/model/exception.py +++ b/src/clanker_bench/game/model/exception.py @@ -1,6 +1,6 @@ - -class IllegalActionException(Exception): +class IllegalActionError(Exception): pass -class IllegalStateException(Exception): - pass \ No newline at end of file + +class IllegalStateError(Exception): + pass diff --git a/src/clanker_bench/game/model/gamestate.py b/src/clanker_bench/game/model/gamestate.py index 4101d9e..bd8ea67 100644 --- a/src/clanker_bench/game/model/gamestate.py +++ b/src/clanker_bench/game/model/gamestate.py @@ -1,50 +1,55 @@ -from enum import Enum +from enum import StrEnum import pydantic -from clanker_bench.game.model.card import Suit, Card +from clanker_bench.game.model.card import Card, Suit from clanker_bench.game.model.player_state import PlayerState from clanker_bench.game.model.scoreboard import Scoreboard class PlayedCard(pydantic.BaseModel): - player_id: int - card: Card + player_id: int + card: Card + class TrickState(pydantic.BaseModel): - starting_player: int - current_player: int - current_trick: list[PlayedCard] = pydantic.Field(default_factory=list) + starting_player: int + current_player: int + current_trick: list[PlayedCard] = pydantic.Field(default_factory=list) + class RoundState(pydantic.BaseModel): - trick_nr: int = pydantic.Field(default=0) - dealer_id: int - current_trump_suit: Suit | None - predicted_player_tricks: list[int] = pydantic.Field(default_factory=list) - actual_player_tricks: list[int] = pydantic.Field(default_factory=list) - played_cards: list[PlayedCard] = pydantic.Field(default_factory=list) + trick_nr: int = pydantic.Field(default=0) + dealer_id: int + current_trump_suit: Suit | None + predicted_player_tricks: list[int] = pydantic.Field(default_factory=list) + actual_player_tricks: list[int] = pydantic.Field(default_factory=list) + played_cards: list[PlayedCard] = pydantic.Field(default_factory=list) + class Observation(pydantic.BaseModel): - own_hand: list[Card] = pydantic.Field(default_factory=list) - current_trump_suit: Suit | None - current_trick: list[PlayedCard] = pydantic.Field(default_factory=list) - played_cards: list[PlayedCard] = pydantic.Field(default_factory=list) - predicted_player_tricks: list[int | None] = pydantic.Field(default_factory=list) - actual_player_tricks: list[int] = pydantic.Field(default_factory=list) - -class Phase(str, Enum): - SELECT_TRUMP = "select_trump" - PREDICT = "predict" - PLAY = "play" - FINISHED = "finished" + own_hand: list[Card] = pydantic.Field(default_factory=list) + current_trump_suit: Suit | None + current_trick: list[PlayedCard] = pydantic.Field(default_factory=list) + played_cards: list[PlayedCard] = pydantic.Field(default_factory=list) + predicted_player_tricks: list[int | None] = pydantic.Field(default_factory=list) + actual_player_tricks: list[int] = pydantic.Field(default_factory=list) + + +class Phase(StrEnum): + SELECT_TRUMP = "select_trump" + PREDICT = "predict" + PLAY = "play" + FINISHED = "finished" + class GameState(pydantic.BaseModel): - seed: int = pydantic.Field(default=0) - phase: Phase - round_count: int - round_nr: int - player_count: int - scoreboard: Scoreboard - players: list[PlayerState] - round_state: RoundState - trick_state: TrickState + seed: int = pydantic.Field(default=0) + phase: Phase + round_count: int + round_nr: int + player_count: int + scoreboard: Scoreboard + players: list[PlayerState] + round_state: RoundState + trick_state: TrickState diff --git a/src/clanker_bench/game/model/player_state.py b/src/clanker_bench/game/model/player_state.py index 8c8da0a..ed3d58f 100644 --- a/src/clanker_bench/game/model/player_state.py +++ b/src/clanker_bench/game/model/player_state.py @@ -2,7 +2,8 @@ from clanker_bench.game.model.card import Card + class PlayerState(pydantic.BaseModel): player_id: int name: str - own_hand: list[Card] \ No newline at end of file + own_hand: list[Card] diff --git a/src/clanker_bench/game/model/scoreboard.py b/src/clanker_bench/game/model/scoreboard.py index b5ac857..1a4b9a6 100644 --- a/src/clanker_bench/game/model/scoreboard.py +++ b/src/clanker_bench/game/model/scoreboard.py @@ -1,5 +1,6 @@ import pydantic + class RoundScore(pydantic.BaseModel): predicted_trick_count: list[int] actual_trick_count: list[int] @@ -7,13 +8,16 @@ class RoundScore(pydantic.BaseModel): @pydantic.model_validator(mode="after") def validate_lengths(self): - same: bool = (len(self.predicted_trick_count) - == len(self.actual_trick_count) - == len(self.round_score)) + same: bool = ( + len(self.predicted_trick_count) + == len(self.actual_trick_count) + == len(self.round_score) + ) if not same: raise ValueError("All score lists must have the same length") return self + class Scoreboard(pydantic.BaseModel): rounds: list[RoundScore] = pydantic.Field(default_factory=list) diff --git a/src/clanker_bench/game/rules/play_card_rules.py b/src/clanker_bench/game/rules/play_card_rules.py index 40738e0..8429495 100644 --- a/src/clanker_bench/game/rules/play_card_rules.py +++ b/src/clanker_bench/game/rules/play_card_rules.py @@ -1,30 +1,37 @@ from clanker_bench.game.model.card import Card, Suit from clanker_bench.game.model.gamestate import GameState, PlayedCard + def compute_demanded_suit(current_trick: list[PlayedCard]) -> Suit | None: for played in current_trick: if played.card.suit == Suit.CLANKER: continue - elif played.card.suit == Suit.SINGULARITY: + if played.card.suit == Suit.SINGULARITY: return None return played.card.suit return None -def get_allowed_suits(current_trick: list[PlayedCard], player_hand: list[Card]) -> set[Suit]: + +def get_allowed_suits( + current_trick: list[PlayedCard], player_hand: list[Card], +) -> set[Suit]: suits_in_hand: set[Suit] = {card.suit for card in player_hand} demanded_suit: Suit | None = compute_demanded_suit(current_trick) player_has_demanded_suit: bool = demanded_suit in suits_in_hand if player_has_demanded_suit and demanded_suit is not None: return {Suit.CLANKER, Suit.SINGULARITY, demanded_suit} - else: - return {suit for suit in Suit} + return set(Suit) + -def get_allowed_cards(current_trick: list[PlayedCard], player_hand: list[Card]) -> list[Card]: +def get_allowed_cards( + current_trick: list[PlayedCard], player_hand: list[Card], +) -> list[Card]: allowed_suits: set[Suit] = get_allowed_suits(current_trick, player_hand) return [card for card in player_hand if card.suit in allowed_suits] def is_legal_card_play(state: GameState, card: Card) -> bool: - if card.suit not in get_allowed_suits(state.trick_state.current_trick, state.players[state.trick_state.current_player].own_hand): - return False - return True + return card.suit in get_allowed_suits( + state.trick_state.current_trick, + state.players[state.trick_state.current_player].own_hand, + ) diff --git a/src/clanker_bench/game/rules/predict_trick_rules.py b/src/clanker_bench/game/rules/predict_trick_rules.py index 2bbc382..578efd7 100644 --- a/src/clanker_bench/game/rules/predict_trick_rules.py +++ b/src/clanker_bench/game/rules/predict_trick_rules.py @@ -1,15 +1,25 @@ from clanker_bench.game.model.gamestate import GameState + def is_legal_trick_prediction(state: GameState, predicted_tricks: int) -> bool: - return predicted_tricks in legal_trick_predictions(state.round_nr, state.round_state.predicted_player_tricks, state.round_state.dealer_id, state.trick_state.current_player) + return predicted_tricks in legal_trick_predictions( + state.round_nr, + state.round_state.predicted_player_tricks, + state.round_state.dealer_id, + state.trick_state.current_player, + ) + def tricks_in_round(round_nr: int) -> int: """Round 0 deals 1 card → 1 trick; round r → r+1 tricks.""" return round_nr + 1 -def legal_trick_predictions(round_nr: int, players_predicted_tricks: list[int], dealer_id: int, player_id: int) -> list[int]: + +def legal_trick_predictions( + round_nr: int, players_predicted_tricks: list[int], dealer_id: int, player_id: int, +) -> list[int]: num_tricks = tricks_in_round(round_nr) - candidates = range(0, num_tricks + 1) # 0..num_tricks inclusive + candidates = range(0, num_tricks + 1) # 0..num_tricks inclusive if dealer_id == player_id: current_sum = sum(t for t in players_predicted_tricks if t != -1) forbidden = num_tricks - current_sum diff --git a/src/clanker_bench/game/rules/trump_selection_rules.py b/src/clanker_bench/game/rules/trump_selection_rules.py index 02bde40..9cca3c1 100644 --- a/src/clanker_bench/game/rules/trump_selection_rules.py +++ b/src/clanker_bench/game/rules/trump_selection_rules.py @@ -3,11 +3,12 @@ _LEGAL_SUITS: set[Suit] = {Suit.RED, Suit.BLUE, Suit.GREEN, Suit.YELLOW} + def is_legal_trump_select(state: GameState, suit: Suit) -> bool: return suit in legal_trump_selects(state.phase) + def legal_trump_selects(phase: Phase) -> set[Suit]: - if not phase == Phase.SELECT_TRUMP: + if phase != Phase.SELECT_TRUMP: return set() - else: - return _LEGAL_SUITS.copy() \ No newline at end of file + return _LEGAL_SUITS.copy() diff --git a/src/clanker_bench/game/setup.py b/src/clanker_bench/game/setup.py index 9adb820..25220ee 100644 --- a/src/clanker_bench/game/setup.py +++ b/src/clanker_bench/game/setup.py @@ -1,22 +1,22 @@ import random +from typing import TYPE_CHECKING -from clanker_bench.game.deck import init_deck, shuffle_deck, deal_cards, determine_trump -from clanker_bench.game.model.card import Card, Suit +from clanker_bench.game.deck import deal_cards, determine_trump, init_deck, shuffle_deck from clanker_bench.game.model.config import Config +from clanker_bench.game.model.gamestate import GameState, Phase, RoundState, TrickState from clanker_bench.game.model.player_state import PlayerState -from clanker_bench.game.model.scoreboard import Scoreboard, RoundScore -from clanker_bench.game.model.gamestate import GameState, RoundState, TrickState, Phase +from clanker_bench.game.model.scoreboard import Scoreboard + +if TYPE_CHECKING: + from clanker_bench.game.model.card import Card def _round_rng(seed: int, round_nr: int) -> random.Random: return random.Random(f"{seed}:{round_nr}") + def deal_round( - seed: int, - round_nr: int, - dealer_id: int, - player_count: int, - scoreboard: Scoreboard + seed: int, round_nr: int, dealer_id: int, player_count: int, scoreboard: Scoreboard, ) -> GameState: initial_deck: list[Card] = init_deck() rng = _round_rng(seed, round_nr) @@ -26,9 +26,7 @@ def deal_round( for player_id in range(player_count): player_hand, deck = deal_cards(round_nr + 1, deck) player_state: PlayerState = PlayerState( - player_id=player_id, - name=f"Player {player_id + 1}", - own_hand=player_hand + player_id=player_id, name=f"Player {player_id + 1}", own_hand=player_hand, ) player_states.append(player_state) @@ -38,7 +36,7 @@ def deal_round( starting = (dealer_id + 1) % player_count return GameState( seed=seed, - phase= Phase.SELECT_TRUMP if awaiting_trump_select else Phase.PREDICT, + phase=Phase.SELECT_TRUMP if awaiting_trump_select else Phase.PREDICT, round_nr=round_nr, round_count=round_count, player_count=player_count, @@ -55,5 +53,10 @@ def deal_round( def new_game(config: Config, seed: int) -> GameState: - return deal_round(seed, round_nr=0, dealer_id=0, - player_count=config.player_count, scoreboard=Scoreboard()) + return deal_round( + seed, + round_nr=0, + dealer_id=0, + player_count=config.player_count, + scoreboard=Scoreboard(), + ) diff --git a/src/clanker_bench/render.py b/src/clanker_bench/render.py index 7d7dc66..9dae404 100644 --- a/src/clanker_bench/render.py +++ b/src/clanker_bench/render.py @@ -1,6 +1,5 @@ from clanker_bench.game.model.card import Card, Suit from clanker_bench.game.model.gamestate import GameState -from clanker_bench.game.model.scoreboard import RoundScore _SUIT_CODE = {Suit.RED: "R", Suit.BLUE: "B", Suit.GREEN: "G", Suit.YELLOW: "Y"} @@ -23,7 +22,7 @@ def render(state: GameState) -> str: f" | Trick {rs.trick_nr + 1}" f" | Phase {state.phase.value}" f" | Trump {trump}" - f" | Dealer P{rs.dealer_id} ===" + f" | Dealer P{rs.dealer_id} ===", ] for p in state.players: pred = rs.predicted_player_tricks[p.player_id] @@ -31,8 +30,12 @@ def render(state: GameState) -> str: won = rs.actual_player_tricks[p.player_id] marker = ">" if p.player_id == ts.current_player else " " hand = " ".join(_card(c) for c in p.own_hand) or "-" - lines.append(f" {marker} P{p.player_id} {p.name:<10} pred:{pred_str:>2} won:{won:>2} hand: [{hand}]") + lines.append( + f" {marker} P{p.player_id} {p.name:<10} pred:{pred_str:>2} won:{won:>2} hand: [{hand}]", + ) - trick = " ".join(f"P{pc.player_id}:{_card(pc.card)}" for pc in ts.current_trick) or "-" + trick = ( + " ".join(f"P{pc.player_id}:{_card(pc.card)}" for pc in ts.current_trick) or "-" + ) lines.append(f" trick: {trick}") return "\n".join(lines) diff --git a/src/clanker_bench/runner/run.py b/src/clanker_bench/runner/run.py index 3bb69fd..3bc4a18 100644 --- a/src/clanker_bench/runner/run.py +++ b/src/clanker_bench/runner/run.py @@ -1,12 +1,17 @@ -from clanker_bench.render import render +from typing import TYPE_CHECKING + from clanker_bench.agents.agent import Agent -from clanker_bench.game.engine import get_action_space, step, active_player -from clanker_bench.game.model.action import GameAction +from clanker_bench.game.engine import active_player, get_action_space, step from clanker_bench.game.model.card import Suit from clanker_bench.game.model.config import Config -from clanker_bench.game.model.gamestate import GameState, Phase, Observation +from clanker_bench.game.model.gamestate import GameState, Observation, Phase from clanker_bench.game.model.scoreboard import Scoreboard from clanker_bench.game.setup import new_game +from clanker_bench.render import render + +if TYPE_CHECKING: + from clanker_bench.game.model.action import GameAction + def play_game(agents: list[Agent], seed: int) -> Scoreboard: config: Config = Config(player_count=len(agents)) @@ -15,7 +20,9 @@ def play_game(agents: list[Agent], seed: int) -> Scoreboard: action_space: tuple[GameAction, ...] = get_action_space(game_state) actor: int = active_player(game_state) agent: Agent = agents[actor] - action: GameAction = agent.act(Observation(current_trump_suit=Suit.BLUE), action_space) + action: GameAction = agent.act( + Observation(current_trump_suit=Suit.BLUE), action_space, + ) game_state: GameState = step(game_state, action) print(render(game_state)) - return game_state.scoreboard \ No newline at end of file + return game_state.scoreboard diff --git a/tests/game/conftest.py b/tests/game/conftest.py index cb875b9..e2e75fe 100644 --- a/tests/game/conftest.py +++ b/tests/game/conftest.py @@ -1,5 +1,3 @@ -import pytest - from clanker_bench.game.model.card import Card, Suit from clanker_bench.game.model.gamestate import ( GameState, @@ -87,4 +85,4 @@ def make_state( current_player=current_player, current_trick=_to_played_cards(current_trick), ), - ) \ No newline at end of file + ) diff --git a/tests/game/rules/test_play_card_rules.py b/tests/game/rules/test_play_card_rules.py index dd92912..8b773f4 100644 --- a/tests/game/rules/test_play_card_rules.py +++ b/tests/game/rules/test_play_card_rules.py @@ -9,6 +9,7 @@ werden muss (das kann auch die Trumpffarbe sein). Wer sie hat, muss sie spielen; wer sie nicht hat, darf eine beliebige andere Karte spielen. """ + import pytest from clanker_bench.game.model.card import Card, Suit @@ -67,7 +68,9 @@ def test_leading_player_may_play_anything(self): assert allowed == set(Suit) def test_singularity_led_trick_allows_anything(self): - allowed = get_allowed_suits(trick(SINGULARITY), [card(Suit.RED), card(Suit.BLUE)]) + allowed = get_allowed_suits( + trick(SINGULARITY), [card(Suit.RED), card(Suit.BLUE)], + ) assert allowed == set(Suit) def test_must_follow_demanded_suit_when_held(self): diff --git a/tests/game/rules/test_predict_tricks_rules.py b/tests/game/rules/test_predict_tricks_rules.py index 5547454..2245a46 100644 --- a/tests/game/rules/test_predict_tricks_rules.py +++ b/tests/game/rules/test_predict_tricks_rules.py @@ -16,23 +16,28 @@ def make_state(predicts: list[int], player_count, dealer_id, round_nr) -> GameSt phase=Phase.PLAY, players=[PlayerState(player_id=0, name="test", own_hand=[])], round_state=RoundState( - current_trump_suit=None, dealer_id=dealer_id, predicted_player_tricks=predicts - ), - trick_state=TrickState( - starting_player=0, current_player=current_player + current_trump_suit=None, + dealer_id=dealer_id, + predicted_player_tricks=predicts, ), + trick_state=TrickState(starting_player=0, current_player=current_player), ) + class TestLegalTrickPredicts: @pytest.mark.parametrize( "predicted_tricks, expected, dealer_id, player_id, round_nr", [ - ([1,-1, 2, 2], [0, 2,3,4,5,6], 1,1, 5), + ([1, -1, 2, 2], [0, 2, 3, 4, 5, 6], 1, 1, 5), ([-1, -1, 2, 2], [0, 1, 2, 3], 1, 0, 2), ([1, -1, 2, 2], [0, 1, 2, 3], 1, 1, 2), ([1, -1, 1, 1], [1, 2, 3], 1, 1, 2), ], ) - def test_legal_trick_predicts(self, predicted_tricks, expected, player_id, dealer_id, round_nr): - assert legal_trick_predictions(round_nr, predicted_tricks, dealer_id, player_id) == expected - + def test_legal_trick_predicts( + self, predicted_tricks, expected, player_id, dealer_id, round_nr, + ): + assert ( + legal_trick_predictions(round_nr, predicted_tricks, dealer_id, player_id) + == expected + ) diff --git a/tests/game/rules/test_trump_selection_rules.py b/tests/game/rules/test_trump_selection_rules.py index 013ac4d..7789f41 100644 --- a/tests/game/rules/test_trump_selection_rules.py +++ b/tests/game/rules/test_trump_selection_rules.py @@ -4,6 +4,7 @@ Trumpffarbe, nachdem er seine Handkarten angeschaut hat. Es kann nur eine der vier echten Farben (RED/BLUE/GREEN/YELLOW) als Trumpf gewaehlt werden. """ + import pytest from clanker_bench.game.model.card import Suit diff --git a/tests/game/test_deck.py b/tests/game/test_deck.py index 329cdb3..ba9cd08 100644 --- a/tests/game/test_deck.py +++ b/tests/game/test_deck.py @@ -5,6 +5,7 @@ - Die oberste Karte bestimmt die Trumpffarbe; ist sie ein CLANKER -> kein Trumpf; ist sie ein SINGULARITY -> der Geber waehlt (awaiting_trump_select). """ + import random import pytest @@ -61,9 +62,11 @@ def test_shuffle_preserves_multiset(self): deck = init_deck() shuffled = shuffle_deck(deck, random.Random(123)) assert sorted(shuffled, key=lambda c: (c.suit.value, c.rank)) == sorted( - deck, key=lambda c: (c.suit.value, c.rank) + deck, key=lambda c: (c.suit.value, c.rank), ) def test_shuffle_is_deterministic_for_seed(self): deck = init_deck() - assert shuffle_deck(deck, random.Random(1)) == shuffle_deck(deck, random.Random(1)) + assert shuffle_deck(deck, random.Random(1)) == shuffle_deck( + deck, random.Random(1), + ) diff --git a/tests/game/test_engine.py b/tests/game/test_engine.py index 761c3fa..03ff8c3 100644 --- a/tests/game/test_engine.py +++ b/tests/game/test_engine.py @@ -1,9 +1,8 @@ +import hypothesis.strategies as st from hypothesis import given from clanker_bench.game import engine from clanker_bench.game.model.card import Card, Suit -import hypothesis.strategies as st - from tests.game.conftest import make_state @@ -11,7 +10,11 @@ class TestRemoveCardFromHand: def test_remove_card_from_hand(self): # Arrange card: Card = Card(suit=Suit.BLUE, rank=10) - expected_hand = [Card(suit=Suit.BLUE, rank=1), Card(suit=Suit.CLANKER), Card(suit=Suit.SINGULARITY)] + expected_hand = [ + Card(suit=Suit.BLUE, rank=1), + Card(suit=Suit.CLANKER), + Card(suit=Suit.SINGULARITY), + ] hand: list[Card] = [*expected_hand, card] # Act @@ -21,32 +24,42 @@ def test_remove_card_from_hand(self): assert len(new_hand) == (len(hand) - 1) assert new_hand == expected_hand assert len(new_hand) == len(expected_hand) - assert (card not in new_hand) + assert card not in new_hand def test_removes_card_just_once(self): # Arrange card: Card = Card(suit=Suit.CLANKER) - hand = [card, Card(suit=Suit.BLUE, rank=1), Card(suit=Suit.CLANKER), Card(suit=Suit.SINGULARITY)] + hand = [ + card, + Card(suit=Suit.BLUE, rank=1), + Card(suit=Suit.CLANKER), + Card(suit=Suit.SINGULARITY), + ] # Act new_hand = engine._remove_card_from_hand(card, hand) # Assert assert len(new_hand) == (len(hand) - 1) - assert (card in new_hand) - assert(new_hand.count(card) == 1) + assert card in new_hand + assert new_hand.count(card) == 1 def test_removes_card_twice(self): # Arrange card: Card = Card(suit=Suit.CLANKER) - hand = [card, Card(suit=Suit.BLUE, rank=1), Card(suit=Suit.CLANKER), Card(suit=Suit.SINGULARITY)] + hand = [ + card, + Card(suit=Suit.BLUE, rank=1), + Card(suit=Suit.CLANKER), + Card(suit=Suit.SINGULARITY), + ] # Act new_hand = engine._remove_card_from_hand(card, hand) new_hand = engine._remove_card_from_hand(card, new_hand) assert len(new_hand) == (len(hand) - 2) - assert (card not in new_hand) + assert card not in new_hand def test_remove_nonexistent_card(self): # Arrange @@ -56,7 +69,7 @@ def test_remove_nonexistent_card(self): # Act new_hand = engine._remove_card_from_hand(card, hand) assert len(new_hand) == (len(hand)) - assert (card not in new_hand) + assert card not in new_hand class TestTrickComplete: @@ -79,53 +92,59 @@ def test_is_trick_should_complete_modulo(self): assert trick_complete @given( - data=st.integers(min_value=2, max_value=6).flatmap( - lambda player_count: st.tuples( - st.just(player_count), - st.integers(min_value=0, max_value=player_count - 1), - st.integers(min_value=0, max_value=player_count - 1), - ) - ) - ) + data=st.integers(min_value=2, max_value=6).flatmap( + lambda player_count: st.tuples( + st.just(player_count), + st.integers(min_value=0, max_value=player_count - 1), + st.integers(min_value=0, max_value=player_count - 1), + ), + ), + ) def test_trick_found_always_once_per_rotation(self, data): player_count, starting_player, current_player = data state = make_state( - player_count=player_count, starting_player=starting_player, current_player=current_player + player_count=player_count, + starting_player=starting_player, + current_player=current_player, ) trick_found_count = 0 for player in range(player_count): state.trick_state.current_player = (starting_player + player) % player_count trick_complete = engine._is_trick_complete(state) if trick_complete: - trick_found_count =+ 1 + trick_found_count = +1 assert trick_found_count == 1 @given( - data=st.integers(min_value=2, max_value=6).flatmap( - lambda player_count: st.tuples( - st.just(player_count), - st.integers(min_value=0, max_value=player_count - 1), - ) - ) - ) + data=st.integers(min_value=2, max_value=6).flatmap( + lambda player_count: st.tuples( + st.just(player_count), + st.integers(min_value=0, max_value=player_count - 1), + ), + ), + ) def test_trick_found_at_starting_player_minus_one(self, data): player_count, starting_player = data current_player = (starting_player - 1) % player_count state = make_state( - player_count=player_count, starting_player=starting_player, current_player=current_player + player_count=player_count, + starting_player=starting_player, + current_player=current_player, ) assert engine._is_trick_complete(state) -class TestNextPlayer: - @given( current_player=st.integers( - min_value=0, max_value=6), - player_count=st.integers(min_value=2, max_value=6) +class TestNextPlayer: + @given( + current_player=st.integers(min_value=0, max_value=6), + player_count=st.integers(min_value=2, max_value=6), ) def test_next_player(self, current_player, player_count): - state = make_state(player_count=player_count, starting_player=0, current_player=current_player) + state = make_state( + player_count=player_count, starting_player=0, current_player=current_player, + ) next_state = engine._move_to_next_player(state) next_player = (current_player + 1) % player_count assert next_player == next_state.trick_state.current_player diff --git a/tests/game/test_game_flow.py b/tests/game/test_game_flow.py index c6d8bbe..5a4aaf2 100644 --- a/tests/game/test_game_flow.py +++ b/tests/game/test_game_flow.py @@ -7,6 +7,7 @@ - In der letzten Runde werden alle Karten ausgeteilt; es gibt keinen Trumpf. """ + import pytest from clanker_bench.game.deck import deal_cards, init_deck @@ -18,9 +19,16 @@ class TestRoundStructure: - @pytest.mark.parametrize("player_count, expected_rounds", ROUNDS_BY_PLAYER_COUNT.items()) - def test_round_count_is_deck_divided_by_players(self, player_count, expected_rounds): - assert new_game(Config(player_count=player_count), 0).round_count == expected_rounds + @pytest.mark.parametrize( + "player_count, expected_rounds", ROUNDS_BY_PLAYER_COUNT.items(), + ) + def test_round_count_is_deck_divided_by_players( + self, player_count, expected_rounds, + ): + assert ( + new_game(Config(player_count=player_count), 0).round_count + == expected_rounds + ) @pytest.mark.parametrize("player_count, rounds", ROUNDS_BY_PLAYER_COUNT.items()) def test_last_round_deals_out_the_whole_deck(self, player_count, rounds): diff --git a/tests/game/test_scoring.py b/tests/game/test_scoring.py index 4bb96a5..e927719 100644 --- a/tests/game/test_scoring.py +++ b/tests/game/test_scoring.py @@ -9,6 +9,7 @@ - 4 angesagt, 2 bekommen -> (2 - 4) * 10 = -20 - 0 angesagt, 0 bekommen -> 20 = 20 """ + import pytest from clanker_bench.game import engine @@ -19,18 +20,18 @@ class TestRoundScore: @pytest.mark.parametrize( "predicted, actual, expected", [ - ([3], [3], [50]), # right Prediction: 20 + 30 - ([4], [2], [-20]), # 2 below : -10 * 2 - ([0], [0], [20]), # 0 predicted, 0 gotten - ([2], [5], [-30]), # 3 to much: -10 * 3 - ([1], [0], [-10]), # 1 below - ([5], [5], [70]), # right Prediction 20 + 50 + ([3], [3], [50]), # right Prediction: 20 + 30 + ([4], [2], [-20]), # 2 below : -10 * 2 + ([0], [0], [20]), # 0 predicted, 0 gotten + ([2], [5], [-30]), # 3 to much: -10 * 3 + ([1], [0], [-10]), # 1 below + ([5], [5], [70]), # right Prediction 20 + 50 ([3, 4, 0], [3, 2, 0], [50, -20, 20]), # multiple players combined ], ) def test_round_score(self, predicted, actual, expected): state = make_state( - predicted=predicted, actual=actual, player_count=len(predicted) + predicted=predicted, actual=actual, player_count=len(predicted), ) result = engine._calculate_round_score(state) assert result is not None, "_calculate_round_score muss ein Ergebnis liefern" diff --git a/tests/game/test_setup.py b/tests/game/test_setup.py index a248fdd..65ae12c 100644 --- a/tests/game/test_setup.py +++ b/tests/game/test_setup.py @@ -6,7 +6,6 @@ -> kein fester Trumpf; SINGULARITY -> Geber waehlt). - Trumpf ist nie eine CLANKER- oder SINGULARITY-"Farbe". """ -import random import pytest @@ -41,13 +40,20 @@ def test_trump_matches_revealed_top_card(seed): state = new_game(Config(player_count=3), seed=seed) assert state.round_state.current_trump_suit == expected_trump - assert state.phase == Phase.SELECT_TRUMP if expected_awaiting else state.phase != Phase.SELECT_TRUMP + assert ( + state.phase == Phase.SELECT_TRUMP + if expected_awaiting + else state.phase != Phase.SELECT_TRUMP + ) @pytest.mark.parametrize("seed", SEEDS) def test_trump_is_never_a_special_suit(seed): state = new_game(Config(player_count=3), seed=seed) - assert state.round_state.current_trump_suit in {None, *(Suit.RED, Suit.BLUE, Suit.GREEN, Suit.YELLOW)} + assert state.round_state.current_trump_suit in { + None, + *(Suit.RED, Suit.BLUE, Suit.GREEN, Suit.YELLOW), + } @pytest.mark.parametrize("seed", SEEDS) @@ -56,6 +62,7 @@ def test_awaiting_trump_implies_no_trump_yet(seed): if state.phase == Phase.SELECT_TRUMP: assert state.round_state.current_trump_suit is None + def test_player_names_are_unique(): # Every player has unique name state = new_game(Config(player_count=4), seed=0) diff --git a/tests/game/test_step.py b/tests/game/test_step.py index 0bfef4e..446c84c 100644 --- a/tests/game/test_step.py +++ b/tests/game/test_step.py @@ -3,6 +3,7 @@ GameStates kommen aus dem zentralen `make_state`-Fixture (siehe conftest.py). """ + import pytest from clanker_bench.game.engine import step @@ -12,7 +13,7 @@ SelectTrumpAction, ) from clanker_bench.game.model.card import Card, Suit -from clanker_bench.game.model.exception import IllegalActionException +from clanker_bench.game.model.exception import IllegalActionError from clanker_bench.game.model.gamestate import Phase, PlayedCard from tests.game.conftest import make_state @@ -34,13 +35,13 @@ def test_sets_trump_and_moves_to_predict(self): def test_rejected_outside_select_phase(self): state = make_state(phase=Phase.PREDICT) - with pytest.raises(IllegalActionException): + with pytest.raises(IllegalActionError): step(state, SelectTrumpAction(suit=Suit.RED)) @pytest.mark.parametrize("bad", [Suit.CLANKER, Suit.SINGULARITY]) def test_rejects_non_colour(self, bad): state = make_state(phase=Phase.SELECT_TRUMP) - with pytest.raises(IllegalActionException): + with pytest.raises(IllegalActionError): step(state, SelectTrumpAction(suit=bad)) @@ -50,8 +51,12 @@ class TestPredictBranch: @staticmethod def _state(make_state, **kw): return make_state( - phase=Phase.PREDICT, player_count=3, starting_player=1, - dealer_id=0, round_nr=2, **kw + phase=Phase.PREDICT, + player_count=3, + starting_player=1, + dealer_id=0, + round_nr=2, + **kw, ) def test_records_prediction_and_advances(self): @@ -66,23 +71,25 @@ def test_intermediate_prediction_stays_in_predict(self): assert new.phase == Phase.PREDICT def test_all_predictions_then_switch_to_play(self): - state = self._state(make_state) # p1 am Zug - state = step(state, PredictTricksAction(trick_count=1)) # p1 - state = step(state, PredictTricksAction(trick_count=1)) # p2 - state = step(state, PredictTricksAction(trick_count=0)) # p0 (Dealer, 1 verboten) + state = self._state(make_state) # p1 am Zug + state = step(state, PredictTricksAction(trick_count=1)) # p1 + state = step(state, PredictTricksAction(trick_count=1)) # p2 + state = step( + state, PredictTricksAction(trick_count=0), + ) # p0 (Dealer, 1 verboten) assert state.round_state.predicted_player_tricks == [0, 1, 1] assert state.phase == Phase.PLAY - assert state.trick_state.current_player == 1 # zurueck beim Ausspieler + assert state.trick_state.current_player == 1 # zurueck beim Ausspieler def test_rejected_outside_predict_phase(self): state = make_state(phase=Phase.PLAY) - with pytest.raises(IllegalActionException): + with pytest.raises(IllegalActionError): step(state, PredictTricksAction(trick_count=0)) def test_dealer_cannot_make_total_equal_trick_count(self): # p1=1, p2=1 schon angesagt; Dealer p0 darf nicht 1 waehlen (Summe 3 = Stichzahl). state = self._state(make_state, predicted=[-1, 1, 1], current_player=0) - with pytest.raises(IllegalActionException): + with pytest.raises(IllegalActionError): step(state, PredictTricksAction(trick_count=1)) @@ -91,13 +98,20 @@ class TestPlayCardBranch: @staticmethod def _state(make_state, hands, **kw): return make_state( - phase=Phase.PLAY, player_count=3, starting_player=0, - dealer_id=2, trump=None, hands=hands, **kw + phase=Phase.PLAY, + player_count=3, + starting_player=0, + dealer_id=2, + trump=None, + hands=hands, + **kw, ) def test_playing_a_card_advances_to_next_player(self): hands = [[card(Suit.RED, 5)], [card(Suit.RED, 9)], [card(Suit.RED, 2)]] - new = step(self._state(make_state, hands), PlayCardAction(card=card(Suit.RED, 5))) + new = step( + self._state(make_state, hands), PlayCardAction(card=card(Suit.RED, 5)), + ) assert len(new.trick_state.current_trick) == 1 assert card(Suit.RED, 5) not in new.players[0].own_hand assert new.trick_state.current_player == 1 @@ -106,35 +120,42 @@ def test_playing_a_card_advances_to_next_player(self): def test_completing_trick_tallies_the_winner(self): hands = [[card(Suit.RED, 5)], [card(Suit.RED, 9)], [card(Suit.RED, 2)]] state = self._state(make_state, hands) - state = step(state, PlayCardAction(card=card(Suit.RED, 5))) # p0 - state = step(state, PlayCardAction(card=card(Suit.RED, 9))) # p1 - state = step(state, PlayCardAction(card=card(Suit.RED, 2))) # p2 -> Stich voll - assert state.round_state.actual_player_tricks[1] == 1 # RED9 gewinnt + state = step(state, PlayCardAction(card=card(Suit.RED, 5))) # p0 + state = step(state, PlayCardAction(card=card(Suit.RED, 9))) # p1 + state = step(state, PlayCardAction(card=card(Suit.RED, 2))) # p2 -> Stich voll + assert state.round_state.actual_player_tricks[1] == 1 # RED9 gewinnt assert state.round_state.trick_nr == 1 assert state.trick_state.current_trick == [] - assert state.trick_state.starting_player == 1 # Gewinner fuehrt neu + assert state.trick_state.starting_player == 1 # Gewinner fuehrt neu assert len(state.round_state.played_cards) == 3 assert state.phase == Phase.PLAY def test_last_trick_finishes_round_and_deals_next(self): # round_nr=0 => genau 1 Stich; danach Rundenwechsel (round_count=2). hands = [[card(Suit.RED, 5)], [card(Suit.RED, 9)], [card(Suit.RED, 2)]] - state = self._state(make_state, hands, round_nr=0, round_count=2, predicted=[0, 1, 0]) + state = self._state( + make_state, hands, round_nr=0, round_count=2, predicted=[0, 1, 0], + ) state = step(state, PlayCardAction(card=card(Suit.RED, 5))) state = step(state, PlayCardAction(card=card(Suit.RED, 9))) - state = step(state, PlayCardAction(card=card(Suit.RED, 2))) # Runde fertig + state = step(state, PlayCardAction(card=card(Suit.RED, 2))) # Runde fertig assert len(state.scoreboard.rounds) == 1 assert state.round_nr == 1 assert state.phase in (Phase.SELECT_TRUMP, Phase.PREDICT) def test_rejected_outside_play_phase(self): state = make_state(phase=Phase.PREDICT, hands=[[card(Suit.RED, 5)], [], []]) - with pytest.raises(IllegalActionException): + with pytest.raises(IllegalActionError): step(state, PlayCardAction(card=card(Suit.RED, 5))) def test_must_follow_demanded_suit(self): # RED gefordert (p0 hat RED5 gelegt), p1 hat RED -> darf nicht BLUE spielen. hands = [[], [card(Suit.RED, 9), card(Suit.BLUE, 3)], []] - state = self._state(make_state, hands, current_player=1, current_trick=(pc(0, card(Suit.RED, 5)),)) - with pytest.raises(IllegalActionException): + state = self._state( + make_state, + hands, + current_player=1, + current_trick=(pc(0, card(Suit.RED, 5)),), + ) + with pytest.raises(IllegalActionError): step(state, PlayCardAction(card=card(Suit.BLUE, 3))) diff --git a/tests/game/test_trick_resolution.py b/tests/game/test_trick_resolution.py index 6550efe..9848dbc 100644 --- a/tests/game/test_trick_resolution.py +++ b/tests/game/test_trick_resolution.py @@ -9,6 +9,7 @@ - Ein CLANKER macht keinen Stich, es sei denn, in einem Stich werden nur CLANKER gespielt. In diesem Fall gewinnt der erste CLANKER den Stich. """ + from clanker_bench.game import engine from clanker_bench.game.model.card import Card, Suit from tests.game.conftest import make_state @@ -23,26 +24,46 @@ def card(suit: Suit, rank: int = 1) -> Card: class TestIsHigherCard: def test_singularity_beats_trump(self): - assert engine._is_higher_card(SINGULARITY, card(Suit.GREEN, 13), Suit.GREEN, Suit.RED) - assert not engine._is_higher_card(card(Suit.GREEN, 13), SINGULARITY, Suit.GREEN, Suit.RED) + assert engine._is_higher_card( + SINGULARITY, card(Suit.GREEN, 13), Suit.GREEN, Suit.RED, + ) + assert not engine._is_higher_card( + card(Suit.GREEN, 13), SINGULARITY, Suit.GREEN, Suit.RED, + ) def test_trump_beats_demanded_and_offsuit(self): # Trump GREEN, demanded RED. - assert engine._is_higher_card(card(Suit.GREEN, 2), card(Suit.RED, 13), Suit.GREEN, Suit.RED) - assert not engine._is_higher_card(card(Suit.RED, 13), card(Suit.GREEN, 2), Suit.GREEN, Suit.RED) + assert engine._is_higher_card( + card(Suit.GREEN, 2), card(Suit.RED, 13), Suit.GREEN, Suit.RED, + ) + assert not engine._is_higher_card( + card(Suit.RED, 13), card(Suit.GREEN, 2), Suit.GREEN, Suit.RED, + ) def test_higher_trump_beats_lower_trump(self): - assert engine._is_higher_card(card(Suit.GREEN, 10), card(Suit.GREEN, 5), Suit.GREEN, Suit.RED) - assert not engine._is_higher_card(card(Suit.GREEN, 5), card(Suit.GREEN, 10), Suit.GREEN, Suit.RED) + assert engine._is_higher_card( + card(Suit.GREEN, 10), card(Suit.GREEN, 5), Suit.GREEN, Suit.RED, + ) + assert not engine._is_higher_card( + card(Suit.GREEN, 5), card(Suit.GREEN, 10), Suit.GREEN, Suit.RED, + ) def test_demanded_beats_offsuit(self): # No trump; demanded RED beats an unrelated colour regardless of rank. - assert engine._is_higher_card(card(Suit.RED, 2), card(Suit.BLUE, 13), None, Suit.RED) - assert not engine._is_higher_card(card(Suit.BLUE, 13), card(Suit.RED, 2), None, Suit.RED) + assert engine._is_higher_card( + card(Suit.RED, 2), card(Suit.BLUE, 13), None, Suit.RED, + ) + assert not engine._is_higher_card( + card(Suit.BLUE, 13), card(Suit.RED, 2), None, Suit.RED, + ) def test_higher_rank_wins_within_demanded_suit(self): - assert engine._is_higher_card(card(Suit.RED, 10), card(Suit.RED, 5), None, Suit.RED) - assert not engine._is_higher_card(card(Suit.RED, 5), card(Suit.RED, 10), None, Suit.RED) + assert engine._is_higher_card( + card(Suit.RED, 10), card(Suit.RED, 5), None, Suit.RED, + ) + assert not engine._is_higher_card( + card(Suit.RED, 5), card(Suit.RED, 10), None, Suit.RED, + ) def test_clanker_loses_to_everything(self): assert not engine._is_higher_card(CLANKER, card(Suit.RED, 1), None, Suit.RED) @@ -51,35 +72,54 @@ def test_clanker_loses_to_everything(self): def test_equal_tier_does_not_override_first(self): # Two SINGULARITY / two CLANKER: the later one must NOT count as higher, # so the first-played one stays the winner. - assert not engine._is_higher_card(SINGULARITY, SINGULARITY, Suit.GREEN, Suit.RED) + assert not engine._is_higher_card( + SINGULARITY, SINGULARITY, Suit.GREEN, Suit.RED, + ) assert not engine._is_higher_card(CLANKER, CLANKER, Suit.GREEN, Suit.RED) class TestDetermineTrickWinner: # `current_trick` nimmt rohe Cards an -> player_id = Reihenfolge (via conftest-Factory). def test_first_singularity_always_wins(self): - state = make_state(current_trick=[card(Suit.RED, 5), SINGULARITY, card(Suit.GREEN, 13)], trump=Suit.GREEN) + state = make_state( + current_trick=[card(Suit.RED, 5), SINGULARITY, card(Suit.GREEN, 13)], + trump=Suit.GREEN, + ) assert engine._determine_trick_winner(state) == 1 def test_first_of_several_singularities_wins(self): - state = make_state(current_trick=[card(Suit.RED, 5), SINGULARITY, SINGULARITY], trump=Suit.GREEN) + state = make_state( + current_trick=[card(Suit.RED, 5), SINGULARITY, SINGULARITY], + trump=Suit.GREEN, + ) assert engine._determine_trick_winner(state) == 1 def test_highest_trump_wins_without_singularity(self): - state = make_state(current_trick=[card(Suit.RED, 5), card(Suit.GREEN, 3), card(Suit.RED, 9)], trump=Suit.GREEN) + state = make_state( + current_trick=[card(Suit.RED, 5), card(Suit.GREEN, 3), card(Suit.RED, 9)], + trump=Suit.GREEN, + ) assert engine._determine_trick_winner(state) == 1 def test_highest_demanded_wins_without_trump_or_singularity(self): - state = make_state(current_trick=[card(Suit.RED, 2), card(Suit.RED, 13), card(Suit.RED, 7)], trump=None) + state = make_state( + current_trick=[card(Suit.RED, 2), card(Suit.RED, 13), card(Suit.RED, 7)], + trump=None, + ) assert engine._determine_trick_winner(state) == 1 def test_offsuit_card_never_wins(self): # No trump; demanded is RED, an off-suit GREEN must not win even with high rank. - state = make_state(current_trick=[card(Suit.RED, 5), card(Suit.GREEN, 13), card(Suit.RED, 9)], trump=None) + state = make_state( + current_trick=[card(Suit.RED, 5), card(Suit.GREEN, 13), card(Suit.RED, 9)], + trump=None, + ) assert engine._determine_trick_winner(state) == 2 def test_clanker_does_not_win_against_colour(self): - state = make_state(current_trick=[CLANKER, card(Suit.RED, 5), card(Suit.RED, 9)], trump=None) + state = make_state( + current_trick=[CLANKER, card(Suit.RED, 5), card(Suit.RED, 9)], trump=None, + ) assert engine._determine_trick_winner(state) == 2 def test_only_clankers_first_clanker_wins(self): @@ -88,5 +128,7 @@ def test_only_clankers_first_clanker_wins(self): def test_clanker_lead_then_singularity_wins(self): # CLANKER leads (no influence), SINGULARITY is first real card -> it wins. - state = make_state(current_trick=[CLANKER, SINGULARITY, card(Suit.GREEN, 13)], trump=Suit.GREEN) + state = make_state( + current_trick=[CLANKER, SINGULARITY, card(Suit.GREEN, 13)], trump=Suit.GREEN, + ) assert engine._determine_trick_winner(state) == 1 diff --git a/uv.lock b/uv.lock index b9c622c..ac6b0fe 100644 --- a/uv.lock +++ b/uv.lock @@ -17,21 +17,27 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "pydantic" }, + { name = "pyrefly" }, ] [package.dev-dependencies] dev = [ { name = "hypothesis" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.13.4" }] +requires-dist = [ + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pyrefly", specifier = ">=0.60.0" }, +] [package.metadata.requires-dev] dev = [ { name = "hypothesis", specifier = ">=6.155.7" }, { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.15.21" }, ] [[package]] @@ -181,6 +187,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyrefly" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/20/976165fa4b1517a1a92f393b3f4d4badabfff1165eff09d4cd4908428183/pyrefly-1.1.1.tar.gz", hash = "sha256:6deda959f8603a7dbdf112c48983e2275b2903cf33c8c739ed65d7e71a4fd520", size = 5880491, upload-time = "2026-06-18T23:45:43.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/d6/02ba666018c6a1cb4ddfa2db98ada721adddd374db5c29ba47a0bf2637fa/pyrefly-1.1.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f4b8595f91885bc8b5e3c282ab68d1df21201668a84e6508b1e15f2feec0bb8d", size = 13631867, upload-time = "2026-06-18T23:45:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/7a3457dbbddb513a83cf4fe527d5d5ebda5201a1010ad2a6034030e3e358/pyrefly-1.1.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6b238e1362622d47a6eb5af704fd8b613c94e8c303386efd6350e3da59fecc8", size = 13075304, upload-time = "2026-06-18T23:45:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/70f4b3f42d58ed686a80df31e04eca54d88036cea4f9b96195c64ad0b2b5/pyrefly-1.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b50d4510e4f8aaea79e2c4b343a4d7a060c9451c0b2aa9bfe10d7ca1ef33d68d", size = 13446966, upload-time = "2026-06-18T23:45:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/3c/53/12a19bd6c7af985bcbc13c6910d0f9f6684069ead2282a5c08c2bfbb5d03/pyrefly-1.1.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f330cf039ef3da3b910c84f3a7e431f0cf8d0c1d2dad26491d6cadf3c7cd4759", size = 14449222, upload-time = "2026-06-18T23:45:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/93/f0/e55c48a50076fc0f9ecf4bdedec50456db383e01162f5e2121f8468be071/pyrefly-1.1.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6342d87c52b04f72156da04f554c4d57f3616f2b32d1763969efb22d05a1407", size = 14472947, upload-time = "2026-06-18T23:45:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/30e085b31fed978ecb675bdbb54df566673ab550469e5af2d350f6af0be6/pyrefly-1.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c08b814ad03175e9cf47111390537161828b472044c39ab3320252b3ac6b2edd", size = 13975252, upload-time = "2026-06-18T23:45:27.247Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/49c3e67641133d3fe5d8d9a660dc0826c6c37ca197d86cad05fa7dd8bfd6/pyrefly-1.1.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d50cad97f19fc893b04deff7239626cffff5dd27ffb29b7d303a1b770247b208", size = 13471780, upload-time = "2026-06-18T23:45:29.775Z" }, + { url = "https://files.pythonhosted.org/packages/71/1e/65a7ba8355e2c39d8331832905fb74dcc85fc122a3f1dfd6dbf2a88907ad/pyrefly-1.1.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2150b450ee6a6bcbe69b2d45d9a4ebc934a609e1abcf65e490433f38eb873d84", size = 13989306, upload-time = "2026-06-18T23:45:32.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/b7ee1ab2392c36945738246fba7524439810befa3cfcc03cb6157567fc10/pyrefly-1.1.1-py3-none-win32.whl", hash = "sha256:5ffd8a8ed62fe4e6bf0afe1837d1bad149bb3b9f80e928ef248c96b836db3742", size = 12608469, upload-time = "2026-06-18T23:45:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/a0f5b52934bf80e9c7eff08222e7caf318287b9aef76acb8d9ac5740581b/pyrefly-1.1.1-py3-none-win_amd64.whl", hash = "sha256:4e0430f3ef69c8ac73505fd6584db70ed504665a9f0816fef7f723de510f26cb", size = 13502172, upload-time = "2026-06-18T23:45:38.375Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/4c6bcb3d456835f51445d3662a428f56c3ea5643ec798c577030ae34298c/pyrefly-1.1.1-py3-none-win_arm64.whl", hash = "sha256:83baf0db71e172665db1fca0ced50b8f7773f5192ca57e8ac6773a772b6d2fc5", size = 12895979, upload-time = "2026-06-18T23:45:41.026Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -197,6 +222,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0"