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:
Aaron D. Lee 2026-01-24 19:26:12 -05:00
parent b4a661a801
commit d18cea2104
18 changed files with 8186 additions and 0 deletions

165
README.md Normal file
View File

@ -0,0 +1,165 @@
# Golf Card Game
A multiplayer online 6-card Golf card game with AI opponents and extensive house rules support.
## Features
- **Multiplayer:** 2-6 players via WebSocket
- **AI Opponents:** 8 unique CPU personalities with distinct play styles
- **House Rules:** 15+ optional rule variants
- **Game Logging:** SQLite logging for AI decision analysis
- **Comprehensive Testing:** 80+ tests for rules and AI behavior
## Quick Start
### 1. Install Dependencies
```bash
cd server
pip install -r requirements.txt
```
### 2. Start the Server
```bash
cd server
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 3. Open the Game
Open `http://localhost:8000` in your browser.
## Game Rules
See [server/RULES.md](server/RULES.md) for complete rules documentation.
### Basic Scoring
| Card | Points |
|------|--------|
| Ace | 1 |
| 2 | **-2** |
| 3-10 | Face value |
| Jack, Queen | 10 |
| King | **0** |
| Joker | -2 |
**Column pairs** (same rank in a column) score **0 points**.
### Turn Structure
1. Draw from deck OR take from discard pile
2. **If from deck:** Swap with a card OR discard and flip a face-down card
3. **If from discard:** Must swap (cannot re-discard)
### Ending
When a player reveals all 6 cards, others get one final turn. Lowest score wins.
## AI Personalities
| Name | Style | Description |
|------|-------|-------------|
| Sofia | Calculated & Patient | Conservative, low risk |
| Maya | Aggressive Closer | Goes out early |
| Priya | Pair Hunter | Holds cards hoping for pairs |
| Marcus | Steady Eddie | Balanced, consistent |
| Kenji | Risk Taker | High variance plays |
| Diego | Chaotic Gambler | Unpredictable |
| River | Adaptive Strategist | Adjusts to game state |
| Sage | Sneaky Finisher | Aggressive end-game |
## House Rules
### Point Modifiers
- `super_kings` - Kings worth -2 (instead of 0)
- `lucky_sevens` - 7s worth 0 (instead of 7)
- `ten_penny` - 10s worth 1 (instead of 10)
- `lucky_swing` - Single Joker worth -5
- `eagle_eye` - Paired Jokers score -8
### Bonuses & Penalties
- `knock_bonus` - First to go out gets -5
- `underdog_bonus` - Lowest scorer gets -3
- `knock_penalty` - +10 if you go out but aren't lowest
- `tied_shame` - +5 penalty for tied scores
- `blackjack` - Score of exactly 21 becomes 0
### Gameplay Twists
- `flip_on_discard` - Must flip a card when discarding from deck
- `queens_wild` - Queens match any rank for pairing
- `four_of_a_kind` - 4 of same rank in grid = all score 0
- `use_jokers` - Add Jokers to deck
## Development
### Project Structure
```
golfgame/
├── server/
│ ├── main.py # FastAPI WebSocket server
│ ├── game.py # Core game logic
│ ├── ai.py # AI decision making
│ ├── room.py # Room/lobby management
│ ├── game_log.py # SQLite logging
│ ├── game_analyzer.py # Decision analysis CLI
│ ├── simulate.py # AI-vs-AI simulation
│ ├── score_analysis.py # Score distribution analysis
│ ├── test_game.py # Game rules tests
│ ├── test_analyzer.py # Analyzer tests
│ ├── test_maya_bug.py # Bug regression tests
│ ├── test_house_rules.py # House rules testing
│ └── RULES.md # Rules documentation
├── client/
│ ├── index.html
│ ├── style.css
│ └── app.js
└── README.md
```
### Running Tests
```bash
cd server
pytest test_game.py test_analyzer.py test_maya_bug.py -v
```
### AI Simulation
```bash
# Run 50 games with 4 AI players
python simulate.py 50 4
# Run detailed single game
python simulate.py detail 4
# Analyze AI decisions for blunders
python game_analyzer.py blunders
# Score distribution analysis
python score_analysis.py 100 4
# Test all house rules
python test_house_rules.py 40
```
### AI Performance
From testing (1000+ games):
- **0 blunders** detected in simulation
- **Median score:** 12 points
- **Score range:** -4 to 34 (typical)
- Personalities influence style without compromising competence
## Technology Stack
- **Backend:** Python 3.12+, FastAPI, WebSockets
- **Frontend:** Vanilla HTML/CSS/JavaScript
- **Database:** SQLite (optional, for game logging)
- **Testing:** pytest
## License
MIT

1044
client/app.js Normal file

File diff suppressed because it is too large Load Diff

264
client/index.html Normal file
View File

@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golf Card Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen active">
<h1>🏌️ Golf</h1>
<p class="subtitle">6-Card Golf Card Game</p>
<div class="form-group">
<label for="player-name">Your Name</label>
<input type="text" id="player-name" placeholder="Enter your name" maxlength="20">
</div>
<div class="button-group">
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
</div>
<div class="divider">or</div>
<div class="form-group">
<label for="room-code">Room Code</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div>
<div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
</div>
<p id="lobby-error" class="error"></p>
</div>
<!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen">
<h2>Room: <span id="display-room-code"></span></h2>
<div class="players-list">
<h3>Players</h3>
<ul id="players-list"></ul>
</div>
<div id="host-settings" class="settings hidden">
<h3>Game Settings</h3>
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-secondary">- CPU</button>
<button id="add-cpu-btn" class="btn btn-small btn-primary">+ CPU</button>
</div>
</div>
<div class="form-group">
<label for="num-decks">Number of Decks</label>
<select id="num-decks">
<option value="1">1 Deck (2-4 players)</option>
<option value="2">2 Decks (4-6 players)</option>
<option value="3">3 Decks (5-6 players)</option>
</select>
<p id="deck-recommendation" class="recommendation hidden">Strongly recommended: 2+ decks for 4+ players to avoid running out of cards</p>
</div>
<div class="form-group">
<label for="num-rounds">Number of Holes</label>
<select id="num-rounds">
<option value="9" selected>9 Holes (Front Nine)</option>
<option value="18">18 Holes (Full Round)</option>
<option value="3">3 Holes (Quick Game)</option>
<option value="1">1 Hole</option>
</select>
</div>
<div class="form-group">
<label for="initial-flips">Starting Cards Revealed</label>
<select id="initial-flips">
<option value="2" selected>2 cards (Standard)</option>
<option value="1">1 card</option>
<option value="0">None (Blind start)</option>
</select>
</div>
<div class="form-group">
<label>Variants</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="flip-on-discard">
<span>Flip card when discarding from deck</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="knock-penalty">
<span>+10 penalty if you go out but don't have lowest</span>
</label>
</div>
</div>
<details class="house-rules-section">
<summary>House Rules</summary>
<div class="house-rules-category">
<h4>Jokers</h4>
<div class="form-group compact">
<select id="joker-mode">
<option value="none">No Jokers</option>
<option value="standard">Standard (2 per deck, -2 each)</option>
<option value="lucky-swing">Lucky Swing (1 joker in all decks, -5 pts)</option>
</select>
<label class="checkbox-label eagle-eye-option hidden" id="eagle-eye-label">
<input type="checkbox" id="eagle-eye">
<span>Eagle Eye</span>
<span class="rule-desc">Paired jokers score -8</span>
</label>
</div>
</div>
<div class="house-rules-category">
<h4>Point Modifiers</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="super-kings">
<span>Super Kings</span>
<span class="rule-desc">Kings worth -2 instead of 0</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="lucky-sevens">
<span>Lucky Sevens</span>
<span class="rule-desc">7s worth 0 instead of 7</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="ten-penny">
<span>Ten Penny</span>
<span class="rule-desc">10s worth 1 (like Ace)</span>
</label>
</div>
</div>
<div class="house-rules-category">
<h4>Bonuses & Penalties</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="knock-bonus">
<span>Knock Out Bonus</span>
<span class="rule-desc">-5 for going out first</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="underdog-bonus">
<span>Underdog Bonus</span>
<span class="rule-desc">-3 for lowest score each hole</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="tied-shame">
<span>Tied Shame</span>
<span class="rule-desc">+5 if you tie with someone</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="blackjack">
<span>Blackjack</span>
<span class="rule-desc">Exact 21 becomes 0</span>
</label>
</div>
</div>
<div class="house-rules-category">
<h4>Gameplay Twists</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="queens-wild">
<span>Queens Wild</span>
<span class="rule-desc">Queens pair with any rank</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="four-of-a-kind">
<span>Four of a Kind</span>
<span class="rule-desc">4 matching cards all score 0</span>
</label>
</div>
</div>
</details>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div>
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div>
<!-- Game Screen -->
<div id="game-screen" class="screen">
<div class="game-layout">
<div class="game-main">
<div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div class="deck-info">Deck: <span id="deck-count">52</span></div>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
</div>
<div class="game-table">
<div id="opponents-row" class="opponents-row"></div>
<div class="player-row">
<div class="table-center">
<div class="deck-area">
<div id="deck" class="card card-back">
<span>DECK</span>
</div>
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
</div>
<div id="drawn-card-area" class="hidden">
<div id="drawn-card" class="card"></div>
<button id="discard-btn" class="btn btn-small">Discard</button>
</div>
</div>
<div class="player-section">
<div id="flip-prompt" class="flip-prompt hidden"></div>
<div class="player-area">
<div id="player-cards" class="card-grid"></div>
</div>
<div id="toast" class="toast hidden"></div>
</div>
</div>
</div>
</div>
<div id="scoreboard" class="scoreboard-panel">
<h4>Scores</h4>
<table id="score-table">
<thead>
<tr>
<th>Player</th>
<th>Hole</th>
<th>Tot</th>
<th>W</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
</div>
</div>
</div>
</div>
<!-- CPU Select Modal -->
<div id="cpu-select-modal" class="modal hidden">
<div class="modal-content">
<h3>Select CPU Opponents</h3>
<div id="cpu-profiles-grid" class="profiles-grid"></div>
<div class="modal-buttons">
<button id="cancel-cpu-btn" class="btn btn-secondary">Cancel</button>
<button id="add-selected-cpus-btn" class="btn btn-primary" disabled>Add</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

1210
client/style.css Normal file

File diff suppressed because it is too large Load Diff

190
server/RULES.md Normal file
View File

@ -0,0 +1,190 @@
# 6-Card Golf Rules
This document defines the canonical rules implemented in this game engine, based on standard 6-Card Golf rules from [Pagat.com](https://www.pagat.com/draw/golf.html) and [Bicycle Cards](https://bicyclecards.com/how-to-play/six-card-golf).
## Overview
Golf is a card game where players try to achieve the **lowest score** over multiple rounds ("holes"). The name comes from golf scoring - lower is better.
## Players & Equipment
- **Players:** 2-6 players
- **Deck:** Standard 52-card deck (optionally with 2 Jokers)
- **Multiple decks:** For 5+ players, use 2 decks
## Setup
1. Dealer shuffles and deals **6 cards face-down** to each player
2. Players arrange cards in a **2 row × 3 column grid**:
```
[0] [1] [2] ← Top row
[3] [4] [5] ← Bottom row
```
3. Remaining cards form the **draw pile** (face-down)
4. Top card of draw pile is flipped to start the **discard pile**
5. Each player flips **2 of their cards** face-up (standard rules)
## Card Values
| Card | Points |
|------|--------|
| Ace | 1 |
| 2 | **-2** (negative!) |
| 3-10 | Face value |
| Jack | 10 |
| Queen | 10 |
| King | **0** |
| Joker | -2 |
## Column Pairing
**Critical rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values.
Example:
```
[K] [5] [7] K-K pair = 0
[K] [3] [9] 5+3 = 8, 7+9 = 16
Total: 0 + 8 + 16 = 24
```
**Note:** Paired 2s score 0 (not -4). The pair cancels out, it doesn't double the negative.
## Turn Structure
On your turn:
### 1. Draw Phase
Choose ONE:
- Draw the **top card from the draw pile** (face-down deck)
- Take the **top card from the discard pile** (face-up)
### 2. Play Phase
**If you drew from the DECK:**
- **Swap:** Replace any card in your grid (old card goes to discard face-up)
- **Discard:** Put the drawn card on the discard pile and flip one face-down card
**If you took from the DISCARD PILE:**
- **You MUST swap** - you cannot re-discard the same card
- Replace any card in your grid (old card goes to discard)
### Important Rules
- Swapped cards are always placed **face-up**
- You **cannot look** at a face-down card before deciding to replace it
- When swapping a face-down card, reveal it only as it goes to discard
## Round End
### Triggering the Final Turn
When any player has **all 6 cards face-up**, the round enters "final turn" phase.
### Final Turn Phase
- Each **other player** gets exactly **one more turn**
- The player who triggered final turn does NOT get another turn
- After all players have had their final turn, the round ends
### Scoring
1. All remaining face-down cards are revealed
2. Calculate each player's score (with column pairing)
3. Add round score to total score
## Winning
- Standard game: **9 rounds** ("9 holes")
- Player with the **lowest total score** wins
- Optionally play 18 rounds for a longer game
---
# House Rules (Optional)
Our implementation supports these optional rule variations:
## Standard Options
| Option | Description | Default |
|--------|-------------|---------|
| `initial_flips` | Cards revealed at start (0, 1, or 2) | 2 |
| `flip_on_discard` | Must flip a card after discarding from deck | Off |
| `knock_penalty` | +10 if you go out but don't have lowest score | Off |
| `use_jokers` | Add Jokers to deck (-2 points each) | Off |
## Point Modifiers
| Option | Effect |
|--------|--------|
| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) |
| `super_kings` | Kings worth **-2** (instead of 0) |
| `lucky_sevens` | 7s worth **0** (instead of 7) |
| `ten_penny` | 10s worth **1** (instead of 10) |
## Bonuses & Penalties
| Option | Effect |
|--------|--------|
| `knock_bonus` | First to reveal all cards gets **-5** bonus |
| `underdog_bonus` | Lowest scorer each round gets **-3** |
| `tied_shame` | Tying another player's score = **+5** penalty to both |
| `blackjack` | Exact score of 21 becomes **0** |
## Gameplay Twists
| Option | Effect |
|--------|--------|
| `queens_wild` | Queens match any rank for column pairing |
| `four_of_a_kind` | 4 cards of same rank in grid = all 4 score 0 |
| `eagle_eye` | Paired Jokers score **-8** (instead of canceling to 0) |
---
# Game Theory Notes
## Expected Turn Count
With standard rules (2 initial flips):
- Start: 2 face-up, 4 face-down
- Each turn reveals 1 card (swap or discard+flip)
- **Minimum turns to go out:** 4
- **Typical range:** 4-8 turns per player per round
## Strategic Considerations
### Good Cards (keep these)
- **Jokers** (-2 or -5): Best cards in the game
- **2s** (-2): Second best, but don't pair them!
- **Kings** (0): Safe, good for pairing
- **Aces** (1): Low risk
### Bad Cards (replace these)
- **10, J, Q** (10 points): Worst cards
- **8, 9** (8-9 points): High priority to replace
### Pairing Strategy
- Pairing is powerful - column score goes to 0
- **Don't pair negative cards** - you lose the negative benefit
- Target pairs with mid-value cards (3-7) for maximum gain
### When to Go Out
- Go out with **score ≤ 10** when confident you're lowest
- Consider opponent visible cards before going out early
- With `knock_penalty`, be careful - +10 hurts if you're wrong
---
# Test Coverage
The game engine has comprehensive test coverage in `test_game.py`:
- **Card Values:** All 13 ranks verified
- **Column Pairing:** Matching, non-matching, negative card edge cases
- **House Rules:** All scoring modifiers tested
- **Draw/Discard:** Deck draws, discard draws, must-swap rule
- **Turn Flow:** Turn advancement, wrap-around, player validation
- **Round End:** Final turn triggering, one-more-turn logic
- **Multi-Round:** Score accumulation, hand reset
Run tests with:
```bash
pytest test_game.py -v
```

