This commit is contained in:
2026-03-19 22:34:02 +01:00
parent d1a39620a7
commit fa05447895
18 changed files with 796 additions and 369 deletions

View File

@@ -1,8 +1,13 @@
import asyncio import asyncio
import random import random
import logging
from dataclasses import dataclass
from enum import Enum from enum import Enum
from itertools import combinations
from card import Card 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" AI_USER_ID = "ai"
@@ -21,244 +26,418 @@ def get_random_personality() -> AIPersonality:
def calculate_exact_cost(attack: int, defense: int) -> float: def calculate_exact_cost(attack: int, defense: int) -> float:
"""Calculate the exact cost before rounding (matches card.py formula).""" """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. Returns how much above the power curve a card is.
Positive values mean the card is better than expected for its cost. Positive values mean the card is a better-than-expected deal for its cost.
""" """
exact_cost = calculate_exact_cost(card.attack, card.defense) exact_cost = calculate_exact_cost(card.attack, card.defense)
return exact_cost - card.cost 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]: def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) -> list[Card]:
""" BUDGET = 50
Choose 20 cards from available cards based on difficulty and personality.
Difficulty (1-10) affects: logger.info(f"Personality: {personality.value}")
- Higher difficulty = prefers cards above the power curve logger.info(f"Difficulty: {difficulty}")
- Lower difficulty = prefers low-cost cards for early game playability card_strings = [
- Lower difficulty = avoids taking the ridiculously good high-cost cards 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. # 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 len(cards) < 20: if difficulty >= 6:
return cards max_card_cost = difficulty+1
else:
max_card_cost = 6
# Get target energy curve based on difficulty and personality allowed = [c for c in cards if c.cost <= max_card_cost] or list(cards)
target_low, target_mid, target_high = energy_curve(difficulty, personality)
selected = [] def card_score(card: Card) -> float:
remaining = list(cards) 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 cost_norm = card.cost / max_card_cost # [0, 1]; higher = more expensive
for cost_min, cost_max, target_count in [(1, 3, target_low), (4, 6, target_mid), (7, 12, target_high)]: total = card.attack + card.defense
if target_count == 0: atk_ratio = card.attack / total if total else 0.5
continue
bracket_cards = [c for c in remaining if cost_min <= c.cost <= cost_max] if personality == AIPersonality.AGGRESSIVE:
if not bracket_cards: # Prefers high-attack cards; slight bias toward high cost for raw power
continue return 0.50 * atk_ratio + 0.30 * pcv_norm + 0.20 * cost_norm
# Group cards by exact cost if personality == AIPersonality.DEFENSIVE:
by_cost = {} # Prefers high-defense cards; same cost bias
for card in bracket_cards: return 0.50 * (1.0 - atk_ratio) + 0.30 * pcv_norm + 0.20 * cost_norm
if card.cost not in by_cost:
by_cost[card.cost] = []
by_cost[card.cost].append(card)
# Distribute target_count across available costs if personality == AIPersonality.GREEDY:
available_costs = sorted(by_cost.keys()) # Fills budget with the fewest, most expensive cards possible
if not available_costs: return 0.70 * cost_norm + 0.30 * pcv_norm
continue
# Calculate how many cards to take from each cost level if personality == AIPersonality.SWARM:
per_cost = max(1, target_count // len(available_costs)) # Cheap cards
remainder = target_count % len(available_costs) return 0.45 * (1.0 - cost_norm) + 0.35 * atk_ratio + 0.20 * pcv_norm
for cost in available_costs: if personality == AIPersonality.CONTROL:
cost_cards = by_cost[cost] # Values efficiency above all: wants cards that are above the power curve,
# Score cards at this specific cost level # with a secondary preference for higher cost
cost_scores = [] return 0.70 * pcv_norm + 0.30 * cost_norm
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 if personality == AIPersonality.BALANCED:
power_curve = get_power_curve_value(card) # Blends everything: efficiency, cost spread, and a slight attack lean
difficulty_factor = (difficulty - 5.5) / 4.5 return 0.40 * pcv_norm + 0.35 * cost_norm + 0.15 * atk_ratio + 0.10 * (1.0 - atk_ratio)
power_curve_score = power_curve * difficulty_factor * 5
# For low difficulties, heavily penalize high-cost cards with good stats # ARBITRARY: mostly random at lower difficulties
if difficulty <= 4 and card.cost >= 7: return (0.05 * difficulty) * pcv_norm + (1 - (0.05 * difficulty)) * random.random()
power_penalty = max(0, power_curve) * -10
normalized_score += power_penalty
total_score = normalized_score + power_curve_score # Higher difficulty -> less noise -> more optimal deck composition
cost_scores.append((card, total_score)) noise = ((10 - difficulty) / 9.0) * 0.50
# Sort and take best from this cost level scored = sorted(
cost_scores.sort(key=lambda x: x[1], reverse=True) [(card_score(c) + random.gauss(0, noise), c) for c in allowed],
# Take per_cost, plus 1 extra if this is one of the remainder slots key=lambda x: x[0],
to_take = per_cost reverse=True,
if remainder > 0: )
to_take += 1
remainder -= 1
to_take = min(to_take, len(cost_scores))
for i in range(to_take): # Minimum budget reserved for cheap (cost 1-3) cards to ensure early-game presence.
card = cost_scores[i][0] # Without cheap cards the AI will play nothing for the first several turns.
selected.append(card) early_budget = {
remaining.remove(card) AIPersonality.GREEDY: 4,
if len(selected) >= 20: 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 break
if card.cost > 3 or total_cost + card.cost > BUDGET:
if len(selected) >= 20: continue
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) 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): async def run_ai_turn(game_id: str):
from game_manager import ( 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)) await asyncio.sleep(calculate_combat_animation_time(state.last_combat_events))
player = state.players[AI_USER_ID] 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) ws = connections[game_id].get(human_id)
async def send_state(state):
async def send_state(s):
if ws: if ws:
try: try:
await ws.send_json({ await ws.send_json({"type": "state", "state": serialize_state(s, human_id)})
"type": "state",
"state": serialize_state(state, human_id),
})
except Exception: except Exception:
pass pass
most_expensive_in_hand = max((c.cost for c in player.hand), default=0) async def send_sacrifice_anim(instance_id):
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: if ws:
try: try:
await ws.send_json({ await ws.send_json({"type": "sacrifice_animation", "instance_id": instance_id})
"type": "sacrifice_animation",
"instance_id": slot_card.instance_id,
})
except Exception: except Exception:
pass pass
# --- 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) await asyncio.sleep(0.65)
action_sacrifice(state, slot) action_sacrifice(state, slot)
await send_state(state) await send_state(state)
await asyncio.sleep(0.35) await asyncio.sleep(0.35)
play_order = list(range(BOARD_SIZE)) # --- Execute plays ---
random.shuffle(play_order) # Shuffle play order so the AI doesn't always fill slots left-to-right
for slot in play_order: 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: if player.board[slot] is not None:
continue continue
affordable = [i for i, c in enumerate(player.hand) if c.cost <= player.energy] if card.cost > player.energy:
if not affordable: continue
break action_play_card(state, hand_idx, slot)
best = max(affordable, key=lambda i: player.hand[i].cost)
action_play_card(state, best, slot)
await send_state(state) await send_state(state)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)

