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))

View File

@@ -489,15 +489,12 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
card_type_task, wikirank_task, pageviews_task card_type_task, wikirank_task, pageviews_task
) )
if ( if (
(card_type == CardType.other and instance == "") or
language_count == 0 or language_count == 0 or
score is None or score is None or
views is None views is None
): ):
error_message = f"Could not generate card '{title}': " error_message = f"Could not generate card '{title}': "
if card_type == CardType.other and instance == "": if language_count == 0:
error_message += "Not instance of a class"
elif language_count == 0:
error_message += "No language pages found" error_message += "No language pages found"
elif score is None: elif score is None:
error_message += "No wikirank score" error_message += "No wikirank score"

View File

@@ -96,6 +96,8 @@ class GameState:
result: Optional[GameResult] = None result: Optional[GameResult] = None
last_combat_events: list[CombatEvent] = field(default_factory=list) last_combat_events: list[CombatEvent] = field(default_factory=list)
turn_started_at: Optional[datetime] = None turn_started_at: Optional[datetime] = None
ai_difficulty: int = 5 # 1-10, only used for AI games
ai_personality: Optional[str] = None # AI personality type, only used for AI games
def opponent_id(self, player_id: str) -> str: def opponent_id(self, player_id: str) -> str:
return next(p for p in self.player_order if p != player_id) return next(p for p in self.player_order if p != player_id)

View File

@@ -14,11 +14,10 @@ from game import (
) )
from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
from card import compute_deck_type from card import compute_deck_type
from ai import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
logger = logging.getLogger("app") logger = logging.getLogger("app")
AI_USER_ID = "ai"
## Storage ## Storage
active_games: dict[str, GameState] = {} active_games: dict[str, GameState] = {}
@@ -376,15 +375,22 @@ def create_solo_game(
player_cards: list, player_cards: list,
ai_cards: list, ai_cards: list,
deck_id: str, deck_id: str,
difficulty: int = 5,
) -> str: ) -> str:
ai_personality = get_random_personality()
ai_deck = choose_cards(ai_cards, difficulty, ai_personality)
player_deck_type = compute_deck_type(player_cards) or "Balanced" player_deck_type = compute_deck_type(player_cards) or "Balanced"
ai_deck_type = compute_deck_type(ai_cards) or "Balanced" ai_deck_type = compute_deck_type(ai_deck) or "Balanced"
state = create_game( state = create_game(
user_id, username, player_deck_type, player_cards, user_id, username, player_deck_type, player_cards,
AI_USER_ID, "Computer", ai_deck_type, ai_cards, AI_USER_ID, "Computer", ai_deck_type, ai_deck,
) )
state.ai_difficulty = difficulty
state.ai_personality = ai_personality.value
active_games[state.game_id] = state active_games[state.game_id] = state
connections[state.game_id] = {} connections[state.game_id] = {}
active_deck_ids[user_id] = deck_id active_deck_ids[user_id] = deck_id
@@ -422,86 +428,3 @@ def calculate_combat_animation_time(events: list[CombatEvent]) -> float:
total += ANIMATION_DELAYS["post_combat_buffer"] total += ANIMATION_DELAYS["post_combat_buffer"]
return total return total
async def run_ai_turn(game_id: str):
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: GameState):
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))

View File

@@ -430,7 +430,10 @@ async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_
return {"message": "Win claimed"} return {"message": "Win claimed"}
@app.post("/game/solo") @app.post("/game/solo")
async def start_solo_game(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if difficulty < 1 or difficulty > 10:
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
deck = db.query(DeckModel).filter( deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id), DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == user.id DeckModel.user_id == user.id
@@ -448,7 +451,7 @@ async def start_solo_game(deck_id: str, user: UserModel = Depends(get_current_us
ai_cards = db.query(CardModel).filter( ai_cards = db.query(CardModel).filter(
CardModel.user_id == None CardModel.user_id == None
).order_by(func.random()).limit(20).all() ).order_by(func.random()).limit(100).all()
if len(ai_cards) < 20: if len(ai_cards) < 20:
raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck") raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
@@ -458,7 +461,7 @@ async def start_solo_game(deck_id: str, user: UserModel = Depends(get_current_us
db.commit() db.commit()
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id) game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty)
asyncio.create_task(fill_card_pool()) asyncio.create_task(fill_card_pool())
@@ -528,3 +531,33 @@ def refresh(req: RefreshRequest, db: Session = Depends(get_db)):
"refresh_token": create_refresh_token(str(user.id)), "refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer", "token_type": "bearer",
} }
if __name__ == "__main__":
from ai import AIPersonality, choose_cards
from card import generate_cards, Card
from time import sleep
all_cards: list[Card] = []
for i in range(30):
print(i)
all_cards += generate_cards(10)
sleep(5)
all_cards.sort(key=lambda x: x.cost, reverse=True)
print(len(all_cards))
def write_cards(cards: list[Card], file: str):
with open(file, "w") as fp:
fp.write('\n'.join([
f"{c.name} - {c.attack}/{c.defense} - {c.cost}"
for c in cards
]))
write_cards(all_cards, "output/all.txt")
for personality in AIPersonality:
print(personality.value)
for difficulty in range(1,11):
chosen_cards = choose_cards(all_cards, difficulty, personality)
chosen_cards.sort(key=lambda x: x.cost, reverse=True)
write_cards(chosen_cards, f"output/{personality.value}-{difficulty}.txt")

View File

@@ -270,7 +270,7 @@
if (!selectedDeckId || selectedDeck?.card_count < 20) return; if (!selectedDeckId || selectedDeck?.card_count < 20) return;
error = ''; error = '';
phase = 'queuing'; phase = 'queuing';
const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}`, { const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}&difficulty=5`, {
method: 'POST' method: 'POST'
}); });
if (!res.ok) { if (!res.ok) {