Break the 666-line choose_swap_or_discard into 8 focused sub-functions, extract named constants for ~15 magic numbers, add column/pair utility functions (iter_columns, project_score, count_hidden, hidden_positions), and extract _log_cpu_action helper to reduce logging boilerplate in process_cpu_turn. No behavior changes - validated with simulate.py 500. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2035 lines
86 KiB
Python
2035 lines
86 KiB
Python
"""AI personalities for CPU players in Golf."""
|
|
|
|
import logging
|
|
import os
|
|
import random
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
from enum import Enum
|
|
|
|
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, Suit, get_card_value
|
|
|
|
|
|
# Debug logging configuration
|
|
# Set AI_DEBUG=1 environment variable to enable detailed AI decision logging
|
|
AI_DEBUG = os.environ.get("AI_DEBUG", "0") == "1"
|
|
|
|
# Create a dedicated logger for AI decisions
|
|
ai_logger = logging.getLogger("golf.ai")
|
|
if AI_DEBUG:
|
|
ai_logger.setLevel(logging.DEBUG)
|
|
# Add console handler if not already present
|
|
if not ai_logger.handlers:
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(logging.Formatter(
|
|
"%(asctime)s [AI] %(message)s", datefmt="%H:%M:%S"
|
|
))
|
|
ai_logger.addHandler(handler)
|
|
|
|
|
|
def ai_log(message: str):
|
|
"""Log AI decision info when AI_DEBUG is enabled."""
|
|
if AI_DEBUG:
|
|
ai_logger.debug(message)
|
|
|
|
|
|
# =============================================================================
|
|
# CPU Turn Timing Configuration (seconds)
|
|
# =============================================================================
|
|
# Centralized timing constants for all CPU turn delays.
|
|
# Adjust these values to tune the "feel" of CPU gameplay.
|
|
|
|
CPU_TIMING = {
|
|
# Delay before CPU "looks at" the discard pile
|
|
"initial_look": (0.3, 0.5),
|
|
# Brief pause after draw broadcast - let draw animation complete
|
|
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
|
|
"post_draw_settle": 1.1,
|
|
# Consideration time after drawing (before swap/discard decision)
|
|
"post_draw_consider": (0.2, 0.4),
|
|
# Variance multiplier range for chaotic personality players
|
|
"thinking_multiplier_chaotic": (0.6, 1.4),
|
|
# Pause after swap/discard to let animation complete and show result
|
|
# Should match unified swap animation duration (~0.5s)
|
|
"post_action_pause": (0.5, 0.7),
|
|
}
|
|
|
|
# Thinking time ranges by card difficulty (seconds)
|
|
THINKING_TIME = {
|
|
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
|
|
"easy_good": (0.15, 0.3),
|
|
# Obviously bad cards (10s, Jacks, Queens) - easy pass
|
|
"easy_bad": (0.15, 0.3),
|
|
# Medium difficulty (3, 4, 8, 9)
|
|
"medium": (0.15, 0.3),
|
|
# Hardest decisions (5, 6, 7 - middle of range)
|
|
"hard": (0.15, 0.3),
|
|
# No discard available - quick decision
|
|
"no_card": (0.15, 0.3),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# AI Decision Constants
|
|
# =============================================================================
|
|
|
|
# Expected value of an unknown (face-down) card, based on deck distribution
|
|
EXPECTED_HIDDEN_VALUE = 4.5
|
|
|
|
# Pessimistic estimate for hidden cards (used in go-out safety checks)
|
|
PESSIMISTIC_HIDDEN_VALUE = 6.0
|
|
|
|
# Conservative estimate (used in opponent score estimation)
|
|
CONSERVATIVE_HIDDEN_VALUE = 2.5
|
|
|
|
# Cards at or above this value should never be swapped into unknown positions
|
|
HIGH_CARD_THRESHOLD = 8
|
|
|
|
# Maximum card value for unpredictability swaps
|
|
UNPREDICTABLE_MAX_VALUE = 7
|
|
|
|
# Pair potential discount when adjacent card matches (25% chance of pair)
|
|
PAIR_POTENTIAL_DISCOUNT = 0.25
|
|
|
|
# Blackjack target score
|
|
BLACKJACK_TARGET = 21
|
|
|
|
# Base acceptable score range for go-out decisions
|
|
GO_OUT_SCORE_BASE = 12
|
|
GO_OUT_SCORE_MAX = 20
|
|
|
|
# Personality tie-breaker threshold (options within this many points are "close")
|
|
TIE_BREAKER_THRESHOLD = 2.0
|
|
|
|
# Alias for backwards compatibility - use the centralized function from game.py
|
|
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
|
"""Get card value with house rules applied for AI decisions.
|
|
|
|
This is an alias for game.get_card_value() for backwards compatibility.
|
|
"""
|
|
return get_card_value(card, options)
|
|
|
|
|
|
def can_make_pair(card1: Card, card2: Card) -> bool:
|
|
"""Check if two cards can form a pair."""
|
|
return card1.rank == card2.rank
|
|
|
|
|
|
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
|
|
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
|
|
|
|
Easy decisions (obviously good or bad cards) = quick
|
|
Hard decisions (medium value cards) = slower
|
|
|
|
Returns time in seconds. Uses THINKING_TIME constants.
|
|
"""
|
|
if not card:
|
|
# No discard available - quick decision to draw from deck
|
|
t = THINKING_TIME["no_card"]
|
|
return random.uniform(t[0], t[1])
|
|
|
|
value = get_card_value(card, options)
|
|
|
|
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
|
|
if value <= 1:
|
|
t = THINKING_TIME["easy_good"]
|
|
return random.uniform(t[0], t[1])
|
|
|
|
# Obviously bad cards (easy pass): 10, J, Q (value 10)
|
|
if value >= 10:
|
|
t = THINKING_TIME["easy_bad"]
|
|
return random.uniform(t[0], t[1])
|
|
|
|
# Medium cards require more thought: 3-9
|
|
# 5, 6, 7 are the hardest decisions (middle of the range)
|
|
if value in (5, 6, 7):
|
|
t = THINKING_TIME["hard"]
|
|
return random.uniform(t[0], t[1])
|
|
|
|
# 3, 4, 8, 9 - moderate difficulty
|
|
t = THINKING_TIME["medium"]
|
|
return random.uniform(t[0], t[1])
|
|
|
|
|
|
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
|
|
"""Estimate minimum opponent score from visible cards.
|
|
|
|
Args:
|
|
player: The player making the estimation (excluded from opponents)
|
|
game: The game state
|
|
optimistic: If True, assume opponents' hidden cards are average (4.5).
|
|
If False, assume opponents could get lucky (lower estimate).
|
|
"""
|
|
min_est = 999
|
|
for p in game.players:
|
|
if p.id == player.id:
|
|
continue
|
|
visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
|
|
hidden = sum(1 for c in p.cards if not c.face_up)
|
|
|
|
if optimistic:
|
|
# Assume average hidden cards
|
|
estimate = visible + int(hidden * EXPECTED_HIDDEN_VALUE)
|
|
else:
|
|
# Assume opponents could get lucky - hidden cards might be low
|
|
# or could complete pairs, so use lower estimate
|
|
# Check for potential pairs in opponent's hand
|
|
pair_potential = 0
|
|
for col in range(3):
|
|
top, bot = p.cards[col], p.cards[col + 3]
|
|
# If one card is visible and the other is hidden, there's pair potential
|
|
if top.face_up and not bot.face_up:
|
|
pair_potential += get_ai_card_value(top, game.options)
|
|
elif bot.face_up and not top.face_up:
|
|
pair_potential += get_ai_card_value(bot, game.options)
|
|
|
|
# Conservative estimate: assume low avg for hidden (could be low cards)
|
|
# and subtract some pair potential (hidden cards might match visible)
|
|
base_estimate = visible + int(hidden * CONSERVATIVE_HIDDEN_VALUE)
|
|
estimate = base_estimate - int(pair_potential * PAIR_POTENTIAL_DISCOUNT)
|
|
|
|
min_est = min(min_est, estimate)
|
|
return min_est
|
|
|
|
|
|
def get_end_game_pressure(player: Player, game: Game) -> float:
|
|
"""
|
|
Calculate pressure level based on how close opponents are to going out.
|
|
Returns 0.0-1.0 where higher means more pressure to improve hand NOW.
|
|
|
|
Pressure increases when:
|
|
- Opponents have few hidden cards (close to going out)
|
|
- We have many hidden cards (stuck with unknown values)
|
|
"""
|
|
my_hidden = sum(1 for c in player.cards if not c.face_up)
|
|
|
|
# Find the opponent closest to going out
|
|
min_opponent_hidden = 6
|
|
for p in game.players:
|
|
if p.id == player.id:
|
|
continue
|
|
opponent_hidden = sum(1 for c in p.cards if not c.face_up)
|
|
min_opponent_hidden = min(min_opponent_hidden, opponent_hidden)
|
|
|
|
# No pressure if opponents have lots of hidden cards
|
|
if min_opponent_hidden >= 4:
|
|
return 0.0
|
|
|
|
# Pressure scales based on how close opponent is to finishing
|
|
# 3 hidden = mild pressure (0.4), 2 hidden = medium (0.7), 1 hidden = high (0.9), 0 = max (1.0)
|
|
base_pressure = {0: 1.0, 1: 0.9, 2: 0.7, 3: 0.4}.get(min_opponent_hidden, 0.0)
|
|
|
|
# Increase pressure further if WE have many hidden cards (more unknowns to worry about)
|
|
hidden_risk_bonus = (my_hidden - 2) * 0.05 # +0.05 per hidden card above 2
|
|
hidden_risk_bonus = max(0, hidden_risk_bonus)
|
|
|
|
return min(1.0, base_pressure + hidden_risk_bonus)
|
|
|
|
|
|
def get_standings_pressure(player: Player, game: Game) -> float:
|
|
"""
|
|
Calculate pressure based on player's position in standings.
|
|
Returns 0.0-1.0 where higher = more behind, needs aggressive play.
|
|
|
|
Factors:
|
|
- How far behind the leader in total_score
|
|
- How late in the game (current_round / num_rounds)
|
|
"""
|
|
if len(game.players) < 2 or game.num_rounds <= 1:
|
|
return 0.0
|
|
|
|
# Calculate standings gap
|
|
scores = [p.total_score for p in game.players]
|
|
leader_score = min(scores) # Lower is better in golf
|
|
my_score = player.total_score
|
|
gap = my_score - leader_score # Positive = behind
|
|
|
|
# Normalize gap (assume ~10 pts/round average, 20+ behind is dire)
|
|
gap_pressure = min(gap / 20.0, 1.0) if gap > 0 else 0.0
|
|
|
|
# Late-game multiplier (ramps up in final third of game)
|
|
round_progress = game.current_round / game.num_rounds
|
|
late_game_factor = max(0, (round_progress - 0.66) * 3) # 0 until 66%, then ramps to 1
|
|
|
|
return min(gap_pressure * (1 + late_game_factor), 1.0)
|
|
|
|
|
|
def count_rank_in_hand(player: Player, rank: Rank) -> int:
|
|
"""Count how many cards of a given rank the player has visible."""
|
|
return sum(1 for c in player.cards if c.face_up and c.rank == rank)
|
|
|
|
|
|
def count_visible_cards_by_rank(game: Game) -> dict[Rank, int]:
|
|
"""
|
|
Count all visible cards of each rank across the entire table.
|
|
Includes: all face-up player cards + top of discard pile.
|
|
|
|
Note: Buried discard cards are NOT counted because they reshuffle
|
|
back into the deck when it empties.
|
|
"""
|
|
counts: dict[Rank, int] = {rank: 0 for rank in Rank}
|
|
|
|
# Count all face-up cards in all players' hands
|
|
for player in game.players:
|
|
for card in player.cards:
|
|
if card.face_up:
|
|
counts[card.rank] += 1
|
|
|
|
# Count top of discard pile (the only visible discard)
|
|
discard_top = game.discard_top()
|
|
if discard_top:
|
|
counts[discard_top.rank] += 1
|
|
|
|
return counts
|
|
|
|
|
|
def get_pair_viability(rank: Rank, game: Game, exclude_discard_top: bool = False) -> float:
|
|
"""
|
|
Calculate how viable it is to pair a card of this rank.
|
|
Returns 0.0-1.0 where higher means better odds of finding a pair.
|
|
|
|
In a standard deck: 4 of each rank (2 Jokers).
|
|
If you can see N cards of that rank, only (4-N) remain.
|
|
|
|
Args:
|
|
rank: The rank we want to pair
|
|
exclude_discard_top: If True, don't count discard top (useful when
|
|
evaluating taking that card - it won't be visible after)
|
|
"""
|
|
counts = count_visible_cards_by_rank(game)
|
|
visible = counts.get(rank, 0)
|
|
|
|
# Adjust if we're evaluating the discard top card itself
|
|
if exclude_discard_top:
|
|
discard_top = game.discard_top()
|
|
if discard_top and discard_top.rank == rank:
|
|
visible = max(0, visible - 1)
|
|
|
|
# Cards in deck for this rank
|
|
max_copies = 2 if rank == Rank.JOKER else 4
|
|
remaining = max(0, max_copies - visible)
|
|
|
|
# Viability scales with remaining copies
|
|
# 4 remaining = 1.0, 3 = 0.75, 2 = 0.5, 1 = 0.25, 0 = 0.0
|
|
return remaining / max_copies
|
|
|
|
|
|
def get_game_phase(game: Game) -> str:
|
|
"""
|
|
Determine current game phase based on average hidden cards.
|
|
Returns: 'early', 'mid', or 'late'
|
|
"""
|
|
total_hidden = sum(
|
|
sum(1 for c in p.cards if not c.face_up)
|
|
for p in game.players
|
|
)
|
|
avg_hidden = total_hidden / len(game.players) if game.players else 6
|
|
|
|
if avg_hidden >= EXPECTED_HIDDEN_VALUE:
|
|
return 'early'
|
|
elif avg_hidden >= 2.5:
|
|
return 'mid'
|
|
else:
|
|
return 'late'
|
|
|
|
|
|
def get_next_player(game: Game, current_player: Player) -> Optional[Player]:
|
|
"""Get the player who plays after current_player in turn order."""
|
|
if len(game.players) <= 1:
|
|
return None
|
|
current_idx = next(
|
|
(i for i, p in enumerate(game.players) if p.id == current_player.id),
|
|
None
|
|
)
|
|
if current_idx is None:
|
|
return None
|
|
next_idx = (current_idx + 1) % len(game.players)
|
|
return game.players[next_idx]
|
|
|
|
|
|
def would_help_opponent_pair(card: Card, opponent: Player) -> tuple[bool, Optional[int]]:
|
|
"""
|
|
Check if discarding this card would give opponent a pair opportunity.
|
|
|
|
Returns:
|
|
(would_help, opponent_position) - True if opponent has an unpaired visible
|
|
card of the same rank, along with the position of that card.
|
|
"""
|
|
for i, opp_card in enumerate(opponent.cards):
|
|
if opp_card.face_up and opp_card.rank == card.rank:
|
|
# Check if this card is already paired
|
|
partner_pos = get_column_partner_position(i)
|
|
partner = opponent.cards[partner_pos]
|
|
if partner.face_up and partner.rank == card.rank:
|
|
continue # Already paired, no benefit to them
|
|
# They have an unpaired visible card of this rank!
|
|
return True, i
|
|
return False, None
|
|
|
|
|
|
def calculate_denial_value(
|
|
card: Card,
|
|
opponent: Player,
|
|
game: Game,
|
|
options: GameOptions
|
|
) -> float:
|
|
"""
|
|
Calculate how valuable it would be to deny this card to the next opponent.
|
|
|
|
Returns a score from 0 (no denial value) to ~15 (high denial value).
|
|
Higher values mean we should consider NOT discarding this card.
|
|
"""
|
|
would_help, opp_pos = would_help_opponent_pair(card, opponent)
|
|
if not would_help or opp_pos is None:
|
|
return 0.0
|
|
|
|
card_value = get_ai_card_value(card, options)
|
|
|
|
# Base denial value = how many points we'd save them by denying the pair
|
|
# If they pair a 9, they save 18 points (9 + 9 -> 0)
|
|
if card_value >= 0:
|
|
denial_value = card_value * 2 # Pairing saves them 2x the card value
|
|
else:
|
|
# Negative cards (2s, Jokers) - pairing actually WASTES their value
|
|
# Less denial value since we WANT them to waste their negative cards
|
|
denial_value = 2.0 # Small denial value - pairing is still annoying
|
|
|
|
# Adjust for game phase - denial matters more in late game
|
|
phase = get_game_phase(game)
|
|
if phase == 'late':
|
|
denial_value *= 1.5
|
|
elif phase == 'early':
|
|
denial_value *= 0.7
|
|
|
|
# Adjust for how close opponent is to going out
|
|
opponent_hidden = sum(1 for c in opponent.cards if not c.face_up)
|
|
if opponent_hidden <= 1:
|
|
denial_value *= 1.8 # Critical to deny when they're about to go out
|
|
elif opponent_hidden <= 2:
|
|
denial_value *= 1.3
|
|
|
|
return denial_value
|
|
|
|
|
|
def has_worse_visible_card(player: Player, card_value: int, options: GameOptions) -> bool:
|
|
"""Check if player has a visible card worse than the given value.
|
|
|
|
Used to determine if taking a card from discard makes sense -
|
|
we should only take if we have something worse to replace.
|
|
"""
|
|
for c in player.cards:
|
|
if c.face_up and get_ai_card_value(c, options) > card_value:
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_column_partner_position(pos: int) -> int:
|
|
"""Get the column partner position for a given position.
|
|
|
|
Column pairs: (0,3), (1,4), (2,5)
|
|
"""
|
|
return (pos + 3) % 6 if pos < 3 else pos - 3
|
|
|
|
|
|
# =============================================================================
|
|
# Column/Pair Utility Functions
|
|
# =============================================================================
|
|
|
|
def iter_columns(player: Player):
|
|
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
|
|
for col in range(3):
|
|
top_idx = col
|
|
bot_idx = col + 3
|
|
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
|
|
|
|
|
|
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
|
|
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
|
|
|
|
Handles pair cancellation correctly. Used by multiple decision paths.
|
|
"""
|
|
total = 0
|
|
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
|
|
# Substitute the new card if it's in this column
|
|
effective_top = new_card if top_idx == swap_pos else top_card
|
|
effective_bot = new_card if bot_idx == swap_pos else bot_card
|
|
|
|
if effective_top.rank == effective_bot.rank:
|
|
# Pair cancels (standard rules)
|
|
continue
|
|
total += get_ai_card_value(effective_top, options)
|
|
total += get_ai_card_value(effective_bot, options)
|
|
return total
|
|
|
|
|
|
def count_hidden(player: Player) -> int:
|
|
"""Count face-down cards."""
|
|
return sum(1 for c in player.cards if not c.face_up)
|
|
|
|
|
|
def hidden_positions(player: Player) -> list[int]:
|
|
"""Get indices of face-down cards."""
|
|
return [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
|
|
|
|
def visible_score_excluding_column(player: Player, exclude_col_pos: int, options: GameOptions) -> int:
|
|
"""Calculate score from visible columns, excluding the column containing exclude_col_pos.
|
|
|
|
Used in go-out calculations where one column has special handling.
|
|
"""
|
|
exclude_col = exclude_col_pos if exclude_col_pos < 3 else exclude_col_pos - 3
|
|
total = 0
|
|
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
|
|
if col == exclude_col:
|
|
continue
|
|
if top_card.face_up and bot_card.face_up:
|
|
if top_card.rank == bot_card.rank:
|
|
continue # Pair = 0
|
|
total += get_ai_card_value(top_card, options)
|
|
total += get_ai_card_value(bot_card, options)
|
|
elif top_card.face_up:
|
|
total += get_ai_card_value(top_card, options)
|
|
elif bot_card.face_up:
|
|
total += get_ai_card_value(bot_card, options)
|
|
return total
|
|
|
|
|
|
def filter_bad_pair_positions(
|
|
positions: list[int],
|
|
drawn_card: Card,
|
|
player: Player,
|
|
options: GameOptions
|
|
) -> list[int]:
|
|
"""Filter out positions that would create wasteful pairs with negative cards.
|
|
|
|
When placing a card (especially negative value cards like 2s or Jokers),
|
|
we should avoid positions where the column partner is a visible card of
|
|
the same rank - pairing negative cards wastes their value.
|
|
|
|
Args:
|
|
positions: List of candidate positions
|
|
drawn_card: The card we're placing
|
|
player: The player's hand
|
|
options: Game options for house rules
|
|
|
|
Returns:
|
|
Filtered list excluding bad pair positions. If all positions are bad,
|
|
returns the original list (we have to place somewhere).
|
|
"""
|
|
drawn_value = get_ai_card_value(drawn_card, options)
|
|
|
|
# Only filter if the drawn card has negative value (2s, Jokers, super_kings Kings)
|
|
# Pairing positive cards is fine - it turns their value to 0
|
|
if drawn_value >= 0:
|
|
return positions
|
|
|
|
# Exception: Eagle Eye makes pairing Jokers GOOD (-4 instead of 0)
|
|
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)
|
|
partner = player.cards[partner_pos]
|
|
|
|
# If partner is face-up and same rank, this would create a wasteful pair
|
|
if partner.face_up and partner.rank == drawn_card.rank:
|
|
continue # Skip this position
|
|
|
|
filtered.append(pos)
|
|
|
|
# If all positions were filtered out, return original (must place somewhere)
|
|
return filtered if filtered else positions
|
|
|
|
|
|
@dataclass
|
|
class CPUProfile:
|
|
"""Pre-defined CPU player profile with personality traits."""
|
|
name: str
|
|
style: str # Brief description shown to players
|
|
# Tipping point: swap if card value is at or above this (4-8)
|
|
swap_threshold: int
|
|
# How likely to hold high cards hoping for pairs (0.0-1.0)
|
|
pair_hope: float
|
|
# Screw your neighbor: tendency to go out early (0.0-1.0)
|
|
aggression: float
|
|
# Wildcard factor: chance of unexpected plays (0.0-0.3)
|
|
unpredictability: float
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"name": self.name,
|
|
"style": self.style,
|
|
}
|
|
|
|
|
|
# Pre-defined CPU profiles (3 female, 3 male, 2 non-binary)
|
|
CPU_PROFILES = [
|
|
# Female profiles
|
|
CPUProfile(
|
|
name="Sofia",
|
|
style="Calculated & Patient",
|
|
swap_threshold=4,
|
|
pair_hope=0.2,
|
|
aggression=0.2,
|
|
unpredictability=0.02,
|
|
),
|
|
CPUProfile(
|
|
name="Maya",
|
|
style="Aggressive Closer",
|
|
swap_threshold=6,
|
|
pair_hope=0.4,
|
|
aggression=0.85,
|
|
unpredictability=0.1,
|
|
),
|
|
CPUProfile(
|
|
name="Priya",
|
|
style="Pair Hunter",
|
|
swap_threshold=7,
|
|
pair_hope=0.8,
|
|
aggression=0.5,
|
|
unpredictability=0.05,
|
|
),
|
|
# Male profiles
|
|
CPUProfile(
|
|
name="Marcus",
|
|
style="Steady Eddie",
|
|
swap_threshold=5,
|
|
pair_hope=0.35,
|
|
aggression=0.4,
|
|
unpredictability=0.03,
|
|
),
|
|
CPUProfile(
|
|
name="Kenji",
|
|
style="Risk Taker",
|
|
swap_threshold=8,
|
|
pair_hope=0.7,
|
|
aggression=0.75,
|
|
unpredictability=0.12,
|
|
),
|
|
CPUProfile(
|
|
name="Diego",
|
|
style="Chaotic Gambler",
|
|
swap_threshold=6,
|
|
pair_hope=0.5,
|
|
aggression=0.6,
|
|
unpredictability=0.28,
|
|
),
|
|
# Non-binary profiles
|
|
CPUProfile(
|
|
name="River",
|
|
style="Adaptive Strategist",
|
|
swap_threshold=5,
|
|
pair_hope=0.45,
|
|
aggression=0.55,
|
|
unpredictability=0.08,
|
|
),
|
|
CPUProfile(
|
|
name="Sage",
|
|
style="Sneaky Finisher",
|
|
swap_threshold=5,
|
|
pair_hope=0.3,
|
|
aggression=0.9,
|
|
unpredictability=0.15,
|
|
),
|
|
]
|
|
|
|
# Track profiles per room (room_code -> set of used profile names)
|
|
_room_used_profiles: dict[str, set[str]] = {}
|
|
# Track cpu_id -> (room_code, profile) mapping
|
|
_cpu_profiles: dict[str, tuple[str, CPUProfile]] = {}
|
|
|
|
|
|
def get_available_profile(room_code: str) -> Optional[CPUProfile]:
|
|
"""Get a random available CPU profile for a specific room."""
|
|
used_in_room = _room_used_profiles.get(room_code, set())
|
|
available = [p for p in CPU_PROFILES if p.name not in used_in_room]
|
|
if not available:
|
|
return None
|
|
profile = random.choice(available)
|
|
if room_code not in _room_used_profiles:
|
|
_room_used_profiles[room_code] = set()
|
|
_room_used_profiles[room_code].add(profile.name)
|
|
return profile
|
|
|
|
|
|
def release_profile(name: str, room_code: str):
|
|
"""Release a CPU profile back to the room's pool."""
|
|
if room_code in _room_used_profiles:
|
|
_room_used_profiles[room_code].discard(name)
|
|
# Clean up empty room entries
|
|
if not _room_used_profiles[room_code]:
|
|
del _room_used_profiles[room_code]
|
|
# Also remove from cpu_profiles by finding the cpu_id with this profile in this room
|
|
to_remove = [
|
|
cpu_id for cpu_id, (rc, profile) in _cpu_profiles.items()
|
|
if profile.name == name and rc == room_code
|
|
]
|
|
for cpu_id in to_remove:
|
|
del _cpu_profiles[cpu_id]
|
|
|
|
|
|
def cleanup_room_profiles(room_code: str):
|
|
"""Clean up all profile tracking for a room when it's deleted."""
|
|
if room_code in _room_used_profiles:
|
|
del _room_used_profiles[room_code]
|
|
# Remove all cpu_profiles for this room
|
|
to_remove = [cpu_id for cpu_id, (rc, _) in _cpu_profiles.items() if rc == room_code]
|
|
for cpu_id in to_remove:
|
|
del _cpu_profiles[cpu_id]
|
|
|
|
|
|
def reset_all_profiles():
|
|
"""Reset all profile tracking (for cleanup)."""
|
|
_room_used_profiles.clear()
|
|
_cpu_profiles.clear()
|
|
|
|
|
|
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
|
|
"""Get the profile for a CPU player."""
|
|
entry = _cpu_profiles.get(cpu_id)
|
|
return entry[1] if entry else None
|
|
|
|
|
|
def assign_profile(cpu_id: str, room_code: str) -> Optional[CPUProfile]:
|
|
"""Assign a random profile to a CPU player in a specific room."""
|
|
profile = get_available_profile(room_code)
|
|
if profile:
|
|
_cpu_profiles[cpu_id] = (room_code, profile)
|
|
return profile
|
|
|
|
|
|
def assign_specific_profile(cpu_id: str, profile_name: str, room_code: str) -> Optional[CPUProfile]:
|
|
"""Assign a specific profile to a CPU player by name in a specific room."""
|
|
used_in_room = _room_used_profiles.get(room_code, set())
|
|
# Check if profile exists and is available in this room
|
|
for profile in CPU_PROFILES:
|
|
if profile.name == profile_name and profile.name not in used_in_room:
|
|
if room_code not in _room_used_profiles:
|
|
_room_used_profiles[room_code] = set()
|
|
_room_used_profiles[room_code].add(profile.name)
|
|
_cpu_profiles[cpu_id] = (room_code, profile)
|
|
return profile
|
|
return None
|
|
|
|
|
|
def get_available_profiles(room_code: str) -> list[dict]:
|
|
"""Get available CPU profiles for a specific room."""
|
|
used_in_room = _room_used_profiles.get(room_code, set())
|
|
return [p.to_dict() for p in CPU_PROFILES if p.name not in used_in_room]
|
|
|
|
|
|
def get_all_profiles() -> list[dict]:
|
|
"""Get all CPU profiles for display."""
|
|
return [p.to_dict() for p in CPU_PROFILES]
|
|
|
|
|
|
class GolfAI:
|
|
"""AI decision-making for Golf game."""
|
|
|
|
@staticmethod
|
|
def choose_initial_flips(count: int = 2) -> list[int]:
|
|
"""Choose cards to flip at the start."""
|
|
if count == 0:
|
|
return []
|
|
if count == 1:
|
|
return [random.randint(0, 5)]
|
|
|
|
# For 2 cards, prefer different columns for pair info
|
|
options = [
|
|
[0, 4], [2, 4], [3, 1], [5, 1],
|
|
[0, 5], [2, 3],
|
|
]
|
|
return random.choice(options)
|
|
|
|
@staticmethod
|
|
def should_take_discard(discard_card: Optional[Card], player: Player,
|
|
profile: CPUProfile, game: Game) -> bool:
|
|
"""Decide whether to take from discard pile or deck."""
|
|
if not discard_card:
|
|
return False
|
|
|
|
options = game.options
|
|
discard_value = get_ai_card_value(discard_card, options)
|
|
|
|
ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")
|
|
|
|
# SAFEGUARD: If we have only 1 face-down card, taking from discard
|
|
# forces us to swap and go out. Check if that would be acceptable.
|
|
face_down = hidden_positions(player)
|
|
if len(face_down) == 1:
|
|
projected_score = project_score(player, face_down[0], discard_card, options)
|
|
|
|
# Don't take if score would be terrible
|
|
max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
|
|
ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
|
|
if projected_score > max_acceptable:
|
|
# Exception: still take if it's an excellent card (Joker, 2, King, Ace)
|
|
# and we have a visible bad card to replace instead
|
|
if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
|
|
ai_log(f" >> REJECT: would force go-out with {projected_score} pts")
|
|
return False # Don't take - would force bad go-out
|
|
|
|
# Unpredictable players occasionally make random choice
|
|
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
|
|
if random.random() < profile.unpredictability:
|
|
if discard_value <= 5:
|
|
return random.choice([True, False])
|
|
|
|
# Always take Jokers and Kings (even better with house rules)
|
|
if discard_card.rank == Rank.JOKER:
|
|
# Eagle Eye: If we have a visible Joker, take to pair them (doubled negative!)
|
|
if options.eagle_eye:
|
|
for card in player.cards:
|
|
if card.face_up and card.rank == Rank.JOKER:
|
|
ai_log(f" >> TAKE: Joker for Eagle Eye pair")
|
|
return True
|
|
ai_log(f" >> TAKE: Joker (always take)")
|
|
return True
|
|
|
|
if discard_card.rank == Rank.KING:
|
|
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
|
|
|
|
# Wolfpack pursuit: Take Jacks when pursuing the bonus
|
|
if options.wolfpack and discard_card.rank == Rank.JACK:
|
|
jack_count = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)
|
|
if jack_count >= 2 and profile.aggression > 0.5:
|
|
ai_log(f" >> TAKE: Jack for wolfpack pursuit ({jack_count} Jacks visible)")
|
|
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)")
|
|
return True
|
|
|
|
# Four-of-a-kind pursuit: Take cards when building toward bonus
|
|
if options.four_of_a_kind and profile.aggression > 0.5:
|
|
rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank)
|
|
if rank_count >= 2:
|
|
# Already have 2+ of this rank, take to pursue four-of-a-kind!
|
|
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
|
return True
|
|
|
|
# Take card if it could make a column pair (but NOT for negative value cards)
|
|
# Pairing negative cards is bad - you lose the negative benefit
|
|
if discard_value > 0:
|
|
for i, card in enumerate(player.cards):
|
|
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
|
pair_card = player.cards[pair_pos]
|
|
|
|
# Direct rank match
|
|
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
|
ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}")
|
|
return True
|
|
|
|
# Take low cards (using house rule adjusted values)
|
|
# Threshold adjusts by game phase - early game be picky, late game less so
|
|
phase = get_game_phase(game)
|
|
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
|
|
|
if discard_value <= base_threshold:
|
|
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
|
|
return True
|
|
|
|
# For marginal cards (not auto-take), preview swap scores before committing.
|
|
# Taking from discard FORCES a swap - don't take if no good swap exists.
|
|
def has_good_swap_option() -> bool:
|
|
"""Preview swap scores to check if any position is worth swapping into."""
|
|
for pos in range(6):
|
|
score = GolfAI.calculate_swap_score(
|
|
pos, discard_card, discard_value, player, options, game, profile
|
|
)
|
|
if score > 0:
|
|
return True
|
|
return False
|
|
|
|
# Calculate end-game pressure from opponents close to going out
|
|
pressure = get_end_game_pressure(player, game)
|
|
|
|
# Under pressure, expand what we consider "worth taking"
|
|
# When opponents are close to going out, take decent cards to avoid
|
|
# getting stuck with unknown bad cards when the round ends
|
|
if pressure > 0.2:
|
|
# Scale threshold: at pressure 0.2 take 4s, at 0.5+ take 6s
|
|
pressure_threshold = 3 + int(pressure * 6) # 4 to 9 based on pressure
|
|
pressure_threshold = min(pressure_threshold, 7) # Cap at 7
|
|
if discard_value <= pressure_threshold:
|
|
# Only take if we have hidden cards that could be worse
|
|
if count_hidden(player) > 0:
|
|
# CRITICAL: Verify there's actually a good swap position
|
|
if has_good_swap_option():
|
|
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
|
return True
|
|
else:
|
|
ai_log(f" >> SKIP: pressure would take, but no good swap position")
|
|
|
|
# Check if we have cards worse than the discard
|
|
worst_visible = -999
|
|
for card in player.cards:
|
|
if card.face_up:
|
|
worst_visible = max(worst_visible, get_ai_card_value(card, options))
|
|
|
|
if worst_visible > discard_value + 1:
|
|
# Sanity check: only take if we actually have something worse to replace
|
|
# This prevents taking a bad card when all visible cards are better
|
|
if has_worse_visible_card(player, discard_value, options):
|
|
# CRITICAL: Verify there's actually a good swap position
|
|
if has_good_swap_option():
|
|
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
|
return True
|
|
else:
|
|
ai_log(f" >> SKIP: have worse card, but no good swap position")
|
|
|
|
ai_log(f" >> PASS: drawing from deck instead")
|
|
return False
|
|
|
|
@staticmethod
|
|
def calculate_swap_score(
|
|
pos: int,
|
|
drawn_card: Card,
|
|
drawn_value: int,
|
|
player: Player,
|
|
options: GameOptions,
|
|
game: Game,
|
|
profile: CPUProfile
|
|
) -> float:
|
|
"""
|
|
Calculate a score for swapping into a specific position.
|
|
Higher score = better swap. Weighs all incentives:
|
|
- Pair bonus (highest priority for positive cards)
|
|
- Point gain from replacement
|
|
- Reveal bonus for hidden cards
|
|
- Go-out safety check
|
|
|
|
Personality traits affect weights:
|
|
- pair_hope: higher = values pairing more, lower = prefers spreading
|
|
- aggression: higher = more willing to go out, take risks
|
|
- swap_threshold: affects how picky about card values
|
|
"""
|
|
current_card = player.cards[pos]
|
|
partner_pos = get_column_partner_position(pos)
|
|
partner_card = player.cards[partner_pos]
|
|
|
|
score = 0.0
|
|
|
|
# Personality-based weight modifiers
|
|
# pair_hope: 0.0-1.0, affects how much we value pairing vs spreading
|
|
pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0
|
|
spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse)
|
|
|
|
# 1. PAIR BONUS - Creating a pair
|
|
# pair_hope affects how much we value this
|
|
if partner_card.face_up and partner_card.rank == drawn_card.rank:
|
|
partner_value = get_ai_card_value(partner_card, options)
|
|
|
|
if drawn_value >= 0:
|
|
# Good pair! Both cards cancel to 0
|
|
pair_bonus = drawn_value + partner_value
|
|
score += pair_bonus * pair_weight # Pair hunters value this more
|
|
else:
|
|
# Pairing negative cards
|
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
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:
|
|
# Standard rules: penalty for wasting negative cards
|
|
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
|
score -= penalty
|
|
ai_log(f" Negative pair penalty at pos {pos}: -{penalty:.1f} (score now={score:.2f})")
|
|
|
|
# 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
|
|
# Players with low pair_hope prefer spreading aces/2s across columns
|
|
if not partner_card.face_up or partner_card.rank != drawn_card.rank:
|
|
if drawn_value <= 1: # Excellent cards (K, 2, A, Joker)
|
|
# Small bonus for spreading - scales with spread preference
|
|
score += spread_weight * 0.5
|
|
|
|
# 2. POINT GAIN - Direct value improvement
|
|
if current_card.face_up:
|
|
current_value = get_ai_card_value(current_card, options)
|
|
|
|
# CRITICAL: Check if current card is part of an existing column pair
|
|
# If so, breaking the pair is usually terrible - the paired column is worth 0,
|
|
# but after breaking it becomes (drawn_value + orphaned_partner_value)
|
|
if partner_card.face_up and partner_card.rank == current_card.rank:
|
|
partner_value = get_ai_card_value(partner_card, options)
|
|
|
|
# Determine the current column value (what the pair contributes)
|
|
if options.eagle_eye and current_card.rank == Rank.JOKER:
|
|
# Eagle Eye: paired jokers contribute -4 total
|
|
old_column_value = -4
|
|
# After swap: orphan joker becomes +2 (unpaired eagle_eye value)
|
|
new_column_value = drawn_value + 2
|
|
point_gain = old_column_value - new_column_value
|
|
ai_log(f" Breaking Eagle Eye joker pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
elif options.negative_pairs_keep_value and (current_value < 0 or partner_value < 0):
|
|
# Negative pairs keep value: column is worth sum of both values
|
|
old_column_value = current_value + partner_value
|
|
new_column_value = drawn_value + partner_value
|
|
point_gain = old_column_value - new_column_value
|
|
ai_log(f" Breaking negative-keep pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
else:
|
|
# Standard pair - column is worth 0
|
|
# After swap: column becomes drawn_value + partner_value
|
|
old_column_value = 0
|
|
new_column_value = drawn_value + partner_value
|
|
point_gain = old_column_value - new_column_value
|
|
ai_log(f" Breaking standard pair at pos {pos}: column 0 -> {new_column_value}, gain={point_gain}")
|
|
elif partner_card.face_up and partner_card.rank == drawn_card.rank:
|
|
# CREATING a new pair (drawn matches partner, but current doesn't)
|
|
# Calculate column change properly
|
|
old_column_value = current_value + partner_value
|
|
# Determine new column value based on rules
|
|
if drawn_value < 0 and not options.negative_pairs_keep_value:
|
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
new_column_value = -4
|
|
else:
|
|
new_column_value = 0 # Negative pair under standard rules
|
|
elif options.negative_pairs_keep_value and (drawn_value < 0 or partner_value < 0):
|
|
new_column_value = drawn_value + partner_value
|
|
else:
|
|
new_column_value = 0 # Standard positive pair
|
|
point_gain = old_column_value - new_column_value
|
|
ai_log(f" Creating pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
else:
|
|
# No existing pair, not creating pair - normal calculation
|
|
point_gain = current_value - drawn_value
|
|
|
|
score += point_gain
|
|
else:
|
|
# Hidden card - expected value ~4.5
|
|
# BUT: Don't add this bonus if we're creating a negative pair
|
|
# (the pair penalty already accounts for the bad outcome)
|
|
creates_negative_pair = (
|
|
partner_card.face_up and
|
|
partner_card.rank == drawn_card.rank and
|
|
drawn_value < 0 and
|
|
not options.negative_pairs_keep_value and
|
|
not (options.eagle_eye and drawn_card.rank == Rank.JOKER)
|
|
)
|
|
if not creates_negative_pair:
|
|
expected_hidden = EXPECTED_HIDDEN_VALUE
|
|
point_gain = expected_hidden - drawn_value
|
|
# Conservative players (low swap_threshold) discount uncertain gains more
|
|
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
|
score += point_gain * discount
|
|
|
|
# 3. REVEAL BONUS - Value of revealing hidden cards
|
|
# More aggressive players want to reveal faster to go out
|
|
if not current_card.face_up:
|
|
reveal_bonus = min(count_hidden(player), 4)
|
|
|
|
# Aggressive players get bigger reveal bonus (want to go out faster)
|
|
aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2
|
|
|
|
# Scale by card quality
|
|
if drawn_value <= 0: # Excellent
|
|
score += reveal_bonus * 1.2 * aggression_multiplier
|
|
elif drawn_value == 1: # Great
|
|
score += reveal_bonus * 1.0 * aggression_multiplier
|
|
elif drawn_value <= 4: # Good
|
|
score += reveal_bonus * 0.6 * aggression_multiplier
|
|
elif drawn_value <= 6: # Medium
|
|
score += reveal_bonus * 0.3 * aggression_multiplier
|
|
# Bad cards: no reveal bonus
|
|
|
|
# 4. FUTURE PAIR POTENTIAL
|
|
# Pair hunters value positions where both cards are hidden
|
|
if not current_card.face_up and not partner_card.face_up:
|
|
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
|
|
# Boost when behind in standings
|
|
standings_pressure = get_standings_pressure(player, game)
|
|
if standings_pressure > 0.3:
|
|
four_kind_bonus *= (1 + standings_pressure * 0.5) # Up to 50% boost
|
|
score += four_kind_bonus
|
|
ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus:.1f}")
|
|
|
|
# 4c. WOLFPACK PURSUIT - Aggressive players chase Jack pairs for -20 bonus
|
|
if options.wolfpack and profile.aggression > 0.5:
|
|
# Count Jack pairs already formed
|
|
jack_pair_count = 0
|
|
for col in range(3):
|
|
top, bot = player.cards[col], player.cards[col + 3]
|
|
if top.face_up and bot.face_up and top.rank == Rank.JACK and bot.rank == Rank.JACK:
|
|
jack_pair_count += 1
|
|
|
|
# Count visible Jacks that could form pairs
|
|
visible_jacks = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)
|
|
|
|
if drawn_card.rank == Rank.JACK:
|
|
# Drawing a Jack - evaluate wolfpack potential
|
|
if jack_pair_count == 1:
|
|
# Already have one pair! Second pair gives -20 bonus
|
|
if partner_card.face_up and partner_card.rank == Rank.JACK:
|
|
# Completing second Jack pair!
|
|
wolfpack_bonus = 15 * profile.aggression
|
|
score += wolfpack_bonus
|
|
ai_log(f" Wolfpack pursuit: completing 2nd Jack pair! +{wolfpack_bonus:.1f}")
|
|
elif not partner_card.face_up:
|
|
# Partner unknown - speculative wolfpack pursuit
|
|
# Probability of unknown card being Jack is very low (~3%)
|
|
# Expected value of swapping Jack into unknown is negative
|
|
# Only give small bonus - not enough to override negative point_gain
|
|
wolfpack_bonus = 2 * profile.aggression
|
|
score += wolfpack_bonus
|
|
ai_log(f" Wolfpack pursuit (speculative): +{wolfpack_bonus:.1f}")
|
|
elif visible_jacks >= 1 and partner_card.face_up and partner_card.rank == Rank.JACK:
|
|
# Completing first Jack pair while having other Jacks
|
|
wolfpack_bonus = 8 * profile.aggression
|
|
score += wolfpack_bonus
|
|
ai_log(f" Wolfpack pursuit: first Jack pair +{wolfpack_bonus:.1f}")
|
|
|
|
# 4d. COMEBACK AGGRESSION - Boost reveal bonus when behind in late game
|
|
# Only for cards that aren't objectively bad (value < 8)
|
|
# Don't incentivize locking in 8, 9, 10, J, Q just to "go out faster"
|
|
standings_pressure = get_standings_pressure(player, game)
|
|
if standings_pressure > 0.3 and not current_card.face_up and drawn_value < HIGH_CARD_THRESHOLD:
|
|
# Behind in standings - boost incentive to reveal and play faster
|
|
comeback_bonus = standings_pressure * 3 * profile.aggression
|
|
score += comeback_bonus
|
|
ai_log(f" Comeback aggression bonus: +{comeback_bonus:.1f} (pressure={standings_pressure:.2f})")
|
|
|
|
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
|
face_down_positions = hidden_positions(player)
|
|
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
|
projected_score = project_score(player, pos, drawn_card, options)
|
|
|
|
# Aggressive players accept higher scores when going out
|
|
max_acceptable = GO_OUT_SCORE_BASE + int(profile.aggression * (GO_OUT_SCORE_MAX - GO_OUT_SCORE_BASE))
|
|
if projected_score > max_acceptable:
|
|
score -= 100
|
|
|
|
return score
|
|
|
|
@staticmethod
|
|
def choose_swap_or_discard(drawn_card: Card, player: Player,
|
|
profile: CPUProfile, game: Game) -> Optional[int]:
|
|
"""
|
|
Decide whether to swap the drawn card or discard.
|
|
Returns position to swap with, or None to discard.
|
|
|
|
Uses a unified scoring system that weighs all incentives:
|
|
- Pair creation (best for positive cards, bad for negative)
|
|
- Point gain from replacement
|
|
- Revealing hidden cards (catching up, information)
|
|
- Safety (don't go out with terrible score)
|
|
"""
|
|
options = game.options
|
|
drawn_value = get_ai_card_value(drawn_card, options)
|
|
|
|
# Check if we should force a go-out swap (exactly 1 face-down card)
|
|
go_out_pos = GolfAI._check_go_out_swap(drawn_card, drawn_value, player, profile, game)
|
|
if go_out_pos is not None:
|
|
return go_out_pos
|
|
|
|
ai_log(f"=== {profile.name} deciding: drew {drawn_card.rank.value}{drawn_card.suit.value} (value={drawn_value}) ===")
|
|
ai_log(f" Personality: pair_hope={profile.pair_hope:.2f}, aggression={profile.aggression:.2f}, "
|
|
f"swap_threshold={profile.swap_threshold}, unpredictability={profile.unpredictability:.2f}")
|
|
|
|
# Log current hand state
|
|
hand_str = " ".join(
|
|
f"[{i}:{c.rank.value if c.face_up else '?'}]" for i, c in enumerate(player.cards)
|
|
)
|
|
ai_log(f" Hand: {hand_str}")
|
|
|
|
# Check for unpredictable random play
|
|
unpredictable_pos = GolfAI._check_unpredictable_swap(
|
|
drawn_card, drawn_value, player, profile, options
|
|
)
|
|
if unpredictable_pos is not None:
|
|
return unpredictable_pos
|
|
|
|
# Score all positions and select best candidate
|
|
position_scores = GolfAI._score_all_positions(
|
|
drawn_card, drawn_value, player, profile, options, game
|
|
)
|
|
best_pos, best_score = GolfAI._select_best_candidate(
|
|
position_scores, drawn_card, drawn_value, player, profile, options, game
|
|
)
|
|
|
|
# Blackjack special case: chase exactly 21
|
|
if options.blackjack and best_pos is None:
|
|
blackjack_pos = GolfAI._check_blackjack_swap(drawn_card, drawn_value, player, profile, options)
|
|
if blackjack_pos is not None:
|
|
return blackjack_pos
|
|
|
|
# Check if pair hunter wants to hold for future pair
|
|
best_pos = GolfAI._check_hold_for_pair(
|
|
best_pos, drawn_card, drawn_value, player, profile, game
|
|
)
|
|
|
|
# Final safety: force swap if about to go out with bad score
|
|
best_pos = GolfAI._check_final_safety(
|
|
best_pos, drawn_card, drawn_value, player, profile, options
|
|
)
|
|
|
|
# Opponent denial: consider keeping card to deny next player
|
|
best_pos = GolfAI._check_denial_swap(
|
|
best_pos, drawn_card, drawn_value, player, profile, game, options
|
|
)
|
|
|
|
# Log final decision
|
|
if best_pos is not None:
|
|
target_card = player.cards[best_pos]
|
|
target_str = target_card.rank.value if target_card.face_up else "hidden"
|
|
ai_log(f" DECISION: SWAP into position {best_pos} (replacing {target_str}) [score={best_score:.2f}]")
|
|
else:
|
|
ai_log(f" DECISION: DISCARD {drawn_card.rank.value} (no good swap options)")
|
|
|
|
return best_pos
|
|
|
|
@staticmethod
|
|
def _check_go_out_swap(drawn_card: Card, drawn_value: int, player: Player,
|
|
profile: CPUProfile, game: Game) -> Optional[int]:
|
|
"""If player has exactly 1 face-down card, decide the best go-out swap.
|
|
|
|
Returns position to swap into, or None to fall through to normal scoring.
|
|
Uses a sentinel value of -1 (converted to None by caller) is not needed -
|
|
we return None to indicate "no early decision, continue normal flow".
|
|
"""
|
|
options = game.options
|
|
face_down_positions = hidden_positions(player)
|
|
if len(face_down_positions) != 1:
|
|
return None
|
|
|
|
last_pos = face_down_positions[0]
|
|
last_partner_pos = get_column_partner_position(last_pos)
|
|
last_partner = player.cards[last_partner_pos]
|
|
|
|
# Calculate base visible score (EXCLUDING the column with hidden card entirely)
|
|
visible_score = visible_score_excluding_column(player, last_pos, options)
|
|
|
|
# Get partner value for calculations
|
|
partner_value = get_ai_card_value(last_partner, options) if last_partner.face_up else 0
|
|
|
|
# Calculate score if we SWAP drawn card into last position
|
|
if last_partner.face_up and last_partner.rank == drawn_card.rank:
|
|
# Would create a pair - calculate actual column contribution
|
|
if drawn_value < 0 and not options.negative_pairs_keep_value:
|
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
pair_column_value = -4
|
|
else:
|
|
pair_column_value = 0
|
|
ai_log(f" GO-OUT: pairing negative cards would waste {abs(drawn_value + partner_value)} pts")
|
|
elif options.negative_pairs_keep_value and (drawn_value < 0 or partner_value < 0):
|
|
pair_column_value = drawn_value + partner_value
|
|
else:
|
|
pair_column_value = 0
|
|
score_if_swap = visible_score + pair_column_value
|
|
else:
|
|
score_if_swap = visible_score + drawn_value + partner_value
|
|
|
|
# Estimate score if we DISCARD and FLIP (hidden card is unknown)
|
|
estimated_hidden = PESSIMISTIC_HIDDEN_VALUE
|
|
score_if_flip = visible_score + estimated_hidden + partner_value
|
|
|
|
# Check if swap would create a wasteful negative pair
|
|
would_waste_negative = (
|
|
last_partner.face_up and
|
|
last_partner.rank == drawn_card.rank and
|
|
drawn_value < 0 and
|
|
not options.negative_pairs_keep_value and
|
|
not (options.eagle_eye and drawn_card.rank == Rank.JOKER)
|
|
)
|
|
|
|
max_acceptable_go_out = 14 + int(profile.aggression * 4)
|
|
|
|
ai_log(f" Go-out safety check: visible_base={visible_score}, "
|
|
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
|
|
f"max_acceptable={max_acceptable_go_out}")
|
|
|
|
# If BOTH options are bad, choose the better one
|
|
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
|
|
if score_if_swap <= score_if_flip:
|
|
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
|
f"<= flip ({score_if_flip}), forcing swap")
|
|
return last_pos
|
|
else:
|
|
ai_log(f" >> WARNING: both options bad, flip ({score_if_flip}) "
|
|
f"< swap ({score_if_swap}), will try to find better swap")
|
|
return None # Fall through to normal scoring
|
|
|
|
# If swap would waste negative cards, fall through to normal scoring
|
|
elif would_waste_negative:
|
|
ai_log(f" >> SKIP GO-OUT SHORTCUT: would waste negative pair, checking other positions")
|
|
return None
|
|
|
|
# If swap is good, prefer it (known outcome vs unknown flip)
|
|
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
|
|
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
|
return last_pos
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _check_unpredictable_swap(drawn_card: Card, drawn_value: int, player: Player,
|
|
profile: CPUProfile, options: GameOptions) -> Optional[int]:
|
|
"""Unpredictable players occasionally make surprising plays.
|
|
|
|
Returns position to swap into, or None to continue normal scoring.
|
|
"""
|
|
if random.random() >= profile.unpredictability:
|
|
return None
|
|
if drawn_value <= 1:
|
|
return None # Never discard excellent cards
|
|
|
|
face_down = hidden_positions(player)
|
|
if not face_down or random.random() >= 0.5:
|
|
return None
|
|
|
|
# SAFETY: Don't randomly go out with a bad score
|
|
if len(face_down) == 1:
|
|
last_pos = face_down[0]
|
|
projected = drawn_value
|
|
for i, c in enumerate(player.cards):
|
|
if i != last_pos and c.face_up:
|
|
projected += get_ai_card_value(c, options)
|
|
# Apply pair cancellation
|
|
for col in range(3):
|
|
top_idx, bot_idx = col, col + 3
|
|
top_card = drawn_card if top_idx == last_pos else player.cards[top_idx]
|
|
bot_card = drawn_card if bot_idx == last_pos else player.cards[bot_idx]
|
|
if top_card.face_up or top_idx == last_pos:
|
|
if bot_card.face_up or bot_idx == last_pos:
|
|
if top_card.rank == bot_card.rank:
|
|
top_val = drawn_value if top_idx == last_pos else get_ai_card_value(player.cards[top_idx], options)
|
|
bot_val = drawn_value if bot_idx == last_pos else get_ai_card_value(player.cards[bot_idx], options)
|
|
projected -= (top_val + bot_val)
|
|
max_acceptable = GO_OUT_SCORE_BASE + int(profile.aggression * (GO_OUT_SCORE_MAX - GO_OUT_SCORE_BASE))
|
|
if projected > max_acceptable:
|
|
ai_log(f" >> UNPREDICTABLE: blocked - would go out with {projected} > {max_acceptable}")
|
|
return None
|
|
else:
|
|
ai_log(f" >> UNPREDICTABLE: randomly chose position {last_pos} (projected {projected})")
|
|
return last_pos
|
|
else:
|
|
# Only allow random swaps for cards that aren't objectively bad
|
|
if drawn_value <= UNPREDICTABLE_MAX_VALUE:
|
|
choice = random.choice(face_down)
|
|
ai_log(f" >> UNPREDICTABLE: randomly chose position {choice} (value {drawn_value} <= {UNPREDICTABLE_MAX_VALUE})")
|
|
return choice
|
|
else:
|
|
ai_log(f" >> UNPREDICTABLE: blocked - value {drawn_value} > {UNPREDICTABLE_MAX_VALUE} threshold")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _score_all_positions(drawn_card: Card, drawn_value: int, player: Player,
|
|
profile: CPUProfile, options: GameOptions,
|
|
game: Game) -> list[tuple[int, float]]:
|
|
"""Calculate swap benefit score for each of the 6 positions.
|
|
|
|
Returns list of (position, score) tuples.
|
|
"""
|
|
position_scores: list[tuple[int, float]] = []
|
|
for pos in range(6):
|
|
score = GolfAI.calculate_swap_score(
|
|
pos, drawn_card, drawn_value, player, options, game, profile
|
|
)
|
|
position_scores.append((pos, score))
|
|
|
|
# Log all scores
|
|
ai_log(f" Position scores:")
|
|
for pos, score in position_scores:
|
|
card = player.cards[pos]
|
|
partner_pos = get_column_partner_position(pos)
|
|
partner = player.cards[partner_pos]
|
|
card_str = card.rank.value if card.face_up else "?"
|
|
partner_str = partner.rank.value if partner.face_up else "?"
|
|
pair_indicator = " [PAIR]" if partner.face_up and partner.rank == drawn_card.rank else ""
|
|
reveal_indicator = " [REVEAL]" if not card.face_up else ""
|
|
ai_log(f" pos {pos} ({card_str}, partner={partner_str}): {score:+.2f}{pair_indicator}{reveal_indicator}")
|
|
|
|
return position_scores
|
|
|
|
@staticmethod
|
|
def _select_best_candidate(position_scores: list[tuple[int, float]],
|
|
drawn_card: Card, drawn_value: int, player: Player,
|
|
profile: CPUProfile, options: GameOptions,
|
|
game: Game) -> tuple[Optional[int], float]:
|
|
"""From scored positions, apply safety filters and personality tie-breaking.
|
|
|
|
Returns (best_position, best_score) or (None, 0.0) if no good swap.
|
|
"""
|
|
# Filter to positive scores only
|
|
positive_scores = [(p, s) for p, s in position_scores if s > 0]
|
|
|
|
# SAFETY: Never swap high cards into hidden positions
|
|
if drawn_value >= HIGH_CARD_THRESHOLD:
|
|
safe_positive = []
|
|
for pos, score in positive_scores:
|
|
card = player.cards[pos]
|
|
partner_pos = get_column_partner_position(pos)
|
|
partner = player.cards[partner_pos]
|
|
creates_pair = partner.face_up and partner.rank == drawn_card.rank
|
|
|
|
if card.face_up or creates_pair:
|
|
safe_positive.append((pos, score))
|
|
else:
|
|
ai_log(f" SAFETY: rejecting pos {pos} - high card ({drawn_value}) into hidden")
|
|
|
|
positive_scores = safe_positive
|
|
|
|
best_pos: Optional[int] = None
|
|
best_score = 0.0
|
|
|
|
if positive_scores:
|
|
# Sort by score descending
|
|
positive_scores.sort(key=lambda x: x[1], reverse=True)
|
|
best_pos, best_score = positive_scores[0]
|
|
|
|
# PERSONALITY TIE-BREAKER: When top options are close, let personality decide
|
|
close_threshold = TIE_BREAKER_THRESHOLD
|
|
close_options = [(p, s) for p, s in positive_scores if s >= best_score - close_threshold]
|
|
|
|
if len(close_options) > 1:
|
|
ai_log(f" TIE-BREAKER: {len(close_options)} options within {close_threshold} pts of best ({best_score:.2f})")
|
|
original_best = best_pos
|
|
|
|
for pos, score in close_options:
|
|
partner_pos = get_column_partner_position(pos)
|
|
partner_card = player.cards[partner_pos]
|
|
is_pair_move = partner_card.face_up and partner_card.rank == drawn_card.rank
|
|
is_reveal_move = not player.cards[pos].face_up
|
|
|
|
if is_pair_move and profile.pair_hope > 0.6:
|
|
ai_log(f" >> PAIR_HOPE ({profile.pair_hope:.2f}): chose pair move at pos {pos}")
|
|
best_pos = pos
|
|
break
|
|
if is_reveal_move and profile.aggression > 0.7:
|
|
ai_log(f" >> AGGRESSION ({profile.aggression:.2f}): chose reveal move at pos {pos}")
|
|
best_pos = pos
|
|
break
|
|
if not is_reveal_move and profile.swap_threshold <= 4:
|
|
ai_log(f" >> CONSERVATIVE (threshold={profile.swap_threshold}): chose safe move at pos {pos}")
|
|
best_pos = pos
|
|
break
|
|
|
|
if profile.unpredictability > 0.1 and random.random() < profile.unpredictability:
|
|
best_pos = random.choice([p for p, s in close_options])
|
|
ai_log(f" >> RANDOM (unpredictability={profile.unpredictability:.2f}): chose pos {best_pos}")
|
|
|
|
if best_pos != original_best:
|
|
ai_log(f" Tie-breaker changed choice: {original_best} -> {best_pos}")
|
|
|
|
return best_pos, best_score
|
|
|
|
@staticmethod
|
|
def _check_blackjack_swap(drawn_card: Card, drawn_value: int, player: Player,
|
|
profile: CPUProfile, options: GameOptions) -> Optional[int]:
|
|
"""Check if we can chase exactly 21 for blackjack bonus."""
|
|
current_score = player.calculate_score()
|
|
if current_score >= 15:
|
|
for i, card in enumerate(player.cards):
|
|
if card.face_up:
|
|
potential_change = drawn_value - get_ai_card_value(card, options)
|
|
if current_score + potential_change == BLACKJACK_TARGET:
|
|
if random.random() < profile.aggression:
|
|
ai_log(f" >> BLACKJACK: chasing 21 at position {i}")
|
|
return i
|
|
return None
|
|
|
|
@staticmethod
|
|
def _check_hold_for_pair(best_pos: Optional[int], drawn_card: Card, drawn_value: int,
|
|
player: Player, profile: CPUProfile,
|
|
game: Game) -> Optional[int]:
|
|
"""Pair hunters might hold medium cards hoping for matches.
|
|
|
|
Returns best_pos (unchanged) or None (discard to hold for pair).
|
|
"""
|
|
face_down_count = count_hidden(player)
|
|
# Only hold if best swap is into a hidden position and we have >1 face-down
|
|
if best_pos is None or player.cards[best_pos].face_up or face_down_count <= 1:
|
|
return best_pos
|
|
if drawn_value < 5:
|
|
return best_pos # Only hold out for medium/high cards
|
|
|
|
# DON'T hold if placing at best_pos would actually CREATE a pair right now!
|
|
partner_pos = get_column_partner_position(best_pos)
|
|
partner_card = player.cards[partner_pos]
|
|
would_make_pair = partner_card.face_up and partner_card.rank == drawn_card.rank
|
|
|
|
if would_make_pair:
|
|
ai_log(f" Skip hold-for-pair: placing at {best_pos} creates pair with {partner_card.rank.value}")
|
|
return best_pos
|
|
|
|
pair_viability = get_pair_viability(drawn_card.rank, game)
|
|
phase = get_game_phase(game)
|
|
pressure = get_end_game_pressure(player, game)
|
|
|
|
effective_hope = profile.pair_hope * pair_viability
|
|
if phase == 'late' or pressure > 0.5:
|
|
effective_hope *= 0.3
|
|
|
|
ai_log(f" Hold-for-pair check: value={drawn_value}, viability={pair_viability:.2f}, "
|
|
f"phase={phase}, effective_hope={effective_hope:.2f}")
|
|
|
|
if effective_hope > 0.5 and random.random() < effective_hope:
|
|
ai_log(f" >> HOLDING: discarding {drawn_card.rank.value} hoping for future pair")
|
|
return None # Discard and hope for pair later
|
|
|
|
return best_pos
|
|
|
|
@staticmethod
|
|
def _check_final_safety(best_pos: Optional[int], drawn_card: Card, drawn_value: int,
|
|
player: Player, profile: CPUProfile,
|
|
options: GameOptions) -> Optional[int]:
|
|
"""If we have exactly 1 face-down card and would discard, force a swap.
|
|
|
|
Returns updated best_pos.
|
|
"""
|
|
face_down_count = count_hidden(player)
|
|
if best_pos is not None or face_down_count != 1:
|
|
return best_pos
|
|
|
|
last_pos = hidden_positions(player)[0]
|
|
|
|
# Find the worst visible card we could replace instead
|
|
worst_visible_pos = None
|
|
worst_visible_val = -999
|
|
for i, c in enumerate(player.cards):
|
|
if c.face_up:
|
|
val = get_ai_card_value(c, options)
|
|
partner_pos = get_column_partner_position(i)
|
|
partner = player.cards[partner_pos]
|
|
if partner.face_up and partner.rank == c.rank:
|
|
continue # Card is paired, don't replace
|
|
if val > worst_visible_val:
|
|
worst_visible_val = val
|
|
worst_visible_pos = i
|
|
|
|
if drawn_value < HIGH_CARD_THRESHOLD:
|
|
ai_log(f" >> FINAL SAFETY: forcing swap into hidden pos {last_pos} "
|
|
f"(drawn value {drawn_value} < {HIGH_CARD_THRESHOLD})")
|
|
return last_pos
|
|
elif worst_visible_pos is not None and drawn_value < worst_visible_val:
|
|
ai_log(f" >> FINAL SAFETY: swapping into visible pos {worst_visible_pos} "
|
|
f"(drawn {drawn_value} < worst visible {worst_visible_val})")
|
|
return worst_visible_pos
|
|
elif drawn_value >= HIGH_CARD_THRESHOLD:
|
|
ai_log(f" >> FINAL SAFETY: discarding bad card ({drawn_value}), will flip unknown")
|
|
return None
|
|
else:
|
|
ai_log(f" >> FINAL SAFETY: forcing swap into hidden pos {last_pos} "
|
|
f"(drawn value {drawn_value} is acceptable)")
|
|
return last_pos
|
|
|
|
@staticmethod
|
|
def _check_denial_swap(best_pos: Optional[int], drawn_card: Card, drawn_value: int,
|
|
player: Player, profile: CPUProfile,
|
|
game: Game, options: GameOptions) -> Optional[int]:
|
|
"""Check if we should swap to deny opponents a useful card.
|
|
|
|
Only triggers when we're about to discard (best_pos is None).
|
|
Returns updated best_pos.
|
|
"""
|
|
if best_pos is not None:
|
|
return best_pos
|
|
|
|
next_opponent = get_next_player(game, player)
|
|
if not next_opponent:
|
|
return best_pos
|
|
|
|
denial_value = calculate_denial_value(drawn_card, next_opponent, game, options)
|
|
if denial_value <= 0:
|
|
return best_pos
|
|
|
|
ai_log(f" DENIAL CHECK: discarding {drawn_card.rank.value} would help "
|
|
f"{next_opponent.name} (denial_value={denial_value:.1f})")
|
|
|
|
denial_threshold = 4.0 + profile.aggression * 4
|
|
|
|
if denial_value < denial_threshold:
|
|
return best_pos
|
|
|
|
# Find acceptable swap positions (minimize our loss)
|
|
denial_candidates = []
|
|
for pos in range(6):
|
|
card = player.cards[pos]
|
|
if not card.face_up:
|
|
if drawn_value >= HIGH_CARD_THRESHOLD:
|
|
continue # Never swap high cards into hidden for denial
|
|
cost = drawn_value
|
|
denial_candidates.append((pos, cost, "hidden"))
|
|
else:
|
|
replaced_val = get_ai_card_value(card, options)
|
|
partner_pos = get_column_partner_position(pos)
|
|
partner = player.cards[partner_pos]
|
|
if partner.face_up and partner.rank == card.rank:
|
|
continue # Don't break a pair
|
|
cost = drawn_value - replaced_val
|
|
denial_candidates.append((pos, cost, card.rank.value))
|
|
|
|
denial_candidates.sort(key=lambda x: x[1])
|
|
|
|
if denial_candidates:
|
|
best_denial_pos, best_cost, card_desc = denial_candidates[0]
|
|
max_acceptable_cost = denial_value / 2
|
|
|
|
if best_cost <= max_acceptable_cost:
|
|
ai_log(f" >> DENIAL: swapping into pos {best_denial_pos} ({card_desc}) "
|
|
f"to deny pair (cost={best_cost:.1f}, denial={denial_value:.1f})")
|
|
return best_denial_pos
|
|
else:
|
|
ai_log(f" >> DENIAL REJECTED: best option cost {best_cost:.1f} > "
|
|
f"max acceptable {max_acceptable_cost:.1f}")
|
|
|
|
return best_pos
|
|
|
|
@staticmethod
|
|
def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
|
|
"""Choose which face-down card to flip after discarding."""
|
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
|
|
if not face_down:
|
|
return 0
|
|
|
|
# Prefer flipping cards that could reveal pair info
|
|
for i in face_down:
|
|
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
|
if player.cards[pair_pos].face_up:
|
|
return i
|
|
|
|
return random.choice(face_down)
|
|
|
|
@staticmethod
|
|
def should_skip_optional_flip(player: Player, profile: CPUProfile, game: Game) -> bool:
|
|
"""
|
|
Decide whether to skip the optional flip in endgame mode.
|
|
|
|
In endgame (Suspense) mode, the flip is optional. AI should generally
|
|
flip for information, but may skip if:
|
|
- Already has good information about their hand
|
|
- Wants to keep cards hidden for suspense
|
|
- Random unpredictability factor
|
|
|
|
Returns True if AI should skip the flip, False if it should flip.
|
|
"""
|
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
|
|
if not face_down:
|
|
return True # No cards to flip
|
|
|
|
# Very conservative players (low aggression) might skip to keep hidden
|
|
# But information is usually valuable, so mostly flip
|
|
skip_chance = 0.1 # Base 10% chance to skip
|
|
|
|
# More hidden cards = more value in flipping for information
|
|
if len(face_down) >= 3:
|
|
skip_chance = 0.05 # Less likely to skip with many hidden cards
|
|
|
|
# If only 1 hidden card, we might skip to keep opponents guessing
|
|
if len(face_down) == 1:
|
|
skip_chance = 0.2 + (1.0 - profile.aggression) * 0.2
|
|
|
|
# Unpredictable players are more random about this
|
|
skip_chance += profile.unpredictability * 0.15
|
|
|
|
ai_log(f" Optional flip decision: {len(face_down)} face-down cards, skip_chance={skip_chance:.2f}")
|
|
|
|
if random.random() < skip_chance:
|
|
ai_log(f" >> SKIP: choosing not to flip (endgame mode)")
|
|
return True
|
|
|
|
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) * EXPECTED_HIDDEN_VALUE
|
|
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]:
|
|
"""
|
|
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:
|
|
"""
|
|
Decide if CPU should try to go out (reveal all cards) to screw neighbors.
|
|
"""
|
|
options = game.options
|
|
face_down_count = sum(1 for c in player.cards if not c.face_up)
|
|
|
|
if face_down_count > 2:
|
|
return False
|
|
|
|
estimated_score = player.calculate_score()
|
|
|
|
# Blackjack: If score is exactly 21, definitely go out (becomes 0!)
|
|
if options.blackjack and estimated_score == BLACKJACK_TARGET:
|
|
return True
|
|
|
|
# Base threshold based on aggression
|
|
go_out_threshold = 8 if profile.aggression > 0.7 else (12 if profile.aggression > 0.4 else 16)
|
|
|
|
# COMEBACK MODE: Accept higher scores when significantly behind
|
|
standings_pressure = get_standings_pressure(player, game)
|
|
if standings_pressure > 0.5:
|
|
# Behind and late - swing for the fences
|
|
go_out_threshold += int(standings_pressure * 6) # Up to +6 points tolerance
|
|
ai_log(f" Comeback mode: raised go-out threshold to {go_out_threshold}")
|
|
|
|
# Knock Bonus (-5 for going out): Can afford to go out with higher score
|
|
if options.knock_bonus:
|
|
go_out_threshold += 5
|
|
|
|
# Knock Penalty (+10 if not lowest): Need to be confident we're lowest
|
|
if options.knock_penalty:
|
|
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
|
# Conservative players require bigger lead
|
|
safety_margin = 5 if profile.aggression < 0.4 else 2
|
|
if estimated_score > opponent_min - safety_margin:
|
|
# We might not have the lowest score - be cautious
|
|
go_out_threshold -= 4
|
|
|
|
# Tied Shame: Estimate if we might tie someone
|
|
if options.tied_shame:
|
|
for p in game.players:
|
|
if p.id == player.id:
|
|
continue
|
|
visible = sum(get_ai_card_value(c, options) for c in p.cards if c.face_up)
|
|
hidden_count = sum(1 for c in p.cards if not c.face_up)
|
|
# Rough estimate - if visible scores are close, be cautious
|
|
if hidden_count <= 2 and abs(visible - estimated_score) <= 3:
|
|
go_out_threshold -= 2
|
|
break
|
|
|
|
# Underdog Bonus: Minor factor - you get -3 for lowest regardless
|
|
# This slightly reduces urgency to go out first
|
|
if options.underdog_bonus:
|
|
go_out_threshold -= 1
|
|
|
|
# HIGH SCORE CAUTION: When our score is >10, be extra careful
|
|
# Opponents' hidden cards could easily beat us with pairs or low cards
|
|
if estimated_score > 10:
|
|
# Get pessimistic estimate of opponent's potential score
|
|
opponent_min_pessimistic = estimate_opponent_min_score(player, game, optimistic=False)
|
|
opponent_min_optimistic = estimate_opponent_min_score(player, game, optimistic=True)
|
|
|
|
ai_log(f" High score caution: our score={estimated_score}, "
|
|
f"opponent estimates: optimistic={opponent_min_optimistic}, pessimistic={opponent_min_pessimistic}")
|
|
|
|
# If opponents could potentially beat us, reduce our willingness to go out
|
|
if opponent_min_pessimistic < estimated_score:
|
|
# Calculate how risky this is
|
|
risk_margin = estimated_score - opponent_min_pessimistic
|
|
# Reduce threshold based on risk (more risk = lower threshold)
|
|
risk_penalty = min(risk_margin, 8) # Cap at 8 point penalty
|
|
go_out_threshold -= risk_penalty
|
|
ai_log(f" Risk penalty: -{risk_penalty} (opponents could score {opponent_min_pessimistic})")
|
|
|
|
# Additional penalty for very high scores (>15) - almost never go out
|
|
if estimated_score > 15:
|
|
extra_penalty = (estimated_score - 15) * 2
|
|
go_out_threshold -= extra_penalty
|
|
ai_log(f" Very high score penalty: -{extra_penalty}")
|
|
|
|
ai_log(f" Go-out decision: score={estimated_score}, threshold={go_out_threshold}, "
|
|
f"aggression={profile.aggression:.2f}")
|
|
|
|
if estimated_score <= go_out_threshold:
|
|
if random.random() < profile.aggression:
|
|
ai_log(f" >> GOING OUT with score {estimated_score}")
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Game,
|
|
action: str, card=None, position: Optional[int] = None,
|
|
decision_reason: str = ""):
|
|
"""Log a CPU action if logger is available."""
|
|
if logger and game_id:
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action=action,
|
|
card=card,
|
|
position=position,
|
|
game=game,
|
|
decision_reason=decision_reason,
|
|
)
|
|
|
|
|
|
async def process_cpu_turn(
|
|
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
|
) -> None:
|
|
"""Process a complete turn for a CPU player."""
|
|
import asyncio
|
|
from services.game_logger import get_logger
|
|
|
|
profile = get_profile(cpu_player.id)
|
|
if not profile:
|
|
profile = CPUProfile("CPU", "Balanced", 5, 0.4, 0.5, 0.1)
|
|
|
|
logger = get_logger() if game_id else None
|
|
|
|
# Brief initial delay before CPU "looks at" the discard pile
|
|
initial_look = CPU_TIMING["initial_look"]
|
|
await asyncio.sleep(random.uniform(initial_look[0], initial_look[1]))
|
|
|
|
# "Thinking" delay based on how obvious the discard decision is
|
|
discard_top = game.discard_top()
|
|
thinking_time = get_discard_thinking_time(discard_top, game.options)
|
|
|
|
# Adjust for personality - chaotic players have more variance
|
|
if profile.unpredictability > 0.2:
|
|
chaos_mult = CPU_TIMING["thinking_multiplier_chaotic"]
|
|
thinking_time *= random.uniform(chaos_mult[0], chaos_mult[1])
|
|
|
|
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
|
|
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
|
|
await asyncio.sleep(thinking_time)
|
|
ai_log(f"{cpu_player.name} done thinking, making decision")
|
|
|
|
# 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_cpu_action(logger, game_id, cpu_player, game,
|
|
action="knock_early",
|
|
decision_reason=f"knocked early, revealing {count_hidden(cpu_player)} hidden cards")
|
|
await broadcast_callback()
|
|
return
|
|
|
|
# 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_cpu_action(logger, game_id, cpu_player, game,
|
|
action="flip_as_action",
|
|
card=cpu_player.cards[flip_action_pos],
|
|
position=flip_action_pos,
|
|
decision_reason=f"used flip-as-action to reveal position {flip_action_pos}")
|
|
await broadcast_callback()
|
|
return
|
|
|
|
# Decide whether to draw from discard or deck
|
|
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
|
|
|
|
source = "discard" if take_discard else "deck"
|
|
drawn = game.draw_card(cpu_player.id, source)
|
|
|
|
if drawn:
|
|
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="take_discard" if take_discard else "draw_deck",
|
|
card=drawn, decision_reason=reason)
|
|
|
|
if not drawn:
|
|
return
|
|
|
|
await broadcast_callback()
|
|
await asyncio.sleep(CPU_TIMING["post_draw_settle"])
|
|
consider = CPU_TIMING["post_draw_consider"]
|
|
await asyncio.sleep(consider[0] + random.uniform(0, consider[1] - consider[0]))
|
|
|
|
# Decide whether to swap or discard
|
|
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
|
|
|
|
# If drawn from discard, must swap (always enforced)
|
|
if swap_pos is None and game.drawn_from_discard:
|
|
face_down = hidden_positions(cpu_player)
|
|
if face_down:
|
|
safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options)
|
|
swap_pos = random.choice(safe_positions)
|
|
else:
|
|
# All cards are face up - find worst card to replace
|
|
worst_pos = 0
|
|
worst_effective_val = -999
|
|
for i, c in enumerate(cpu_player.cards):
|
|
card_val = get_ai_card_value(c, game.options)
|
|
partner_pos = get_column_partner_position(i)
|
|
partner = cpu_player.cards[partner_pos]
|
|
|
|
if partner.rank == c.rank:
|
|
if card_val >= 0 or not game.options.negative_pairs_keep_value:
|
|
effective_val = -get_ai_card_value(partner, game.options)
|
|
elif game.options.eagle_eye and c.rank == Rank.JOKER:
|
|
effective_val = -2
|
|
else:
|
|
effective_val = card_val
|
|
else:
|
|
effective_val = card_val
|
|
|
|
if effective_val > worst_effective_val:
|
|
worst_effective_val = effective_val
|
|
worst_pos = i
|
|
swap_pos = worst_pos
|
|
|
|
drawn_val = get_ai_card_value(drawn, game.options)
|
|
if worst_effective_val < drawn_val:
|
|
logging.warning(
|
|
f"AI {cpu_player.name} forced to swap good card (value={worst_effective_val}) "
|
|
f"for bad card {drawn.rank.value} (value={drawn_val})"
|
|
)
|
|
|
|
if swap_pos is not None:
|
|
old_card = cpu_player.cards[swap_pos]
|
|
game.swap_card(cpu_player.id, swap_pos)
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="swap", card=drawn, position=swap_pos,
|
|
decision_reason=f"swapped {drawn.rank.value} into position {swap_pos}, replaced {old_card.rank.value}")
|
|
else:
|
|
game.discard_drawn(cpu_player.id)
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="discard", card=drawn,
|
|
decision_reason=f"discarded {drawn.rank.value}")
|
|
|
|
if game.flip_on_discard:
|
|
if game.flip_is_optional:
|
|
if GolfAI.should_skip_optional_flip(cpu_player, profile, game):
|
|
game.skip_flip_and_end_turn(cpu_player.id)
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="skip_flip",
|
|
decision_reason="skipped optional flip (endgame mode)")
|
|
else:
|
|
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
|
|
game.flip_and_end_turn(cpu_player.id, flip_pos)
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="flip", card=cpu_player.cards[flip_pos],
|
|
position=flip_pos,
|
|
decision_reason=f"flipped card at position {flip_pos} (chose to flip in endgame mode)")
|
|
else:
|
|
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
|
|
game.flip_and_end_turn(cpu_player.id, flip_pos)
|
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
|
action="flip", card=cpu_player.cards[flip_pos],
|
|
position=flip_pos,
|
|
decision_reason=f"flipped card at position {flip_pos}")
|
|
|
|
await broadcast_callback()
|
|
|
|
post_action = CPU_TIMING["post_action_pause"]
|
|
await asyncio.sleep(random.uniform(post_action[0], post_action[1]))
|