View File

@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv()
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config

View File

@@ -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 ###

View File

@@ -90,6 +90,7 @@ class Card(NamedTuple):
WIKIDATA_INSTANCE_TYPE_MAP = { WIKIDATA_INSTANCE_TYPE_MAP = {
"Q5": CardType.person, # human "Q5": CardType.person, # human
"Q95074": CardType.person, # character
"Q215627": CardType.person, # person "Q215627": CardType.person, # person
"Q15632617": CardType.person, # fictional human "Q15632617": CardType.person, # fictional human
"Q22988604": CardType.person, # fictional human "Q22988604": CardType.person, # fictional human
@@ -141,6 +142,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q23442": CardType.location, # island "Q23442": CardType.location, # island
"Q82794": CardType.location, # geographic region "Q82794": CardType.location, # geographic region
"Q34442": CardType.location, # road "Q34442": CardType.location, # road
"Q486972": CardType.location, # human settlement
"Q192611": CardType.location, # electoral unit "Q192611": CardType.location, # electoral unit
"Q398141": CardType.location, # school district "Q398141": CardType.location, # school district
"Q133056": CardType.location, # mountain pass "Q133056": CardType.location, # mountain pass
@@ -149,6 +151,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q7930989": CardType.location, # city/town "Q7930989": CardType.location, # city/town
"Q1250464": CardType.location, # realm "Q1250464": CardType.location, # realm
"Q3146899": CardType.location, # diocese of the catholic church "Q3146899": CardType.location, # diocese of the catholic church
"Q12076836": CardType.location, # administrative territorial entity of a single country
"Q35145263": CardType.location, # natural geographic object "Q35145263": CardType.location, # natural geographic object
"Q15642541": CardType.location, # human-geographic territorial entity "Q15642541": CardType.location, # human-geographic territorial entity
@@ -179,6 +182,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q13406554": CardType.event, # sports competition "Q13406554": CardType.event, # sports competition
"Q15275719": CardType.event, # recurring event "Q15275719": CardType.event, # recurring event
"Q27968055": CardType.event, # recurring event edition "Q27968055": CardType.event, # recurring event edition
"Q15091377": CardType.event, # cycling race
"Q114609228": CardType.event, # recurring sporting event "Q114609228": CardType.event, # recurring sporting event
"Q7278": CardType.group, # political party "Q7278": CardType.group, # political party
@@ -216,6 +220,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q12140": CardType.science_thing, # medication "Q12140": CardType.science_thing, # medication
"Q11276": CardType.science_thing, # globular cluster "Q11276": CardType.science_thing, # globular cluster
"Q83373": CardType.science_thing, # quasar "Q83373": CardType.science_thing, # quasar
"Q177719": CardType.science_thing, # medical diagnosis
"Q898273": CardType.science_thing, # protein domain "Q898273": CardType.science_thing, # protein domain
"Q134808": CardType.science_thing, # vaccine "Q134808": CardType.science_thing, # vaccine
"Q168845": CardType.science_thing, # star cluster "Q168845": CardType.science_thing, # star cluster
@@ -224,7 +229,8 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q1840368": CardType.science_thing, # cloud type "Q1840368": CardType.science_thing, # cloud type
"Q2154519": CardType.science_thing, # astrophysical x-ray source "Q2154519": CardType.science_thing, # astrophysical x-ray source
"Q17444909": CardType.science_thing, # astronomical object type "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 "Q113145171": CardType.science_thing, # type of chemical entity
"Q1420": CardType.vehicle, # car "Q1420": CardType.vehicle, # car
@@ -519,7 +525,7 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
text=text, text=text,
attack=attack, attack=attack,
defense=defense, 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]: async def _get_cards_async(size: int) -> list[Card]:
@@ -542,7 +548,7 @@ def generate_card(title: str) -> Card|None:
# Cards helper function # Cards helper function
def compute_deck_type(cards: list) -> str | None: def compute_deck_type(cards: list) -> str | None:
if len(cards) < 20: if len(cards) == 0:
return None return None
avg_atk = sum(c.attack for c in cards) / len(cards) avg_atk = sum(c.attack for c in cards) / len(cards)
avg_def = sum(c.defense for c in cards) / len(cards) avg_def = sum(c.defense for c in cards) / len(cards)

View File

@@ -11,10 +11,10 @@ from database import SessionLocal
logger = logging.getLogger("app") logger = logging.getLogger("app")
POOL_MINIMUM = 500 POOL_MINIMUM = 1000
POOL_TARGET = 1000 POOL_TARGET = 2000
POOL_BATCH_SIZE = 10 POOL_BATCH_SIZE = 10
POOL_SLEEP = 5.0 POOL_SLEEP = 4.0
pool_filling = False pool_filling = False
@@ -25,8 +25,9 @@ async def fill_card_pool():
return return
db: Session = SessionLocal() db: Session = SessionLocal()
while True:
try: try:
unassigned = db.query(CardModel).filter(CardModel.user_id == None).count() unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
logger.info(f"Card pool has {unassigned} unassigned cards") logger.info(f"Card pool has {unassigned} unassigned cards")
if unassigned >= POOL_MINIMUM: if unassigned >= POOL_MINIMUM:
logger.info("Pool sufficiently stocked, skipping fill") logger.info("Pool sufficiently stocked, skipping fill")

View File

@@ -21,7 +21,7 @@ def send_password_reset_email(to_email: str, username: str, reset_token: str):
</a> </a>
</p> </p>
<p>This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p> <p>This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
<p style="color: #888; font-size: 13px;"> WikiTCG</p> <p style="color: #888; font-size: 13px;">- WikiTCG</p>
</div> </div>
""", """,
}) })

