Initial commit: 6-Card Golf with AI opponents
Features: - Multiplayer WebSocket game server (FastAPI) - 8 AI personalities with distinct play styles - 15+ house rule variants - SQLite game logging for AI analysis - Comprehensive test suite (80+ tests) AI improvements: - Fixed Maya bug (taking bad cards, discarding good ones) - Personality traits influence style without overriding competence - Zero blunders detected in 1000+ game simulations Testing infrastructure: - Game rules verification (test_game.py) - AI decision analysis (game_analyzer.py) - Score distribution analysis (score_analysis.py) - House rules testing (test_house_rules.py) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
319
server/test_maya_bug.py
Normal file
319
server/test_maya_bug.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
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, lucky_sevens, 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"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user