🐐
This commit is contained in:
717
backend/ai.py
717
backend/ai.py
@@ -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
|
||||||
|
|
||||||
|
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
|
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)
|
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:
|
if ws:
|
||||||
for slot in range(BOARD_SIZE):
|
try:
|
||||||
slot_card = player.board[slot]
|
await ws.send_json({"type": "sacrifice_animation", "instance_id": instance_id})
|
||||||
if slot_card is not None and player.energy + slot_card.cost <= most_expensive_in_hand:
|
except Exception:
|
||||||
if ws:
|
pass
|
||||||
try:
|
|
||||||
await ws.send_json({
|
|
||||||
"type": "sacrifice_animation",
|
|
||||||
"instance_id": slot_card.instance_id,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(0.65)
|
|
||||||
action_sacrifice(state, slot)
|
|
||||||
await send_state(state)
|
|
||||||
await asyncio.sleep(0.35)
|
|
||||||
|
|
||||||
play_order = list(range(BOARD_SIZE))
|
# --- Generate and score candidate plans ---
|
||||||
random.shuffle(play_order)
|
plans = generate_plans(player, opponent)
|
||||||
for slot in play_order:
|
|
||||||
|
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:
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,42 +25,43 @@ async def fill_card_pool():
|
|||||||
return
|
return
|
||||||
|
|
||||||
db: Session = SessionLocal()
|
db: Session = SessionLocal()
|
||||||
try:
|
while True:
|
||||||
unassigned = db.query(CardModel).filter(CardModel.user_id == None).count()
|
try:
|
||||||
logger.info(f"Card pool has {unassigned} unassigned cards")
|
unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
|
||||||
if unassigned >= POOL_MINIMUM:
|
logger.info(f"Card pool has {unassigned} unassigned cards")
|
||||||
logger.info("Pool sufficiently stocked, skipping fill")
|
if unassigned >= POOL_MINIMUM:
|
||||||
return
|
logger.info("Pool sufficiently stocked, skipping fill")
|
||||||
|
return
|
||||||
|
|
||||||
pool_filling = True
|
pool_filling = True
|
||||||
needed = POOL_TARGET - unassigned
|
needed = POOL_TARGET - unassigned
|
||||||
logger.info(f"Filling pool with {needed} cards")
|
logger.info(f"Filling pool with {needed} cards")
|
||||||
|
|
||||||
fetched = 0
|
fetched = 0
|
||||||
while fetched < needed:
|
while fetched < needed:
|
||||||
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
|
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
|
||||||
cards = await _get_cards_async(batch_size)
|
cards = await _get_cards_async(batch_size)
|
||||||
|
|
||||||
for card in cards:
|
for card in cards:
|
||||||
db.add(CardModel(
|
db.add(CardModel(
|
||||||
name=card.name,
|
name=card.name,
|
||||||
image_link=card.image_link,
|
image_link=card.image_link,
|
||||||
card_rarity=card.card_rarity.name,
|
card_rarity=card.card_rarity.name,
|
||||||
card_type=card.card_type.name,
|
card_type=card.card_type.name,
|
||||||
text=card.text,
|
text=card.text,
|
||||||
attack=card.attack,
|
attack=card.attack,
|
||||||
defense=card.defense,
|
defense=card.defense,
|
||||||
cost=card.cost,
|
cost=card.cost,
|
||||||
user_id=None,
|
user_id=None,
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
fetched += batch_size
|
fetched += batch_size
|
||||||
logger.info(f"Pool fill progress: {fetched}/{needed}")
|
logger.info(f"Pool fill progress: {fetched}/{needed}")
|
||||||
await asyncio.sleep(POOL_SLEEP)
|
await asyncio.sleep(POOL_SLEEP)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
pool_filling = False
|
pool_filling = False
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
BOOSTER_MAX = 5
|
BOOSTER_MAX = 5
|
||||||
BOOSTER_COOLDOWN_HOURS = 5
|
BOOSTER_COOLDOWN_HOURS = 5
|
||||||
|
|||||||
@@ -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>
|
||||||
""",
|
""",
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first()
|
if winner_deck_id:
|
||||||
if deck:
|
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first()
|
||||||
deck.times_played += 1
|
if deck:
|
||||||
deck.wins += 1
|
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 loser_deck_id:
|
||||||
if deck:
|
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first()
|
||||||
deck.times_played += 1
|
if deck:
|
||||||
deck.losses += 1
|
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()
|
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"
|
||||||
|
|
||||||
record_game_result(state, db)
|
from database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user