View File

@@ -253,7 +253,8 @@ def action_sacrifice(state: GameState, slot: int) -> str | None:
if card is None: if card is None:
return "No card in that slot" return "No card in that slot"
player.energy += card.cost # player.energy += card.cost
player.energy += 1
player.board[slot] = None player.board[slot] = None
return None return None

View File

@@ -56,15 +56,21 @@ def record_game_result(state: GameState, db: Session):
loser_deck_id = active_deck_ids.get(loser_id_str) loser_deck_id = active_deck_ids.get(loser_id_str)
if AI_USER_ID not in [winner_id_str, loser_id_str]: if AI_USER_ID not in [winner_id_str, loser_id_str]:
if winner_deck_id:
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first() deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first()
if deck: if deck:
deck.times_played += 1 deck.times_played += 1
deck.wins += 1 deck.wins += 1
else:
logger.warning(f"record_game_result: no deck_id found for winner {winner_id_str}")
if loser_deck_id:
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first() deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first()
if deck: if deck:
deck.times_played += 1 deck.times_played += 1
deck.losses += 1 deck.losses += 1
else:
logger.warning(f"record_game_result: no deck_id found for loser {loser_id_str}")
db.commit() db.commit()
@@ -251,6 +257,7 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
db.commit() db.commit()
except Exception as e: except Exception as e:
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}") logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
db.rollback()
elif action == "sacrifice": elif action == "sacrifice":
slot = message.get("slot") slot = message.get("slot")
if slot is None: 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 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) await asyncio.sleep(DISCONNECT_GRACE_SECONDS)
# Check if game still exists and player hasn't reconnected # 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" state.phase = "end"
from database import SessionLocal
db = SessionLocal()
try:
record_game_result(state, db) record_game_result(state, db)
finally:
db.close()
# Notify the remaining player # Notify the remaining player
winner_ws = connections[game_id].get(winner_id) winner_ws = connections[game_id].get(winner_id)

