diff --git a/backend/ai.py b/backend/ai.py
new file mode 100644
index 0000000..bdc86ec
--- /dev/null
+++ b/backend/ai.py
@@ -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))
diff --git a/backend/card.py b/backend/card.py
index dd09811..61b66dd 100644
--- a/backend/card.py
+++ b/backend/card.py
@@ -489,15 +489,12 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
card_type_task, wikirank_task, pageviews_task
)
if (
- (card_type == CardType.other and instance == "") or
language_count == 0 or
score is None or
views is None
):
error_message = f"Could not generate card '{title}': "
- if card_type == CardType.other and instance == "":
- error_message += "Not instance of a class"
- elif language_count == 0:
+ if language_count == 0:
error_message += "No language pages found"
elif score is None:
error_message += "No wikirank score"
diff --git a/backend/game.py b/backend/game.py
index ac99f71..aa0fa49 100644
--- a/backend/game.py
+++ b/backend/game.py
@@ -96,6 +96,8 @@ class GameState:
result: Optional[GameResult] = None
last_combat_events: list[CombatEvent] = field(default_factory=list)
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:
return next(p for p in self.player_order if p != player_id)
diff --git a/backend/game_manager.py b/backend/game_manager.py
index fc698de..8ea2f50 100644
--- a/backend/game_manager.py
+++ b/backend/game_manager.py
@@ -14,11 +14,10 @@ from game import (
)
from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
from card import compute_deck_type
+from ai import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
logger = logging.getLogger("app")
-AI_USER_ID = "ai"
-
## Storage
active_games: dict[str, GameState] = {}
@@ -376,15 +375,22 @@ def create_solo_game(
player_cards: list,
ai_cards: list,
deck_id: str,
+ difficulty: int = 5,
) -> 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"
- ai_deck_type = compute_deck_type(ai_cards) or "Balanced"
+ ai_deck_type = compute_deck_type(ai_deck) or "Balanced"
state = create_game(
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
connections[state.game_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"]
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))
diff --git a/backend/main.py b/backend/main.py
index 5254d9f..40cdd90 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -430,7 +430,10 @@ async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_
return {"message": "Win claimed"}
@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(
DeckModel.id == uuid.UUID(deck_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(
CardModel.user_id == None
- ).order_by(func.random()).limit(20).all()
+ ).order_by(func.random()).limit(100).all()
if len(ai_cards) < 20:
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()
- 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())
@@ -528,3 +531,33 @@ def refresh(req: RefreshRequest, db: Session = Depends(get_db)):
"refresh_token": create_refresh_token(str(user.id)),
"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")
diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg
index cc5dc66..dc32f98 100644
--- a/frontend/src/lib/assets/favicon.svg
+++ b/frontend/src/lib/assets/favicon.svg
@@ -1 +1 @@
-
\ No newline at end of file
+]>
\ No newline at end of file
diff --git a/frontend/src/routes/decks/[id]/+page.svelte b/frontend/src/routes/decks/[id]/+page.svelte
index c184704..5097014 100644
--- a/frontend/src/routes/decks/[id]/+page.svelte
+++ b/frontend/src/routes/decks/[id]/+page.svelte
@@ -43,10 +43,10 @@
result = result.slice().sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
- else if (sortBy === 'cost') cmp = a.cost - b.cost || a.name.localeCompare(b.name);
- else if (sortBy === 'attack') cmp = a.attack - b.attack || a.name.localeCompare(b.name);
- else if (sortBy === 'defense') cmp = a.defense - b.defense || a.name.localeCompare(b.name);
- else if (sortBy === 'rarity') cmp = RARITY_ORDER[a.card_rarity] - RARITY_ORDER[b.card_rarity] || a.name.localeCompare(b.name);
+ else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
+ else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
+ else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
+ else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp;
});
return result;
diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte
index 935dac3..1d3cc61 100644
--- a/frontend/src/routes/play/+page.svelte
+++ b/frontend/src/routes/play/+page.svelte
@@ -270,7 +270,7 @@
if (!selectedDeckId || selectedDeck?.card_count < 20) return;
error = '';
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'
});
if (!res.ok) {