1087 lines
43 KiB
Python
1087 lines
43 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, 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)
|
|
|
|
|
|
# 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 estimate_opponent_min_score(player: Player, game: Game) -> int:
|
|
"""Estimate minimum opponent score from visible cards."""
|
|
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)
|
|
estimate = visible + int(hidden * 4.5) # Assume ~4.5 avg for hidden
|
|
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 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 >= 4.5:
|
|
return 'early'
|
|
elif avg_hidden >= 2.5:
|
|
return 'mid'
|
|
else:
|
|
return 'late'
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
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 which profiles are in use
|
|
_used_profiles: set[str] = set()
|
|
_cpu_profiles: dict[str, CPUProfile] = {}
|
|
|
|
|
|
def get_available_profile() -> Optional[CPUProfile]:
|
|
"""Get a random available CPU profile."""
|
|
available = [p for p in CPU_PROFILES if p.name not in _used_profiles]
|
|
if not available:
|
|
return None
|
|
profile = random.choice(available)
|
|
_used_profiles.add(profile.name)
|
|
return profile
|
|
|
|
|
|
def release_profile(name: str):
|
|
"""Release a CPU profile back to the pool."""
|
|
_used_profiles.discard(name)
|
|
# Also remove from cpu_profiles by finding the cpu_id with this profile
|
|
to_remove = [cpu_id for cpu_id, profile in _cpu_profiles.items() if profile.name == name]
|
|
for cpu_id in to_remove:
|
|
del _cpu_profiles[cpu_id]
|
|
|
|
|
|
def reset_all_profiles():
|
|
"""Reset all profile tracking (for cleanup)."""
|
|
_used_profiles.clear()
|
|
_cpu_profiles.clear()
|
|
|
|
|
|
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
|
|
"""Get the profile for a CPU player."""
|
|
return _cpu_profiles.get(cpu_id)
|
|
|
|
|
|
def assign_profile(cpu_id: str) -> Optional[CPUProfile]:
|
|
"""Assign a random profile to a CPU player."""
|
|
profile = get_available_profile()
|
|
if profile:
|
|
_cpu_profiles[cpu_id] = profile
|
|
return profile
|
|
|
|
|
|
def assign_specific_profile(cpu_id: str, profile_name: str) -> Optional[CPUProfile]:
|
|
"""Assign a specific profile to a CPU player by name."""
|
|
# Check if profile exists and is available
|
|
for profile in CPU_PROFILES:
|
|
if profile.name == profile_name and profile.name not in _used_profiles:
|
|
_used_profiles.add(profile.name)
|
|
_cpu_profiles[cpu_id] = profile
|
|
return profile
|
|
return None
|
|
|
|
|
|
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 = [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
if len(face_down) == 1:
|
|
# Calculate projected score if we swap into the last face-down position
|
|
projected_score = 0
|
|
for i, c in enumerate(player.cards):
|
|
if i == face_down[0]:
|
|
projected_score += discard_value
|
|
elif c.face_up:
|
|
projected_score += get_ai_card_value(c, options)
|
|
|
|
# Apply column pair cancellation
|
|
for col in range(3):
|
|
top_idx, bot_idx = col, col + 3
|
|
top_card = discard_card if top_idx == face_down[0] else player.cards[top_idx]
|
|
bot_card = discard_card if bot_idx == face_down[0] else player.cards[bot_idx]
|
|
if top_card.rank == bot_card.rank:
|
|
top_val = discard_value if top_idx == face_down[0] else get_ai_card_value(player.cards[top_idx], options)
|
|
bot_val = discard_value if bot_idx == face_down[0] else get_ai_card_value(player.cards[bot_idx], options)
|
|
projected_score -= (top_val + bot_val)
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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
|
|
my_hidden = sum(1 for c in player.cards if not c.face_up)
|
|
if my_hidden > 0:
|
|
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
|
return True
|
|
|
|
# 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):
|
|
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
|
return True
|
|
|
|
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 - usually bad
|
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
score += 8 * pair_weight # Eagle Eye Joker pairs
|
|
else:
|
|
# Penalty, but pair hunters might still do it
|
|
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
|
score -= penalty
|
|
|
|
# 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)
|
|
point_gain = current_value - drawn_value
|
|
score += point_gain
|
|
else:
|
|
# Hidden card - expected value ~4.5
|
|
expected_hidden = 4.5
|
|
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:
|
|
hidden_count = sum(1 for c in player.cards if not c.face_up)
|
|
reveal_bonus = min(hidden_count, 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
|
|
|
|
# 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]:
|
|
projected_score = drawn_value
|
|
for i, c in enumerate(player.cards):
|
|
if i != pos and c.face_up:
|
|
projected_score += 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 == pos else player.cards[top_idx]
|
|
bot_card = drawn_card if bot_idx == pos else player.cards[bot_idx]
|
|
if top_card.rank == bot_card.rank:
|
|
top_val = drawn_value if top_idx == pos else get_ai_card_value(player.cards[top_idx], options)
|
|
bot_val = drawn_value if bot_idx == pos else get_ai_card_value(player.cards[bot_idx], options)
|
|
projected_score -= (top_val + bot_val)
|
|
|
|
# Aggressive players accept higher scores when going out
|
|
max_acceptable = 12 + int(profile.aggression * 8) # Range: 12 to 20
|
|
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)
|
|
|
|
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}")
|
|
|
|
# Unpredictable players occasionally make surprising plays
|
|
# But never discard excellent cards (Jokers, 2s, Kings, Aces)
|
|
if random.random() < profile.unpredictability:
|
|
if drawn_value > 1:
|
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
if face_down and random.random() < 0.5:
|
|
choice = random.choice(face_down)
|
|
ai_log(f" >> UNPREDICTABLE: randomly chose position {choice}")
|
|
return choice
|
|
|
|
# Calculate score for each position
|
|
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}")
|
|
|
|
# Filter to positive scores only
|
|
positive_scores = [(p, s) for p, s in position_scores if s > 0]
|
|
|
|
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 = 2.0 # Options within 2 points are "close"
|
|
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
|
|
|
|
# Multiple close options - personality decides
|
|
# Categorize each option
|
|
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
|
|
|
|
# Pair hunters prefer pair moves
|
|
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
|
|
# Aggressive players prefer reveal moves (to go out faster)
|
|
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
|
|
# Conservative players prefer safe visible card replacements
|
|
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 still tied, add small random factor based on unpredictability
|
|
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}")
|
|
|
|
# Blackjack special case: chase exactly 21
|
|
if options.blackjack and best_pos is None:
|
|
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 == 21:
|
|
if random.random() < profile.aggression:
|
|
ai_log(f" >> BLACKJACK: chasing 21 at position {i}")
|
|
return i
|
|
|
|
# Pair hunters might hold medium cards hoping for matches
|
|
if best_pos is not None and not player.cards[best_pos].face_up:
|
|
if drawn_value >= 5: # Only hold out for medium/high cards
|
|
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
|
|
|
|
# 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 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_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 == 21:
|
|
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)
|
|
|
|
# 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)
|
|
# 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
|
|
|
|
if estimated_score <= go_out_threshold:
|
|
if random.random() < profile.aggression:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
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 game_log import get_logger
|
|
|
|
profile = get_profile(cpu_player.id)
|
|
if not profile:
|
|
# Fallback to balanced profile
|
|
profile = CPUProfile("CPU", "Balanced", 5, 0.4, 0.5, 0.1)
|
|
|
|
# Get logger if game_id provided
|
|
logger = get_logger() if game_id else None
|
|
|
|
# Add delay based on unpredictability (chaotic players are faster/slower)
|
|
delay = 0.8 + random.uniform(0, 0.5)
|
|
if profile.unpredictability > 0.2:
|
|
delay = random.uniform(0.3, 1.2)
|
|
await asyncio.sleep(delay)
|
|
|
|
# Check if we should try to go out early
|
|
GolfAI.should_go_out_early(cpu_player, game, profile)
|
|
|
|
# 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)
|
|
|
|
source = "discard" if take_discard else "deck"
|
|
drawn = game.draw_card(cpu_player.id, source)
|
|
|
|
# Log draw decision
|
|
if logger and game_id and drawn:
|
|
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="take_discard" if take_discard else "draw_deck",
|
|
card=drawn,
|
|
game=game,
|
|
decision_reason=reason,
|
|
)
|
|
|
|
if not drawn:
|
|
return
|
|
|
|
await broadcast_callback()
|
|
await asyncio.sleep(0.4 + random.uniform(0, 0.4))
|
|
|
|
# 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 = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
|
|
if face_down:
|
|
# Filter out positions that would create bad pairs with negative cards
|
|
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 (using house rules)
|
|
worst_pos = 0
|
|
worst_val = -999
|
|
for i, c in enumerate(cpu_player.cards):
|
|
card_val = get_ai_card_value(c, game.options) # Apply house rules
|
|
if card_val > worst_val:
|
|
worst_val = card_val
|
|
worst_pos = i
|
|
swap_pos = worst_pos
|
|
|
|
# Sanity check: warn if we're swapping out a good card for a bad one
|
|
drawn_val = get_ai_card_value(drawn, game.options)
|
|
if worst_val < drawn_val:
|
|
logging.warning(
|
|
f"AI {cpu_player.name} forced to swap good card (value={worst_val}) "
|
|
f"for bad card {drawn.rank.value} (value={drawn_val})"
|
|
)
|
|
|
|
if swap_pos is not None:
|
|
old_card = cpu_player.cards[swap_pos] # Card being replaced
|
|
game.swap_card(cpu_player.id, swap_pos)
|
|
|
|
# Log swap decision
|
|
if logger and game_id:
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="swap",
|
|
card=drawn,
|
|
position=swap_pos,
|
|
game=game,
|
|
decision_reason=f"swapped {drawn.rank.value} into position {swap_pos}, replaced {old_card.rank.value}",
|
|
)
|
|
else:
|
|
game.discard_drawn(cpu_player.id)
|
|
|
|
# Log discard decision
|
|
if logger and game_id:
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="discard",
|
|
card=drawn,
|
|
game=game,
|
|
decision_reason=f"discarded {drawn.rank.value}",
|
|
)
|
|
|
|
if game.flip_on_discard:
|
|
# Check if flip is optional (endgame mode) and decide whether to skip
|
|
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 skip decision
|
|
if logger and game_id:
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="skip_flip",
|
|
card=None,
|
|
game=game,
|
|
decision_reason="skipped optional flip (endgame mode)",
|
|
)
|
|
else:
|
|
# Choose to flip
|
|
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
|
|
game.flip_and_end_turn(cpu_player.id, flip_pos)
|
|
|
|
# Log flip decision
|
|
if logger and game_id:
|
|
flipped_card = cpu_player.cards[flip_pos]
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="flip",
|
|
card=flipped_card,
|
|
position=flip_pos,
|
|
game=game,
|
|
decision_reason=f"flipped card at position {flip_pos} (chose to flip in endgame mode)",
|
|
)
|
|
else:
|
|
# Mandatory flip (always mode)
|
|
flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
|
|
game.flip_and_end_turn(cpu_player.id, flip_pos)
|
|
|
|
# Log flip decision
|
|
if logger and game_id:
|
|
flipped_card = cpu_player.cards[flip_pos]
|
|
logger.log_move(
|
|
game_id=game_id,
|
|
player=cpu_player,
|
|
is_cpu=True,
|
|
action="flip",
|
|
card=flipped_card,
|
|
position=flip_pos,
|
|
game=game,
|
|
decision_reason=f"flipped card at position {flip_pos}",
|
|
)
|
|
|
|
await broadcast_callback()
|