This commit is contained in:
2026-03-26 00:51:25 +01:00
parent 99db0b3c67
commit ef4496aa5d
31 changed files with 4185 additions and 452 deletions

View File

@@ -4,4 +4,7 @@ RESEND_API_KEY= # from resend.com dashboard
EMAIL_FROM= # e.g. noreply@yourdomain.com EMAIL_FROM= # e.g. noreply@yourdomain.com
FRONTEND_URL= # e.g. https://yourdomain.com FRONTEND_URL= # e.g. https://yourdomain.com
CORS_ORIGINS= # comma-separated, 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

View File

@@ -19,16 +19,17 @@ class AIPersonality(Enum):
GREEDY = "greedy" # Prioritizes high cost cards, willing to sacrifice GREEDY = "greedy" # Prioritizes high cost cards, willing to sacrifice
SWARM = "swarm" # Prefers low cost cards, fills board quickly SWARM = "swarm" # Prefers low cost cards, fills board quickly
CONTROL = "control" # Focuses on board control and efficiency 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 ARBITRARY = "arbitrary" # Just does whatever
JEBRASKA = "jebraska" # Trained neural network plan scorer
def get_random_personality() -> AIPersonality: def get_random_personality() -> AIPersonality:
"""Returns a random AI personality.""" """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: def calculate_exact_cost(attack: int, defense: int) -> float:
"""Calculate the exact cost before rounding (matches card.py formula).""" """Calculate the exact cost before rounding (matches card.py formula)."""
return min(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: 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) defn = np.array([c.defense for c in allowed], dtype=np.float32)
cost = np.array([c.cost 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) pcv_norm = np.clip(exact_cost - cost, 0.0, 1.0)
cost_norm = cost / max_card_cost cost_norm = cost / max_card_cost
totals = atk + defn totals = atk + defn
@@ -78,21 +79,14 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
elif personality == AIPersonality.CONTROL: elif personality == AIPersonality.CONTROL:
# Small cost_norm keeps flavour without causing severe deck shrinkage at D10 # Small cost_norm keeps flavour without causing severe deck shrinkage at D10
scores = 0.85 * pcv_norm + 0.15 * cost_norm 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) 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 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) 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. # 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,12 - difficulty)**2) * 0.008
noise = max(0.03, (10 - difficulty) / 9.0) * 0.50
scores = scores + np.random.normal(0, noise, len(allowed)).astype(np.float32) scores = scores + np.random.normal(0, noise, len(allowed)).astype(np.float32)
order = np.argsort(-scores) 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.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels
AIPersonality.CONTROL: 8, AIPersonality.CONTROL: 8,
AIPersonality.BALANCED: 25, # spread the deck across all cost levels 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, AIPersonality.ARBITRARY: 8,
}[personality] }[personality]
@@ -303,15 +297,11 @@ def score_plans_batch(
score = (0.12 * atk_score + 0.13 * block_score + 0.15 * cover_score + score = (0.12 * atk_score + 0.13 * block_score + 0.15 * cover_score +
0.10 * net_value_norm + 0.12 * destroy_score + 0.10 * net_value_norm + 0.12 * destroy_score +
0.15 * attrition_score + 0.12 * pcv_score + 0.11 * threat_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 else: # ARBITRARY
score = (0.60 * np.random.random(n).astype(np.float32) + score = (0.50 * np.random.random(n).astype(np.float32) +
0.05 * atk_score + 0.05 * block_score + 0.05 * cover_score + 0.06 * atk_score + 0.06 * block_score + 0.08 * cover_score +
0.05 * net_value_norm + 0.05 * destroy_score + 0.05 * net_value_norm + 0.06 * destroy_score +
0.05 * attrition_score + 0.05 * pcv_score + 0.05 * threat_score) 0.08 * attrition_score + 0.06 * pcv_score + 0.05 * threat_score)
# --- Context adjustments --- # --- Context adjustments ---
score = np.where(direct_damage >= opponent.life, np.maximum(score, 0.95), score) 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) 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) 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) noise = np.random.normal(0, noise_scale, len(scores)).astype(np.float32)
return plans[int(np.argmax(scores + noise))] return plans[int(np.argmax(scores + noise))]
@@ -388,7 +391,7 @@ async def run_ai_turn(game_id: str):
pass pass
# --- Generate and score candidate plans --- # --- Generate and score candidate plans ---
best_plan = await choose_plan(player, opponent, personality, difficulty) best_plan = choose_plan(player, opponent, personality, difficulty)
logger.info( logger.info(
f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} " + f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} " +

View 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')

View File

@@ -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')

View File

@@ -113,6 +113,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q734698": CardType.artwork, # collectible card game "Q734698": CardType.artwork, # collectible card game
"Q506240": CardType.artwork, # television film "Q506240": CardType.artwork, # television film
"Q738377": CardType.artwork, # student newspaper "Q738377": CardType.artwork, # student newspaper
"Q2031291": CardType.artwork, # musical release
"Q1259759": CardType.artwork, # miniseries "Q1259759": CardType.artwork, # miniseries
"Q3305213": CardType.artwork, # painting "Q3305213": CardType.artwork, # painting
"Q3177859": CardType.artwork, # dedicated deck card game "Q3177859": CardType.artwork, # dedicated deck card game
@@ -121,6 +122,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q1761818": CardType.artwork, # advertising campaign "Q1761818": CardType.artwork, # advertising campaign
"Q1446621": CardType.artwork, # recital "Q1446621": CardType.artwork, # recital
"Q1868552": CardType.artwork, # local newspaper "Q1868552": CardType.artwork, # local newspaper
"Q3244175": CardType.artwork, # tabletop game
"Q63952888": CardType.artwork, # anime television series "Q63952888": CardType.artwork, # anime television series
"Q47461344": CardType.artwork, # written work "Q47461344": CardType.artwork, # written work
"Q71631512": CardType.artwork, # tabletop role-playing game supplement "Q71631512": CardType.artwork, # tabletop role-playing game supplement
@@ -151,11 +153,14 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q7930989": CardType.location, # city/town "Q7930989": CardType.location, # city/town
"Q1250464": CardType.location, # realm "Q1250464": CardType.location, # realm
"Q3146899": CardType.location, # diocese of the catholic church "Q3146899": CardType.location, # diocese of the catholic church
"Q17350442": CardType.location, # venue
"Q23764314": CardType.location, # sports location
"Q12076836": CardType.location, # administrative territorial entity of a single country "Q12076836": CardType.location, # administrative territorial entity of a single country
"Q35145263": CardType.location, # natural geographic object "Q35145263": CardType.location, # natural geographic object
"Q15642541": CardType.location, # human-geographic territorial entity "Q15642541": CardType.location, # human-geographic territorial entity
"Q16521": CardType.life_form, # taxon "Q16521": CardType.life_form, # taxon
"Q38829": CardType.life_form, # breed
"Q310890": CardType.life_form, # monotypic taxon "Q310890": CardType.life_form, # monotypic taxon
"Q23038290": CardType.life_form, # fossil taxon "Q23038290": CardType.life_form, # fossil taxon
"Q12045585": CardType.life_form, # cattle breed "Q12045585": CardType.life_form, # cattle breed
@@ -183,6 +188,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q15275719": CardType.event, # recurring event "Q15275719": CardType.event, # recurring event
"Q27968055": CardType.event, # recurring event edition "Q27968055": CardType.event, # recurring event edition
"Q15091377": CardType.event, # cycling race "Q15091377": CardType.event, # cycling race
"Q87267404": CardType.event, # formula race
"Q114609228": CardType.event, # recurring sporting event "Q114609228": CardType.event, # recurring sporting event
"Q7278": CardType.group, # political party "Q7278": CardType.group, # political party
@@ -225,15 +231,23 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q134808": CardType.science_thing, # vaccine "Q134808": CardType.science_thing, # vaccine
"Q168845": CardType.science_thing, # star cluster "Q168845": CardType.science_thing, # star cluster
"Q1491746": CardType.science_thing, # galaxy group "Q1491746": CardType.science_thing, # galaxy group
"Q2465832": CardType.science_thing, # branch of science
"Q1341811": CardType.science_thing, # astronomical maser "Q1341811": CardType.science_thing, # astronomical maser
"Q1840368": CardType.science_thing, # cloud type "Q1840368": CardType.science_thing, # cloud type
"Q2154519": CardType.science_thing, # astrophysical x-ray source "Q2154519": CardType.science_thing, # astrophysical x-ray source
"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 "Q17444909": CardType.science_thing, # astronomical object type
"Q24034552": CardType.science_thing, # mathematical concept
"Q12089225": CardType.science_thing, # mineral species "Q12089225": CardType.science_thing, # mineral species
"Q55640599": CardType.science_thing, # group of chemical entities "Q55640599": CardType.science_thing, # group of chemical entities
"Q119459661": CardType.science_thing, # scientific activity
"Q113145171": CardType.science_thing, # type of chemical entity "Q113145171": CardType.science_thing, # type of chemical entity
"Q1420": CardType.vehicle, # car "Q1420": CardType.vehicle, # car
"Q42889": CardType.vehicle, # vehicle
"Q11446": CardType.vehicle, # ship "Q11446": CardType.vehicle, # ship
"Q43193": CardType.vehicle, # truck "Q43193": CardType.vehicle, # truck
"Q25956": CardType.vehicle, # space station "Q25956": CardType.vehicle, # space station
@@ -252,12 +266,15 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q928235": CardType.vehicle, # sloop-of-war "Q928235": CardType.vehicle, # sloop-of-war
"Q391022": CardType.vehicle, # research vessel "Q391022": CardType.vehicle, # research vessel
"Q202527": CardType.vehicle, # minesweeper "Q202527": CardType.vehicle, # minesweeper
"Q1229765": CardType.vehicle, # watercraft
"Q2031121": CardType.vehicle, # warship
"Q1185562": CardType.vehicle, # light aircraft carrier "Q1185562": CardType.vehicle, # light aircraft carrier
"Q7233751": CardType.vehicle, # post ship "Q7233751": CardType.vehicle, # post ship
"Q3231690": CardType.vehicle, # automobile model "Q3231690": CardType.vehicle, # automobile model
"Q1428357": CardType.vehicle, # submarine class "Q1428357": CardType.vehicle, # submarine class
"Q1499623": CardType.vehicle, # destroyer escort "Q1499623": CardType.vehicle, # destroyer escort
"Q4818021": CardType.vehicle, # attack submarine "Q4818021": CardType.vehicle, # attack submarine
"Q15141321": CardType.vehicle, # train service
"Q19832486": CardType.vehicle, # locomotive class "Q19832486": CardType.vehicle, # locomotive class
"Q23866334": CardType.vehicle, # motorcycle model "Q23866334": CardType.vehicle, # motorcycle model
"Q29048322": CardType.vehicle, # vehicle model "Q29048322": CardType.vehicle, # vehicle model
@@ -267,8 +284,12 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q43229": CardType.organization, # organization "Q43229": CardType.organization, # organization
"Q47913": CardType.organization, # intelligence agency "Q47913": CardType.organization, # intelligence agency
"Q35535": CardType.organization, # police "Q35535": CardType.organization, # police
"Q740752": CardType.organization, # transport company
"Q4830453": CardType.organization, # business "Q4830453": CardType.organization, # business
"Q4671277": CardType.organization, # academic institution "Q4671277": CardType.organization, # academic institution
"Q2659904": CardType.organization, # government organization
"Q686822": CardType.other, # bill (written work)
} }
import asyncio 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: if superclass_qid2 in WIKIDATA_INSTANCE_TYPE_MAP:
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count
# Fallback: coordinate location # Fallback: classify by presence of specific claims
if "P625" in claims: CLAIMS_FALLBACK = {
return CardType.location, (qids[0] if qids else ""), language_count "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 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, text=text,
attack=attack, attack=attack,
defense=defense, 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]: async def _get_cards_async(size: int) -> list[Card]:

View File

@@ -14,6 +14,9 @@ JWT_SECRET_KEY = require("JWT_SECRET_KEY")
DATABASE_URL = require("DATABASE_URL") DATABASE_URL = require("DATABASE_URL")
RESEND_API_KEY = require("RESEND_API_KEY") RESEND_API_KEY = require("RESEND_API_KEY")
EMAIL_FROM = require("EMAIL_FROM") 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 # Optional with sensible defaults for local dev
FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173") FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173")

View File

@@ -68,8 +68,9 @@ BOOSTER_COOLDOWN_HOURS = 5
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]: def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
if user.boosters_countdown is None: if user.boosters_countdown is None:
user.boosters = 5 if user.boosters < BOOSTER_MAX:
db.commit() user.boosters = BOOSTER_MAX
db.commit()
return (user.boosters, user.boosters_countdown) return (user.boosters, user.boosters_countdown)
now = datetime.now() now = datetime.now()

View File

@@ -2,6 +2,31 @@ import resend
import os import os
from config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL 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): def send_password_reset_email(to_email: str, username: str, reset_token: str):
resend.api_key = RESEND_API_KEY resend.api_key = RESEND_API_KEY
reset_url = f"{FRONTEND_URL}/forgot-password/reset?token={reset_token}" reset_url = f"{FRONTEND_URL}/forgot-password/reset?token={reset_token}"

View File

@@ -56,7 +56,7 @@ class PlayerState:
def draw_to_full(self): def draw_to_full(self):
"""Draw cards until hand has HAND_SIZE cards or deck is empty.""" """Draw cards until hand has HAND_SIZE cards or deck is empty."""
while len(self.hand) < HAND_SIZE and self.deck: 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): def refill_energy(self):
self.energy = self.energy_cap self.energy = self.energy_cap

63
backend/give_card.py Normal file
View 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]))

View File

@@ -33,9 +33,16 @@ from game_manager import (
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games, queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
serialize_state, handle_disconnect, handle_timeout_claim, load_deck_cards, create_solo_game 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 card import compute_deck_type, _get_specific_card_async
from email_utils import send_password_reset_email from email_utils import send_password_reset_email, send_verification_email
from config import CORS_ORIGINS 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") logger = logging.getLogger("app")
@@ -82,6 +89,11 @@ app.add_middleware(
allow_headers=["*"], 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: def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip(): if not username.strip():
return "Username is required" 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" return "Username must be 16 characters or fewer"
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email): if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
return "Please enter a valid 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: if len(password) < 8:
return "Password must be at least 8 characters" return "Password must be at least 8 characters"
if len(password) > 256: 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") raise HTTPException(status_code=400, detail="Username already taken")
if db.query(UserModel).filter(UserModel.email == req.email).first(): if db.query(UserModel).filter(UserModel.email == req.email).first():
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
verification_token = secrets.token_urlsafe(32)
user = UserModel( user = UserModel(
id=uuid.uuid4(), id=uuid.uuid4(),
username=req.username, username=req.username,
email=req.email, email=req.email,
password_hash=hash_password(req.password), 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.add(user)
db.commit() 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") @app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): 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") @app.get("/boosters")
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]: def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
return check_boosters(user, db) count, countdown = check_boosters(user, db)
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
@app.get("/cards") @app.get("/cards")
def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): 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") @app.post("/open_pack")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): 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) check_boosters(user, db)
if user.boosters == 0: 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) connections[game_id].pop(user_id, None)
asyncio.create_task(handle_disconnect(game_id, user_id)) 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") @app.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
total_games = user.wins + user.losses total_games = user.wins + user.losses
@@ -343,9 +436,11 @@ def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depen
return { return {
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"email_verified": user.email_verified,
"created_at": user.created_at, "created_at": user.created_at,
"wins": user.wins, "wins": user.wins,
"losses": user.losses, "losses": user.losses,
"shards": user.shards,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None, "win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"most_played_deck": { "most_played_deck": {
"name": most_played_deck.name, "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, } 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") @app.post("/cards/{card_id}/report")
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter( card = db.query(CardModel).filter(
@@ -382,8 +631,8 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
if not card: if not card:
raise HTTPException(status_code=404, detail="Card not found") raise HTTPException(status_code=404, detail="Card not found")
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=48): if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=2):
remaining = (user.last_refresh_at + timedelta(hours=48)) - datetime.now() remaining = (user.last_refresh_at + timedelta(hours=2)) - datetime.now()
hours = int(remaining.total_seconds() // 3600) hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60) minutes = int((remaining.total_seconds() % 3600) // 60)
raise HTTPException( 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)): def refresh_status(user: UserModel = Depends(get_current_user)):
if not user.last_refresh_at: if not user.last_refresh_at:
return {"can_refresh": True, "next_refresh_at": None} 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 can_refresh = datetime.now() >= next_refresh
return { return {
"can_refresh": can_refresh, "can_refresh": can_refresh,
@@ -516,6 +765,35 @@ def reset_password_with_token(req: ResetPasswordWithTokenRequest, db: Session =
db.commit() db.commit()
return {"message": "Password updated"} 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): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str

View File

@@ -17,9 +17,13 @@ class User(Base):
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False) wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: 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) last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reset_token: Mapped[str | None] = mapped_column(String, nullable=True) reset_token: Mapped[str | None] = mapped_column(String, nullable=True)
reset_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, 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") cards: Mapped[list["Card"]] = relationship(back_populates="user")
decks: Mapped[list["Deck"]] = relationship(back_populates="user") decks: Mapped[list["Deck"]] = relationship(back_populates="user")

269
backend/nn.py Normal file
View 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

File diff suppressed because one or more lines are too long

View File

@@ -14,3 +14,5 @@ psycopg2-binary==2.9.11
python-multipart==0.0.22 python-multipart==0.0.22
alembic==1.18.4 alembic==1.18.4
websockets==13.1 websockets==13.1
disposable-email-domains==0.0.169
stripe==14.4.1

View File

@@ -1,4 +1,5 @@
import json import json
import math
import os import os
import random import random
import uuid import uuid
@@ -94,7 +95,7 @@ def _make_instances(deck: list[Card]) -> list[CardInstance]:
] ]
async def simulate_game( def simulate_game(
cards: list[Card], cards: list[Card],
difficulty1: int, difficulty1: int,
personality1: AIPersonality, personality1: AIPersonality,
@@ -106,8 +107,6 @@ async def simulate_game(
Player 1 always goes first. Player 1 always goes first.
Returns "p1", "p2", or None if the game exceeds MAX_TURNS. 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) deck1 = choose_cards(cards, difficulty1, personality1)
deck2 = choose_cards(cards, difficulty2, personality2) deck2 = choose_cards(cards, difficulty2, personality2)
@@ -152,7 +151,7 @@ async def simulate_game(
player = state.players[active_id] player = state.players[active_id]
opponent = state.players[state.opponent_id(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: for slot in plan.sacrifice_slots:
if player.board[slot] is not None: 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: def _run_game_sync(args: tuple) -> str | None:
"""Synchronous entry point for a worker process.""" """Synchronous entry point for a worker process."""
d1, p1_name, d2, p2_name = args d1, p1_name, d2, p2_name = args
return asyncio.run(simulate_game( return simulate_game(
_worker_cards, _worker_cards,
d1, AIPersonality(p1_name), d1, AIPersonality(p1_name),
d2, AIPersonality(p2_name), d2, AIPersonality(p2_name),
)) )
# ==================== Tournament ==================== # ==================== Tournament ====================
@@ -290,26 +289,188 @@ async def run_tournament(
return wins 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( def rank_players(
wins: dict[tuple[int, int], int], wins: dict[tuple[int, int], int],
games_per_matchup: int,
players: list[tuple[AIPersonality, int]], players: list[tuple[AIPersonality, int]],
played: dict[tuple[int, int], int] | None = None,
games_per_matchup: int | None = None,
) -> list[int]: ) -> list[int]:
""" """
Rank player indices by total wins (as first + second player combined). Rank player indices by Bradley-Terry strength. Returns indices sorted worst-to-best.
Returns indices sorted worst-to-best.
Provide either `played` (adaptive tournament) or `games_per_matchup` (fixed).
""" """
n = len(players) if played is None and games_per_matchup is None:
total_wins = [0] * n raise ValueError("Provide either played or games_per_matchup")
for (i, j), p1_wins in wins.items(): ratings = compute_bradley_terry(wins, len(players), played=played, games_per_matchup=games_per_matchup)
if i == j: return sorted(range(len(players)), key=lambda i: ratings[i])
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])
TOURNAMENT_RESULTS_PATH = os.path.join(os.path.dirname(__file__), "tournament_results.json") 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( def save_tournament(
wins: dict[tuple[int, int], int], wins: dict[tuple[int, int], int],
games_per_matchup: int,
players: list[tuple[AIPersonality, int]], players: list[tuple[AIPersonality, int]],
path: str = TOURNAMENT_RESULTS_PATH, path: str = TOURNAMENT_RESULTS_PATH,
played: dict[tuple[int, int], int] | None = None,
games_per_matchup: int | None = None,
): ):
data = { data = {
"games_per_matchup": games_per_matchup,
"players": [ "players": [
{"personality": p.value, "difficulty": d} {"personality": p.value, "difficulty": d}
for p, d in players for p, d in players
], ],
"wins": {f"{i},{j}": w for (i, j), w in wins.items()}, "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: with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
print(f"Tournament results saved to {path}") 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]]]: def load_tournament(
"""Returns (wins, games_per_matchup, players).""" 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: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
wins = {
(int(k.split(",")[0]), int(k.split(",")[1])): v def parse_pair_dict(d: dict) -> dict[tuple[int, int], int]:
for k, v in data["wins"].items() 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 = [ players = [
(AIPersonality(p["personality"]), p["difficulty"]) (AIPersonality(p["personality"]), p["difficulty"])
for p in data["players"] for p in data["players"]
] ]
return wins, data["games_per_matchup"], players return wins, played, games_per_matchup, players
def draw_grid( def draw_grid(
wins: dict[tuple[int, int], int], wins: dict[tuple[int, int], int],
games_per_matchup: int = 5,
players: list[tuple[AIPersonality, int]] | None = None, players: list[tuple[AIPersonality, int]] | None = None,
output_path: str = "tournament_grid.png", 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. Draw a heatmap grid of tournament results.
@@ -370,19 +551,28 @@ def draw_grid(
import matplotlib.colors as mcolors import matplotlib.colors as mcolors
import numpy as np 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: if players is None:
players = _all_players() players = _all_players()
n = len(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] 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) matrix = np.full((n, n), np.nan)
for row, i in enumerate(ranked): for row, i in enumerate(ranked):
for col, j in enumerate(ranked): for col, j in enumerate(ranked):
g = games(i, j)
p1_wins = wins.get((i, j), 0) 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 cell_size = 0.22
fig_size = n * cell_size + 3 fig_size = n * cell_size + 3
@@ -398,8 +588,9 @@ def draw_grid(
# × marks for sweeps # × marks for sweeps
for row, i in enumerate(ranked): for row, i in enumerate(ranked):
for col, j in enumerate(ranked): for col, j in enumerate(ranked):
g = games(i, j)
p1_wins = wins.get((i, j), 0) 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", ax.text(col, row, "×", ha="center", va="center",
fontsize=5, color="black", fontweight="bold", zorder=3) fontsize=5, color="black", fontweight="bold", zorder=3)
@@ -427,14 +618,26 @@ def draw_grid(
if __name__ == "__main__": if __name__ == "__main__":
import sys difficulties = list(range(7, 11))
GAMES_PER_MATCHUP = 10
difficulties = list(range(6, 11))
card_pool = get_simulation_cards() card_pool = get_simulation_cards()
players = _all_players(difficulties) players = _all_players(difficulties)
wins = asyncio.run(run_tournament(card_pool, games_per_matchup=GAMES_PER_MATCHUP, difficulties=difficulties)) wins, played = asyncio.run(run_tournament_adaptive(
save_tournament(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players) card_pool,
draw_grid(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players) 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
View 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
View 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()

View 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>

View File

@@ -9,7 +9,8 @@
{ href: '/cards', label: 'Cards' }, { href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' }, { href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' }, { href: '/play', label: 'Play' },
{ href: '/how-to-play', label: 'How to Play' }, { href: '/trade', label: 'Trade' },
{ href: '/store', label: 'Store' },
]; ];
function close() { menuOpen = false; } function close() { menuOpen = false; }

View File

@@ -5,15 +5,35 @@
import { page } from '$app/state'; import { page } from '$app/state';
let { children } = $props(); let { children } = $props();
</script>
{#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))} const showHeader = $derived(!['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`)));
<Header /> </script>
{/if}
<svelte:head> <svelte:head>
<title>WikiTCG</title> <title>WikiTCG</title>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </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>

View File

@@ -9,6 +9,7 @@
let loading = $state(false); let loading = $state(false);
let boosters = $state(null); let boosters = $state(null);
let countdown = $state(null); let countdown = $state(null);
let emailVerified = $state(true);
let countdownDisplay = $state(''); let countdownDisplay = $state('');
let countdownInterval = null; let countdownInterval = null;
@@ -27,9 +28,10 @@
async function fetchBoosters() { async function fetchBoosters() {
const res = await apiFetch(`${API_URL}/boosters`); const res = await apiFetch(`${API_URL}/boosters`);
if (res.status === 401) { goto('/auth'); return; } if (res.status === 401) { goto('/auth'); return; }
const [count, countdownTs] = await res.json(); const data = await res.json();
boosters = count; boosters = data.count;
countdown = countdownTs ? new Date(countdownTs) : null; countdown = data.countdown ? new Date(data.countdown) : null;
emailVerified = data.email_verified;
startCountdown(); startCountdown();
} }
@@ -140,7 +142,9 @@
</div> </div>
<!-- Idle pack --> <!-- 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}> <div class="pack-wrap" bind:this={packRef}>
<button class="pack-btn" onclick={openPack}> <button class="pack-btn" onclick={openPack}>
<div class="booster-pack"> <div class="booster-pack">
@@ -234,6 +238,15 @@
margin: 0; 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 { .pack-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -30,14 +30,17 @@
let sortAsc = $state(true); let sortAsc = $state(true);
let costMin = $state(1); let costMin = $state(1);
let costMax = $state(11); let costMax = $state(10);
let searchQuery = $state('');
let filtered = $derived.by(() => { let filtered = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
let result = allCards.filter(c => let result = allCards.filter(c =>
selectedRarities.has(c.card_rarity) && selectedRarities.has(c.card_rarity) &&
selectedTypes.has(c.card_type) && selectedTypes.has(c.card_type) &&
c.cost >= costMin && c.cost >= costMin &&
c.cost <= costMax c.cost <= costMax &&
(!q || c.name.toLowerCase().includes(q))
); );
result = result.slice().sort((a, b) => { result = result.slice().sort((a, b) => {
@@ -189,6 +192,13 @@
</button> </button>
{/each} {/each}
<input
class="search-input"
type="search"
placeholder="Search by name…"
bind:value={searchQuery}
/>
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}> <button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'} {filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10} {#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">
<div class="filter-group-header"> <div class="filter-group-header">
<span class="filter-group-label">Cost</span> <span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button> <button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
</div> </div>
<div class="cost-range"> <div class="cost-range">
<span class="range-label">Min: {costMin}</span> <span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="11" bind:value={costMin} <input type="range" min="1" max="10" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} /> oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span> <span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="11" bind:value={costMax} <input type="range" min="1" max="10" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} /> oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div> </div>
</div> </div>
@@ -327,6 +337,22 @@
flex-wrap: wrap; 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 { .toolbar-label {
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
font-size: 11px; font-size: 11px;
@@ -374,7 +400,7 @@
color: rgba(240, 180, 80, 0.6); color: rgba(240, 180, 80, 0.6);
padding: 4px 10px; padding: 4px 10px;
cursor: pointer; cursor: pointer;
margin-left: auto; margin-left: 0.5rem;
position: relative; position: relative;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -530,6 +556,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
backdrop-filter: blur(6px);
} }
.card-popup { .card-popup {

View File

@@ -1,10 +1,10 @@
<script> <script>
import { API_URL, WS_URL } from '$lib/api.js'; import { API_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js'; import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Card from '$lib/Card.svelte'; import CardSelector from '$lib/CardSelector.svelte';
const deckId = $derived($page.params.id); const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token'); const token = () => localStorage.getItem('token');
@@ -17,79 +17,10 @@
let loading = $state(true); let loading = $state(true);
let saving = $state(false); 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( const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0) 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() { function startEditName() {
nameInput = deckName; nameInput = deckName;
editingName = true; editingName = true;
@@ -104,9 +35,7 @@
saving = true; saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, { await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
name: deckName, name: deckName,
card_ids: [...selectedIds], card_ids: [...selectedIds],
@@ -130,7 +59,6 @@
const currentCardIds = await deckCardsRes.json(); const currentCardIds = await deckCardsRes.json();
selectedIds = new Set(currentCardIds); selectedIds = new Set(currentCardIds);
// Get deck name
const decksRes = await apiFetch(`${API_URL}/decks`); const decksRes = await apiFetch(`${API_URL}/decks`);
const decks = await decksRes.json(); const decks = await decksRes.json();
const deck = decks.find(d => d.id === deckId); const deck = decks.find(d => d.id === deckId);
@@ -164,92 +92,17 @@
</button> </button>
</div> </div>
</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> </div>
{#if loading} {#if loading}
<p class="status">Loading...</p> <p class="status">Loading...</p>
{:else if filtered.length === 0}
<p class="status">No cards match your filters.</p>
{:else} {:else}
<div class="grid"> <CardSelector
{#each filtered as card (card.id)} allCards={allCards}
<button bind:selectedIds={selectedIds}
class="card-wrap" costLimit={50}
class:selected={selectedIds.has(card.id)} showFooter={false}
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>
{/if} {/if}
</main> </main>
@@ -258,23 +111,17 @@
main { main {
height: 100vh; height: 100vh;
overflow-y: auto; overflow: hidden;
background: #0d0a04; background: #0d0a04;
padding: 0 2rem 2rem 2rem; display: flex;
flex-direction: column;
} }
.toolbar { .toolbar {
position: sticky; flex-shrink: 0;
top: 0;
z-index: 50;
background: #0d0a04; background: #0d0a04;
padding-bottom: 1rem; padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3); 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 { .deck-header {
@@ -348,187 +195,6 @@
.done-btn:hover:not(:disabled) { background: #5a3510; } .done-btn:hover:not(:disabled) { background: #5a3510; }
.done-btn:disabled { opacity: 0.5; cursor: default; } .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 { .status {
font-family: 'Crimson Text', serif; font-family: 'Crimson Text', serif;
font-size: 16px; font-size: 16px;

View File

@@ -113,7 +113,7 @@
<div class="rule-card"> <div class="rule-card">
<div class="rule-icon">🃏</div> <div class="rule-icon">🃏</div>
<h3 class="rule-title">No Card Limit</h3> <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>
</div> </div>
</section> </section>

View File

@@ -304,6 +304,7 @@
{#if phase === 'idle'} {#if phase === 'idle'}
<div class="lobby"> <div class="lobby">
<h1 class="lobby-title">Find a Match</h1> <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} {#if decks.length === 0}
<p class="lobby-hint">You need a deck to play. <a href="/decks">Build one first.</a></p> <p class="lobby-hint">You need a deck to play. <a href="/decks">Build one first.</a></p>
{:else} {:else}
@@ -538,6 +539,18 @@
.lobby-title.win { color: #6aaa6a; } .lobby-title.win { color: #6aaa6a; }
.lobby-title.lose { color: #c85050; } .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 { .lobby-hint {
font-family: 'Crimson Text', serif; font-family: 'Crimson Text', serif;
font-size: 16px; font-size: 16px;
@@ -643,6 +656,8 @@
color: #c85050; color: #c85050;
margin: 0; margin: 0;
height: 1.4em; height: 1.4em;
margin-top: -1rem;
margin-bottom: -1rem;
} }
.spinner { .spinner {

View File

@@ -22,6 +22,18 @@
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
goto('/auth'); 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> </script>
<main> <main>
@@ -34,7 +46,17 @@
<div class="avatar">{profile.username[0].toUpperCase()}</div> <div class="avatar">{profile.username[0].toUpperCase()}</div>
<div class="profile-info"> <div class="profile-info">
<h1 class="username">{profile.username}</h1> <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> <p class="joined">Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div> </div>
<button class="logout-btn" onclick={logout}>Log Out</button> <button class="logout-btn" onclick={logout}>Log Out</button>
@@ -43,6 +65,15 @@
<div class="section-divider"></div> <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> <h2 class="section-title">Stats</h2>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
@@ -164,6 +195,37 @@
margin: 0; 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 { .joined {
font-family: 'Crimson Text', serif; font-family: 'Crimson Text', serif;
font-size: 13px; font-size: 13px;
@@ -206,6 +268,53 @@
.reset-link:hover { color: rgba(240, 180, 80, 0.7); } .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 { .section-divider {
height: 1px; height: 1px;
background: rgba(107, 76, 30, 0.3); background: rgba(107, 76, 30, 0.3);

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>