🐐
This commit is contained in:
@@ -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
|
||||||
@@ -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} " +
|
||||||
|
|||||||
25
backend/alembic/versions/a9f2d4e7c301_add_shards_to_users.py
Normal file
25
backend/alembic/versions/a9f2d4e7c301_add_shards_to_users.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""add shards to users
|
||||||
|
|
||||||
|
Revision ID: a9f2d4e7c301
|
||||||
|
Revises: f3a1c8e2b950
|
||||||
|
Create Date: 2026-03-25 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'a9f2d4e7c301'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'f3a1c8e2b950'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('shards', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'shards')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add email verification
|
||||||
|
|
||||||
|
Revision ID: f3a1c8e2b950
|
||||||
|
Revises: adee6bcc23e1
|
||||||
|
Create Date: 2026-03-25 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f3a1c8e2b950'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'cd7ebb9b11bd'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Existing users are considered verified so they aren't locked out.
|
||||||
|
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
op.add_column('users', sa.Column('email_verification_token', sa.String(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('email_verification_token_expires_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'email_verification_token_expires_at')
|
||||||
|
op.drop_column('users', 'email_verification_token')
|
||||||
|
op.drop_column('users', 'email_verified')
|
||||||
@@ -113,6 +113,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q734698": CardType.artwork, # collectible card game
|
"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]:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
63
backend/give_card.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Give a user a specific card generated from a Wikipedia page title.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python give_card.py <username> <page_title>
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python give_card.py nikolaj "Marie Curie"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import User as UserModel, Card as CardModel
|
||||||
|
from card import _get_specific_card_async
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
async def main(username: str, page_title: str) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(UserModel).filter(UserModel.username == username).first()
|
||||||
|
if not user:
|
||||||
|
print(f"Error: user '{username}' not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Generating card for '{page_title}'...")
|
||||||
|
card = await _get_specific_card_async(page_title)
|
||||||
|
if not card:
|
||||||
|
print(f"Error: could not generate a card for '{page_title}'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
db_card = CardModel(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user.id,
|
||||||
|
name=card.name,
|
||||||
|
image_link=card.image_link or None,
|
||||||
|
card_rarity=card.card_rarity.name,
|
||||||
|
card_type=card.card_type.name,
|
||||||
|
text=card.text or None,
|
||||||
|
attack=card.attack,
|
||||||
|
defense=card.defense,
|
||||||
|
cost=card.cost,
|
||||||
|
)
|
||||||
|
db.add(db_card)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f"Gave '{card.name}' ({card.card_rarity.name} {card.card_type.name}) to {username}")
|
||||||
|
print(f" ATK {card.attack} DEF {card.defense} Cost {card.cost}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
asyncio.run(main(sys.argv[1], sys.argv[2]))
|
||||||
294
backend/main.py
294
backend/main.py
@@ -33,9 +33,16 @@ from game_manager import (
|
|||||||
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
269
backend/nn.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import numpy as np
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15) | opp_deck_type(8)]
|
||||||
|
N_FEATURES = 64
|
||||||
|
|
||||||
|
_DECK_TYPES = ["Balanced", "Aggro", "Wall", "Rush", "Control", "God Card", "Pantheon", "Unplayable"]
|
||||||
|
_DECK_TYPE_IDX = {dt: i for i, dt in enumerate(_DECK_TYPES)}
|
||||||
|
|
||||||
|
_MAX_ATK = 50.0
|
||||||
|
_MAX_DEF = 100.0
|
||||||
|
_MAX_DECK = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _softmax(x: np.ndarray) -> np.ndarray:
|
||||||
|
e = np.exp(x - x.max())
|
||||||
|
return e / e.sum()
|
||||||
|
|
||||||
|
|
||||||
|
class NeuralNet:
|
||||||
|
"""
|
||||||
|
Fully-connected plan scorer: n_features → 64 → 32 → 1
|
||||||
|
Pure numpy so it can be pickled into worker processes.
|
||||||
|
Optimizer: Adam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, n_features: int = N_FEATURES, hidden: tuple = (64, 32), seed: int | None = None):
|
||||||
|
rng = np.random.RandomState(seed)
|
||||||
|
sizes = [n_features] + list(hidden) + [1]
|
||||||
|
|
||||||
|
self.weights: list[np.ndarray] = []
|
||||||
|
self.biases: list[np.ndarray] = []
|
||||||
|
self.m_w: list[np.ndarray] = []
|
||||||
|
self.v_w: list[np.ndarray] = []
|
||||||
|
self.m_b: list[np.ndarray] = []
|
||||||
|
self.v_b: list[np.ndarray] = []
|
||||||
|
self.t = 0
|
||||||
|
|
||||||
|
for fan_in, fan_out in zip(sizes, sizes[1:]):
|
||||||
|
w = rng.randn(fan_in, fan_out).astype(np.float32) * np.sqrt(2.0 / fan_in)
|
||||||
|
b = np.zeros(fan_out, dtype=np.float32)
|
||||||
|
self.weights.append(w)
|
||||||
|
self.biases.append(b)
|
||||||
|
self.m_w.append(np.zeros_like(w))
|
||||||
|
self.v_w.append(np.zeros_like(w))
|
||||||
|
self.m_b.append(np.zeros_like(b))
|
||||||
|
self.v_b.append(np.zeros_like(b))
|
||||||
|
|
||||||
|
self._acts: list[np.ndarray] = []
|
||||||
|
self._pre_acts: list[np.ndarray] = []
|
||||||
|
|
||||||
|
def forward(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
"""X: (n, n_features) → scores: (n,)"""
|
||||||
|
h = X.astype(np.float32)
|
||||||
|
self._acts = [h]
|
||||||
|
self._pre_acts = []
|
||||||
|
for i, (W, b) in enumerate(zip(self.weights, self.biases)):
|
||||||
|
z = h @ W + b
|
||||||
|
self._pre_acts.append(z)
|
||||||
|
h = np.maximum(0.0, z) if i < len(self.weights) - 1 else z
|
||||||
|
self._acts.append(h)
|
||||||
|
return h.squeeze(-1)
|
||||||
|
|
||||||
|
def backward(self, upstream: np.ndarray) -> tuple[list, list]:
|
||||||
|
"""
|
||||||
|
upstream: (n,) — dJ/d(scores), gradient for ascent.
|
||||||
|
Returns (grads_w, grads_b).
|
||||||
|
"""
|
||||||
|
n = len(upstream)
|
||||||
|
delta = upstream[:, None] # (n, 1)
|
||||||
|
grads_w = [None] * len(self.weights)
|
||||||
|
grads_b = [None] * len(self.biases)
|
||||||
|
for i in range(len(self.weights) - 1, -1, -1):
|
||||||
|
h_in = self._acts[i] # (n, in_size)
|
||||||
|
grads_w[i] = h_in.T @ delta / n
|
||||||
|
grads_b[i] = delta.mean(axis=0)
|
||||||
|
if i > 0:
|
||||||
|
delta = (delta @ self.weights[i].T) * (self._pre_acts[i - 1] > 0)
|
||||||
|
return grads_w, grads_b
|
||||||
|
|
||||||
|
def adam_update(self, grads_w: list, grads_b: list,
|
||||||
|
lr: float = 1e-3, beta1: float = 0.9,
|
||||||
|
beta2: float = 0.999, eps: float = 1e-8,
|
||||||
|
grad_clip: float = 1.0) -> None:
|
||||||
|
# Global gradient norm clipping
|
||||||
|
all_grads = [g for g in grads_w + grads_b if g is not None]
|
||||||
|
global_norm = np.sqrt(sum(np.sum(g * g) for g in all_grads))
|
||||||
|
if global_norm > grad_clip:
|
||||||
|
scale = grad_clip / global_norm
|
||||||
|
grads_w = [g * scale for g in grads_w]
|
||||||
|
grads_b = [g * scale for g in grads_b]
|
||||||
|
|
||||||
|
self.t += 1
|
||||||
|
bc1 = 1 - beta1 ** self.t
|
||||||
|
bc2 = 1 - beta2 ** self.t
|
||||||
|
for i, (gw, gb) in enumerate(zip(grads_w, grads_b)):
|
||||||
|
self.m_w[i] = beta1 * self.m_w[i] + (1 - beta1) * gw
|
||||||
|
self.v_w[i] = beta2 * self.v_w[i] + (1 - beta2) * gw * gw
|
||||||
|
self.weights[i] += lr * (self.m_w[i] / bc1) / (np.sqrt(self.v_w[i] / bc2) + eps)
|
||||||
|
|
||||||
|
self.m_b[i] = beta1 * self.m_b[i] + (1 - beta1) * gb
|
||||||
|
self.v_b[i] = beta2 * self.v_b[i] + (1 - beta2) * gb * gb
|
||||||
|
self.biases[i] += lr * (self.m_b[i] / bc1) / (np.sqrt(self.v_b[i] / bc2) + eps)
|
||||||
|
|
||||||
|
def save(self, path: str) -> None:
|
||||||
|
data = {
|
||||||
|
"weights": [w.tolist() for w in self.weights],
|
||||||
|
"biases": [b.tolist() for b in self.biases],
|
||||||
|
"m_w": [m.tolist() for m in self.m_w],
|
||||||
|
"v_w": [v.tolist() for v in self.v_w],
|
||||||
|
"m_b": [m.tolist() for m in self.m_b],
|
||||||
|
"v_b": [v.tolist() for v in self.v_b],
|
||||||
|
"t": self.t,
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str) -> "NeuralNet":
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
net = cls.__new__(cls)
|
||||||
|
net.weights = [np.array(w, dtype=np.float32) for w in data["weights"]]
|
||||||
|
net.biases = [np.array(b, dtype=np.float32) for b in data["biases"]]
|
||||||
|
net.m_w = [np.array(m, dtype=np.float32) for m in data["m_w"]]
|
||||||
|
net.v_w = [np.array(v, dtype=np.float32) for v in data["v_w"]]
|
||||||
|
net.m_b = [np.array(m, dtype=np.float32) for m in data["m_b"]]
|
||||||
|
net.v_b = [np.array(v, dtype=np.float32) for v in data["v_b"]]
|
||||||
|
net.t = data["t"]
|
||||||
|
net._acts = []
|
||||||
|
net._pre_acts = []
|
||||||
|
return net
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Feature extraction ====================
|
||||||
|
|
||||||
|
def extract_plan_features(plans: list, player, opponent) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Returns (n_plans, N_FEATURES) float32 array.
|
||||||
|
Layout: [state(8) | my_board(15) | opp_board(15) | plan(3) | result_board(15)]
|
||||||
|
"""
|
||||||
|
from game import BOARD_SIZE, HAND_SIZE, MAX_ENERGY_CAP, STARTING_LIFE
|
||||||
|
|
||||||
|
n = len(plans)
|
||||||
|
|
||||||
|
# ---- state (same for every plan) ----
|
||||||
|
state = np.array([
|
||||||
|
player.life / STARTING_LIFE,
|
||||||
|
opponent.life / STARTING_LIFE,
|
||||||
|
player.energy / MAX_ENERGY_CAP,
|
||||||
|
player.energy_cap / MAX_ENERGY_CAP,
|
||||||
|
len(player.hand) / HAND_SIZE,
|
||||||
|
len(opponent.hand) / HAND_SIZE,
|
||||||
|
len(player.deck) / _MAX_DECK,
|
||||||
|
len(opponent.deck) / _MAX_DECK,
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
# ---- current boards (same for every plan) ----
|
||||||
|
my_board = np.zeros(BOARD_SIZE * 3, dtype=np.float32)
|
||||||
|
opp_board = np.zeros(BOARD_SIZE * 3, dtype=np.float32)
|
||||||
|
for slot in range(BOARD_SIZE):
|
||||||
|
c = player.board[slot]
|
||||||
|
if c is not None:
|
||||||
|
my_board[slot * 3] = c.attack / _MAX_ATK
|
||||||
|
my_board[slot * 3 + 1] = c.defense / _MAX_DEF
|
||||||
|
my_board[slot * 3 + 2] = 1.0
|
||||||
|
c = opponent.board[slot]
|
||||||
|
if c is not None:
|
||||||
|
opp_board[slot * 3] = c.attack / _MAX_ATK
|
||||||
|
opp_board[slot * 3 + 1] = c.defense / _MAX_DEF
|
||||||
|
opp_board[slot * 3 + 2] = 1.0
|
||||||
|
|
||||||
|
# ---- per-plan features ----
|
||||||
|
plan_part = np.zeros((n, 3 + BOARD_SIZE * 3), dtype=np.float32)
|
||||||
|
for idx, plan in enumerate(plans):
|
||||||
|
# simulate board result
|
||||||
|
result = list(player.board)
|
||||||
|
for slot in plan.sacrifice_slots:
|
||||||
|
result[slot] = None
|
||||||
|
for card, slot in plan.plays:
|
||||||
|
result[slot] = card
|
||||||
|
|
||||||
|
total_cost = sum(c.cost for c, _ in plan.plays) if plan.plays else 0
|
||||||
|
plan_part[idx, 0] = len(plan.sacrifice_slots) / BOARD_SIZE
|
||||||
|
plan_part[idx, 1] = len(plan.plays) / HAND_SIZE
|
||||||
|
plan_part[idx, 2] = total_cost / (MAX_ENERGY_CAP + BOARD_SIZE)
|
||||||
|
|
||||||
|
for slot in range(BOARD_SIZE):
|
||||||
|
c = result[slot]
|
||||||
|
if c is not None:
|
||||||
|
plan_part[idx, 3 + slot * 3] = c.attack / _MAX_ATK
|
||||||
|
plan_part[idx, 3 + slot * 3 + 1] = c.defense / _MAX_DEF
|
||||||
|
plan_part[idx, 3 + slot * 3 + 2] = 1.0
|
||||||
|
|
||||||
|
# ---- opponent deck type one-hot (same for every plan) ----
|
||||||
|
opp_deck_oh = np.zeros(len(_DECK_TYPES), dtype=np.float32)
|
||||||
|
opp_deck_oh[_DECK_TYPE_IDX.get(opponent.deck_type, 0)] = 1.0
|
||||||
|
|
||||||
|
state_t = np.tile(state, (n, 1))
|
||||||
|
my_board_t = np.tile(my_board, (n, 1))
|
||||||
|
opp_board_t = np.tile(opp_board, (n, 1))
|
||||||
|
opp_deck_t = np.tile(opp_deck_oh, (n, 1))
|
||||||
|
|
||||||
|
return np.concatenate([state_t, my_board_t, opp_board_t, plan_part, opp_deck_t], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Neural player ====================
|
||||||
|
|
||||||
|
class NeuralPlayer:
|
||||||
|
"""
|
||||||
|
Wraps a NeuralNet for use in game simulation.
|
||||||
|
In training mode, samples plans stochastically and records the trajectory
|
||||||
|
for a REINFORCE update after the game ends.
|
||||||
|
In inference mode, picks the highest-scoring plan deterministically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, net: NeuralNet, training: bool = False, temperature: float = 1.0):
|
||||||
|
self.net = net
|
||||||
|
self.training = training
|
||||||
|
self.temperature = temperature
|
||||||
|
self.trajectory: list[tuple[np.ndarray, int]] = [] # (features, chosen_idx)
|
||||||
|
|
||||||
|
def choose_plan(self, player, opponent):
|
||||||
|
from ai import generate_plans
|
||||||
|
plans = generate_plans(player, opponent)
|
||||||
|
features = extract_plan_features(plans, player, opponent)
|
||||||
|
scores = self.net.forward(features)
|
||||||
|
|
||||||
|
if self.training:
|
||||||
|
probs = _softmax((scores / self.temperature).astype(np.float64))
|
||||||
|
probs = np.clip(probs, 1e-10, None)
|
||||||
|
probs /= probs.sum()
|
||||||
|
chosen_idx = int(np.random.choice(len(plans), p=probs))
|
||||||
|
self.trajectory.append((features, chosen_idx))
|
||||||
|
else:
|
||||||
|
chosen_idx = int(np.argmax(scores))
|
||||||
|
|
||||||
|
return plans[chosen_idx]
|
||||||
|
|
||||||
|
def compute_grads(self, outcome: float) -> tuple[list, list] | None:
|
||||||
|
"""
|
||||||
|
Computes averaged REINFORCE gradients for this trajectory without updating weights.
|
||||||
|
outcome: centered reward (win/loss minus baseline).
|
||||||
|
Returns (grads_w, grads_b), or None if trajectory is empty.
|
||||||
|
"""
|
||||||
|
if not self.trajectory:
|
||||||
|
return None
|
||||||
|
|
||||||
|
acc_gw = [np.zeros_like(w) for w in self.net.weights]
|
||||||
|
acc_gb = [np.zeros_like(b) for b in self.net.biases]
|
||||||
|
|
||||||
|
for features, chosen_idx in self.trajectory:
|
||||||
|
scores = self.net.forward(features)
|
||||||
|
probs = _softmax(scores.astype(np.float64)).astype(np.float32)
|
||||||
|
upstream = -probs.copy()
|
||||||
|
upstream[chosen_idx] += 1.0
|
||||||
|
upstream *= outcome
|
||||||
|
gw, gb = self.net.backward(upstream)
|
||||||
|
for i in range(len(acc_gw)):
|
||||||
|
acc_gw[i] += gw[i]
|
||||||
|
acc_gb[i] += gb[i]
|
||||||
|
|
||||||
|
n = len(self.trajectory)
|
||||||
|
for i in range(len(acc_gw)):
|
||||||
|
acc_gw[i] /= n
|
||||||
|
acc_gb[i] /= n
|
||||||
|
|
||||||
|
self.trajectory.clear()
|
||||||
|
return acc_gw, acc_gb
|
||||||
1
backend/nn_weights.json
Normal file
1
backend/nn_weights.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
@@ -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
283
backend/trade_manager.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models import Card as CardModel, DeckCard as DeckCardModel
|
||||||
|
|
||||||
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeOffer:
|
||||||
|
username: str
|
||||||
|
cards: list[dict] = field(default_factory=list)
|
||||||
|
accepted: bool = False
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeSession:
|
||||||
|
trade_id: str
|
||||||
|
offers: dict[str, TradeOffer] # user_id -> TradeOffer
|
||||||
|
connections: dict[str, WebSocket] = field(default_factory=dict)
|
||||||
|
|
||||||
|
active_trades: dict[str, TradeSession] = {}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeQueueEntry:
|
||||||
|
user_id: str
|
||||||
|
username: str
|
||||||
|
websocket: WebSocket
|
||||||
|
|
||||||
|
trade_queue: list[TradeQueueEntry] = []
|
||||||
|
trade_queue_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
def serialize_card_model(card: CardModel) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(card.id),
|
||||||
|
"name": card.name,
|
||||||
|
"card_rarity": card.card_rarity,
|
||||||
|
"card_type": card.card_type,
|
||||||
|
"image_link": card.image_link,
|
||||||
|
"attack": card.attack,
|
||||||
|
"defense": card.defense,
|
||||||
|
"cost": card.cost,
|
||||||
|
"text": card.text,
|
||||||
|
"created_at": card.created_at.isoformat() if card.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
|
||||||
|
partner_id = next(uid for uid in session.offers if uid != perspective_user_id)
|
||||||
|
my_offer = session.offers[perspective_user_id]
|
||||||
|
their_offer = session.offers[partner_id]
|
||||||
|
return {
|
||||||
|
"trade_id": session.trade_id,
|
||||||
|
"partner_username": their_offer.username,
|
||||||
|
"my_offer": {
|
||||||
|
"cards": my_offer.cards,
|
||||||
|
"accepted": my_offer.accepted,
|
||||||
|
},
|
||||||
|
"their_offer": {
|
||||||
|
"cards": their_offer.cards,
|
||||||
|
"accepted": their_offer.accepted,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
## Broadcasting
|
||||||
|
|
||||||
|
async def broadcast_trade(session: TradeSession) -> None:
|
||||||
|
for user_id, ws in list(session.connections.items()):
|
||||||
|
try:
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "state",
|
||||||
|
"state": serialize_trade(session, user_id),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
## Matchmaking
|
||||||
|
|
||||||
|
async def try_trade_match() -> None:
|
||||||
|
async with trade_queue_lock:
|
||||||
|
if len(trade_queue) < 2:
|
||||||
|
return
|
||||||
|
# Guard: same user queued twice
|
||||||
|
if trade_queue[0].user_id == trade_queue[1].user_id:
|
||||||
|
return
|
||||||
|
p1 = trade_queue.pop(0)
|
||||||
|
p2 = trade_queue.pop(0)
|
||||||
|
|
||||||
|
trade_id = str(uuid.uuid4())
|
||||||
|
session = TradeSession(
|
||||||
|
trade_id=trade_id,
|
||||||
|
offers={
|
||||||
|
p1.user_id: TradeOffer(username=p1.username),
|
||||||
|
p2.user_id: TradeOffer(username=p2.username),
|
||||||
|
},
|
||||||
|
connections={
|
||||||
|
p1.user_id: p1.websocket,
|
||||||
|
p2.user_id: p2.websocket,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
active_trades[trade_id] = session
|
||||||
|
|
||||||
|
for entry in [p1, p2]:
|
||||||
|
try:
|
||||||
|
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
## Action handling
|
||||||
|
|
||||||
|
async def handle_trade_action(
|
||||||
|
trade_id: str,
|
||||||
|
user_id: str,
|
||||||
|
message: dict,
|
||||||
|
db: Session,
|
||||||
|
) -> None:
|
||||||
|
session = active_trades.get(trade_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
action = message.get("type")
|
||||||
|
ws = session.connections.get(user_id)
|
||||||
|
|
||||||
|
if action == "update_offer":
|
||||||
|
card_ids = message.get("card_ids", [])
|
||||||
|
|
||||||
|
if card_ids:
|
||||||
|
try:
|
||||||
|
parsed_ids = [uuid.UUID(cid) for cid in card_ids]
|
||||||
|
except ValueError:
|
||||||
|
if ws:
|
||||||
|
await ws.send_json({"type": "error", "message": "Invalid card IDs"})
|
||||||
|
return
|
||||||
|
|
||||||
|
db_cards = db.query(CardModel).filter(
|
||||||
|
CardModel.id.in_(parsed_ids),
|
||||||
|
CardModel.user_id == uuid.UUID(user_id),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if len(db_cards) != len(card_ids):
|
||||||
|
if ws:
|
||||||
|
await ws.send_json({"type": "error", "message": "Some cards are not in your collection"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Preserve the order of card_ids
|
||||||
|
card_map = {str(c.id): c for c in db_cards}
|
||||||
|
ordered = [card_map[cid] for cid in card_ids if cid in card_map]
|
||||||
|
session.offers[user_id].cards = [serialize_card_model(c) for c in ordered]
|
||||||
|
else:
|
||||||
|
session.offers[user_id].cards = []
|
||||||
|
|
||||||
|
# Any offer change unaccepts both sides
|
||||||
|
for offer in session.offers.values():
|
||||||
|
offer.accepted = False
|
||||||
|
|
||||||
|
await broadcast_trade(session)
|
||||||
|
|
||||||
|
elif action == "accept":
|
||||||
|
either_has_cards = any(len(o.cards) > 0 for o in session.offers.values())
|
||||||
|
if not either_has_cards:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate ownership of offered cards one more time
|
||||||
|
my_offer = session.offers[user_id]
|
||||||
|
if my_offer.cards:
|
||||||
|
owned_count = db.query(CardModel).filter(
|
||||||
|
CardModel.id.in_([uuid.UUID(c["id"]) for c in my_offer.cards]),
|
||||||
|
CardModel.user_id == uuid.UUID(user_id),
|
||||||
|
).count()
|
||||||
|
if owned_count != len(my_offer.cards):
|
||||||
|
if ws:
|
||||||
|
await ws.send_json({"type": "error", "message": "Some offered cards are no longer in your collection"})
|
||||||
|
return
|
||||||
|
|
||||||
|
my_offer.accepted = True
|
||||||
|
|
||||||
|
if all(o.accepted for o in session.offers.values()):
|
||||||
|
await _complete_trade(trade_id, db)
|
||||||
|
else:
|
||||||
|
await broadcast_trade(session)
|
||||||
|
|
||||||
|
elif action == "unaccept":
|
||||||
|
session.offers[user_id].accepted = False
|
||||||
|
await broadcast_trade(session)
|
||||||
|
|
||||||
|
## Trade completion
|
||||||
|
|
||||||
|
async def _complete_trade(trade_id: str, db: Session) -> None:
|
||||||
|
session = active_trades.get(trade_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Re-check that both sides are still accepted and have a non-empty offer.
|
||||||
|
# A last-second unaccept or offer change (race or client bug) should abort.
|
||||||
|
if not all(o.accepted for o in session.offers.values()):
|
||||||
|
await broadcast_trade(session)
|
||||||
|
return
|
||||||
|
if not any(len(o.cards) > 0 for o in session.offers.values()):
|
||||||
|
for offer in session.offers.values():
|
||||||
|
offer.accepted = False
|
||||||
|
await broadcast_trade(session)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_ids = list(session.offers.keys())
|
||||||
|
u1, u2 = user_ids[0], user_ids[1]
|
||||||
|
cards_u1 = session.offers[u1].cards # u1 gives these to u2
|
||||||
|
cards_u2 = session.offers[u2].cards # u2 gives these to u1
|
||||||
|
|
||||||
|
# Final ownership double-check before writing
|
||||||
|
def verify(from_id: str, card_dicts: list[dict]) -> bool:
|
||||||
|
if not card_dicts:
|
||||||
|
return True
|
||||||
|
card_uuids = [uuid.UUID(c["id"]) for c in card_dicts]
|
||||||
|
count = db.query(CardModel).filter(
|
||||||
|
CardModel.id.in_(card_uuids),
|
||||||
|
CardModel.user_id == uuid.UUID(from_id),
|
||||||
|
).count()
|
||||||
|
return count == len(card_uuids)
|
||||||
|
|
||||||
|
if not verify(u1, cards_u1) or not verify(u2, cards_u2):
|
||||||
|
db.rollback()
|
||||||
|
for ws in list(session.connections.values()):
|
||||||
|
try:
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for offer in session.offers.values():
|
||||||
|
offer.accepted = False
|
||||||
|
await broadcast_trade(session)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transfer ownership and clear deck relationships
|
||||||
|
for cid_str in [c["id"] for c in cards_u1]:
|
||||||
|
cid = uuid.UUID(cid_str)
|
||||||
|
card = db.query(CardModel).filter(CardModel.id == cid).first()
|
||||||
|
if card:
|
||||||
|
card.user_id = uuid.UUID(u2)
|
||||||
|
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
|
||||||
|
|
||||||
|
for cid_str in [c["id"] for c in cards_u2]:
|
||||||
|
cid = uuid.UUID(cid_str)
|
||||||
|
card = db.query(CardModel).filter(CardModel.id == cid).first()
|
||||||
|
if card:
|
||||||
|
card.user_id = uuid.UUID(u1)
|
||||||
|
db.query(DeckCardModel).filter(DeckCardModel.card_id == cid).delete()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
active_trades.pop(trade_id, None)
|
||||||
|
|
||||||
|
for ws in list(session.connections.values()):
|
||||||
|
try:
|
||||||
|
await ws.send_json({"type": "trade_complete"})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
## Disconnect handling
|
||||||
|
|
||||||
|
async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
|
||||||
|
session = active_trades.get(trade_id)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_trades.pop(trade_id, None)
|
||||||
|
|
||||||
|
for uid, ws in list(session.connections.items()):
|
||||||
|
if uid == user_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await ws.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Your trade partner disconnected. Trade cancelled.",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
205
backend/train_nn.py
Normal file
205
backend/train_nn.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
import numpy as np
|
||||||
|
from collections import deque
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from card import compute_deck_type
|
||||||
|
from ai import AIPersonality, choose_cards, choose_plan
|
||||||
|
from game import PlayerState, GameState, action_play_card, action_sacrifice, action_end_turn
|
||||||
|
from simulate import get_simulation_cards, _make_instances, MAX_TURNS
|
||||||
|
from nn import NeuralNet, NeuralPlayer
|
||||||
|
|
||||||
|
NN_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "nn_weights.json")
|
||||||
|
|
||||||
|
P1 = "p1"
|
||||||
|
P2 = "p2"
|
||||||
|
|
||||||
|
FIXED_PERSONALITIES = [p for p in AIPersonality if p != AIPersonality.ARBITRARY]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Game runner ====================
|
||||||
|
|
||||||
|
def _build_player(pid: str, name: str, cards: list, difficulty: int, personality: AIPersonality) -> PlayerState:
|
||||||
|
deck = choose_cards(cards, difficulty, personality)
|
||||||
|
instances = _make_instances(deck)
|
||||||
|
random.shuffle(instances)
|
||||||
|
p = PlayerState(
|
||||||
|
user_id=pid, username=name,
|
||||||
|
deck_type=compute_deck_type(deck) or "Balanced",
|
||||||
|
deck=instances,
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run_episode(
|
||||||
|
p1_state: PlayerState,
|
||||||
|
p2_state: PlayerState,
|
||||||
|
p1_ctrl, # (player, opponent) -> MovePlan
|
||||||
|
p2_ctrl, # (player, opponent) -> MovePlan
|
||||||
|
) -> str | None:
|
||||||
|
"""Returns winner_id (P1 or P2) or None on timeout."""
|
||||||
|
p1_state.increment_energy_cap()
|
||||||
|
p2_state.increment_energy_cap()
|
||||||
|
p1_state.refill_energy()
|
||||||
|
p1_state.draw_to_full()
|
||||||
|
|
||||||
|
state = GameState(
|
||||||
|
game_id=str(uuid.uuid4()),
|
||||||
|
players={P1: p1_state, P2: p2_state},
|
||||||
|
player_order=[P1, P2],
|
||||||
|
active_player_id=P1,
|
||||||
|
phase="main",
|
||||||
|
turn=1,
|
||||||
|
)
|
||||||
|
ctrls = {P1: p1_ctrl, P2: p2_ctrl}
|
||||||
|
|
||||||
|
for _ in range(MAX_TURNS):
|
||||||
|
if state.result:
|
||||||
|
break
|
||||||
|
active_id = state.active_player_id
|
||||||
|
player = state.players[active_id]
|
||||||
|
opponent = state.players[state.opponent_id(active_id)]
|
||||||
|
|
||||||
|
plan = ctrls[active_id](player, opponent)
|
||||||
|
|
||||||
|
for slot in plan.sacrifice_slots:
|
||||||
|
if player.board[slot] is not None:
|
||||||
|
action_sacrifice(state, slot)
|
||||||
|
|
||||||
|
plays = list(plan.plays)
|
||||||
|
random.shuffle(plays)
|
||||||
|
for card, slot in plays:
|
||||||
|
hand_idx = next((i for i, c in enumerate(player.hand) if c is card), None)
|
||||||
|
if hand_idx is None or player.board[slot] is not None or card.cost > player.energy:
|
||||||
|
continue
|
||||||
|
action_play_card(state, hand_idx, slot)
|
||||||
|
|
||||||
|
action_end_turn(state)
|
||||||
|
|
||||||
|
return state.result.winner_id if state.result else None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Training loop ====================
|
||||||
|
|
||||||
|
def train(
|
||||||
|
n_episodes: int = 20_000,
|
||||||
|
self_play_start: int = 5_000,
|
||||||
|
self_play_max_frac: float = 0.4,
|
||||||
|
lr: float = 1e-3,
|
||||||
|
opp_difficulty: int = 10,
|
||||||
|
temperature: float = 1.0,
|
||||||
|
batch_size: int = 50,
|
||||||
|
save_every: int = 5_000,
|
||||||
|
save_path: str = NN_WEIGHTS_PATH,
|
||||||
|
) -> NeuralNet:
|
||||||
|
cards = get_simulation_cards()
|
||||||
|
|
||||||
|
if os.path.exists(save_path):
|
||||||
|
print(f"Resuming from {save_path}")
|
||||||
|
net = NeuralNet.load(save_path)
|
||||||
|
else:
|
||||||
|
print("Initializing new network")
|
||||||
|
net = NeuralNet(seed=42)
|
||||||
|
|
||||||
|
recent_outcomes: deque[int] = deque(maxlen=1000) # rolling window for win rate display
|
||||||
|
baseline = 0.0 # EMA of recent outcomes; subtracted before each update
|
||||||
|
baseline_alpha = 0.99 # decay — roughly a 100-episode window
|
||||||
|
|
||||||
|
batch_gw = [np.zeros_like(w) for w in net.weights]
|
||||||
|
batch_gb = [np.zeros_like(b) for b in net.biases]
|
||||||
|
batch_count = 0
|
||||||
|
|
||||||
|
for episode in range(1, n_episodes + 1):
|
||||||
|
# Ramp self-play fraction linearly from 0 to self_play_max_frac
|
||||||
|
if episode >= self_play_start:
|
||||||
|
progress = (episode - self_play_start) / max(1, n_episodes - self_play_start)
|
||||||
|
self_play_prob = self_play_max_frac * progress
|
||||||
|
else:
|
||||||
|
self_play_prob = 0.0
|
||||||
|
|
||||||
|
# Randomly decide who goes first (NN is always P1 for simplicity)
|
||||||
|
nn_goes_first = random.random() < 0.5
|
||||||
|
|
||||||
|
if random.random() < self_play_prob:
|
||||||
|
# ---- Self-play ----
|
||||||
|
nn1 = NeuralPlayer(net, training=True, temperature=temperature)
|
||||||
|
nn2 = NeuralPlayer(net, training=True, temperature=temperature)
|
||||||
|
|
||||||
|
p1_state = _build_player(P1, "NN1", cards, 10, AIPersonality.BALANCED)
|
||||||
|
p2_state = _build_player(P2, "NN2", cards, 10, AIPersonality.BALANCED)
|
||||||
|
|
||||||
|
if not nn_goes_first:
|
||||||
|
p1_state, p2_state = p2_state, p1_state
|
||||||
|
|
||||||
|
winner = run_episode(p1_state, p2_state, nn1.choose_plan, nn2.choose_plan)
|
||||||
|
p1_outcome = 1.0 if winner == P1 else -1.0
|
||||||
|
baseline = baseline_alpha * baseline + (1 - baseline_alpha) * p1_outcome
|
||||||
|
|
||||||
|
for player_grads in [nn1.compute_grads(p1_outcome - baseline),
|
||||||
|
nn2.compute_grads(-p1_outcome - baseline)]:
|
||||||
|
if player_grads is not None:
|
||||||
|
gw, gb = player_grads
|
||||||
|
for i in range(len(batch_gw)):
|
||||||
|
batch_gw[i] += gw[i]
|
||||||
|
batch_gb[i] += gb[i]
|
||||||
|
batch_count += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ---- NN vs fixed opponent ----
|
||||||
|
opp_personality = random.choice(FIXED_PERSONALITIES)
|
||||||
|
nn_player = NeuralPlayer(net, training=True, temperature=temperature)
|
||||||
|
opp_ctrl = lambda p, o, pers=opp_personality, diff=opp_difficulty: choose_plan(p, o, pers, diff)
|
||||||
|
|
||||||
|
if nn_goes_first:
|
||||||
|
nn_id = P1
|
||||||
|
p1_state = _build_player(P1, "NN", cards, 10, AIPersonality.BALANCED)
|
||||||
|
p2_state = _build_player(P2, "OPP", cards, opp_difficulty, opp_personality)
|
||||||
|
winner = run_episode(p1_state, p2_state, nn_player.choose_plan, opp_ctrl)
|
||||||
|
else:
|
||||||
|
nn_id = P2
|
||||||
|
p1_state = _build_player(P1, "OPP", cards, opp_difficulty, opp_personality)
|
||||||
|
p2_state = _build_player(P2, "NN", cards, 10, AIPersonality.BALANCED)
|
||||||
|
winner = run_episode(p1_state, p2_state, opp_ctrl, nn_player.choose_plan)
|
||||||
|
|
||||||
|
nn_outcome = 1.0 if winner == nn_id else -1.0
|
||||||
|
player_grads = nn_player.compute_grads(nn_outcome - baseline)
|
||||||
|
baseline = baseline_alpha * baseline + (1 - baseline_alpha) * nn_outcome
|
||||||
|
|
||||||
|
if player_grads is not None:
|
||||||
|
gw, gb = player_grads
|
||||||
|
for i in range(len(batch_gw)):
|
||||||
|
batch_gw[i] += gw[i]
|
||||||
|
batch_gb[i] += gb[i]
|
||||||
|
batch_count += 1
|
||||||
|
|
||||||
|
recent_outcomes.append(1 if winner == nn_id else 0)
|
||||||
|
|
||||||
|
if batch_count >= batch_size:
|
||||||
|
for i in range(len(batch_gw)):
|
||||||
|
batch_gw[i] /= batch_count
|
||||||
|
batch_gb[i] /= batch_count
|
||||||
|
net.adam_update(batch_gw, batch_gb, lr=lr)
|
||||||
|
batch_gw = [np.zeros_like(w) for w in net.weights]
|
||||||
|
batch_gb = [np.zeros_like(b) for b in net.biases]
|
||||||
|
batch_count = 0
|
||||||
|
|
||||||
|
if episode % 1000 == 0 or episode == n_episodes:
|
||||||
|
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
||||||
|
print(f"[{episode:>6}/{n_episodes}] win rate (last {len(recent_outcomes)}): {wr:.1%} "
|
||||||
|
f"self-play frac: {self_play_prob:.0%}", flush=True)
|
||||||
|
|
||||||
|
if episode % save_every == 0:
|
||||||
|
net.save(save_path)
|
||||||
|
print(f" → saved to {save_path}")
|
||||||
|
|
||||||
|
net.save(save_path)
|
||||||
|
wr = sum(recent_outcomes) / len(recent_outcomes) if recent_outcomes else 0.0
|
||||||
|
print(f"Done. Final win rate (last {len(recent_outcomes)}): {wr:.1%}")
|
||||||
|
return net
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
train()
|
||||||
454
frontend/src/lib/CardSelector.svelte
Normal file
454
frontend/src/lib/CardSelector.svelte
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
<script>
|
||||||
|
import Card from '$lib/Card.svelte';
|
||||||
|
|
||||||
|
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
||||||
|
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
||||||
|
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
||||||
|
|
||||||
|
let {
|
||||||
|
allCards = [],
|
||||||
|
selectedIds = $bindable(new Set()),
|
||||||
|
onclose = null,
|
||||||
|
costLimit = null, // if set, prevents selecting cards that would exceed it
|
||||||
|
showFooter = true, // set false to hide the Done button (e.g. inline deck builder)
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const selectedCost = $derived(
|
||||||
|
costLimit !== null
|
||||||
|
? allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function label(str) {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortBy = $state('name');
|
||||||
|
let sortAsc = $state(true);
|
||||||
|
let selectedRarities = $state(new Set(RARITIES));
|
||||||
|
let selectedTypes = $state(new Set(TYPES));
|
||||||
|
let costMin = $state(1);
|
||||||
|
let costMax = $state(10);
|
||||||
|
let filtersOpen = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
let filtered = $derived.by(() => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
let result = allCards.filter(c =>
|
||||||
|
selectedRarities.has(c.card_rarity) &&
|
||||||
|
selectedTypes.has(c.card_type) &&
|
||||||
|
c.cost >= costMin &&
|
||||||
|
c.cost <= costMax &&
|
||||||
|
(!q || c.name.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
result = result.slice().sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||||
|
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
||||||
|
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
||||||
|
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
||||||
|
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(val) {
|
||||||
|
if (sortBy === val) sortAsc = !sortAsc;
|
||||||
|
else { sortBy = val; sortAsc = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRarity(r) {
|
||||||
|
const s = new Set(selectedRarities);
|
||||||
|
s.has(r) ? s.delete(r) : s.add(r);
|
||||||
|
selectedRarities = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(t) {
|
||||||
|
const s = new Set(selectedTypes);
|
||||||
|
s.has(t) ? s.delete(t) : s.add(t);
|
||||||
|
selectedTypes = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
|
||||||
|
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
|
||||||
|
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
|
||||||
|
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
|
||||||
|
|
||||||
|
function toggleCard(id) {
|
||||||
|
const s = new Set(selectedIds);
|
||||||
|
if (s.has(id)) {
|
||||||
|
s.delete(id);
|
||||||
|
} else {
|
||||||
|
if (costLimit !== null) {
|
||||||
|
const card = allCards.find(c => c.id === id);
|
||||||
|
if (card && selectedCost + card.cost > costLimit) return;
|
||||||
|
}
|
||||||
|
s.add(id);
|
||||||
|
}
|
||||||
|
selectedIds = s;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="selector">
|
||||||
|
{#if showFooter && onclose}
|
||||||
|
<div class="top-bar">
|
||||||
|
<span class="counter">{selectedIds.size} card{selectedIds.size === 1 ? '' : 's'} selected</span>
|
||||||
|
<button class="done-btn" onclick={onclose}>Done</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="sort-row">
|
||||||
|
<span class="toolbar-label">Sort by</span>
|
||||||
|
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
||||||
|
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
|
||||||
|
{lbl}
|
||||||
|
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
||||||
|
{filtersOpen ? 'Hide filters' : 'Filter'}
|
||||||
|
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
||||||
|
<span class="filter-dot"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filtersOpen}
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<div class="filter-group-header">
|
||||||
|
<span class="filter-group-label">Rarity</span>
|
||||||
|
<button class="select-all" onclick={toggleAllRarities}>{allRaritiesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxes">
|
||||||
|
{#each RARITIES as r}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
|
||||||
|
{label(r)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<div class="filter-group-header">
|
||||||
|
<span class="filter-group-label">Type</span>
|
||||||
|
<button class="select-all" onclick={toggleAllTypes}>{allTypesSelected() ? 'Deselect all' : 'Select all'}</button>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxes">
|
||||||
|
{#each TYPES as t}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
|
||||||
|
{label(t)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<div class="filter-group-header">
|
||||||
|
<span class="filter-group-label">Cost</span>
|
||||||
|
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="cost-range">
|
||||||
|
<span class="range-label">Min: {costMin}</span>
|
||||||
|
<input type="range" min="1" max="10" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
|
||||||
|
<span class="range-label">Max: {costMax}</span>
|
||||||
|
<input type="range" min="1" max="10" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<p class="status">No cards match your filters.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each filtered as card (card.id)}
|
||||||
|
<button
|
||||||
|
class="card-wrap"
|
||||||
|
class:selected={selectedIds.has(card.id)}
|
||||||
|
class:disabled={costLimit !== null && !selectedIds.has(card.id) && selectedCost + card.cost > costLimit}
|
||||||
|
onclick={() => toggleCard(card.id)}
|
||||||
|
>
|
||||||
|
<Card {card} noHover={true} />
|
||||||
|
{#if selectedIds.has(card.id)}
|
||||||
|
<div class="selected-badge">✓</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
background: #0d0a04;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1.5rem 2rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #f0d080;
|
||||||
|
padding: 5px 10px;
|
||||||
|
outline: none;
|
||||||
|
width: 220px;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: #c8861a; }
|
||||||
|
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
||||||
|
|
||||||
|
.toolbar-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||||
|
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
|
||||||
|
.sort-arrow { font-size: 10px; margin-left: 3px; }
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
|
||||||
|
|
||||||
|
.filter-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c8861a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 3rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
|
||||||
|
|
||||||
|
.filter-group-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all:hover { color: #f0d080; }
|
||||||
|
|
||||||
|
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(240, 180, 80, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
accent-color: #c8861a;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
|
||||||
|
.range-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(240, 180, 80, 0.7);
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range] {
|
||||||
|
accent-color: #c8861a;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap {
|
||||||
|
all: unset;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap.selected {
|
||||||
|
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #c8861a;
|
||||||
|
color: #fff8e0;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 23.875px;
|
||||||
|
font-weight: 1000;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 23px;
|
||||||
|
border: black 3px solid;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||||
|
background: #0d0a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #3d2507;
|
||||||
|
border: 1px solid #c8861a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #f0d080;
|
||||||
|
padding: 8px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-btn:hover { background: #5a3510; }
|
||||||
|
</style>
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
{ href: '/cards', label: 'Cards' },
|
{ href: '/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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
288
frontend/src/routes/shards/+page.svelte
Normal file
288
frontend/src/routes/shards/+page.svelte
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<script>
|
||||||
|
import { API_URL } from '$lib/api.js';
|
||||||
|
import { apiFetch } from '$lib/api.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import CardSelector from '$lib/CardSelector.svelte';
|
||||||
|
|
||||||
|
let allCards = $state([]);
|
||||||
|
let shards = $state(null);
|
||||||
|
let selectedIds = $state(new Set());
|
||||||
|
let selectorOpen = $state(false);
|
||||||
|
let shattering = $state(false);
|
||||||
|
let result = $state(null); // { gained, shards }
|
||||||
|
|
||||||
|
const selectedCards = $derived(allCards.filter(c => selectedIds.has(c.id)));
|
||||||
|
const totalYield = $derived(selectedCards.reduce((sum, c) => sum + c.cost, 0));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||||
|
const [cardsRes, profileRes] = await Promise.all([
|
||||||
|
apiFetch(`${API_URL}/cards`),
|
||||||
|
apiFetch(`${API_URL}/profile`),
|
||||||
|
]);
|
||||||
|
if (cardsRes.status === 401) { goto('/auth'); return; }
|
||||||
|
allCards = await cardsRes.json();
|
||||||
|
const profile = await profileRes.json();
|
||||||
|
shards = profile.shards;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shatter() {
|
||||||
|
if (selectedIds.size === 0 || shattering) return;
|
||||||
|
shattering = true;
|
||||||
|
const res = await apiFetch(`${API_URL}/shards/shatter`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ card_ids: [...selectedIds] }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
shards = data.shards;
|
||||||
|
result = { gained: data.gained };
|
||||||
|
allCards = allCards.filter(c => !selectedIds.has(c.id));
|
||||||
|
selectedIds = new Set();
|
||||||
|
}
|
||||||
|
shattering = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissResult() { result = null; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="top">
|
||||||
|
<h1 class="page-title">Shards</h1>
|
||||||
|
{#if shards !== null}
|
||||||
|
<div class="shards-display">
|
||||||
|
<span class="shards-icon">◈</span>
|
||||||
|
<span class="shards-amount">{shards}</span>
|
||||||
|
<span class="shards-label">Shards</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="explainer">
|
||||||
|
Shatter cards you no longer need to recover shards equal to their cost.
|
||||||
|
Shattered cards are permanently destroyed.
|
||||||
|
</p>
|
||||||
|
<p class="store-hint">You can also <a href="/store#buy-shards" class="store-link">buy shards</a> in the store.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<div class="result-banner">
|
||||||
|
<span class="result-icon">◈</span>
|
||||||
|
+{result.gained} shards gained
|
||||||
|
<button class="dismiss" onclick={dismissResult}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="action-area">
|
||||||
|
<button class="select-btn" onclick={() => { selectorOpen = true; }}>
|
||||||
|
{selectedIds.size === 0 ? 'Select cards to shatter' : `${selectedIds.size} card${selectedIds.size === 1 ? '' : 's'} selected`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if selectedIds.size > 0}
|
||||||
|
<button
|
||||||
|
class="shatter-btn"
|
||||||
|
onclick={shatter}
|
||||||
|
disabled={shattering}
|
||||||
|
>
|
||||||
|
{shattering ? 'Shattering...' : `Shatter for ◈ ${totalYield}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if selectorOpen}
|
||||||
|
<div class="selector-overlay">
|
||||||
|
<CardSelector
|
||||||
|
allCards={allCards}
|
||||||
|
bind:selectedIds={selectedIds}
|
||||||
|
onclose={() => { selectorOpen = false; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0d0a04;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 500px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: clamp(22px, 4vw, 32px);
|
||||||
|
font-weight: 900;
|
||||||
|
color: #f0d080;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shards-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shards-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #7ecfcf;
|
||||||
|
position: relative;
|
||||||
|
top: -0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shards-amount {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7ecfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shards-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(126, 207, 207, 0.5);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explainer {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-hint {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.35);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-link {
|
||||||
|
color: #7ecfcf;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.store-link:hover { color: #a8e8e8; }
|
||||||
|
|
||||||
|
/* ── Result banner ── */
|
||||||
|
.result-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #0d2a0d;
|
||||||
|
border: 1.5px solid #6aaa6a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6aaa6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon { color: #7ecfcf; position: relative; top: -0.1em; }
|
||||||
|
|
||||||
|
.dismiss {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(106, 170, 106, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 0 0 0.75rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.dismiss:hover { color: #6aaa6a; }
|
||||||
|
|
||||||
|
/* ── Action area ── */
|
||||||
|
.action-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #3d2507;
|
||||||
|
border: 1.5px solid #c8861a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f0d080;
|
||||||
|
padding: 10px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover { background: #5a3510; }
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-count { color: #f0d080; }
|
||||||
|
.summary-arrow { color: rgba(240, 180, 80, 0.35); }
|
||||||
|
.summary-yield { color: #7ecfcf; display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.shards-icon-sm { font-size: 14px; color: #7ecfcf; position: relative; top: -0.1em; }
|
||||||
|
|
||||||
|
.shatter-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #1a1008;
|
||||||
|
border: 1.5px solid #7ecfcf;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #7ecfcf;
|
||||||
|
padding: 10px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shatter-btn:hover:not(:disabled) { background: #0d2a2a; }
|
||||||
|
.shatter-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Card selector overlay ── */
|
||||||
|
.selector-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1048
frontend/src/routes/store/+page.svelte
Normal file
1048
frontend/src/routes/store/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
560
frontend/src/routes/trade/+page.svelte
Normal file
560
frontend/src/routes/trade/+page.svelte
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
<script>
|
||||||
|
import { API_URL, WS_URL, apiFetch } from '$lib/api.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Card from '$lib/Card.svelte';
|
||||||
|
import CardSelector from '$lib/CardSelector.svelte';
|
||||||
|
|
||||||
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
|
let phase = $state('idle'); // idle | queuing | trading | complete
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let queueWs = null;
|
||||||
|
let tradeWs = null;
|
||||||
|
let tradeId = $state('');
|
||||||
|
|
||||||
|
let allCards = $state([]); // user's full card collection (for selector)
|
||||||
|
let tradeState = $state(null); // latest trade state from server
|
||||||
|
|
||||||
|
let selectorOpen = $state(false);
|
||||||
|
let selectorIds = $state(new Set());
|
||||||
|
|
||||||
|
let myOffer = $derived(tradeState?.my_offer ?? { cards: [], accepted: false });
|
||||||
|
let theirOffer = $derived(tradeState?.their_offer ?? { cards: [], accepted: false });
|
||||||
|
let partnerUsername = $derived(tradeState?.partner_username ?? '');
|
||||||
|
|
||||||
|
let eitherHasCards = $derived(myOffer.cards.length > 0 || theirOffer.cards.length > 0);
|
||||||
|
|
||||||
|
// disabled | ready | accepted
|
||||||
|
let acceptState = $derived(
|
||||||
|
!eitherHasCards ? 'disabled' :
|
||||||
|
myOffer.accepted ? 'accepted' :
|
||||||
|
'ready'
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!token()) { goto('/auth'); return; }
|
||||||
|
const res = await apiFetch(`${API_URL}/cards`);
|
||||||
|
if (!res.ok) { goto('/auth'); return; }
|
||||||
|
allCards = await res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
queueWs?.close();
|
||||||
|
tradeWs?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function joinQueue() {
|
||||||
|
error = '';
|
||||||
|
phase = 'queuing';
|
||||||
|
queueWs = new WebSocket(`${WS_URL}/ws/trade/queue`);
|
||||||
|
queueWs.onopen = () => queueWs.send(token());
|
||||||
|
queueWs.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'trade_start') {
|
||||||
|
tradeId = msg.trade_id;
|
||||||
|
queueWs.close();
|
||||||
|
connectToTrade();
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
error = msg.message;
|
||||||
|
phase = 'idle';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelQueue() {
|
||||||
|
queueWs?.close();
|
||||||
|
phase = 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectToTrade() {
|
||||||
|
phase = 'trading';
|
||||||
|
tradeWs = new WebSocket(`${WS_URL}/ws/trade/${tradeId}`);
|
||||||
|
tradeWs.onopen = () => tradeWs.send(token());
|
||||||
|
tradeWs.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'state') {
|
||||||
|
tradeState = msg.state;
|
||||||
|
} else if (msg.type === 'trade_complete') {
|
||||||
|
phase = 'complete';
|
||||||
|
tradeWs?.close();
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
error = msg.message;
|
||||||
|
setTimeout(() => { if (phase !== 'idle') error = ''; }, 4000);
|
||||||
|
if (msg.message.includes('disconnected')) {
|
||||||
|
phase = 'idle';
|
||||||
|
tradeState = null;
|
||||||
|
tradeWs?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tradeWs.onerror = () => { error = 'Connection lost'; phase = 'idle'; };
|
||||||
|
tradeWs.onclose = (e) => {
|
||||||
|
if (phase === 'trading') { error = 'Connection lost'; phase = 'idle'; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelector() {
|
||||||
|
if (myOffer.accepted) {
|
||||||
|
tradeWs?.send(JSON.stringify({ type: 'unaccept' }));
|
||||||
|
}
|
||||||
|
selectorIds = new Set(myOffer.cards.map(c => c.id));
|
||||||
|
selectorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSelector() {
|
||||||
|
selectorOpen = false;
|
||||||
|
tradeWs?.send(JSON.stringify({
|
||||||
|
type: 'update_offer',
|
||||||
|
card_ids: [...selectorIds],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccept() {
|
||||||
|
if (acceptState === 'disabled' || acceptState === 'accepted') return;
|
||||||
|
tradeWs?.send(JSON.stringify({ type: 'accept' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
phase = 'idle';
|
||||||
|
tradeState = null;
|
||||||
|
tradeId = '';
|
||||||
|
error = '';
|
||||||
|
selectorOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if phase === 'idle'}
|
||||||
|
<div class="center-screen">
|
||||||
|
<h1 class="title">Trade</h1>
|
||||||
|
<p class="subtitle">Exchange cards with another player</p>
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<button class="primary-btn" onclick={joinQueue}>Find Trade Partner</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if phase === 'queuing'}
|
||||||
|
<div class="center-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="searching-text">Searching for a trade partner...</p>
|
||||||
|
<button class="cancel-btn" onclick={cancelQueue}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if phase === 'trading'}
|
||||||
|
<div class="trade-layout">
|
||||||
|
{#if selectorOpen}
|
||||||
|
<div class="selector-overlay">
|
||||||
|
<CardSelector
|
||||||
|
allCards={allCards}
|
||||||
|
bind:selectedIds={selectorIds}
|
||||||
|
onclose={closeSelector}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="trade-panels">
|
||||||
|
<div class="panel your-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Your Offer</span>
|
||||||
|
{#if myOffer.accepted}
|
||||||
|
<span class="accepted-badge">Accepted ✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="panel-cards">
|
||||||
|
{#if myOffer.cards.length === 0}
|
||||||
|
<div class="empty-offer">
|
||||||
|
<p>No cards offered yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-scroll">
|
||||||
|
{#each myOffer.cards as card (card.id)}
|
||||||
|
<div class="card-wrap">
|
||||||
|
<Card {card} noHover={true} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel their-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">{partnerUsername || 'Partner'}'s Offer</span>
|
||||||
|
{#if theirOffer.accepted}
|
||||||
|
<span class="accepted-badge">Accepted ✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="panel-cards">
|
||||||
|
{#if theirOffer.cards.length === 0}
|
||||||
|
<div class="empty-offer">
|
||||||
|
<p>No cards offered yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-scroll">
|
||||||
|
{#each theirOffer.cards as card (card.id)}
|
||||||
|
<div class="card-wrap">
|
||||||
|
<Card {card} noHover={true} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="choose-btn" onclick={openSelector}>Choose Cards</button>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<span class="error-inline">{error}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="accept-btn"
|
||||||
|
class:accept-ready={acceptState === 'ready'}
|
||||||
|
class:accept-accepted={acceptState === 'accepted'}
|
||||||
|
class:accept-disabled={acceptState === 'disabled'}
|
||||||
|
disabled={acceptState === 'disabled' || acceptState === 'accepted'}
|
||||||
|
onclick={handleAccept}
|
||||||
|
>
|
||||||
|
{#if acceptState === 'accepted'}
|
||||||
|
Accepted ✓
|
||||||
|
{:else if acceptState === 'disabled'}
|
||||||
|
Accept Trade
|
||||||
|
{:else}
|
||||||
|
Accept Trade
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if phase === 'complete'}
|
||||||
|
<div class="center-screen">
|
||||||
|
<div class="complete-icon">⇄</div>
|
||||||
|
<h1 class="title">Trade Complete!</h1>
|
||||||
|
<p class="subtitle">Your cards have been exchanged.</p>
|
||||||
|
<button class="primary-btn" onclick={reset}>Trade Again</button>
|
||||||
|
<a href="/cards" class="secondary-link">View Your Cards</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
|
main {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
background: #0d0a04;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Center screens (idle, queuing, complete) ── */
|
||||||
|
|
||||||
|
.center-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0d080;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #c85050;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #3d2507;
|
||||||
|
border: 1px solid #c8861a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #f0d080;
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover { background: #5a3510; }
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
padding: 6px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
|
||||||
|
|
||||||
|
.searching-text {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(200, 134, 26, 0.2);
|
||||||
|
border-top-color: #c8861a;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.complete-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: #6aaa6a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-link {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.5);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-link:hover { color: #f0d080; }
|
||||||
|
|
||||||
|
/* ── Trade layout ── */
|
||||||
|
|
||||||
|
.trade-layout {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-panels {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 180, 80, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accepted-badge {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6aaa6a;
|
||||||
|
background: rgba(106, 170, 106, 0.12);
|
||||||
|
border: 1px solid rgba(106, 170, 106, 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-cards {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-offer {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-offer p {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.25);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: scale(0.82);
|
||||||
|
transform-origin: top center;
|
||||||
|
margin-bottom: -42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(107, 76, 30, 0.35);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action bar ── */
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid rgba(107, 76, 30, 0.35);
|
||||||
|
background: #0d0a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choose-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #1e1208;
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(240, 180, 80, 0.8);
|
||||||
|
padding: 8px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choose-btn:hover { background: #2e1c0c; border-color: #c8861a; color: #f0d080; }
|
||||||
|
|
||||||
|
.error-inline {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c85050;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled: grayed out, no interaction */
|
||||||
|
.accept-btn.accept-disabled {
|
||||||
|
background: rgba(30, 18, 8, 0.5);
|
||||||
|
border: 1px solid rgba(107, 76, 30, 0.25);
|
||||||
|
color: rgba(240, 180, 80, 0.2);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ready: gold, inviting click */
|
||||||
|
.accept-btn.accept-ready {
|
||||||
|
background: #3d2507;
|
||||||
|
border: 2px solid #c8861a;
|
||||||
|
color: #f0d080;
|
||||||
|
box-shadow: 0 0 12px rgba(200, 134, 26, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn.accept-ready:hover {
|
||||||
|
background: #5a3510;
|
||||||
|
box-shadow: 0 0 20px rgba(200, 134, 26, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accepted: bright green, pulsing, waiting */
|
||||||
|
.accept-btn.accept-accepted {
|
||||||
|
background: rgba(40, 90, 40, 0.4);
|
||||||
|
border: 2px solid #6aaa6a;
|
||||||
|
color: #6aaa6a;
|
||||||
|
cursor: default;
|
||||||
|
animation: pulse-green 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-green {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(106, 170, 106, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(106, 170, 106, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.trade-panels { flex-direction: column; }
|
||||||
|
.divider { width: 100%; height: 1px; }
|
||||||
|
.card-wrap { transform: scale(0.7); margin-bottom: -60px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
frontend/src/routes/verify-email/+page.svelte
Normal file
108
frontend/src/routes/verify-email/+page.svelte
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script>
|
||||||
|
import { API_URL } from '$lib/api.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let status = $state('verifying'); // 'verifying' | 'success' | 'error'
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
const token = $derived($page.url.searchParams.get('token') ?? '');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!token) {
|
||||||
|
status = 'error';
|
||||||
|
errorMessage = 'No verification token found in this link.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
status = 'success';
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
status = 'error';
|
||||||
|
errorMessage = data.detail ?? 'Verification failed.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status = 'error';
|
||||||
|
errorMessage = 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
{#if status === 'verifying'}
|
||||||
|
<h1 class="title">Verifying...</h1>
|
||||||
|
<p class="hint">Please wait while we verify your email.</p>
|
||||||
|
{:else if status === 'success'}
|
||||||
|
<h1 class="title">Email Verified</h1>
|
||||||
|
<p class="hint">Your email has been confirmed. You can now open packs and trade cards.</p>
|
||||||
|
<button class="btn" onclick={() => goto('/')}>Start Playing</button>
|
||||||
|
{:else}
|
||||||
|
<h1 class="title">Verification Failed</h1>
|
||||||
|
<p class="hint">{errorMessage}</p>
|
||||||
|
<p class="hint">Your link may have expired. You can request a new one from your profile.</p>
|
||||||
|
<button class="btn" onclick={() => goto('/profile')}>Go to Profile</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0d0a04;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 380px;
|
||||||
|
background: #3d2507;
|
||||||
|
border: 2px solid #c8861a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f5d060;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(245, 208, 96, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: #c8861a;
|
||||||
|
color: #fff8e0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: #e09820; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user