diff --git a/wizard_ai/tests/test_ai.py b/wizard_ai/tests/test_ai.py index fd07b81..5ebf6da 100644 --- a/wizard_ai/tests/test_ai.py +++ b/wizard_ai/tests/test_ai.py @@ -30,7 +30,7 @@ def test_action_space_round_trip(): @pytest.mark.parametrize("agent", [HeuristicAgent(), RandomAgent(1), ModelAgent(UniformPolicy(1))]) 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: obs = game.observation(game.current_player) action = agent.act(obs) diff --git a/wizard_ai/wizard_ai/__main__.py b/wizard_ai/wizard_ai/__main__.py index 4c3f519..85de975 100644 --- a/wizard_ai/wizard_ai/__main__.py +++ b/wizard_ai/wizard_ai/__main__.py @@ -33,7 +33,6 @@ def main() -> None: 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("--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("--resume", action="store_true", help="continue from --out if it exists") train.add_argument("--out", default=config.model_path, help="checkpoint directory") @@ -65,7 +64,7 @@ def main() -> None: training_config = TrainingConfig( episodes=args.episodes, 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, checkpoint_dir=out, seed=args.seed, diff --git a/wizard_ai/wizard_ai/model/encoding.py b/wizard_ai/wizard_ai/model/encoding.py index 09a792f..780377c 100644 --- a/wizard_ai/wizard_ai/model/encoding.py +++ b/wizard_ai/wizard_ai/model/encoding.py @@ -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(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 += [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, len(obs.current_trick)) # position in the trick @@ -150,7 +150,7 @@ def encode_observation(obs: Observation) -> list[float]: 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 + 5 + MAX_OPPONENTS * 7 diff --git a/wizard_server/app/protocol.py b/wizard_server/app/protocol.py index f322868..0c51a4c 100644 --- a/wizard_server/app/protocol.py +++ b/wizard_server/app/protocol.py @@ -50,7 +50,6 @@ class RemovePlayer(BaseModel): class SetOptions(BaseModel): type: Literal["set_options"] - not_equal: bool hidden_bids: bool diff --git a/wizard_server/app/service.py b/wizard_server/app/service.py index 1738c12..ce4fcb8 100644 --- a/wizard_server/app/service.py +++ b/wizard_server/app/service.py @@ -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) diff --git a/wizard_server/game_logic/README.md b/wizard_server/game_logic/README.md index f26f660..fc08155 100644 --- a/wizard_server/game_logic/README.md +++ b/wizard_server/game_logic/README.md @@ -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 diff --git a/wizard_server/game_logic/tests/test_game.py b/wizard_server/game_logic/tests/test_game.py index 3bd6ae1..07c4d6e 100644 --- a/wizard_server/game_logic/tests/test_game.py +++ b/wizard_server/game_logic/tests/test_game.py @@ -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(): diff --git a/wizard_server/game_logic/wizard_game/game.py b/wizard_server/game_logic/wizard_game/game.py index 51e8038..8baa2bf 100644 --- a/wizard_server/game_logic/wizard_game/game.py +++ b/wizard_server/game_logic/wizard_game/game.py @@ -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 diff --git a/wizard_server/game_logic/wizard_game/state.py b/wizard_server/game_logic/wizard_game/state.py index b601b73..8e21627 100644 --- a/wizard_server/game_logic/wizard_game/state.py +++ b/wizard_server/game_logic/wizard_game/state.py @@ -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) diff --git a/wizard_server/tests/test_server.py b/wizard_server/tests/test_server.py index 6c988c9..87a3b23 100644 --- a/wizard_server/tests/test_server.py +++ b/wizard_server/tests/test_server.py @@ -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) diff --git a/wizard_website/src/api/types.ts b/wizard_website/src/api/types.ts index a28fa17..db3c68d 100644 --- a/wizard_website/src/api/types.ts +++ b/wizard_website/src/api/types.ts @@ -19,7 +19,6 @@ export type Action = export type Phase = 'trump_selection' | 'bidding' | 'playing' | 'finished' export interface GameOptions { - not_equal: boolean hidden_bids: boolean } @@ -115,7 +114,7 @@ export type ClientMessage = | { type: 'leave_lobby' } | { type: 'add_ai' } | { 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: 'action'; action: Action } | { type: 'return_to_lobby' } diff --git a/wizard_website/src/screens/GameScreen/BidPanel.tsx b/wizard_website/src/screens/GameScreen/BidPanel.tsx index fa74db9..7d5f7b3 100644 --- a/wizard_website/src/screens/GameScreen/BidPanel.tsx +++ b/wizard_website/src/screens/GameScreen/BidPanel.tsx @@ -34,7 +34,7 @@ export function BidPanel({ observation, onBid }: BidPanelProps) {

{!observation.options.hidden_bids && `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(', ')}.`}

) diff --git a/wizard_website/src/screens/LobbyScreen/LobbyScreen.module.css b/wizard_website/src/screens/LobbyScreen/LobbyScreen.module.css index 789abdc..fdee31e 100644 --- a/wizard_website/src/screens/LobbyScreen/LobbyScreen.module.css +++ b/wizard_website/src/screens/LobbyScreen/LobbyScreen.module.css @@ -205,3 +205,9 @@ margin: 0; color: var(--text-muted); } + +.rule { + margin: 0 0 4px; + color: var(--text-muted); + font-size: 14px; +} diff --git a/wizard_website/src/screens/LobbyScreen/LobbyScreen.tsx b/wizard_website/src/screens/LobbyScreen/LobbyScreen.tsx index 3a77fa1..0748f41 100644 --- a/wizard_website/src/screens/LobbyScreen/LobbyScreen.tsx +++ b/wizard_website/src/screens/LobbyScreen/LobbyScreen.tsx @@ -98,18 +98,10 @@ export function LobbyScreen({ lobby, playerId, actions }: LobbyScreenProps) {

Rules

- +

+ The sum of all bids may never equal the number of cards in hand — the last player to bid has + to adjust their bid. +