641
server/ai.py Normal file
View File

@ -0,0 +1,641 @@
"""AI personalities for CPU players in Golf."""
import logging
import random
from dataclasses import dataclass
from typing import Optional
from enum import Enum
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank
def get_ai_card_value(card: Card, options: GameOptions) -> int:
"""Get card value with house rules applied for AI decisions."""
if card.rank == Rank.JOKER:
return -5 if options.lucky_swing else -2
if card.rank == Rank.KING and options.super_kings:
return -2
if card.rank == Rank.SEVEN and options.lucky_sevens:
return 0
if card.rank == Rank.TEN and options.ten_penny:
return 0
return card.value()
def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool:
"""Check if two cards can form a pair (with Queens Wild support)."""
if card1.rank == card2.rank:
return True
if options.queens_wild:
if card1.rank == Rank.QUEEN or card2.rank == Rank.QUEEN:
return True
return False
def estimate_opponent_min_score(player: Player, game: Game) -> int:
"""Estimate minimum opponent score from visible cards."""
min_est = 999
for p in game.players:
if p.id == player.id:
continue
visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
hidden = sum(1 for c in p.cards if not c.face_up)
estimate = visible + int(hidden * 4.5) # Assume ~4.5 avg for hidden
min_est = min(min_est, estimate)
return min_est
def count_rank_in_hand(player: Player, rank: Rank) -> int:
"""Count how many cards of a given rank the player has visible."""
return sum(1 for c in player.cards if c.face_up and c.rank == rank)
def has_worse_visible_card(player: Player, card_value: int, options: GameOptions) -> bool:
"""Check if player has a visible card worse than the given value.
Used to determine if taking a card from discard makes sense -
we should only take if we have something worse to replace.
"""
for c in player.cards:
if c.face_up and get_ai_card_value(c, options) > card_value:
return True
return False
@dataclass
class CPUProfile:
"""Pre-defined CPU player profile with personality traits."""
name: str
style: str # Brief description shown to players
# Tipping point: swap if card value is at or above this (4-8)
swap_threshold: int
# How likely to hold high cards hoping for pairs (0.0-1.0)
pair_hope: float
# Screw your neighbor: tendency to go out early (0.0-1.0)
aggression: float
# Wildcard factor: chance of unexpected plays (0.0-0.3)
unpredictability: float
def to_dict(self) -> dict:
return {
"name": self.name,
"style": self.style,
}
# Pre-defined CPU profiles (3 female, 3 male, 2 non-binary)
CPU_PROFILES = [
# Female profiles
CPUProfile(
name="Sofia",
style="Calculated & Patient",
swap_threshold=4,
pair_hope=0.2,
aggression=0.2,
unpredictability=0.02,
),
CPUProfile(
name="Maya",
style="Aggressive Closer",
swap_threshold=6,
pair_hope=0.4,
aggression=0.85,
unpredictability=0.1,
),
CPUProfile(
name="Priya",
style="Pair Hunter",
swap_threshold=7,
pair_hope=0.8,
aggression=0.5,
unpredictability=0.05,
),
# Male profiles
CPUProfile(
name="Marcus",
style="Steady Eddie",
swap_threshold=5,
pair_hope=0.35,
aggression=0.4,
unpredictability=0.03,
),
CPUProfile(
name="Kenji",
style="Risk Taker",
swap_threshold=8,
pair_hope=0.7,
aggression=0.75,
unpredictability=0.12,
),
CPUProfile(
name="Diego",
style="Chaotic Gambler",
swap_threshold=6,
pair_hope=0.5,
aggression=0.6,
unpredictability=0.28,
),
# Non-binary profiles
CPUProfile(
name="River",
style="Adaptive Strategist",
swap_threshold=5,
pair_hope=0.45,
aggression=0.55,
unpredictability=0.08,
),
CPUProfile(
name="Sage",
style="Sneaky Finisher",
swap_threshold=5,
pair_hope=0.3,
aggression=0.9,
unpredictability=0.15,
),
]
# Track which profiles are in use
_used_profiles: set[str] = set()
_cpu_profiles: dict[str, CPUProfile] = {}
def get_available_profile() -> Optional[CPUProfile]:
"""Get a random available CPU profile."""
available = [p for p in CPU_PROFILES if p.name not in _used_profiles]
if not available:
return None
profile = random.choice(available)
_used_profiles.add(profile.name)
return profile
def release_profile(name: str):
"""Release a CPU profile back to the pool."""
_used_profiles.discard(name)
# Also remove from cpu_profiles by finding the cpu_id with this profile
to_remove = [cpu_id for cpu_id, profile in _cpu_profiles.items() if profile.name == name]
for cpu_id in to_remove:
del _cpu_profiles[cpu_id]
def reset_all_profiles():
"""Reset all profile tracking (for cleanup)."""
_used_profiles.clear()
_cpu_profiles.clear()
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
"""Get the profile for a CPU player."""
return _cpu_profiles.get(cpu_id)
def assign_profile(cpu_id: str) -> Optional[CPUProfile]:
"""Assign a random profile to a CPU player."""
profile = get_available_profile()
if profile:
_cpu_profiles[cpu_id] = profile
return profile
def assign_specific_profile(cpu_id: str, profile_name: str) -> Optional[CPUProfile]:
"""Assign a specific profile to a CPU player by name."""
# Check if profile exists and is available
for profile in CPU_PROFILES:
if profile.name == profile_name and profile.name not in _used_profiles:
_used_profiles.add(profile.name)
_cpu_profiles[cpu_id] = profile
return profile
return None
def get_all_profiles() -> list[dict]:
"""Get all CPU profiles for display."""
return [p.to_dict() for p in CPU_PROFILES]
class GolfAI:
"""AI decision-making for Golf game."""
@staticmethod
def choose_initial_flips(count: int = 2) -> list[int]:
"""Choose cards to flip at the start."""
if count == 0:
return []
if count == 1:
return [random.randint(0, 5)]
# For 2 cards, prefer different columns for pair info
options = [
[0, 4], [2, 4], [3, 1], [5, 1],
[0, 5], [2, 3],
]
return random.choice(options)
@staticmethod
def should_take_discard(discard_card: Optional[Card], player: Player,
profile: CPUProfile, game: Game) -> bool:
"""Decide whether to take from discard pile or deck."""
if not discard_card:
return False
options = game.options
discard_value = get_ai_card_value(discard_card, options)
# Unpredictable players occasionally make random choice
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
if random.random() < profile.unpredictability:
if discard_value <= 5:
return random.choice([True, False])
# Always take Jokers and Kings (even better with house rules)
if discard_card.rank == Rank.JOKER:
# Eagle Eye: If we have a visible Joker, take to pair them (doubled negative!)
if options.eagle_eye:
for card in player.cards:
if card.face_up and card.rank == Rank.JOKER:
return True
return True
if discard_card.rank == Rank.KING:
return True
# Auto-take 7s when lucky_sevens enabled (they're worth 0)
if discard_card.rank == Rank.SEVEN and options.lucky_sevens:
return True
# Auto-take 10s when ten_penny enabled (they're worth 0)
if discard_card.rank == Rank.TEN and options.ten_penny:
return True
# Queens Wild: Queen can complete ANY pair
if options.queens_wild and discard_card.rank == Rank.QUEEN:
for i, card in enumerate(player.cards):
if card.face_up:
pair_pos = (i + 3) % 6 if i < 3 else i - 3
if not player.cards[pair_pos].face_up:
# We have an incomplete column - Queen could pair it
return True
# Four of a Kind: If we have 2+ of this rank, consider taking
if options.four_of_a_kind:
rank_count = count_rank_in_hand(player, discard_card.rank)
if rank_count >= 2:
return True
# Take card if it could make a column pair (but NOT for negative value cards)
# Pairing negative cards is bad - you lose the negative benefit
if discard_value > 0:
for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3
pair_card = player.cards[pair_pos]
# Direct rank match
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
return True
# Queens Wild: check if we can pair with Queen
if options.queens_wild:
if card.face_up and can_make_pair(card, discard_card, options) and not pair_card.face_up:
return True
# Take low cards (using house rule adjusted values)
if discard_value <= 2:
return True
# Check if we have cards worse than the discard
worst_visible = -999
for card in player.cards:
if card.face_up:
worst_visible = max(worst_visible, get_ai_card_value(card, options))
if worst_visible > discard_value + 1:
# Sanity check: only take if we actually have something worse to replace
# This prevents taking a bad card when all visible cards are better
if has_worse_visible_card(player, discard_value, options):
return True
return False
@staticmethod
def choose_swap_or_discard(drawn_card: Card, player: Player,
profile: CPUProfile, game: Game) -> Optional[int]:
"""
Decide whether to swap the drawn card or discard.
Returns position to swap with, or None to discard.
"""
options = game.options
drawn_value = get_ai_card_value(drawn_card, options)
# Unpredictable players occasionally make surprising play
# BUT never discard excellent cards (Jokers, 2s, Kings, Aces)
if random.random() < profile.unpredictability:
if drawn_value > 1: # Only be unpredictable with non-excellent cards
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down and random.random() < 0.5:
return random.choice(face_down)
# Eagle Eye: If drawn card is Joker, look for existing visible Joker to pair
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
for i, card in enumerate(player.cards):
if card.face_up and card.rank == Rank.JOKER:
pair_pos = (i + 3) % 6 if i < 3 else i - 3
if not player.cards[pair_pos].face_up:
return pair_pos
# Four of a Kind: If we have 3 of this rank and draw the 4th, prioritize keeping
if options.four_of_a_kind:
rank_count = count_rank_in_hand(player, drawn_card.rank)
if rank_count >= 3:
# We'd have 4 - swap into any face-down spot
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
return random.choice(face_down)
# Check for column pair opportunity first
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
# Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative)
should_pair = drawn_value > 0
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
should_pair = True
if should_pair:
for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3
pair_card = player.cards[pair_pos]
# Direct rank match
if card.face_up and card.rank == drawn_card.rank and not pair_card.face_up:
return pair_pos
if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up:
return i
# Queens Wild: Queen can pair with anything
if options.queens_wild:
if card.face_up and can_make_pair(card, drawn_card, options) and not pair_card.face_up:
return pair_pos
if pair_card.face_up and can_make_pair(pair_card, drawn_card, options) and not card.face_up:
return i
# Find best swap among face-up cards that are BAD (positive value)
# Don't swap good cards (Kings, 2s, etc.) just for marginal gains -
# we want to keep good cards and put new good cards into face-down positions
best_swap: Optional[int] = None
best_gain = 0
for i, card in enumerate(player.cards):
if card.face_up:
card_value = get_ai_card_value(card, options)
# Only consider replacing cards that are actually bad (positive value)
if card_value > 0:
gain = card_value - drawn_value
if gain > best_gain:
best_gain = gain
best_swap = i
# Swap if we gain points (conservative players need more gain)
min_gain = 2 if profile.swap_threshold <= 4 else 1
if best_gain >= min_gain:
return best_swap
# Blackjack: Check if any swap would result in exactly 21
if options.blackjack:
current_score = player.calculate_score()
if current_score >= 15: # Only chase 21 from high scores
for i, card in enumerate(player.cards):
if card.face_up:
# Calculate score if we swap here
potential_change = drawn_value - get_ai_card_value(card, options)
potential_score = current_score + potential_change
if potential_score == 21:
# Aggressive players more likely to chase 21
if random.random() < profile.aggression:
return i
# Consider swapping with face-down cards for very good cards (negative or zero value)
# 7s (lucky_sevens) and 10s (ten_penny) become "excellent" cards worth keeping
is_excellent = (drawn_value <= 0 or
drawn_card.rank == Rank.ACE or
(options.lucky_sevens and drawn_card.rank == Rank.SEVEN) or
(options.ten_penny and drawn_card.rank == Rank.TEN))
if is_excellent:
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
# Pair hunters might hold out hoping for matches
if profile.pair_hope > 0.6 and random.random() < profile.pair_hope:
return None
return random.choice(face_down)
# For medium cards, swap threshold based on profile
if drawn_value <= profile.swap_threshold:
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
# Pair hunters hold high cards hoping for matches
if profile.pair_hope > 0.5 and drawn_value >= 6:
if random.random() < profile.pair_hope:
return None
return random.choice(face_down)
return None
@staticmethod
def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
"""Choose which face-down card to flip after discarding."""
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if not face_down:
return 0
# Prefer flipping cards that could reveal pair info
for i in face_down:
pair_pos = (i + 3) % 6 if i < 3 else i - 3
if player.cards[pair_pos].face_up:
return i
return random.choice(face_down)
@staticmethod
def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool:
"""
Decide if CPU should try to go out (reveal all cards) to screw neighbors.
"""
options = game.options
face_down_count = sum(1 for c in player.cards if not c.face_up)
if face_down_count > 2:
return False
estimated_score = player.calculate_score()
# Blackjack: If score is exactly 21, definitely go out (becomes 0!)
if options.blackjack and estimated_score == 21:
return True
# Base threshold based on aggression
go_out_threshold = 8 if profile.aggression > 0.7 else (12 if profile.aggression > 0.4 else 16)
# Knock Bonus (-5 for going out): Can afford to go out with higher score
if options.knock_bonus:
go_out_threshold += 5
# Knock Penalty (+10 if not lowest): Need to be confident we're lowest
if options.knock_penalty:
opponent_min = estimate_opponent_min_score(player, game)
# Conservative players require bigger lead
safety_margin = 5 if profile.aggression < 0.4 else 2
if estimated_score > opponent_min - safety_margin:
# We might not have the lowest score - be cautious
go_out_threshold -= 4
# Tied Shame: Estimate if we might tie someone
if options.tied_shame:
for p in game.players:
if p.id == player.id:
continue
visible = sum(get_ai_card_value(c, options) for c in p.cards if c.face_up)
hidden_count = sum(1 for c in p.cards if not c.face_up)
# Rough estimate - if visible scores are close, be cautious
if hidden_count <= 2 and abs(visible - estimated_score) <= 3:
go_out_threshold -= 2
break
# Underdog Bonus: Minor factor - you get -3 for lowest regardless
# This slightly reduces urgency to go out first
if options.underdog_bonus:
go_out_threshold -= 1
if estimated_score <= go_out_threshold:
if random.random() < profile.aggression:
return True
return False
async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
) -> None:
"""Process a complete turn for a CPU player."""
import asyncio
from game_log import get_logger
profile = get_profile(cpu_player.id)
if not profile:
# Fallback to balanced profile
profile = CPUProfile("CPU", "Balanced", 5, 0.4, 0.5, 0.1)
# Get logger if game_id provided
logger = get_logger() if game_id else None
# Add delay based on unpredictability (chaotic players are faster/slower)
delay = 0.8 + random.uniform(0, 0.5)
if profile.unpredictability > 0.2:
delay = random.uniform(0.3, 1.2)
await asyncio.sleep(delay)
# Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile)
# Decide whether to draw from discard or deck
discard_top = game.discard_top()
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
source = "discard" if take_discard else "deck"
drawn = game.draw_card(cpu_player.id, source)
# Log draw decision
if logger and game_id and drawn:
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
logger.log_move(
game_id=game_id,
player=cpu_player,
is_cpu=True,
action="take_discard" if take_discard else "draw_deck",
card=drawn,
game=game,
decision_reason=reason,
)
if not drawn:
return
await broadcast_callback()
await asyncio.sleep(0.4 + random.uniform(0, 0.4))
# Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
# If drawn from discard, must swap (always enforced)
if swap_pos is None and game.drawn_from_discard:
face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
if face_down:
swap_pos = random.choice(face_down)
else:
# All cards are face up - find worst card to replace (using house rules)
worst_pos = 0
worst_val = -999
for i, c in enumerate(cpu_player.cards):
card_val = get_ai_card_value(c, game.options) # Apply house rules
if card_val > worst_val:
worst_val = card_val
worst_pos = i
swap_pos = worst_pos
# Sanity check: warn if we're swapping out a good card for a bad one
drawn_val = get_ai_card_value(drawn, game.options)
if worst_val < drawn_val:
logging.warning(
f"AI {cpu_player.name} forced to swap good card (value={worst_val}) "
f"for bad card {drawn.rank.value} (value={drawn_val})"
)
if swap_pos is not None:
old_card = cpu_player.cards[swap_pos] # Card being replaced
game.swap_card(cpu_player.id, swap_pos)
# Log swap decision
if logger and game_id:
logger.log_move(
game_id=game_id,
player=cpu_player,
is_cpu=True,
action="swap",
card=drawn,
position=swap_pos,
game=game,
decision_reason=f"swapped {drawn.rank.value} into position {swap_pos}, replaced {old_card.rank.value}",
)
else:
game.discard_drawn(cpu_player.id)
# Log discard decision
if logger and game_id:
logger.log_move(
game_id=game_id,
player=cpu_player,
is_cpu=True,
action="discard",
card=drawn,
game=game,
decision_reason=f"discarded {drawn.rank.value}",
)
if game.flip_on_discard:
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
game.flip_and_end_turn(cpu_player.id, flip_pos)
# Log flip decision
if logger and game_id:
flipped_card = cpu_player.cards[flip_pos]
logger.log_move(
game_id=game_id,
player=cpu_player,
is_cpu=True,
action="flip",
card=flipped_card,
position=flip_pos,
game=game,
decision_reason=f"flipped card at position {flip_pos}",
)
await broadcast_callback()

