From c912a56c2d6e9fa2df39f70f9918ae6d91b9668d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Mon, 26 Jan 2026 22:23:12 -0500 Subject: [PATCH] Early Knock house rule and improved error handling. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Early Knock variant: flip all remaining cards (≤2) to go out early - Update RULES.md with comprehensive documentation for all new variants - Shorten flip mode dropdown descriptions for cleaner UI - Add try-catch and optional chaining in startGame() for robustness - Add WebSocket connection error feedback with reject sound - AI awareness for Early Knock decisions Co-Authored-By: Claude Opus 4.5 --- client/app.js | 168 ++++++++++++++++++++++++++++++++-------------- client/index.html | 17 ++++- server/RULES.md | 112 +++++++++++++++++++++++++++---- server/ai.py | 69 +++++++++++++++++++ server/game.py | 48 +++++++++++++ server/main.py | 130 +++++++++++++++++++++++++++++++++++ 6 files changed, 478 insertions(+), 66 deletions(-) diff --git a/client/app.js b/client/app.js index 7e859a1..1380840 100644 --- a/client/app.js +++ b/client/app.js @@ -168,6 +168,7 @@ class GolfGame { this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind'); this.negativePairsCheckbox = document.getElementById('negative-pairs-keep-value'); this.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks'); + this.knockEarlyCheckbox = document.getElementById('knock-early'); this.wolfpackComboNote = document.getElementById('wolfpack-combo-note'); this.startGameBtn = document.getElementById('start-game-btn'); this.leaveRoomBtn = document.getElementById('leave-room-btn'); @@ -191,6 +192,7 @@ class GolfGame { this.discardContent = document.getElementById('discard-content'); this.discardBtn = document.getElementById('discard-btn'); this.skipFlipBtn = document.getElementById('skip-flip-btn'); + this.knockEarlyBtn = document.getElementById('knock-early-btn'); this.playerCards = document.getElementById('player-cards'); this.playerArea = this.playerCards.closest('.player-area'); this.swapAnimation = document.getElementById('swap-animation'); @@ -216,6 +218,7 @@ class GolfGame { this.discard.addEventListener('click', () => { this.drawFromDiscard(); }); this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); }); this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); }); + this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); }); 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(); }); @@ -326,6 +329,9 @@ class GolfGame { send(message) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); + } else { + console.error('WebSocket not ready, cannot send:', message.type); + this.showError('Connection lost. Please refresh.'); } } @@ -414,8 +420,20 @@ class GolfGame { break; case 'your_turn': - if (this.gameState && this.gameState.flip_as_action) { + // Build toast based on available actions + const canFlip = this.gameState && this.gameState.flip_as_action; + let canKnock = false; + if (this.gameState && this.gameState.knock_early) { + const myData = this.gameState.players.find(p => p.id === this.playerId); + const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; + canKnock = faceDownCount >= 1 && faceDownCount <= 2; + } + if (canFlip && canKnock) { + this.showToast('Your turn! Draw, flip, or knock', 'your-turn'); + } else if (canFlip) { this.showToast('Your turn! Draw or flip a card', 'your-turn'); + } else if (canKnock) { + this.showToast('Your turn! Draw or knock', 'your-turn'); } else { this.showToast('Your turn! Draw a card', 'your-turn'); } @@ -512,59 +530,67 @@ class GolfGame { } startGame() { - const decks = parseInt(this.numDecksSelect.value); - const rounds = parseInt(this.numRoundsSelect.value); - const initial_flips = parseInt(this.initialFlipsSelect.value); + try { + const decks = parseInt(this.numDecksSelect.value); + const rounds = parseInt(this.numRoundsSelect.value); + const initial_flips = parseInt(this.initialFlipsSelect.value); - // Standard options - const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame" - const knock_penalty = this.knockPenaltyCheckbox.checked; + // Standard options + const flip_mode = this.flipModeSelect.value; // "never", "always", or "endgame" + const knock_penalty = this.knockPenaltyCheckbox?.checked || false; - // Joker mode (radio buttons) - const joker_mode = document.querySelector('input[name="joker-mode"]:checked').value; - const use_jokers = joker_mode !== 'none'; - const lucky_swing = joker_mode === 'lucky-swing'; - const eagle_eye = joker_mode === 'eagle-eye'; + // Joker mode (radio buttons) + const jokerRadio = document.querySelector('input[name="joker-mode"]:checked'); + const joker_mode = jokerRadio ? jokerRadio.value : 'none'; + const use_jokers = joker_mode !== 'none'; + const lucky_swing = joker_mode === 'lucky-swing'; + const eagle_eye = joker_mode === 'eagle-eye'; - // House Rules - Point Modifiers - const super_kings = this.superKingsCheckbox.checked; - const ten_penny = this.tenPennyCheckbox.checked; + // House Rules - Point Modifiers + const super_kings = this.superKingsCheckbox?.checked || false; + const ten_penny = this.tenPennyCheckbox?.checked || false; - // 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; - const wolfpack = this.wolfpackCheckbox.checked; + // House Rules - Bonuses/Penalties + const knock_bonus = this.knockBonusCheckbox?.checked || false; + const underdog_bonus = this.underdogBonusCheckbox?.checked || false; + const tied_shame = this.tiedShameCheckbox?.checked || false; + const blackjack = this.blackjackCheckbox?.checked || false; + const wolfpack = this.wolfpackCheckbox?.checked || false; - // House Rules - New Variants - const flip_as_action = this.flipAsActionCheckbox.checked; - const four_of_a_kind = this.fourOfAKindCheckbox.checked; - const negative_pairs_keep_value = this.negativePairsCheckbox.checked; - const one_eyed_jacks = this.oneEyedJacksCheckbox.checked; + // House Rules - New Variants + const flip_as_action = this.flipAsActionCheckbox?.checked || false; + const four_of_a_kind = this.fourOfAKindCheckbox?.checked || false; + const negative_pairs_keep_value = this.negativePairsCheckbox?.checked || false; + const one_eyed_jacks = this.oneEyedJacksCheckbox?.checked || false; + const knock_early = this.knockEarlyCheckbox?.checked || false; - this.send({ - type: 'start_game', - decks, - rounds, - initial_flips, - flip_mode, - knock_penalty, - use_jokers, - lucky_swing, - super_kings, - ten_penny, - knock_bonus, - underdog_bonus, - tied_shame, - blackjack, - eagle_eye, - wolfpack, - flip_as_action, - four_of_a_kind, - negative_pairs_keep_value, - one_eyed_jacks - }); + this.send({ + type: 'start_game', + decks, + rounds, + initial_flips, + flip_mode, + knock_penalty, + use_jokers, + lucky_swing, + super_kings, + ten_penny, + knock_bonus, + underdog_bonus, + tied_shame, + blackjack, + eagle_eye, + wolfpack, + flip_as_action, + four_of_a_kind, + negative_pairs_keep_value, + one_eyed_jacks, + knock_early + }); + } catch (error) { + console.error('Error starting game:', error); + this.showError('Error starting game. Please refresh.'); + } } showCpuSelect() { @@ -857,6 +883,13 @@ class GolfGame { this.hideToast(); } + knockEarly() { + // Flip all remaining face-down cards to go out early + if (!this.gameState || !this.gameState.knock_early) return; + this.send({ type: 'knock_early' }); + this.hideToast(); + } + // Fire-and-forget animation triggers based on state changes triggerAnimationsForStateChange(oldState, newState) { if (!oldState) return; @@ -1398,6 +1431,8 @@ class GolfGame { showError(message) { this.lobbyError.textContent = message; + this.playSound('reject'); + console.error('Game error:', message); } updatePlayersList(players) { @@ -1495,8 +1530,21 @@ class GolfGame { if (currentPlayer && currentPlayer.id !== this.playerId) { this.setStatus(`${currentPlayer.name}'s turn`); } else if (this.isMyTurn()) { - if (this.gameState.flip_as_action && !this.drawnCard && !this.gameState.has_drawn_card) { - this.setStatus('Your turn - draw a card or flip one', 'your-turn'); + if (!this.drawnCard && !this.gameState.has_drawn_card) { + // Build status message based on available actions + let options = ['draw']; + if (this.gameState.flip_as_action) options.push('flip'); + // Check knock early eligibility + const myData = this.gameState.players.find(p => p.id === this.playerId); + const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; + if (this.gameState.knock_early && faceDownCount >= 1 && faceDownCount <= 2) { + options.push('knock'); + } + if (options.length === 1) { + this.setStatus('Your turn - draw a card', 'your-turn'); + } else { + this.setStatus(`Your turn - ${options.join('/')}`, 'your-turn'); + } } else { this.setStatus('Your turn - draw a card', 'your-turn'); } @@ -1769,6 +1817,26 @@ class GolfGame { this.skipFlipBtn.classList.add('hidden'); } + // Show/hide knock early button (when knock_early rule is enabled) + // Conditions: rule enabled, my turn, no drawn card, have 1-2 face-down cards + const canKnockEarly = this.gameState.knock_early && + this.isMyTurn() && + !this.drawnCard && + !this.gameState.has_drawn_card && + !this.gameState.waiting_for_initial_flip; + if (canKnockEarly) { + // Count face-down cards for current player + const myData = this.gameState.players.find(p => p.id === this.playerId); + const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0; + if (faceDownCount >= 1 && faceDownCount <= 2) { + this.knockEarlyBtn.classList.remove('hidden'); + } else { + this.knockEarlyBtn.classList.add('hidden'); + } + } else { + this.knockEarlyBtn.classList.add('hidden'); + } + // Update scoreboard panel this.updateScorePanel(); } diff --git a/client/index.html b/client/index.html index 59ff8ed..8cfb1da 100644 --- a/client/index.html +++ b/client/index.html @@ -102,9 +102,9 @@
After discarding a drawn card
@@ -118,6 +118,11 @@ Knock Penalty +10 if not lowest + @@ -262,6 +267,7 @@ + @@ -568,6 +574,11 @@ TOTAL: 0 + 8 + 16 = 24 points

The Jack of Hearts (J♥) and Jack of Spades (J♠) - the "one-eyed" Jacks - are worth 0 points instead of 10.

Strategic impact: Two of the four Jacks become safe cards, comparable to Kings. J♥ and J♠ are now good cards to keep! Only J♣ and J♦ remain dangerous. Reduces the "Jack disaster" probability by half.

+
+

Early Knock

+

If you have 2 or fewer face-down cards, you may use your turn to flip all remaining cards at once and immediately end the round. Click the "Knock!" button during your draw phase.

+

Strategic impact: A high-risk, high-reward option! If you're confident your hidden cards are low, you can knock early to surprise opponents. But if those hidden cards are bad, you've just locked in a terrible score. Best used when you've deduced your face-down cards are safe (like after drawing and discarding duplicates).

+
diff --git a/server/RULES.md b/server/RULES.md index 985c75a..9de824c 100644 --- a/server/RULES.md +++ b/server/RULES.md @@ -78,12 +78,15 @@ Golf is a card game where players try to achieve the **lowest score** over multi | Tier | Cards | Strategy | |------|-------|----------| -| **Excellent** | Joker (-2), 2 (-2) | Always keep, never pair | +| **Excellent** | Joker (-2), 2 (-2) | Always keep, never pair (unless `negative_pairs_keep_value`) | | **Good** | King (0) | Safe, good for pairing | +| **Good** | J♥, J♠ (0)* | Safe with `one_eyed_jacks` rule | | **Decent** | Ace (1) | Low risk | | **Neutral** | 3, 4, 5 | Acceptable | | **Bad** | 6, 7 | Replace when possible | -| **Terrible** | 8, 9, 10, J, Q | High priority to replace | +| **Terrible** | 8, 9, 10, J♣, J♦, Q | High priority to replace | + +*With `one_eyed_jacks` enabled, J♥ and J♠ are worth 0 points. | Implementation | File | |----------------|------| @@ -263,6 +266,8 @@ Our implementation supports these optional rule variations. All are **disabled b | `flip_mode` | What happens when discarding from deck (see below) | `never` | | `knock_penalty` | +10 if you go out but don't have lowest score | Off | | `use_jokers` | Add Jokers to deck (-2 points each) | Off | +| `flip_as_action` | Use turn to flip a card instead of drawing | Off | +| `knock_early` | Flip all remaining cards (≤2) to go out early | Off | ### Flip Mode Options @@ -298,9 +303,11 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch | Implementation | File | |----------------|------| -| LUCKY_SWING_JOKER_VALUE | `constants.py:23` | -| SUPER_KINGS_VALUE | `constants.py:21` | -| TEN_PENNY_VALUE | `constants.py:22` | +| LUCKY_SWING_JOKER_VALUE | `constants.py:59` | +| SUPER_KINGS_VALUE | `constants.py:57` | +| TEN_PENNY_VALUE | `constants.py:58` | +| WOLFPACK_BONUS | `constants.py:66` | +| FOUR_OF_A_KIND_BONUS | `constants.py:67` | | Value application | `game.py:58-66` | | Tests | File | @@ -318,7 +325,10 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch | `underdog_bonus` | Lowest scorer each round gets **-3** | Round end | | `tied_shame` | Tying another player's score = **+5** penalty to both | Round end | | `blackjack` | Exact score of 21 becomes **0** | Round end | -| `wolfpack` | 2 pairs of Jacks = **-5** bonus | Scoring | +| `wolfpack` | 2 pairs of Jacks = **-20** bonus | Scoring | +| `four_of_a_kind` | 4 cards of same rank in 2 columns = **-20** bonus | Scoring | + +> **Note:** Wolfpack and Four of a Kind stack. Four Jacks = -20 (wolfpack) + -20 (four of a kind) = **-40 total**. | Implementation | File | |----------------|------| @@ -327,7 +337,8 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch | Knock bonus | `game.py:527-531` | | Underdog bonus | `game.py:533-538` | | Tied shame | `game.py:540-546` | -| Wolfpack | `game.py:180-182` | +| Wolfpack (-20 bonus) | `game.py:180-182` | +| Four of a kind (-20 bonus) | `game.py:192-205` | | Tests | File | |-------|------| @@ -345,6 +356,49 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch | Eagle eye unpaired value | `game.py:60-61` | | Eagle eye paired value | `game.py:169-173` | +## New Variants + +These rules add alternative gameplay options based on traditional Golf variants. + +### Flip as Action + +Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately. + +**Strategic impact:** Lets you gather information without risking a bad deck draw. Conservative players can learn their hand safely. However, you miss the chance to actively improve your hand. + +### Four of a Kind + +Having 4 cards of the same rank across two columns (two complete column pairs of the same rank) scores a **-20 bonus**. + +**Strategic impact:** Rewards collecting matching cards beyond just column pairs. Changes whether you should take a third or fourth copy of a rank. Stacks with Wolfpack: four Jacks = -40 total. + +### Negative Pairs Keep Value + +When you pair 2s or Jokers in a column, they keep their combined **-4 points** instead of becoming 0. + +**Strategic impact:** Major change! Pairing your best cards is now beneficial. Two 2s paired = -4 points, not 0. Encourages hunting for duplicate negative cards. + +### One-Eyed Jacks + +The Jack of Hearts (J♥) and Jack of Spades (J♠) - the "one-eyed" Jacks - are worth **0 points** instead of 10. + +**Strategic impact:** Two of the four Jacks become safe cards, comparable to Kings. J♥ and J♠ are now good cards to keep. Only J♣ and J♦ remain dangerous. Reduces the "Jack disaster" by half. + +### Early Knock + +If you have **2 or fewer face-down cards**, you may use your turn to flip all remaining cards at once and immediately trigger the end of the round (all other players get one final turn). + +**Strategic impact:** High-risk, high-reward option! If you're confident your hidden cards are low, you can knock early to surprise opponents. But if those hidden cards are bad, you've just locked in a terrible score. Best used when you've deduced your face-down cards are safe. + +| Implementation | File | +|----------------|------| +| GameOptions new fields | `game.py:450-459` | +| flip_card_as_action() | `game.py:936-962` | +| knock_early() | `game.py:963-1010` | +| One-eyed Jacks value | `game.py:65-67` | +| Four of a kind scoring | `game.py:192-205` | +| Negative pairs scoring | `game.py:169-185` | + --- # Part 3: AI Decision Making @@ -371,6 +425,26 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch ## Key AI Decision Functions +### should_knock_early() + +Decides whether to use the knock_early action to flip all remaining cards at once. + +**Logic priority:** +1. Only consider if knock_early rule is enabled and player has 1-2 face-down cards +2. Aggressive players with good visible scores are more likely to knock +3. Consider opponent scores and game phase +4. Factor in personality profile aggression + +### should_use_flip_action() + +Decides whether to use flip_as_action instead of drawing (information gathering). + +**Logic priority:** +1. Only consider if flip_as_action rule is enabled +2. Don't use if discard pile has a good card we want +3. Conservative players (low aggression) prefer this safe option +4. Prioritize positions where column partner is visible (pair info) + ### should_take_discard() Decides whether to take from discard pile or draw from deck. @@ -378,11 +452,13 @@ Decides whether to take from discard pile or draw from deck. **Logic priority:** 1. Always take Jokers (and pair if Eagle Eye) 2. Always take Kings -3. Take 10s if ten_penny enabled -4. Take cards that complete a column pair (**except negative cards**) -5. Take low cards based on game phase threshold -6. Consider end-game pressure -7. Take if we have worse visible cards +3. Always take one-eyed Jacks (J♥, J♠) if rule enabled +4. Take 10s if ten_penny enabled +5. Take cards that complete a column pair (**except negative cards**, unless `negative_pairs_keep_value`) +6. Take low cards based on game phase threshold +7. Consider four_of_a_kind potential when collecting ranks +8. Consider end-game pressure +9. Take if we have worse visible cards | Implementation | File | |----------------|------| @@ -541,6 +617,10 @@ WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER -> GAME_OVER ``` Draw Phase: + ├── should_knock_early() returns True (if knock_early enabled, ≤2 face-down) + │ └── Knock early - flip all remaining cards, trigger final turn + ├── should_use_flip_action() returns position (if flip_as_action enabled) + │ └── Flip card at position, end turn ├── should_take_discard() returns True │ └── Draw from discard pile │ └── MUST swap (can_discard_drawn=False) @@ -569,12 +649,18 @@ Draw Phase: | Scenario | Expected | Test | |----------|----------|------| -| Paired 2s | 0 (not -4) | `test_game.py:108-115` | +| Paired 2s (standard) | 0 (not -4) | `test_game.py:108-115` | +| Paired 2s (negative_pairs_keep_value) | -4 | `game.py:169-185` | | Paired Jokers (standard) | 0 | Implicit | | Paired Jokers (eagle_eye) | -4 | `game.py:169-173` | +| Paired Jokers (negative_pairs_keep_value) | -4 | `game.py:169-185` | | Unpaired negative cards | -2 each | `test_game.py:117-124` | | All columns matched | 0 total | `test_game.py:93-98` | | Blackjack (21) | 0 | `test_game.py:175-183` | +| One-eyed Jacks (J♥, J♠) | 0 (with rule) | `game.py:65-67` | +| Four of a kind | -20 bonus | `game.py:192-205` | +| Wolfpack (4 Jacks) | -20 bonus | `game.py:180-182` | +| Four Jacks + Four of a Kind | -40 total | Stacks | ## Running Tests diff --git a/server/ai.py b/server/ai.py index 666e40f..b3bfdc3 100644 --- a/server/ai.py +++ b/server/ai.py @@ -892,6 +892,57 @@ class GolfAI: ai_log(f" >> FLIP: choosing to reveal for information") return False + @staticmethod + def should_knock_early(game: Game, player: Player, profile: CPUProfile) -> bool: + """ + Decide whether to use knock_early to flip all remaining cards at once. + + Only available when knock_early house rule is enabled and player + has 1-2 face-down cards. This is a gamble - aggressive players + with good visible cards may take the risk. + """ + if not game.options.knock_early: + return False + + face_down = [c for c in player.cards if not c.face_up] + if len(face_down) == 0 or len(face_down) > 2: + return False + + # Calculate current visible score + visible_score = 0 + for col in range(3): + top_idx, bot_idx = col, col + 3 + top = player.cards[top_idx] + bot = player.cards[bot_idx] + + # Only count if both are visible + if top.face_up and bot.face_up: + if top.rank == bot.rank: + continue # Pair = 0 + visible_score += get_ai_card_value(top, game.options) + visible_score += get_ai_card_value(bot, game.options) + elif top.face_up: + visible_score += get_ai_card_value(top, game.options) + elif bot.face_up: + visible_score += get_ai_card_value(bot, game.options) + + # Aggressive players with low visible scores might knock early + # Expected value of hidden card is ~4.5 + expected_hidden_total = len(face_down) * 4.5 + projected_score = visible_score + expected_hidden_total + + # More aggressive players accept higher risk + max_acceptable = 8 + int(profile.aggression * 10) # Range: 8 to 18 + + if projected_score <= max_acceptable: + # Add some randomness based on aggression + knock_chance = profile.aggression * 0.4 # Max 40% for most aggressive + if random.random() < knock_chance: + ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") + return True + + return False + @staticmethod def should_use_flip_action(game: Game, player: Player, profile: CPUProfile) -> Optional[int]: """ @@ -1029,6 +1080,24 @@ async def process_cpu_turn( # Check if we should try to go out early GolfAI.should_go_out_early(cpu_player, game, profile) + # Check if we should knock early (flip all remaining cards at once) + if GolfAI.should_knock_early(game, cpu_player, profile): + if game.knock_early(cpu_player.id): + # Log knock early + if logger and game_id: + face_down_count = sum(1 for c in cpu_player.cards if not c.face_up) + logger.log_move( + game_id=game_id, + player=cpu_player, + is_cpu=True, + action="knock_early", + card=None, + game=game, + decision_reason=f"knocked early, revealing {face_down_count} hidden cards", + ) + await broadcast_callback() + return # Turn is over + # Check if we should use flip-as-action instead of drawing flip_action_pos = GolfAI.should_use_flip_action(game, cpu_player, profile) if flip_action_pos is not None: diff --git a/server/game.py b/server/game.py index 7d060d3..a1ff979 100644 --- a/server/game.py +++ b/server/game.py @@ -455,6 +455,9 @@ class GameOptions: one_eyed_jacks: bool = False """One-eyed Jacks (J♥ and J♠) are worth 0 points instead of 10.""" + knock_early: bool = False + """Allow going out early by flipping all remaining cards (max 2 face-down).""" + @dataclass class Game: @@ -957,6 +960,48 @@ class Game: self._check_end_turn(player) return True + def knock_early(self, player_id: str) -> bool: + """ + Flip all remaining face-down cards at once to go out early. + + Only valid if knock_early house rule is enabled and player has + at most 2 face-down cards remaining. This is a gamble - you're + betting your hidden cards are good enough to win. + + Args: + player_id: ID of the player knocking early. + + Returns: + True if action was valid, False otherwise. + """ + if not self.options.knock_early: + return False + + player = self.current_player() + if not player or player.id != player_id: + return False + + if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): + return False + + # Can't use this action if already drawn a card + if self.drawn_card is not None: + return False + + # Count face-down cards + face_down_indices = [i for i, c in enumerate(player.cards) if not c.face_up] + + # Must have at least 1 and at most 2 face-down cards + if len(face_down_indices) == 0 or len(face_down_indices) > 2: + return False + + # Flip all remaining face-down cards + for idx in face_down_indices: + player.cards[idx].face_up = True + + self._check_end_turn(player) + return True + # ------------------------------------------------------------------------- # Turn & Round Flow (Internal) # ------------------------------------------------------------------------- @@ -1177,6 +1222,8 @@ class Game: active_rules.append("Negative Pairs Keep Value") if self.options.one_eyed_jacks: active_rules.append("One-Eyed Jacks") + if self.options.knock_early: + active_rules.append("Early Knock") return { "phase": self.phase.value, @@ -1197,6 +1244,7 @@ class Game: "flip_mode": self.options.flip_mode, "flip_is_optional": self.flip_is_optional, "flip_as_action": self.options.flip_as_action, + "knock_early": self.options.knock_early, "card_values": self.get_card_values(), "active_rules": active_rules, } diff --git a/server/main.py b/server/main.py index 7e23a0d..57597ce 100644 --- a/server/main.py +++ b/server/main.py @@ -31,6 +31,10 @@ app = FastAPI( room_manager = RoomManager() +# Initialize game logger database at startup +_game_logger = get_logger() +logger.info(f"Game analytics database initialized at: {_game_logger.db_path}") + @app.get("/health") async def health_check(): @@ -580,6 +584,7 @@ async def websocket_endpoint(websocket: WebSocket): four_of_a_kind=data.get("four_of_a_kind", False), negative_pairs_keep_value=data.get("negative_pairs_keep_value", False), one_eyed_jacks=data.get("one_eyed_jacks", False), + knock_early=data.get("knock_early", False), ) # Validate settings @@ -630,9 +635,27 @@ async def websocket_endpoint(websocket: WebSocket): continue source = data.get("source", "deck") + # Capture discard top before draw (for logging decision context) + discard_before_draw = current_room.game.discard_top() card = current_room.game.draw_card(player_id, source) if card: + # Log draw decision for human player + if current_room.game_log_id: + game_logger = get_logger() + player = current_room.game.get_player(player_id) + if player: + reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck" + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="take_discard" if source == "discard" else "draw_deck", + card=card, + game=current_room.game, + decision_reason=reason, + ) + # Send drawn card only to the player who drew await websocket.send_json({ "type": "card_drawn", @@ -647,9 +670,29 @@ async def websocket_endpoint(websocket: WebSocket): continue position = data.get("position", 0) + # Capture drawn card before swap for logging + drawn_card = current_room.game.drawn_card + player = current_room.game.get_player(player_id) + old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None + discarded = current_room.game.swap_card(player_id, position) if discarded: + # Log swap decision for human player + if current_room.game_log_id and drawn_card and player: + game_logger = get_logger() + old_rank = old_card.rank.value if old_card else "?" + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="swap", + card=drawn_card, + position=position, + game=current_room.game, + decision_reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}", + ) + await broadcast_game_state(current_room) await check_and_run_cpu_turn(current_room) @@ -657,7 +700,24 @@ async def websocket_endpoint(websocket: WebSocket): if not current_room: continue + # Capture drawn card before discard for logging + drawn_card = current_room.game.drawn_card + player = current_room.game.get_player(player_id) + if current_room.game.discard_drawn(player_id): + # Log discard decision for human player + if current_room.game_log_id and drawn_card and player: + game_logger = get_logger() + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="discard", + card=drawn_card, + game=current_room.game, + decision_reason=f"discarded {drawn_card.rank.value}", + ) + await broadcast_game_state(current_room) if current_room.game.flip_on_discard: @@ -681,7 +741,24 @@ async def websocket_endpoint(websocket: WebSocket): continue position = data.get("position", 0) + player = current_room.game.get_player(player_id) current_room.game.flip_and_end_turn(player_id, position) + + # Log flip decision for human player + if current_room.game_log_id and player and 0 <= position < len(player.cards): + game_logger = get_logger() + flipped_card = player.cards[position] + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="flip", + card=flipped_card, + position=position, + game=current_room.game, + decision_reason=f"flipped card at position {position}", + ) + await broadcast_game_state(current_room) await check_and_run_cpu_turn(current_room) @@ -689,7 +766,21 @@ async def websocket_endpoint(websocket: WebSocket): if not current_room: continue + player = current_room.game.get_player(player_id) if current_room.game.skip_flip_and_end_turn(player_id): + # Log skip flip decision for human player + if current_room.game_log_id and player: + game_logger = get_logger() + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="skip_flip", + card=None, + game=current_room.game, + decision_reason="skipped optional flip (endgame mode)", + ) + await broadcast_game_state(current_room) await check_and_run_cpu_turn(current_room) @@ -698,7 +789,46 @@ async def websocket_endpoint(websocket: WebSocket): continue position = data.get("position", 0) + player = current_room.game.get_player(player_id) if current_room.game.flip_card_as_action(player_id, position): + # Log flip-as-action for human player + if current_room.game_log_id and player and 0 <= position < len(player.cards): + game_logger = get_logger() + flipped_card = player.cards[position] + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="flip_as_action", + card=flipped_card, + position=position, + game=current_room.game, + decision_reason=f"used flip-as-action to reveal position {position}", + ) + + await broadcast_game_state(current_room) + await check_and_run_cpu_turn(current_room) + + elif msg_type == "knock_early": + if not current_room: + continue + + player = current_room.game.get_player(player_id) + if current_room.game.knock_early(player_id): + # Log knock early for human player + if current_room.game_log_id and player: + game_logger = get_logger() + face_down_count = sum(1 for c in player.cards if not c.face_up) + game_logger.log_move( + game_id=current_room.game_log_id, + player=player, + is_cpu=False, + action="knock_early", + card=None, + game=current_room.game, + decision_reason=f"knocked early, revealing {face_down_count} hidden cards", + ) + await broadcast_game_state(current_room) await check_and_run_cpu_turn(current_room)