golfgame/server/ai.py
Aaron D. Lee 39b78a2ba6 Add final results modal, active rules display, and UI improvements
- Add big final results modal at game end with rankings and share button
- Add active rules bar showing enabled variants during gameplay
- Increase spacing between player cards and opponents row
- Add Wolfpack bonus rule (2 pairs of Jacks = -5 pts)
- Change joker options to radio buttons (None/Standard/Lucky Swing/Eagle-Eye)
- Update Eagle-Eye jokers: +2 pts unpaired, -4 pts paired
- Add card flip animation on discard pile
- Redesign waiting room layout with side-by-side columns
- Style card backs with red Bee-style diamond crosshatch pattern
- Compact standings panel to show top 4 per category
- Various CSS polish and responsive improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 23:53:07 -05:00

738 lines
26 KiB
Python

"""AI personalities for CPU players in Golf."""
import logging
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
# 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
@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)
# 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:
return True
return True
if discard_card.rank == Rank.KING:
return True
# Auto-take 10s when ten_penny enabled (they're worth 1)
if discard_card.rank == Rank.TEN and options.ten_penny:
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:
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:
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:
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):
return True
return False
@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.
"""
options = game.options
drawn_value = get_ai_card_value(drawn_card, options)
# Unpredictable players occasionally make surprising play
# BUT never discard excellent cards (Jokers, 2s, Kings, Aces)
if random.random() < profile.unpredictability:
if drawn_value > 1: # Only be unpredictable with non-excellent cards
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down and random.random() < 0.5:
return random.choice(face_down)
# Eagle Eye: If drawn card is Joker, look for existing visible Joker to pair
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
for i, card in enumerate(player.cards):
if card.face_up and card.rank == Rank.JOKER:
pair_pos = (i + 3) % 6 if i < 3 else i - 3
if not player.cards[pair_pos].face_up:
return pair_pos
# Check for column pair opportunity first
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
# Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative)
should_pair = drawn_value > 0
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
should_pair = True
if should_pair:
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 == drawn_card.rank and not pair_card.face_up:
return pair_pos
if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up:
return i
# Find best swap among face-up cards that are BAD (positive value)
# Don't swap good cards (Kings, 2s, etc.) just for marginal gains -
# we want to keep good cards and put new good cards into face-down positions
best_swap: Optional[int] = None
best_gain = 0
for i, card in enumerate(player.cards):
if card.face_up:
card_value = get_ai_card_value(card, options)
# Only consider replacing cards that are actually bad (positive value)
if card_value > 0:
gain = card_value - drawn_value
if gain > best_gain:
best_gain = gain
best_swap = i
# Swap if we gain points (conservative players need more gain)
min_gain = 2 if profile.swap_threshold <= 4 else 1
if best_gain >= min_gain:
return best_swap
# Blackjack: Check if any swap would result in exactly 21
if options.blackjack:
current_score = player.calculate_score()
if current_score >= 15: # Only chase 21 from high scores
for i, card in enumerate(player.cards):
if card.face_up:
# Calculate score if we swap here
potential_change = drawn_value - get_ai_card_value(card, options)
potential_score = current_score + potential_change
if potential_score == 21:
# Aggressive players more likely to chase 21
if random.random() < profile.aggression:
return i
# Consider swapping with face-down cards for very good cards (negative or zero value)
# 10s (ten_penny) become "excellent" cards worth keeping
is_excellent = (drawn_value <= 0 or
drawn_card.rank == Rank.ACE or
(options.ten_penny and drawn_card.rank == Rank.TEN))
# Calculate pair viability and game phase for smarter decisions
pair_viability = get_pair_viability(drawn_card.rank, game)
phase = get_game_phase(game)
pressure = get_end_game_pressure(player, game)
if is_excellent:
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
# Pair hunters might hold out hoping for matches
# BUT: reduce hope if pair is unlikely or late game pressure
effective_hope = profile.pair_hope * pair_viability
if phase == 'late' or pressure > 0.5:
effective_hope *= 0.3 # Much less willing to gamble late game
if effective_hope > 0.6 and random.random() < effective_hope:
return None
return random.choice(face_down)
# For medium cards, swap threshold based on profile
# Late game: be more willing to swap in medium cards
effective_threshold = profile.swap_threshold
if phase == 'late' or pressure > 0.5:
effective_threshold += 2 # Accept higher value cards under pressure
if drawn_value <= effective_threshold:
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down:
# Pair hunters hold high cards hoping for matches
# BUT: check if pairing is actually viable
effective_hope = profile.pair_hope * pair_viability
if phase == 'late' or pressure > 0.5:
effective_hope *= 0.3 # Don't gamble late game
if effective_hope > 0.5 and drawn_value >= 6:
if random.random() < effective_hope:
return None
return random.choice(face_down)
return None
@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_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:
swap_pos = random.choice(face_down)
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:
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()