View File

@@ -149,7 +149,7 @@ async def open_pack(request: Request, user: UserModel = Depends(get_current_user
cards = ( cards = (
db.query(CardModel) db.query(CardModel)
.filter(CardModel.user_id == None) .filter(CardModel.user_id == None, CardModel.ai_used == False)
.limit(5) .limit(5)
.all() .all()
) )
@@ -191,6 +191,7 @@ def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends
"id": str(deck.id), "id": str(deck.id),
"name": deck.name, "name": deck.name,
"card_count": len(cards), "card_count": len(cards),
"total_cost": sum(card.cost for card in cards),
"times_played": deck.times_played, "times_played": deck.times_played,
"wins": deck.wins, "wins": deck.wins,
"losses": deck.losses, "losses": deck.losses,
@@ -215,7 +216,7 @@ def update_deck(deck_id: str, body: dict, user: UserModel = Depends(get_current_
deck.name = body["name"] deck.name = body["name"]
if "card_ids" in body: if "card_ids" in body:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete() 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))) db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
if deck.times_played > 0: if deck.times_played > 0:
deck.wins = 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) await websocket.close(code=1008)
return return
card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count() card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
if card_count < 20: total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
await websocket.send_json({"type": "error", "message": "Deck must have 20 cards"}) 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) await websocket.close(code=1008)
return return
@@ -318,7 +320,7 @@ async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depend
except WebSocketDisconnect: except WebSocketDisconnect:
if game_id in connections: if game_id in connections:
connections[game_id].pop(user_id, None) 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") @app.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): 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: if not deck:
raise HTTPException(status_code=404, detail="Deck not found") raise HTTPException(status_code=404, detail="Deck not found")
card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count() card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
if card_count < 20: total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
raise HTTPException(status_code=400, detail="Deck must have 20 cards") 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) player_cards = load_deck_cards(deck_id, str(user.id), db)
if player_cards is None: if player_cards is None:
raise HTTPException(status_code=503, detail="Couldn't load deck") raise HTTPException(status_code=503, detail="Couldn't load deck")
ai_cards = db.query(CardModel).filter( ai_cards = db.query(CardModel).filter(
CardModel.user_id == None CardModel.user_id == None,
).order_by(func.random()).limit(100).all() ).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") raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
for card in ai_cards: for card in ai_cards:
db.delete(card) card.ai_used = True
db.commit() db.commit()
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty) game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty)
asyncio.create_task(fill_card_pool()) asyncio.create_task(fill_card_pool())
return {"game_id": game_id} return {"game_id": game_id}
@@ -488,7 +489,7 @@ def reset_password(req: ResetPasswordRequest, user: UserModel = Depends(get_curr
@app.post("/auth/forgot-password") @app.post("/auth/forgot-password")
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)): def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first() 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: if user:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
user.reset_token = token user.reset_token = token

View File

@@ -41,6 +41,7 @@ class Card(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False) times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reported: Mapped[bool] = mapped_column(Boolean, default=False, 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") user: Mapped["User | None"] = relationship(back_populates="cards")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card") deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card")

