From fa05447895fa130be77a574d39e3a81cd3fe159f Mon Sep 17 00:00:00 2001
From: NikolajDanger
Date: Thu, 19 Mar 2026 22:34:02 +0100
Subject: [PATCH] :goat:
---
backend/ai.py | 717 ++++++++++++------
backend/alembic/env.py | 3 +
.../cd7ebb9b11bd_add_ai_used_to_cards.py | 34 +
backend/card.py | 12 +-
backend/database_functions.py | 71 +-
backend/email_utils.py | 2 +-
backend/game.py | 3 +-
backend/game_manager.py | 32 +-
backend/main.py | 33 +-
backend/models.py | 1 +
backend/test_game.py | 9 +-
frontend/src/routes/+layout.svelte | 1 +
frontend/src/routes/cards/+page.svelte | 8 +-
frontend/src/routes/decks/+page.svelte | 22 +-
frontend/src/routes/decks/[id]/+page.svelte | 28 +-
frontend/src/routes/how-to-play/+page.svelte | 36 +-
frontend/src/routes/play/+page.svelte | 151 +++-
frontend/src/routes/profile/+page.svelte | 2 +-
18 files changed, 796 insertions(+), 369 deletions(-)
create mode 100644 backend/alembic/versions/cd7ebb9b11bd_add_ai_used_to_cards.py
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 @@
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 @@
Are you sure you want to delete {deleteConfirm.name} ?
{#if deleteConfirm.times_played > 0}
- This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'} — its stats will be preserved in your profile history.
+ This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'}. Its stats will be preserved in your profile history.
{/if}
+
+ 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 @@
Choose your deck
{#each decks as deck}
- {deck.name} ({deck.card_count}/20)
+ {deck.name} ({deck.card_count} cards, cost {deck.total_cost})
{/each}
- {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})` : ''}
-
+ 50}>
Find Opponent
-
+ showDifficultyModal = true} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
vs. Computer
@@ -320,7 +334,7 @@
{ queueWs?.close(); phase = 'idle'; }}>Cancel
- {:else if phase === 'playing' && gameState}
+ {:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
+ {/if}
+
+ {#if showDifficultyModal}
+
showDifficultyModal = false}>
+
e.stopPropagation()}>
+
Choose Difficulty
+
+ {selectedDifficulty}
+ {difficultyLabel}
+
+
+
+ 1 10
+
+
+ showDifficultyModal = false}>Cancel
+ Start Game
+
+
{/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}%` : '-'}