Always forbid bids that add up to the cards in hand

The sum of all bids may never equal the number of cards each player
holds in the round; the last bidder has to adjust their bid. This was an
optional "not equal" lobby setting and is now a fixed rule of the game
engine, so it applies to humans, AI players and training alike.

- Remove GameOptions.not_equal, the set_options field, the lobby toggle
  and the --not-equal training flag
- Explain the rule in the lobby and show the blocked bid while bidding
- Drop the option from the AI observation encoding
- Tests check that no round ends with bids summing to the cards in hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:15:25 +02:00
co-authored by Claude Opus 5.5
parent 90176e1cca
commit fde8e2fb2e
14 changed files with 35 additions and 41 deletions
-1
View File
@@ -50,7 +50,6 @@ class RemovePlayer(BaseModel):
class SetOptions(BaseModel):
type: Literal["set_options"]
not_equal: bool
hidden_bids: bool
+2 -2
View File
@@ -126,9 +126,9 @@ class WizardService:
seat.session.send(protocol.error("You were removed from the lobby"))
self._send_no_lobby(seat.session)
lobby.broadcast()
case protocol.SetOptions(not_equal=not_equal, hidden_bids=hidden_bids):
case protocol.SetOptions(hidden_bids=hidden_bids):
lobby = self._lobby_of(session)
lobby.set_options(session, GameOptions(not_equal=not_equal, hidden_bids=hidden_bids))
lobby.set_options(session, GameOptions(hidden_bids=hidden_bids))
lobby.broadcast()
case protocol.StartGame():
lobby = self._lobby_of(session)
+1 -1
View File
@@ -11,7 +11,7 @@ pip install ./wizard_server/game_logic
import random
from wizard_game import WizardGame, GameOptions
game = WizardGame(num_players=4, options=GameOptions(not_equal=True), seed=1)
game = WizardGame(num_players=4, options=GameOptions(hidden_bids=True), seed=1)
rng = random.Random(1)
while not game.is_finished:
player = game.current_player
+6 -7
View File
@@ -38,15 +38,14 @@ def play_random_game(num_players: int, seed: int, options: GameOptions | None =
@pytest.mark.parametrize("num_players", [3, 4, 5, 6])
@pytest.mark.parametrize("seed", range(5))
def test_random_games_complete(num_players, seed):
options = GameOptions(not_equal=seed % 2 == 0, hidden_bids=seed % 3 == 0)
options = GameOptions(hidden_bids=seed % 2 == 0)
game = play_random_game(num_players, seed, options)
assert game.phase is Phase.FINISHED
assert len(game.round_history) == game.total_rounds
assert game.scores == list(game.round_history[-1].scores)
for result in game.round_history:
assert sum(result.tricks) == result.round_number
if options.not_equal:
assert sum(result.bids) != result.round_number
assert sum(result.bids) != result.round_number
def test_invalid_player_count():
@@ -94,8 +93,8 @@ def test_wizard_trump_card_lets_dealer_choose():
assert game.current_player == 2
def test_not_equal_restricts_last_bidder():
game = WizardGame(3, GameOptions(not_equal=True), seed=11, first_dealer=0)
def test_sum_of_bids_never_equals_cards_in_hand():
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))
@@ -112,8 +111,8 @@ def test_hidden_bids():
assert game.observation(2).bids[1] is None
assert game.observation(2).has_bid[1] is True
game.apply(2, Bid(0))
game.apply(0, Bid(0))
assert game.observation(2).bids == (0, 1, 0)
game.apply(0, Bid(1)) # 0 is not allowed: the bids would add up to the 1 card in hand
assert game.observation(2).bids == (1, 1, 0)
def test_last_round_has_no_trump():
+4 -1
View File
@@ -217,11 +217,14 @@ class WizardGame:
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.
"""
rnd = self.round
allowed = list(range(rnd.round_number + 1))
placed = [bid for bid in rnd.bids if bid is not None]
is_last_bidder = len(placed) == self.num_players - 1 and rnd.bids[player] is None
if self.options.not_equal and is_last_bidder:
if is_last_bidder:
forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
@@ -18,21 +18,18 @@ class Phase(str, Enum):
@dataclass(frozen=True, slots=True)
class GameOptions:
# "notequal": the sum of all bids must not equal the number of tricks
# of the round. The last bidder has to adjust their bid.
not_equal: bool = False
# Note: the sum of all bids never equals the number of tricks of a round;
# this is a fixed rule (see ``WizardGame._allowed_bids``), not an option.
# "hiddentip": bids are revealed only once every player has bid.
hidden_bids: bool = False
def to_dict(self) -> dict:
return {"not_equal": self.not_equal, "hidden_bids": self.hidden_bids}
return {"hidden_bids": self.hidden_bids}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GameOptions:
return cls(
not_equal=bool(data.get("not_equal", False)),
hidden_bids=bool(data.get("hidden_bids", False)),
)
return cls(hidden_bids=bool(data.get("hidden_bids", False)))
@dataclass(frozen=True, slots=True)
+1 -1
View File
@@ -79,7 +79,7 @@ def test_lobby_flow_and_full_game(client):
assert "needs" in receive_until(host, lambda m: m["type"] == "error")["message"]
host.send_json({"type": "add_ai"})
host.send_json({"type": "set_options", "not_equal": True, "hidden_bids": False})
host.send_json({"type": "set_options", "hidden_bids": True})
host.send_json({"type": "start_game"})
rng = random.Random(0)