Early Knock house rule and improved error handling.
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
36a71799b5
commit
c912a56c2d
102
client/app.js
102
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,36 +530,39 @@ class GolfGame {
|
||||
}
|
||||
|
||||
startGame() {
|
||||
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;
|
||||
const knock_penalty = this.knockPenaltyCheckbox?.checked || false;
|
||||
|
||||
// Joker mode (radio buttons)
|
||||
const joker_mode = document.querySelector('input[name="joker-mode"]:checked').value;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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',
|
||||
@ -563,8 +584,13 @@ class GolfGame {
|
||||
flip_as_action,
|
||||
four_of_a_kind,
|
||||
negative_pairs_keep_value,
|
||||
one_eyed_jacks
|
||||
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();
|
||||
}
|
||||
|
||||
@ -102,9 +102,9 @@
|
||||
<div class="select-option">
|
||||
<label for="flip-mode">Flip on Discard</label>
|
||||
<select id="flip-mode">
|
||||
<option value="never">Standard - No flip after discarding</option>
|
||||
<option value="always">Speed Golf - MUST flip a card after discarding</option>
|
||||
<option value="endgame">Endgame - Optional flip to help trailing players catch up</option>
|
||||
<option value="never">Standard (no flip)</option>
|
||||
<option value="always">Speed Golf (must flip)</option>
|
||||
<option value="endgame">Endgame (opt. flip late in game)</option>
|
||||
</select>
|
||||
<span class="rule-desc">After discarding a drawn card</span>
|
||||
</div>
|
||||
@ -118,6 +118,11 @@
|
||||
<span>Knock Penalty</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♦</span>+10 if not lowest</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="knock-early">
|
||||
<span>Early Knock</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>flip all (≤2) to go out</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -262,6 +267,7 @@
|
||||
</div>
|
||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -568,6 +574,11 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<p>The Jack of Hearts (J♥) and Jack of Spades (J♠) - the "one-eyed" Jacks - are worth <strong>0 points</strong> instead of 10.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> 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.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Early Knock</h4>
|
||||
<p>If you have <strong>2 or fewer face-down cards</strong>, 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.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> 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).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
112
server/RULES.md
112
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
|
||||
|
||||
|
||||
69
server/ai.py
69
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:
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
130
server/main.py
130
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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user