golfgame/server/score_analysis.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

350 lines
10 KiB
Python

"""
Score distribution analysis for Golf AI.
Generates box plots and statistics to verify AI plays reasonably.
"""
import random
import sys
from collections import defaultdict
from game import Game, Player, GamePhase, GameOptions
from ai import GolfAI, CPUProfile, CPU_PROFILES, get_ai_card_value
def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
"""Run a single game and return final scores by player name."""
# Pick random profiles
profiles = random.sample(CPU_PROFILES, min(num_players, len(CPU_PROFILES)))
game = Game()
player_profiles: dict[str, CPUProfile] = {}
for i, profile in enumerate(profiles):
player = Player(id=f"cpu_{i}", name=profile.name)
game.add_player(player)
player_profiles[player.id] = profile
options = GameOptions(initial_flips=2, flip_on_discard=False, use_jokers=False)
game.start_game(num_decks=1, num_rounds=1, options=options)
# Initial flips
for player in game.players:
positions = GolfAI.choose_initial_flips(options.initial_flips)
game.flip_initial_cards(player.id, positions)
# Play game
turn = 0
max_turns = 200
while game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN) and turn < max_turns:
current = game.current_player()
if not current:
break
profile = player_profiles[current.id]
# Draw
discard_top = game.discard_top()
take_discard = GolfAI.should_take_discard(discard_top, current, profile, game)
source = "discard" if take_discard else "deck"
drawn = game.draw_card(current.id, source)
if not drawn:
break
# Swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, current, profile, game)
if swap_pos is None and game.drawn_from_discard:
face_down = [i for i, c in enumerate(current.cards) if not c.face_up]
if face_down:
swap_pos = random.choice(face_down)
else:
worst_pos = 0
worst_val = -999
for i, c in enumerate(current.cards):
card_val = get_ai_card_value(c, game.options)
if card_val > worst_val:
worst_val = card_val
worst_pos = i
swap_pos = worst_pos
if swap_pos is not None:
game.swap_card(current.id, swap_pos)
else:
game.discard_drawn(current.id)
if game.flip_on_discard:
flip_pos = GolfAI.choose_flip_after_discard(current, profile)
game.flip_and_end_turn(current.id, flip_pos)
turn += 1
# Return scores
return {p.name: p.total_score for p in game.players}
def collect_scores(num_games: int = 100, num_players: int = 4) -> dict[str, list[int]]:
"""Run multiple games and collect all scores by player."""
all_scores: dict[str, list[int]] = defaultdict(list)
print(f"Running {num_games} games with {num_players} players each...")
for i in range(num_games):
if (i + 1) % 20 == 0:
print(f" {i + 1}/{num_games} games completed")
scores = run_game_for_scores(num_players)
for name, score in scores.items():
all_scores[name].append(score)
return dict(all_scores)
def print_statistics(all_scores: dict[str, list[int]]):
"""Print statistical summary."""
print("\n" + "=" * 60)
print("SCORE STATISTICS BY PLAYER")
print("=" * 60)
# Combine all scores
combined = []
for scores in all_scores.values():
combined.extend(scores)
combined.sort()
def percentile(data, p):
k = (len(data) - 1) * p / 100
f = int(k)
c = f + 1 if f + 1 < len(data) else f
return data[f] + (k - f) * (data[c] - data[f])
def stats(data):
data = sorted(data)
n = len(data)
mean = sum(data) / n
q1 = percentile(data, 25)
median = percentile(data, 50)
q3 = percentile(data, 75)
return {
'n': n,
'min': min(data),
'q1': q1,
'median': median,
'q3': q3,
'max': max(data),
'mean': mean,
'iqr': q3 - q1
}
print(f"\n{'Player':<12} {'N':>5} {'Min':>6} {'Q1':>6} {'Med':>6} {'Q3':>6} {'Max':>6} {'Mean':>7}")
print("-" * 60)
for name in sorted(all_scores.keys()):
s = stats(all_scores[name])
print(f"{name:<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
print("-" * 60)
s = stats(combined)
print(f"{'OVERALL':<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
print(f"\nInterquartile Range (IQR): {s['iqr']:.1f}")
print(f"Typical score range: {s['q1']:.0f} to {s['q3']:.0f}")
# Score distribution buckets
print("\n" + "=" * 60)
print("SCORE DISTRIBUTION")
print("=" * 60)
buckets = defaultdict(int)
for score in combined:
if score < -5:
bucket = "< -5"
elif score < 0:
bucket = "-5 to -1"
elif score < 5:
bucket = "0 to 4"
elif score < 10:
bucket = "5 to 9"
elif score < 15:
bucket = "10 to 14"
elif score < 20:
bucket = "15 to 19"
elif score < 25:
bucket = "20 to 24"
else:
bucket = "25+"
buckets[bucket] += 1
bucket_order = ["< -5", "-5 to -1", "0 to 4", "5 to 9", "10 to 14", "15 to 19", "20 to 24", "25+"]
total = len(combined)
for bucket in bucket_order:
count = buckets.get(bucket, 0)
pct = count / total * 100
bar = "#" * int(pct / 2)
print(f"{bucket:>10}: {count:>4} ({pct:>5.1f}%) {bar}")
return stats(combined)
def create_box_plot(all_scores: dict[str, list[int]], output_file: str = "score_distribution.png"):
"""Create a box plot visualization."""
try:
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # Non-interactive backend
except ImportError:
print("\nMatplotlib not installed. Install with: pip install matplotlib")
print("Skipping box plot generation.")
return False
# Prepare data
names = sorted(all_scores.keys())
data = [all_scores[name] for name in names]
# Also add combined data
combined = []
for scores in all_scores.values():
combined.extend(scores)
names.append("ALL")
data.append(combined)
# Create figure
fig, ax = plt.subplots(figsize=(12, 6))
# Box plot
bp = ax.boxplot(data, labels=names, patch_artist=True)
# Color boxes
colors = ['#FF9999', '#99FF99', '#9999FF', '#FFFF99',
'#FF99FF', '#99FFFF', '#FFB366', '#B366FF', '#CCCCCC']
for patch, color in zip(bp['boxes'], colors[:len(bp['boxes'])]):
patch.set_facecolor(color)
patch.set_alpha(0.7)
# Labels
ax.set_xlabel('Player (AI Personality)', fontsize=12)
ax.set_ylabel('Round Score (lower is better)', fontsize=12)
ax.set_title('6-Card Golf AI Score Distribution', fontsize=14)
# Add horizontal line at 0
ax.axhline(y=0, color='green', linestyle='--', alpha=0.5, label='Zero (par)')
# Add reference lines
ax.axhline(y=10, color='orange', linestyle=':', alpha=0.5, label='Good (10)')
ax.axhline(y=20, color='red', linestyle=':', alpha=0.5, label='Poor (20)')
ax.legend(loc='upper right')
ax.grid(axis='y', alpha=0.3)
# Save
plt.tight_layout()
plt.savefig(output_file, dpi=150)
print(f"\nBox plot saved to: {output_file}")
return True
def create_ascii_box_plot(all_scores: dict[str, list[int]]):
"""Create an ASCII box plot for terminal display."""
print("\n" + "=" * 70)
print("ASCII BOX PLOT (Score Distribution)")
print("=" * 70)
def percentile(data, p):
data = sorted(data)
k = (len(data) - 1) * p / 100
f = int(k)
c = f + 1 if f + 1 < len(data) else f
return data[f] + (k - f) * (data[c] - data[f])
# Find global min/max for scaling
all_vals = []
for scores in all_scores.values():
all_vals.extend(scores)
global_min = min(all_vals)
global_max = max(all_vals)
# Scale to 50 characters
width = 50
def scale(val):
if global_max == global_min:
return width // 2
return int((val - global_min) / (global_max - global_min) * (width - 1))
# Print scale
print(f"\n{' ' * 12} {global_min:<6} {'':^{width-12}} {global_max:>6}")
print(f"{' ' * 12} |{'-' * (width - 2)}|")
# Add combined
combined = list(all_vals)
scores_to_plot = dict(all_scores)
scores_to_plot["COMBINED"] = combined
for name in sorted(scores_to_plot.keys()):
scores = scores_to_plot[name]
q1 = percentile(scores, 25)
med = percentile(scores, 50)
q3 = percentile(scores, 75)
min_val = min(scores)
max_val = max(scores)
# Build the line
line = [' '] * width
# Whiskers
min_pos = scale(min_val)
max_pos = scale(max_val)
q1_pos = scale(q1)
q3_pos = scale(q3)
med_pos = scale(med)
# Left whisker
line[min_pos] = '|'
for i in range(min_pos + 1, q1_pos):
line[i] = '-'
# Box
for i in range(q1_pos, q3_pos + 1):
line[i] = '='
# Median
line[med_pos] = '|'
# Right whisker
for i in range(q3_pos + 1, max_pos):
line[i] = '-'
line[max_pos] = '|'
print(f"{name:>11} {''.join(line)}")
print(f"\n Legend: |---[===|===]---| = min--Q1--median--Q3--max")
print(f" Lower scores are better (left side of plot)")
if __name__ == "__main__":
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 100
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
# Collect scores
all_scores = collect_scores(num_games, num_players)
# Print statistics
print_statistics(all_scores)
# ASCII box plot (always works)
create_ascii_box_plot(all_scores)
# Try matplotlib box plot
create_box_plot(all_scores)