diff --git a/backend/ai.py b/backend/ai.py index bdc86ec..9240f0e 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -1,8 +1,13 @@ import asyncio import random +import logging +from dataclasses import dataclass from enum import Enum +from itertools import combinations from card import Card -from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE +from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE, STARTING_LIFE + +logger = logging.getLogger("app") AI_USER_ID = "ai" @@ -21,244 +26,418 @@ def get_random_personality() -> 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)) + return min(11.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5)) -def get_power_curve_value(card: Card) -> float: +def get_power_curve_value(card) -> float: """ - Returns how much "above the power curve" a card is. - Positive values mean the card is better than expected for its cost. + Returns how much above the power curve a card is. + Positive values mean the card is a better-than-expected deal 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. + BUDGET = 50 - 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 + logger.info(f"Personality: {personality.value}") + logger.info(f"Difficulty: {difficulty}") + card_strings = [ + f"{c.name} {c.cost}" + for c in sorted(cards, key=lambda x: x.cost)[::-1][:20] + ] + logger.info("Cards:\n"+("\n".join(card_strings))) - Personality affects which types of cards are preferred. - """ - if len(cards) < 20: - return cards + # God cards (cost 7-11) are gated by difficulty. Below difficulty 7 they are excluded. + # Each level from 7 upward unlocks a higher cost tier; at difficulty 10 all are allowed. + if difficulty >= 6: + max_card_cost = difficulty+1 + else: + max_card_cost = 6 - # Get target energy curve based on difficulty and personality - target_low, target_mid, target_high = energy_curve(difficulty, personality) + allowed = [c for c in cards if c.cost <= max_card_cost] or list(cards) - selected = [] - remaining = list(cards) + def card_score(card: Card) -> float: + pcv = get_power_curve_value(card) + # Normalize pcv to [0, 1]. + pcv_norm = max(0.0, min(1.0, pcv)) - # 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: + cost_norm = card.cost / max_card_cost # [0, 1]; higher = more expensive + total = card.attack + card.defense + atk_ratio = card.attack / total if total else 0.5 + + if personality == AIPersonality.AGGRESSIVE: + # Prefers high-attack cards; slight bias toward high cost for raw power + return 0.50 * atk_ratio + 0.30 * pcv_norm + 0.20 * cost_norm + + if personality == AIPersonality.DEFENSIVE: + # Prefers high-defense cards; same cost bias + return 0.50 * (1.0 - atk_ratio) + 0.30 * pcv_norm + 0.20 * cost_norm + + if personality == AIPersonality.GREEDY: + # Fills budget with the fewest, most expensive cards possible + return 0.70 * cost_norm + 0.30 * pcv_norm + + if personality == AIPersonality.SWARM: + # Cheap cards + return 0.45 * (1.0 - cost_norm) + 0.35 * atk_ratio + 0.20 * pcv_norm + + if personality == AIPersonality.CONTROL: + # Values efficiency above all: wants cards that are above the power curve, + # with a secondary preference for higher cost + return 0.70 * pcv_norm + 0.30 * cost_norm + + if personality == AIPersonality.BALANCED: + # Blends everything: efficiency, cost spread, and a slight attack lean + return 0.40 * pcv_norm + 0.35 * cost_norm + 0.15 * atk_ratio + 0.10 * (1.0 - atk_ratio) + + # ARBITRARY: mostly random at lower difficulties + return (0.05 * difficulty) * pcv_norm + (1 - (0.05 * difficulty)) * random.random() + + # Higher difficulty -> less noise -> more optimal deck composition + noise = ((10 - difficulty) / 9.0) * 0.50 + + scored = sorted( + [(card_score(c) + random.gauss(0, noise), c) for c in allowed], + key=lambda x: x[0], + reverse=True, + ) + + # Minimum budget reserved for cheap (cost 1-3) cards to ensure early-game presence. + # Without cheap cards the AI will play nothing for the first several turns. + early_budget = { + AIPersonality.GREEDY: 4, + AIPersonality.SWARM: 12, + AIPersonality.AGGRESSIVE: 8, + AIPersonality.DEFENSIVE: 10, + AIPersonality.CONTROL: 8, + AIPersonality.BALANCED: 10, + AIPersonality.ARBITRARY: 8, + }[personality] + + selected: list[Card] = [] + total_cost = 0 + + # First pass: secure early-game cards + cheap_spent = 0 + for _, card in scored: + if cheap_spent >= early_budget: + break + if card.cost > 3 or total_cost + card.cost > BUDGET: 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) + total_cost += card.cost + cheap_spent += card.cost - return selected[:20] + # Second pass: fill remaining budget greedily by score + taken = {id(c) for c in selected} + for _, card in scored: + if total_cost >= BUDGET: + break + if id(card) in taken or total_cost + card.cost > BUDGET: + continue + selected.append(card) + total_cost += card.cost + + + card_strings = [ + f"{c.name} {c.cost}" + for c in sorted(selected, key=lambda x: x.cost) + ] + logger.info("Selected:\n"+("\n".join(card_strings))) + + return selected + + +# ==================== Turn planning ==================== + +@dataclass +class MovePlan: + sacrifice_slots: list[int] + plays: list[tuple] # (CardInstance, board_slot: int) + label: str = "" + + +def _affordable_subsets(hand, energy, start=0): + """Yield every subset of cards from hand whose total cost fits within energy.""" + yield [] + for i in range(start, len(hand)): + card = hand[i] + if card.cost <= energy: + for rest in _affordable_subsets(hand, energy - card.cost, i + 1): + yield [card] + rest + + +def _plans_for_sacrifice(player, opponent, sacrifice_slots): + """Generate one plan per affordable card subset for a given sacrifice set.""" + board = list(player.board) + energy = player.energy + + for slot in sacrifice_slots: + if board[slot] is not None: + board[slot] = None + energy += 1 + + hand = list(player.hand) + empty_slots = [i for i, c in enumerate(board) if c is None] + en_board = opponent.board + + # For scoring: open enemy slots first so the simulation reflects + # direct-damage potential accurately. + scoring_slots = ( + [s for s in empty_slots if en_board[s] is None] + + [s for s in empty_slots if en_board[s] is not None] + ) + + return [ + MovePlan( + sacrifice_slots=list(sacrifice_slots), + plays=list(zip(cards, scoring_slots)), + label=f"sac{len(sacrifice_slots)}_play{len(cards)}", + ) + for cards in _affordable_subsets(hand, energy) + ] + + +def generate_plans(player, opponent) -> list[MovePlan]: + """Generate diverse candidate move plans covering a range of strategies.""" + plans = [] + + # Sacrifice n board cards + occupied = [s for s in range(BOARD_SIZE) if player.board[s] is not None] + for n in range(len(occupied) + 1): + for slots in combinations(occupied, n): + plans += _plans_for_sacrifice(player, opponent, list(slots)) + + # Idle: do nothing + plans.append(MovePlan(sacrifice_slots=[], plays=[], label="idle")) + + return plans + + +def score_plan(plan: MovePlan, player, opponent, personality: AIPersonality) -> float: + """ + Score a plan from ~0.0 to ~1.0 based on the projected board state after + executing it. Higher is better. + """ + # Simulate board after sacrifices + plays + board = list(player.board) + energy = player.energy + for slot in plan.sacrifice_slots: + if board[slot] is not None: + board[slot] = None + energy += 1 + for card, slot in plan.plays: + board[slot] = card + + en_board = opponent.board + enemy_occupied = sum(1 for c in en_board if c is not None) + + # --- Combat metrics --- + direct_damage = 0 # AI attacks going straight to opponent life + board_damage = 0 # AI attacks hitting enemy cards + blocking_slots = 0 # Slots where AI blocks an enemy card + cards_destroyed = 0 # Enemy cards the AI would destroy this turn + unblocked_incoming = 0 # Enemy attacks that go straight to AI life + cards_on_board = 0 + + for slot in range(BOARD_SIZE): + my = board[slot] + en = en_board[slot] + if my: + cards_on_board += 1 + if my and en is None: + direct_damage += my.attack + if my and en: + board_damage += my.attack + blocking_slots += 1 + if my.attack >= en.defense: + cards_destroyed += 1 + if not my and en: + unblocked_incoming += en.attack + + # --- Normalize to [0, 1] --- + # How threatening is the attack relative to what remains of opponent's life? + atk_score = min(1.0, direct_damage / max(opponent.life, 1)) + + # What fraction of enemy slots are blocked? + block_score = (blocking_slots / enemy_occupied) if enemy_occupied > 0 else 1.0 + + # What fraction of all slots are filled? + cover_score = cards_on_board / BOARD_SIZE + + # What fraction of enemy cards do are destroyed? + destroy_score = (cards_destroyed / enemy_occupied) if enemy_occupied > 0 else 0.0 + + # How safe is the AI from unblocked hits relative to its own life? + threat_score = 1.0 - min(1.0, unblocked_incoming / max(player.life, 1)) + + # How many cards compared to the enemy? + opponent_cards_left = len(opponent.deck) + len(opponent.hand) + enemy_occupied + my_cards_left = len(player.deck) + len(player.hand) + blocking_slots + attrition_score = my_cards_left/(my_cards_left + opponent_cards_left) + + # Net value: cost of cards played minus cost of cards sacrificed. + n_sac = len(plan.sacrifice_slots) + sac_value = sum(player.board[s].cost for s in plan.sacrifice_slots if player.board[s] is not None) + play_value = sum(c.cost for c, _ in plan.plays) + net_value = play_value - sac_value + net_value_norm = max(0.0, min(1.0, (net_value + 10) / 20)) + + # Sacrifice penalty. Applied as a flat deduction after personality scoring. + sacrifice_penalty = 0.0 + if n_sac > 0: + # Penalty 1: wasted energy. Each sacrifice gives +1 energy; if that energy + # goes unspent it was pointless. Weighted heavily. + energy_leftover = player.energy + n_sac - play_value + wasted_sac_energy = max(0, min(n_sac, energy_leftover)) + wasted_penalty = wasted_sac_energy / n_sac + + # Penalty 2: low-value swap. Each sacrifice should at minimum unlock a card + # that costs more than the one removed (net_value > n_sac means each + # sacrifice bought at least one extra cost point). Anything less is a bad trade. + swap_penalty = max(0.0, min(1.0, (n_sac - net_value) / max(n_sac, 1))) + + sacrifice_penalty = 0.65 * wasted_penalty + 0.35 * swap_penalty + + # Power curve value of the cards played (are they good value for their cost?) + if plan.plays: + pcv_scores = [max(0.0, min(1.0, get_power_curve_value(c))) for c, _ in plan.plays] + pcv_score = sum(pcv_scores) / len(pcv_scores) + else: + pcv_score = 0.5 + + # --- Personality weights --- + if personality == AIPersonality.AGGRESSIVE: + # Maximize direct damage + score = ( + 0.40 * atk_score + + 0.10 * block_score + + 0.10 * cover_score + + 0.10 * net_value_norm + + 0.15 * destroy_score + + 0.05 * attrition_score + + 0.05 * pcv_score + + 0.05 * threat_score + ) + + elif personality == AIPersonality.DEFENSIVE: + # Block everything + score = ( + 0.05 * atk_score + + 0.35 * block_score + + 0.20 * cover_score + + 0.05 * net_value_norm + + 0.05 * destroy_score + + 0.10 * attrition_score + + 0.05 * pcv_score + + 0.15 * threat_score + ) + + elif personality == AIPersonality.SWARM: + # Fill the board and press with direct damage + score = ( + 0.25 * atk_score + + 0.10 * block_score + + 0.35 * cover_score + + 0.05 * net_value_norm + + 0.05 * destroy_score + + 0.10 * attrition_score + + 0.05 * pcv_score + + 0.05 * threat_score + ) + + elif personality == AIPersonality.GREEDY: + # High-value card plays, willing to sacrifice weak cards for strong ones + score = ( + 0.20 * atk_score + + 0.05 * block_score + + 0.10 * cover_score + + 0.40 * net_value_norm + + 0.05 * destroy_score + + 0.05 * attrition_score + + 0.10 * pcv_score + + 0.05 * threat_score + ) + + elif personality == AIPersonality.CONTROL: + # Efficiency + score = ( + 0.10 * atk_score + + 0.05 * block_score + + 0.05 * cover_score + + 0.20 * net_value_norm + + 0.05 * destroy_score + + 0.10 * attrition_score + + 0.40 * pcv_score + + 0.05 * threat_score + ) + + elif personality == AIPersonality.BALANCED: + score = ( + 0.10 * atk_score + + 0.15 * block_score + + 0.10 * cover_score + + 0.10 * net_value_norm + + 0.10 * destroy_score + + 0.10 * attrition_score + + 0.15 * pcv_score + + 0.10 * threat_score + ) + + else: # ARBITRARY + score = ( + 0.60 * random.random() + + 0.05 * atk_score + + 0.05 * block_score + + 0.05 * cover_score + + 0.05 * net_value_norm + + 0.05 * destroy_score + + 0.05 * attrition_score + + 0.05 * pcv_score + + 0.05 * threat_score + ) + + # --- Context adjustments --- + + # Lethal takes priority regardless of personality + if direct_damage >= opponent.life: + score = max(score, 0.95) + + if unblocked_incoming >= player.life: + score = min(score, 0.05) + + # Against god-card decks: cover all slots so their big cards can't attack freely + if opponent.deck_type in ("God Card", "Pantheon"): + score = min(1.0, score + 0.08 * cover_score) + + # Against aggro/rush: need to block more urgently + if opponent.deck_type in ("Aggro", "Rush"): + score = min(1.0, score + 0.06 * block_score + 0.04 * threat_score) + + # Against wall decks: direct damage matters more than destroying cards + if opponent.deck_type == "Wall": + score = min(1.0, score + 0.06 * atk_score) + + # Press the advantage when opponent is low on life + if opponent.life < STARTING_LIFE * 0.3: + score = min(1.0, score + 0.06 * atk_score) + + # Prioritize survival when low on life + if player.life < STARTING_LIFE * 0.3: + score = min(1.0, score + 0.06 * threat_score + 0.04 * block_score) + + # Opponent running low on cards: keep a card on board for attrition win condition + if opponent_cards_left <= 5 and cards_on_board > 0: + score = min(1.0, score + 0.05) + + # Apply sacrifice penalty last so it can override all other considerations. + score = max(0.0, score - sacrifice_penalty) + + return score + + +# ==================== Turn execution ==================== async def run_ai_turn(game_id: str): from game_manager import ( @@ -281,46 +460,78 @@ async def run_ai_turn(game_id: str): await asyncio.sleep(calculate_combat_animation_time(state.last_combat_events)) player = state.players[AI_USER_ID] + opponent = state.players[human_id] + difficulty = state.ai_difficulty + personality = ( + AIPersonality(state.ai_personality) + if state.ai_personality + else AIPersonality.BALANCED + ) ws = connections[game_id].get(human_id) - async def send_state(state): + + async def send_state(s): if ws: try: - await ws.send_json({ - "type": "state", - "state": serialize_state(state, human_id), - }) + await ws.send_json({"type": "state", "state": serialize_state(s, 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) + async def send_sacrifice_anim(instance_id): + if ws: + try: + await ws.send_json({"type": "sacrifice_animation", "instance_id": instance_id}) + except Exception: + pass - play_order = list(range(BOARD_SIZE)) - random.shuffle(play_order) - for slot in play_order: + # --- Generate and score candidate plans --- + plans = generate_plans(player, opponent) + + if difficulty <= 2: + # Actively bad + scored = [(score_plan(p, player, opponent, personality) + random.gauss(0, 0.15*difficulty), p) + for p in plans] + best_plan = min(scored, key=lambda x: x[0])[1] + elif difficulty == 3: + # Fully random + best_plan = random.choice(plans) + else: + noise = max(0.0, ((8 - difficulty) / 6.0) * 0.30) + scored = [(score_plan(p, player, opponent, personality) + random.gauss(0, noise), p) + for p in plans] + best_plan = max(scored, key=lambda x: x[0])[1] + + logger.info( + f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} plans={len(plans)} " + + f"sac={best_plan.sacrifice_slots} plays={[c.name for c, _ in best_plan.plays]}" + ) + + # --- Execute sacrifices --- + for slot in best_plan.sacrifice_slots: + card_slot = player.board[slot] + if card_slot is None: + continue + await send_sacrifice_anim(card_slot.instance_id) + await asyncio.sleep(0.65) + action_sacrifice(state, slot) + await send_state(state) + await asyncio.sleep(0.35) + + # --- Execute plays --- + # Shuffle play order so the AI doesn't always fill slots left-to-right + plays = list(best_plan.plays) + random.shuffle(plays) + + for card, slot in plays: + # Re-look up hand index each time (hand shrinks as cards are played) + hand_idx = next((i for i, c in enumerate(player.hand) if c is card), None) + if hand_idx is None: + continue 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) + if card.cost > player.energy: + continue + action_play_card(state, hand_idx, slot) await send_state(state) await asyncio.sleep(0.5) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index cb3baa2..2b78f47 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,3 +1,6 @@ +from dotenv import load_dotenv +load_dotenv() + from logging.config import fileConfig from sqlalchemy import engine_from_config diff --git a/backend/alembic/versions/cd7ebb9b11bd_add_ai_used_to_cards.py b/backend/alembic/versions/cd7ebb9b11bd_add_ai_used_to_cards.py new file mode 100644 index 0000000..4aa3079 --- /dev/null +++ b/backend/alembic/versions/cd7ebb9b11bd_add_ai_used_to_cards.py @@ -0,0 +1,34 @@ +"""add ai_used to cards + +Revision ID: cd7ebb9b11bd +Revises: adee6bcc23e1 +Create Date: 2026-03-19 18:23:14.422342 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cd7ebb9b11bd' +down_revision: Union[str, Sequence[str], None] = 'adee6bcc23e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('cards', sa.Column('ai_used', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + op.execute("UPDATE cards SET ai_used = false") + op.alter_column('cards', 'ai_used', nullable=False) + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('cards', 'ai_used') + # ### end Alembic commands ### diff --git a/backend/card.py b/backend/card.py index 61b66dd..6e9807f 100644 --- a/backend/card.py +++ b/backend/card.py @@ -90,6 +90,7 @@ class Card(NamedTuple): WIKIDATA_INSTANCE_TYPE_MAP = { "Q5": CardType.person, # human + "Q95074": CardType.person, # character "Q215627": CardType.person, # person "Q15632617": CardType.person, # fictional human "Q22988604": CardType.person, # fictional human @@ -141,6 +142,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q23442": CardType.location, # island "Q82794": CardType.location, # geographic region "Q34442": CardType.location, # road + "Q486972": CardType.location, # human settlement "Q192611": CardType.location, # electoral unit "Q398141": CardType.location, # school district "Q133056": CardType.location, # mountain pass @@ -149,6 +151,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q7930989": CardType.location, # city/town "Q1250464": CardType.location, # realm "Q3146899": CardType.location, # diocese of the catholic church + "Q12076836": CardType.location, # administrative territorial entity of a single country "Q35145263": CardType.location, # natural geographic object "Q15642541": CardType.location, # human-geographic territorial entity @@ -179,6 +182,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q13406554": CardType.event, # sports competition "Q15275719": CardType.event, # recurring event "Q27968055": CardType.event, # recurring event edition + "Q15091377": CardType.event, # cycling race "Q114609228": CardType.event, # recurring sporting event "Q7278": CardType.group, # political party @@ -216,6 +220,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q12140": CardType.science_thing, # medication "Q11276": CardType.science_thing, # globular cluster "Q83373": CardType.science_thing, # quasar + "Q177719": CardType.science_thing, # medical diagnosis "Q898273": CardType.science_thing, # protein domain "Q134808": CardType.science_thing, # vaccine "Q168845": CardType.science_thing, # star cluster @@ -224,7 +229,8 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q1840368": CardType.science_thing, # cloud type "Q2154519": CardType.science_thing, # astrophysical x-ray source "Q17444909": CardType.science_thing, # astronomical object type - "Q12089225": CardType.science_thing, # Mineral species + "Q12089225": CardType.science_thing, # mineral species + "Q55640599": CardType.science_thing, # group of chemical entities "Q113145171": CardType.science_thing, # type of chemical entity "Q1420": CardType.vehicle, # car @@ -519,7 +525,7 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None text=text, attack=attack, defense=defense, - cost=min(12,max(1,int(((attack**2+defense**2)**0.18)/1.5))) + cost=min(11,max(1,int(((attack**2+defense**2)**0.18)/1.5))) ) async def _get_cards_async(size: int) -> list[Card]: @@ -542,7 +548,7 @@ def generate_card(title: str) -> Card|None: # Cards helper function def compute_deck_type(cards: list) -> str | None: - if len(cards) < 20: + if len(cards) == 0: return None avg_atk = sum(c.attack for c in cards) / len(cards) avg_def = sum(c.defense for c in cards) / len(cards) diff --git a/backend/database_functions.py b/backend/database_functions.py index b06db2e..fdbcb6c 100644 --- a/backend/database_functions.py +++ b/backend/database_functions.py @@ -11,10 +11,10 @@ from database import SessionLocal logger = logging.getLogger("app") -POOL_MINIMUM = 500 -POOL_TARGET = 1000 +POOL_MINIMUM = 1000 +POOL_TARGET = 2000 POOL_BATCH_SIZE = 10 -POOL_SLEEP = 5.0 +POOL_SLEEP = 4.0 pool_filling = False @@ -25,42 +25,43 @@ async def fill_card_pool(): return db: Session = SessionLocal() - try: - unassigned = db.query(CardModel).filter(CardModel.user_id == None).count() - logger.info(f"Card pool has {unassigned} unassigned cards") - if unassigned >= POOL_MINIMUM: - logger.info("Pool sufficiently stocked, skipping fill") - return + while True: + try: + unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count() + logger.info(f"Card pool has {unassigned} unassigned cards") + if unassigned >= POOL_MINIMUM: + logger.info("Pool sufficiently stocked, skipping fill") + return - pool_filling = True - needed = POOL_TARGET - unassigned - logger.info(f"Filling pool with {needed} cards") + pool_filling = True + needed = POOL_TARGET - unassigned + logger.info(f"Filling pool with {needed} cards") - fetched = 0 - while fetched < needed: - batch_size = min(POOL_BATCH_SIZE, needed - fetched) - cards = await _get_cards_async(batch_size) + fetched = 0 + while fetched < needed: + batch_size = min(POOL_BATCH_SIZE, needed - fetched) + cards = await _get_cards_async(batch_size) - for card in cards: - db.add(CardModel( - name=card.name, - image_link=card.image_link, - card_rarity=card.card_rarity.name, - card_type=card.card_type.name, - text=card.text, - attack=card.attack, - defense=card.defense, - cost=card.cost, - user_id=None, - )) - db.commit() - fetched += batch_size - logger.info(f"Pool fill progress: {fetched}/{needed}") - await asyncio.sleep(POOL_SLEEP) + for card in cards: + db.add(CardModel( + name=card.name, + image_link=card.image_link, + card_rarity=card.card_rarity.name, + card_type=card.card_type.name, + text=card.text, + attack=card.attack, + defense=card.defense, + cost=card.cost, + user_id=None, + )) + db.commit() + fetched += batch_size + logger.info(f"Pool fill progress: {fetched}/{needed}") + await asyncio.sleep(POOL_SLEEP) - finally: - pool_filling = False - db.close() + finally: + pool_filling = False + db.close() BOOSTER_MAX = 5 BOOSTER_COOLDOWN_HOURS = 5 diff --git a/backend/email_utils.py b/backend/email_utils.py index 03b7846..ea339d9 100644 --- a/backend/email_utils.py +++ b/backend/email_utils.py @@ -21,7 +21,7 @@ def send_password_reset_email(to_email: str, username: str, reset_token: str):

