This commit is contained in:
2026-03-18 15:33:24 +01:00
parent 5e7a6808ab
commit 867c51062b
39 changed files with 6499 additions and 161 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
.vscode/ .vscode/
__pycache__/ __pycache__/
.svelte-kit/ .svelte-kit/
versions/ .env

7
backend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
JWT_SECRET_KEY= # generate with: python -c "import secrets; print(secrets.token_hex(32))"
DATABASE_URL= # postgresql://user:password@host/dbname
RESEND_API_KEY= # from resend.com dashboard
EMAIL_FROM= # e.g. noreply@yourdomain.com
FRONTEND_URL= # e.g. https://yourdomain.com
CORS_ORIGINS= # comma-separated, e.g. https://yourdomain.com
WIKIRANK_USER_AGENT= # e.g. WikiTCG/1.0 (you@email.com

View File

@@ -0,0 +1,61 @@
"""add stats and soft delete fields
Revision ID: 18537b3ac57d
Revises: d8f5b502ec96
Create Date: 2026-03-18 00:32:05.076132
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '18537b3ac57d'
down_revision: Union[str, Sequence[str], None] = 'd8f5b502ec96'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# Add columns as nullable first
op.add_column('cards', sa.Column('times_played', sa.Integer(), nullable=True))
op.add_column('cards', sa.Column('reported', sa.Boolean(), nullable=True))
op.add_column('decks', sa.Column('times_played', sa.Integer(), nullable=True))
op.add_column('decks', sa.Column('wins', sa.Integer(), nullable=True))
op.add_column('decks', sa.Column('losses', sa.Integer(), nullable=True))
op.add_column('decks', sa.Column('deleted', sa.Boolean(), nullable=True))
op.add_column('users', sa.Column('wins', sa.Integer(), nullable=True))
op.add_column('users', sa.Column('losses', sa.Integer(), nullable=True))
op.add_column('users', sa.Column('last_refresh_at', sa.DateTime(), nullable=True))
# Populate existing rows with defaults
op.execute("UPDATE cards SET times_played = 0, reported = false")
op.execute("UPDATE decks SET times_played = 0, wins = 0, losses = 0, deleted = false")
op.execute("UPDATE users SET wins = 0, losses = 0")
# Now apply not-null constraints where needed
op.alter_column('cards', 'times_played', nullable=False)
op.alter_column('cards', 'reported', nullable=False)
op.alter_column('decks', 'times_played', nullable=False)
op.alter_column('decks', 'wins', nullable=False)
op.alter_column('decks', 'losses', nullable=False)
op.alter_column('decks', 'deleted', nullable=False)
op.alter_column('users', 'wins', nullable=False)
op.alter_column('users', 'losses', nullable=False)
# last_refresh_at stays nullable
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'last_refresh_at')
op.drop_column('users', 'losses')
op.drop_column('users', 'wins')
op.drop_column('decks', 'deleted')
op.drop_column('decks', 'losses')
op.drop_column('decks', 'wins')
op.drop_column('decks', 'times_played')
op.drop_column('cards', 'reported')
op.drop_column('cards', 'times_played')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""add possword reset token to user
Revision ID: adee6bcc23e1
Revises: 18537b3ac57d
Create Date: 2026-03-18 14:10:50.943628
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'adee6bcc23e1'
down_revision: Union[str, Sequence[str], None] = '18537b3ac57d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('reset_token', sa.String(), nullable=True))
op.add_column('users', sa.Column('reset_token_expires_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'reset_token_expires_at')
op.drop_column('users', 'reset_token')
# ### end Alembic commands ###

View File

@@ -0,0 +1,76 @@
"""initial schema
Revision ID: b342602d3eab
Revises:
Create Date: 2026-03-16 15:04:47.516397
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b342602d3eab'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('boosters', sa.Integer(), nullable=False),
sa.Column('booster_countdown', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_table('cards',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('image_link', sa.String(), nullable=True),
sa.Column('card_rarity', sa.String(), nullable=False),
sa.Column('card_type', sa.String(), nullable=False),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('attack', sa.Integer(), nullable=False),
sa.Column('defense', sa.Integer(), nullable=False),
sa.Column('cost', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('decks',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('deck_cards',
sa.Column('deck_id', sa.UUID(), nullable=False),
sa.Column('card_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['card_id'], ['cards.id'], ),
sa.ForeignKeyConstraint(['deck_id'], ['decks.id'], ),
sa.PrimaryKeyConstraint('deck_id', 'card_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('deck_cards')
op.drop_table('decks')
op.drop_table('cards')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""add boosters to users
Revision ID: d8f5b502ec96
Revises: b342602d3eab
Create Date: 2026-03-16 15:18:17.252061
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'd8f5b502ec96'
down_revision: Union[str, Sequence[str], None] = 'b342602d3eab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('boosters_countdown', sa.DateTime(), nullable=True))
op.drop_column('users', 'booster_countdown')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('booster_countdown', postgresql.TIMESTAMP(), autoincrement=False, nullable=False))
op.drop_column('users', 'boosters_countdown')
# ### end Alembic commands ###

View File

@@ -3,11 +3,14 @@ from datetime import datetime, timedelta
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from config import JWT_SECRET_KEY
logger = logging.getLogger("app") logger = logging.getLogger("app")
SECRET_KEY = "changethis" SECRET_KEY = JWT_SECRET_KEY
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 30 # 1 month ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 30
pwd_context = CryptContext(schemes=["bcrypt"]) pwd_context = CryptContext(schemes=["bcrypt"])
@@ -19,7 +22,20 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(user_id: str) -> str: def create_access_token(user_id: str) -> str:
expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode({"sub": user_id, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode({"sub": user_id, "exp": expire, "type": "access"}, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode({"sub": user_id, "exp": expire, "type": "refresh"}, SECRET_KEY, algorithm=ALGORITHM)
def decode_refresh_token(token: str) -> str | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh":
return None
return payload.get("sub")
except JWTError:
return None
def decode_access_token(token: str) -> str | None: def decode_access_token(token: str) -> str | None:
try: try:

View File

@@ -6,6 +6,9 @@ from urllib.parse import quote
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from config import WIKIRANK_USER_AGENT
HEADERS = {"User-Agent": WIKIRANK_USER_AGENT}
logger = logging.getLogger("app") logger = logging.getLogger("app")
class CardType(Enum): class CardType(Enum):
@@ -14,11 +17,11 @@ class CardType(Enum):
location = 2 location = 2
artwork = 3 artwork = 3
life_form = 4 life_form = 4
conflict = 5 event = 5
group = 6 group = 6
science_thing = 7 science_thing = 7
vehicle = 8 vehicle = 8
business = 9 organization = 9
class CardRarity(Enum): class CardRarity(Enum):
common = 0 common = 0
@@ -52,9 +55,11 @@ class Card(NamedTuple):
rarity_text = f"Rarity: {self.card_rarity.name}" rarity_text = f"Rarity: {self.card_rarity.name}"
return_string += ""+f"{rarity_text:<50}"+"\n" return_string += ""+f"{rarity_text:<50}"+"\n"
return_string += ""+""*50+"\n" return_string += ""+""*50+"\n"
link = "Image: "+("Yes" if self.image_link != "" else "No") link = "Image: "+self.image_link
return_string += ""+f"{link:{' '}<50}"+"\n" return_string += ""+f"{link:{' '}<50}"+"\n"
return_string += ""+""*50+"\n" return_string += ""+""*50+"\n"
return_string += ""+f"{'*'*self.cost:{' '}<50}"+"\n"
return_string += ""+""*50+"\n"
lines = [] lines = []
words = self.text.split(" ") words = self.text.split(" ")
current_line = "" current_line = ""
@@ -85,14 +90,16 @@ class Card(NamedTuple):
WIKIDATA_INSTANCE_TYPE_MAP = { WIKIDATA_INSTANCE_TYPE_MAP = {
"Q5": CardType.person, # human "Q5": CardType.person, # human
"Q15632617": CardType.person, # fictional human
"Q215627": CardType.person, # person "Q215627": CardType.person, # person
"Q15632617": CardType.person, # fictional human
"Q22988604": CardType.person, # fictional human
"Q7889": CardType.artwork, # video game
"Q1004": CardType.artwork, # comics "Q1004": CardType.artwork, # comics
"Q15416": CardType.artwork, # television program "Q7889": CardType.artwork, # video game
"Q11424": CardType.artwork, # film "Q11424": CardType.artwork, # film
"Q24862": CardType.artwork, # film "Q11410": CardType.artwork, # game
"Q15416": CardType.artwork, # television program
"Q24862": CardType.artwork, # short film
"Q11032": CardType.artwork, # newspaper "Q11032": CardType.artwork, # newspaper
"Q25379": CardType.artwork, # play "Q25379": CardType.artwork, # play
"Q41298": CardType.artwork, # magazine "Q41298": CardType.artwork, # magazine
@@ -101,15 +108,18 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q169930": CardType.artwork, # EP "Q169930": CardType.artwork, # EP
"Q196600": CardType.artwork, # media franchise "Q196600": CardType.artwork, # media franchise
"Q202866": CardType.artwork, # animated film "Q202866": CardType.artwork, # animated film
"Q277759": CardType.artwork, # book series
"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
"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
"Q5398426": CardType.artwork, # television series "Q5398426": CardType.artwork, # television series
"Q7725634": CardType.artwork, # literary work "Q7725634": CardType.artwork, # literary work
"Q1761818": CardType.artwork, # advertising campaign "Q1761818": CardType.artwork, # advertising campaign
"Q1446621": CardType.artwork, # recital "Q1446621": CardType.artwork, # recital
"Q1868552": CardType.artwork, # local newspaper
"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
@@ -119,30 +129,53 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q105543609": CardType.artwork, # musical work / composition "Q105543609": CardType.artwork, # musical work / composition
"Q106499608": CardType.artwork, # literary reading "Q106499608": CardType.artwork, # literary reading
"Q117467246": CardType.artwork, # animated television series "Q117467246": CardType.artwork, # animated television series
"Q106042566": CardType.artwork, # single album
"Q515": CardType.location, # city "Q515": CardType.location, # city
"Q8502": CardType.location, # mountain "Q8502": CardType.location, # mountain
"Q4022": CardType.location, # river "Q4022": CardType.location, # river
"Q6256": CardType.location, # country "Q6256": CardType.location, # country
"Q15284": CardType.location, # municipality
"Q27686": CardType.location, # hotel
"Q41176": CardType.location, # building "Q41176": CardType.location, # building
"Q23442": CardType.location, # island "Q23442": CardType.location, # island
"Q82794": CardType.location, # geographic region "Q82794": CardType.location, # geographic region
"Q34442": CardType.location, # road "Q34442": CardType.location, # road
"Q398141": CardType.location, # school district
"Q133056": CardType.location, # mountain pass
"Q3624078": CardType.location, # sovereign state "Q3624078": CardType.location, # sovereign state
"Q1093829": CardType.location, # city in the United States "Q1093829": CardType.location, # city in the United States
"Q7930989": CardType.location, # city/town "Q7930989": CardType.location, # city/town
"Q1250464": CardType.location, # realm
"Q3146899": CardType.location, # diocese of the catholic church "Q3146899": CardType.location, # diocese of the catholic church
"Q35145263": CardType.location, # natural geographic object "Q35145263": CardType.location, # natural geographic object
"Q16521": CardType.life_form, # taxon "Q16521": CardType.life_form, # 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
"Q198": CardType.conflict, # war "Q198": CardType.event, # war
"Q8465": CardType.conflict, # civil war "Q8465": CardType.event, # civil war
"Q103495": CardType.conflict, # world war "Q141022": CardType.event, # eclipse
"Q997267": CardType.conflict, # skirmish "Q103495": CardType.event, # world war
"Q178561": CardType.conflict, # battle "Q350604": CardType.event, # armed conflict
"Q1361229": CardType.conflict, # conquest "Q217327": CardType.event, # suicide attack
"Q750215": CardType.event, # mass murder
"Q898712": CardType.event, # aircraft hijacking
"Q997267": CardType.event, # skirmish
"Q178561": CardType.event, # battle
"Q273120": CardType.event, # protest
"Q1190554": CardType.event, # occurrence
"Q1361229": CardType.event, # conquest
"Q2223653": CardType.event, # terrorist attack
"Q2672648": CardType.event, # social conflict
"Q16510064": CardType.event, # sporting event
"Q10688145": CardType.event, # season
"Q13418847": CardType.event, # historical event
"Q13406554": CardType.event, # sports competition
"Q15275719": CardType.event, # recurring event
"Q114609228": CardType.event, # recurring sporting event
"Q7278": CardType.group, # political party "Q7278": CardType.group, # political party
"Q476028": CardType.group, # association football club "Q476028": CardType.group, # association football club
@@ -150,25 +183,43 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q215380": CardType.group, # musical group "Q215380": CardType.group, # musical group
"Q176799": CardType.group, # military unit "Q176799": CardType.group, # military unit
"Q178790": CardType.group, # labor union "Q178790": CardType.group, # labor union
"Q851990": CardType.group, # people group
"Q2367225": CardType.group, # university and college sports club "Q2367225": CardType.group, # university and college sports club
"Q4801149": CardType.group, # artillery brigade "Q4801149": CardType.group, # artillery brigade
"Q9248092": CardType.group, # infantry division "Q9248092": CardType.group, # infantry division
"Q7210356": CardType.group, # political organization "Q7210356": CardType.group, # political organization
"Q5419137": CardType.group, # veterans' organization "Q5419137": CardType.group, # veterans' organization
"Q6979593": CardType.group, # national association football team
"Q1194951": CardType.group, # national sports team
"Q1539532": CardType.group, # sports season of a sports club
"Q13393265": CardType.group, # basketball team
"Q17148672": CardType.group, # social club
"Q12973014": CardType.group, # sports team "Q12973014": CardType.group, # sports team
"Q11446438": CardType.group, # female idol group "Q11446438": CardType.group, # female idol group
"Q10517054": CardType.group, # handball team "Q10517054": CardType.group, # handball team
"Q135408445": CardType.group, # men's national association football team "Q135408445": CardType.group, # men's national association football team
"Q120143756": CardType.group, # division
"Q134601727": CardType.group, # group of persons
"Q127334927": CardType.group, # band
"Q523": CardType.science_thing, # star
"Q318": CardType.science_thing, # galaxy
"Q6999": CardType.science_thing, # astronomical object
"Q7187": CardType.science_thing, # gene "Q7187": CardType.science_thing, # gene
"Q8054": CardType.science_thing, # protein "Q8054": CardType.science_thing, # protein
"Q12136": CardType.science_thing, # disease
"Q65943": CardType.science_thing, # theorem "Q65943": CardType.science_thing, # theorem
"Q12140": CardType.science_thing, # medication "Q12140": CardType.science_thing, # medication
"Q11276": CardType.science_thing, # globular cluster "Q11276": CardType.science_thing, # globular cluster
"Q83373": CardType.science_thing, # quasar
"Q898273": CardType.science_thing, # protein domain "Q898273": CardType.science_thing, # protein domain
"Q134808": CardType.science_thing, # vaccine
"Q168845": CardType.science_thing, # star cluster "Q168845": CardType.science_thing, # star cluster
"Q1491746": CardType.science_thing, # galaxy group
"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
"Q17444909": CardType.science_thing, # astronomical object type
"Q113145171": CardType.science_thing, # type of chemical entity "Q113145171": CardType.science_thing, # type of chemical entity
"Q1420": CardType.vehicle, # car "Q1420": CardType.vehicle, # car
@@ -176,6 +227,11 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q43193": CardType.vehicle, # truck "Q43193": CardType.vehicle, # truck
"Q25956": CardType.vehicle, # space station "Q25956": CardType.vehicle, # space station
"Q39804": CardType.vehicle, # cruise ship "Q39804": CardType.vehicle, # cruise ship
"Q170483": CardType.vehicle, # sailing ship
"Q964162": CardType.vehicle, # express train
"Q848944": CardType.vehicle, # merchant vessel
"Q189418": CardType.vehicle, # brigantine
"Q281019": CardType.vehicle, # ghost ship
"Q811704": CardType.vehicle, # rolling stock class "Q811704": CardType.vehicle, # rolling stock class
"Q673687": CardType.vehicle, # racing automobile "Q673687": CardType.vehicle, # racing automobile
"Q174736": CardType.vehicle, # destroyer "Q174736": CardType.vehicle, # destroyer
@@ -184,21 +240,28 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q830335": CardType.vehicle, # protected cruiser "Q830335": CardType.vehicle, # protected cruiser
"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
"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
"Q19832486": CardType.vehicle, # locomotive class
"Q23866334": CardType.vehicle, # motorcycle model
"Q29048322": CardType.vehicle, # vehicle model
"Q137188246": CardType.vehicle, # combat vehicle model
"Q100709275": CardType.vehicle, # combat vehicle family
"Q4830453": CardType.business, # business "Q43229": CardType.organization, # organization
"Q47913": CardType.organization, # intelligence agency
"Q35535": CardType.organization, # police
"Q4830453": CardType.organization, # business
} }
import asyncio import asyncio
import httpx import httpx
HEADERS = {"User-Agent": "WikiTCG/1.0 (nikolaj@gade.gg)"}
async def _get_random_summary_async(client: httpx.AsyncClient) -> dict: async def _get_random_summary_async(client: httpx.AsyncClient) -> dict:
try: try:
response = await client.get( response = await client.get(
@@ -257,6 +320,24 @@ async def _get_page_summary_async(client: httpx.AsyncClient, title: str) -> dict
return response.json() return response.json()
async def _get_superclasses(client: httpx.AsyncClient, qid: str) -> list[str]:
try:
response = await client.get(
"https://www.wikidata.org/wiki/Special:EntityData/" + qid + ".json",
headers=HEADERS
)
except:
return []
if not response.is_success:
return []
entity = response.json().get("entities", {}).get(qid, {})
subclass_claims = entity.get("claims", {}).get("P279", [])
return [
c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id")
for c in subclass_claims
if c.get("mainsnak", {}).get("datavalue")
]
async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> tuple[CardType, str, int]: async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> tuple[CardType, str, int]:
try: try:
response = await client.get( response = await client.get(
@@ -272,7 +353,11 @@ async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> t
entity = response.json().get("entities", {}).get(entity_id, {}) entity = response.json().get("entities", {}).get(entity_id, {})
claims = entity.get("claims", {}) claims = entity.get("claims", {})
instance_of_claims = claims.get("P31", []) instance_of_claims = claims.get("P31", [])
qids = [c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id") for c in instance_of_claims] qids = [
c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id")
for c in instance_of_claims
if c.get("mainsnak", {}).get("datavalue")
]
sitelinks = entity.get("sitelinks", {}) sitelinks = entity.get("sitelinks", {})
language_count = sum( language_count = sum(
@@ -281,12 +366,31 @@ async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> t
and key not in ("commonswiki", "wikidatawiki", "specieswiki") and key not in ("commonswiki", "wikidatawiki", "specieswiki")
) )
if "P625" in claims: # First pass: direct match
return CardType.location, (qids[0] if qids != [] else ""), language_count
for qid in qids: for qid in qids:
if qid in WIKIDATA_INSTANCE_TYPE_MAP: if qid in WIKIDATA_INSTANCE_TYPE_MAP:
return WIKIDATA_INSTANCE_TYPE_MAP[qid], qid, language_count return WIKIDATA_INSTANCE_TYPE_MAP[qid], qid, language_count
# Second pass: check superclasses of each instance-of QID
superclass_results = sum(
await asyncio.gather(*[_get_superclasses(client, qid) for qid in qids if qid]),
[])
for superclass_qid in superclass_results:
if superclass_qid in WIKIDATA_INSTANCE_TYPE_MAP:
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid], superclass_qid, language_count
# Third pass: check superclasses of each superclass
superclass_results2 = sum(
await asyncio.gather(*[_get_superclasses(client, qid) for qid in superclass_results if qid])
,[])
for superclass_qid2 in superclass_results2:
if superclass_qid2 in WIKIDATA_INSTANCE_TYPE_MAP:
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count
# Fallback: coordinate location
if "P625" in claims:
return CardType.location, (qids[0] if qids else ""), language_count
return CardType.other, (qids[0] if qids != [] else ""), language_count return CardType.other, (qids[0] if qids != [] else ""), language_count
async def _get_wikirank_score(client: httpx.AsyncClient, title: str) -> float | None: async def _get_wikirank_score(client: httpx.AsyncClient, title: str) -> float | None:
@@ -348,7 +452,7 @@ async def _get_monthly_pageviews(client: httpx.AsyncClient, title: str) -> int |
return None return None
items = response.json().get("items", []) items = response.json().get("items", [])
return items[0]["views"] if items else None return items[0]["views"] if items else None
except Exception: except:
return None return None
def _pageviews_to_defense(views: int | None) -> int: def _pageviews_to_defense(views: int | None) -> int:
@@ -378,6 +482,24 @@ async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None
(card_type, instance, language_count), score, views = await asyncio.gather( (card_type, instance, language_count), score, views = await asyncio.gather(
card_type_task, wikirank_task, pageviews_task card_type_task, wikirank_task, pageviews_task
) )
if (
(card_type == CardType.other and instance == "") or
language_count == 0 or
score is None or
views is None
):
error_message = f"Could not generate card '{title}': "
if card_type == CardType.other and instance == "":
error_message += "Not instance of a class"
elif language_count == 0:
error_message += "No language pages found"
elif score is None:
error_message += "No wikirank score"
elif views is None:
error_message += "No monthly view data"
logger.warning(error_message)
return None
rarity = _score_to_rarity(score) rarity = _score_to_rarity(score)
multiplier = RARITY_MULTIPLIER[rarity] multiplier = RARITY_MULTIPLIER[rarity]
@@ -415,6 +537,29 @@ def generate_cards(size: int) -> list[Card]:
def generate_card(title: str) -> Card|None: def generate_card(title: str) -> Card|None:
return asyncio.run(_get_specific_card_async(title)) return asyncio.run(_get_specific_card_async(title))
# Cards helper function
def compute_deck_type(cards: list) -> str | None:
if len(cards) < 20:
return None
avg_atk = sum(c.attack for c in cards) / len(cards)
avg_def = sum(c.defense for c in cards) / len(cards)
avg_cost = sum(c.cost for c in cards) / len(cards)
if all(c.cost > 6 for c in cards):
return "Unplayable"
if sum(1 for c in cards if c.cost >= 10) == 1:
return "God Card"
if sum(1 for c in cards if c.cost >= 10) > 1:
return "Pantheon"
if avg_cost >= 3.2:
return "Control"
if avg_cost <= 1.8:
return "Rush"
if avg_def > avg_atk * 1.5:
return "Wall"
if avg_atk > avg_def * 1.5:
return "Aggro"
return "Balanced"
# for card in generate_cards(5): # for card in generate_cards(5):
# print(card) # print(card)
@@ -437,3 +582,10 @@ def generate_card(title: str) -> Card|None:
# for card in generate_cards(100): # for card in generate_cards(100):
# if card.card_type == CardType.other: # if card.card_type == CardType.other:
# print(card) # print(card)
# print(generate_card("9/11"))
# print(generate_card("Julius Caesar"))
# print(generate_card("List of lists of lists"))
# print(generate_card("Boudica"))
# print(generate_card("Harald Bluetooth"))
# print(generate_card("Nørrebro"))

21
backend/config.py Normal file
View File

@@ -0,0 +1,21 @@
import os
def require(key: str) -> str:
value = os.environ.get(key)
if not value:
raise RuntimeError(f"Required environment variable {key} is not set")
return value
def optional(key: str, default: str = "") -> str:
return os.environ.get(key, default)
# Required
JWT_SECRET_KEY = require("JWT_SECRET_KEY")
DATABASE_URL = require("DATABASE_URL")
RESEND_API_KEY = require("RESEND_API_KEY")
EMAIL_FROM = require("EMAIL_FROM")
# Optional with sensible defaults for local dev
FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173")
CORS_ORIGINS = optional("CORS_ORIGINS", "http://localhost:5173").split(",")
WIKIRANK_USER_AGENT = optional("WIKIRANK_USER_AGENT", "WikiTCG/1.0")

View File

@@ -1,7 +1,7 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATABASE_URL = "postgresql://wikitcg:password@localhost/wikitcg" from config import DATABASE_URL
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)

10
backend/dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "log_conf.yaml"]

27
backend/email_utils.py Normal file
View File

@@ -0,0 +1,27 @@
import resend
import os
from config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL
def send_password_reset_email(to_email: str, username: str, reset_token: str):
resend.api_key = RESEND_API_KEY
reset_url = f"{FRONTEND_URL}/forgot-password/reset?token={reset_token}"
resend.Emails.send({
"from": EMAIL_FROM,
"to": to_email,
"subject": "Reset your WikiTCG password",
"html": f"""
<div style="font-family: serif; max-width: 480px; margin: 0 auto; color: #1a1208;">
<h2 style="color: #b87830;">WikiTCG Password Reset</h2>
<p>Hi {username},</p>
<p>Someone requested a password reset for your account. If this was you, click the link below:</p>
<p style="margin: 2rem 0;">
<a href="{reset_url}" style="background: #c8861a; color: #fff8e0; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-family: sans-serif; font-size: 14px;">
Reset Password
</a>
</p>
<p>This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
<p style="color: #888; font-size: 13px;">— WikiTCG</p>
</div>
""",
})

295
backend/game.py Normal file
View File

@@ -0,0 +1,295 @@
from dataclasses import dataclass, field
from typing import Optional
import random
import uuid
from datetime import datetime
from models import Card as CardModel
STARTING_LIFE = 500
MAX_ENERGY_CAP = 6
BOARD_SIZE = 5
HAND_SIZE = 5
@dataclass
class CardInstance:
instance_id: str
card_id: str
name: str
attack: int
defense: int
max_defense: int
cost: int
card_type: str
card_rarity: str
image_link: str
text: str
@classmethod
def from_db_card(cls, card: CardModel) -> "CardInstance":
return cls(
instance_id=str(uuid.uuid4()),
card_id=str(card.id),
name=card.name,
attack=card.attack,
defense=card.defense,
max_defense=card.defense,
cost=card.cost,
card_type=card.card_type,
card_rarity=card.card_rarity,
image_link=card.image_link or "",
text=card.text
)
@dataclass
class PlayerState:
user_id: str
username: str
deck_type: str
life: int = STARTING_LIFE
hand: list[CardInstance] = field(default_factory=list)
deck: list[CardInstance] = field(default_factory=list)
board: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * BOARD_SIZE)
energy: int = 0
energy_cap: int = 0
def draw_to_full(self):
"""Draw cards until hand has HAND_SIZE cards or deck is empty."""
while len(self.hand) < HAND_SIZE and self.deck:
self.hand.append(self.deck.pop(0))
def refill_energy(self):
self.energy = self.energy_cap
def increment_energy_cap(self):
self.energy_cap = min(self.energy_cap + 1, MAX_ENERGY_CAP)
def has_playable_cards(self) -> bool:
"""True if the player has any playable cards left in deck or hand or on board."""
board_empty = all([c is None for c in self.board])
non_played_cards = self.deck + self.hand
return (not board_empty) or any(c.cost <= MAX_ENERGY_CAP for c in non_played_cards)
@dataclass
class CombatEvent:
attacker_slot: int
attacker_name: str
defender_slot: Optional[int] # None if attacking life
defender_name: Optional[str]
damage: int
defender_destroyed: bool
life_damage: int # > 0 if hitting life total
@dataclass
class GameResult:
winner_id: Optional[str] # None if still ongoing
reason: Optional[str]
@dataclass
class GameState:
game_id: str
players: dict[str, PlayerState]
player_order: list[str] # [player1_id, player2_id]
active_player_id: str
phase: str # "main" | "combat" | "end"
turn: int = 1
result: Optional[GameResult] = None
last_combat_events: list[CombatEvent] = field(default_factory=list)
turn_started_at: Optional[datetime] = None
def opponent_id(self, player_id: str) -> str:
return next(p for p in self.player_order if p != player_id)
def active_player(self) -> PlayerState:
return self.players[self.active_player_id]
def opponent(self) -> PlayerState:
return self.players[self.opponent_id(self.active_player_id)]
def create_game(
player1_id: str,
player1_username: str,
player1_deck_type: str,
player1_cards: list,
player2_id: str,
player2_username: str,
player2_deck_type: str,
player2_cards: list,
) -> GameState:
def make_player(user_id, username, deck_type, cards):
deck = [CardInstance.from_db_card(c) for c in cards]
random.shuffle(deck)
player = PlayerState(user_id=user_id, username=username, deck_type=deck_type, deck=deck)
return player
p1 = make_player(player1_id, player1_username, player1_deck_type, player1_cards)
p2 = make_player(player2_id, player2_username, player2_deck_type, player2_cards)
# Randomly decide who goes first
order = [player1_id, player2_id]
random.shuffle(order)
first = order[0]
# First player starts with energy cap 1, draw immediately
p1.increment_energy_cap()
p2.increment_energy_cap()
players = {player1_id: p1, player2_id: p2}
players[first].refill_energy()
players[first].draw_to_full()
state = GameState(
game_id=str(uuid.uuid4()),
players=players,
player_order=order,
active_player_id=first,
phase="main",
turn=1,
)
state.turn_started_at = datetime.now()
return state
def check_win_condition(state: GameState) -> Optional[GameResult]:
for pid, player in state.players.items():
if player.life <= 0:
return GameResult(
winner_id=state.opponent_id(pid),
reason="Life reduced to zero"
)
for pid, player in state.players.items():
opp = state.players[state.opponent_id(pid)]
if any(c for c in player.board if c) and not opp.has_playable_cards():
return GameResult(
winner_id=pid,
reason="Opponent has no playable cards remaining"
)
return None
def resolve_combat(state: GameState) -> list[CombatEvent]:
active = state.active_player()
opponent = state.opponent()
events = []
for slot in range(BOARD_SIZE):
attacker = active.board[slot]
if attacker is None:
continue
defender = opponent.board[slot]
if defender is None:
# Direct life damage
opponent.life -= attacker.attack
events.append(CombatEvent(
attacker_slot=slot,
attacker_name=attacker.name,
defender_slot=None,
defender_name=None,
damage=attacker.attack,
defender_destroyed=False,
life_damage=attacker.attack,
))
else:
# Attack the opposing card
defender.defense -= attacker.attack
destroyed = defender.defense <= 0
if destroyed:
opponent.board[slot] = None
events.append(CombatEvent(
attacker_slot=slot,
attacker_name=attacker.name,
defender_slot=slot,
defender_name=defender.name,
damage=attacker.attack,
defender_destroyed=destroyed,
life_damage=0,
))
return events
def action_play_card(state: GameState, hand_index: int, slot: int) -> str | None:
if state.phase != "main":
return "Not in main phase"
player = state.active_player()
if slot < 0 or slot >= BOARD_SIZE:
return f"Invalid slot {slot}"
if hand_index < 0 or hand_index >= len(player.hand):
return "Invalid hand index"
if player.board[slot] is not None:
return "Slot already occupied"
card = player.hand[hand_index]
if card.cost > player.energy:
return f"Not enough energy (have {player.energy}, need {card.cost})"
player.energy -= card.cost
player.board[slot] = card
player.hand.pop(hand_index)
return None
def action_sacrifice(state: GameState, slot: int) -> str | None:
if state.phase != "main":
return "Not in main phase"
player = state.active_player()
if slot < 0 or slot >= BOARD_SIZE:
return f"Invalid slot {slot}"
card = player.board[slot]
if card is None:
return "No card in that slot"
player.energy += card.cost
player.board[slot] = None
return None
def action_end_turn(state: GameState) -> str | None:
if state.phase != "main":
return "Not in main phase"
state.phase = "combat"
# Resolve combat
events = resolve_combat(state)
state.last_combat_events = events
# Check win condition after combat
result = check_win_condition(state)
if result:
state.result = result
state.phase = "end"
return None
# Switch to next player
next_player_id = state.opponent_id(state.active_player_id)
state.active_player_id = next_player_id
state.turn += 1
next_player = state.active_player()
next_player.increment_energy_cap()
next_player.refill_energy()
next_player.draw_to_full()
# Check if next player can't do anything
result = check_win_condition(state)
if result:
state.result = result
state.phase = "end"
return None
state.phase = "main"
state.turn_started_at = datetime.now()
return None

502
backend/game_manager.py Normal file
View File

@@ -0,0 +1,502 @@
import asyncio
import uuid
from datetime import datetime
import logging
import random
from dataclasses import dataclass
from fastapi import WebSocket
from sqlalchemy.orm import Session
from game import (
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE
)
from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
from card import compute_deck_type
logger = logging.getLogger("app")
AI_USER_ID = "ai"
## Storage
active_games: dict[str, GameState] = {}
active_deck_ids: dict[str, str|None] = {} # user_id -> deck_id
connections: dict[str, dict[str, WebSocket]] = {} # game_id -> {user_id -> websocket}
@dataclass
class QueueEntry:
user_id: str
deck_id: str
websocket: WebSocket
queue: list[QueueEntry] = []
queue_lock = asyncio.Lock()
## Game Result
def record_game_result(state: GameState, db: Session):
if state.result is None or state.result.winner_id is None:
return
winner_id_str = state.result.winner_id
loser_id_str = state.opponent_id(winner_id_str)
# Skip database updates for AI battles
if AI_USER_ID not in [winner_id_str, loser_id_str]:
winner = db.query(UserModel).filter(UserModel.id == uuid.UUID(winner_id_str)).first()
if winner:
winner.wins += 1
loser = db.query(UserModel).filter(UserModel.id == uuid.UUID(loser_id_str)).first()
if loser:
loser.losses += 1
winner_deck_id = active_deck_ids.get(winner_id_str)
loser_deck_id = active_deck_ids.get(loser_id_str)
if AI_USER_ID not in [winner_id_str, loser_id_str]:
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first()
if deck:
deck.wins += 1
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first()
if deck:
deck.losses += 1
db.commit()
## Serialization
def serialize_card(card: CardInstance|None) -> dict | None:
if card is None:
return None
return {
"instance_id": card.instance_id,
"card_id": card.card_id,
"name": card.name,
"attack": card.attack,
"defense": card.defense,
"max_defense": card.max_defense,
"cost": card.cost,
"card_type": card.card_type,
"card_rarity": card.card_rarity,
"image_link": card.image_link,
"text": card.text
}
def serialize_player(player: PlayerState, hide_hand=False) -> dict:
return {
"user_id": player.user_id,
"username": player.username,
"deck_type": player.deck_type,
"life": player.life,
"energy": player.energy,
"energy_cap": player.energy_cap,
"board": [serialize_card(c) for c in player.board],
"hand": [serialize_card(c) for c in player.hand] if not hide_hand else [],
"hand_size": len(player.hand),
"deck_size": len(player.deck),
}
def serialize_event(event: CombatEvent) -> dict:
return {
"attacker_slot": event.attacker_slot,
"attacker_name": event.attacker_name,
"defender_slot": event.defender_slot,
"defender_name": event.defender_name,
"damage": event.damage,
"defender_destroyed": event.defender_destroyed,
"life_damage": event.life_damage,
}
def serialize_state(state: GameState, perspective_user_id: str) -> dict:
opponent_id = state.opponent_id(perspective_user_id)
return {
"game_id": state.game_id,
"phase": state.phase,
"turn": state.turn,
"active_player_id": state.active_player_id,
"player_order": state.player_order,
"you": serialize_player(state.players[perspective_user_id]),
"opponent": serialize_player(state.players[opponent_id], hide_hand=True),
"last_combat_events": [serialize_event(e) for e in state.last_combat_events],
"result": {
"winner_id": state.result.winner_id,
"reason": state.result.reason,
} if state.result else None,
"turn_started_at": state.turn_started_at.isoformat() if state.turn_started_at else None,
}
## Broadcasting
async def broadcast_state(game_id: str):
state = active_games.get(game_id)
if not state:
return
game_connections = connections.get(game_id, {})
for user_id, ws in game_connections.items():
try:
await ws.send_json({
"type": "state",
"state": serialize_state(state, user_id),
})
except Exception:
pass
if state.active_player_id == AI_USER_ID and not state.result:
asyncio.create_task(run_ai_turn(game_id))
async def send_error(ws: WebSocket, message: str):
await ws.send_json({"type": "error", "message": message})
## Matchmaking
def load_deck_cards(deck_id: str, user_id: str, db: Session) -> list | None:
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == uuid.UUID(user_id)
).first()
if not deck:
return None
deck_card_ids = [
dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()
]
cards = db.query(CardModel).filter(CardModel.id.in_(deck_card_ids)).all()
return cards
async def try_match(db: Session):
async with queue_lock:
if len(queue) < 2:
return
p1_entry = queue.pop(0)
p2_entry = queue.pop(0)
p1_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p1_entry.user_id)).first()
p2_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p2_entry.user_id)).first()
p1_cards = load_deck_cards(p1_entry.deck_id, p1_entry.user_id, db)
p2_cards = load_deck_cards(p2_entry.deck_id, p2_entry.user_id, db)
p1_deck_type = compute_deck_type(p1_cards if p1_cards else [])
p2_deck_type = compute_deck_type(p2_cards if p2_cards else [])
active_deck_ids[p1_entry.user_id] = p1_entry.deck_id
active_deck_ids[p2_entry.user_id] = p2_entry.deck_id
for entry, _ in [(p1_entry, p1_cards), (p2_entry, p2_cards)]:
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(entry.deck_id)).first()
if deck:
deck.times_played += 1
db.commit()
if not p1_cards or not p2_cards or not p1_user or not p2_user:
await send_error(p1_entry.websocket, "Failed to load deck")
await send_error(p2_entry.websocket, "Failed to load deck")
return
state = create_game(
p1_entry.user_id, p1_user.username, p1_deck_type if p1_deck_type else "", p1_cards,
p2_entry.user_id, p2_user.username, p2_deck_type if p2_deck_type else "", p2_cards,
)
active_games[state.game_id] = state
connections[state.game_id] = {
p1_entry.user_id: p1_entry.websocket,
p2_entry.user_id: p2_entry.websocket,
}
# Notify both players the game has started
for user_id, ws in connections[state.game_id].items():
await ws.send_json({
"type": "game_start",
"game_id": state.game_id,
})
await broadcast_state(state.game_id)
## Action handler
async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
state = active_games.get(game_id)
if not state:
logger.warning(f"handle_action: game {game_id} not found")
return
if state.result:
logger.warning(f"handle_action: game {game_id} already over")
return
if state.active_player_id != user_id:
logger.warning(f"handle_action: not {user_id}'s turn, active is {state.active_player_id}")
ws = connections[game_id].get(user_id)
if ws:
await send_error(ws, "It's not your turn")
return
action = message.get("type")
err = None
if action == "play_card":
err = action_play_card(state, message["hand_index"], message["slot"])
if not err:
# Find the card that was just played
slot = message["slot"]
card_instance = state.players[user_id].board[slot]
if card_instance:
try:
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_instance.card_id)
).first()
if card:
card.times_played += 1
db.commit()
except Exception as e:
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
elif action == "sacrifice":
slot = message.get("slot")
if slot is None:
err = "No slot provided"
else:
# Find the card instance_id before it's removed
card = state.players[user_id].board[slot]
if card:
# Notify opponent first
opponent_id = state.opponent_id(user_id)
opp_ws = connections[game_id].get(opponent_id)
if opp_ws:
try:
await opp_ws.send_json({
"type": "sacrifice_animation",
"instance_id": card.instance_id,
})
except Exception:
pass
await asyncio.sleep(0.65)
err = action_sacrifice(state, slot)
elif action == "end_turn":
err = action_end_turn(state)
else:
ws = connections[game_id].get(user_id)
if ws:
await send_error(ws, f"Unknown action: {action}")
return
if err:
ws = connections[game_id].get(user_id)
if ws:
await send_error(ws, err)
return
await broadcast_state(game_id)
if state.result:
record_game_result(state, db)
for uid in list(connections.get(game_id,{}).keys()):
active_deck_ids.pop(uid, None)
active_games.pop(game_id, None)
connections.pop(game_id, None)
DISCONNECT_GRACE_SECONDS = 15
async def handle_disconnect(game_id: str, user_id: str, db: Session):
await asyncio.sleep(DISCONNECT_GRACE_SECONDS)
# Check if game still exists and player hasn't reconnected
if game_id not in active_games:
return
if user_id in connections.get(game_id, {}):
return # player reconnected during grace period
state = active_games[game_id]
if state.result:
return # game already ended normally
winner_id = state.opponent_id(user_id)
state.result = GameResult(
winner_id=winner_id,
reason="Opponent disconnected"
)
state.phase = "end"
record_game_result(state, db)
# Notify the remaining player
winner_ws = connections[game_id].get(winner_id)
if winner_ws:
try:
await winner_ws.send_json({
"type": "state",
"state": serialize_state(state, winner_id),
})
except Exception:
pass
active_deck_ids.pop(user_id, None)
active_deck_ids.pop(winner_id, None)
active_games.pop(game_id, None)
connections.pop(game_id, None)
TURN_TIME_LIMIT_SECONDS = 120
async def handle_timeout_claim(game_id: str, claimant_id: str, db: Session) -> str | None:
state = active_games.get(game_id)
if not state:
return "Game not found"
if state.result:
return "Game already ended"
if state.active_player_id == claimant_id:
return "It's your turn"
if not state.turn_started_at:
return "No turn timer running"
elapsed = (datetime.now() - state.turn_started_at).total_seconds()
if elapsed < TURN_TIME_LIMIT_SECONDS:
return f"Timer has not expired yet ({int(TURN_TIME_LIMIT_SECONDS - elapsed)}s remaining)"
state.result = GameResult(
winner_id=claimant_id,
reason="Opponent ran out of time"
)
state.phase = "end"
record_game_result(state, db)
await broadcast_state(game_id)
active_deck_ids.pop(state.active_player_id, None)
active_deck_ids.pop(claimant_id, None)
active_games.pop(game_id, None)
connections.pop(game_id, None)
return None
def create_solo_game(
user_id: str,
username: str,
player_cards: list,
ai_cards: list,
deck_id: str,
) -> str:
player_deck_type = compute_deck_type(player_cards) or "Balanced"
ai_deck_type = compute_deck_type(ai_cards) or "Balanced"
state = create_game(
user_id, username, player_deck_type, player_cards,
AI_USER_ID, "Computer", ai_deck_type, ai_cards,
)
active_games[state.game_id] = state
connections[state.game_id] = {}
active_deck_ids[user_id] = deck_id
active_deck_ids[AI_USER_ID] = None
if state.active_player_id == AI_USER_ID:
asyncio.create_task(run_ai_turn(state.game_id))
return state.game_id
ANIMATION_DELAYS = {
"pre_combat_pause": 0.5, # pause before end_turn
"per_attack_pre": 0.1, # delay before each attack animation
"lunge_duration": 0.42, # lunge animation duration
"shake_duration": 0.4, # shake animation duration
"damage_point": 0.22, # when damage is applied mid-shake
"post_attack": 0.08, # gap between attacks
"destroy_duration": 0.6, # crumble animation duration
"post_combat_buffer": 0.3, # buffer after all animations finish
}
def calculate_combat_animation_time(events: list[CombatEvent]) -> float:
total = 0.0
for event in events:
total += ANIMATION_DELAYS["per_attack_pre"]
# Lunge and shake run simultaneously, so take the longer of the two
total += max(
ANIMATION_DELAYS["lunge_duration"],
ANIMATION_DELAYS["shake_duration"]
)
total += ANIMATION_DELAYS["post_attack"]
if event.defender_destroyed:
total += ANIMATION_DELAYS["destroy_duration"]
total += ANIMATION_DELAYS["post_combat_buffer"]
return total
async def run_ai_turn(game_id: str):
state = active_games.get(game_id)
if not state or state.result:
return
if state.active_player_id != AI_USER_ID:
return
human_id = state.opponent_id(AI_USER_ID)
waited = 0
while not connections[game_id].get(human_id) and waited < 10:
await asyncio.sleep(0.5)
waited += 0.5
await asyncio.sleep(calculate_combat_animation_time(state.last_combat_events))
player = state.players[AI_USER_ID]
ws = connections[game_id].get(human_id)
async def send_state(state: GameState):
if ws:
try:
await ws.send_json({
"type": "state",
"state": serialize_state(state, human_id),
})
except Exception:
pass
most_expensive_in_hand = max((c.cost for c in player.hand), default=0)
if player.energy < most_expensive_in_hand:
for slot in range(BOARD_SIZE):
slot_card = player.board[slot]
if slot_card is not None and player.energy + slot_card.cost <= most_expensive_in_hand:
action_sacrifice(state, slot)
await send_state(state)
await asyncio.sleep(1)
play_order = list(range(BOARD_SIZE))
random.shuffle(play_order)
for slot in play_order:
if player.board[slot] is not None:
continue
affordable = [i for i, c in enumerate(player.hand) if c.cost <= player.energy]
if not affordable:
break
best = max(affordable, key=lambda i: player.hand[i].cost)
action_play_card(state, best, slot)
await send_state(state)
await asyncio.sleep(0.5)
action_end_turn(state)
await send_state(state)
if state.result:
from database import SessionLocal
db = SessionLocal()
try:
record_game_result(state, db)
if ws:
await ws.send_json({
"type": "state",
"state": serialize_state(state, human_id),
})
finally:
db.close()
active_deck_ids.pop(human_id, None)
active_deck_ids.pop(AI_USER_ID, None)
active_games.pop(game_id, None)
connections.pop(game_id, None)
return
if state.active_player_id == AI_USER_ID:
asyncio.create_task(run_ai_turn(game_id))

View File

@@ -1,20 +1,41 @@
import asyncio import asyncio
import logging import logging
import uuid import uuid
import re
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timedelta
from typing import cast, Callable
import secrets
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from fastapi import FastAPI, Depends, HTTPException, status from sqlalchemy import func
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from database import get_db from database import get_db
from database_functions import fill_card_pool, check_boosters, BOOSTER_MAX from database_functions import fill_card_pool, check_boosters, BOOSTER_MAX
from models import Card as CardModel from models import Card as CardModel
from models import User as UserModel from models import User as UserModel
from auth import hash_password, verify_password, create_access_token, decode_access_token from models import Deck as DeckModel
from models import DeckCard as DeckCardModel
from auth import (
hash_password, verify_password, create_access_token, create_refresh_token,
decode_access_token, decode_refresh_token
)
from game_manager import (
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
serialize_state, handle_disconnect, handle_timeout_claim, load_deck_cards, create_solo_game
)
from card import compute_deck_type, _get_specific_card_async
from email_utils import send_password_reset_email
from config import CORS_ORIGINS
logger = logging.getLogger("app") logger = logging.getLogger("app")
@@ -35,6 +56,13 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user return user
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordWithTokenRequest(BaseModel):
token: str
new_password: str
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
asyncio.create_task(fill_card_pool()) asyncio.create_task(fill_card_pool())
@@ -42,15 +70,36 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, cast(Callable, _rate_limit_exceeded_handler))
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], # SvelteKit's default dev port allow_origins=CORS_ORIGINS, # SvelteKit's default dev port
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip():
return "Username is required"
if len(username) > 16:
return "Username must be 16 characters or fewer"
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
return "Please enter a valid email"
if len(password) < 8:
return "Password must be at least 8 characters"
if len(password) > 256:
return "Password must be 256 characters or fewer"
return None
@app.post("/register") @app.post("/register")
def register(req: RegisterRequest, db: Session = Depends(get_db)): def register(req: RegisterRequest, db: Session = Depends(get_db)):
err = validate_register(req.username, req.email, req.password)
if err:
raise HTTPException(status_code=400, detail=err)
if db.query(UserModel).filter(UserModel.username == req.username).first(): if db.query(UserModel).filter(UserModel.username == req.username).first():
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():
@@ -70,15 +119,29 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
user = db.query(UserModel).filter(UserModel.username == form.username).first() user = db.query(UserModel).filter(UserModel.username == form.username).first()
if not user or not verify_password(form.password, user.password_hash): if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=400, detail="Invalid username or password") raise HTTPException(status_code=400, detail="Invalid username or password")
token = create_access_token(str(user.id)) return {
return {"access_token": token, "token_type": "bearer"} "access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}
@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)) -> tuple[int,datetime|None]:
return check_boosters(user, db) return check_boosters(user, db)
@app.get("/cards")
def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
cards = db.query(CardModel).filter(CardModel.user_id == user.id).all()
return [
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type}
for card in cards
]
@app.post("/open_pack") @app.post("/open_pack")
async def open_pack(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): @limiter.limit("10/minute")
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
check_boosters(user, db) check_boosters(user, db)
if user.boosters == 0: if user.boosters == 0:
@@ -92,15 +155,16 @@ async def open_pack(user: UserModel = Depends(get_current_user), db: Session = D
) )
if len(cards) < 5: if len(cards) < 5:
asyncio.create_task(fill_card_pool())
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly") raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
for card in cards: for card in cards:
card.user_id = user.id card.user_id = user.id
# was_full = user.boosters == BOOSTER_MAX was_full = user.boosters == BOOSTER_MAX
# user.boosters -= 1 user.boosters -= 1
# if was_full: if was_full:
# user.boosters_countdown = datetime.now() user.boosters_countdown = datetime.now()
db.commit() db.commit()
@@ -112,3 +176,356 @@ async def open_pack(user: UserModel = Depends(get_current_user), db: Session = D
"card_type": card.card_type} "card_type": card.card_type}
for card in cards for card in cards
] ]
@app.get("/decks")
def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
decks = db.query(DeckModel).filter(
DeckModel.user_id == user.id,
DeckModel.deleted == False
).order_by(DeckModel.created_at).all()
result = []
for deck in decks:
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
cards = db.query(CardModel).filter(CardModel.id.in_(card_ids)).all()
result.append({
"id": str(deck.id),
"name": deck.name,
"card_count": len(cards),
"times_played": deck.times_played,
"wins": deck.wins,
"losses": deck.losses,
"deck_type": compute_deck_type(cards),
})
return result
@app.post("/decks")
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
db.add(deck)
db.commit()
return {"id": str(deck.id), "name": deck.name, "card_count": 0}
@app.patch("/decks/{deck_id}")
def update_deck(deck_id: str, body: dict, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if "name" in body:
deck.name = body["name"]
if "card_ids" in body:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
for card_id in body["card_ids"][:20]:
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
if deck.times_played > 0:
deck.wins = 0
deck.losses = 0
deck.times_played = 0
db.commit()
return {"id": str(deck.id), "name": deck.name}
@app.delete("/decks/{deck_id}")
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if deck.times_played > 0:
deck.deleted = True
else:
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
db.delete(deck)
db.commit()
return {"message": "Deleted"}
@app.get("/decks/{deck_id}/cards")
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
deck_cards = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()
return [str(dc.card_id) for dc in deck_cards]
@app.websocket("/ws/queue")
async def queue_endpoint(websocket: WebSocket, deck_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
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == uuid.UUID(user_id)
).first()
if not deck:
await websocket.send_json({"type": "error", "message": "Deck not found"})
await websocket.close(code=1008)
return
card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count()
if card_count < 20:
await websocket.send_json({"type": "error", "message": "Deck must have 20 cards"})
await websocket.close(code=1008)
return
entry = QueueEntry(user_id=user_id, deck_id=deck_id, websocket=websocket)
async with queue_lock:
queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_match(db)
try:
while True:
# Keeping socket alive
await websocket.receive_text()
except WebSocketDisconnect:
async with queue_lock:
queue[:] = [e for e in queue if e.user_id != user_id]
@app.websocket("/ws/game/{game_id}")
async def game_endpoint(websocket: WebSocket, game_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
if game_id not in active_games:
await websocket.close(code=1008)
return
# Register this connection (handles reconnects)
connections[game_id][user_id] = websocket
# Send current state immediately on connect
await websocket.send_json({
"type": "state",
"state": serialize_state(active_games[game_id], user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_action(game_id, user_id, data, db)
except WebSocketDisconnect:
if game_id in connections:
connections[game_id].pop(user_id, None)
asyncio.create_task(handle_disconnect(game_id, user_id, db))
@app.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
total_games = user.wins + user.losses
most_played_deck = (
db.query(DeckModel)
.filter(DeckModel.user_id == user.id, DeckModel.times_played > 0)
.order_by(DeckModel.times_played.desc())
.first()
)
most_played_card = (
db.query(CardModel)
.filter(CardModel.user_id == user.id, CardModel.times_played > 0)
.order_by(CardModel.times_played.desc())
.first()
)
return {
"username": user.username,
"email": user.email,
"created_at": user.created_at,
"wins": user.wins,
"losses": user.losses,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"most_played_deck": {
"name": most_played_deck.name,
"times_played": most_played_deck.times_played,
} if most_played_deck else None,
"most_played_card": {
"name": most_played_card.name,
"times_played": most_played_card.times_played,
"card_type": most_played_card.card_type,
"card_rarity": most_played_card.card_rarity,
"image_link": most_played_card.image_link,
} if most_played_card else None,
}
@app.post("/cards/{card_id}/report")
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
card.reported = True
db.commit()
return {"message": "Card reported"}
@app.post("/cards/{card_id}/refresh")
@limiter.limit("5/hour")
async def refresh_card(request: Request, card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
card = db.query(CardModel).filter(
CardModel.id == uuid.UUID(card_id),
CardModel.user_id == user.id
).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=48):
remaining = (user.last_refresh_at + timedelta(hours=48)) - datetime.now()
hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60)
raise HTTPException(
status_code=429,
detail=f"You can refresh again in {hours}h {minutes}m"
)
new_card = await _get_specific_card_async(card.name)
if not new_card:
raise HTTPException(status_code=502, detail="Failed to regenerate card from Wikipedia")
card.image_link = new_card.image_link
card.card_rarity = new_card.card_rarity.name
card.card_type = new_card.card_type.name
card.text = new_card.text
card.attack = new_card.attack
card.defense = new_card.defense
card.cost = new_card.cost
card.reported = False
user.last_refresh_at = datetime.now()
db.commit()
return {
**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type,
}
@app.get("/profile/refresh-status")
def refresh_status(user: UserModel = Depends(get_current_user)):
if not user.last_refresh_at:
return {"can_refresh": True, "next_refresh_at": None}
next_refresh = user.last_refresh_at + timedelta(hours=48)
can_refresh = datetime.now() >= next_refresh
return {
"can_refresh": can_refresh,
"next_refresh_at": next_refresh.isoformat() if not can_refresh else None,
}
@app.post("/game/{game_id}/claim-timeout-win")
async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
err = await handle_timeout_claim(game_id, str(user.id), db)
if err:
raise HTTPException(status_code=400, detail=err)
return {"message": "Win claimed"}
@app.post("/game/solo")
async def start_solo_game(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.user_id == user.id
).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
card_count = db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).count()
if card_count < 20:
raise HTTPException(status_code=400, detail="Deck must have 20 cards")
player_cards = load_deck_cards(deck_id, str(user.id), db)
if player_cards is None:
raise HTTPException(status_code=503, detail="Couldn't load deck")
ai_cards = db.query(CardModel).filter(
CardModel.user_id == None
).order_by(func.random()).limit(20).all()
if len(ai_cards) < 20:
raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
for card in ai_cards:
db.delete(card)
deck.times_played += 1
db.commit()
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id)
asyncio.create_task(fill_card_pool())
return {"game_id": game_id}
class ResetPasswordRequest(BaseModel):
current_password: str
new_password: str
@app.post("/auth/reset-password")
def reset_password(req: ResetPasswordRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not verify_password(req.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
if req.current_password == req.new_password:
raise HTTPException(status_code=400, detail="New password must be different from current password")
user.password_hash = hash_password(req.new_password)
db.commit()
return {"message": "Password updated"}
@app.post("/auth/forgot-password")
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success even if email not found — prevents user enumeration
if user:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now() + timedelta(hours=1)
db.commit()
try:
send_password_reset_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to send reset email: {e}")
return {"message": "If that email is registered you will receive a reset link shortly"}
@app.post("/auth/reset-password-with-token")
def reset_password_with_token(req: ResetPasswordWithTokenRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.reset_token == req.token).first()
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired reset link")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if len(req.new_password) > 256:
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
user.password_hash = hash_password(req.new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return {"message": "Password updated"}
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/auth/refresh")
def refresh(req: RefreshRequest, db: Session = Depends(get_db)):
user_id = decode_refresh_token(req.refresh_token)
if not user_id:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
"token_type": "bearer",
}

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Integer, ForeignKey, DateTime, Text from sqlalchemy import String, Integer, ForeignKey, DateTime, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from database import Base from database import Base
@@ -15,6 +15,11 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False) boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
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)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reset_token: Mapped[str | None] = mapped_column(String, nullable=True)
reset_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
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")
@@ -34,6 +39,8 @@ class Card(Base):
defense: Mapped[int] = mapped_column(Integer, nullable=False) defense: Mapped[int] = mapped_column(Integer, nullable=False)
cost: Mapped[int] = mapped_column(Integer, nullable=False) cost: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reported: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User | None"] = relationship(back_populates="cards") user: Mapped["User | None"] = relationship(back_populates="cards")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card") deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card")
@@ -46,6 +53,10 @@ class Deck(Base):
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
times_played: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
wins: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
losses: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship(back_populates="decks") user: Mapped["User"] = relationship(back_populates="decks")
deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck") deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck")

