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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,6 @@ class RemovePlayer(BaseModel):
|
||||
|
||||
class SetOptions(BaseModel):
|
||||
type: Literal["set_options"]
|
||||
not_equal: bool
|
||||
hidden_bids: bool
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -34,7 +34,7 @@ export function BidPanel({ observation, onBid }: BidPanelProps) {
|
||||
<p className={styles.hint}>
|
||||
{!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(', ')}.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -205,3 +205,9 @@
|
||||
margin: 0;
|
||||
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}>
|
||||
<h2 className={styles.heading}>Rules</h2>
|
||||
<label className={styles.option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lobby.options.not_equal}
|
||||
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>
|
||||
<p className={styles.rule}>
|
||||
The sum of all bids may never equal the number of cards in hand — the last player to bid has
|
||||
to adjust their bid.
|
||||
</p>
|
||||
<label className={styles.option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Reference in New Issue
Block a user