This link expires in 1 hour. If you didn't request this, you can safely ignore this email.

-

— WikiTCG

+

- WikiTCG

""", }) \ No newline at end of file diff --git a/backend/game.py b/backend/game.py index aa0fa49..23bfb6f 100644 --- a/backend/game.py +++ b/backend/game.py @@ -253,7 +253,8 @@ def action_sacrifice(state: GameState, slot: int) -> str | None: if card is None: return "No card in that slot" - player.energy += card.cost + # player.energy += card.cost + player.energy += 1 player.board[slot] = None return None diff --git a/backend/game_manager.py b/backend/game_manager.py index 8ea2f50..3cae411 100644 --- a/backend/game_manager.py +++ b/backend/game_manager.py @@ -56,15 +56,21 @@ def record_game_result(state: GameState, db: Session): loser_deck_id = active_deck_ids.get(loser_id_str) if AI_USER_ID not in [winner_id_str, loser_id_str]: - deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first() - if deck: - deck.times_played += 1 - deck.wins += 1 + if winner_deck_id: + deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first() + if deck: + deck.times_played += 1 + deck.wins += 1 + else: + logger.warning(f"record_game_result: no deck_id found for winner {winner_id_str}") - deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first() - if deck: - deck.times_played += 1 - deck.losses += 1 + if loser_deck_id: + deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first() + if deck: + deck.times_played += 1 + deck.losses += 1 + else: + logger.warning(f"record_game_result: no deck_id found for loser {loser_id_str}") db.commit() @@ -251,6 +257,7 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session): db.commit() except Exception as e: logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}") + db.rollback() elif action == "sacrifice": slot = message.get("slot") if slot is None: @@ -297,7 +304,7 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session): DISCONNECT_GRACE_SECONDS = 15 -async def handle_disconnect(game_id: str, user_id: str, db: Session): +async def handle_disconnect(game_id: str, user_id: str): await asyncio.sleep(DISCONNECT_GRACE_SECONDS) # Check if game still exists and player hasn't reconnected @@ -318,7 +325,12 @@ async def handle_disconnect(game_id: str, user_id: str, db: Session): ) state.phase = "end" - record_game_result(state, db) + from database import SessionLocal + db = SessionLocal() + try: + record_game_result(state, db) + finally: + db.close() # Notify the remaining player winner_ws = connections[game_id].get(winner_id) diff --git a/backend/main.py b/backend/main.py index 40cdd90..95f8852 100644 --- a/backend/main.py +++ b/backend/main.py @@ -149,7 +149,7 @@ async def open_pack(request: Request, user: UserModel = Depends(get_current_user cards = ( db.query(CardModel) - .filter(CardModel.user_id == None) + .filter(CardModel.user_id == None, CardModel.ai_used == False) .limit(5) .all() ) @@ -191,6 +191,7 @@ def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends "id": str(deck.id), "name": deck.name, "card_count": len(cards), + "total_cost": sum(card.cost for card in cards), "times_played": deck.times_played, "wins": deck.wins, "losses": deck.losses, @@ -215,7 +216,7 @@ def update_deck(deck_id: str, body: dict, user: UserModel = Depends(get_current_ deck.name = body["name"] if "card_ids" in body: db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete() - for card_id in body["card_ids"][:20]: + for card_id in body["card_ids"]: db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id))) if deck.times_played > 0: deck.wins = 0 @@ -265,9 +266,10 @@ async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depen await websocket.close(code=1008) return - card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count() - if card_count < 20: - await websocket.send_json({"type": "error", "message": "Deck must have 20 cards"}) + card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()] + total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0 + if total_cost == 0 or total_cost > 50: + await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"}) await websocket.close(code=1008) return @@ -318,7 +320,7 @@ async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depend except WebSocketDisconnect: if game_id in connections: connections[game_id].pop(user_id, None) - asyncio.create_task(handle_disconnect(game_id, user_id, db)) + asyncio.create_task(handle_disconnect(game_id, user_id)) @app.get("/profile") def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): @@ -441,28 +443,27 @@ async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = D if not deck: raise HTTPException(status_code=404, detail="Deck not found") - card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count() - if card_count < 20: - raise HTTPException(status_code=400, detail="Deck must have 20 cards") + card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()] + total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0 + if total_cost == 0 or total_cost > 50: + raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50") player_cards = load_deck_cards(deck_id, str(user.id), db) if player_cards is None: raise HTTPException(status_code=503, detail="Couldn't load deck") ai_cards = db.query(CardModel).filter( - CardModel.user_id == None - ).order_by(func.random()).limit(100).all() + CardModel.user_id == None, + ).order_by(func.random()).limit(500).all() - if len(ai_cards) < 20: + if len(ai_cards) == 0: raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck") for card in ai_cards: - db.delete(card) - + card.ai_used = True db.commit() game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty) - asyncio.create_task(fill_card_pool()) return {"game_id": game_id} @@ -488,7 +489,7 @@ def reset_password(req: ResetPasswordRequest, user: UserModel = Depends(get_curr @app.post("/auth/forgot-password") def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)): user = db.query(UserModel).filter(UserModel.email == req.email).first() - # Always return success even if email not found — prevents user enumeration + # Always return success even if email not found. Prevents user enumeration if user: token = secrets.token_urlsafe(32) user.reset_token = token diff --git a/backend/models.py b/backend/models.py index 8bebf75..daca59a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -41,6 +41,7 @@ class Card(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False) reported: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + ai_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) user: Mapped["User | None"] = relationship(back_populates="cards") deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card") diff --git a/backend/test_game.py b/backend/test_game.py index ff83c06..7a1e7ba 100644 --- a/backend/test_game.py +++ b/backend/test_game.py @@ -1,3 +1,6 @@ +from dotenv import load_dotenv +load_dotenv() + from game import ( GameState, PlayerState, CardInstance, CombatEvent, GameResult, create_game, resolve_combat, check_win_condition, @@ -222,7 +225,7 @@ class TestSacrifice: err = action_sacrifice(state, slot=0) assert err is None assert state.players["p1"].board[0] is None - assert state.players["p1"].energy == 4 + assert state.players["p1"].energy == 2 def test_sacrifice_empty_slot(self): state = make_game(p1_energy=3) @@ -249,10 +252,10 @@ class TestSacrifice: cheap = make_card(name="Cheap", cost=3) expensive = make_card(name="Expensive", cost=5) board = [None] + [cheap] + [None] * (BOARD_SIZE - 2) - state = make_game(p1_board=board, p1_hand=[expensive], p1_energy=2) + state = make_game(p1_board=board, p1_hand=[expensive], p1_energy=4) err1 = action_play_card(state, hand_index=0, slot=0) assert err1 is not None - assert err1 == "Not enough energy (have 2, need 5)" + assert err1 == "Not enough energy (have 4, need 5)" action_sacrifice(state, slot=1) assert state.players["p1"].energy == 5 diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e612055..328e2ea 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,6 +12,7 @@ {/if} + WikiTCG diff --git a/frontend/src/routes/cards/+page.svelte b/frontend/src/routes/cards/+page.svelte index 875c429..42898c8 100644 --- a/frontend/src/routes/cards/+page.svelte +++ b/frontend/src/routes/cards/+page.svelte @@ -30,7 +30,7 @@ let sortAsc = $state(true); let costMin = $state(1); - let costMax = $state(12); + let costMax = $state(11); let filtered = $derived.by(() => { let result = allCards.filter(c => @@ -236,14 +236,14 @@
Cost - +
Min: {costMin} - { if (costMin > costMax) costMax = costMin; }} /> Max: {costMax} - { if (costMax < costMin) costMin = costMax; }} />
diff --git a/frontend/src/routes/decks/+page.svelte b/frontend/src/routes/decks/+page.svelte index e01c634..f1a095c 100644 --- a/frontend/src/routes/decks/+page.svelte +++ b/frontend/src/routes/decks/+page.svelte @@ -75,6 +75,7 @@ Name Cards + Cost Type Played W / L @@ -87,8 +88,9 @@ {@const wr = winRate(deck)} {deck.name} - - {deck.card_count}/20 + {deck.card_count} + 50}> + {deck.total_cost}/50 @@ -100,14 +102,14 @@ / {deck.losses} {:else} - + - {/if} {#if wr !== null} = 50} class:bad-wr={wr < 50}>{wr}% {:else} - + - {/if} @@ -144,7 +146,7 @@ +
+

Building a Deck

+
+
+
+

Cost Limit

+

Your deck's total cost, the sum of all card costs, must be 50 or less. You can't queue for a game with an empty deck or one that exceeds the limit.

+
+
+
🃏
+

No Card Limit

+

There is no minimum or maximum number of cards. On the extreme ends, you can have just 4 11-cost cards, or 50 1-cost cards.

+
+
+
+

Taking a Turn

@@ -123,7 +139,7 @@
🗡

Sacrificing

-

Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover its energy cost. Use this to afford expensive cards.

+

Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover 1 energy. Use this to afford expensive cards.

diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index 1d3cc61..1f07a7d 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -21,6 +21,19 @@ let gameState = $state(null); let myId = $state(''); + let viewingBoard = $state(false); + let showDifficultyModal = $state(false); + let selectedDifficulty = $state(5); + + const difficultyLabel = $derived( + selectedDifficulty <= 2 ? 'Throws the game' : + selectedDifficulty === 3 ? 'Fully random' : + selectedDifficulty <= 5 ? 'Beginner' : + selectedDifficulty <= 7 ? 'Intermediate' : + selectedDifficulty <= 9 ? 'Advanced' : + 'Expert' + ); + let selectedHandIndex = $state(null); let combatAnimating = $state(false); let lunging = $state(new Set()); @@ -69,7 +82,7 @@ method: 'POST' }); if (!res.ok) { - // Server rejected the claim — game may have ended another way + // Server rejected the claim. Game may have ended another way const err = await res.json(); console.warn('Timeout claim rejected:', err.detail); } @@ -78,8 +91,8 @@ $effect(() => { if (!gameState || combatAnimating) return; displayedLife = { - [gameState.you.user_id]: gameState.you.life, - [gameState.opponent.user_id]: gameState.opponent.life, + [gameState.you.user_id]: Math.max(0, gameState.you.life), + [gameState.opponent.user_id]: Math.max(0, gameState.opponent.life), }; }); @@ -113,7 +126,7 @@ }); function joinQueue() { - if (!selectedDeckId || selectedDeck?.card_count < 20) return; + if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return; error = ''; phase = 'queuing'; queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`); @@ -168,7 +181,7 @@ combatAnimating = true; // The attacker is whoever was active when end_turn was called. - // After end_turn resolves, active_player_id switches — so we look + // After end_turn resolves, active_player_id switches. So we look // at who is NOT the current active player to find the attacker, // unless the game just ended (result is set), in which case // active_player_id hasn't switched yet. @@ -267,10 +280,11 @@ } async function joinSolo() { - if (!selectedDeckId || selectedDeck?.card_count < 20) return; + if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return; + showDifficultyModal = false; error = ''; phase = 'queuing'; - const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}&difficulty=5`, { + const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}&difficulty=${selectedDifficulty}`, { method: 'POST' }); if (!res.ok) { @@ -297,16 +311,16 @@ -

{selectedDeck && selectedDeck.card_count < 20 ? `Deck must have 20 cards (${selectedDeck.card_count}/20)` : ''}

+

{selectedDeck && (selectedDeck.total_cost === 0 || selectedDeck.total_cost > 50) ? `Deck cost must be between 1 and 50 (current: ${selectedDeck.total_cost})` : ''}

- -
@@ -320,7 +334,7 @@ - {:else if phase === 'playing' && gameState} + {:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
+ {/if} + + {#if showDifficultyModal} + {/if} @@ -1040,6 +1085,82 @@ .hand-card.unaffordable { filter: grayscale(0.7) brightness(0.55); } .hand-card:disabled { cursor: default; } + /* ── Difficulty modal ── */ + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + } + + .modal { + background: #110d04; + border: 1.5px solid #6b4c1e; + border-radius: 10px; + padding: 2rem 2.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + min-width: 300px; + } + + .modal-title { + font-family: 'Cinzel', serif; + font-size: 20px; + font-weight: 700; + color: #f0d080; + margin: 0; + letter-spacing: 0.08em; + } + + .difficulty-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + } + + .difficulty-number { + font-family: 'Cinzel', serif; + font-size: 48px; + font-weight: 900; + color: #f0d080; + line-height: 1; + } + + .difficulty-label { + font-family: 'Crimson Text', serif; + font-size: 16px; + font-style: italic; + color: rgba(240, 180, 80, 0.6); + } + + .difficulty-slider { + width: 100%; + accent-color: #c8861a; + cursor: pointer; + } + + .difficulty-ticks { + width: 100%; + display: flex; + justify-content: space-between; + font-family: 'Cinzel', serif; + font-size: 10px; + color: rgba(240, 180, 80, 0.4); + margin-top: -0.75rem; + } + + .modal-buttons { + display: flex; + gap: 0.75rem; + margin-top: 0.25rem; + } + /* ── Toast ── */ .toast { position: fixed; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index bf960ef..decb85c 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -60,7 +60,7 @@
Win Rate = 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}> - {profile.win_rate !== null ? `${profile.win_rate}%` : '—'} + {profile.win_rate !== null ? `${profile.win_rate}%` : '-'}