diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6cb9469
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/client/app.js b/client/app.js
new file mode 100644
index 0000000..df32033
--- /dev/null
+++ b/client/app.js
@@ -0,0 +1,1044 @@
+// Golf Card Game - Client Application
+
+class GolfGame {
+ constructor() {
+ this.ws = null;
+ this.playerId = null;
+ this.roomCode = null;
+ this.isHost = false;
+ this.gameState = null;
+ this.drawnCard = null;
+ this.selectedCards = [];
+ this.waitingForFlip = false;
+ this.currentPlayers = [];
+ this.allProfiles = [];
+ this.soundEnabled = true;
+ this.audioCtx = null;
+
+ this.initElements();
+ this.initAudio();
+ this.bindEvents();
+ }
+
+ initAudio() {
+ // Initialize audio context on first user interaction
+ const initCtx = () => {
+ if (!this.audioCtx) {
+ this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ document.removeEventListener('click', initCtx);
+ };
+ document.addEventListener('click', initCtx);
+ }
+
+ playSound(type = 'click') {
+ if (!this.soundEnabled || !this.audioCtx) return;
+
+ const ctx = this.audioCtx;
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ if (type === 'click') {
+ oscillator.frequency.setValueAtTime(600, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
+ gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.05);
+ } else if (type === 'card') {
+ oscillator.frequency.setValueAtTime(800, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08);
+ gainNode.gain.setValueAtTime(0.08, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.08);
+ } else if (type === 'success') {
+ oscillator.frequency.setValueAtTime(400, ctx.currentTime);
+ oscillator.frequency.setValueAtTime(600, ctx.currentTime + 0.1);
+ gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.2);
+ } else if (type === 'shuffle') {
+ // Multiple quick sounds to simulate shuffling
+ for (let i = 0; i < 8; i++) {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.type = 'square';
+ const time = ctx.currentTime + i * 0.06;
+ osc.frequency.setValueAtTime(200 + Math.random() * 400, time);
+ gain.gain.setValueAtTime(0.03, time);
+ gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
+ osc.start(time);
+ osc.stop(time + 0.05);
+ }
+ return; // Early return since we don't use the main oscillator
+ }
+ }
+
+ toggleSound() {
+ this.soundEnabled = !this.soundEnabled;
+ this.muteBtn.textContent = this.soundEnabled ? '🔊' : '🔇';
+ this.playSound('click');
+ }
+
+ initElements() {
+ // Screens
+ this.lobbyScreen = document.getElementById('lobby-screen');
+ this.waitingScreen = document.getElementById('waiting-screen');
+ this.gameScreen = document.getElementById('game-screen');
+
+ // Lobby elements
+ this.playerNameInput = document.getElementById('player-name');
+ this.roomCodeInput = document.getElementById('room-code');
+ this.createRoomBtn = document.getElementById('create-room-btn');
+ this.joinRoomBtn = document.getElementById('join-room-btn');
+ this.lobbyError = document.getElementById('lobby-error');
+
+ // Waiting room elements
+ this.displayRoomCode = document.getElementById('display-room-code');
+ this.playersList = document.getElementById('players-list');
+ this.hostSettings = document.getElementById('host-settings');
+ this.waitingMessage = document.getElementById('waiting-message');
+ this.numDecksSelect = document.getElementById('num-decks');
+ this.deckRecommendation = document.getElementById('deck-recommendation');
+ this.numRoundsSelect = document.getElementById('num-rounds');
+ this.initialFlipsSelect = document.getElementById('initial-flips');
+ this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard');
+ this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
+ this.jokerModeSelect = document.getElementById('joker-mode');
+ // House Rules - Point Modifiers
+ this.superKingsCheckbox = document.getElementById('super-kings');
+ this.luckySevensCheckbox = document.getElementById('lucky-sevens');
+ this.tenPennyCheckbox = document.getElementById('ten-penny');
+ // House Rules - Bonuses/Penalties
+ this.knockBonusCheckbox = document.getElementById('knock-bonus');
+ this.underdogBonusCheckbox = document.getElementById('underdog-bonus');
+ this.tiedShameCheckbox = document.getElementById('tied-shame');
+ this.blackjackCheckbox = document.getElementById('blackjack');
+ // House Rules - Gameplay Twists
+ this.queensWildCheckbox = document.getElementById('queens-wild');
+ this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind');
+ this.eagleEyeCheckbox = document.getElementById('eagle-eye');
+ this.eagleEyeLabel = document.getElementById('eagle-eye-label');
+ this.startGameBtn = document.getElementById('start-game-btn');
+ this.leaveRoomBtn = document.getElementById('leave-room-btn');
+ this.addCpuBtn = document.getElementById('add-cpu-btn');
+ this.removeCpuBtn = document.getElementById('remove-cpu-btn');
+ this.cpuSelectModal = document.getElementById('cpu-select-modal');
+ this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
+ this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
+ this.addSelectedCpusBtn = document.getElementById('add-selected-cpus-btn');
+
+ // Game elements
+ this.currentRoundSpan = document.getElementById('current-round');
+ this.totalRoundsSpan = document.getElementById('total-rounds');
+ this.deckCountSpan = document.getElementById('deck-count');
+ this.muteBtn = document.getElementById('mute-btn');
+ this.opponentsRow = document.getElementById('opponents-row');
+ this.deck = document.getElementById('deck');
+ this.discard = document.getElementById('discard');
+ this.discardContent = document.getElementById('discard-content');
+ this.drawnCardArea = document.getElementById('drawn-card-area');
+ this.drawnCardEl = document.getElementById('drawn-card');
+ this.discardBtn = document.getElementById('discard-btn');
+ this.playerCards = document.getElementById('player-cards');
+ this.flipPrompt = document.getElementById('flip-prompt');
+ this.toast = document.getElementById('toast');
+ this.scoreboard = document.getElementById('scoreboard');
+ this.scoreTable = document.getElementById('score-table').querySelector('tbody');
+ this.gameButtons = document.getElementById('game-buttons');
+ this.nextRoundBtn = document.getElementById('next-round-btn');
+ this.newGameBtn = document.getElementById('new-game-btn');
+ }
+
+ bindEvents() {
+ this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
+ this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
+ this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
+ this.leaveRoomBtn.addEventListener('click', () => { this.playSound('click'); this.leaveRoom(); });
+ this.deck.addEventListener('click', () => { this.playSound('card'); this.drawFromDeck(); });
+ this.discard.addEventListener('click', () => { this.playSound('card'); this.drawFromDiscard(); });
+ this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
+ this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
+ this.newGameBtn.addEventListener('click', () => { this.playSound('click'); this.newGame(); });
+ this.addCpuBtn.addEventListener('click', () => { this.playSound('click'); this.showCpuSelect(); });
+ this.removeCpuBtn.addEventListener('click', () => { this.playSound('click'); this.removeCpu(); });
+ this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
+ this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
+ this.muteBtn.addEventListener('click', () => this.toggleSound());
+
+ // Enter key handlers
+ this.playerNameInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.createRoomBtn.click();
+ });
+ this.roomCodeInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.joinRoomBtn.click();
+ });
+
+ // Auto-uppercase room code
+ this.roomCodeInput.addEventListener('input', (e) => {
+ e.target.value = e.target.value.toUpperCase();
+ });
+
+ // Eagle Eye only works with Standard Jokers (need 2 to pair them)
+ const updateEagleEyeVisibility = () => {
+ const isStandardJokers = this.jokerModeSelect.value === 'standard';
+ if (isStandardJokers) {
+ this.eagleEyeLabel.classList.remove('hidden');
+ } else {
+ this.eagleEyeLabel.classList.add('hidden');
+ this.eagleEyeCheckbox.checked = false;
+ }
+ };
+ this.jokerModeSelect.addEventListener('change', updateEagleEyeVisibility);
+ // Check initial state
+ updateEagleEyeVisibility();
+
+ // Update deck recommendation when deck selection changes
+ this.numDecksSelect.addEventListener('change', () => {
+ const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
+ this.updateDeckRecommendation(playerCount);
+ });
+
+ // Toggle scoreboard collapse on mobile
+ const scoreboardTitle = this.scoreboard.querySelector('h4');
+ if (scoreboardTitle) {
+ scoreboardTitle.addEventListener('click', () => {
+ if (window.innerWidth <= 700) {
+ this.scoreboard.classList.toggle('collapsed');
+ }
+ });
+ }
+ }
+
+ connect() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = window.location.host || 'localhost:8000';
+ const wsUrl = `${protocol}//${host}/ws`;
+
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log('Connected to server');
+ };
+
+ this.ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ this.handleMessage(data);
+ };
+
+ this.ws.onclose = () => {
+ console.log('Disconnected from server');
+ this.showError('Connection lost. Please refresh the page.');
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ this.showError('Connection error. Please try again.');
+ };
+ }
+
+ send(message) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(message));
+ }
+ }
+
+ handleMessage(data) {
+ console.log('Received:', data);
+
+ switch (data.type) {
+ case 'room_created':
+ this.playerId = data.player_id;
+ this.roomCode = data.room_code;
+ this.isHost = true;
+ this.showWaitingRoom();
+ break;
+
+ case 'room_joined':
+ this.playerId = data.player_id;
+ this.roomCode = data.room_code;
+ this.isHost = false;
+ this.showWaitingRoom();
+ break;
+
+ case 'player_joined':
+ this.updatePlayersList(data.players);
+ this.currentPlayers = data.players;
+ break;
+
+ case 'cpu_profiles':
+ this.allProfiles = data.profiles;
+ this.renderCpuSelect();
+ break;
+
+ case 'player_left':
+ this.updatePlayersList(data.players);
+ this.currentPlayers = data.players;
+ break;
+
+ case 'game_started':
+ case 'round_started':
+ this.gameState = data.game_state;
+ this.playSound('shuffle');
+ this.showGameScreen();
+ this.renderGame();
+ break;
+
+ case 'game_state':
+ this.gameState = data.game_state;
+ this.renderGame();
+ break;
+
+ case 'your_turn':
+ this.showToast('Your turn! Draw a card', 'your-turn');
+ break;
+
+ case 'card_drawn':
+ this.drawnCard = data.card;
+ this.showDrawnCard();
+ this.showToast('Swap with a card or discard', '', 3000);
+ break;
+
+ case 'can_flip':
+ this.waitingForFlip = true;
+ this.showToast('Flip a face-down card', '', 3000);
+ this.renderGame();
+ break;
+
+ case 'round_over':
+ this.showScoreboard(data.scores, false, data.rankings);
+ break;
+
+ case 'game_over':
+ this.showScoreboard(data.final_scores, true, data.rankings);
+ break;
+
+ case 'error':
+ this.showError(data.message);
+ break;
+ }
+ }
+
+ // Room Actions
+ createRoom() {
+ const name = this.playerNameInput.value.trim() || 'Player';
+ this.connect();
+ this.ws.onopen = () => {
+ this.send({ type: 'create_room', player_name: name });
+ };
+ }
+
+ joinRoom() {
+ const name = this.playerNameInput.value.trim() || 'Player';
+ const code = this.roomCodeInput.value.trim().toUpperCase();
+
+ if (code.length !== 4) {
+ this.showError('Please enter a 4-letter room code');
+ return;
+ }
+
+ this.connect();
+ this.ws.onopen = () => {
+ this.send({ type: 'join_room', room_code: code, player_name: name });
+ };
+ }
+
+ leaveRoom() {
+ this.send({ type: 'leave_room' });
+ this.ws.close();
+ this.showLobby();
+ }
+
+ startGame() {
+ const decks = parseInt(this.numDecksSelect.value);
+ const rounds = parseInt(this.numRoundsSelect.value);
+ const initial_flips = parseInt(this.initialFlipsSelect.value);
+
+ // Standard options
+ const flip_on_discard = this.flipOnDiscardCheckbox.checked;
+ const knock_penalty = this.knockPenaltyCheckbox.checked;
+
+ // Joker mode
+ const joker_mode = this.jokerModeSelect.value;
+ const use_jokers = joker_mode !== 'none';
+ const lucky_swing = joker_mode === 'lucky-swing';
+
+ // House Rules - Point Modifiers
+ const super_kings = this.superKingsCheckbox.checked;
+ const lucky_sevens = this.luckySevensCheckbox.checked;
+ const ten_penny = this.tenPennyCheckbox.checked;
+
+ // House Rules - Bonuses/Penalties
+ const knock_bonus = this.knockBonusCheckbox.checked;
+ const underdog_bonus = this.underdogBonusCheckbox.checked;
+ const tied_shame = this.tiedShameCheckbox.checked;
+ const blackjack = this.blackjackCheckbox.checked;
+
+ // House Rules - Gameplay Twists
+ const queens_wild = this.queensWildCheckbox.checked;
+ const four_of_a_kind = this.fourOfAKindCheckbox.checked;
+ const eagle_eye = this.eagleEyeCheckbox.checked;
+
+ this.send({
+ type: 'start_game',
+ decks,
+ rounds,
+ initial_flips,
+ flip_on_discard,
+ knock_penalty,
+ use_jokers,
+ lucky_swing,
+ super_kings,
+ lucky_sevens,
+ ten_penny,
+ knock_bonus,
+ underdog_bonus,
+ tied_shame,
+ blackjack,
+ queens_wild,
+ four_of_a_kind,
+ eagle_eye
+ });
+ }
+
+ showCpuSelect() {
+ // Request available profiles from server
+ this.selectedCpus = new Set();
+ this.send({ type: 'get_cpu_profiles' });
+ this.cpuSelectModal.classList.remove('hidden');
+ }
+
+ hideCpuSelect() {
+ this.cpuSelectModal.classList.add('hidden');
+ this.selectedCpus = new Set();
+ }
+
+ renderCpuSelect() {
+ if (!this.allProfiles) return;
+
+ // Get names of CPUs already in the game
+ const usedNames = new Set(
+ (this.currentPlayers || [])
+ .filter(p => p.is_cpu)
+ .map(p => p.name)
+ );
+
+ this.cpuProfilesGrid.innerHTML = '';
+
+ this.allProfiles.forEach(profile => {
+ const div = document.createElement('div');
+ const isUsed = usedNames.has(profile.name);
+ const isSelected = this.selectedCpus && this.selectedCpus.has(profile.name);
+ div.className = 'profile-card' + (isUsed ? ' unavailable' : '') + (isSelected ? ' selected' : '');
+
+ const avatar = this.getCpuAvatar(profile.name);
+ const checkbox = isUsed ? '' : `
${isSelected ? '✓' : ''}
`;
+
+ div.innerHTML = `
+ ${checkbox}
+ ${avatar}
+ ${profile.name}
+ ${profile.style}
+ ${isUsed ? 'In Game
' : ''}
+ `;
+
+ if (!isUsed) {
+ div.addEventListener('click', () => this.toggleCpuSelection(profile.name));
+ }
+
+ this.cpuProfilesGrid.appendChild(div);
+ });
+
+ this.updateAddCpuButton();
+ }
+
+ getCpuAvatar(name) {
+ const avatars = {
+ 'Sofia': ``,
+ 'Maya': ``,
+ 'Priya': ``,
+ 'Marcus': ``,
+ 'Kenji': ``,
+ 'Diego': ``,
+ 'River': ``,
+ 'Sage': ``
+ };
+ return avatars[name] || ``;
+ }
+
+ toggleCpuSelection(profileName) {
+ if (!this.selectedCpus) this.selectedCpus = new Set();
+
+ if (this.selectedCpus.has(profileName)) {
+ this.selectedCpus.delete(profileName);
+ } else {
+ this.selectedCpus.add(profileName);
+ }
+ this.renderCpuSelect();
+ }
+
+ updateAddCpuButton() {
+ const count = this.selectedCpus ? this.selectedCpus.size : 0;
+ this.addSelectedCpusBtn.textContent = count > 0 ? `Add ${count} CPU${count > 1 ? 's' : ''}` : 'Add';
+ this.addSelectedCpusBtn.disabled = count === 0;
+ }
+
+ addSelectedCpus() {
+ if (!this.selectedCpus || this.selectedCpus.size === 0) return;
+
+ this.selectedCpus.forEach(profileName => {
+ this.send({ type: 'add_cpu', profile_name: profileName });
+ });
+ this.hideCpuSelect();
+ }
+
+ removeCpu() {
+ this.send({ type: 'remove_cpu' });
+ }
+
+ // Game Actions
+ drawFromDeck() {
+ if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return;
+ if (this.gameState.waiting_for_initial_flip) return;
+ this.send({ type: 'draw', source: 'deck' });
+ }
+
+ drawFromDiscard() {
+ if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) return;
+ if (this.gameState.waiting_for_initial_flip) return;
+ if (!this.gameState.discard_top) return;
+ this.send({ type: 'draw', source: 'discard' });
+ }
+
+ discardDrawn() {
+ if (!this.drawnCard) return;
+ this.send({ type: 'discard' });
+ this.drawnCard = null;
+ this.hideDrawnCard();
+ this.hideToast();
+ }
+
+ swapCard(position) {
+ if (!this.drawnCard) return;
+ this.send({ type: 'swap', position });
+ this.drawnCard = null;
+ this.hideDrawnCard();
+ }
+
+ flipCard(position) {
+ this.send({ type: 'flip_card', position });
+ this.waitingForFlip = false;
+ }
+
+ handleCardClick(position) {
+ const myData = this.getMyPlayerData();
+ if (!myData) return;
+
+ const card = myData.cards[position];
+
+ // Initial flip phase
+ if (this.gameState.waiting_for_initial_flip) {
+ if (card.face_up) return;
+
+ this.playSound('card');
+ const requiredFlips = this.gameState.initial_flips || 2;
+
+ if (this.selectedCards.includes(position)) {
+ this.selectedCards = this.selectedCards.filter(p => p !== position);
+ } else {
+ this.selectedCards.push(position);
+ }
+
+ if (this.selectedCards.length === requiredFlips) {
+ this.send({ type: 'flip_initial', positions: this.selectedCards });
+ this.selectedCards = [];
+ this.hideToast();
+ } else {
+ const remaining = requiredFlips - this.selectedCards.length;
+ this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
+ }
+ this.renderGame();
+ return;
+ }
+
+ // Swap with drawn card
+ if (this.drawnCard) {
+ this.swapCard(position);
+ this.hideToast();
+ return;
+ }
+
+ // Flip after discarding from deck
+ if (this.waitingForFlip && !card.face_up) {
+ this.flipCard(position);
+ this.hideToast();
+ return;
+ }
+ }
+
+ nextRound() {
+ this.send({ type: 'next_round' });
+ this.gameButtons.classList.add('hidden');
+ }
+
+ newGame() {
+ this.leaveRoom();
+ }
+
+ // UI Helpers
+ showScreen(screen) {
+ this.lobbyScreen.classList.remove('active');
+ this.waitingScreen.classList.remove('active');
+ this.gameScreen.classList.remove('active');
+ screen.classList.add('active');
+ }
+
+ showLobby() {
+ this.showScreen(this.lobbyScreen);
+ this.lobbyError.textContent = '';
+ this.roomCode = null;
+ this.playerId = null;
+ this.isHost = false;
+ this.gameState = null;
+ }
+
+ showWaitingRoom() {
+ this.showScreen(this.waitingScreen);
+ this.displayRoomCode.textContent = this.roomCode;
+
+ if (this.isHost) {
+ this.hostSettings.classList.remove('hidden');
+ this.waitingMessage.classList.add('hidden');
+ } else {
+ this.hostSettings.classList.add('hidden');
+ this.waitingMessage.classList.remove('hidden');
+ }
+ }
+
+ showGameScreen() {
+ this.showScreen(this.gameScreen);
+ this.gameButtons.classList.add('hidden');
+ this.drawnCard = null;
+ this.selectedCards = [];
+ this.waitingForFlip = false;
+ }
+
+ showError(message) {
+ this.lobbyError.textContent = message;
+ }
+
+ updatePlayersList(players) {
+ this.playersList.innerHTML = '';
+ players.forEach(player => {
+ const li = document.createElement('li');
+ let badges = '';
+ if (player.is_host) badges += 'HOST';
+ if (player.is_cpu) badges += 'CPU';
+
+ let nameDisplay = player.name;
+ if (player.style) {
+ nameDisplay += ` (${player.style})`;
+ }
+
+ li.innerHTML = `
+ ${nameDisplay}
+ ${badges}
+ `;
+ if (player.id === this.playerId) {
+ li.style.background = 'rgba(244, 164, 96, 0.3)';
+ }
+ this.playersList.appendChild(li);
+
+ if (player.id === this.playerId && player.is_host) {
+ this.isHost = true;
+ this.hostSettings.classList.remove('hidden');
+ this.waitingMessage.classList.add('hidden');
+ }
+ });
+
+ // Auto-select 2 decks when reaching 4+ players (host only)
+ const prevCount = this.currentPlayers ? this.currentPlayers.length : 0;
+ if (this.isHost && prevCount < 4 && players.length >= 4) {
+ this.numDecksSelect.value = '2';
+ }
+
+ // Update deck recommendation visibility
+ this.updateDeckRecommendation(players.length);
+ }
+
+ updateDeckRecommendation(playerCount) {
+ if (!this.isHost || !this.deckRecommendation) return;
+
+ const decks = parseInt(this.numDecksSelect.value);
+ // Show recommendation if 4+ players and only 1 deck selected
+ if (playerCount >= 4 && decks < 2) {
+ this.deckRecommendation.classList.remove('hidden');
+ } else {
+ this.deckRecommendation.classList.add('hidden');
+ }
+ }
+
+ isMyTurn() {
+ return this.gameState && this.gameState.current_player_id === this.playerId;
+ }
+
+ getMyPlayerData() {
+ if (!this.gameState) return null;
+ return this.gameState.players.find(p => p.id === this.playerId);
+ }
+
+ showToast(message, type = '', duration = 2500) {
+ this.toast.textContent = message;
+ this.toast.className = 'toast' + (type ? ' ' + type : '');
+
+ clearTimeout(this.toastTimeout);
+ this.toastTimeout = setTimeout(() => {
+ this.toast.classList.add('hidden');
+ }, duration);
+ }
+
+ hideToast() {
+ this.toast.classList.add('hidden');
+ clearTimeout(this.toastTimeout);
+ }
+
+ showDrawnCard() {
+ this.drawnCardArea.classList.remove('hidden');
+ // Drawn card is always revealed to the player, so render directly
+ const card = this.drawnCard;
+ this.drawnCardEl.className = 'card card-front';
+
+ // Handle jokers specially
+ if (card.rank === '★') {
+ this.drawnCardEl.innerHTML = '★
JOKER';
+ this.drawnCardEl.classList.add('joker');
+ } else {
+ this.drawnCardEl.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
+ if (this.isRedSuit(card.suit)) {
+ this.drawnCardEl.classList.add('red');
+ } else {
+ this.drawnCardEl.classList.add('black');
+ }
+ }
+ }
+
+ hideDrawnCard() {
+ this.drawnCardArea.classList.add('hidden');
+ }
+
+ isRedSuit(suit) {
+ return suit === 'hearts' || suit === 'diamonds';
+ }
+
+ getSuitSymbol(suit) {
+ const symbols = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+ };
+ return symbols[suit] || '';
+ }
+
+ renderCardContent(card) {
+ if (!card || !card.face_up) return '';
+ // Jokers show star symbol without suit
+ if (card.rank === '★') {
+ return '★
JOKER';
+ }
+ return `${card.rank}
${this.getSuitSymbol(card.suit)}`;
+ }
+
+ renderGame() {
+ if (!this.gameState) return;
+
+ // Update header
+ this.currentRoundSpan.textContent = this.gameState.current_round;
+ this.totalRoundsSpan.textContent = this.gameState.total_rounds;
+ this.deckCountSpan.textContent = this.gameState.deck_remaining;
+
+ // Update discard pile
+ if (this.gameState.discard_top) {
+ const discardCard = this.gameState.discard_top;
+ this.discard.classList.add('has-card', 'card-front');
+ this.discard.classList.remove('card-back', 'red', 'black', 'joker');
+
+ if (discardCard.rank === '★') {
+ this.discard.classList.add('joker');
+ } else if (this.isRedSuit(discardCard.suit)) {
+ this.discard.classList.add('red');
+ } else {
+ this.discard.classList.add('black');
+ }
+ this.discardContent.innerHTML = this.renderCardContent(discardCard);
+ } else {
+ this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
+ this.discardContent.innerHTML = '';
+ }
+
+ // Update deck/discard clickability and visual state
+ const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
+ const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
+
+ this.deck.classList.toggle('clickable', canDraw);
+ this.deck.classList.toggle('disabled', hasDrawn);
+
+ this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
+ this.discard.classList.toggle('disabled', hasDrawn);
+
+ // Render opponents in a single row
+ const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
+
+ this.opponentsRow.innerHTML = '';
+
+ opponents.forEach((player) => {
+ const div = document.createElement('div');
+ div.className = 'opponent-area';
+ if (player.id === this.gameState.current_player_id) {
+ div.classList.add('current-turn');
+ }
+
+ const displayName = player.name.length > 8 ? player.name.substring(0, 7) + '…' : player.name;
+
+ div.innerHTML = `
+ ${displayName}${player.all_face_up ? ' ✓' : ''}
+
+ ${player.cards.map(card => this.renderCard(card, false, false)).join('')}
+
+ `;
+
+ this.opponentsRow.appendChild(div);
+ });
+
+ // Render player's cards
+ const myData = this.getMyPlayerData();
+ if (myData) {
+ this.playerCards.innerHTML = '';
+
+ myData.cards.forEach((card, index) => {
+ const isClickable = (
+ (this.gameState.waiting_for_initial_flip && !card.face_up) ||
+ (this.drawnCard) ||
+ (this.waitingForFlip && !card.face_up)
+ );
+ const isSelected = this.selectedCards.includes(index);
+
+ const cardEl = document.createElement('div');
+ cardEl.innerHTML = this.renderCard(card, isClickable, isSelected);
+ cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
+ this.playerCards.appendChild(cardEl.firstChild);
+ });
+ }
+
+ // Show flip prompt for initial flip
+ if (this.gameState.waiting_for_initial_flip) {
+ const requiredFlips = this.gameState.initial_flips || 2;
+ const remaining = requiredFlips - this.selectedCards.length;
+ if (remaining > 0) {
+ this.flipPrompt.textContent = `Select ${remaining} card${remaining > 1 ? 's' : ''} to flip`;
+ this.flipPrompt.classList.remove('hidden');
+ } else {
+ this.flipPrompt.classList.add('hidden');
+ }
+ } else {
+ this.flipPrompt.classList.add('hidden');
+ }
+
+ // Disable discard button if can't discard (must_swap_discard rule)
+ if (this.drawnCard && !this.gameState.can_discard) {
+ this.discardBtn.disabled = true;
+ this.discardBtn.classList.add('disabled');
+ } else {
+ this.discardBtn.disabled = false;
+ this.discardBtn.classList.remove('disabled');
+ }
+
+ // Update scoreboard panel
+ this.updateScorePanel();
+ }
+
+ updateScorePanel() {
+ if (!this.gameState) return;
+
+ this.scoreTable.innerHTML = '';
+
+ this.gameState.players.forEach(player => {
+ const tr = document.createElement('tr');
+
+ // Highlight current player
+ if (player.id === this.gameState.current_player_id) {
+ tr.classList.add('current-player');
+ }
+
+ // Truncate long names
+ const displayName = player.name.length > 10
+ ? player.name.substring(0, 9) + '…'
+ : player.name;
+
+ const roundScore = player.score !== null ? player.score : '-';
+ const roundsWon = player.rounds_won || 0;
+
+ tr.innerHTML = `
+ ${displayName} |
+ ${roundScore} |
+ ${player.total_score} |
+ ${roundsWon} |
+ `;
+ this.scoreTable.appendChild(tr);
+ });
+ }
+
+ renderCard(card, clickable, selected) {
+ let classes = 'card';
+ let content = '';
+
+ if (card.face_up) {
+ classes += ' card-front';
+ if (card.rank === '★') {
+ classes += ' joker';
+ } else if (this.isRedSuit(card.suit)) {
+ classes += ' red';
+ } else {
+ classes += ' black';
+ }
+ content = this.renderCardContent(card);
+ } else {
+ classes += ' card-back';
+ }
+
+ if (clickable) classes += ' clickable';
+ if (selected) classes += ' selected';
+
+ return `${content}
`;
+ }
+
+ showScoreboard(scores, isFinal, rankings) {
+ this.scoreTable.innerHTML = '';
+
+ const minScore = Math.min(...scores.map(s => s.total || s.score || 0));
+
+ scores.forEach(score => {
+ const tr = document.createElement('tr');
+ const total = score.total !== undefined ? score.total : score.score;
+ const roundScore = score.score !== undefined ? score.score : '-';
+ const roundsWon = score.rounds_won || 0;
+
+ // Truncate long names
+ const displayName = score.name.length > 10
+ ? score.name.substring(0, 9) + '…'
+ : score.name;
+
+ if (total === minScore) {
+ tr.classList.add('winner');
+ }
+
+ tr.innerHTML = `
+ ${displayName} |
+ ${roundScore} |
+ ${total} |
+ ${roundsWon} |
+ `;
+ this.scoreTable.appendChild(tr);
+ });
+
+ // Show rankings announcement
+ this.showRankingsAnnouncement(rankings, isFinal);
+
+ // Show game buttons
+ this.gameButtons.classList.remove('hidden');
+
+ if (isFinal) {
+ this.nextRoundBtn.classList.add('hidden');
+ this.newGameBtn.classList.remove('hidden');
+ } else if (this.isHost) {
+ this.nextRoundBtn.classList.remove('hidden');
+ this.newGameBtn.classList.add('hidden');
+ } else {
+ this.nextRoundBtn.classList.add('hidden');
+ this.newGameBtn.classList.add('hidden');
+ }
+ }
+
+ showRankingsAnnouncement(rankings, isFinal) {
+ // Remove existing announcement if any
+ const existing = document.getElementById('rankings-announcement');
+ if (existing) existing.remove();
+
+ if (!rankings) return;
+
+ const announcement = document.createElement('div');
+ announcement.id = 'rankings-announcement';
+ announcement.className = 'rankings-announcement';
+
+ const title = isFinal ? 'Final Results' : 'Current Standings';
+
+ // Check for double victory (same player leads both categories) - only at game end
+ const pointsLeader = rankings.by_points[0];
+ const holesLeader = rankings.by_holes_won[0];
+ const isDoubleVictory = isFinal && pointsLeader && holesLeader &&
+ pointsLeader.name === holesLeader.name &&
+ holesLeader.rounds_won > 0;
+
+ // Build points ranking (lowest wins) with tie handling
+ let pointsRank = 0;
+ let prevPoints = null;
+ const pointsHtml = rankings.by_points.map((p, i) => {
+ if (p.total !== prevPoints) {
+ pointsRank = i;
+ prevPoints = p.total;
+ }
+ const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`;
+ const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name;
+ return `${medal}${name}${p.total}pt
`;
+ }).join('');
+
+ // Build holes won ranking (most wins) with tie handling
+ let holesRank = 0;
+ let prevHoles = null;
+ const holesHtml = rankings.by_holes_won.map((p, i) => {
+ if (p.rounds_won !== prevHoles) {
+ holesRank = i;
+ prevHoles = p.rounds_won;
+ }
+ // No medal for 0 wins
+ const medal = p.rounds_won === 0 ? '-' :
+ holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`;
+ const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name;
+ return `${medal}${name}${p.rounds_won}W
`;
+ }).join('');
+
+ const doubleVictoryHtml = isDoubleVictory
+ ? `DOUBLE VICTORY! ${pointsLeader.name}
`
+ : '';
+
+ announcement.innerHTML = `
+ ${title}
+ ${doubleVictoryHtml}
+
+
+
Points (Low Wins)
+ ${pointsHtml}
+
+
+
Holes Won
+ ${holesHtml}
+
+
+ `;
+
+ // Insert before the scoreboard
+ this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild);
+ }
+}
+
+// Initialize game when page loads
+document.addEventListener('DOMContentLoaded', () => {
+ window.game = new GolfGame();
+});
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..37ce113
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,264 @@
+
+
+
+
+
+ Golf Card Game
+
+
+
+
+
+
+
🏌️ Golf
+
6-Card Golf Card Game
+
+
+
+
+
+
+
+
+
+
+
or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Room:
+
+
+
+
+
Game Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+ House Rules
+
+
+
Jokers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Waiting for host to start the game...
+
+
+
+
+
+
+
+
+
+
+
Scores
+
+
+
+ | Player |
+ Hole |
+ Tot |
+ W |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select CPU Opponents
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/style.css b/client/style.css
new file mode 100644
index 0000000..2d56e5a
--- /dev/null
+++ b/client/style.css
@@ -0,0 +1,1210 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background-color: #1a472a;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cg fill='%23224d32' fill-opacity='0.4'%3E%3Cpath d='M15 5c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2V5zm0 40c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2v-8z'/%3E%3Cpath d='M35 25c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2v-8z'/%3E%3Ccircle cx='10' cy='30' r='3'/%3E%3Ccircle cx='50' cy='10' r='3'/%3E%3Ccircle cx='50' cy='50' r='3'/%3E%3Cpath d='M30 18l3 5h-6l3-5zm0 24l3 5h-6l3-5z'/%3E%3C/g%3E%3C/svg%3E");
+ min-height: 100vh;
+ color: #fff;
+}
+
+#app {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 15px;
+ min-height: 100vh;
+}
+
+/* Game screen uses full width */
+#game-screen {
+ max-width: none;
+ width: 100vw;
+ margin-left: calc(-50vw + 50%);
+ padding: 0 15px;
+ box-sizing: border-box;
+}
+
+.screen {
+ display: none;
+}
+
+.screen.active {
+ display: block;
+}
+
+/* Lobby Screen */
+#lobby-screen {
+ max-width: 400px;
+ margin: 0 auto;
+ padding: 20px;
+ text-align: center;
+}
+
+#lobby-screen .form-group {
+ text-align: left;
+}
+
+#lobby-screen input {
+ text-align: center;
+ font-size: 1.1rem;
+}
+
+#lobby-screen #room-code {
+ text-transform: uppercase;
+ letter-spacing: 0.3em;
+ font-weight: 600;
+}
+
+/* Waiting Screen */
+#waiting-screen {
+ max-width: 500px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+
+h1 {
+ font-size: 3rem;
+ text-align: center;
+ margin-bottom: 10px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
+}
+
+.subtitle {
+ text-align: center;
+ opacity: 0.8;
+ margin-bottom: 40px;
+}
+
+h2 {
+ text-align: center;
+ margin-bottom: 20px;
+}
+
+h3 {
+ margin-bottom: 15px;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 5px;
+ font-weight: 500;
+}
+
+input, select {
+ width: 100%;
+ padding: 12px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ background: rgba(255,255,255,0.9);
+ color: #333;
+}
+
+input::placeholder {
+ color: #999;
+}
+
+.button-group {
+ margin-bottom: 20px;
+}
+
+.btn {
+ display: inline-block;
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+ width: 100%;
+}
+
+.btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn-primary {
+ background: #f4a460;
+ color: #1a472a;
+}
+
+.btn-secondary {
+ background: #fff;
+ color: #1a472a;
+}
+
+.btn-danger {
+ background: #c0392b;
+ color: #fff;
+}
+
+.btn-small {
+ padding: 8px 16px;
+ font-size: 0.9rem;
+ width: auto;
+}
+
+.btn.disabled,
+.btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.divider {
+ text-align: center;
+ margin: 30px 0;
+ opacity: 0.6;
+}
+
+.error {
+ color: #e74c3c;
+ text-align: center;
+ margin-top: 15px;
+}
+
+.info {
+ text-align: center;
+ opacity: 0.8;
+ margin: 20px 0;
+}
+
+.recommendation {
+ color: #f4a460;
+ font-size: 0.8rem;
+ margin-top: 6px;
+ padding: 6px 8px;
+ background: rgba(244, 164, 96, 0.15);
+ border-radius: 4px;
+ border-left: 3px solid #f4a460;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Players List */
+.players-list {
+ background: rgba(0,0,0,0.2);
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.players-list ul {
+ list-style: none;
+}
+
+.players-list li {
+ padding: 10px;
+ background: rgba(255,255,255,0.1);
+ border-radius: 6px;
+ margin-bottom: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.players-list .host-badge {
+ background: #f4a460;
+ color: #1a472a;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.players-list .cpu-badge {
+ background: #3498db;
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.cpu-style {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ font-style: italic;
+}
+
+.cpu-controls {
+ display: flex;
+ gap: 10px;
+}
+
+.checkbox-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+}
+
+/* Settings */
+.settings {
+ background: rgba(0,0,0,0.2);
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+/* Game Screen */
+.game-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 5px 12px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 6px;
+ font-size: 0.8rem;
+ width: calc(100vw - 30px);
+ max-width: 1400px;
+}
+
+.mute-btn {
+ background: transparent;
+ border: none;
+ font-size: 1.1rem;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ opacity: 0.8;
+ transition: opacity 0.2s;
+}
+
+.mute-btn:hover {
+ opacity: 1;
+ background: rgba(255,255,255,0.1);
+}
+
+/* Card Styles */
+.card {
+ width: clamp(60px, 6vw, 80px);
+ height: clamp(84px, 8.4vw, 112px);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: clamp(1.2rem, 1.4vw, 1.7rem);
+ font-weight: bold;
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+ position: relative;
+ user-select: none;
+}
+
+.card:hover {
+ transform: scale(1.05);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+
+.card-back {
+ background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
+ border: 3px solid #1a252f;
+ color: #fff;
+ font-size: 0.8rem;
+}
+
+.card-front {
+ background: #fff;
+ border: 2px solid #ddd;
+ color: #333;
+}
+
+.card-front.red {
+ color: #c0392b;
+}
+
+.card-front.black {
+ color: #2c3e50;
+}
+
+.card-front.joker {
+ color: #9b59b6;
+ font-size: 1.1rem;
+}
+
+.card.clickable {
+ cursor: pointer;
+ box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
+}
+
+.card.clickable:hover {
+ box-shadow: 0 0 0 3px #f4a460;
+}
+
+.card.selected,
+.card.clickable.selected {
+ box-shadow: 0 0 0 4px #fff, 0 0 12px 4px #f4a460;
+ transform: scale(1.08);
+}
+
+/* Card Grid */
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(3, clamp(60px, 6vw, 80px));
+ gap: clamp(6px, 0.8vw, 12px);
+ justify-content: center;
+}
+
+/* Game Table Layout */
+.game-table {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ width: 100%;
+}
+
+/* Player row - deck/discard and player cards side by side */
+.player-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 25px;
+ width: 100%;
+ flex-wrap: wrap;
+}
+
+.opponents-row {
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: flex-end;
+ gap: clamp(10px, 2vw, 30px);
+ min-height: clamp(100px, 12vw, 150px);
+ padding: 0 20px;
+ width: 100%;
+ max-width: calc(100vw - 240px); /* Leave room for scoreboard */
+}
+
+/* Arch layout - middle items higher, edges lower with rotation for "around the table" feel */
+.opponents-row .opponent-area {
+ flex-shrink: 0;
+ transition: transform 0.3s ease;
+}
+
+/* 2 opponents: slight rotation toward center */
+.opponents-row .opponent-area:first-child:nth-last-child(2) {
+ margin-bottom: 15px;
+ transform: rotate(-4deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(2) ~ .opponent-area {
+ margin-bottom: 15px;
+ transform: rotate(4deg);
+}
+
+/* 3 opponents: middle higher, edges rotated toward center */
+.opponents-row .opponent-area:first-child:nth-last-child(3) {
+ margin-bottom: 0;
+ transform: rotate(-6deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(3) ~ .opponent-area:not(:last-child) {
+ margin-bottom: 35px;
+ transform: rotate(0deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(3) ~ .opponent-area:last-child {
+ margin-bottom: 0;
+ transform: rotate(6deg);
+}
+
+/* 4 opponents: arch shape with rotation toward center */
+.opponents-row .opponent-area:first-child:nth-last-child(4) {
+ margin-bottom: 0;
+ transform: rotate(-8deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:nth-child(2) {
+ margin-bottom: 30px;
+ transform: rotate(-3deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:nth-child(3) {
+ margin-bottom: 30px;
+ transform: rotate(3deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:last-child {
+ margin-bottom: 0;
+ transform: rotate(8deg);
+}
+
+/* 5 opponents: deeper arch with graduated rotation toward center */
+.opponents-row .opponent-area:first-child:nth-last-child(5) {
+ margin-bottom: 0;
+ transform: rotate(-10deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(2) {
+ margin-bottom: 25px;
+ transform: rotate(-5deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(3) {
+ margin-bottom: 40px;
+ transform: rotate(0deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(4) {
+ margin-bottom: 25px;
+ transform: rotate(5deg);
+}
+.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:last-child {
+ margin-bottom: 0;
+ transform: rotate(10deg);
+}
+
+.table-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ background: rgba(0,0,0,0.15);
+ padding: 15px 20px;
+ border-radius: 12px;
+}
+
+.deck-area {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+#discard {
+ background: rgba(255,255,255,0.1);
+ border: 2px dashed rgba(255,255,255,0.3);
+}
+
+#discard.has-card {
+ background: #fff;
+ border: 2px solid #ddd;
+}
+
+#deck.disabled,
+#discard.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ filter: grayscale(50%);
+}
+
+#deck.disabled:hover,
+#discard.disabled:hover {
+ transform: none;
+ box-shadow: none;
+}
+
+#drawn-card-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: rgba(0,0,0,0.25);
+ border-radius: 8px;
+}
+
+
+#drawn-card-area .btn {
+ white-space: nowrap;
+}
+
+/* Player Area */
+.player-section {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+}
+
+.player-area {
+ background: rgba(0,0,0,0.2);
+ border-radius: 8px;
+ padding: 10px 15px;
+ text-align: center;
+}
+
+/* Opponent Areas */
+.opponent-area {
+ background: rgba(0,0,0,0.2);
+ border-radius: 6px;
+ padding: clamp(3px, 0.4vw, 6px) clamp(5px, 0.6vw, 10px) clamp(5px, 0.6vw, 10px);
+ text-align: center;
+}
+
+.opponent-area h4 {
+ font-size: clamp(0.65rem, 0.75vw, 0.85rem);
+ margin: 0 0 4px 0;
+ padding: clamp(2px, 0.3vw, 4px) clamp(6px, 0.8vw, 10px);
+ background: rgba(244, 164, 96, 0.5);
+ border-radius: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.opponent-area .card-grid {
+ display: grid;
+ grid-template-columns: repeat(3, clamp(40px, 4.5vw, 60px));
+ gap: clamp(3px, 0.4vw, 6px);
+}
+
+.opponent-area .card {
+ width: clamp(40px, 4.5vw, 60px);
+ height: clamp(56px, 6.3vw, 84px);
+ font-size: clamp(0.75rem, 0.9vw, 1.1rem);
+ border-radius: 4px;
+}
+
+.opponent-area.current-turn {
+ background: rgba(244, 164, 96, 0.3);
+ box-shadow: 0 0 0 2px #f4a460;
+}
+
+/* Toast Notification */
+.toast {
+ background: rgba(0, 0, 0, 0.9);
+ color: #fff;
+ padding: 8px 20px;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ text-align: center;
+ margin-top: 8px;
+ animation: toastIn 0.3s ease;
+}
+
+.toast.hidden {
+ display: none;
+}
+
+.toast.your-turn {
+ background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
+ color: #1a472a;
+ font-weight: 600;
+}
+
+@keyframes toastIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Flip prompt */
+.flip-prompt {
+ background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
+ color: #1a472a;
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-align: center;
+ margin-bottom: 8px;
+}
+
+.flip-prompt.hidden {
+ display: none;
+}
+
+/* Game screen layout */
+#game-screen.active {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+}
+
+.game-layout {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ align-items: flex-start;
+}
+
+.game-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ max-width: 100%;
+}
+
+/* Scoreboard Panel - positioned as overlay */
+.scoreboard-panel {
+ position: fixed;
+ top: 10px;
+ right: 10px;
+ background: rgba(0,0,0,0.7);
+ border-radius: 8px;
+ padding: 10px;
+ width: 200px;
+ flex-shrink: 0;
+ overflow: hidden;
+ z-index: 100;
+ backdrop-filter: blur(4px);
+}
+
+.scoreboard-panel > h4 {
+ font-size: 0.85rem;
+ text-align: center;
+ margin-bottom: 8px;
+ opacity: 0.9;
+}
+
+.scoreboard-panel table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.scoreboard-panel th,
+.scoreboard-panel td {
+ padding: 5px 4px;
+ text-align: center;
+ border-bottom: 1px solid rgba(255,255,255,0.15);
+}
+
+.scoreboard-panel th {
+ font-weight: 600;
+ background: rgba(0,0,0,0.2);
+ font-size: 0.7rem;
+}
+
+.scoreboard-panel td:first-child {
+ text-align: left;
+}
+
+.scoreboard-panel tr.winner {
+ background: rgba(244, 164, 96, 0.3);
+}
+
+.scoreboard-panel tr.current-player {
+ background: rgba(244, 164, 96, 0.15);
+}
+
+.game-buttons {
+ margin-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.game-buttons .btn {
+ font-size: 0.75rem;
+ padding: 6px 10px;
+}
+
+/* Rankings Announcement */
+.rankings-announcement {
+ background: linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.25) 100%);
+ border-radius: 8px;
+ padding: 8px;
+ margin-bottom: 10px;
+ border: 1px solid rgba(244, 164, 96, 0.3);
+ overflow: hidden;
+}
+
+.rankings-announcement h3 {
+ font-size: 0.85rem;
+ text-align: center;
+ margin: 0 0 8px 0;
+ color: #f4a460;
+}
+
+.rankings-announcement h4 {
+ font-size: 0.7rem;
+ text-align: center;
+ margin: 0 0 5px 0;
+ opacity: 0.8;
+}
+
+.rankings-columns {
+ display: flex;
+ gap: 6px;
+}
+
+.ranking-section {
+ flex: 1;
+ min-width: 0;
+ background: rgba(0,0,0,0.2);
+ border-radius: 5px;
+ padding: 5px;
+ overflow: hidden;
+}
+
+.rank-row {
+ display: flex;
+ align-items: center;
+ font-size: 0.7rem;
+ padding: 2px 0;
+ gap: 2px;
+ flex-wrap: nowrap;
+}
+
+.rank-row.leader {
+ color: #f4a460;
+ font-weight: 600;
+}
+
+.rank-pos {
+ width: 16px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.rank-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.rank-val {
+ font-size: 0.6rem;
+ opacity: 0.9;
+ flex-shrink: 0;
+ white-space: nowrap;
+ text-align: right;
+}
+
+/* Double Victory */
+.double-victory {
+ background: linear-gradient(135deg, #ffd700 0%, #f4a460 50%, #ffd700 100%);
+ color: #1a472a;
+ text-align: center;
+ padding: 8px;
+ border-radius: 6px;
+ font-weight: 700;
+ font-size: 0.9rem;
+ margin-bottom: 8px;
+ animation: victoryPulse 1s ease-in-out infinite alternate;
+ text-shadow: 0 1px 0 rgba(255,255,255,0.3);
+}
+
+@keyframes victoryPulse {
+ from { box-shadow: 0 0 5px rgba(255, 215, 0, 0.5); }
+ to { box-shadow: 0 0 15px rgba(255, 215, 0, 0.8); }
+}
+
+/* Responsive */
+@media (max-width: 700px) {
+ .game-layout {
+ flex-direction: column;
+ }
+
+ .scoreboard-panel {
+ width: 100%;
+ order: -1;
+ margin-bottom: 8px;
+ padding: 8px;
+ }
+
+ .scoreboard-panel > h4 {
+ cursor: pointer;
+ user-select: none;
+ padding: 4px;
+ margin: -4px -4px 0 -4px;
+ border-radius: 4px;
+ }
+
+ .scoreboard-panel > h4:hover {
+ background: rgba(255,255,255,0.1);
+ }
+
+ .scoreboard-panel > h4::after {
+ content: ' ▼';
+ font-size: 0.6rem;
+ }
+
+ .scoreboard-panel.collapsed > h4::after {
+ content: ' ▶';
+ }
+
+ .scoreboard-panel.collapsed > *:not(h4):not(.game-buttons) {
+ display: none;
+ }
+
+ .scoreboard-panel table {
+ max-width: 100%;
+ margin: 8px auto 0;
+ }
+
+ .rankings-announcement {
+ padding: 8px;
+ margin-bottom: 8px;
+ }
+
+ .rankings-columns {
+ flex-direction: row;
+ gap: 6px;
+ }
+
+ .ranking-section {
+ padding: 5px;
+ }
+
+ .game-buttons {
+ flex-direction: row;
+ justify-content: center;
+ }
+}
+
+@media (max-width: 500px) {
+ #app {
+ padding: 6px;
+ }
+
+ h1 {
+ font-size: 2rem;
+ }
+
+ .card {
+ width: 55px;
+ height: 77px;
+ font-size: 1.2rem;
+ }
+
+ .card-grid {
+ grid-template-columns: repeat(3, 55px);
+ gap: 6px;
+ }
+
+ .opponent-area .card {
+ width: 38px;
+ height: 53px;
+ font-size: 0.75rem;
+ }
+
+ .opponent-area .card-grid {
+ grid-template-columns: repeat(3, 38px);
+ gap: 3px;
+ }
+
+ .opponents-row {
+ min-height: 100px;
+ gap: 8px;
+ }
+
+ /* Reduce arch heights and rotations on mobile */
+ .opponents-row .opponent-area:first-child:nth-last-child(2),
+ .opponents-row .opponent-area:first-child:nth-last-child(2) ~ .opponent-area {
+ transform: rotate(0deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(3) {
+ transform: rotate(-4deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(3) ~ .opponent-area:not(:last-child) {
+ margin-bottom: 22px;
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(3) ~ .opponent-area:last-child {
+ transform: rotate(4deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(4) {
+ transform: rotate(-5deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:nth-child(2),
+ .opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:nth-child(3) {
+ margin-bottom: 18px;
+ transform: rotate(0deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(4) ~ .opponent-area:last-child {
+ transform: rotate(5deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) {
+ transform: rotate(-6deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(2),
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(4) {
+ margin-bottom: 15px;
+ transform: rotate(0deg);
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(3) {
+ margin-bottom: 25px;
+ }
+ .opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:last-child {
+ transform: rotate(6deg);
+ }
+
+ .game-header {
+ flex-direction: column;
+ text-align: center;
+ gap: 3px;
+ }
+
+ .table-center {
+ padding: 10px 15px;
+ }
+
+ .player-row {
+ flex-direction: column;
+ gap: 10px;
+ }
+}
+
+/* Suit symbols */
+.suit-hearts::after { content: "♥"; }
+.suit-diamonds::after { content: "♦"; }
+.suit-clubs::after { content: "♣"; }
+.suit-spades::after { content: "♠"; }
+
+/* Modal */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-content {
+ background: linear-gradient(135deg, #1a472a 0%, #2d5a3d 100%);
+ border-radius: 12px;
+ padding: 15px;
+ max-width: 700px;
+ width: 95%;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.modal-content h3 {
+ text-align: center;
+ margin-bottom: 10px;
+ font-size: 1.1rem;
+}
+
+/* CPU Profile Grid */
+.profiles-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.profile-card {
+ background: rgba(255, 255, 255, 0.1);
+ border: 2px solid transparent;
+ border-radius: 6px;
+ padding: 8px 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: center;
+ position: relative;
+}
+
+.profile-card:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(244, 164, 96, 0.5);
+}
+
+.profile-card.unavailable {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.profile-card.unavailable:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: transparent;
+}
+
+.profile-card.selected {
+ background: rgba(244, 164, 96, 0.3);
+ border-color: #f4a460;
+ box-shadow: 0 0 8px rgba(244, 164, 96, 0.5);
+}
+
+.profile-card.selected:hover {
+ background: rgba(244, 164, 96, 0.4);
+}
+
+.profile-checkbox {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 20px;
+ height: 20px;
+ border: 2px solid rgba(255,255,255,0.4);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: bold;
+ color: #1a472a;
+ background: rgba(255,255,255,0.1);
+}
+
+.profile-card.selected .profile-checkbox {
+ background: #f4a460;
+ border-color: #f4a460;
+}
+
+.profile-avatar {
+ width: 50px;
+ height: 50px;
+ margin: 0 auto 8px;
+ border-radius: 50%;
+ background: rgba(255,255,255,0.15);
+ overflow: hidden;
+}
+
+.profile-avatar svg {
+ width: 100%;
+ height: 100%;
+}
+
+.profile-card .profile-name {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.profile-card .profile-style {
+ font-size: 0.85rem;
+ opacity: 0.8;
+ font-style: italic;
+}
+
+.profile-card .profile-in-game {
+ font-size: 0.75rem;
+ color: #f4a460;
+ margin-top: 4px;
+}
+
+.modal-buttons {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+}
+
+.modal-buttons .btn {
+ min-width: 100px;
+}
+
+/* House Rules Section */
+.house-rules-section {
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ margin: 15px 0;
+ overflow: hidden;
+}
+
+.house-rules-section summary {
+ padding: 12px 15px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.95rem;
+ background: rgba(0, 0, 0, 0.2);
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.house-rules-section summary::-webkit-details-marker {
+ display: none;
+}
+
+.house-rules-section summary::before {
+ content: "▸";
+ font-size: 0.8rem;
+ transition: transform 0.2s;
+}
+
+.house-rules-section[open] summary::before {
+ transform: rotate(90deg);
+}
+
+.house-rules-section summary:hover {
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.house-rules-category {
+ padding: 12px 15px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.house-rules-category h4 {
+ font-size: 0.85rem;
+ margin-bottom: 10px;
+ opacity: 0.9;
+ color: #f4a460;
+}
+
+.house-rules-category .checkbox-group {
+ gap: 6px;
+}
+
+.house-rules-category .checkbox-label {
+ font-size: 0.85rem;
+ padding: 5px 0;
+ flex-wrap: wrap;
+}
+
+/* Rule description */
+.rule-desc {
+ width: 100%;
+ font-size: 0.7rem;
+ opacity: 0.7;
+ margin-left: 22px;
+ margin-top: 1px;
+}
+
+/* Compact form group for house rules */
+.house-rules-category .form-group.compact {
+ margin: 0;
+}
+
+.house-rules-category .form-group.compact select {
+ width: 100%;
+ font-size: 0.8rem;
+ padding: 6px 8px;
+}
+
+/* Eagle Eye option under joker dropdown */
+.eagle-eye-option {
+ margin-top: 8px;
+}
+
+/* Disabled checkbox styling */
+.checkbox-label:has(input:disabled) {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.checkbox-label input:disabled {
+ cursor: not-allowed;
+}
diff --git a/server/RULES.md b/server/RULES.md
new file mode 100644
index 0000000..65294ae
--- /dev/null
+++ b/server/RULES.md
@@ -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
+```
diff --git a/server/ai.py b/server/ai.py
new file mode 100644
index 0000000..46cca44
--- /dev/null
+++ b/server/ai.py
@@ -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()
diff --git a/server/game.py b/server/game.py
new file mode 100644
index 0000000..e0b7214
--- /dev/null
+++ b/server/game.py
@@ -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,
+ }
diff --git a/server/game_analyzer.py b/server/game_analyzer.py
new file mode 100644
index 0000000..8ce5139
--- /dev/null
+++ b/server/game_analyzer.py
@@ -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 ")
+ 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)
diff --git a/server/game_log.py b/server/game_log.py
new file mode 100644
index 0000000..f984598
--- /dev/null
+++ b/server/game_log.py
@@ -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
diff --git a/server/main.py b/server/main.py
new file mode 100644
index 0000000..f6c2279
--- /dev/null
+++ b/server/main.py
@@ -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")
diff --git a/server/requirements.txt b/server/requirements.txt
new file mode 100644
index 0000000..cb3770d
--- /dev/null
+++ b/server/requirements.txt
@@ -0,0 +1,3 @@
+fastapi==0.109.0
+uvicorn[standard]==0.27.0
+websockets==12.0
diff --git a/server/room.py b/server/room.py
new file mode 100644
index 0000000..bc6b77c
--- /dev/null
+++ b/server/room.py
@@ -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
diff --git a/server/score_analysis.py b/server/score_analysis.py
new file mode 100644
index 0000000..a93a76b
--- /dev/null
+++ b/server/score_analysis.py
@@ -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)
diff --git a/server/simulate.py b/server/simulate.py
new file mode 100644
index 0000000..d1d4e6f
--- /dev/null
+++ b/server/simulate.py
@@ -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)
diff --git a/server/test_analyzer.py b/server/test_analyzer.py
new file mode 100644
index 0000000..909020e
--- /dev/null
+++ b/server/test_analyzer.py
@@ -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"])
diff --git a/server/test_game.py b/server/test_game.py
new file mode 100644
index 0000000..5c70289
--- /dev/null
+++ b/server/test_game.py
@@ -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"])
diff --git a/server/test_house_rules.py b/server/test_house_rules.py
new file mode 100644
index 0000000..d353caf
--- /dev/null
+++ b/server/test_house_rules.py
@@ -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()
diff --git a/server/test_maya_bug.py b/server/test_maya_bug.py
new file mode 100644
index 0000000..9b9b3f6
--- /dev/null
+++ b/server/test_maya_bug.py
@@ -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"])