609
server/game.py Normal file
View File

@ -0,0 +1,609 @@
"""Game logic for 6-Card Golf."""
import random
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
class Suit(Enum):
HEARTS = "hearts"
DIAMONDS = "diamonds"
CLUBS = "clubs"
SPADES = "spades"
class Rank(Enum):
ACE = "A"
TWO = "2"
THREE = "3"
FOUR = "4"
FIVE = "5"
SIX = "6"
SEVEN = "7"
EIGHT = "8"
NINE = "9"
TEN = "10"
JACK = "J"
QUEEN = "Q"
KING = "K"
JOKER = ""
RANK_VALUES = {
Rank.ACE: 1,
Rank.TWO: -2,
Rank.THREE: 3,
Rank.FOUR: 4,
Rank.FIVE: 5,
Rank.SIX: 6,
Rank.SEVEN: 7,
Rank.EIGHT: 8,
Rank.NINE: 9,
Rank.TEN: 10,
Rank.JACK: 10,
Rank.QUEEN: 10,
Rank.KING: 0,
Rank.JOKER: -2,
}
@dataclass
class Card:
suit: Suit
rank: Rank
face_up: bool = False
def to_dict(self, reveal: bool = False) -> dict:
if self.face_up or reveal:
return {
"suit": self.suit.value,
"rank": self.rank.value,
"face_up": self.face_up,
}
return {"face_up": False}
def value(self) -> int:
return RANK_VALUES[self.rank]
class Deck:
def __init__(self, num_decks: int = 1, use_jokers: bool = False, lucky_swing: bool = False):
self.cards: list[Card] = []
for _ in range(num_decks):
for suit in Suit:
for rank in Rank:
if rank != Rank.JOKER:
self.cards.append(Card(suit, rank))
if use_jokers and not lucky_swing:
# Standard: Add 2 jokers worth -2 each per deck
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
self.cards.append(Card(Suit.SPADES, Rank.JOKER))
# Lucky Swing: Add just 1 joker total (worth -5)
if use_jokers and lucky_swing:
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
self.shuffle()
def shuffle(self):
random.shuffle(self.cards)
def draw(self) -> Optional[Card]:
if self.cards:
return self.cards.pop()
return None
def cards_remaining(self) -> int:
return len(self.cards)
def add_cards(self, cards: list[Card]):
"""Add cards to the deck and shuffle."""
self.cards.extend(cards)
self.shuffle()
@dataclass
class Player:
id: str
name: str
cards: list[Card] = field(default_factory=list)
score: int = 0
total_score: int = 0
rounds_won: int = 0
def all_face_up(self) -> bool:
return all(card.face_up for card in self.cards)
def flip_card(self, position: int):
if 0 <= position < len(self.cards):
self.cards[position].face_up = True
def swap_card(self, position: int, new_card: Card) -> Card:
old_card = self.cards[position]
new_card.face_up = True
self.cards[position] = new_card
return old_card
def calculate_score(self, options: Optional["GameOptions"] = None) -> int:
"""Calculate score with column pair matching and house rules."""
if len(self.cards) != 6:
return 0
def get_card_value(card: Card) -> int:
"""Get card value with house rules applied."""
if options:
if card.rank == Rank.JOKER:
return -5 if options.lucky_swing else -2
if card.rank == Rank.KING and options.super_kings:
return -2
if card.rank == Rank.SEVEN and options.lucky_sevens:
return 0
if card.rank == Rank.TEN and options.ten_penny:
return 1
return card.value()
def cards_match(card1: Card, card2: Card) -> bool:
"""Check if two cards match for pairing (with Queens Wild support)."""
if card1.rank == card2.rank:
return True
if options and options.queens_wild:
if card1.rank == Rank.QUEEN or card2.rank == Rank.QUEEN:
return True
return False
total = 0
# Cards are arranged in 2 rows x 3 columns
# Position mapping: [0, 1, 2] (top row)
# [3, 4, 5] (bottom row)
# Columns: (0,3), (1,4), (2,5)
# Check for Four of a Kind first (4 cards same rank = all score 0)
four_of_kind_positions: set[int] = set()
if options and options.four_of_a_kind:
from collections import Counter
rank_positions: dict[Rank, list[int]] = {}
for i, card in enumerate(self.cards):
if card.rank not in rank_positions:
rank_positions[card.rank] = []
rank_positions[card.rank].append(i)
for rank, positions in rank_positions.items():
if len(positions) >= 4:
four_of_kind_positions.update(positions)
for col in range(3):
top_idx = col
bottom_idx = col + 3
top_card = self.cards[top_idx]
bottom_card = self.cards[bottom_idx]
# Skip if part of four of a kind
if top_idx in four_of_kind_positions and bottom_idx in four_of_kind_positions:
continue
# Check if column pair matches (same rank or Queens Wild)
if cards_match(top_card, bottom_card):
# Eagle Eye: paired jokers score -8 (2³) instead of canceling
if (options and options.eagle_eye and
top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER):
total -= 8
continue
# Normal matching pair scores 0
continue
else:
if top_idx not in four_of_kind_positions:
total += get_card_value(top_card)
if bottom_idx not in four_of_kind_positions:
total += get_card_value(bottom_card)
self.score = total
return total
def cards_to_dict(self, reveal: bool = False) -> list[dict]:
return [card.to_dict(reveal) for card in self.cards]
class GamePhase(Enum):
WAITING = "waiting"
INITIAL_FLIP = "initial_flip"
PLAYING = "playing"
FINAL_TURN = "final_turn"
ROUND_OVER = "round_over"
GAME_OVER = "game_over"
@dataclass
class GameOptions:
# Standard options
flip_on_discard: bool = False # Flip a card when discarding from deck
initial_flips: int = 2 # Cards to flip at start (0, 1, or 2)
knock_penalty: bool = False # +10 if you go out but don't have lowest
use_jokers: bool = False # Add jokers worth -2 points
# House Rules - Point Modifiers
lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers
super_kings: bool = False # Kings worth -2 instead of 0
lucky_sevens: bool = False # 7s worth 0 instead of 7
ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10
# House Rules - Bonuses/Penalties
knock_bonus: bool = False # First to reveal all cards gets -5 bonus
underdog_bonus: bool = False # Lowest score player gets -3 each hole
tied_shame: bool = False # Tie with someone's score = +5 penalty to both
blackjack: bool = False # Hole score of exactly 21 becomes 0
# House Rules - Gameplay Twists
queens_wild: bool = False # Queens count as any rank for pairing
four_of_a_kind: bool = False # 4 cards of same rank in grid = all 4 score 0
eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10)
@dataclass
class Game:
players: list[Player] = field(default_factory=list)
deck: Optional[Deck] = None
discard_pile: list[Card] = field(default_factory=list)
current_player_index: int = 0
phase: GamePhase = GamePhase.WAITING
num_decks: int = 1
num_rounds: int = 1
current_round: int = 1
drawn_card: Optional[Card] = None
drawn_from_discard: bool = False # Track if current draw was from discard
finisher_id: Optional[str] = None
players_with_final_turn: set = field(default_factory=set)
initial_flips_done: set = field(default_factory=set)
options: GameOptions = field(default_factory=GameOptions)
@property
def flip_on_discard(self) -> bool:
return self.options.flip_on_discard
def add_player(self, player: Player) -> bool:
if len(self.players) >= 6:
return False
self.players.append(player)
return True
def remove_player(self, player_id: str) -> Optional[Player]:
for i, player in enumerate(self.players):
if player.id == player_id:
return self.players.pop(i)
return None
def get_player(self, player_id: str) -> Optional[Player]:
for player in self.players:
if player.id == player_id:
return player
return None
def current_player(self) -> Optional[Player]:
if self.players:
return self.players[self.current_player_index]
return None
def start_game(self, num_decks: int = 1, num_rounds: int = 1, options: Optional[GameOptions] = None):
self.num_decks = num_decks
self.num_rounds = num_rounds
self.options = options or GameOptions()
self.current_round = 1
self.start_round()
def start_round(self):
self.deck = Deck(
self.num_decks,
use_jokers=self.options.use_jokers,
lucky_swing=self.options.lucky_swing
)
self.discard_pile = []
self.drawn_card = None
self.drawn_from_discard = False
self.finisher_id = None
self.players_with_final_turn = set()
self.initial_flips_done = set()
# Deal 6 cards to each player
for player in self.players:
player.cards = []
player.score = 0
for _ in range(6):
card = self.deck.draw()
if card:
player.cards.append(card)
# Start discard pile with one card
first_discard = self.deck.draw()
if first_discard:
first_discard.face_up = True
self.discard_pile.append(first_discard)
self.current_player_index = 0
# Skip initial flip phase if 0 flips required
if self.options.initial_flips == 0:
self.phase = GamePhase.PLAYING
else:
self.phase = GamePhase.INITIAL_FLIP
def flip_initial_cards(self, player_id: str, positions: list[int]) -> bool:
if self.phase != GamePhase.INITIAL_FLIP:
return False
if player_id in self.initial_flips_done:
return False
required_flips = self.options.initial_flips
if len(positions) != required_flips:
return False
player = self.get_player(player_id)
if not player:
return False
for pos in positions:
if not (0 <= pos < 6):
return False
player.flip_card(pos)
self.initial_flips_done.add(player_id)
# Check if all players have flipped
if len(self.initial_flips_done) == len(self.players):
self.phase = GamePhase.PLAYING
return True
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
player = self.current_player()
if not player or player.id != player_id:
return None
if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return None
if self.drawn_card is not None:
return None
if source == "deck":
card = self.deck.draw()
if not card:
# Deck empty - try to reshuffle discard pile
card = self._reshuffle_discard_pile()
if card:
self.drawn_card = card
self.drawn_from_discard = False
return card
else:
# No cards available anywhere - end round gracefully
self._end_round()
return None
elif source == "discard" and self.discard_pile:
card = self.discard_pile.pop()
self.drawn_card = card
self.drawn_from_discard = True
return card
return None
def _reshuffle_discard_pile(self) -> Optional[Card]:
"""Reshuffle discard pile into deck, keeping top card. Returns drawn card or None."""
if len(self.discard_pile) <= 1:
# No cards to reshuffle (only top card or empty)
return None
# Keep the top card, take the rest
top_card = self.discard_pile[-1]
cards_to_reshuffle = self.discard_pile[:-1]
# Reset face_up for reshuffled cards
for card in cards_to_reshuffle:
card.face_up = False
# Add to deck and shuffle
self.deck.add_cards(cards_to_reshuffle)
# Keep only top card in discard pile
self.discard_pile = [top_card]
# Draw from the newly shuffled deck
return self.deck.draw()
def swap_card(self, player_id: str, position: int) -> Optional[Card]:
player = self.current_player()
if not player or player.id != player_id:
return None
if self.drawn_card is None:
return None
if not (0 <= position < 6):
return None
old_card = player.swap_card(position, self.drawn_card)
old_card.face_up = True
self.discard_pile.append(old_card)
self.drawn_card = None
self._check_end_turn(player)
return old_card
def can_discard_drawn(self) -> bool:
"""Check if player can discard the drawn card."""
# Must swap if taking from discard pile (always enforced)
if self.drawn_from_discard:
return False
return True
def discard_drawn(self, player_id: str) -> bool:
player = self.current_player()
if not player or player.id != player_id:
return False
if self.drawn_card is None:
return False
# Cannot discard if drawn from discard pile (must swap)
if not self.can_discard_drawn():
return False
self.drawn_card.face_up = True
self.discard_pile.append(self.drawn_card)
self.drawn_card = None
if self.flip_on_discard:
# Version 1: Must flip a card after discarding
has_face_down = any(not card.face_up for card in player.cards)
if not has_face_down:
self._check_end_turn(player)
# Otherwise, wait for flip_and_end_turn to be called
else:
# Version 2 (default): Just end the turn
self._check_end_turn(player)
return True
def flip_and_end_turn(self, player_id: str, position: int) -> bool:
"""Flip a face-down card after discarding from deck draw."""
player = self.current_player()
if not player or player.id != player_id:
return False
if not (0 <= position < 6):
return False
if player.cards[position].face_up:
return False
player.flip_card(position)
self._check_end_turn(player)
return True
def _check_end_turn(self, player: Player):
# Check if player finished (all cards face up)
if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN
self.players_with_final_turn.add(player.id)
# Move to next player
self._next_turn()
def _next_turn(self):
if self.phase == GamePhase.FINAL_TURN:
# In final turn phase, track who has had their turn
next_index = (self.current_player_index + 1) % len(self.players)
next_player = self.players[next_index]
if next_player.id in self.players_with_final_turn:
# Everyone has had their final turn
self._end_round()
return
self.current_player_index = next_index
self.players_with_final_turn.add(next_player.id)
else:
self.current_player_index = (self.current_player_index + 1) % len(self.players)
def _end_round(self):
self.phase = GamePhase.ROUND_OVER
# Reveal all cards and calculate scores
for player in self.players:
for card in player.cards:
card.face_up = True
player.calculate_score(self.options)
# Apply Blackjack rule: score of exactly 21 becomes 0
if self.options.blackjack:
for player in self.players:
if player.score == 21:
player.score = 0
# Apply knock penalty if enabled (+10 if you go out but don't have lowest)
if self.options.knock_penalty and self.finisher_id:
finisher = self.get_player(self.finisher_id)
if finisher:
min_score = min(p.score for p in self.players)
if finisher.score > min_score:
finisher.score += 10
# Apply knock bonus if enabled (-5 to first player who reveals all)
if self.options.knock_bonus and self.finisher_id:
finisher = self.get_player(self.finisher_id)
if finisher:
finisher.score -= 5
# Apply underdog bonus (-3 to lowest scorer)
if self.options.underdog_bonus:
min_score = min(p.score for p in self.players)
for player in self.players:
if player.score == min_score:
player.score -= 3
# Apply tied shame (+5 to players who tie with someone else)
if self.options.tied_shame:
from collections import Counter
score_counts = Counter(p.score for p in self.players)
for player in self.players:
if score_counts[player.score] > 1:
player.score += 5
for player in self.players:
player.total_score += player.score
# Award round win to lowest scorer(s)
min_score = min(p.score for p in self.players)
for player in self.players:
if player.score == min_score:
player.rounds_won += 1
def start_next_round(self) -> bool:
if self.phase != GamePhase.ROUND_OVER:
return False
if self.current_round >= self.num_rounds:
self.phase = GamePhase.GAME_OVER
return False
self.current_round += 1
self.start_round()
return True
def discard_top(self) -> Optional[Card]:
if self.discard_pile:
return self.discard_pile[-1]
return None
def get_state(self, for_player_id: str) -> dict:
current = self.current_player()
players_data = []
for player in self.players:
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
is_self = player.id == for_player_id
players_data.append({
"id": player.id,
"name": player.name,
"cards": player.cards_to_dict(reveal=reveal or is_self),
"score": player.score if reveal else None,
"total_score": player.total_score,
"rounds_won": player.rounds_won,
"all_face_up": player.all_face_up(),
})
discard_top = self.discard_top()
return {
"phase": self.phase.value,
"players": players_data,
"current_player_id": current.id if current else None,
"discard_top": discard_top.to_dict(reveal=True) if discard_top else None,
"deck_remaining": self.deck.cards_remaining() if self.deck else 0,
"current_round": self.current_round,
"total_rounds": self.num_rounds,
"has_drawn_card": self.drawn_card is not None,
"can_discard": self.can_discard_drawn() if self.drawn_card else True,
"waiting_for_initial_flip": (
self.phase == GamePhase.INITIAL_FLIP and
for_player_id not in self.initial_flips_done
),
"initial_flips": self.options.initial_flips,
"flip_on_discard": self.flip_on_discard,
}

