🐐
This commit is contained in:
@@ -4,4 +4,7 @@ RESEND_API_KEY= # from resend.com dashboard
|
||||
EMAIL_FROM= # e.g. noreply@yourdomain.com
|
||||
FRONTEND_URL= # e.g. https://yourdomain.com
|
||||
CORS_ORIGINS= # comma-separated, e.g. https://yourdomain.com
|
||||
WIKIRANK_USER_AGENT= # e.g. WikiTCG/1.0 (you@email.com
|
||||
WIKIRANK_USER_AGENT= # e.g. WikiTCG/1.0 (you@email.com)
|
||||
STRIPE_SECRET_KEY= # from stripe dashboard
|
||||
STRIPE_PUBLISHABLE_KEY= # from stripe dashboard
|
||||
STRIPE_WEBHOOK_SECRET= # from stripe dashboard
|
||||
@@ -19,16 +19,17 @@ class AIPersonality(Enum):
|
||||
GREEDY = "greedy" # Prioritizes high cost cards, willing to sacrifice
|
||||
SWARM = "swarm" # Prefers low cost cards, fills board quickly
|
||||
CONTROL = "control" # Focuses on board control and efficiency
|
||||
SHOCKER = "shocker" # Cheap high-defense walls + a few powerful high-attack finishers
|
||||
ARBITRARY = "arbitrary" # Just does whatever
|
||||
JEBRASKA = "jebraska" # Trained neural network plan scorer
|
||||
|
||||
def get_random_personality() -> AIPersonality:
|
||||
"""Returns a random AI personality."""
|
||||
return random.choice(list(AIPersonality))
|
||||
# return random.choice(list(AIPersonality))
|
||||
return AIPersonality.JEBRASKA
|
||||
|
||||
def calculate_exact_cost(attack: int, defense: int) -> float:
|
||||
"""Calculate the exact cost before rounding (matches card.py formula)."""
|
||||
return min(11.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5))
|
||||
return min(10.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5))
|
||||
|
||||
def get_power_curve_value(card) -> float:
|
||||
"""
|
||||
@@ -54,7 +55,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
|
||||
defn = np.array([c.defense for c in allowed], dtype=np.float32)
|
||||
cost = np.array([c.cost for c in allowed], dtype=np.float32)
|
||||
|
||||
exact_cost = np.minimum(11.0, np.maximum(1.0, ((atk**2 + defn**2)**0.18) / 1.5))
|
||||
exact_cost = np.minimum(10.0, np.maximum(1.0, ((atk**2 + defn**2)**0.18) / 1.5))
|
||||
pcv_norm = np.clip(exact_cost - cost, 0.0, 1.0)
|
||||
cost_norm = cost / max_card_cost
|
||||
totals = atk + defn
|
||||
@@ -78,21 +79,14 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
|
||||
elif personality == AIPersonality.CONTROL:
|
||||
# Small cost_norm keeps flavour without causing severe deck shrinkage at D10
|
||||
scores = 0.85 * pcv_norm + 0.15 * cost_norm
|
||||
elif personality == AIPersonality.BALANCED:
|
||||
elif personality in (AIPersonality.BALANCED, AIPersonality.JEBRASKA):
|
||||
scores = 0.60 * pcv_norm + 0.25 * atk_ratio + 0.15 * (1.0 - atk_ratio)
|
||||
elif personality == AIPersonality.SHOCKER:
|
||||
# Both cheap walls and expensive finishers want high attack.
|
||||
# (1-cost_norm) drives first-pass cheap-card selection; pcv_norm drives second-pass finishers.
|
||||
# defense_ok zeros out cards with defense==1 on the first term so fragile walls are excluded.
|
||||
# cost-11 cards have pcv=0 so they score near-zero and never shrink the deck.
|
||||
scores = atk_ratio * (1.0 - cost_norm) * def_not_one + atk_ratio * pcv_norm
|
||||
else: # ARBITRARY
|
||||
w = 0.05 * difficulty
|
||||
w = 0.09 * difficulty
|
||||
scores = w * pcv_norm + (1.0 - w) * np.random.random(len(allowed)).astype(np.float32)
|
||||
|
||||
# Small noise floor at D10 prevents fully deterministic deck building.
|
||||
# A locked-in deck loses every game against counters; tiny randomness avoids this.
|
||||
noise = max(0.03, (10 - difficulty) / 9.0) * 0.50
|
||||
noise = (max(0,12 - difficulty)**2) * 0.008
|
||||
scores = scores + np.random.normal(0, noise, len(allowed)).astype(np.float32)
|
||||
|
||||
order = np.argsort(-scores)
|
||||
@@ -105,7 +99,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
|
||||
AIPersonality.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels
|
||||
AIPersonality.CONTROL: 8,
|
||||
AIPersonality.BALANCED: 25, # spread the deck across all cost levels
|
||||
AIPersonality.SHOCKER: 15, # ~15 cost-1 shields, then expensive attackers fill remaining budget
|
||||
AIPersonality.JEBRASKA: 25, # same as balanced
|
||||
AIPersonality.ARBITRARY: 8,
|
||||
}[personality]
|
||||
|
||||
@@ -303,15 +297,11 @@ def score_plans_batch(
|
||||
score = (0.12 * atk_score + 0.13 * block_score + 0.15 * cover_score +
|
||||
0.10 * net_value_norm + 0.12 * destroy_score +
|
||||
0.15 * attrition_score + 0.12 * pcv_score + 0.11 * threat_score)
|
||||
elif personality == AIPersonality.SHOCKER:
|
||||
score = (0.25 * destroy_score + 0.33 * cover_score + 0.18 * atk_score +
|
||||
0.05 * block_score + 0.8 * attrition_score + 0.02 * threat_score +
|
||||
0.05 * net_value_norm + 0.04 * pcv_score)
|
||||
else: # ARBITRARY
|
||||
score = (0.60 * np.random.random(n).astype(np.float32) +
|
||||
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)
|
||||
score = (0.50 * np.random.random(n).astype(np.float32) +
|
||||
0.06 * atk_score + 0.06 * block_score + 0.08 * cover_score +
|
||||
0.05 * net_value_norm + 0.06 * destroy_score +
|
||||
0.08 * attrition_score + 0.06 * pcv_score + 0.05 * threat_score)
|
||||
|
||||
# --- Context adjustments ---
|
||||
score = np.where(direct_damage >= opponent.life, np.maximum(score, 0.95), score)
|
||||
@@ -333,12 +323,25 @@ def score_plans_batch(
|
||||
return np.maximum(0.0, score - sac_penalty)
|
||||
|
||||
|
||||
async def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPersonality, difficulty: int) -> MovePlan:
|
||||
def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPersonality, difficulty: int) -> MovePlan:
|
||||
plans = generate_plans(player, opponent)
|
||||
|
||||
scores = score_plans_batch(plans, player, opponent, personality)
|
||||
if personality == AIPersonality.JEBRASKA:
|
||||
from nn import NeuralNet
|
||||
import os
|
||||
_weights = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
||||
if not hasattr(choose_plan, "_neural_net"):
|
||||
choose_plan._neural_net = NeuralNet.load(_weights) if os.path.exists(_weights) else None
|
||||
net = choose_plan._neural_net
|
||||
if net is not None:
|
||||
from nn import extract_plan_features
|
||||
scores = net.forward(extract_plan_features(plans, player, opponent))
|
||||
else: # fallback to BALANCED if weights not found
|
||||
scores = score_plans_batch(plans, player, opponent, AIPersonality.BALANCED)
|
||||
else:
|
||||
scores = score_plans_batch(plans, player, opponent, personality)
|
||||
|
||||
noise_scale = (max(0,11 - difficulty)**2) * 0.01 - 0.01
|
||||
noise_scale = ((max(0,12 - difficulty)**2) - 4) * 0.008
|
||||
noise = np.random.normal(0, noise_scale, len(scores)).astype(np.float32)
|
||||
return plans[int(np.argmax(scores + noise))]
|
||||
|
||||
@@ -388,7 +391,7 @@ async def run_ai_turn(game_id: str):
|
||||
pass
|
||||
|
||||
# --- Generate and score candidate plans ---
|
||||
best_plan = await choose_plan(player, opponent, personality, difficulty)
|
||||
best_plan = choose_plan(player, opponent, personality, difficulty)
|
||||
|
||||
logger.info(
|
||||
f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} " +
|
||||
|
||||
25
backend/alembic/versions/a9f2d4e7c301_add_shards_to_users.py
Normal file
25
backend/alembic/versions/a9f2d4e7c301_add_shards_to_users.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""add shards to users
|
||||
|
||||
Revision ID: a9f2d4e7c301
|
||||
Revises: f3a1c8e2b950
|
||||
Create Date: 2026-03-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'a9f2d4e7c301'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f3a1c8e2b950'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('shards', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'shards')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add email verification
|
||||
|
||||
Revision ID: f3a1c8e2b950
|
||||
Revises: adee6bcc23e1
|
||||
Create Date: 2026-03-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'f3a1c8e2b950'
|
||||
down_revision: Union[str, Sequence[str], None] = 'cd7ebb9b11bd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Existing users are considered verified so they aren't locked out.
|
||||
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='true'))
|
||||
op.add_column('users', sa.Column('email_verification_token', sa.String(), nullable=True))
|
||||
op.add_column('users', sa.Column('email_verification_token_expires_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'email_verification_token_expires_at')
|
||||
op.drop_column('users', 'email_verification_token')
|
||||
op.drop_column('users', 'email_verified')
|
||||
@@ -113,6 +113,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q734698": CardType.artwork, # collectible card game
|
||||
"Q506240": CardType.artwork, # television film
|
||||
"Q738377": CardType.artwork, # student newspaper
|
||||
"Q2031291": CardType.artwork, # musical release
|
||||
"Q1259759": CardType.artwork, # miniseries
|
||||
"Q3305213": CardType.artwork, # painting
|
||||
"Q3177859": CardType.artwork, # dedicated deck card game
|
||||
@@ -121,6 +122,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q1761818": CardType.artwork, # advertising campaign
|
||||
"Q1446621": CardType.artwork, # recital
|
||||
"Q1868552": CardType.artwork, # local newspaper
|
||||
"Q3244175": CardType.artwork, # tabletop game
|
||||
"Q63952888": CardType.artwork, # anime television series
|
||||
"Q47461344": CardType.artwork, # written work
|
||||
"Q71631512": CardType.artwork, # tabletop role-playing game supplement
|
||||
@@ -151,11 +153,14 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q7930989": CardType.location, # city/town
|
||||
"Q1250464": CardType.location, # realm
|
||||
"Q3146899": CardType.location, # diocese of the catholic church
|
||||
"Q17350442": CardType.location, # venue
|
||||
"Q23764314": CardType.location, # sports location
|
||||
"Q12076836": CardType.location, # administrative territorial entity of a single country
|
||||
"Q35145263": CardType.location, # natural geographic object
|
||||
"Q15642541": CardType.location, # human-geographic territorial entity
|
||||
|
||||
"Q16521": CardType.life_form, # taxon
|
||||
"Q38829": CardType.life_form, # breed
|
||||
"Q310890": CardType.life_form, # monotypic taxon
|
||||
"Q23038290": CardType.life_form, # fossil taxon
|
||||
"Q12045585": CardType.life_form, # cattle breed
|
||||
@@ -183,6 +188,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q15275719": CardType.event, # recurring event
|
||||
"Q27968055": CardType.event, # recurring event edition
|
||||
"Q15091377": CardType.event, # cycling race
|
||||
"Q87267404": CardType.event, # formula race
|
||||
"Q114609228": CardType.event, # recurring sporting event
|
||||
|
||||
"Q7278": CardType.group, # political party
|
||||
@@ -225,15 +231,23 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q134808": CardType.science_thing, # vaccine
|
||||
"Q168845": CardType.science_thing, # star cluster
|
||||
"Q1491746": CardType.science_thing, # galaxy group
|
||||
"Q2465832": CardType.science_thing, # branch of science
|
||||
"Q1341811": CardType.science_thing, # astronomical maser
|
||||
"Q1840368": CardType.science_thing, # cloud type
|
||||
"Q2154519": CardType.science_thing, # astrophysical x-ray source
|
||||
"Q3132741": CardType.science_thing, # substellar object
|
||||
"Q15636229": CardType.science_thing, # surgery procedure
|
||||
"Q11862829": CardType.science_thing, # academic discipline
|
||||
"Q78088984": CardType.science_thing, # study type
|
||||
"Q17444909": CardType.science_thing, # astronomical object type
|
||||
"Q24034552": CardType.science_thing, # mathematical concept
|
||||
"Q12089225": CardType.science_thing, # mineral species
|
||||
"Q55640599": CardType.science_thing, # group of chemical entities
|
||||
"Q119459661": CardType.science_thing, # scientific activity
|
||||
"Q113145171": CardType.science_thing, # type of chemical entity
|
||||
|
||||
"Q1420": CardType.vehicle, # car
|
||||
"Q42889": CardType.vehicle, # vehicle
|
||||
"Q11446": CardType.vehicle, # ship
|
||||
"Q43193": CardType.vehicle, # truck
|
||||
"Q25956": CardType.vehicle, # space station
|
||||
@@ -252,12 +266,15 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q928235": CardType.vehicle, # sloop-of-war
|
||||
"Q391022": CardType.vehicle, # research vessel
|
||||
"Q202527": CardType.vehicle, # minesweeper
|
||||
"Q1229765": CardType.vehicle, # watercraft
|
||||
"Q2031121": CardType.vehicle, # warship
|
||||
"Q1185562": CardType.vehicle, # light aircraft carrier
|
||||
"Q7233751": CardType.vehicle, # post ship
|
||||
"Q3231690": CardType.vehicle, # automobile model
|
||||
"Q1428357": CardType.vehicle, # submarine class
|
||||
"Q1499623": CardType.vehicle, # destroyer escort
|
||||
"Q4818021": CardType.vehicle, # attack submarine
|
||||
"Q15141321": CardType.vehicle, # train service
|
||||
"Q19832486": CardType.vehicle, # locomotive class
|
||||
"Q23866334": CardType.vehicle, # motorcycle model
|
||||
"Q29048322": CardType.vehicle, # vehicle model
|
||||
@@ -267,8 +284,12 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q43229": CardType.organization, # organization
|
||||
"Q47913": CardType.organization, # intelligence agency
|
||||
"Q35535": CardType.organization, # police
|
||||
"Q740752": CardType.organization, # transport company
|
||||
"Q4830453": CardType.organization, # business
|
||||
"Q4671277": CardType.organization, # academic institution
|
||||
"Q2659904": CardType.organization, # government organization
|
||||
|
||||
"Q686822": CardType.other, # bill (written work)
|
||||
}
|
||||
|
||||
import asyncio
|
||||
@@ -399,9 +420,14 @@ async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> t
|
||||
if superclass_qid2 in WIKIDATA_INSTANCE_TYPE_MAP:
|
||||
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count
|
||||
|
||||
# Fallback: coordinate location
|
||||
if "P625" in claims:
|
||||
return CardType.location, (qids[0] if qids else ""), language_count
|
||||
# Fallback: classify by presence of specific claims
|
||||
CLAIMS_FALLBACK = {
|
||||
"P625": CardType.location, # coordinate location
|
||||
"P437": CardType.artwork, # distribution format
|
||||
}
|
||||
for prop, fallback_type in CLAIMS_FALLBACK.items():
|
||||
if prop in claims:
|
||||
return fallback_type, (qids[0] if qids else ""), language_count
|
||||
|
||||
return CardType.other, (qids[0] if qids != [] else ""), language_count
|
||||
|
||||
@@ -525,7 +551,7 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
|
||||
text=text,
|
||||
attack=attack,
|
||||
defense=defense,
|
||||
cost=min(11,max(1,int(((attack**2+defense**2)**0.18)/1.5)))
|
||||
cost=min(10,max(1,int(((attack**2+defense**2)**0.18)/1.5)))
|
||||
)
|
||||
|
||||
async def _get_cards_async(size: int) -> list[Card]:
|
||||
|
||||
@@ -14,6 +14,9 @@ JWT_SECRET_KEY = require("JWT_SECRET_KEY")
|
||||
DATABASE_URL = require("DATABASE_URL")
|
||||
RESEND_API_KEY = require("RESEND_API_KEY")
|
||||
EMAIL_FROM = require("EMAIL_FROM")
|
||||
STRIPE_SECRET_KEY = require("STRIPE_SECRET_KEY")
|
||||
STRIPE_PUBLISHABLE_KEY = require("STRIPE_PUBLISHABLE_KEY")
|
||||
STRIPE_WEBHOOK_SECRET = require("STRIPE_WEBHOOK_SECRET")
|
||||
|
||||
# Optional with sensible defaults for local dev
|
||||
FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173")
|
||||
|
||||
@@ -68,8 +68,9 @@ BOOSTER_COOLDOWN_HOURS = 5
|
||||
|
||||
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
|
||||
if user.boosters_countdown is None:
|
||||
user.boosters = 5
|
||||
db.commit()
|
||||
if user.boosters < BOOSTER_MAX:
|
||||
user.boosters = BOOSTER_MAX
|
||||
db.commit()
|
||||
return (user.boosters, user.boosters_countdown)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
@@ -2,6 +2,31 @@ import resend
|
||||
import os
|
||||
from config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL
|
||||
|
||||
def send_verification_email(to_email: str, username: str, token: str):
|
||||
resend.api_key = RESEND_API_KEY
|
||||
verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
|
||||
|
||||
resend.Emails.send({
|
||||
"from": EMAIL_FROM,
|
||||
"to": to_email,
|
||||
"subject": "Verify your WikiTCG email",
|
||||
"html": f"""
|
||||
<div style="font-family: serif; max-width: 480px; margin: 0 auto; color: #1a1208;">
|
||||
<h2 style="color: #b87830;">Welcome to WikiTCG</h2>
|
||||
<p>Hi {username},</p>
|
||||
<p>Please verify your email address to complete your registration:</p>
|
||||
<p style="margin: 2rem 0;">
|
||||
<a href="{verify_url}" style="background: #c8861a; color: #fff8e0; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-family: sans-serif; font-size: 14px;">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
<p style="color: #888; font-size: 13px;">- WikiTCG</p>
|
||||
</div>
|
||||
""",
|
||||
})
|
||||
|
||||
|
||||
def send_password_reset_email(to_email: str, username: str, reset_token: str):
|
||||
resend.api_key = RESEND_API_KEY
|
||||
reset_url = f"{FRONTEND_URL}/forgot-password/reset?token={reset_token}"
|
||||
|
||||
@@ -56,7 +56,7 @@ class PlayerState:
|
||||
def draw_to_full(self):
|
||||
"""Draw cards until hand has HAND_SIZE cards or deck is empty."""
|
||||
while len(self.hand) < HAND_SIZE and self.deck:
|
||||
self.hand.append(self.deck.pop(0))
|
||||
self.hand.append(self.deck.pop())
|
||||
|
||||
def refill_energy(self):
|
||||
self.energy = self.energy_cap
|
||||
|
||||
63
backend/give_card.py
Normal file
63
backend/give_card.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Give a user a specific card generated from a Wikipedia page title.
|
||||
|
||||
Usage:
|
||||
python give_card.py <username> <page_title>
|
||||
|
||||
Example:
|
||||
python give_card.py nikolaj "Marie Curie"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from database import SessionLocal
|
||||
from models import User as UserModel, Card as CardModel
|
||||
from card import _get_specific_card_async
|
||||
import uuid
|
||||
|
||||
|
||||
async def main(username: str, page_title: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not user:
|
||||
print(f"Error: user '{username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Generating card for '{page_title}'...")
|
||||
card = await _get_specific_card_async(page_title)
|
||||
if not card:
|
||||
print(f"Error: could not generate a card for '{page_title}'")
|
||||
sys.exit(1)
|
||||
|
||||
db_card = CardModel(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
name=card.name,
|
||||
image_link=card.image_link or None,
|
||||
card_rarity=card.card_rarity.name,
|
||||
card_type=card.card_type.name,
|
||||
text=card.text or None,
|
||||
attack=card.attack,
|
||||
defense=card.defense,
|
||||
cost=card.cost,
|
||||
)
|
||||
db.add(db_card)
|
||||
db.commit()
|
||||
|
||||
print(f"Gave '{card.name}' ({card.card_rarity.name} {card.card_type.name}) to {username}")
|
||||
print(f" ATK {card.attack} DEF {card.defense} Cost {card.cost}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(main(sys.argv[1], sys.argv[2]))
|
||||
294
backend/main.py
294
backend/main.py
@@ -33,9 +33,16 @@ from game_manager import (
|
||||
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
|
||||
serialize_state, handle_disconnect, handle_timeout_claim, load_deck_cards, create_solo_game
|
||||
)
|
||||
from trade_manager import (
|
||||
trade_queue, trade_queue_lock, TradeQueueEntry, try_trade_match,
|
||||
handle_trade_action, active_trades, handle_trade_disconnect,
|
||||
serialize_trade,
|
||||
)
|
||||
from card import compute_deck_type, _get_specific_card_async
|
||||
from email_utils import send_password_reset_email
|
||||
from config import CORS_ORIGINS
|
||||
from email_utils import send_password_reset_email, send_verification_email
|
||||
from config import CORS_ORIGINS, STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, FRONTEND_URL
|
||||
import stripe
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
@@ -82,6 +89,11 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
try:
|
||||
from disposable_email_domains import blocklist as _disposable_blocklist
|
||||
except ImportError:
|
||||
_disposable_blocklist: set[str] = set()
|
||||
|
||||
def validate_register(username: str, email: str, password: str) -> str | None:
|
||||
if not username.strip():
|
||||
return "Username is required"
|
||||
@@ -89,6 +101,9 @@ def validate_register(username: str, email: str, password: str) -> str | None:
|
||||
return "Username must be 16 characters or fewer"
|
||||
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
|
||||
return "Please enter a valid email"
|
||||
domain = email.split("@")[-1].lower()
|
||||
if domain in _disposable_blocklist:
|
||||
return "Disposable email addresses are not allowed"
|
||||
if len(password) < 8:
|
||||
return "Password must be at least 8 characters"
|
||||
if len(password) > 256:
|
||||
@@ -104,15 +119,23 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
if db.query(UserModel).filter(UserModel.email == req.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
user = UserModel(
|
||||
id=uuid.uuid4(),
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
password_hash=hash_password(req.password),
|
||||
email_verified=False,
|
||||
email_verification_token=verification_token,
|
||||
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return {"message": "User created"}
|
||||
try:
|
||||
send_verification_email(req.email, req.username, verification_token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send verification email: {e}")
|
||||
return {"message": "Account created. Please check your email to verify your account."}
|
||||
|
||||
@app.post("/login")
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
@@ -126,8 +149,9 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
|
||||
}
|
||||
|
||||
@app.get("/boosters")
|
||||
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]:
|
||||
return check_boosters(user, db)
|
||||
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
count, countdown = check_boosters(user, db)
|
||||
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
|
||||
|
||||
@app.get("/cards")
|
||||
def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@@ -142,6 +166,9 @@ def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends
|
||||
@app.post("/open_pack")
|
||||
@limiter.limit("10/minute")
|
||||
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not user.email_verified:
|
||||
raise HTTPException(status_code=403, detail="You must verify your email before opening packs")
|
||||
|
||||
check_boosters(user, db)
|
||||
|
||||
if user.boosters == 0:
|
||||
@@ -322,6 +349,72 @@ async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depend
|
||||
connections[game_id].pop(user_id, None)
|
||||
asyncio.create_task(handle_disconnect(game_id, user_id))
|
||||
|
||||
@app.websocket("/ws/trade/queue")
|
||||
async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
if not user:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if not user.email_verified:
|
||||
await websocket.send_json({"type": "error", "message": "You must verify your email before trading."})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
entry = TradeQueueEntry(user_id=user_id, username=user.username, websocket=websocket)
|
||||
|
||||
async with trade_queue_lock:
|
||||
trade_queue.append(entry)
|
||||
|
||||
await websocket.send_json({"type": "queued"})
|
||||
await try_trade_match()
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
async with trade_queue_lock:
|
||||
trade_queue[:] = [e for e in trade_queue if e.user_id != user_id]
|
||||
|
||||
|
||||
@app.websocket("/ws/trade/{trade_id}")
|
||||
async def trade_endpoint(websocket: WebSocket, trade_id: str, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session = active_trades.get(trade_id)
|
||||
if not session or user_id not in session.offers:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session.connections[user_id] = websocket
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_trade(session, user_id),
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
await handle_trade_action(trade_id, user_id, data, db)
|
||||
except WebSocketDisconnect:
|
||||
session.connections.pop(user_id, None)
|
||||
asyncio.create_task(handle_trade_disconnect(trade_id, user_id))
|
||||
|
||||
|
||||
@app.get("/profile")
|
||||
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
total_games = user.wins + user.losses
|
||||
@@ -343,9 +436,11 @@ def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depen
|
||||
return {
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"created_at": user.created_at,
|
||||
"wins": user.wins,
|
||||
"losses": user.losses,
|
||||
"shards": user.shards,
|
||||
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
|
||||
"most_played_deck": {
|
||||
"name": most_played_deck.name,
|
||||
@@ -360,6 +455,160 @@ def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depen
|
||||
} if most_played_card else None,
|
||||
}
|
||||
|
||||
class ShatterRequest(BaseModel):
|
||||
card_ids: list[str]
|
||||
|
||||
@app.post("/shards/shatter")
|
||||
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not req.card_ids:
|
||||
raise HTTPException(status_code=400, detail="No cards selected")
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid card IDs")
|
||||
|
||||
cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(parsed_ids),
|
||||
CardModel.user_id == user.id,
|
||||
).all()
|
||||
|
||||
if len(cards) != len(parsed_ids):
|
||||
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
|
||||
|
||||
total = sum(c.cost for c in cards)
|
||||
|
||||
for card in cards:
|
||||
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete()
|
||||
db.delete(card)
|
||||
|
||||
user.shards += total
|
||||
db.commit()
|
||||
return {"shards": user.shards, "gained": total}
|
||||
|
||||
# Shard packages sold for real money.
|
||||
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
|
||||
SHARD_PACKAGES = {
|
||||
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
|
||||
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
|
||||
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
|
||||
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
|
||||
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
|
||||
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
|
||||
}
|
||||
|
||||
class StripeCheckoutRequest(BaseModel):
|
||||
package_id: str
|
||||
|
||||
@app.post("/store/stripe/checkout")
|
||||
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
|
||||
package = SHARD_PACKAGES.get(req.package_id)
|
||||
if not package:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[{
|
||||
"price_data": {
|
||||
"currency": "dkk",
|
||||
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
|
||||
"unit_amount": package["price_oere"],
|
||||
},
|
||||
"quantity": 1,
|
||||
}],
|
||||
mode="payment",
|
||||
success_url=f"{FRONTEND_URL}/store?payment=success",
|
||||
cancel_url=f"{FRONTEND_URL}/store",
|
||||
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
|
||||
)
|
||||
return {"url": session.url}
|
||||
|
||||
@app.post("/stripe/webhook")
|
||||
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
payload = await request.body()
|
||||
sig = request.headers.get("stripe-signature", "")
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
|
||||
except stripe.error.SignatureVerificationError: # type: ignore
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
if event["type"] == "checkout.session.completed":
|
||||
data = event["data"]["object"]
|
||||
user_id = data.get("metadata", {}).get("user_id")
|
||||
shards = data.get("metadata", {}).get("shards")
|
||||
if user_id and shards:
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
if user:
|
||||
user.shards += int(shards)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/store/config")
|
||||
def store_config():
|
||||
return {
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
"shard_packages": SHARD_PACKAGES,
|
||||
}
|
||||
|
||||
STORE_PACKAGES = {
|
||||
1: 15,
|
||||
5: 65,
|
||||
10: 120,
|
||||
25: 260,
|
||||
}
|
||||
|
||||
class StoreBuyRequest(BaseModel):
|
||||
quantity: int
|
||||
|
||||
class BuySpecificCardRequest(BaseModel):
|
||||
wiki_title: str
|
||||
|
||||
SPECIFIC_CARD_COST = 1000
|
||||
|
||||
@app.post("/store/buy-specific-card")
|
||||
@limiter.limit("10/hour")
|
||||
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if user.shards < SPECIFIC_CARD_COST:
|
||||
raise HTTPException(status_code=400, detail="Not enough shards")
|
||||
|
||||
card = await _get_specific_card_async(req.wiki_title)
|
||||
if card is None:
|
||||
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
|
||||
|
||||
db_card = CardModel(
|
||||
name=card.name,
|
||||
image_link=card.image_link,
|
||||
card_rarity=card.card_rarity.name,
|
||||
card_type=card.card_type.name,
|
||||
text=card.text,
|
||||
attack=card.attack,
|
||||
defense=card.defense,
|
||||
cost=card.cost,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(db_card)
|
||||
user.shards -= SPECIFIC_CARD_COST
|
||||
db.commit()
|
||||
db.refresh(db_card)
|
||||
|
||||
return {
|
||||
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
|
||||
"card_rarity": db_card.card_rarity,
|
||||
"card_type": db_card.card_type,
|
||||
"shards": user.shards,
|
||||
}
|
||||
|
||||
@app.post("/store/buy")
|
||||
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
cost = STORE_PACKAGES.get(req.quantity)
|
||||
if cost is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
if user.shards < cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough shards")
|
||||
user.shards -= cost
|
||||
user.boosters += req.quantity
|
||||
db.commit()
|
||||
return {"shards": user.shards, "boosters": user.boosters}
|
||||
|
||||
@app.post("/cards/{card_id}/report")
|
||||
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
@@ -382,8 +631,8 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=48):
|
||||
remaining = (user.last_refresh_at + timedelta(hours=48)) - datetime.now()
|
||||
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=2):
|
||||
remaining = (user.last_refresh_at + timedelta(hours=2)) - datetime.now()
|
||||
hours = int(remaining.total_seconds() // 3600)
|
||||
minutes = int((remaining.total_seconds() % 3600) // 60)
|
||||
raise HTTPException(
|
||||
@@ -417,7 +666,7 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
|
||||
def refresh_status(user: UserModel = Depends(get_current_user)):
|
||||
if not user.last_refresh_at:
|
||||
return {"can_refresh": True, "next_refresh_at": None}
|
||||
next_refresh = user.last_refresh_at + timedelta(hours=48)
|
||||
next_refresh = user.last_refresh_at + timedelta(hours=2)
|
||||
can_refresh = datetime.now() >= next_refresh
|
||||
return {
|
||||
"can_refresh": can_refresh,
|
||||
@@ -516,6 +765,35 @@ def reset_password_with_token(req: ResetPasswordWithTokenRequest, db: Session =
|
||||
db.commit()
|
||||
return {"message": "Password updated"}
|
||||
|
||||
@app.get("/auth/verify-email")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
|
||||
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
|
||||
user.email_verified = True
|
||||
user.email_verification_token = None
|
||||
user.email_verification_token_expires_at = None
|
||||
db.commit()
|
||||
return {"message": "Email verified"}
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@app.post("/auth/resend-verification")
|
||||
def resend_verification(req: ResendVerificationRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.email == req.email).first()
|
||||
# Always return success to prevent user enumeration
|
||||
if user and not user.email_verified:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.email_verification_token = token
|
||||
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
|
||||
db.commit()
|
||||
try:
|
||||
send_verification_email(user.email, user.username, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resend verification email: {e}")
|
||||
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@@ -17,9 +17,13 @@ class User(Base):
|
||||
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
shards: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
reset_token: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
reset_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
email_verification_token: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
email_verification_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
cards: Mapped[list["Card"]] = relationship(back_populates="user")
|
||||
decks: Mapped[list["Deck"]] = relationship(back_populates="user")
|
||||
|
||||
269
backend/nn.py
Normal file
269
backend/nn.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import numpy as np
|
||||
import json
|
||||
|
||||
# Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15) | opp_deck_type(8)]
|
||||
N_FEATURES = 64
|
||||
|
||||
_DECK_TYPES = ["Balanced", "Aggro", "Wall", "Rush", "Control", "God Card", "Pantheon", "Unplayable"]
|
||||
_DECK_TYPE_IDX = {dt: i for i, dt in enumerate(_DECK_TYPES)}
|
||||
|
||||
_MAX_ATK = 50.0
|
||||
_MAX_DEF = 100.0
|
||||
_MAX_DECK = 30.0
|
||||
|
||||
|
||||
def _softmax(x: np.ndarray) -> np.ndarray:
|
||||
e = np.exp(x - x.max())
|
||||
return e / e.sum()
|
||||
|
||||
|
||||
class NeuralNet:
|
||||
"""
|
||||
Fully-connected plan scorer: n_features → 64 → 32 → 1
|
||||
Pure numpy so it can be pickled into worker processes.
|
||||
Optimizer: Adam.
|
||||
"""
|
||||
|
||||
def __init__(self, n_features: int = N_FEATURES, hidden: tuple = (64, 32), seed: int | None = None):
|
||||
rng = np.random.RandomState(seed)
|
||||
sizes = [n_features] + list(hidden) + [1]
|
||||
|
||||
self.weights: list[np.ndarray] = []
|
||||
self.biases: list[np.ndarray] = []
|
||||
self.m_w: list[np.ndarray] = []
|
||||
self.v_w: list[np.ndarray] = []
|
||||
self.m_b: list[np.ndarray] = []
|
||||
self.v_b: list[np.ndarray] = []
|
||||
self.t = 0
|
||||
|
||||
for fan_in, fan_out in zip(sizes, sizes[1:]):
|
||||
w = rng.randn(fan_in, fan_out).astype(np.float32) * np.sqrt(2.0 / fan_in)
|
||||
b = np.zeros(fan_out, dtype=np.float32)
|
||||
self.weights.append(w)
|
||||
self.biases.append(b)
|
||||
self.m_w.append(np.zeros_like(w))
|
||||
self.v_w.append(np.zeros_like(w))
|
||||
self.m_b.append(np.zeros_like(b))
|
||||
self.v_b.append(np.zeros_like(b))
|
||||
|
||||
self._acts: list[np.ndarray] = []
|
||||
self._pre_acts: list[np.ndarray] = []
|
||||
|
||||
def forward(self, X: np.ndarray) -> np.ndarray:
|
||||
"""X: (n, n_features) → scores: (n,)"""
|
||||
h = X.astype(np.float32)
|
||||
self._acts = [h]
|
||||
self._pre_acts = []
|
||||
for i, (W, b) in enumerate(zip(self.weights, self.biases)):
|
||||
z = h @ W + b
|
||||
self._pre_acts.append(z)
|
||||
h = np.maximum(0.0, z) if i < len(self.weights) - 1 else z
|
||||
self._acts.append(h)
|
||||
return h.squeeze(-1)
|
||||
|
||||
def backward(self, upstream: np.ndarray) -> tuple[list, list]:
|
||||
"""
|
||||
upstream: (n,) — dJ/d(scores), gradient for ascent.
|
||||
Returns (grads_w, grads_b).
|
||||
"""
|
||||
n = len(upstream)
|
||||
delta = upstream[:, None] # (n, 1)
|
||||
grads_w = [None] * len(self.weights)
|
||||
grads_b = [None] * len(self.biases)
|
||||
for i in range(len(self.weights) - 1, -1, -1):
|
||||
h_in = self._acts[i] # (n, in_size)
|
||||
grads_w[i] = h_in.T @ delta / n
|
||||
grads_b[i] = delta.mean(axis=0)
|
||||
if i > 0:
|
||||
delta = (delta @ self.weights[i].T) * (self._pre_acts[i - 1] > 0)
|
||||
return grads_w, grads_b
|
||||
|
||||
def adam_update(self, grads_w: list, grads_b: list,
|
||||
lr: float = 1e-3, beta1: float = 0.9,
|
||||
beta2: float = 0.999, eps: float = 1e-8,
|
||||
grad_clip: float = 1.0) -> None:
|
||||
# Global gradient norm clipping
|
||||
all_grads = [g for g in grads_w + grads_b if g is not None]
|
||||
global_norm = np.sqrt(sum(np.sum(g * g) for g in all_grads))
|
||||
if global_norm > grad_clip:
|
||||
scale = grad_clip / global_norm
|
||||
grads_w = [g * scale for g in grads_w]
|
||||
grads_b = [g * scale for g in grads_b]
|
||||
|
||||
self.t += 1
|
||||
bc1 = 1 - beta1 ** self.t
|
||||
bc2 = 1 - beta2 ** self.t
|
||||
for i, (gw, gb) in enumerate(zip(grads_w, grads_b)):
|
||||
self.m_w[i] = beta1 * self.m_w[i] + (1 - beta1) * gw
|
||||
self.v_w[i] = beta2 * self.v_w[i] + (1 - beta2) * gw * gw
|
||||
self.weights[i] += lr * (self.m_w[i] / bc1) / (np.sqrt(self.v_w[i] / bc2) + eps)
|
||||
|
||||
self.m_b[i] = beta1 * self.m_b[i] + (1 - beta1) * gb
|
||||
self.v_b[i] = beta2 * self.v_b[i] + (1 - beta2) * gb * gb
|
||||
self.biases[i] += lr * (self.m_b[i] / bc1) / (np.sqrt(self.v_b[i] / bc2) + eps)
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
data = {
|
||||
"weights": [w.tolist() for w in self.weights],
|
||||
"biases": [b.tolist() for b in self.biases],
|
||||
"m_w": [m.tolist() for m in self.m_w],
|
||||
"v_w": [v.tolist() for v in self.v_w],
|
||||
"m_b": [m.tolist() for m in self.m_b],
|
||||
"v_b": [v.tolist() for v in self.v_b],
|
||||
"t": self.t,
|
||||
}
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "NeuralNet":
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
net = cls.__new__(cls)
|
||||
net.weights = [np.array(w, dtype=np.float32) for w in data["weights"]]
|
||||
net.biases = [np.array(b, dtype=np.float32) for b in data["biases"]]
|
||||
net.m_w = [np.array(m, dtype=np.float32) for m in data["m_w"]]
|
||||
net.v_w = [np.array(v, dtype=np.float32) for v in data["v_w"]]
|
||||
net.m_b = [np.array(m, dtype=np.float32) for m in data["m_b"]]
|
||||
net.v_b = [np.array(v, dtype=np.float32) for v in data["v_b"]]
|
||||
net.t = data["t"]
|
||||
net._acts = []
|
||||
net._pre_acts = []
|
||||
return net
|
||||
|
||||
|
||||
# ==================== Feature extraction ====================
|
||||
|
||||
def extract_plan_features(plans: list, player, opponent) -> np.ndarray:
|
||||
"""
|
||||
Returns (n_plans, N_FEATURES) float32 array.
|
||||
Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15)]
|
||||
"""
|
||||
from game import BOARD_SIZE, HAND_SIZE, MAX_ENERGY_CAP, STARTING_LIFE
|
||||
|
||||
n = len(plans)
|
||||
|
||||
# ---- state (same for every plan) ----
|
||||
state = np.array([
|
||||
player.life / STARTING_LIFE,
|
||||
opponent.life / STARTING_LIFE,
|
||||
player.energy / MAX_ENERGY_CAP,
|
||||
player.energy_cap / MAX_ENERGY_CAP,
|
||||
len(player.hand) / HAND_SIZE,
|
||||
len(opponent.hand) / HAND_SIZE,
|
||||
len(player.deck) / _MAX_DECK,
|
||||
len(opponent.deck) / _MAX_DECK,
|
||||
], dtype=np.float32)
|
||||
|
||||
# ---- current boards (same for every plan) ----
|
||||
my_board = np.zeros(BOARD_SIZE * 3, dtype=np.float32)
|
||||
opp_board = np.zeros(BOARD_SIZE * 3, dtype=np.float32)
|
||||
for slot in range(BOARD_SIZE):
|
||||
c = player.board[slot]
|
||||
if c is not None:
|
||||
my_board[slot * 3] = c.attack / _MAX_ATK
|
||||
my_board[slot * 3 + 1] = c.defense / _MAX_DEF
|
||||
my_board[slot * 3 + 2] = 1.0
|
||||
c = opponent.board[slot]
|
||||
if c is not None:
|
||||
opp_board[slot * 3] = c.attack / _MAX_ATK
|
||||
opp_board[slot * 3 + 1] = c.defense / _MAX_DEF
|
||||
opp_board[slot * 3 + 2] = 1.0
|
||||
|
||||
# ---- per-plan features ----
|
||||
plan_part = np.zeros((n, 3 + BOARD_SIZE * 3), dtype=np.float32)
|
||||
for idx, plan in enumerate(plans):
|
||||
# simulate board result
|
||||
result = list(player.board)
|
||||
for slot in plan.sacrifice_slots:
|
||||
result[slot] = None
|
||||
for card, slot in plan.plays:
|
||||
result[slot] = card
|
||||
|
||||
total_cost = sum(c.cost for c, _ in plan.plays) if plan.plays else 0
|
||||
plan_part[idx, 0] = len(plan.sacrifice_slots) / BOARD_SIZE
|
||||
plan_part[idx, 1] = len(plan.plays) / HAND_SIZE
|
||||
plan_part[idx, 2] = total_cost / (MAX_ENERGY_CAP + BOARD_SIZE)
|
||||
|
||||
for slot in range(BOARD_SIZE):
|
||||
c = result[slot]
|
||||
if c is not None:
|
||||
plan_part[idx, 3 + slot * 3] = c.attack / _MAX_ATK
|
||||
plan_part[idx, 3 + slot * 3 + 1] = c.defense / _MAX_DEF
|
||||
plan_part[idx, 3 + slot * 3 + 2] = 1.0
|
||||
|
||||
# ---- opponent deck type one-hot (same for every plan) ----
|
||||
opp_deck_oh = np.zeros(len(_DECK_TYPES), dtype=np.float32)
|
||||
opp_deck_oh[_DECK_TYPE_IDX.get(opponent.deck_type, 0)] = 1.0
|
||||
|
||||
state_t = np.tile(state, (n, 1))
|
||||
my_board_t = np.tile(my_board, (n, 1))
|
||||
opp_board_t = np.tile(opp_board, (n, 1))
|
||||
opp_deck_t = np.tile(opp_deck_oh, (n, 1))
|
||||
|
||||
return np.concatenate([state_t, my_board_t, opp_board_t, plan_part, opp_deck_t], axis=1)
|
||||
|
||||
|
||||
# ==================== Neural player ====================
|
||||
|
||||
class NeuralPlayer:
|
||||
"""
|
||||
Wraps a NeuralNet for use in game simulation.
|
||||
In training mode, samples plans stochastically and records the trajectory
|
||||
for a REINFORCE update after the game ends.
|
||||
In inference mode, picks the highest-scoring plan deterministically.
|
||||
"""
|
||||
|
||||
def __init__(self, net: NeuralNet, training: bool = False, temperature: float = 1.0):
|
||||
self.net = net
|
||||
self.training = training
|
||||
self.temperature = temperature
|
||||
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features, chosen_idx)
|
||||
|
||||
def choose_plan(self, player, opponent):
|
||||
from ai import generate_plans
|
||||
plans = generate_plans(player, opponent)
|
||||
features = extract_plan_features(plans, player, opponent)
|
||||
scores = self.net.forward(features)
|
||||
|
||||
if self.training:
|
||||
probs = _softmax((scores / self.temperature).astype(np.float64))
|
||||
probs = np.clip(probs, 1e-10, None)
|
||||
probs /= probs.sum()
|
||||
chosen_idx = int(np.random.choice(len(plans), p=probs))
|
||||
self.trajectory.append((features, chosen_idx))
|
||||
else:
|
||||
chosen_idx = int(np.argmax(scores))
|
||||
|
||||
return plans[chosen_idx]
|
||||
|
||||
def compute_grads(self, outcome: float) -> tuple[list, list] | None:
|
||||
"""
|
||||
Computes averaged REINFORCE gradients for this trajectory without updating weights.
|
||||
outcome: centered reward (win/loss minus baseline).
|
||||
Returns (grads_w, grads_b), or None if trajectory is empty.
|
||||
"""
|
||||
if not self.trajectory:
|
||||
return None
|
||||
|
||||
acc_gw = [np.zeros_like(w) for w in self.net.weights]
|
||||
acc_gb = [np.zeros_like(b) for b in self.net.biases]
|
||||
|
||||
for features, chosen_idx in self.trajectory:
|
||||
scores = self.net.forward(features)
|
||||
probs = _softmax(scores.astype(np.float64)).astype(np.float32)
|
||||
upstream = -probs.copy()
|
||||
upstream[chosen_idx] += 1.0
|
||||
upstream *= outcome
|
||||
gw, gb = self.net.backward(upstream)
|
||||
for i in range(len(acc_gw)):
|
||||
acc_gw[i] += gw[i]
|
||||
acc_gb[i] += gb[i]
|
||||
|
||||
n = len(self.trajectory)
|
||||
for i in range(len(acc_gw)):
|
||||
acc_gw[i] /= n
|
||||
acc_gb[i] /= n
|
||||
|
||||
self.trajectory.clear()
|
||||
return acc_gw, acc_gb
|
||||
1
backend/nn_weights.json
Normal file
1
backend/nn_weights.json
Normal file
File diff suppressed because one or more lines are too long
@@ -13,4 +13,6 @@ pyyaml==6.0.3
|
||||
psycopg2-binary==2.9.11
|
||||
python-multipart==0.0.22
|
||||
alembic==1.18.4
|
||||
websockets==13.1
|
||||
websockets==13.1
|
||||
disposable-email-domains==0.0.169
|
||||
stripe==14.4.1
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
@@ -94,7 +95,7 @@ def _make_instances(deck: list[Card]) -> list[CardInstance]:
|
||||
]
|
||||
|
||||
|
||||
async def simulate_game(
|
||||
def simulate_game(
|
||||
cards: list[Card],
|
||||
difficulty1: int,
|
||||
personality1: AIPersonality,
|
||||
@@ -106,8 +107,6 @@ async def simulate_game(
|
||||
Player 1 always goes first.
|
||||
|
||||
Returns "p1", "p2", or None if the game exceeds MAX_TURNS.
|
||||
|
||||
Designed to be awaited inside asyncio.gather() to run many games concurrently.
|
||||
"""
|
||||
deck1 = choose_cards(cards, difficulty1, personality1)
|
||||
deck2 = choose_cards(cards, difficulty2, personality2)
|
||||
@@ -152,7 +151,7 @@ async def simulate_game(
|
||||
player = state.players[active_id]
|
||||
opponent = state.players[state.opponent_id(active_id)]
|
||||
|
||||
plan = await choose_plan(player, opponent, personality, difficulty)
|
||||
plan = choose_plan(player, opponent, personality, difficulty)
|
||||
|
||||
for slot in plan.sacrifice_slots:
|
||||
if player.board[slot] is not None:
|
||||
@@ -189,11 +188,11 @@ def _init_worker(cards: list[Card]) -> None:
|
||||
def _run_game_sync(args: tuple) -> str | None:
|
||||
"""Synchronous entry point for a worker process."""
|
||||
d1, p1_name, d2, p2_name = args
|
||||
return asyncio.run(simulate_game(
|
||||
return simulate_game(
|
||||
_worker_cards,
|
||||
d1, AIPersonality(p1_name),
|
||||
d2, AIPersonality(p2_name),
|
||||
))
|
||||
)
|
||||
|
||||
|
||||
# ==================== Tournament ====================
|
||||
@@ -290,26 +289,188 @@ async def run_tournament(
|
||||
return wins
|
||||
|
||||
|
||||
def _sprt_check(wins: int, total: int, log_win: float, log_loss: float, log_B: float) -> bool:
|
||||
"""
|
||||
Return True when the SPRT has reached a decision for this matchup.
|
||||
|
||||
Tests H0: win_rate = 0.5 vs H1: win_rate = p_decisive (or 1-p_decisive).
|
||||
log_win = log(p_decisive / 0.5)
|
||||
log_loss = log((1 - p_decisive) / 0.5)
|
||||
|
||||
LLR drifts slowly for near-50% matchups and quickly for lopsided ones.
|
||||
Decided when LLR crosses ±log_B.
|
||||
"""
|
||||
llr = wins * log_win + (total - wins) * log_loss
|
||||
return llr >= log_B or llr <= -log_B
|
||||
|
||||
|
||||
async def run_tournament_adaptive(
|
||||
cards: list[Card],
|
||||
difficulties: list[int] | None = None,
|
||||
min_games: int = 5,
|
||||
max_games: int = 200,
|
||||
p_decisive: float = 0.65,
|
||||
alpha: float = 0.05,
|
||||
) -> tuple[dict[tuple[int, int], int], dict[tuple[int, int], int]]:
|
||||
"""
|
||||
Like run_tournament but allocates games adaptively.
|
||||
|
||||
Each ordered pair (i, j) plays until SPRT decides one player is dominant
|
||||
(win rate ≥ p_decisive with confidence 1-alpha) or max_games is reached.
|
||||
Close matchups play more games; lopsided ones stop early.
|
||||
|
||||
Returns (wins, played):
|
||||
wins[(i, j)] — how many games player i won as first player against j
|
||||
played[(i, j)] — how many games were played for that pair
|
||||
|
||||
Each round, all currently-undecided pairs play one game in parallel across
|
||||
all CPU cores, preserving full parallelism while adapting per-pair budgets.
|
||||
"""
|
||||
players = _all_players(difficulties)
|
||||
n = len(players)
|
||||
all_pairs = [(i, j) for i in range(n) for j in range(n)]
|
||||
|
||||
wins: dict[tuple[int, int], int] = {pair: 0 for pair in all_pairs}
|
||||
played: dict[tuple[int, int], int] = {pair: 0 for pair in all_pairs}
|
||||
decided: set[tuple[int, int]] = set()
|
||||
|
||||
# Precompute SPRT constants (H0: p=0.5, H1: p=p_decisive)
|
||||
log_B = math.log((1 - alpha) / alpha)
|
||||
log_win = math.log(p_decisive / 0.5)
|
||||
log_loss = math.log((1 - p_decisive) / 0.5)
|
||||
|
||||
def make_args(i: int, j: int) -> tuple:
|
||||
p1, d1 = players[i]
|
||||
p2, d2 = players[j]
|
||||
return (d1, p1.value, d2, p2.value)
|
||||
|
||||
n_workers = os.cpu_count() or 1
|
||||
loop = asyncio.get_running_loop()
|
||||
total_played = 0
|
||||
max_possible = len(all_pairs) * max_games
|
||||
|
||||
print(
|
||||
f"Adaptive tournament: {n} players, {len(all_pairs)} pairs, "
|
||||
f"SPRT p_decisive={p_decisive} alpha={alpha}, "
|
||||
f"min={min_games} max={max_games} games/pair\n"
|
||||
f"Worst case: {max_possible:,} games across {n_workers} workers"
|
||||
)
|
||||
|
||||
with ProcessPoolExecutor(
|
||||
max_workers=n_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(cards,),
|
||||
) as executor:
|
||||
round_num = 0
|
||||
while True:
|
||||
pending = [
|
||||
pair for pair in all_pairs
|
||||
if pair not in decided and played[pair] < max_games
|
||||
]
|
||||
if not pending:
|
||||
break
|
||||
|
||||
round_num += 1
|
||||
batch = [(i, j, make_args(i, j)) for (i, j) in pending]
|
||||
futures = [
|
||||
loop.run_in_executor(executor, _run_game_sync, args)
|
||||
for _, _, args in batch
|
||||
]
|
||||
results = await asyncio.gather(*futures)
|
||||
|
||||
newly_decided = 0
|
||||
for (i, j, _), winner in zip(batch, results):
|
||||
played[(i, j)] += 1
|
||||
if winner == PLAYER1_ID:
|
||||
wins[(i, j)] += 1
|
||||
total_played += 1
|
||||
|
||||
if (played[(i, j)] >= min_games
|
||||
and _sprt_check(wins[(i, j)], played[(i, j)], log_win, log_loss, log_B)):
|
||||
decided.add((i, j))
|
||||
newly_decided += 1
|
||||
|
||||
remaining = len(all_pairs) - len(decided)
|
||||
pct = total_played / max_possible * 100
|
||||
print(
|
||||
f" Round {round_num:3d}: {len(pending):5d} games, "
|
||||
f"+{newly_decided:4d} decided, "
|
||||
f"{remaining:5d} pairs left, "
|
||||
f"{total_played:,} total ({pct:.1f}% of worst case)",
|
||||
end="\r", flush=True,
|
||||
)
|
||||
|
||||
savings = max_possible - total_played
|
||||
print(
|
||||
f"\nFinished: {total_played:,} games played "
|
||||
f"(saved {savings:,} vs fixed, "
|
||||
f"{savings / max_possible * 100:.1f}% reduction)"
|
||||
)
|
||||
print(
|
||||
f"Early decisions: {len(decided)}/{len(all_pairs)} pairs "
|
||||
f"({len(decided) / len(all_pairs) * 100:.1f}%)"
|
||||
)
|
||||
|
||||
return wins, played
|
||||
|
||||
|
||||
def compute_bradley_terry(
|
||||
wins: dict[tuple[int, int], int],
|
||||
n: int,
|
||||
played: dict[tuple[int, int], int] | None = None,
|
||||
games_per_matchup: int | None = None,
|
||||
iterations: int = 1000,
|
||||
) -> list[float]:
|
||||
"""
|
||||
Compute Bradley-Terry strength parameters for all n players.
|
||||
|
||||
For each pair (i, j): w_ij wins for i, w_ji wins for j.
|
||||
Iteratively updates: strength[i] = sum_j(w_ij) / sum_j((w_ij+w_ji) / (s[i]+s[j]))
|
||||
|
||||
Returns a list of strength values indexed by player. Unlike Elo, this is
|
||||
path-independent and converges to a unique maximum-likelihood solution.
|
||||
"""
|
||||
w: list[list[int]] = [[0] * n for _ in range(n)]
|
||||
for (i, j), p1_wins in wins.items():
|
||||
g = played[(i, j)] if played is not None else games_per_matchup
|
||||
if g:
|
||||
w[i][j] += p1_wins
|
||||
w[j][i] += g - p1_wins
|
||||
|
||||
strength = [1.0] * n
|
||||
for _ in range(iterations):
|
||||
new_strength = [0.0] * n
|
||||
for i in range(n):
|
||||
wins_i = sum(w[i][j] for j in range(n) if j != i)
|
||||
denom = sum(
|
||||
(w[i][j] + w[j][i]) / (strength[i] + strength[j])
|
||||
for j in range(n)
|
||||
if j != i and (w[i][j] + w[j][i]) > 0
|
||||
)
|
||||
new_strength[i] = wins_i / denom if denom > 0 else strength[i]
|
||||
# Normalize so the mean stays at 1.0
|
||||
mean = sum(new_strength) / n
|
||||
strength = [s / mean for s in new_strength]
|
||||
|
||||
return strength
|
||||
|
||||
|
||||
def rank_players(
|
||||
wins: dict[tuple[int, int], int],
|
||||
games_per_matchup: int,
|
||||
players: list[tuple[AIPersonality, int]],
|
||||
played: dict[tuple[int, int], int] | None = None,
|
||||
games_per_matchup: int | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Rank player indices by total wins (as first + second player combined).
|
||||
Returns indices sorted worst-to-best.
|
||||
Rank player indices by Bradley-Terry strength. Returns indices sorted worst-to-best.
|
||||
|
||||
Provide either `played` (adaptive tournament) or `games_per_matchup` (fixed).
|
||||
"""
|
||||
n = len(players)
|
||||
total_wins = [0] * n
|
||||
if played is None and games_per_matchup is None:
|
||||
raise ValueError("Provide either played or games_per_matchup")
|
||||
|
||||
for (i, j), p1_wins in wins.items():
|
||||
if i == j:
|
||||
continue # self-matchups are symmetric; skip to avoid double-counting
|
||||
p2_wins = games_per_matchup - p1_wins
|
||||
total_wins[i] += p1_wins
|
||||
total_wins[j] += p2_wins
|
||||
|
||||
return sorted(range(n), key=lambda k: total_wins[k])
|
||||
ratings = compute_bradley_terry(wins, len(players), played=played, games_per_matchup=games_per_matchup)
|
||||
return sorted(range(len(players)), key=lambda i: ratings[i])
|
||||
|
||||
|
||||
TOURNAMENT_RESULTS_PATH = os.path.join(os.path.dirname(__file__), "tournament_results.json")
|
||||
@@ -317,43 +478,63 @@ TOURNAMENT_RESULTS_PATH = os.path.join(os.path.dirname(__file__), "tournament_re
|
||||
|
||||
def save_tournament(
|
||||
wins: dict[tuple[int, int], int],
|
||||
games_per_matchup: int,
|
||||
players: list[tuple[AIPersonality, int]],
|
||||
path: str = TOURNAMENT_RESULTS_PATH,
|
||||
played: dict[tuple[int, int], int] | None = None,
|
||||
games_per_matchup: int | None = None,
|
||||
):
|
||||
data = {
|
||||
"games_per_matchup": games_per_matchup,
|
||||
"players": [
|
||||
{"personality": p.value, "difficulty": d}
|
||||
for p, d in players
|
||||
],
|
||||
"wins": {f"{i},{j}": w for (i, j), w in wins.items()},
|
||||
}
|
||||
if played is not None:
|
||||
data["played"] = {f"{i},{j}": g for (i, j), g in played.items()}
|
||||
if games_per_matchup is not None:
|
||||
data["games_per_matchup"] = games_per_matchup
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f"Tournament results saved to {path}")
|
||||
|
||||
|
||||
def load_tournament(path: str = TOURNAMENT_RESULTS_PATH) -> tuple[dict[tuple[int, int], int], int, list[tuple[AIPersonality, int]]]:
|
||||
"""Returns (wins, games_per_matchup, players)."""
|
||||
def load_tournament(
|
||||
path: str = TOURNAMENT_RESULTS_PATH,
|
||||
) -> tuple[
|
||||
dict[tuple[int, int], int],
|
||||
dict[tuple[int, int], int] | None,
|
||||
int | None,
|
||||
list[tuple[AIPersonality, int]],
|
||||
]:
|
||||
"""Returns (wins, played, games_per_matchup, players).
|
||||
|
||||
`played` is None for legacy fixed-game files (use games_per_matchup instead).
|
||||
`games_per_matchup` is None for adaptive files (use played instead).
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
wins = {
|
||||
(int(k.split(",")[0]), int(k.split(",")[1])): v
|
||||
for k, v in data["wins"].items()
|
||||
}
|
||||
|
||||
def parse_pair_dict(d: dict) -> dict[tuple[int, int], int]:
|
||||
return {(int(k.split(",")[0]), int(k.split(",")[1])): v for k, v in d.items()}
|
||||
|
||||
wins = parse_pair_dict(data["wins"])
|
||||
played = parse_pair_dict(data["played"]) if "played" in data else None
|
||||
games_per_matchup = data.get("games_per_matchup")
|
||||
players = [
|
||||
(AIPersonality(p["personality"]), p["difficulty"])
|
||||
for p in data["players"]
|
||||
]
|
||||
return wins, data["games_per_matchup"], players
|
||||
return wins, played, games_per_matchup, players
|
||||
|
||||
|
||||
def draw_grid(
|
||||
wins: dict[tuple[int, int], int],
|
||||
games_per_matchup: int = 5,
|
||||
players: list[tuple[AIPersonality, int]] | None = None,
|
||||
output_path: str = "tournament_grid.png",
|
||||
played: dict[tuple[int, int], int] | None = None,
|
||||
games_per_matchup: int | None = None,
|
||||
ranked: list[int] | None = None,
|
||||
):
|
||||
"""
|
||||
Draw a heatmap grid of tournament results.
|
||||
@@ -370,19 +551,28 @@ def draw_grid(
|
||||
import matplotlib.colors as mcolors
|
||||
import numpy as np
|
||||
|
||||
if played is None and games_per_matchup is None:
|
||||
raise ValueError("Provide either played or games_per_matchup")
|
||||
|
||||
if players is None:
|
||||
players = _all_players()
|
||||
n = len(players)
|
||||
ranked = rank_players(wins, games_per_matchup, players) # worst-to-best indices
|
||||
if ranked is None:
|
||||
ranked = rank_players(wins, players, played=played, games_per_matchup=games_per_matchup)
|
||||
|
||||
labels = [_player_label(*players[i]) for i in ranked]
|
||||
|
||||
# Build value matrix: (p1_wins - p2_wins) / games_per_matchup ∈ [-1, 1], NaN on diagonal
|
||||
def games(i: int, j: int) -> int:
|
||||
return_value = played[(i, j)] if played is not None else games_per_matchup
|
||||
return return_value if return_value is not None else 0
|
||||
|
||||
# Build value matrix: (p1_wins - p2_wins) / total_games ∈ [-1, 1]
|
||||
matrix = np.full((n, n), np.nan)
|
||||
for row, i in enumerate(ranked):
|
||||
for col, j in enumerate(ranked):
|
||||
g = games(i, j)
|
||||
p1_wins = wins.get((i, j), 0)
|
||||
matrix[row, col] = (p1_wins - (games_per_matchup - p1_wins)) / games_per_matchup
|
||||
matrix[row, col] = (p1_wins - (g - p1_wins)) / g if g > 0 else 0.0
|
||||
|
||||
cell_size = 0.22
|
||||
fig_size = n * cell_size + 3
|
||||
@@ -398,8 +588,9 @@ def draw_grid(
|
||||
# × marks for sweeps
|
||||
for row, i in enumerate(ranked):
|
||||
for col, j in enumerate(ranked):
|
||||
g = games(i, j)
|
||||
p1_wins = wins.get((i, j), 0)
|
||||
if p1_wins == games_per_matchup or p1_wins == 0:
|
||||
if p1_wins == g or p1_wins == 0:
|
||||
ax.text(col, row, "×", ha="center", va="center",
|
||||
fontsize=5, color="black", fontweight="bold", zorder=3)
|
||||
|
||||
@@ -427,14 +618,26 @@ def draw_grid(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
GAMES_PER_MATCHUP = 10
|
||||
|
||||
difficulties = list(range(6, 11))
|
||||
difficulties = list(range(7, 11))
|
||||
|
||||
card_pool = get_simulation_cards()
|
||||
players = _all_players(difficulties)
|
||||
wins = asyncio.run(run_tournament(card_pool, games_per_matchup=GAMES_PER_MATCHUP, difficulties=difficulties))
|
||||
save_tournament(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players)
|
||||
draw_grid(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players)
|
||||
wins, played = asyncio.run(run_tournament_adaptive(
|
||||
card_pool,
|
||||
difficulties=difficulties,
|
||||
min_games=20,
|
||||
max_games=1000,
|
||||
p_decisive=0.65,
|
||||
alpha=0.05,
|
||||
))
|
||||
save_tournament(wins, players=players, played=played)
|
||||
|
||||
ratings = compute_bradley_terry(wins, len(players), played=played)
|
||||
ranked = sorted(range(len(players)), key=lambda i: ratings[i]) # worst-to-best
|
||||
draw_grid(wins, players=players, played=played, ranked=ranked)
|
||||
|
||||
print("\nFinal Elo ratings (best to worst):")
|
||||
for rank, i in enumerate(reversed(ranked), 1):
|
||||
personality, difficulty = players[i]
|
||||
label = _player_label(personality, difficulty)
|
||||
print(f" {rank:2d}. {label:<12} {ratings[i]:.1f}")
|
||||
|
||||
283
backend/trade_manager.py
Normal file
283
backend/trade_manager.py
Normal file
@@ -0,0 +1,283 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Card as CardModel, DeckCard as DeckCardModel
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
## Storage
|
||||
|
||||
@dataclass
|
||||
class TradeOffer:
|
||||
username: str
|
||||
cards: list[dict] = field(default_factory=list)
|
||||
accepted: bool = False
|
||||
|
||||
@dataclass
|
||||
class TradeSession:
|
||||
trade_id: str
|
||||
offers: dict[str, TradeOffer] # user_id -> TradeOffer
|
||||
connections: dict[str, WebSocket] = field(default_factory=dict)
|
||||
|
||||
active_trades: dict[str, TradeSession] = {}
|
||||
|
||||
@dataclass
|
||||
class TradeQueueEntry:
|
||||
user_id: str
|
||||
username: str
|
||||
websocket: WebSocket
|
||||
|
||||
trade_queue: list[TradeQueueEntry] = []
|
||||
trade_queue_lock = asyncio.Lock()
|
||||
|
||||
## Serialization
|
||||
|
||||
def serialize_card_model(card: CardModel) -> dict:
|
||||
return {
|
||||
"id": str(card.id),
|
||||
"name": card.name,
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type,
|
||||
"image_link": card.image_link,
|
||||
"attack": card.attack,
|
||||
"defense": card.defense,
|
||||
"cost": card.cost,
|
||||
"text": card.text,
|
||||
"created_at": card.created_at.isoformat() if card.created_at else None,
|
||||
}
|
||||
|
||||
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
|
||||
partner_id = next(uid for uid in session.offers if uid != perspective_user_id)
|
||||
my_offer = session.offers[perspective_user_id]
|
||||
their_offer = session.offers[partner_id]
|
||||
return {
|
||||
"trade_id": session.trade_id,
|
||||
"partner_username": their_offer.username,
|
||||
"my_offer": {
|
||||
"cards": my_offer.cards,
|
||||
"accepted": my_offer.accepted,
|
||||
},
|
||||
"their_offer": {
|
||||
"cards": their_offer.cards,
|
||||
"accepted": their_offer.accepted,
|
||||
},
|
||||
}
|
||||
|
||||
## Broadcasting
|
||||
|
||||
async def broadcast_trade(session: TradeSession) -> None:
|
||||
for user_id, ws in list(session.connections.items()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_trade(session, user_id),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
## Matchmaking
|
||||
|
||||
async def try_trade_match() -> None:
|
||||
async with trade_queue_lock:
|
||||
if len(trade_queue) < 2:
|
||||
return
|
||||
# Guard: same user queued twice
|
||||
if trade_queue[0].user_id == trade_queue[1].user_id:
|
||||
return
|
||||
p1 = trade_queue.pop(0)
|
||||
p2 = trade_queue.pop(0)
|
||||
|
||||
trade_id = str(uuid.uuid4())
|
||||
session = TradeSession(
|
||||
trade_id=trade_id,
|
||||
offers={
|
||||
p1.user_id: TradeOffer(username=p1.username),
|
||||
p2.user_id: TradeOffer(username=p2.username),
|
||||
},
|
||||
connections={
|
||||
p1.user_id: p1.websocket,
|
||||
p2.user_id: p2.websocket,
|
||||
},
|
||||
)
|
||||
active_trades[trade_id] = session
|
||||
|
||||
for entry in [p1, p2]:
|
||||
try:
|
||||
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
## Action handling
|
||||
|
||||
async def handle_trade_action(
|
||||
trade_id: str,
|
||||
user_id: str,
|
||||
message: dict,
|
||||
db: Session,
|
||||
) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
action = message.get("type")
|
||||
ws = session.connections.get(user_id)
|
||||
|
||||
if action == "update_offer":
|
||||
card_ids = message.get("card_ids", [])
|
||||
|
||||
if card_ids:
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in card_ids]
|
||||
except ValueError:
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Invalid card IDs"})
|
||||
return
|
||||
|
||||
db_cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(parsed_ids),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).all()
|
||||
|
||||
if len(db_cards) != len(card_ids):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some cards are not in your collection"})
|
||||
return
|
||||
|
||||
# Preserve the order of card_ids
|
||||
card_map = {str(c.id): c for c in db_cards}
|
||||
ordered = [card_map[cid] for cid in card_ids if cid in card_map]
|
||||
session.offers[user_id].cards = [serialize_card_model(c) for c in ordered]
|
||||
else:
|
||||
session.offers[user_id].cards = []
|
||||
|
||||
# Any offer change unaccepts both sides
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "accept":
|
||||
either_has_cards = any(len(o.cards) > 0 for o in session.offers.values())
|
||||
if not either_has_cards:
|
||||
return
|
||||
|
||||
# Validate ownership of offered cards one more time
|
||||
my_offer = session.offers[user_id]
|
||||
if my_offer.cards:
|
||||
owned_count = db.query(CardModel).filter(
|
||||
CardModel.id.in_([uuid.UUID(c["id"]) for c in my_offer.cards]),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).count()
|
||||
if owned_count != len(my_offer.cards):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some offered cards are no longer in your collection"})
|
||||
return
|
||||
|
||||
my_offer.accepted = True
|
||||
|
||||
if all(o.accepted for o in session.offers.values()):
|
||||
await _complete_trade(trade_id, db)
|
||||
else:
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "unaccept":
|
||||
session.offers[user_id].accepted = False
|
||||
await broadcast_trade(session)
|
||||
|
||||
## Trade completion
|
||||
|
||||
async def _complete_trade(trade_id: str, db: Session) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Re-check that both sides are still accepted and have a non-empty offer.
|
||||
# A last-second unaccept or offer change (race or client bug) should abort.
|
||||
if not all(o.accepted for o in session.offers.values()):
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
if not any(len(o.cards) > 0 for o in session.offers.values()):
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
user_ids = list(session.offers.keys())
|
||||
u1, u2 = user_ids[0], user_ids[1]
|
||||
cards_u1 = session.offers[u1].cards # u1 gives these to u2
|
||||
cards_u2 = session.offers[u2].cards # u2 gives these to u1
|
||||
|
||||
# Final ownership double-check before writing
|
||||
def verify(from_id: str, card_dicts: list[dict]) -> bool:
|
||||
if not card_dicts:
|
||||
return True
|
||||
card_uuids = [uuid.UUID(c["id"]) for c in card_dicts]
|
||||
count = db.query(CardModel).filter(
|
||||
CardModel.id.in_(card_uuids),
|
||||
CardModel.user_id == uuid.UUID(from_id),
|
||||
).count()
|
||||
return count == len(card_uuids)
|
||||
|
||||
if not verify(u1, cards_u1) or not verify(u2, cards_u2):
|
||||
db.rollback()
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
# Transfer ownership and clear deck relationships
|
||||
for cid_str in [c["id"] for c in cards_u1]:
|
||||
cid = uuid.UUID(cid_str)
|
||||
card = db.query(CardModel).filter(CardModel.id == cid).first()
|
||||
if card:
|
||||
card.user_id = uuid.UUID(u2)
|
||||
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
|
||||
|
||||
for cid_str in [c["id"] for c in cards_u2]:
|
||||
cid = uuid.UUID(cid_str)
|
||||
card = db.query(CardModel).filter(CardModel.id == cid).first()
|
||||
if card:
|
||||
card.user_id = uuid.UUID(u1)
|
||||
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
|
||||
|
||||
db.commit()
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({"type": "trade_complete"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
## Disconnect handling
|
||||
|
||||
async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for uid, ws in list(session.connections.items()):
|
||||
if uid == user_id:
|
||||
continue
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Your trade partner disconnected. Trade cancelled.",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
205
backend/train_nn.py
Normal file
205
backend/train_nn.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
import numpy as np
|
||||
from collections import deque
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from card import compute_deck_type
|
||||
from ai import AIPersonality, choose_cards, choose_plan
|
||||
from game import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
|
||||
from simulate import get_simulation_cards, _make_instances, MAX_TURNS
|
||||
from nn import NeuralNet, NeuralPlayer
|
||||
|
||||
NN_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
||||
|
||||
P1 = "p1"
|
||||
P2 = "p2"
|
||||
|
||||
FIXED_PERSONALITIES = [p for p in AIPersonality if p != AIPersonality.ARBITRARY]
|
||||
|
||||
|
||||
# ==================== Game runner ====================
|
||||
|
||||
def _build_player(pid: str, name: str, cards: list, difficulty: int, personality: AIPersonality) -> PlayerState:
|
||||
deck = choose_cards(cards, difficulty, personality)
|
||||
instances = _make_instances(deck)
|
||||
random.shuffle(instances)
|
||||
p = PlayerState(
|
||||
user_id=pid, username=name,
|
||||
deck_type=compute_deck_type(deck) or "Balanced",
|
||||
deck=instances,
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def run_episode(
|
||||
p1_state: PlayerState,
|
||||
p2_state: PlayerState,
|
||||
p1_ctrl, # (player, opponent) -> MovePlan
|
||||
p2_ctrl, # (player, opponent) -> MovePlan
|
||||
) -> str | None:
|
||||
"""Returns winner_id (P1 or P2) or None on timeout."""
|
||||
p1_state.increment_energy_cap()
|
||||
p2_state.increment_energy_cap()
|
||||
p1_state.refill_energy()
|
||||
p1_state.draw_to_full()
|
||||
|
||||
state = GameState(
|
||||
game_id=str(uuid.uuid4()),
|
||||
players={P1: p1_state, P2: p2_state},
|
||||
player_order=[P1, P2],
|
||||
active_player_id=P1,
|
||||
phase="main",
|
||||
turn=1,
|
||||
)
|
||||
ctrls = {P1: p1_ctrl, P2: p2_ctrl}
|
||||
|
||||
for _ in range(MAX_TURNS):
|
||||
if state.result:
|
||||
break
|
||||
active_id = state.active_player_id
|
||||
player = state.players[active_id]
|
||||
opponent = state.players[state.opponent_id(active_id)]
|
||||
|
||||
plan = ctrls[active_id](player, opponent)
|
||||
|
||||
for slot in plan.sacrifice_slots:
|
||||
if player.board[slot] is not None:
|
||||
action_sacrifice(state, slot)
|
||||
|
||||
plays = list(plan.plays)
|
||||
random.shuffle(plays)
|
||||
for card, slot in plays:
|
||||
hand_idx = next((i for i, c in enumerate(player.hand) if c is card), None)
|
||||
if hand_idx is None or player.board[slot] is not None or card.cost > player.energy:
|
||||
continue
|
||||
action_play_card(state, hand_idx, slot)
|
||||
|
||||
action_end_turn(state)
|
||||
|
||||
return state.result.winner_id if state.result else None
|
||||
|
||||
|
||||
# ==================== Training loop ====================
|
||||
|
||||
def train(
|
||||
n_episodes: int = 20_000,
|
||||
self_play_start: int = 5_000,
|
||||
self_play_max_frac: float = 0.4,
|
||||
lr: float = 1e-3,
|
||||
opp_difficulty: int = 10,
|
||||
temperature: float = 1.0,
|
||||
batch_size: int = 50,
|
||||
save_every: int = 5_000,
|
||||
save_path: str = NN_WEIGHTS_PATH,
|
||||
) -> NeuralNet:
|
||||
cards = get_simulation_cards()
|
||||
|
||||
if os.path.exists(save_path):
|
||||
print(f"Resuming from {save_path}")
|
||||
net = NeuralNet.load(save_path)
|
||||
else:
|
||||
print("Initializing new network")
|
||||
net = NeuralNet(seed=42)
|
||||
|
||||
recent_outcomes: deque[int] = deque(maxlen=1000) # rolling window for win rate display
|
||||
baseline = 0.0 # EMA of recent outcomes; subtracted before each update
|
||||
baseline_alpha = 0.99 # decay — roughly a 100-episode window
|
||||
|
||||
batch_gw = [np.zeros_like(w) for w in net.weights]
|
||||
batch_gb = [np.zeros_like(b) for b in net.biases]
|
||||
batch_count = 0
|
||||
|
||||
for episode in range(1, n_episodes + 1):
|
||||
# Ramp self-play fraction linearly from 0 to self_play_max_frac
|
||||
if episode >= self_play_start:
|
||||
progress = (episode - self_play_start) / max(1, n_episodes - self_play_start)
|
||||
self_play_prob = self_play_max_frac * progress
|
||||
else:
|
||||
self_play_prob = 0.0
|
||||
|
||||
# Randomly decide who goes first (NN is always P1 for simplicity)
|
||||
nn_goes_first = random.random() < 0.5
|
||||
|
||||
if random.random() < self_play_prob:
|
||||
# ---- Self-play ----
|
||||
nn1 = NeuralPlayer(net, training=True, temperature=temperature)
|
||||
nn2 = NeuralPlayer(net, training=True, temperature=temperature)
|
||||
|
||||
p1_state = _build_player(P1, "NN1", cards, 10, AIPersonality.BALANCED)
|
||||
p2_state = _build_player(P2, "NN2", cards, 10, AIPersonality.BALANCED)
|
||||
|
||||
if not nn_goes_first:
|
||||
p1_state, p2_state = p2_state, p1_state
|
||||
|
||||
winner = run_episode(p1_state, p2_state, nn1.choose_plan, nn2.choose_plan)
|
||||
p1_outcome = 1.0 if winner == P1 else -1.0
|
||||
baseline = baseline_alpha * baseline + (1 - baseline_alpha) * p1_outcome
|
||||
|
||||
for player_grads in [nn1.compute_grads(p1_outcome - baseline),
|
||||
nn2.compute_grads(-p1_outcome - baseline)]:
|
||||
if player_grads is not None:
|
||||
gw, gb = player_grads
|
||||
for i in range(len(batch_gw)):
|
||||
batch_gw[i] += gw[i]
|
||||
batch_gb[i] += gb[i]
|
||||
batch_count += 1
|
||||
|
||||
else:
|
||||
# ---- NN vs fixed opponent ----
|
||||
opp_personality = random.choice(FIXED_PERSONALITIES)
|
||||
nn_player = NeuralPlayer(net, training=True, temperature=temperature)
|
||||
opp_ctrl = lambda p, o, pers=opp_personality, diff=opp_difficulty: choose_plan(p, o, pers, diff)
|
||||
|
||||
if nn_goes_first:
|
||||
nn_id = P1
|
||||
p1_state = _build_player(P1, "NN", cards, 10, AIPersonality.BALANCED)
|
||||
p2_state = _build_player(P2, "OPP", cards, opp_difficulty, opp_personality)
|
||||
winner = run_episode(p1_state, p2_state, nn_player.choose_plan, opp_ctrl)
|
||||
else:
|
||||
nn_id = P2
|
||||
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality)
|
||||
p2_state = _build_player(P2, "NN", cards, 10, AIPersonality.BALANCED)
|
||||
winner = run_episode(p1_state, p2_state, opp_ctrl, nn_player.choose_plan)
|
||||
|
||||
nn_outcome = 1.0 if winner == nn_id else -1.0
|
||||
player_grads = nn_player.compute_grads(nn_outcome - baseline)
|
||||
baseline = baseline_alpha * baseline + (1 - baseline_alpha) * nn_outcome
|
||||
|
||||
if player_grads is not None:
|
||||
gw, gb = player_grads
|
||||
for i in range(len(batch_gw)):
|
||||
batch_gw[i] += gw[i]
|
||||
batch_gb[i] += gb[i]
|
||||
batch_count += 1
|
||||
|
||||
recent_outcomes.append(1 if winner == nn_id else 0)
|
||||
|
||||
if batch_count >= batch_size:
|
||||
for i in range(len(batch_gw)):
|
||||
batch_gw[i] /= batch_count
|
||||
batch_gb[i] /= batch_count
|
||||
net.adam_update(batch_gw, batch_gb, lr=lr)
|
||||
batch_gw = [np.zeros_like(w) for w in net.weights]
|
||||
batch_gb = [np.zeros_like(b) for b in net.biases]
|
||||
batch_count = 0
|
||||
|
||||
if episode % 1000 == 0 or episode == n_episodes:
|
||||
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
||||
print(f"[{episode:>6}/{n_episodes}] win rate (last {len(recent_outcomes)}): {wr:.1%} "
|
||||
f"self-play frac: {self_play_prob:.0%}", flush=True)
|
||||
|
||||
if episode % save_every == 0:
|
||||
net.save(save_path)
|
||||
print(f" → saved to {save_path}")
|
||||
|
||||
net.save(save_path)
|
||||
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
||||
print(f"Done. Final win rate (last {len(recent_outcomes)}): {wr:.1%}")
|
||||
return net
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
train()
|
||||
454
frontend/src/lib/CardSelector.svelte
Normal file
454
frontend/src/lib/CardSelector.svelte
Normal file
@@ -0,0 +1,454 @@
|
||||
<script>
|
||||
import Card from '$lib/Card.svelte';
|
||||
|
||||
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
let {
|
||||
allCards = [],
|
||||
selectedIds = $bindable(new Set()),
|
||||
onclose = null,
|
||||
costLimit = null, // if set, prevents selecting cards that would exceed it
|
||||
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
|
||||
} = $props();
|
||||
|
||||
const selectedCost = $derived(
|
||||
costLimit !== null
|
||||
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
function label(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
let sortBy = $state('name');
|
||||
let sortAsc = $state(true);
|
||||
let selectedRarities = $state(new Set(RARITIES));
|
||||
let selectedTypes = $state(new Set(TYPES));
|
||||
let costMin = $state(1);
|
||||
let costMax = $state(10);
|
||||
let filtersOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
);
|
||||
result = result.slice().sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
if (sortBy === val) sortAsc = !sortAsc;
|
||||
else { sortBy = val; sortAsc = true; }
|
||||
}
|
||||
|
||||
function toggleRarity(r) {
|
||||
const s = new Set(selectedRarities);
|
||||
s.has(r) ? s.delete(r) : s.add(r);
|
||||
selectedRarities = s;
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
const s = new Set(selectedTypes);
|
||||
s.has(t) ? s.delete(t) : s.add(t);
|
||||
selectedTypes = s;
|
||||
}
|
||||
|
||||
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
|
||||
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
|
||||
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
||||
|
||||
function toggleCard(id) {
|
||||
const s = new Set(selectedIds);
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
if (costLimit !== null) {
|
||||
const card = allCards.find(c => c.id === id);
|
||||
if (card && selectedCost + card.cost > costLimit) return;
|
||||
}
|
||||
s.add(id);
|
||||
}
|
||||
selectedIds = s;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="selector">
|
||||
{#if showFooter && onclose}
|
||||
<div class="top-bar">
|
||||
<span class="counter">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
|
||||
<button class="done-btn" onclick={onclose}>Done</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="toolbar">
|
||||
<div class="sort-row">
|
||||
<span class="toolbar-label">Sort by</span>
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||
{lbl}
|
||||
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search by name…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if filtersOpen}
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Rarity</span>
|
||||
<button class="select-all" onclick={toggleAllRarities}>{allRaritiesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
{#each RARITIES as r}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
|
||||
{label(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Type</span>
|
||||
<button class="select-all" onclick={toggleAllTypes}>{allTypesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
{#each TYPES as t}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
|
||||
{label(t)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Cost</span>
|
||||
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
|
||||
</div>
|
||||
<div class="cost-range">
|
||||
<span class="range-label">Min: {costMin}</span>
|
||||
<input type="range" min="1" max="10" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
|
||||
<span class="range-label">Max: {costMax}</span>
|
||||
<input type="range" min="1" max="10" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="status">No cards match your filters.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as card (card.id)}
|
||||
<button
|
||||
class="card-wrap"
|
||||
class:selected={selectedIds.has(card.id)}
|
||||
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
||||
onclick={() => toggleCard(card.id)}
|
||||
>
|
||||
<Card {card} noHover={true} />
|
||||
{#if selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
.selector {
|
||||
background: #0d0a04;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sort-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
padding-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.select-all:hover { color: #f0d080; }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 2rem 2rem 0;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
all: unset;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-wrap:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.card-wrap.selected {
|
||||
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
}
|
||||
|
||||
.card-wrap.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 23.875px;
|
||||
font-weight: 1000;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
background: #0d0a04;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
}
|
||||
|
||||
.done-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.done-btn:hover { background: #5a3510; }
|
||||
</style>
|
||||
@@ -9,7 +9,8 @@
|
||||
{ href: '/cards', label: 'Cards' },
|
||||
{ href: '/decks', label: 'Decks' },
|
||||
{ href: '/play', label: 'Play' },
|
||||
{ href: '/how-to-play', label: 'How to Play' },
|
||||
{ href: '/trade', label: 'Trade' },
|
||||
{ href: '/store', label: 'Store' },
|
||||
];
|
||||
|
||||
function close() { menuOpen = false; }
|
||||
|
||||
@@ -5,15 +5,35 @@
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))}
|
||||
<Header />
|
||||
{/if}
|
||||
const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>WikiTCG</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
<div class="layout">
|
||||
{#if showHeader}
|
||||
<Header />
|
||||
{/if}
|
||||
<div class="page-area">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
let loading = $state(false);
|
||||
let boosters = $state(null);
|
||||
let countdown = $state(null);
|
||||
let emailVerified = $state(true);
|
||||
let countdownDisplay = $state('');
|
||||
let countdownInterval = null;
|
||||
|
||||
@@ -27,9 +28,10 @@
|
||||
async function fetchBoosters() {
|
||||
const res = await apiFetch(`${API_URL}/boosters`);
|
||||
if (res.status === 401) { goto('/auth'); return; }
|
||||
const [count, countdownTs] = await res.json();
|
||||
boosters = count;
|
||||
countdown = countdownTs ? new Date(countdownTs) : null;
|
||||
const data = await res.json();
|
||||
boosters = data.count;
|
||||
countdown = data.countdown ? new Date(data.countdown) : null;
|
||||
emailVerified = data.email_verified;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
@@ -140,7 +142,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Idle pack -->
|
||||
{#if boosters !== null && boosters > 0 && phase === 'idle'}
|
||||
{#if !emailVerified}
|
||||
<p class="verify-notice">Verify your email to begin opening packs</p>
|
||||
{:else if boosters !== null && boosters > 0 && phase === 'idle'}
|
||||
<div class="pack-wrap" bind:this={packRef}>
|
||||
<button class="pack-btn" onclick={openPack}>
|
||||
<div class="booster-pack">
|
||||
@@ -234,6 +238,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verify-notice {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.55);
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.pack-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -30,14 +30,17 @@
|
||||
|
||||
let sortAsc = $state(true);
|
||||
let costMin = $state(1);
|
||||
let costMax = $state(11);
|
||||
let costMax = $state(10);
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let result = allCards.filter(c =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax
|
||||
c.cost <= costMax &&
|
||||
(!q || c.name.toLowerCase().includes(q))
|
||||
);
|
||||
|
||||
result = result.slice().sort((a, b) => {
|
||||
@@ -189,6 +192,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search by name…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||
@@ -236,14 +246,14 @@
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Cost</span>
|
||||
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
|
||||
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
|
||||
</div>
|
||||
<div class="cost-range">
|
||||
<span class="range-label">Min: {costMin}</span>
|
||||
<input type="range" min="1" max="11" bind:value={costMin}
|
||||
<input type="range" min="1" max="10" bind:value={costMin}
|
||||
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
|
||||
<span class="range-label">Max: {costMax}</span>
|
||||
<input type="range" min="1" max="11" bind:value={costMax}
|
||||
<input type="range" min="1" max="10" bind:value={costMax}
|
||||
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +337,22 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: #c8861a; }
|
||||
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
@@ -374,7 +400,7 @@
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
@@ -530,6 +556,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.card-popup {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
const deckId = $derived($page.params.id);
|
||||
const token = () => localStorage.getItem('token');
|
||||
@@ -17,79 +17,10 @@
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
|
||||
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
||||
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||
|
||||
let sortBy = $state('name');
|
||||
let sortAsc = $state(true);
|
||||
let selectedRarities = $state(new Set(RARITIES));
|
||||
let selectedTypes = $state(new Set(TYPES));
|
||||
let costMin = $state(1);
|
||||
let costMax = $state(11);
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
function label(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
let result = allCards.filter(c =>
|
||||
selectedRarities.has(c.card_rarity) &&
|
||||
selectedTypes.has(c.card_type) &&
|
||||
c.cost >= costMin &&
|
||||
c.cost <= costMax
|
||||
);
|
||||
result = result.slice().sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function toggleSort(val) {
|
||||
if (sortBy === val) sortAsc = !sortAsc;
|
||||
else { sortBy = val; sortAsc = true; }
|
||||
}
|
||||
|
||||
function toggleRarity(r) {
|
||||
const s = new Set(selectedRarities);
|
||||
s.has(r) ? s.delete(r) : s.add(r);
|
||||
selectedRarities = s;
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
const s = new Set(selectedTypes);
|
||||
s.has(t) ? s.delete(t) : s.add(t);
|
||||
selectedTypes = s;
|
||||
}
|
||||
|
||||
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
|
||||
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
|
||||
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||
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) {
|
||||
const s = new Set(selectedIds);
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
const card = allCards.find(c => c.id === id);
|
||||
if (card && selectedCost + card.cost > 50) return;
|
||||
s.add(id);
|
||||
}
|
||||
selectedIds = s;
|
||||
}
|
||||
|
||||
function startEditName() {
|
||||
nameInput = deckName;
|
||||
editingName = true;
|
||||
@@ -104,9 +35,7 @@
|
||||
saving = true;
|
||||
await apiFetch(`${API_URL}/decks/${deckId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: deckName,
|
||||
card_ids: [...selectedIds],
|
||||
@@ -130,7 +59,6 @@
|
||||
const currentCardIds = await deckCardsRes.json();
|
||||
selectedIds = new Set(currentCardIds);
|
||||
|
||||
// Get deck name
|
||||
const decksRes = await apiFetch(`${API_URL}/decks`);
|
||||
const decks = await decksRes.json();
|
||||
const deck = decks.find(d => d.id === deckId);
|
||||
@@ -164,92 +92,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sort-row">
|
||||
<span class="toolbar-label">Sort by</span>
|
||||
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||
{lbl}
|
||||
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 11}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if filtersOpen}
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Rarity</span>
|
||||
<button class="select-all" onclick={toggleAllRarities}>{allRaritiesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
{#each RARITIES as r}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
|
||||
{label(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Type</span>
|
||||
<button class="select-all" onclick={toggleAllTypes}>{allTypesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
{#each TYPES as t}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
|
||||
{label(t)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-header">
|
||||
<span class="filter-group-label">Cost</span>
|
||||
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
|
||||
</div>
|
||||
<div class="cost-range">
|
||||
<span class="range-label">Min: {costMin}</span>
|
||||
<input type="range" min="1" max="11" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
|
||||
<span class="range-label">Max: {costMax}</span>
|
||||
<input type="range" min="1" max="11" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Loading...</p>
|
||||
{:else if filtered.length === 0}
|
||||
<p class="status">No cards match your filters.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as card (card.id)}
|
||||
<button
|
||||
class="card-wrap"
|
||||
class:selected={selectedIds.has(card.id)}
|
||||
class:disabled={!selectedIds.has(card.id) && selectedCost + card.cost > 50}
|
||||
onclick={() => toggleCard(card.id)}
|
||||
>
|
||||
<Card {card} noHover={true} />
|
||||
{#if selectedIds.has(card.id)}
|
||||
<div class="selected-badge">✓</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectedIds}
|
||||
costLimit={50}
|
||||
showFooter={false}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -258,23 +111,17 @@
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
background: #0d0a04;
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
background: #0d0a04;
|
||||
padding-bottom: 1rem;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
margin-bottom: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.deck-header {
|
||||
@@ -348,187 +195,6 @@
|
||||
.done-btn:hover:not(:disabled) { background: #5a3510; }
|
||||
.done-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Reuse cards page toolbar styles */
|
||||
.sort-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
||||
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
||||
|
||||
.filter-toggle {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #c8861a;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
padding-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
|
||||
|
||||
.filter-group-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
}
|
||||
|
||||
.select-all {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.select-all:hover { color: #f0d080; }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: #c8861a;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
|
||||
.range-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
accent-color: #c8861a;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
/* Grid + selection */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
all: unset;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.card-wrap:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.card-wrap.selected {
|
||||
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
}
|
||||
|
||||
.card-wrap.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 23.875px;
|
||||
font-weight: 1000;
|
||||
padding: 4px 10px;
|
||||
border-radius: 23px;
|
||||
border: black 3px solid;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
@@ -537,4 +203,4 @@
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<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>
|
||||
<p class="rule-body">There is no minimum or maximum number of cards. On the extreme ends, you can have just 5 10-cost cards, or 50 1-cost cards.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
{#if phase === 'idle'}
|
||||
<div class="lobby">
|
||||
<h1 class="lobby-title">Find a Match</h1>
|
||||
<a href="/how-to-play" class="how-to-play-link">How to Play</a>
|
||||
{#if decks.length === 0}
|
||||
<p class="lobby-hint">You need a deck to play. <a href="/decks">Build one first.</a></p>
|
||||
{:else}
|
||||
@@ -538,6 +539,18 @@
|
||||
.lobby-title.win { color: #6aaa6a; }
|
||||
.lobby-title.lose { color: #c85050; }
|
||||
|
||||
.how-to-play-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
margin-top: -1rem;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.how-to-play-link:hover { color: rgba(240, 180, 80, 0.7); }
|
||||
|
||||
.lobby-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
@@ -643,6 +656,8 @@
|
||||
color: #c85050;
|
||||
margin: 0;
|
||||
height: 1.4em;
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
localStorage.removeItem('refresh_token');
|
||||
goto('/auth');
|
||||
}
|
||||
|
||||
let resendStatus = $state('');
|
||||
|
||||
async function resendVerification() {
|
||||
resendStatus = 'sending';
|
||||
await apiFetch(`${API_URL}/auth/resend-verification`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: profile.email }),
|
||||
});
|
||||
resendStatus = 'sent';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
@@ -34,7 +46,17 @@
|
||||
<div class="avatar">{profile.username[0].toUpperCase()}</div>
|
||||
<div class="profile-info">
|
||||
<h1 class="username">{profile.username}</h1>
|
||||
<p class="email">{profile.email}</p>
|
||||
<p class="email">
|
||||
{profile.email}
|
||||
{#if !profile.email_verified}
|
||||
<span class="unverified-badge">unverified</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if !profile.email_verified}
|
||||
<button class="resend-btn" onclick={resendVerification} disabled={resendStatus === 'sending' || resendStatus === 'sent'}>
|
||||
{resendStatus === 'sent' ? 'Email sent' : resendStatus === 'sending' ? 'Sending...' : 'Resend verification email'}
|
||||
</button>
|
||||
{/if}
|
||||
<p class="joined">Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||
</div>
|
||||
<button class="logout-btn" onclick={logout}>Log Out</button>
|
||||
@@ -43,6 +65,15 @@
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<div class="shards-row">
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-value">{profile.shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
<a href="/shards" class="shards-link">shatter cards</a>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<h2 class="section-title">Stats</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
@@ -164,6 +195,37 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unverified-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #c87830;
|
||||
border: 1px solid rgba(200, 120, 48, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.resend-btn {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.resend-btn:hover:not(:disabled) { color: rgba(240, 180, 80, 0.8); }
|
||||
.resend-btn:disabled { cursor: default; opacity: 0.6; }
|
||||
|
||||
.joined {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 13px;
|
||||
@@ -206,6 +268,53 @@
|
||||
|
||||
.reset-link:hover { color: rgba(240, 180, 80, 0.7); }
|
||||
|
||||
.shards-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shards-link {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.6);
|
||||
border: 1px solid rgba(126, 207, 207, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.shards-link:hover { color: #7ecfcf; border-color: rgba(126, 207, 207, 0.7); }
|
||||
|
||||
.shards-icon {
|
||||
font-size: 22px;
|
||||
color: #7ecfcf;
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.shards-value {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.5);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: rgba(107, 76, 30, 0.3);
|
||||
|
||||
288
frontend/src/routes/shards/+page.svelte
Normal file
288
frontend/src/routes/shards/+page.svelte
Normal file
@@ -0,0 +1,288 @@
|
||||
<script>
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
let allCards = $state([]);
|
||||
let shards = $state(null);
|
||||
let selectedIds = $state(new Set());
|
||||
let selectorOpen = $state(false);
|
||||
let shattering = $state(false);
|
||||
let result = $state(null); // { gained, shards }
|
||||
|
||||
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
|
||||
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
|
||||
|
||||
onMount(async () => {
|
||||
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||
const [cardsRes, profileRes] = await Promise.all([
|
||||
apiFetch(`${API_URL}/cards`),
|
||||
apiFetch(`${API_URL}/profile`),
|
||||
]);
|
||||
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||
allCards = await cardsRes.json();
|
||||
const profile = await profileRes.json();
|
||||
shards = profile.shards;
|
||||
});
|
||||
|
||||
async function shatter() {
|
||||
if (selectedIds.size === 0 || shattering) return;
|
||||
shattering = true;
|
||||
const res = await apiFetch(`${API_URL}/shards/shatter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_ids: [...selectedIds] }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
shards = data.shards;
|
||||
result = { gained: data.gained };
|
||||
allCards = allCards.filter(c => !selectedIds.has(c.id));
|
||||
selectedIds = new Set();
|
||||
}
|
||||
shattering = false;
|
||||
}
|
||||
|
||||
function dismissResult() { result = null; }
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="top">
|
||||
<h1 class="page-title">Shards</h1>
|
||||
{#if shards !== null}
|
||||
<div class="shards-display">
|
||||
<span class="shards-icon">◈</span>
|
||||
<span class="shards-amount">{shards}</span>
|
||||
<span class="shards-label">Shards</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="explainer">
|
||||
Shatter cards you no longer need to recover shards equal to their cost.
|
||||
Shattered cards are permanently destroyed.
|
||||
</p>
|
||||
<p class="store-hint">You can also <a href="/store#buy-shards" class="store-link">buy shards</a> in the store.</p>
|
||||
</div>
|
||||
|
||||
{#if result}
|
||||
<div class="result-banner">
|
||||
<span class="result-icon">◈</span>
|
||||
+{result.gained} shards gained
|
||||
<button class="dismiss" onclick={dismissResult}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="action-area">
|
||||
<button class="select-btn" onclick={() => { selectorOpen = true; }}>
|
||||
{selectedIds.size === 0 ? 'Select cards to shatter' : `${selectedIds.size} card${selectedIds.size === 1 ? '' : 's'} selected`}
|
||||
</button>
|
||||
|
||||
{#if selectedIds.size > 0}
|
||||
<button
|
||||
class="shatter-btn"
|
||||
onclick={shatter}
|
||||
disabled={shattering}
|
||||
>
|
||||
{shattering ? 'Shattering...' : `Shatter for ◈ ${totalYield}`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectedIds}
|
||||
onclose={() => { selectorOpen = false; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 900;
|
||||
color: #f0d080;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shards-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.shards-icon {
|
||||
font-size: 20px;
|
||||
color: #7ecfcf;
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.shards-amount {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #7ecfcf;
|
||||
}
|
||||
|
||||
.shards-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(126, 207, 207, 0.5);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.explainer {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.store-hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-link {
|
||||
color: #7ecfcf;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.store-link:hover { color: #a8e8e8; }
|
||||
|
||||
/* ── Result banner ── */
|
||||
.result-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #0d2a0d;
|
||||
border: 1.5px solid #6aaa6a;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #6aaa6a;
|
||||
}
|
||||
|
||||
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
|
||||
.dismiss {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(106, 170, 106, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 0 0 0.75rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.dismiss:hover { color: #6aaa6a; }
|
||||
|
||||
/* ── Action area ── */
|
||||
.action-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.select-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
padding: 10px 24px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-btn:hover { background: #5a3510; }
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-count { color: #f0d080; }
|
||||
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
|
||||
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
|
||||
|
||||
.shatter-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #7ecfcf;
|
||||
border-radius: 6px;
|
||||
color: #7ecfcf;
|
||||
padding: 10px 24px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.shatter-btn:hover:not(:disabled) { background: #0d2a2a; }
|
||||
.shatter-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Card selector overlay ── */
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
1048
frontend/src/routes/store/+page.svelte
Normal file
1048
frontend/src/routes/store/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
560
frontend/src/routes/trade/+page.svelte
Normal file
560
frontend/src/routes/trade/+page.svelte
Normal file
@@ -0,0 +1,560 @@
|
||||
<script>
|
||||
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Card from '$lib/Card.svelte';
|
||||
import CardSelector from '$lib/CardSelector.svelte';
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
let phase = $state('idle'); // idle | queuing | trading | complete
|
||||
let error = $state('');
|
||||
|
||||
let queueWs = null;
|
||||
let tradeWs = null;
|
||||
let tradeId = $state('');
|
||||
|
||||
let allCards = $state([]); // user's full card collection (for selector)
|
||||
let tradeState = $state(null); // latest trade state from server
|
||||
|
||||
let selectorOpen = $state(false);
|
||||
let selectorIds = $state(new Set());
|
||||
|
||||
let myOffer = $derived(tradeState?.my_offer ?? { cards: [], accepted: false });
|
||||
let theirOffer = $derived(tradeState?.their_offer ?? { cards: [], accepted: false });
|
||||
let partnerUsername = $derived(tradeState?.partner_username ?? '');
|
||||
|
||||
let eitherHasCards = $derived(myOffer.cards.length > 0 || theirOffer.cards.length > 0);
|
||||
|
||||
// disabled | ready | accepted
|
||||
let acceptState = $derived(
|
||||
!eitherHasCards ? 'disabled' :
|
||||
myOffer.accepted ? 'accepted' :
|
||||
'ready'
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
if (!token()) { goto('/auth'); return; }
|
||||
const res = await apiFetch(`${API_URL}/cards`);
|
||||
if (!res.ok) { goto('/auth'); return; }
|
||||
allCards = await res.json();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
queueWs?.close();
|
||||
tradeWs?.close();
|
||||
});
|
||||
|
||||
function joinQueue() {
|
||||
error = '';
|
||||
phase = 'queuing';
|
||||
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
|
||||
queueWs.onopen = () => queueWs.send(token());
|
||||
queueWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'trade_start') {
|
||||
tradeId = msg.trade_id;
|
||||
queueWs.close();
|
||||
connectToTrade();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
phase = 'idle';
|
||||
}
|
||||
};
|
||||
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
|
||||
}
|
||||
|
||||
function cancelQueue() {
|
||||
queueWs?.close();
|
||||
phase = 'idle';
|
||||
}
|
||||
|
||||
function connectToTrade() {
|
||||
phase = 'trading';
|
||||
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
|
||||
tradeWs.onopen = () => tradeWs.send(token());
|
||||
tradeWs.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') {
|
||||
tradeState = msg.state;
|
||||
} else if (msg.type === 'trade_complete') {
|
||||
phase = 'complete';
|
||||
tradeWs?.close();
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
setTimeout(() => { if (phase !== 'idle') error = ''; }, 4000);
|
||||
if (msg.message.includes('disconnected')) {
|
||||
phase = 'idle';
|
||||
tradeState = null;
|
||||
tradeWs?.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
|
||||
tradeWs.onclose = (e) => {
|
||||
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
|
||||
};
|
||||
}
|
||||
|
||||
function openSelector() {
|
||||
if (myOffer.accepted) {
|
||||
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
|
||||
}
|
||||
selectorIds = new Set(myOffer.cards.map(c => c.id));
|
||||
selectorOpen = true;
|
||||
}
|
||||
|
||||
function closeSelector() {
|
||||
selectorOpen = false;
|
||||
tradeWs?.send(JSON.stringify({
|
||||
type: 'update_offer',
|
||||
card_ids: [...selectorIds],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleAccept() {
|
||||
if (acceptState === 'disabled' || acceptState === 'accepted') return;
|
||||
tradeWs?.send(JSON.stringify({ type: 'accept' }));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
phase = 'idle';
|
||||
tradeState = null;
|
||||
tradeId = '';
|
||||
error = '';
|
||||
selectorOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if phase === 'idle'}
|
||||
<div class="center-screen">
|
||||
<h1 class="title">Trade</h1>
|
||||
<p class="subtitle">Exchange cards with another player</p>
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
<button class="primary-btn" onclick={joinQueue}>Find Trade Partner</button>
|
||||
</div>
|
||||
|
||||
{:else if phase === 'queuing'}
|
||||
<div class="center-screen">
|
||||
<div class="spinner"></div>
|
||||
<p class="searching-text">Searching for a trade partner...</p>
|
||||
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{:else if phase === 'trading'}
|
||||
<div class="trade-layout">
|
||||
{#if selectorOpen}
|
||||
<div class="selector-overlay">
|
||||
<CardSelector
|
||||
allCards={allCards}
|
||||
bind:selectedIds={selectorIds}
|
||||
onclose={closeSelector}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="trade-panels">
|
||||
<div class="panel your-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Your Offer</span>
|
||||
{#if myOffer.accepted}
|
||||
<span class="accepted-badge">Accepted ✓</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if myOffer.cards.length === 0}
|
||||
<div class="empty-offer">
|
||||
<p>No cards offered yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each myOffer.cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="panel their-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
|
||||
{#if theirOffer.accepted}
|
||||
<span class="accepted-badge">Accepted ✓</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-cards">
|
||||
{#if theirOffer.cards.length === 0}
|
||||
<div class="empty-offer">
|
||||
<p>No cards offered yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-scroll">
|
||||
{#each theirOffer.cards as card (card.id)}
|
||||
<div class="card-wrap">
|
||||
<Card {card} noHover={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
|
||||
|
||||
{#if error}
|
||||
<span class="error-inline">{error}</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="accept-btn"
|
||||
class:accept-ready={acceptState === 'ready'}
|
||||
class:accept-accepted={acceptState === 'accepted'}
|
||||
class:accept-disabled={acceptState === 'disabled'}
|
||||
disabled={acceptState === 'disabled' || acceptState === 'accepted'}
|
||||
onclick={handleAccept}
|
||||
>
|
||||
{#if acceptState === 'accepted'}
|
||||
Accepted ✓
|
||||
{:else if acceptState === 'disabled'}
|
||||
Accept Trade
|
||||
{:else}
|
||||
Accept Trade
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if phase === 'complete'}
|
||||
<div class="center-screen">
|
||||
<div class="complete-icon">⇄</div>
|
||||
<h1 class="title">Trade Complete!</h1>
|
||||
<p class="subtitle">Your cards have been exchanged.</p>
|
||||
<button class="primary-btn" onclick={reset}>Trade Again</button>
|
||||
<a href="/cards" class="secondary-link">View Your Cards</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
height: calc(100vh - 56px);
|
||||
background: #0d0a04;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Center screens (idle, queuing, complete) ── */
|
||||
|
||||
.center-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #f0d080;
|
||||
margin: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: #c85050;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #3d2507;
|
||||
border: 1px solid #c8861a;
|
||||
border-radius: 4px;
|
||||
color: #f0d080;
|
||||
padding: 10px 28px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.primary-btn:hover { background: #5a3510; }
|
||||
|
||||
.cancel-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
padding: 6px 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||
|
||||
.searching-text {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||
border-top-color: #c8861a;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.complete-icon {
|
||||
font-size: 56px;
|
||||
color: #6aaa6a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.secondary-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.5);
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.secondary-link:hover { color: #f0d080; }
|
||||
|
||||
/* ── Trade layout ── */
|
||||
|
||||
.trade-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trade-panels {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 180, 80, 0.7);
|
||||
}
|
||||
|
||||
.accepted-badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6aaa6a;
|
||||
background: rgba(106, 170, 106, 0.12);
|
||||
border: 1px solid rgba(106, 170, 106, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.panel-cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.empty-offer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-offer p {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
color: rgba(240, 180, 80, 0.25);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-scroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
flex-shrink: 0;
|
||||
transform: scale(0.82);
|
||||
transform-origin: top center;
|
||||
margin-bottom: -42px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
background: rgba(107, 76, 30, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Action bar ── */
|
||||
|
||||
.action-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(107, 76, 30, 0.35);
|
||||
background: #0d0a04;
|
||||
}
|
||||
|
||||
.choose-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: #1e1208;
|
||||
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||
border-radius: 4px;
|
||||
color: rgba(240, 180, 80, 0.8);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
|
||||
|
||||
.error-inline {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #c85050;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 10px 28px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Disabled: grayed out, no interaction */
|
||||
.accept-btn.accept-disabled {
|
||||
background: rgba(30, 18, 8, 0.5);
|
||||
border: 1px solid rgba(107, 76, 30, 0.25);
|
||||
color: rgba(240, 180, 80, 0.2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Ready: gold, inviting click */
|
||||
.accept-btn.accept-ready {
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
color: #f0d080;
|
||||
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
||||
}
|
||||
|
||||
.accept-btn.accept-ready:hover {
|
||||
background: #5a3510;
|
||||
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
|
||||
}
|
||||
|
||||
/* Accepted: bright green, pulsing, waiting */
|
||||
.accept-btn.accept-accepted {
|
||||
background: rgba(40, 90, 40, 0.4);
|
||||
border: 2px solid #6aaa6a;
|
||||
color: #6aaa6a;
|
||||
cursor: default;
|
||||
animation: pulse-green 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-green {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(106, 170, 106, 0.3); }
|
||||
50% { box-shadow: 0 0 20px rgba(106, 170, 106, 0.6); }
|
||||
}
|
||||
|
||||
.selector-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trade-panels { flex-direction: column; }
|
||||
.divider { width: 100%; height: 1px; }
|
||||
.card-wrap { transform: scale(0.7); margin-bottom: -60px; }
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/routes/verify-email/+page.svelte
Normal file
108
frontend/src/routes/verify-email/+page.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { API_URL } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let status = $state('verifying'); // 'verifying' | 'success' | 'error'
|
||||
let errorMessage = $state('');
|
||||
|
||||
const token = $derived($page.url.searchParams.get('token') ?? '');
|
||||
|
||||
onMount(async () => {
|
||||
if (!token) {
|
||||
status = 'error';
|
||||
errorMessage = 'No verification token found in this link.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||
if (res.ok) {
|
||||
status = 'success';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
status = 'error';
|
||||
errorMessage = data.detail ?? 'Verification failed.';
|
||||
}
|
||||
} catch {
|
||||
status = 'error';
|
||||
errorMessage = 'Something went wrong. Please try again.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
{#if status === 'verifying'}
|
||||
<h1 class="title">Verifying...</h1>
|
||||
<p class="hint">Please wait while we verify your email.</p>
|
||||
{:else if status === 'success'}
|
||||
<h1 class="title">Email Verified</h1>
|
||||
<p class="hint">Your email has been confirmed. You can now open packs and trade cards.</p>
|
||||
<button class="btn" onclick={() => goto('/')}>Start Playing</button>
|
||||
{:else}
|
||||
<h1 class="title">Verification Failed</h1>
|
||||
<p class="hint">{errorMessage}</p>
|
||||
<p class="hint">Your link may have expired. You can request a new one from your profile.</p>
|
||||
<button class="btn" onclick={() => goto('/profile')}>Go to Profile</button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { background: #e09820; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user