From d18cea210469a65421310772551ef774f29c8463 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sat, 24 Jan 2026 19:26:12 -0500 Subject: [PATCH] Initial commit: 6-Card Golf with AI opponents Features: - Multiplayer WebSocket game server (FastAPI) - 8 AI personalities with distinct play styles - 15+ house rule variants - SQLite game logging for AI analysis - Comprehensive test suite (80+ tests) AI improvements: - Fixed Maya bug (taking bad cards, discarding good ones) - Personality traits influence style without overriding competence - Zero blunders detected in 1000+ game simulations Testing infrastructure: - Game rules verification (test_game.py) - AI decision analysis (game_analyzer.py) - Score distribution analysis (score_analysis.py) - House rules testing (test_house_rules.py) Co-Authored-By: Claude Opus 4.5 --- README.md | 165 +++++ client/app.js | 1044 +++++++++++++++++++++++++++++++ client/index.html | 264 ++++++++ client/style.css | 1210 ++++++++++++++++++++++++++++++++++++ server/RULES.md | 190 ++++++ server/ai.py | 641 +++++++++++++++++++ server/game.py | 609 ++++++++++++++++++ server/game_analyzer.py | 649 +++++++++++++++++++ server/game_log.py | 242 ++++++++ server/main.py | 459 ++++++++++++++ server/requirements.txt | 3 + server/room.py | 155 +++++ server/score_analysis.py | 349 +++++++++++ server/simulate.py | 435 +++++++++++++ server/test_analyzer.py | 299 +++++++++ server/test_game.py | 582 +++++++++++++++++ server/test_house_rules.py | 571 +++++++++++++++++ server/test_maya_bug.py | 319 ++++++++++ 18 files changed, 8186 insertions(+) create mode 100644 README.md create mode 100644 client/app.js create mode 100644 client/index.html create mode 100644 client/style.css create mode 100644 server/RULES.md create mode 100644 server/ai.py create mode 100644 server/game.py create mode 100644 server/game_analyzer.py create mode 100644 server/game_log.py create mode 100644 server/main.py create mode 100644 server/requirements.txt create mode 100644 server/room.py create mode 100644 server/score_analysis.py create mode 100644 server/simulate.py create mode 100644 server/test_analyzer.py create mode 100644 server/test_game.py create mode 100644 server/test_house_rules.py create mode 100644 server/test_maya_bug.py 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:

+ +
+

Players

+
    +
    + + + +

    Waiting for host to start the game...

    + + +
    + + +
    +
    +
    +
    +
    Hole 1/9
    +
    Deck: 52
    + +
    + +
    +
    + +
    +
    +
    +
    + DECK +
    +
    + +
    +
    + +
    + +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Scores

    + + + + + + + + + + +
    PlayerHoleTotW
    + +
    +
    +
    +
    + + + + + + + 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"])