649
server/game_analyzer.py Normal file
View File

@ -0,0 +1,649 @@
"""
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)

242
server/game_log.py Normal file
View File

@ -0,0 +1,242 @@
"""SQLite game logging for AI decision analysis."""
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from dataclasses import asdict
from game import Card, Player, Game, GameOptions
class GameLogger:
"""Logs game state and AI decisions to SQLite for post-game analysis."""
def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path)
self._init_db()
def _init_db(self):
"""Initialize database schema."""
with sqlite3.connect(self.db_path) as conn:
conn.executescript("""
-- Games table
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
room_code TEXT,
started_at TIMESTAMP,
ended_at TIMESTAMP,
num_players INTEGER,
options_json TEXT
);
-- Moves table (one per AI decision)
CREATE TABLE IF NOT EXISTS moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT REFERENCES games(id),
move_number INTEGER,
timestamp TIMESTAMP,
player_id TEXT,
player_name TEXT,
is_cpu BOOLEAN,
-- Decision context
action TEXT,
-- Cards involved
card_rank TEXT,
card_suit TEXT,
position INTEGER,
-- Full state snapshot
hand_json TEXT,
discard_top_json TEXT,
visible_opponents_json TEXT,
-- AI reasoning
decision_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
""")
def log_game_start(
self, room_code: str, num_players: int, options: GameOptions
) -> str:
"""Log start of a new game. Returns game_id."""
game_id = str(uuid.uuid4())
options_dict = {
"flip_on_discard": options.flip_on_discard,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
"lucky_swing": options.lucky_swing,
"super_kings": options.super_kings,
"lucky_sevens": options.lucky_sevens,
"ten_penny": options.ten_penny,
"knock_bonus": options.knock_bonus,
"underdog_bonus": options.underdog_bonus,
"tied_shame": options.tied_shame,
"blackjack": options.blackjack,
"queens_wild": options.queens_wild,
"four_of_a_kind": options.four_of_a_kind,
"eagle_eye": options.eagle_eye,
}
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO games (id, room_code, started_at, num_players, options_json)
VALUES (?, ?, ?, ?, ?)
""",
(game_id, room_code, datetime.now(), num_players, json.dumps(options_dict)),
)
return game_id
def log_move(
self,
game_id: str,
player: Player,
is_cpu: bool,
action: str,
card: Optional[Card] = None,
position: Optional[int] = None,
game: Optional[Game] = None,
decision_reason: Optional[str] = None,
):
"""Log a single move/decision."""
# Get current move number
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"SELECT COALESCE(MAX(move_number), 0) + 1 FROM moves WHERE game_id = ?",
(game_id,),
)
move_number = cursor.fetchone()[0]
# Serialize hand
hand_data = []
for c in player.cards:
hand_data.append({
"rank": c.rank.value,
"suit": c.suit.value,
"face_up": c.face_up,
})
# Serialize discard top
discard_top_data = None
if game:
discard_top = game.discard_top()
if discard_top:
discard_top_data = {
"rank": discard_top.rank.value,
"suit": discard_top.suit.value,
}
# Serialize visible opponent cards
visible_opponents = {}
if game:
for p in game.players:
if p.id != player.id:
visible = []
for c in p.cards:
if c.face_up:
visible.append({
"rank": c.rank.value,
"suit": c.suit.value,
})
visible_opponents[p.name] = visible
conn.execute(
"""
INSERT INTO moves (
game_id, move_number, timestamp, player_id, player_name, is_cpu,
action, card_rank, card_suit, position,
hand_json, discard_top_json, visible_opponents_json, decision_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
game_id,
move_number,
datetime.now(),
player.id,
player.name,
is_cpu,
action,
card.rank.value if card else None,
card.suit.value if card else None,
position,
json.dumps(hand_data),
json.dumps(discard_top_data) if discard_top_data else None,
json.dumps(visible_opponents),
decision_reason,
),
)
def log_game_end(self, game_id: str):
"""Mark game as ended."""
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE games SET ended_at = ? WHERE id = ?",
(datetime.now(), game_id),
)
# Query helpers for analysis
def find_suspicious_discards(db_path: str = "games.db") -> list[dict]:
"""Find cases where AI discarded good cards (Ace, 2, King)."""
with sqlite3.connect(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.action = 'discard'
AND m.card_rank IN ('A', '2', 'K')
AND m.is_cpu = 1
ORDER BY m.timestamp DESC
""")
return [dict(row) for row in cursor.fetchall()]
def get_player_decisions(db_path: str, game_id: str, player_name: str) -> list[dict]:
"""Get all decisions made by a specific player in a game."""
with sqlite3.connect(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 get_recent_games(db_path: str = "games.db", limit: int = 10) -> list[dict]:
"""Get list of recent games."""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT g.*, COUNT(m.id) as total_moves
FROM games g
LEFT JOIN moves m ON g.id = m.game_id
GROUP BY g.id
ORDER BY g.started_at DESC
LIMIT ?
""", (limit,))
return [dict(row) for row in cursor.fetchall()]
# Global logger instance (lazy initialization)
_logger: Optional[GameLogger] = None
def get_logger() -> GameLogger:
"""Get or create the global game logger instance."""
global _logger
if _logger is None:
_logger = GameLogger()
return _logger

459
server/main.py Normal file
View File

@ -0,0 +1,459 @@
"""FastAPI WebSocket server for Golf card game."""
import uuid
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from room import RoomManager, Room
from game import GamePhase, GameOptions
from ai import GolfAI, process_cpu_turn, get_all_profiles
from game_log import get_logger
app = FastAPI(title="Golf Card Game")
room_manager = RoomManager()
@app.get("/health")
async def health_check():
return {"status": "ok"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
player_id = str(uuid.uuid4())
current_room: Room | None = None
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "create_room":
player_name = data.get("player_name", "Player")
room = room_manager.create_room()
room.add_player(player_id, player_name, websocket)
current_room = room
await websocket.send_json({
"type": "room_created",
"room_code": room.code,
"player_id": player_id,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
elif msg_type == "join_room":
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player")
room = room_manager.get_room(room_code)
if not room:
await websocket.send_json({
"type": "error",
"message": "Room not found",
})
continue
if len(room.players) >= 6:
await websocket.send_json({
"type": "error",
"message": "Room is full",
})
continue
if room.game.phase != GamePhase.WAITING:
await websocket.send_json({
"type": "error",
"message": "Game already in progress",
})
continue
room.add_player(player_id, player_name, websocket)
current_room = room
await websocket.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": player_id,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
elif msg_type == "get_cpu_profiles":
if not current_room:
continue
await websocket.send_json({
"type": "cpu_profiles",
"profiles": get_all_profiles(),
})
elif msg_type == "add_cpu":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
await websocket.send_json({
"type": "error",
"message": "Only the host can add CPU players",
})
continue
if len(current_room.players) >= 6:
await websocket.send_json({
"type": "error",
"message": "Room is full",
})
continue
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
profile_name = data.get("profile_name")
cpu_player = current_room.add_cpu_player(cpu_id, profile_name)
if not cpu_player:
await websocket.send_json({
"type": "error",
"message": "CPU profile not available",
})
continue
await current_room.broadcast({
"type": "player_joined",
"players": current_room.player_list(),
})
elif msg_type == "remove_cpu":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
continue
# Remove the last CPU player
cpu_players = current_room.get_cpu_players()
if cpu_players:
current_room.remove_player(cpu_players[-1].id)
await current_room.broadcast({
"type": "player_joined",
"players": current_room.player_list(),
})
elif msg_type == "start_game":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
await websocket.send_json({
"type": "error",
"message": "Only the host can start the game",
})
continue
if len(current_room.players) < 2:
await websocket.send_json({
"type": "error",
"message": "Need at least 2 players",
})
continue
num_decks = data.get("decks", 1)
num_rounds = data.get("rounds", 1)
# Build game options
options = GameOptions(
# Standard options
flip_on_discard=data.get("flip_on_discard", False),
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False),
# House Rules - Point Modifiers
lucky_swing=data.get("lucky_swing", False),
super_kings=data.get("super_kings", False),
lucky_sevens=data.get("lucky_sevens", False),
ten_penny=data.get("ten_penny", False),
# House Rules - Bonuses/Penalties
knock_bonus=data.get("knock_bonus", False),
underdog_bonus=data.get("underdog_bonus", False),
tied_shame=data.get("tied_shame", False),
blackjack=data.get("blackjack", False),
# House Rules - Gameplay Twists
queens_wild=data.get("queens_wild", False),
four_of_a_kind=data.get("four_of_a_kind", False),
eagle_eye=data.get("eagle_eye", False),
)
# Validate settings
num_decks = max(1, min(3, num_decks))
num_rounds = max(1, min(18, num_rounds))
current_room.game.start_game(num_decks, num_rounds, options)
# Log game start for AI analysis
logger = get_logger()
current_room.game_log_id = logger.log_game_start(
room_code=current_room.code,
num_players=len(current_room.players),
options=options,
)
# CPU players do their initial flips immediately (if required)
if options.initial_flips > 0:
for cpu in current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips(options.initial_flips)
current_room.game.flip_initial_cards(cpu.id, positions)
# Send game started to all human players with their personal view
for pid, player in current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_started",
"game_state": game_state,
})
# Check if it's a CPU's turn to start
await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_initial":
if not current_room:
continue
positions = data.get("positions", [])
if current_room.game.flip_initial_cards(player_id, positions):
await broadcast_game_state(current_room)
# Check if it's a CPU's turn
await check_and_run_cpu_turn(current_room)
elif msg_type == "draw":
if not current_room:
continue
source = data.get("source", "deck")
card = current_room.game.draw_card(player_id, source)
if card:
# Send drawn card only to the player who drew
await websocket.send_json({
"type": "card_drawn",
"card": card.to_dict(reveal=True),
"source": source,
})
await broadcast_game_state(current_room)
elif msg_type == "swap":
if not current_room:
continue
position = data.get("position", 0)
discarded = current_room.game.swap_card(player_id, position)
if discarded:
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "discard":
if not current_room:
continue
if current_room.game.discard_drawn(player_id):
await broadcast_game_state(current_room)
if current_room.game.flip_on_discard:
# Version 1: Check if player has face-down cards to flip
player = current_room.game.get_player(player_id)
has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down:
await websocket.send_json({
"type": "can_flip",
})
else:
await check_and_run_cpu_turn(current_room)
else:
# Version 2 (default): Turn ended, check for CPU
await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_card":
if not current_room:
continue
position = data.get("position", 0)
current_room.game.flip_and_end_turn(player_id, position)
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "next_round":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
continue
if current_room.game.start_next_round():
# CPU players do their initial flips
for cpu in current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips()
current_room.game.flip_initial_cards(cpu.id, positions)
for pid, player in current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "round_started",
"game_state": game_state,
})
await check_and_run_cpu_turn(current_room)
else:
# Game over
await broadcast_game_state(current_room)
elif msg_type == "leave_room":
if current_room:
await handle_player_leave(current_room, player_id)
current_room = None
except WebSocketDisconnect:
if current_room:
await handle_player_leave(current_room, player_id)
async def broadcast_game_state(room: Room):
"""Broadcast game state to all human players in a room."""
for pid, player in room.players.items():
# Skip CPU players
if player.is_cpu or not player.websocket:
continue
game_state = room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_state",
"game_state": game_state,
})
# Check for round over
if room.game.phase == GamePhase.ROUND_OVER:
scores = [
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
# Build rankings
by_points = sorted(scores, key=lambda x: x["total"])
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
await player.websocket.send_json({
"type": "round_over",
"scores": scores,
"round": room.game.current_round,
"total_rounds": room.game.num_rounds,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
# Check for game over
elif room.game.phase == GamePhase.GAME_OVER:
# Log game end
if room.game_log_id:
logger = get_logger()
logger.log_game_end(room.game_log_id)
room.game_log_id = None # Clear to avoid duplicate logging
scores = [
{"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
by_points = sorted(scores, key=lambda x: x["total"])
by_holes_won = sorted(scores, key=lambda x: -x["rounds_won"])
await player.websocket.send_json({
"type": "game_over",
"final_scores": by_points,
"rankings": {
"by_points": by_points,
"by_holes_won": by_holes_won,
},
})
# Notify current player it's their turn (only if human)
elif room.game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
current = room.game.current_player()
if current and pid == current.id and not room.game.drawn_card:
await player.websocket.send_json({
"type": "your_turn",
})
async def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn."""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room)
async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room."""
room_player = room.remove_player(player_id)
# If no human players left, clean up the room entirely
if room.is_empty() or room.human_player_count() == 0:
# Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id)
room_manager.remove_room(room.code)
elif room_player:
await room.broadcast({
"type": "player_left",
"player_id": player_id,
"player_name": room_player.name,
"players": room.player_list(),
})
# Serve static files if client directory exists
client_path = os.path.join(os.path.dirname(__file__), "..", "client")
if os.path.exists(client_path):
@app.get("/")
async def serve_index():
return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/style.css")
async def serve_css():
return FileResponse(os.path.join(client_path, "style.css"), media_type="text/css")
@app.get("/app.js")
async def serve_js():
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")

3
server/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
websockets==12.0

155
server/room.py Normal file
View File

@ -0,0 +1,155 @@
"""Room management for multiplayer games."""
import random
import string
from dataclasses import dataclass, field
from typing import Optional
from fastapi import WebSocket
from game import Game, Player
from ai import assign_profile, release_profile, get_profile, assign_specific_profile
@dataclass
class RoomPlayer:
id: str
name: str
websocket: Optional[WebSocket] = None
is_host: bool = False
is_cpu: bool = False
@dataclass
class Room:
code: str
players: dict[str, RoomPlayer] = field(default_factory=dict)
game: Game = field(default_factory=Game)
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None # For SQLite logging
def add_player(self, player_id: str, name: str, websocket: WebSocket) -> RoomPlayer:
is_host = len(self.players) == 0
room_player = RoomPlayer(
id=player_id,
name=name,
websocket=websocket,
is_host=is_host,
)
self.players[player_id] = room_player
# Add to game
game_player = Player(id=player_id, name=name)
self.game.add_player(game_player)
return room_player
def add_cpu_player(self, cpu_id: str, profile_name: Optional[str] = None) -> Optional[RoomPlayer]:
# Get a CPU profile (specific or random)
if profile_name:
profile = assign_specific_profile(cpu_id, profile_name)
else:
profile = assign_profile(cpu_id)
if not profile:
return None # Profile not available
room_player = RoomPlayer(
id=cpu_id,
name=profile.name,
websocket=None,
is_host=False,
is_cpu=True,
)
self.players[cpu_id] = room_player
# Add to game
game_player = Player(id=cpu_id, name=profile.name)
self.game.add_player(game_player)
return room_player
def remove_player(self, player_id: str) -> Optional[RoomPlayer]:
if player_id in self.players:
room_player = self.players.pop(player_id)
self.game.remove_player(player_id)
# Release CPU profile back to the pool
if room_player.is_cpu:
release_profile(room_player.name)
# Assign new host if needed
if room_player.is_host and self.players:
next_host = next(iter(self.players.values()))
next_host.is_host = True
return room_player
return None
def get_player(self, player_id: str) -> Optional[RoomPlayer]:
return self.players.get(player_id)
def is_empty(self) -> bool:
return len(self.players) == 0
def player_list(self) -> list[dict]:
result = []
for p in self.players.values():
player_data = {"id": p.id, "name": p.name, "is_host": p.is_host, "is_cpu": p.is_cpu}
if p.is_cpu:
profile = get_profile(p.id)
if profile:
player_data["style"] = profile.style
result.append(player_data)
return result
def get_cpu_players(self) -> list[RoomPlayer]:
return [p for p in self.players.values() if p.is_cpu]
def human_player_count(self) -> int:
return sum(1 for p in self.players.values() if not p.is_cpu)
async def broadcast(self, message: dict, exclude: Optional[str] = None):
for player_id, player in self.players.items():
if player_id != exclude and player.websocket and not player.is_cpu:
try:
await player.websocket.send_json(message)
except Exception:
pass
async def send_to(self, player_id: str, message: dict):
player = self.players.get(player_id)
if player and player.websocket and not player.is_cpu:
try:
await player.websocket.send_json(message)
except Exception:
pass
class RoomManager:
def __init__(self):
self.rooms: dict[str, Room] = {}
def _generate_code(self) -> str:
while True:
code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms:
return code
def create_room(self) -> Room:
code = self._generate_code()
room = Room(code=code)
self.rooms[code] = room
return room
def get_room(self, code: str) -> Optional[Room]:
return self.rooms.get(code.upper())
def remove_room(self, code: str):
if code in self.rooms:
del self.rooms[code]
def find_player_room(self, player_id: str) -> Optional[Room]:
for room in self.rooms.values():
if player_id in room.players:
return room
return None

349
server/score_analysis.py Normal file
View 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)

435
server/simulate.py Normal file
View File

@ -0,0 +1,435 @@
"""
Golf AI Simulation Runner
Runs AI-vs-AI games to generate decision logs for analysis.
No server/websocket needed - runs games directly.
Usage:
python simulate.py [num_games] [num_players]
Examples:
python simulate.py 10 # Run 10 games with 4 players each
python simulate.py 50 2 # Run 50 games with 2 players each
"""
import asyncio
import random
import sys
from typing import Optional
from game import Game, Player, GamePhase, GameOptions
from ai import (
GolfAI, CPUProfile, CPU_PROFILES,
get_ai_card_value, has_worse_visible_card
)
from game_log import GameLogger
class SimulationStats:
"""Track simulation statistics."""
def __init__(self):
self.games_played = 0
self.total_rounds = 0
self.total_turns = 0
self.player_wins: dict[str, int] = {}
self.player_scores: dict[str, list[int]] = {}
self.decisions: dict[str, dict] = {} # player -> {action: count}
def record_game(self, game: Game, winner_name: str):
self.games_played += 1
self.total_rounds += game.current_round
if winner_name not in self.player_wins:
self.player_wins[winner_name] = 0
self.player_wins[winner_name] += 1
for player in game.players:
if player.name not in self.player_scores:
self.player_scores[player.name] = []
self.player_scores[player.name].append(player.total_score)
def record_turn(self, player_name: str, action: str):
self.total_turns += 1
if player_name not in self.decisions:
self.decisions[player_name] = {}
if action not in self.decisions[player_name]:
self.decisions[player_name][action] = 0
self.decisions[player_name][action] += 1
def report(self) -> str:
lines = [
"=" * 50,
"SIMULATION RESULTS",
"=" * 50,
f"Games played: {self.games_played}",
f"Total rounds: {self.total_rounds}",
f"Total turns: {self.total_turns}",
f"Avg turns/game: {self.total_turns / max(1, self.games_played):.1f}",
"",
"WIN RATES:",
]
total_wins = sum(self.player_wins.values())
for name, wins in sorted(self.player_wins.items(), key=lambda x: -x[1]):
pct = wins / max(1, total_wins) * 100
lines.append(f" {name}: {wins} wins ({pct:.1f}%)")
lines.append("")
lines.append("AVERAGE SCORES (lower is better):")
for name, scores in sorted(
self.player_scores.items(),
key=lambda x: sum(x[1]) / len(x[1]) if x[1] else 999
):
avg = sum(scores) / len(scores) if scores else 0
lines.append(f" {name}: {avg:.1f}")
lines.append("")
lines.append("DECISION BREAKDOWN:")
for name, actions in sorted(self.decisions.items()):
total = sum(actions.values())
lines.append(f" {name}:")
for action, count in sorted(actions.items()):
pct = count / max(1, total) * 100
lines.append(f" {action}: {count} ({pct:.1f}%)")
return "\n".join(lines)
def create_cpu_players(num_players: int) -> list[tuple[Player, CPUProfile]]:
"""Create CPU players with random profiles."""
# Shuffle profiles and pick
profiles = random.sample(CPU_PROFILES, min(num_players, len(CPU_PROFILES)))
players = []
for i, profile in enumerate(profiles):
player = Player(id=f"cpu_{i}", name=profile.name)
players.append((player, profile))
return players
def run_cpu_turn(
game: Game,
player: Player,
profile: CPUProfile,
logger: Optional[GameLogger],
game_id: Optional[str],
stats: SimulationStats
) -> str:
"""Run a single CPU turn synchronously. Returns action taken."""
# Decide whether to draw from discard or deck
discard_top = game.discard_top()
take_discard = GolfAI.should_take_discard(discard_top, player, profile, game)
source = "discard" if take_discard else "deck"
drawn = game.draw_card(player.id, source)
if not drawn:
return "no_card"
action = "take_discard" if take_discard else "draw_deck"
stats.record_turn(player.name, action)
# Log draw decision
if logger and game_id:
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action=action,
card=drawn,
game=game,
decision_reason=reason,
)
# Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, player, profile, game)
# If drawn from discard, must swap
if swap_pos is None and game.drawn_from_discard:
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
swap_pos = random.choice(face_down)
else:
# Find worst card using house rules
worst_pos = 0
worst_val = -999
for i, c in enumerate(player.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:
old_card = player.cards[swap_pos]
game.swap_card(player.id, swap_pos)
action = "swap"
stats.record_turn(player.name, action)
if logger and game_id:
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="swap",
card=drawn,
position=swap_pos,
game=game,
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
)
else:
game.discard_drawn(player.id)
action = "discard"
stats.record_turn(player.name, action)
if logger and game_id:
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="discard",
card=drawn,
game=game,
decision_reason=f"discarded {drawn.rank.value}",
)
if game.flip_on_discard:
flip_pos = GolfAI.choose_flip_after_discard(player, profile)
game.flip_and_end_turn(player.id, flip_pos)
if logger and game_id:
flipped = player.cards[flip_pos]
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="flip",
card=flipped,
position=flip_pos,
game=game,
decision_reason=f"flipped position {flip_pos}",
)
return action
def run_game(
players_with_profiles: list[tuple[Player, CPUProfile]],
options: GameOptions,
logger: Optional[GameLogger],
stats: SimulationStats,
verbose: bool = False
) -> tuple[str, int]:
"""Run a complete game. Returns (winner_name, winner_score)."""
game = Game()
profiles: dict[str, CPUProfile] = {}
for player, profile in players_with_profiles:
# Reset player state
player.cards = []
player.score = 0
player.total_score = 0
player.rounds_won = 0
game.add_player(player)
profiles[player.id] = profile
game.start_game(num_decks=1, num_rounds=1, options=options)
# Log game start
game_id = None
if logger:
game_id = logger.log_game_start(
room_code="SIM",
num_players=len(players_with_profiles),
options=options
)
# Do initial flips for all players
if options.initial_flips > 0:
for player, profile in players_with_profiles:
positions = GolfAI.choose_initial_flips(options.initial_flips)
game.flip_initial_cards(player.id, positions)
# Play until game over
turn_count = 0
max_turns = 200 # Safety limit
while game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN) and turn_count < max_turns:
current = game.current_player()
if not current:
break
profile = profiles[current.id]
action = run_cpu_turn(game, current, profile, logger, game_id, stats)
if verbose and turn_count % 10 == 0:
print(f" Turn {turn_count}: {current.name} - {action}")
turn_count += 1
# Log game end
if logger and game_id:
logger.log_game_end(game_id)
# Find winner
winner = min(game.players, key=lambda p: p.total_score)
stats.record_game(game, winner.name)
return winner.name, winner.total_score
def run_simulation(
num_games: int = 10,
num_players: int = 4,
verbose: bool = True
):
"""Run multiple games and report statistics."""
print(f"\nRunning {num_games} games with {num_players} players each...")
print("=" * 50)
logger = GameLogger()
stats = SimulationStats()
# Default options
options = GameOptions(
initial_flips=2,
flip_on_discard=False,
use_jokers=False,
)
for i in range(num_games):
players = create_cpu_players(num_players)
if verbose:
names = [p.name for p, _ in players]
print(f"\nGame {i+1}/{num_games}: {', '.join(names)}")
winner, score = run_game(players, options, logger, stats, verbose=False)
if verbose:
print(f" Winner: {winner} (score: {score})")
print("\n")
print(stats.report())
print("\n" + "=" * 50)
print("ANALYSIS")
print("=" * 50)
print("\nRun analysis with:")
print(" python game_analyzer.py blunders")
print(" python game_analyzer.py summary")
def run_detailed_game(num_players: int = 4):
"""Run a single game with detailed output."""
print(f"\nRunning detailed game with {num_players} players...")
print("=" * 50)
logger = GameLogger()
stats = SimulationStats()
options = GameOptions(
initial_flips=2,
flip_on_discard=False,
use_jokers=False,
)
players_with_profiles = create_cpu_players(num_players)
game = Game()
profiles: dict[str, CPUProfile] = {}
for player, profile in players_with_profiles:
game.add_player(player)
profiles[player.id] = profile
print(f" {player.name} ({profile.style})")
game.start_game(num_decks=1, num_rounds=1, options=options)
game_id = logger.log_game_start(
room_code="DETAIL",
num_players=num_players,
options=options
)
# Initial flips
print("\nInitial flips:")
for player, profile in players_with_profiles:
positions = GolfAI.choose_initial_flips(options.initial_flips)
game.flip_initial_cards(player.id, positions)
visible = [(i, c.rank.value) for i, c in enumerate(player.cards) if c.face_up]
print(f" {player.name}: {visible}")
print(f"\nDiscard pile: {game.discard_top().rank.value}")
print("\n" + "-" * 50)
# Play game
turn = 0
while game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN) and turn < 100:
current = game.current_player()
if not current:
break
profile = profiles[current.id]
discard_before = game.discard_top()
# Show state before turn
visible = [(i, c.rank.value) for i, c in enumerate(current.cards) if c.face_up]
hidden = sum(1 for c in current.cards if not c.face_up)
print(f"\nTurn {turn + 1}: {current.name}")
print(f" Hand: {visible} + {hidden} hidden")
print(f" Discard: {discard_before.rank.value}")
# Run turn
action = run_cpu_turn(game, current, profile, logger, game_id, stats)
# Show result
discard_after = game.discard_top()
print(f" Action: {action}")
print(f" New discard: {discard_after.rank.value if discard_after else 'empty'}")
if game.phase == GamePhase.FINAL_TURN and game.finisher_id == current.id:
print(f" >>> {current.name} went out! Final turn phase.")
turn += 1
# Game over
logger.log_game_end(game_id)
print("\n" + "=" * 50)
print("FINAL SCORES")
print("=" * 50)
for player in sorted(game.players, key=lambda p: p.total_score):
cards = [c.rank.value for c in player.cards]
print(f" {player.name}: {player.total_score} points")
print(f" Cards: {cards}")
winner = min(game.players, key=lambda p: p.total_score)
print(f"\nWinner: {winner.name}!")
print(f"\nGame logged as: {game_id[:8]}...")
print("Run: python game_analyzer.py game", game_id, winner.name)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "detail":
# Detailed single game
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
run_detailed_game(num_players)
else:
# Batch simulation
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 10
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
run_simulation(num_games, num_players)

299
server/test_analyzer.py Normal file
View File

@ -0,0 +1,299 @@
"""
Tests for the GameAnalyzer decision evaluation logic.
Verifies that the analyzer correctly identifies:
- Optimal plays
- Mistakes
- Blunders
Run with: pytest test_analyzer.py -v
"""
import pytest
from game_analyzer import (
DecisionEvaluator, DecisionQuality,
get_card_value, rank_quality
)
# =============================================================================
# Card Value Tests
# =============================================================================
class TestCardValues:
"""Verify card value lookups."""
def test_standard_values(self):
assert get_card_value('A') == 1
assert get_card_value('2') == -2
assert get_card_value('5') == 5
assert get_card_value('10') == 10
assert get_card_value('J') == 10
assert get_card_value('Q') == 10
assert get_card_value('K') == 0
assert get_card_value('') == -2
def test_house_rules(self):
opts = {'lucky_swing': True}
assert get_card_value('', opts) == -5
opts = {'super_kings': True}
assert get_card_value('K', opts) == -2
opts = {'lucky_sevens': True}
assert get_card_value('7', opts) == 0
opts = {'ten_penny': True}
assert get_card_value('10', opts) == 1
class TestRankQuality:
"""Verify card quality classification."""
def test_excellent_cards(self):
assert rank_quality('') == "excellent"
assert rank_quality('2') == "excellent"
def test_good_cards(self):
assert rank_quality('K') == "good"
def test_decent_cards(self):
assert rank_quality('A') == "decent"
def test_neutral_cards(self):
assert rank_quality('3') == "neutral"
assert rank_quality('4') == "neutral"
assert rank_quality('5') == "neutral"
def test_bad_cards(self):
assert rank_quality('6') == "bad"
assert rank_quality('7') == "bad"
def test_terrible_cards(self):
assert rank_quality('8') == "terrible"
assert rank_quality('9') == "terrible"
assert rank_quality('10') == "terrible"
assert rank_quality('J') == "terrible"
assert rank_quality('Q') == "terrible"
# =============================================================================
# Take Discard Evaluation Tests
# =============================================================================
class TestTakeDiscardEvaluation:
"""Test evaluation of take discard vs draw deck decisions."""
def setup_method(self):
self.evaluator = DecisionEvaluator()
# Hand with mix of cards
self.hand = [
{'rank': '7', 'face_up': True},
{'rank': '5', 'face_up': True},
{'rank': '?', 'face_up': False},
{'rank': '9', 'face_up': True},
{'rank': '?', 'face_up': False},
{'rank': '?', 'face_up': False},
]
def test_taking_joker_is_optimal(self):
"""Taking a Joker should always be optimal."""
result = self.evaluator.evaluate_take_discard('', self.hand, took_discard=True)
assert result.quality == DecisionQuality.OPTIMAL
def test_not_taking_joker_is_blunder(self):
"""Not taking a Joker is a blunder."""
result = self.evaluator.evaluate_take_discard('', self.hand, took_discard=False)
assert result.quality == DecisionQuality.BLUNDER
def test_taking_king_is_optimal(self):
"""Taking a King should be optimal."""
result = self.evaluator.evaluate_take_discard('K', self.hand, took_discard=True)
assert result.quality == DecisionQuality.OPTIMAL
def test_not_taking_king_is_mistake(self):
"""Not taking a King is a mistake."""
result = self.evaluator.evaluate_take_discard('K', self.hand, took_discard=False)
assert result.quality == DecisionQuality.MISTAKE
def test_taking_queen_is_blunder(self):
"""Taking a Queen (10 points) with decent hand is a blunder."""
result = self.evaluator.evaluate_take_discard('Q', self.hand, took_discard=True)
assert result.quality == DecisionQuality.BLUNDER
def test_not_taking_queen_is_optimal(self):
"""Not taking a Queen is optimal."""
result = self.evaluator.evaluate_take_discard('Q', self.hand, took_discard=False)
assert result.quality == DecisionQuality.OPTIMAL
def test_taking_card_better_than_worst(self):
"""Taking a card better than worst visible is optimal."""
# Worst visible is 9
result = self.evaluator.evaluate_take_discard('3', self.hand, took_discard=True)
assert result.quality == DecisionQuality.OPTIMAL
def test_neutral_card_better_than_worst(self):
"""A card better than worst visible should be taken."""
# 4 is better than worst visible (9), so taking is correct
result = self.evaluator.evaluate_take_discard('4', self.hand, took_discard=True)
assert result.quality == DecisionQuality.OPTIMAL
# Not taking a 4 when worst is 9 is suboptimal (but not terrible)
result = self.evaluator.evaluate_take_discard('4', self.hand, took_discard=False)
assert result.quality == DecisionQuality.QUESTIONABLE
# =============================================================================
# Swap Evaluation Tests
# =============================================================================
class TestSwapEvaluation:
"""Test evaluation of swap vs discard decisions."""
def setup_method(self):
self.evaluator = DecisionEvaluator()
self.hand = [
{'rank': '7', 'face_up': True},
{'rank': '5', 'face_up': True},
{'rank': '?', 'face_up': False},
{'rank': '9', 'face_up': True},
{'rank': '?', 'face_up': False},
{'rank': '?', 'face_up': False},
]
def test_discarding_joker_is_blunder(self):
"""Discarding a Joker is a severe blunder."""
result = self.evaluator.evaluate_swap(
drawn_rank='',
hand=self.hand,
swapped=False,
swap_position=None,
was_from_discard=False
)
assert result.quality == DecisionQuality.BLUNDER
def test_discarding_2_is_blunder(self):
"""Discarding a 2 is a severe blunder."""
result = self.evaluator.evaluate_swap(
drawn_rank='2',
hand=self.hand,
swapped=False,
swap_position=None,
was_from_discard=False
)
assert result.quality == DecisionQuality.BLUNDER
def test_discarding_king_is_mistake(self):
"""Discarding a King is a mistake."""
result = self.evaluator.evaluate_swap(
drawn_rank='K',
hand=self.hand,
swapped=False,
swap_position=None,
was_from_discard=False
)
assert result.quality == DecisionQuality.MISTAKE
def test_discarding_queen_is_optimal(self):
"""Discarding a Queen is optimal."""
result = self.evaluator.evaluate_swap(
drawn_rank='Q',
hand=self.hand,
swapped=False,
swap_position=None,
was_from_discard=False
)
assert result.quality == DecisionQuality.OPTIMAL
def test_swap_good_for_bad_is_optimal(self):
"""Swapping a good card for a bad card is optimal."""
# Swap King (0) for 9 (9 points)
result = self.evaluator.evaluate_swap(
drawn_rank='K',
hand=self.hand,
swapped=True,
swap_position=3, # Position of the 9
was_from_discard=False
)
assert result.quality == DecisionQuality.OPTIMAL
assert result.expected_value > 0
def test_swap_bad_for_good_is_mistake(self):
"""Swapping a bad card for a good card is a mistake."""
# Swap 9 for 5
hand_with_known = [
{'rank': '7', 'face_up': True},
{'rank': '5', 'face_up': True},
{'rank': 'K', 'face_up': True}, # Good card
{'rank': '9', 'face_up': True},
{'rank': '?', 'face_up': False},
{'rank': '?', 'face_up': False},
]
result = self.evaluator.evaluate_swap(
drawn_rank='9',
hand=hand_with_known,
swapped=True,
swap_position=2, # Position of King
was_from_discard=False
)
assert result.quality == DecisionQuality.MISTAKE
assert result.expected_value < 0
def test_swap_into_facedown_with_good_card(self):
"""Swapping a good card into face-down position is optimal."""
result = self.evaluator.evaluate_swap(
drawn_rank='K',
hand=self.hand,
swapped=True,
swap_position=2, # Face-down position
was_from_discard=False
)
assert result.quality == DecisionQuality.OPTIMAL
def test_must_swap_from_discard(self):
"""Failing to swap when drawing from discard is invalid."""
result = self.evaluator.evaluate_swap(
drawn_rank='5',
hand=self.hand,
swapped=False,
swap_position=None,
was_from_discard=True
)
assert result.quality == DecisionQuality.BLUNDER
# =============================================================================
# House Rules Evaluation Tests
# =============================================================================
class TestHouseRulesEvaluation:
"""Test that house rules affect evaluation correctly."""
def test_lucky_swing_joker_more_valuable(self):
"""With lucky_swing, Joker is worth -5, so discarding is worse."""
evaluator = DecisionEvaluator({'lucky_swing': True})
hand = [{'rank': '5', 'face_up': True}] * 6
result = evaluator.evaluate_swap(
drawn_rank='',
hand=hand,
swapped=False,
swap_position=None,
was_from_discard=False
)
assert result.quality == DecisionQuality.BLUNDER
# EV loss should be higher with lucky_swing
assert result.expected_value > 5
def test_super_kings_more_valuable(self):
"""With super_kings, King is -2, so not taking is worse."""
evaluator = DecisionEvaluator({'super_kings': True})
hand = [{'rank': '5', 'face_up': True}] * 6
result = evaluator.evaluate_take_discard('K', hand, took_discard=False)
# King is now "excellent" tier
assert result.quality in (DecisionQuality.MISTAKE, DecisionQuality.BLUNDER)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

582
server/test_game.py Normal file
View File

@ -0,0 +1,582 @@
"""
Test suite for 6-Card Golf game rules.
Verifies our implementation matches canonical 6-Card Golf rules:
- Card values (A=1, 2=-2, 3-10=face, J/Q=10, K=0)
- Column pairing (matching ranks in column = 0 points)
- Draw/discard mechanics
- Cannot re-discard card taken from discard pile
- Round end conditions
- Final turn logic
Run with: pytest test_game.py -v
"""
import pytest
from game import (
Card, Deck, Player, Game, GamePhase, GameOptions,
Suit, Rank, RANK_VALUES
)
# =============================================================================
# Card Value Tests
# =============================================================================
class TestCardValues:
"""Verify card values match standard 6-Card Golf rules."""
def test_ace_worth_1(self):
assert RANK_VALUES[Rank.ACE] == 1
def test_two_worth_negative_2(self):
assert RANK_VALUES[Rank.TWO] == -2
def test_three_through_ten_face_value(self):
assert RANK_VALUES[Rank.THREE] == 3
assert RANK_VALUES[Rank.FOUR] == 4
assert RANK_VALUES[Rank.FIVE] == 5
assert RANK_VALUES[Rank.SIX] == 6
assert RANK_VALUES[Rank.SEVEN] == 7
assert RANK_VALUES[Rank.EIGHT] == 8
assert RANK_VALUES[Rank.NINE] == 9
assert RANK_VALUES[Rank.TEN] == 10
def test_jack_worth_10(self):
assert RANK_VALUES[Rank.JACK] == 10
def test_queen_worth_10(self):
assert RANK_VALUES[Rank.QUEEN] == 10
def test_king_worth_0(self):
assert RANK_VALUES[Rank.KING] == 0
def test_joker_worth_negative_2(self):
assert RANK_VALUES[Rank.JOKER] == -2
def test_card_value_method(self):
"""Card.value() should return correct value."""
card = Card(Suit.HEARTS, Rank.KING)
assert card.value() == 0
card = Card(Suit.SPADES, Rank.TWO)
assert card.value() == -2
# =============================================================================
# Column Pairing Tests
# =============================================================================
class TestColumnPairing:
"""Verify column pair scoring rules."""
def setup_method(self):
"""Create a player with controllable hand."""
self.player = Player(id="test", name="Test")
def set_hand(self, ranks: list[Rank]):
"""Set player's hand to specific ranks (all hearts for simplicity)."""
self.player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
def test_matching_column_scores_zero(self):
"""Two cards of same rank in column = 0 points for that column."""
# Layout: [K, 5, 7]
# [K, 3, 9]
# Column 0 (K-K) = 0, Column 1 (5+3) = 8, Column 2 (7+9) = 16
self.set_hand([Rank.KING, Rank.FIVE, Rank.SEVEN,
Rank.KING, Rank.THREE, Rank.NINE])
score = self.player.calculate_score()
assert score == 24 # 0 + 8 + 16
def test_all_columns_matched(self):
"""All three columns matched = 0 total."""
self.set_hand([Rank.ACE, Rank.FIVE, Rank.KING,
Rank.ACE, Rank.FIVE, Rank.KING])
score = self.player.calculate_score()
assert score == 0
def test_no_columns_matched(self):
"""No matches = sum of all cards."""
# A(1) + 3 + 5 + 7 + 9 + K(0) = 25
self.set_hand([Rank.ACE, Rank.THREE, Rank.FIVE,
Rank.SEVEN, Rank.NINE, Rank.KING])
score = self.player.calculate_score()
assert score == 25
def test_twos_pair_still_zero(self):
"""Paired 2s score 0, not -4 (pair cancels, doesn't double)."""
# [2, 5, 5]
# [2, 5, 5] = all columns matched = 0
self.set_hand([Rank.TWO, Rank.FIVE, Rank.FIVE,
Rank.TWO, Rank.FIVE, Rank.FIVE])
score = self.player.calculate_score()
assert score == 0
def test_negative_cards_unpaired_keep_value(self):
"""Unpaired 2s and Jokers contribute their negative value."""
# [2, K, K]
# [A, K, K] = -2 + 1 + 0 + 0 = -1
self.set_hand([Rank.TWO, Rank.KING, Rank.KING,
Rank.ACE, Rank.KING, Rank.KING])
score = self.player.calculate_score()
assert score == -1
# =============================================================================
# House Rules Scoring Tests
# =============================================================================
class TestHouseRulesScoring:
"""Verify house rule scoring modifiers."""
def setup_method(self):
self.player = Player(id="test", name="Test")
def set_hand(self, ranks: list[Rank]):
self.player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
def test_super_kings_negative_2(self):
"""With super_kings, Kings worth -2."""
options = GameOptions(super_kings=True)
self.set_hand([Rank.KING, Rank.ACE, Rank.ACE,
Rank.THREE, Rank.ACE, Rank.ACE])
score = self.player.calculate_score(options)
# K=-2, 3=3, columns 1&2 matched = 0
assert score == 1
def test_lucky_sevens_zero(self):
"""With lucky_sevens, 7s worth 0."""
options = GameOptions(lucky_sevens=True)
self.set_hand([Rank.SEVEN, Rank.ACE, Rank.ACE,
Rank.THREE, Rank.ACE, Rank.ACE])
score = self.player.calculate_score(options)
# 7=0, 3=3, columns 1&2 matched = 0
assert score == 3
def test_ten_penny(self):
"""With ten_penny, 10s worth 1."""
options = GameOptions(ten_penny=True)
self.set_hand([Rank.TEN, Rank.KING, Rank.KING,
Rank.ACE, Rank.KING, Rank.KING])
score = self.player.calculate_score(options)
# 10=1, A=1, columns 1&2 matched = 0
assert score == 2
def test_lucky_swing_joker(self):
"""With lucky_swing, single Joker worth -5."""
options = GameOptions(use_jokers=True, lucky_swing=True)
self.player.cards = [
Card(Suit.HEARTS, Rank.JOKER, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.ACE, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
]
score = self.player.calculate_score(options)
# Joker=-5, A=1, columns 1&2 matched = 0
assert score == -4
def test_blackjack_21_becomes_0(self):
"""With blackjack option, score of exactly 21 becomes 0."""
# This is applied at round end, not in calculate_score directly
# Testing the raw score first
self.set_hand([Rank.JACK, Rank.ACE, Rank.THREE,
Rank.FOUR, Rank.TWO, Rank.FIVE])
# J=10, A=1, 3=3, 4=4, 2=-2, 5=5 = 21
score = self.player.calculate_score()
assert score == 21
# =============================================================================
# Draw and Discard Mechanics
# =============================================================================
class TestDrawDiscardMechanics:
"""Verify draw/discard rules match standard Golf."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
# Skip initial flip phase to test draw/discard mechanics directly
self.game.start_game(options=GameOptions(initial_flips=0))
def test_can_draw_from_deck(self):
"""Player can draw from deck."""
card = self.game.draw_card("p1", "deck")
assert card is not None
assert self.game.drawn_card == card
assert self.game.drawn_from_discard is False
def test_can_draw_from_discard(self):
"""Player can draw from discard pile."""
discard_top = self.game.discard_top()
card = self.game.draw_card("p1", "discard")
assert card is not None
assert card == discard_top
assert self.game.drawn_card == card
assert self.game.drawn_from_discard is True
def test_can_discard_deck_draw(self):
"""Card drawn from deck CAN be discarded."""
self.game.draw_card("p1", "deck")
assert self.game.can_discard_drawn() is True
result = self.game.discard_drawn("p1")
assert result is True
def test_cannot_discard_discard_draw(self):
"""Card drawn from discard pile CANNOT be re-discarded."""
self.game.draw_card("p1", "discard")
assert self.game.can_discard_drawn() is False
result = self.game.discard_drawn("p1")
assert result is False
def test_must_swap_discard_draw(self):
"""When drawing from discard, must swap with a hand card."""
self.game.draw_card("p1", "discard")
# Can't discard, must swap
assert self.game.can_discard_drawn() is False
# Swap works
old_card = self.game.swap_card("p1", 0)
assert old_card is not None
assert self.game.drawn_card is None
def test_swap_makes_card_face_up(self):
"""Swapped card is placed face up."""
player = self.game.get_player("p1")
assert player.cards[0].face_up is False # Initially face down
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
assert player.cards[0].face_up is True
def test_cannot_peek_before_swap(self):
"""Face-down cards stay hidden until swapped/revealed."""
player = self.game.get_player("p1")
# Card is face down
assert player.cards[0].face_up is False
# to_dict doesn't reveal it
card_dict = player.cards[0].to_dict(reveal=False)
assert "rank" not in card_dict
# =============================================================================
# Turn Flow Tests
# =============================================================================
class TestTurnFlow:
"""Verify turn progression rules."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
self.game.add_player(Player(id="p3", name="Player 3"))
# Skip initial flip phase
self.game.start_game(options=GameOptions(initial_flips=0))
def test_turn_advances_after_discard(self):
"""Turn advances to next player after discarding."""
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.current_player().id == "p2"
def test_turn_advances_after_swap(self):
"""Turn advances to next player after swapping."""
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
assert self.game.current_player().id == "p2"
def test_turn_wraps_around(self):
"""Turn wraps from last player to first."""
# Complete turns for p1 and p2
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
assert self.game.current_player().id == "p3"
self.game.draw_card("p3", "deck")
self.game.discard_drawn("p3")
assert self.game.current_player().id == "p1" # Wrapped
def test_only_current_player_can_act(self):
"""Only current player can draw."""
assert self.game.current_player().id == "p1"
card = self.game.draw_card("p2", "deck") # Wrong player
assert card is None
# =============================================================================
# Round End Tests
# =============================================================================
class TestRoundEnd:
"""Verify round end conditions and final turn logic."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
self.game.start_game(options=GameOptions(initial_flips=0))
def reveal_all_cards(self, player_id: str):
"""Helper to flip all cards for a player."""
player = self.game.get_player(player_id)
for card in player.cards:
card.face_up = True
def test_revealing_all_triggers_final_turn(self):
"""When a player reveals all cards, final turn phase begins."""
# Reveal 5 cards for p1
player = self.game.get_player("p1")
for i in range(5):
player.cards[i].face_up = True
assert self.game.phase == GamePhase.PLAYING
# Draw and swap into last face-down position
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 5) # Last card
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.finisher_id == "p1"
def test_other_players_get_final_turn(self):
"""After one player finishes, others each get one more turn."""
# P1 reveals all
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.current_player().id == "p2"
# P2 takes final turn
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# Round should be over
assert self.game.phase == GamePhase.ROUND_OVER
def test_finisher_does_not_get_extra_turn(self):
"""The player who went out doesn't get another turn."""
# P1 reveals all and triggers final turn
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# P2's turn
assert self.game.current_player().id == "p2"
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# Should be round over, not p1's turn again
assert self.game.phase == GamePhase.ROUND_OVER
def test_all_cards_revealed_at_round_end(self):
"""At round end, all cards are revealed."""
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
assert self.game.phase == GamePhase.ROUND_OVER
# All cards should be face up now
for player in self.game.players:
assert all(card.face_up for card in player.cards)
# =============================================================================
# Multi-Round Tests
# =============================================================================
class TestMultiRound:
"""Verify multi-round game logic."""
def test_next_round_resets_hands(self):
"""Starting next round deals new hands."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(num_rounds=2, options=GameOptions(initial_flips=0))
# Force round end
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
old_cards_p1 = [c.rank for c in game.players[0].cards]
game.start_next_round()
# Cards should be different (statistically)
# and face down again
assert game.phase in (GamePhase.PLAYING, GamePhase.INITIAL_FLIP)
assert not all(game.players[0].cards[i].face_up for i in range(6))
def test_scores_accumulate_across_rounds(self):
"""Total scores persist across rounds."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(num_rounds=2, options=GameOptions(initial_flips=0))
# End round 1
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
round1_total = game.players[0].total_score
game.start_next_round()
# End round 2
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
# Total should have increased (or stayed same if score was 0)
assert game.players[0].total_score >= round1_total or game.players[0].score < 0
# =============================================================================
# Initial Flip Tests
# =============================================================================
class TestInitialFlip:
"""Verify initial flip phase mechanics."""
def test_initial_flip_two_cards(self):
"""With initial_flips=2, players must flip 2 cards."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=2))
assert game.phase == GamePhase.INITIAL_FLIP
# Try to flip wrong number
result = game.flip_initial_cards("p1", [0]) # Only 1
assert result is False
# Flip correct number
result = game.flip_initial_cards("p1", [0, 3])
assert result is True
def test_initial_flip_zero_skips_phase(self):
"""With initial_flips=0, skip straight to playing."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
assert game.phase == GamePhase.PLAYING
def test_game_starts_after_all_flip(self):
"""Game starts when all players have flipped."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=2))
game.flip_initial_cards("p1", [0, 1])
assert game.phase == GamePhase.INITIAL_FLIP # Still waiting
game.flip_initial_cards("p2", [2, 3])
assert game.phase == GamePhase.PLAYING # Now playing
# =============================================================================
# Deck Management Tests
# =============================================================================
class TestDeckManagement:
"""Verify deck initialization and reshuffling."""
def test_standard_deck_52_cards(self):
"""Standard deck has 52 cards."""
deck = Deck(num_decks=1, use_jokers=False)
assert deck.cards_remaining() == 52
def test_joker_deck_54_cards(self):
"""Deck with jokers has 54 cards."""
deck = Deck(num_decks=1, use_jokers=True)
assert deck.cards_remaining() == 54
def test_lucky_swing_single_joker(self):
"""Lucky swing adds only 1 joker total."""
deck = Deck(num_decks=1, use_jokers=True, lucky_swing=True)
assert deck.cards_remaining() == 53
def test_multi_deck(self):
"""Multiple decks multiply cards."""
deck = Deck(num_decks=2, use_jokers=False)
assert deck.cards_remaining() == 104
# =============================================================================
# Edge Cases
# =============================================================================
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
def test_cannot_draw_twice(self):
"""Cannot draw again before playing drawn card."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
second_draw = game.draw_card("p1", "deck")
assert second_draw is None
def test_swap_position_bounds(self):
"""Swap position must be 0-5."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
result = game.swap_card("p1", -1)
assert result is None
result = game.swap_card("p1", 6)
assert result is None
result = game.swap_card("p1", 3) # Valid
assert result is not None
def test_empty_discard_pile(self):
"""Cannot draw from empty discard pile."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
# Clear discard pile (normally has 1 card)
game.discard_pile = []
card = game.draw_card("p1", "discard")
assert card is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])

