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
+1 -1
View File
@@ -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.
+2 -14
View File
@@ -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:
+3 -9
View File
@@ -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]:
+1 -1
View File
@@ -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())
``` ```
+61 -22
View File
@@ -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,8 +60,8 @@ 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
def test_wrong_player_rejected(): def test_wrong_player_rejected():
@@ -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)
else: rounds_seen = 0
pytest.fail("no seed produced a wizard as trump card") while not game.is_finished:
assert game.phase is Phase.TRUMP_SELECTION if game.phase is Phase.BIDDING and all(bid is None for bid in game.round.bids):
assert game.current_player == 1 rounds_seen += 1
game.apply(1, ChooseTrump(Suit.GREEN)) hands = [card for player in range(num_players) for card in game.hand(player)]
assert game.round.trump_suit is Suit.GREEN 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:
assert trump is None # last round: every card was dealt
game.apply(game.current_player, rng.choice(game.legal_actions()))
assert rounds_seen == game.total_rounds
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}")
+17 -33
View File
@@ -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,
) )
+3 -2
View File
@@ -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>
)
}