golfgame/server/game_analyzer.py
Aaron D. Lee d18cea2104 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>
2026-01-24 19:30:13 -05:00

650 lines
24 KiB
Python

"""
Game Analyzer for 6-Card Golf AI decisions.
Evaluates AI decisions against optimal play baselines and generates
reports on decision quality, mistake rates, and areas for improvement.
"""
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
from enum import Enum
from game import Rank, RANK_VALUES, GameOptions
# =============================================================================
# Card Value Utilities
# =============================================================================
def get_card_value(rank: str, options: Optional[dict] = None) -> int:
"""Get point value for a card rank string."""
rank_map = {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '': -2
}
value = rank_map.get(rank, 0)
# Apply house rules if provided
if options:
if rank == '' and options.get('lucky_swing'):
value = -5
if rank == 'K' and options.get('super_kings'):
value = -2
if rank == '7' and options.get('lucky_sevens'):
value = 0
if rank == '10' and options.get('ten_penny'):
value = 1
return value
def rank_quality(rank: str, options: Optional[dict] = None) -> str:
"""Categorize a card as excellent, good, neutral, bad, or terrible."""
value = get_card_value(rank, options)
if value <= -2:
return "excellent" # Jokers, 2s
if value <= 0:
return "good" # Kings (or lucky 7s, ten_penny 10s)
if value <= 2:
return "decent" # Aces, 2s without special rules
if value <= 5:
return "neutral" # 3-5
if value <= 7:
return "bad" # 6-7
return "terrible" # 8-10, J, Q
# =============================================================================
# Decision Classification
# =============================================================================
class DecisionQuality(Enum):
"""Classification of decision quality."""
OPTIMAL = "optimal" # Best possible decision
GOOD = "good" # Reasonable decision, minor suboptimality
QUESTIONABLE = "questionable" # Debatable, might be personality-driven
MISTAKE = "mistake" # Clear suboptimal play (2-5 point cost)
BLUNDER = "blunder" # Severe error (5+ point cost)
@dataclass
class DecisionAnalysis:
"""Analysis of a single decision."""
move_id: int
action: str
card_rank: Optional[str]
position: Optional[int]
quality: DecisionQuality
expected_value: float # EV impact of this decision
reasoning: str
optimal_play: Optional[str] = None # What should have been done
@dataclass
class GameSummary:
"""Summary analysis of a complete game."""
game_id: str
player_name: str
total_decisions: int
optimal_count: int
good_count: int
questionable_count: int
mistake_count: int
blunder_count: int
total_ev_lost: float # Points "left on table"
decisions: list[DecisionAnalysis]
@property
def accuracy(self) -> float:
"""Percentage of optimal/good decisions."""
if self.total_decisions == 0:
return 100.0
return (self.optimal_count + self.good_count) / self.total_decisions * 100
@property
def mistake_rate(self) -> float:
"""Percentage of mistakes + blunders."""
if self.total_decisions == 0:
return 0.0
return (self.mistake_count + self.blunder_count) / self.total_decisions * 100
# =============================================================================
# Decision Evaluators
# =============================================================================
class DecisionEvaluator:
"""Evaluates individual decisions against optimal play."""
def __init__(self, options: Optional[dict] = None):
self.options = options or {}
def evaluate_take_discard(
self,
discard_rank: str,
hand: list[dict],
took_discard: bool
) -> DecisionAnalysis:
"""
Evaluate decision to take from discard vs draw from deck.
Optimal play:
- Always take: Jokers, Kings, 2s
- Take if: Value < worst visible card
- Don't take: High cards (8+) with good hand
"""
discard_value = get_card_value(discard_rank, self.options)
discard_qual = rank_quality(discard_rank, self.options)
# Find worst visible card in hand
visible_cards = [c for c in hand if c.get('face_up')]
worst_visible_value = max(
(get_card_value(c['rank'], self.options) for c in visible_cards),
default=5 # Assume average if no visible
)
# Determine if taking was correct
should_take = False
reasoning = ""
# Auto-take excellent cards
if discard_qual == "excellent":
should_take = True
reasoning = f"{discard_rank} is excellent (value={discard_value}), always take"
# Auto-take good cards
elif discard_qual == "good":
should_take = True
reasoning = f"{discard_rank} is good (value={discard_value}), should take"
# Take if better than worst visible
elif discard_value < worst_visible_value - 1:
should_take = True
reasoning = f"{discard_rank} ({discard_value}) better than worst visible ({worst_visible_value})"
# Don't take bad cards
elif discard_qual in ("bad", "terrible"):
should_take = False
reasoning = f"{discard_rank} is {discard_qual} (value={discard_value}), should not take"
else:
# Neutral - personality can influence
should_take = None # Either is acceptable
reasoning = f"{discard_rank} is neutral, either choice reasonable"
# Evaluate the actual decision
if should_take is None:
quality = DecisionQuality.GOOD
ev = 0
elif took_discard == should_take:
quality = DecisionQuality.OPTIMAL
ev = 0
else:
# Wrong decision
if discard_qual == "excellent" and not took_discard:
quality = DecisionQuality.BLUNDER
ev = -abs(discard_value) # Lost opportunity
reasoning = f"Failed to take {discard_rank} - significant missed opportunity"
elif discard_qual == "terrible" and took_discard:
quality = DecisionQuality.BLUNDER
ev = discard_value - 5 # Expected deck draw ~5
reasoning = f"Took terrible card {discard_rank} when should have drawn from deck"
elif discard_qual == "good" and not took_discard:
quality = DecisionQuality.MISTAKE
ev = -2
reasoning = f"Missed good card {discard_rank}"
elif discard_qual == "bad" and took_discard:
quality = DecisionQuality.MISTAKE
ev = discard_value - 5
reasoning = f"Took bad card {discard_rank}"
else:
quality = DecisionQuality.QUESTIONABLE
ev = -1
reasoning = f"Suboptimal choice with {discard_rank}"
return DecisionAnalysis(
move_id=0,
action="take_discard" if took_discard else "draw_deck",
card_rank=discard_rank,
position=None,
quality=quality,
expected_value=ev,
reasoning=reasoning,
optimal_play="take" if should_take else "draw" if should_take is False else "either"
)
def evaluate_swap(
self,
drawn_rank: str,
hand: list[dict],
swapped: bool,
swap_position: Optional[int],
was_from_discard: bool
) -> DecisionAnalysis:
"""
Evaluate swap vs discard decision.
Optimal play:
- Swap excellent cards into face-down positions
- Swap if drawn card better than position card
- Don't discard good cards
"""
drawn_value = get_card_value(drawn_rank, self.options)
drawn_qual = rank_quality(drawn_rank, self.options)
# If from discard, must swap - evaluate position choice
if was_from_discard and not swapped:
# This shouldn't happen per rules
return DecisionAnalysis(
move_id=0,
action="invalid",
card_rank=drawn_rank,
position=swap_position,
quality=DecisionQuality.BLUNDER,
expected_value=-10,
reasoning="Must swap when drawing from discard",
optimal_play="swap"
)
if not swapped:
# Discarded the drawn card
if drawn_qual == "excellent":
return DecisionAnalysis(
move_id=0,
action="discard",
card_rank=drawn_rank,
position=None,
quality=DecisionQuality.BLUNDER,
expected_value=abs(drawn_value) + 5, # Lost value + avg replacement
reasoning=f"Discarded excellent card {drawn_rank}!",
optimal_play="swap into face-down"
)
elif drawn_qual == "good":
return DecisionAnalysis(
move_id=0,
action="discard",
card_rank=drawn_rank,
position=None,
quality=DecisionQuality.MISTAKE,
expected_value=3,
reasoning=f"Discarded good card {drawn_rank}",
optimal_play="swap into face-down"
)
else:
# Discarding neutral/bad card is fine
return DecisionAnalysis(
move_id=0,
action="discard",
card_rank=drawn_rank,
position=None,
quality=DecisionQuality.OPTIMAL,
expected_value=0,
reasoning=f"Correctly discarded {drawn_qual} card {drawn_rank}",
)
# Swapped - evaluate position choice
if swap_position is not None and 0 <= swap_position < len(hand):
replaced_card = hand[swap_position]
if replaced_card.get('face_up'):
replaced_rank = replaced_card.get('rank', '?')
replaced_value = get_card_value(replaced_rank, self.options)
ev_change = replaced_value - drawn_value
if ev_change > 0:
quality = DecisionQuality.OPTIMAL
reasoning = f"Good swap: {drawn_rank} ({drawn_value}) for {replaced_rank} ({replaced_value})"
elif ev_change < -3:
quality = DecisionQuality.MISTAKE
reasoning = f"Bad swap: lost {-ev_change} points swapping {replaced_rank} for {drawn_rank}"
elif ev_change < 0:
quality = DecisionQuality.QUESTIONABLE
reasoning = f"Marginal swap: {drawn_rank} for {replaced_rank}"
else:
quality = DecisionQuality.GOOD
reasoning = f"Neutral swap: {drawn_rank} for {replaced_rank}"
return DecisionAnalysis(
move_id=0,
action="swap",
card_rank=drawn_rank,
position=swap_position,
quality=quality,
expected_value=ev_change,
reasoning=reasoning,
)
else:
# Swapped into face-down - generally good for good cards
if drawn_qual in ("excellent", "good", "decent"):
return DecisionAnalysis(
move_id=0,
action="swap",
card_rank=drawn_rank,
position=swap_position,
quality=DecisionQuality.OPTIMAL,
expected_value=5 - drawn_value, # vs expected ~5 hidden
reasoning=f"Good: swapped {drawn_rank} into unknown position",
)
else:
return DecisionAnalysis(
move_id=0,
action="swap",
card_rank=drawn_rank,
position=swap_position,
quality=DecisionQuality.QUESTIONABLE,
expected_value=0,
reasoning=f"Risky: swapped {drawn_qual} card {drawn_rank} into unknown",
)
return DecisionAnalysis(
move_id=0,
action="swap",
card_rank=drawn_rank,
position=swap_position,
quality=DecisionQuality.GOOD,
expected_value=0,
reasoning="Swap decision",
)
# =============================================================================
# Game Analyzer
# =============================================================================
class GameAnalyzer:
"""Analyzes logged games for decision quality."""
def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path)
if not self.db_path.exists():
raise FileNotFoundError(f"Database not found: {db_path}")
def get_game_options(self, game_id: str) -> Optional[dict]:
"""Load game options from database."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"SELECT options_json FROM games WHERE id = ?",
(game_id,)
)
row = cursor.fetchone()
if row and row[0]:
return json.loads(row[0])
return None
def get_player_moves(self, game_id: str, player_name: str) -> list[dict]:
"""Get all moves for a player in a game."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT * FROM moves
WHERE game_id = ? AND player_name = ?
ORDER BY move_number
""", (game_id, player_name))
return [dict(row) for row in cursor.fetchall()]
def analyze_player_game(self, game_id: str, player_name: str) -> GameSummary:
"""Analyze all decisions made by a player in a game."""
options = self.get_game_options(game_id)
moves = self.get_player_moves(game_id, player_name)
evaluator = DecisionEvaluator(options)
decisions = []
draw_context = None # Track the draw for evaluating subsequent swap
for move in moves:
action = move['action']
card_rank = move['card_rank']
position = move['position']
hand = json.loads(move['hand_json']) if move['hand_json'] else []
discard_top = json.loads(move['discard_top_json']) if move['discard_top_json'] else None
if action in ('take_discard', 'draw_deck'):
# Evaluate draw decision
if discard_top:
analysis = evaluator.evaluate_take_discard(
discard_rank=discard_top.get('rank', '?'),
hand=hand,
took_discard=(action == 'take_discard')
)
analysis.move_id = move['id']
decisions.append(analysis)
# Store context for swap evaluation
draw_context = {
'rank': card_rank,
'from_discard': action == 'take_discard',
'hand': hand
}
elif action == 'swap':
if draw_context:
analysis = evaluator.evaluate_swap(
drawn_rank=draw_context['rank'],
hand=draw_context['hand'],
swapped=True,
swap_position=position,
was_from_discard=draw_context['from_discard']
)
analysis.move_id = move['id']
decisions.append(analysis)
draw_context = None
elif action == 'discard':
if draw_context:
analysis = evaluator.evaluate_swap(
drawn_rank=draw_context['rank'],
hand=draw_context['hand'],
swapped=False,
swap_position=None,
was_from_discard=draw_context['from_discard']
)
analysis.move_id = move['id']
decisions.append(analysis)
draw_context = None
# Tally results
counts = {q: 0 for q in DecisionQuality}
total_ev_lost = 0.0
for d in decisions:
counts[d.quality] += 1
if d.expected_value < 0:
total_ev_lost += abs(d.expected_value)
return GameSummary(
game_id=game_id,
player_name=player_name,
total_decisions=len(decisions),
optimal_count=counts[DecisionQuality.OPTIMAL],
good_count=counts[DecisionQuality.GOOD],
questionable_count=counts[DecisionQuality.QUESTIONABLE],
mistake_count=counts[DecisionQuality.MISTAKE],
blunder_count=counts[DecisionQuality.BLUNDER],
total_ev_lost=total_ev_lost,
decisions=decisions
)
def find_blunders(self, limit: int = 20) -> list[dict]:
"""Find all blunders across all games."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT m.*, g.room_code
FROM moves m
JOIN games g ON m.game_id = g.id
WHERE m.is_cpu = 1
ORDER BY m.timestamp DESC
LIMIT ?
""", (limit * 10,)) # Get more, then filter
blunders = []
options_cache = {}
for row in cursor:
move = dict(row)
game_id = move['game_id']
# Cache options lookup
if game_id not in options_cache:
options_cache[game_id] = self.get_game_options(game_id)
options = options_cache[game_id]
card_rank = move['card_rank']
if not card_rank:
continue
# Check for obvious blunders
quality = rank_quality(card_rank, options)
action = move['action']
is_blunder = False
reason = ""
if action == 'discard' and quality in ('excellent', 'good'):
is_blunder = True
reason = f"Discarded {quality} card {card_rank}"
elif action == 'take_discard' and quality == 'terrible':
# Check if this was for pairing - that's smart play!
hand = json.loads(move['hand_json']) if move['hand_json'] else []
card_value = get_card_value(card_rank, options)
has_matching_visible = any(
c.get('rank') == card_rank and c.get('face_up')
for c in hand
)
# Also check if player has worse visible cards (taking to swap is smart)
has_worse_visible = any(
c.get('face_up') and get_card_value(c.get('rank', '?'), options) > card_value
for c in hand
)
if has_matching_visible:
# Taking to pair - this is good play, not a blunder
pass
elif has_worse_visible:
# Taking to swap for a worse card - reasonable play
pass
else:
is_blunder = True
reason = f"Took terrible card {card_rank} with no improvement path"
if is_blunder:
blunders.append({
**move,
'blunder_reason': reason
})
if len(blunders) >= limit:
break
return blunders
# =============================================================================
# Report Generation
# =============================================================================
def generate_player_report(summary: GameSummary) -> str:
"""Generate a text report for a player's game performance."""
lines = [
f"=== Decision Analysis: {summary.player_name} ===",
f"Game: {summary.game_id[:8]}...",
f"",
f"Total Decisions: {summary.total_decisions}",
f"Accuracy: {summary.accuracy:.1f}%",
f"",
f"Breakdown:",
f" Optimal: {summary.optimal_count}",
f" Good: {summary.good_count}",
f" Questionable: {summary.questionable_count}",
f" Mistakes: {summary.mistake_count}",
f" Blunders: {summary.blunder_count}",
f"",
f"Points Lost to Errors: {summary.total_ev_lost:.1f}",
f"",
]
# List specific issues
issues = [d for d in summary.decisions
if d.quality in (DecisionQuality.MISTAKE, DecisionQuality.BLUNDER)]
if issues:
lines.append("Issues Found:")
for d in issues:
marker = "!!!" if d.quality == DecisionQuality.BLUNDER else "!"
lines.append(f" {marker} {d.reasoning}")
return "\n".join(lines)
def print_blunder_report(blunders: list[dict]):
"""Print a report of found blunders."""
print(f"\n=== Blunder Report ({len(blunders)} found) ===\n")
for b in blunders:
print(f"Player: {b['player_name']}")
print(f"Action: {b['action']} {b['card_rank']}")
print(f"Reason: {b['blunder_reason']}")
print(f"Room: {b.get('room_code', 'N/A')}")
print("-" * 40)
# =============================================================================
# CLI Interface
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage:")
print(" python game_analyzer.py blunders [limit]")
print(" python game_analyzer.py game <game_id> <player_name>")
print(" python game_analyzer.py summary")
sys.exit(1)
command = sys.argv[1]
try:
analyzer = GameAnalyzer()
except FileNotFoundError:
print("No games.db found. Play some games first!")
sys.exit(1)
if command == "blunders":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
blunders = analyzer.find_blunders(limit)
print_blunder_report(blunders)
elif command == "game" and len(sys.argv) >= 4:
game_id = sys.argv[2]
player_name = sys.argv[3]
summary = analyzer.analyze_player_game(game_id, player_name)
print(generate_player_report(summary))
elif command == "summary":
# Quick summary of recent games
with sqlite3.connect("games.db") as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT g.id, g.room_code, g.started_at, g.num_players,
COUNT(m.id) as move_count
FROM games g
LEFT JOIN moves m ON g.id = m.game_id
GROUP BY g.id
ORDER BY g.started_at DESC
LIMIT 10
""")
print("\n=== Recent Games ===\n")
for row in cursor:
print(f"Game: {row['id'][:8]}... Room: {row['room_code']}")
print(f" Players: {row['num_players']}, Moves: {row['move_count']}")
print(f" Started: {row['started_at']}")
print()
else:
print(f"Unknown command: {command}")
sys.exit(1)