Additional house rules to accomodate more common game variants.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
114
server/ai.py
114
server/ai.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value
|
||||
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, Suit, get_card_value
|
||||
|
||||
|
||||
# Debug logging configuration
|
||||
@@ -226,6 +226,10 @@ def filter_bad_pair_positions(
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
return positions
|
||||
|
||||
# Exception: Negative Pairs Keep Value makes pairing negative cards GOOD
|
||||
if options.negative_pairs_keep_value:
|
||||
return positions
|
||||
|
||||
filtered = []
|
||||
for pos in positions:
|
||||
partner_pos = get_column_partner_position(pos)
|
||||
@@ -475,6 +479,12 @@ class GolfAI:
|
||||
ai_log(f" >> TAKE: King (always take)")
|
||||
return True
|
||||
|
||||
# One-eyed Jacks: J♥ and J♠ are worth 0, always take them
|
||||
if options.one_eyed_jacks:
|
||||
if discard_card.rank == Rank.JACK and discard_card.suit in (Suit.HEARTS, Suit.SPADES):
|
||||
ai_log(f" >> TAKE: One-eyed Jack (worth 0)")
|
||||
return True
|
||||
|
||||
# Auto-take 10s when ten_penny enabled (they're worth 1)
|
||||
if discard_card.rank == Rank.TEN and options.ten_penny:
|
||||
ai_log(f" >> TAKE: 10 (ten_penny rule)")
|
||||
@@ -578,11 +588,17 @@ class GolfAI:
|
||||
pair_bonus = drawn_value + partner_value
|
||||
score += pair_bonus * pair_weight # Pair hunters value this more
|
||||
else:
|
||||
# Pairing negative cards - usually bad
|
||||
# Pairing negative cards
|
||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||
score += 8 * pair_weight # Eagle Eye Joker pairs
|
||||
score += 8 * pair_weight # Eagle Eye Joker pairs = -4
|
||||
elif options.negative_pairs_keep_value:
|
||||
# Negative Pairs Keep Value: pairing 2s/Jokers is NOW good!
|
||||
# Pair of 2s = -4, pair of Jokers = -4 (instead of 0)
|
||||
pair_benefit = abs(drawn_value + partner_value)
|
||||
score += pair_benefit * pair_weight
|
||||
ai_log(f" Negative pair keep value bonus: +{pair_benefit * pair_weight:.1f}")
|
||||
else:
|
||||
# Penalty, but pair hunters might still do it
|
||||
# Standard rules: penalty for wasting negative cards
|
||||
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
||||
score -= penalty
|
||||
|
||||
@@ -632,6 +648,20 @@ class GolfAI:
|
||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||
score += pair_viability * pair_weight * 0.5
|
||||
|
||||
# 4b. FOUR OF A KIND PURSUIT
|
||||
# When four_of_a_kind rule is enabled, boost score for collecting 3rd/4th card
|
||||
if options.four_of_a_kind:
|
||||
# Count how many of this rank player already has visible (excluding current position)
|
||||
rank_count = sum(
|
||||
1 for i, c in enumerate(player.cards)
|
||||
if c.face_up and c.rank == drawn_card.rank and i != pos
|
||||
)
|
||||
if rank_count >= 2:
|
||||
# Already have 2+ of this rank, getting more is great for 4-of-a-kind
|
||||
four_kind_bonus = rank_count * 4 # 8 for 2 cards, 12 for 3 cards
|
||||
score += four_kind_bonus
|
||||
ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus}")
|
||||
|
||||
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
||||
face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
||||
@@ -862,6 +892,62 @@ class GolfAI:
|
||||
ai_log(f" >> FLIP: choosing to reveal for information")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def should_use_flip_action(game: Game, player: Player, profile: CPUProfile) -> Optional[int]:
|
||||
"""
|
||||
Decide whether to use flip-as-action instead of drawing.
|
||||
|
||||
Returns card index to flip, or None to draw normally.
|
||||
|
||||
Only available when flip_as_action house rule is enabled.
|
||||
Conservative players may prefer this to avoid risky deck draws.
|
||||
"""
|
||||
if not game.options.flip_as_action:
|
||||
return None
|
||||
|
||||
# Find face-down cards
|
||||
face_down = [(i, c) for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if not face_down:
|
||||
return None # No cards to flip
|
||||
|
||||
# Check if discard has a good card we want - if so, don't use flip action
|
||||
discard_top = game.discard_top()
|
||||
if discard_top:
|
||||
discard_value = get_ai_card_value(discard_top, game.options)
|
||||
if discard_value <= 2: # Good card available
|
||||
ai_log(f" Flip-as-action: skipping, good discard available ({discard_value})")
|
||||
return None
|
||||
|
||||
# Aggressive players prefer drawing (more action, chance to improve)
|
||||
if profile.aggression > 0.6:
|
||||
ai_log(f" Flip-as-action: skipping, too aggressive ({profile.aggression:.2f})")
|
||||
return None
|
||||
|
||||
# Consider flip action with probability based on personality
|
||||
# Conservative players (low aggression) are more likely to use it
|
||||
flip_chance = (1.0 - profile.aggression) * 0.25 # Max 25% for most conservative
|
||||
|
||||
# Increase chance if we have many hidden cards (info is valuable)
|
||||
if len(face_down) >= 4:
|
||||
flip_chance *= 1.5
|
||||
|
||||
if random.random() > flip_chance:
|
||||
return None
|
||||
|
||||
ai_log(f" Flip-as-action: choosing to flip instead of draw")
|
||||
|
||||
# Prioritize positions where column partner is visible (pair info)
|
||||
for idx, card in face_down:
|
||||
partner_idx = idx + 3 if idx < 3 else idx - 3
|
||||
if player.cards[partner_idx].face_up:
|
||||
ai_log(f" Flipping position {idx} (partner visible)")
|
||||
return idx
|
||||
|
||||
# Random face-down card
|
||||
choice = random.choice(face_down)[0]
|
||||
ai_log(f" Flipping position {choice} (random)")
|
||||
return choice
|
||||
|
||||
@staticmethod
|
||||
def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool:
|
||||
"""
|
||||
@@ -943,6 +1029,26 @@ 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 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:
|
||||
if game.flip_card_as_action(cpu_player.id, flip_action_pos):
|
||||
# Log flip-as-action
|
||||
if logger and game_id:
|
||||
flipped_card = cpu_player.cards[flip_action_pos]
|
||||
logger.log_move(
|
||||
game_id=game_id,
|
||||
player=cpu_player,
|
||||
is_cpu=True,
|
||||
action="flip_as_action",
|
||||
card=flipped_card,
|
||||
position=flip_action_pos,
|
||||
game=game,
|
||||
decision_reason=f"used flip-as-action to reveal position {flip_action_pos}",
|
||||
)
|
||||
await broadcast_callback()
|
||||
return # Turn is over
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -59,6 +59,14 @@ else:
|
||||
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bonus/Penalty Constants
|
||||
# =============================================================================
|
||||
|
||||
WOLFPACK_BONUS: int = -20 # All 4 Jacks (2 pairs) bonus (was -5, buffed)
|
||||
FOUR_OF_A_KIND_BONUS: int = -20 # Four equal cards in two columns bonus
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Game Constants
|
||||
# =============================================================================
|
||||
|
||||
@@ -29,6 +29,8 @@ from constants import (
|
||||
SUPER_KINGS_VALUE,
|
||||
TEN_PENNY_VALUE,
|
||||
LUCKY_SWING_JOKER_VALUE,
|
||||
WOLFPACK_BONUS,
|
||||
FOUR_OF_A_KIND_BONUS,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +112,10 @@ def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int
|
||||
return SUPER_KINGS_VALUE
|
||||
if card.rank == Rank.TEN and options.ten_penny:
|
||||
return TEN_PENNY_VALUE
|
||||
# One-eyed Jacks: J♥ and J♠ are worth 0 instead of 10
|
||||
if options.one_eyed_jacks:
|
||||
if card.rank == Rank.JACK and card.suit in (Suit.HEARTS, Suit.SPADES):
|
||||
return 0
|
||||
return RANK_VALUES[card.rank]
|
||||
|
||||
|
||||
@@ -301,6 +307,7 @@ class Player:
|
||||
|
||||
total = 0
|
||||
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
||||
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
|
||||
|
||||
for col in range(3):
|
||||
top_idx = col
|
||||
@@ -310,6 +317,8 @@ class Player:
|
||||
|
||||
# Check if column pair matches (same rank cancels out)
|
||||
if top_card.rank == bottom_card.rank:
|
||||
paired_ranks.append(top_card.rank)
|
||||
|
||||
# Track Jack pairs for Wolfpack bonus
|
||||
if top_card.rank == Rank.JACK:
|
||||
jack_pairs += 1
|
||||
@@ -320,6 +329,15 @@ class Player:
|
||||
total -= 4
|
||||
continue
|
||||
|
||||
# Negative Pairs Keep Value: paired 2s/Jokers keep their negative value
|
||||
if options and options.negative_pairs_keep_value:
|
||||
top_val = get_card_value(top_card, options)
|
||||
bottom_val = get_card_value(bottom_card, options)
|
||||
if top_val < 0 or bottom_val < 0:
|
||||
# Keep negative value instead of 0
|
||||
total += top_val + bottom_val
|
||||
continue
|
||||
|
||||
# Normal matching pair: scores 0 (skip adding values)
|
||||
continue
|
||||
|
||||
@@ -327,9 +345,18 @@ class Player:
|
||||
total += get_card_value(top_card, options)
|
||||
total += get_card_value(bottom_card, options)
|
||||
|
||||
# Wolfpack bonus: 2+ pairs of Jacks = -5 pts
|
||||
# Wolfpack bonus: 2+ pairs of Jacks
|
||||
if options and options.wolfpack and jack_pairs >= 2:
|
||||
total -= 5
|
||||
total += WOLFPACK_BONUS # -20
|
||||
|
||||
# Four of a Kind bonus: same rank appears twice in paired_ranks
|
||||
# (meaning 4 cards of that rank across 2 columns)
|
||||
if options and options.four_of_a_kind:
|
||||
rank_counts = Counter(paired_ranks)
|
||||
for rank, count in rank_counts.items():
|
||||
if count >= 2:
|
||||
# Four of a kind! Apply bonus
|
||||
total += FOUR_OF_A_KIND_BONUS # -20
|
||||
|
||||
self.score = total
|
||||
return total
|
||||
@@ -415,6 +442,19 @@ class GameOptions:
|
||||
eagle_eye: bool = False
|
||||
"""Jokers worth +2 unpaired, -4 when paired (instead of -2/0)."""
|
||||
|
||||
# --- House Rules: New Variants (all OFF by default for classic gameplay) ---
|
||||
flip_as_action: bool = False
|
||||
"""Allow using turn to flip a face-down card without drawing."""
|
||||
|
||||
four_of_a_kind: bool = False
|
||||
"""Four equal cards in two columns scores -20 points bonus."""
|
||||
|
||||
negative_pairs_keep_value: bool = False
|
||||
"""Paired 2s and Jokers keep their negative value (-4) instead of becoming 0."""
|
||||
|
||||
one_eyed_jacks: bool = False
|
||||
"""One-eyed Jacks (J♥ and J♠) are worth 0 points instead of 10."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Game:
|
||||
@@ -878,6 +918,45 @@ class Game:
|
||||
self._check_end_turn(player)
|
||||
return True
|
||||
|
||||
def flip_card_as_action(self, player_id: str, card_index: int) -> bool:
|
||||
"""
|
||||
Use turn to flip a face-down card without drawing.
|
||||
|
||||
Only valid if flip_as_action house rule is enabled.
|
||||
This is an alternative to drawing - player flips one of their
|
||||
face-down cards to see what it is, then their turn ends.
|
||||
|
||||
Args:
|
||||
player_id: ID of the player using this action.
|
||||
card_index: Index 0-5 of the card to flip.
|
||||
|
||||
Returns:
|
||||
True if action was valid and turn ended, False otherwise.
|
||||
"""
|
||||
if not self.options.flip_as_action:
|
||||
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
|
||||
|
||||
if not (0 <= card_index < len(player.cards)):
|
||||
return False
|
||||
|
||||
if player.cards[card_index].face_up:
|
||||
return False # Already face-up, can't flip
|
||||
|
||||
player.cards[card_index].face_up = True
|
||||
self._check_end_turn(player)
|
||||
return True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Turn & Round Flow (Internal)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1089,6 +1168,15 @@ class Game:
|
||||
active_rules.append("Blackjack")
|
||||
if self.options.wolfpack:
|
||||
active_rules.append("Wolfpack")
|
||||
# New house rules
|
||||
if self.options.flip_as_action:
|
||||
active_rules.append("Flip as Action")
|
||||
if self.options.four_of_a_kind:
|
||||
active_rules.append("Four of a Kind")
|
||||
if self.options.negative_pairs_keep_value:
|
||||
active_rules.append("Negative Pairs Keep Value")
|
||||
if self.options.one_eyed_jacks:
|
||||
active_rules.append("One-Eyed Jacks")
|
||||
|
||||
return {
|
||||
"phase": self.phase.value,
|
||||
@@ -1108,6 +1196,7 @@ class Game:
|
||||
"flip_on_discard": self.flip_on_discard,
|
||||
"flip_mode": self.options.flip_mode,
|
||||
"flip_is_optional": self.flip_is_optional,
|
||||
"flip_as_action": self.options.flip_as_action,
|
||||
"card_values": self.get_card_values(),
|
||||
"active_rules": active_rules,
|
||||
}
|
||||
|
||||
@@ -575,6 +575,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
blackjack=data.get("blackjack", False),
|
||||
eagle_eye=data.get("eagle_eye", False),
|
||||
wolfpack=data.get("wolfpack", False),
|
||||
# House Rules - New Variants
|
||||
flip_as_action=data.get("flip_as_action", False),
|
||||
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),
|
||||
)
|
||||
|
||||
# Validate settings
|
||||
@@ -688,6 +693,15 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "flip_as_action":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
if current_room.game.flip_card_as_action(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
|
||||
|
||||
Reference in New Issue
Block a user