golfgame/server/test_maya_bug.py

479 lines
18 KiB
Python

"""
Test for the original Maya bug:
Maya took a 10 from discard and had to discard an Ace.
Bug chain:
1. should_take_discard() incorrectly decided to take the 10
2. choose_swap_or_discard() correctly returned None (don't swap)
3. But drawing from discard FORCES a swap
4. The forced-swap fallback found the "worst" visible card
5. The Ace (value 1) was swapped out for the 10
This test verifies the fixes work.
"""
import pytest
from game import Card, Player, Game, GameOptions, Suit, Rank
from ai import (
GolfAI, CPUProfile, CPU_PROFILES,
get_ai_card_value, has_worse_visible_card
)
def get_maya_profile() -> CPUProfile:
"""Get Maya's profile."""
for p in CPU_PROFILES:
if p.name == "Maya":
return p
# Fallback - create Maya-like profile
return CPUProfile(
name="Maya",
style="Aggressive Closer",
swap_threshold=6,
pair_hope=0.4,
aggression=0.85,
unpredictability=0.1,
)
def create_test_game() -> Game:
"""Create a game in playing state."""
game = Game()
game.add_player(Player(id="maya", name="Maya"))
game.add_player(Player(id="other", name="Other"))
game.start_game(options=GameOptions(initial_flips=0))
return game
class TestMayaBugFix:
"""Test that the original Maya bug is fixed."""
def test_maya_does_not_take_10_with_good_hand(self):
"""
Original bug: Maya took a 10 from discard when she had good cards.
Setup: Maya has visible Ace, King, 2 (all good cards)
Discard: 10
Expected: Maya should NOT take the 10
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
# Set up Maya's hand with good visible cards
maya.cards = [
Card(Suit.HEARTS, Rank.ACE, face_up=True), # Value 1
Card(Suit.HEARTS, Rank.KING, face_up=True), # Value 0
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Value -2
Card(Suit.SPADES, Rank.FIVE, face_up=False),
Card(Suit.SPADES, Rank.SIX, face_up=False),
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
]
# Put a 10 on discard
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
game.discard_pile = [discard_10]
# Maya should NOT take the 10
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
assert should_take is False, (
"Maya should not take a 10 when her visible cards are Ace, King, 2"
)
def test_maya_does_not_take_10_even_with_unpredictability(self):
"""
The unpredictability trait should NOT cause taking bad cards.
Run multiple times to account for randomness.
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
maya.cards = [
Card(Suit.HEARTS, Rank.ACE, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.TWO, face_up=True),
Card(Suit.SPADES, Rank.FIVE, face_up=False),
Card(Suit.SPADES, Rank.SIX, face_up=False),
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
]
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
game.discard_pile = [discard_10]
# Run 100 times - should NEVER take the 10
took_10_count = 0
for _ in range(100):
if GolfAI.should_take_discard(discard_10, maya, profile, game):
took_10_count += 1
assert took_10_count == 0, (
f"Maya took a 10 {took_10_count}/100 times despite having good cards. "
"Unpredictability should not override basic logic for bad cards."
)
def test_has_worse_visible_card_utility(self):
"""Test the utility function that guards against taking bad cards."""
game = create_test_game()
maya = game.get_player("maya")
options = game.options
# Hand with good visible cards (Ace=1, King=0, 2=-2)
maya.cards = [
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
Card(Suit.HEARTS, Rank.KING, face_up=True), # 0
Card(Suit.HEARTS, Rank.TWO, face_up=True), # -2
Card(Suit.SPADES, Rank.FIVE, face_up=False),
Card(Suit.SPADES, Rank.SIX, face_up=False),
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
]
# No visible card is worse than 10 (value 10)
assert has_worse_visible_card(maya, 10, options) is False
# No visible card is worse than 5
assert has_worse_visible_card(maya, 5, options) is False
# Ace (1) is worse than 0
assert has_worse_visible_card(maya, 0, options) is True
def test_forced_swap_uses_house_rules(self):
"""
When forced to swap (drew from discard), the AI should use
get_ai_card_value() to find the worst card, not raw value().
This matters for house rules like super_kings, ten_penny, etc.
"""
game = create_test_game()
game.options = GameOptions(super_kings=True) # Kings now worth -2
maya = game.get_player("maya")
# All face up - forced swap scenario
maya.cards = [
Card(Suit.HEARTS, Rank.KING, face_up=True), # -2 with super_kings
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
Card(Suit.HEARTS, Rank.THREE, face_up=True), # 3 - worst!
Card(Suit.SPADES, Rank.KING, face_up=True), # -2 with super_kings
Card(Suit.SPADES, Rank.TWO, face_up=True), # -2
Card(Suit.SPADES, Rank.ACE, face_up=True), # 1
]
# Find worst card using house rules
worst_pos = 0
worst_val = -999
for i, c in enumerate(maya.cards):
card_val = get_ai_card_value(c, game.options)
if card_val > worst_val:
worst_val = card_val
worst_pos = i
# Position 2 (Three, value 3) should be worst
assert worst_pos == 2, (
f"With super_kings, the Three (value 3) should be worst, "
f"not position {worst_pos} (value {worst_val})"
)
def test_choose_swap_does_not_discard_excellent_cards(self):
"""
Unpredictability should NOT cause discarding excellent cards (2s, Jokers).
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
maya.cards = [
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
Card(Suit.HEARTS, Rank.SIX, face_up=True),
Card(Suit.HEARTS, Rank.SEVEN, face_up=False),
Card(Suit.SPADES, Rank.EIGHT, face_up=False),
Card(Suit.SPADES, Rank.NINE, face_up=False),
Card(Suit.SPADES, Rank.TEN, face_up=False),
]
# Drew a 2 (excellent card, value -2)
drawn_two = Card(Suit.CLUBS, Rank.TWO)
# Run 100 times - should ALWAYS swap (never discard a 2)
discarded_count = 0
for _ in range(100):
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
if swap_pos is None:
discarded_count += 1
assert discarded_count == 0, (
f"Maya discarded a 2 (excellent card) {discarded_count}/100 times. "
"Unpredictability should not cause discarding excellent cards."
)
def test_full_scenario_maya_10_ace(self):
"""
Full reproduction of the original bug scenario.
Maya has: [A, K, 2, ?, ?, ?] (good visible cards)
Discard: 10
Expected behavior:
1. Maya should NOT take the 10
2. If she somehow did, she should swap into face-down, not replace the Ace
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
# Setup exactly like the bug report
maya.cards = [
Card(Suit.HEARTS, Rank.ACE, face_up=True), # Good - don't replace!
Card(Suit.HEARTS, Rank.KING, face_up=True), # Good
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Excellent
Card(Suit.SPADES, Rank.JACK, face_up=False), # Unknown
Card(Suit.SPADES, Rank.QUEEN, face_up=False),# Unknown
Card(Suit.SPADES, Rank.TEN, face_up=False), # Unknown
]
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
game.discard_pile = [discard_10]
# Step 1: Maya should not take the 10
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
assert should_take is False, "Maya should not take a 10 with this hand"
# Step 2: Even if she did take it (simulating old bug), verify swap logic
# The swap logic should prefer face-down positions
drawn_10 = Card(Suit.CLUBS, Rank.TEN)
swap_pos = GolfAI.choose_swap_or_discard(drawn_10, maya, profile, game)
# Should either discard (None) or swap into face-down (positions 3, 4, 5)
# Should NEVER swap into position 0 (Ace), 1 (King), or 2 (Two)
if swap_pos is not None:
assert swap_pos >= 3, (
f"Maya tried to swap 10 into position {swap_pos}, replacing a good card. "
"Should only swap into face-down positions (3, 4, 5)."
)
class TestEdgeCases:
"""Test edge cases related to the bug."""
def test_all_face_up_forced_swap_finds_actual_worst(self):
"""
When all cards are face up and forced to swap, find the ACTUAL worst card.
"""
game = create_test_game()
maya = game.get_player("maya")
# All face up, varying values
maya.cards = [
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
Card(Suit.HEARTS, Rank.KING, face_up=True), # 0
Card(Suit.HEARTS, Rank.TWO, face_up=True), # -2
Card(Suit.SPADES, Rank.JACK, face_up=True), # 10 - WORST
Card(Suit.SPADES, Rank.THREE, face_up=True), # 3
Card(Suit.SPADES, Rank.FOUR, face_up=True), # 4
]
# Find worst
worst_pos = 0
worst_val = -999
for i, c in enumerate(maya.cards):
card_val = get_ai_card_value(c, game.options)
if card_val > worst_val:
worst_val = card_val
worst_pos = i
assert worst_pos == 3, f"Jack (position 3, value 10) should be worst, got position {worst_pos}"
assert worst_val == 10, f"Worst value should be 10, got {worst_val}"
def test_take_discard_respects_pair_potential(self):
"""
Taking a bad card to complete a pair IS valid strategy.
This should still work after the bug fix.
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
# Maya has a visible 10 - taking another 10 to pair is GOOD
maya.cards = [
Card(Suit.HEARTS, Rank.TEN, face_up=True), # Visible 10
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.ACE, face_up=True),
Card(Suit.SPADES, Rank.FIVE, face_up=False), # Pair position for the 10
Card(Suit.SPADES, Rank.SIX, face_up=False),
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
]
# 10 on discard - should take to pair!
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
game.discard_pile = [discard_10]
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
assert should_take is True, (
"Maya SHOULD take a 10 when she has a visible 10 to pair with"
)
class TestAvoidBadPairs:
"""Test that AI avoids creating wasteful pairs with negative cards."""
def test_filter_bad_pair_positions_with_visible_two(self):
"""
When placing a 2, avoid positions where column partner is a visible 2.
Setup: Visible 2 at position 0
Placing: Another 2
Expected: Position 3 should be filtered out (would pair with position 0)
"""
from ai import filter_bad_pair_positions
game = create_test_game()
player = game.get_player("maya")
# Position 0 has a visible 2
player.cards = [
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: column partner of 0
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5
]
drawn_two = Card(Suit.CLUBS, Rank.TWO)
face_down = [3, 4, 5]
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
# Position 3 should be filtered out (would pair with visible 2 at position 0)
assert 3 not in safe_positions, (
"Position 3 should be filtered - would create wasteful 2-2 pair"
)
assert 4 in safe_positions
assert 5 in safe_positions
def test_filter_allows_positive_card_pairs(self):
"""
Positive value cards can be paired - no filtering needed.
Pairing a 5 with another 5 is GOOD (saves 10 points).
"""
from ai import filter_bad_pair_positions
game = create_test_game()
player = game.get_player("maya")
# Position 0 has a visible 5
player.cards = [
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 0: visible 5
Card(Suit.HEARTS, Rank.SIX, face_up=True),
Card(Suit.HEARTS, Rank.SEVEN, face_up=True),
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 3: column partner
Card(Suit.SPADES, Rank.NINE, face_up=False),
Card(Suit.SPADES, Rank.TEN, face_up=False),
]
drawn_five = Card(Suit.CLUBS, Rank.FIVE)
face_down = [3, 4, 5]
safe_positions = filter_bad_pair_positions(face_down, drawn_five, player, game.options)
# All positions should be allowed - pairing 5s is good!
assert safe_positions == face_down
def test_choose_swap_avoids_pairing_twos(self):
"""
The full choose_swap_or_discard flow should avoid placing 2s
in positions that would pair them.
Run multiple times to verify randomness doesn't cause bad pairs.
"""
game = create_test_game()
maya = game.get_player("maya")
profile = get_maya_profile()
# Position 0 has a visible 2
maya.cards = [
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: BAD - would pair
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: OK
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
]
drawn_two = Card(Suit.CLUBS, Rank.TWO)
# Run 100 times - should NEVER pick position 3
bad_pair_count = 0
for _ in range(100):
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
if swap_pos == 3:
bad_pair_count += 1
assert bad_pair_count == 0, (
f"AI picked position 3 (creating 2-2 pair) {bad_pair_count}/100 times. "
"Should avoid positions that waste negative card value."
)
def test_forced_swap_avoids_pairing_twos(self):
"""
Even when forced to swap from discard, AI should avoid bad pairs.
"""
from ai import filter_bad_pair_positions
game = create_test_game()
player = game.get_player("maya")
# Position 1 has a visible 2, only positions 3, 4 are face-down
player.cards = [
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 1: visible 2
Card(Suit.HEARTS, Rank.SIX, face_up=True),
Card(Suit.SPADES, Rank.SEVEN, face_up=True),
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: BAD - pairs with pos 1
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
]
drawn_two = Card(Suit.CLUBS, Rank.TWO)
face_down = [4, 5]
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
# Position 4 should be filtered out (would pair with visible 2 at position 1)
assert 4 not in safe_positions
assert 5 in safe_positions
def test_all_positions_bad_falls_back(self):
"""
If ALL positions would create bad pairs, fall back to original list.
(Must place the card somewhere)
"""
from ai import filter_bad_pair_positions
game = create_test_game()
player = game.get_player("maya")
# Only position 3 is face-down, and it would pair with visible 2 at position 0
player.cards = [
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
Card(Suit.HEARTS, Rank.SIX, face_up=True),
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: only option, but bad
Card(Suit.SPADES, Rank.EIGHT, face_up=True),
Card(Suit.SPADES, Rank.NINE, face_up=True),
]
drawn_two = Card(Suit.CLUBS, Rank.TWO)
face_down = [3] # Only option
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
# Should return original list since there's no alternative
assert safe_positions == face_down
if __name__ == "__main__":
pytest.main([__file__, "-v"])