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:
+1
-1
@@ -6,7 +6,7 @@ provides everything around it.
|
|||||||
- `agents/` — `Agent` interface (`act(observation) -> action`) plus `RandomAgent`,
|
- `agents/` — `Agent` interface (`act(observation) -> action`) plus `RandomAgent`,
|
||||||
`HeuristicAgent` (baseline / fallback) and `ModelAgent` (wraps a `Policy`)
|
`HeuristicAgent` (baseline / fallback) and `ModelAgent` (wraps a `Policy`)
|
||||||
- `model/encoding.py` — observation → fixed-size feature vector (`FEATURE_SIZE`), and the fixed
|
- `model/encoding.py` — observation → fixed-size feature vector (`FEATURE_SIZE`), and the fixed
|
||||||
action space (21 bids + 60 cards + 4 trump suits) with legal-action masks; works for 3–6 players
|
action space (21 bids + 60 cards) with legal-action masks; works for 3–6 players
|
||||||
- `model/policy.py` — **the place to plug in the model**: subclass `Policy`, implement
|
- `model/policy.py` — **the place to plug in the model**: subclass `Policy`, implement
|
||||||
`select`, `update`, `save_weights`/`load`, register with `@register_policy`.
|
`select`, `update`, `save_weights`/`load`, register with `@register_policy`.
|
||||||
`UniformPolicy` is a placeholder so the pipeline runs end to end.
|
`UniformPolicy` is a placeholder so the pipeline runs end to end.
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ as the fallback in production while no trained model is available.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from wizard_game import Action, Bid, Card, Observation, Phase, PlayCard, Suit
|
||||||
|
|
||||||
from wizard_game import SUITS, Action, Bid, Card, ChooseTrump, Observation, Phase, PlayCard, Suit
|
|
||||||
from wizard_game.rules import trick_winner
|
from wizard_game.rules import trick_winner
|
||||||
|
|
||||||
from .base import Agent
|
from .base import Agent
|
||||||
@@ -18,21 +16,11 @@ class HeuristicAgent(Agent):
|
|||||||
name = "heuristic"
|
name = "heuristic"
|
||||||
|
|
||||||
def act(self, observation: Observation) -> Action:
|
def act(self, observation: Observation) -> Action:
|
||||||
if observation.phase is Phase.TRUMP_SELECTION:
|
|
||||||
return ChooseTrump(self._best_trump(observation.hand))
|
|
||||||
if observation.phase is Phase.BIDDING:
|
if observation.phase is Phase.BIDDING:
|
||||||
return self._bid(observation)
|
return self._bid(observation)
|
||||||
return self._play(observation)
|
return self._play(observation)
|
||||||
|
|
||||||
# --- Trump & bidding ----------------------------------------------------
|
# --- Bidding ------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _best_trump(hand: tuple[Card, ...]) -> Suit:
|
|
||||||
strength = Counter({suit: 0.0 for suit in SUITS})
|
|
||||||
for card in hand:
|
|
||||||
if card.is_number and card.suit is not None:
|
|
||||||
strength[card.suit] += 1 + card.rank / 13
|
|
||||||
return strength.most_common(1)[0][0]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _expected_tricks(observation: Observation) -> float:
|
def _expected_tricks(observation: Observation) -> float:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from wizard_game import (
|
|||||||
SUITS,
|
SUITS,
|
||||||
Action,
|
Action,
|
||||||
Bid,
|
Bid,
|
||||||
ChooseTrump,
|
|
||||||
Observation,
|
Observation,
|
||||||
Phase,
|
Phase,
|
||||||
PlayCard,
|
PlayCard,
|
||||||
@@ -32,12 +31,11 @@ SCORE_SCALE = 100.0
|
|||||||
|
|
||||||
|
|
||||||
class ActionSpace:
|
class ActionSpace:
|
||||||
"""Fixed action indexing: bids 0-20, then the 60 cards, then 4 trump suits."""
|
"""Fixed action indexing: bids 0-20, then the 60 cards."""
|
||||||
|
|
||||||
BID_OFFSET = 0
|
BID_OFFSET = 0
|
||||||
CARD_OFFSET = BID_OFFSET + MAX_BID + 1
|
CARD_OFFSET = BID_OFFSET + MAX_BID + 1
|
||||||
TRUMP_OFFSET = CARD_OFFSET + DECK_SIZE
|
SIZE = CARD_OFFSET + DECK_SIZE
|
||||||
SIZE = TRUMP_OFFSET + len(SUITS)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def index(cls, action: Action) -> int:
|
def index(cls, action: Action) -> int:
|
||||||
@@ -45,16 +43,12 @@ class ActionSpace:
|
|||||||
return cls.BID_OFFSET + action.value
|
return cls.BID_OFFSET + action.value
|
||||||
if isinstance(action, PlayCard):
|
if isinstance(action, PlayCard):
|
||||||
return cls.CARD_OFFSET + action.card.id
|
return cls.CARD_OFFSET + action.card.id
|
||||||
if isinstance(action, ChooseTrump):
|
|
||||||
return cls.TRUMP_OFFSET + action.suit.index
|
|
||||||
raise TypeError(f"Unknown action {action!r}")
|
raise TypeError(f"Unknown action {action!r}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def action(cls, index: int) -> Action:
|
def action(cls, index: int) -> Action:
|
||||||
if not 0 <= index < cls.SIZE:
|
if not 0 <= index < cls.SIZE:
|
||||||
raise ValueError(f"Action index out of range: {index}")
|
raise ValueError(f"Action index out of range: {index}")
|
||||||
if index >= cls.TRUMP_OFFSET:
|
|
||||||
return ChooseTrump(SUITS[index - cls.TRUMP_OFFSET])
|
|
||||||
if index >= cls.CARD_OFFSET:
|
if index >= cls.CARD_OFFSET:
|
||||||
return PlayCard(card_by_id(index - cls.CARD_OFFSET))
|
return PlayCard(card_by_id(index - cls.CARD_OFFSET))
|
||||||
return Bid(index - cls.BID_OFFSET)
|
return Bid(index - cls.BID_OFFSET)
|
||||||
@@ -86,7 +80,7 @@ def _cards(cards) -> list[float]:
|
|||||||
return vector
|
return vector
|
||||||
|
|
||||||
|
|
||||||
PHASES = (Phase.TRUMP_SELECTION, Phase.BIDDING, Phase.PLAYING)
|
PHASES = (Phase.BIDDING, Phase.PLAYING)
|
||||||
|
|
||||||
|
|
||||||
def encode_observation(obs: Observation) -> list[float]:
|
def encode_observation(obs: Observation) -> list[float]:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ rng = random.Random(1)
|
|||||||
while not game.is_finished:
|
while not game.is_finished:
|
||||||
player = game.current_player
|
player = game.current_player
|
||||||
obs = game.observation(player) # what this player may see
|
obs = game.observation(player) # what this player may see
|
||||||
action = rng.choice(obs.legal_actions) # Bid / PlayCard / ChooseTrump
|
action = rng.choice(obs.legal_actions) # Bid / PlayCard
|
||||||
events = game.apply(player, action) # TrickCompleted, RoundCompleted, ...
|
events = game.apply(player, action) # TrickCompleted, RoundCompleted, ...
|
||||||
print(game.scores, game.winners())
|
print(game.scores, game.winners())
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import random
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wizard_game import (
|
from wizard_game import (
|
||||||
|
DECK_SIZE,
|
||||||
Bid,
|
Bid,
|
||||||
ChooseTrump,
|
|
||||||
GameFinished,
|
GameFinished,
|
||||||
GameOptions,
|
GameOptions,
|
||||||
IllegalActionError,
|
IllegalActionError,
|
||||||
@@ -13,7 +13,8 @@ from wizard_game import (
|
|||||||
Phase,
|
Phase,
|
||||||
PlayCard,
|
PlayCard,
|
||||||
RoundCompleted,
|
RoundCompleted,
|
||||||
Suit,
|
TrickCompleted,
|
||||||
|
TrickResult,
|
||||||
WizardGame,
|
WizardGame,
|
||||||
action_from_dict,
|
action_from_dict,
|
||||||
card_by_id,
|
card_by_id,
|
||||||
@@ -59,7 +60,7 @@ def test_deal_sizes_and_starter():
|
|||||||
game = WizardGame(4, seed=3, first_dealer=2)
|
game = WizardGame(4, seed=3, first_dealer=2)
|
||||||
assert game.round_number == 1
|
assert game.round_number == 1
|
||||||
assert all(len(game.hand(p)) == 1 for p in range(4))
|
assert all(len(game.hand(p)) == 1 for p in range(4))
|
||||||
if game.phase is Phase.BIDDING:
|
assert game.phase is Phase.BIDDING
|
||||||
assert game.current_player == 3
|
assert game.current_player == 3
|
||||||
|
|
||||||
|
|
||||||
@@ -72,31 +73,69 @@ def test_wrong_player_rejected():
|
|||||||
|
|
||||||
def test_illegal_bid_rejected():
|
def test_illegal_bid_rejected():
|
||||||
game = WizardGame(3, seed=1, first_dealer=0)
|
game = WizardGame(3, seed=1, first_dealer=0)
|
||||||
if game.phase is Phase.TRUMP_SELECTION:
|
|
||||||
game.apply(game.current_player, ChooseTrump(Suit.RED))
|
|
||||||
with pytest.raises(IllegalActionError):
|
with pytest.raises(IllegalActionError):
|
||||||
game.apply(game.current_player, Bid(5))
|
game.apply(game.current_player, Bid(5))
|
||||||
|
|
||||||
|
|
||||||
def test_wizard_trump_card_lets_dealer_choose():
|
@pytest.mark.parametrize("num_players", [3, 4, 5, 6])
|
||||||
for seed in range(200):
|
def test_trump_card_is_taken_from_the_stack(num_players):
|
||||||
game = WizardGame(4, seed=seed, first_dealer=1)
|
"""The trump card is dealt to nobody and every card exists only once."""
|
||||||
if game.round.trump_card and game.round.trump_card.is_wizard:
|
game = WizardGame(num_players, seed=num_players)
|
||||||
break
|
rng = random.Random(num_players)
|
||||||
|
rounds_seen = 0
|
||||||
|
while not game.is_finished:
|
||||||
|
if game.phase is Phase.BIDDING and all(bid is None for bid in game.round.bids):
|
||||||
|
rounds_seen += 1
|
||||||
|
hands = [card for player in range(num_players) for card in game.hand(player)]
|
||||||
|
assert len(hands) == len(set(hands)) == game.round_number * num_players
|
||||||
|
trump = game.round.trump_card
|
||||||
|
if game.round_number * num_players < DECK_SIZE:
|
||||||
|
assert trump is not None and trump not in hands
|
||||||
else:
|
else:
|
||||||
pytest.fail("no seed produced a wizard as trump card")
|
assert trump is None # last round: every card was dealt
|
||||||
assert game.phase is Phase.TRUMP_SELECTION
|
game.apply(game.current_player, rng.choice(game.legal_actions()))
|
||||||
assert game.current_player == 1
|
assert rounds_seen == game.total_rounds
|
||||||
game.apply(1, ChooseTrump(Suit.GREEN))
|
|
||||||
assert game.round.trump_suit is Suit.GREEN
|
|
||||||
|
def find_game(predicate, num_players=4):
|
||||||
|
for seed in range(500):
|
||||||
|
game = WizardGame(num_players, seed=seed, first_dealer=1)
|
||||||
|
if predicate(game.round.trump_card):
|
||||||
|
return game
|
||||||
|
pytest.fail("no seed produced the wanted trump card")
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_trump_card_defines_trump_suit():
|
||||||
|
game = find_game(lambda card: card is not None and card.is_number)
|
||||||
|
assert game.round.trump_suit is game.round.trump_card.suit
|
||||||
|
assert game.observation(0).trump_suit is game.round.trump_card.suit
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_trick_keeps_its_rounds_trump_card():
|
||||||
|
game = WizardGame(3, seed=4)
|
||||||
|
rng = random.Random(4)
|
||||||
|
for _ in range(200):
|
||||||
|
trump = game.round.trump_card
|
||||||
|
events = game.apply(game.current_player, rng.choice(game.legal_actions()))
|
||||||
|
tricks = [e.trick for e in events if isinstance(e, TrickCompleted)]
|
||||||
|
for trick in tricks:
|
||||||
|
assert trick.trump_card == trump
|
||||||
|
assert TrickResult.from_dict(trick.to_dict()) == trick
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["wizard", "jester"])
|
||||||
|
def test_special_trump_card_means_no_trump(kind):
|
||||||
|
game = find_game(lambda card: card is not None and card.kind.value == kind)
|
||||||
|
obs = game.observation(0)
|
||||||
|
assert obs.trump_card == game.round.trump_card
|
||||||
|
assert obs.trump_suit is None
|
||||||
|
# Bidding starts right away with the player left of the dealer.
|
||||||
assert game.phase is Phase.BIDDING
|
assert game.phase is Phase.BIDDING
|
||||||
assert game.current_player == 2
|
assert game.current_player == 2
|
||||||
|
|
||||||
|
|
||||||
def test_sum_of_bids_never_equals_cards_in_hand():
|
def test_sum_of_bids_never_equals_cards_in_hand():
|
||||||
game = WizardGame(3, seed=11, first_dealer=0)
|
game = WizardGame(3, seed=11, first_dealer=0)
|
||||||
if game.phase is Phase.TRUMP_SELECTION:
|
|
||||||
game.apply(game.current_player, ChooseTrump(Suit.RED))
|
|
||||||
game.apply(1, Bid(0))
|
game.apply(1, Bid(0))
|
||||||
game.apply(2, Bid(0))
|
game.apply(2, Bid(0))
|
||||||
assert game.legal_actions(0) == [Bid(0)] # bidding 1 would make the sum equal 1
|
assert game.legal_actions(0) == [Bid(0)] # bidding 1 would make the sum equal 1
|
||||||
@@ -104,8 +143,6 @@ def test_sum_of_bids_never_equals_cards_in_hand():
|
|||||||
|
|
||||||
def test_hidden_bids():
|
def test_hidden_bids():
|
||||||
game = WizardGame(3, GameOptions(hidden_bids=True), seed=5, first_dealer=0)
|
game = WizardGame(3, GameOptions(hidden_bids=True), seed=5, first_dealer=0)
|
||||||
if game.phase is Phase.TRUMP_SELECTION:
|
|
||||||
game.apply(game.current_player, ChooseTrump(Suit.RED))
|
|
||||||
game.apply(1, Bid(1))
|
game.apply(1, Bid(1))
|
||||||
assert game.observation(1).bids[1] == 1
|
assert game.observation(1).bids[1] == 1
|
||||||
assert game.observation(2).bids[1] is None
|
assert game.observation(2).bids[1] is None
|
||||||
@@ -130,8 +167,10 @@ def test_observation_hides_other_hands_and_round_trips():
|
|||||||
|
|
||||||
|
|
||||||
def test_action_round_trip():
|
def test_action_round_trip():
|
||||||
for action in (Bid(3), PlayCard(card_by_id(WIZARD_ID_OFFSET)), ChooseTrump(Suit.BLUE)):
|
for action in (Bid(3), PlayCard(card_by_id(WIZARD_ID_OFFSET))):
|
||||||
assert action_from_dict(action.to_dict()) == action
|
assert action_from_dict(action.to_dict()) == action
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
action_from_dict({"type": "trump", "suit": "red"})
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
action_from_dict({"type": "bid", "value": True})
|
action_from_dict({"type": "bid", "value": True})
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
|
|||||||
@@ -77,3 +77,12 @@ def test_round_score():
|
|||||||
assert round_score(3, 3) == 50
|
assert round_score(3, 3) == 50
|
||||||
assert round_score(2, 0) == -20
|
assert round_score(2, 0) == -20
|
||||||
assert round_score(1, 4) == -30
|
assert round_score(1, 4) == -30
|
||||||
|
|
||||||
|
|
||||||
|
def test_trump_suit_from_trump_card():
|
||||||
|
from wizard_game.rules import trump_suit
|
||||||
|
|
||||||
|
assert trump_suit(num(Suit.YELLOW, 4)) is Suit.YELLOW
|
||||||
|
assert trump_suit(WIZ1) is None
|
||||||
|
assert trump_suit(JES1) is None
|
||||||
|
assert trump_suit(None) is None
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This package is independent of the game server so that it can also be used
|
|||||||
to train AI players.
|
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 .cards import ALL_CARDS, DECK_SIZE, SUITS, Card, CardKind, Suit, card_by_id
|
||||||
from .game import IllegalActionError, WizardGame
|
from .game import IllegalActionError, WizardGame
|
||||||
from .observation import Observation
|
from .observation import Observation
|
||||||
@@ -32,7 +32,6 @@ __all__ = [
|
|||||||
"Bid",
|
"Bid",
|
||||||
"Card",
|
"Card",
|
||||||
"CardKind",
|
"CardKind",
|
||||||
"ChooseTrump",
|
|
||||||
"Event",
|
"Event",
|
||||||
"GameFinished",
|
"GameFinished",
|
||||||
"GameOptions",
|
"GameOptions",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ frontend, the game server and the AI player:
|
|||||||
|
|
||||||
- ``{"type": "bid", "value": 2}``
|
- ``{"type": "bid", "value": 2}``
|
||||||
- ``{"type": "play", "card": 17}``
|
- ``{"type": "play", "card": 17}``
|
||||||
- ``{"type": "trump", "suit": "red"}``
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +12,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .cards import Card, Suit, card_by_id
|
from .cards import Card, card_by_id
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -32,15 +31,7 @@ class PlayCard:
|
|||||||
return {"type": "play", "card": self.card.id}
|
return {"type": "play", "card": self.card.id}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
Action = Bid | PlayCard
|
||||||
class ChooseTrump:
|
|
||||||
suit: Suit
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {"type": "trump", "suit": self.suit.value}
|
|
||||||
|
|
||||||
|
|
||||||
Action = Bid | PlayCard | ChooseTrump
|
|
||||||
|
|
||||||
|
|
||||||
def action_to_dict(action: Action) -> dict:
|
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):
|
if not isinstance(card_id, int) or isinstance(card_id, bool):
|
||||||
raise ValueError("Card must be an integer id")
|
raise ValueError("Card must be an integer id")
|
||||||
return PlayCard(card_by_id(card_id))
|
return PlayCard(card_by_id(card_id))
|
||||||
if kind == "trump":
|
|
||||||
return ChooseTrump(Suit(data["suit"]))
|
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise ValueError(f"Missing field {exc} in action") from exc
|
raise ValueError(f"Missing field {exc} in action") from exc
|
||||||
raise ValueError(f"Unknown action type: {kind!r}")
|
raise ValueError(f"Unknown action type: {kind!r}")
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import copy
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from . import rules
|
from . import rules
|
||||||
from .actions import Action, Bid, ChooseTrump, PlayCard
|
from .actions import Action, Bid, PlayCard
|
||||||
from .cards import SUITS, Card, new_shuffled_deck
|
from .cards import Card, new_shuffled_deck
|
||||||
from .observation import Observation
|
from .observation import Observation
|
||||||
from .state import (
|
from .state import (
|
||||||
Event,
|
Event,
|
||||||
@@ -99,9 +99,6 @@ class WizardGame:
|
|||||||
if player is None or player != self.current_player:
|
if player is None or player != self.current_player:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if self.phase is Phase.TRUMP_SELECTION:
|
|
||||||
return [ChooseTrump(suit) for suit in SUITS]
|
|
||||||
|
|
||||||
if self.phase is Phase.BIDDING:
|
if self.phase is Phase.BIDDING:
|
||||||
return [Bid(value) for value in self._allowed_bids(player)]
|
return [Bid(value) for value in self._allowed_bids(player)]
|
||||||
|
|
||||||
@@ -127,8 +124,6 @@ class WizardGame:
|
|||||||
if not self.is_legal(player, action):
|
if not self.is_legal(player, action):
|
||||||
raise IllegalActionError(f"Illegal action {action} for player {player}")
|
raise IllegalActionError(f"Illegal action {action} for player {player}")
|
||||||
|
|
||||||
if isinstance(action, ChooseTrump):
|
|
||||||
return self._choose_trump(action)
|
|
||||||
if isinstance(action, Bid):
|
if isinstance(action, Bid):
|
||||||
return self._bid(player, action)
|
return self._bid(player, action)
|
||||||
if isinstance(action, PlayCard):
|
if isinstance(action, PlayCard):
|
||||||
@@ -183,39 +178,32 @@ class WizardGame:
|
|||||||
return (player + 1) % self.num_players
|
return (player + 1) % self.num_players
|
||||||
|
|
||||||
def _deal(self, round_number: int, dealer: int) -> RoundState:
|
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)]
|
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):
|
for i in range(round_number * self.num_players):
|
||||||
hands[(dealer + 1 + i) % self.num_players].append(deck[i])
|
hands[(dealer + 1 + i) % self.num_players].append(stack.pop())
|
||||||
remaining = deck[round_number * self.num_players:]
|
|
||||||
|
|
||||||
# The next card defines the trump suit. In the last round no cards
|
# The next card of the stack is turned up as trump card. It is taken
|
||||||
# are left, so there is no trump.
|
# off the stack and belongs to nobody's hand. In the last round the
|
||||||
trump_card = remaining[0] if remaining else None
|
# stack is empty, so there is no trump card.
|
||||||
|
trump_card = stack.pop() if stack else None
|
||||||
starter = self._next(dealer)
|
starter = self._next(dealer)
|
||||||
|
|
||||||
rnd = RoundState(
|
self.phase = Phase.BIDDING
|
||||||
|
self.current_player = starter
|
||||||
|
return RoundState(
|
||||||
round_number=round_number,
|
round_number=round_number,
|
||||||
dealer=dealer,
|
dealer=dealer,
|
||||||
hands=hands,
|
hands=hands,
|
||||||
trump_card=trump_card,
|
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,
|
bids=[None] * self.num_players,
|
||||||
tricks_won=[0] * self.num_players,
|
tricks_won=[0] * self.num_players,
|
||||||
trick_leader=starter,
|
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]:
|
def _allowed_bids(self, player: int) -> list[int]:
|
||||||
"""Bids 0..cards in hand, except that the last bidder may not make the
|
"""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.
|
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]
|
allowed = [bid for bid in allowed if bid != forbidden]
|
||||||
return allowed
|
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]:
|
def _bid(self, player: int, action: Bid) -> list[Event]:
|
||||||
rnd = self.round
|
rnd = self.round
|
||||||
rnd.bids[player] = action.value
|
rnd.bids[player] = action.value
|
||||||
@@ -263,7 +245,9 @@ class WizardGame:
|
|||||||
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
|
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
|
||||||
rnd.tricks_won[winner] += 1
|
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
|
self.last_trick = result
|
||||||
events: list[Event] = [TrickCompleted(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:
|
def lead_suit(trick: Sequence[Card]) -> Suit | None:
|
||||||
"""The suit that has to be followed in a trick.
|
"""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):
|
class Phase(str, Enum):
|
||||||
TRUMP_SELECTION = "trump_selection"
|
|
||||||
BIDDING = "bidding"
|
BIDDING = "bidding"
|
||||||
PLAYING = "playing"
|
PLAYING = "playing"
|
||||||
FINISHED = "finished"
|
FINISHED = "finished"
|
||||||
@@ -51,6 +50,8 @@ class TrickResult:
|
|||||||
trick_number: int
|
trick_number: int
|
||||||
cards: tuple[PlayedCard, ...]
|
cards: tuple[PlayedCard, ...]
|
||||||
winner: int
|
winner: int
|
||||||
|
# The trump card of the round the trick was played in.
|
||||||
|
trump_card: Card | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +59,7 @@ class TrickResult:
|
|||||||
"trick_number": self.trick_number,
|
"trick_number": self.trick_number,
|
||||||
"cards": [played.to_dict() for played in self.cards],
|
"cards": [played.to_dict() for played in self.cards],
|
||||||
"winner": self.winner,
|
"winner": self.winner,
|
||||||
|
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -67,6 +69,7 @@ class TrickResult:
|
|||||||
trick_number=data["trick_number"],
|
trick_number=data["trick_number"],
|
||||||
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
|
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
|
||||||
winner=data["winner"],
|
winner=data["winner"],
|
||||||
|
trump_card=card_by_id(data["trump_card"]["id"]) if data.get("trump_card") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ export interface Card {
|
|||||||
export type Action =
|
export type Action =
|
||||||
| { type: 'bid'; value: number }
|
| { type: 'bid'; value: number }
|
||||||
| { type: 'play'; card: number }
|
| { type: 'play'; card: number }
|
||||||
| { type: 'trump'; suit: Suit }
|
|
||||||
|
|
||||||
export type Phase = 'trump_selection' | 'bidding' | 'playing' | 'finished'
|
export type Phase = 'bidding' | 'playing' | 'finished'
|
||||||
|
|
||||||
export interface GameOptions {
|
export interface GameOptions {
|
||||||
hidden_bids: boolean
|
hidden_bids: boolean
|
||||||
@@ -32,6 +31,8 @@ export interface TrickResult {
|
|||||||
trick_number: number
|
trick_number: number
|
||||||
cards: PlayedCard[]
|
cards: PlayedCard[]
|
||||||
winner: number
|
winner: number
|
||||||
|
// Trump card of the round the trick was played in.
|
||||||
|
trump_card: Card | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundResult {
|
export interface RoundResult {
|
||||||
|
|||||||
@@ -80,12 +80,12 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Not rotated: an upside down 6 would read as 9 and vice versa. */
|
||||||
.cornerBottom {
|
.cornerBottom {
|
||||||
top: auto;
|
top: auto;
|
||||||
right: 8%;
|
right: 8%;
|
||||||
bottom: 4%;
|
bottom: 4%;
|
||||||
left: auto;
|
left: auto;
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { PlayerSeat } from './PlayerSeat'
|
|||||||
import { RoundSummary } from './RoundSummary'
|
import { RoundSummary } from './RoundSummary'
|
||||||
import { Scoreboard } from './Scoreboard'
|
import { Scoreboard } from './Scoreboard'
|
||||||
import { TrickArea } from './TrickArea'
|
import { TrickArea } from './TrickArea'
|
||||||
import { TrumpPicker } from './TrumpPicker'
|
|
||||||
|
|
||||||
interface GameScreenProps {
|
interface GameScreenProps {
|
||||||
lobby: Lobby
|
lobby: Lobby
|
||||||
@@ -39,9 +38,6 @@ export function GameScreen({ lobby, game, playerId, completedTrick, completedRou
|
|||||||
|
|
||||||
let status: string
|
let status: string
|
||||||
switch (obs.phase) {
|
switch (obs.phase) {
|
||||||
case 'trump_selection':
|
|
||||||
status = myTurn ? 'Choose the trump suit' : `${name(obs.current_player)} is choosing the trump suit…`
|
|
||||||
break
|
|
||||||
case 'bidding':
|
case 'bidding':
|
||||||
status = myTurn ? 'Your turn to bid' : `Waiting for ${name(obs.current_player)} to bid…`
|
status = myTurn ? 'Your turn to bid' : `Waiting for ${name(obs.current_player)} to bid…`
|
||||||
break
|
break
|
||||||
@@ -52,9 +48,19 @@ export function GameScreen({ lobby, game, playerId, completedTrick, completedRou
|
|||||||
status = 'Game over'
|
status = 'Game over'
|
||||||
}
|
}
|
||||||
|
|
||||||
let trumpText = 'No trump this round'
|
// While the last trick of the previous round is still on the table, show the
|
||||||
if (obs.trump_suit) trumpText = `Trump: ${SUIT_NAMES[obs.trump_suit]}`
|
// trump card that trick was played with, not the next round's one.
|
||||||
else if (obs.phase === 'trump_selection') trumpText = 'Wizard turned up — dealer chooses'
|
const showingPreviousRound =
|
||||||
|
completedTrick !== null &&
|
||||||
|
obs.current_trick.length === 0 &&
|
||||||
|
completedTrick.round_number !== obs.round_number
|
||||||
|
const trumpCard = showingPreviousRound ? completedTrick.trump_card : obs.trump_card
|
||||||
|
const trumpSuit = trumpCard?.kind === 'number' ? trumpCard.suit : null
|
||||||
|
|
||||||
|
let trumpText = 'Last round — no trump'
|
||||||
|
if (trumpSuit) trumpText = `Trump: ${SUIT_NAMES[trumpSuit]}`
|
||||||
|
else if (trumpCard?.kind === 'wizard') trumpText = 'Wizard turned up — no trump'
|
||||||
|
else if (trumpCard?.kind === 'jester') trumpText = 'Jester turned up — no trump'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
@@ -84,8 +90,12 @@ export function GameScreen({ lobby, game, playerId, completedTrick, completedRou
|
|||||||
|
|
||||||
<section className={styles.table}>
|
<section className={styles.table}>
|
||||||
<div className={styles.trump}>
|
<div className={styles.trump}>
|
||||||
{obs.trump_card ? <PlayingCard card={obs.trump_card} size="md" /> : <div className={styles.noTrump}>—</div>}
|
{trumpCard ? (
|
||||||
<span className={`${styles.trumpLabel} ${obs.trump_suit ? styles[obs.trump_suit] : ''}`}>{trumpText}</span>
|
<PlayingCard key={trumpCard.id} card={trumpCard} size="md" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.noTrump}>—</div>
|
||||||
|
)}
|
||||||
|
<span className={`${styles.trumpLabel} ${trumpSuit ? styles[trumpSuit] : ''}`}>{trumpText}</span>
|
||||||
</div>
|
</div>
|
||||||
<TrickArea observation={obs} players={players} completedTrick={completedTrick} />
|
<TrickArea observation={obs} players={players} completedTrick={completedTrick} />
|
||||||
</section>
|
</section>
|
||||||
@@ -96,9 +106,6 @@ export function GameScreen({ lobby, game, playerId, completedTrick, completedRou
|
|||||||
|
|
||||||
<section className={styles.me}>
|
<section className={styles.me}>
|
||||||
{myTurn && obs.phase === 'bidding' && <BidPanel observation={obs} onBid={(value) => actions.play({ type: 'bid', value })} />}
|
{myTurn && obs.phase === 'bidding' && <BidPanel observation={obs} onBid={(value) => actions.play({ type: 'bid', value })} />}
|
||||||
{myTurn && obs.phase === 'trump_selection' && (
|
|
||||||
<TrumpPicker onChoose={(suit) => actions.play({ type: 'trump', suit })} />
|
|
||||||
)}
|
|
||||||
<div className={styles.myRow}>
|
<div className={styles.myRow}>
|
||||||
<PlayerSeat observation={obs} seat={you} player={players[you]} isYou showHand={false} />
|
<PlayerSeat observation={obs} seat={you} player={players[you]} isYou showHand={false} />
|
||||||
<Hand observation={obs} onPlay={(card) => actions.play({ type: 'play', card })} />
|
<Hand observation={obs} onPlay={(card) => actions.play({ type: 'play', card })} />
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
.panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border: 1px solid var(--gold);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: rgb(26 22 64 / 95%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.question {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suits {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suit {
|
|
||||||
min-width: 88px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #fff;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s, filter 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suit:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.red {
|
|
||||||
background: var(--suit-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.yellow {
|
|
||||||
background: var(--suit-yellow);
|
|
||||||
color: #2b2200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.green {
|
|
||||||
background: var(--suit-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blue {
|
|
||||||
background: var(--suit-blue);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Suit } from '../../api/types'
|
|
||||||
import { SUIT_NAMES, SUITS } from '../../util/cards'
|
|
||||||
import styles from './TrumpPicker.module.css'
|
|
||||||
|
|
||||||
interface TrumpPickerProps {
|
|
||||||
onChoose: (suit: Suit) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrumpPicker({ onChoose }: TrumpPickerProps) {
|
|
||||||
return (
|
|
||||||
<div className={styles.panel}>
|
|
||||||
<p className={styles.question}>A wizard was turned up — choose the trump suit.</p>
|
|
||||||
<div className={styles.suits}>
|
|
||||||
{SUITS.map((suit) => (
|
|
||||||
<button key={suit} type="button" className={`${styles.suit} ${styles[suit]}`} onClick={() => onChoose(suit)}>
|
|
||||||
{SUIT_NAMES[suit]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user