571
server/test_house_rules.py Normal file
View File

@ -0,0 +1,571 @@
"""
House Rules Testing Suite
Tests all house rule combinations to:
1. Find edge cases and bugs
2. Establish baseline performance metrics
3. Verify rules affect gameplay as expected
"""
import random
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional
from game import Game, Player, GamePhase, GameOptions
from ai import GolfAI, CPUProfile, CPU_PROFILES, get_ai_card_value
@dataclass
class RuleTestResult:
"""Results from testing a house rule configuration."""
name: str
options: GameOptions
games_played: int
scores: list[int]
turn_counts: list[int]
negative_scores: int # Count of scores < 0
zero_scores: int # Count of exactly 0
high_scores: int # Count of scores > 25
errors: list[str]
@property
def mean_score(self) -> float:
return sum(self.scores) / len(self.scores) if self.scores else 0
@property
def median_score(self) -> float:
if not self.scores:
return 0
s = sorted(self.scores)
n = len(s)
if n % 2 == 0:
return (s[n//2 - 1] + s[n//2]) / 2
return s[n//2]
@property
def mean_turns(self) -> float:
return sum(self.turn_counts) / len(self.turn_counts) if self.turn_counts else 0
@property
def min_score(self) -> int:
return min(self.scores) if self.scores else 0
@property
def max_score(self) -> int:
return max(self.scores) if self.scores else 0
def run_game_with_options(options: GameOptions, num_players: int = 4) -> tuple[list[int], int, Optional[str]]:
"""
Run a single game with given options.
Returns (scores, turn_count, error_message).
"""
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
try:
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 = 300 # Higher limit for edge cases
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:
# Deck exhausted - this is an edge case
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
if turn >= max_turns:
return [], turn, f"Game exceeded {max_turns} turns - possible infinite loop"
scores = [p.total_score for p in game.players]
return scores, turn, None
except Exception as e:
return [], 0, f"Exception: {str(e)}"
def test_rule_config(name: str, options: GameOptions, num_games: int = 50) -> RuleTestResult:
"""Test a specific rule configuration."""
all_scores = []
turn_counts = []
errors = []
negative_count = 0
zero_count = 0
high_count = 0
for _ in range(num_games):
scores, turns, error = run_game_with_options(options)
if error:
errors.append(error)
continue
all_scores.extend(scores)
turn_counts.append(turns)
for s in scores:
if s < 0:
negative_count += 1
elif s == 0:
zero_count += 1
elif s > 25:
high_count += 1
return RuleTestResult(
name=name,
options=options,
games_played=num_games,
scores=all_scores,
turn_counts=turn_counts,
negative_scores=negative_count,
zero_scores=zero_count,
high_scores=high_count,
errors=errors
)
# =============================================================================
# House Rule Configurations to Test
# =============================================================================
def get_test_configs() -> list[tuple[str, GameOptions]]:
"""Get all house rule configurations to test."""
configs = []
# Baseline (no house rules)
configs.append(("BASELINE", GameOptions(
initial_flips=2,
flip_on_discard=False,
use_jokers=False,
)))
# === Standard Options ===
configs.append(("flip_on_discard", GameOptions(
initial_flips=2,
flip_on_discard=True,
)))
configs.append(("initial_flips=0", GameOptions(
initial_flips=0,
flip_on_discard=False,
)))
configs.append(("initial_flips=1", GameOptions(
initial_flips=1,
flip_on_discard=False,
)))
configs.append(("knock_penalty", GameOptions(
initial_flips=2,
knock_penalty=True,
)))
configs.append(("use_jokers", GameOptions(
initial_flips=2,
use_jokers=True,
)))
# === Point Modifiers ===
configs.append(("lucky_swing", GameOptions(
initial_flips=2,
use_jokers=True,
lucky_swing=True,
)))
configs.append(("super_kings", GameOptions(
initial_flips=2,
super_kings=True,
)))
configs.append(("lucky_sevens", GameOptions(
initial_flips=2,
lucky_sevens=True,
)))
configs.append(("ten_penny", GameOptions(
initial_flips=2,
ten_penny=True,
)))
# === Bonuses/Penalties ===
configs.append(("knock_bonus", GameOptions(
initial_flips=2,
knock_bonus=True,
)))
configs.append(("underdog_bonus", GameOptions(
initial_flips=2,
underdog_bonus=True,
)))
configs.append(("tied_shame", GameOptions(
initial_flips=2,
tied_shame=True,
)))
configs.append(("blackjack", GameOptions(
initial_flips=2,
blackjack=True,
)))
# === Gameplay Twists ===
configs.append(("queens_wild", GameOptions(
initial_flips=2,
queens_wild=True,
)))
configs.append(("four_of_a_kind", GameOptions(
initial_flips=2,
four_of_a_kind=True,
)))
configs.append(("eagle_eye", GameOptions(
initial_flips=2,
use_jokers=True,
eagle_eye=True,
)))
# === Interesting Combinations ===
configs.append(("CHAOS (all point mods)", GameOptions(
initial_flips=2,
use_jokers=True,
lucky_swing=True,
super_kings=True,
lucky_sevens=True,
ten_penny=True,
)))
configs.append(("COMPETITIVE (penalties)", GameOptions(
initial_flips=2,
knock_penalty=True,
tied_shame=True,
)))
configs.append(("GENEROUS (bonuses)", GameOptions(
initial_flips=2,
knock_bonus=True,
underdog_bonus=True,
)))
configs.append(("WILD CARDS", GameOptions(
initial_flips=2,
use_jokers=True,
queens_wild=True,
four_of_a_kind=True,
eagle_eye=True,
)))
configs.append(("CLASSIC+ (jokers + flip)", GameOptions(
initial_flips=2,
flip_on_discard=True,
use_jokers=True,
)))
configs.append(("EVERYTHING", GameOptions(
initial_flips=2,
flip_on_discard=True,
knock_penalty=True,
use_jokers=True,
lucky_swing=True,
super_kings=True,
lucky_sevens=True,
ten_penny=True,
knock_bonus=True,
underdog_bonus=True,
tied_shame=True,
blackjack=True,
queens_wild=True,
four_of_a_kind=True,
eagle_eye=True,
)))
return configs
# =============================================================================
# Reporting
# =============================================================================
def print_results_table(results: list[RuleTestResult]):
"""Print a summary table of all results."""
print("\n" + "=" * 100)
print("HOUSE RULES TEST RESULTS")
print("=" * 100)
# Find baseline for comparison
baseline = next((r for r in results if r.name == "BASELINE"), results[0])
baseline_mean = baseline.mean_score
print(f"\n{'Rule Config':<25} {'Games':>6} {'Mean':>7} {'Med':>6} {'Min':>5} {'Max':>5} {'Turns':>6} {'Neg%':>6} {'Err':>4} {'vs Base':>8}")
print("-" * 100)
for r in results:
if not r.scores:
print(f"{r.name:<25} {'ERROR':>6} - no scores collected")
continue
neg_pct = r.negative_scores / len(r.scores) * 100 if r.scores else 0
diff = r.mean_score - baseline_mean
diff_str = f"{diff:+.1f}" if r.name != "BASELINE" else "---"
err_str = str(len(r.errors)) if r.errors else ""
print(f"{r.name:<25} {r.games_played:>6} {r.mean_score:>7.1f} {r.median_score:>6.1f} "
f"{r.min_score:>5} {r.max_score:>5} {r.mean_turns:>6.0f} {neg_pct:>5.1f}% {err_str:>4} {diff_str:>8}")
print("-" * 100)
def print_anomalies(results: list[RuleTestResult]):
"""Identify and print any anomalies or edge cases."""
print("\n" + "=" * 100)
print("ANOMALY DETECTION")
print("=" * 100)
baseline = next((r for r in results if r.name == "BASELINE"), results[0])
issues_found = False
for r in results:
issues = []
# Check for errors
if r.errors:
issues.append(f" ERRORS: {r.errors[:3]}") # Show first 3
# Check for extreme scores
if r.min_score < -15:
issues.append(f" Very low min score: {r.min_score} (possible scoring bug)")
if r.max_score > 60:
issues.append(f" Very high max score: {r.max_score} (possible stuck game)")
# Check for unusual turn counts
if r.mean_turns > 150:
issues.append(f" High turn count: {r.mean_turns:.0f} avg (games taking too long)")
if r.mean_turns < 20:
issues.append(f" Low turn count: {r.mean_turns:.0f} avg (games ending too fast)")
# Check for dramatic score shifts from baseline
if r.name != "BASELINE" and r.scores:
diff = r.mean_score - baseline.mean_score
if abs(diff) > 10:
issues.append(f" Large score shift from baseline: {diff:+.1f} points")
# Check for too many negative scores (unless expected)
neg_pct = r.negative_scores / len(r.scores) * 100 if r.scores else 0
if neg_pct > 20 and "super_kings" not in r.name.lower() and "lucky" not in r.name.lower():
issues.append(f" High negative score rate: {neg_pct:.1f}%")
if issues:
issues_found = True
print(f"\n{r.name}:")
for issue in issues:
print(issue)
if not issues_found:
print("\nNo anomalies detected - all configurations behaving as expected.")
def print_expected_effects(results: list[RuleTestResult]):
"""Verify house rules have expected effects."""
print("\n" + "=" * 100)
print("EXPECTED EFFECTS VERIFICATION")
print("=" * 100)
baseline = next((r for r in results if r.name == "BASELINE"), None)
if not baseline:
print("No baseline found!")
return
checks = []
# Find specific results
def find(name):
return next((r for r in results if r.name == name), None)
# super_kings should lower scores (Kings worth -2 instead of 0)
r = find("super_kings")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "LOWER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff < 0 else ""
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# lucky_sevens should lower scores (7s worth 0 instead of 7)
r = find("lucky_sevens")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "LOWER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff < 0 else ""
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# ten_penny should lower scores (10s worth 1 instead of 10)
r = find("ten_penny")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "LOWER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff < 0 else ""
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# use_jokers should lower scores (jokers are -2)
r = find("use_jokers")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "LOWER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff < 0 else "?" # Might be small effect
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# knock_bonus should lower scores (-5 for going out)
r = find("knock_bonus")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "LOWER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff < 0 else "?"
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# tied_shame should raise scores (+5 penalty for ties)
r = find("tied_shame")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "HIGHER scores"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff > 0 else "?"
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# flip_on_discard might slightly lower scores (more info)
r = find("flip_on_discard")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "SIMILAR or lower"
actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar"
status = "" if diff <= 1 else "?"
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
# CHAOS mode should have very low scores
r = find("CHAOS (all point mods)")
if r and r.scores:
diff = r.mean_score - baseline.mean_score
expected = "MUCH LOWER scores"
actual = "much lower" if diff < -5 else "lower" if diff < -1 else "similar"
status = "" if diff < -3 else ""
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
print(f"\n{'Rule':<30} {'Expected':<20} {'Actual':<20} {'Status'}")
print("-" * 80)
for name, expected, actual, status in checks:
print(f"{name:<30} {expected:<20} {actual:<20} {status}")
# =============================================================================
# Main
# =============================================================================
def main():
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 30
print(f"Testing house rules with {num_games} games each...")
print("This may take a few minutes...\n")
configs = get_test_configs()
results = []
for i, (name, options) in enumerate(configs):
print(f"[{i+1}/{len(configs)}] Testing: {name}...")
result = test_rule_config(name, options, num_games)
results.append(result)
# Quick status
if result.errors:
print(f" WARNING: {len(result.errors)} errors")
else:
print(f" Mean: {result.mean_score:.1f}, Turns: {result.mean_turns:.0f}")
# Reports
print_results_table(results)
print_expected_effects(results)
print_anomalies(results)
print("\n" + "=" * 100)
print("SUMMARY")
print("=" * 100)
total_games = sum(r.games_played for r in results)
total_errors = sum(len(r.errors) for r in results)
print(f"Total games run: {total_games}")
print(f"Total errors: {total_errors}")
if total_errors == 0:
print("All house rule configurations working correctly!")
if __name__ == "__main__":
main()

319
server/test_maya_bug.py Normal file
View 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"])