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:
349
server/score_analysis.py
Normal file
349
server/score_analysis.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user