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:
parent
b4a661a801
commit
d18cea2104
165
README.md
Normal file
165
README.md
Normal 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
1044
client/app.js
Normal file
File diff suppressed because it is too large
Load Diff
264
client/index.html
Normal file
264
client/index.html
Normal 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
1210
client/style.css
Normal file
File diff suppressed because it is too large
Load Diff
190
server/RULES.md
Normal file
190
server/RULES.md
Normal 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
641
server/ai.py
Normal 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
609
server/game.py
Normal 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
649
server/game_analyzer.py
Normal 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
242
server/game_log.py
Normal 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
459
server/main.py
Normal 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
3
server/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
websockets==12.0
|
||||||
155
server/room.py
Normal file
155
server/room.py
Normal 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
349
server/score_analysis.py
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
"""
|
||||||
|
Score distribution analysis for Golf AI.
|
||||||
|
|
||||||
|
Generates box plots and statistics to verify AI plays reasonably.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from game import Game, Player, GamePhase, GameOptions
|
||||||
|
from ai import GolfAI, CPUProfile, CPU_PROFILES, get_ai_card_value
|
||||||
|
|
||||||
|
|
||||||
|
def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
|
||||||
|
"""Run a single game and return final scores by player name."""
|
||||||
|
|
||||||
|
# Pick random profiles
|
||||||
|
profiles = random.sample(CPU_PROFILES, min(num_players, len(CPU_PROFILES)))
|
||||||
|
|
||||||
|
game = Game()
|
||||||
|
player_profiles: dict[str, CPUProfile] = {}
|
||||||
|
|
||||||
|
for i, profile in enumerate(profiles):
|
||||||
|
player = Player(id=f"cpu_{i}", name=profile.name)
|
||||||
|
game.add_player(player)
|
||||||
|
player_profiles[player.id] = profile
|
||||||
|
|
||||||
|
options = GameOptions(initial_flips=2, flip_on_discard=False, use_jokers=False)
|
||||||
|
game.start_game(num_decks=1, num_rounds=1, options=options)
|
||||||
|
|
||||||
|
# Initial flips
|
||||||
|
for player in game.players:
|
||||||
|
positions = GolfAI.choose_initial_flips(options.initial_flips)
|
||||||
|
game.flip_initial_cards(player.id, positions)
|
||||||
|
|
||||||
|
# Play game
|
||||||
|
turn = 0
|
||||||
|
max_turns = 200
|
||||||
|
|
||||||
|
while game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN) and turn < max_turns:
|
||||||
|
current = game.current_player()
|
||||||
|
if not current:
|
||||||
|
break
|
||||||
|
|
||||||
|
profile = player_profiles[current.id]
|
||||||
|
|
||||||
|
# Draw
|
||||||
|
discard_top = game.discard_top()
|
||||||
|
take_discard = GolfAI.should_take_discard(discard_top, current, profile, game)
|
||||||
|
source = "discard" if take_discard else "deck"
|
||||||
|
drawn = game.draw_card(current.id, source)
|
||||||
|
|
||||||
|
if not drawn:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Swap or discard
|
||||||
|
swap_pos = GolfAI.choose_swap_or_discard(drawn, current, profile, game)
|
||||||
|
|
||||||
|
if swap_pos is None and game.drawn_from_discard:
|
||||||
|
face_down = [i for i, c in enumerate(current.cards) if not c.face_up]
|
||||||
|
if face_down:
|
||||||
|
swap_pos = random.choice(face_down)
|
||||||
|
else:
|
||||||
|
worst_pos = 0
|
||||||
|
worst_val = -999
|
||||||
|
for i, c in enumerate(current.cards):
|
||||||
|
card_val = get_ai_card_value(c, game.options)
|
||||||
|
if card_val > worst_val:
|
||||||
|
worst_val = card_val
|
||||||
|
worst_pos = i
|
||||||
|
swap_pos = worst_pos
|
||||||
|
|
||||||
|
if swap_pos is not None:
|
||||||
|
game.swap_card(current.id, swap_pos)
|
||||||
|
else:
|
||||||
|
game.discard_drawn(current.id)
|
||||||
|
if game.flip_on_discard:
|
||||||
|
flip_pos = GolfAI.choose_flip_after_discard(current, profile)
|
||||||
|
game.flip_and_end_turn(current.id, flip_pos)
|
||||||
|
|
||||||
|
turn += 1
|
||||||
|
|
||||||
|
# Return scores
|
||||||
|
return {p.name: p.total_score for p in game.players}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_scores(num_games: int = 100, num_players: int = 4) -> dict[str, list[int]]:
|
||||||
|
"""Run multiple games and collect all scores by player."""
|
||||||
|
|
||||||
|
all_scores: dict[str, list[int]] = defaultdict(list)
|
||||||
|
|
||||||
|
print(f"Running {num_games} games with {num_players} players each...")
|
||||||
|
|
||||||
|
for i in range(num_games):
|
||||||
|
if (i + 1) % 20 == 0:
|
||||||
|
print(f" {i + 1}/{num_games} games completed")
|
||||||
|
|
||||||
|
scores = run_game_for_scores(num_players)
|
||||||
|
for name, score in scores.items():
|
||||||
|
all_scores[name].append(score)
|
||||||
|
|
||||||
|
return dict(all_scores)
|
||||||
|
|
||||||
|
|
||||||
|
def print_statistics(all_scores: dict[str, list[int]]):
|
||||||
|
"""Print statistical summary."""
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SCORE STATISTICS BY PLAYER")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Combine all scores
|
||||||
|
combined = []
|
||||||
|
for scores in all_scores.values():
|
||||||
|
combined.extend(scores)
|
||||||
|
|
||||||
|
combined.sort()
|
||||||
|
|
||||||
|
def percentile(data, p):
|
||||||
|
k = (len(data) - 1) * p / 100
|
||||||
|
f = int(k)
|
||||||
|
c = f + 1 if f + 1 < len(data) else f
|
||||||
|
return data[f] + (k - f) * (data[c] - data[f])
|
||||||
|
|
||||||
|
def stats(data):
|
||||||
|
data = sorted(data)
|
||||||
|
n = len(data)
|
||||||
|
mean = sum(data) / n
|
||||||
|
q1 = percentile(data, 25)
|
||||||
|
median = percentile(data, 50)
|
||||||
|
q3 = percentile(data, 75)
|
||||||
|
return {
|
||||||
|
'n': n,
|
||||||
|
'min': min(data),
|
||||||
|
'q1': q1,
|
||||||
|
'median': median,
|
||||||
|
'q3': q3,
|
||||||
|
'max': max(data),
|
||||||
|
'mean': mean,
|
||||||
|
'iqr': q3 - q1
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"\n{'Player':<12} {'N':>5} {'Min':>6} {'Q1':>6} {'Med':>6} {'Q3':>6} {'Max':>6} {'Mean':>7}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
for name in sorted(all_scores.keys()):
|
||||||
|
s = stats(all_scores[name])
|
||||||
|
print(f"{name:<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
|
||||||
|
|
||||||
|
print("-" * 60)
|
||||||
|
s = stats(combined)
|
||||||
|
print(f"{'OVERALL':<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
|
||||||
|
|
||||||
|
print(f"\nInterquartile Range (IQR): {s['iqr']:.1f}")
|
||||||
|
print(f"Typical score range: {s['q1']:.0f} to {s['q3']:.0f}")
|
||||||
|
|
||||||
|
# Score distribution buckets
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SCORE DISTRIBUTION")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
buckets = defaultdict(int)
|
||||||
|
for score in combined:
|
||||||
|
if score < -5:
|
||||||
|
bucket = "< -5"
|
||||||
|
elif score < 0:
|
||||||
|
bucket = "-5 to -1"
|
||||||
|
elif score < 5:
|
||||||
|
bucket = "0 to 4"
|
||||||
|
elif score < 10:
|
||||||
|
bucket = "5 to 9"
|
||||||
|
elif score < 15:
|
||||||
|
bucket = "10 to 14"
|
||||||
|
elif score < 20:
|
||||||
|
bucket = "15 to 19"
|
||||||
|
elif score < 25:
|
||||||
|
bucket = "20 to 24"
|
||||||
|
else:
|
||||||
|
bucket = "25+"
|
||||||
|
buckets[bucket] += 1
|
||||||
|
|
||||||
|
bucket_order = ["< -5", "-5 to -1", "0 to 4", "5 to 9", "10 to 14", "15 to 19", "20 to 24", "25+"]
|
||||||
|
|
||||||
|
total = len(combined)
|
||||||
|
for bucket in bucket_order:
|
||||||
|
count = buckets.get(bucket, 0)
|
||||||
|
pct = count / total * 100
|
||||||
|
bar = "#" * int(pct / 2)
|
||||||
|
print(f"{bucket:>10}: {count:>4} ({pct:>5.1f}%) {bar}")
|
||||||
|
|
||||||
|
return stats(combined)
|
||||||
|
|
||||||
|
|
||||||
|
def create_box_plot(all_scores: dict[str, list[int]], output_file: str = "score_distribution.png"):
|
||||||
|
"""Create a box plot visualization."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # Non-interactive backend
|
||||||
|
except ImportError:
|
||||||
|
print("\nMatplotlib not installed. Install with: pip install matplotlib")
|
||||||
|
print("Skipping box plot generation.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Prepare data
|
||||||
|
names = sorted(all_scores.keys())
|
||||||
|
data = [all_scores[name] for name in names]
|
||||||
|
|
||||||
|
# Also add combined data
|
||||||
|
combined = []
|
||||||
|
for scores in all_scores.values():
|
||||||
|
combined.extend(scores)
|
||||||
|
names.append("ALL")
|
||||||
|
data.append(combined)
|
||||||
|
|
||||||
|
# Create figure
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 6))
|
||||||
|
|
||||||
|
# Box plot
|
||||||
|
bp = ax.boxplot(data, labels=names, patch_artist=True)
|
||||||
|
|
||||||
|
# Color boxes
|
||||||
|
colors = ['#FF9999', '#99FF99', '#9999FF', '#FFFF99',
|
||||||
|
'#FF99FF', '#99FFFF', '#FFB366', '#B366FF', '#CCCCCC']
|
||||||
|
for patch, color in zip(bp['boxes'], colors[:len(bp['boxes'])]):
|
||||||
|
patch.set_facecolor(color)
|
||||||
|
patch.set_alpha(0.7)
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
ax.set_xlabel('Player (AI Personality)', fontsize=12)
|
||||||
|
ax.set_ylabel('Round Score (lower is better)', fontsize=12)
|
||||||
|
ax.set_title('6-Card Golf AI Score Distribution', fontsize=14)
|
||||||
|
|
||||||
|
# Add horizontal line at 0
|
||||||
|
ax.axhline(y=0, color='green', linestyle='--', alpha=0.5, label='Zero (par)')
|
||||||
|
|
||||||
|
# Add reference lines
|
||||||
|
ax.axhline(y=10, color='orange', linestyle=':', alpha=0.5, label='Good (10)')
|
||||||
|
ax.axhline(y=20, color='red', linestyle=':', alpha=0.5, label='Poor (20)')
|
||||||
|
|
||||||
|
ax.legend(loc='upper right')
|
||||||
|
ax.grid(axis='y', alpha=0.3)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(output_file, dpi=150)
|
||||||
|
print(f"\nBox plot saved to: {output_file}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_ascii_box_plot(all_scores: dict[str, list[int]]):
|
||||||
|
"""Create an ASCII box plot for terminal display."""
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("ASCII BOX PLOT (Score Distribution)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
def percentile(data, p):
|
||||||
|
data = sorted(data)
|
||||||
|
k = (len(data) - 1) * p / 100
|
||||||
|
f = int(k)
|
||||||
|
c = f + 1 if f + 1 < len(data) else f
|
||||||
|
return data[f] + (k - f) * (data[c] - data[f])
|
||||||
|
|
||||||
|
# Find global min/max for scaling
|
||||||
|
all_vals = []
|
||||||
|
for scores in all_scores.values():
|
||||||
|
all_vals.extend(scores)
|
||||||
|
|
||||||
|
global_min = min(all_vals)
|
||||||
|
global_max = max(all_vals)
|
||||||
|
|
||||||
|
# Scale to 50 characters
|
||||||
|
width = 50
|
||||||
|
|
||||||
|
def scale(val):
|
||||||
|
if global_max == global_min:
|
||||||
|
return width // 2
|
||||||
|
return int((val - global_min) / (global_max - global_min) * (width - 1))
|
||||||
|
|
||||||
|
# Print scale
|
||||||
|
print(f"\n{' ' * 12} {global_min:<6} {'':^{width-12}} {global_max:>6}")
|
||||||
|
print(f"{' ' * 12} |{'-' * (width - 2)}|")
|
||||||
|
|
||||||
|
# Add combined
|
||||||
|
combined = list(all_vals)
|
||||||
|
scores_to_plot = dict(all_scores)
|
||||||
|
scores_to_plot["COMBINED"] = combined
|
||||||
|
|
||||||
|
for name in sorted(scores_to_plot.keys()):
|
||||||
|
scores = scores_to_plot[name]
|
||||||
|
|
||||||
|
q1 = percentile(scores, 25)
|
||||||
|
med = percentile(scores, 50)
|
||||||
|
q3 = percentile(scores, 75)
|
||||||
|
min_val = min(scores)
|
||||||
|
max_val = max(scores)
|
||||||
|
|
||||||
|
# Build the line
|
||||||
|
line = [' '] * width
|
||||||
|
|
||||||
|
# Whiskers
|
||||||
|
min_pos = scale(min_val)
|
||||||
|
max_pos = scale(max_val)
|
||||||
|
q1_pos = scale(q1)
|
||||||
|
q3_pos = scale(q3)
|
||||||
|
med_pos = scale(med)
|
||||||
|
|
||||||
|
# Left whisker
|
||||||
|
line[min_pos] = '|'
|
||||||
|
for i in range(min_pos + 1, q1_pos):
|
||||||
|
line[i] = '-'
|
||||||
|
|
||||||
|
# Box
|
||||||
|
for i in range(q1_pos, q3_pos + 1):
|
||||||
|
line[i] = '='
|
||||||
|
|
||||||
|
# Median
|
||||||
|
line[med_pos] = '|'
|
||||||
|
|
||||||
|
# Right whisker
|
||||||
|
for i in range(q3_pos + 1, max_pos):
|
||||||
|
line[i] = '-'
|
||||||
|
line[max_pos] = '|'
|
||||||
|
|
||||||
|
print(f"{name:>11} {''.join(line)}")
|
||||||
|
|
||||||
|
print(f"\n Legend: |---[===|===]---| = min--Q1--median--Q3--max")
|
||||||
|
print(f" Lower scores are better (left side of plot)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 100
|
||||||
|
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
|
||||||
|
|
||||||
|
# Collect scores
|
||||||
|
all_scores = collect_scores(num_games, num_players)
|
||||||
|
|
||||||
|
# Print statistics
|
||||||
|
print_statistics(all_scores)
|
||||||
|
|
||||||
|
# ASCII box plot (always works)
|
||||||
|
create_ascii_box_plot(all_scores)
|
||||||
|
|
||||||
|
# Try matplotlib box plot
|
||||||
|
create_box_plot(all_scores)
|
||||||
435
server/simulate.py
Normal file
435
server/simulate.py
Normal 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
299
server/test_analyzer.py
Normal 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
582
server/test_game.py
Normal 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
571
server/test_house_rules.py
Normal 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
319
server/test_maya_bug.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Test for the original Maya bug:
|
||||||
|
|
||||||
|
Maya took a 10 from discard and had to discard an Ace.
|
||||||
|
|
||||||
|
Bug chain:
|
||||||
|
1. should_take_discard() incorrectly decided to take the 10
|
||||||
|
2. choose_swap_or_discard() correctly returned None (don't swap)
|
||||||
|
3. But drawing from discard FORCES a swap
|
||||||
|
4. The forced-swap fallback found the "worst" visible card
|
||||||
|
5. The Ace (value 1) was swapped out for the 10
|
||||||
|
|
||||||
|
This test verifies the fixes work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from game import Card, Player, Game, GameOptions, Suit, Rank
|
||||||
|
from ai import (
|
||||||
|
GolfAI, CPUProfile, CPU_PROFILES,
|
||||||
|
get_ai_card_value, has_worse_visible_card
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_maya_profile() -> CPUProfile:
|
||||||
|
"""Get Maya's profile."""
|
||||||
|
for p in CPU_PROFILES:
|
||||||
|
if p.name == "Maya":
|
||||||
|
return p
|
||||||
|
# Fallback - create Maya-like profile
|
||||||
|
return CPUProfile(
|
||||||
|
name="Maya",
|
||||||
|
style="Aggressive Closer",
|
||||||
|
swap_threshold=6,
|
||||||
|
pair_hope=0.4,
|
||||||
|
aggression=0.85,
|
||||||
|
unpredictability=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_game() -> Game:
|
||||||
|
"""Create a game in playing state."""
|
||||||
|
game = Game()
|
||||||
|
game.add_player(Player(id="maya", name="Maya"))
|
||||||
|
game.add_player(Player(id="other", name="Other"))
|
||||||
|
game.start_game(options=GameOptions(initial_flips=0))
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
class TestMayaBugFix:
|
||||||
|
"""Test that the original Maya bug is fixed."""
|
||||||
|
|
||||||
|
def test_maya_does_not_take_10_with_good_hand(self):
|
||||||
|
"""
|
||||||
|
Original bug: Maya took a 10 from discard when she had good cards.
|
||||||
|
|
||||||
|
Setup: Maya has visible Ace, King, 2 (all good cards)
|
||||||
|
Discard: 10
|
||||||
|
Expected: Maya should NOT take the 10
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
# Set up Maya's hand with good visible cards
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True), # Value 1
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True), # Value 0
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Value -2
|
||||||
|
Card(Suit.SPADES, Rank.FIVE, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SIX, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Put a 10 on discard
|
||||||
|
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
|
||||||
|
game.discard_pile = [discard_10]
|
||||||
|
|
||||||
|
# Maya should NOT take the 10
|
||||||
|
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
|
||||||
|
|
||||||
|
assert should_take is False, (
|
||||||
|
"Maya should not take a 10 when her visible cards are Ace, King, 2"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_maya_does_not_take_10_even_with_unpredictability(self):
|
||||||
|
"""
|
||||||
|
The unpredictability trait should NOT cause taking bad cards.
|
||||||
|
|
||||||
|
Run multiple times to account for randomness.
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.FIVE, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SIX, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
|
||||||
|
game.discard_pile = [discard_10]
|
||||||
|
|
||||||
|
# Run 100 times - should NEVER take the 10
|
||||||
|
took_10_count = 0
|
||||||
|
for _ in range(100):
|
||||||
|
if GolfAI.should_take_discard(discard_10, maya, profile, game):
|
||||||
|
took_10_count += 1
|
||||||
|
|
||||||
|
assert took_10_count == 0, (
|
||||||
|
f"Maya took a 10 {took_10_count}/100 times despite having good cards. "
|
||||||
|
"Unpredictability should not override basic logic for bad cards."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_has_worse_visible_card_utility(self):
|
||||||
|
"""Test the utility function that guards against taking bad cards."""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
options = game.options
|
||||||
|
|
||||||
|
# Hand with good visible cards (Ace=1, King=0, 2=-2)
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True), # 0
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # -2
|
||||||
|
Card(Suit.SPADES, Rank.FIVE, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SIX, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
# No visible card is worse than 10 (value 10)
|
||||||
|
assert has_worse_visible_card(maya, 10, options) is False
|
||||||
|
|
||||||
|
# No visible card is worse than 5
|
||||||
|
assert has_worse_visible_card(maya, 5, options) is False
|
||||||
|
|
||||||
|
# Ace (1) is worse than 0
|
||||||
|
assert has_worse_visible_card(maya, 0, options) is True
|
||||||
|
|
||||||
|
def test_forced_swap_uses_house_rules(self):
|
||||||
|
"""
|
||||||
|
When forced to swap (drew from discard), the AI should use
|
||||||
|
get_ai_card_value() to find the worst card, not raw value().
|
||||||
|
|
||||||
|
This matters for house rules like super_kings, lucky_sevens, etc.
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
game.options = GameOptions(super_kings=True) # Kings now worth -2
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
|
||||||
|
# All face up - forced swap scenario
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True), # -2 with super_kings
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
|
||||||
|
Card(Suit.HEARTS, Rank.THREE, face_up=True), # 3 - worst!
|
||||||
|
Card(Suit.SPADES, Rank.KING, face_up=True), # -2 with super_kings
|
||||||
|
Card(Suit.SPADES, Rank.TWO, face_up=True), # -2
|
||||||
|
Card(Suit.SPADES, Rank.ACE, face_up=True), # 1
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find worst card using house rules
|
||||||
|
worst_pos = 0
|
||||||
|
worst_val = -999
|
||||||
|
for i, c in enumerate(maya.cards):
|
||||||
|
card_val = get_ai_card_value(c, game.options)
|
||||||
|
if card_val > worst_val:
|
||||||
|
worst_val = card_val
|
||||||
|
worst_pos = i
|
||||||
|
|
||||||
|
# Position 2 (Three, value 3) should be worst
|
||||||
|
assert worst_pos == 2, (
|
||||||
|
f"With super_kings, the Three (value 3) should be worst, "
|
||||||
|
f"not position {worst_pos} (value {worst_val})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_choose_swap_does_not_discard_excellent_cards(self):
|
||||||
|
"""
|
||||||
|
Unpredictability should NOT cause discarding excellent cards (2s, Jokers).
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.SEVEN, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.TEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Drew a 2 (excellent card, value -2)
|
||||||
|
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||||
|
|
||||||
|
# Run 100 times - should ALWAYS swap (never discard a 2)
|
||||||
|
discarded_count = 0
|
||||||
|
for _ in range(100):
|
||||||
|
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
|
||||||
|
if swap_pos is None:
|
||||||
|
discarded_count += 1
|
||||||
|
|
||||||
|
assert discarded_count == 0, (
|
||||||
|
f"Maya discarded a 2 (excellent card) {discarded_count}/100 times. "
|
||||||
|
"Unpredictability should not cause discarding excellent cards."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_scenario_maya_10_ace(self):
|
||||||
|
"""
|
||||||
|
Full reproduction of the original bug scenario.
|
||||||
|
|
||||||
|
Maya has: [A, K, 2, ?, ?, ?] (good visible cards)
|
||||||
|
Discard: 10
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
1. Maya should NOT take the 10
|
||||||
|
2. If she somehow did, she should swap into face-down, not replace the Ace
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
# Setup exactly like the bug report
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True), # Good - don't replace!
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True), # Good
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Excellent
|
||||||
|
Card(Suit.SPADES, Rank.JACK, face_up=False), # Unknown
|
||||||
|
Card(Suit.SPADES, Rank.QUEEN, face_up=False),# Unknown
|
||||||
|
Card(Suit.SPADES, Rank.TEN, face_up=False), # Unknown
|
||||||
|
]
|
||||||
|
|
||||||
|
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
|
||||||
|
game.discard_pile = [discard_10]
|
||||||
|
|
||||||
|
# Step 1: Maya should not take the 10
|
||||||
|
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
|
||||||
|
assert should_take is False, "Maya should not take a 10 with this hand"
|
||||||
|
|
||||||
|
# Step 2: Even if she did take it (simulating old bug), verify swap logic
|
||||||
|
# The swap logic should prefer face-down positions
|
||||||
|
drawn_10 = Card(Suit.CLUBS, Rank.TEN)
|
||||||
|
swap_pos = GolfAI.choose_swap_or_discard(drawn_10, maya, profile, game)
|
||||||
|
|
||||||
|
# Should either discard (None) or swap into face-down (positions 3, 4, 5)
|
||||||
|
# Should NEVER swap into position 0 (Ace), 1 (King), or 2 (Two)
|
||||||
|
if swap_pos is not None:
|
||||||
|
assert swap_pos >= 3, (
|
||||||
|
f"Maya tried to swap 10 into position {swap_pos}, replacing a good card. "
|
||||||
|
"Should only swap into face-down positions (3, 4, 5)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases related to the bug."""
|
||||||
|
|
||||||
|
def test_all_face_up_forced_swap_finds_actual_worst(self):
|
||||||
|
"""
|
||||||
|
When all cards are face up and forced to swap, find the ACTUAL worst card.
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
|
||||||
|
# All face up, varying values
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True), # 1
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True), # 0
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # -2
|
||||||
|
Card(Suit.SPADES, Rank.JACK, face_up=True), # 10 - WORST
|
||||||
|
Card(Suit.SPADES, Rank.THREE, face_up=True), # 3
|
||||||
|
Card(Suit.SPADES, Rank.FOUR, face_up=True), # 4
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find worst
|
||||||
|
worst_pos = 0
|
||||||
|
worst_val = -999
|
||||||
|
for i, c in enumerate(maya.cards):
|
||||||
|
card_val = get_ai_card_value(c, game.options)
|
||||||
|
if card_val > worst_val:
|
||||||
|
worst_val = card_val
|
||||||
|
worst_pos = i
|
||||||
|
|
||||||
|
assert worst_pos == 3, f"Jack (position 3, value 10) should be worst, got position {worst_pos}"
|
||||||
|
assert worst_val == 10, f"Worst value should be 10, got {worst_val}"
|
||||||
|
|
||||||
|
def test_take_discard_respects_pair_potential(self):
|
||||||
|
"""
|
||||||
|
Taking a bad card to complete a pair IS valid strategy.
|
||||||
|
This should still work after the bug fix.
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
# Maya has a visible 10 - taking another 10 to pair is GOOD
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.TEN, face_up=True), # Visible 10
|
||||||
|
Card(Suit.HEARTS, Rank.KING, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.ACE, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.FIVE, face_up=False), # Pair position for the 10
|
||||||
|
Card(Suit.SPADES, Rank.SIX, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 10 on discard - should take to pair!
|
||||||
|
discard_10 = Card(Suit.CLUBS, Rank.TEN, face_up=True)
|
||||||
|
game.discard_pile = [discard_10]
|
||||||
|
|
||||||
|
should_take = GolfAI.should_take_discard(discard_10, maya, profile, game)
|
||||||
|
assert should_take is True, (
|
||||||
|
"Maya SHOULD take a 10 when she has a visible 10 to pair with"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
Loading…
Reference in New Issue
Block a user