View File

@@ -2,7 +2,9 @@ fastapi==0.135.1
httpx==0.28.1 httpx==0.28.1
passlib==1.7.4 passlib==1.7.4
pydantic==2.12.5 pydantic==2.12.5
python-dotenv==1.2.2
python_jose==3.5.0 python_jose==3.5.0
resend==2.25.0
slowapi==0.1.9
SQLAlchemy==2.0.48 SQLAlchemy==2.0.48
python-multipart
bcrypt==4.3.0 bcrypt==4.3.0

View File

@@ -1 +0,0 @@
uvicorn main:app --reload --log-config=log_conf.yaml

444
backend/test_game.py Normal file
View File

@@ -0,0 +1,444 @@
from game import (
GameState, PlayerState, CardInstance, CombatEvent, GameResult,
create_game, resolve_combat, check_win_condition,
action_play_card, action_sacrifice, action_end_turn,
BOARD_SIZE, HAND_SIZE, STARTING_LIFE, MAX_ENERGY_CAP,
)
import uuid
# ── Helpers ──────────────────────────────────────────────────────────────────
def make_card(name="Test Card", attack=10, defense=10, cost=2, **kwargs) -> CardInstance:
return CardInstance(
instance_id=str(uuid.uuid4()),
card_id=str(uuid.uuid4()),
name=name,
attack=attack,
defense=defense,
max_defense=defense,
cost=cost,
card_type="other",
card_rarity="common",
image_link="",
text="",
**kwargs,
)
def make_game(
p1_board=None, p2_board=None,
p1_hand=None, p2_hand=None,
p1_deck=None, p2_deck=None,
p1_life=STARTING_LIFE, p2_life=STARTING_LIFE,
p1_energy=6, p2_energy=6,
) -> GameState:
p1 = PlayerState(
user_id="p1",
username="player 1",
deck_type="test",
life=p1_life,
hand=p1_hand or [],
deck=p1_deck or [],
board=p1_board or [None] * BOARD_SIZE,
energy=p1_energy,
energy_cap=6,
)
p2 = PlayerState(
user_id="p2",
username="player 2",
deck_type="test",
life=p2_life,
hand=p2_hand or [],
deck=p2_deck or [],
board=p2_board or [None] * BOARD_SIZE,
energy=p2_energy,
energy_cap=6,
)
return GameState(
game_id="test-game",
players={"p1": p1, "p2": p2},
player_order=["p1", "p2"],
active_player_id="p1",
phase="main",
)
# ── create_game ───────────────────────────────────────────────────────────────
class TestCreateGame:
def test_both_players_present(self):
class FakeCard:
id = uuid.uuid4()
name = "Card"
attack = 5
defense = 5
cost = 1
card_type = "other"
card_rarity = "common"
image_link = ""
text = ""
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
assert "p1" in state.players
assert "p2" in state.players
def test_first_player_has_drawn(self):
class FakeCard:
id = uuid.uuid4()
name = "Card"
attack = 5
defense = 5
cost = 1
card_type = "other"
card_rarity = "common"
image_link = ""
text = ""
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
first = state.players[state.active_player_id]
assert len(first.hand) == HAND_SIZE
def test_second_player_has_not_drawn(self):
class FakeCard:
id = uuid.uuid4()
name = "Card"
attack = 5
defense = 5
cost = 1
card_type = "other"
card_rarity = "common"
image_link = ""
text = ""
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
second_id = state.opponent_id(state.active_player_id)
second = state.players[second_id]
assert len(second.hand) == 0
def test_first_player_starts_with_energy(self):
class FakeCard:
id = uuid.uuid4()
name = "Card"
attack = 5
defense = 5
cost = 1
card_type = "other"
card_rarity = "common"
image_link = ""
text = ""
cards = [FakeCard() for _ in range(20)]
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
first = state.players[state.active_player_id]
opp = state.players[state.opponent_id(state.active_player_id)]
assert first.energy == 1
assert first.energy_cap == 1
assert opp.energy == 0
assert opp.energy_cap == 1
# ── action_play_card ──────────────────────────────────────────────────────────
class TestPlayCard:
def test_play_card_successfully(self):
card = make_card(cost=2)
state = make_game(p1_hand=[card], p1_energy=3)
err = action_play_card(state, hand_index=0, slot=0)
assert err is None
assert state.players["p1"].board[0] is card
assert state.players["p1"].energy == 1
assert card not in state.players["p1"].hand
def test_play_card_multiple(self):
card1 = make_card(cost=2)
card2 = make_card(cost=1)
state = make_game(p1_hand=[card1,card2], p1_energy=3)
err1 = action_play_card(state, hand_index=0, slot=0)
err2 = action_play_card(state, hand_index=0, slot=1)
assert err1 is None
assert err2 is None
assert state.players["p1"].board[0] is card1
assert state.players["p1"].board[1] is card2
assert state.players["p1"].energy == 0
assert card1 not in state.players["p1"].hand
assert card2 not in state.players["p1"].hand
def test_play_card_not_enough_energy(self):
card = make_card(cost=5)
state = make_game(p1_hand=[card], p1_energy=2)
err = action_play_card(state, hand_index=0, slot=0)
assert err is not None
assert err == "Not enough energy (have 2, need 5)"
assert state.players["p1"].board[0] is None
def test_play_card_slot_occupied(self):
existing = make_card(name="Existing")
new_card = make_card(name="New")
board = [existing] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=board, p1_hand=[new_card], p1_energy=6)
err = action_play_card(state, hand_index=0, slot=0)
assert err is not None
assert err == "Slot already occupied"
assert state.players["p1"].board[0] is existing
def test_play_card_invalid_slot(self):
card = make_card()
state = make_game(p1_hand=[card], p1_energy=6)
err = action_play_card(state, hand_index=0, slot=99)
assert err is not None
assert err == "Invalid slot 99"
def test_play_card_invalid_hand_index(self):
state = make_game(p1_hand=[], p1_energy=6)
err = action_play_card(state, hand_index=0, slot=0)
assert err is not None
assert err == "Invalid hand index"
def test_play_card_wrong_phase(self):
card = make_card()
state = make_game(p1_hand=[card], p1_energy=6)
state.phase = "combat"
err = action_play_card(state, hand_index=0, slot=0)
assert err is not None
assert err == "Not in main phase"
def test_play_card_exact_energy(self):
card = make_card(cost=3)
state = make_game(p1_hand=[card], p1_energy=3)
err = action_play_card(state, hand_index=0, slot=0)
assert err is None
assert state.players["p1"].energy == 0
# ── action_sacrifice ──────────────────────────────────────────────────────────
class TestSacrifice:
def test_sacrifice_returns_energy(self):
card = make_card(cost=3)
board = [card] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=board, p1_energy=1)
err = action_sacrifice(state, slot=0)
assert err is None
assert state.players["p1"].board[0] is None
assert state.players["p1"].energy == 4
def test_sacrifice_empty_slot(self):
state = make_game(p1_energy=3)
err = action_sacrifice(state, slot=0)
assert err is not None
assert err == "No card in that slot"
def test_sacrifice_invalid_slot(self):
state = make_game(p1_energy=3)
err = action_sacrifice(state, slot=99)
assert err is not None
assert err == "Invalid slot 99"
def test_sacrifice_wrong_phase(self):
card = make_card(cost=3)
board = [card] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=board, p1_energy=1)
state.phase = "combat"
err = action_sacrifice(state, slot=0)
assert err is not None
assert err == "Not in main phase"
def test_sacrifice_then_replay(self):
cheap = make_card(name="Cheap", cost=3)
expensive = make_card(name="Expensive", cost=5)
board = [None] + [cheap] + [None] * (BOARD_SIZE - 2)
state = make_game(p1_board=board, p1_hand=[expensive], p1_energy=2)
err1 = action_play_card(state, hand_index=0, slot=0)
assert err1 is not None
assert err1 == "Not enough energy (have 2, need 5)"
action_sacrifice(state, slot=1)
assert state.players["p1"].energy == 5
err2 = action_play_card(state, hand_index=0, slot=0)
assert err2 is None
# ── resolve_combat ────────────────────────────────────────────────────────────
class TestResolveCombat:
def test_uncontested_attack_hits_life(self):
attacker = make_card(attack=5)
board = [attacker] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=board)
events = resolve_combat(state)
assert state.players["p2"].life == STARTING_LIFE - 5
assert len(events) == 1
assert events[0].life_damage == 5
assert events[0].defender_slot is None
def test_contested_attack_reduces_defense(self):
attacker = make_card(attack=4)
defender = make_card(defense=10)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_board=p2_board)
events = resolve_combat(state)
assert defender.defense == 6
assert state.players["p2"].life == STARTING_LIFE
assert events[0].life_damage == 0
assert not events[0].defender_destroyed
def test_attack_destroys_defender(self):
attacker = make_card(attack=10)
defender = make_card(defense=5)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_board=p2_board)
events = resolve_combat(state)
assert state.players["p2"].board[0] is None
assert events[0].defender_destroyed is True
def test_no_overflow_damage(self):
attacker = make_card(attack=100)
defender = make_card(defense=5)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_board=p2_board)
resolve_combat(state)
assert state.players["p2"].life == STARTING_LIFE
def test_multiple_attackers(self):
attackers = [make_card(attack=3) for _ in range(3)]
p1_board = attackers + [None, None]
state = make_game(p1_board=p1_board)
resolve_combat(state)
assert state.players["p2"].life == STARTING_LIFE - 9
def test_empty_board_no_events(self):
state = make_game()
events = resolve_combat(state)
assert events == []
assert state.players["p2"].life == STARTING_LIFE
def test_exact_defense_destroys(self):
attacker = make_card(attack=5)
defender = make_card(defense=5)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_board=p2_board)
events = resolve_combat(state)
assert state.players["p2"].board[0] is None
assert events[0].defender_destroyed is True
# ── check_win_condition ───────────────────────────────────────────────────────
class TestWinCondition:
def test_no_win_initially(self):
state = make_game()
assert check_win_condition(state) is None
def test_p2_life_zero(self):
state = make_game(p2_life=0)
result = check_win_condition(state)
assert result is not None
assert result.winner_id == "p1"
assert result.reason == "Life reduced to zero"
def test_p1_life_zero(self):
state = make_game(p1_life=0)
result = check_win_condition(state)
assert result is not None
assert result.winner_id == "p2"
assert result.reason == "Life reduced to zero"
def test_p1_life_negative(self):
state = make_game(p1_life=-5)
result = check_win_condition(state)
assert result is not None
assert result.winner_id == "p2"
assert result.reason == "Life reduced to zero"
def test_win_by_no_cards(self):
card = make_card()
p1_board = [card] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_hand=[], p2_deck=[])
result = check_win_condition(state)
assert result is not None
assert result.winner_id == "p1"
assert result.reason == "Opponent has no playable cards remaining"
def test_no_win_if_p2_has_cards_in_hand(self):
card_on_board = make_card()
card_in_hand = make_card()
p1_board = [card_on_board] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board, p2_hand=[card_in_hand])
assert check_win_condition(state) is None
def test_no_win_if_attacker_has_no_board(self):
"""p1 has nothing on board even though p2 is empty."""
state = make_game(p2_hand=[], p2_deck=[])
assert check_win_condition(state) is None
# ── action_end_turn ───────────────────────────────────────────────────────────
class TestEndTurn:
def test_switches_active_player(self):
state = make_game()
action_end_turn(state)
assert state.active_player_id == "p2"
def test_next_player_draws(self):
deck = [make_card() for _ in range(10)]
state = make_game(p2_deck=deck)
action_end_turn(state)
assert len(state.players["p2"].hand) == HAND_SIZE
def test_next_player_energy_increments(self):
state = make_game()
state.players["p2"].energy_cap = 2
action_end_turn(state)
assert state.players["p2"].energy_cap == 3
assert state.players["p2"].energy == 3
def test_energy_cap_maxes_out(self):
state = make_game()
state.players["p2"].energy_cap = MAX_ENERGY_CAP
action_end_turn(state)
assert state.players["p2"].energy_cap == MAX_ENERGY_CAP
def test_combat_events_recorded(self):
attacker = make_card(attack=5)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board)
action_end_turn(state)
assert len(state.last_combat_events) == 1
def test_game_ends_on_lethal(self):
attacker = make_card(attack=STARTING_LIFE)
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
state = make_game(p1_board=p1_board)
action_end_turn(state)
assert state.result is not None
assert state.result.winner_id == "p1"
assert state.phase == "end"
def test_wrong_phase_returns_error(self):
state = make_game()
state.phase = "combat"
err = action_end_turn(state)
assert err is not None
def test_draw_partial_when_deck_small(self):
deck = [make_card() for _ in range(2)]
state = make_game(p2_deck=deck)
action_end_turn(state)
assert len(state.players["p2"].hand) == 2
def test_full_turn_cycle(self):
card = make_card(cost=1)
deck = [make_card() for _ in range(10)]
state = make_game(p1_hand=[card], p2_deck=deck)
action_play_card(state, hand_index=0, slot=0)
action_end_turn(state)
assert state.active_player_id == "p2"
assert len(state.players["p2"].hand) == HAND_SIZE
assert state.phase == "main"

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
services:
db:
image: postgres:16
container_name: wikitcg-db
restart: unless-stopped
environment:
POSTGRES_USER: wikitcg
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: wikitcg
volumes:
- /mnt/user/appdata/wikitcg/postgres:/var/lib/postgresql/data
networks:
- wikitcg
backend:
build: ./backend
container_name: wikitcg-backend
restart: unless-stopped
depends_on:
- db
environment:
DATABASE_URL: postgresql://wikitcg:${DB_PASSWORD}@db/wikitcg
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
RESEND_API_KEY: ${RESEND_API_KEY}
EMAIL_FROM: ${EMAIL_FROM}
FRONTEND_URL: ${FRONTEND_URL}
CORS_ORIGINS: ${FRONTEND_URL}
WIKIRANK_USER_AGENT: ${WIKIRANK_USER_AGENT}
ports:
- "8000:8000"
networks:
- wikitcg
frontend:
build:
context: ./frontend
args:
PUBLIC_API_URL: ${BACKEND_URL}
container_name: wikitcg-frontend
restart: unless-stopped
ports:
- "3000:80"
networks:
- wikitcg
networks:
wikitcg:
driver: bridge

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
PUBLIC_API_URL=http://localhost:8000

