From cf9176edbd877fe03f267083bed6d8cca8695463 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 19 Mar 2026 11:54:06 +0100 Subject: [PATCH 1/2] :goat: --- frontend/src/lib/assets/favicon.svg | 2 +- frontend/src/routes/decks/[id]/+page.svelte | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 @@ -svelte-logo \ No newline at end of file +]>Wikipedia logo version 2 \ 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; From 0de769284ca96fb79319fa4690cff36ce0ae859b Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 19 Mar 2026 14:53:33 +0100 Subject: [PATCH 2/2] :goat: --- backend/ai.py | 349 ++++++++++++++++++++++++++ backend/card.py | 5 +- backend/game.py | 2 + backend/game_manager.py | 97 +------ backend/main.py | 39 ++- frontend/src/routes/play/+page.svelte | 2 +- 6 files changed, 399 insertions(+), 95 deletions(-) create mode 100644 backend/ai.py 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 fa747ff..41309e5 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 2fe2ed4..52f423e 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/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) {