Trump card: draw from the stack, no trump for wizards and jesters

- Deal the hands from the shuffled stack and draw the trump card from it,
  so the trump card is in nobody's hand and cannot be played
- A wizard or jester as trump card means no trump suit (previously a
  wizard let the dealer choose); remove the trump selection phase, the
  ChooseTrump action, the trump picker UI and the AI trump actions
- Completed tricks carry their round's trump card, so the table keeps
  showing the matching trump card while the last trick of a round is
  displayed
- Card corners are no longer rotated, so 6 and 9 cannot be confused
- Trump label explains why there is no trump (wizard, jester, last round)
- Tests: trump card never dealt, special cards give no trump, tricks keep
  their trump card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:28:53 +02:00
co-authored by Claude Opus 5.5
parent fde8e2fb2e
commit 21a2b044d9
16 changed files with 135 additions and 190 deletions
@@ -4,7 +4,7 @@ This package is independent of the game server so that it can also be used
to train AI players.
"""
from .actions import Action, Bid, ChooseTrump, PlayCard, action_from_dict, action_to_dict
from .actions import Action, Bid, PlayCard, action_from_dict, action_to_dict
from .cards import ALL_CARDS, DECK_SIZE, SUITS, Card, CardKind, Suit, card_by_id
from .game import IllegalActionError, WizardGame
from .observation import Observation
@@ -32,7 +32,6 @@ __all__ = [
"Bid",
"Card",
"CardKind",
"ChooseTrump",
"Event",
"GameFinished",
"GameOptions",
@@ -5,7 +5,6 @@ frontend, the game server and the AI player:
- ``{"type": "bid", "value": 2}``
- ``{"type": "play", "card": 17}``
- ``{"type": "trump", "suit": "red"}``
"""
from __future__ import annotations
@@ -13,7 +12,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .cards import Card, Suit, card_by_id
from .cards import Card, card_by_id
@dataclass(frozen=True, slots=True)
@@ -32,15 +31,7 @@ class PlayCard:
return {"type": "play", "card": self.card.id}
@dataclass(frozen=True, slots=True)
class ChooseTrump:
suit: Suit
def to_dict(self) -> dict:
return {"type": "trump", "suit": self.suit.value}
Action = Bid | PlayCard | ChooseTrump
Action = Bid | PlayCard
def action_to_dict(action: Action) -> dict:
@@ -66,8 +57,6 @@ def action_from_dict(data: dict[str, Any]) -> Action:
if not isinstance(card_id, int) or isinstance(card_id, bool):
raise ValueError("Card must be an integer id")
return PlayCard(card_by_id(card_id))
if kind == "trump":
return ChooseTrump(Suit(data["suit"]))
except KeyError as exc:
raise ValueError(f"Missing field {exc} in action") from exc
raise ValueError(f"Unknown action type: {kind!r}")
+17 -33
View File
@@ -19,8 +19,8 @@ import copy
import random
from . import rules
from .actions import Action, Bid, ChooseTrump, PlayCard
from .cards import SUITS, Card, new_shuffled_deck
from .actions import Action, Bid, PlayCard
from .cards import Card, new_shuffled_deck
from .observation import Observation
from .state import (
Event,
@@ -99,9 +99,6 @@ class WizardGame:
if player is None or player != self.current_player:
return []
if self.phase is Phase.TRUMP_SELECTION:
return [ChooseTrump(suit) for suit in SUITS]
if self.phase is Phase.BIDDING:
return [Bid(value) for value in self._allowed_bids(player)]
@@ -127,8 +124,6 @@ class WizardGame:
if not self.is_legal(player, action):
raise IllegalActionError(f"Illegal action {action} for player {player}")
if isinstance(action, ChooseTrump):
return self._choose_trump(action)
if isinstance(action, Bid):
return self._bid(player, action)
if isinstance(action, PlayCard):
@@ -183,39 +178,32 @@ class WizardGame:
return (player + 1) % self.num_players
def _deal(self, round_number: int, dealer: int) -> RoundState:
deck = new_shuffled_deck(self._rng)
stack = new_shuffled_deck(self._rng)
hands: list[list[Card]] = [[] for _ in range(self.num_players)]
# Deal one card at a time starting left of the dealer.
# Deal one card at a time from the top of the stack, starting left of
# the dealer.
for i in range(round_number * self.num_players):
hands[(dealer + 1 + i) % self.num_players].append(deck[i])
remaining = deck[round_number * self.num_players:]
hands[(dealer + 1 + i) % self.num_players].append(stack.pop())
# The next card defines the trump suit. In the last round no cards
# are left, so there is no trump.
trump_card = remaining[0] if remaining else None
# The next card of the stack is turned up as trump card. It is taken
# off the stack and belongs to nobody's hand. In the last round the
# stack is empty, so there is no trump card.
trump_card = stack.pop() if stack else None
starter = self._next(dealer)
rnd = RoundState(
self.phase = Phase.BIDDING
self.current_player = starter
return RoundState(
round_number=round_number,
dealer=dealer,
hands=hands,
trump_card=trump_card,
trump_suit=trump_card.suit if trump_card and trump_card.is_number else None,
trump_suit=rules.trump_suit(trump_card),
bids=[None] * self.num_players,
tricks_won=[0] * self.num_players,
trick_leader=starter,
)
if trump_card is not None and trump_card.is_wizard:
# A wizard as trump card: the dealer chooses the trump suit.
self.phase = Phase.TRUMP_SELECTION
self.current_player = dealer
else:
# No trump card or a jester: the round is played without trump.
self.phase = Phase.BIDDING
self.current_player = starter
return rnd
def _allowed_bids(self, player: int) -> list[int]:
"""Bids 0..cards in hand, except that the last bidder may not make the
sum of all bids equal the number of cards each player holds this round.
@@ -229,12 +217,6 @@ class WizardGame:
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
def _choose_trump(self, action: ChooseTrump) -> list[Event]:
self.round.trump_suit = action.suit
self.phase = Phase.BIDDING
self.current_player = self._next(self.round.dealer)
return []
def _bid(self, player: int, action: Bid) -> list[Event]:
rnd = self.round
rnd.bids[player] = action.value
@@ -263,7 +245,9 @@ class WizardGame:
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
rnd.tricks_won[winner] += 1
result = TrickResult(rnd.round_number, rnd.trick_number, tuple(rnd.current_trick), winner)
result = TrickResult(
rnd.round_number, rnd.trick_number, tuple(rnd.current_trick), winner, rnd.trump_card
)
self.last_trick = result
events: list[Event] = [TrickCompleted(result)]
@@ -28,6 +28,17 @@ def validate_player_count(num_players: int) -> None:
)
def trump_suit(trump_card: Card | None) -> Suit | None:
"""The trump suit defined by the turned up trump card.
Only a number card defines a trump suit. A wizard, a jester or no trump
card at all (last round) means the round is played without trump.
"""
if trump_card is None or not trump_card.is_number:
return None
return trump_card.suit
def lead_suit(trick: Sequence[Card]) -> Suit | None:
"""The suit that has to be followed in a trick.
@@ -10,7 +10,6 @@ from .cards import Card, Suit, card_by_id
class Phase(str, Enum):
TRUMP_SELECTION = "trump_selection"
BIDDING = "bidding"
PLAYING = "playing"
FINISHED = "finished"
@@ -51,6 +50,8 @@ class TrickResult:
trick_number: int
cards: tuple[PlayedCard, ...]
winner: int
# The trump card of the round the trick was played in.
trump_card: Card | None = None
def to_dict(self) -> dict:
return {
@@ -58,6 +59,7 @@ class TrickResult:
"trick_number": self.trick_number,
"cards": [played.to_dict() for played in self.cards],
"winner": self.winner,
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
}
@classmethod
@@ -67,6 +69,7 @@ class TrickResult:
trick_number=data["trick_number"],
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
winner=data["winner"],
trump_card=card_by_id(data["trump_card"]["id"]) if data.get("trump_card") else None,
)