15
frontend/dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG PUBLIC_API_URL
ENV PUBLIC_API_URL=$PUBLIC_API_URL
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

8
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,8 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,5 +1,5 @@
<script> <script>
let { card } = $props(); let { card, noHover = false, defenseOverride = null } = $props();
const RARITY_BADGE = { const RARITY_BADGE = {
common: { symbol: "C", label: "Common", bg: "#c8c8c8", color: "#333" }, common: { symbol: "C", label: "Common", bg: "#c8c8c8", color: "#333" },
@@ -15,12 +15,12 @@
location: { bg: "#d8e8d4", header: "#4a7a50" }, location: { bg: "#d8e8d4", header: "#4a7a50" },
artwork: { bg: "#e4d4e8", header: "#7a5090" }, artwork: { bg: "#e4d4e8", header: "#7a5090" },
life_form: { bg: "#ccdce8", header: "#3a6878" }, life_form: { bg: "#ccdce8", header: "#3a6878" },
conflict: { bg: "#e8d4d4", header: "#8b2020" }, event: { bg: "#e8d4d4", header: "#8b2020" },
group: { bg: "#e8e4d0", header: "#748c12" }, group: { bg: "#e8e4d0", header: "#748c12" },
science_thing: { bg: "#c7c5c1", header: "#060c17" }, science_thing: { bg: "#c7c5c1", header: "#060c17" },
vehicle: { bg: "#c7c1c4", header: "#801953" }, vehicle: { bg: "#c7c1c4", header: "#801953" },
business: { bg: "#b7c1c4", header: "#3c5251" }, organization: { bg: "#b7c1c4", header: "#3c5251" },
other: { bg: "#dddad4", header: "#6a6860" }, other: { bg: "#dddad4", header: "#827e6f" },
}; };
const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]); const FOIL_RARITIES = new Set(["super_rare", "epic", "legendary"]);
@@ -37,7 +37,7 @@
let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_"))); let wikiUrl = $derived("https://en.wikipedia.org/wiki/" + encodeURIComponent(card.name.replace(/ /g, "_")));
</script> </script>
<div class="card" class:foil class:super_rare class:epic class:legendary style="--foil-offset: {foilOffset}"> <div class="card" class:foil class:super_rare class:epic class:legendary class:no-hover={noHover} style="--foil-offset: {foilOffset}">
<div class="card-inner" style="--bg: {colors.bg}; --header: {colors.header}"> <div class="card-inner" style="--bg: {colors.bg}; --header: {colors.header}">
<div class="card-header"> <div class="card-header">
@@ -77,7 +77,7 @@
<div class="card-footer"> <div class="card-footer">
<span class="stat">ATK <strong>{card.attack}</strong></span> <span class="stat">ATK <strong>{card.attack}</strong></span>
<span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span> <span class="card-date">{new Date(card.created_at).toLocaleDateString()}</span>
<span class="stat">DEF <strong>{card.defense}</strong></span> <span class="stat">DEF <strong>{defenseOverride !== null ? defenseOverride : card.defense}</strong></span>
</div> </div>
</div> </div>
@@ -133,11 +133,11 @@
.card.foil.epic::before { .card.foil.epic::before {
background: repeating-linear-gradient( background: repeating-linear-gradient(
105deg, 105deg,
rgba(255,0,128,0.3) 0%, rgba(255,0,128,0.28) 0%,
rgba(255,200,0,0.3) 10%, rgba(255,200,0,0.26) 10%,
rgba(0,255,128,0.3) 20%, rgba(0,255,128,0.24) 20%,
rgba(0,200,255,0.3) 30%, rgba(0,200,255,0.26) 30%,
rgba(128,0,255,0.3) 40%, rgba(128,0,255,0.28) 40%,
rgba(255,0,128,0.3) 50% rgba(255,0,128,0.3) 50%
); );
background-size: 300% 300%; background-size: 300% 300%;
@@ -147,10 +147,10 @@
background: repeating-linear-gradient( background: repeating-linear-gradient(
105deg, 105deg,
rgba(255,215,0,0.35) 0%, rgba(255,215,0,0.35) 0%,
rgba(255,180,0,0.15) 15%, rgba(255,180,0,0.08) 15%,
rgba(255,255,180,0.40) 30%, rgba(255,255,180,0.35) 30%,
rgba(255,200,0,0.15) 45%, rgba(255,200,0,0.08) 45%,
rgba(255,215,0,0.35) 60% rgba(255,215,0,0.30) 60%
); );
animation-duration: 1.8s; animation-duration: 1.8s;
background-size: 300% 300%; background-size: 300% 300%;
@@ -348,4 +348,9 @@
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
line-height: 1; line-height: 1;
} }
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
</style> </style>

