import asyncio import random from enum import Enum from card import Card from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE AI_USER_ID = "ai" class AIPersonality(Enum): AGGRESSIVE = "aggressive" # Prefers high attack cards, plays aggressively DEFENSIVE = "defensive" # Prefers high defense cards, plays conservatively BALANCED = "balanced" # Mix of offense and defense GREEDY = "greedy" # Prioritizes high cost cards, willing to sacrifice SWARM = "swarm" # Prefers low cost cards, fills board quickly CONTROL = "control" # Focuses on board control and efficiency ARBITRARY = "arbitrary" # Just does whatever def get_random_personality() -> AIPersonality: """Returns a random AI personality.""" return random.choice(list(AIPersonality)) def calculate_exact_cost(attack: int, defense: int) -> float: """Calculate the exact cost before rounding (matches card.py formula).""" return min(12.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5)) def get_power_curve_value(card: Card) -> float: """ Returns how much "above the power curve" a card is. Positive values mean the card is better than expected for its cost. """ exact_cost = calculate_exact_cost(card.attack, card.defense) return exact_cost - card.cost def get_card_efficiency(card: Card) -> float: """ Returns the total stats per cost ratio. Higher is better (more stats for the cost). """ if card.cost == 0: return 0 return (card.attack + card.defense) / card.cost def score_card_for_personality(card: Card, personality: AIPersonality) -> float: """ Score a card based on how well it fits the AI personality. Higher scores are better fits. """ if personality == AIPersonality.AGGRESSIVE: # Prefer high attack, attack > defense attack_bias = card.attack * 1.5 return attack_bias + (card.attack - card.defense) * 0.5 elif personality == AIPersonality.DEFENSIVE: # Prefer high defense, defense > attack defense_bias = card.defense * 1.5 return defense_bias + (card.defense - card.attack) * 0.5 elif personality == AIPersonality.BALANCED: # Prefer balanced stats stat_diff = abs(card.attack - card.defense) balance_score = (card.attack + card.defense) - stat_diff * 0.3 return balance_score elif personality == AIPersonality.GREEDY: # Prefer high cost cards return card.cost * 2 + (card.attack + card.defense) * 0.5 elif personality == AIPersonality.SWARM: # Prefer low cost cards low_cost_bonus = (13 - card.cost) * 1.5 return low_cost_bonus + (card.attack + card.defense) * 0.3 elif personality == AIPersonality.CONTROL: # Prefer efficient cards (good stats per cost) efficiency = get_card_efficiency(card) total_stats = card.attack + card.defense return efficiency * 5 + total_stats * 0.2 elif personality == AIPersonality.ARBITRARY: # Does whatever return random.random()*100 return card.attack + card.defense def energy_curve(difficulty: int, personality: AIPersonality) -> tuple[int, int, int]: """Calculate a desired energy curve based on difficulty, personality, and a random factor""" # First: cards with cost 1-3 # Second: cards with cost 4-6 # Third is inferred, and is cards with cost 7+ diff_low, diff_mid = [ (12, 8), # 1 (11, 9), # 2 (10, 9), # 3 ( 9,10), # 4 ( 9, 9), # 5 ( 9, 8), # 6 ( 8, 9), # 7 ( 7,10), # 8 ( 7, 9), # 9 ( 6, 9), # 10 ][difficulty - 1] r1 = random.randint(0,20) r2 = random.randint(0,20-r1) pers_low, pers_mid = { AIPersonality.AGGRESSIVE: ( 8,10), AIPersonality.ARBITRARY: (r1,r2), AIPersonality.BALANCED: ( 7,10), AIPersonality.CONTROL: ( 3, 8), AIPersonality.DEFENSIVE: ( 6, 8), AIPersonality.GREEDY: ( 3, 7), AIPersonality.SWARM: (15, 3), }[personality] # Blend difficulty (70%) and personality (30%) curves blended_low = diff_low * 0.7 + pers_low * 0.3 blended_mid = diff_mid * 0.7 + pers_mid * 0.3 # Add small random variance (±1) low = int(blended_low + random.uniform(-1, 1)) mid = int(blended_mid + random.uniform(-1, 1)) # Ensure low + mid doesn't exceed 20 if low + mid > 20: # Scale down proportionally total = low + mid low = int((low / total) * 20) mid = 20 - low high = 0 else: high = 20 - low - mid # Apply difficulty constraints if difficulty == 1: # Difficulty 1: absolutely no high-cost cards if high > 0: # Redistribute high cards to low and mid low += high // 2 mid += high - (high // 2) high = 0 # Final bounds checking low = max(0, min(20, low)) mid = max(0, min(20 - low, mid)) high = max(0, 20 - low - mid) return (low, mid, high) def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) -> list[Card]: """ Choose 20 cards from available cards based on difficulty and personality. Difficulty (1-10) affects: - Higher difficulty = prefers cards above the power curve - Lower difficulty = prefers low-cost cards for early game playability - Lower difficulty = avoids taking the ridiculously good high-cost cards Personality affects which types of cards are preferred. """ if len(cards) < 20: return cards # Get target energy curve based on difficulty and personality target_low, target_mid, target_high = energy_curve(difficulty, personality) selected = [] remaining = list(cards) # Fill each cost bracket by distributing across individual cost levels for cost_min, cost_max, target_count in [(1, 3, target_low), (4, 6, target_mid), (7, 12, target_high)]: if target_count == 0: continue bracket_cards = [c for c in remaining if cost_min <= c.cost <= cost_max] if not bracket_cards: continue # Group cards by exact cost by_cost = {} for card in bracket_cards: if card.cost not in by_cost: by_cost[card.cost] = [] by_cost[card.cost].append(card) # Distribute target_count across available costs available_costs = sorted(by_cost.keys()) if not available_costs: continue # Calculate how many cards to take from each cost level per_cost = max(1, target_count // len(available_costs)) remainder = target_count % len(available_costs) for cost in available_costs: cost_cards = by_cost[cost] # Score cards at this specific cost level cost_scores = [] for card in cost_cards: # Base score from personality (but normalize by cost to avoid bias) personality_score = score_card_for_personality(card, personality) # Normalize: divide by cost to make 1-cost and 3-cost comparable # Then multiply by average cost in bracket for scaling avg_bracket_cost = (cost_min + cost_max) / 2 normalized_score = (personality_score / max(1, card.cost)) * avg_bracket_cost # Power curve bonus power_curve = get_power_curve_value(card) difficulty_factor = (difficulty - 5.5) / 4.5 power_curve_score = power_curve * difficulty_factor * 5 # For low difficulties, heavily penalize high-cost cards with good stats if difficulty <= 4 and card.cost >= 7: power_penalty = max(0, power_curve) * -10 normalized_score += power_penalty total_score = normalized_score + power_curve_score cost_scores.append((card, total_score)) # Sort and take best from this cost level cost_scores.sort(key=lambda x: x[1], reverse=True) # Take per_cost, plus 1 extra if this is one of the remainder slots to_take = per_cost if remainder > 0: to_take += 1 remainder -= 1 to_take = min(to_take, len(cost_scores)) for i in range(to_take): card = cost_scores[i][0] selected.append(card) remaining.remove(card) if len(selected) >= 20: break if len(selected) >= 20: break # Fill remaining slots with best available cards # This handles cases where brackets didn't have enough cards while len(selected) < 20 and remaining: remaining_scores = [] for card in remaining: personality_score = score_card_for_personality(card, personality) power_curve = get_power_curve_value(card) difficulty_factor = (difficulty - 5.5) / 4.5 power_curve_score = power_curve * difficulty_factor * 5 # For remaining slots, add a slight preference for lower cost cards # to ensure we have early-game plays cost_penalty = (card.cost - 4) * 0.5 # Neutral at 4, penalty for higher total_score = personality_score + power_curve_score - cost_penalty remaining_scores.append((card, total_score)) remaining_scores.sort(key=lambda x: x[1], reverse=True) card = remaining_scores[0][0] selected.append(card) remaining.remove(card) return selected[:20] async def run_ai_turn(game_id: str): from game_manager import ( active_games, connections, active_deck_ids, serialize_state, record_game_result, calculate_combat_animation_time ) state = active_games.get(game_id) if not state or state.result: return if state.active_player_id != AI_USER_ID: return human_id = state.opponent_id(AI_USER_ID) waited = 0 while not connections[game_id].get(human_id) and waited < 10: await asyncio.sleep(0.5) waited += 0.5 await asyncio.sleep(calculate_combat_animation_time(state.last_combat_events)) player = state.players[AI_USER_ID] ws = connections[game_id].get(human_id) async def send_state(state): if ws: try: await ws.send_json({ "type": "state", "state": serialize_state(state, human_id), }) except Exception: pass most_expensive_in_hand = max((c.cost for c in player.hand), default=0) if player.energy < most_expensive_in_hand: for slot in range(BOARD_SIZE): slot_card = player.board[slot] if slot_card is not None and player.energy + slot_card.cost <= most_expensive_in_hand: if ws: try: await ws.send_json({ "type": "sacrifice_animation", "instance_id": slot_card.instance_id, }) except Exception: pass await asyncio.sleep(0.65) action_sacrifice(state, slot) await send_state(state) await asyncio.sleep(0.35) play_order = list(range(BOARD_SIZE)) random.shuffle(play_order) for slot in play_order: if player.board[slot] is not None: continue affordable = [i for i, c in enumerate(player.hand) if c.cost <= player.energy] if not affordable: break best = max(affordable, key=lambda i: player.hand[i].cost) action_play_card(state, best, slot) await send_state(state) await asyncio.sleep(0.5) action_end_turn(state) await send_state(state) if state.result: from database import SessionLocal db = SessionLocal() try: record_game_result(state, db) if ws: await ws.send_json({ "type": "state", "state": serialize_state(state, human_id), }) finally: db.close() active_deck_ids.pop(human_id, None) active_deck_ids.pop(AI_USER_ID, None) active_games.pop(game_id, None) connections.pop(game_id, None) return if state.active_player_id == AI_USER_ID: asyncio.create_task(run_ai_turn(game_id))