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)