View File

@@ -0,0 +1,31 @@
<script>
let { deckType } = $props();
</script>
{#if deckType}
<span class="type-badge type-{deckType.toLowerCase().replace(' ', '-')}">
{deckType}
</span>
{/if}
<style>
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
display: inline-block;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
</style>

65
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,65 @@
let isRefreshing = false;
let refreshPromise = null;
import { PUBLIC_API_URL } from '$env/static/public';
export const API_URL = PUBLIC_API_URL;
export const WS_URL = PUBLIC_API_URL.replace('http', 'ws');
async function refreshTokens() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) throw new Error('No refresh token');
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
window.location.href = '/auth';
throw new Error('Refresh failed');
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data.access_token;
}
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/auth';
throw new Error('No token');
}
const headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshTokens().finally(() => { isRefreshing = false; });
}
try {
const newToken = await refreshPromise;
const retryRes = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
});
return retryRes;
} catch {
window.location.href = '/auth';
throw new Error('Unauthorized');
}
}
return res;
}

View File

@@ -2,12 +2,6 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
function logout() {
localStorage.removeItem('token');
goto('/auth');
close();
}
let menuOpen = $state(false); let menuOpen = $state(false);
const links = [ const links = [
@@ -15,6 +9,7 @@
{ 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' },
]; ];
function close() { menuOpen = false; } function close() { menuOpen = false; }
@@ -27,7 +22,7 @@
{#each links as link} {#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a> <a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
{/each} {/each}
<button class="logout" onclick={logout}>Log out</button> <a href="/profile" class:active={$page.url.pathname === '/profile'}>Profile</a>
</nav> </nav>
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu"> <button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
@@ -43,7 +38,6 @@
{#each links as link} {#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a> <a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each} {/each}
<button class="logout mobile-logout" onclick={logout}>Log out</button>
</nav> </nav>
{/if} {/if}
@@ -159,33 +153,6 @@
color: #f0d080; color: #f0d080;
} }
.logout {
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(200, 80, 80, 0.7);
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
width: auto;
transition: color 0.15s, border-color 0.15s;
}
.logout:hover {
color: #c84040;
border-bottom-color: #c84040;
}
.mobile-logout {
font-size: 13px;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
text-align: left;
}
@media (max-width: 640px) { @media (max-width: 640px) {
nav.desktop { display: none; } nav.desktop { display: none; }
.hamburger { display: flex; } .hamburger { display: flex; }

View File

@@ -7,7 +7,7 @@
let { children } = $props(); let { children } = $props();
</script> </script>
{#if page.url.pathname !== '/auth'} {#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))}
<Header /> <Header />
{/if} {/if}

View File

@@ -1,4 +1,6 @@
<script> <script>
import { API_URL, WS_URL } 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 Card from '$lib/Card.svelte'; import Card from '$lib/Card.svelte';
@@ -23,9 +25,7 @@
}); });
async function fetchBoosters() { async function fetchBoosters() {
const res = await fetch('http://localhost:8000/boosters', { const res = await apiFetch(`${API_URL}/boosters`);
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
if (res.status === 401) { goto('/auth'); return; } if (res.status === 401) { goto('/auth'); return; }
const [count, countdownTs] = await res.json(); const [count, countdownTs] = await res.json();
boosters = count; boosters = count;
@@ -74,9 +74,8 @@
phase = 'dropping'; phase = 'dropping';
// Fetch while pack is sliding away // Fetch while pack is sliding away
const fetchPromise = fetch('http://localhost:8000/open_pack', { const fetchPromise = apiFetch(`${API_URL}/open_pack`, {
method: 'POST', method: 'POST'
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
}); });
await delay(700); await delay(700);
@@ -320,7 +319,7 @@
/* no-top clips the pack body below the tear line */ /* no-top clips the pack body below the tear line */
.booster-pack.no-top { .booster-pack.no-top {
clip-path: inset(60px 0 0 0 round 0 0 10px 10px); clip-path: inset(41px 0 0 0 round 0 0 10px 10px);
} }
/* ── Overlay ── */ /* ── Overlay ── */
@@ -359,15 +358,16 @@
top: 0; top: 0;
left: 0; left: 0;
width: 300px; width: 300px;
height: 60px; height: 41px;
background: #dfdbcf; background: #dfdbcf;
background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%); /* background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%); */
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
90deg, 90deg,
rgba(255,255,255,0.18) 0%, rgba(255,255,255,0.25),
rgba(255,255,255,0.18) 1px, rgba(0,0,0,0.15) 2%,
transparent 1px, rgba(0,0,0,0.08) 3%,
transparent 8px transparent 3%,
transparent 4%
); );
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
transform-origin: top left; transform-origin: top left;

View File

@@ -1,4 +1,5 @@
<script> <script>
import { API_URL, WS_URL } from '$lib/api.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
let mode = $state('login'); let mode = $state('login');
@@ -8,16 +9,26 @@
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
function validate() {
if (!username.trim()) return 'Username is required';
if (username.length > 16) return 'Username must be 16 characters or fewer';
if (mode === 'register') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email';
if (password.length < 8) return 'Password must be at least 8 characters';
if (password.length > 256) return 'Password must be 256 characters or fewer';
}
return null;
}
async function submit() { async function submit() {
error = ''; error = '';
if (!username.trim()) { const validationError = validate();
error = 'Username is required'; if (validationError) { error = validationError; return;}
return;
}
loading = true; loading = true;
try { try {
if (mode === 'register') { if (mode === 'register') {
const res = await fetch('http://localhost:8000/register', { const res = await fetch(`${API_URL}/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }), body: JSON.stringify({ username, email, password }),
@@ -32,13 +43,14 @@
const form = new FormData(); const form = new FormData();
form.append('username', username); form.append('username', username);
form.append('password', password); form.append('password', password);
const res = await fetch('http://localhost:8000/login', { const res = await fetch(`${API_URL}/login`, {
method: 'POST', method: 'POST',
body: form, body: form,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail); if (!res.ok) throw new Error(data.detail);
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
goto('/'); goto('/');
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -79,6 +91,7 @@
<button onclick={submit} disabled={loading}> <button onclick={submit} disabled={loading}>
{loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account'} {loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account'}
</button> </button>
<a href="/forgot-password" class="forgot-link">Forgot your password?</a>
<p class="toggle"> <p class="toggle">
{mode === 'login' ? "Don't have an account?" : 'Already have an account?'} {mode === 'login' ? "Don't have an account?" : 'Already have an account?'}
@@ -198,4 +211,15 @@
.link:hover { .link:hover {
color: #f0d080; color: #f0d080;
} }
.forgot-link {
font-family: 'Crimson Text', serif;
font-size: 13px;
color: rgba(245, 208, 96, 0.45);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.forgot-link:hover { color: rgba(245, 208, 96, 0.8); }
</style> </style>

View File

@@ -0,0 +1,635 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Card from '$lib/Card.svelte';
let allCards = $state([]);
let loading = $state(true);
const token = () => localStorage.getItem('token');
// Sort
let sortBy = $state('name');
// Filters
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let filtersOpen = $state(false);
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
function label(str) {
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(12);
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 = a.cost - b.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = a.attack - b.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = a.defense - b.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[a.card_rarity] - RARITY_ORDER[b.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);
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/cards`);
if (res.status === 401) { goto('/auth'); return; }
allCards = await res.json();
loading = false;
});
let selectedCard = $state(null);
let refreshStatus = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
let reportLoading = $state(false);
let refreshLoading = $state(false);
let actionMessage = $state('');
async function fetchRefreshStatus() {
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
refreshStatus = await res.json();
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
}
}
function startRefreshCountdown(nextRefreshAt) {
clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const diff = nextRefreshAt - Date.now();
if (diff <= 0) {
clearInterval(countdownInterval);
refreshStatus = { can_refresh: true, next_refresh_at: null };
countdownDisplay = '';
return;
}
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}, 1000);
}
function openCard(card) {
selectedCard = card;
actionMessage = '';
fetchRefreshStatus();
}
function closeCard() {
selectedCard = null;
clearInterval(countdownInterval);
countdownDisplay = '';
actionMessage = '';
}
async function reportCard() {
reportLoading = true;
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/report`, {
method: 'POST'
});
reportLoading = false;
if (res.ok) {
selectedCard = { ...selectedCard, reported: true };
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
actionMessage = 'Card reported. Thank you!';
} else {
actionMessage = 'Failed to report card.';
}
}
async function refreshCard() {
refreshLoading = true;
actionMessage = '';
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/refresh`, {
method: 'POST'
});
refreshLoading = false;
if (res.ok) {
const updated = await res.json();
// Update card in allCards list
allCards = allCards.map(c => c.id === updated.id ? updated : c);
selectedCard = updated;
refreshStatus = { can_refresh: false, next_refresh_at: null };
await fetchRefreshStatus();
actionMessage = 'Card refreshed!';
} else {
const err = await res.json();
actionMessage = err.detail ?? 'Failed to refresh card.';
}
}
</script>
<main>
<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}
<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 = 12; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
{/if}
</div>
{#if loading}
<p class="status">Loading your cards...</p>
{:else if filtered.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
<div class="grid">
{#each filtered as card (card.id)}
<button class="card-btn" onclick={() => openCard(card)}>
<Card {card} />
</button>
{/each}
</div>
{/if}
{#if selectedCard}
<div class="backdrop" onclick={closeCard}>
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
<Card card={selectedCard} />
<div class="popup-actions">
<div class="action-col">
<button
class="report-btn"
onclick={reportCard}
disabled={reportLoading || selectedCard.reported}
>
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
</button>
</div>
<div class="action-col">
<button
class="refresh-btn"
onclick={refreshCard}
disabled={refreshLoading || (refreshStatus && !refreshStatus.can_refresh)}
>
{refreshLoading ? 'Refreshing...' : 'Refresh Card'}
</button>
<span class="refresh-countdown">
{refreshStatus && !refreshStatus.can_refresh && countdownDisplay ? `${countdownDisplay} until next refresh` : ''}
</span>
</div>
</div>
<p class="action-message">{actionMessage}</p>
<button class="close-btn" onclick={closeCard}>✕</button>
</div>
</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: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 0 2rem 2rem 2rem;
}
.toolbar {
position: sticky;
top: 0px;
z-index: 50;
background: #0d0a04;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
margin-bottom: 2rem;
padding-top: 32px;
}
.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;
}
.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;
}
.card-count {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
margin-bottom: 1.5rem;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding-bottom: 50px
}
.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;
}
.sort-arrow {
font-size: 10px;
margin-left: 3px;
}
.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;
}
.card-btn {
all: unset;
cursor: pointer;
display: block;
border-radius: 12px;
transition: transform 0.15s;
}
.card-btn:hover {
transform: translateY(-4px);
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.card-popup {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.popup-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.action-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
min-height: 60px;
}
.report-btn, .refresh-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
}
.report-btn {
background: rgba(180, 60, 60, 0.5);
border: 1px solid rgba(240,250,240,0.8);
color: white;
}
.report-btn:hover:not(:disabled) {
/* border-color: #c84040; */
color: #E0E0E0;
background: rgba(180, 40, 40, 0.9);
}
.refresh-btn {
background: #3d2507;
border: 1px solid #c8861a;
color: #f0d080;
}
.refresh-btn:hover:not(:disabled) {
background: #5a3510;
}
.report-btn:disabled, .refresh-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.refresh-countdown {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
}
.action-message {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.7);
margin: 0;
min-height: 1.4em;
text-align: center;
}
.close-btn {
position: absolute;
top: -12px;
right: -12px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #1a1008;
border: 1px solid #6b4c1e;
color: rgba(240, 180, 80, 0.7);
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
border-color: #c8861a;
color: #f0d080;
}
</style>