View File

@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv()
from game import ( from game import (
GameState, PlayerState, CardInstance, CombatEvent, GameResult, GameState, PlayerState, CardInstance, CombatEvent, GameResult,
create_game, resolve_combat, check_win_condition, create_game, resolve_combat, check_win_condition,
@@ -222,7 +225,7 @@ class TestSacrifice:
err = action_sacrifice(state, slot=0) err = action_sacrifice(state, slot=0)
assert err is None assert err is None
assert state.players["p1"].board[0] 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): def test_sacrifice_empty_slot(self):
state = make_game(p1_energy=3) state = make_game(p1_energy=3)
@@ -249,10 +252,10 @@ class TestSacrifice:
cheap = make_card(name="Cheap", cost=3) cheap = make_card(name="Cheap", cost=3)
expensive = make_card(name="Expensive", cost=5) expensive = make_card(name="Expensive", cost=5)
board = [None] + [cheap] + [None] * (BOARD_SIZE - 2) 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) err1 = action_play_card(state, hand_index=0, slot=0)
assert err1 is not None 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) action_sacrifice(state, slot=1)
assert state.players["p1"].energy == 5 assert state.players["p1"].energy == 5

View File

@@ -12,6 +12,7 @@
{/if} {/if}
<svelte:head> <svelte:head>
<title>WikiTCG</title>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>

View File

@@ -30,7 +30,7 @@
let sortAsc = $state(true); let sortAsc = $state(true);
let costMin = $state(1); let costMin = $state(1);
let costMax = $state(12); let costMax = $state(11);
let filtered = $derived.by(() => { let filtered = $derived.by(() => {
let result = allCards.filter(c => let result = allCards.filter(c =>
@@ -236,14 +236,14 @@
<div class="filter-group"> <div class="filter-group">
<div class="filter-group-header"> <div class="filter-group-header">
<span class="filter-group-label">Cost</span> <span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 12; }}>Reset</button> <button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
</div> </div>
<div class="cost-range"> <div class="cost-range">
<span class="range-label">Min: {costMin}</span> <span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin} <input type="range" min="1" max="11" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} /> oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span> <span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax} <input type="range" min="1" max="11" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} /> oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div> </div>
</div> </div>

View File

