🐐
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
versions/
|
.env
|
||||||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal 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
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
76
backend/alembic/versions/b342602d3eab_initial_schema.py
Normal file
76
backend/alembic/versions/b342602d3eab_initial_schema.py
Normal 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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -3,27 +3,43 @@ 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"])
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
return pwd_context.verify(plain, hashed)
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
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:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
194
backend/card.py
194
backend/card.py
@@ -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
21
backend/config.py
Normal 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")
|
||||||
@@ -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
10
backend/dockerfile
Normal 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
27
backend/email_utils.py
Normal 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
295
backend/game.py
Normal 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
502
backend/game_manager.py
Normal 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))
|
||||||
439
backend/main.py
439
backend/main.py
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,61 +1,72 @@
|
|||||||
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
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
password_hash: 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)
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class Card(Base):
|
class Card(Base):
|
||||||
__tablename__ = "cards"
|
__tablename__ = "cards"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
image_link: Mapped[str] = mapped_column(String, nullable=True)
|
image_link: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
card_rarity: Mapped[str] = mapped_column(String, nullable=False)
|
card_rarity: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
card_type: Mapped[str] = mapped_column(String, nullable=False)
|
card_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
text: Mapped[str] = mapped_column(Text, nullable=True)
|
text: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
attack: Mapped[int] = mapped_column(Integer, nullable=False)
|
attack: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class Deck(Base):
|
class Deck(Base):
|
||||||
__tablename__ = "decks"
|
__tablename__ = "decks"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class DeckCard(Base):
|
class DeckCard(Base):
|
||||||
__tablename__ = "deck_cards"
|
__tablename__ = "deck_cards"
|
||||||
|
|
||||||
deck_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("decks.id"), primary_key=True)
|
deck_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("decks.id"), primary_key=True)
|
||||||
card_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cards.id"), primary_key=True)
|
card_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cards.id"), primary_key=True)
|
||||||
|
|
||||||
deck: Mapped["Deck"] = relationship(back_populates="deck_cards")
|
deck: Mapped["Deck"] = relationship(back_populates="deck_cards")
|
||||||
card: Mapped["Card"] = relationship(back_populates="deck_cards")
|
card: Mapped["Card"] = relationship(back_populates="deck_cards")
|
||||||
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
uvicorn main:app --reload --log-config=log_conf.yaml
|
|
||||||
444
backend/test_game.py
Normal file
444
backend/test_game.py
Normal 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
48
docker-compose.yml
Normal 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
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PUBLIC_API_URL=http://localhost:8000
|
||||||
15
frontend/dockerfile
Normal file
15
frontend/dockerfile
Normal 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
8
frontend/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
31
frontend/src/lib/DeckTypeBadge.svelte
Normal file
31
frontend/src/lib/DeckTypeBadge.svelte
Normal 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
65
frontend/src/lib/api.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,19 +2,14 @@
|
|||||||
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 = [
|
||||||
{ href: '/', label: 'Booster Packs' },
|
{ href: '/', label: 'Booster Packs' },
|
||||||
{ 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; }
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
635
frontend/src/routes/cards/+page.svelte
Normal file
635
frontend/src/routes/cards/+page.svelte
Normal 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>
|
||||||
441
frontend/src/routes/decks/+page.svelte
Normal file
441
frontend/src/routes/decks/+page.svelte
Normal 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>
|
||||||
534
frontend/src/routes/decks/[id]/+page.svelte
Normal file
534
frontend/src/routes/decks/[id]/+page.svelte
Normal 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>
|
||||||
151
frontend/src/routes/forgot-password/+page.svelte
Normal file
151
frontend/src/routes/forgot-password/+page.svelte
Normal 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>
|
||||||
172
frontend/src/routes/forgot-password/reset/+page.svelte
Normal file
172
frontend/src/routes/forgot-password/reset/+page.svelte
Normal 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>
|
||||||
545
frontend/src/routes/how-to-play/+page.svelte
Normal file
545
frontend/src/routes/how-to-play/+page.svelte
Normal 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>
|
||||||
1053
frontend/src/routes/play/+page.svelte
Normal file
1053
frontend/src/routes/play/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
344
frontend/src/routes/profile/+page.svelte
Normal file
344
frontend/src/routes/profile/+page.svelte
Normal 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>
|
||||||
191
frontend/src/routes/reset-password/+page.svelte
Normal file
191
frontend/src/routes/reset-password/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user