View File

@@ -0,0 +1,441 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
let decks = $state([]);
let loading = $state(true);
let editConfirm = $state(null); // deck object pending edit confirmation
let deleteConfirm = $state(null); // deck object pending delete confirmation
const token = () => localStorage.getItem('token');
onMount(async () => {
if (!token()) { goto('/auth'); return; }
await fetchDecks();
});
async function fetchDecks() {
const res = await apiFetch(`${API_URL}/decks`);
if (res.status === 401) { goto('/auth'); return; }
decks = await res.json();
loading = false;
}
async function createDeck() {
const res = await apiFetch(`${API_URL}/decks`, {
method: 'POST'
});
const deck = await res.json();
goto(`/decks/${deck.id}`);
}
function clickEdit(deck) {
if (deck.times_played > 0) {
editConfirm = deck;
} else {
goto(`/decks/${deck.id}`);
}
}
function clickDelete(deck) {
deleteConfirm = deck;
}
async function confirmDelete() {
await apiFetch(`${API_URL}/decks/${deleteConfirm.id}`, {
method: 'DELETE'
});
decks = decks.filter(d => d.id !== deleteConfirm.id);
deleteConfirm = null;
}
function winRate(deck) {
if (deck.times_played === 0) return null;
return Math.round((deck.wins / deck.times_played) * 100);
}
</script>
<main>
<div class="header">
<h1 class="title">Your Decks</h1>
<button class="new-btn" onclick={createDeck}>+ New Deck</button>
</div>
{#if loading}
<p class="status">Loading decks...</p>
{:else if decks.length === 0}
<p class="status">You have no decks yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Cards</th>
<th>Type</th>
<th>Played</th>
<th>W / L</th>
<th>Win %</th>
<th></th>
</tr>
</thead>
<tbody>
{#each decks as deck}
{@const wr = winRate(deck)}
<tr>
<td class="deck-name">{deck.name}</td>
<td class="deck-count" class:incomplete={deck.card_count < 20}>
{deck.card_count}/20
</td>
<td class="deck-type">
<DeckTypeBadge deckType={deck.deck_type} />
</td>
<td class="deck-stat">{deck.times_played}</td>
<td class="deck-stat">
{#if deck.times_played > 0}
<span class="wins">{deck.wins}</span>
<span class="separator"> / </span>
<span class="losses">{deck.losses}</span>
{:else}
<span class="no-data"></span>
{/if}
</td>
<td class="deck-stat">
{#if wr !== null}
<span class:good-wr={wr >= 50} class:bad-wr={wr < 50}>{wr}%</span>
{:else}
<span class="no-data"></span>
{/if}
</td>
<td class="deck-actions">
<button class="edit-btn" onclick={() => clickEdit(deck)}>Edit</button>
<button class="delete-btn" onclick={() => clickDelete(deck)}>Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<!-- Edit confirmation popup -->
{#if editConfirm}
<div class="backdrop" onclick={() => editConfirm = null}>
<div class="popup" onclick={(e) => e.stopPropagation()}>
<h2 class="popup-title">Reset Stats?</h2>
<p class="popup-body">
<strong>{editConfirm.name}</strong> has been played {editConfirm.times_played} time{editConfirm.times_played === 1 ? '' : 's'} ({editConfirm.wins}W / {editConfirm.losses}L). Editing this deck will reset its stats.
</p>
<div class="popup-actions">
<button class="popup-cancel" onclick={() => editConfirm = null}>Cancel</button>
<button class="popup-confirm" onclick={() => { goto(`/decks/${editConfirm.id}`); editConfirm = null; }}>Continue</button>
</div>
</div>
</div>
{/if}
<!-- Delete confirmation popup -->
{#if deleteConfirm}
<div class="backdrop" onclick={() => deleteConfirm = null}>
<div class="popup" onclick={(e) => e.stopPropagation()}>
<h2 class="popup-title">Delete Deck?</h2>
<p class="popup-body">
Are you sure you want to delete <strong>{deleteConfirm.name}</strong>?
{#if deleteConfirm.times_played > 0}
This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'} — its stats will be preserved in your profile history.
{/if}
</p>
<div class="popup-actions">
<button class="popup-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
<button class="popup-delete" onclick={confirmDelete}>Delete</button>
</div>
</div>
</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: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 2rem;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
padding-bottom: 1rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.new-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: 6px 14px;
cursor: pointer;
transition: background 0.15s;
}
.new-btn:hover { background: #5a3510; }
table {
width: 100%;
border-collapse: collapse;
font-family: 'Crimson Text', serif;
}
thead tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.5);
}
th {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
padding: 0 1rem 0.75rem 0;
text-align: left;
}
tbody tr {
border-bottom: 1px solid rgba(107, 76, 30, 0.2);
transition: background 0.1s;
}
tbody tr:hover { background: rgba(107, 76, 30, 0.08); }
td {
padding: 0.9rem 1rem 0.9rem 0;
vertical-align: middle;
}
.deck-name {
font-size: 17px;
color: #e8d090;
}
.deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #6aaa6a;
width: 60px;
}
.deck-count.incomplete { color: #c85050; }
.deck-type { width: 90px; }
.type-badge {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
cursor: default;
}
.type-wall { background: rgba(58, 104, 120, 0.3); color: #a0d4e8; border: 1px solid rgba(58, 104, 120, 0.5); }
.type-aggro { background: rgba(139, 32, 32, 0.3); color: #e89090; border: 1px solid rgba(139, 32, 32, 0.5); }
.type-god-card { background: rgba(184, 120, 32, 0.3); color: #f5d880; border: 1px solid rgba(184, 120, 32, 0.5); }
.type-rush { background: rgba(74, 122, 80, 0.3); color: #a8dca8; border: 1px solid rgba(74, 122, 80, 0.5); }
.type-control { background: rgba(122, 80, 144, 0.3); color: #d0a0e8; border: 1px solid rgba(122, 80, 144, 0.5); }
.type-unplayable { background: rgba(60, 60, 60, 0.3); color: #909090; border: 1px solid rgba(60, 60, 60, 0.5); }
.type-pantheon { background: rgba(184, 150, 60, 0.3); color: #fce8a0; border: 1px solid rgba(184, 150, 60, 0.5); }
.type-balanced { background: rgba(106, 104, 96, 0.3); color: #c8c6c0; border: 1px solid rgba(106, 104, 96, 0.5); }
.deck-stat {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.6);
width: 60px;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.separator { color: rgba(240, 180, 80, 0.3); }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.no-data {
color: rgba(240, 180, 80, 0.2);
}
.deck-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.edit-btn, .delete-btn {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
transition: background 0.15s;
}
.edit-btn {
background: none;
border: 1px solid rgba(107, 76, 30, 0.5);
color: rgba(240, 180, 80, 0.7);
}
.edit-btn:hover {
border-color: #c8861a;
color: #f0d080;
}
.delete-btn {
background: none;
border: 1px solid rgba(180, 60, 60, 0.3);
color: rgba(200, 80, 80, 0.6);
}
.delete-btn:hover {
border-color: #c84040;
color: #e05050;
}
.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;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.popup {
background: #1a1008;
border: 1px solid #6b4c1e;
border-radius: 10px;
padding: 2rem;
max-width: 400px;
width: calc(100% - 2rem);
display: flex;
flex-direction: column;
gap: 1rem;
}
.popup-title {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.popup-body {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.7);
margin: 0;
line-height: 1.6;
}
.popup-body strong {
color: #f0d080;
}
.popup-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.popup-cancel {
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: 7px 16px;
cursor: pointer;
transition: all 0.15s;
}
.popup-cancel:hover {
border-color: #c8861a;
color: #f0d080;
}
.popup-confirm {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
border: none;
border-radius: 4px;
color: #fff8e0;
padding: 7px 16px;
cursor: pointer;
transition: background 0.15s;
}
.popup-confirm:hover { background: #e09820; }
.popup-delete {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(180, 40, 40, 0.8);
border: none;
border-radius: 4px;
color: #fff;
padding: 7px 16px;
cursor: pointer;
transition: background 0.15s;
}
.popup-delete:hover { background: rgba(220, 60, 60, 0.9); }
</style>

View File

@@ -0,0 +1,534 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Card from '$lib/Card.svelte';
const deckId = $derived($page.params.id);
const token = () => localStorage.getItem('token');
let allCards = $state([]);
let selectedIds = $state(new Set());
let deckName = $state('');
let editingName = $state(false);
let nameInput = $state('');
let loading = $state(true);
let saving = $state(false);
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
let sortBy = $state('name');
let sortAsc = $state(true);
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let costMin = $state(1);
let costMax = $state(12);
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 = a.cost - b.cost || a.name.localeCompare(b.name);
else if (sortBy === 'attack') cmp = a.attack - b.attack || a.name.localeCompare(b.name);
else if (sortBy === 'defense') cmp = a.defense - b.defense || a.name.localeCompare(b.name);
else if (sortBy === 'rarity') cmp = RARITY_ORDER[a.card_rarity] - RARITY_ORDER[b.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 (s.size >= 20) return;
s.add(id);
}
selectedIds = s;
}
function startEditName() {
nameInput = deckName;
editingName = true;
}
function commitName() {
if (nameInput.trim()) deckName = nameInput.trim();
editingName = false;
}
async function save() {
saving = true;
await apiFetch(`${API_URL}/decks/${deckId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: deckName,
card_ids: [...selectedIds],
}),
});
saving = false;
goto('/decks');
}
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const [cardsRes, deckCardsRes] = await Promise.all([
apiFetch(`${API_URL}/cards`),
apiFetch(`${API_URL}/decks/${deckId}/cards`),
]);
if (cardsRes.status === 401) { goto('/auth'); return; }
allCards = await cardsRes.json();
const currentCardIds = await deckCardsRes.json();
selectedIds = new Set(currentCardIds);
// Get deck name
const decksRes = await apiFetch(`${API_URL}/decks`);
const decks = await decksRes.json();
const deck = decks.find(d => d.id === deckId);
deckName = deck?.name ?? 'Untitled Deck';
loading = false;
});
</script>
<main>
<div class="toolbar">
<div class="deck-header">
{#if editingName}
<input
class="name-input"
bind:value={nameInput}
onblur={commitName}
onkeydown={e => e.key === 'Enter' && commitName()}
autofocus
/>
{:else}
<button class="name-btn" onclick={startEditName}>{deckName} ✎</button>
{/if}
<div class="header-right">
<span class="card-counter" class:full={selectedIds.size === 20} class:empty={selectedIds.size === 0}>
{selectedIds.size}/20
</span>
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
</button>
</div>
</div>
<div class="sort-row">
<span class="toolbar-label">Sort by</span>
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
<button class="sort-btn" class:active={sortBy === val} onclick={() => toggleSort(val)}>
{lbl}
{#if sortBy === val}<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>{/if}
</button>
{/each}
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 12}
<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 = 12; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
{/if}
</div>
{#if loading}
<p class="status">Loading...</p>
{:else if filtered.length === 0}
<p class="status">No cards match your filters.</p>
{:else}
<div class="grid">
{#each filtered as card (card.id)}
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={!selectedIds.has(card.id) && selectedIds.size >= 20}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
{#if selectedIds.has(card.id)}
<div class="selected-badge"></div>
{/if}
</button>
{/each}
</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: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 0 2rem 2rem 2rem;
}
.toolbar {
position: sticky;
top: 0;
z-index: 50;
background: #0d0a04;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
margin-bottom: 2rem;
padding-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.deck-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.name-btn {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
background: none;
border: none;
cursor: pointer;
padding: 0;
letter-spacing: 0.03em;
transition: color 0.15s;
}
.name-btn:hover { color: #fff8d0; }
.name-input {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
background: transparent;
border: none;
border-bottom: 1.5px solid #c8861a;
outline: none;
padding: 0 0 2px 0;
min-width: 200px;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.card-counter {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
color: #c85050;
transition: color 0.2s;
}
.card-counter.full { color: #6aaa6a; }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.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: 6px 16px;
cursor: pointer;
transition: background 0.15s;
}
.done-btn:hover:not(:disabled) { background: #5a3510; }
.done-btn:disabled { opacity: 0.5; cursor: default; }
/* Reuse cards page toolbar styles */
.sort-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.5);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-right: 0.25rem;
}
.sort-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
cursor: pointer;
transition: all 0.15s;
}
.sort-btn:hover { border-color: #c8861a; color: #f0d080; }
.sort-btn.active { background: #3d2507; border-color: #c8861a; color: #f0d080; }
.sort-arrow { font-size: 10px; margin-left: 3px; }
.filter-toggle {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(107, 76, 30, 0.4);
border-radius: 4px;
color: rgba(240, 180, 80, 0.6);
padding: 4px 10px;
cursor: pointer;
margin-left: auto;
position: relative;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: #c8861a; color: #f0d080; }
.filter-dot {
position: absolute;
top: -3px;
right: -3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8861a;
}
.filters {
display: flex;
gap: 3rem;
padding-top: 1rem;
flex-wrap: wrap;
}
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
.filter-group-header { display: flex; align-items: baseline; gap: 1rem; }
.filter-group-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
}
.select-all {
font-family: 'Crimson Text', serif;
font-size: 12px;
font-style: italic;
background: none;
border: none;
color: rgba(240, 180, 80, 0.5);
cursor: pointer;
padding: 0;
text-decoration: underline;
transition: color 0.15s;
}
.select-all:hover { color: #f0d080; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.8);
cursor: pointer;
}
.checkbox-label input {
accent-color: #c8861a;
width: 14px;
height: 14px;
cursor: pointer;
}
.cost-range { display: flex; flex-direction: column; gap: 0.4rem; }
.range-label {
font-family: 'Cinzel', serif;
font-size: 11px;
color: rgba(240, 180, 80, 0.7);
min-width: 60px;
}
input[type=range] {
accent-color: #c8861a;
width: 160px;
}
/* Grid + selection */
.grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding-bottom: 50px;
}
.card-wrap {
all: unset;
position: relative;
cursor: pointer;
display: block;
border-radius: 12px;
transition: transform 0.15s, box-shadow 0.15s;
}
.card-wrap:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.card-wrap.selected {
box-shadow: 0 0 0 3px #c8861a, 0 0 20px rgba(200, 134, 26, 0.4);
}
.card-wrap.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.selected-badge {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #c8861a;
color: #fff8e0;
font-family: 'Cinzel', serif;
font-size: 23.875px;
font-weight: 1000;
padding: 4px 10px;
border-radius: 23px;
border: black 3px solid;
pointer-events: none;
z-index: 10;
}
.status {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.5);
text-align: center;
margin-top: 4rem;
}
</style>

View File

@@ -0,0 +1,151 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
let email = $state('');
let submitted = $state(false);
let loading = $state(false);
let error = $state('');
async function submit() {
error = '';
if (!email.trim()) { error = 'Email is required'; return; }
loading = true;
try {
const res = await fetch(`${API_URL}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
submitted = true;
} catch {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if submitted}
<h1 class="title">Check Your Email</h1>
<p class="hint">If that email address is registered, you'll receive a password reset link shortly.</p>
{:else}
<h1 class="title">Forgot Password</h1>
<p class="hint">Enter your email address and we'll send you a reset link.</p>
<input type="email" placeholder="Email address" bind:value={email} />
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
<a href="/auth" class="back-link">← Back to login</a>
{/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: 340px;
background: #2e1c05;
border: 2px solid #6b4c1e;
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.title {
font-family: 'Cinzel', serif;
font-size: 20px;
color: #f0d080;
text-align: center;
margin: 0;
}
.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;
}
input {
width: 100%;
padding: 9px 12px;
background: #1a1008;
border: 1.5px solid #8b6420;
border-radius: 6px;
color: #f0d080;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
}
input::placeholder {
color: rgba(240, 180, 80, 0.4);
}
input:focus { border-color: #f5d060; }
button {
width: 100%;
padding: 10px;
background: #6b4c1e;
color: #f0d080;
border: 1.5px solid #8b6420;
border-radius: 6px;
font-family: 'Cinzel', serif;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #8b6420;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.back-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
text-align: center;
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,172 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let newPassword = $state('');
let confirmPassword = $state('');
let error = $state('');
let success = $state(false);
let loading = $state(false);
let token = $derived($page.url.searchParams.get('token') ?? '');
function validate() {
if (newPassword.length < 8) return 'Password must be at least 8 characters';
if (newPassword.length > 256) return 'Password must be 256 characters or fewer';
if (newPassword !== confirmPassword) return 'Passwords do not match';
return null;
}
async function submit() {
error = '';
const validationError = validate();
if (validationError) { error = validationError; return; }
loading = true;
try {
const res = await fetch(`${API_URL}/auth/reset-password-with-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: newPassword }),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
success = true;
} catch {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if !token}
<h1 class="title">Invalid Link</h1>
<p class="hint">This reset link is invalid. Please request a new one.</p>
<a href="/forgot-password" class="btn" style="text-align:center; text-decoration:none;">Request New Link</a>
{:else if success}
<h1 class="title">Password Updated</h1>
<p class="hint">Your password has been changed. You can now log in.</p>
<button class="btn" onclick={() => goto('/auth')}>Go to Login</button>
{:else}
<h1 class="title">Set New Password</h1>
<div class="fields">
<label class="field-label" for="new">New Password</label>
<input id="new" type="password" placeholder="At least 8 characters" bind:value={newPassword} />
<label class="field-label" for="confirm">Confirm Password</label>
<input id="confirm" type="password" placeholder="Repeat new password" bind:value={confirmPassword} />
</div>
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Updating...' : 'Set New Password'}
</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;
}
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
.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;
display: block;
}
.btn:hover:not(:disabled) { background: #e09820; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,545 @@
<script>
import { onMount } from 'svelte';
// A fake card for display purposes
const exampleCard = {
name: "Harald Bluetooth",
image_link: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/1200_Harald_Bl%C3%A5tand_anagoria.jpg/330px-1200_Harald_Bl%C3%A5tand_anagoria.jpg",
card_rarity: "rare",
card_type: "person",
wikidata_instance: "Q5",
text: "Harald \"Bluetooth\" Gormsson was a king of Denmark and Norway.",
attack: 351,
defense: 222,
cost: 5,
created_at: new Date().toISOString(),
reported: false,
};
const annotations = [
{ number: 1, label: "Name", description: "The name of the Wikipedia article this card was generated from." },
{ number: 2, label: "Type", description: "The category of the subject — Person, Location, Artwork, etc." },
{ number: 3, label: "Rarity badge", description: "Rarity is determined by the article's WikiRank quality score. From lowest to highest: Common, Uncommon, Rare, Super Rare, Epic, and Legendary." },
{ number: 4, label: "Wikipedia link", description: "Opens the Wikipedia article this card was generated from." },
{ number: 5, label: "Cost bubbles", description: "How much energy it costs to play this card. Derived from the card's attack and defense stats." },
{ number: 6, label: "Article text", description: "The opening paragraph of the Wikipedia article." },
{ number: 7, label: "Attack", description: "Determines how much damage this card deals when it attacks. Based on how many Wikipedia language editions the article appears in." },
{ number: 8, label: "Defense", description: "How much damage this card can absorb before being destroyed. Based on the article's monthly page views." },
];
// Annotation positions as percentage of card width/height
const markerPositions = [
{ number: 1, x: 15, y: 3 }, // name — top center
{ number: 2, x: 75, y: 3 }, // type badge — top right
{ number: 3, x: 14, y: 20 }, // rarity badge — top left of image
{ number: 4, x: 85, y: 20 }, // wiki link — top right of image
{ number: 5, x: 15, y: 53 }, // cost bubbles — bottom left of image
{ number: 6, x: 50, y: 73 }, // text — middle
{ number: 7, x: 15, y: 88 }, // attack — bottom left
{ number: 8, x: 85, y: 88 }, // defense — bottom right
];
</script>
<main>
<div class="content">
<h1 class="page-title">How to Play</h1>
<section class="section">
<h2 class="section-title">Understanding Your Cards</h2>
<div class="card-explainer">
<div class="card-annotated">
<div class="card-display">
<!-- Inline card rendering matching Card.svelte visuals -->
<div class="demo-card">
<div class="demo-inner" style="--bg: #f0e0c8; --header: #b87830">
<div class="demo-header">
<span class="demo-name">{exampleCard.name}</span>
<span class="demo-type-badge">Person</span>
</div>
<div class="demo-image-wrap">
<img src={exampleCard.image_link} alt={exampleCard.name} class="demo-image" />
<div class="demo-rarity" style="background: #2a5a9b; color: #fff">R</div>
<a href="https://en.wikipedia.org/wiki/Harald_Bluetooth" target="_blank" rel="noopener" class="demo-wiki">
<svg viewBox="0 0 50 50" width="14" height="14"><circle cx="25" cy="25" r="24" fill="white" stroke="#888" stroke-width="1"/><text x="25" y="33" text-anchor="middle" font-family="serif" font-size="28" font-weight="bold" fill="#000">W</text></svg>
</a>
<div class="demo-cost-bubbles">
{#each { length: exampleCard.cost } as _}
<div class="demo-cost-bubble"></div>
{/each}
</div>
</div>
<div class="demo-divider"></div>
<div class="demo-text">{exampleCard.text}</div>
<div class="demo-footer" style="background: #e8d8b8">
<span class="demo-stat">ATK <strong>{exampleCard.attack}</strong></span>
<span class="demo-date">{new Date(exampleCard.created_at).toLocaleDateString()}</span>
<span class="demo-stat">DEF <strong>{exampleCard.defense}</strong></span>
</div>
</div>
</div>
</div>
<!-- Annotation markers -->
<div class="markers">
{#each markerPositions as pos}
<div class="marker" style="left: {pos.x}%; top: {pos.y}%">
<div class="marker-bubble">{pos.number}</div>
</div>
{/each}
</div>
</div>
<ol class="annotation-list">
{#each annotations as a}
<li>
<span class="annotation-number">{a.number}</span>
<div class="annotation-content">
<span class="annotation-label">{a.label}</span>
<span class="annotation-desc">{a.description}</span>
</div>
</li>
{/each}
</ol>
</div>
</section>
<section class="section">
<h2 class="section-title">Taking a Turn</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Energy</h3>
<p class="rule-body">You start your first turn with 1 energy (or 2 if you're the second player). Each subsequent turn you gain one more, up to a maximum of 6. Energy does not carry over between turns.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Drawing</h3>
<p class="rule-body">At the start of your turn you draw cards until you have 5 in your hand. You cannot draw more than 5 cards.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Playing Cards</h3>
<p class="rule-body">Select a card from your hand, then click an empty slot on your side of the board. The card must have a cost less or equal to your current energy. You can have up to 5 cards in play at once.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🗡</div>
<h3 class="rule-title">Sacrificing</h3>
<p class="rule-body">Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover its energy cost. Use this to afford expensive cards.</p>
</div>
</div>
</section>
<section class="section">
<h2 class="section-title">Combat</h2>
<p class="body-text">When you end your turn, all your cards attack simultaneously. Each card attacks the card directly opposite it:</p>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Opposed Attack</h3>
<p class="rule-body">If there is an enemy card in the opposing slot, your card deals its ATK as damage to that card's DEF. If DEF reaches zero, the card is destroyed.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Direct Attack</h3>
<p class="rule-body">If the opposing slot is empty, your card attacks the opponent's life total directly, dealing damage equal to its ATK.</p>
</div>
<!-- <div class="rule-card">
<div class="rule-icon">🛡</div>
<h3 class="rule-title">No Overflow</h3>
<p class="rule-body">Excess damage does not carry through. A card that destroys an opposing card does not deal the remaining damage to the opponent's life total.</p>
</div> -->
</div>
</section>
<section class="section">
<h2 class="section-title">Winning & Losing</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon">💀</div>
<h3 class="rule-title">Life Total</h3>
<p class="rule-body">Each player starts with 500 life. Reduce your opponent's life to zero to win.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🃏</div>
<h3 class="rule-title">No Cards Left</h3>
<p class="rule-body">If you have cards in play and your opponent has no playable cards remaining in their deck, hand, or board, you win.</p>
</div>
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Timeout</h3>
<p class="rule-body">Each player has 2 minutes per turn. If your opponent's timer runs out, you win.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🔌</div>
<h3 class="rule-title">Disconnect</h3>
<p class="rule-body">If your opponent disconnects and does not reconnect within 15 seconds, you win.</p>
</div>
</div>
</section>
</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 {
height: 100vh;
overflow-y: auto;
background: #0d0a04;
}
.content {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
.page-title {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
margin: 0 0 2rem;
letter-spacing: 0.08em;
}
.section {
margin-bottom: 3rem;
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #f0d080AA;
margin: 0 0 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0d08055;
}
.body-text ul {
margin-left: 20px;
}
.body-text {
font-family: 'Crimson Text', serif;
font-size: 17px;
color: rgba(240, 180, 80, 0.75);
line-height: 1.7;
margin: 0 0 1rem;
}
/* ── Card explainer ── */
.card-explainer {
display: flex;
gap: 3rem;
align-items: flex-start;
flex-wrap: wrap;
}
.card-annotated {
position: relative;
flex-shrink: 0;
}
.card-display {
position: relative;
}
.markers {
position: absolute;
inset: 0;
pointer-events: none;
}
.marker {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
}
.marker-bubble {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
border: 2px solid #fff;
color: #fff;
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* ── Annotation list ── */
.annotation-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
flex: 1;
min-width: 260px;
}
.annotation-list li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.annotation-number {
width: 22px;
height: 22px;
border-radius: 50%;
background: #c8861a;
color: #fff;
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.annotation-content {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.annotation-label {
font-family: 'Cinzel', serif;
font-size: 12px;
font-weight: 700;
color: #f0d080;
letter-spacing: 0.04em;
}
.annotation-desc {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
line-height: 1.5;
}
/* ── Rules grid ── */
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.rule-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-icon {
color: #f0d080AA;
font-size: 20px;
line-height: 1;
}
.rule-title {
font-family: 'Cinzel', serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: #f0d080;
margin: 0;
}
.rule-body {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(240, 180, 80, 0.6);
line-height: 1.55;
margin: 0;
}
/* ── Demo card ── */
.demo-card {
width: 300px;
border-radius: 12px;
padding: 7px;
background: #111;
border: 2px solid #111;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
font-family: 'Crimson Text', serif;
position: relative;
user-select: none;
}
.demo-inner {
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 2px solid #000;
display: flex;
flex-direction: column;
}
.demo-header {
padding: 9px 12px 7px;
background: var(--header);
border-bottom: 2px solid #000;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.demo-name {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.demo-type-badge {
font-family: 'Cinzel', serif;
font-size: 9px;
color: rgba(255,255,255,0.95);
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0,0,0,0.25);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.demo-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
border-bottom: 2px solid #000;
}
.demo-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
.demo-rarity {
position: absolute;
top: 7px;
left: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid #000;
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-wiki {
position: absolute;
top: 7px;
right: 7px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255,255,255,0.92);
border: 1.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.demo-cost-bubbles {
position: absolute;
bottom: 6px;
left: 8px;
display: flex;
gap: 3px;
flex-wrap: wrap;
max-width: calc(100% - 16px);
}
.demo-cost-bubble {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6ea0ec;
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 12px;
font-weight: 700;
font-family: 'Cinzel', serif;
line-height: 1;
}
.demo-divider {
height: 2px;
background: #000;
}
.demo-text {
padding: 10px 12px;
font-size: 13px;
line-height: 1.55;
color: #1a1208;
font-style: italic;
background: #f0e6cc;
border-bottom: 2px solid #000;
height: 110px;
overflow: hidden;
}
.demo-footer {
padding: 7px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.demo-stat {
font-family: 'Cinzel', serif;
font-size: 11px;
color: #2a2010;
letter-spacing: 0.03em;
}
.demo-stat strong {
color: #000;
font-size: 15px;
}
.demo-date {
font-size: 10px;
color: rgba(0,0,0,0.5);
font-style: italic;
font-family: 'Crimson Text', serif;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let profile = $state(null);
let loading = $state(true);
const token = () => localStorage.getItem('token');
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/profile`);
if (res.status === 401) { goto('/auth'); return; }
profile = await res.json();
loading = false;
});
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
goto('/auth');
}
</script>
<main>
{#if loading}
<p class="status">Loading...</p>
{:else if profile}
<div class="profile">
<div class="profile-header">
<div class="avatar">{profile.username[0].toUpperCase()}</div>
<div class="profile-info">
<h1 class="username">{profile.username}</h1>
<p class="email">{profile.email}</p>
<p class="joined">Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
<button class="logout-btn" onclick={logout}>Log Out</button>
<a href="/reset-password" class="reset-link">Change password</a>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Stats</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Wins</span>
<span class="stat-value wins">{profile.wins}</span>
</div>
<div class="stat-card">
<span class="stat-label">Losses</span>
<span class="stat-value losses">{profile.losses}</span>
</div>
<div class="stat-card">
<span class="stat-label">Games Played</span>
<span class="stat-value">{profile.wins + profile.losses}</span>
</div>
<div class="stat-card">
<span class="stat-label">Win Rate</span>
<span class="stat-value" class:good-wr={profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
{profile.win_rate !== null ? `${profile.win_rate}%` : '—'}
</span>
</div>
</div>
<div class="section-divider"></div>
<h2 class="section-title">Highlights</h2>
<div class="highlights">
<div class="highlight-card">
<span class="highlight-label">Most Played Deck</span>
{#if profile.most_played_deck}
<span class="highlight-value">{profile.most_played_deck.name}</span>
<span class="highlight-sub">{profile.most_played_deck.times_played} games</span>
{:else}
<span class="no-data">No games played yet</span>
{/if}
</div>
<div class="highlight-card">
<span class="highlight-label">Most Played Card</span>
{#if profile.most_played_card}
<div class="card-preview">
{#if profile.most_played_card.image_link}
<img src={profile.most_played_card.image_link} alt={profile.most_played_card.name} class="card-thumb" />
{/if}
<div class="card-preview-info">
<span class="highlight-value">{profile.most_played_card.name}</span>
<span class="highlight-sub">{profile.most_played_card.times_played} times played</span>
<span class="highlight-sub">{profile.most_played_card.card_type} · {profile.most_played_card.card_rarity}</span>
</div>
</div>
{:else}
<span class="no-data">No cards played yet</span>
{/if}
</div>
</div>
</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: 100vh;
overflow-y: auto;
background: #0d0a04;
padding: 2rem;
}
.profile {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3d2507;
border: 2px solid #c8861a;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
flex-shrink: 0;
}
.profile-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.username {
font-family: 'Cinzel', serif;
font-size: 24px;
font-weight: 700;
color: #f0d080;
margin: 0;
}
.email {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: rgba(240, 180, 80, 0.5);
margin: 0;
}
.joined {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-style: italic;
color: rgba(240, 180, 80, 0.35);
margin: 0;
}
.logout-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(180, 60, 60, 0.4);
border-radius: 4px;
color: rgba(200, 80, 80, 0.7);
padding: 8px 16px;
cursor: pointer;
transition: all 0.15s;
align-self: flex-start;
}
.logout-btn:hover {
border-color: #c84040;
color: #e05050;
background: rgba(180, 40, 40, 0.1);
}
.reset-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
align-self: flex-start;
transition: color 0.15s;
}
.reset-link:hover { color: rgba(240, 180, 80, 0.7); }
.section-divider {
height: 1px;
background: rgba(107, 76, 30, 0.3);
}
.section-title {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.stat-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
}
.stat-value {
font-family: 'Cinzel', serif;
font-size: 28px;
font-weight: 700;
color: #f0d080;
}
.wins { color: #6aaa6a; }
.losses { color: #c85050; }
.good-wr { color: #6aaa6a; }
.bad-wr { color: #c85050; }
.highlights {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.highlight-card {
background: #1a1008;
border: 1px solid rgba(107, 76, 30, 0.3);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.highlight-label {
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.4);
}
.highlight-value {
font-family: 'Cinzel', serif;
font-size: 16px;
font-weight: 700;
color: #f0d080;
}
.highlight-sub {
font-family: 'Crimson Text', serif;
font-size: 13px;
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
.card-preview {
display: flex;
gap: 0.75rem;
align-items: flex-start;
margin-top: 0.25rem;
}
.card-thumb {
width: 48px;
height: 48px;
object-fit: cover;
object-position: top;
border-radius: 4px;
border: 1px solid rgba(107, 76, 30, 0.4);
flex-shrink: 0;
}
.card-preview-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.no-data {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.25);
}
.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;
}
@media (max-width: 640px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.highlights { grid-template-columns: 1fr; }
.profile-header { flex-wrap: wrap; }
}
</style>

View File

@@ -0,0 +1,191 @@
<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let error = $state('');
let success = $state(false);
let loading = $state(false);
const token = () => localStorage.getItem('token');
function validate() {
if (!currentPassword) return 'Current password is required';
if (newPassword.length < 8) return 'New password must be at least 8 characters';
if (newPassword.length > 256) return 'New password must be 256 characters or fewer';
if (newPassword !== confirmPassword) return 'Passwords do not match';
if (newPassword === currentPassword) return 'New password must be different from current password';
return null;
}
async function submit() {
error = '';
const validationError = validate();
if (validationError) { error = validationError; return; }
loading = true;
try {
const res = await apiFetch(`${API_URL}/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
const data = await res.json();
if (!res.ok) { error = data.detail; return; }
success = true;
} catch (e) {
error = 'Something went wrong. Please try again.';
} finally {
loading = false;
}
}
</script>
<main>
<div class="card">
{#if success}
<h1 class="title">Password Updated</h1>
<p class="hint">Your password has been changed successfully.</p>
<button class="btn" onclick={() => goto('/profile')}>Back to Profile</button>
{:else}
<h1 class="title">Reset Password</h1>
<div class="fields">
<label class="field-label" for="current">Current Password</label>
<input id="current" type="password" placeholder="Current password" bind:value={currentPassword} />
<label class="field-label" for="new">New Password</label>
<input id="new" type="password" placeholder="At least 8 characters" bind:value={newPassword} />
<label class="field-label" for="confirm">Confirm New Password</label>
<input id="confirm" type="password" placeholder="Repeat new password" bind:value={confirmPassword} />
</div>
<p class="error">{error}</p>
<button class="btn" onclick={submit} disabled={loading}>
{loading ? 'Updating...' : 'Update Password'}
</button>
<button class="back-link" onclick={() => goto('/profile')}> Back 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;
}
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-family: 'Cinzel', serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(245, 208, 96, 0.5);
}
input {
width: 100%;
padding: 9px 12px;
background: #221508;
border: 1.5px solid #c8861a;
border-radius: 6px;
color: #f5d060;
font-family: 'Crimson Text', serif;
font-size: 15px;
box-sizing: border-box;
outline: none;
margin-bottom: 0.4rem;
}
input:focus { border-color: #f5d060; }
input::placeholder { color: rgba(245, 208, 96, 0.35); }
.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:not(:disabled) { background: #e09820; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.back-link {
all: unset;
font-family: 'Crimson Text', serif;
font-size: 14px;
color: rgba(245, 208, 96, 0.5);
cursor: pointer;
text-align: center;
transition: color 0.15s;
}
.back-link:hover { color: #f5d060; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #f06060;
margin: 0;
min-height: 1.4em;
text-align: center;
}
</style>