@@ -75,6 +75,7 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Cards</th> <th>Cards</th>
<th>Cost</th>
<th>Type</th> <th>Type</th>
<th>Played</th> <th>Played</th>
<th>W / L</th> <th>W / L</th>
@@ -87,8 +88,9 @@
{@const wr = winRate(deck)} {@const wr = winRate(deck)}
<tr> <tr>
<td class="deck-name">{deck.name}</td> <td class="deck-name">{deck.name}</td>
<td class="deck-count" class:incomplete={deck.card_count < 20}> <td class="deck-count">{deck.card_count}</td>
{deck.card_count}/20 <td class="deck-cost" class:over-budget={deck.total_cost > 50}>
{deck.total_cost}/50
</td> </td>
<td class="deck-type"> <td class="deck-type">
<DeckTypeBadge deckType={deck.deck_type} /> <DeckTypeBadge deckType={deck.deck_type} />
@@ -100,14 +102,14 @@
<span class="separator"> / </span> <span class="separator"> / </span>
<span class="losses">{deck.losses}</span> <span class="losses">{deck.losses}</span>
{:else} {:else}
<span class="no-data"></span> <span class="no-data">-</span>
{/if} {/if}
</td> </td>
<td class="deck-stat"> <td class="deck-stat">
{#if wr !== null} {#if wr !== null}
<span class:good-wr={wr >= 50} class:bad-wr={wr < 50}>{wr}%</span> <span class:good-wr={wr >= 50} class:bad-wr={wr < 50}>{wr}%</span>
{:else} {:else}
<span class="no-data"></span> <span class="no-data">-</span>
{/if} {/if}
</td> </td>
<td class="deck-actions"> <td class="deck-actions">
@@ -144,7 +146,7 @@
<p class="popup-body"> <p class="popup-body">
Are you sure you want to delete <strong>{deleteConfirm.name}</strong>? Are you sure you want to delete <strong>{deleteConfirm.name}</strong>?
{#if deleteConfirm.times_played > 0} {#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} {/if}
</p> </p>
<div class="popup-actions"> <div class="popup-actions">
@@ -240,6 +242,14 @@
} }
.deck-count { .deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
width: 60px;
}
.deck-cost {
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -247,7 +257,7 @@
width: 60px; width: 60px;
} }
.deck-count.incomplete { color: #c85050; } .deck-cost.over-budget { color: #c85050; }
.deck-type { width: 90px; } .deck-type { width: 90px; }

View File

@@ -26,7 +26,7 @@
let selectedRarities = $state(new Set(RARITIES)); let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES)); let selectedTypes = $state(new Set(TYPES));
let costMin = $state(1); let costMin = $state(1);
let costMax = $state(12); let costMax = $state(11);
let filtersOpen = $state(false); let filtersOpen = $state(false);
function label(str) { function label(str) {
@@ -74,12 +74,17 @@
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); } function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); } function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
);
function toggleCard(id) { function toggleCard(id) {
const s = new Set(selectedIds); const s = new Set(selectedIds);
if (s.has(id)) { if (s.has(id)) {
s.delete(id); s.delete(id);
} else { } else {
if (s.size >= 20) return; const card = allCards.find(c => c.id === id);
if (card && selectedCost + card.cost > 50) return;
s.add(id); s.add(id);
} }
selectedIds = s; selectedIds = s;
@@ -151,8 +156,8 @@
{/if} {/if}
<div class="header-right"> <div class="header-right">
<span class="card-counter" class:full={selectedIds.size === 20} class:empty={selectedIds.size === 0}> <span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
{selectedIds.size}/20 {selectedIds.size} cards · {selectedCost}/50
</span> </span>
<button class="done-btn" onclick={save} disabled={saving}> <button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'} {saving ? 'Saving...' : 'Done'}
@@ -171,7 +176,7 @@
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}> <button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'} {filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 12} {#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 11}
<span class="filter-dot"></span> <span class="filter-dot"></span>
{/if} {/if}
</button> </button>
@@ -212,13 +217,13 @@
<div class="filter-group"> <div class="filter-group">
<div class="filter-group-header"> <div class="filter-group-header">
<span class="filter-group-label">Cost</span> <span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 12; }}>Reset</button> <button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
</div> </div>
<div class="cost-range"> <div class="cost-range">
<span class="range-label">Min: {costMin}</span> <span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} /> <input type="range" min="1" max="11" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span> <span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} /> <input type="range" min="1" max="11" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div> </div>
</div> </div>
</div> </div>
@@ -235,7 +240,7 @@
<button <button
class="card-wrap" class="card-wrap"
class:selected={selectedIds.has(card.id)} class:selected={selectedIds.has(card.id)}
class:disabled={!selectedIds.has(card.id) && selectedIds.size >= 20} class:disabled={!selectedIds.has(card.id) && selectedCost + card.cost > 50}
onclick={() => toggleCard(card.id)} onclick={() => toggleCard(card.id)}
> >
<Card {card} noHover={true} /> <Card {card} noHover={true} />
@@ -317,11 +322,12 @@
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #c85050; color: rgba(240, 180, 80, 0.7);
transition: color 0.2s; transition: color 0.2s;
} }
.card-counter.full { color: #6aaa6a; } .card-counter.full { color: #6aaa6a; }
.card-counter.over { color: #c85050; }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); } .card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.done-btn { .done-btn {

View File

@@ -18,7 +18,7 @@
const annotations = [ const annotations = [
{ number: 1, label: "Name", description: "The name of the Wikipedia article this card was generated from." }, { number: 1, label: "Name", description: "The name of the Wikipedia article this card was generated from." },
{ number: 2, label: "Type", description: "The category of the subject Person, Location, Artwork, etc." }, { number: 2, label: "Type", description: "The category of the subject. Person, Location, Artwork, etc." },
{ number: 3, label: "Rarity badge", description: "Rarity is determined by the article's WikiRank quality score. From lowest to highest: Common, Uncommon, Rare, Super Rare, Epic, and Legendary." }, { number: 3, label: "Rarity badge", description: "Rarity is determined by the article's WikiRank quality score. From lowest to highest: Common, Uncommon, Rare, Super Rare, Epic, and Legendary." },
{ number: 4, label: "Wikipedia link", description: "Opens the Wikipedia article this card was generated from." }, { number: 4, label: "Wikipedia link", description: "Opens the Wikipedia article this card was generated from." },
{ number: 5, label: "Cost bubbles", description: "How much energy it costs to play this card. Derived from the card's attack and defense stats." }, { number: 5, label: "Cost bubbles", description: "How much energy it costs to play this card. Derived from the card's attack and defense stats." },
@@ -29,14 +29,14 @@
// Annotation positions as percentage of card width/height // Annotation positions as percentage of card width/height
const markerPositions = [ const markerPositions = [
{ number: 1, x: 15, y: 3 }, // name top center { number: 1, x: 15, y: 3 }, // name. top center
{ number: 2, x: 75, y: 3 }, // type badge top right { number: 2, x: 75, y: 3 }, // type badge. top right
{ number: 3, x: 14, y: 20 }, // rarity badge top left of image { number: 3, x: 14, y: 20 }, // rarity badge. top left of image
{ number: 4, x: 85, y: 20 }, // wiki link top right of image { number: 4, x: 85, y: 20 }, // wiki link. top right of image
{ number: 5, x: 15, y: 53 }, // cost bubbles bottom left of image { number: 5, x: 15, y: 53 }, // cost bubbles. bottom left of image
{ number: 6, x: 50, y: 73 }, // text middle { number: 6, x: 50, y: 73 }, // text. middle
{ number: 7, x: 15, y: 88 }, // attack bottom left { number: 7, x: 15, y: 88 }, // attack. bottom left
{ number: 8, x: 85, y: 88 }, // defense bottom right { number: 8, x: 85, y: 88 }, // defense. bottom right
]; ];
</script> </script>
@@ -102,6 +102,22 @@
</div> </div>
</section> </section>
<section class="section">
<h2 class="section-title">Building a Deck</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Cost Limit</h3>
<p class="rule-body">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.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🃏</div>
<h3 class="rule-title">No Card Limit</h3>
<p class="rule-body">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.</p>
</div>
</div>
</section>
<section class="section"> <section class="section">
<h2 class="section-title">Taking a Turn</h2> <h2 class="section-title">Taking a Turn</h2>
<div class="rules-grid"> <div class="rules-grid">
@@ -123,7 +139,7 @@
<div class="rule-card"> <div class="rule-card">
<div class="rule-icon">🗡</div> <div class="rule-icon">🗡</div>
<h3 class="rule-title">Sacrificing</h3> <h3 class="rule-title">Sacrificing</h3>
<p class="rule-body">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.</p> <p class="rule-body">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.</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -21,6 +21,19 @@
let gameState = $state(null); let gameState = $state(null);
let myId = $state(''); 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 selectedHandIndex = $state(null);
let combatAnimating = $state(false); let combatAnimating = $state(false);
let lunging = $state(new Set()); let lunging = $state(new Set());
@@ -69,7 +82,7 @@
method: 'POST' method: 'POST'
}); });
if (!res.ok) { 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(); const err = await res.json();
console.warn('Timeout claim rejected:', err.detail); console.warn('Timeout claim rejected:', err.detail);
} }
@@ -78,8 +91,8 @@
$effect(() => { $effect(() => {
if (!gameState || combatAnimating) return; if (!gameState || combatAnimating) return;
displayedLife = { displayedLife = {
[gameState.you.user_id]: gameState.you.life, [gameState.you.user_id]: Math.max(0, gameState.you.life),
[gameState.opponent.user_id]: gameState.opponent.life, [gameState.opponent.user_id]: Math.max(0, gameState.opponent.life),
}; };
}); });
@@ -113,7 +126,7 @@
}); });
function joinQueue() { function joinQueue() {
if (!selectedDeckId || selectedDeck?.card_count < 20) return; if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return;
error = ''; error = '';
phase = 'queuing'; phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`); queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
@@ -168,7 +181,7 @@
combatAnimating = true; combatAnimating = true;
// The attacker is whoever was active when end_turn was called. // 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, // at who is NOT the current active player to find the attacker,
// unless the game just ended (result is set), in which case // unless the game just ended (result is set), in which case
// active_player_id hasn't switched yet. // active_player_id hasn't switched yet.
@@ -267,10 +280,11 @@
} }
async function joinSolo() { async function joinSolo() {
if (!selectedDeckId || selectedDeck?.card_count < 20) return; if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return;
showDifficultyModal = false;
error = ''; error = '';
phase = 'queuing'; 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' method: 'POST'
}); });
if (!res.ok) { if (!res.ok) {
@@ -297,16 +311,16 @@
<label class="deck-label" for="deck">Choose your deck</label> <label class="deck-label" for="deck">Choose your deck</label>
<select id="deck" bind:value={selectedDeckId}> <select id="deck" bind:value={selectedDeckId}>
{#each decks as deck} {#each decks as deck}
<option value={deck.id}>{deck.name} ({deck.card_count}/20)</option> <option value={deck.id}>{deck.name} ({deck.card_count} cards, cost {deck.total_cost})</option>
{/each} {/each}
</select> </select>
</div> </div>
<p class="error">{selectedDeck && selectedDeck.card_count < 20 ? `Deck must have 20 cards (${selectedDeck.card_count}/20)` : ''}</p> <p class="error">{selectedDeck && (selectedDeck.total_cost === 0 || selectedDeck.total_cost > 50) ? `Deck cost must be between 1 and 50 (current: ${selectedDeck.total_cost})` : ''}</p>
<div class="lobby-buttons"> <div class="lobby-buttons">
<button class="play-btn" onclick={joinQueue} disabled={!selectedDeckId || selectedDeck?.card_count < 20}> <button class="play-btn" onclick={joinQueue} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
Find Opponent Find Opponent
</button> </button>
<button class="play-btn solo-btn" onclick={joinSolo} disabled={!selectedDeckId || selectedDeck?.card_count < 20}> <button class="play-btn solo-btn" onclick={() => showDifficultyModal = true} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
vs. Computer vs. Computer
</button> </button>
</div> </div>
@@ -320,7 +334,7 @@
<button class="cancel-btn" onclick={() => { queueWs?.close(); phase = 'idle'; }}>Cancel</button> <button class="cancel-btn" onclick={() => { queueWs?.close(); phase = 'idle'; }}>Cancel</button>
</div> </div>
{:else if phase === 'playing' && gameState} {:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
<div class="game"> <div class="game">
<div class="sidebar left-sidebar"> <div class="sidebar left-sidebar">
@@ -374,7 +388,7 @@
<div class="divider"> <div class="divider">
<span class="turn-indicator" class:my-turn={isMyTurn}> <span class="turn-indicator" class:my-turn={isMyTurn}>
{isMyTurn ? 'Your turn' : `${opp.username}'s turn`} {phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
</span> </span>
{#if secondsRemaining <= TIMER_WARNING} {#if secondsRemaining <= TIMER_WARNING}
<span class="turn-timer" class:urgent={secondsRemaining <= 10}> <span class="turn-timer" class:urgent={secondsRemaining <= 10}>
@@ -420,7 +434,9 @@
</div> </div>
<div class="sidebar right-sidebar"> <div class="sidebar right-sidebar">
{#if isMyTurn && !combatAnimating} {#if phase === 'ended'}
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
{:else if isMyTurn && !combatAnimating}
<button class="end-turn-btn" onclick={endTurn}>End Turn</button> <button class="end-turn-btn" onclick={endTurn}>End Turn</button>
{/if} {/if}
</div> </div>
@@ -453,7 +469,36 @@
{gameState.result.winner_id === myId ? 'Victory' : 'Defeat'} {gameState.result.winner_id === myId ? 'Victory' : 'Defeat'}
</h1> </h1>
<p class="lobby-hint">{gameState.result.reason}</p> <p class="lobby-hint">{gameState.result.reason}</p>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go back</button> <div class="lobby-buttons">
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
</div>
</div>
{/if}
{#if showDifficultyModal}
<div class="modal-backdrop" onclick={() => showDifficultyModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 class="modal-title">Choose Difficulty</h2>
<div class="difficulty-display">
<span class="difficulty-number">{selectedDifficulty}</span>
<span class="difficulty-label">{difficultyLabel}</span>
</div>
<input
class="difficulty-slider"
type="range"
min="1"
max="10"
bind:value={selectedDifficulty}
/>
<div class="difficulty-ticks">
<span>1</span><span>10</span>
</div>
<div class="modal-buttons">
<button class="cancel-btn" onclick={() => showDifficultyModal = false}>Cancel</button>
<button class="play-btn" onclick={joinSolo}>Start Game</button>
</div>
</div>
</div> </div>
{/if} {/if}
@@ -1040,6 +1085,82 @@
.hand-card.unaffordable { filter: grayscale(0.7) brightness(0.55); } .hand-card.unaffordable { filter: grayscale(0.7) brightness(0.55); }
.hand-card:disabled { cursor: default; } .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 ── */
.toast { .toast {
position: fixed; position: fixed;

View File

@@ -60,7 +60,7 @@
<div class="stat-card"> <div class="stat-card">
<span class="stat-label">Win Rate</span> <span class="stat-label">Win Rate</span>
<span class="stat-value" class:good-wr={profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}> <span class="stat-value" class:good-wr={profile.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}%` : '-'}
</span> </span>
</div> </div>
</div> </div>