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 -1
View File
@@ -30,7 +30,7 @@ def test_action_space_round_trip():
@pytest.mark.parametrize("agent", [HeuristicAgent(), RandomAgent(1), ModelAgent(UniformPolicy(1))]) @pytest.mark.parametrize("agent", [HeuristicAgent(), RandomAgent(1), ModelAgent(UniformPolicy(1))])
def test_agents_play_legal_games(agent): def test_agents_play_legal_games(agent):
game = WizardGame(4, GameOptions(not_equal=True), seed=3) game = WizardGame(4, seed=3)
while not game.is_finished: while not game.is_finished:
obs = game.observation(game.current_player) obs = game.observation(game.current_player)
action = agent.act(obs) action = agent.act(obs)
+1 -2
View File
@@ -33,7 +33,6 @@ def main() -> None:
train.add_argument("--episodes", type=int, default=1000) train.add_argument("--episodes", type=int, default=1000)
train.add_argument("--players", default="3,4,5,6", help="comma separated player counts") train.add_argument("--players", default="3,4,5,6", help="comma separated player counts")
train.add_argument("--opponents", default="self", choices=("self", "heuristic", "random")) train.add_argument("--opponents", default="self", choices=("self", "heuristic", "random"))
train.add_argument("--not-equal", action="store_true")
train.add_argument("--hidden-bids", action="store_true") train.add_argument("--hidden-bids", action="store_true")
train.add_argument("--resume", action="store_true", help="continue from --out if it exists") train.add_argument("--resume", action="store_true", help="continue from --out if it exists")
train.add_argument("--out", default=config.model_path, help="checkpoint directory") train.add_argument("--out", default=config.model_path, help="checkpoint directory")
@@ -65,7 +64,7 @@ def main() -> None:
training_config = TrainingConfig( training_config = TrainingConfig(
episodes=args.episodes, episodes=args.episodes,
player_counts=tuple(int(n) for n in args.players.split(",")), player_counts=tuple(int(n) for n in args.players.split(",")),
options=GameOptions(not_equal=args.not_equal, hidden_bids=args.hidden_bids), options=GameOptions(hidden_bids=args.hidden_bids),
opponents=args.opponents, opponents=args.opponents,
checkpoint_dir=out, checkpoint_dir=out,
seed=args.seed, seed=args.seed,
+2 -2
View File
@@ -104,7 +104,7 @@ def encode_observation(obs: Observation) -> list[float]:
features += _one_hot(MAX_PLAYERS - MIN_PLAYERS + 1, n - MIN_PLAYERS) features += _one_hot(MAX_PLAYERS - MIN_PLAYERS + 1, n - MIN_PLAYERS)
features += _one_hot(len(PHASES), PHASES.index(obs.phase) if obs.phase in PHASES else None) features += _one_hot(len(PHASES), PHASES.index(obs.phase) if obs.phase in PHASES else None)
features += [obs.round_number / MAX_BID, obs.round_number / obs.total_rounds] features += [obs.round_number / MAX_BID, obs.round_number / obs.total_rounds]
features += [float(obs.options.not_equal), float(obs.options.hidden_bids)] features += [float(obs.options.hidden_bids)]
features += _one_hot(MAX_PLAYERS, (me - obs.dealer) % n) # seat relative to dealer features += _one_hot(MAX_PLAYERS, (me - obs.dealer) % n) # seat relative to dealer
features += _one_hot(MAX_PLAYERS, len(obs.current_trick)) # position in the trick features += _one_hot(MAX_PLAYERS, len(obs.current_trick)) # position in the trick
@@ -150,7 +150,7 @@ def encode_observation(obs: Observation) -> list[float]:
FEATURE_SIZE = ( FEATURE_SIZE = (
(MAX_PLAYERS - MIN_PLAYERS + 1) + len(PHASES) + 2 + 2 + MAX_PLAYERS + MAX_PLAYERS (MAX_PLAYERS - MIN_PLAYERS + 1) + len(PHASES) + 2 + 1 + MAX_PLAYERS + MAX_PLAYERS
+ DECK_SIZE * 3 + (len(SUITS) + 1) * 2 + DECK_SIZE + DECK_SIZE * 3 + (len(SUITS) + 1) * 2 + DECK_SIZE
+ 5 + 5
+ MAX_OPPONENTS * 7 + MAX_OPPONENTS * 7
-1
View File
@@ -50,7 +50,6 @@ class RemovePlayer(BaseModel):
class SetOptions(BaseModel): class SetOptions(BaseModel):
type: Literal["set_options"] type: Literal["set_options"]
not_equal: bool
hidden_bids: 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")) seat.session.send(protocol.error("You were removed from the lobby"))
self._send_no_lobby(seat.session) self._send_no_lobby(seat.session)
lobby.broadcast() 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 = 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() lobby.broadcast()
case protocol.StartGame(): case protocol.StartGame():
lobby = self._lobby_of(session) lobby = self._lobby_of(session)
+1 -1
View File
@@ -11,7 +11,7 @@ pip install ./wizard_server/game_logic
import random import random
from wizard_game import WizardGame, GameOptions 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) rng = random.Random(1)
while not game.is_finished: while not game.is_finished:
player = game.current_player 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("num_players", [3, 4, 5, 6])
@pytest.mark.parametrize("seed", range(5)) @pytest.mark.parametrize("seed", range(5))
def test_random_games_complete(num_players, seed): 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) game = play_random_game(num_players, seed, options)
assert game.phase is Phase.FINISHED assert game.phase is Phase.FINISHED
assert len(game.round_history) == game.total_rounds assert len(game.round_history) == game.total_rounds
assert game.scores == list(game.round_history[-1].scores) assert game.scores == list(game.round_history[-1].scores)
for result in game.round_history: for result in game.round_history:
assert sum(result.tricks) == result.round_number 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(): def test_invalid_player_count():
@@ -94,8 +93,8 @@ def test_wizard_trump_card_lets_dealer_choose():
assert game.current_player == 2 assert game.current_player == 2
def test_not_equal_restricts_last_bidder(): def test_sum_of_bids_never_equals_cards_in_hand():
game = WizardGame(3, GameOptions(not_equal=True), seed=11, first_dealer=0) game = WizardGame(3, seed=11, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION: if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED)) game.apply(game.current_player, ChooseTrump(Suit.RED))
game.apply(1, Bid(0)) 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).bids[1] is None
assert game.observation(2).has_bid[1] is True assert game.observation(2).has_bid[1] is True
game.apply(2, Bid(0)) game.apply(2, Bid(0))
game.apply(0, Bid(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 == (0, 1, 0) assert game.observation(2).bids == (1, 1, 0)
def test_last_round_has_no_trump(): def test_last_round_has_no_trump():
+4 -1
View File
@@ -217,11 +217,14 @@ class WizardGame:
return rnd 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
sum of all bids equal the number of cards each player holds this round.
"""
rnd = self.round rnd = self.round
allowed = list(range(rnd.round_number + 1)) allowed = list(range(rnd.round_number + 1))
placed = [bid for bid in rnd.bids if bid is not None] 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 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) forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden] allowed = [bid for bid in allowed if bid != forbidden]
return allowed return allowed
@@ -18,21 +18,18 @@ class Phase(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class GameOptions: class GameOptions:
# "notequal": the sum of all bids must not equal the number of tricks # Note: the sum of all bids never equals the number of tricks of a round;
# of the round. The last bidder has to adjust their bid. # this is a fixed rule (see ``WizardGame._allowed_bids``), not an option.
not_equal: bool = False
# "hiddentip": bids are revealed only once every player has bid. # "hiddentip": bids are revealed only once every player has bid.
hidden_bids: bool = False hidden_bids: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return {"not_equal": self.not_equal, "hidden_bids": self.hidden_bids} return {"hidden_bids": self.hidden_bids}
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> GameOptions: def from_dict(cls, data: dict[str, Any]) -> GameOptions:
return cls( return cls(hidden_bids=bool(data.get("hidden_bids", False)))
not_equal=bool(data.get("not_equal", False)),
hidden_bids=bool(data.get("hidden_bids", False)),
)
@dataclass(frozen=True, slots=True) @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"] assert "needs" in receive_until(host, lambda m: m["type"] == "error")["message"]
host.send_json({"type": "add_ai"}) 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"}) host.send_json({"type": "start_game"})
rng = random.Random(0) rng = random.Random(0)
+1 -2
View File
@@ -19,7 +19,6 @@ export type Action =
export type Phase = 'trump_selection' | 'bidding' | 'playing' | 'finished' export type Phase = 'trump_selection' | 'bidding' | 'playing' | 'finished'
export interface GameOptions { export interface GameOptions {
not_equal: boolean
hidden_bids: boolean hidden_bids: boolean
} }
@@ -115,7 +114,7 @@ export type ClientMessage =
| { type: 'leave_lobby' } | { type: 'leave_lobby' }
| { type: 'add_ai' } | { type: 'add_ai' }
| { type: 'remove_player'; player_id: string } | { type: 'remove_player'; player_id: string }
| { type: 'set_options'; not_equal: boolean; hidden_bids: boolean } | { type: 'set_options'; hidden_bids: boolean }
| { type: 'start_game' } | { type: 'start_game' }
| { type: 'action'; action: Action } | { type: 'action'; action: Action }
| { type: 'return_to_lobby' } | { type: 'return_to_lobby' }
@@ -34,7 +34,7 @@ export function BidPanel({ observation, onBid }: BidPanelProps) {
<p className={styles.hint}> <p className={styles.hint}>
{!observation.options.hidden_bids && {!observation.options.hidden_bids &&
`Bids so far: ${sum} for ${observation.round_number} trick${observation.round_number > 1 ? 's' : ''}. `} `Bids so far: ${sum} for ${observation.round_number} trick${observation.round_number > 1 ? 's' : ''}. `}
{forbidden.length > 0 && `Not-equal rule: you may not bid ${forbidden.join(', ')}.`} {forbidden.length > 0 && `The bids may not add up to ${observation.round_number}, so you may not bid ${forbidden.join(', ')}.`}
</p> </p>
</div> </div>
) )
@@ -205,3 +205,9 @@
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
} }
.rule {
margin: 0 0 4px;
color: var(--text-muted);
font-size: 14px;
}
@@ -98,18 +98,10 @@ export function LobbyScreen({ lobby, playerId, actions }: LobbyScreenProps) {
<section className={styles.card}> <section className={styles.card}>
<h2 className={styles.heading}>Rules</h2> <h2 className={styles.heading}>Rules</h2>
<label className={styles.option}> <p className={styles.rule}>
<input The sum of all bids may never equal the number of cards in hand — the last player to bid has
type="checkbox" to adjust their bid.
checked={lobby.options.not_equal} </p>
disabled={!isHost}
onChange={(event) => actions.setOptions({ ...lobby.options, not_equal: event.target.checked })}
/>
<span>
<strong>Not equal</strong>
<small>The sum of all bids must not equal the number of tricks in the round.</small>
</span>
</label>
<label className={styles.option}> <label className={styles.option}>
<input <input
type="checkbox" type="checkbox"