This commit is contained in:
Nikolaj
2026-03-19 14:53:33 +01:00
parent cf9176edbd
commit 0de769284c
6 changed files with 399 additions and 95 deletions

349
backend/ai.py Normal file
View File

@@ -0,0 +1,349 @@
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))